Skip to content

feat(auth)!: membership grade is not a capability channel — close the sys_member.role vocabulary (ADR-0108, #3723) - #3802

Merged
os-zhuang merged 3 commits into
mainfrom
claude/sys-member-role-assessment-s09jvx
Jul 28, 2026
Merged

feat(auth)!: membership grade is not a capability channel — close the sys_member.role vocabulary (ADR-0108, #3723)#3802
os-zhuang merged 3 commits into
mainfrom
claude/sys-member-role-assessment-s09jvx

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Closes #3723. Reverses #3747 and #3779, neither of which was consumed into a release.

The problem

sys_member.role answers "what is your standing in this organization". It does not answer "what may you do" — that is what positions are for. One column was answering both.

resolve-authz-context.ts projects every value stored in sys_member.role into current_user.positions, alongside the rows read from sys_user_position. So a business role handed out through the membership role was capability — granted with none of the position system's controls:

Write path Gate
sys_user_position DelegatedAdminGate — BU-subtree anchoring, assignablePermissionSets allowlist, strict containment, granted_by, ADR-0091 validity window
sys_member.role grade checks only — no audit stamp, no validity window, no scope check

Why this is a revert, not a new opinion

This question already had an answer in three accepted ADRs:

  • ADR-0057 D4 introduced additionalOrgRoles with an explicit qualifier — feed the names to better-auth "only so invitations to those role names are accepted — never as the authority for RBAC". The projection above is what voided the qualifier.
  • ADR-0090 D3 bans the word outright: "capability = permission_set · distribution = position … The word 'role' does not exist here." sys_member.role survives as third-party schema we do not own — an exception for a column we cannot rename, not a licence to build a distribution channel on it.
  • ADR-0095 D3: "no enforcement-time code path may consult the better-auth role directly."

No ADR authorized the widening. It arrived as a bug fix (#3747 made app names storable) and was then made automatic in every host (#3779, kernel:ready self-derivation). ADR-0108 records the reversal so the next reader finds a decision rather than a silently reversed doctrine.

Why now

.changeset/pre.json shows neither app-org-roles-storable nor auth-org-roles-self-derived was consumed into an RC, so no published version ever offered this behaviour. Cutting it now removes a feature that never shipped; after the next RC cut it would be a breaking removal of a live one.

The replacement already shipped and reaches further

ADR-0105 D8 invitation placement is a strict superset:

membership-role channel (retired) placement (shipped)
Who may issue org owner/admin only — the invitation role cap holds anyone below admin grade to plain member admins and delegated admins, within subtree + allowlist
What acceptance writes a string on sys_member real sys_user_position rows
Audit / validity none granted_by + ADR-0091 windows
Scope checks none subtree, allowlist, strict containment
- POST /organization/invite-member { email, role: 'sales_rep' }
+ POST /organization/invite-member { email, role: 'member',
+                                    businessUnitId, positions: ['sales_rep'] }

What changed

  • Vocabulary closed to owner / admin / delegated_admin / member. Both role selects declare BUILTIN_MEMBERSHIP_ROLE_OPTIONS statically; nothing widens them at boot.
  • BREAKING — additionalOrgRoles removed from AuthManagerOptions / AuthPluginOptions, with plugin-auth/src/org-roles.ts in full and the kernel:ready derivation hook. A TypeScript error is the intended failure; a silently ignored option would be declared ≠ enforced one more time. Tombstone comments at both option sites carry the FROM → TO.
  • MEMBERSHIP_ROLE_NAME_PATTERN / _MIN_LENGTH dropped from @objectstack/spec — they existed only to validate app-supplied names. api-surface.json regenerated.
  • The ADR-0105 D8 delegated_admin registration is untouched — it is a grade (what you may reach), not capability.
  • lint's MEMBERSHIP_TIERS now derives from BUILTIN_MEMBERSHIP_ROLES. This fixed a live bug on contact: the hand-kept copy carried guest, which the select has never offered, so an approver authored as { type: 'org_membership_level', value: 'guest' } resolved to nobody and the lint whose whole job is to catch that stayed silent. Regression test added.
  • Docs updated (authentication.mdx, positions.mdx, delegated-administration.mdx) with a callout for the removed path.

Proof

app-org-role-invite.dogfood.test.ts proved the opposite and is replaced by membership-role-vocabulary.dogfood.test.ts, which drives the real HTTP route and asserts:

  1. both enforced selects offer the four framework roles and nothing else;
  2. a stack-declared position name is refused at better-auth's door, leaving no row — loud and early, not a 400 at the insert (fix(auth): app-declared org roles are storable, not just registerable (#3723) #3747's symptom) and not silent success storing an ungoverned grant (what fix(auth): app-declared org roles are storable, not just registerable (#3723) #3747 shipped);
  3. a declared PermissionSet name is refused the same way — both collection routes closed;
  4. sys_member.role refuses the name on write too, so the ungoverned grant is unrepresentable at the table, not merely unreachable by route;
  5. the same intent succeeds through placement, with grade and capability in separate columns;
  6. an undeclared name is still refused.
Test Files  1 passed (1)   Tests  6 passed (6)

Suites green after merging latest main: spec 6736, plugin-auth 573, lint 472, runtime 661, platform-objects 239, delegated-admin-invite.dogfood 4. check:api-surface and the ADR-0090 D3 role-word ratchet both clean.

Downstream: objectstack-ai/cloud audited — no impact

#3779 cited cloud#897 as its motivation, so cloud was audited directly at b168e94. Verdict: no compile-time or runtime break.

  • Zero coupling. No removed symbol is imported anywhere; the single textual hit is prose inside a // comment (apps/ee-group-showcase/test/group-posture.dogfood.test.ts:328). Both new AuthPlugin({...}) sites — objectos-runtime/src/artifact-kernel-factory.ts:380 and service-cloud/src/control-plane-preset.ts:389 — omit additionalOrgRoles.
  • Every value cloud can write is inside the closed select: role: 'owner' literals in personal-org-hook.ts:249,319, and environment-org-seed.ts:114 whose parameter is already typed 'owner' | 'admin' | 'member'. Cloud issues no invitations from production code, so ROLE_NOT_FOUND is unreachable. No role picker, no option-list read, no capability inferred from sys_member.role (positions come from sys_user_position).
  • cloud#897 is a different bugKNOWN_METADATA_CATEGORIES had been hand-copied and kept the pre-D3 roles key, so positions[] was silently dropped on package publish/export and SecurityPlugin had nothing to seed into sys_position. It is now derived from PLURAL_TO_SINGULAR. That fix is complementary to this PR: it makes declared positions survive into hosted environments, which is the channel this PR directs everyone to.
  • Corroboration: cloud's own group-posture dogfood already invites with role: 'member' + positions: [...] — the governed placement path, not the retired one.

Two cosmetic follow-ups for cloud, neither blocking: the stale comment above, and two mock fixtures seeding 'viewer' / 'guest' into fake drivers (harmless now; they would fail the closed select only if those tests were ever migrated to a real kernel).

Reviewer notes

🤖 Generated with Claude Code

https://claude.ai/code/session_0186LhwkUBupmLJUUAMda5hU

… `sys_member.role` vocabulary (ADR-0108, #3723)

`sys_member.role` answers "what is your standing in this organization". It
does not answer "what may you do" — that is what positions are for. One
column was answering both.

`resolve-authz-context` projects EVERY value stored in `sys_member.role` into
`current_user.positions`, alongside the rows read from `sys_user_position`. A
business role handed out through the membership role was therefore capability,
granted with none of the position system's controls: no `granted_by`, no
ADR-0091 validity window, no BU-subtree check, no `assignablePermissionSets`
allowlist. ADR-0057 D4 ruled that out ("never as the authority for RBAC"),
ADR-0090 D3's word ban restates it (distribution = `position`), and ADR-0095
D3 keeps the better-auth role out of the enforcement path. No ADR ever
authorized the widening — it arrived as a bug fix (#3747) and was then made
automatic in every host (#3779).

The vocabulary is closed to the four framework-owned names: owner / admin /
delegated_admin / member.

- `additionalOrgRoles` removed from AuthManagerOptions and AuthPluginOptions,
  with `plugin-auth/src/org-roles.ts` in full and the `kernel:ready`
  derivation hook. A TypeScript error is the intended failure; a silently
  ignored option would be `declared != enforced` one more time.
- The two `role` selects carry `BUILTIN_MEMBERSHIP_ROLE_OPTIONS` and nothing
  else; nothing widens them at boot.
- `MEMBERSHIP_ROLE_NAME_PATTERN` / `_MIN_LENGTH` dropped from spec — they
  existed only to validate app-supplied names.
- lint's `MEMBERSHIP_TIERS` derives from `BUILTIN_MEMBERSHIP_ROLES`. The
  hand-kept copy carried `guest`, which the select has never offered, so an
  approver naming it resolved to nobody and the lint whose job is to catch
  that stayed silent — fixed by construction, with a regression test.
- `app-org-role-invite.dogfood.test.ts` is replaced by
  `membership-role-vocabulary.dogfood.test.ts`, which 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.

Migration (also in the changeset):

  - POST /organization/invite-member { email, role: 'sales_rep' }
  + POST /organization/invite-member { email, role: 'member',
  +                                    businessUnitId, positions: ['sales_rep'] }

Placement reaches further than what it replaces: it is authorized against the
issuer's adminScope, so a delegated admin may use it within their subtree,
where the membership-role route was open to org admins only.

Both reversed changesets were unreleased, so no published version ever offered
the behaviour.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186LhwkUBupmLJUUAMda5hU
@vercel

vercel Bot commented Jul 28, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Jul 28, 2026 6:50am

Request Review

@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Jul 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 9 package(s): @objectstack/cli, @objectstack/lint, @objectstack/platform-objects, @objectstack/plugin-auth, @objectstack/plugin-dev, packages/qa, @objectstack/runtime, @objectstack/spec, @objectstack/verify.

119 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via packages/cli, @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/cli, packages/runtime, @objectstack/spec)
  • content/docs/api/data-flow.mdx (via @objectstack/cli)
  • content/docs/api/environment-routing.mdx (via @objectstack/cli, @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/cli, @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/runtime, @objectstack/spec)
  • content/docs/api/wire-format.mdx (via @objectstack/runtime)
  • content/docs/automation/approvals.mdx (via packages/spec)
  • content/docs/automation/flows.mdx (via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via packages/cli, @objectstack/lint, @objectstack/runtime, packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via packages/spec)
  • content/docs/concepts/north-star.mdx (via packages/runtime, packages/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/runtime, @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/backup-restore.mdx (via @objectstack/cli)
  • content/docs/deployment/cli.mdx (via @objectstack/cli, @objectstack/plugin-auth, @objectstack/spec)
  • content/docs/deployment/index.mdx (via @objectstack/runtime)
  • content/docs/deployment/production-readiness.mdx (via @objectstack/plugin-auth, @objectstack/runtime)
  • content/docs/deployment/self-hosting.mdx (via @objectstack/cli)
  • content/docs/deployment/single-project-mode.mdx (via @objectstack/runtime)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/deployment/vercel.mdx (via @objectstack/runtime)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/cli, @objectstack/runtime, @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via packages/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/data-service.mdx (via packages/cli)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/cli, packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via packages/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/plugin-auth, @objectstack/spec)
  • content/docs/permissions/authentication.mdx (via @objectstack/cli, @objectstack/plugin-auth, @objectstack/runtime)
  • content/docs/permissions/authorization.mdx (via @objectstack/lint, packages/qa, packages/runtime, @objectstack/spec)
  • content/docs/permissions/delegated-administration.mdx (via packages/qa)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/spec)
  • content/docs/permissions/sso.mdx (via @objectstack/plugin-auth)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/plugin-auth, @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/cli, @objectstack/platform-objects, @objectstack/plugin-auth, @objectstack/plugin-dev, @objectstack/runtime, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/i18n-standard.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/runtime, @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/runtime, @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/cli, @objectstack/spec)
  • content/docs/protocol/kernel/realtime-protocol.mdx (via @objectstack/cli)
  • content/docs/protocol/kernel/runtime-capabilities.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/releases/implementation-status.mdx (via @objectstack/cli, @objectstack/plugin-auth, @objectstack/runtime, @objectstack/spec, @objectstack/verify)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v15.mdx (via @objectstack/verify)
  • content/docs/releases/v16.mdx (via @objectstack/cli, @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/plugin-auth, @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/platform-objects, @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

… ratchet)

The role-word ratchet caught this PR's own prose growing the count it exists
to shrink — in the docs for the change that closes the vocabulary, which is
about as pointed as a lint failure gets.

Every avoidable use is now "membership tier". What remains in the two touched
files is the better-auth boundary itself (`sys_member.role`, the wire field in
the invite sample), which D3 keeps as its documented exception.

authentication.mdx lands at 4, below its baseline of 5, so the baseline
ratchets down one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186LhwkUBupmLJUUAMda5hU
@os-zhuang
os-zhuang marked this pull request as ready for review July 28, 2026 06:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants