diff --git a/.github/workflows/closing-target-claim-guard.yml b/.github/workflows/closing-target-claim-guard.yml
deleted file mode 100644
index 023b93d7572..00000000000
--- a/.github/workflows/closing-target-claim-guard.yml
+++ /dev/null
@@ -1,140 +0,0 @@
-# A pull request may close a card only while that card's own thread carries a
-# `Claim:` naming this pull request's head branch.
-#
-# Every agent here shares ONE GitHub identity, so the assignee field is a
-# presence bit and the `Claim:` comment is the identity record. Two guards
-# already run at pull-request time — the Duplicate Fix Guard ("no other open PR
-# may claim the same issue") and the Single-Claim Path Guard — and neither asks
-# the question that decides ownership. That question WAS already asked, by row
-# H46 of the half-state sweep, and asked AFTER THE FACT as a report-only patrol
-# input: the cloud-repo incident (one card implemented 29 minutes apart by two
-# seats, the second with no claim) and an objectstack card implemented twice in
-# one morning were both visible to that predicate and stopped by nothing. A
-# control that detects a defect it cannot prevent is a TIMING defect in an
-# existing control, not an absent one — so this workflow runs the same
-# predicate at the moment the merge can still be stopped.
-#
-# The rule, the imported predicates, the declined-number policy, the
-# branch-rename remedy and the exit contract all live in
-# `scripts/check-closing-target-claim.mjs`; that header is authoritative and
-# this file is the invocation.
-#
-# Sibling shape, deliberately copied rather than reinvented: the Part-of
-# Closing-Keyword Guard and the Single-Claim Path Guard are this repo's other
-# PR-scoped blocking checks, and this takes their job shape, their runtime pin
-# and their `pull_request` trigger set.
-name: Closing-Target Claim Guard
-
-# THREE things are load-bearing here.
-#
-# `edited`: one of the two remedies is to drop the closing keyword and write
-# `Part of #N` instead. The body is the frozen half of this check's input, so
-# that remedy needs a fresh event rather than a re-run — exactly as in the two
-# body-scoped sibling guards.
-#
-# `synchronize`: the head BRANCH is half the question, and a force-free re-push
-# onto a differently-named branch arrives as a new pull request, but a push that
-# changes what the PR contains must be re-judged on the commit that changed it.
-#
-# `merge_group`: and this is where this gate deliberately DIVERGES from both
-# siblings, whose headers say they take no queue leg because a merge-queue event
-# carries no pull request. True — and here it does not matter, because the queue
-# REF NAMES its pull request (`gh-readonly-queue//pr--`). The
-# script reads that number with the governed queue guard's own shipped parser
-# and then reads the same PR body and the same live comment threads, so the
-# queue build judges the SAME pull request by the SAME rule. That is what makes
-# the card's "the check holds in the queue too" a wiring fact rather than an
-# assumption.
-#
-# ⚠️ In a MULTI-PR group the queue ref names only the LAST pull request; the
-# script header states that limit. Every member of the group passed the
-# `pull_request` leg to be armed at all.
-on:
- pull_request:
- types: [opened, edited, reopened, synchronize]
- merge_group:
-
-# ⛔ NO `paths:` filter, on either leg. A skipped job counts as SUCCESS in branch
-# protection, so a path filter would hand a green check to the very PR the
-# filter mis-scoped — and this gate's input is a PR BODY, which no path filter
-# can see at all. (`merge_group` has no paths support either, so a filter would
-# also make the two legs disagree about what they cover.)
-
-# Read-only, and that is the whole grant. This gate reports; it never closes a
-# PR, comments, edits a body, or writes a label.
-#
-# `contents: read` is not optional padding: naming a `permissions:` block at all
-# sets every scope NOT listed to `none`, and this job checks the repo out to get
-# at the script. `issues: read` is what the card comment threads need — the
-# claim record is an ISSUE comment, and the pull-requests scope does not reach
-# it. `pull-requests: read` is what the merge_group leg needs to read the body
-# of the pull request its queue ref names.
-permissions:
- contents: read
- issues: read
- pull-requests: read
-
-concurrency:
- group: closing-target-claim-${{ github.event.pull_request.number || github.ref }}
- cancel-in-progress: true
-
-jobs:
- closing-target-claim:
- # ⚠️ THIS LITERAL IS THE CHECK-RUN NAME branch protection would pin, and it
- # is duplicated in `CHECK_CONTEXT_NAME` in the script — deliberately, and
- # pinned in both directions: the script's `--self-test` reads THIS FILE and
- # fails if the two ever disagree. Renaming a job silently detaches a
- # required context.
- #
- # ⛔ Making it a required context is NOT this PR's step. Branch protection
- # takes a `REQUIRED_CONTEXTS` row PLUS the Settings → Rulesets entry, in one
- # sitting, and the settings half is the maintainer's. Either half alone is
- # an outage. What the merge_group leg above buys today is that the question
- # is asked on the queue build; what it buys the day someone does make this
- # required is that it cannot deadlock the queue, which a workflow with no
- # queue leg always does.
- name: The card this PR closes must claim this branch
- runs-on: ubuntu-latest
- timeout-minutes: 10
- steps:
- - name: Checkout repository
- uses: actions/checkout@v7
-
- # Pinned to the same major and spelling as every other setup-node in this
- # repo, for the measured reason the sibling guards record: a setup-node
- # major whose package-manager-cache default is on reads `packageManager`
- # out of package.json and shells out to pnpm to find its store, killing
- # the job in the SETUP step with "Unable to locate executable file: pnpm"
- # — before the script runs, and naming a tool the workflow source never
- # mentions. This job installs no package manager on purpose: the script is
- # dependency-free and imports two sibling modules.
- - name: Setup Node.js
- uses: actions/setup-node@v7
- with:
- node-version-file: .nvmrc
-
- # Everything reaches the script through `env:`, never through `${{ }}`
- # inside the `run:` line. A PR body is arbitrary author-controlled text,
- # and an expression interpolated into a shell line is substituted before
- # bash ever sees it; through `env:` it is inert data. The script's
- # self-test pins this spelling.
- #
- # On the merge_group leg every `github.event.pull_request.*` expression
- # renders EMPTY and `MERGE_GROUP_HEAD_REF` carries the queue ref, which is
- # what selects the leg inside the script. On the pull_request leg the
- # reverse holds. Neither leg needs its own step.
- #
- # `PR_HEAD_REF` is not decoration: the head branch IS the question this
- # gate asks, so a blank one exits NOT MEASURED rather than green.
- #
- # The token is required, not optional: the claim record is a comment on
- # another issue, which cannot be answered from the event payload.
- - name: A PR may close only a card that claims its branch
- env:
- PR_NUMBER: ${{ github.event.pull_request.number }}
- PR_BODY: ${{ github.event.pull_request.body }}
- PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
- MERGE_GROUP_HEAD_REF: ${{ github.event.merge_group.head_ref }}
- GITHUB_REPOSITORY: ${{ github.repository }}
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: node scripts/check-closing-target-claim.mjs
diff --git a/.github/workflows/duplicate-fix-guard.yml b/.github/workflows/duplicate-fix-guard.yml
deleted file mode 100644
index 4fffeb5e806..00000000000
--- a/.github/workflows/duplicate-fix-guard.yml
+++ /dev/null
@@ -1,156 +0,0 @@
-# GitHub happily lets any number of open PRs declare `Fixes #N` for the same
-# issue. On 2026-08-02 that cost a full duplicate implementation: #4555 and
-# #4559 both declared `Fixes #4551`, both ran the whole gate suite green, and
-# the duplication sat machine-detectable from the moment the second PR opened
-# (03:08) until a human noticed it (08:52). All agents here share one GitHub
-# identity, so the issue's assignee could not warn the second author either —
-# "assigned to os-zhuang" reads the same whether it is you or another session.
-# Full post-mortem: #4588.
-#
-# This gate makes the second PR red at open time. First come, first served —
-# the EARLIER open PR (lower number) keeps its claim and stays green; the
-# later one fails with a pointer to it. That matches the pm-dispatch claim
-# convention ("first claim comment wins") already in .claude/skills/.
-#
-# Scope: same-repo references only (bare `#N` and qualified `#N`).
-# Cross-repo references are the cross-repo-issue-closer's territory, and a
-# duplicate across repos cannot be resolved by failing one side's CI anyway.
-name: Duplicate Fix Guard
-
-# `edited` matters as much as `opened`: a PR that adds `Fixes #N` to its body
-# after the fact must re-run this check, and one that drops the line must be
-# able to go green again.
-on:
- pull_request:
- types: [opened, edited, reopened, synchronize]
-
-permissions:
- pull-requests: read
-
-# A newer run for the same pull request supersedes this one, so the older run is
-# cancelled. The group key is ci.yml's shape; `cancel-in-progress` is true only on
-# `pull_request`, so no other event's run is ever cancelled.
-concurrency:
- group: duplicate-fix-guard-${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.event.after || github.ref }}
- cancel-in-progress: ${{ github.event_name == 'pull_request' }}
-
-jobs:
- duplicate-fix-guard:
- name: No other open PR may claim the same issue
- runs-on: ubuntu-latest
- # Run wall over the 100 most recent completed runs to 2026-10-27: p99 0.3 min, max
- # 0.8.
- timeout-minutes: 5
- steps:
- - name: Check declared issues against other open PRs
- uses: actions/github-script@v9
- with:
- script: |
- const pr = context.payload.pull_request;
- const thisRepo = `${context.repo.owner}/${context.repo.repo}`;
-
- // GitHub's own closing-keyword set. The optional colon is part of
- // GitHub's accepted syntax (`Fixes: #123`). Two same-repo forms:
- // bare `#N` and qualified `owner/repo#N` naming THIS repo.
- const KEYWORDS = 'close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved';
- const pattern = new RegExp(
- `\\b(?:${KEYWORDS}):?\\s+(?:([\\w.-]+)\\/([\\w.-]+))?#(\\d+)\\b`,
- 'gi',
- );
-
- // Strip fenced blocks and inline code spans before matching.
- // GitHub's own closing-keyword parser ignores code formatting, and
- // this guard must not be stricter than the linker it protects:
- // this very PR's first run counted its own DISCUSSION of
- // `Fixes #4551` (in backticks, describing the incident) as a
- // declaration. Benign there — #4551 is closed — but a prose
- // mention of an issue some other open PR really fixes would have
- // been a spurious red.
- const declaredIssues = (body) => {
- const prose = (body || '')
- .replace(/```[\s\S]*?```/g, ' ')
- .replace(/`[^`\n]*`/g, ' ');
- const found = new Set();
- for (const [, owner, repo, number] of prose.matchAll(pattern)) {
- // A qualified reference to ANOTHER repo is not ours to judge.
- if (owner && `${owner}/${repo}`.toLowerCase() !== thisRepo.toLowerCase()) continue;
- found.add(Number(number));
- }
- return found;
- };
-
- const mine = declaredIssues(pr.body);
- if (mine.size === 0) {
- core.info('This PR declares no same-repo closing keywords — nothing to guard.');
- return;
- }
- core.info(`This PR declares: ${[...mine].map((n) => `#${n}`).join(', ')}`);
-
- // Branch-name convention (advisory, never red): a fix branch named
- // `claude/issue--` is discoverable by the next session
- // with one `git ls-remote --heads origin | grep -E 'issue-(-|$)'`.
- // #4555 vs #4559 happened partly because the branches shared no
- // token to grep. Both spellings delimit the number on the RIGHT:
- // undelimited, `issue-186` matches `claude/issue-18611-x`, so the
- // advisory stays silent for a branch naming a DIFFERENT card —
- // the false negative. The `$` arm keeps the slug-less spelling
- // `claude/issue-186` matching; `issue-` anchors the left, so
- // `issue-1186` and `issue-0186` do not match card 186 either.
- // Warning only — existing branches must not go red retroactively.
- const branch = pr.head.ref;
- if (![...mine].some((n) => new RegExp(`issue-${n}(-|$)`).test(branch))) {
- core.warning(
- `Branch \`${branch}\` does not name any declared issue. ` +
- `Convention: claude/issue-- (e.g. claude/issue-${[...mine][0]}-short-slug) ` +
- `so parallel sessions can discover in-flight work with git ls-remote.`,
- );
- }
-
- // Drafts count: a draft PR is work in flight, which is exactly
- // what the second session needs to see.
- const openPrs = await github.paginate(github.rest.pulls.list, {
- owner: context.repo.owner,
- repo: context.repo.repo,
- state: 'open',
- per_page: 100,
- });
-
- const conflicts = [];
- for (const other of openPrs) {
- if (other.number === pr.number) continue;
- const theirs = declaredIssues(other.body);
- const shared = [...mine].filter((n) => theirs.has(n));
- if (shared.length > 0) conflicts.push({ other, shared });
- }
-
- if (conflicts.length === 0) {
- core.info('No other open PR declares these issues.');
- return;
- }
-
- // First come, first served: only the LATER PR goes red. Failing
- // both would leave the original author red through no action of
- // their own; failing the earlier one would reward racing.
- const older = conflicts.filter((c) => c.other.number < pr.number);
- const newer = conflicts.filter((c) => c.other.number > pr.number);
-
- for (const { other, shared } of newer) {
- core.info(
- `#${other.number} (newer) also declares ${shared.map((n) => `#${n}`).join(', ')} — ` +
- `it will fail its own run of this guard; this PR keeps its claim.`,
- );
- }
-
- if (older.length > 0) {
- const lines = older.map(({ other, shared }) =>
- ` - ${shared.map((n) => `#${n}`).join(', ')} is already claimed by #${other.number} ` +
- `(${other.html_url}, branch \`${other.head.ref}\`${other.draft ? ', draft' : ''})`,
- );
- core.setFailed(
- `Another open PR already declares a fix for the same issue(s):\n${lines.join('\n')}\n` +
- `If this PR is the duplicate, close it and add anything it uniquely covers to the ` +
- `earlier PR or a follow-up issue (that is what saved #4560 when #4559 was closed). ` +
- `If the EARLIER one is abandoned, close it first — this check re-runs on 'edited' ` +
- `and 'synchronize', and goes green once the conflict is gone.`,
- );
- }
diff --git a/.github/workflows/partof-closing-keyword-guard.yml b/.github/workflows/partof-closing-keyword-guard.yml
deleted file mode 100644
index 8adf46dd9f2..00000000000
--- a/.github/workflows/partof-closing-keyword-guard.yml
+++ /dev/null
@@ -1,103 +0,0 @@
-# A PR that declares itself only `Part of #N` must not also tell GitHub to
-# close `#N`. GitHub's closing-keyword parser matches the keyword plus the
-# number and ignores the surrounding prose entirely — negations and modals
-# included — so the sentence an author writes to PREVENT an auto-close is
-# exactly what performs it on merge. That happened: a half-delivered card was
-# closed `completed` two seconds after its PR merged, by its own warning
-# sentence, and a closed card reads as finished, so it was found only by a
-# post-merge inventory re-pull. The author wrote the warning correctly and
-# still lost the card, which is why this is mechanical and not advice.
-#
-# The rule, the wording of the failure, and the code-stripping this depends on
-# all live in `scripts/check-partof-closing-keyword.mjs` and the predicate it
-# reuses; that header is authoritative, this file is the invocation.
-#
-# Sibling shape, deliberately copied rather than reinvented: the Duplicate Fix
-# Guard is one of this repo's other PR-body-scoped blocking checks, and it takes
-# the same trigger set for the same measured reason.
-name: Part-of Closing-Keyword Guard
-
-# `edited` is load-bearing, not decoration. The body is this check's whole
-# input, and GitHub does not re-deliver a `pull_request` event when a body
-# changes under any other activity type — while `rerun_failed_jobs` replays the
-# frozen payload, so a re-run of a fixed body stays red forever. With `edited`
-# subscribed, rewording the sentence fires a fresh event carrying the fresh
-# body and the check goes green with no push and no re-run.
-#
-# No `merge_group:` trigger, and that is not an oversight: a merge-queue event
-# carries no pull request and therefore no body, so this check has nothing to
-# judge there. That also keeps it out of the required-context registry, whose
-# entries must report on queue builds; see the script header on where branch
-# protection fits.
-on:
- pull_request:
- types: [opened, edited, reopened, synchronize]
-
-# `contents: read` checks the repo out to get at the script, and naming a
-# `permissions:` block at all sets every scope NOT listed to `none`, so that one
-# line is the whole grant. It used to also grant `pull-requests: read`, for a
-# step that gathered the PR's commit messages; that rule is a pre-push hook now
-# (the script header says why), so the scope went with it. This gate reports,
-# and never closes a PR, comments, or edits a body.
-permissions:
- contents: read
-
-concurrency:
- group: partof-closing-keyword-${{ github.event.pull_request.number }}
- cancel-in-progress: true
-
-jobs:
- partof-closing-keyword:
- name: Part-of PR must not also close its card
- runs-on: ubuntu-latest
- # Run wall over the 100 most recent completed runs to 2026-10-27: p99 0.8 min, max
- # 0.8.
- timeout-minutes: 5
- steps:
- - name: Checkout repository
- uses: actions/checkout@v7
-
- # Pinned to the same major and spelling as every other setup-node in this
- # repo, and for a measured reason rather than tidiness. The first draft of
- # this file used `actions/setup-node@v5` with `node-version-file`, and the
- # job died IN THIS STEP, before the script ever ran:
- #
- # ##[error]Unable to locate executable file: pnpm.
- #
- # v5 defaults `package-manager-cache: true` (the run log echoes it as an
- # input), which reads `packageManager: pnpm@...` out of package.json and
- # shells out to pnpm to locate the store to cache. This job installs no
- # package manager on purpose — the script is dependency-free and imports
- # one sibling module — so pnpm is not on PATH and the action hard-errors.
- # The failure is worth naming because nothing in the step that failed
- # mentions pnpm: it is an implicit default of the action, invisible in the
- # workflow source, and the misleading first read is that the `run:` line
- # below invoked a package manager. It does not, and never did.
- #
- # The check-links workflow's ADR-links job is the known-good shape this
- # now matches exactly: checkout, setup-node, one `node scripts/check-*.mjs`
- # call, no install and no corepack. It is green on this repo today.
- - name: Setup Node.js
- uses: actions/setup-node@v7
- with:
- node-version-file: .nvmrc
-
- # The body reaches the script through `env:`, never through `${{ }}`
- # inside the `run:` script. An expression interpolated into a shell line
- # is substituted before bash ever sees it, so a PR body is arbitrary
- # attacker-controlled text landing in a command; through `env:` it is
- # inert data. The script's self-test pins this spelling.
- #
- # `PR_NUMBER` is not only for the message: GitHub renders a null body as
- # an EMPTY value, so it is the witness that separates "this PR has an
- # empty body" (a real, clean verdict) from "this step was handed nothing"
- # (a wiring failure, which exits 2 and says so).
- #
- # No install step: the script imports one sibling module and reads no
- # workspace package, so `node` on the pinned runtime is the whole
- # toolchain it needs.
- - name: A PR body may not close the card it is only part of
- env:
- PR_BODY: ${{ github.event.pull_request.body }}
- PR_NUMBER: ${{ github.event.pull_request.number }}
- run: node scripts/check-partof-closing-keyword.mjs
diff --git a/.github/workflows/pr-body-guards.yml b/.github/workflows/pr-body-guards.yml
new file mode 100644
index 00000000000..6e5110a9bfd
--- /dev/null
+++ b/.github/workflows/pr-body-guards.yml
@@ -0,0 +1,498 @@
+# ══════════════════════════════════════════════════════════════════════════════
+# PR-body guards — four pull-request-scoped blocking checks in ONE workflow, ONE
+# job, FOUR steps (#26071, ruling A step (iii)). Formerly four files, each one job
+# with one gate step:
+#
+# closing-target-claim-guard.yml → step `The card this PR closes must claim this branch`
+# duplicate-fix-guard.yml → step `No other open PR may claim the same issue`
+# partof-closing-keyword-guard.yml → step `Part-of PR must not also close its card`
+# single-claim-path-guard.yml → step `No other open PR may claim the same single-writer path`
+#
+# Each retired file's header is carried whole as the comment block above its step,
+# and each gate step is the retired step verbatim but for its `name:` (the retired
+# JOB's display name, which was its check-run name) and an `if:`. The rules, the
+# wording of every failure and the exit contracts live in the scripts the steps
+# run (the duplicate-fix guard is an inline github-script, as it was); this file is
+# the invocation.
+#
+# What one job changes, and what it keeps:
+#
+# - ONE check run, `PR-body guards (…)`, where there were four. Each guard's
+# verdict is its STEP, and every step runs even when an earlier one failed
+# (`if: ${{ !cancelled() }}`), so one run still shows all four verdicts and a
+# PR red on two guards is red on two steps, not on the first alone. None of
+# the four was a required context (`REQUIRED_CONTEXTS` names only `ci.yml`,
+# `lint.yml` and `governed-surface-guard.yml`), so no required context moves.
+# - The retired files' run history stays under their retired names (GitHub
+# keeps a deleted workflow's runs; `GET /actions/workflows/FILE.yml` answers
+# `state: deleted`). New history lands under `PR-Body Guards`.
+# - `merge_group`: only the closing-target claim guard took the queue leg (its
+# script reads the pull request the queue ref NAMES). It keeps it. The other
+# three steps are gated to the `pull_request` leg, because a merge-queue event
+# carries no pull request and therefore no body or file list for them to judge
+# — exactly the reason their retired files subscribed no `merge_group:` at all.
+# - No checkout for the step that needs none: the duplicate-fix guard is an
+# inline github-script and runs FIRST, before the one checkout + setup-node
+# pair the three script-backed guards share.
+# - `permissions:` is the union of the four retired grants, all read; one
+# `concurrency:` group per pull request; `timeout-minutes` is the largest of
+# the four (10), with the three measured siblings' walls recorded at their
+# steps.
+#
+# Three self-tests read THIS FILE by name and pin its shape — the wiring batteries
+# of `scripts/check-closing-target-claim.mjs`, `scripts/check-partof-closing-keyword.mjs`
+# and `scripts/check-single-claim-paths.mjs` — and `scripts/check-closing-keyword-parity.mjs`
+# extracts the duplicate-fix guard's keyword parser from the github-script step
+# below. Renaming this file, a gate step, or the env spelling of a step reddens
+# `pnpm check:closing-target-claim`, `check:partof-closing-keyword`,
+# `check:single-claim-paths` or the parity gate, by design.
+# ══════════════════════════════════════════════════════════════════════════════
+name: PR-Body Guards
+
+# The trigger set is the one all four retired files carried, and every activity
+# type in it is load-bearing for at least one guard:
+#
+# `edited`: three guards' whole input is the PR BODY (the closing-target claim,
+# the duplicate-fix and the Part-of keyword guards), and GitHub does not
+# re-deliver a `pull_request` event when a body changes under any other activity
+# type — while `rerun_failed_jobs` replays the FROZEN payload, so a re-run of a
+# fixed body stays red forever. With `edited` subscribed, rewording the sentence
+# or dropping the closing keyword fires a fresh event carrying the fresh body and
+# the check goes green with no push and no re-run. The single-claim path guard
+# keeps it for the same recovery: closing the earlier of two colliding PRs must
+# let the surviving one go green without a push.
+#
+# `synchronize`: the single-claim path guard's input is the PR's CHANGED FILES,
+# which move on every push, and the closing-target guard's is half the head
+# BRANCH — a push that changes what the PR contains must be re-judged on the
+# commit that changed it.
+#
+# `merge_group`: the closing-target claim guard's leg, and ITS ALONE. A
+# merge-queue event carries no pull request — but the queue REF NAMES its pull
+# request (`gh-readonly-queue//pr--`); that script reads the number
+# with the governed queue guard's own shipped parser and then reads the same PR
+# body and the same live comment threads, so the queue build judges the SAME
+# pull request by the SAME rule. The other three steps have nothing to judge on
+# a queue build and are gated to the `pull_request` leg (see their `if:`).
+#
+# ⚠️ In a MULTI-PR group the queue ref names only the LAST pull request; the
+# script header states that limit. Every member of the group passed the
+# `pull_request` leg to be armed at all.
+on:
+ pull_request:
+ types: [opened, edited, reopened, synchronize]
+ merge_group:
+
+# ⛔ NO `paths:` filter, on either leg. A skipped job counts as SUCCESS in branch
+# protection, so a path filter would hand a green check to the very PR the
+# filter mis-scoped — and three of these gates' input is a PR BODY, which no path
+# filter can see at all. (`merge_group` has no paths support either, so a filter
+# would also make the two legs disagree about what they cover.)
+
+# Read-only, and that is the whole grant — the UNION of the four retired files'
+# blocks, every scope `read`. These gates report; none closes a PR, comments,
+# edits a body, or writes a label.
+#
+# `contents: read` is not optional padding: naming a `permissions:` block at all
+# sets every scope NOT listed to `none`, and this job checks the repo out to get
+# at three of the scripts; with only the pull-requests scope the job dies in the
+# checkout step, before any gate runs. `issues: read` is what the closing-target
+# guard's card comment threads need — the claim record is an ISSUE comment, and
+# the pull-requests scope does not reach it. `pull-requests: read` is what the
+# duplicate-fix and single-claim guards' reads of OTHER open PRs need, and what
+# the merge_group leg needs to read the body of the pull request its queue ref
+# names.
+permissions:
+ contents: read
+ issues: read
+ pull-requests: read
+
+# A newer run for the same pull request supersedes this one, so the older run is
+# cancelled. On the merge_group leg `github.ref` is the queue ref, unique per
+# queue build, so no other PR's build is ever cancelled.
+concurrency:
+ group: pr-body-guards-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ pr-body-guards:
+ # ⚠️ THE STEP NAMES BELOW ARE PINNED, not this job's. Each gate step is named
+ # as the retired job was — the literal `CHECK_CONTEXT_NAME` in
+ # `scripts/check-closing-target-claim.mjs`, and the sibling-gate string the
+ # single-claim self-test looks for — and those self-tests read THIS FILE and
+ # fail if a step name drifts. The check run itself is this job's display
+ # name, one for all four guards.
+ #
+ # ⛔ Making this a required context is NOT this PR's step. Branch protection
+ # takes a `REQUIRED_CONTEXTS` row PLUS the Settings → Rulesets entry, in one
+ # sitting, and the settings half is the maintainer's. Either half alone is
+ # an outage. What the merge_group leg above buys today is that the claim
+ # question is asked on the queue build; what it buys the day someone does
+ # make this required is that it cannot deadlock the queue, which a workflow
+ # with no queue leg always does.
+ name: PR-body guards (claim · duplicate · Part-of · single-writer path)
+ runs-on: ubuntu-latest
+ # The largest of the four retired budgets (closing-target 10; the other three
+ # 5, each with a measured p99 under a minute — recorded at their steps). The
+ # four gates now run in sequence after ONE checkout + setup-node, so the sum
+ # of the measured walls stays well inside this.
+ timeout-minutes: 10
+ steps:
+ # ══ `duplicate-fix-guard` — formerly `.github/workflows/duplicate-fix-guard.yml` (`Duplicate Fix Guard`) ══
+ #
+ # The retired file's header, carried whole; the step at the end of this block
+ # is its gate step, verbatim but for its `name:` (now the retired JOB's display
+ # name, so the verdict reads in the step list as it read on the Checks tab) and
+ # the `if:` that keeps every guard's verdict visible on one run.
+ #
+ # GitHub happily lets any number of open PRs declare `Fixes #N` for the same
+ # issue. On 2026-08-02 that cost a full duplicate implementation: #4555 and
+ # #4559 both declared `Fixes #4551`, both ran the whole gate suite green, and
+ # the duplication sat machine-detectable from the moment the second PR opened
+ # (03:08) until a human noticed it (08:52). All agents here share one GitHub
+ # identity, so the issue's assignee could not warn the second author either —
+ # "assigned to os-zhuang" reads the same whether it is you or another session.
+ # Full post-mortem: #4588.
+ #
+ # This gate makes the second PR red at open time. First come, first served —
+ # the EARLIER open PR (lower number) keeps its claim and stays green; the
+ # later one fails with a pointer to it. That matches the pm-dispatch claim
+ # convention ("first claim comment wins") already in .claude/skills/.
+ #
+ # Scope: same-repo references only (bare `#N` and qualified `#N`).
+ # Cross-repo references are the cross-repo-issue-closer's territory, and a
+ # duplicate across repos cannot be resolved by failing one side's CI anyway.
+ #
+ # Gated to the pull_request leg: a merge-queue event carries no pull request,
+ # so there is no body to read declared issues from. No checkout precedes this
+ # step — it is an inline github-script and needs none.
+ #
+ # Run wall of the retired job over its 100 most recent completed runs to
+ # 2026-10-27: p99 0.3 min, max 0.8.
+ - name: No other open PR may claim the same issue
+ if: ${{ !cancelled() && github.event_name == 'pull_request' }}
+ uses: actions/github-script@v9
+ with:
+ script: |
+ const pr = context.payload.pull_request;
+ const thisRepo = `${context.repo.owner}/${context.repo.repo}`;
+
+ // GitHub's own closing-keyword set. The optional colon is part of
+ // GitHub's accepted syntax (`Fixes: #123`). Two same-repo forms:
+ // bare `#N` and qualified `owner/repo#N` naming THIS repo.
+ const KEYWORDS = 'close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved';
+ const pattern = new RegExp(
+ `\\b(?:${KEYWORDS}):?\\s+(?:([\\w.-]+)\\/([\\w.-]+))?#(\\d+)\\b`,
+ 'gi',
+ );
+
+ // Strip fenced blocks and inline code spans before matching.
+ // GitHub's own closing-keyword parser ignores code formatting, and
+ // this guard must not be stricter than the linker it protects:
+ // this very PR's first run counted its own DISCUSSION of
+ // `Fixes #4551` (in backticks, describing the incident) as a
+ // declaration. Benign there — #4551 is closed — but a prose
+ // mention of an issue some other open PR really fixes would have
+ // been a spurious red.
+ const declaredIssues = (body) => {
+ const prose = (body || '')
+ .replace(/```[\s\S]*?```/g, ' ')
+ .replace(/`[^`\n]*`/g, ' ');
+ const found = new Set();
+ for (const [, owner, repo, number] of prose.matchAll(pattern)) {
+ // A qualified reference to ANOTHER repo is not ours to judge.
+ if (owner && `${owner}/${repo}`.toLowerCase() !== thisRepo.toLowerCase()) continue;
+ found.add(Number(number));
+ }
+ return found;
+ };
+
+ const mine = declaredIssues(pr.body);
+ if (mine.size === 0) {
+ core.info('This PR declares no same-repo closing keywords — nothing to guard.');
+ return;
+ }
+ core.info(`This PR declares: ${[...mine].map((n) => `#${n}`).join(', ')}`);
+
+ // Branch-name convention (advisory, never red): a fix branch named
+ // `claude/issue--` is discoverable by the next session
+ // with one `git ls-remote --heads origin | grep -E 'issue-(-|$)'`.
+ // #4555 vs #4559 happened partly because the branches shared no
+ // token to grep. Both spellings delimit the number on the RIGHT:
+ // undelimited, `issue-186` matches `claude/issue-18611-x`, so the
+ // advisory stays silent for a branch naming a DIFFERENT card —
+ // the false negative. The `$` arm keeps the slug-less spelling
+ // `claude/issue-186` matching; `issue-` anchors the left, so
+ // `issue-1186` and `issue-0186` do not match card 186 either.
+ // Warning only — existing branches must not go red retroactively.
+ const branch = pr.head.ref;
+ if (![...mine].some((n) => new RegExp(`issue-${n}(-|$)`).test(branch))) {
+ core.warning(
+ `Branch \`${branch}\` does not name any declared issue. ` +
+ `Convention: claude/issue-- (e.g. claude/issue-${[...mine][0]}-short-slug) ` +
+ `so parallel sessions can discover in-flight work with git ls-remote.`,
+ );
+ }
+
+ // Drafts count: a draft PR is work in flight, which is exactly
+ // what the second session needs to see.
+ const openPrs = await github.paginate(github.rest.pulls.list, {
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ state: 'open',
+ per_page: 100,
+ });
+
+ const conflicts = [];
+ for (const other of openPrs) {
+ if (other.number === pr.number) continue;
+ const theirs = declaredIssues(other.body);
+ const shared = [...mine].filter((n) => theirs.has(n));
+ if (shared.length > 0) conflicts.push({ other, shared });
+ }
+
+ if (conflicts.length === 0) {
+ core.info('No other open PR declares these issues.');
+ return;
+ }
+
+ // First come, first served: only the LATER PR goes red. Failing
+ // both would leave the original author red through no action of
+ // their own; failing the earlier one would reward racing.
+ const older = conflicts.filter((c) => c.other.number < pr.number);
+ const newer = conflicts.filter((c) => c.other.number > pr.number);
+
+ for (const { other, shared } of newer) {
+ core.info(
+ `#${other.number} (newer) also declares ${shared.map((n) => `#${n}`).join(', ')} — ` +
+ `it will fail its own run of this guard; this PR keeps its claim.`,
+ );
+ }
+
+ if (older.length > 0) {
+ const lines = older.map(({ other, shared }) =>
+ ` - ${shared.map((n) => `#${n}`).join(', ')} is already claimed by #${other.number} ` +
+ `(${other.html_url}, branch \`${other.head.ref}\`${other.draft ? ', draft' : ''})`,
+ );
+ core.setFailed(
+ `Another open PR already declares a fix for the same issue(s):\n${lines.join('\n')}\n` +
+ `If this PR is the duplicate, close it and add anything it uniquely covers to the ` +
+ `earlier PR or a follow-up issue (that is what saved #4560 when #4559 was closed). ` +
+ `If the EARLIER one is abandoned, close it first — this check re-runs on 'edited' ` +
+ `and 'synchronize', and goes green once the conflict is gone.`,
+ );
+ }
+
+ # ── The one checkout + setup-node the three script-backed guards share ──
+ #
+ # Taken from the Part-of guard's file, whose setup-node comment recorded the
+ # measured incident the other two retired files cited; the closing-target and
+ # single-claim files carried the same two steps byte-for-byte.
+ #
+ - name: Checkout repository
+ if: ${{ !cancelled() }}
+ uses: actions/checkout@v7
+
+ # Pinned to the same major and spelling as every other setup-node in this
+ # repo, and for a measured reason rather than tidiness. The first draft of
+ # the Part-of guard's retired file used `actions/setup-node@v5` with
+ # `node-version-file`, and the job died IN THIS STEP, before the script
+ # ever ran:
+ #
+ # ##[error]Unable to locate executable file: pnpm.
+ #
+ # v5 defaults `package-manager-cache: true` (the run log echoes it as an
+ # input), which reads `packageManager: pnpm@...` out of package.json and
+ # shells out to pnpm to locate the store to cache. This job installs no
+ # package manager on purpose — the three scripts are dependency-free and
+ # import at most two sibling modules — so pnpm is not on PATH and the action
+ # hard-errors.
+ # The failure is worth naming because nothing in the step that failed
+ # mentions pnpm: it is an implicit default of the action, invisible in the
+ # workflow source, and the misleading first read is that the `run:` line
+ # below invoked a package manager. It does not, and never did.
+ #
+ # The check-links workflow's ADR-links job is the known-good shape this
+ # matches: checkout, setup-node, `node scripts/check-*.mjs` calls, no install
+ # and no corepack. It is green on this repo today.
+ - name: Setup Node.js
+ if: ${{ !cancelled() }}
+ uses: actions/setup-node@v7
+ with:
+ node-version-file: .nvmrc
+
+ # ══ `closing-target-claim` — formerly `.github/workflows/closing-target-claim-guard.yml` (`Closing-Target Claim Guard`) ══
+ #
+ # The retired file's header, carried whole; the step at the end of this block
+ # is its gate step, verbatim but for its `name:` (now the retired JOB's display
+ # name, so the verdict reads in the step list as it read on the Checks tab) and
+ # the `if:` that keeps every guard's verdict visible on one run.
+ #
+ # A pull request may close a card only while that card's own thread carries a
+ # `Claim:` naming this pull request's head branch.
+ #
+ # Every agent here shares ONE GitHub identity, so the assignee field is a
+ # presence bit and the `Claim:` comment is the identity record. Two guards
+ # already run at pull-request time — the Duplicate Fix Guard ("no other open PR
+ # may claim the same issue") and the Single-Claim Path Guard — and neither asks
+ # the question that decides ownership. That question WAS already asked, by row
+ # H46 of the half-state sweep, and asked AFTER THE FACT as a report-only patrol
+ # input: the cloud-repo incident (one card implemented 29 minutes apart by two
+ # seats, the second with no claim) and an objectstack card implemented twice in
+ # one morning were both visible to that predicate and stopped by nothing. A
+ # control that detects a defect it cannot prevent is a TIMING defect in an
+ # existing control, not an absent one — so this workflow runs the same
+ # predicate at the moment the merge can still be stopped.
+ #
+ # The rule, the imported predicates, the declined-number policy, the
+ # branch-rename remedy and the exit contract all live in
+ # `scripts/check-closing-target-claim.mjs`; that header is authoritative and
+ # this file is the invocation.
+ #
+ # Sibling shape, deliberately copied rather than reinvented: the Part-of
+ # Closing-Keyword Guard and the Single-Claim Path Guard are this workflow's
+ # other script-backed steps, and this shares their runtime pin and their
+ # `pull_request` trigger set.
+ #
+ # Runs on BOTH legs — not gated to pull_request, by design; see the `on:`
+ # comment and the step comment below.
+ #
+ # Everything reaches the script through `env:`, never through `${{ }}`
+ # inside the `run:` line. A PR body is arbitrary author-controlled text,
+ # and an expression interpolated into a shell line is substituted before
+ # bash ever sees it; through `env:` it is inert data. The script's
+ # self-test pins this spelling.
+ #
+ # On the merge_group leg every `github.event.pull_request.*` expression
+ # renders EMPTY and `MERGE_GROUP_HEAD_REF` carries the queue ref, which is
+ # what selects the leg inside the script. On the pull_request leg the
+ # reverse holds. Neither leg needs its own step.
+ #
+ # `PR_HEAD_REF` is not decoration: the head branch IS the question this
+ # gate asks, so a blank one exits NOT MEASURED rather than green.
+ #
+ # The token is required, not optional: the claim record is a comment on
+ # another issue, which cannot be answered from the event payload.
+ - name: The card this PR closes must claim this branch
+ if: ${{ !cancelled() }}
+ env:
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ PR_BODY: ${{ github.event.pull_request.body }}
+ PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
+ MERGE_GROUP_HEAD_REF: ${{ github.event.merge_group.head_ref }}
+ GITHUB_REPOSITORY: ${{ github.repository }}
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: node scripts/check-closing-target-claim.mjs
+
+ # ══ `partof-closing-keyword` — formerly `.github/workflows/partof-closing-keyword-guard.yml` (`Part-of Closing-Keyword Guard`) ══
+ #
+ # The retired file's header, carried whole; the step at the end of this block
+ # is its gate step, verbatim but for its `name:` (now the retired JOB's display
+ # name, so the verdict reads in the step list as it read on the Checks tab) and
+ # the `if:` that keeps every guard's verdict visible on one run.
+ #
+ # A PR that declares itself only `Part of #N` must not also tell GitHub to
+ # close `#N`. GitHub's closing-keyword parser matches the keyword plus the
+ # number and ignores the surrounding prose entirely — negations and modals
+ # included — so the sentence an author writes to PREVENT an auto-close is
+ # exactly what performs it on merge. That happened: a half-delivered card was
+ # closed `completed` two seconds after its PR merged, by its own warning
+ # sentence, and a closed card reads as finished, so it was found only by a
+ # post-merge inventory re-pull. The author wrote the warning correctly and
+ # still lost the card, which is why this is mechanical and not advice.
+ #
+ # The rule, the wording of the failure, and the code-stripping this depends on
+ # all live in `scripts/check-partof-closing-keyword.mjs` and the predicate it
+ # reuses; that header is authoritative, this file is the invocation.
+ #
+ # Sibling shape, deliberately copied rather than reinvented: the Duplicate Fix
+ # Guard is this workflow's other PR-body-scoped blocking check, and both take
+ # the same trigger set for the same measured reason.
+ #
+ # Gated to the pull_request leg, and that is not an oversight: a merge-queue
+ # event carries no pull request and therefore no body, so this check has
+ # nothing to judge there. A skipped STEP is not a green check: the job's
+ # verdict on a queue build is the closing-target step's alone.
+ #
+ # Run wall of the retired job over its 100 most recent completed runs to
+ # 2026-10-27: p99 0.8 min, max 0.8.
+ #
+ # The body reaches the script through `env:`, never through `${{ }}`
+ # inside the `run:` script. An expression interpolated into a shell line
+ # is substituted before bash ever sees it, so a PR body is arbitrary
+ # attacker-controlled text landing in a command; through `env:` it is
+ # inert data. The script's self-test pins this spelling.
+ #
+ # `PR_NUMBER` is not only for the message: GitHub renders a null body as
+ # an EMPTY value, so it is the witness that separates "this PR has an
+ # empty body" (a real, clean verdict) from "this step was handed nothing"
+ # (a wiring failure, which exits 2 and says so).
+ #
+ # No install step: the script imports one sibling module and reads no
+ # workspace package, so `node` on the pinned runtime is the whole
+ # toolchain it needs.
+ - name: Part-of PR must not also close its card
+ if: ${{ !cancelled() && github.event_name == 'pull_request' }}
+ env:
+ PR_BODY: ${{ github.event.pull_request.body }}
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ run: node scripts/check-partof-closing-keyword.mjs
+
+ # ══ `single-claim-path` — formerly `.github/workflows/single-claim-path-guard.yml` (`Single-Claim Path Guard`) ══
+ #
+ # The retired file's header, carried whole; the step at the end of this block
+ # is its gate step, verbatim but for its `name:` (now the retired JOB's display
+ # name, so the verdict reads in the step list as it read on the Checks tab) and
+ # the `if:` that keeps every guard's verdict visible on one run.
+ #
+ # Two PRs reached green independently while shipping ONE physical change: both
+ # rewrote the objectui pin from the same old sha to the same new sha and both
+ # added the same generated changeset — a byte-identical hunk under two card
+ # numbers. The first entered the merge queue; the second was green and one flip
+ # from enqueueing behind it, where it would have conflicted on both files.
+ #
+ # The repo's Duplicate Fix Guard is not broken and is not touched by this step.
+ # It asks whether two open PRs claim the same CARD, and it answered correctly:
+ # these claimed two genuinely different, independently filed cards, neither a
+ # duplicate of the other as written. The duplication existed only in the diff.
+ # This step adds the second question — do two open PRs write the same
+ # at-most-one-writer path — and the two gates run side by side, as steps
+ # of one job.
+ #
+ # The rule, the declared path list, the measured false-positive rates that
+ # forced a declared list instead of repo-wide diff intersection, and the wording
+ # of the failure all live in `scripts/check-single-claim-paths.mjs`; that header
+ # is authoritative and this file is the invocation.
+ #
+ # Sibling shape, deliberately copied rather than reinvented: the Part-of
+ # Closing-Keyword Guard is this workflow's other single-script PR-scoped
+ # blocking check, and this shares its runtime pin and its trigger set.
+ #
+ # Gated to the pull_request leg, and not by oversight: a merge-queue event
+ # carries no pull request, so this check has nothing to judge there.
+ #
+ # Run wall of the retired job over its 100 most recent completed runs to
+ # 2026-10-27: p99 0.8 min, max 0.9.
+ #
+ # Everything reaches the script through `env:`, never through `${{ }}`
+ # inside the `run:` line. Nothing here is attacker-controlled text today
+ # — a PR number is a number — but the spelling is the one the sibling
+ # guard's self-test pins, and keeping both gates identical on this point
+ # is what stops a later edit from introducing the unsafe form by analogy.
+ #
+ # `PR_NUMBER` is also the witness that separates a real verdict from an
+ # unwired run: with it absent the script exits 2 and says it judged
+ # nothing, rather than exiting 0 and reading as "no violations".
+ #
+ # The token is required, not optional: this gate's question is about OTHER
+ # open PRs, which cannot be answered from the event payload. The read is
+ # short-circuited on the cheap side — when this PR touches none of the
+ # declared paths, which is ~99.7% of runs by measurement, the job makes a
+ # single API call and stops.
+ - name: No other open PR may claim the same single-writer path
+ if: ${{ !cancelled() && github.event_name == 'pull_request' }}
+ env:
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ GITHUB_REPOSITORY: ${{ github.repository }}
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: node scripts/check-single-claim-paths.mjs
diff --git a/.github/workflows/single-claim-path-guard.yml b/.github/workflows/single-claim-path-guard.yml
deleted file mode 100644
index bb9010364a7..00000000000
--- a/.github/workflows/single-claim-path-guard.yml
+++ /dev/null
@@ -1,111 +0,0 @@
-# Two PRs reached green independently while shipping ONE physical change: both
-# rewrote the objectui pin from the same old sha to the same new sha and both
-# added the same generated changeset — a byte-identical hunk under two card
-# numbers. The first entered the merge queue; the second was green and one flip
-# from enqueueing behind it, where it would have conflicted on both files.
-#
-# The repo's Duplicate Fix Guard is not broken and is not touched by this file.
-# It asks whether two open PRs claim the same CARD, and it answered correctly:
-# these claimed two genuinely different, independently filed cards, neither a
-# duplicate of the other as written. The duplication existed only in the diff.
-# This workflow adds the second question — do two open PRs write the same
-# at-most-one-writer path — and the two gates run side by side.
-#
-# The rule, the declared path list, the measured false-positive rates that
-# forced a declared list instead of repo-wide diff intersection, and the wording
-# of the failure all live in `scripts/check-single-claim-paths.mjs`; that header
-# is authoritative and this file is the invocation.
-#
-# Sibling shape, deliberately copied rather than reinvented: the Part-of
-# Closing-Keyword Guard is one of this repo's other single-script PR-scoped
-# blocking checks, and this takes its job shape, its runtime pin and its trigger
-# set.
-name: Single-Claim Path Guard
-
-# `synchronize` is the load-bearing one here, and that is the difference from
-# the two body-scoped guards this otherwise copies. Their whole input is the PR
-# body, so `edited` is what they need; this one's input is the PR's CHANGED
-# FILES, which move on every push. A PR that adds a claim on a listed path in
-# its second commit must be judged on that commit, and one that drops the path
-# again must be able to go green without being closed and reopened.
-#
-# `edited` is kept anyway and is not decoration: the failure text tells a reader
-# to close one of the two PRs, and closing the earlier one must let the later
-# one recover. That recovery arrives as an event on the SURVIVING PR only if it
-# is touched — so `edited` is the cheap, no-push way back to green, exactly as
-# in the sibling guards.
-#
-# No `merge_group:` trigger, and not by oversight: a merge-queue event carries
-# no pull request, so this check has nothing to judge there. That also keeps it
-# out of the required-context registry, whose entries must report on queue
-# builds. Branch protection is a settings change no agent seat can make; this
-# publishes a check run, and whether it becomes REQUIRED carries a maintainer
-# ruling. The Duplicate Fix Guard sits in exactly the same position.
-on:
- pull_request:
- types: [opened, edited, reopened, synchronize]
-
-# Read-only, and that is the whole grant. This gate reports; it never closes a
-# PR, comments, or edits a body. The one incident where a duplicate PR was
-# closed, it was closed by a human who spotted by eye what no gate could see —
-# and a gate that closed PRs by itself would be a far more expensive mistake
-# than the one it prevents.
-#
-# `contents: read` is not optional padding: naming a `permissions:` block at all
-# sets every scope NOT listed to `none`, and this job checks the repo out to get
-# at the script. With only the pull-requests scope here the job dies in the
-# checkout step, before the gate ever runs — a gate that is red for a reason
-# that has nothing to do with what it checks.
-permissions:
- contents: read
- pull-requests: read
-
-concurrency:
- group: single-claim-path-${{ github.event.pull_request.number }}
- cancel-in-progress: true
-
-jobs:
- single-claim-path:
- name: No other open PR may claim the same single-writer path
- runs-on: ubuntu-latest
- # Run wall over the 100 most recent completed runs to 2026-10-27: p99 0.8 min, max
- # 0.9.
- timeout-minutes: 5
- steps:
- - name: Checkout repository
- uses: actions/checkout@v7
-
- # Pinned to the same major and spelling as every other setup-node in this
- # repo, for the measured reason recorded in the sibling guard's header:
- # a setup-node major whose package-manager-cache default is on reads
- # `packageManager` out of package.json and shells out to pnpm to find its
- # store, killing the job in the SETUP step with "Unable to locate
- # executable file: pnpm" — before the script runs, and naming a tool the
- # workflow source never mentions. This job installs no package manager on
- # purpose: the script is dependency-free and imports nothing.
- - name: Setup Node.js
- uses: actions/setup-node@v7
- with:
- node-version-file: .nvmrc
-
- # Everything reaches the script through `env:`, never through `${{ }}`
- # inside the `run:` line. Nothing here is attacker-controlled text today
- # — a PR number is a number — but the spelling is the one the sibling
- # guard's self-test pins, and keeping both gates identical on this point
- # is what stops a later edit from introducing the unsafe form by analogy.
- #
- # `PR_NUMBER` is also the witness that separates a real verdict from an
- # unwired run: with it absent the script exits 2 and says it judged
- # nothing, rather than exiting 0 and reading as "no violations".
- #
- # The token is required, not optional: this gate's question is about OTHER
- # open PRs, which cannot be answered from the event payload. The read is
- # short-circuited on the cheap side — when this PR touches none of the
- # declared paths, which is ~99.7% of runs by measurement, the job makes a
- # single API call and stops.
- - name: At most one open PR may modify a declared single-writer path
- env:
- PR_NUMBER: ${{ github.event.pull_request.number }}
- GITHUB_REPOSITORY: ${{ github.repository }}
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: node scripts/check-single-claim-paths.mjs
diff --git a/scripts/check-closing-keyword-parity.mjs b/scripts/check-closing-keyword-parity.mjs
index 9540403cdbd..af11ed28a95 100644
--- a/scripts/check-closing-keyword-parity.mjs
+++ b/scripts/check-closing-keyword-parity.mjs
@@ -17,12 +17,13 @@
*
* - `.github/workflows/cross-repo-issue-closer.yml` decides whether an issue
* in ANOTHER repository gets closed;
- * - `.github/workflows/duplicate-fix-guard.yml` decides whether a PR goes RED
- * for claiming an issue another open PR already claimed;
+ * - the duplicate-fix guard step of `.github/workflows/pr-body-guards.yml`
+ * (formerly `duplicate-fix-guard.yml`) decides whether a PR goes RED for
+ * claiming an issue another open PR already claimed;
* - `closingKeywordRe()` in `scripts/pm/check-half-states.mjs` (H7, the
- * blocking gate behind `.github/workflows/partof-closing-keyword-guard.yml`)
- * decides whether a `Part of #N` PR is about to close the card it is only
- * part of.
+ * blocking gate behind the Part-of closing-keyword guard step of that same
+ * workflow, formerly `partof-closing-keyword-guard.yml`) decides whether a
+ * `Part of #N` PR is about to close the card it is only part of.
*
* They are three spellings of one judgement, and they HAVE drifted. Measured on
* 2026-08-20, `Fixes: objectstack-ai/objectui#456` matched the duplicate-fix
@@ -38,9 +39,10 @@
* that diverged live inside `actions/github-script` blocks, and neither workflow
* checks this repository out: the closer deliberately never does (it runs on
* `pull_request_target`, and its header makes the no-checkout posture an
- * invariant rather than an accident), and the duplicate-fix guard has no
- * checkout step at all -- its whole job is one github-script step. `require()`
- * of a repo file therefore resolves to nothing in either. Giving both a checkout
+ * invariant rather than an accident), and the duplicate-fix guard runs BEFORE
+ * its job's checkout -- it is one github-script step, deliberately first of the
+ * four (#26071). `require()` of a repo file therefore resolves to nothing in
+ * either. Giving both a checkout
* to share ten characters of regex would buy the import by spending the closer's
* stated security posture, which is the worse trade.
*
@@ -58,8 +60,8 @@
* parsers: not "do the three agree" but "which of them binds what in THIS
* text". It is a pre-flight instrument for a PR body, runnable BEFORE the PR
* exists -- otherwise the only enforcement that reads a body is the blocking
- * gate behind partof-closing-keyword-guard.yml, which fires after the body is
- * already written.
+ * gate behind the Part-of closing-keyword guard step of pr-body-guards.yml,
+ * which fires after the body is already written.
*
* It adds NO grammar. It calls `loadParsers()` and runs whatever that returns,
* because a fourth spelling transcribed into a body checker is precisely the
@@ -118,7 +120,7 @@
* That specimen settles two things at once: the colon binds (GitHub parsed
* `fixed: #10240` as a closing declaration), and the surrounding prose does not
* (the sentence said the issue was NOT fixed and it was closed anyway, which is
- * the property partof-closing-keyword-guard.yml is built on).
+ * the property the Part-of closing-keyword guard is built on).
*
* ## Deliberately NOT asserted
*
@@ -180,8 +182,8 @@ const PARSERS = [
},
{
id: 'duplicate-fix-guard',
- file: '.github/workflows/duplicate-fix-guard.yml',
- job: 'duplicate-fix-guard',
+ file: '.github/workflows/pr-body-guards.yml',
+ job: 'pr-body-guards',
kind: 'workflow',
scope: 'same-repo',
},
@@ -555,7 +557,7 @@ const MUTATIONS = [
{
id: 'X2',
what: 'the duplicate-fix guard drops the optional colon',
- file: '.github/workflows/duplicate-fix-guard.yml',
+ file: '.github/workflows/pr-body-guards.yml',
from: '}):?\\\\s+(?:',
to: '})\\\\s+(?:',
expect: 'duplicate-fix-guard',
diff --git a/scripts/check-closing-target-claim.mjs b/scripts/check-closing-target-claim.mjs
index 5aedd0a6750..42291636426 100644
--- a/scripts/check-closing-target-claim.mjs
+++ b/scripts/check-closing-target-claim.mjs
@@ -403,14 +403,16 @@ function batteryFloorFailures() {
const ROOT = new URL('..', import.meta.url).pathname;
/** The wiring that gives this gate a pull request to judge. */
-const WIRING_WORKFLOW = '.github/workflows/closing-target-claim-guard.yml';
+const WIRING_WORKFLOW = '.github/workflows/pr-body-guards.yml';
/**
- * ⚠️ THIS LITERAL IS THE CHECK-RUN NAME branch protection would pin, and it is
- * duplicated in the workflow's `name:` — deliberately, and pinned in both
- * directions by the self-test below. Renaming a job silently detaches a
- * required context, and this is the cheap half of the two-step that makes that
- * impossible to do by accident.
+ * ⚠️ THIS LITERAL IS THE GATE STEP'S `name:` in the wiring workflow — the name
+ * its job carried as a check run until the four PR-body guards became the four
+ * steps of one job (#26071) — duplicated there deliberately, and pinned in both
+ * directions by the self-test below. The check-run name branch protection
+ * would pin is now that one job's, shared by all four guards; renaming either
+ * silently detaches a required context, and this is the cheap half of the
+ * two-step that makes that impossible to do by accident.
*/
export const CHECK_CONTEXT_NAME = 'The card this PR closes must claim this branch';
@@ -1245,14 +1247,17 @@ async function selfTest() {
const wiring = existsSync(wiringPath) ? readFileSync(wiringPath, 'utf8') : '';
t('the wiring workflow exists', wiring !== '', true);
t('the wiring workflow runs this script', wiring.includes('node scripts/check-closing-target-claim.mjs'), true);
- t('the wiring declares the check-run name this file pins', wiring.includes(CHECK_CONTEXT_NAME), true);
+ // Comment-stripped, deliberately: the one-job workflow's header QUOTES every
+ // step name in prose, so a raw-text read would stay green with the step
+ // renamed — a phantom pin. Only an executable `name:` line satisfies this.
+ const wiringCommands = wiring.split('\n').filter((line) => !/^\s*#/.test(line)).join('\n');
+ t('the wiring names the gate step as this file pins it (the retired check-run name), on an executable line', wiringCommands.includes(CHECK_CONTEXT_NAME), true);
t('the wiring subscribes to body edits, so dropping the keyword can go green', /types:\s*\[[^\]]*\bedited\b[^\]]*\]/.test(wiring), true);
t('the wiring subscribes to `synchronize`, so a re-pushed branch is re-judged', /types:\s*\[[^\]]*\bsynchronize\b[^\]]*\]/.test(wiring), true);
t('the wiring takes the merge_group leg — the queue claim is wiring, not prose', /^\s{2}merge_group:\s*$/m.test(wiring), true);
t('the wiring passes the head ref, without which nothing can be judged', /PR_HEAD_REF:/.test(wiring), true);
t('the wiring passes the merge-queue head ref', /MERGE_GROUP_HEAD_REF:/.test(wiring), true);
t('everything reaches the script through `env:`, never through shell interpolation', /env:[\s\S]{0,400}?PR_BODY:/.test(wiring), true);
- const wiringCommands = wiring.split('\n').filter((line) => !/^\s*#/.test(line)).join('\n');
t('the guard job invokes no package manager (it needs node and nothing else)', /\b(pnpm|corepack|yarn|npm)\b/.test(wiringCommands), false);
// --- A claim its own session later YIELDED is not the card's claim. The
diff --git a/scripts/check-partof-closing-keyword.mjs b/scripts/check-partof-closing-keyword.mjs
index bd5f8ab12fa..c75978cd43b 100644
--- a/scripts/check-partof-closing-keyword.mjs
+++ b/scripts/check-partof-closing-keyword.mjs
@@ -361,7 +361,7 @@ const ROOT = new URL('..', import.meta.url).pathname;
const PREDICATE_SOURCE = 'scripts/pm/check-half-states.mjs';
/** The wiring that gives this gate a PR to judge. */
-const WIRING_WORKFLOW = '.github/workflows/partof-closing-keyword-guard.yml';
+const WIRING_WORKFLOW = '.github/workflows/pr-body-guards.yml';
export const EXIT_CLEAN = 0;
export const EXIT_CONTRADICTION = 1;
diff --git a/scripts/check-single-claim-paths.mjs b/scripts/check-single-claim-paths.mjs
index 8850395d785..a9f246bef48 100644
--- a/scripts/check-single-claim-paths.mjs
+++ b/scripts/check-single-claim-paths.mjs
@@ -299,7 +299,7 @@ const ROOT = new URL('..', import.meta.url).pathname;
const SELF_PATH = fileURLToPath(import.meta.url);
/** The wiring that gives this gate a PR to judge. */
-const WIRING_WORKFLOW = '.github/workflows/single-claim-path-guard.yml';
+const WIRING_WORKFLOW = '.github/workflows/pr-body-guards.yml';
export const EXIT_CLEAN = 0;
export const EXIT_CONFLICT = 1;
@@ -924,11 +924,17 @@ function selfTest() {
t('the guard job invokes no package manager (it needs node and nothing else)', /\b(pnpm|corepack|yarn|npm)\b/.test(wiringLines), false);
// --- The older gate must still be there, unweakened. This gate ADDS a
- // second question; it does not replace the card-keyed one.
- const siblingPath = join(ROOT, '.github/workflows/duplicate-fix-guard.yml');
+ // second question; it does not replace the card-keyed one. Since #26071 the
+ // card-keyed gate is a step of the SAME workflow, so this reads the same
+ // file as the wiring battery above and looks for that step's name.
+ const siblingPath = join(ROOT, '.github/workflows/pr-body-guards.yml');
const sibling = existsSync(siblingPath) ? readFileSync(siblingPath, 'utf8') : '';
+ // Comment-stripped for the same reason as the permission reads above: that
+ // file's header quotes the step name in prose, and prose must not satisfy
+ // a pin on an executable `name:` line.
+ const siblingLines = sibling.split('\n').filter((line) => !/^\s*#/.test(line)).join('\n');
t('the card-keyed duplicate gate still exists', sibling !== '', true);
- t('...and still asks its own question', sibling.includes('No other open PR may claim the same issue'), true);
+ t('...and still asks its own question, as a step of that file', siblingLines.includes('No other open PR may claim the same issue'), true);
// --- A transport failure is a PREREQUISITE, never the accusation this
// gate reserves for a real earlier claim. The live read is the only part
diff --git a/scripts/pm/dispatch-gates.self-test.mjs b/scripts/pm/dispatch-gates.self-test.mjs
index 53dac95db02..a581f392a93 100644
--- a/scripts/pm/dispatch-gates.self-test.mjs
+++ b/scripts/pm/dispatch-gates.self-test.mjs
@@ -1612,7 +1612,8 @@ export function selfTest({ tier = 'full' } = {}) {
// ── The step's `env:` is the OTHER carrier of a workflow value (#15761) ────
//
- // `partof-closing-keyword-guard.yml` passes the whole input through `env:`
+ // `pr-body-guards.yml` (the Part-of closing-keyword guard's step; formerly
+ // `partof-closing-keyword-guard.yml`) passes the whole input through `env:`
// and leaves the argv bare, so the argv-shaped classifier scored
// `node scripts/check-partof-closing-keyword.mjs` as a command a dev can
// paste — one whose only possible outcome here is the gate's own exit 2
@@ -1648,7 +1649,7 @@ export function selfTest({ tier = 'full' } = {}) {
' NESTED: ${{ github.sha }}',
' run: node scripts/check-nested-env.mjs',
].join('\n');
- const envInvs = extractCheckInvocations(envWf, 'partof-closing-keyword-guard.yml');
+ const envInvs = extractCheckInvocations(envWf, 'pr-body-guards.yml');
const envOf = (name) => (envInvs.find((i) => i.script === name)?.envVariables ?? null);
// (a) The specimen: the step's `env:` names ride the invocation, in order.
t(
@@ -13761,7 +13762,7 @@ export function selfTest({ tier = 'full' } = {}) {
// a runnable command, and running it here exits 2 with the gate's own
// "NOT WIRED" refusal.
{
- const envLive = runCli(['--commands', '.github/workflows/partof-closing-keyword-guard.yml']);
+ const envLive = runCli(['--commands', '.github/workflows/pr-body-guards.yml']);
const envLiveOut = (envLive.stdout ?? '').split('\n');
const envLiveErr = envLive.stderr ?? '';
t(