Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/derive-org-membership-levels.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@objectstack/spec': minor
---

`ORG_MEMBERSHIP_LEVELS` is now derived from `BUILTIN_MEMBERSHIP_ROLES` instead of hand-spelling a copy, so the `org_membership_level` approver vocabulary is exactly the `sys_member.role` vocabulary. Accept-set widening: `delegated_admin` (ObjectStack's own ADR-0105 D8 tier, already storable and enforced on `sys_member.role`) is now offered by the approver picker and valid as an `org_membership_level` approver value. The constant's provenance doc-comment is corrected in the same change: the list is ObjectStack's closed membership vocabulary (ADR-0108), no longer "better-auth's closed set".
21 changes: 21 additions & 0 deletions packages/plugins/plugin-approvals/src/approval-service.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -862,6 +862,27 @@ describe('ApprovalService (node era)', () => {
expect(req.pending_approvers.sort()).toEqual(['u1', 'u2']);
});

// `ORG_MEMBERSHIP_LEVELS` is derived from `BUILTIN_MEMBERSHIP_ROLES`, so the
// ADR-0105 D8 tier is authorable — and the expander routes it like any other
// tier (the filter carries the value straight to `sys_member.role`).
it('delegated_admin tier (ADR-0105 D8) expands to its members like any other tier', async () => {
engine._tables['sys_member'] = [
{ id: 'm1', user_id: 'u1', role: 'delegated_admin', organization_id: 't1' },
{ id: 'm2', user_id: 'u2', role: 'admin', organization_id: 't1' }, // other tier
];
const input = {
...tierInput('org_membership_level'),
config: {
approvers: [{ type: 'org_membership_level' as any, value: 'delegated_admin' }],
behavior: 'first_response' as const,
lockRecord: true,
},
};
const req = await svc.openNodeRequest(input, CTX);
if ('autoApproved' in req) throw new Error('expected a pending request, not an auto-approve outcome');
expect(req.pending_approvers).toEqual(['u1']);
});

it('deprecated `role` alias resolves IDENTICALLY to org_membership_level', async () => {
engine._tables['sys_member'] = [
{ id: 'm1', user_id: 'u1', role: 'admin', organization_id: 't1' },
Expand Down
24 changes: 24 additions & 0 deletions packages/spec/src/automation/approval.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,6 +17,7 @@ import {
getApprovalNodeConfigJsonSchema,
normalizeDecisionOutputs,
} from './approval.zod';
import { BUILTIN_MEMBERSHIP_ROLES } from '../identity/membership-role';

describe('ApproverType', () => {
it('should accept all valid approver types', () => {
Expand DownExpand Up@@ -77,6 +78,29 @@ describe('ApproverType', () => {
});
});

// `ORG_MEMBERSHIP_LEVELS` once hand-spelled a three-value copy of the
// membership vocabulary while `sys_member.role` enforced four, so the enforced
// `delegated_admin` tier (ADR-0105 D8) could not be authored as an approver.
// The list is now DERIVED from `BUILTIN_MEMBERSHIP_ROLES`; these pins keep the
// derivation from ever being silently replaced by a copy again.
describe('ORG_MEMBERSHIP_LEVELS derives from BUILTIN_MEMBERSHIP_ROLES', () => {
it('is the same list, in the same display order, as the sys_member.role vocabulary', () => {
expect([...ORG_MEMBERSHIP_LEVELS]).toEqual([...BUILTIN_MEMBERSHIP_ROLES]);
});

it('admits delegated_admin — an enforced sys_member.role value — as an approver tier', () => {
expect(ORG_MEMBERSHIP_LEVELS).toContain('delegated_admin');
});

it('exposes the whole vocabulary on the wire picker surface, deprecated alias included', () => {
expect(APPROVER_VALUE_SOURCES.org_membership_level).toEqual({
source: 'enum',
values: [...BUILTIN_MEMBERSHIP_ROLES],
});
expect(APPROVER_VALUE_SOURCES.role).toEqual(APPROVER_VALUE_SOURCES.org_membership_level);
});
});

// #3508: the designer contract for sourcing each approver row's `value`. The
// record-backed kinds MUST match the engine's resolution semantics
// (`plugin-approvals` resolveApproverSpec / expand*Users) — these assertions
Expand Down
22 changes: 18 additions & 4 deletions packages/spec/src/automation/approval.zod.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { z } from 'zod';
import { BUILTIN_MEMBERSHIP_ROLES } from '../identity/membership-role';
import { lazySchema } from '../shared/lazy-schema';
import { strictObject } from '../shared/strict-object';

Expand DownExpand Up@@ -44,8 +45,9 @@ export const ApproverType = z.enum([
* reusing it here would silently alias one of the two times.
*/
'expression',
// The better-auth ORG-MEMBERSHIP TIER (sys_member.role: owner / admin /
// member), spelled with the projection name ADR-0057 D7 mandates and
// The ORG-MEMBERSHIP TIER (`sys_member.role` — the whole closed
// `BUILTIN_MEMBERSHIP_ROLES` vocabulary, ObjectStack-owned; see
// `ORG_MEMBERSHIP_LEVELS` below), spelled with the projection name ADR-0057 D7 mandates and
// ADR-0090 D3 assumes ("relabelled `org_membership_level` … its UI label is
// 'organization membership', never 'role'"). NOT an org position: a value
// like 'sales_manager' matches nobody — author `position` for those.
Expand DownExpand Up@@ -135,8 +137,20 @@ export type ApproverValueBinding =
/** Declared-but-unenforced — do not offer for authoring (#3508). */
| { source: 'unsupported' };

/** Org-membership tiers (`sys_member.role`: better-auth's closed set). */
export const ORG_MEMBERSHIP_LEVELS = ['owner', 'admin', 'member'] as const;
/**
* Org-membership tiers (`sys_member.role`) — DERIVED from
* {@link BUILTIN_MEMBERSHIP_ROLES}, never re-spelled.
*
* The vocabulary is ObjectStack's own closed membership-role list (ADR-0108),
* not "better-auth's closed set" as this comment once claimed:
* `delegated_admin` is ObjectStack's ADR-0105 D8 addition to the column. A
* hand-spelled copy here carried the stale three-value list, so the one tier
* the column enforces but the copy omitted could not be authored as an
* approver. Deriving makes the drift unrepresentable: the approver picker
* offers exactly what `sys_member.role` stores, and the next tier addition
* reaches this surface automatically.
*/
export const ORG_MEMBERSHIP_LEVELS = BUILTIN_MEMBERSHIP_ROLES;

/**
* The CLOSED root set an `expression` approver may reference (#3447 P2).
Expand Down
Loading