Skip to content

ci(governance): require pinned human approval for governed-surface merges - #6739

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-6596-governed-queue-guard
Aug 29, 2026
Merged

ci(governance): require pinned human approval for governed-surface merges#6739
os-zhuang merged 1 commit into
mainfrom
claude/issue-6596-governed-queue-guard

Conversation

@os-litant

@os-litantos-litant commented Aug 29, 2026

Copy link
Copy Markdown
Collaborator

Fixes#6596

Session: https://claude.ai/code/session_01MnijPVVDakqK2J335JoJtq (durable copy — a body edit downgrades the footer link).

Maintainer ruling 2026-08-27 (「同意,并继续」, accepting Option A + C on #6325). The governed surface — AGENTS.md, CLAUDE.md, .claude/**, skills/**, docs/adr/** (2026-08-18 definition) — is merged by a human, not by the queue. Until now that rule lived only in prose.

Which shape, and why (measured, not assumed)

The card offered two candidates. Both have their enforcement half in branch protection, which no agent can flip, so the choice turned on what each one lands as repo files today:

Shape 1 — CODEOWNERSShape 2 — required status check (chosen)
What lands in this PRa file that is inert until the setting flipsa workflow that runs on every PR and every queue build from the moment it merges
Signal before the togglenonethe early warning on every governed PR; a printed refusal on a governed queue build
Can it be self-tested?noyes — 132 predicate cases, run as the workflow's own first step
Can it distinguish a stale approval from a fresh one?no — CODEOWNERS review requirements are not bound to a shayes — the approval must be pinned to the PR's current head
Seat-side "would this be governed?"noyes — --test PATH…, offline, zero API

The last two rows decided it. #6183's mechanism was a PR whose state changed under a review nobody had given; a clearance that is not bound to specific bytes cannot see that class at all. And this repo already has a merge_group-subscribing gate family with a written-down required-context register, so shape 2 slots into machinery that exists rather than needing new machinery of its own.

Sibling parity was a supporting reason, not the deciding one: objectstack has run this mechanism since 2026-08-28.

The design: split by event

The PR leg must not redden, and not out of politeness. A governed PR parked as a draft for the maintainer to merge by hand is this regime's healthy end state, so a check red on it would be red on the healthy case forever — and a permanently red check is one everybody learns to ignore. Red on the queue build is red on the anomaly.

types: names ready_for_review alongside the three defaults (naming types: replaces GitHub's default set rather than extending it). That is the addition that matters: flipping a governed draft to ready is the first move of the exact sequence this interrupts, and it is not in the default set.

Two properties held in tension and reconciled by order, not tolerance: a diff touching nothing governed must never be blocked by an API hiccup, and an unreadable review list must never read as a pass. runGuard returns a verdict before fetchReviews exists as a possibility, so a clear diff costs zero API calls; on a governed diff an unreadable head or review list is a refusal with its own exit code (4, distinct from 3 for "nobody approved"). The self-test measures the first with a spy that throws if the API is touched at all — a mock returning [] would have passed against a version that called it.

The port / parity decision: objectui-native, not a pinned port

Two measurements, both taken rather than argued:

  1. Nothing to port the bulk of. Upstream splits the mechanism across check-governed-queue-guard.mjs (1,381 lines) and check-governed-merges.mjs (2,614 lines). The register half is mostly a multi-repo post-merge audit plus a provenance-recompute engine for generated artifacts sitting inside governed paths. This tree has none: .claude/workflows/ does not exist here, and skills/ carries no generator output (no references/_index.md, no react-blocks contract) — checked against the tree. Every row of that register is inapplicable.
  2. The pin cannot express the deletion that implies.validatePin in scripts/check-upstream-port-parity.mjs refuses a divergence whose ported side is empty. Driven directly against the shipped function with a deletion divergence, it returns the single problem files[0].divergences[0].ported is empty — exit 2, "the pin itself is unusable". A pinned port here is structurally impossible, not merely undesirable.

So the divergence is declared in prose, in the file header, where a reader can act on it, and the obligation the pin would have carried is stated instead: when the sibling's predicate changes, this file is a hand re-read, not an automatic re-sync. scripts/upstream-port-pin.json is untouched, and check:upstream-port-parity still reports its two pinned files green.

The toggle the maintainer must flip

Everything else is landed. The one remaining half is a repository-settings change:

Settings → Rules → Rulesets → the main ruleset → "Require status checks to pass" → add Governed Surface Queue Guard.

It must also be in the merge queue's required set on the same ruleset — that is the set the merge_group build is judged by, and the refusal lives on that leg. Adding it to the PR-side required set alone changes nothing, because the PR leg is green by design.

Until it is flipped, the queue leg reports without stopping anything. What this repository can write down it now does: Governed Surface Queue Guard is registered in REQUIRED_CONTEXTS (scripts/dependabot-merge-gate.mjs), which is what merge-queue-reporting.test.ts derives the merge_group subscription floor from.

⚠️One line for the maintainer to confirm or edit.GOVERNED_APPROVERS is os-zhuang and hotlong, and it is inherited, not ruled for this repo: it is the roster the sibling's maintainer ruled for its own governed surface on 2026-08-27 (「os-zhuang hotlong 批准算数」). objectui's card rules the mechanism; the roster is carried across because it is the same maintainer and the same surface. Nothing is blocked either way — the remedy the refusal prints first is back-to-draft plus a human merge, which needs no approver at all.

Scope kept to the ruling

Two widenings deliberately not taken, and pinned so a later edit has to be a decision rather than a drift:

  • .github/workflows/** is not governed, so this guard does not govern its own workflow. Promoting CI config to a governed surface is a strictly larger rule than the one ruled.
  • The two root rows are exact matches, so examples/AGENTS.md and any vendored template copy stay ordinary source.

Sibling-repo mirroring is explicitly not this card.

Honest note on the draft status: run against its own file list, the new gate reports this PR NOT GOVERNED — 7 paths checked, none matched, exit 0. It lands as a draft for human merge because the card requires it, not because this guard demands it of itself.

Files

PathWhat
scripts/check-governed-queue-guard.mjsnew — the register, the predicates, the CI entry, --test, --self-test
.github/workflows/governed-surface-guard.ymlnew — both legs, no path filter, fetch-depth: 0, self-test first
scripts/__tests__/check-governed-queue-guard.test.tsnew — the wiring pins plus the surface asserted against the real tree
scripts/dependabot-merge-gate.mjsregisters the context in REQUIRED_CONTEXTS
scripts/__tests__/merge-queue-reporting.test.tsthe reason row in MUST_SUBSCRIBE_MERGE_GROUP
content/docs/guide/ci-cd-pipeline.mdinventory row + its own section (both pinned by ci-cd-pipeline-doc.test.ts)
package.jsoncheck:governed-queue-guard, governed

⛔ No paths: filter on either leg, and that is the load-bearing omission: a skipped job counts as success in branch protection, so a filter that mis-scoped would hand the queue a green verdict from a job that never ran — on the one check whose entire purpose is to refuse. The path test lives inside the script, where "nothing governed" is a verdict that says so and costs nothing.

Gates — all run at c4885f3, the commit this PR carries

Exit codes captured with redirect-then-capture, never through a pipe.

GateVerdict line
check-governed-queue-guard.mjs --self-testOK check-governed-queue-guard self-test: 132 cases pass — exit 0
npx vitest run scripts/__tests__Test Files 86 passed (86) / Tests 2404 passed (2404) — exit 0
pnpm type-check:scriptsexit 0 (and --listFiles confirms the new test file is in the program — 1 hit — so this covers it)
pnpm lint:root28 problems (0 errors, 28 warnings) — exit 0; all pre-existing, and the CI gate ratchets errors
check-lint-coverage.mjslint coverage: 46/46 packages linted, 0 with outstanding errors (0 total).
check-control-bytes.mjscheck-control-bytes: OK (scanned 5596 tracked text file(s); skipped 85 binary).
check-entry-guard.mjs52 scripts/ file(s) — no entry guard outside the baseline; 0 file(s) still hand-type one
check-upstream-port-parity.mjs2 ported file(s) match objectstack-ai/objectstack@bf10debd5 modulo their declared divergences.
check-pre-install-import-graph.mjs20 pre-install step(s) in 17 job(s) run 19 scripts/ gate(s); 21 module(s) walked, every non-relative leaf a node builtin.
check-changeset-presence.mjsNo source of a released package changed in this range, so no changeset is owed.
check-doc-links.mjsLinks are valid across 17 scan roots.
check-skills-paths.mjscheck-skills-paths: OK (93/94 stated path(s) resolve across 18 guide file(s); 1 baselined).
check-shell-escape-residue.mjscheck-shell-escape-residue: OK (4/4 root(s) resolved …)
check-doc-fence-languages.mjsevery TypeScript block in 223 document(s) is fenced ts/tsx/typescript …
check-doc-component-types.mjsEvery documented component type is registered.

Changeset: none, and that is measured rather than recalled — check-changeset-presence.mjs reports 7 file(s) changed, 0 of them published source of a package the release covers. Matches the precedent for scripts/workflow-only changes (#6672, the parity-gate PR: scripts + workflows + package.json + tests, no changeset). ⛔ No skip-changeset label — it is a phantom in this repo and ci-cd-pipeline-doc.test.ts pins that.

One red was found and fixed here rather than in CI: tsconfig.scripts.json type-checks the pin tests with allowJs, so the optional-chain spelling over a union of frozen literals is a genuine TS2339. It is an in narrowing now, with a comment saying why the obvious spelling does not compile.

Generated by Claude Code

…rges
The governed surface (AGENTS.md, CLAUDE.md, .claude/**, skills/**,
docs/adr/**) is merged by a human, not by the queue. That rule lived only
in prose until PR #6183: an AGENTS.md change was correctly parked as a
draft, an update_pull_request call passing only `reviewers` silently set
draft:false, the pull request entered the merge queue and landed as
5b3290f with no human approval, and converting it back to a draft did
not dequeue it. Nothing in CI could have refused that.
Adds the refusal, split by event because the split is the design: on a
pull request the check is deliberately green and prints an early warning
(a governed PR parked as a draft is the healthy end state, and a check
red on the healthy case is a permanently red check); on a merge-queue
build the same finding refuses unless an APPROVED review by an authorized
approver is pinned to the pull request's current head sha. The path test
runs before any request is built, so an ordinary diff costs zero API
calls; an unreadable review list is a refusal with its own exit code,
never a pass.
Written objectui-native rather than registered as a pinned port: upstream
splits the mechanism over two files whose register half is mostly a
provenance engine for generated artifacts inside governed paths, and this
tree has none of those artifacts. A port would have to declare that
deletion, and check-upstream-port-parity's validatePin refuses a
divergence whose ported side is empty, so a pin cannot express one.
Making the context required is a branch-protection setting only the
maintainer can flip; until then the queue leg reports without stopping
anything. What the repository can write down is REQUIRED_CONTEXTS, and
the check name is registered there.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MnijPVVDakqK2J335JoJtq
@os-zhuang
os-zhuang marked this pull request as ready for review August 29, 2026 10:03
@os-zhuang
os-zhuang added this pull request to the merge queueAug 29, 2026
Merged via the queue into main with commit ff8a257Aug 29, 2026
29 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-6596-governed-queue-guard branch August 29, 2026 10:17
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Machine-enforce human review on governed-surface paths — so the no-bypass rule stops resting on seat discipline alone

3 participants

@os-litant@os-zhuang@claude