Skip to content

Commit 420804d

Browse files
Elon Muskclaude
andauthored
fix(spec): derive ORG_MEMBERSHIP_LEVELS from BUILTIN_MEMBERSHIP_ROLES — delegated_admin becomes an addressable approver tier (#9942)
* fix(spec): derive ORG_MEMBERSHIP_LEVELS from BUILTIN_MEMBERSHIP_ROLES The approver tier list hand-spelled ['owner', 'admin', 'member'] while sys_member.role enforces four values including delegated_admin (ADR-0105 D8), so an enforced, storable tier could not be authored as an approver. Maintainer ruling 2026-08-19 (issue comment, Option B): this is drift, not deliberate exclusion — derive the list from the one membership vocabulary so the next tier addition cannot silently miss the approver surface. Also corrects the constant's provenance doc-comment: the vocabulary is ObjectStack's own closed membership-role list (ADR-0108), not better-auth's closed set — delegated_admin is ObjectStack's ADR-0105 D8 addition. Pins: spec asserts ORG_MEMBERSHIP_LEVELS === BUILTIN_MEMBERSHIP_ROLES as a list (so the derivation can never be silently replaced by a copy), that delegated_admin is admitted, and that the wire picker projection (APPROVER_VALUE_SOURCES, deprecated role alias included) carries the whole vocabulary; plugin-approvals pins that a delegated_admin approver expands to its members at runtime. Fixes #9806 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016D9wdJR14KKCxz1WgdAzcw * test(plugin-approvals): narrow openNodeRequest union in delegated_admin pin The TEST_DEBT ratchet (check:type-check-debt) is frozen at 348 raw errors for this package; the new pin's bare pending_approvers access added a 349th TS2339. Guard the union the way the position test does so the pin contributes zero new debt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016D9wdJR14KKCxz1WgdAzcw --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 301bf26 commit 420804d

4 files changed

Lines changed: 68 additions & 4 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
`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".

packages/plugins/plugin-approvals/src/approval-service.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -862,6 +862,27 @@ describe('ApprovalService (node era)', () => {
862862
expect(req.pending_approvers.sort()).toEqual(['u1', 'u2']);
863863
});
864864

865+
// `ORG_MEMBERSHIP_LEVELS` is derived from `BUILTIN_MEMBERSHIP_ROLES`, so the
866+
// ADR-0105 D8 tier is authorable — and the expander routes it like any other
867+
// tier (the filter carries the value straight to `sys_member.role`).
868+
it('delegated_admin tier (ADR-0105 D8) expands to its members like any other tier', async () => {
869+
engine._tables['sys_member'] = [
870+
{ id: 'm1', user_id: 'u1', role: 'delegated_admin', organization_id: 't1' },
871+
{ id: 'm2', user_id: 'u2', role: 'admin', organization_id: 't1' }, // other tier
872+
];
873+
const input = {
874+
...tierInput('org_membership_level'),
875+
config: {
876+
approvers: [{ type: 'org_membership_level' as any, value: 'delegated_admin' }],
877+
behavior: 'first_response' as const,
878+
lockRecord: true,
879+
},
880+
};
881+
const req = await svc.openNodeRequest(input, CTX);
882+
if ('autoApproved' in req) throw new Error('expected a pending request, not an auto-approve outcome');
883+
expect(req.pending_approvers).toEqual(['u1']);
884+
});
885+
865886
it('deprecated `role` alias resolves IDENTICALLY to org_membership_level', async () => {
866887
engine._tables['sys_member'] = [
867888
{ id: 'm1', user_id: 'u1', role: 'admin', organization_id: 't1' },

packages/spec/src/automation/approval.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
getApprovalNodeConfigJsonSchema,
1818
normalizeDecisionOutputs,
1919
} from './approval.zod';
20+
import { BUILTIN_MEMBERSHIP_ROLES } from '../identity/membership-role';
2021

2122
describe('ApproverType', () => {
2223
it('should accept all valid approver types', () => {
@@ -77,6 +78,29 @@ describe('ApproverType', () => {
7778
});
7879
});
7980

81+
// `ORG_MEMBERSHIP_LEVELS` once hand-spelled a three-value copy of the
82+
// membership vocabulary while `sys_member.role` enforced four, so the enforced
83+
// `delegated_admin` tier (ADR-0105 D8) could not be authored as an approver.
84+
// The list is now DERIVED from `BUILTIN_MEMBERSHIP_ROLES`; these pins keep the
85+
// derivation from ever being silently replaced by a copy again.
86+
describe('ORG_MEMBERSHIP_LEVELS derives from BUILTIN_MEMBERSHIP_ROLES', () => {
87+
it('is the same list, in the same display order, as the sys_member.role vocabulary', () => {
88+
expect([...ORG_MEMBERSHIP_LEVELS]).toEqual([...BUILTIN_MEMBERSHIP_ROLES]);
89+
});
90+
91+
it('admits delegated_admin — an enforced sys_member.role value — as an approver tier', () => {
92+
expect(ORG_MEMBERSHIP_LEVELS).toContain('delegated_admin');
93+
});
94+
95+
it('exposes the whole vocabulary on the wire picker surface, deprecated alias included', () => {
96+
expect(APPROVER_VALUE_SOURCES.org_membership_level).toEqual({
97+
source: 'enum',
98+
values: [...BUILTIN_MEMBERSHIP_ROLES],
99+
});
100+
expect(APPROVER_VALUE_SOURCES.role).toEqual(APPROVER_VALUE_SOURCES.org_membership_level);
101+
});
102+
});
103+
80104
// #3508: the designer contract for sourcing each approver row's `value`. The
81105
// record-backed kinds MUST match the engine's resolution semantics
82106
// (`plugin-approvals` resolveApproverSpec / expand*Users) — these assertions

packages/spec/src/automation/approval.zod.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { z } from 'zod';
4+
import { BUILTIN_MEMBERSHIP_ROLES } from '../identity/membership-role';
45
import { lazySchema } from '../shared/lazy-schema';
56
import { strictObject } from '../shared/strict-object';
67

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

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

141155
/**
142156
* The CLOSED root set an `expression` approver may reference (#3447 P2).

0 commit comments

Comments
 (0)