Skip to content

check:role-word's per-file ratchet makes it impossible to document ANY new route whose body carries better-auth's role field — measured while writing #10050 #10533

Description

@os-zhuang

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.json containing the word → fail;
  • a baselined file whose count increases → fail (New occurrences are banned (ADR-0090 D3)), and the remedy message marks --update as 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

  1. Expand the baseline for content/docs/permissions/authentication.mdx by 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.
  2. 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.
  3. 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

  1. added
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    domain:devxDomain lane — seat card indexed by label:pm:seat
    on Aug 21, 2026
  2. added theissue type on Aug 21, 2026
  3. os-zhuang commented on Aug 21, 2026

    @os-zhuang
    ContributorAuthor

    分诊落卡(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 一例)。
    • better-auth 1.7.1 crud-members.mjs 仍读 ctx.body.role(vendor bump 后重验)。

    选项 × 真实代价

    选项 做什么 客户可感知代价
    A 扩 baseline 维护者给 authentication.mdx 放行 +1~3 次,照实写字段名 文档立即写全;但每来一个新会员路由都要维护者再议价一次,棘轮按次失血
    B 棘轮开窄豁免 check-role-word.mjs 只对 fenced code block 中标记为 vendor 线上载荷的块不计数,散文照禁 一次改动关闭整类;门禁复杂度小增;需自测+正控防豁免被误用到散文
    C 维持现状 间接措辞 workaround 固化成文档惯例 参考页永远写不出必填参数的字面名 —— 客户/AI 照文档拼不出正确请求体,每个新会员路由重付一次

    四轴(业务立场)

    • 实际业务拉动:真实且已发生 —— 文档写不出必填参数名,用户照文档发不出正确请求;不是假设成本,docs: the newly mounted POST /organization/add-member is undocumented — and it is the ONLY multi-org path to attach an existing user #10050 实测。
    • 长远合理性:B 把「D3 反对的是 ObjectStack 散文借用 role,不反对第三方线上契约」写进机器,一次到位;A 每路由重新议价;C 永久扣发布文档质量。
    • 防 AI 犯错:棘轮的存在就是防 AI 扩散 role 词 —— B 的豁免面收窄到 fenced vendor 块,散文半个口子不开;A 的先例反而会被未来 AI 引用来要求再扩。
    • 创业不扩散:B 是小改动、关闭整类;C 是零成本但把质量债固化;A 最省今天、最贵长期。

    推荐 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

  4. added
    pm:queueReady for the PM dispatch loop
    and removed
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    on Aug 21, 2026
  5. os-zhuang commented on Aug 21, 2026

    @os-zhuang
    ContributorAuthor

    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 role parameter name (and the update-member-role / invite-member class becomes documentable) — the accepting seat files or rides that docs card.


    Generated by Claude Code

  6. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    toolingFix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface
    and removed
    pm:queueReady for the PM dispatch loop
    on Aug 21, 2026
  7. claude commented on Aug 21, 2026

    @claude
    Contributor

    Claimed by the domain:devx PM seat.

    • session c970724d-303c-5614-9d20-a3f92205cfad
    • branch claude/issue-10533-role-word-vendor-fence

    Dispatching 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.

    ⚠️ Also carried into the brief: 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 role parameter name, and the update-member-role / invite-member class 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

  8. claude commented on Aug 21, 2026

    @claude
    Contributor
    {
      "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

  9. removed
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    on Aug 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

domain:devxDomain lane — seat card indexed by label:pm:seattoolingFix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions