Skip to content

fix(platform-objects): sys_user.role help text names the platform-admin route that works on every tenancy posture - #19892

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-19875-sys-user-role-help-text
Sep 23, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-19875-sys-user-role-help-text

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #19875

Clause-②: no

The sys_user.role field's help text told administrators to grant platform-admin standing with an unscoped admin_full_access assignment. Since PR #19136 that remedy does nothing on a walled deployment. The help text now names the route that works on every tenancy posture and keeps the grant clause single-only. This is a text correction on a platform object. No behaviour changes.

How platform-admin standing is conferred today (measured on origin/main 3bd221d)

posture unscoped admin_full_access row in sys_user_permission_set OS_PLATFORM_OWNER_EMAIL lists the user's verified email
single confers confers
group grants the set's capabilities, no standing confers
isolated grants the set's capabilities, no standing confers
  • The grant-row route is packages/core/src/security/resolve-authz-context.ts §6b. hasPlatformAdminGrant is set from ps.name === ADMIN_FULL_ACCESS && unscopedUserPsIds.has(ps.id) && !legacyGrantAnchorRetired, where legacyGrantAnchorRetired = postureEnforcesWall(resolveTenancyPosture()). postureEnforcesWall (packages/spec/src/security/tenancy-posture.ts) is posture !== 'single'.
  • The config route is the same file's §6b-config: configConfersPlatformAdmin = platformAdminConfig.emails.length > 0 && matchesConfiguredPlatformAdmin(await getUserRow(), platformAdminConfig). It has no posture gate. matchesConfiguredPlatformAdmin and resolvePlatformAdminEmails (packages/core/src/security/platform-admin.ts) read OS_PLATFORM_OWNER_EMAIL and admit only an email-verified stored row.
  • Both readings are already pinned in packages/core/src/security/resolve-authz-context.platform-admin-config.test.ts. The pins are "the legacy grant row is an anchor under single, and NOWHERE else" and "a CONFIG anchor resolves under BOTH postures".

What changed

packages/platform-objects/src/identity/sys-user.object.ts: the role field's description.

Before:

Legacy better-auth role scalar (admin, user, …). ObjectStack no longer writes it (ADR-0068 D2) — grant platform-admin standing with an unscoped admin_full_access assignment in sys_user_permission_set.

After:

Legacy better-auth role scalar (admin, user, …). ObjectStack no longer writes it (ADR-0068 D2). To grant platform-admin standing, list the user's verified email in OS_PLATFORM_OWNER_EMAIL; under the single tenancy posture an unscoped admin_full_access assignment in sys_user_permission_set also confers it.

The text states the posture split as it ships today. It says nothing about any future change to the single posture. It stays a prescription, not an exhaustive list, so it makes no claim about the legacy role scalar either way.

Generated artefacts, regenerated through their generator (not by hand):

  • packages/platform-objects/src/apps/translations/en.objects.generated.ts (sys_user.role.help), produced by pnpm i18n:extract. The run wrote 11 files. The other 10 (zh-CN / ja-JP / es-ES objects, metadata-forms and source-hash tables, plus the en metadata-forms bundle) came out byte-identical. The translated role.help leaves have no source-hash entry, so they are hand-written and legacy-trusted, and the extract keeps them.
  • No other generated artefact carries the string. git grep over the whole tree finds it in no reference doc, JSON schema or snapshot.

The pin on the old text, packages/platform-objects/src/platform-objects.test.ts (the #15188 test). It asserted only that the description contains sys_user_permission_set and admin_full_access, and those substrings survive in the new single clause. So restoring the old sentence would have passed every test. The pin now also requires OS_PLATFORM_OWNER_EMAIL and a `single` qualifier. It asserts named subjects, not wording.

.changeset/sys-user-role-help-posture-split.md: @objectstack/platform-objects patch, quoting what the help text said and what it says now.

Evidence (final head ed9dc25cdd)

  • pnpm --filter @objectstack/platform-objects test: 54 files / 883 tests passed. pnpm --filter @objectstack/platform-objects typecheck: exit 0 (tsc --noEmit ×2 plus check:test-typecheck: OK). Both ran through the verify lock with VERDICT command-exit 0.
  • Ablation of the updated pin used scripts/ablation-replace.mjs, wrapped around vitest run src/platform-objects.test.ts. It restored the old description in sys-user.object.ts: anchor hit x1 to x0, replacement x0 to x1, blob ca076aa169ea to 7f8718e29dfc. Result: 1 failed / 126 passed, and the failure was the #15188 test with expected 'Legacy better-auth role scalar (admin…' to contain 'OS_PLATFORM_OWNER_EMAIL'. The expected direction was red, and red is what happened. Restore proven: blob after restore == blob at HEAD (ca076aa169ea), git diff HEAD empty, git status --porcelain empty. No dist involved: the suite imports the object from src/.
  • The old string is gone from packages/**: git grep -c -F 'grant platform-admin standing with an unscoped `admin_full_access` assignment' -- 'packages/**' gives 0 hits (exit 1). The control git grep -c -F 'verified email in `OS_PLATFORM_OWNER_EMAIL`; under the `single` tenancy posture' -- 'packages/**' gives 2 hits (exit 0), one in sys-user.object.ts and one in en.objects.generated.ts. Across the whole tree it survives only in two changesets: this PR's, which quotes it as the old text, and the pending .changeset/sys-user-role-prose-retired-action.md (see Acceptance notes).
  • Gates. node scripts/pm/dispatch-gates.mjs --commands (no paths) on ed9dc25cdd derived 60 commands, and every one exited 0. pnpm check:i18n and pnpm check:dual-build-cjs-loads first exited 3 (PREREQUISITE NOT MET: no dist/). I built their stated prerequisites and re-ran them, and both then exited 0. Reconciled with --ran: 60 derived, 60 run, 0 NOT-MEASURED, 0 UNRUN. The live node scripts/check-issue-citations.mjs was run by hand: exit 0, no issue citations added against 3bd221dfe.
  • Narrowed lint: eslint --no-inline-config --format json over the 3 changed .ts files gave 3 files, 0 errors, 0 warnings. Population: eslint --print-config resolves a config for each of the 3, and the JSON carries no ignored-file notice. File count: 3, read from the JSON array. Invariance: eslint.config.mjs enables no type-aware linting for any file (no parserOptions.project), so this diff cannot move the verdict on any untouched file. The repo-wide pnpm lint is left to CI.
  • NOT MEASURED locally, left to CI: the path-scheduled CI jobs and the type-check lanes that dispatch-gates lists outside its derived set.

Acceptance notes

Sweep of packages/platform-objects/src/** for text that prescribes the unscoped grant, or that derives platform-admin standing from admin_full_access:

Outside this package, listed and not edited:

origin/main was merged into this branch once (3 commits: objectql, metadata and scripts/pm/close-cards.mjs, none in this package) so that the gate derivation read a current tree. origin/main has since moved by one docs-only commit.


Generated by Claude Code

…orks on every tenancy posture

The role field's description prescribed an unscoped admin_full_access
assignment as the way to grant platform-admin standing. Under a walled
posture (group / isolated) that row no longer confers standing, so the
help text now names OS_PLATFORM_OWNER_EMAIL (every posture) and keeps
the unscoped grant clause single-only. The existing pin on the
description also requires both.

Claude-Session: https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr
Co-authored-by: Claude <noreply@anthropic.com>
…_user.role help text

Produced by pnpm i18n:extract; translated-locale leaves and source-hash
tables are byte-unchanged.

Claude-Session: https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/s documentation Improvements or additions to documentation tests tooling labels Sep 23, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: sys_user (symbol, 37 pages)
  • 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 — 3 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 dabf8d795ee9279c18b4b00bfabb322544106b2f → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json dabf8d795ee9279c18b4b00bfabb322544106b2f

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: ed9dc25cdd08fc3566ce3cae85dc56fb4bdbd0a7

Diff from merge-base 3bd221dfe1; the branch's one merge of main carries nothing of its own. Four files, +27/−2: the changeset, one description literal in sys-user.object.ts, one help leaf in en.objects.generated.ts, and two assertions added to the existing #15188 test. No governed path.

① Derived judgments

The served text, measured on origin/main:

  • The env name is exact (PLATFORM_OWNER_EMAIL_ENV = 'OS_PLATFORM_OWNER_EMAIL'); several addresses are comma-separated, and an unparseable entry fails the whole variable closed. "list the user's … email in" is consistent with a list.
  • "verified" in the code is the caller's own sys_user row reading email_verified truthy (isEmailVerifiedUserRow), consulted by matchesConfiguredPlatformAdmin.
  • The email route has no posture gate (§6b-config); pinned "a CONFIG anchor resolves under BOTH postures".
  • Under single the unscoped row confers standing (§6b, !legacyGrantAnchorRetired, postureEnforcesWall = posture !== 'single', PR feat(security)!: retire the walled legacy platform-admin grant dual read and its deprecation log (#11663 L5) #19136); pinned in both directions. "also confers" is right: the two anchors are additive under single.
  • Implicit conditions (the grant's validity window, an active permission set) narrow the prescription without misleading.
  • platform-admin re-anchor follow-up (Choice 4B): config-anchor the single posture — first-user promotion becomes development-only fallback #11979 is not pre-empted: the text states today's split only.
  • Omissions judged, none actionable: an env change lands on the next process start (ordinary env semantics); an administrator reading Setup help is on an already bootstrapped rig; the text names an environment variable, so no reader expects a Setup toggle.
  • The regenerated en leaf equals the source description byte for byte after unescaping (314 characters).
  • The extended pin requires OS_PLATFORM_OWNER_EMAIL and `single`; the ablation shows the old sentence goes red.
  • Every changeset sentence is true.
  • Checks on the head, latest per name: all complete; success everywhere except the roster skips (Build Docs, Console Pin Gate, Packed-tarball smoke).

② Semver level

@objectstack/platform-objects: patch, Clause-②: no, confirmed. One served string literal, a test and a changeset; no accept set widens, no public surface moves, no behaviour moves. A false served string in a released package is a bug fix.

③ Boundary flags

  1. The zh-CN, ja-JP and es-ES values of objects.sys_user.fields.role.help still point at the "Set Platform Role" action retired in impersonate_user and set_user_role still 403 every platform admin — neither route is safely raw-mountable, and each blocks for a different reason #9968. The leaf has no source-hash entry in any locale, so check:i18n and check:i18n-stale-fill cannot see it. REAL; [finding] sys-user.object.ts: the role field description and readonly comment still point at the retired Set Platform Role action (set_user_role) #15188 residue, not this card's defect. FILE SEPARATELY. Acceptable to land without it.
  2. content/docs/permissions/permission-sets.mdx "Who holds admin_full_access": REAL, already in hand. PR docs(permissions,qa): the unscoped admin_full_access anchor confers platform-admin standing under single only #19876 (Part of #19145) rewrites exactly that section, and the domain:devx seat amended [finding] the platform-admin derivation becomes posture-dependent when #18336 lands, and two shipped documents still say it is not — authorization.mdx's "either of which is sufficient" and three access-security checklist lines naming the retired pointer #19145's claim surface to include the file (comment 5796745302). ROUTE TO [finding] the platform-admin derivation becomes posture-dependent when #18336 lands, and two shipped documents still say it is not — authorization.mdx's "either of which is sufficient" and three access-security checklist lines naming the retired pointer #19145: nothing to file here.
  3. The pending .changeset/sys-user-role-prose-retired-action.md (PR fix(platform-objects): point sys_user.role's prose at the path that exists, not the retired Set Platform Role action #17102) quotes the sentence this PR retires. The foreign-changeset rule forbids this PR from editing it, and the two notes compile as before-and-after, not as a contradiction. ACCEPTABLE.
  4. Source comments that are incomplete under a wall (sys-user.object.ts's role readonly comment, sys-user-position.object.ts's reserved-identity comment, and packages/types/src/env.ts's "The single posture never consults this", stale since [Design] Re-anchor platform-admin: admin_full_access becomes a kernel metadata declaration; WHO holds it comes from env-configured verified emails — retiring the org-less row anchor #11663 L2 and plugin-security: the first-user promotion picks the oldest authenticable user from an UNORDERED 50-row sys_user window, so on the default driver a seeded job seeker became platform admin and owned every seeded row #16682). None is served. ACCEPTABLE; at most one comment-hygiene sweep later.

Implemented-by: claude/issue-19875-sys-user-role-help-text
Reviewed-by: session_01TEhopqrWQYBycZzyJHpAZr

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 23, 2026 15:51
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 23, 2026
Merged via the queue into main with commit 029d8a4 Sep 23, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-19875-sys-user-role-help-text branch September 23, 2026 16:14
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…e retired Set Platform Role action (objectstack-ai#19914)

Fixes objectstack-ai#19897

Clause-②: no

## What changed

The `zh-CN`, `ja-JP` and `es-ES` help text for
`objects.sys_user.fields.role.help`
(`packages/platform-objects/src/apps/translations/{zh-CN,ja-JP,es-ES}.objects.generated.ts`)
still told a Setup administrator to press the "Set Platform Role" action
retired in objectstack-ai#9968. The `en` text had already moved on twice (objectstack-ai#17102, then
objectstack-ai#19892/objectstack-ai#19875) to name `OS_PLATFORM_OWNER_EMAIL` and the
`single`-posture `admin_full_access` grant, but the three translations
were never brought forward, and no i18n gate could see the drift because
the leaf carries no source-hash entry.

Retranslated the leaf in all three locales as a faithful rendering of
the CURRENT `en` text (per dispatch Zone 1), keeping code spans
verbatim: `OS_PLATFORM_OWNER_EMAIL`, `single`, `admin_full_access`,
`sys_user_permission_set`.

A repo-wide grep for the retired action's name/label across every
locale's `objects`/`metadata-forms` bundles (en included) now returns 0
hits — nothing else still references it.

## The source-hash half of the suggested fix — measured, not done, and
why

The issue's suggested shape (also in Zone 2 A2) is to also give the leaf
a source-hash entry so a future `en` change is caught. Traced and
measured, and it does not hold for a leaf that carries a genuine
translation:

- `collectFilledFromHashes`
(`packages/platform-objects/src/apps/translations/source-hash.ts`) — the
function `os i18n extract --source-hashes` calls to populate
`<locale>.source-hashes.generated.ts` — records a hash for a leaf
**only** when `value === currentSource` (it's still a literal,
untranslated copy of `en`) or `previous[path] === hash(value)` (it was
already such a copy on a prior run). A leaf holding a real translation
satisfies neither, by the mechanism's own documented design (ruling
objectstack-ai#12069 Option A): "a leaf someone actually TRANSLATED … gets no record
and stays legacy-trusted."
- Ran `pnpm i18n:extract` after writing the three translations: it
rewrote nothing else (`git status` showed only the 3 hand-edited lines —
A3 satisfied), and it did **not** add a hash entry for this leaf,
exactly as the trace predicts.
- A2's own ablation, run for real: mutated the `sys_user.role` field's
`description` in `sys-user.object.ts`, regenerated `en` via `pnpm
i18n:extract`, and ran `pnpm check:i18n` and `pnpm
check:i18n-stale-fill`. **Both report nothing** for this leaf — 0
findings, 0 stale fills — both before and after this fix. Mutation and
regeneration were fully reverted (`git checkout HEAD --`, blob hashes
re-verified equal to `HEAD`).

So the leaf remains legacy-trusted going forward — not a regression this
PR introduces, but the pre-existing, ruled status quo for every
hand-translated leaf in the `objects` bundle (measured: 1203 in zh-CN,
1182 in ja-JP, 1173 in es-ES carry no hash and are not byte-copies of
`en`). Extending protection to genuinely-translated generated leaves
would need a new ruling in the shape of objectstack-ai#8765/objectstack-ai#12069, which is out of
this card's scope — full measurement and a recommendation are in the dev
report.

## Tests

- `pnpm --filter @objectstack/platform-objects build` — clean.
- `pnpm --filter @objectstack/platform-objects test` — 883/883 passed.
- `pnpm --filter @objectstack/platform-objects typecheck` — clean
(pre-existing shrink-only test-typecheck-debt entry untouched).
- `pnpm check:i18n` — OK (9/9 packages in sync).
- `pnpm check:i18n-stale-fill` — OK (0 stale fills, 0 baselined).
- Full derived gate list (`node scripts/pm/dispatch-gates.mjs
--commands`, re-derived on the final commit, `origin/main` fetched
first): 54 families, 53 ran clean, 1 (`pnpm check:dual-build-cjs-loads`)
NOT MEASURED — exits 3 PREREQUISITE NOT MET, needs a full
whole-workspace `pnpm build` unrelated to this diff's closure; CI's
`Build Core` covers it. `node scripts/check-issue-citations.mjs` (the
live command, since the derivation only reaches its `--self-test`) run
by hand: exit 0.

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

---------

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

documentation Improvements or additions to documentation size/s tests tooling

Projects

None yet

1 participant