diff --git a/content/docs/references/identity/eval-user.mdx b/content/docs/references/identity/eval-user.mdx
new file mode 100644
index 0000000000..bef6604d4f
--- /dev/null
+++ b/content/docs/references/identity/eval-user.mdx
@@ -0,0 +1,61 @@
+---
+title: Eval User
+description: Eval User protocol schemas
+---
+
+{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs go in content/docs/guides/. */}
+
+EvalUser — the one user-context contract (ADR-0068 D1).
+
+The signed-in user exposed to every predicate surface (server formula, server
+
+RLS, client UI gates) under the canonical variable name `current_user`
+
+(aliases `user`, `ctx.user`) with an **identical shape**. A predicate such as
+
+`current_user.roles.exists(r, r == 'org_admin')` (or
+
+`'org_admin' in current_user.roles`) therefore evaluates identically wherever
+
+it is written.
+
+`roles: string[]` is the **only canonical** role field. Singular `role` is NOT
+
+part of this contract — its legacy "overwritten to 'admin' on promotion"
+
+behavior is the footgun this eliminates.
+
+@see docs/adr/0068-unified-user-context-and-built-in-identity-roles.md
+
+
+**Source:** `packages/spec/src/identity/eval-user.zod.ts`
+
+
+## TypeScript Usage
+
+```typescript
+import { EvalUser } from '@objectstack/spec/identity';
+import type { EvalUser } from '@objectstack/spec/identity';
+
+// Validate data
+const result = EvalUser.parse(data);
+```
+
+---
+
+## EvalUser
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **id** | `string` | ✅ | User ID |
+| **name** | `string` | optional | Display name |
+| **email** | `string` | optional | Email address |
+| **roles** | `string[]` | ✅ | Canonical role names assigned to the user (scope-resolved) |
+| **isPlatformAdmin** | `boolean` | optional | DERIVED alias of 'platform_admin' in roles. Deprecated. |
+| **organizationId** | `string \| null` | optional | Active organization ID (null = platform/unscoped) |
+
+
+---
+
diff --git a/content/docs/references/identity/index.mdx b/content/docs/references/identity/index.mdx
index d4e8b140a2..20acb8642e 100644
--- a/content/docs/references/identity/index.mdx
+++ b/content/docs/references/identity/index.mdx
@@ -6,6 +6,7 @@ description: Complete reference for all identity protocol schemas
This section contains all protocol schemas for the identity layer of ObjectStack.
+
diff --git a/content/docs/references/identity/meta.json b/content/docs/references/identity/meta.json
index 782d0bb300..89019b65e1 100644
--- a/content/docs/references/identity/meta.json
+++ b/content/docs/references/identity/meta.json
@@ -1,6 +1,7 @@
{
"title": "Identity Protocol",
"pages": [
+ "eval-user",
"identity",
"organization",
"role",
diff --git a/packages/formula/src/stdlib.ts b/packages/formula/src/stdlib.ts
index b409ef2e46..23e93c3be4 100644
--- a/packages/formula/src/stdlib.ts
+++ b/packages/formula/src/stdlib.ts
@@ -14,6 +14,7 @@
import type { Environment } from '@marcbachmann/cel-js';
import type { EvalContext } from './types';
+import { createEvalUser, type EvalUser } from '@objectstack/spec';
/**
* Calendar-day parts (y/m/d) of an instant *as seen in a timezone*
@@ -266,6 +267,34 @@ export function registerNumericCoercions(env: Environment): Environment {
return env;
}
+/**
+ * Normalize the loosely-typed EvalContext user into the canonical EvalUser
+ * (ADR-0068). `roles` is preferred; a legacy singular `role` is folded in so
+ * existing call sites keep working.
+ */
+function toEvalUser(u: NonNullable): EvalUser {
+ const legacyRole = typeof u.role === 'string' && u.role ? [u.role] : [];
+ const roles = Array.isArray(u.roles) ? (u.roles as string[]) : [];
+ const canonical = createEvalUser({
+ id: u.id,
+ name: typeof u.name === 'string' ? u.name : undefined,
+ email: typeof u.email === 'string' ? u.email : undefined,
+ roles: [...roles, ...legacyRole],
+ organizationId:
+ typeof u.organizationId === 'string' || u.organizationId === null
+ ? (u.organizationId as string | null)
+ : undefined,
+ });
+ // Back-compat: keep the DEPRECATED singular `role` readable so existing
+ // predicates (`os.user.role`, `current_user.role`) keep resolving during the
+ // ADR-0068 migration window. `roles[]` is the canonical surface; the footgun
+ // ADR-0068 removes is the server-side OVERWRITE of `role`, not read access.
+ if (typeof u.role === 'string' && u.role) {
+ (canonical as EvalUser & { role?: string }).role = u.role;
+ }
+ return canonical;
+}
+
/**
* Build the variable scope for a single evaluation. Absent fields are simply
* not bound — CEL macros (`has(record.foo)`) handle missing-key safely.
@@ -279,7 +308,19 @@ export function buildScope(ctx: EvalContext): Record {
// Namespaced data — written as `os.user.id`, `os.env`, etc. in CEL.
const os: Record = {};
- if (ctx.user !== undefined) os.user = ctx.user;
+ if (ctx.user !== undefined) {
+ // ADR-0068: one canonical EvalUser under every alias (`current_user`,
+ // `user`, `ctx.user`, `os.user`) — the SAME object, so a predicate
+ // evaluates identically wherever it is authored.
+ const currentUser = toEvalUser(ctx.user);
+ scope.current_user = currentUser;
+ scope.user = currentUser;
+ scope.ctx = {
+ ...(typeof scope.ctx === 'object' && scope.ctx !== null ? scope.ctx : {}),
+ user: currentUser,
+ };
+ os.user = currentUser;
+ }
if (ctx.org !== undefined) os.org = ctx.org;
if (ctx.env !== undefined) os.env = ctx.env;
if (Object.keys(os).length > 0) scope.os = os;
diff --git a/packages/formula/src/types.ts b/packages/formula/src/types.ts
index d743f972c0..70a2569829 100644
--- a/packages/formula/src/types.ts
+++ b/packages/formula/src/types.ts
@@ -30,9 +30,22 @@ export interface EvalContext {
* Defaults to `UTC` when unset. Calendar-day `date` rendering stays tz-naive.
*/
timezone?: string;
- /** Current authenticated subject (hook / action / view contexts). */
+ /**
+ * Current authenticated subject (hook / action / view contexts).
+ *
+ * ADR-0068: the canonical user contract is {@link EvalUser} from
+ * `@objectstack/spec`, surfaced to predicates as `current_user` (aliases
+ * `user`, `ctx.user`). `roles: string[]` is the only canonical role field;
+ * the singular `role` is deprecated (its "overwritten to 'admin' on
+ * promotion" behavior is the footgun ADR-0068 eliminates).
+ */
user?: {
id: string;
+ /** CANONICAL (ADR-0068). Scope-resolved role names assigned to the user. */
+ roles?: string[];
+ /** Active organization ID (null = platform / unscoped). */
+ organizationId?: string | null;
+ /** @deprecated ADR-0068 — use {@link roles}. Retained for back-compat only. */
role?: string;
email?: string;
[key: string]: unknown;
diff --git a/packages/formula/src/validate.ts b/packages/formula/src/validate.ts
index c5aac84d08..fcdcbd2d92 100644
--- a/packages/formula/src/validate.ts
+++ b/packages/formula/src/validate.ts
@@ -53,6 +53,14 @@ export interface ExprSchemaHint {
* did-you-mean *warning*. (Default.)
*/
scope?: 'record' | 'flattened';
+ /**
+ * ADR-0068 D4 — the closed catalog of valid role names (built-in + declared).
+ * When supplied, a role-membership predicate testing a role NOT in this set
+ * (e.g. `'org_admni' in current_user.roles`) is flagged as an error. Closes
+ * the AI-hallucination hole where a model invents a plausible-but-nonexistent
+ * role that then silently never matches. Absent => role checks are skipped.
+ */
+ roleCatalog?: readonly string[];
}
export interface ExprValidationError {
@@ -146,6 +154,51 @@ function levenshtein(a: string, b: string): number {
return dp[m];
}
+// ADR-0068 D4 — role-membership predicate heads: a role NAME literal used in a
+// membership test against a user subject's `.roles` (or the deprecated singular
+// `.role`). Matched names are validated against the closed role catalog.
+const ROLE_IN_RE = /(['"])([a-z0-9_]+)\1\s+in\s+(?:current_user|user|ctx\.user)\.roles\b/g;
+const ROLE_CONTAINS_RE = /(?:current_user|user|ctx\.user)\.roles\s*\.\s*contains\(\s*(['"])([a-z0-9_]+)\1\s*\)/g;
+// Bounded quantifiers ({0,N}, not * / *?) keep this linear: a CEL `exists`
+// body is tiny in practice, and unbounded greedy/lazy scanners here backtrack
+// polynomially (O(n^2)) on adversarial input like repeated `user.roles.exists(`
+// (ADR-0068 D4 ReDoS hardening). The pre-`==` class excludes `=` so the bounded
+// run stops cleanly before the operator without a lazy quantifier.
+const ROLE_EXISTS_RE = /(?:current_user|user|ctx\.user)\.roles\s*\.\s*exists\s*\([^,)]{0,64},[^)=]{0,128}==\s*(['"])([a-z0-9_]+)\1/g;
+const ROLE_EQ_RE = /(?:current_user|user|ctx\.user)\.role\s*==\s*(['"])([a-z0-9_]+)\1/g;
+
+/**
+ * Flag role-membership predicates referencing a role outside the closed catalog
+ * (ADR-0068 D4 — anti-hallucination). No-op when no `roleCatalog` is supplied.
+ */
+function checkRoleCatalog(
+ source: string,
+ schema: ExprSchemaHint | undefined,
+ errors: ExprValidationError[],
+): void {
+ const catalog = schema?.roleCatalog;
+ if (!catalog || catalog.length === 0) return;
+ const known = new Set(catalog);
+ const seen = new Set();
+ for (const re of [ROLE_IN_RE, ROLE_CONTAINS_RE, ROLE_EXISTS_RE, ROLE_EQ_RE]) {
+ re.lastIndex = 0;
+ let m: RegExpExecArray | null;
+ while ((m = re.exec(source)) !== null) {
+ const name = m[2];
+ if (known.has(name) || seen.has(name)) continue;
+ seen.add(name);
+ const suggestion = nearest(name, catalog);
+ errors.push({
+ source,
+ message:
+ `unknown role \`${name}\` — not a defined role` +
+ (suggestion ? `; did you mean \`${suggestion}\`?` : '.') +
+ ` Valid roles: ${catalog.join(', ')}.`,
+ });
+ }
+ }
+}
+
/**
* Validate one expression for a given field role. Never throws — returns a
* structured result. Call sites decide whether to throw (build/registration)
@@ -194,6 +247,7 @@ export function validateExpression(
});
} else {
checkFieldExistence(source, schema, errors);
+ checkRoleCatalog(source, schema, errors);
if (schema?.scope === 'record') {
// In a `record`-scoped site a bare top-level identifier is a silent bug —
// it must be `record.` (#1928). Hard error.
@@ -246,12 +300,14 @@ export function introspectScope(role: FieldRole, schema?: ExprSchemaHint): {
dialect: 'cel' | 'template';
fields: string[];
roots: string[];
+ roles: string[];
functions: string[];
} {
return {
dialect: expectedDialect(role),
fields: [...(schema?.fields ?? [])],
- roots: ['record', 'previous', 'input', 'os', 'vars'],
+ roots: ['record', 'previous', 'input', 'os', 'current_user', 'user', 'vars'],
+ roles: [...(schema?.roleCatalog ?? [])],
functions: CEL_STDLIB_FUNCTIONS,
};
}
diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts
index 1ab0900e19..be30ffd0a4 100644
--- a/packages/plugins/plugin-auth/src/auth-manager.test.ts
+++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts
@@ -1319,26 +1319,29 @@ describe('AuthManager', () => {
expect(result.user.roles).toEqual(['manager']);
});
- it('keeps stored business roles in roles[] when promoting a platform admin', async () => {
+ it('appends platform_admin to roles[] without overwriting role when promoting a platform admin', async () => {
const callback = await getSessionCallback(makeDataEngine({ platformAdmin: true }));
const result = await callback({
user: { id: 'u-1', email: 'a@b.com', role: 'manager' },
session: {},
});
- // Promotion replaces `role` (existing semantics) but the stored
- // business role survives in the array.
- expect(result.user.role).toBe('admin');
- expect(result.user.roles).toEqual(['manager', 'admin']);
+ // ADR-0068: NO `role:'admin'` overwrite footgun. The deprecated scalar
+ // keeps its stored value; the canonical platform_admin identity is added
+ // to roles[], and isPlatformAdmin is a derived alias.
+ expect(result.user.role).toBe('manager');
+ expect(result.user.roles).toEqual(['manager', 'platform_admin']);
+ expect(result.user.isPlatformAdmin).toBe(true);
});
- it('does not duplicate admin in roles[] when the stored role already includes it', async () => {
+ it('splits a multi-token stored role and appends platform_admin without duplicates', async () => {
const callback = await getSessionCallback(makeDataEngine({ platformAdmin: true }));
const result = await callback({
user: { id: 'u-1', email: 'a@b.com', role: 'admin,manager' },
session: {},
});
- expect(result.user.role).toBe('admin');
- expect(result.user.roles).toEqual(['admin', 'manager']);
+ expect(result.user.role).toBe('admin,manager');
+ expect(result.user.roles).toEqual(['admin', 'manager', 'platform_admin']);
+ expect(result.user.isPlatformAdmin).toBe(true);
});
it('returns the payload untouched when the user has no id', async () => {
diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts
index 2721a9cdef..f17699a42e 100644
--- a/packages/plugins/plugin-auth/src/auth-manager.ts
+++ b/packages/plugins/plugin-auth/src/auth-manager.ts
@@ -13,6 +13,7 @@ import type {
import type { IDataEngine } from '@objectstack/core';
import type { IEmailService } from '@objectstack/spec/contracts';
import { readEnvWithDeprecation } from '@objectstack/types';
+import { mapMembershipRole, BUILTIN_ROLE_PLATFORM_ADMIN } from '@objectstack/spec';
import { createObjectQLAdapterFactory } from './objectql-adapter.js';
import {
AUTH_USER_CONFIG,
@@ -967,10 +968,10 @@ export class AuthManager {
// `admin`. Org owners/admins are entitled to manage org-scoped
// metadata such as saved list views, dashboards, etc.
//
- // Either path synthesizes `user.role = 'admin'` so the frontend can gate
- // metadata-edit affordances uniformly. The raw membership role remains
- // available via the `organization` plugin's `member` payload for
- // finer-grained checks.
+ // ADR-0068 D2: rather than synthesizing `user.role = 'admin'`, both paths now
+ // contribute CANONICAL role names to `user.roles` (platform_admin / org_*),
+ // and `user.isPlatformAdmin` is a derived alias. The raw membership role
+ // remains available via the `organization` plugin's `member` payload.
const dataEngine = this.config.dataEngine;
if (dataEngine) {
const { customSession } = await import('better-auth/plugins/custom-session');
@@ -1000,37 +1001,42 @@ export class AuthManager {
}
};
- const isActiveOrgAdmin = async (): Promise => {
+ // ADR-0068 D2 — surface CANONICAL org_* role names (not a boolean flag):
+ // a membership owner/admin/member maps to org_owner/org_admin/org_member.
+ const activeOrgRoles = async (): Promise => {
try {
const orgId = (session as any)?.activeOrganizationId;
- if (!orgId) return false;
+ if (!orgId) return [];
const members = await dataEngine.find('sys_member', {
where: { user_id: user.id, organization_id: orgId },
limit: 5,
});
- return (Array.isArray(members) ? members : []).some((m: any) => {
- // better-auth org plugin stores roles as either a single string
- // or a comma-separated list (e.g. `"owner,admin"`). Treat any
- // membership that includes `owner` or `admin` as administrative.
+ const out: string[] = [];
+ for (const m of (Array.isArray(members) ? members : [])) {
const raw = typeof m?.role === 'string' ? m.role : '';
- const roles = raw.split(',').map((s: string) => s.trim().toLowerCase());
- return roles.includes('owner') || roles.includes('admin');
- });
+ for (const r of raw.split(',').map((s: string) => s.trim()).filter(Boolean)) {
+ const mapped = mapMembershipRole(r);
+ if (!out.includes(mapped)) out.push(mapped);
+ }
+ }
+ return out;
} catch {
- return false;
+ return [];
}
};
+ // ADR-0068 D1/D2 — emit ONE canonical roles[] (identities-as-roles), with
+ // NO `role:'admin'` overwrite. isPlatformAdmin is a DERIVED alias of
+ // `'platform_admin' in roles`, retained for back-compat clients.
const platformAdmin = await isPlatformAdmin();
- const promote = platformAdmin || (await isActiveOrgAdmin());
+ const orgRoles = await activeOrgRoles();
const storedRole = typeof (user as any).role === 'string' ? (user as any).role : '';
- const roles = storedRole
- .split(',')
- .map((s: string) => s.trim())
- .filter(Boolean);
- if (promote && !roles.includes('admin')) roles.push('admin');
- if (!promote) return { user: { ...user, roles, isPlatformAdmin: platformAdmin }, session };
- return { user: { ...user, role: 'admin', roles, isPlatformAdmin: platformAdmin }, session };
+ const roles = Array.from(new Set([
+ ...storedRole.split(',').map((s: string) => s.trim()).filter(Boolean),
+ ...orgRoles,
+ ...(platformAdmin ? [BUILTIN_ROLE_PLATFORM_ADMIN] : []),
+ ]));
+ return { user: { ...user, roles, isPlatformAdmin: platformAdmin }, session };
}));
}
diff --git a/packages/plugins/plugin-security/src/bootstrap-builtin-roles.ts b/packages/plugins/plugin-security/src/bootstrap-builtin-roles.ts
new file mode 100644
index 0000000000..90a212049c
--- /dev/null
+++ b/packages/plugins/plugin-security/src/bootstrap-builtin-roles.ts
@@ -0,0 +1,71 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * bootstrapBuiltinRoles — seed the framework's reserved built-in identity roles
+ * (ADR-0068 D2) into `sys_role`.
+ *
+ * The four built-in roles (`platform_admin`, `org_owner`, `org_admin`,
+ * `org_member`) are a normalized PROJECTION surfaced in `current_user.roles`.
+ * Seeding their `sys_role` rows makes the role catalog (consumed by role-bound
+ * permission sets, sharing-rule recipients, and the ADR-0068 D4 role-catalog
+ * validator) self-describing and AI-groundable. Their SOURCES OF TRUTH —
+ * `sys_member.role` for the org_* roles and the unscoped `admin_full_access`
+ * grant for platform_admin — are NEVER changed by this seed.
+ *
+ * Idempotent upsert-by-name, no prune. Rows are stamped `managed_by = 'system'`
+ * so tenants can see (but not repurpose) them. Runs on `kernel:ready` alongside
+ * the platform-admin and declared-role bootstraps.
+ */
+
+import { BUILTIN_ROLE_NAMES, BUILTIN_ROLE_METADATA } from '@objectstack/spec';
+
+const SYSTEM_CTX = { isSystem: true };
+
+function genId(prefix: string): string {
+ const rand = Math.random().toString(36).slice(2, 10);
+ const ts = Date.now().toString(36);
+ return `${prefix}_${ts}${rand}`;
+}
+
+async function tryFind(ql: any, object: string, where: any, limit = 100): Promise {
+ try {
+ const rows = await ql.find(object, { where, limit }, { context: SYSTEM_CTX });
+ return Array.isArray(rows) ? rows : [];
+ } catch { return []; }
+}
+async function tryInsert(ql: any, object: string, data: any): Promise {
+ try { return await ql.insert(object, data, { context: SYSTEM_CTX }); } catch { return null; }
+}
+async function tryUpdate(ql: any, object: string, data: any): Promise {
+ try { await ql.update(object, data, { context: SYSTEM_CTX }); return true; } catch { return false; }
+}
+
+interface SeedOptions {
+ logger?: { info: (m: string, meta?: Record) => void; warn: (m: string, meta?: Record) => void };
+}
+
+export async function bootstrapBuiltinRoles(
+ ql: any,
+ options: SeedOptions = {},
+): Promise<{ seeded: number; updated: number }> {
+ if (!ql || typeof ql.find !== 'function' || typeof ql.insert !== 'function') {
+ return { seeded: 0, updated: 0 };
+ }
+ let seeded = 0;
+ let updated = 0;
+ for (const name of BUILTIN_ROLE_NAMES) {
+ const meta = BUILTIN_ROLE_METADATA[name];
+ const fields = { label: meta.label, description: meta.description, managed_by: 'system' };
+ const existing = await tryFind(ql, 'sys_role', { name }, 1);
+ if (existing[0]?.id) {
+ if (await tryUpdate(ql, 'sys_role', { id: existing[0].id, ...fields })) updated += 1;
+ } else {
+ const created = await tryInsert(ql, 'sys_role', {
+ id: genId('role'), name, ...fields, active: true, is_default: false,
+ });
+ if (created) seeded += 1;
+ }
+ }
+ options.logger?.info?.('[security] built-in identity roles seeded into sys_role', { seeded, updated, total: BUILTIN_ROLE_NAMES.length });
+ return { seeded, updated };
+}
diff --git a/packages/plugins/plugin-security/src/objects/sys-role.object.ts b/packages/plugins/plugin-security/src/objects/sys-role.object.ts
index 4ae967bffa..ac0fd9e229 100644
--- a/packages/plugins/plugin-security/src/objects/sys-role.object.ts
+++ b/packages/plugins/plugin-security/src/objects/sys-role.object.ts
@@ -19,6 +19,10 @@ export const SysRole = ObjectSchema.create({
managedBy: 'config',
// ADR-0010 §3.7 — RBAC primitive; tenants may add custom rows
// (created via UI / API) but the schema itself is locked.
+ // ADR-0068 D3: role-DEFINITION authority follows the isolation boundary.
+ // Framework-reserved built-in roles (platform_admin / org_*) are seeded with
+ // `managed_by = 'system'` and MUST NOT be repurposed by a tenant; ad-hoc role
+ // definitions in a shared cross-tenant kernel namespace are forbidden.
protection: {
lock: 'no-overlay',
reason: 'RBAC schema is platform-defined — see ADR-0010.',
@@ -197,6 +201,16 @@ export const SysRole = ObjectSchema.create({
}),
// ── System ───────────────────────────────────────────────────
+ // ADR-0068 D2/D3 — provenance of this row. `system` = a framework-reserved
+ // built-in identity role (seeded by bootstrapBuiltinRoles); `config` =
+ // stack-declared; null / `user` = tenant-created. Built-in rows are read-only.
+ managed_by: Field.text({
+ label: 'Managed By',
+ readonly: true,
+ description: 'Provenance: system (built-in) / config (declared) / user (tenant)',
+ group: 'System',
+ }),
+
id: Field.text({
label: 'Role ID',
required: true,
diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts
index fa6ca5f763..1bd24e5897 100644
--- a/packages/plugins/plugin-security/src/security-plugin.ts
+++ b/packages/plugins/plugin-security/src/security-plugin.ts
@@ -4,6 +4,7 @@ import { Plugin, PluginContext } from '@objectstack/core';
import type { PermissionSet, RowLevelSecurityPolicy } from '@objectstack/spec/security';
import { PermissionEvaluator } from './permission-evaluator.js';
import { bootstrapDeclaredRoles } from './bootstrap-declared-roles.js';
+import { bootstrapBuiltinRoles } from './bootstrap-builtin-roles.js';
import { bootstrapSystemCapabilities } from './bootstrap-system-capabilities.js';
import { RLSCompiler, RLS_DENY_FILTER } from './rls-compiler.js';
import { matchesFilterCondition } from '@objectstack/formula';
@@ -719,6 +720,13 @@ export class SecurityPlugin implements Plugin {
} catch (e) {
ctx.logger.warn('[security] declared-role seeding failed', { error: (e as Error).message });
}
+ // [ADR-0068 D2] Seed the framework's reserved built-in identity roles
+ // (platform_admin / org_*) so the role catalog is self-describing.
+ try {
+ await bootstrapBuiltinRoles(ql, { logger: ctx.logger });
+ } catch (e) {
+ ctx.logger.warn('[security] built-in role seeding failed', { error: (e as Error).message });
+ }
// [ADR-0066 D1] Back-compat seed the capability registry (sys_capability)
// from the curated platform set + the default grants' systemPermissions.
try {
diff --git a/packages/runtime/src/security/resolve-execution-context.ts b/packages/runtime/src/security/resolve-execution-context.ts
index 4a1a0dee81..1a193fadc8 100644
--- a/packages/runtime/src/security/resolve-execution-context.ts
+++ b/packages/runtime/src/security/resolve-execution-context.ts
@@ -22,6 +22,11 @@
import type { ExecutionContext } from '@objectstack/spec/kernel';
import { resolveApiKeyPrincipal } from './api-key.js';
+import {
+ mapMembershipRole,
+ BUILTIN_ROLE_PLATFORM_ADMIN,
+ ADMIN_FULL_ACCESS,
+} from '@objectstack/spec';
interface ResolveOptions {
/** Function returning a service from the active kernel (or undefined). */
@@ -237,7 +242,10 @@ export async function resolveExecutionContext(opts: ResolveOptions): Promise s.trim()).filter(Boolean)) {
+ for (const raw of m.role.split(',').map((s: string) => s.trim()).filter(Boolean)) {
+ // ADR-0068 D2: normalize better-auth owner/admin/member to the canonical
+ // org_owner/org_admin/org_member built-in role names.
+ const r = mapMembershipRole(raw);
if (!ctx.roles!.includes(r)) ctx.roles!.push(r);
}
}
@@ -308,6 +316,18 @@ export async function resolveExecutionContext(opts: ResolveOptions): Promise(
+ upsRows
+ .filter((r) => ((r.organization_id ?? r.organizationId) ?? null) === null)
+ .map((r) => r.permission_set_id ?? r.permissionSetId)
+ .filter(Boolean),
+ );
+ let hasPlatformAdminGrant = false;
+
// Resolve role-bound permission sets.
if (ctx.roles!.length > 0) {
const roleRows = await tryFind(ql, 'sys_role', { name: { $in: ctx.roles } }, 100);
@@ -346,6 +366,10 @@ export async function resolveExecutionContext(opts: ResolveOptions): Promise platform admin.
+ if (ps.name === ADMIN_FULL_ACCESS && unscopedUserPsIds.has(ps.id)) {
+ hasPlatformAdminGrant = true;
+ }
// System permissions may be stored as JSON string in DB rows.
const sysPerms = typeof ps.system_permissions === 'string'
? safeJsonParse(ps.system_permissions, [])
@@ -375,6 +399,12 @@ export async function resolveExecutionContext(opts: ResolveOptions): Promise `admin_full_access`.
+ */
+export const BUILTIN_ROLE_PLATFORM_ADMIN = 'platform_admin';
+/** Organization owner within a tenant. Source: `sys_member.role = owner`. */
+export const BUILTIN_ROLE_ORG_OWNER = 'org_owner';
+/** Organization administrator within a tenant. Source: `sys_member.role = admin`. */
+export const BUILTIN_ROLE_ORG_ADMIN = 'org_admin';
+/** Organization member within a tenant. Source: `sys_member.role = member`. */
+export const BUILTIN_ROLE_ORG_MEMBER = 'org_member';
+
+/**
+ * The reserved, framework-seeded role names (ADR-0068 D2). These are a
+ * normalized **projection** into `current_user.roles`; their sources of truth
+ * (membership rows, the unscoped admin link) are never changed by the projection.
+ */
+export const BUILTIN_ROLE_NAMES = [
+ BUILTIN_ROLE_PLATFORM_ADMIN,
+ BUILTIN_ROLE_ORG_OWNER,
+ BUILTIN_ROLE_ORG_ADMIN,
+ BUILTIN_ROLE_ORG_MEMBER,
+] as const;
+
+export type BuiltinRoleName = (typeof BUILTIN_ROLE_NAMES)[number];
+
+/**
+ * Permission-set name whose unscoped grant is the source of truth for
+ * `platform_admin` (ADR-0068 D2).
+ */
+export const ADMIN_FULL_ACCESS = 'admin_full_access';
+
+/** Human-readable metadata for the built-in roles (seeded into `sys_role`; AI grounding). */
+export const BUILTIN_ROLE_METADATA: Record = {
+ [BUILTIN_ROLE_PLATFORM_ADMIN]: { label: 'Platform Admin', description: 'Platform operator (SaaS admin). NOT a tenant user role.' },
+ [BUILTIN_ROLE_ORG_OWNER]: { label: 'Organization Owner', description: 'Organization owner within a tenant.' },
+ [BUILTIN_ROLE_ORG_ADMIN]: { label: 'Organization Admin', description: 'Organization administrator within a tenant.' },
+ [BUILTIN_ROLE_ORG_MEMBER]: { label: 'Organization Member', description: 'Organization member within a tenant.' },
+};
+
+/** Normalize a raw better-auth membership role (owner/admin/member) to its canonical
+ * built-in role name (org_owner/org_admin/org_member). Unknown values pass through. */
+export function mapMembershipRole(raw: string): string {
+ switch (raw.trim().toLowerCase()) {
+ case 'owner': return BUILTIN_ROLE_ORG_OWNER;
+ case 'admin': return BUILTIN_ROLE_ORG_ADMIN;
+ case 'member': return BUILTIN_ROLE_ORG_MEMBER;
+ default: return raw.trim();
+ }
+}
+
+// ==========================================
+// Contract
+// ==========================================
+
+export const EvalUserSchema = lazySchema(() =>
+ z.object({
+ id: z.string().describe('User ID'),
+ name: z.string().optional().describe('Display name'),
+ email: z.string().optional().describe('Email address'),
+ /** CANONICAL. Scope-resolved (ADR-0068 D3); includes built-in + business roles. */
+ roles: z.array(z.string()).default([]).describe('Canonical role names assigned to the user (scope-resolved)'),
+ /** DERIVED alias of roles.includes(platform_admin) (ADR-0068 D2). Deprecated surface. */
+ isPlatformAdmin: z.boolean().optional().describe("DERIVED alias of 'platform_admin' in roles. Deprecated."),
+ organizationId: z.string().nullable().optional().describe('Active organization ID (null = platform/unscoped)'),
+ })
+);
+
+export type EvalUser = z.infer;
+/** Authoring input for EvalUser — defaulted fields are optional. */
+export type EvalUserInput = z.input;
+
+/**
+ * Build a canonical EvalUser from loosely-typed source fields. The single factory
+ * every surface uses (server buildScope, the customSession bridge, objectui
+ * fallback/guest/preview users) so the user shape — and the isPlatformAdmin
+ * derivation — never drifts. isPlatformAdmin is always derived from roles.
+ */
+export function createEvalUser(input: {
+ id: string;
+ name?: string | null;
+ email?: string | null;
+ roles?: readonly string[] | null;
+ organizationId?: string | null;
+}): EvalUser {
+ const roles = Array.from(
+ new Set((input.roles ?? []).map((r) => String(r).trim()).filter(Boolean))
+ );
+ return {
+ id: input.id,
+ ...(input.name != null ? { name: input.name } : {}),
+ ...(input.email != null ? { email: input.email } : {}),
+ roles,
+ isPlatformAdmin: roles.includes(BUILTIN_ROLE_PLATFORM_ADMIN),
+ ...(input.organizationId !== undefined ? { organizationId: input.organizationId } : {}),
+ };
+}
diff --git a/packages/spec/src/identity/index.ts b/packages/spec/src/identity/index.ts
index cd9c375dee..1730b247fd 100644
--- a/packages/spec/src/identity/index.ts
+++ b/packages/spec/src/identity/index.ts
@@ -6,3 +6,4 @@ export * from './role.zod';
export { roleForm } from './role.form';
export * from './organization.zod';
export * from './scim.zod';
+export * from './eval-user.zod';
diff --git a/packages/spec/src/index.ts b/packages/spec/src/index.ts
index 17c153b41f..6fc7cabdef 100644
--- a/packages/spec/src/index.ts
+++ b/packages/spec/src/index.ts
@@ -138,3 +138,18 @@ export type {
PredicateInput,
} from './shared/expression.zod';
+
+// ADR-0068: unified user-context contract (EvalUser) + built-in identity roles.
+export {
+ createEvalUser,
+ mapMembershipRole,
+ EvalUserSchema,
+ BUILTIN_ROLE_NAMES,
+ BUILTIN_ROLE_METADATA,
+ BUILTIN_ROLE_PLATFORM_ADMIN,
+ BUILTIN_ROLE_ORG_OWNER,
+ BUILTIN_ROLE_ORG_ADMIN,
+ BUILTIN_ROLE_ORG_MEMBER,
+ ADMIN_FULL_ACCESS,
+} from './identity/eval-user.zod';
+export type { EvalUser, EvalUserInput, BuiltinRoleName } from './identity/eval-user.zod';
diff --git a/packages/spec/src/ui/app.zod.ts b/packages/spec/src/ui/app.zod.ts
index 0a70329e14..0ce79fc453 100644
--- a/packages/spec/src/ui/app.zod.ts
+++ b/packages/spec/src/ui/app.zod.ts
@@ -46,7 +46,7 @@ const BaseNavItemSchema = z.object({
* Formula expression returning boolean.
* e.g. "user.is_admin || user.department == 'sales'"
*/
- visible: ExpressionInputSchema.optional().describe('Visibility predicate (CEL). e.g. P`os.user.role == "admin"`'),
+ visible: ExpressionInputSchema.optional().describe('Visibility predicate (CEL). e.g. P`\'org_admin\' in current_user.roles`'),
/** Permissions required to see/access this navigation item */
requiredPermissions: z.array(z.string()).optional().describe('Permissions required to access this item'),