Repository navigation
Commit 74fb2f7
Part of #19249
> ⛔ **`Part of`, ⛔ not `Fixes` — changed by the dispatching seat after
review, ⛔ not by the author.**
> The card's scope item 1 names two behaviours: re-point a manager, and
**`managerId: null` clears**. This PR delivers the first. The second is
not declarable until one reading nobody in this container can take —
whether objectui's param dialog submits an explicit `null` for an
untouched optional lookup — because the endpoint refuses an ABSENT
`managerId` and treats only an explicit `null` as a clear
(`packages/plugins/plugin-auth/src/admin-set-user-manager.test.ts`:
`:162` pins the null-clear, `:175` pins 「an ABSENT managerId is refused,
never read as a clear」). ⇒ merging this must **not** close the card.
Clause-②: no
## What this declares
`sys_user.manager_id` drives the approvals `{ type: 'manager' }` rung
and the ADR-0057 `own_and_reports` read scope, and `POST
/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: one
`set_user_manager` row action on `sys_user`, offered on `list_item` and
`record_header`, collecting the manager through an inline `sys_user`
lookup and POSTing `{ userId, managerId }` to the admin endpoint.
Origin ruling (objectstack#16678 Phase 2, decision batch #127 item 1,
maintainer 2026-09-13), verbatim:
> 同意 经理 = 管理员在用户上显式设置的 manager_id;部门负责人 = 单元上的 manager_user_id,两者独立。
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_id` keeps `readonly: true` and renders in
the existing `group: 'Organization'` exactly as before (re-read on the
branch base; ADR-0092 D4).
⛔ It does not write `manager_id` through the generic data API.
`sys_user` is `managedBy: '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 `visible` predicate — the card's count holds; copying the
predicate whole would not
The 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 with `has(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:
```
visible: 'has(record.source) && record.source != "idp_provisioned"'
```
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_native` row) 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, deliberately
`unlock_user` and `set_user_password` carry it, and the block header
states why: those actions hit endpoints "that are only wired when
`auth.plugins.admin` is 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 beside `unlock-user` in
`auth-plugin.ts`, authorized by the ADR-0068 platform-admin gate
(`judgePlatformAdmin`), never by the better-auth admin plugin. Gating it
on `features.admin == true` would 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.gatedInputs` in
`packages/spec` enumerates every action gated on that flag, and
`feature-gate-guard.test.ts` reds in its **reverse** direction when an
action carries a `features.*` term that is not booked there. Declaring
the gate would therefore have required a `packages/spec` edit; 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 a `params[]` entry of `type: 'lookup'` with
`reference`. No new or widened `packages/spec` key, no spec file
touched, so `Clause-②: no` holds.
The half that is **not** declarable is "a user lookup filtered to the
same organization". `ActionParamSchema` is strict and declares no filter
key at all; the only structured picker filter in the schema is
`FieldSchema.lookupFilters`, whose entries are literal `{ field,
operator, value }` triples with no context token — and `sys_user`
carries no `organization_id` column to filter on, being a global
identity table (`sys_member` rows are the only tenancy fact either
identity has, which is exactly why the endpoint's cross-organization
screen reads `sys_member`). So the org-scoping half has no existing-key
spelling, and what does exist is the server's named `cross_organization`
refusal. 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`:
```
dispatch-gates --ran: 58 derived famil(ies) accounted for — 58 run,
0 NOT-MEASURED (a DERIVED zero — all 58 recorded an exit code and none of them is 3).
```
| check | result |
|---|---|
| 58 derived gate families | all exit 0 |
| `pnpm --filter @objectstack/platform-objects test` | 41 files / 584
tests passed |
| `pnpm --filter @objectstack/platform-objects typecheck` | OK (incl.
`check:test-typecheck`) |
| `pnpm build` | 73/73 tasks |
| `pnpm check:i18n` | OK — 9 packages, all bundles in sync |
| `pnpm check:i18n-coverage` | OK — 13 configs, 621 baselined
untranslated strings, **none new** |
| `pnpm lint` (`eslint . --no-inline-config`, whole repo) | exit 0 at
`b0131a89f` |
`pnpm check:dual-build-cjs-loads` first answered **exit 3 — PREREQUISITE
NOT MET** (no `dist/` for 12 packages). That is not a pass, so the
prerequisite was cleared with a full `pnpm build` and 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 `#7309` trap: an English value in a
non-English bundle is perfectly "in sync" to `check:i18n` and 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
- **Clearing the link has no affordance in this action, and that is a
declared narrowing rather than an oversight.** The endpoint's clear path
requires `managerId` to be **present and `null`** — "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 explicit `null` for 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 is `required: 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 measured `defaultValue: null` path or a companion action
carrying `bodyExtra: { managerId: null }` — one existing-key line either
way, on a measurement this container cannot take. Noted, not filed;
successor: the next author of a `sys_user` action, whom this note and
the pin in `sys-user-set-manager-action.test.ts` both reach.
- **The file surface ran wider than the dispatch's
`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, so `pnpm check:i18n` reds until
`src/apps/translations/*.objects.generated.ts` is regenerated. Four
bundle files outside `identity/`, all generated-then-translated, no
hand-written structure. Flagged rather than silently widened.
- Noted, not filed: `unlock_user` carries `requiresFeature: '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 `#2874` feature-gate roster.
- Noted, not filed: `sys_user.manager_id`'s own field `help` string is
untranslated English in all three translated bundles (pre-existing,
inside the 621 baselined strings this PR leaves flat).
---
_Generated by [Claude Code](https://claude.ai/code)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 81e12e1 commit 74fb2f7
7 files changed
Lines changed: 327 additions & 0 deletions
File tree
- .changeset
- packages/platform-objects/src
- apps/translations
- identity
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
Lines changed: 11 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
218 | 218 | | |
219 | 219 | | |
220 | 220 | | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
221 | 232 | | |
222 | 233 | | |
223 | 234 | | |
| |||
Lines changed: 11 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
218 | 218 | | |
219 | 219 | | |
220 | 220 | | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
221 | 232 | | |
222 | 233 | | |
223 | 234 | | |
| |||
Lines changed: 11 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
218 | 218 | | |
219 | 219 | | |
220 | 220 | | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
221 | 232 | | |
222 | 233 | | |
223 | 234 | | |
| |||
Lines changed: 11 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
218 | 218 | | |
219 | 219 | | |
220 | 220 | | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
221 | 232 | | |
222 | 233 | | |
223 | 234 | | |
| |||
Lines changed: 182 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
0 commit comments