Skip to content

fix(gate): let check:role-word exempt marked vendor-wire fences - #10787

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-10533-role-word-vendor-fence
Aug 21, 2026
Merged

fix(gate): let check:role-word exempt marked vendor-wire fences#10787
os-zhuang merged 1 commit into
mainfrom
claude/issue-10533-role-word-vendor-fence

Conversation

@claude

@claudeclaudeBot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Fixes#10533

The defect

scripts/check-role-word.mjs is a per-file, exact-count ratchet: a baselined file whose
count moves in either direction fails, and --update is ⛔ MAINTAINER-ONLY. Because it is
per-file and exact, no file anywhere has spare budget — so the corpus cannot gain a single
new occurrence of the reserved word, and a route whose wire parameter is literally named role
could not have its request shape documented by anyone but the maintainer.

Measured, not hypothetical. POST /api/v1/auth/organization/add-member reads body.role
(readRole() in packages/plugins/plugin-auth/src/organization-add-member.ts) and — unlike
userId / organizationId / teamId — carries no snake_case alias, so the wire name is
that word and nothing else. #10050 had to describe a required parameter without naming it.

Reproduced on the base commit (1793fb66fd), by adding the field to the http fence that page
already has:

content/docs/permissions/authentication.mdx: role-word count grew 4 → 5.
New occurrences are banned (ADR-0090 D3). EXIT=1

The ruling this implements

Maintainer ruling, 2026-08-21T03:52:39Z (issue comment
5364977770),
verbatim and untranslated: 「其他接受」 — accepting option B:

the ratchet gains a narrowly-scoped exemption for upstream-owned vocabulary appearing inside
fenced code blocks marked as vendor wire payloads; prose remains fully ratcheted (ADR-0090
D3's actual target). Option A (baseline expansion) stands only as the recorded fallback if
implementation shows the checker complexity is not worth it; option C rejected.

scripts/role-word-baseline.json is untouched — option A's path is not taken here.

ADR-0090 is not amended, and needed no change

D3's own Word ban paragraph already carves out this exact boundary in its own words:

Word ban. "role" is a reserved-forbidden word in identifiers, UI copy, and documentation,
enforced by lint. Single documented exception: the better-auth boundary — sys_member.role is
third-party schema we do not own; it remains…

So B clarifies what D3 always targeted — ObjectStack prose reaching for the word where
permission_set / position / business_unit is meant. The gate simply had no way to express
"legitimate kind, new occurrence"; it could only express "frozen count". docs/adr/** is
untouched.

What bounds the exemption: the MARKING, not the fence

Keying on "is a fenced block" would exempt every code block in the corpus — materially broader
than what was accepted. A block is exempt only when the line directly above its opening fence
is exactly the marker, naming a vendor declared in VENDOR_BOUNDARIES (today: better-auth):

file kindmarker
.mdx{/* os:vendor-wire better-auth */}
.md<!-- os:vendor-wire better-auth -->

This is the repo's existing os:check convention, per-extension for the same reason
(packages/spec/scripts/check-skill-examples.ts is the reference: MDX has no HTML comments,
and the MDX form renders as literal text in .md). The marker is explicit, greppable
(git grep os:vendor-wire) and impossible to apply by accident — it opts nothing in unless
all three of syntax-for-this-extension, the exact token, and a declared vendor hold.

Four properties keep it from widening on its own:

  • A marker that opts nothing in is a loud orphan, never a silent no-op — a placed-but-inert
    marker reads as intentional while its block is still fully counted.
  • An unclosed claimed fence exempts nothing. An unclosed fence runs to end of document
    (CommonMark), so honouring one would hand a single line the whole rest of the file.
  • A second vendor is a source edit under review, not an author typing a new word into a
    docs page. D3 documents exactly one boundary.
  • Every run publishes what it suppressed, so the blast radius is printed rather than
    rediscovered:
check-role-word: OK, no new occurrences of the reserved word.
Scanned: 225 .md/.mdx file(s) read across 2 root(s) — content/docs 189, skills 36.
Exempt: 0 os:vendor-wire block(s) suppressed 0 occurrence(s).
Ledger: 43 baselined file(s) still carrying it (129 occurrence(s)) in scripts/role-word-baseline.json.

Deliberately NOT extended to inline code spans. A backticked word in a sentence is prose with
backticks around it; inline code is everywhere and exempting it would leave the ratchet nothing.
A prose mention still costs a baselined occurrence — including the route path
/organization/update-member-role written into a sentence, which the card names as a
second-order bite. The remedy there is the same one every author has: put the wire shape in a
marked fence.

Corpus delta: 0

0 blocks exempted, 0 occurrences suppressed. Nothing in content/docs or skills is
touched by this PR, so no existing count moves: the scan reads the same 225 files and the
same 43 baselined files / 129 occurrences before and after. The only difference in the
gate's output on a clean tree is the new Exempt: line.

Evidence

Gate union re-run at final HEAD 93d559b5ac, exit codes captured before any pipe.

runverdict
node scripts/check-role-word.mjs --self-testEXIT=0
node scripts/check-role-word.mjsEXIT=0OK, no new occurrences
node scripts/check-cross-package-test-inputs.mjsEXIT=0OK: 13 package(s) read outside themselves, all declared
node scripts/check-parse-guard.mjsEXIT=0✓ check:parse-guard: 125 scripts/ file(s)
node scripts/check-nul-bytes.mjsEXIT=0no raw ASCII control bytes
node scripts/pm/dispatch-gates.mjsEXIT=0 — derived from the real changeset

CI runs check:role-word as --self-test && <gate> (root package.json), so the new self-test
is reachable in CI rather than a phantom check.

Before / after, on the real docs file

Same fenced addition to content/docs/permissions/authentication.mdx, all three directions
(each reverted afterwards — this PR changes no docs):

changebase 1793fb66fdthis branch
add the field to the http fence, markedEXIT=1 count grew 4 → 5EXIT=0, Exempt: 1 … suppressed 1
add the field to the same fence, unmarkedEXIT=1EXIT=1 count grew 4 → 5
add the word to prose in that fileEXIT=1EXIT=1 count grew 4 → 5, not 6 — the marked block in the same file stayed exempt

Ablation — both directions, predicted vs observed

An exemption can rot two ways, so it was mutated both ways. Each mutation was confirmed on disk
by anchor count (injected token present, replaced text absent), not by an editor's exit code;
each was restored with git checkout from the commit and the tree verified byte-identical
(git status --porcelain empty, ablation markers 0).

mutationpredictedobserved
exemption never matches (vendorWireClaimnull)10 failures10
exemption keys on "is a fence", ignoring the marking12 failures12

Composition matched the prediction exactly in both runs. The first fells (B1), (B2) and both
(B1, program) legs; (B3) stays green there on purpose — it asserts the absence of
exemption, so removing the exemption cannot break it. That is exactly why the second ablation
exists, and it is led by:

x self-test: #10533 (B3) — an UNMARKED fence still FAILS (the exemption keys on the MARKING,
never on being a code block)

plus the five near-miss markers, three orphan legs, the cross-spelling leg, the
marker-inside-a-fence leg, and (B3, program).

Self-test shape (the ruling's required set)

All three directions over a fixture proven to carry the word — that is the positive control,
and it comes first because every other leg is vacuous without it (a fixture that had lost the
word would make (B1) pass by saying nothing at all):

  • (B4) positive control — the fixtures really do contain the reserved word.
  • (B1) a fenced vendor-wire block passes.
  • (B2) a prose occurrence still fails, in the very same file whose fenced block is
    exempt — the exemption suppresses a block, never a file.
  • (B3) an unmarked fence still fails.

Then, for each way the marking could stop bounding it: a blank line between marker and fence,
the other format's spelling, an undeclared vendor, the bare token, a marker above prose — each
opts nothing in and is reported as an orphan. Plus backtick run-length (a ```` fence wrapping

be documentable in the roots it governs), and both refusal messages naming the marker in the
spelling for the file they name.
Every one of those also runs **through a real child process** against built trees, so a
predicate the program never consulted could not pass the suite — the discipline the #9932 legs
in this file already established.
## Scope
Gate and its self-test only — one file. Writing the literal parameter name into #10050's docs is
the **follow-up owed after merge** that the ruling records, and the accepting seat files it;
widening this diff into `content/docs` would also make the gate change unreviewable against its
own card. No changeset: gate tooling publishes nothing (`skip-changeset`).
⛔ Not enabled for auto-merge and not marked ready — the dispatching seat lands this.
_Generated by [Claude Code](https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt)_
---
_Generated by [Claude Code](https://claude.ai/code)_

ADR-0090 D3's ratchet is per-file and EXACT, so no file anywhere has spare
budget and the corpus cannot gain a single new occurrence of the reserved
word. A route whose wire parameter is literally named `role` therefore could
not have its request shape documented by anyone but the maintainer:
`POST /api/v1/auth/organization/add-member` reads `body.role` with no
snake_case alias, and #10050 had to describe a REQUIRED parameter without
naming it.
Per the maintainer ruling of 2026-08-21 (option B), the gate gains a
narrowly-scoped exemption for upstream-owned vocabulary inside fenced code
blocks MARKED as vendor wire payloads. Prose stays fully ratcheted — that is
what D3 is aimed at, and D3's Word ban paragraph already names the better-auth
boundary as its single documented exception, so this clarifies D3 rather than
amending it. The ADR is untouched.
The marking is what bounds the exemption, not the fence. A block is exempt only
when the line directly above its opening fence is exactly
`{/* os:vendor-wire <vendor> */}` (.mdx) or `<!-- os:vendor-wire <vendor> -->`
(.md), naming a vendor declared in VENDOR_BOUNDARIES — the same per-extension
comment convention `os:check` already uses. A marker that opts nothing in is a
loud orphan rather than a silent no-op, an unclosed claimed fence exempts
nothing, and every run publishes how many blocks it suppressed, so the
exemption cannot widen unobserved. Today's corpus: 0 blocks, 0 occurrences.
Self-test covers all three directions over a fixture proven to carry the word
(marked fence passes, prose still fails, UNMARKED fence still fails), plus the
positive control and every near-miss marker, driven both as predicates and
through a real child process.
Refs #10533
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt
@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 21, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 21, 2026 11:40
@os-zhuang
os-zhuang enabled auto-merge August 21, 2026 11:40
@os-zhuang
os-zhuang added this pull request to the merge queueAug 21, 2026
@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 32478655502 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Build Docs — 失败步骤: Verify pnpm version(日志不可读,点进 job 看)

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

跨 PR 相同签名(24h,按失败测试文件聚合):

  • ⚠️本次没有可用的聚合签名(日志里没有能解析出测试文件名的 FAIL 行)—— 这不是「没有同签名的其他 PR」,是这一轮没测到。跨 PR 聚合本次不可用,请手工比对其他 PR 的同类评论。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 5 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@claude

claudeBot commented Aug 21, 2026

Copy link
Copy Markdown
ContributorAuthor

Queue-build triage: environmental, not this PR. No fix will be pushed; one re-queue when I can.

Following the triage checklist on the automated comment above. Item 2 — failing job unrelated to this PR — with the evidence.

The failed job is Build Docs, and the automated extract could not read its step. I fetched the full log (209 lines). It fails in corepack, during install, before any test body runs:

Error: Error when performing the request to
https://registry.npmjs.org/pnpm/-/pnpm-10.31.0.tgz;
for troubleshooting help, see https://github.com/nodejs/corepack#troubleshooting
[cause]: TypeError: fetch failed
[cause]: Error: Client network socket disconnected before secure TLS connection was established
##[error]Process completed with exit code 1.

A TLS socket dropping while fetching the pnpm tarball from the npm registry. This PR changes exactly one file — scripts/check-role-word.mjs — and there is no path by which a role-word recognizer alters whether corepack can complete a TLS handshake with registry.npmjs.org.

Why a re-queue is the right response here, and not the reflex it usually is

The triage comment is right that blind re-queuing burns a full rebuild for every PR behind this one, and I am not doing it on a hunch. The standing rule this seat works to allows a re-run in exactly one shape: the job died before any test body executed — checkout, install, or runner loss. This is the install case, named explicitly, and the log shows no test ever started.

The two harder branches do not apply:

  • Not a real regression — the diff cannot reach the failing step.
  • Not a known flaky signature — and I will not claim otherwise. The automated comment says plainly that no aggregated signature was available this round because no FAIL line carried a parseable test file name, and that the 24h ledger was not read to the end, so its "5 other failing builds" is a lower bound. That is a bounded read, not a clean one, and I am not treating it as evidence either way.

Current state and what happens next

added_to_merge_queue at 11:45:10Z, and no removed_from_merge_queue event has arrived — but auto_merge now reads False, which is the same shape that preceded a silently lapsed arming on #10786 earlier today. So the honest statement is: queue membership is uncertain, not it is still queued.

⛔ I cannot re-arm right now. GraphQL is exhausted (0/5000, reset 12:16:43Z) and both the draft flip and auto-merge go through it; REST core is fine at ~14.9k. This is a stated blocker, not a silent wait. After the reset I will re-check membership and re-arm once. If it fails the same way a second time, that is no longer environmental-by-assumption and I will treat it as real.

⛔ Nothing about this changes the PR's content: it implements the maintainer's option-B ruling of 2026-08-21T03:52:39Z, corpus delta is 0, and both ablation directions matched their predictions exactly (10/10 and 12/12).


Generated by Claude Code

Merged via the queue into main with commit 4fef302Aug 21, 2026
38 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-10533-role-word-vendor-fence branch August 21, 2026 12:01
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/lskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants

@os-zhuang@claude