Skip to content

fix(hooks): guard-governed-enqueue's sibling-checkout lookup is injectable, so its self-test is hermetic on a box that carries the sibling - #18365

Merged
hotlong merged 2 commits into
mainfrom
claude/issue-18321-governed-enqueue-selftest-hermetic-sibling
Sep 16, 2026
Merged

hotlong merged 2 commits into
mainfrom
claude/issue-18321-governed-enqueue-selftest-hermetic-sibling

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #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 #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 #14 需要一次授权的 APPROVED 审核;
本 PR 保持 draft,未请求任何 reviewer,未触碰 auto-merge。除批准外无需其他动作。


🤖 Generated with Claude Code

https://claude.ai/code/session_01HZfg2AwVX191qCizp88gQr


Generated by Claude Code

…ectable so its self-test is hermetic

The self-test 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. On a container that does
carry a sibling `cloud` checkout the guard resolved it, recomputed the register
predicate on it 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.

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 stays the whole admission rule, so a sibling that
resolves is audited exactly as before, and a root holding no matching checkout
resolves nothing and falls through to the fail-open a box without the sibling
already takes. Unset or empty means the parent of this checkout — today's
behaviour to the byte, proved by re-running the untouched matrix against the
changed hook for output identical to the baseline.

The matrix now owns its own premise: the "cannot resolve" case points the
lookup at a directory it created and knows is empty, and a new case pins the
other half — a sibling that DOES resolve is audited on its own tree, asserted
as agreement with the register rather than as a copied verdict, beside a
structural assertion that the no-checkout fail-open did not fire.

Two things measured on the way and corrected rather than restated: that case
never reached the "no checkout … is available" fail-open it claimed to pin (with
nothing resolved the register is asked without `--root`, answers about this
tree, lifts the path, and the hook leaves at the cleared-predicate exit with
empty stderr), and the slug reader keeps a `.git` suffix, so a sibling cloned
with the conventional URL does not resolve at all. The first is corrected in the
case comment; the second is reported for its own card.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HZfg2AwVX191qCizp88gQr
…rtion

Two repairs to the case added in the previous commit, both found by running its
firing control rather than by reading it.

The companion assertion that the "no checkout … is available" text is ABSENT
was a phantom: it stayed green under BOTH controls (a not-governed fixture, and
a throwaway sibling whose origin names a different repo). When the sibling does
not resolve the hook does not print that warning either — it leaves at the
cleared-predicate exit with empty stderr — so the assertion held in both worlds
and discriminated nothing. The verdict row is the discriminator, and it does go
red under both controls; the phantom is removed and the reason recorded so it
is not written again.

The register leg also spelled the path a second time, so changing the fixture
alone would have left it agreeing about a question nobody asked. One
CROSS_REPO_PATH now feeds both.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HZfg2AwVX191qCizp88gQr
@os-zhuang os-zhuang added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 16, 2026 — with Claude

Copy link
Copy Markdown
Contributor Author

CI reading — skills seat, session session_01HZfg2AwVX191qCizp88gQr, 2026-09-16T04:21Z. The TypeScript Type Check failure at 2026-09-16T04:20Z is on 68d26af5, this PR's FIRST commit, superseded by the push of 5391e5c0 at 2026-09-16T04:10Z: the Lint & Type Check run 35055036498 on the old commit was CANCELLED by concurrency when the same workflow started on the new head (2026-09-16T04:20Z), and the aggregate job reads failure because its four lanes were cancelled (「Verify every type-check lane succeeded」) — not because any lane failed (Type Check · workspace had already passed). The same check is green on origin/main b57671ed (2026-09-16T04:01Z). Nothing to fix and nothing to re-run: the verdict that counts is the run on 5391e5c0, in progress as this is written; the seat's review reads that head.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 5391e5c00749febe0b1b125f64a3a7fd8555a3d0

Reviewing seat: domain:skills, in-seat review at the contract-review tier — get_session read at 2026-09-16T04:26Z: configured_model = session_context.model = last_served_model, the constant's tier, no fallback notice this round. Card #18321, claim 5691754378, report 5691990182 (posted through the tool; author read back os-zhuang / User); Clause-②: no on the claim; default-tier build (no path mandate for .claude/hooks/**); seat review at tier.

① Derived judgments

  • Accept set / public surface: none moves — .claude/hooks/guard-governed-enqueue.sh (+39 / −2: a 28-line header paragraph and a two-line resolution change) and .claude/hooks/guard-governed-enqueue.selftest.sh (+88 / −4) against merge base 1411cf2c (two commits, the second correcting the first; not rebased); nothing published; skip-changeset read back (size/m + skip-changeset). GOVERNED (.claude/** ×2 by check-governed-merges --test, exit 3) ⇒ the four-piece hangs here and the PR waits for an authorized APPROVED; ruling C after it.
  • The change, read whole on a detached worktree of the head: the hook gains OS_GOVERNED_ENQUEUE_SIBLING_ROOT — the directory a sibling checkout is resolved UNDER (sibling_root="${OS_GOVERNED_ENQUEUE_SIBLING_ROOT:-$(dirname "$repo_root")}"), moving WHERE the search looks and never WHAT is admitted: the origin-slug comparison stays the whole admission rule, so unset = today's behaviour to the byte, empty = unset (an accident reads as no override, never as 「look nowhere」), a root with no matching checkout = the existing no-sibling path; no fail-open was added or widened. The matrix's 「cannot resolve」 case now owns its premise (the lookup pointed at a directory it created and knows is empty; ONE spelling of the cross-repo path shared by the fixture and the register leg), and ONE new case pins the other half — a throwaway sibling built in a temp dir (git init + an origin naming objectstack-ai/cloud, the bare URL on purpose) is audited on its own tree, asserted as AGREEMENT with the register (here and in CI that branch is block: 「the generator declared no output set … fail closed」), so a register change the hook has nothing to do with cannot redden the matrix. The seat agrees with both design choices (the four axes in the report): an injectable root drives the PRODUCTION path against a directory the test controls, where a 「skip the lookup」 switch would pin a branch production never takes; agreement over a hard-coded verdict is this file's own recorded lesson.
  • Seat re-runs on the head, in this container (which carries the real read-only /home/user/cloud): the self-test 56 passed, 0 failed, exit 0, in three injection states — unset, an empty dir, a non-existent dir — and the baseline on origin/main 54 passed, 1 failed, exit 1 (the finding reproduced, then closed); bash -n clean on both files; check:bash32-floor green; check-governed-merges --test GOVERNED ×2; --pair 18365 0; git diff --name-only = the two hook files. Gates: 13 derived, 13 run, 0 NOT MEASURED (--ran DERIVED zero, the derivation identical with and without the two paths named); check:doc-formula-expressions built under the verify lock then green; check:pm-settings-deny-roster run additively; shellcheck NOT MEASURED — the tree wires none and the binary is absent (bash -n + the bash-3.2 floor ran instead). CI on this head: the TypeScript Type Check failure at 2026-09-16T04:20Z is on the superseded first commit 68d26af5 — its run was CANCELLED by concurrency when 5391e5c0 was pushed and the aggregate reads failure for cancelled lanes (recorded on the PR at 5691988381); the same check is green on origin/main; the run on this head was still in progress at record time — ruling C reads it green or waits.

② Semver level

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

③ Boundary flags

open_questions: none. One seat premise REFUTED and owned: the dispatch said the 「cannot resolve」 case pins the hook's 「no checkout of the target repo is available」 fail-open and its warning — measured false: with nothing resolved the register is asked without --root, answers about THIS tree where the path is byte-exact against its generator, and the hook exits 0 at the cleared-predicate branch with empty stderr, so the warn-text assertion the dispatch asked for would have pinned a sentence nothing prints; the dev wrote it, measured it, removed it and corrected the case's stale comment — the seat accepts, and the unpinned branch is FILED (#18367, p3). Eight deviations answered: the warn-text assertion dropped — accepted (a phantom); AGREEMENT rather than a hard-coded block — accepted (the prescribed not-governed-fixture control therefore cannot fire; the property-targeted control — mutating the throwaway sibling's origin so it no longer resolves — goes RED, 55 passed, 1 failed, and is the discriminator); the pass ledger +1 not +2 (a second phantom removed) — accepted; shellcheck NOT MEASURED — accepted; derivation both ways — accepted; two commits — accepted (the PR body's sha matches the head); the body's Chinese 速读 section — accepted (the os-dev contract's own form; the seat's 终稿 below is the maintainer-facing one); lint.yml read, not edited, and names no case count — accepted. Out of scope, FILED by the seat as two cards of this lane: (1) #18366 (p2 · Bug): the hook's slug_of keeps a .git suffix (the path class owns the dot and is greedy), so a checkout cloned with the conventional URL never resolves as a sibling (measured: .git remote → allow, bare remote → block, same tree), and the SAME expression inlined for a bare gh pr merge NUMBER derives a non-matching slug ⇒ the API read 404s ⇒ the guard fails OPEN on any standard-URL clone; (2) #18367 (p3 · Task): the 「no checkout available」 fail-open is pinned by no case in any environment — the dev's repro shape (--test skills/zz-no-such-skill/references/_index.md returns exit 3 with one exception) plus the new injection makes a hermetic case. Noted, not filed, agreed: lint.yml's step name is illustrative, discovery by glob is the contract. Landing: governed — the four-piece hangs here (this record, the 速读终稿 below, the ACCEPT on #18321, needs-user-decision, reviewer hotlong requested — os-zhuang is this PR's author under the seat's write identity); ⛔ this seat writes no approving review; ruling C after an authorized APPROVED — record on the current head, --pair 0, every check success or a rostered skip on 5391e5c0, clear needs-user-decision, ready through the CCR route, auto-merge SQUASH — by whoever holds the seat when the approval arrives, or the approver's own ready + enqueue, since this seat stands down this shift on the maintainer's order.

Implemented-by: claude/issue-18321-governed-enqueue-selftest-hermetic-sibling
Reviewed-by: session_01HZfg2AwVX191qCizp88gQr

VERDICT: PASS


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

维护者速读(终稿)— PR #18365 / #18321

改了什么:guard-governed-enqueue.sh 的自检有一条「目标仓在本容器解析不到 ⇒ 放行」的用例,其前提是箱子上没有 cloud 兄弟检出——这是环境事实不是钩子事实;凡是挂了 /home/user/cloud 的箱子(本席就是)钩子会解析到它、在它的树上重算、然后 block,自检读 54 过 / 1 败,CI 绿只因 runner 没挂兄弟仓。修法:钩子加一个环境变量 OS_GOVERNED_ENQUEUE_SIBLING_ROOT,只改去哪找兄弟检出,不改认不认(origin slug 比对原样是唯一准入规则);未设 = 现状逐字节不变,空值 = 未设(误设不会变成「不找」而静默丢审计),指到没有匹配检出的目录 = 走既有的无兄弟路径,没有新的放行口。自检那条用例改为把查找指向自己建的空目录(前提由文件自己拥有),并新增一条:能解析到的兄弟检出(临时目录里 git init + origin 指向 objectstack-ai/cloud)在它自己的树上被审,断言写成「与 register 一致」而非硬编码 block(本文件自己记过硬编码裁决被上游变化误红的教训)。

验证:本席在带真 /home/user/cloud 的容器上、head 5391e5c0 的独立 worktree 重跑:自检 56 过 / 0 败,exit 0,三种注入态(未设、空目录、不存在目录)一致;origin/main 基线 54/1 复现;bash -n 干净,bash 3.2 地板绿;受管面读数 GOVERNED ×2;--pair 0;dev 13 条派生门禁全跑全绿,发火对照(把临时兄弟仓的 origin 改指别的仓)真的变红。⚠️ PR 上那条 TypeScript Type Check 红是首个提交的 run 被第二次推送并发取消导致的聚合 failure,main 上同一 check 绿,已在 PR 上记读数;当前 head 的 run 落地前要读到绿。

要你做的:审 PR #18365,同意即 APPROVE(只能请 hotlong;os-zhuang 是 PR 作者)。本席本班收班,批准后的裁定 C 落地由届时在席者做或你自行 ready + 入队。

顺带发现(已立卡):① slug_of 的 sed 会把 .git 后缀吞进 slug,用标准 URL 克隆的兄弟仓永远解析不到;同一表达式还内联在裸 gh pr merge N 的 slug 推导里,slug 不匹配 ⇒ API 404 ⇒ 守卫放行——标准 URL 克隆上这条拼写是没守的(p2 Bug);② 钩子的「无检出可重算 ⇒ 放行」分支在任何环境都没被自检钉住(p3)。


Generated by Claude Code

@hotlong
hotlong marked this pull request as ready for review September 16, 2026 06:40
@hotlong
hotlong enabled auto-merge September 16, 2026 06:40
@hotlong
hotlong added this pull request to the merge queue Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

Ruling-C landing provenance — skills seat (signed off, 留守 on this PR), session session_01HZfg2AwVX191qCizp88gQr, 2026-09-16T06:42Z. Governed rules layer (.claude/hooks/guard-governed-enqueue.sh + its self-test — the sibling-checkout lookup made injectable, the matrix hermetic): the authorized APPROVED review is hotlong's 5219301524 at 2026-09-16T06:40Z on the current head 5391e5c00749febe0b1b125f64a3a7fd8555a3d0 (not dismissed) — the record 5692046150 PASS is on that same head, check-clause2-carriers.mjs --pair 18365 exits 0, every check on the head read green at 2026-09-16T04:39Z (30 check-runs: 17 success · 13 skipped, every skip in the roster — check-expected-skips.mjs --head 5391e5c0 exit 0); at provenance time the ready flip had started a fresh pull_request run on the same head (mergeable_state: blocked while it runs — a re-run of an already-green head, and the queue build is where the verdict is taken; the red on the superseded commit 68d26af5 is explained at 5691988381). The seat cleared needs-user-decision at 2026-09-16T06:41Z (read back: size/m + skip-changeset). The approver pre-empted the landing strokes: ready_for_review by hotlong at 2026-09-16T06:40Z, auto_merge_enabled by hotlong at 2026-09-16T06:40Z (the stored method reads merge; the queue rule enforces SQUASH), added_to_merge_queue at 2026-09-16T06:41Z; the queue ref refs/heads/gh-readonly-queue/main/pr-18365-* read 1 at 2026-09-16T06:42Z. ⛔ No approving review was written by this seat; the queue is the only landing path; merged_at and the single-parent squash are the landing readings, posted on #18321.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

3 participants