You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
> 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)
The original symptom is fixed.fix(auth): app-declared org roles are storable, not just registerable (#3723) #3747 built the one list (normalizeAdditionalOrgRoles → better-auth's role map and both select option lists; built-ins in @objectstack/spec as BUILTIN_MEMBERSHIP_ROLE_OPTIONS), dogfood-proven end to end (app-org-role-invite.dogfood.test.ts).
The "un-gated entrance" is no longer un-gated. Three gates: the invitation role cap (invitation-role-cap.ts:143-150 — below-admin issuers invite plain member only), fix(security): govern sys_member writes — membership is not a delegable capability (#3697 follow-up) #3767 (sys_member in GOVERNED_OBJECTS, tenant-admin-only, non-delegable), and better-auth's admin-grade ACL on update-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 on main.
But the channel itself was made deliberate: plugin-auth/src/org-roles.ts:61-69 documents the projection into grants.positions as "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.role is 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 existing mapMembershipRole normalization 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-324 is ADR-0057 D4's transition window ("union sys_member.role during 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 carries business_unit_id + positions; issuance is dry-run through DelegatedAdminGate against the very sys_user_position rows acceptance would write; acceptance applies idempotently, failure-isolated.
org owner/admin only (the cap holds delegates to member)
admins and delegated admins, inside subtree + allowlist
What acceptance writes
a role string on sys_member
real sys_user_position rows
Audit / validity
none — no granted_by, no ADR-0091 windows
full assignment-grade audit
Scope checks
none (its only issuers are admins, who bypass)
subtree, allowlist, strict containment
ADR status
reverses 0057 D4
implements 0105 D8
The 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.json shows app-org-roles-storable is not consumed by any RC: cutting now removes a feature that never shipped. After the next RC cut it becomes a breaking removal. Plan:
Stop registering app names with better-auth; deprecate the additionalOrgRoles app-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.
Flip the dogfood (app-org-role-invite.dogfood.test.ts): the app-role invite is refused at the door; the same intent expressed as member + placement succeeds, governed.
ADR note (short amendment, not new doctrine): affirm 0057 D4 post-fix(auth): app-declared org roles are storable, not just registerable (#3723) #3747; name placement as the delegable capability channel; record the three-facts distinction (what names exist / which mean authority / how names project) so the next agent does not re-unify them.
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 is isSystem-short-circuited for every actual writer.
Revision 2, first cut (2026-07-28): corrected the record; recommended keeping the landed design. Amended hours later when ADR-0057 D4 / ADR-0095 D3 and the shipped placement channel were surfaced: the default flips to enforcing the ADR.
> 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.