Repository navigation
fix(pm): an authorised APPROVED review satisfies the queue guard on any commit (2026-09-04 ruling) - #15203
Merged
Conversation
…ny commit The maintainer ruled on 2026-09-04, in the live PM chat, while a governed PR that an authorised approver had approved three times kept falling out of the merge queue (verbatim, untranslated): 你的门禁有问题,只需要有人工批准记录就行,不需要卡最新的提交。 So the `merge_group` leg of `check-governed-queue-guard.mjs` now passes a governed PR iff an account in `GOVERNED_APPROVERS` holds a latest-decisive APPROVED review — on ANY commit, with `commit_id` unread by the decision. This supersedes the sha-pin half of the 2026-08-27 predicate and nothing else: that ruling's approver set stands, DISMISSED and superseded approvals still never count, an unauthorised APPROVED still never counts, and an empty or unreadable review list still fails closed. The accepted cost is stated out loud in the header rather than left to be discovered: a push after an approval is no longer re-reviewed by this gate. It is the same shape of cost the 2026-08-12 ruling on the retired per-PR gate accepted when it dropped the identity proxy. `staleApprovers` is kept as a PRINTED READING and nothing more — authorised approvals given on a commit that is no longer the head, which are also in `approvers` because they count. A cleared queue log therefore names the commit each approval was given on, so the accepted cost is visible at the moment it is paid. The PR head is still read for that line but decides nothing, so an unreadable head is no longer a refusal: a read that decides nothing may not block a landing. The review list, which IS the predicate's input, still refuses. The `pull_request` early-warning leg, the path test, the exit codes, the check-run name, `GOVERNED_APPROVERS` and the generated-surface register are all untouched, and the early-warning leg's rendered output stays byte-identical. Self-test: 133 cases pass. The cases that pinned the sha pin now pin the ruling in both directions — an authorised approval on an OLDER commit passes, while dismissed, superseded, unauthorised and empty still refuse. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019RfFHiRCSs3JXLK4cwcfox
…eue-guard-unpinned-approval
…e governed surfaces The guard half landed in the previous commit; this is the half that lives in governed files, and the two only work together. Without it the CI job "Claude hook guard self-tests" is red on main, because the seat-side enqueue hook's self-test pinned the sha pin the maintainer retired. `.claude/hooks/guard-governed-enqueue.selftest.sh` — the fixture that was `governed-stale` is now `governed-approved-on-an-earlier-commit`, and the case that asserted it BLOCKS asserts it ALLOWS. It is flipped rather than deleted on purpose: the hook holds no predicate of its own, so that direction arrives entirely through the imported predicate, and flipping the guard back would flip this case red. Adds `expect_lacks`, because a retired RULE leaves its sentence behind in the text a seat reads long after the predicate stopped enforcing it, and only an assertion that a phrase is ABSENT can catch that. `.claude/hooks/guard-governed-enqueue.sh` — the blocking message no longer tells a seat that a push unpins the approval; it quotes the ruling instead. The `approvalsOnEarlierCommits` line is dropped from the rendered detail, because since the ruling that bucket can only be non-empty when the verdict is `approved`, and the detail string is only ever printed on the blocking path. Both renames land here, with their only importer, in one commit: `pinnedApprovalVerdict` becomes `authorizedApprovalVerdict` and `staleApprovers` becomes `approvalsOnEarlierCommits`. A bucket still named for a refusal is one a later reader re-derives a refusal from. Renaming either without this hook in the same diff would have turned the hook into a silent fail-open: it catches the failure and ALLOWS, one warning line on stderr, every governed enqueue through. `AGENTS.md` and `.claude/skills/pm-dispatch/SKILL.md` — the two prose sites that stated the retired pin as normative now state the ruled predicate, with the ruling quoted verbatim and the accepted cost named once. The skill rewrite is line-for-line so the line ratchet is unmoved. The sentence that enumerates the governed surfaces is untouched. Hook self-test: 50 passed, 0 failed. Guard self-test: 133 cases pass. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019RfFHiRCSs3JXLK4cwcfox
…eue-guard-unpinned-approval
…o the 120-byte line budget `check:pm-skill-ratchet` prices a per-session token read by counting lines, so a long line is an unmetered tax. The rewrite in the previous commit put AGENTS.md L313 at 136B and two SKILL.md lines at 121B and 164B. Re-wrapped at legal break points with the content otherwise unchanged, and the verbatim ruling kept whole on one line in both files — a soft line break inside 「」 renders as a space, and a quotation with a space in it is a quotation that was edited. AGENTS.md is 1161 lines against its 1162 ceiling; the pm-dispatch skill is 971 against 1005. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019RfFHiRCSs3JXLK4cwcfox
…eue-guard-unpinned-approval
This was referenced Sep 4, 2026
os-zhuang
approved these changes
Sep 4, 2026
os-zhuang
marked this pull request as ready for review
September 4, 2026 07:15
os-zhuang
enabled auto-merge
September 4, 2026 07:15
os-zhuang
deleted the
claude/issue-15187-queue-guard-unpinned-approval
branch
September 4, 2026 08:14
This was referenced Sep 4, 2026
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.
Fixes #15187
Fixes #15209
The ruling
Maintainer, 2026-09-04, live PM chat, verbatim and untranslated — said while a governed PR that an authorised approver had approved three times kept falling out of the merge queue:
It supersedes the sha-pin half of the 2026-08-27 predicate. The approver set of that ruling (「os-zhuang hotlong 批准算数」) stands unchanged.
The predicate, before and after
scripts/pm/check-governed-queue-guard.mjs, themerge_groupleg:commit_idat allThe accepted cost is stated out loud in the header rather than left to be discovered: a push after an approval is no longer re-reviewed by this gate. It is the same shape of cost the 2026-08-12 ruling on the retired per-PR gate accepted when it dropped the identity proxy (「不要指定具体的人」); the institutional-memory section of
scripts/pm/check-governed-merges.mjscarries that history.The PR head is still read, so a cleared queue log can name the commit each approval was given on — that is where the accepted cost becomes visible at the moment it is paid. But it decides nothing, so its own read failure is no longer a refusal: a read that decides nothing may not block a landing. The review list, which IS the predicate's input, still refuses.
Two renames, landed with their only importer
pinnedApprovalVerdictauthorizedApprovalVerdictstaleApproversapprovalsOnEarlierCommitsapproversas well.claude/hooks/guard-governed-enqueue.shimports that function and reads that bucket, which is the "no second mechanism" design paying off: the seat-side hook's verdict flipped with this ruling for free, and it needed no predicate of its own. It is also why the renames could not land alone — the hook catches an import failure and allows, with one warning line on stderr, so every governed enqueue would have sailed through it silently.The hook self-test, measured in three states
.claude/hooks/guard-governed-enqueue.selftest.shis discovered and run bylint.ymlas the job Claude hook guard self-tests, and it pinned the sha pin.origin/maincopy of the one file restored on disk, hash-confirmed, then restored from HEAD)The two cases were flipped, not deleted. The fixture
governed-stalebecamegoverned-approved-on-an-earlier-commit, and the case that asserted it BLOCKS now asserts it ALLOWS — the hook holds no predicate of its own, so that direction arrives entirely through the imported function, and reverting the guard would turn this case red. The case that asserted the refusal says "STALE approval" is replaced by two: one asserting the refusal states the ruled predicate, and one asserting the retired sentence is absent, through a newexpect_lackshelper. A retired rule leaves its sentence behind in the text a seat actually reads long after the predicate stopped enforcing it, and only an absence assertion catches that.The hook's blocking message also changed:
The
approvalsOnEarlierCommitsline is dropped from the hook's rendered detail: since the ruling that bucket can only be non-empty when the verdict isapproved, and the detail string is only ever printed on the blocking path, so a line for it would be dead code that reads like a live refusal reason.The two prose sites
AGENTS.md, the governed-landing paragraph:.claude/skills/pm-dispatch/SKILL.md, the ACCEPT-fork clause — this one matters twice over, because it is the text every later PM dispatch reads:Both rewrites are wrapped inside the 120-byte line budget, with the verbatim ruling kept whole on one line in each file — a soft line break inside 「」 renders as a space, and a quotation with a space in it is a quotation that was edited. The sentence in
AGENTS.mdthat enumerates the governed surfaces is untouched, whichcheck:pm-governed-proseasserts.Guard self-test
node scripts/pm/check-governed-queue-guard.mjs --self-test— 133 cases pass. The battery that pinned the sha pin is renamed and flipped; no battery was deleted, soSELF_TEST_BATTERY_FLOORis untouched at 17. Cases now pinning the ruling in both directions:GOVERNED_APPROVERScommit_idat all passesapprovalsOnEarlierCommitsand COUNTED inapproversat the same timepull_requestearly-warning leg stays byte-identical and head-read-freeUntouched
The
pull_requestearly-warning leg and its rendered output, the path test, the exit codes, the check-run name,GOVERNED_APPROVERS, the generated-surface register, and.github/workflows/governed-surface-guard.yml.Gates
Derived on the final tree with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands(no paths) — 5 changed paths, 31 commands, no stale-tree warning. Every exit code captured before any pipe. Verification-run head sha:0a8763f6.bash .claude/hooks/guard-governed-enqueue.selftest.sh50 passed, 0 failednode scripts/pm/check-governed-queue-guard.mjs --self-test✓ check-governed-queue-guard self-test: 133 cases passpnpm check:pm-skill-ratchetAGENTS.md is 1161 lines (ceiling 1162)·pm-dispatch/SKILL.md is 971 lines (ceiling 1005)pnpm check:pm-governed-prose✓ 2 instruction surface(s) name all 5 registered governed surfaces … and claim no otherspnpm check:skill-frame-syncbinding sentence present in all 2; 5 count mention(s) agreepnpm check:pm-skill-id-lint✓ check-skill-id-lint: 23 file(s) cleanpnpm check:pm-dispatch-gates✓ dispatch-gates self-test: 1353 cases pass.pnpm check:pm-governed-merges✓ check-governed-merges --self-test: 245 assertionspnpm check:doc-authoring✓ 14730 customer-facing string(s) … cleanpnpm check:nul-bytescheck-nul-bytes: OK (… no raw ASCII control bytes)pnpm --filter @objectstack/lint run check:doc-formula-expressions@objectstack/lint...); it exits 3 = PREREQUISITE NOT MET on an unbuilt treepnpm lint(whole repo, through the shared verify lock)os-verify-lock: VERDICT command-exit 0 · held the lock 73snode scripts/pm/check-governed-merges.mjs --teston the final five paths exits 3, which is the expected answer now:One reading worth surfacing, found while running these and not caused by this diff:
node scripts/check-required-contexts.mjs --verify-required-set, once givenNODE_OPTIONS=--use-env-proxyso the read reaches the API, reportsdirection B — required in the live set, pinned by NO registry row (1): Governed Surface Queue Guard. The check this PR changes is already a required context in the live ruleset with noREQUIRED_CONTEXTSrow pinning it. That is a pre-existing registry gap; it is reported to the PM rather than fixed here.Authored by the ObjectStack dev seat in Claude Code session
session_019RfFHiRCSs3JXLK4cwcfox. (Durable attribution is written here as prose: the platform rewrites the footer block on every body edit, so the session-URL form does not survive a PATCH.)Generated by Claude Code