Skip to content

fix(spec): EvalUser.isPlatformAdmin is the ADR-0095 D3 PLATFORM_ADMIN standing, not a deprecated alias - #22018

Merged
os-zhuang merged 3 commits into
mainfrom
claude/issue-22012-isplatformadmin-standing
Oct 7, 2026
Merged

os-zhuang merged 3 commits into
mainfrom
claude/issue-22012-isplatformadmin-standing

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #22012

Clause-②: no

What this changes

This executes the maintainer's ruling A-lite on #21886 (comment 6019378035): the protocol text changes and the mechanism does not. EvalUserSchema.isPlatformAdmin loses its stale "Deprecated, derived from positions" mark. It is now described as the PLATFORM_ADMIN standing of ADR-0095 D3, and ADR-0068 gets a dated note under D2 and under D4.

  1. packages/spec/src/identity/eval-user.zod.ts: only the JSDoc and the .describe() of isPlatformAdmin change. The key is the PLATFORM_ADMIN standing of ADR-0095 D3, resolved per request: from the declared administrator list (OS_PLATFORM_OWNER_EMAIL) under every tenancy posture, or from an unscoped admin_full_access grant under single. It is the predicate platform-operator gates read (ADR-0068 D4). The text says the resolver projects platform_admin into positions from the same grant, and that gates read the key, never the array. The schema shape, optionality, default and createEvalUser are unchanged.
  2. Generated docs: content/docs/references/identity/eval-user.mdx changes by one row. This is the output of check:generated --fix; gen:docs was the only stale artifact out of 15.
  3. docs/adr/0068-unified-user-context-and-built-in-identity-roles.md: one dated note under D2 and one under D4. They name ADR-0095 D3 and EvalUserSchema (symbol-anchored) as the superseding text. The original wording is not rewritten (git diff on the file: 4 additions, 0 deletions).
  4. Hand-written docs:
    • content/docs/permissions/permission-metadata.mdx: the "derived, deprecated alias" aside now describes the standing and says gates read the key, never the array.
    • content/docs/permissions/authentication.mdx: the old sentence listed "the platform_admin position" among the admin routes' gate signals. That was false: the gate in platform-admin-gate.ts (isPlatformAdminUser) reads isPlatformAdmin plus the legacy better-auth scalar, and its header says the positions leg was removed. The sentence now says so.

Also in this PR: a pin, packages/spec/src/identity/eval-user.test.ts, and .changeset/22012-isplatformadmin-standing.md (@objectstack/spec patch).

⛔ Not taken, by the ruling: createEvalUser taking the rung as input; any @objectstack/formula buildScope change; a new authorable key; treating 'platform_admin' in current_user.positions as a standing read; any visible edit on any action. That last item belongs to #21886 and #21903, which this PR leaves open.

Premise check (on origin/main 1bc6ca1d)

  • Door: packages/plugins/plugin-auth/src/platform-admin-gate.ts#isPlatformAdminUser reads u.isPlatformAdmin === true, with role === 'admin' as the legacy fallback. It does not read positions.
  • Session: the customSession callback in auth-manager.ts emits isPlatformAdmin: grants.posture === 'PLATFORM_ADMIN', never from the array. Zero non-test createEvalUser calls exist in plugin-auth.
  • Core: resolve-authz-context.ts derives the standing at §6b (the legacy grant, retired on walled postures) and §6b-config (OS_PLATFORM_OWNER_EMAIL). It projects platform_admin into positions at §6c from the same hasPlatformAdminGrant, and hasPlatformAdminStanding reads the rung.
  • Write side: sys_position.name and sys_user_position.position refuse the reserved built-in names (plugin-security/src/objects/reserved-identity-names.ts).

The premise holds.

Readings of the dispatch hypotheses

  • H1 (confirmed): the only deprecation wording on the key was eval-user.zod.ts lines 225 and 226. grep -ci deprecat on the file gives 2 before, 0 after. That file is identical on f7b8a593 and 1bc6ca1d.
    • I left the createEvalUser docblock unchanged. "isPlatformAdmin is always derived from positions" is what the factory computes (positions.includes(BUILTIN_IDENTITY_PLATFORM_ADMIN)), the ruling keeps that computation, and the new description does not contradict it.
    • One clause in that docblock was already false before this PR: "the customSession bridge" is listed among the factory's users, and the session reads the rung instead. See Acceptance notes.
  • H2 (confirmed): the D1 sample's comment at ADR-0068 line 48 carries the superseded wording. It stays, and the D2 note names it, along with the D2 bullet and TL;DR item 2.
  • H3 (confirmed): the generators moved exactly one tracked file, the reference page, by 1 row.
    • check:generated before --fix: 14 of 15 current, 1 stale (check:docs).
    • json-schema.manifest/** and spec-changes.json did not move. check:authorable-surface is green, and the identity/EvalUser:isPlatformAdmin row is unchanged.
    • git diff 1bc6ca1d HEAD over packages/spec/authorable-surface*, authorable-defaults and liveness is 0 lines, so no liveness row moved.
  • H4 (confirmed): both line numbers are right.
    • Scope of the grep: the hand-written docs tree (content/ without references/ and releases/, plus apps/docs, skills/ and package READMEs). It has 2 hits for isPlatformAdmin, exactly the card's two. Only 1 of them carries deprecated or alias wording.
    • Repo-wide, the same grep at 1bc6ca1d (isPlatformAdmin within 80 characters of deprecat or alias, CHANGELOGs and releases excluded) gives 16 lines:
      • 3 fixed here: the spec source, the generated page and permission-metadata;
      • 6 in ADR-0068's original wording, covered by the notes;
      • 7 in plugin-auth (code comments and tests), outside this card. See Acceptance notes.

Tests and gates (final head 39076f84)

  • Pin: pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/identity/eval-user.test.ts gives 3 passed. It asserts that the published JSON Schema description names the PLATFORM_ADMIN standing, ADR-0095 D3 and ADR-0068 D4, carries no deprecation word and no deprecated flag, and equals the .describe() at the point of use.
  • Reverse verification (run from committed 35709496 with scripts/ablation-replace.mjs): the old describe text was put back on disk. The anchor count went 1 to 0, the replacement count went 0 to 1, and the blob went 73dc1f88 to 565e7b3e. The pin went red as predicted: 2 failed (standing named; no deprecation word) and 1 passed (consistency). The tool proved the restore: the blob equals HEAD 73dc1f88 and git diff HEAD is empty. The test imports ./eval-user.zod relatively, so it reads src and no dist rebuild was involved.
  • Spec suite: pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2 gives 620 files, 18488 passed, 1 todo.
  • Spec typecheck: pnpm --filter @objectstack/spec typecheck exits 0. That covers tsc --noEmit, check:scripts-typecheck and check:test-typecheck, and the last of these compiles the new test file.
  • Build: pnpm --filter @objectstack/spec build exits 0. packages/spec has no workspace dependencies, so its dependency closure is empty.
  • Derived gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 116 commands from 7 paths against merge base 1bc6ca1d. All 116 were run, and --ran with recorded exit codes reports "116 run, 0 NOT-MEASURED (a DERIVED zero)".
    • 7 commands first exited 3 (PREREQUISITE NOT MET: an unbuilt dependency, or a fixture commit outside the shallow clone). After the prerequisite was supplied, all 7 reran with exit 0: check-plugin-teardown-shape --self-test, lint check:doc-formula-expressions, lint check:doc-security-posture, spec check:skill-examples, check:docs-transcript-drift, check:dual-build-cjs-loads and check:lean-entry-closure.
    • The re-derivation added 14 commands to the list derived at f7b8a593, mostly the changeset families, which apply now that the changeset exists. All 14 are run and green.
  • Lint (a proven narrowing; the repo-wide pnpm lint belongs to CI): eslint --no-inline-config --format json on the 2 changed TS files reports 2 files, 0 errors and 0 warnings.
    • The population comes from eslint.config.mjs itself: files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'], so the md and mdx files are outside it.
    • Invariance: the config never enables type-aware linting (no parserOptions.project, no typed rules), so this diff cannot move the verdict on any untouched file.

Acceptance notes

  • The createEvalUser docblock in eval-user.zod.ts lists "the customSession bridge" among the factory's users. It is not one: the session reads the rung, and plugin-auth has 0 non-test calls to the factory. This predates this PR and is unchanged here, by the claim's file surface. Who picks it up: nobody named.
  • Code comments in plugin-auth still call the key a "derived alias". The ADR-0068 D2 paragraph of the customSession comment in auth-manager.ts and the isPlatformAdminUser docblock in platform-admin-gate.ts say it, and the first even notes that D2's wording predates D4 rows. Two tests do as well: their titles and comments say "derived isPlatformAdmin alias". The code is right; only the word is stale after this ruling. Who picks it up: nobody named.
  • ADR-0068 also uses "alias" wording in its Status line, under Consequences ("Bad / costs") and in checklist items 4 and 6. These lines are original wording, not rewritten by rule. The D2 note supersedes that reading as a whole, but it names only the D2 bullet, TL;DR item 2 and the D1 sample.

维护者速读(草稿)

改了什么

  • 规范层:EvalUser.isPlatformAdmin(当前用户是否为平台管理员)的说明去掉了“已弃用、由 positions 派生”的标记,改为说明它就是 ADR-0095 D3 定义的平台管理员身份(PLATFORM_ADMIN 档位)。该身份按请求判定:部署声明的管理员名单 OS_PLATFORM_OWNER_EMAIL,在 single 模式下另含无范围的 admin_full_access 授权。
  • ADR-0068 在 D2、D4 下各加一条带日期的注记,指向新说法,原文一字未改。
  • 两处手写文档同步更新,生成的参考页随之重生成。另加一条测试钉住新说明,并附 changeset(patch)。

为什么改

风险与代价(含回滚)

  • 运行时行为零变化:schema 形状、可选性、默认值、导出、接受集合和 createEvalUser 都不变,已有写法照常可用。
  • 回滚:revert 本 PR 即可,不涉及数据迁移。
  • 代价:ADR-0068 多出两段注记。

席位意见

你要做的


Generated by Claude Code

claude added 3 commits October 6, 2026 15:56
…ORM_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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@github-actions github-actions Bot added the size/m label Oct 6, 2026
@github-actions

github-actions Bot commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/kernel/runtime-services/sharing-service.mdx (via platform_admin (literal, a string literal in EvalUserSchema; a string literal in a comment in EvalUserSchema))
  • content/docs/permissions/authentication.mdx (via platform_admin (literal, a string literal in EvalUserSchema; a string literal in a comment in EvalUserSchema))
  • content/docs/permissions/authorization.mdx (via platform_admin (literal, a string literal in EvalUserSchema; a string literal in a comment in EvalUserSchema))
  • content/docs/permissions/permission-metadata.mdx (via platform_admin (literal, a string literal in EvalUserSchema; a string literal in a comment in EvalUserSchema))
  • content/docs/permissions/positions.mdx (via platform_admin (literal, a string literal in EvalUserSchema; a string literal in a comment in EvalUserSchema))
  • content/docs/permissions/sharing-rules.mdx (via platform_admin (literal, a string literal in EvalUserSchema; a string literal in a comment in EvalUserSchema))

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

  • content/docs/releases/v14.mdx (via platform_admin (literal, a string literal in EvalUserSchema; a string literal in a comment in EvalUserSchema))
  • content/docs/releases/v17/17-4.mdx (via platform_admin (literal, a string literal in EvalUserSchema; a string literal in a comment in EvalUserSchema))
  • content/docs/releases/v17/17-5.mdx (via platform_admin (literal, a string literal in EvalUserSchema; a string literal in a comment in EvalUserSchema))

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
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 139 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 1fb274e61cfff12ede54963d779acdfd079be318 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 7b4251fd4c4dfd2e4c409455413cd82c28445236 — the merge of head 39076f8471870248d8f3febad7645c211a80d221 into base 1fb274e61cfff12ede54963d779acdfd079be318, 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 7b4251fd4c4dfd2e4c409455413cd82c28445236 && git checkout 7b4251fd4c4dfd2e4c409455413cd82c28445236
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1fb274e61cfff12ede54963d779acdfd079be318 39076f8471870248d8f3febad7645c211a80d221 && git checkout -B drift-repro 1fb274e61cfff12ede54963d779acdfd079be318 && git merge --no-ff 39076f8471870248d8f3febad7645c211a80d221

node scripts/docs-audit/affected-docs.mjs --json 1fb274e61cfff12ede54963d779acdfd079be318

⚠️ 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 1fb274e61cfff12ede54963d779acdfd079be318 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 39076f8471870248d8f3febad7645c211a80d221
Local-runs: none

Inputs read: card #22012 (body and both comments, the claim 6019998508 and the os-dev-report 6021314784), the ruling 6019378035 on #21886 in full, PR #22018 (body, 7-file list, net diff against main; merge base 1bc6ca1d, and origin/main 1fb274e6 has moved on none of the PR's paths nor on any source named below), and the check-runs on the head, read at 2026-10-06T17:15Z. Every source line cited below was read on origin/main 1fb274e6 with git show; nothing was built, run or re-run.

① Derived judgments

Accept set and public surface of @objectstack/spec:

  • EvalUserSchema.isPlatformAdmin stays z.boolean().optional(), so the accept set of EvalUserSchema is unchanged. Right.
  • EvalUser, EvalUserParsed and createEvalUser are unchanged; the only .d.ts delta is JSDoc text, so no api-surface move is owed. Right.
  • The published JSON Schema description of properties.isPlatformAdmin changes. The deprecated flag was never set on main (the old node carried the word in prose only, no .meta), so the pin asserting the flag is absent holds a non-regression, not a change. Right.
  • Generated artifacts: content/docs/references/identity/eval-user.mdx moves by one row, and that row is byte-equal to the new .describe() (579 characters, compared). On main the old text "DERIVED alias" occurs in exactly four places (that page, docs/adr/0068 line 48, auth-manager.ts line 4021, eval-user.zod.ts lines 225 and 226), so no manifest or ledger carries it and nothing else was owed to the generators. The authorable-surface row identity/EvalUser:isPlatformAdmin is a name-only row and is unchanged. Right.
  • packages/spec/src/identity/eval-user.test.ts: excluded from the build by tsconfig.json (**/*.test.ts), compiled by tsconfig.test.json through check:test-typecheck; the extension-less relative import matches its siblings (position.test.ts); lazySchema resolves .shape through its Proxy and aliases the real node's metadata into toJSONSchema (packages/spec/src/shared/lazy-schema.ts), so all three assertions reach the published text. Right.

The factual claims the new .describe() and JSDoc make, each read on main:

  • "from the deployment's declared administrator list (OS_PLATFORM_OWNER_EMAIL) under every tenancy posture": packages/core/src/security/resolve-authz-context.ts §6b-config (configConfersPlatformAdmin, platformAdminConfig.emails) sets hasPlatformAdminGrant with no posture gate. Right.
  • "or from an unscoped admin_full_access grant under the single posture": §6b sets the same flag only when !legacyGrantAnchorRetired, which is !postureEnforcesWall(resolveTenancyPosture()); postureEnforcesWall in packages/spec/src/security/tenancy-posture.ts is posture !== 'single' over the enum single, group, isolated. Exactly right.
  • "The resolver projects platform_admin into positions from the same grant": §6c unshifts BUILTIN_IDENTITY_PLATFORM_ADMIN from hasPlatformAdminGrant, and §6d derives the rung from that same flag (derivePosture({ isPlatformAdmin: hasPlatformAdminGrant, ... })). Right.
  • "the session payload emits this key from the rung": packages/plugins/plugin-auth/src/auth-manager.ts customSession, platformAdmin = grants.posture === 'PLATFORM_ADMIN', emitted as isPlatformAdmin. Right.
  • "the platform-admin route gate ... judge[s] by the rung too": packages/plugins/plugin-auth/src/platform-admin-gate.ts isPlatformAdminUser reads u.isPlatformAdmin === true, then the legacy u.role === 'admin' scalar, and its header records the positions leg as removed. Right (the JSDoc speaks of the array, not the scalar; the scalar fallback is stated in authentication.mdx).
  • "hasPlatformAdminStanding judge[s] by the rung": resolve-authz-context.ts hasPlatformAdminStanding returns grants.posture === 'PLATFORM_ADMIN'. Right.
  • "The predicate platform-operator gates read (ADR-0068 D4)": ADR-0068 line 82. Right.
  • Residual, named so it is on the record: createEvalUser still computes isPlatformAdmin as positions.includes('platform_admin'), and its two non-test callers (packages/formula/src/stdlib.ts toEvalUser, packages/runtime/src/security/actor-user.ts buildActorUser) feed it positions from the caller's context. On those surfaces the key equals the array read; the two agree with the rung whenever positions came from resolveUserAuthzGrants (§6c), and the one way they can diverge, a stored platform_admin position row, is refused on write by packages/plugins/plugin-security/src/objects/reserved-identity-names.ts (RESERVED_IDENTITY_NAMES is BUILTIN_IDENTITY_NAMES). The ruling keeps this computation by name ("Not taken: createEvalUser taking the rung as input"), and the .describe() says what the key reports, not how the factory computes it. Judged right as written.

Governed rule text, docs/adr/0068-unified-user-context-and-built-in-identity-roles.md:

  • The two hunks are pure additions (4 added lines, 0 removed): one blockquote note under D2 and one under D4, in the dated-note form ADR-0021 and ADR-0061 already use. No original wording is rewritten. Right.
  • What the D2 note names as superseded exists on main as named: TL;DR item 2 (line 19), the D1 sample comment (line 48), the D2 bullet (line 71); the D4 note's parenthetical "(≡ 'platform_admin' in roles)" is line 82. The D2 note says "the unscoped admin_full_access grant this decision names", and ADR-0068 D2 names it (lines 65 and 69). ADR-0095 D3 is titled "Posture derives from capabilities, never from roles" and rules PLATFORM_ADMIN derived from held capability grants. Right.
  • The three symbol anchors resolve to declarations on main: eval-user.zod.ts#EvalUserSchema (line 214), resolve-authz-context.ts#resolveUserAuthzGrants (line 720), resolve-authz-context.ts#hasPlatformAdminStanding (line 1214), the form check:adr-symbol-anchors requires (comments stripped). Right.
  • "The session payload, the platform-admin route gate and hasPlatformAdminStanding judge by the rung, never by the array" is the same three claims verified above. Right.

Hand-written docs:

  • content/docs/permissions/permission-metadata.mdx: the link target /docs/permissions/authorization#combination-semantics-the-fixed-order is the heading "Combination semantics (the fixed order)" at authorization.mdx line 97, and Check Documentation Links is green on the head. The sentence's claims are the verified ones. Right.
  • content/docs/permissions/authentication.mdx: "all three routes" are /admin/create-user, /admin/set-user-password and /admin/import-users (the section's three subsections), each mounted in auth-plugin.ts behind the shared gateAdmin, which is judgePlatformAdmin(session) and therefore isPlatformAdminUser: isPlatformAdmin, legacy role === 'admin', no positions read. The old sentence's "the platform_admin position" was false on main; the correction is inside the sentence the card names. Right.

Rules and conventions:

  • No tracker number in the .describe() or the JSDoc (runtime strings); the ADR note cites [finding] sys_member.add_member is offered to every organization member, owners and admins included, but its door admits only a platform admin #21886 and the ruling comment as provenance, as ADR-0061's note does. Right.
  • .changeset/22012-isplatformadmin-standing.md follows the issue-slug naming on main. Right.
  • Independent re-count of the dev's H4: on main, lines naming isPlatformAdmin that also say deprecat or alias (CHANGELOGs and releases excluded) number 16: 3 fixed here, 6 in ADR-0068's original wording, 7 in plugin-auth comments and tests. Matches the report.
  • Governed surface: docs/adr/ is a Tier H row of GOVERNED_SURFACES (scripts/pm/check-governed-merges.mjs), so this PR lands only on an authorized APPROVED review; packages/spec/src/** is not a governed row. This record is the contract-tier review the card asked for on packages/spec/src/**; it authorizes no landing of the ADR path, and the PR presents itself that way (draft; the body's last section). Right.

② Semver level

@objectstack/spec: patch. A .describe() and JSDoc change ships in the tarball (the .d.ts JSDoc, the JSON Schema description, the reference page), so skip-changeset would be wrong; it adds no capability, so not minor; nothing an author writes is removed, renamed or narrowed, so no (narrowing) arm and no ADR-0087 marker is owed. Clause-②: no with no arm is the right declaration for a changeset that moves neither the accept set nor the public type surface, and the PR body and the changeset body carry the same line. Check Changeset is green on the head. The changeset matches what the diff publishes.

Clause-②: no

③ Boundary flags

open_questions: none declared. The three out_of_scope_findings (each "noted, not filed", carried as PR acceptance notes):

  1. The createEvalUser docblock lists "the customSession bridge" among the factory's users. Verified false on main: customSession asks resolveUserAuthzGrants and reads the rung, and plugin-auth has no non-test createEvalUser call (the callers are formula/src/stdlib.ts and runtime/src/security/actor-user.ts). Answered: a stale code comment is neither a defect, a contract violation nor an authoring trap (Prime Directive chore: version packages #10), so the acceptance note is the right carrier, and the docblock sits outside the claim's file surface (the isPlatformAdmin JSDoc and .describe() only). Whoever next edits that docblock drops the clause. Not escalated.
  2. plugin-auth comments and two test titles still say "derived alias" (auth-manager.ts lines 3991 and 4021, platform-admin-gate.ts line 61, admin-ban-endpoints.test.ts, session-platform-admin-rung-agreement.test.ts). Verified the code reads the rung at every one of those sites. Answered: same class, same carrier; auth-manager.ts line 4021 literally contradicts line 4055 of the same comment and is the first to fix when that file is next open. Not escalated.
  3. ADR-0068's Status line, "Bad / costs" and checklist items 4 and 6 keep "alias" wording. Answered: the dated-note rule adds and never rewrites; those lines record the migration-era decision to keep the key, which the notes do not reverse (the key stays; its standing changes), and the D2 note states which lines it supersedes. Not escalated.

Dev deviations declared: a self-killed vitest launch that never ran; one fixture commit fetched at depth 1 into the shared object store; a bash -c gate loop refused by a safety check and run directly; a transient self-inflicted build collision during check:type-check-debt --re-measure. None touches the diff. The premise check is stated on 1bc6ca1d; every source it names is byte-identical on 1fb274e6.

Check-runs on the head as read: 22 success (among them Build Core, Build Docs, Check Changeset, Check Documentation Links, Dogfood Regression Gate and its three shards, Dogfood Verify CLI, Spec property liveness, Temporal Conformance (live PG + MySQL), Type Check · source gates, Type Check · consumer gates, Type Check · debt ledger, and the PR-hygiene checks), 2 skipped (Console Pin Gate, Packed-tarball smoke (opt-in)), 8 in_progress (Lint & Repo Gates, Test Core 1/6 to 6/6, Type Check · workspace), and 1 cancelled: Governed Surface Queue Guard (run 37500047610, job 112394433739). Escalated to the owning seat: that job was cancelled at its Checkout repository step by the workflow's 10-minute job timeout (timeout-minutes: 10); every guard-predicate step is skipped, so the guard rendered no verdict on this head. Its pull_request leg is the early-warning leg that exits 0 by design; the refusing leg is merge_group. It needs a re-run before any landing act, which is a write this review does not perform, and a Tier H landing waits on the maintainer's approval regardless. No check-run on the head has concluded failure.

Implemented-by: claude/issue-22012-isplatformadmin-standing
Reviewed-by: session_01GV6oYwgc1kWiUCb1YaprQ7

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

CI note from the owning domain:spec seat 2 (session_01GV6oYwgc1kWiUCb1YaprQ7) · 2026-10-06T17:16Z. ⛔ Not a review verdict.

Governed Surface Queue Guard reads cancelled on head 39076f8471, and the cancel is not this PR's.

  • Where it stopped: job 112394433739 (run 37500047610) died in step 2, "Checkout repository" (fetch-depth: 0), after exactly the workflow's timeout-minutes: 10. Every later step, the guard's self-test and its verdict included, was skipped. So no guard logic ran on this diff.
  • Why it is not this PR's: the same workflow passed on five other PR runs within the hour, including PR feat(spec,analytics): a dataset answer's measure column states its aggregate, labelled or not (fields[].aggregate) #22021's at 2026-10-06T17:10Z. This diff touches no workflow or guard script.
  • What happens next: the seat has no sanctioned re-run route; its write relay carries no Actions op. The guard runs again on this PR's next push. This Tier H PR is likely to take a main merge before it lands anyway. Its pull_request leg is an early warning that exits 0 on a governed draft by design; the refusing leg is merge_group.

Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

维护者速读

domain:spec 二号席 · session_01GV6oYwgc1kWiUCb1YaprQ7 · 2026-10-06T17:20Z · 终稿,按席位所读 diff 校正自 PR 正文草稿。

改了什么

  • 规范里 isPlatformAdmin("当前用户是不是平台管理员")的说明,去掉了"已弃用、由 positions 派生"的标记。现在写明它就是 ADR-0095 D3 的平台管理员身份:每次请求按部署声明的管理员名单 OS_PLATFORM_OWNER_EMAIL 判定;在默认的 single 模式下,第一个真人账号获得的那条不限组织的 admin_full_access 授权也算。
  • ADR-0068 的 D2、D4 下各加一条带日期的注记,说明旧的"别名、已弃用"说法已被取代。原文一字未改。
  • 两处手写文档改成同样的说法,参考页随之重新生成。另加一个测试钉住新说明,附 patch 级 changeset。

为什么改

风险与代价(含回滚)

  • 运行时零变化:字段的类型、可选性、默认值、导出和接受范围都没变,createEvalUser 的算法也没变。已有写法照常可用。
  • 回滚:revert 这个 PR 即可,不涉及数据。
  • 代价:ADR-0068 多了两段注记。

席位意见

  • 建议批准。本席逐行读过 diff,并在 main 上逐条核对了新文字里的说法:接口只认这个键和旧的 role === 'admin',不读 positions;single 模式下的授权来源也与代码一致。
  • 独立的契约复审(CONTRACT_REVIEW_TIER)在当前 head 上判 PASS,记录 6021543875。
  • CI 没有失败项。一项治理守卫在拉代码这一步超时取消,一行逻辑都没跑,与本 PR 无关;下次推送会重跑(说明 6021576789)。

你要做的


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review October 7, 2026 04:27
@os-zhuang
os-zhuang enabled auto-merge October 7, 2026 04:27
@os-zhuang
os-zhuang added this pull request to the merge queue Oct 7, 2026
Merged via the queue into main with commit d5a14dd Oct 7, 2026
40 of 41 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-22012-isplatformadmin-standing branch October 7, 2026 05:06
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…ectstack-ai#22064)

Fixes objectstack-ai#21886
Clause-②: no

## What changes

`sys_member.add_member` ("Add Member" on an organization's member list)
is now offered only to a platform administrator. That is the one
standing its endpoint admits.

- **Before:** the action was gated on the `organization` feature alone,
so every member of the organization saw it: plain members, admins and
owners. `POST /api/v1/auth/organization/add-member` then answered each
of them 403 `PERMISSION_DENIED`, because the mount runs the ADR-0068
platform-admin gate before anything else.
- **After:** the action declares `visible: 'current_user.isPlatformAdmin
== true'`, the predicate the maintainer's ruling A-lite fixed (record
6019378035). ADR-0068 D4 names that predicate, and since PR objectstack-ai#22018
`EvalUserSchema.isPlatformAdmin` describes it as the PLATFORM_ADMIN
standing of ADR-0095 D3. `requiresFeature: 'organization'` composes onto
it at parse time, so the served predicate is
`(current_user.isPlatformAdmin == true) && features.organization !=
false`.

The door and the callers it admits are unchanged. No key, export or
parameter is added, and the label is unchanged. That makes `Clause-②:
no`, and the changeset is a `@objectstack/platform-objects` patch.

**File surface, as the claim declared it.** The producer is
`packages/platform-objects/src/identity/sys-member.object.ts`. The other
files are the lowering-matrix pin in
`packages/platform-objects/src/platform-objects.test.ts`, one dogfood
case in
`packages/qa/dogfood/test/org-admin-affordance-reach.dogfood.test.ts`,
and `.changeset/21886-add-member-platform-admin-visibility.md`. There is
no `packages/spec`, `plugin-auth` or `@objectstack/formula` edit.

## The dispatch's hypotheses, measured at `56c88446ea`

- **H1: confirmed. The two keys compose with AND, the authored term
first.** `lowerRequiresFeature`
(`packages/spec/src/kernel/public-auth-features.ts:352-419`) turns an
existing CEL `visible` into `(existing) && gate` (`:415-418`). The
first-party precedent that carries both keys is
`sys_user.enable_two_factor` (`sys-user.object.ts:534-535`), pinned at
`platform-objects.test.ts` as `(...) && features.twoFactor == true`.
- **H2: confirmed.** The served `visible` is exactly
`(current_user.isPlatformAdmin == true) && features.organization !=
false`. This was read from the parsed `SysMember` object in the matrix
test, and from the served `/meta/object/sys_member` on a real boot
through the dogfood case. `EvalUserSchema.isPlatformAdmin`
(`eval-user.zod.ts:225-241`) now describes the ADR-0095 D3 standing.
- **H3: confirmed, with one correction.**
- The console binds `current_user` through `extra`. At the current
objectui pin `a58626c88d`:
    - `fieldRules.ts:284` passes the scope as `extra`;
- `ExpressionProvider.tsx:190` builds one subject under `current_user`,
`user`, `ctx.user` and `os.user`, with `features` beside it;
- `expressionUser.ts:176` forwards `isPlatformAdmin` from the session.
- The session value is `grants.posture === 'PLATFORM_ADMIN'`
(plugin-auth `auth-manager.ts:4083`, emitted at `:4107`).
- Under `user:`, `@objectstack/formula` re-derives it from `positions`
(`stdlib.ts:417-430` through `createEvalUser`). Under `extra`, the bag
is merged verbatim (`stdlib.ts:462`).
- **The correction:** the file's existing `owner` principal is the
harness's seeded dev admin, and on this boot its served session carries
`isPlatformAdmin: true`. It is the platform-admin principal, reused.
Nothing on this boot declares `OS_PLATFORM_OWNER_EMAIL`: it is unset in
the environment, absent from the dogfood package and the showcase
config, and the harness sets it only under a walled posture
(`packages/verify/src/harness.ts:481-497`). By `EvalUserSchema`'s
definition, the rung therefore comes from the `single` posture's
unscoped `admin_full_access` grant. The file had no org owner who is not
a platform admin, so the case signs one up and sets its membership to
`owner`, the way the file already sets grades.
  - The existing cases still bind through `user:`, unchanged.
- **H4: confirmed. No server path evaluates an action `visible`.**
- `rest`, `runtime`, `objectql`, `services`, `metadata`,
`metadata-protocol`, `core` and `plugins` have zero non-test hits for an
action-`visible` evaluation.
- The eight `ExpressionEngine.evaluate` / `celEngine.evaluate` call
sites in package sources evaluate other things: formula fields and
defaults (`objectql/src/engine.ts:2263`, `:6190`), hook conditions
(`hook-wrappers.ts:309`), approval conditions, share-link gates and
automation steps.
- So nothing on the server binds `current_user.isPlatformAdmin` for this
predicate, and the door stays the authority.

## Tests

All runs below are at the final HEAD (`bb53601015`) unless marked
otherwise.

- **Pin (a), `platform-objects`:** the lowering-matrix row
`SysMember#add_member` now expects `(current_user.isPlatformAdmin ==
true) && features.organization != false`.
- `pnpm --filter @objectstack/platform-objects exec vitest run`: 62
files, 996 passed.
- The package `typecheck` passed, with 0 TS errors. Its main program
does not compile `platform-objects.test.ts`. `check:test-typecheck`
compiles it through `tsconfig.test.json`: `--listFiles` counts 1 hit
there and 0 in the main program.
- **Pin (b), one dogfood case on a real showcase boot:** `Add Member is
offered to a platform admin alone — current_user bound as the console
binds it — and the door agrees`.
- It reads each principal's served session, its own served
`/meta/object/sys_member` and the served `/auth/config` flags.
- It asserts each session's `isPlatformAdmin`: `true` for the seeded
admin, `false` for the second owner, the admin, the delegated admin and
the plain member. It also asserts that the second owner carries
`org_owner`.
- It evaluates the served predicate with `celEngine`, with
`current_user` bound through `extra` the way the console binds it.
Exactly the platform admin is offered the action.
- It then probes the door on the same boot: the owner and the plain
member get 403 `PERMISSION_DENIED` (envelope `error.code`) and no row is
written; the platform admin gets 200 `success: true` and the
`sys_member` row lands.
- Result: `test/org-admin-affordance-reach.dogfood.test.ts` passed 14 of
14. The dogfood `typecheck` passed, and its program compiles the touched
file (`--listFiles`: 1 hit).
- **Builds:** `turbo run build --filter='@objectstack/dogfood^...'`
passed 63 of 63 after the merge of `origin/main`.

### Reverse verification, on committed HEAD `57b18bf94e`

- **Mutation:** `scripts/ablation-replace.mjs` deleted the line
`visible: 'current_user.isPlatformAdmin == true',`. The anchor count
went from 1 to 0 and the file's blob from `9beac68ce1c7` to
`b8792313cac4`. An outer `trap … EXIT INT TERM` used absolute paths.
- **Proof the mutation reached `dist/`:**
`@objectstack/platform-objects` was rebuilt, then
`scripts/ablation-dist-preflight.mjs @objectstack/platform-objects
'visible: "current_user.isPlatformAdmin == true"' --absent
--source-marker=…` reported the marker absent from all 66 built files.
Before the mutation, the same spelling was present in
`dist/index.mjs:2219`.
- **(a) turned red, 1 failed of 127:** `expected 'features.organization
!= false' to be '(current_user.isPlatformAdmin == true) &&
features.organization != false'`.
- **(b) turned red, 1 failed of 14:** the set offered "Add Member" was
received as `platform admin`, `org owner, not a platform admin`, `org
admin`, `delegated admin` and `plain member`, against the expected
`platform admin` alone. So the owner and member cells (and the admin
cells) went red.
- **Direction:** the run turned red, as predicted.
- **Restore:** `git checkout HEAD --` on the absolute path. The blob
after restore and the HEAD blob are both
`9beac68ce1c7ed2b7143505916818a0a2e39a456`, and `git diff HEAD` is
empty. Both `ablation-replace` and the outer trap proved this.
- **After the restore:** the package was rebuilt, and the preflight in
present mode found the marker in 6 built files on a clean tree. (a)
passed 127 of 127 and (b) passed 14 of 14.

## Gates

These ran locally at `bb53601015`, after the merge of `origin/main`
(`5cfd8661c4`).

- **The derived gates:** `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack` derives 67 commands for this change
set (35 pnpm, 32 node). All 67 ran and exited 0. `--ran` reconciles
them: `67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN`.
- `check:dual-build-cjs-loads` first answered PREREQUISITE NOT MET (exit
3), because the build had covered only the dogfood closure. It was rerun
after a full workspace build (`turbo run build`, 72 of 72) and loaded
106 require entry points across 66 packages.
- `check:dts-closure`, `check:lean-entry-closure`,
`check:published-files` and `check:sourcemap-no-sources-content` were
rerun on that full build too, and all four passed.
- **The artifact-roster block:** the derivation prints 53 more commands
outside its total, and all 53 ran.
  - 50 exited 0.
- Three answered NOT WIRED / NOT MEASURED (exit 2) because they need
pull-request context: `check-partof-closing-keyword`,
`check-closing-target-claim` and `check-single-claim-paths`.
`check-partof-closing-keyword` was then run with this body as `PR_BODY`
and passed. The other two need this PR's number and CI runs them on it.
- `check-sdui-manifest` passed, but its objectui version comparison is
NOT CHECKED, because the container has no objectui checkout.
- `check:console-injection` is NOT MEASURED: there is no console `dist/`
here, so only its self-test ran. This diff touches neither surface.
- **The four symbol-anchor sweeps:** `check:adr-symbol-anchors`,
`check:scripts-symbol-anchors`, `check:spec-docblock-symbol-anchors` and
`check:adr-anchors` all passed.
- **Lint, as a proven narrowing:** `pnpm exec eslint --no-inline-config
--format json` ran over the three touched TypeScript files.
- The population is read from eslint's own config: `--print-config`
resolves for each of the three, so none is ignored.
  - The JSON output counts 3 files, with 0 errors and 0 warnings.
- The narrowing cannot hide a verdict elsewhere. Type-aware linting is
off: the resolved configs carry no `parserOptions.project` or
`projectService`, and `eslint.config.mjs:327-328` states the repo never
enables it. So this diff cannot move the verdict on any untouched file.
- **Left to CI, as a declared narrowing:** the type-check lanes, Test
Core, Dogfood Regression Gate, Build Core and the repo-wide `pnpm lint`.

## Acceptance notes

- **The door also admits a legacy `user.role === 'admin'` scalar**
(`platform-admin-gate.ts:80-84`), and this predicate does not read it. A
deployment that still carries that pre-ADR-0068-D2 scalar on a user who
lacks the posture rung would be admitted by the door, but would not be
offered the button. The gate's own header says nothing in ObjectStack
writes that scalar for a platform admin, and that re-synthesizing it is
vetoed. The predicate is the one the ruling fixed, so this is noted, not
filed.
- **Only one platform-admin route is exercised here.** The dogfood boot
uses the `single` posture, where the standing comes from the seeded
admin's unscoped grant. The `OS_PLATFORM_OWNER_EMAIL` route of the
walled postures is not exercised by this case.
- **Only the new case binds through `extra`.** The grade cases above it
still bind through `user:`, as the dispatch required. Their predicates
read only `current_user.positions`, which is identical under either
binding.
- **Out of this PR:** objectstack-ai#21903 (`Blocked-by: objectstack-ai#21886`) remains open and
carries the thirteen sibling platform-admin actions and the enumeration
pin.

---

_Generated by [Claude
Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

3 participants