Skip to content

fix(settings,pm): deny the MCP enqueue class — enable/disable_pr_auto_merge join lock 1 and the roster gate - #18317

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-18282-deny-enqueue-class
Sep 16, 2026
Merged

os-zhuang merged 2 commits into
mainfrom
claude/issue-18282-deny-enqueue-class

Conversation

@claude

@claude claude Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

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

A — mcp__github__enable_pr_auto_merge and mcp__github__disable_pr_auto_merge join permissions.deny in .claude/settings.json (lock 1, 15 → 17 entries); guard-governed-enqueue.sh stays as defence in depth; references/rest-channel.md :53 drops the 「auto-merge 备用 MCP 未拒」 clause. The REST route (PUT …/pulls/N/ccr/auto_merge, rest-channel :50) already serves every real enqueue, so nothing a seat does today is lost.

Rules layer: the change lands as a four-piece governed draft on an authorized APPROVED, ⛔ never armed or merged by a seat; the objectui mirror of lock 1 (PR objectui#9448) gets the same two entries in its own card once this lands.

Class-1 self-adjudication, batch #135 追认, the maintainer's 「135 同意」.

What changes — three files, nothing else

  1. .claude/settings.jsonmcp__github__enable_pr_auto_merge and mcp__github__disable_pr_auto_merge appended to permissions.deny after mcp__github__fork_repository, in the file's own write-class order (issue → PR → comments → review → files → branch → sub-issue → merge → repo → enqueue). 15 → 17 mcp__github__ entries; every other byte of the file unchanged. The guard-governed-enqueue.sh matcher on line 82 still names enable_pr_auto_merge — that is the hook's registration, not a permission, and it stays.
  2. 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_TOOLS gains 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 battery the 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_FLOOR 7 → 8; self-test 32 → 36 cases.
  3. .claude/skills/pm-dispatch/references/rest-channel.md line 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, no ruledRaises owed.

Untouched by design: .claude/hooks/guard-governed-enqueue.sh and its self-test (defence in depth, per the ruling), every other row of rest-channel.md, platform-readings.md, objectui.

Why the header paragraph is re-keyed rather than deleted — the one design choice, on the four axes

Verification (all readings on this branch; final union on ad7a3da5)

Reverse verification — the count is the control.

  • Base 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-test32 cases pass.
  • Base roster vs a 17-entry fixture (the two new deny entries added to main's file): 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. This is the direction the change removes.
  • Head: ✓ 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-test36 cases pass.
  • Head roster vs a fixture missing 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 -- PATH for the mutation, git checkout HEAD -- PATH for the restore, a trap on EXIT/INT/TERM with absolute paths, every blob hash compared and non-empty):

  • Leg A — .claude/settings.json at the base blob 0acd039e (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 blob 428a0300.
  • Leg B — gate at the base blob 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 blob 5736aad3.
  • Restored tree: 17 = 17 green; git diff HEAD empty; git status --porcelain empty.

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.sh reads 54 passed, 1 failed on the untouched base and byte-identically on the head (diff of the two logs empty). The one red is pre-existing and environmental, not this PR's: the case an exception-row path in a repo this container cannot resolve expects allow on the premise that no sibling cloud checkout 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).
  • 38 commands exit 0, including 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).
  • NOT MEASURED: 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/formula means building @objectstack/spec first, so this gate is left to CI, where it runs on a built tree. Declared narrowing, not a skip.
  • ESLint on the gate file: exit 0 before and after. Control-character self-scan over the three files: no hits.

skip-changeset — every path is in the never-published lane (.claude/**, scripts/pm/**); no package files[] moves.

Acceptance notes

  • Out of scope, for the seat to card (as [finding] platform-readings: three rows of the PR-body footer block still read the denied MCP update_pull_request wrapper as a live edit channel (:347 · :348 · :354 on the PR #18307 head) #18310 did for the update_pull_request rows): references/platform-readings.md still describes enable_pr_auto_merge / disable_pr_auto_merge BEHAVIOUR 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 (照样成功 on mergeable_state: clean), 60 (配额枯竭时回成功). Once lock 1 denies the pair those rows describe a channel no seat can reach; the REST ccr/auto_merge readings on rest-channel 50–52 are the live ones. Not touched here — this card is three files.
  • Finding, for the seat (.claude/hooks/** is outside this card): guard-governed-enqueue.selftest.sh is not hermetic on a box with a sibling cloud checkout — the case above reads red there and green in CI. Repro: with /home/user/cloud present, bash .claude/hooks/guard-governed-enqueue.selftest.sh54 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.
  • The objectui mirror of lock 1 (PR objectui#9448) gets the same two entries in its own card once this lands — per the ruling, not this PR.

维护者速读(草稿)

改了什么 — 把 MCP 的 enable_pr_auto_merge / disable_pr_auto_merge 两个工具加进 .claude/settings.jsonpermissions.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

…_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
@github-actions github-actions Bot added size/s documentation Improvements or additions to documentation labels Sep 15, 2026
@claude claude Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 15, 2026
@claude

claude Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: ad7a3da5a8b19597382d78689fe1ffb48d43a5a3

Reviewing seat: domain:skills, in-seat review at the contract-review tier — get_session read at 2026-09-15T07:15Z: configured_model = session_context.model = last_served_model, the constant's tier, no fallback notice this round. Card #18282 (a decision card RULED A by the director seat, 5682626575, class-1 self-adjudication under the maintainer's 「135 同意」), claim 5683705838, report 5684269831 (posted through the tool after two offline stamp-contract refusals, nothing duplicated); Clause-②: no on the claim; the dev held at the review tier by the seat's judgment (rules layer); seat review at tier.

① Derived judgments

  • Accept set / public surface: none moves — .claude/settings.json (+2 deny entries, 15 → 17), scripts/pm/check-settings-deny-roster.mjs (+40/−15: CONTENT_WRITE_TOOLS 15 → 17, the header's 「deliberately NOT asserted」 paragraph re-keyed to 「the ENQUEUE class is in the roster」, one new 4-case battery, floor 7 → 8, self-test 32 → 36), .claude/skills/pm-dispatch/references/rest-channel.md :53 in place (「auto-merge 备用 MCP 未拒」 → 「auto-merge MCP 锁 1 同拒」, 118 → 117 B, 82 / 82) against merge-base 10a7b9fa; guard-governed-enqueue.sh and its self-test untouched (diff-stat over .claude/hooks/ empty); nothing published; skip-changeset read back. Rules layer ⇒ four-piece: this record, the 速读终稿 below, ACCEPT on the card, needs-user-decision + reviewers; lands by ruling C after an authorized APPROVED review.
  • The change, read in full on a detached worktree of the head: exactly the ruling's letter A — the two enqueue tools denied at the harness, the gate's one declaration extended so 17 = 17 in both directions, the register row pointing at lock 1 rather than enumerating the roster a second time. Seat re-run on the head: pnpm check:pm-settings-deny-roster 「17 content-write tool(s) declared = enforced (17 mcp__github__ deny entries, 0 outside)」 with 36 self-test cases; origin/main reads 15 = 15 / 32; ESLint 0/0 on the gate; check:pm-skill-ratchet rest-channel 82 / 82; check-self-test-wired OK; --pair 18317 0; the deny read on the head names both tools.
  • Gates 40 derived / 39 run / 1 NOT MEASURED (check:doc-formula-expressions, exit 3 — @objectstack/formula unbuilt; a prose-and-gate diff touching no package, CI runs it built) by --ran on the merge head, the battery 1730 pass twice (462 s pre-merge, 454 s on ad7a3da5). Reverse verification by the dev: base 15 = 15; a 17-entry fixture against main's roster → 「roster drift」 naming both (the direction this change removes); head 17 = 17; a 16-entry fixture against the head's roster → 「does not deny mcp__github__disable_pr_auto_merge」; two ablation legs with blob hashes (settings ← base: containment finding; gate ← base: drift finding; restored, git diff HEAD and git status --porcelain empty); the hook self-test 54 / 1 on base and head with an empty diff of the logs — the one red is pre-existing and environmental (a sibling /home/user/cloud checkout, which the SEAT itself attached at 2026-09-15T13:15Z for the governed-merge audit, makes the case 「an exception-row path in a repo this container cannot resolve」 resolve), filed as its own card below.

② Semver level

Not applicable — nothing published moves; skip-changeset measured.

③ Boundary flags

open_questions: none. One seat premise refuted, owned here: 「the hook self-test still passes untouched」 — it reads 54 / 1 on this box before and after, environmental, hooks untouched. Seven deviations answered: origin/main moved twice under the branch (fast-forward before any edit, then a merge commit, never a rebase, the union re-run on ad7a3da5) — accepted; the hook self-test red reported and filed rather than fixed — accepted, hooks are outside the card and governed; doc-formula NOT MEASURED — accepted, declared; the PR opened after the union so the body cites the union's sha — accepted; the model-free trailer pair — as required; :53 worded to point at lock 1 rather than name the pair — accepted, the gate header forbids a second roster enumeration; two offline stamp-contract refusals before the report posted — the tool doing its job, nothing duplicated. Out of scope, FILED by the seat: platform-readings :41 / :49 / :58 / :60 describe MCP enqueue behaviour a seat can no longer reach (the #18310 shape, serial behind this PR and PR #18316); the hook self-test's non-hermetic case (class (a), .claude/hooks/** governed). Noted for after the landing: the objectui mirror of lock 1 (objectui PR #9448) gets the same two entries in its own card, per the ruling. Landing: ruling C after an authorized APPROVED review by os-zhuang / hotlong — record on this head, --pair 0, checks green, clear needs-user-decision, ready through the CCR route, auto-merge SQUASH; the seat never approves.

Implemented-by: claude/issue-18282-deny-enqueue-class
Reviewed-by: session_01HZfg2AwVX191qCizp88gQr

VERDICT: PASS


Generated by Claude Code

@claude

claude Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

维护者速读(终稿)— PR #18317 / #18282

改了什么:按总监席裁决 A(你「135 同意」):mcp__github__enable_pr_auto_mergemcp__github__disable_pr_auto_merge.claude/settings.jsonpermissions.deny(锁 1,15 → 17);deny 名单门禁 check-settings-deny-roster.mjs 的唯一声明常量同步到 17,并加一组 4 例自检;rest-channel :53 把「auto-merge 备用 MCP 未拒」改为「auto-merge MCP 锁 1 同拒」。guard-governed-enqueue.sh 不动,作为纵深防御保留。席位现在只能经 REST PUT …/pulls/N/ccr/auto_merge 入队——今天所有真实入队本就走这条路,没有能力丢失。

验证:主干 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 自检有一例在本容器上红——它假定不存在 /home/user/cloud 检出,而本席今天为了审计五仓把 cloud 附加了进来;CI 上绿。属 .claude/hooks/** 治理面,另卡处理。


Generated by Claude Code

@claude
claude Bot requested review from hotlong and os-zhuang September 15, 2026 16:49

Copy link
Copy Markdown
Collaborator

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 hotlong and os-zhuang; the approval itself is the maintainer's click, after which the owning seat lands it under ruling C. ⛔ This seat does not approve, arm or merge a governed PR. Card: #18282.


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review September 16, 2026 01:54
@os-zhuang
os-zhuang enabled auto-merge September 16, 2026 01:54
@os-zhuang
os-zhuang added this pull request to the merge queue Sep 16, 2026

Copy link
Copy Markdown
Contributor

Ruling-C landing provenance — skills seat, session session_01HZfg2AwVX191qCizp88gQr, 2026-09-16T01:57Z. Governed rules layer (.claude/settings.json +2 denies, scripts/pm/check-settings-deny-roster.mjs 15 → 17, references/rest-channel.md :53): the authorized APPROVED review is os-zhuang's 5217679727 at 2026-09-16T01:54Z on the current head ad7a3da5a8b1 (not dismissed) — the record 5684320675 PASS is on that same head, check-clause2-carriers.mjs --pair 18317 exits 0, and the checks on the head read 24 success / 15 skipped / running none, with ONE stale Check Changeset failure from 2026-09-15T16:42Z — the run that fired before skip-changeset was applied at 16:44:15Z; the same check ran twice more on this head after the label (16:44Z, 16:49Z), both skipped, the platform counts the newest run per name, and the queue accepted the entry — so the red is not a reading of this head's tree. The approver flipped the PR ready (2026-09-16T01:54Z), enabled auto-merge and the added_to_merge_queue event reads 2026-09-16T01:55Z by os-zhuang; the seat's strokes reduce to clearing needs-user-decision (removed through label-write.mjs; read back: documentation, size/s, skip-changeset) and this record; the queue ref pr-18317-* read 1, chained fourth behind pr-18315, pr-18327, pr-18328 (the four approvals of 01:52–01:55Z are one queue chain). ⛔ This seat wrote no approving review. Landing is confirmed by the three readings (merged_at + git log origin/main carrying (#18317) single-parent + the queue ref gone) at the next wake; then #18282's residue is stripped and the landing record posted; per the ruling, the objectui mirror of lock 1 (objectui PR #9448's two enqueue tools) gets its own card after the landing, and #18320 (the platform-readings enqueue rows) becomes dispatchable.


Generated by Claude Code

akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…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>
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/s skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

3 participants