Skip to content

fix(pm): an authorised APPROVED review satisfies the queue guard on any commit (2026-09-04 ruling) - #15203

Merged
os-zhuang merged 6 commits into
mainfrom
claude/issue-15187-queue-guard-unpinned-approval
Sep 4, 2026
Merged

os-zhuang merged 6 commits into
mainfrom
claude/issue-15187-queue-guard-unpinned-approval

Conversation

@claude

@claude claude Bot commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #15187
Fixes #15209

⚠️ This PR touches a governed surface, so it stays a DRAFT. No seat flips it ready, enqueues it or arms auto-merge. It lands by the maintainer's own merge, or on one authorised approval — which, under the very predicate this PR carries, no longer expires when the branch moves.

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, the merge_group leg:

before (2026-08-27) after (2026-09-04)
authorised approval on the CURRENT head passes passes
authorised approval on an OLDER commit refuses (stale) passes
approval with no commit_id at all refuses passes
unauthorised account, APPROVED refuses refuses
DISMISSED, or superseded by the same reviewer's later CHANGES_REQUESTED refuses refuses
empty review list refuses refuses
review list unreadable refuses, exit 4 refuses, exit 4
PR head unreadable refuses, exit 4 printed as a missing reading; the reviews still judge

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 (「不要指定具体的人」); the institutional-memory section of scripts/pm/check-governed-merges.mjs carries 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

before after why
pinnedApprovalVerdict authorizedApprovalVerdict it no longer pins anything
staleApprovers approvalsOnEarlierCommits a bucket named for a refusal is one a later reader re-derives a refusal from — these approvals now COUNT and are in approvers as well

.claude/hooks/guard-governed-enqueue.sh imports 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.sh is discovered and run by lint.yml as the job Claude hook guard self-tests, and it pinned the sha pin.

tree result
pre-ruling guard (measured by ablation: the origin/main copy of the one file restored on disk, hash-confirmed, then restored from HEAD) 49 passed, 0 failed
guard changed, hook untouched 47 passed, 2 failed
this PR 50 passed, 0 failed

The two cases were flipped, not deleted. The fixture governed-stale became governed-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 new expect_lacks helper. 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:

- 2. Wait for the APPROVED review to land on THIS head sha ($head_sha).
-    A push after the approval unpins it — re-request, do not re-enqueue.
+ 2. Wait for the APPROVED review to land. It does NOT have to sit on the
+    current head ($head_sha), and a later push does not expire it — maintainer,
+    2026-09-04: 只需要有人工批准记录就行,不需要卡最新的提交。

The approvalsOnEarlierCommits line is dropped from the hook's rendered detail: 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, 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:

- **Those four lift only for an authorized approval pinned to the current head** (2026-08-27; guard
- 2026-08-28): APPROVED, by an account in `GOVERNED_APPROVERS` (...), `commit_id` = that sha — then the
- queue lands it, re-validating as it merges, and any later push expires it. […] Unpinned, the
- maintainer's bypass direct merge (人工直合) is the only landing.
+ **Those four lift only for an authorized APPROVED review** — by an account in `GOVERNED_APPROVERS`
+ (...), on ANY commit; the queue then lands it. Approver set 2026-08-27; pin retired 2026-09-04:
+ 「只需要有人工批准记录就行,不需要卡最新的提交。」 A later push no longer expires it, and this gate
+ does not re-review it. […] Unapproved, the bypass direct merge (人工直合) is the only landing.

.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:

- 或**授权人工批准钉 head ⇒ 队列放行**(2026-08-27 裁定)(… APPROVED 且 `commit_id`
- = PR 当前 head 才算,批准后再推提交即过期转红)。未钉批准 ⛔ 不翻 ready、不入队、不挂 auto-merge
+ 或**授权人工批准 ⇒ 队列放行**(2026-09-04 裁定,逐字「只需要有人工批准记录就行,不需要卡最新的提交。」
+ ——取代 2026-08-27 的钉 head 半边,批准人集合不变)(… 授权账户 APPROVED 即算,⛔ 不卡 `commit_id`,
+ 批准后再推提交也不过期)。无批准 ⛔ 不翻 ready、不入队、不挂 auto-merge

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.md that enumerates the governed surfaces is untouched, which check:pm-governed-prose asserts.

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, so SELF_TEST_BATTERY_FLOOR is untouched at 17. Cases now pinning the ruling in both directions:

  • an authorised approval on an OLDER commit passes — asserted per account, iterating GOVERNED_APPROVERS
  • an approval carrying no commit_id at all passes
  • an unauthorised APPROVED refuses, and does not mask an authorised one on an older commit
  • a DISMISSED authorised approval refuses; so does one superseded by the same reviewer's later CHANGES_REQUESTED
  • an empty review list refuses; a COMMENTED review by an authorised account is not an approval
  • an older commit is REPORTED in approvalsOnEarlierCommits and COUNTED in approvers at the same time
  • an unreadable head reports no reading instead of failing closed
  • end to end: an unreadable PR head no longer refuses, and the reviews are still read and still judge
  • end to end: an authorised approval on an older commit clears the queue leg, and the log says it counted anyway
  • the refusal text says an approval on an EARLIER commit would have counted, and never says STALE
  • the pull_request early-warning leg stays byte-identical and head-read-free

Untouched

The pull_request early-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.

gate exit verdict line
bash .claude/hooks/guard-governed-enqueue.selftest.sh 0 50 passed, 0 failed
node scripts/pm/check-governed-queue-guard.mjs --self-test 0 ✓ check-governed-queue-guard self-test: 133 cases pass
pnpm check:pm-skill-ratchet 0 AGENTS.md is 1161 lines (ceiling 1162) · pm-dispatch/SKILL.md is 971 lines (ceiling 1005)
pnpm check:pm-governed-prose 0 ✓ 2 instruction surface(s) name all 5 registered governed surfaces … and claim no others
pnpm check:skill-frame-sync 0 binding sentence present in all 2; 5 count mention(s) agree
pnpm check:pm-skill-id-lint 0 ✓ check-skill-id-lint: 23 file(s) clean
pnpm check:pm-dispatch-gates 0 ✓ dispatch-gates self-test: 1353 cases pass.
pnpm check:pm-governed-merges 0 ✓ check-governed-merges --self-test: 245 assertions
pnpm check:doc-authoring 0 ✓ 14730 customer-facing string(s) … clean
pnpm check:nul-bytes 0 check-nul-bytes: OK (… no raw ASCII control bytes)
pnpm --filter @objectstack/lint run check:doc-formula-expressions 0 green after building the closure it needs (@objectstack/lint...); it exits 3 = PREREQUISITE NOT MET on an unbuilt tree
the other 20 derived families 0 all green
pnpm lint (whole repo, through the shared verify lock) 0 os-verify-lock: VERDICT command-exit 0 · held the lock 73s

node scripts/pm/check-governed-merges.mjs --test on the final five paths exits 3, which is the expected answer now:

governed-surface predicate: 4 of 5 path(s) hit the register (5 surfaces, repo-agnostic).
  ⛔  GOVERNED — a human merge is the review record for this PR (#9495 regime).

One reading worth surfacing, found while running these and not caused by this diff: node scripts/check-required-contexts.mjs --verify-required-set, once given NODE_OPTIONS=--use-env-proxy so the read reaches the API, reports direction 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 no REQUIRED_CONTEXTS row 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

…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
…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
@github-actions github-actions Bot added the documentation Improvements or additions to documentation label Sep 4, 2026
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants