Repository navigation
Description
Hit head-on while writing the POST /organization/add-member docs for #10050. Reporting rather than acting: the only mechanical remedy is a baseline expansion, which check-role-word.mjs marks ⛔ MAINTAINER-ONLY.
The mechanism
scripts/check-role-word.mjs is a per-file ratchet over content/docs and skills:
WORD = /\brole(?:s)?\b/gi, counted over the whole file — fenced code blocks included.- a file not in
scripts/role-word-baseline.jsoncontaining the word → fail; - a baselined file whose count increases → fail (
New occurrences are banned (ADR-0090 D3)), and the remedy message marks--updateas maintainer-only; - a baselined file whose count decreases → also fail, but that one is the author's own ratchet-down.
There is no exemption mechanism — no SKIP_PATHS, no per-kind allowlist, no fence skipping.
The consequence
Because the ratchet is per file and exact, no file anywhere under content/docs has spare budget. So the corpus cannot gain a single new occurrence of the word — which means a route whose wire parameter is literally named role cannot have its request shape documented, in any file, by any author who is not the maintainer.
POST /api/v1/auth/organization/add-member is exactly that route. Its handler reads body.role (readRole() in packages/plugins/plugin-auth/src/organization-add-member.ts), and unlike userId / organizationId / teamId it has no snake_case alias — the wire name is role and nothing else.
This is not a hypothetical class of one. The same block applies to /organization/update-member-role and /organization/invite-member's tier field, and — a second-order bite — the route path /organization/update-member-role itself matches \brole\b, so that URL cannot be written into the docs either.
What #10050's PR did instead
Documented the field indirectly — "the body also carries the membership tier, under better-auth's own column name for it", linking to the existing #membership-tiers-are-a-closed-list section whose invitation example already spends one of the baselined occurrences and shows the literal key.
That keeps content/docs/permissions/authentication.mdx at exactly its baselined 4 and the gate green, and it is not wrong — but a reference page that describes a required parameter without naming it is a real quality cost, and the next author documenting a membership route pays it again.
Why this is a decision, not a bug
The gate is behaving as designed; ADR-0090 D3 deliberately makes the word reserved. The question is whether the design has a hole for the case it already names as legitimate. check-role-word.mjs's own header lists the legitimate KINDS, first among them:
vocabulary owned upstream (better-auth's
sys_member.role)
That is precisely this case — and the ratchet has no way to express "legitimate kind, new occurrence". It can only express "frozen count".
Options
- Expand the baseline for
content/docs/permissions/authentication.mdxby 1–3 and document the field literally. Cheapest; weakens the ratchet by exactly the occurrences granted, and sets a precedent each future membership route re-litigates. - Add an escape for upstream-owned vocabulary — e.g. count only occurrences outside a
<!-- role-word: upstream -->-marked span, or exempt fenced blocks whose info string marks them as a vendor wire payload. Keeps the ratchet's teeth for prose (which is what D3 is actually about) while letting a wire contract be written down. - Leave it. Accept that better-auth-owned membership routes get tier-field-free prose, and pin the workaround so it is at least consistent.
Recommendation: 2, narrowly scoped to fenced code blocks. D3's target is ObjectStack prose reaching for the word where permission_set / position / business_unit is meant; a JSON body showing a third party's literal key is not that, and the gate's own header already says so. 1 is a reasonable stopgap if 2 is not worth the checker complexity now.
Refs
#10050 (where this was measured) · ADR-0090 D3 · scripts/check-role-word.mjs · scripts/role-word-baseline.json
Activity
os-zhuang commentedon Aug 21, 2026
分诊落卡(
needs-user-decision,type Task,domain:devx—— 落点scripts/check-role-word.mjs+scripts/role-word-baseline.json)。棘轮豁免/baseline 扩容两条路都动门禁,门禁削弱属人工地板,交维护者。以下按业务视角写。<!-- os-decision-facets -->
一句话问题
better-auth 的会员接口线上参数就叫
role(无别名),而 ADR-0090 D3 的逐文件棘轮没有任何豁免通道 —— 结果是任何人(除维护者)永远写不出这些接口的必填参数名文档,#10050 已实测付过一次代价(参考页只能绕着写「按 better-auth 自己的列名传会员档位」)。前提(re-check)
node scripts/check-role-word.mjs—— 确认仍无 SKIP/豁免机制、--update仍标 MAINTAINER-ONLY。grep -n "update-member-role\|invite-member" content/docs -r—— 确认同类路由文档仍缺位(不止 add-member 一例)。crud-members.mjs仍读ctx.body.role(vendor bump 后重验)。选项 × 真实代价
authentication.mdx放行 +1~3 次,照实写字段名check-role-word.mjs只对 fenced code block 中标记为 vendor 线上载荷的块不计数,散文照禁四轴(业务立场)
POST /organization/add-memberis undocumented — and it is the ONLY multi-org path to attach an existing user #10050 实测。推荐 B(豁免仅限 fenced code block 的 vendor 线上载荷,带自测与正控),回退 A(若认为 checker 复杂度不值得,先给 authentication.mdx 一次性放行,C 姿态保留到下个路由再议)。
置信缺口:本分析看不见 ADR-0090 D3 裁决当时是否有意把代码块也纳入禁令(若当时就要求连示例代码也不得出现该词,则 C 才是忠于裁决的答案);也看不见未来会员路由的文档增量(若仅此一批,A 的一次性扩容其实更便宜)。
裁后执行(你不用管)
回一个字母即可。B → 立卡入
domain:devx队列改 checker(豁免窄面+自测),落地后 #10050 文档补写字面参数名;A → 维护者跑--update或逐字引用授权的 baseline 扩容 PR,文档同批补全;C → 本卡关 not planned,间接措辞 pin 成一致惯例。Generated by Claude Code
os-zhuang commentedon Aug 21, 2026
Maintainer ruling (2026-08-21, live PM chat — recorded by the triage seat)
Verbatim (untranslated): 「其他接受」 — accepting the triage recommendation for this card: 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.
State:
needs-user-decision→pm:queue(domain:devx), dispatchable.Dispatch notes: this is a maintainer-authorized gate-semantics change — quote this ruling comment in the PR body. Required shape per house style: self-test covering the new exemption (fenced vendor block passes, prose occurrence still fails, unmarked fence still fails) + positive control. Follow-up owed after merge: #10050's docs gain the literal
roleparameter name (and theupdate-member-role/invite-memberclass becomes documentable) — the accepting seat files or rides that docs card.Generated by Claude Code
claude commentedon Aug 21, 2026
Claimed by the
domain:devxPM seat.c970724d-303c-5614-9d20-a3f92205cfadclaude/issue-10533-role-word-vendor-fenceDispatching on the maintainer ruling of 2026-08-21T03:52:39Z (option B), which already moved this card to
pm:queue/ dispatchable. Not re-litigating the choice — the agent implements B and quotes that ruling in the PR body.The ruling's required shape is carried into the brief verbatim: self-test covering all three directions (fenced vendor block passes · a prose occurrence still fails · an unmarked fence still fails), plus a positive control. The unmarked-fence leg is the one that matters most — without it the exemption widens to every code block in the corpus, which is not what was accepted.
docs/adr/**is governed and the agent must not edit ADR-0090. If implementing B turns out to require the ADR's wording to change, that is a stop-and-report, not an edit.Recorded so it is not lost: the ruling names a follow-up owed after merge — #10050's docs gain the literal
roleparameter name, and theupdate-member-role/invite-memberclass becomes documentable. That is a separate docs card and this seat will file it once this lands; it is not in this PR's scope.If you hold an earlier claim on this card, say so and I will stand down — all agents share one GitHub identity, so the assignee field alone cannot tell us apart.
Generated by Claude Code
fix(gate): let check:role-word exempt marked vendor-wire fences
claude commentedon Aug 21, 2026
{ "issue": 10533, "status": "done", "branch": "claude/issue-10533-role-word-vendor-fence", "pr": "https://github.com/objectstack-ai/objectstack/pull/10787", "premise_still_valid": true, "summary": "Implemented the maintainer's option B verbatim: scripts/check-role-word.mjs now exempts occurrences inside fenced code blocks MARKED as vendor wire payloads, and nothing else. The marker follows the repo's existing os:check idiom, keyed by extension because MDX has no HTML comments: `{/* os:vendor-wire better-auth */}` in .mdx, `<!-- os:vendor-wire better-auth -->` in .md, on the line directly above the opening fence, naming a vendor declared in VENDOR_BOUNDARIES (today only better-auth, so a second boundary is a reviewed source edit rather than an author typing a new word). The exemption keys on the MARKING, never on being a fence: a marker that opts nothing in is a loud orphan rather than a silent no-op, an unclosed claimed fence exempts nothing, backtick run-length is tracked so a ```` block that wraps ``` examples is scoped correctly, a marker shown INSIDE a fence is example text (so the convention stays documentable in the roots it governs), and every run publishes how many blocks it suppressed so the exemption cannot widen unobserved. Both refusal messages now name the marker in the spelling for the file they name -- the count-GREW message previously named no author-takeable remedy at all, which is how this defect was actually met. ADR-0090 NEEDED NO CHANGE and was not edited: D3's own Word ban paragraph already names 'the better-auth boundary' as its single documented exception, so B clarifies what D3 always targeted rather than amending it -- the stop-and-report fence was not triggered. scripts/role-word-baseline.json is untouched (option A not taken). CORPUS DELTA IS 0: no block in today's corpus carries the marker, so 0 blocks and 0 occurrences are exempted, the scan reads the same 225 files and the same 43 baselined files / 129 occurrences before and after, and the only change in the gate's clean-tree output is the new `Exempt:` line. Diff is one file; content/docs and skills are byte-identical to origin/main.", "tests": "Base = origin/main 1793fb66fd (re-derived at my own fetch; the PM's 01c50322a1 had moved). Gate union re-run at FINAL commit 93d559b5ac; every exit code captured before any pipe (`cmd > file 2>&1; EXIT=$?`), and each verdict quoted from the gate's own printed line. GATE UNION (derived by `node scripts/pm/dispatch-gates.mjs` with NO path arguments, EXIT=0, which named 3 families for scripts/check-role-word.mjs): `node scripts/check-role-word.mjs --self-test` EXIT=0; `node scripts/check-role-word.mjs` EXIT=0 -> '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))'; `node scripts/check-cross-package-test-inputs.mjs` EXIT=0 -> 'OK: 13 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.'; `node scripts/check-nul-bytes.mjs` EXIT=0 -> 'OK (scanned 6237 text file(s) ... no raw ASCII control bytes)', plus a direct `grep -naP '[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]'` over the changed file (no hits). ADDED BEYOND THE DERIVED LIST: `node scripts/check-parse-guard.mjs` EXIT=0 -> '(check) check:parse-guard: 125 scripts/ file(s) -- every TypeScript parse goes through ts-parse.mjs.' -- dispatch-gates places it under 'Unreachable BY CONSTRUCTION' (its literal `scripts` is refused as too generic) yet CI runs it over my file every PR, so I ran it deliberately; that gap is filed as #10790. NO BUILD/DIST IS INVOLVED: the gate is a plain node script with only node builtins, run directly from source by CI (root package.json wires check:role-word as `node scripts/check-role-word.mjs --self-test && node scripts/check-role-word.mjs`), so the new self-test is reachable in CI rather than a phantom check -- verified by reading that script line, and no fenced root package.json edit was needed. REPRODUCTION ON BASE: adding the field to the http fence that content/docs/permissions/authentication.mdx already has (mutation confirmed on disk by anchor count: anchor 1 before, injected text present after, word count 4 -> 5) gave EXIT=1 -> 'content/docs/permissions/authentication.mdx: role-word count grew 4 -> 5. New occurrences are banned (ADR-0090 D3).' Reverted; file diffed byte-identical against a pre-mutation copy. AFTER, all three directions on that same real file, each reverted (this PR changes no docs; final `git status --porcelain` showed only scripts/check-role-word.mjs, and content/docs diffed byte-identical to origin/main): (1) the SAME fenced addition, MARKED -> EXIT=0 with 'Exempt: 1 os:vendor-wire block(s) suppressed 1 occurrence(s)'; (2) the same fenced addition UNMARKED -> EXIT=1, count grew 4 -> 5; (3) a PROSE addition -> EXIT=1, count grew 4 -> 5 and NOT 4 -> 6, i.e. the marked block in the same file stayed exempt while prose did not -- the exemption suppresses a block, never a file. ABLATION, RUN IN BOTH DIRECTIONS because an exemption can rot two ways, predictions stated before running. No rebuild step exists to state (source is executed directly, no dist), so the landing evidence is on-disk anchor counts and NOT any editor exit code: for each mutation I grep-counted the injected token AND the replaced text. (A) exemption never matches (`vendorWireClaim` -> `return null`): injected 'ABLATION-1' count 1 on disk; PREDICTED 10 self-test failures, OBSERVED 10, and the composition matched exactly -- (B1), (B2), the self-report leg, the .md opt-in leg, the nested-backtick leg, the unclosed-fence leg, both (B1, program) legs, (B2, program) and (B3, program). Note (B3) pure stays GREEN under this mutation ON PURPOSE, because it asserts the ABSENCE of exemption -- which is exactly why the second ablation exists. (B) exemption keys on 'is a fence', ignoring the marking (the marking-check line replaced by an unconditional vendor): injected 'ABLATION-2' count 1 and the removed `vendorWireClaim(lines[i - 1], ext)` call count 0 on disk; PREDICTED 12 failures, OBSERVED 12, composition matched exactly and LED BY '#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). RESTORE LEG for both: `git checkout scripts/check-role-word.mjs` from the commit, then verified ablation markers count 0, the marking-check line count 1, `git status --porcelain` empty (tree byte-identical to the committed fix), and self-test + gate both EXIT=0 again. SELF-TEST SHAPE (the ruling's required set, all in-process AND driven through a real child process against built trees so a predicate the program never consulted could not pass): (B4) positive control FIRST -- the fixtures are proven to carry the reserved word, without which (B1) would pass by saying nothing at all; (B1) a fenced vendor-wire block passes; (B2) a prose occurrence in the very same file still fails; (B3) an unmarked fence still fails. Plus every near-miss marker (blank line between marker and fence, the other format's spelling, an undeclared vendor, the bare token, a marker above prose) opting nothing in AND being reported as an orphan; backtick run-length; unclosed claimed fences; a marker shown inside a fence; and both refusal messages naming the marker in the spelling for the file they name. CI AT REPORT TIME (single snapshot, not polled -- convergence is the PM's per the 2026-08-10 ruling): 35 check runs on 93d559b5ac, 21 success / 9 skipped / 5 in_progress, none failing.", "open_questions": [], "out_of_scope_findings": [ "filed as #10790: dispatch-gates never names check:parse-guard for a scripts/ card -- its literal `scripts` is refused as too generic, so a gate that reads 125 scripts/ files is invisible to every scripts/ brief while CI runs it anyway; remedy is the ROOT_DIR_WATCH_HINTS subtree-declaration pattern check-role-word.mjs already uses for skills/**. Related to but distinct from #10784 (different gate, different mechanism); cross-linked for merging if triage prefers one card. domain:devx + finding, unassigned, no pm:queue.", "filed as #10791: check:skill-examples flags an `os:check` marker shown INSIDE a fenced example as an orphan (its orphan scan at check-skill-examples.ts:249-253 has no fence awareness), so that convention cannot be documented in the two roots it governs. Latent today -- all 192 markers sit at top level -- and source-derived rather than executed, flagged as needing a confirming run. #10533's gate solves the same case for its own marker and the shape can be lifted. domain:devx + finding, unassigned, no pm:queue." ] }Generated by Claude Code
fix(gate): let check:role-word exempt marked vendor-wire fences (obje…
fix(spec): make check:skill-examples's orphan scan fence-aware (objec…