fix(settings,pm): deny the MCP enqueue class — enable/disable_pr_auto_merge join lock 1 and the roster gate - #18317
Conversation
…_merge join lock 1 and the roster gate
`permissions.deny` in `.claude/settings.json` is the enforced half of the
dispatch charter's rule that GitHub writes go through the REST proxy and
never through an MCP write tool. The enqueue pair —
`mcp__github__enable_pr_auto_merge` and `mcp__github__disable_pr_auto_merge`
— was the one MCP write class left outside it: hook-guarded only, so a
non-governed enqueue could still reach GitHub through MCP under the bound
user account, and the prose had to name the exception.
The director seat ruled A: the pair joins lock 1. The REST route
`PUT .../pulls/{n}/ccr/auto_merge` already serves every real enqueue, so
nothing a seat does today is lost, and a tool the runtime refuses cannot be
mis-called by a seat that mis-reads the hook's scope.
## What lands
- `.claude/settings.json` — the two names appended to `permissions.deny`
beside the fifteen `mcp__github__` entries (15 → 17), nothing else moved.
- `scripts/pm/check-settings-deny-roster.mjs` — `CONTENT_WRITE_TOOLS` gains
the two names in the file's order (the one declaration); the header
paragraph that said the enqueue class is NOT in the roster now says it IS
and why; the membership rule above the constant covers arming/disarming a
landing; a new self-test battery of four fixture cases pins both names,
a document denying exactly the roster reading declared = enforced, and a
document missing one of the pair naming it; battery floor 7 → 8,
self-test 32 → 36 cases.
- `.claude/skills/pm-dispatch/references/rest-channel.md` line 53 — the
「auto-merge 备用 MCP 未拒」 clause is dropped in place; the row now says the
auto-merge MCP pair is denied by lock 1 too (118 → 117 bytes, one line
stays one line, the file stays at its 82-line ceiling).
`guard-governed-enqueue.sh` and its self-test are untouched: the hook stays
as defence in depth. No changeset — nothing published moves.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HZfg2AwVX191qCizp88gQr
Contract reviewServed-tier: Reviewing seat: ① Derived judgments
② Semver levelNot applicable — nothing published moves; ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
维护者速读(终稿)— PR #18317 / #18282改了什么:按总监席裁决 A(你「135 同意」): 验证:主干 15 = 15 → head 17 = 17;两向反证(缺一项 → 报「未 deny」;多一项 → 报「roster drift」)各自命中;40 条派生门禁 39 绿 1 NOT MEASURED(需先构建 formula 包,CI 会跑);电池 1730 例两次通过。 要你做的:审 PR #18317,同意即 APPROVE;之后由本席按裁定 C 落地。落地后 objectui 的锁 1 镜像(objectui PR #9448)另立一张卡加同样两项,按裁决文本。 顺带发现(已立卡,不阻塞):hook 自检有一例在本容器上红——它假定不存在 Generated by Claude Code |
|
Maintainer reply on record (director seat, 2026-09-15T23:56Z): batch #138 presented this PR as a governed draft row — the MCP enqueue-class deny (class-1 A, batch #135) — with the skills seat's in-seat contract review PASS on the current head and 「一个动作:Approve」; the maintainer answered 「138 同意」. Review is already requested from Generated by Claude Code |
|
Ruling-C landing provenance — skills seat, session Generated by Claude Code |
…d reads the REST token class per write, not per session (objectstack-ai#18359) Fixes objectstack-ai#18320 Fixes objectstack-ai#18334 ONE file, one fold: `.claude/skills/pm-dispatch/references/platform-readings.md`. 463 to 465 lines (ratchet ceiling 466, headroom 1); every edited and every added line is at or under 120 bytes; the widest-table-row pin stays at 0. ## Hunk A — the denied enqueue tool's rows (the first card): lines 41, 49, 50, 58, 60 Lock 1 denies MCP `enable_pr_auto_merge` and `disable_pr_auto_merge` (PR objectstack-ai#18317), and four rows still taught a seat how to call them. The live enqueue route is REST `PUT .../pulls/{n}/ccr/auto_merge`, which `rest-channel.md` owns. - **`:49` retired** (the `mergeMethod: "SQUASH"` calling convention). It is a write-side recipe, and `:133` of this same file rules that write-side recipes live in `rest-channel.md` and are never restated here — 「逐操作通道归属、写侧配方与队列路由三读法见 `rest-channel.md`,⛔ 不在本表复述」. `rest-channel.md:50` already carries the live spelling with `{"merge_method":"SQUASH"}`. Re-keying it here would have created the exact duplicate that row forbids. - **`:58` retired** (the tool succeeds on an already-`mergeable_state: clean` PR, against its own description). Its entire content is a contradiction between one MCP tool and that tool's description; nothing of it survives for the REST route. - **`:60` retired** (quota exhaustion returns success with no mount, so verify the effect and not the echo). Its conclusion is carried for the live route by `rest-channel.md:52` 「⛔ `auto_merge` 与回显都不作数」, and inside this file by the surviving row 「回显两向不可靠 … ⛔ 不拿它当任何方向的证据」, which now stands directly above 「效果读数 = …」. The dispatch asked for no duplicate, so no replacement row was written. - **`:41` re-keyed TOOL-NEUTRAL**, deliberately not keyed to the REST unload. The surviving truth (unloading auto-merge alone does not kick this repo's queue) was measured on the MCP `disable` call. I could cite no measurement of that same behaviour on `DELETE .../ccr/auto_merge`: `rest-channel.md:51` measures only the 422 an unmounted PR answers, which is a different question. Keying the row to the REST route would have asserted an unmeasured platform fact, so it now reads 「补救:转 draft 与卸载 auto-merge 都做 —— 本仓卸载 auto-merge 单独不踢队。」 - **`:50` re-keyed in place**, forced by retiring `:49`: `:50` opened with 「它」 and `:49` was its antecedent. The reading is unchanged and the new subject is route-neutral — 「挂上的 auto-merge 存的方法恒为 `merge`」. This is the one hunk outside the four named lines, and it is declared as a deviation in the report. After this hunk, zero rows in the file name either denied tool in any voice, so no history row was needed either: the deny itself is already recorded for the operation in `rest-channel.md:53`, which is where `:133` says it belongs. ## Hunk B — the REST token class (the second card): lines 129-130 become 126-132 `:129` said the class is fixed per session — 「按会话定」. Two measured flips inside one session, each with no seat action and with `GET /user` constant, falsify that: - the triage seat, 2026-09-15: `claude[bot]` at 15:54Z, then `os-sam` at 22:55Z; - this seat, 2026-09-15 into 09-16: `claude[bot]` at 20:04Z, then `os-zhuang` at 01:53Z. The rows now say: two classes, both at core 15,000/h; the class follows the Claude Code account and NOT the session, and can flip between two writes of one session with no seat action; both flips are named by date and account; the class is read from EVERY write's own read-back (`user.login` plus `user.type`) and is never carried forward from the round-open marker, whose identity reading is dated rather than standing; and `performed_via_github_app`, `GET /user` and the core rate-limit header all answer the same for both classes, so none of the three discriminates. The consequence is split across two rows because it does not fit one 120-byte line: a user-class write is author-bound — a suspended account 404s its comments and its filed cards, while labels, state, titles and bodies survive — so the durability calculus is re-run per class and never assumed to sign as `claude[bot]`; and under the user class the PR's author IS that user, so requesting them as reviewer answers 422. That 422 was measured on PR objectstack-ai#18351 at 02:32Z. The second measurement table comes from objectstack-ai#18350, which the second card carries as its duplicate. ## Verification Reverse verification, before and after, on the one file: | reading | before (`1411cf2c`) | after | |---|---|---| | `grep -n -E 'enable_pr_auto_merge\|disable_pr_auto_merge'` | `:41 :49 :58 :60` | zero hits (grep exit 1) | | `grep -n -E 'installation\|user-to-server'` | `:129` only | `:126` only | | 「按会话定」 | present on `:129` | absent; `:127` reads 「⛔ 不按会话定」 | | every-write read-back | absent | `:129` 「类只认每次写回读的 …」 | | consequence row | absent | `:131` and `:132` | | `wc -l` | 463 | 465 | | widest line | 120 B | 120 B, zero lines over 120 B | Firing control — `rest-channel.md` is untouched and still carries the live route: `grep -n 'ccr/auto_merge' .claude/skills/pm-dispatch/references/rest-channel.md` still answers `:50` and `:51`, byte-identical. `git diff --stat` against the merge base is the one file, 9 insertions and 7 deletions. Ratchet, both readings, quoted from the gate itself: ``` ✓ check-skill-line-ratchet: .../platform-readings.md is 465 lines (ceiling 466; headroom 1). ✓ check-skill-line-ratchet: .../platform-readings.md: widest table row is 0 bytes (pin 0; headroom 0). ``` `node scripts/pm/dispatch-gates.mjs --commands THE-FILE` derived 16 families; all 16 were run, every one at exit 0, and reconciled with `--ran`: `✓ dispatch-gates --ran: 16 derived famil(ies) accounted for — 16 run, 0 NOT-MEASURED`. Outside that derivation I also ran `pnpm check:pm-settings-deny-roster` (its roster sits under `.claude/`, so its silence would not have been evidence in either direction) and the path face `node scripts/pm/check-governed-merges.mjs --test THE-FILE`, which answers GOVERNED as expected. No changeset: nothing versioned moves. `.claude/**` ships in no package `files[]`. ## Landing This diff touches `.claude/**`, a governed surface (Prime Directive objectstack-ai#14). Every governed path lies under `.claude/skills/pm-dispatch/references/`, so the landing tier is the skills seat's in-seat review at `CONTRACT_REVIEW_TIER` rather than the maintainer's word. **This PR stays DRAFT.** I requested no reviewers, touched no ccr route, armed no auto-merge and flipped nothing. ## 维护者速读(草稿) **改了什么** — 派发座位的平台事实表改了两处。一是删掉三条、改写两条教座位去调一个已被 锁 1 禁掉的 MCP 入队工具的规则,活路线的写法本来就在 `rest-channel.md`。二是把「REST 写 的身份按会话固定」这条改成实测的样子:身份跟 Claude Code 账号走,一次会话中间会变,每次 写都要自己回读一次。 **为什么改** — 这两条都是写着的事实与实测不符。前者让座位学一个它调不到的工具;后者更贵: 座位的耐久性判断(评论和卡会不会随账号被封而 404)整个建立在「署名恒为 `claude[bot]`」 上,而这一班里已经两次实测到写入落成了用户账号。四天内已经有两个分诊账号在班中被封并因此 丢掉全部书面记录,所以这条假设错的方向正是丢数据的方向。 **风险与代价(含回滚)** — 只动一个内部指令文件,不发布、不进任何包、无运行时影响。棘轮还 剩 1 行余量。回滚 = revert 这一个 commit。⛔ 本 PR 不改写入通道本身:换一个低权限账号或 改回 App installation token 是维护者的决定,这里只把事实表改成实测的样子。 **席位意见** — **你要做的** — 目前不需要你做任何事。这是 references 层,按现行分层由技能席位在座评审后 自己落地;⛔ 不需要你的批准。若你希望把「写入身份」这件事本身处理掉(专用低权限账号,或 恢复 App installation token),那是另一张卡。 --- _Generated by [Claude Code](https://claude.ai/code/session_01HZfg2AwVX191qCizp88gQr)_ Co-authored-by: Claude <noreply@anthropic.com>
…table, so its self-test is hermetic on a box that carries the sibling (objectstack-ai#18365) Fixes objectstack-ai#18321 ## The defect `.claude/hooks/guard-governed-enqueue.selftest.sh`'s case "an exception-row path in a repo this container cannot resolve" rested on a premise about the **box**, not about the hook: that no `objectstack-ai/cloud` checkout sits beside this one. The hook resolves a sibling checkout by comparing origin slugs under the parent directory of its own repo root, so on a container that *does* carry a sibling `cloud` checkout the guard resolved it, recomputed the register predicate on that tree with `--root`, got the governed answer and **blocked**. The matrix read `54 passed, 1 failed` there, and was green in CI only because the runner mounts no sibling. `lint.yml`'s step comment calls these matrices hermetic; this case was not. ## What changed **The hook** now reads `OS_GOVERNED_ENQUEUE_SIBLING_ROOT` for the directory a sibling is resolved under. It moves **where** the search looks and nothing else — the origin-slug comparison remains the entire admission rule, so a sibling that resolves is audited exactly as before, and a root holding no matching checkout resolves nothing. No fail-open path was added or widened. | value | meaning | |:---|:---| | unset | the parent of this checkout — today's behaviour, to the byte | | empty | identical to unset; an empty value is an accident, and the safe reading of an accident is "no override", never "look nowhere" | | a directory carrying no matching checkout (one that does not exist included) | nothing resolves; the run proceeds exactly as on a box without the sibling | **The matrix** now owns its own premise: the "cannot resolve" case points the lookup at a directory it creates and knows is empty, and one new case pins the other half — a sibling that *does* resolve is audited on its own tree. The throwaway sibling is built in the self-test (`git init` plus an `origin` naming the target repo is the whole admission requirement) and removed by the existing `trap` on the matrix's own temp root, so no new cleanup path was needed. Both temp directories live under that root by construction. The new case is written as **agreement with the register**, not as a copied verdict — the shape this file's own header says it learned the hard way, where a hard-coded `expect allow` went red over an upstream register change the hook had nothing to do with. ## Reverse verification BEFORE, on `origin/main` `1411cf2c`, this container, `/home/user/cloud` present: ``` 54 passed, 1 failed FAIL want=allow got=block an exception-row path in a repo this container cannot resolve ``` AFTER, at `5391e5c0`, same container, `/home/user/cloud` still present — four injection states, all `exit 0`: ``` UNSET exit=0 56 passed, 0 failed EMPTYDIR exit=0 56 passed, 0 failed NONEXISTENT exit=0 56 passed, 0 failed EMPTYSTRING exit=0 56 passed, 0 failed ``` **"Unset changes nothing" is proved directly**, not inferred: the *untouched* matrix was run against the *changed* hook, and its output was byte-identical to the baseline log (`diff` empty — still `54 passed, 1 failed`, still the same one case). The variable's semantics were also measured against the hook directly, with the real sibling present, on a payload targeting `objectstack-ai/cloud`: ``` unset -> block (resolves the real sibling) empty string -> block (identical to unset) =/home/user -> block (explicit, same directory as the default) empty dir -> allow (nothing resolves) non-existent -> allow (nothing resolves) ``` **Firing control.** Two were run, each mutating the committed file, proving the mutation landed on disk by hash, and restoring with `git checkout HEAD --` under a `trap` (`git diff HEAD` empty afterwards, blob hash back to the HEAD blob): - *the control this card prescribed* — give the new case a **not-governed** fixture: **stays green**, `56 passed, 0 failed`. It cannot fire, and that is a property of the assertion rather than a gap: an agreement assertion flips the register leg and the hook together, so they still agree. Reported rather than papered over. - *a control that targets the property under test* — the throwaway sibling's `origin` names a different repo, so it is no longer admitted: **red**, `55 passed, 1 failed`, `FAIL want=block got=allow a sibling checkout that resolves is audited, never waved through`. This is the mutation that corresponds to "the hook stopped consulting the sibling tree", which is what the case exists to catch. Diff confined to the two files in the declared surface: ``` .claude/hooks/guard-governed-enqueue.selftest.sh | 92 +++++++++++++++++++--- .claude/hooks/guard-governed-enqueue.sh | 41 +++++++++- 2 files changed ``` ## Gates `dispatch-gates.mjs` derives 13 families from the real change set (2 paths, three-dot vs merge base `1411cf2c6`); all 13 ran and all recorded `exit 0`. Reconciliation: ``` Run reconciliation — 13 derived, 13 run, 0 NOT-MEASURED, 0 UNRUN. ``` `check-doc-formula-expressions` first returned **exit 3 (PREREQUISITE NOT MET — nothing measured)** because two workspace packages were unbuilt; it was re-run to `exit 0` after a targeted build, and only that second reading is recorded. The path face `check-governed-merges.mjs --test` answers **GOVERNED** (`.claude/**`), as expected. `shellcheck` is **not measured**: the tree wires no shellcheck step (`lint.yml` runs the hook matrices, it does not lint them) and the binary is absent from this container. `bash -n` parses both files and `check:bash32-floor` passes. ## Acceptance notes Two things measured on the way, both **out of scope for this PR** and neither fixed here: 1. **The case never reached the fail-open it claimed to pin.** The comment that used to sit on it described the "no checkout of the target repo is available" branch. It does not reach that branch, in any environment including CI: with nothing resolved the register is asked *without* `--root`, answers about *this* tree, finds the path byte-exact against its own generator and lifts it, so the hook leaves at the cleared-predicate `exit 0` with **empty stderr**. That branch is reachable — a path hitting the exception row but absent from the generator's declared output set returns governed with a non-empty `exceptions` list — but no case in the matrix reaches it today. The stale comment is corrected here because it sits on the case being re-keyed; pinning the branch is separate work. 2. **The slug reader keeps a `.git` suffix.** Its path character class owns the dot and is greedy, so an origin of `https://github.com/objectstack-ai/cloud.git` yields `objectstack-ai/cloud.git` and matches nothing. A sibling cloned with the conventional URL therefore does not resolve. The same expression also derives the slug for a bare `gh pr merge NUMBER`, where a non-matching slug makes the API read 404 and the guard fail open. Measured here; reported for its own card. The new case uses the bare URL form on purpose and says so in place, so nobody "tidies" a `.git` onto it. Also noted, not filed: `lint.yml`'s step is named for the two matrices that existed when it was written, while discovery now picks up more. The step comment is explicit that discovery is the contract and that a hard-coded list would be the defect, so the name is illustrative rather than a ledger — nothing to reconcile. Whoever adds the next hook matrix is the one who reads it. `lint.yml` is outside this PR's declared surface and was read, not edited; its hermetic claim becomes true rather than edited. Serial context: PR objectstack-ai#18317 touched `.claude/settings.json` and landed before this branch was cut; it is unrelated to either hook file and nothing here depends on it. ## 维护者速读(草稿) **改了什么** — 给这个 PreToolUse 守卫加了一个环境变量,用来指定「到哪个目录下去找兄弟仓 checkout」。默认不设时行为与今天逐字节一致。自测脚本据此改写了一个用例,并新增一个用例。 **为什么改** — 这个自测用例原本依赖「这台机器上没有 cloud 仓的 checkout」这个环境事实,而不 是依赖守卫本身的行为。凡是挂了 `/home/user/cloud` 的机器上它就红,CI 绿只是因为 runner 上 恰好没有。测试的结论必须由被测代码决定,不能由机器上还挂了什么决定。 **风险与代价(含回滚)** — 风险低。变量只改变「去哪里找」,不改变「找到了算不算数」—— 判定仍然是比对 origin slug,所以误设一个值只会让它找不到(退回到本来就存在的放行分支), 不会让它放过本该拦截的东西。已逐项实测 unset / 空串 / 空目录 / 不存在目录四种取值。回滚就是 还原这两个文件,无数据迁移、无发布物、无下游依赖。 **席位意见** — (待席位填写) **你要做的** — `.claude/**` 属受管面,按 Prime Directive objectstack-ai#14 需要一次授权的 APPROVED 审核; 本 PR 保持 draft,未请求任何 reviewer,未触碰 auto-merge。除批准外无需其他动作。 --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01HZfg2AwVX191qCizp88gQr --- _Generated by [Claude Code](https://claude.ai/code/session_01HZfg2AwVX191qCizp88gQr)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #18282
Draft, rules layer — stays a draft until an authorized APPROVED review. The diff touches
.claude/settings.json(a governed surface under Prime Directive 14), so no seat readies, queues, or arms auto-merge on it. Landing follows ruling C on card #17971 (maintainer direct ruling, 2026-09-13, comment 5652306063: 「C. approve 后不管后续改动都由席位落地:」) — after the authorized approval the owning seat lands it; no reviewer request comes from the dev.The ruling this implements — director seat, comment 5682626575 on #18282, verbatim and untranslated
Class-1 self-adjudication, batch #135 追认, the maintainer's 「135 同意」.
What changes — three files, nothing else
.claude/settings.json—mcp__github__enable_pr_auto_mergeandmcp__github__disable_pr_auto_mergeappended topermissions.denyaftermcp__github__fork_repository, in the file's own write-class order (issue → PR → comments → review → files → branch → sub-issue → merge → repo → enqueue). 15 → 17mcp__github__entries; every other byte of the file unchanged. Theguard-governed-enqueue.shmatcher on line 82 still namesenable_pr_auto_merge— that is the hook's registration, not a permission, and it stays.scripts/pm/check-settings-deny-roster.mjs(the gate PR feat(pm): a gate pins permissions.deny to the charter's content-write roster #18285 landed) —CONTENT_WRITE_TOOLSgains the same two names in the same order (15 → 17; still the ONE declaration). The header paragraph that said the enqueue class is NOT in the roster is re-keyed to say it IS, ruled closed, and why; the membership rule above the constant now covers "arms or disarms a pull request's landing" so the criterion and the constant agree. A new self-test batterythe enqueue class — declared closed(4 fixture cases: both names in the roster; a document denying exactly the roster reads declared = enforced; a document missing one of the pair names it);SELF_TEST_BATTERY_FLOOR7 → 8; self-test 32 → 36 cases..claude/skills/pm-dispatch/references/rest-channel.mdline 53 — the 「auto-merge 备用 MCP 未拒」 clause is dropped in place; the row now ends 「auto-merge MCP 锁 1 同拒。」. 118 → 117 bytes, one line stays one line, the file stays 82/82 on the ratchet, noruledRaisesowed.Untouched by design:
.claude/hooks/guard-governed-enqueue.shand its self-test (defence in depth, per the ruling), every other row ofrest-channel.md,platform-readings.md, objectui.Why the header paragraph is re-keyed rather than deleted — the one design choice, on the four axes
CONTENT_WRITE_TOOLSreads that header to learn the membership rule. Arming auto-merge is not obviously "publishing content", so without a sentence saying the pair is in and why, the pair reads as drift to someone applying the old rule. A reader today: the seat that mirrors lock 1 into objectui.permissions.denyto the MCP write-tool roster the charter's ACCEPT refusal names — declared vs enforced is reconciled by hand #18281, finding(settings): permissions.deny omits mcp__github__update_pull_request — an MCP content write (title/body/draft) the charter now declares closed, and the seat's ready-flip fallback names the same tool #18218, check:react-declaration-parity 是唯一没接进任何 workflow 的源码审计门禁,且无 MANIFEST 时静默 skip 退出 0 —— 它现在永远不可能红 #4690) and carries no narrative.Verification (all readings on this branch; final union on
ad7a3da5)Reverse verification — the count is the control.
6ef33128(origin/main at cut):pnpm check:pm-settings-deny-roster→✓ … 15 content-write tool(s) declared = enforced in .claude/settings.json (15 mcp__github__ deny entr(ies) …);--self-test→32 cases pass.denies mcp__github__enable_pr_auto_merge, mcp__github__disable_pr_auto_merge, which this gate's roster does not declare — roster drift. This is the direction the change removes.✓ check-settings-deny-roster: 17 content-write tool(s) declared = enforced in .claude/settings.json (17 mcp__github__ deny entr(ies), 0 outside this gate's population and ignored).;--self-test→36 cases pass.disable_pr_auto_merge: exit 1 —does not deny mcp__github__disable_pr_auto_merge — the charter declares it closed while the enforced list leaves it open.Ablation from the committed state, hash proofs per leg (
git restore --source=BASE -- PATHfor the mutation,git checkout HEAD -- PATHfor the restore, a trap on EXIT/INT/TERM with absolute paths, every blob hash compared and non-empty):.claude/settings.jsonat the base blob0acd039e(15 entries), gate at HEAD: exit 1,does not deny mcp__github__enable_pr_auto_merge, mcp__github__disable_pr_auto_merge … leaves them open. Restored to HEAD blob428a0300.391f20ce(15-name roster), settings at HEAD: exit 1,denies mcp__github__enable_pr_auto_merge, mcp__github__disable_pr_auto_merge, which this gate's roster does not declare — roster drift. Restored to HEAD blob5736aad3.git diff HEADempty;git status --porcelainempty.The hook is untouched and its self-test reads the same before and after.
git diff --stat 6ef33128 ad7a3da5 -- .claude/hooks/is empty.bash .claude/hooks/guard-governed-enqueue.selftest.shreads54 passed, 1 failedon the untouched base and byte-identically on the head (diffof the two logs empty). The one red is pre-existing and environmental, not this PR's: the casean exception-row path in a repo this container cannot resolveexpects allow on the premise that no siblingcloudcheckout exists, and this container has/home/user/cloud, which the hook resolves at its lines 453–454 and recomputes against, so it blocks. See Acceptance notes.Derived gate union on the merge commit
ad7a3da5(node scripts/pm/dispatch-gates.mjs --commands, 40 commands, identical list before and after merging origin/main; each exit captured by redirect, then--ran):✓ dispatch-gates --ran: 40 derived famil(ies) accounted for — 39 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3).pnpm check:pm-settings-deny-roster,pnpm check:pm-skill-ratchet(rest-channel.md is 82 lines (ceiling 82; headroom 0)),pnpm check:pm-skill-id-lint,pnpm check:pm-half-states(on the feat(pm): the half-state patrol reads the tier a triage round artefact names #18311 copy,4196 cases pass),pnpm check:nul-bytes,pnpm check:watch-hint-literal,pnpm check:skill-frame-sync,pnpm check:pm-governed-merges,node scripts/check-self-test-wired.mjs(+--self-test).pnpm check:pm-dispatch-gates(the battery) run detached: exit 0,dispatch-gates self-test: 1730 cases pass, 454 s on this box (462 s on the pre-merge run).pnpm --filter @objectstack/lint run check:doc-formula-expressions— exit 3,PREREQUISITE NOT MET — the workspace package @objectstack/formula is not built. The diff touches no package; building@objectstack/formulameans building@objectstack/specfirst, so this gate is left to CI, where it runs on a built tree. Declared narrowing, not a skip.skip-changeset— every path is in the never-published lane (.claude/**,scripts/pm/**); no packagefiles[]moves.Acceptance notes
update_pull_requestwrapper as a live edit channel (:347 · :348 · :354 on the PR #18307 head) #18310 did for theupdate_pull_requestrows):references/platform-readings.mdstill describesenable_pr_auto_merge/disable_pr_auto_mergeBEHAVIOUR as if the MCP tools were callable — after PR docs(pm): platform-readings retires the rows that still describe MCP update_pull_request as a live channel #18307's renumbering these are lines 41 (转 draft 与disable_pr_auto_merge都做), 49 (enable_pr_auto_merge恒显式传mergeMethod), 58 (照样成功 onmergeable_state: clean), 60 (配额枯竭时回成功). Once lock 1 denies the pair those rows describe a channel no seat can reach; the RESTccr/auto_mergereadings on rest-channel 50–52 are the live ones. Not touched here — this card is three files..claude/hooks/**is outside this card):guard-governed-enqueue.selftest.shis not hermetic on a box with a siblingcloudcheckout — the case above reads red there and green in CI. Repro: with/home/user/cloudpresent,bash .claude/hooks/guard-governed-enqueue.selftest.sh→54 passed, 1 failed,FAIL want=allow got=block an exception-row path in a repo this container cannot resolve. Dedupe words: guard-governed-enqueue selftest sibling cloud checkout hermetic.维护者速读(草稿)
改了什么 — 把 MCP 的
enable_pr_auto_merge/disable_pr_auto_merge两个工具加进.claude/settings.json的permissions.deny(锁 1,15 → 17 条);门禁check:pm-settings-deny-roster的名单同步加这两条、它自己的说明改成「入队类已裁定关闭」、自测 32 → 36 例;rest-channel.md第 53 行删掉「auto-merge 备用 MCP 未拒」,改为「auto-merge MCP 锁 1 同拒」。钩子guard-governed-enqueue.sh一字未动,继续作纵深防御。为什么改 — 董事席裁决 A(#135 批追认,你的「135 同意」):真实入队全部走 REST 的
PUT …/pulls/N/ccr/auto_merge,MCP 那条备用路没人用却留着口子,靠钩子挡只覆盖受管 PR;运行时直接拒掉,席位就不可能误用,声明 = 强制的原则延伸到入队类。风险与代价(含回滚) — 席位现有流程零损失(所有真实入队都走 REST)。风险只有一种:某个会话若真去调这两个 MCP 工具会被运行时拒绝——这正是目的。回滚 = revert 本 PR 的一个 commit;门禁把 deny 与名单钉成相等,任何半回滚都会立刻变红,不可能静默存在。
席位意见 — (留空,席位定稿)
你要做的 — 审阅并 APPROVE(
.claude/settings.json属 rules 层,需要授权账号的 APPROVED);之后由席位落地,不需要你合并。objectui 镜像(PR objectui#9448 的锁 1)另开一张卡跟进,不在本 PR。Generated by Claude Code