fix(platform-objects): point sys_user.role's prose at the path that exists, not the retired Set Platform Role action - #17102
Conversation
… exists, not the retired Set Platform Role action (#15188) The `role` field's `description` and its `readonly` comment both named `set_user_role` / "Set Platform Role", an action retired in #9968. A field `description` is authored data, not a source comment: it ships in the published bundle and is extracted into the i18n bundles, so it reaches an operator as field help in the admin UI — telling them to press a button that no longer exists anywhere in the product. Both now name the route that actually resolves in this tree: platform-admin standing comes from an unscoped `admin_full_access` grant in `sys_user_permission_set` (ADR-0068 D2), which is what the #9968 removal note in this same file already says. The description deliberately does not claim the scalar confers nothing — `judgePlatformAdmin` still reads `user.role === 'admin'` as the legacy fallback it has always been, so a pre-D2 deployment carrying the value is not locked out. `en.objects.generated.ts` follows by `pnpm i18n:extract`, never by hand. The third mention in this file — inside the removal note, past tense, "a working 'Set Platform Role' button WAS a supported, one-user-at-a-time resurrection channel" — is history and is true, so it is left byte-identical. A new pin holds both directions as occurrence counts over the source text: deleting the history drops a count to 0, re-introducing the retired name in live prose pushes one past 1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
📓 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 — 2 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 8954d87ac6c3906465b54ac8b6292ddeba5f2147 && git checkout 8954d87ac6c3906465b54ac8b6292ddeba5f2147
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e26ea69b85d4d2a82522baa87394bcfc4779e1bc c06938d24eca0e1712d3f1b45e76fee3ee0f78f6 && git checkout -B drift-repro e26ea69b85d4d2a82522baa87394bcfc4779e1bc && git merge --no-ff c06938d24eca0e1712d3f1b45e76fee3ee0f78f6
node scripts/docs-audit/affected-docs.mjs --json e26ea69b85d4d2a82522baa87394bcfc4779e1bc |
Fixes #15188
Clause-②: no— route ① only. Prose in one package: no new exported symbol, no new key on any published payload, no widened or narrowed accept set. Therolefield is not retired here.The sites, re-derived by content on this branch's base
Tree:
854639b311f714d936ae4ca239ef838ecc785845(origin/mainat branch creation — the dispatch quotededf59e3599,mainmoved). Located by grepping the content, not by trusting a line number. On this tree the numbers happen to agree with triage's::282// #9968 —set_user_role(target: /api/v1/auth/admin/set-role) retired:287// working "Set Platform Role" button was a supported, one-user-at-a-time:614readonly: true, // ADR-0092 — set via the Set Platform Role action, never the edit form:617description: 'Platform-level role (admin, user, …). Set via the Set Platform Role action.':287needed the opposite treatment and got it. It is inside the removal note and it is past tense — "a working 'Set Platform Role' button was a supported, one-user-at-a-time resurrection channel…". It narrates what was removed and it is true; "correcting" it would turn a true sentence false. Same trap as #15202: within one batch of literals the present-tense ones are stale descriptions and the past-tense ones are real history, judged site by site rather than swept.Proof the two history sites are unchanged. The whole diff to
sys-user.object.tsis a single hunk,@@ -611,10 +611,11 @@— nothing within 300 lines of the removal note is in it. Grepping the branch head for the retired names now returns exactly the two tombstone lines and nothing else:What all four
set_user_rolenon-test source hits underpackages/turned out to beThis was the first act, before any edit. All four are tombstones. None is live residue. The retirement was complete, so route ②'s premise is re-confirmed rather than disturbed, and this card's scope stays at two strings.
packages/spec/src/kernel/public-auth-features.ts:209— a commented-out entry inPUBLIC_AUTH_FEATURES.admin.gatedInputs:// 'sys_user.actions.set_user_role' retired (#9968) — see the removal note beside \impersonate_user` in sys-user.object.ts.` The live array does not contain it. Tombstone.packages/platform-objects/src/identity/sys-user.object.ts:282— the removal note itself. Tombstone (and the two live-prose sites this PR fixes).packages/platform-objects/CHANGELOG.md:1310, 1318, 9579— published release history.:1310/:1318are theimpersonate_userandset_user_rolestill 403 every platform admin — neither route is safely raw-mountable, and each blocks for a different reason #9968 entry announcing the retirement;:9579is an older entry from before it, describing the action as extant at that time. Both are correct as history and are release-owned.packages/spec/CHANGELOG.md:10618, 10626— the sameimpersonate_userandset_user_rolestill 403 every platform admin — neither route is safely raw-mountable, and each blocks for a different reason #9968 entry on the spec side. Published release history.Control that the grep is not broken:
admin_full_accesshits 164 files underpackages/on this tree (triage measured 163 on theirs). The two*.test.tshits excluded from the four are both pins that assert the retirement is complete —platform-objects.test.ts's#9968test (set_user_role must be gone, every sibling survivor present by name) andfeature-gate-guard.test.ts's floor of 37 booked inputs, "38 before #9968".The route named in the new prose resolves in this tree
Not invented and not taken on the card's word:
sys_user_permission_setis a real declared object —packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts, label "User Permission Set".admin_full_accessis a real shipped permission set, resolved by name inpackages/core/src/security/admin-standing-surface.tsandresolve-authz-context.ts.platform_adminto "unscopedsys_user_permission_set→admin_full_access".What changed
The description deliberately does not say the field confers nothing. That would have replaced one false sentence with another:
judgePlatformAdmin(packages/plugins/plugin-auth/src/platform-admin-gate.ts) still readsuser.role === 'admin'"ONLY as the legacy fallback it has always been", so a deployment carrying the scalar from before D2 is not locked out. What is unambiguously true, and what an operator needs, is that nothing writes it any more and where the grant actually goes."No ObjectStack action writes it" was checked rather than assumed. ADR-0092 notes
roleas an action param ofcreate_user/invite_user/set_user_role; on this treecreate_user's params areemail/phoneNumber/name/generatePassword/password/mustChangePassword— norole— andinvite_user'sroleparam carriesobjectOverride: 'sys_member', i.e. the org membership role, not this scalar.Generated-mirror sweep
packages/platform-objects/src/apps/translations/en.objects.generated.ts:38carried the same sentence and follows by regeneration (pnpm i18n:extract), never a hand edit — the default locale's leaves are rewritten from the source on every run. One line changed there, derived from:617.Swept for other mirrors of both changed strings, tracked and untracked, across the whole tree (
git grepplus a filesystem grep excludingnode_modules/.git/dist/.turbo): exactly two files carry "Set via the Set Platform Role action" — the source and that one generated bundle. Nothing incontent/docs/references/, no second generated page. Thereadonlycomment is a source comment and mirrors nowhere.The generator run rewrote all 11 bundle files and left 10 of them byte-identical, which is the documented merge behaviour (translated-locale values are preserved). See the acceptance note below for what that means for the three translated locales.
Changeset — decided on a measurement, not on the diff's apparent kind
patchon@objectstack/platform-objects. The measurement, taken from the committed state (the gate reads content viagit show REV:PATH, so a working-tree edit is invisible to it):@objectstack/platform-objectswith the fix, sha256 every published artefact underdist/;git checkout 854639b311 --the two authored files, verify on disk that the new text is gone (0 occurrences) and the old text is back (1 occurrence in each file);git checkout HEAD --, verify both blob hashes equal theirHEADblobs andgit diff HEADis empty.10 of the 66 published files change hash between the two builds:
So this publishes,
skip-changesetwould be wrong, and apatchon a released package is the right entry. The rebuild after restore reproduced the "after" hash exactly (dist/index.mjs=6f86336b…), so the two readings are a real before/after and not build nondeterminism.Ablation — APPLICABLE, and it fires in both directions
The diff adds a guard, so this is not reported as inapplicable. New pin:
#15188 — the retired action survives only as HISTORY; no live sys_user prose points at it, inplatform-objects.test.tsbeside the existing#9968test. It reads the object source as text and asserts occurrence counts, which is what makes both directions fail. Fix committed first; each leg proved on disk before it was believed; both restore legs verified by blob hash and an emptygit diff HEAD.was→is) — the exact trap1 → 0, injected text1; blob5fda0c01…≠ HEADbd4ad934…descriptionSet Platform Roleoccurrences1 → 2; blob5d87a79a…≠ HEADAfter each leg: restored blob
bd4ad9344b352ccfd5d024cb83bf6697c59c0208=HEADblob,git diff HEADempty,git status --porcelainempty. The mutation script carried atrap … EXIT INT TERMrestore with absolute paths throughout. No rebuild leg is involved: the pin reads source text and the test'sSysUserimport resolves tosrc/, notdist/.Direction observed is the ordinary one (turns red), stated because a template's expectation is not a reading.
Verification
Derived with
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack— it read the change set from git itself, named this repo and this checkout, and asserted--repoagainst theoriginremote. Derived twice: 51 families before the changeset existed, 57 after it, and re-derived at the final commitc06938d24ewhere the family list is byte-identical.Reconciliation: 57 derived · 57 run · 0 NOT MEASURED · 0 UNRUN · 57 green. Every exit captured by redirect-then-
$?, never across a pipe.Two families first returned
3 PREREQUISITE NOT MET, which is not a pass and was not recorded as one —check:dual-build-cjs-loadsandcheck:i18nboth want built output. Cleared with a fullpnpm build(73/73 tasks) and re-run to exit 0. A third,check:type-check-debt, returned 3 on the final-commit pass for a different reason it named itself: the ablation's restore had bumpedsys-user.object.ts's mtime pastplatform-objects' type entry point. Rebuilt that package and re-ran — exit 0, "5 ledger entries re-measured, 55 raw tsc errors, none above its recorded number, surplus: none".Beyond the derived set:
pnpm --filter @objectstack/platform-objects test— exit 0, 37 files / 546 tests passed.pnpm --filter @objectstack/platform-objects typecheck— exit 0. Worth naming: this package'stsconfig.jsonexcludes**/*.test.ts, sotsc --noEmitdoes not see the new test. Itstypecheckscript also runscheck:test-typecheckagainsttsconfig.test.json, which does, and reported "OK — the test layer compiles; 1 file / 3 errors / 2 pinned signatures held", unchanged and all infeature-gate-guard.test.ts. The new test therefore is type-checked, by that leg rather than the first one.pnpm lint(eslint . --no-inline-config) — exit 0, run in full. No narrowing to declare.check:authz-resolver,check:error-code-casing,check:filter-alias-parity,check:i18n-coverage— all exit 0.验收备注
Noted, not filed — reported to the PM with its measurement rather than filed blind.
The same stale sentence survives in three translated locales, and no gate can ever see it.
zh-CN/ja-JP/es-ESobjects.generated.ts:38each carry a hand-written translation of the old English text, each naming the retired button:They are not stale by accident of this PR — they were already stale — and
pnpm i18n:extractdeliberately leaves them alone. Nothing will ever flag them either:objects.sys_user.fields.role.helphas no entry in any*.source-hashes.generated.ts, and a path with no entry is LEGACY-TRUSTED and never reported stale. This is a known class in this repo (#14931, with #15511 and #16165 as prior instances, each filed and closed as its own card), which is why it is reported rather than ridden along.It is deliberately not fixed here. The bounded in-place exemption needs the file to be held by no other claim, and it is: PR #17068 (
claude/issue-16185-migration-flag-column-move-attest) edits all three of those bundles plus theirsource-hashesfiles. Its hunk is at~:3569(sys_migration), so there is no textual conflict with thesys_userblock at:38, but the file is taken. Measured over all 19 open PR heads via the REST files endpoint; #16883 also appears but only on the hand-written per-locale.tsbundles (en.ts,zh-CN.ts, …), which this diff does not touch.Lane. Triage recorded a tension and left it open, and this seat does not re-route unilaterally. The landing site is
packages/platform-objects/**⇒domain:engineby the anchoring rule. Having read the file: the two strings turned out to be identity semantics — whatsys_user.roleis post-ADR-0068 D2, whatjudgePlatformAdminstill accepts, which grant confers platform-admin standing — and getting the wording right meant readingplugin-auth,core/src/securityand two ADRs, none of which is engine territory. My recommendation is that a services seat reviews the wording, not that the card be re-routed; the mechanical half (regenerate the bundle, pin the boundary, gates) is squarely engine's and is done.Generated by Claude Code