From 35709496cdda480abbbbeec3eec83bb8115eec5a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 15:56:32 +0000 Subject: [PATCH 1/3] fix(spec): describe EvalUser.isPlatformAdmin as the ADR-0095 D3 PLATFORM_ADMIN standing The key is the predicate platform-operator gates read (ADR-0068 D4) and the session emits it from the posture rung, yet its JSDoc and published description still called it a deprecated alias derived from positions. Lift the mark and describe what it reports. Description and JSDoc only: the schema shape, optionality and createEvalUser are unchanged. Adds a pin on the published JSON Schema description. Claude-Session: https://claude.ai/code/session_01GV6oYwgc1kWiUCb1YaprQ7 Co-authored-by: Claude --- packages/spec/src/identity/eval-user.test.ts | 43 ++++++++++++++++++++ packages/spec/src/identity/eval-user.zod.ts | 19 ++++++++- 2 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 packages/spec/src/identity/eval-user.test.ts diff --git a/packages/spec/src/identity/eval-user.test.ts b/packages/spec/src/identity/eval-user.test.ts new file mode 100644 index 00000000000..d0485ef0c1e --- /dev/null +++ b/packages/spec/src/identity/eval-user.test.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +import { EvalUserSchema } from './eval-user.zod'; + +// `EvalUserSchema.isPlatformAdmin` is the predicate platform-operator gates +// read (ADR-0068 D4), and its published description is what an author — human +// or AI — consults before writing that gate. These pins hold the description to +// the standing it actually reports (ADR-0095 D3) and keep the superseded +// "deprecated, derived alias" mark from coming back: a deprecated mark on the +// one key the gates agree on steers authors onto the positions array, which is +// the read every evaluator refuses. + +const PUBLISHED = { target: 'draft-2020-12', io: 'input', unrepresentable: 'any' } as const; + +function publishedDescription(): string { + const json = z.toJSONSchema(EvalUserSchema, PUBLISHED) as { + properties?: Record; + }; + const prop = json.properties?.isPlatformAdmin; + expect(prop, 'isPlatformAdmin is missing from the published JSON Schema').toBeDefined(); + expect(prop?.deprecated).toBeUndefined(); + return prop?.description ?? ''; +} + +describe('EvalUserSchema.isPlatformAdmin — the published description', () => { + it('names the PLATFORM_ADMIN standing of the posture-ladder decision', () => { + const doc = publishedDescription(); + expect(doc).toContain('PLATFORM_ADMIN standing'); + expect(doc).toContain('ADR-0095 D3'); + expect(doc).toContain('ADR-0068 D4'); + }); + + it('carries no deprecation word and no deprecated flag', () => { + // `publishedDescription()` also asserts the JSON Schema `deprecated` flag is absent. + expect(publishedDescription()).not.toMatch(/deprecat/i); + }); + + it('publishes the same text the schema declares at the point of use', () => { + expect(EvalUserSchema.shape.isPlatformAdmin.description).toBe(publishedDescription()); + }); +}); diff --git a/packages/spec/src/identity/eval-user.zod.ts b/packages/spec/src/identity/eval-user.zod.ts index 8a695a2da2d..73dc1f88880 100644 --- a/packages/spec/src/identity/eval-user.zod.ts +++ b/packages/spec/src/identity/eval-user.zod.ts @@ -222,8 +222,23 @@ export const EvalUserSchema = lazySchema(() => * `sys_user.role` scalar, which stays published as `user.role`. */ positions: z.array(z.string()).default([]).describe('Canonical position/identity names the user holds — built-in identity names plus sys_position assignments, scope-resolved (ADR-0068 D3). The security axis, the same set /auth/me/permissions reports; NOT the better-auth user.role scalar, which remains published as user.role'), - /** DERIVED alias of positions.includes(platform_admin) (ADR-0068 D2). Deprecated surface. */ - isPlatformAdmin: z.boolean().optional().describe("DERIVED alias of 'platform_admin' in positions. Deprecated."), + /** + * The `PLATFORM_ADMIN` standing of ADR-0095 D3: true when the platform + * resolves the user, per request, to the `PLATFORM_ADMIN` posture rung — + * from the deployment's declared administrator list + * (`OS_PLATFORM_OWNER_EMAIL`) under every tenancy posture, or from an + * unscoped `admin_full_access` grant under the `single` posture. It is the + * predicate platform-operator gates read + * (`current_user.isPlatformAdmin == true`, ADR-0068 D4). + * + * The resolver projects the `platform_admin` name into `positions` from the + * same grant, so the name and this key agree for every genuine + * administrator. ⛔ Gate on this key, never on + * `'platform_admin' in positions`: the session payload emits this key from + * the rung, and the platform-admin route gate and `hasPlatformAdminStanding` + * judge by the rung too — none of them reads the array. + */ + isPlatformAdmin: z.boolean().optional().describe("The PLATFORM_ADMIN standing of ADR-0095 D3: true when the platform resolves the user, per request, to the PLATFORM_ADMIN posture rung — from the deployment's declared administrator list (OS_PLATFORM_OWNER_EMAIL) under every tenancy posture, or from an unscoped admin_full_access grant under the single posture. The predicate platform-operator gates read (current_user.isPlatformAdmin == true, ADR-0068 D4). The resolver projects 'platform_admin' into positions from the same grant, so the two agree for every genuine administrator; gate on this key, never on the positions array."), organizationId: z.string().nullable().optional().describe('Active organization ID (null = platform/unscoped)'), }) ); From a1ea530c0024b8d1a1a6bc9bcb289248a98ee32d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 16:04:56 +0000 Subject: [PATCH 2/3] docs: isPlatformAdmin is the PLATFORM_ADMIN standing, with dated ADR-0068 D2/D4 notes - ADR-0068: one dated note under D2 and one under D4 naming ADR-0095 D3 and EvalUserSchema.isPlatformAdmin as the superseding text. The original wording is not rewritten. - permission-metadata.mdx: the "derived, deprecated alias" aside now describes the standing and says gates read the key, never the array. - authentication.mdx: the admin user-management routes are gated on isPlatformAdmin with the legacy better-auth scalar as fallback. The platform_admin position is not one of the gate's signals. - Changeset for @objectstack/spec (patch). Claude-Session: https://claude.ai/code/session_01GV6oYwgc1kWiUCb1YaprQ7 Co-authored-by: Claude --- .changeset/22012-isplatformadmin-standing.md | 13 +++++++++++++ content/docs/permissions/authentication.mdx | 10 ++++++---- content/docs/permissions/permission-metadata.mdx | 9 +++++++-- ...fied-user-context-and-built-in-identity-roles.md | 4 ++++ 4 files changed, 30 insertions(+), 6 deletions(-) create mode 100644 .changeset/22012-isplatformadmin-standing.md diff --git a/.changeset/22012-isplatformadmin-standing.md b/.changeset/22012-isplatformadmin-standing.md new file mode 100644 index 00000000000..5741e597ee9 --- /dev/null +++ b/.changeset/22012-isplatformadmin-standing.md @@ -0,0 +1,13 @@ +--- +"@objectstack/spec": patch +--- + +`EvalUserSchema.isPlatformAdmin` is no longer marked deprecated. Its describe and docblock now say what the key reports: the `PLATFORM_ADMIN` standing of ADR-0095 D3. + +Clause-②: no + +- The platform resolves that standing per request, from the deployment's declared administrator list (`OS_PLATFORM_OWNER_EMAIL`) under every tenancy posture, or from an unscoped `admin_full_access` grant under the `single` posture. +- It is the predicate platform-operator gates read: `current_user.isPlatformAdmin == true` (ADR-0068 D4). The session payload emits it from the posture rung, and the platform-admin route gate reads it. +- The resolver projects the `platform_admin` name into `positions` from the same grant, so the name and the key agree for every genuine administrator. Gate on the key, never on `'platform_admin' in current_user.positions`. +- The old text, "DERIVED alias of 'platform_admin' in positions. Deprecated.", described a reading ADR-0095 D3 superseded. ADR-0068 carries dated notes under D2 and D4 that say so. +- ⛔ Nothing you author changes. There is no schema, type, optionality, default, export or accept-set change, and `createEvalUser` computes exactly what it did. A predicate that already reads `current_user.isPlatformAdmin` keeps working and is the supported form. diff --git a/content/docs/permissions/authentication.mdx b/content/docs/permissions/authentication.mdx index 1b02544f137..638c190cf0e 100644 --- a/content/docs/permissions/authentication.mdx +++ b/content/docs/permissions/authentication.mdx @@ -923,10 +923,12 @@ concludes the platform cannot attach an existing user to an organization at all. With `plugins: { admin: true }` (forced on when SCIM is enabled), platform admins get email-independent account management on the Users list — the `create_user` / `set_user_password` actions there drive these endpoints. All -three routes are gated server-side on the platform-admin signals -(`isPlatformAdmin`, the `platform_admin` position, or the legacy `role` -scalar) and drive the better-auth pipeline, so created accounts get a real -scrypt-hashed credential and can sign in immediately. +three routes are gated server-side on the session's `isPlatformAdmin` — the +`PLATFORM_ADMIN` standing of ADR-0095 D3 — with the legacy better-auth `role` +scalar of `admin` still accepted as a back-compat fallback; the +`platform_admin` name in `positions` is not read. They drive the better-auth +pipeline, so created accounts get a real scrypt-hashed credential and can sign +in immediately. #### Create a User Directly diff --git a/content/docs/permissions/permission-metadata.mdx b/content/docs/permissions/permission-metadata.mdx index 91dda47f05f..70e02abf2b4 100644 --- a/content/docs/permissions/permission-metadata.mdx +++ b/content/docs/permissions/permission-metadata.mdx @@ -223,8 +223,13 @@ membership with CEL: `'org_admin' in current_user.positions` or `current_user.positions.exists(p, p == 'sales_manager')`. The framework-seeded built-in position names are `platform_admin`, `org_owner`, `org_admin`, and `org_member`; `everyone`/`guest` are the implicit audience anchors (ADR-0090 D9). -(`current_user.isPlatformAdmin` is a derived, deprecated alias of -`'platform_admin' in current_user.positions`.) +(`current_user.isPlatformAdmin` is the `PLATFORM_ADMIN` standing of +ADR-0095 D3, resolved per request from the deployment's declared platform +administrators — see +[the `PLATFORM_ADMIN` posture](/docs/permissions/authorization#combination-semantics-the-fixed-order). +The `platform_admin` name in `positions` is projected from the same grant, so +the two agree for every genuine administrator, but platform-operator gates read +`current_user.isPlatformAdmin == true` (ADR-0068 D4), never the array.) ## Union semantics — no Profile tier (ADR-0090 D2) diff --git a/docs/adr/0068-unified-user-context-and-built-in-identity-roles.md b/docs/adr/0068-unified-user-context-and-built-in-identity-roles.md index 333381fc2d0..e3b35aeffeb 100644 --- a/docs/adr/0068-unified-user-context-and-built-in-identity-roles.md +++ b/docs/adr/0068-unified-user-context-and-built-in-identity-roles.md @@ -70,6 +70,8 @@ The model underneath is already correct: `sys_role` is platform-native (ADR-0057 - **`org_*` name normalization**: `resolve-execution-context` and the `customSession` bridge emit `org_owner/admin/member` (not the raw better-auth `owner/admin/member`) so the array is unambiguous and self-documenting. - **`isPlatformAdmin` → derived alias** (`roles.includes('platform_admin')`). Kept during migration (so the merged objectui#1928 fix and existing predicates keep working), marked deprecated. **No new identity booleans.** +> **Note (2026-10-06) — `isPlatformAdmin` is the `PLATFORM_ADMIN` standing, not a deprecated alias.** The bullet above, TL;DR item 2 and the comment on `isPlatformAdmin` in the D1 sample call the key a derived alias of `'platform_admin' in roles`, marked deprecated. That half is superseded by [ADR-0095](./0095-authz-kernel-tenant-layer-and-posture-ladder.md) D3, under which `PLATFORM_ADMIN` derives from held capability grants and never from roles, and by the protocol text of [`EvalUserSchema`](../../packages/spec/src/identity/eval-user.zod.ts#EvalUserSchema): `isPlatformAdmin` is the `PLATFORM_ADMIN` standing of ADR-0095 D3 and carries no deprecation mark (the maintainer's ruling A-lite on [#21886](https://github.com/objectstack-ai/objectstack/issues/21886), [comment 6019378035](https://github.com/objectstack-ai/objectstack/issues/21886#issuecomment-6019378035), 2026-10-06). Core resolves that standing per request at one site, [`resolveUserAuthzGrants`](../../packages/core/src/security/resolve-authz-context.ts#resolveUserAuthzGrants): from the deployment's declared administrator list (`OS_PLATFORM_OWNER_EMAIL`) under every tenancy posture, or from the unscoped `admin_full_access` grant this decision names under the `single` posture. It projects the `platform_admin` name into `current_user.positions` from that same grant, so the name and the key agree for every genuine administrator. The session payload, the platform-admin route gate and [`hasPlatformAdminStanding`](../../packages/core/src/security/resolve-authz-context.ts#hasPlatformAdminStanding) judge by the rung, never by the array. The seeded built-in names, the one-way projection and "no new identity booleans" stand. + ### D3 — Role-definition authority follows the isolation boundary [ruled] - **Global / cross-tenant roles**: defined **only** by the super-admin or by **packages** (declared metadata, namespaced `.`, `managed_by: 'package'`, auto-created on install via `bootstrapDeclaredRoles`, updated/removed with the package). [existing mechanism, made a rule] @@ -83,6 +85,8 @@ The model underneath is already correct: `sys_role` is platform-native (ADR-0057 - **AI-authored in-app gates** use the **env's `sys_role` catalog** as a **closed enum**, given to the AI as grounding *including the system-predefined roles* (`platform_admin`, `org_*`) with their `label`/`description` so it disambiguates (`org_admin` = tenant admin vs `platform_admin` = SaaS operator). The server **validates** authored predicates against the catalog and **rejects unknown role names** (anti-hallucination). Roles — concrete, enumerable, tenant-local vocabulary — are the right grounding for AI in-app authoring. - **Capability-gating is deferred** to **cloud#474** (platform capability catalog inventory + migration of platform/cross-tenant gating to `requiredPermissions`). Per ADR-0066, *shippable cross-tenant* metadata must ultimately gate on capabilities (stable platform vocabulary), never tenant role names — but that is not needed for the one-operator v1. +> **Note (2026-10-06) — the predicate stands; the equivalence in parentheses does not.** Platform-operator actions gate on `current_user.isPlatformAdmin == true`, as this decision rules. The parenthetical "(≡ `'platform_admin' in roles`)" is superseded together with D2's alias wording (see the note under D2): the key is the `PLATFORM_ADMIN` standing of [ADR-0095](./0095-authz-kernel-tenant-layer-and-posture-ladder.md) D3, as [`EvalUserSchema`](../../packages/spec/src/identity/eval-user.zod.ts#EvalUserSchema) describes it, and `'platform_admin' in current_user.positions` is not a standing read. A platform-operator gate reads the key, never the array (the maintainer's ruling A-lite on [#21886](https://github.com/objectstack-ai/objectstack/issues/21886), 2026-10-06). + --- ## Scope From 39076f8471870248d8f3febad7645c211a80d221 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 16:11:08 +0000 Subject: [PATCH 3/3] docs(spec): regenerate the EvalUser reference page Output of `pnpm --filter @objectstack/spec check:generated --fix` (gen:docs only; the other 14 artifacts were already current). Claude-Session: https://claude.ai/code/session_01GV6oYwgc1kWiUCb1YaprQ7 Co-authored-by: Claude --- content/docs/references/identity/eval-user.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/references/identity/eval-user.mdx b/content/docs/references/identity/eval-user.mdx index c33297b06bb..83751f4e494 100644 --- a/content/docs/references/identity/eval-user.mdx +++ b/content/docs/references/identity/eval-user.mdx @@ -70,7 +70,7 @@ const result = EvalUserSchema.parse(data); | **name** | `string` | optional | Display name | | **email** | `string` | optional | Email address | | **positions** | `string[]` | optional (default: `[]`) | Canonical position/identity names the user holds — built-in identity names plus sys_position assignments, scope-resolved (ADR-0068 D3). The security axis, the same set /auth/me/permissions reports; NOT the better-auth user.role scalar, which remains published as user.role | -| **isPlatformAdmin** | `boolean` | optional | DERIVED alias of 'platform_admin' in positions. Deprecated. | +| **isPlatformAdmin** | `boolean` | optional | The PLATFORM_ADMIN standing of ADR-0095 D3: true when the platform resolves the user, per request, to the PLATFORM_ADMIN posture rung — from the deployment's declared administrator list (OS_PLATFORM_OWNER_EMAIL) under every tenancy posture, or from an unscoped admin_full_access grant under the single posture. The predicate platform-operator gates read (current_user.isPlatformAdmin == true, ADR-0068 D4). The resolver projects 'platform_admin' into positions from the same grant, so the two agree for every genuine administrator; gate on this key, never on the positions array. | | **organizationId** | `string \| null` | optional | Active organization ID (null = platform/unscoped) |