Repository navigation
Four more client sites still read the retired user.roles — role-addressed approvals and requiredRoles gates degrade silently at protocol 17 #5424
Description
Found while implementing objectui#5389 (which fixes the fifth site, useIsWorkspaceAdmin). #5389 scoped itself to that one hook, so these four are filed separately rather than folded in — three of them are authorization/routing-adjacent and at least one needs a semantics decision, not a mechanical rename.
Claim
user.roles is not emitted by the protocol-17 session face. Framework ADR-0090 D3 renamed it to positions, with no deprecation window. Five objectui sites still read it; #5389 fixes one; these are the other four.
Evidence — measured, not inferred
Booted a real single-tenant server on published @objectstack/cli / @objectstack/plugin-auth 17.1.0 (objectstack dev --seed-admin --fresh, Tenancy: single) and read GET /api/v1/auth/get-session for a permission-set-derived platform admin:
{
"role": "user",
"positions": ["user", "platform_admin"],
"isPlatformAdmin": true
}Object.prototype.hasOwnProperty.call(user, 'roles') on that payload answers false. The published bundle agrees at the source: @objectstack/plugin-auth@17.1.0's customSession returns exactly
user: { ...user, positions, isPlatformAdmin: platformAdmin, ...authGate ? { authGate } : {} }
and no roles key appears anywhere in its emission path.
The four sites
-
packages/auth/src/AuthGuard.tsx:58const userRoles = user.roles ?? (user.role ? [user.role] : []);
user.rolesis always absent now, so everyrequiredRolesgate falls through to the single stored scalar. On the deployment shape above that scalar is'user', sorequiredRoles={['platform_admin']}(or any business position name) denies a user who genuinely holds it. -
packages/app-shell/src/hooks/sharedUserFeeds.ts:490—approverIdentities()buildsrole:+ name identities fromu?.roles ?? []only. At protocol 17 that array is always empty, so no role-addressed identity is ever sent: approvals addressed to a position rather than to a person vanish from the bell badge, the Approvals tab and Home's To-do card. This is the site the framework's owncustomSessiondocblock names — "Consumers that match individual role names (e.g. the Console approvals inbox resolving role-addressed approvers) also readpositions— business names such asmanagersurvive only there." Arguably as severe asuseIsWorkspaceAdminstill readsuser.roles— a key the v17 session payload no longer emits — so platform-admin detection (Setup/Studio visibility) silently fails wherever there is no active-member row #5389 and equally silent. -
apps/console/src/services/approvalsApi.ts:375— the same approver-identity derivation, but it also splitsuser.role, so it degrades rather than dying: it yieldsrole:userand loses every business position name. Its own comment still describes the two shapes as "multi-role shape (some auth providers)" vs better-auth's scalar, which is no longer the distinction that matters. -
packages/app-shell/src/console/AppContent.tsx:860— forwardsroles: (user as any).rolesintoexpressionUser. Lowest severity of the four: the same object already forwardsisPlatformAdminandpositionscorrectly, so this key is vestigial rather than load-bearing. It should go with the rest so the shape stops advertising a field that is alwaysundefined.
The type that hid all five
packages/auth/src/types.ts declares roles?: string[] on the client AuthUser — a hand-copied mirror key with an ?, so nothing ever failed to compile. #5389 tried to retire it in the same pass and could not: removing it breaks site 1 at compile time (measured — src/AuthGuard.tsx(59,60): error TS2339: Property 'includes' does not exist on type '{}', because AuthUser's index signature then types the read as unknown). So the key is not dead; it has one live reader, and retiring it is gated on deciding site 1. #5389 left the declaration in place with a corrected docblock naming this issue.
What is NOT claimed
- No runtime reproduction of sites 1-4 specifically. The session payload above is measured and re-runnable; what each consumer then does with the empty array is read from source, not observed in a browser.
- Site 1 is not a mechanical rename.
requiredRolesis a consumer-supplied list with its own semantics: whether it should readpositions, whether it keeps theuser.rolefallback, and how it relates topackages/permissions' separaterolessurface (fed by/api/v1/me, a different endpoint that is out of this scope) are open questions. Whoever takes this should settle them rather than guess. packages/permissionshas its own unrelatedroles: string[]on its own user shape. Not in scope here.
Refs
- objectui#5389 (the fifth site; carries the same measurement)
- objectstack-ai/cloud#1442 (the sibling actor-face defect, five service-ai read sites)
- framework ADR-0090 D3, framework
toSortItems— a published export — still tolerantly reads the retireddirectionsort spelling (s.order || s.direction), dormant because nothing in-tree calls it #6011 / PR chore(providers): drop the unused @objectstack/spec dependency #6048
Activity
docs(auth,app-shell): retire the stale `roles` wording the rename lef…
fix(auth): useIsWorkspaceAdmin reads the session's published `positio…
os-zhuang commentedon Aug 21, 2026
Triage:
pm:queue, type Bug,domain:ui. Not a fresh product decision: framework ADR-0090 D3 renamedroles→positionswith no deprecation window, and the session face measurably no longer emitsroles— consumers following the rename is the restoration of an existing ruling, so this dispatches. Severity anchor is site 2 (sharedUserFeeds.approverIdentities): role-addressed approvals silently vanish from the bell/Approvals/To-do — the framework's own docblock names this consumer. Site 1 (AuthGuard.requiredRoles) readspositionsper the ADR; whether the scalaruser.rolefallback stays and how it relates topackages/permissions' separate surface are the dev's to settle in the report (stop-and-report if it turns genuinely contractual). Retiring theroles?: string[]mirror key inpackages/auth/src/types.tsbelongs in this card once site 1 is fixed (per #5389's note).packages/permissionsstays out of scope as filed.Generated by Claude Code
fix(approvals): derive approver identities from `positions`, not reti…
test(approvals): make the person-identity cases genuine controls
test(approvals): record measured-vs-predicted ablation outcomes
claude commentedon Aug 21, 2026
PM · 验收(PR #5555 已开,待 CI)+ 两个待决问题的裁定 + 一条必须记下的生产者发现
交付
实现完成于断网期间,已推送:PR #5555(draft,
Refs #5424,非Fixes——站点 1 待裁)。分支claude/issue-5424-user-roles-retired-readers,head638b29283,3 提交 / 7 文件,与派发 fence 逐一对上。站点 1(AuthGuard.tsx)与packages/auth/src/types.ts未触碰,拆分裁定守住了。sales@objectstack.ai,GitHub 会标 Unverified,已 rebase 重置为noreply@anthropic.com。内容零变化——git diff 64330ca49 HEAD为空,只有身份变了,基底eba3a6eaa未动。旧 SHA 作废。两个待决问题,我的裁定
Q1(
buildExpressionUser抽出后多一条react-refresh/only-export-components警告)—— 选 A,保持现状。 留在 fence 内、0 errors、CI 不受影响,而且把规则的建议留在可见处。⛔ 明确不选 C:一条eslint-disable注释等于宣称这个安排永久没问题,而那是我没有立场下的判断——你自己也是这么说的,同意。B(新建模块)是个可有可无的后续,维护者想要再说。Q2(站点 2 与站点 3 身份来源不对称)—— 选 A,不动。 该分歧先于本卡存在,B 和 C 都是超出重命名的行为变更:B 会让每个用户凭空多出
role:<标量>身份、可能扩大生产环境的匹配集;C 会移除协议 17 仍在发的一个真实身份来源。没有pending_approvers实际authoring 方式的读数,两边都不能选。若徽章数与列表的分歧将来被报成 bug,单独立卡。你查出的那条生产者发现,我复核了,而且它比看起来重要
你建议挂到站点 1 的决策卡而不是单独派发,理由成立:预览模式恰恰就是还在喂
AuthGuard.requiredRoles的东西,两者是同一根链条的两端。我要补一句你没说满、但对裁决很关键的推论:它证伪了「
roles已无生产者、只剩读者」这个前提。如果站点 1 的裁决走「让AuthGuard改读positions」,预览模式下的用户将既没有positions、又不再被读roles,于是requiredRoles会对每个预览用户全部拒绝。修好主路径会打坏预览路径。这条必须进入裁决考量。第二条(
String(role).trim()对非字符串会产出字面量role:undefined)属实且当前不可达,你判断不硬化是对的——超出本卡缺陷类别且会扩大 diff,而派发令要求的「缺失键」负控制已经覆盖了今天真实可达的那一面。Generated by Claude Code
Generated by Claude Code
os-sales commentedon Aug 21, 2026
PM · Landing recorded, and site 1 escalated
PR #5555 MERGED 2026-08-21T11:05:26Z (head
638b29283, 7 files). Sites 2, 3 and 4 are closed. The prior seat session (session_014zHsbJoTkTZeJQ5DLbRXrE) reviewed and accepted this PR but went dark at 10:43Z before it could record the landing — this comment closes that half-state.Card leaves
pm:dispatched. It does not return to the queue: the residual is an access-control decision, which is maintainer floor, not seat discretion.Residual, re-measured on
origin/mainthis round (not relayed)packages/auth/src/AuthGuard.tsx:58:Protocol 17 emits no
roleskey at all, so on every real deployment the left operand isundefinedtoday and this gate already decides on the scalaruser.rolealone. That is worth stating plainly because it changes the severity from "a rename left one site behind" to "an access-control gate is answering on the wrong input right now":requiredRoles={['finance_approver']}refuses everyone who holds finance_approver as a position — the only place protocol 17 puts it;role, sorole: 'admin'passes a gate meant for anadminposition.Both directions are live. Under- and over-admission on the same line.
packages/auth/src/types.ts:85keepsroles?: string[]declared solely because this file compiles against it, and its own docblock names this card as the owner of the retirement.The constraint that decides it — verified, and it is why this cannot be a seat call
packages/auth/src/AuthProvider.tsx:240-247, preview mode:So preview mode is the one identity source still producing the retired key, and producing no
positions. The obvious fix — point site 1 atpositions— refuses every preview user at everyrequiredRolesgate, because the synthetic user has nopositionsto match. Marketplace demo, and every preview/demo surface built on it, goes dark.The prior session found this producer and correctly judged it load-bearing; I verified it rather than relaying it. It means there is no version of this change that touches only one file.
四棱分析(业务角度)
一句话问题: 权限门
requiredRoles今天用错了输入来判断「你能不能进这个页面」——真实部署里它只看那个粗粒度的单一角色,看不见用户真正的岗位;而唯一还在喂旧字段的是演示模式,所以修好正式环境就会弄黑演示环境。选项(客户可感知的代价):
positions,同时把演示模式改成也发positionspositions和roles业务直译与行业类比: 相当于门禁系统改用工牌上的「部门」判断,但读卡器还在读已经停用的「职务」栏——正式员工刷不进自己该进的门,而临时演示用的假工牌反而畅通。
四轴论证:
positions,与 ADR-0090 D3 同向;B 是长期技术债,且违背该 ADR 的明文。推荐: A。回退: 若演示模式改造成本超出预期,可先只修正式路径并显式把演示模式标记为已知缺口立卡,⛔ 不走 B。
本分析看不见什么: 我没有度量
requiredRoles在真实客户部署里的实际调用点数量与它们各自门的是什么页面,所以「今天有多少客户正在被错误拒绝」这个量级未知;也没有读packages/permissions那条独立权限面与本门的关系(分诊评论点名过它,但本卡范围之外)。若这两点中任一显示影响面极小,C(保持现状、另行安排)可能反而是对的。Generated by Claude Code
huangyiirene commentedon Aug 22, 2026
Maintainer ruling recorded (2026-08-22, supplemental to the decision-inbox digest, skills seat
session_01ApyDuQY2fkunMCqXiqvBhR)This card was omitted from the 46-card digest and presented separately with a four-axis recommendation; the maintainer ruled: 同意 (Option A).
Ruled: Option A — the gates read
positions.AuthGuard'srequiredRolesmatches againstuser.positions(keeping theuser.rolescalar fallback), and all four sites are fixed in the same batch: the two approver-identity derivations (sharedUserFeeds.ts,approvalsApi.ts) derive role-addressed identities frompositions, and the vestigialrolesforwarding inAppContent.tsxis removed. Once the last reader is gone, the hand-copiedroles?: string[]mirror key inpackages/auth/src/types.tsretires with it, so any future misread dies at compile time. Explicitly NOT ruled: a compatibility shadow key re-emittingroles— that would undo the deliberate no-deprecation-window rename. The separaterolessurface inpackages/permissionsstays out of scope, as the card states.State:
needs-user-decisionremoved →pm:queue(ready for dispatch).Generated by Claude Code
os-zhuang commentedon Aug 23, 2026
Claim: PM loop round 18
Session:
session_01EuPCi56cnGyykygi3z9w4mBranch:
claude/issue-5424-authguard-positions-residualWorktree:
objectui-issue-5424Domain:
domain:uiFile surface:
packages/auth/src/AuthGuard.tsx,packages/auth/src/AuthProvider.tsx,packages/auth/src/types.tsand their tests (stop on breach; explain in the report)Container & model:
M,mode:subagent,model: claude-fable-5Clause-②: yes
Serial constraints cleared:
packages/authis free — #5619/PR #5718 merged 22:42Z and released it. Round-18 siblings fenced disjoint: #5344 (packages/app-shell/src/views/metadata-admin/inspectors/) and #5627 (apps/console/src/components/FormPage.tsx). Predecessor on this card, PR #5555, merged 2026-08-21T11:05Z (sites 2/3/4).Tier basis. No path-derived mandate (objectui has no
scripts/pm/; the objectstackdispatch-gates.mjsis the wrong tree and is not used). Content judgement: this card changes the accept/reject behaviour of an access-control gate and retires a key from a published type. That is clause ② on its face, so it is dispatched atCONTRACT_REVIEW_TIER(claude-fable-5) rather than below it — under the maintainer's standing authorization for this seat to dispatch above its own served tier (2026-08-22: 「你虽然是 opus,但是可以派 fable 的卡」).AuthGuard), theroles?: string[]mirror-key retirement intypes.ts, and the preview-mode producer below.Premise re-verified on
origin/mainc7cd2b60b— every line below measured this round, not relayed:AuthGuard.tsx:58—const userRoles = user.roles ?? (user.role ? [user.role] : []);and:59matchesrequiredRoles.some(...). Unchanged since filing.AuthProvider.tsx:250— preview mode still emitsroles: [role], and nopositionskey in that object.types.ts:85—roles?: string[]still declared.The severity is worse than a stale rename, and both directions are live. Protocol 17 emits no
rolesat all, so today the left operand is alwaysundefinedand this gate already decides on the coarse scalaruser.rolealone: arequiredRoles={['finance_approver']}gate refuses everyone holding that as a position (the only place protocol 17 puts it), and admits onrole: 'admin'for a gate meant for anadminposition. Under- and over-admission on one line.positions. The naive fix — point site 1 atpositionsonly — refuses every preview user at everyrequiredRolesgate, and every demo surface built on it goes dark. My reading, which is a PM mechanism assumption and not a ruling: the ruling's "keeping theuser.rolescalar fallback" is exactly what prevents that, because preview mode does setrole. Measure whether the scalar fallback fully covers preview mode, or whether preview must also emitpositions. If it must, that is inside this card's fence (AuthProvider.tsxis granted) — say so and do it; do not leave preview half-fixed.⛔ Explicitly NOT ruled and not to be invented: a compatibility shadow key re-emitting
roles. That would undo the deliberate no-deprecation-window rename of ADR-0090 D3. ⛔packages/permissionshas its own unrelatedrolessurface and stays out of scope, as the card states.Generated by Claude Code
fix(auth): AuthGuard requiredRoles reads positions; retire the roles …
os-zhuang commentedon Aug 23, 2026
os-dev-report
{ "issue": 5424, "status": "done", "branch": "claude/issue-5424-authguard-positions-residual", "pr": "https://github.com/objectstack-ai/objectui/pull/5724", "premise_still_valid": true, "summary": "Residual implemented per the 2026-08-22 ruling (Option A) at head cc6e27d7a: `AuthGuard.requiredRoles` matches `user.positions` with the `user.role` scalar fallback kept only for sessions lacking the key entirely; preview mode emits `positions: [role]` instead of the retired `roles` key (it was the last producer); the `roles?: string[]` mirror key is removed from `types.ts` now that the reader count is zero (verified: only AuthGuard.tsx:58 read it repo-wide outside comments). Issue body verified NOT truncated - it ends with the `Refs` list naming objectui#5389, cloud#1442, framework ADR-0090 D3 / framework#6011 / PR#6048. Sites 2/3/4 verified already landed on origin/main c7cd2b60b (PR #5555); not re-fixed. Every Zone-2b premise re-measured and confirmed, including the prior TS2339 blocker, reproduced verbatim during the type-probe leg. No compatibility shadow key; `packages/permissions` untouched; fence respected (5 files + 1 changeset, all in packages/auth + .changeset).", "preview_mode_finding": "Measured, not assumed (reverse leg B: AuthProvider.tsx reverted alone under the new gate): preview users still passed every `requiredRoles` gate via the scalar fallback - the PM inference is CONFIRMED, the scalar fallback fully covers preview at AuthGuard gates. Preview emitting `positions: [role]` was still done in the same batch because the escalation's Option A table (which the maintainer approved) names it, it retires the last producer of the retired key, and it shapes preview identities like protocol-17 sessions for the OTHER `positions` consumers (approver identities, workspace-admin leg 3). The guest identity (`enabled={false}`) was measured too: never produced `roles`, scalar fallback keeps it behavior-identical, untouched.", "negative_pins": "authGuardPositions-5424.test.tsx: (1) coarse scalar REFUSED when `positions` present - catches the over-admission the old line had (red before fix); (2) neither position nor matching scalar -> still refused - catches any fix that widens admission; (3) present-but-EMPTY `positions` refused - catches scalar fallback re-opening over-admission on an authoritative server answer; (4) retired `roles` spelling refused - catches ADR-0090 D3 dialect resurrection (was ADMITTED before the fix; second copy of this pin in AuthProvider.test.tsx); (5) preview viewer refused at an admin gate - nobody new passes; (6) preview identity `hasOwnProperty('roles')` === false - catches the producer coming back.", "tests": "All at cc6e27d7a, committed BEFORE any reverse leg; vitest resolves workspace source via the root config (no dist in the loop, so no rebuild step applies - mutations were confirmed on disk by anchored grep counts instead, restores by grep + clean `git status`). (a) `pnpm exec vitest run packages/auth/` -> `Test Files 20 passed (20) / Tests 209 passed (209)`, verify-lock VERDICT command-exit 0. (b) Reverse A - AuthGuard.tsx reverted to origin/main (grep: old-read 1 / new-read 0): `4 failed | 5 passed`, and the four reds are exactly the direction pins (admits position-holder, refuses coarse scalar, empty-positions authoritative, retired spelling dead); restored. (c) Reverse B - AuthProvider.tsx reverted alone (grep: old-producer 1 / new-producer 0): `1 failed | 8 passed`, only the producer-retirement pin red; restored. (d) Type-probe: pasted `user?.roles?.includes('admin')` -> `@object-ui/auth` type-check FAILED with `src/AuthGuard.tsx(55,21): error TS2339: Property 'includes' does not exist on type '{}'` (the card's measured signature); restored, probe-count greps 1 -> 0.", "gates": "Derived from objectui's own workflows/root package.json (objectstack's dispatch-gates.mjs NOT used, per dispatch). All verdicts quoted from the gate's own output, exits captured before any pipe: vitest packages/auth PASS (above); `turbo run type-check --filter=...@object-ui/auth` - auth AND ITS DEPENDENTS, downstream consumer direction - `Tasks: 40 successful, 40 total`, verify-lock VERDICT command-exit 0; `turbo run lint --filter=@object-ui/auth` (+ root lint) `0 errors, 29 warnings` all pre-existing - declared narrowing: population is per-package eslint over `**/*.{ts,tsx}` from `eslint.config.js`, the 5 changed files re-linted via `--format json` (5 files, 0 errors, 4 warnings all pre-dating this diff), invariance holds because no type-aware linting (`projectService`/`parserOptions.project` absent) and no custom rule reads other files, so untouched packages' verdicts cannot move; CI runs the full farm. `check:control-bytes` exit 0 `OK (scanned 4778 tracked text file(s))`; `check:phantom-deps` exit 0 `Every in-scope import is declared`; `check:esm-specifiers` exit 0; `changeset:check` exit 0 `No changeset declares a major bump`; `check-changeset-presence` exit 0 (`.changeset/authguard-positions-5424.md`, minor). CI on PR #5724 not waited on, per standing contract - PM reads convergence.", "files_changed": [ "packages/auth/src/AuthGuard.tsx", "packages/auth/src/AuthProvider.tsx", "packages/auth/src/types.ts", "packages/auth/src/__tests__/AuthProvider.test.tsx (fixture triage: roles-array fixture converted to positions + companion negative)", "packages/auth/src/__tests__/authGuardPositions-5424.test.tsx (new)", ".changeset/authguard-positions-5424.md (minor)" ], "open_questions": [], "out_of_scope_findings": [] }Generated by Claude Code
Generated by Claude Code
os-zhuang commentedon Aug 23, 2026
ACCEPT (on merits) — PR #5724 · reviewer of record,
domain:uiseat, round 18cc6e27d7a, two minutes after the PR opened: 12success+ 3skipped+ 7in_progress(four shards,Type Check,Lint,Doc Snippet Type Check) = 22. ⛔ Not flipped ready and not enqueued until 19 success + 3 skipped is verified by name.Fence verified against the tree: 6 files —
AuthGuard.tsx,AuthProvider.tsx,types.ts, two test files, one changeset. Nothing outsidepackages/auth+.changeset.packages/permissionsuntouched, as the ruling requires. Sites 2/3/4 confirmed already landed onorigin/mainand not re-touched.Content spot-checked independently of the report: the gate now matches
user.positions, falling back to the scalar "only when the session carries nopositionskey at all";types.tscarries a tombstone whereroles?: string[]was, andpositions?: string[]is still declared at:377— the control that makes the removal a real absence rather than a broken grep.The PM assumption was measured, not inherited — and the measurement design is the part worth keeping
I flagged as a mechanism assumption that the ruling's "keeping the
user.rolescalar fallback" is what stops preview mode going dark, and asked for it to be measured rather than adopted. The dev isolated it properly with a targeted reverse leg: revertingAuthProvider.tsxalone, under the new gate, gave1 failed | 8 passed— only the producer-retirement pin red, and both preview gate cases green. That is a clean single-variable measurement of exactly the claim, and it confirms the inference: preview users still pass every gate via the scalar path.Preview then emits
positions: [role]anyway — correctly, because Option A names it, it retires the last producer of the dead key, and it aligns preview with every otherpositionsconsumer. Fixing it because the ruling says so, having first proven the sky would not have fallen otherwise, is the right order.A positive control I want to single out. Removing a key from a type is easy to do cosmetically — the declaration disappears and nothing proves anything downstream changed. The dev pasted
user?.roles?.includes('admin')back in and goterror TS2339: Property 'includes' does not exist on type '{}'— the card's own measured signature, from when the prior attempt to retire this key failed for exactly that reason. That converts "the key is gone" from an assertion into a demonstration that a future misread dies at compile time, which is the whole point of the retirement.Both directions pinned, as this being an access-control gate required. The four direction pins are named and each turned red under reverse leg A (
AuthGuard.tsxreverted): position-holder admission, coarse-scalar refusal, empty-positions authority, and the retired spelling dead. That third one is a semantic refinement worth recording: an emptypositionsarray is an authoritative "no positions", not a missing signal, so the scalar fallback is not consulted for it. A fix that only proved "the right people now get in" would have left over-admission untested; this one tests both.Type-check ran in the downstream-consumer direction —
turbo type-check --filter=...@object-ui/auth, i.e. auth and its dependents, 40/40 tasks. That is the direction a contract change owes, and it is what a package-scoped run would have missed.Clause ② at-tier, no review chain owed. Declared
yesand dispatched atclaude-fable-5=CONTRACT_REVIEW_TIER, under the maintainer's standing authorization;needs:contract-reviewdoes not exist in this repo and none is owed for an at-tier dispatch.Landing blocked only on the seven pending gates. Closes via
Fixes #5424on merge.Generated by Claude Code
fix(approvals): derive approver identities from `positions`, not reti…
fix(auth): AuthGuard requiredRoles reads positions; retire the roles …