From 7763414d6fea1bacce7619528ad2cdfa631eb75d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 08:43:56 +0000 Subject: [PATCH 1/2] fix(spec): derive ORG_MEMBERSHIP_LEVELS from BUILTIN_MEMBERSHIP_ROLES MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_016D9wdJR14KKCxz1WgdAzcw --- .changeset/derive-org-membership-levels.md | 5 ++++ .../src/approval-service.test.ts | 20 ++++++++++++++++ packages/spec/src/automation/approval.test.ts | 24 +++++++++++++++++++ packages/spec/src/automation/approval.zod.ts | 22 +++++++++++++---- 4 files changed, 67 insertions(+), 4 deletions(-) create mode 100644 .changeset/derive-org-membership-levels.md diff --git a/.changeset/derive-org-membership-levels.md b/.changeset/derive-org-membership-levels.md new file mode 100644 index 0000000000..00a9b0369e --- /dev/null +++ b/.changeset/derive-org-membership-levels.md @@ -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". diff --git a/packages/plugins/plugin-approvals/src/approval-service.test.ts b/packages/plugins/plugin-approvals/src/approval-service.test.ts index 618358e8e9..28de550be2 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.test.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.test.ts @@ -862,6 +862,26 @@ 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); + 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' }, diff --git a/packages/spec/src/automation/approval.test.ts b/packages/spec/src/automation/approval.test.ts index a47affe404..35004d62e8 100644 --- a/packages/spec/src/automation/approval.test.ts +++ b/packages/spec/src/automation/approval.test.ts @@ -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', () => { @@ -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 diff --git a/packages/spec/src/automation/approval.zod.ts b/packages/spec/src/automation/approval.zod.ts index 3811e7daa8..aa3eacb6ba 100644 --- a/packages/spec/src/automation/approval.zod.ts +++ b/packages/spec/src/automation/approval.zod.ts @@ -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'; @@ -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. @@ -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). From 8ec58a4db38b8f80816e0b2ecadfda71bb1a9b6d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 09:08:11 +0000 Subject: [PATCH 2/2] 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 Claude-Session: https://claude.ai/code/session_016D9wdJR14KKCxz1WgdAzcw --- packages/plugins/plugin-approvals/src/approval-service.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/plugins/plugin-approvals/src/approval-service.test.ts b/packages/plugins/plugin-approvals/src/approval-service.test.ts index 28de550be2..4e6d5c2997 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.test.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.test.ts @@ -879,6 +879,7 @@ describe('ApprovalService (node era)', () => { }, }; 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']); });