@@ -543,3 +543,68 @@ gate reports the service absent, persist when present, fail-open with no gate, a
543543requiresObject entries are untouched. An end-to-end Setup-nav test is not feasible in the
544544verify harness: the platform Setup app's navigation is not materialized there (` /meta/app `
545545lists only the business app; ` /meta/app/setup ` returns a protection stub).
546+
547+ ---
548+
549+ ## Addendum (2026-06-25): D4's reverse path — ` role → users ` expansion still reads ` sys_member.role `
550+
551+ > This addendum records a follow-up gap found while adjudicating an approval-routing
552+ > question (can an approval ` role ` approver resolve a tenant RBAC role assigned via
553+ > ` sys_user_role ` ?). It ** extends, and does not revise** , D4. D4 stands: ` sys_user_role `
554+ > is the platform-owned source of truth for role assignment.
555+
556+ ### The gap
557+
558+ D4 made ` sys_user_role ` the source of truth and reframed ` sys_member.role ` to
559+ better-auth org-administration (owner/admin/member), and it migrated the ** forward**
560+ direction — * given a user, which roles do they hold?* — into ` ExecutionContext.roles `
561+ as the documented transition-window union ` sys_user_role ∪ sys_member.role `
562+ (` resolve-execution-context ` ).
563+
564+ The ** reverse** direction — * given a role, which users hold it?* — was ** not** migrated.
565+ Both consumers that need it still terminate in ` sys_member.role ` only, and neither unions
566+ ` sys_user_role ` :
567+
568+ - ** plugin-approvals** — ` expandApprovers ` dispatches a ` role ` approver to
569+ ` expandRoleUsers ` (` approval-service.ts:306 ` ), which queries ` find('sys_member', { filter: { role } }) `
570+ (` approval-service.ts:380-389 ` ; the doc comment at ` :286 ` even pins the semantics to
571+ "` sys_member.role = value ` "). No ` sys_role ` / ` sys_user_role ` read.
572+ - ** plugin-sharing** — a ` role ` recipient resolves via ` teamGraph.expandRoleUsers `
573+ (` role-graph.ts:100 ` , ` team-graph.ts:144 ` ), whose terminal query is
574+ ` find('sys_member', …) ` (` team-graph.ts:70-80 ` ; comment ` :25 ` "` sys_member.role ` for
575+ tenant role expansion"). Sharing is * partially* further along — its
576+ ` RoleHierarchyGraph ` walks ` sys_role.parent ` to find subordinate roles
577+ (` role-graph.ts:57 ` ) — but the final role→** user** hop is still ` sys_member ` .
578+
579+ Note ` plugin-approvals ` is ** not** in this ADR's Consumers list; ` plugin-sharing ` ** is** .
580+ That both behave identically is the key signal below.
581+
582+ ### Consequence (measured against the D4 target model)
583+
584+ A user who holds an RBAC role ** only** through ` sys_user_role ` (the source-of-truth table)
585+ and was never written into better-auth's ` sys_member.role ` is ** invisible** to approval
586+ routing and to sharing-rule role expansion. Against D4's stated target this is a ** defect** :
587+ the source of truth is bypassed on the reverse path. It is ** not** an intentional decision
588+ that "approval/sharing roles mean org-membership roles" — no such decision is recorded —
589+ and it is ** not** an approval-specific accident, since the in-scope sibling
590+ ` plugin-sharing ` exhibits the same behaviour. The accurate characterisation is an
591+ ** incomplete / phased migration** : D4 shipped the forward path under a transition-window
592+ union and left the reverse-lookup consumers on the legacy table.
593+
594+ ### Decision (follow-up, does not revise D4)
595+
596+ 1 . Finish D4 on the reverse path: the ` role → users ` expansion must read the source of
597+ truth, i.e. ` sys_user_role ∪ sys_member.role ` (the same union semantics D4 already
598+ applies to ` ctx.roles ` ), scoped by ` organization_id ` where present. Apply it in ** both**
599+ ` plugin-approvals ` ` expandRoleUsers ` and ` plugin-sharing ` ` teamGraph.expandRoleUsers `
600+ so the two stay consistent.
601+ 2 . When ` sys_member.role ` is eventually retired from the union (transition window closes),
602+ retire it in the reverse path at the same time — track it as one migration, not two.
603+ 3 . Until (1) lands, the supported pattern for multi-entity, post-routed approvers
604+ (e.g. four-level approval by 岗位/职务 across plants) is a ` type: 'field' ` approver fed
605+ by a materialised 岗位→人 mapping (a submit-time hook writing ` division.*_user ` ), which
606+ sidesteps the reverse-lookup path entirely and gives 绑岗位不绑人 / 按工厂路由 / 换人不改流程.
607+
608+ Proof obligation when (1) is implemented: a regression test asserting that a user assigned
609+ a role ** only** via ` sys_user_role ` (no ` sys_member.role ` row) is returned by both
610+ ` expandRoleUsers ` paths.
0 commit comments