Repository navigation
Commit d438b3a
feat(plugin-auth): the bulk import admits
Fixes #18028
Clause-②: yes
Ruling `5651634638` row 7, split out of #16678 by its Phase 3 landing
report (`5653051072`): `manager_id` is admitted to the bulk-import tier,
resolved in a **second pass** keyed on the importer's identity key, with
every row-2 refusal applied per row and an unresolved key reported as a
per-row error.
Maintainer verbatim, from the ruling this implements:
> 同意 经理 = 管理员在用户上显式设置的 manager_id;部门负责人 = 单元上的 manager_user_id,两者独立。
## The card's three open questions, answered by measurement
**1. Does the importer have a place for a second pass, or does it commit
row-by-row?** It has one, and no restructure is owed.
`runAdminImportUsers` calls the shared row engine ONCE over the whole
batch (`runImport`, one call) and already carries a post-write section
that walks `results` after it returns — the delivery pass (invitation
email / SMS / temporary password) and the run-level audit row both live
there, both gated on `!prepared.dryRun`. `createData` does commit per
row, but nothing observes those commits until `runImport` has returned,
so "every row in the batch exists" is a state the endpoint already
reaches on its own. The manager pass is a third phase in that existing
section. ⇒ **small addition, not a restructure.**
**2. What does the per-row error channel look like?** Two channels, and
the card named the right one. `rows[]` is `ImportRowResult` (`row`,
`ok`, `action`, `id`, `code`, `error`) plus this endpoint's own
extensions, of which `rows[].delivery` is the precedent: a small string
union stamped by a post-write phase. The sibling post-write FAILURE
(`INVITE_EMAIL_FAILED` / `INVITE_SMS_FAILED`) rides `code` + `error` on
a row that stays `created`. This change follows both: `rows[].manager`
carries the machine-readable outcome in `delivery`'s shape,
`rows[].error` carries the sentence.
It does **not** stamp a `rows[].code`, and that is a fence rather than
an oversight — see *What is fenced out* below.
**3. Are N row-wise `runSetUserManager` calls acceptable, or is a batch
seam owed?** Row-wise is acceptable at this endpoint's cap; a batch seam
is not owed. Per link the derivation issues 1 `sys_user` read for the
user, 1 for the proposed manager, at most 2 `sys_member` reads, and 1
read per ancestor while walking the chain (1-2 in a flat org,
`MAX_MANAGER_CHAIN_DEPTH` = 20 worst case), then 1 update. Against
`IMPORT_USERS_MAX_ROWS` = 500 that is ~3k reads typically and ~12k worst
case — on an endpoint whose module header already records ~100ms/row of
scrypt hashing, i.e. ~50s of CPU the batch is already paying. The seam
that IS owed is a different one, and it is small:
`runSetUserManager(deps, actor, request)` takes an HTTP `Request`, so it
could not be driven row-wise as it stood. Rather than fork the
predicates, the refusal core is extracted:
```
export async function applyUserManagerLink(
deps: SetUserManagerDeps, userId: string, managerId: string | null,
): Promise<SetUserManagerRefusal | null>
```
`runSetUserManager` is now exactly that function plus body parsing and
an HTTP envelope; the importer is exactly that function called once per
row. Zero behaviour change on the endpoint — its own 27-test suite is
unchanged and green.
## The refusal-reuse fence, and how it is held
⛔ There is no second copy of the five predicates. The importer imports
`applyUserManagerLink` and reports the `SetUserManagerRefusal` it gets
back — status, code and the `reason` discriminator — without re-wording
it. Four of the five are driven end-to-end through the importer in the
new suite and asserted to surface with the endpoint's own reason:
`self_assignment`, `cycle`, `idp_provisioned`, `cross_organization`
(that last one proves the delegate's `sys_member` screen really runs,
since nothing in the importer reads that table).
The fence is also pinned as an ABSENCE, with a positive control so the
absence is a reading rather than a vacuous pass after a rename: the
importer's source is asserted to contain `applyUserManagerLink` and the
delegate's module specifier, and to contain zero occurrences of each of
the five reason literals, of `MAX_MANAGER_CHAIN_DEPTH`, and of
`sys_member`.
## Proving the pass is really SECOND
A pin that only checks "a manager got resolved" passes against a
single-pass resolve that happens to work because the fixture was written
in dependency order. The discriminating fixture is deliberately out of
order — row 1 names a manager that row 2 creates — and three assertions
stand on it:
| assertion | what it catches |
|---|---|
| row 1 links to the id row 2 minted | the feature |
| CONTROL: the harness records `sys_user` at the instant each row was
written, and the manager is NOT in the snapshot taken for row 1 | proves
the forward reference is real, so the pass above is not explained by
ordered input |
| CONTROL: the link's `update` has a later `invocationCallOrder` than
the LAST `createUser` | a single-pass resolve writes row 1's link before
row 2 is created, so this is false for it |
Both directions of the per-row error are pinned too, because a test
asserting only the first passes against an implementation that aborts
everything: the offending row reports (`manager: 'unresolved'`, an
`error` naming the column), AND `summary.created` is 2, `summary.errors`
is 0, the other row carries no code, and the unlinked user exists with
`manager_id` null — ⛔ not a silent skip.
## Reverse verification — three ablations, each with an on-disk proof
and a restore proof
Every leg: mutate by exact string replacement, prove the mutation
reached disk by occurrence count (never an editor's exit code), run,
restore with `git checkout HEAD -- path`, and prove the restore by blob
hash against `HEAD` plus a clean `git diff HEAD`. All three legs
restored cleanly; the tree ended at `git diff HEAD` clean.
| ablation | expected | MEASURED |
|---|---|---|
| **C** — withhold the second pass itself (its guard to `false`) | red |
**14 of 20 red.** The 6 that stay green are the ones that should: the
empty-cell case, the Tier-1 pin, the source-fence pin, the upsert-patch
pin, the dryRun pin, and the fixture-premise control |
| **B** — neutralize the DELEGATE's `self_assignment` predicate | red in
BOTH suites | **2 red, one in each suite** —
`admin-import-users-manager-pass.test.ts` and
`admin-set-user-manager.test.ts`. One mutation, two callers: that is the
single derivation, demonstrated rather than asserted |
| **A** — withhold the batch-wide in-batch index (`idByKey`) | red |
**GREEN, 20 passed — reported as measured, not as expected.** Correct
and informative: a created row is in `sys_user` by the time the pass
runs, so the directory lookup resolves it anyway. The index is an
OPTIMIZATION (it saves a read per row); the MECHANISM is the pass's
position, which is what C and the two ordering controls measure |
Control run on the unmutated tree: both suites, 47 passed.
## What is fenced out, and routed rather than ridden in
⛔ `packages/spec` is untouched, and one thing wanted to reach into it.
The obvious symmetry with `INVITE_EMAIL_FAILED` would be `rows[].code =
'MANAGER_UNRESOLVED' | 'MANAGER_REFUSED'`. It was written that way
first, and `pnpm check:dispatcher-error-vocabulary` refused it:
```
[unclassified-site] packages/plugins/plugin-auth/src/admin-import-users.ts stamps unregistered
code 'MANAGER_REFUSED' (codehelper) and packages/runtime/src/dispatcher-error-vocabulary.ts does
not classify it.
That file is inside the published face, so the way out is the LEDGER: register
'MANAGER_REFUSED' in packages/spec/src/api/error-code-ledger.zod.ts under its stamping package
```
That registration is a `packages/spec` edit this lane is fenced out of,
and #17995 already carries the closed-vocabulary question for this
endpoint's refusals. So the code half was **removed, not registered and
not faked**: the machine-readable half lives on `rows[].manager` (which
discriminates `unresolved` from each refusal `reason` — finer than two
codes would have been) and the sentence on `rows[].error`. ⛔ Reaching
for an already-registered code whose meaning is something else would be
the lenient alias Prime Directive #12 refuses. The absence is pinned by
a test, so the symmetry cannot be restored without the registration that
makes it legal. The gate is green.
Also untouched, deliberately: `SYS_USER_PROFILE_EDIT_FIELDS` and
`SYS_USER_IMPORT_UPDATE_FIELDS` (asserted unchanged by a test, exactly
as #16678's delivery pinned them — the import reaches the column by
system context, the way it already reaches `phone_number` and `role`);
`content/docs/releases/**`; and §4 org-unit derivation, which the ruling
CUT on axis ①.
## Clause-② re-derived from the BUILT entry, with both controls
`packages/plugins/plugin-auth/src/index.ts:26` carries `export * from
'./admin-import-users.js'`, so the importer's exports reach the package
entry. Measured by occurrence count in `dist/index.d.ts` after a real
build, before and after this change:
| symbol | before | after | reading |
|---|---|---|---|
| `IMPORT_USERS_MAX_ROWS` | 2 | 3 | POSITIVE control — a symbol known to
be published |
| `runAdminImportUsers` | 2 | 2 | POSITIVE control |
| `IdentityImportRowResult` | 0 | 2 | NEW, and in the entry's `export {
… }` clause |
| `ImportManagerOutcome` | 0 | 3 | NEW, and in the entry's `export { …
}` clause |
| `SetUserManagerRefusalReason` | 0 | 3 | newly reachable STRUCTURALLY
(inlined as `ImportManagerOutcome`'s union), not by name |
| `applyUserManagerLink` | 0 | 1 | a docblock mention only — ⛔ not an
export; `admin-set-user-manager.js` is NOT in `index.ts` |
| `SetUserManagerRefusal`, `runSetUserManager`,
`MAX_MANAGER_CHAIN_DEPTH` | 0 | 0 | still unreachable from the entry |
| `resolveManagerKey` / `noteManagerFailure` / `identityKey` /
`ManagerKeyRef` | 0 | 0 | NEGATIVE controls — present in the source at 2
/ 5 / 9 / 3 occurrences, absent from the built entry |
⇒ the card's `Clause-②: yes` is CONFIRMED, not merely inherited: two
named type exports land on the published entry and a third union arrives
structurally. The changeset is graded `minor` accordingly (AGENTS.md: a
PR declaring `Clause-②: yes` takes at least `minor`), which
`check-changeset-no-major --base origin/main` reads as discharged.
## Gates
Head SHA: `2be67d2d4a7bdfc43fe674ab343ea462ba0ac885`.
- `pnpm --filter @objectstack/plugin-auth test` — **111 files, 2360
tests, all passed.**
- `pnpm --filter @objectstack/plugin-auth typecheck` — **exit 0**
(includes `check:test-typecheck` against `tsconfig.test.json`).
- `pnpm lint` (the whole repo, `eslint . --no-inline-config`) — **exit
0**, 2m59s. Not a narrowed run: the full scan.
- The derived gate families for this diff, via `node
scripts/pm/dispatch-gates.mjs --commands` and reconciled with `--ran`:
**69 derived, 68 run green, 1 NOT MEASURED, 0 unrun**, plus 2 run beyond
the union.
The one NOT MEASURED family, declared rather than rounded to green:
> `pnpm check:type-check-debt` — exit 3, PREREQUISITE NOT MET. Its first
exit 3 named four unbuilt workspace dependencies; the closure was then
built (`turbo run build --filter='./packages/*'
--filter='./packages/*/*'`, exit 0) and the prerequisite it named is
satisfied. It now dies differently: its pinned `tsc
--max-old-space-size=6144` re-measure is killed by the V8 heap limit on
this shared container (`FATAL ERROR: Ineffective mark-compacts near heap
limit`). ⛔ Not a pass and not a finding — nothing was measured. Its
sibling `pnpm check:type-check-coverage` (the non-re-measure half) is
**exit 0**.
manager_id, resolved in a second pass through the admin write surface's own refusals (#18046)1 parent b3d6918 commit d438b3a
5 files changed
Lines changed: 994 additions & 22 deletions
File tree
- .changeset
- packages/plugins/plugin-auth/src
- scripts
| 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 | + | |
0 commit comments