feat(ci): refuse a merge group whose queued PR still carries needs:contract-review - #9212
Conversation
…s `needs:contract-review`
`scripts/check-governed-queue-guard.mjs` gains a second, independent `merge_group`
predicate, keyed on a LABEL rather than on paths (objectui#9018). It is a
re-implementation — against this file's own shapes — of the leg objectstack landed
as objectstack#17484; objectui's guard is objectui-native by decision (objectui#6596
-> PR objectui#6739) and is deliberately not registered in
`scripts/upstream-port-pin.json`, because that pin cannot express the deletion
divergence.
What it does:
- enumerates EVERY pull request the merge group lands, PER COMMIT, reusing the same
decomposition the governed leg uses. `merge_group.head_ref` names only the LAST
pull request in the group, so keying this leg to it would let an earlier pull
request's open carrier ride into `main` behind a clean one. That trap is invisible
at review time and a head-ref implementation passes every test anyone would think
to write, which is why this is a port rather than a fresh derivation;
- reads each pull request's labels from the PULL object, so the leg costs no new
workflow permission: `pull-requests: read` already covers it, while the
`issues/{n}/labels` route would need `issues: read`;
- exit 6 while `needs:contract-review` is on any queued pull request;
- exit 7 when a label set cannot be READ, or the group names no pull request at all.
Split from 6 for the same reason 4 is split from 3 — 7 is not a worse 6;
- leaves the `pull_request` leg untouched: it is `not-applicable`, renders the empty
string, and makes zero label reads (measured with a spy that throws).
The honest boundary is stated in the code, in the verdict text on the CLEAR path as
well as the refusal, and in the docs: this leg reads the LABEL, not the verdict. A
carrier stripped before any PASS existed is indistinguishable here from one that was
never hung. Of the eleven enqueues measured on 2026-09-09, five are refused and six
pass — objectui#8164, which landed a real defect, is one of the six, and upgrading
this predicate until it catches that would make it a verdict check.
The label spelling is MIRRORED from `scripts/pm/check-half-states.mjs`, which owns
it, and pinned to that file's source. objectstack mirrors the same constant because
importing it there is a module-eval cycle; that reason does not hold in objectui and
is deliberately not restated — the reason here is that
`.github/workflows/governed-surface-guard.yml` runs this file with no install, so it
imports nothing out of `scripts/pm/`. Both the pin and the reason are asserted.
The `merge_group` zero-cost-clear rendering is SCOPED rather than rewritten: this leg
makes an unconditional API call there, so the old promise that an outage can never
block a governed-clear diff is no longer true on that leg. The `pull_request` leg's
bytes are unchanged.
Self-test 132 -> 185 cases, including the eleven 2026-09-09 enqueues replayed
individually and again as one merge group, and an ACCEPTANCE case showing a
three-pull-request group with no carrier anywhere clearing at exit 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhBNJcLRZLe8M87VcUgpKr
ACCEPT — objectui#9018 / PR #9212Reviewed against the diff at 0. ⛔ My verification line contradicted my own fence, and the round chose the fenceI wrote — carrying the card's sentence forward — "objectui#8164 is the row that landed a real defect: it is the case that must REFUSE." It must CLEAR. The objectstack implementation I ordered ported says so in its own fixture: Verified independently just now: PR objectui#8164's labels today are ⇒ making #8164 refuse is reachable only by reading the verdict — which the same dispatch forbade two bullets earlier ("do not quietly upgrade it into a verdict check"). ⭐ The round implemented the fence and refused the verification line, replayed #8164 as CLEAR, and pinned the 5/6 split so only two of objectui's six rows refuse. That is the correct resolution of a self-contradicting order, and it is the second time today a round has had to choose between two things I wrote. 1.
|
| path | reading |
|---|---|
scripts/pm/ |
contains exactly one file: check-half-states.mjs |
.claude/skills/ |
objectui-contributor, verify — ⛔ no pm-dispatch |
check-clause2-carriers.mjs, anywhere in the tree |
0 |
⇒ porting that sentence verbatim would have shipped a refusal pointing a reader at nothing — the exact false-signpost class this lane keeps paying for. The round kept the quote (it is the operative criterion) and changed the citation to name the sibling repository, then pinned it in the falsifiable direction.
⭐ I checked that pin by printing the matched lines rather than counting them — and it is worth showing, because my own echoed expectation was wrong:
187: * `scripts/pm/check-clause2-carriers.mjs`, and this repository has no such file
1959: // ⚠️ objectui has no `check-clause2-carriers.mjs` to defer the verdict question
1966: !renderCarrierVerdict(bareOne).includes('check-clause2-carriers'),
A raw count says 3; two are prose and the third is the negative assertion itself. The refusal rendering is clean.
3. The "port, do not re-derive" fence is right about enumeration and wrong about the label constant
objectstack mirrors CONTRACT_REVIEW_LABEL rather than importing it, because importing its check-half-states.mjs is a module-eval cycle (top-level await → the queue guard → back). ⛔ That reason does not exist in objectui — measured: no module-scope top-level await, and no import of this guard.
Copying the comment would have shipped a paragraph describing a mechanism this repo does not have. The round still mirrors, for objectui's own reason — governed-surface-guard.yml runs this file with no install and no build, and the file's own comment rests on "imports node builtins and one local module only", so coupling the one script whose failure direction is "the gate stops running" to a 13,103-line PM sweeper is the wrong trade — and pins that reason so it reds if H31 ever grows the cycle, telling the next reader to revisit rather than to copy.
⇒ ⛔ "Port, do not re-derive" is a rule about the ALGORITHM, not about the prose around it. I did not draw that line and should have.
4. Structural verification
| claim | verdict |
|---|---|
| exits 6 and 7 added, 0–5 untouched | ✅ EXIT_REFUSED_CARRIER = 6, EXIT_REFUSED_CARRIER_UNREADABLE = 7 |
⛔ not added to scripts/upstream-port-pin.json |
✅ that file is not in the diff — this is a re-implementation, not a sync |
enumeration reuses decomposeGovernedWork's rows |
✅ — ⛔ never the queue head ref |
| the self-test moved | ✅ 132 → 185 (144 → 183, which does not transfer — a figure I passed on without checking it described this repo) |
✅ both touch content/docs/guide/ci-cd-pipeline.md — this PR at its lines 43 and 1683, objectui#9141 at 218. git merge-tree: 0 conflict markers against main and 0 pairwise against objectui#9141 |
⭐ And the acceptance leg landed: a three-PR group with no carrier anywhere is CLEAR at exit 0 with reads === 3 — so the after-state cannot be read as "a guard that now refuses everything", and the read count proves it actually looked.
⭐ The ablation set is four-legged and each leg fails for its own reason — including that exit 7 is not a worse 6. And the first attempt at leg 1 was refused by the harness because its anchor matched two lines (the same for-loop header also occurs in decomposeGovernedWork), which would have been a no-op or a mutation of the wrong function. ⛔ Recorded rather than quietly retried.
The one red in the wider run — check-side-effects-array.test.ts — was proven pre-existing by reverting both changed files to HEAD~1 (blob hashes verified) and reproducing the identical assertion, and is already open as objectui#9124. ⛔ Correctly not re-filed.
5. Two things for my own template, both handed to me
.claude/hooks/guard-main-checkout-bash.sh blocks writes to any repo whose git-dir is not */worktrees/*, a fresh clone included. The only shape satisfying both is clone fresh AND cut a linked worktree from that clone. ⇒ into the template; the naive reading of my own fence is blocked by a hook my own dispatch cites.
--- + _Generated by…_ block was dropped by issue-create (read back: the body ends at the Refs line) but survived PR-create. That bears on this seat's own filing practice, since I have filed eight bare cards today.
6. Routed, ⛔ not folded in
objectui#9213 — AGENTS.md's account of the queue guard's merge_group refusal names only the approval predicate, so after this card a reader concludes a non-governed PR can never be refused by that check. That is now false (exits 6 and 7). AGENTS.md is governed surface ⇒ ⛔ correctly left alone and filed bare.
Proceeding to the merge queue.
Generated by Claude Code
Armed — auto-merge
|
| anchor | <merge>^ |
<merge> |
|---|---|---|
export const EXIT_REFUSED_CARRIER = 6; |
0 | 1 |
export const EXIT_REFUSED_CARRIER_UNREADABLE = 7; |
0 | 1 |
| guard file line count | 1,399 | 2,171 |
L4 — ⛔ a re-implementation, not a sync. scripts/upstream-port-pin.json must not appear in the diff — count 0. ⭐ Its control is L1: the file set is exactly 2, so this is an absence with a positive beside it. (objectui's guard is objectui-native by objectui#6596's decision; the pin cannot express its deletion divergence.)
L5 — nothing moved in transit, by blob.
| path | expected |
|---|---|
scripts/check-governed-queue-guard.mjs |
2b842f2c115d2388e6e02a1b862a2155565e8809 |
content/docs/guide/ci-cd-pipeline.md |
0a824eca19ced863907ac147e02add9cabb363da |
L6 — the false-signpost pin, which is this round's own finding. objectstack's refusal text defers to scripts/pm/check-clause2-carriers.mjs, a file that does not exist anywhere in this repository. On the merge commit:
CONTRACT_REVIEW_LABEL = 'needs:contract-review'declared exactly once;- the self-test's negative assertion
!renderCarrierVerdict(bareOne).includes('check-clause2-carriers')present exactly once, and it is the only code-line occurrence of that string — the other two are prose saying the file is absent.
⛔ NOT MEASURED: the self-test's 185 cases, the four ablation legs, and every gate. Bare stale checkout, and the standing lesson is that a runnable script here answers fluently about the wrong tree. CI on this head is the reading.
⚠️ One thing I will re-check at merge time rather than assume
This PR and open PR objectui#9141 both touch content/docs/guide/ci-cd-pipeline.md — this one at its lines 43 and 1683, objectui#9141 at 218. Measured now: git merge-tree reports 0 conflict markers against main and 0 pairwise against objectui#9141. ⇒ no serialisation is needed, but objectui#9141 is held and moving further behind, so I will re-run the pairwise read after this lands rather than carry today's answer forward.
A leg that comes back amber will be published with its cause. ⛔ It will not be re-run into green.
Generated by Claude Code
LANDED —
|
| leg | reading |
|---|---|
| L1 file set | 2 — scripts/check-governed-queue-guard.mjs and content/docs/guide/ci-cd-pipeline.md; 775 3 and 36 5 ✅ |
| L2 ⛔ the existing exit vocabulary | 6 of 6 EXIT_* lines byte-identical on both sides ✅ — a port that renumbers an existing exit breaks every caller silently, and this is the leg that would have caught it |
| L3 what moved | EXIT_REFUSED_CARRIER = 6 0 → 1, EXIT_REFUSED_CARRIER_UNREADABLE = 7 0 → 1, guard 1,399 → 2,171 lines ✅ |
| L4 ⛔ a re-implementation, not a sync | upstream-port-pin.json absent from the diff ✅, with L1's file count of 2 as its control |
| L5 nothing moved in transit | 2b842f2c115d2388e6e02a1b862a2155565e8809 · 0a824eca19ced863907ac147e02add9cabb363da ✅ |
| L6 the false-signpost pin | CONTRACT_REVIEW_LABEL declared once ✅ |
⭐ L6 is the one worth printing, because the count and the reading disagree
raw grep for 'check-clause2-carriers' → 3
code-only occurrences, matched lines printed:
1966: !renderCarrierVerdict(bareOne).includes('check-clause2-carriers'),
One code occurrence, and it is the assertion that the refusal text must not name a script this repository does not have. The other two are prose recording its absence. ⇒ a raw count says "the dead signpost shipped"; the printed line says the opposite. That is the third time today the count and the reading have disagreed on a diff I was accepting.
⚠️ The collision read, re-run rather than carried forward
This PR touched content/docs/guide/ci-cd-pipeline.md, which open PR objectui#9141 also owns a hunk in. I measured 0 conflicts before the merge and said I would not trust that afterwards. Re-measured on the landed tree:
| reading | |
|---|---|
objectui#9141 vs main |
41 behind, 1 ahead, head b41bea3927 unchanged |
| conflict markers | 0 |
| where they touch the file | objectui#9141 at 218; this PR landed at 43 and 1683 |
⇒ still clean, and now measured against the tree that actually exists rather than the one I predicted.
What the round established that the card did not
- ⛔ objectui#8164 must CLEAR, not refuse. The card and my dispatch both called it "the case that must refuse"; the objectstack fixture names it as one of the six the leg does not cover — its carrier was stripped a day before the enqueue, so a label reader sees nothing (verified: its labels today are
tests,package: app-shell). Making it refuse is reachable only by reading the verdict, which the same dispatch forbade. The round implemented the fence and refused the verification line. - ⛔ objectstack's refusal text cites two paths that do not exist here —
scripts/pm/check-clause2-carriers.mjs(nowhere in the tree) and apm-dispatchskill (.claude/skills/holds two, neither of them that). Porting verbatim would have shipped a refusal pointing a reader at nothing. The quote was kept; the citation was corrected; L6 pins it. - ⭐ "Port, do not re-derive" is a rule about the algorithm, not the prose around it. objectstack mirrors the label constant to dodge a module-eval cycle that does not exist in objectui; the round mirrors for objectui's own reason — this file runs with no install — and pins that reason so it reds if the cycle ever appears.
⛔ What this green does not say
It says what landed. It does not run the self-test's 185 cases or the four ablation legs — bare stale checkout, and the standing lesson is that a runnable script here answers fluently about the wrong tree. The queue build on this head is that reading.
And the leg's own boundary stands as written: it reads the LABEL, not the verdict. A carrier stripped before any PASS existed is indistinguishable here from one that was never hung. That is stated in the code, on the clear path as well as the refusal, and in the docs.
Closing objectui#9018. objectui#9213 stays open: AGENTS.md's account of this guard's merge_group refusal names only the approval predicate and is now false — governed surface, ⛔ correctly left alone and filed bare.
Generated by Claude Code
Fixes #9018
A second, independent
merge_grouppredicate inscripts/check-governed-queue-guard.mjs, keyed on a label rather than on paths. It is a re-implementation — against this file's own shapes — of the leg objectstack landed as objectstack-ai/objectstack#17484 (merged 2026-09-10,33e07f7c, card objectstack#17040).What it does
merge_group.head_refnames only the last pull request in the group, so keying this leg to it would let an earlier pull request's open carrier ride intomainbehind a clean one. ⭐ That is the entire reason this is a port and not a fresh derivation: the trap is invisible at review time and a head-ref implementation passes every test anyone would think to write, because a single-PR group is the shape everyone tests. The self-test replays the defect itself — a gated PR sitting behind a bare head ref — and requires a refusal.pull-requests: read(already declared atgoverned-surface-guard.ymlline 64) coversGET /repos/{o}/{r}/pulls/{n}, while theissues/{n}/labelsroute would needissues: read, which this workflow does not grant.needs:contract-reviewis on any queued pull request.refusalKinds so a queue log can tell them apart.pull_requestleg is untouched:not-applicable, renders the empty string, and makes zero label reads — measured with a spy that throws if it is called at all.⭐ The honest boundary: this leg reads the LABEL, not the verdict
A carrier stripped seconds before an enqueue with no PASS on record is, to a label reader, identical to one that was never hung — 「被剥」 and 「从未挂过」 are the same bytes. Of the eleven enqueues measured on 2026-09-09, five are refused and six pass. objectui#8164 — the row that landed a real published-face defect — is one of the six that pass: its carrier was stripped a day before the enqueue, so there is no label for a label reader to see. Making it refuse would require reading the verdict, which is a strictly larger rule than the one ruled, and is deliberately not taken here.
scripts/pm/check-clause2-carriers.mjs; that file does not exist in this repository (measured).scripts/pm/check-half-states.mjsH31 compares the gate's two carriers with each other, which is a different question. The refusal text and the CLEAR text both say so out loud, so a green is never read as "the review happened".Until the maintainer adds
Governed Surface Queue Guardto objectuimain's ruleset required set (objectui#6596's still-openMaintainer-action:line), a refusal here is a red check, not a blocked queue. ⛔ This PR does not claim otherwise and does not touch the ruleset. It also does not create or apply the label — both are out of the card's reach.Design notes specific to objectui
scripts/upstream-port-pin.json. objectui's guard is objectui-native by decision (objectui#6596 → PR objectui#6739):check-upstream-port-parity.mjs'svalidatePinrefuses a divergence whoseportedside is empty, so a pin cannot express this file's deletion divergence.check-upstream-port-paritystill reports 4 ported file(s) match … modulo their declared divergences, unchanged.scripts/pm/check-half-states.mjs, which owns it, and pinned to that file's source. ⭐ objectstack mirrors the same constant because importing it there is a module-eval cycle; ⛔ that reason does not hold here and is deliberately not restated — objectui'scheck-half-states.mjshas no module-scope top-levelawaitand does not import this guard (measured, and asserted). The reason the mirror stays is availability:governed-surface-guard.ymlruns this file with no install and no build, and its own comment states the property that rests on — "the script imports node builtins and one local module (scripts/invoked-as.mjs) only". The self-test asserts this file imports nothing out ofscripts/pm/.merge_groupzero-cost-clear rendering is SCOPED, not rewritten. This leg makes an unconditional API call there, so the old line "an API outage can never block a diff that touches nothing governed" is no longer true on that leg. Thepull_requestleg's bytes are unchanged, and the self-test pins both directions.content/docs/guide/ci-cd-pipeline.mdis updated for the same reason: it carried the now-false zero-cost promise verbatim.Verification
Self-test 132 → 185 cases (
node scripts/check-governed-queue-guard.mjs --self-test, exit 0), including:reads === 3), so the after-state is not "a guard that now refuses everything";Ablation — four legs, each proving the mutation landed on disk before anything was read (anchor counts with the matched lines printed, plus a moved blob hash; an empty hash coded as FAILURE), each restored under
trap … EXIT INT TERMand each restore proven by blob equality and an emptygit diff HEAD:state: 'bare'always)A first attempt at the head-ref leg was refused by the harness rather than silently applied: its anchor matched 2 lines (the same
for (const row of …)also occurs indecomposeGovernedWork), so it would have been a no-op or a mutation of the wrong function. It was re-anchored and re-run, and this is recorded rather than quietly retried.Gates, exit codes captured by redirect-then-capture (never through a pipe), verdict lines quoted:
--self-testOK check-governed-queue-guard self-test: 185 cases pass (…)pnpm exec vitest run scripts/__tests__/check-governed-queue-guard.test.ts …Test Files 3 passed (3) · Tests 106 passed (106)type-check:scriptstsc -p tsconfig.scripts.json— cleanlint:root✖ 32 problems (0 errors, 32 warnings)— all pre-existing warnings, 0 errorscheck-changeset-presence✅ No source or published contract of a released package changed in this range, so no changeset is owed.check-control-bytes✅ check-control-bytes: OK (scanned 7407 tracked text file(s); skipped 85 binary).check-pre-install-import-graph✅ … 34 module(s) walked, every non-relative leaf a node builtin.check-upstream-port-parity✓ … 4 ported file(s) match … modulo their declared divergencescheck-node-esm-loadcheck-doc-links·check-doc-fences·check-doc-snippets·check-doc-example-ids·check-lint-coverage·check-new-cross-file-line-citationsturbo ls --affectedfrom the branch point reports 0 packages, so no package test or type-check is owed;Test (shard N/4)is left to CI, withscripts/__tests__/run in full locally as the narrowed stand-in (147 passed / 1 failed / 2 skipped of 150 files).scripts/__tests__/check-side-effects-array.test.ts › finds a stylesheet subpath and a second build format…(expected 0 to be greater than 0). Proven by reverting both changed files toHEAD~1(blob hashes verified equal toHEAD~1's) and re-running: identical failure; restore then proven by blob equality and an emptygit diff HEAD. Neither the test norscripts/check-side-effects-array.mjsmentions either changed file. Filed bare, out of scope.Preconditions re-measured rather than inherited (the card's table was the PM's reading):
needs:contract-reviewlabel existsnode_id LA_kwDOQ40TZM8AAAACyH71rA, colouredededgoverned-surface-guard.yml:42merge_group:withtypes: [checks_requested]scripts/was 1 (check-half-states.mjs:4452), raw 3EXIT_CLEAR 0 · EXIT_CANNOT_RUN 1 · EXIT_BAD_USAGE 2 · 3 · 4 · 5⛔ Nothing in this diff touches the governed surface —
node scripts/check-governed-queue-guard.mjs --test <the two changed paths>answers✅ NOT GOVERNED — 2 path(s) checked against 5 governed surface(s); none matched.🤖 Generated with Claude Code
https://claude.ai/code/session_01FhBNJcLRZLe8M87VcUgpKr
Generated by Claude Code