Skip to content

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

@os-zhuang

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

membership-role channel (#3747) placement channel (shipped)
Who can issue 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:

  1. Pin sys_member.role / sys_invitation.role to the four built-ins — BUILTIN_MEMBERSHIP_ROLE_OPTIONS becomes 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.
  2. 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.
  3. 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.
  4. 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.
  5. Derive the closed vocabulary: lint's MEMBERSHIP_TIERS (validate-approval-approvers.ts:109-117 — hand-maintained, carries guest, absent from BUILTIN_MEMBERSHIP_ROLES; verify which is right) and objectui's mirror (feat(console): make delegated_admin reachable 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 is isSystem-short-circuited for every actual writer.

History

Related: #3697, #3722, #3747 (the reversal), #3767, objectstack-ai/objectui#2891 · ADR-0057 D4 · ADR-0095 D3 · ADR-0105 D8.

Activity

  1. self-assigned this
    on Jul 28, 2026
  2. 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
  3. os-zhuang commented on Jul 28, 2026

    @os-zhuang
    ContributorAuthor

    ⚠️ 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 the delegated_admin value 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-322 pushes sys_member.role and sys_user_position.position into the same grants.positions array. A membership role is a position by another name — and the two doors are guarded asymmetrically: the position table sits behind DelegatedAdminGate (subtree anchoring, allowlist, strict containment, granted_by stamp); the membership role behind nothing. additionalOrgRoles is 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. additionalOrgRoles deprecates toward sys_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_invitation insert — 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 in sys_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

  4. 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
  5. os-zhuang commented on Jul 28, 2026

    @os-zhuang
    ContributorAuthor

    Body rewritten (revision 2) after re-auditing every claim against main. Corrections to the record:

    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 on sys_member), a re-aimed lint (adminScope-carrying bindings on membership roles; MEMBERSHIP_TIERS false-warns and hand-maintains guest), finishing the derivation for lint/objectui, and a standing review-checklist item for any new sys_member.role writer. Details in the body.


    Generated by Claude Code

  6. 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
  7. os-zhuang commented on Jul 28, 2026

    @os-zhuang
    ContributorAuthor

    Revision 2 amended — recommendation reversed (was: keep the landed design).

    Two facts were put back on the table:

    1. This question was already decided. ADR-0057 D4 (accepted, landed): additionalOrgRoles is fed to better-auth "only so invitations to those role names are accepted — never as the authority for RBAC", and sys_member.role is "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.
    2. The governed replacement already shipped. Scoped invitation placement (ADR-0105 D8) carries business_unit_id + positions on the invitation, dry-runs DelegatedAdminGate at 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-storable is 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

  8. os-zhuang commented on Jul 28, 2026

    @os-zhuang
    ContributorAuthor

    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 into AuthPlugin's own kernel:ready hook, 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.role only 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:

    1. Vocabulary closed to owner/admin/delegated_admin/member; additionalOrgRoles, org-roles.ts and the derivation hook removed (breaking, with FROM → TO in the changeset). Both reversed changesets were unreleased, so no published version ever offered the behaviour.
    2. ADR-0108 records the doctrine, including the three-facts distinction so the next reader does not re-unify them.
    3. Lint re-aimed and derived — and it caught a live bug on contact: the hand-kept MEMBERSHIP_TIERS carried guest, which the sys_member.role select 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.
    4. Dogfood flipped — membership-role-vocabulary.dogfood.test.ts proves 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_member still has no granted_by or 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 new sys_member.role writer.

    ⚠️ objectstack-ai/cloud needs a check before #3802 merges — cloud#897 is outside this session's repo scope, so I could not verify whether its ArtifactKernelFactory passes additionalOrgRoles or depends on the derived roles. If it does, it needs the placement migration.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions