Repository navigation
fix(platform-objects): sys_user.role help text names the platform-admin route that works on every tenancy posture - #19892
Conversation
…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>
…s-user-role-help-text
📓 Docs Drift Check1 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
Coarse fallback — 3 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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 |
Contract reviewServed-tier: Diff from merge-base ① Derived judgmentsThe served text, measured on
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
…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>
Fixes #19875
Clause-②: no
The
sys_user.rolefield's help text told administrators to grant platform-admin standing with an unscopedadmin_full_accessassignment. 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 clausesingle-only. This is a text correction on a platform object. No behaviour changes.How platform-admin standing is conferred today (measured on
origin/main3bd221d)admin_full_accessrow insys_user_permission_setOS_PLATFORM_OWNER_EMAILlists the user's verified emailsinglegroupisolatedpackages/core/src/security/resolve-authz-context.ts§6b.hasPlatformAdminGrantis set fromps.name === ADMIN_FULL_ACCESS && unscopedUserPsIds.has(ps.id) && !legacyGrantAnchorRetired, wherelegacyGrantAnchorRetired = postureEnforcesWall(resolveTenancyPosture()).postureEnforcesWall(packages/spec/src/security/tenancy-posture.ts) isposture !== 'single'.configConfersPlatformAdmin = platformAdminConfig.emails.length > 0 && matchesConfiguredPlatformAdmin(await getUserRow(), platformAdminConfig). It has no posture gate.matchesConfiguredPlatformAdminandresolvePlatformAdminEmails(packages/core/src/security/platform-admin.ts) readOS_PLATFORM_OWNER_EMAILand admit only an email-verified stored row.packages/core/src/security/resolve-authz-context.platform-admin-config.test.ts. The pins are "the legacy grant row is an anchor undersingle, and NOWHERE else" and "a CONFIG anchor resolves under BOTH postures".What changed
packages/platform-objects/src/identity/sys-user.object.ts: therolefield'sdescription.Before:
After:
The text states the posture split as it ships today. It says nothing about any future change to the
singleposture. It stays a prescription, not an exhaustive list, so it makes no claim about the legacyrolescalar 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 bypnpm i18n:extract. The run wrote 11 files. The other 10 (zh-CN/ja-JP/es-ESobjects, metadata-forms and source-hash tables, plus theenmetadata-forms bundle) came out byte-identical. The translatedrole.helpleaves have no source-hash entry, so they are hand-written and legacy-trusted, and the extract keeps them.git grepover 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#15188test). It asserted only that the description containssys_user_permission_setandadmin_full_access, and those substrings survive in the newsingleclause. So restoring the old sentence would have passed every test. The pin now also requiresOS_PLATFORM_OWNER_EMAILand a`single`qualifier. It asserts named subjects, not wording..changeset/sys-user-role-help-posture-split.md:@objectstack/platform-objectspatch, 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 pluscheck:test-typecheck: OK). Both ran through the verify lock withVERDICT command-exit 0.scripts/ablation-replace.mjs, wrapped aroundvitest run src/platform-objects.test.ts. It restored the old description insys-user.object.ts: anchor hit x1 to x0, replacement x0 to x1, blobca076aa169eato7f8718e29dfc. Result: 1 failed / 126 passed, and the failure was the#15188test withexpected '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 HEADempty,git status --porcelainempty. No dist involved: the suite imports the object fromsrc/.packages/**:git grep -c -F 'grant platform-admin standing with an unscoped `admin_full_access` assignment' -- 'packages/**'gives 0 hits (exit 1). The controlgit grep -c -F 'verified email in `OS_PLATFORM_OWNER_EMAIL`; under the `single` tenancy posture' -- 'packages/**'gives 2 hits (exit 0), one insys-user.object.tsand one inen.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).node scripts/pm/dispatch-gates.mjs --commands(no paths) oned9dc25cddderived 60 commands, and every one exited 0.pnpm check:i18nandpnpm check:dual-build-cjs-loadsfirst exited 3 (PREREQUISITE NOT MET: nodist/). 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 livenode scripts/check-issue-citations.mjswas run by hand: exit 0,no issue citations added against 3bd221dfe.--no-inline-config --format jsonover the 3 changed.tsfiles gave 3 files, 0 errors, 0 warnings. Population:eslint --print-configresolves 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.mjsenables no type-aware linting for any file (noparserOptions.project), so this diff cannot move the verdict on any untouched file. The repo-widepnpm lintis left to CI.dispatch-gateslists 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 fromadmin_full_access:identity/sys-user.object.tsrole.description: changed here.apps/translations/en.objects.generated.tssys_user.role.help: regenerated here.identity/sys-user.object.ts, therolefield'sreadonlycomment (platform admin: sys_user_permission_set / admin_full_access): a source comment, not served. Left as is. It is true undersingleand incomplete under a wall.identity/sys-user.object.ts, theimpersonate_userandset_user_rolestill 403 every platform admin — neither route is safely raw-mountable, and each blocks for a different reason #9968 removal note (Platform-admin membership is granted through sys_user_permission_set / admin_full_access): a past-tense tombstone comment that the#15188test pins by occurrence count. Left as is.platform-objects.test.ts, the#9968test's comment: history. Left as is.apps/translations/{zh-CN,ja-JP,es-ES}.objects.generated.tssys_user.role.help: these do not prescribe the grant. They still tell the reader to use the "Set Platform Role" action, which was retired inimpersonate_userandset_user_rolestill 403 every platform admin — neither route is safely raw-mountable, and each blocks for a different reason #9968 (an older wording that the [finding] sys-user.object.ts: therolefield description and readonly comment still point at the retired Set Platform Role action (set_user_role) #15188 change updated only inen). They are hand-written, legacy-trusted leaves with no source-hash entry, so neithercheck:i18nnorcheck:i18n-stale-fillreports them. Reported to the seat as a separate finding and not edited here: this card's file surface is theentext and its generated artefacts.Outside this package, listed and not edited:
content/docs/permissions/authorization.mdx("either of which is sufficient") anddocs/qa/platform-checklist/areas/access-security.jsonare carriers 1 and 2 of [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, which remains open for them.content/docs/permissions/permission-sets.mdx, "Who holdsadmin_full_access". It says the unscoped grant row "still confers standing", "either one is sufficient", and that it "logs a pointer, once per process". That is false under a wall, and the pointer was deleted on every posture. This page is not in [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 carrier list. It is the same docs class, so it is routed 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's scope and not edited here.packages/runtime/src/domains/automation.tsRUN_LIFECYCLE_DENY_MESSAGEis carrier 3, split to thedomain:clilane..changeset/sys-user-role-prose-retired-action.md(pending, from PR fix(platform-objects): pointsys_user.role's prose at the path that exists, not the retired Set Platform Role action #17102) quotes the old sentence as what it changed the text to. It also says standing "comes from an unscopedadmin_full_accessgrant", which was true when it landed. It is a history record of that PR and is left unedited. It will be compiled into the same release as this changeset, whose text supersedes it.packages/plugins/plugin-security/src/objects/sys-user-position.object.ts, reserved-identity comment ("the unscopedadmin_full_accessgrant forplatform_admin,bootstrapPlatformAdminwrites asys_user_permission_setrow"): a source comment, incomplete under a wall, not served. No PR or person is currently set to pick it up.origin/mainwas merged into this branch once (3 commits: objectql, metadata andscripts/pm/close-cards.mjs, none in this package) so that the gate derivation read a current tree.origin/mainhas since moved by one docs-only commit.Generated by Claude Code