Repository navigation
feat(platform-objects): declare the sys_user set_user_manager row action (#19249) - #19316
Conversation
…19249) Declares the `set_user_manager` row action on `sys_user`, posting `POST /api/v1/auth/admin/set-user-manager` with `{ userId, managerId }`. The endpoint (#16678 Phase 3) already exists and is ledgered; this is the Console affordance that reaches it. Claude-Session: https://claude.ai/code/session_01NcPSwnmJHczmTu6FG7NMjE Co-authored-by: Claude <noreply@anthropic.com>
…, changeset (#19249) Claude-Session: https://claude.ai/code/session_01NcPSwnmJHczmTu6FG7NMjE Co-authored-by: Claude <noreply@anthropic.com>
…s-user-set-manager-action
📓 Docs Drift CheckThis PR changes 1 package(s): 28 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. 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 309573a61ae097b026612c87125459e12ceba5c7 && git checkout 309573a61ae097b026612c87125459e12ceba5c7
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e3b3cdd2df3bda349ef7a41b0de39c8de4fddc87 b0131a89f0b855e043b3a0dd93ae9a8a1de982d6 && git checkout -B drift-repro e3b3cdd2df3bda349ef7a41b0de39c8de4fddc87 && git merge --no-ff b0131a89f0b855e043b3a0dd93ae9a8a1de982d6
node scripts/docs-audit/affected-docs.mjs --json e3b3cdd2df3bda349ef7a41b0de39c8de4fddc87
|
Part of #19249
Clause-②: no
What this declares
sys_user.manager_iddrives the approvals{ type: 'manager' }rung and the ADR-0057own_and_reportsread scope, andPOST /api/v1/auth/admin/set-user-manager(#16678 Phase 3) has been its only product write surface since it landed — with nothing in the Console reaching it. This declares that affordance and nothing else: oneset_user_managerrow action onsys_user, offered onlist_itemandrecord_header, collecting the manager through an inlinesys_userlookup and POSTing{ userId, managerId }to the admin endpoint.Origin ruling (objectstack#16678 Phase 2, decision batch #127 item 1, maintainer 2026-09-13), verbatim:
So
sys_business_unit.manager_user_id(Business Unit Head) is untouched: not read, not written, not derived from or for. The read side is untouched too —manager_idkeepsreadonly: trueand renders in the existinggroup: 'Organization'exactly as before (re-read on the branch base; ADR-0092 D4).⛔ It does not write
manager_idthrough the generic data API.sys_userismanagedBy: 'better-auth'and the ADR-0092 D2 managed-update whitelist is{name, image, locale}, so that write is refused by the identity write guard — correctly — and the failure would read as a Console bug. The endpoint reaches the column by system context instead, which is why no Tier-1 list moves.⛔ It declares no second copy of the server's refusals. Self-assignment, cycle, depth, cross-organization and directory-owned identity are all enforced at the write, in one derivation (
applyUserManagerLink, which the bulk importer already routes onto rather than re-deriving), and surface from there.Three readings that changed the shape
1. The
visiblepredicate — the card's count holds; copying the predicate whole would notThe card calls
record.source != "idp_provisioned""the shape the three existing self-service identity actions already use". Measured on the branch base: there are exactly three (change_my_password,change_my_email,delete_my_account), and all three spell that term byte-identically. But all three also AND it withhas(record.id) && record.id == ctx.user.id— they are self-service actions, offered to the row owner. This is an admin action on someone else's row, so only the directory-sync term is carried:Copying the predicate whole would have hidden the button from every admin — silently and fail-closed, the #8990 shape. A per-site verdict pins the counter-direction (offered to an admin on someone else's
env_nativerow) beside the directory-owned verdict, because a guard that is accidentally always-false is user-visibly identical to the bug.2. No
requiresFeature: 'admin'— the one key where this departs from its precedent, deliberatelyunlock_userandset_user_passwordcarry it, and the block header states why: those actions hit endpoints "that are only wired whenauth.plugins.adminis enabled", so the gate keeps the UI from rendering buttons that 404. This route is not one of them. It is an ObjectStack mount registered unconditionally besideunlock-userinauth-plugin.ts, authorized by the ADR-0068 platform-admin gate (judgePlatformAdmin), never by the better-auth admin plugin. Gating it onfeatures.admin == truewould hide a working affordance on every host that never opted into that plugin — precisely the population #16678 measured as having no write surface for this column at all.There is a second-order consequence worth stating, because it also decides the file surface:
PUBLIC_AUTH_FEATURES.admin.gatedInputsinpackages/specenumerates every action gated on that flag, andfeature-gate-guard.test.tsreds in its reverse direction when an action carries afeatures.*term that is not booked there. Declaring the gate would therefore have required apackages/specedit; not declaring it requires none. The absence is pinned together with that consequence, so a later flip cannot happen without reading it.3. The spec fork did not fire — and the one half of the suggested route that would have fired it
Everything here uses existing action keys:
type,target,recordIdParam,visible,description,successMessage,refreshAfter, and aparams[]entry oftype: 'lookup'withreference. No new or widenedpackages/speckey, no spec file touched, soClause-②: noholds.The half that is not declarable is "a user lookup filtered to the same organization".
ActionParamSchemais strict and declares no filter key at all; the only structured picker filter in the schema isFieldSchema.lookupFilters, whose entries are literal{ field, operator, value }triples with no context token — andsys_usercarries noorganization_idcolumn to filter on, being a global identity table (sys_memberrows are the only tenancy fact either identity has, which is exactly why the endpoint's cross-organization screen readssys_member). So the org-scoping half has no existing-key spelling, and what does exist is the server's namedcross_organizationrefusal. A client-side approximation of it would have been the second copy this card forbids, so the picker is left unscoped and the refusal surfaces.Verification
Gate families derived from the actual diff with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, every command run with its exit code captured before any pipe, reconciled with--ran:pnpm --filter @objectstack/platform-objects testpnpm --filter @objectstack/platform-objects typecheckcheck:test-typecheck)pnpm buildpnpm check:i18npnpm check:i18n-coveragepnpm lint(eslint . --no-inline-config, whole repo)b0131a89fpnpm check:dual-build-cjs-loadsfirst answered exit 3 — PREREQUISITE NOT MET (nodist/for 12 packages). That is not a pass, so the prerequisite was cleared with a fullpnpm buildand the gate re-run: exit 0. Control-character self-scan over all seven changed files: no match.The i18n bundles were regenerated with
node scripts/check-i18n-bundles.mjs --write, and the three translated locales were then hand-translated rather than left as the extractor's English fill — the#7309trap: an English value in a non-English bundle is perfectly "in sync" tocheck:i18nand invisible to every gate. The generated source-hash tables drop their entries for a re-translated leaf by themselves, which is why they carry no diff here.Acceptance notes
managerIdto be present andnull— "managerId is required — send null to clear the link, never omit the key" — and refuses both an absent key and an empty string. Whether the Console's param dialog submits an explicitnullfor an untouched optional lookup is objectui behaviour, and objectui is not checked out in this container, so it could not be measured here. Rather than half-declare it, the param isrequired: true: the dialog collects a value before anything is POSTed, so no submit path can produce that 400 about a key the user never saw. Re-pointing a manager works; unsetting one still needs either a measureddefaultValue: nullpath or a companion action carryingbodyExtra: { managerId: null }— one existing-key line either way, on a measurement this container cannot take. Noted, not filed; successor: the next author of asys_useraction, whom this note and the pin insys-user-set-manager-action.test.tsboth reach.packages/platform-objects/src/identity/line, mechanically and in one direction only. A new action label, description, success message and param label are authorable i18n keys, sopnpm check:i18nreds untilsrc/apps/translations/*.objects.generated.tsis regenerated. Four bundle files outsideidentity/, all generated-then-translated, no hand-written structure. Flagged rather than silently widened.unlock_usercarriesrequiresFeature: 'admin'while its own route is mounted unconditionally on the raw app, so on a host without the better-auth admin plugin the Unlock Account button is hidden although the endpoint answers. Same class as the reading in §2 above, on an action this PR does not touch. Successor: whoever next revisits the#2874feature-gate roster.sys_user.manager_id's own fieldhelpstring is untranslated English in all three translated bundles (pre-existing, inside the 621 baselined strings this PR leaves flat).Generated by Claude Code