fix(pm): the queue guard's remedy names the Merge button's bypass-rules option, and a pin reads the ruleset - #19448
Conversation
…es option, and a pin reads the ruleset The size limb (and the governed limb beside it) prescribed 人工直合 — "the maintainer's own click" — without naming which click. Ruleset `main` mandates the merge queue and lists this guard as a required context, so the only Merge that is not an enqueue is the Merge button's bypass-rules option, offered only while the ruleset configures a bypass actor. With none configured the remedy named a terminal nobody could reach: PR #19024 was enqueued three times and refused three times. The four remedy sentences (the header's and the three rendered ones) now name that option and keep 人工直合 as the NAME of the act. A new self-test battery judges the remedy text against a RECORDED reading of `GET /repos/objectstack-ai/objectstack/rulesets/12119582`: red when `bypass_actors` is present and empty, red when the remedy drifts back to a bare click, and a pass that PRINTS its reading when the field is unreadable — which is what every seat and Actions token gets, `administration` being outside the 17 permissions a workflow may grant. The reading is recorded rather than fetched because this self-test is the required guard job's first step and is declared offline; a live read would answer "unreadable" on every CI run, asserting nothing while adding a network dependency to a merge precondition. Claude-Session: https://claude.ai/code/session_017ETYWqMQD4qMtZzAGovWNi Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: ① Derived judgments
② Semver levelNone — nothing published; ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #19344
Clause-②: no
Item 6 of the maintainer's ratified directive — verbatim 「1 2 3 4 5 6 8」 (record
5750077963, relayed onto this card as comment5750078192) — narrowed by the director's pointer5749924838: route (a) was taken in person, PR #19024 merged by bypass (merged_byos-zhuang, squash2277d1f). What remained is exactly what this PR does — the guard's remedy sentences name a path that EXISTS on rulesetmain, and a pin fails when they name one it does not offer.The defect
Ruleset
main(id 12119582, enforcementactive, target the default branch) carries amerge_queuerule and listsGoverned Surface Queue Guardamong its seven required contexts. So the only Merge button an ordinary account is offered is "Merge when ready" = enqueue, and the size limb then refuses the queued group. The remedy prescribed 人工直合 — "the maintainer's own click" — without naming WHICH click, and the only click that is not an enqueue is the Merge button's bypass-rules option, offered only while the ruleset configures a bypass actor. With none configured, the remedy named a terminal nobody could reach: PR #19024 was enqueued three times and refused three times.Before / after — the four sentences
Each keeps 人工直合 as the NAME of the act (the 2026-09-18 ruling stands untouched); what changes is the description of how the act is reached.
1. The header sentence (
:410, the size limb's own contract)Before:
After: the same opener, then —
mainmandates the queue and requires this check, so the only Merge that is not an enqueue is the Merge button's BYPASS-RULES option, offered only while the ruleset configures a bypass actor; while none was, the remedy named a terminal nobody could reach and PR #19024 was enqueued and refused three times; that it IS offered is a ruleset fact the pin reads.2. The governed limb's early warning (
renderGuardVerdict, Tier H block)Before:
After:
3. The governed limb's refusal (
renderGuardVerdict, remedy item 2) — same replacement, plus "the audit log records it".4. The SIZE limb's refusal (
renderSizeVerdict, remedy item 2) — rendered on the #19024 shape:The ruleset reading this seat measured
GET /repos/objectstack-ai/objectstack/rulesets/12119582, with this seat's token, on the day this PR was written: HTTP 200, and the response carries nobypass_actorskey at all — notnull, absent. The keys it does return areid name target source_type source enforcement conditions rules node_id created_at updated_at current_user_can_bypass _links, andcurrent_user_can_bypassreads"never".That is a different fact and is recorded beside it: it says THIS token is not itself a bypass actor, which is true of every agent seat and says nothing about whether the ruleset configures one for the maintainer. The director's earlier pointer read the field as
null; this seat's read gets no key. Both are "cannot conclude", and the pin treats them differently only in what it prints.check-required-contexts.mjs's measured header is the authority on why: the ruleset endpoints answer 200 formetadata=read, but the bypass roster is anadministrationfield, andadministrationis not one of the 17 permissions a workflow may grant itsGITHUB_TOKEN. So no token this repository's CI can hold will ever readbypass_actors.The pin, and its three verdicts
One new battery in
--self-test, five cases, driving two new pure exports (bypassActorReading,remedyPathVerdict) over a frozen copy of the measured response:bypass_actorsas readunreadablebypass_actors: unreadable with this token (the key is absent); current_user_can_bypass: "never"[]ornullnot-offeredofferedTwo ways to red, not one: the ruleset is READ to offer none, or the remedy stops naming a path at all — which is the defect this card filed, and a pin that only checked the first would sit green through it. The self-test prints the reading on every run, so "unreadable" is never a silent pass. ⛔ It never asserts a path from a field it did not read, and ⛔ it never reds CI on a permission difference.
Recorded, not live — the four axes
The card allowed either a live read or a recorded fixture. Recorded, and the live read deliberately not added.
bypass_actorsis unreadable to this seat's token AND to any Actions token (above). A live read wired into this self-test would therefore answerunreadableon every CI run in existence — it would assert nothing, on every run, while adding a network call. The real need is that the remedy sentence names a reachable path; the only party who can verify reachability is the maintainer, and their verification already happened (the revert(spec): take back the declaration-text snapshot, restore the 27 signature hashes #19024 bypass merge). A recorded reading is what that evidence looks like in this file.Governed Surface Queue Guardjob, run underbash -eas the precondition for trusting the guard, and its own usage line declares itoffline, no network, no git. Making a merge precondition depend on api.github.com reachability and on a token's permission tier is the permanently-red-gate shape this repo has already retired. The precedent is in-repo and exact:check-required-contexts.mjskeeps a frozenRULESET_SNAPSHOTin its self-test and leaves the live diff to a report-only mode that never runs in CI, for the same structural reason.The fixture's cost is drift, and it is bounded on purpose: it carries its provenance in the comment above it (endpoint, id, read date, token class), its reading is printed on every self-test run rather than asserted silently, and the field it records is one no CI token can re-read anyway — so a live read would not have detected drift either.
Evidence
node scripts/pm/check-governed-queue-guard.mjs --self-test:: exit 0 — 301 cases (296 before; +5 is exactly the new battery), and the run printsℹ #19344 ruleset reading — bypass_actors: unreadable with this token (the key is absent); current_user_can_bypass: "never".scripts/ablation-replace.mjs, wrap mode, on the committed state): anchorclick is the Merge button's bypass-rules optionreplaced inrenderSizeVerdict— on-disk proofanchor 1 -> 0, blob 5b75964229ac -> e6ee4e00f482— self-test went red, 3 of 301, namingall-four-remedies-NAME-the-bypass-rules-option…,a-field-this-token-cannot-see-is-UNREADABLE-and-PASSES…andone-configured-bypass-actor-makes-the-named-path-REACHABLE…. Restore proven byte-identical:blob == HEAD (5b75964229ac)andgit diff HEADempty. The direction is the expected one (red), and it reds via BOTH limbs of the contract, which is what "two ways to red" means.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, no paths, 31 families): 30 run with exit captured before any pipe, all exit 0;pnpm check:pm-dispatch-gateswas still running when this PR was opened and is reported in the card comment.--ranreconciliation: 31 derived, 30 accounted with real exit codes, 0 NOT-MEASURED among them.node scripts/pm/check-governed-merges.mjs --test scripts/pm/check-governed-queue-guard.mjs:: exit 0 — NOT governed; ordinary queue landing applies.--pr 19024:: exit 3 — the size-decided landing is listed, which is the recognition the remedy text now points at.origin/main; (2) PR fix(pm): repair two carriers still spelling the superseded references-tier boundary #19379's region:268–:294is untouched by this diff; (3) confirmed — the self-test is wired at.github/workflows/governed-surface-guard.yml, extended in place, no second entry point; (4) confirmed above.Acceptance notes
current_user_can_bypass, which an ordinary token CAN read and which directly answers "is the bypass option offered to the account asking". Nothing in this repo probes it live; it is recorded in this fixture only. Observation, not filed — it is a capability nobody has pulled on, and the four-axis call above is not to add a live probe to a required precondition.check-required-contexts.mjs'sRULESET_SNAPSHOTrecords the 2026-08-18 reading with six required contexts; the live ruleset now carries seven (Governed Surface Queue Guardjoined since). That file's assertions are deliberately written on the SHAPE, not on membership, and its header says so, so nothing is wrong — but the snapshot is a frozen historical reading and reads at a glance like a current one. Observation, not filed.check-governed-merges.mjs, imported here), the required-context set, the 「THIRD leg」 header at:268–:294(PR fix(pm): repair two carriers still spelling the superseded references-tier boundary #19379's region), and any workflow.Generated by Claude Code