Skip to content

fix(spec): derive ORG_MEMBERSHIP_LEVELS from BUILTIN_MEMBERSHIP_ROLES — delegated_admin becomes an addressable approver tier - #9942

Merged
os-elon merged 2 commits into
mainfrom
claude/issue-9806-approver-tier-derive
Aug 19, 2026
Merged

fix(spec): derive ORG_MEMBERSHIP_LEVELS from BUILTIN_MEMBERSHIP_ROLES — delegated_admin becomes an addressable approver tier#9942
os-elon merged 2 commits into
mainfrom
claude/issue-9806-approver-tier-derive

Conversation

@os-elon

@os-elonos-elon commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Fixes#9806

Clause-② notice: this PR widens an accept setdelegated_admin (ObjectStack's ADR-0105 D8 membership tier, already storable and enforced on sys_member.role) becomes a valid, offered org_membership_level approver value. Additive only; nothing is removed or narrowed. Draft by dispatch contract — the PM reviews and lands it.

Ruling implemented

Maintainer ruling of 2026-08-19 on #9806 (comment 5339439775, Option B): this is drift, not deliberate exclusion. Derive ORG_MEMBERSHIP_LEVELS from BUILTIN_MEMBERSHIP_ROLES (single source) instead of re-spelling it, so the approver picker offers the whole sys_member.role vocabulary and the next tier addition cannot silently miss the approver surface; fix the provenance doc-comment in the same change.

Derivation shape chosen, and why

export const ORG_MEMBERSHIP_LEVELS = BUILTIN_MEMBERSHIP_ROLES; — a pure re-export alias, not a spread copy.

  • Type-level guarantees preserved and strengthened: BUILTIN_MEMBERSHIP_ROLES is an as const readonly tuple of literal types, so the alias keeps the exact shape consumers relied on (readonly [...] tuple, literal member types, typeof ORG_MEMBERSHIP_LEVELS[number] still works) — it simply grows from a 3-tuple to the 4-tuple the column enforces.
  • Display order inherited: ADR-0108's list is "the WHOLE vocabulary, in display order", so the strict select renders owner / admin / delegated_admin / member with zero extra ordering logic here.
  • A spread ([...BUILTIN_MEMBERSHIP_ROLES] as const) would also type correctly but manufactures a second array object for no benefit; the alias makes "one source" literal.
  • The ApproverValueBinding enum arm types values as readonly string[], so APPROVER_VALUE_BINDINGS / APPROVER_VALUE_SOURCES (including the deprecated role alias row) follow the derivation with no signature change.

Provenance fix

The constant's doc-comment claimed the list was "sys_member.role: better-auth's closed set" — false since ADR-0105 D8: delegated_admin is ObjectStack's own addition. The comment now states the vocabulary is BUILTIN_MEMBERSHIP_ROLES, ObjectStack-owned (ADR-0108's closed membership list), with ADR-0105 D8 as the fourth member's provenance, and records why deriving (not transcribing) is the fix. The ApproverType enum's inline comment on the org_membership_level member repeated the same stale three-value/better-auth attribution and is corrected in the same change. (ADR prose referenced only — no docs/adr/** edits.)

Anti-drift pins

  • spec (approval.test.ts): ① [...ORG_MEMBERSHIP_LEVELS] equals [...BUILTIN_MEMBERSHIP_ROLES] — same list, same display order, so the derivation can never be silently replaced by a copy; ② delegated_admin is admitted; ③ the wire picker surface (APPROVER_VALUE_SOURCES.org_membership_level, deprecated role alias included) carries the whole vocabulary.
  • plugin-approvals (approval-service.test.ts): a delegated_admin approver expands to its sys_member members at runtime like any other tier (the expander filters sys_member.role by the authored value — no tier list on that path).
  • lint already pins acceptance of delegated_admin and refusal of guest — see triage below.

Consumer fixture triage (accept-set change — every known consumer audited, real readings)

SurfaceReadingDisposition
packages/lint/src/validate-approval-approvers.tsAlready derived: MEMBERSHIP_TIERS = new Set(BUILTIN_MEMBERSHIP_ROLES) (line 120, four values), diagnostics render from the same listNo change needed
packages/lint/src/validate-approval-approvers.test.tsAccept pin for delegated_admin and refusal pin for guest already present (lines 37–57) — no fixture pinned the old three-value refusalNo flip needed; suite green
packages/plugins/plugin-approvals/src/approval-service.tsexpandMembershipTierUsers passes the authored value straight to the sys_member.role filter — no hardcoded tier list; the alias/type tables carry type names, not tier valuesNo change; runtime accept pin added
packages/plugins/plugin-approvals/src/approver-org-scope.tsMentions the approver type name only, never tier valuesNo change
packages/spec/src/automation/approval.test.tsExisting assertions referenced ORG_MEMBERSHIP_LEVELS symbolically — they follow the derivation but could not catch a copy (proven: they stayed green under the reverse-verification mutation)New pins added (above)
examples/app-showcase flowsAuthor value: 'owner' only — valid under both setsNo change
Whole-tree grep (org_membership_level, three-value tier spellings)Remaining hits are prose/type-name references (position.zod.ts, service comments) — no other fixture pins the old set in this repoNo change
objectui (out of scope for this repo)FlowReferenceField.tsx hardcodes a three-value fallback and its strict select ignores the server-published enum source; stored delegated_admin renders "(invalid)"; two label/comment echoes in flow-node-config.tsFiled unassigned: objectstack-ai/objectui#5309

Remaining references audit

ORG_MEMBERSHIP_LEVELS referents after the change: the derivation site, the two APPROVER_VALUE_BINDINGS rows (org_membership_level + deprecated role), the APPROVER_VALUE_SOURCES projection, spec tests, and the api-surface/export-origins snapshots (existence entries — unchanged, check:generated confirms all 13 artifacts current). No consumer re-spells the tier list in this repo any more; the only remaining hand copy is objectui's (filed above).

Reverse verification (from the committed state)

Replaced the derivation with the old ['owner', 'admin', 'member'] as const copy in srcexactly the 3 new pins red, all 11,019 other spec tests green (Test Files 1 failed | 413 passed, Tests 3 failed | 11019 passed). Observed direction as predicted — and the pre-existing symbolic assertions stayed green under the copy, confirming the new pins are the only guard against this drift. Restored by checking the mutated file back out of the branch's committed fix (git checkout of the branch ref, path-scoped — the fix was committed before the mutation, so a real restore point existed). Both legs ran source-resolved (the spec test imports ./approval.zod relatively, in-package — no dist in that measurement loop, so no rebuild was required for either leg; spec dist was rebuilt afterwards before the ratchet re-measure).

Verification (all at head 8ec58a4)

  • Suites, full runs, all green: spec 414 files / 11,022 tests · lint 76 / 2,106 · plugin-approvals 24 / 497. Typecheck green for all three (tsc --noEmit; spec includes its test-layer ledger check, "OK").
  • pnpm --filter @objectstack/spec check:generated: "All 13 generated artifacts are up to date" — no artifact moved (the doc-comment is not a .describe(), and api-surface records export existence).
  • node scripts/pm/dispatch-gates.mjs re-derived on the actual committed diff (no hand-fed paths). Every named gate run and green at head 8ec58a4: all PM dispatch-time readings (check:cross-package-test-inputs + node twin, check:doc-formula-expressions, spec check:empty-state / check:liveness / check:strictness-ledger / check:variant-docs, check:merge-driver, check:slot-lookup, check:spec-parsed-alias, check:test-source-alias, check:type-source-resolution, check-dev-prereqs, check-affected-docs) plus the derivation's additions (check:changeset-gate-self-tests, check:objectui-changeset, check-adr-0087-registration, check-changeset-no-major, check-empty-changeset, check:nul-bytes) and the convention-triggered set on edited test files (check:query-options-erasure, check:type-check-coverage, check:engine-double-contract, check:where-matcher, check:i18n, and check:type-check-debt --re-measure on the fully built workspace — "1926 raw tsc error(s) total, none above its recorded number", surplus none).
  • The ratchet caught one real thing during development: the new plugin-approvals pin initially added a 349th frozen test-layer error (TS2339 on the un-narrowed openNodeRequest union); fixed by guarding the union the way the sibling test does — the ledger was not touched.

Changeset / ADR-0087

One changeset: @objectstack/specminor (accept-set widened, additive — never major). No changeset for lint (untouched) or plugin-approvals (test-only edit, nothing user-visible ships). ADR-0087: not required — the widening admits an already-enforced value of the same column; no authored metadata changes shape, nothing is removed or renamed, so there is no conversion or migration to register, and the marker gate applies only to declared-breaking changesets (check-adr-0087-registration green at head).

Out of scope

Filed unassigned: objectstack-ai/objectui#5309 (Studio's membership-tier select hardcodes the three-value fallback, ignores the server-published enum source on the enum path, and renders a stored delegated_admin as "(invalid)"). The ObjectOS approvals docs echo was already on file per the issue body and inherits the four-value vocabulary via objectstack-ai/objectos#131.


Generated by Claude Code

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
…in 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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 2 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/approvals.mdx(via ApproverType (symbol))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx(via ApproverType (symbol))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see

Coarse fallback — 115 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 90417a8085ce3747a590e1ecb2c62fe859f195a2packageMentionDocs.

Which tree this was computed on

This run read content/docs from c28da976a4e66b9a37bb14f5475e1cd3e53f355a — the merge of head 8ec58a4db38b8f80816e0b2ecadfda71bb1a9b6d into base 90417a8085ce3747a590e1ecb2c62fe859f195a2, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin c28da976a4e66b9a37bb14f5475e1cd3e53f355a && git checkout c28da976a4e66b9a37bb14f5475e1cd3e53f355a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 90417a8085ce3747a590e1ecb2c62fe859f195a2 8ec58a4db38b8f80816e0b2ecadfda71bb1a9b6d && git checkout -B drift-repro 90417a8085ce3747a590e1ecb2c62fe859f195a2 && git merge --no-ff 8ec58a4db38b8f80816e0b2ecadfda71bb1a9b6d
node scripts/docs-audit/affected-docs.mjs --json 90417a8085ce3747a590e1ecb2c62fe859f195a2

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 90417a8085ce3747a590e1ecb2c62fe859f195a2 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

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

Labels

documentationImprovements or additions to documentationsize/steststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

org_membership_level approver sources a three-value tier list while sys_member.role enforces four (delegated_admin unreachable)

2 participants

@os-elon@claude