Repository navigation
sys_member.role app-role channel reverses ADR-0057 D4 ("never as the authority for RBAC") — pin to the built-ins before the next RC; placement is the governed channel #3723
Description
Activity
- added a commit that references this issue
on Jul 28, 2026 - added a commit that references this issue
on Jul 28, 2026 - changed the title
[-]`additionalOrgRoles` registers roles better-auth accepts but the platform objects reject on write[/-][+]`sys_member.role` is a second, un-gated entrance to the position system — `additionalOrgRoles` widens it[/+]on Jul 28, 2026 ⚠️ Reopened and rewritten — the finding got bigger, not smaller.Two things changed since this was filed.
1. It was closed as
completed(2026-07-28 02:44Z) without anything having addressed it. #3722 explicitly scoped it out — it added thedelegated_adminvalue to the two selects, which this issue listed under "Not in scope here". The general case was untouched. Reopened; re-close if the closure was a deliberate wont-fix rather than triage noise, but please leave the reason.2. The original framing was wrong about the cause. I filed this as a list-mismatch: two role lists that must agree, nothing keeping them in step. Verifying the projection path while implementing #3722 and #3767 showed that is the symptom.
resolve-authz-context.ts:305-322pushessys_member.roleandsys_user_position.positioninto the samegrants.positionsarray. A membership role is a position by another name — and the two doors are guarded asymmetrically: the position table sits behindDelegatedAdminGate(subtree anchoring, allowlist, strict containment,granted_bystamp); the membership role behind nothing.additionalOrgRolesis therefore a second entrance to the position system with none of ADR-0090 D12's controls.That reframes the options:
- option 1 (open the two fields) makes app roles work by widening the un-gated door — the most tempting and the most wrong;
- option 2 (derive one list) makes five copies consistent, but making a wrong design reliable is not fixing it; it would fossilize "membership role = un-governed position" into a formal contract;
- option 3 (lint) is honest and worth doing first, but does not make app roles work.
The rewritten body recommends a fourth: retire the channel. Membership role = organization grade (a closed, framework-owned four-name vocabulary — the
Field.selects are then correct as they stand); capability = positions, which already carry the full D12 apparatus.additionalOrgRolesdeprecates towardsys_position+sys_user_position— exactly what ADR-0090 D3's rename was for. Only after the vocabulary is closed does deriving it (option 2) become worth the effort, and it then collapses three of the five copies.Two corrections worth carrying forward regardless of which option wins:
- "Make it one list" is too coarse. Three different facts are tangled here — what names exist (the five duplicated lists), which names mean administrative authority (
isAdminRole, the role-cap grade ladder,useIsWorkspaceAdmin), and how a name projects into positions (mapMembershipRole). Only the first is duplication. Unifying all three would be a modeling error. - The migration is probably not a migration. The write path has never worked — an app-role invitation has always 400'd at the
sys_invitationinsert — so this is closer to removing a never-enforced feature than to migrating a live one. One thing to check before relying on that: whether any deployment already has custom values insys_member.role(reachable only by direct DB write; expected zero).
Step 1 of the suggested order (the lint) needs no decision and can land immediately — say the word and I'll do it. Steps 2–4 need the founder call.
Generated by Claude Code
- added a commit that references this issue
on Jul 28, 2026 - changed the title
[-]`sys_member.role` is a second, un-gated entrance to the position system — `additionalOrgRoles` widens it[/-][+]`sys_member.role` is now an admin-gated capability channel — record the doctrine, close the audit gap; keep-vs-retire expires at the next RC cut[/+]on Jul 28, 2026 Body rewritten (revision 2) after re-auditing every claim against
main. Corrections to the record:- This issue was not closed without rationale — it was closed by fix(auth): app-declared org roles are storable, not just registerable (#3723) #3747 (merged 2026-07-28 02:44Z, the same minute), which made app-declared org roles storable end-to-end (dogfood-proven). The revision-1 reopen missed it.
- The "un-gated entrance" no longer exists on
main: the invitation role cap (feat(auth): give ADR-0105 D8's scope-bounded issuance a caller —delegated_admin, capped (#3697) #3722),sys_membergovernance (fix(security): governsys_memberwrites — membership is not a delegable capability (#3697 follow-up) #3767) and better-auth's ACL leave tenant admins as the only principals who can hand out an app role — and they bypass D12 by design. No sub-admin escalation path remains. - Revision 1's "removal is free — the path never worked" argument now holds only for releases:
app-org-roles-storableis not yet consumed into any RC (.changeset/pre.json). Keep-vs-retire must be decided before the next RC cut; after that, retiring is a breaking removal of a shipped feature.
The issue is re-scoped to the residual work: an ADR recording the landed doctrine (grade + admin-issued capability entry, three-facts distinction), audit parity for role-derived capability (no
granted_by/ validity windows onsys_member), a re-aimed lint (adminScope-carrying bindings on membership roles;MEMBERSHIP_TIERSfalse-warns and hand-maintainsguest), finishing the derivation for lint/objectui, and a standing review-checklist item for any newsys_member.rolewriter. Details in the body.
Generated by Claude Code
- changed the title
[-]`sys_member.role` is now an admin-gated capability channel — record the doctrine, close the audit gap; keep-vs-retire expires at the next RC cut[/-][+]`sys_member.role` app-role channel reverses ADR-0057 D4 ("never as the authority for RBAC") — pin to the built-ins before the next RC; placement is the governed channel[/+]on Jul 28, 2026 Revision 2 amended — recommendation reversed (was: keep the landed design).
Two facts were put back on the table:
- This question was already decided. ADR-0057 D4 (accepted, landed):
additionalOrgRolesis fed to better-auth "only so invitations to those role names are accepted — never as the authority for RBAC", andsys_member.roleis "reframed to org-administration only". ADR-0095 D3 bars any enforcement-time read of the better-auth role. fix(auth): app-declared org roles are storable, not just registerable (#3723) #3747's storable app roles reverse an accepted ADR via a patch-level changeset, with no superseding ADR — and its projection rides on what ADR-0057 D4 explicitly called a transition window. - The governed replacement already shipped. Scoped invitation placement (ADR-0105 D8) carries
business_unit_id+ positions on the invitation, dry-runsDelegatedAdminGateat issuance, and applies on acceptance — strictly more delegable (works for delegated admins, not just org admins) and fully audited. The membership-role channel is a redundant, weaker duplicate; its only unique behavior is a stored label.
Default therefore flips to enforcing the ADR before the next RC cut (
app-org-roles-storableis not yet consumed — cutting now removes a feature that never shipped): pin the two selects to the four built-ins, deprecate the app-role feed with a pointer to placement, flip the dogfood, and derive the closed vocabulary. Comparison table and full plan in the body.
Generated by Claude Code
- This question was already decided. ADR-0057 D4 (accepted, landed):
Implemented in #3802 (draft) — ADR-0108, the decision this issue had been circling for three revisions.
Two things surfaced while implementing that change the record again:
1. The channel had been widened twice more. #3779 (
96242ef,042537c, merged ~04:00Z) moved app-role derivation intoAuthPlugin's ownkernel:readyhook, so what had been per-host opt-in wiring became automatic in every deployment — and it cites a downstream consumer,cloud#897. That strengthened rather than weakened the case: an ungoverned capability channel that is on by default is worse than one a host had to remember to switch on.2. The binding doctrine is stronger and more current than revision 2 said. I cited ADR-0057 D4, whose D4–D7 are marked superseded. The live authorities are ADR-0090 D3 — the word ban, which keeps
sys_member.roleonly as "third-party schema we do not own" and commands "capability =permission_set· distribution =position… The word 'role' does not exist here" — and ADR-0095 D3, "no enforcement-time code path may consult the better-auth role directly." ADR-0057 D4's "never as the authority for RBAC" is the origin, carried forward by both. No ADR ever authorized the widening; it arrived as a bug fix.What landed, against the residual-work list in the body:
- Vocabulary closed to
owner/admin/delegated_admin/member;additionalOrgRoles,org-roles.tsand the derivation hook removed (breaking, with FROM → TO in the changeset). Both reversed changesets were unreleased, so no published version ever offered the behaviour. - ADR-0108 records the doctrine, including the three-facts distinction so the next reader does not re-unify them.
- Lint re-aimed and derived — and it caught a live bug on contact: the hand-kept
MEMBERSHIP_TIERScarriedguest, which thesys_member.roleselect has never offered, so an approver authored as{ type: 'org_membership_level', value: 'guest' }resolved to nobody while the lint whose whole job is to catch that stayed silent. Now derived from@objectstack/spec, with a regression test. - Dogfood flipped —
membership-role-vocabulary.dogfood.test.tsproves the vocabulary is closed, that an app name is refused at better-auth's door leaving no row, and that the same intent succeeds through ADR-0105 D8 placement.
Not done, and still open as follow-ups: audit parity (residual item 2 —
sys_memberstill has nogranted_byor ADR-0091 validity columns, which now matters only for grade changes), objectui's mirror (objectstack-ai/objectui#2891 can derive from spec now that the list is genuinely closed), and the standing review-checklist item for any newsys_member.rolewriter.⚠️ objectstack-ai/cloudneeds a check before #3802 merges —cloud#897is outside this session's repo scope, so I could not verify whether itsArtifactKernelFactorypassesadditionalOrgRolesor depends on the derived roles. If it does, it needs the placement migration.
Generated by Claude Code
- Vocabulary closed to
- added a commit that references this issue
on Jul 28, 2026 - added 4 commits that reference this issue
on Jul 28, 2026
> Revision 2 (2026-07-28, amended the same day): the first cut of this revision corrected the record — this issue was closed by #3747 (merged 2026-07-28 02:44Z, the same minute), not without rationale, and the reopen comment missed it — and recommended keeping #3747's landed design. The amendment reverses that recommendation. Two things were put back on the table: ADR-0057 D4 already decided this exact question, and the governed replacement (invitation placement, ADR-0105 D8) has already shipped. Earlier framings are summarized in History.
Where this stands (audited against
main, 2026-07-28)normalizeAdditionalOrgRoles→ better-auth's role map and bothselectoption lists; built-ins in@objectstack/specasBUILTIN_MEMBERSHIP_ROLE_OPTIONS), dogfood-proven end to end (app-org-role-invite.dogfood.test.ts).invitation-role-cap.ts:143-150— below-admin issuers invite plainmemberonly), fix(security): governsys_memberwrites — membership is not a delegable capability (#3697 follow-up) #3767 (sys_memberinGOVERNED_OBJECTS, tenant-admin-only, non-delegable), and better-auth's admin-grade ACL onupdate-member-role. Only tenant admins can hand out an app role, and they bypass D12 by design — the sub-admin escalation revision 1 was reopened for does not exist onmain.plugin-auth/src/org-roles.ts:61-69documents the projection intogrants.positionsas "the intended channel (it is why apps declare these roles)".Already decided — the doctrine exists, and #3747 reverses it
ADR-0057 D4 (accepted; status table: ✅ landed):
>
sys_member.roleis reframed to org-administration only (owner/admin/member) […]> Continue feeding declared role names to better-auth
additionalOrgRoles[…] only so invitations to those role names are accepted — never as the authority for RBAC.Consequences: "The model becomes self-owned: RBAC no longer borrows better-auth's membership role; better-auth is cleanly confined to identity + org-administration."
ADR-0095 D3 (accepted): better-auth
role='admin'is "demoted to one source that grants that position (the existingmapMembershipRolenormalization becomes a grant-provisioning concern, not an enforcement-time input)", and "no enforcement-time code path may consult the better-auth role directly".ADR-0105 D8 conforms:
delegated_admin"carries NO ObjectStack authority by construction […] Role = can reach the endpoint; adminScope = what the endpoint permits."Against that: the projection union in
resolve-authz-context.ts:305-324is ADR-0057 D4's transition window ("unionsys_member.roleduring a transition window"), not a contract —mapMembershipRole's default passthrough is its residue. #3747 took the residue and promoted it to "the intended channel", storable app roles included: an accepted ADR reversed by a patch-level changeset, with no superseding ADR. Revision 1's "option 4" was never a new proposal — it is the standing recorded doctrine.The channel is also redundant — the governed one-step flow already shipped
Scoped invitation placement (ADR-0105 D8,
.changeset/scoped-invitation-placement.md): an invitation carriesbusiness_unit_id+positions; issuance is dry-run throughDelegatedAdminGateagainst the verysys_user_positionrows acceptance would write; acceptance applies idempotently, failure-isolated.member)sys_membersys_user_positionrowsgranted_by, no ADR-0091 windowsThe governed path is a strict superset: more delegable, fully audited. The convenience channel's only unique behavior is storing a business-role label on the membership row — presentational, and derivable from positions.
Decision — default: enforce the ADR, before the next RC cut
.changeset/pre.jsonshowsapp-org-roles-storableis not consumed by any RC: cutting now removes a feature that never shipped. After the next RC cut it becomes a breaking removal. Plan:sys_member.role/sys_invitation.roleto the four built-ins —BUILTIN_MEMBERSHIP_ROLE_OPTIONSbecomes the whole list. Keep fix(auth): app-declared org roles are storable, not just registerable (#3723) #3747's one-list infrastructure; pinned, it is exactly "derive once the vocabulary is closed" from revision 1.additionalOrgRolesapp-role feed (collectStackOrgRoles) with a boot warning pointing at placement. An invitation naming an app role then fails loudly at the door (ROLE_NOT_FOUND) with a migration pointer, instead of being stored as un-governed authority.app-org-role-invite.dogfood.test.ts): the app-role invite is refused at the door; the same intent expressed asmember+ placement succeeds, governed.MEMBERSHIP_TIERS(validate-approval-approvers.ts:109-117— hand-maintained, carriesguest, absent fromBUILTIN_MEMBERSHIP_ROLES; verify which is right) and objectui's mirror (feat(console): makedelegated_adminreachable and narrow both role pickers (framework#3697) objectui#2891) read the four names from@objectstack/spec.Keeping #3747 instead would mean superseding ADR-0057 D4 with a new ADR — and with placement shipped there is no DX case left to make in it; what remains is a label the console can render from positions.
Standing review-checklist item either way: any new surface that writes
sys_member.role(SCIM group mapping, a future direct-write API) must carry the invitation cap's logic — #3767's gate isisSystem-short-circuited for every actual writer.History
ValidationError). Symptom accurate; cause misattributed. Fixed by fix(auth): app-declared org roles are storable, not just registerable (#3723) #3747.delegated_admin, capped (#3697) #3722's cap + fix(security): governsys_memberwrites — membership is not a delegable capability (#3697 follow-up) #3767 while it was being written; the reopen premise ("nothing had addressed it") missed fix(auth): app-declared org roles are storable, not just registerable (#3723) #3747. Its option 4 turned out to be ADR-0057 D4's standing text, not a proposal.Related: #3697, #3722, #3747 (the reversal), #3767, objectstack-ai/objectui#2891 · ADR-0057 D4 · ADR-0095 D3 · ADR-0105 D8.