feat(plugin-auth): the bulk import admits manager_id, resolved in a second pass through the admin write surface's own refusals - #18046
Conversation
… second pass The admin write surface's five refusals are extracted behind one seam, applyUserManagerLink, and the importer's second pass calls it row-wise instead of carrying a copy of the predicates. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj
…d the refusal fence Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj
… spec ledger does not register Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj
📓 Docs Drift CheckThis PR changes 1 package(s): 13 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 14 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 aa9b95be91c1669e8e24e65d0bc473dc6ea91075 && git checkout aa9b95be91c1669e8e24e65d0bc473dc6ea91075
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7ef05f997325c1ca425546bb764fb65eb729d5c6 6ff3e0b783a78b35ed3a02d53d5eebc344007440 && git checkout -B drift-repro 7ef05f997325c1ca425546bb764fb65eb729d5c6 && git merge --no-ff 6ff3e0b783a78b35ed3a02d53d5eebc344007440
node scripts/docs-audit/affected-docs.mjs --json 7ef05f997325c1ca425546bb764fb65eb729d5c6
|
🔴 Serial-relay hold — this PR and a sibling both append to
|
| side | rows |
|---|---|
origin/main c185d087b |
1491 |
#18022 merge-base 32a321430 |
1491 |
#18046 merge-base 1e20f816e |
1491 |
#18022 head 498f60543 |
1492 |
#18046 head 2be67d2d4 |
1492 |
⇒ the only correct end state once both have landed is 1493. A reading of 1492 means a side was silently swallowed.
⛔ One baton at a time. Whichever of the two lands second must, before enqueueing: merge main, regenerate with the repo's own --write, and prove 0 lost AND byte-identical, with the row count reading 1493. ⛔ The merge exiting 0 is not evidence. ⛔ Path-disjointness is not evidence either — that reasoning was wrong and is recorded as such (correction 170, amended against correction 149: the hazard is the ordinary text merge, and this file carries no merge=os-regen driver, so checking the driver and finding none proves nothing).
Re-measure with:
git show <ref>:scripts/engine-double-contract.pinned.json | python3 -c \
"import json,sys;d=json.load(sys.stdin);print(sum(len(v) for v in d.values()) if isinstance(d,dict) else len(d))"Recorded in the seat registry (#6021 §3). Neither PR is enqueued today in any case — both wait on #18032.
Generated by Claude Code
Contract reviewHead reviewed: ① Clause-② —
|
| limb | reading |
|---|---|
| (a) new exported symbol reachable from the entry | ✅ export type ImportManagerOutcome (:312) |
| (b) new key on an already-published payload | ✅ rows[].manager?: ImportManagerOutcome (:329) and summary.manager: { linked, unresolved, refused } |
⇒ changeset minor ✅, needs:contract-review on both carriers ✅.
🔴 Finding — a published type names an UNPUBLISHED one
// admin-import-users.ts:312 (reachable from the entry)
export type ImportManagerOutcome = 'linked' | 'unresolved' | SetUserManagerRefusalReason;
// ^ not reachable from the entryA consumer can receive the value and switch on the literal strings, but ⛔ cannot import type { SetUserManagerRefusalReason } from the package entry to name the arm, and cannot see its members from the entry's surface. It compiles — both modules live in the same dist — so no gate catches it.
ImportManagerOutcome meant to be nameable end-to-end by a consumer? If yes, the entry should re-export the reason type; if no, say so and the union arm should be spelled out inline instead of borrowing a private name. The predicate to settle it: whether the built dist/index.d.ts carries the literal members or a bare reference to the unexported name.
error.codes for these same five refusals, which would reshape this type.
② The fence this card exists for — HELD, and verified independently
The card's central instruction was ⛔ do not re-derive the five refusals inside the importer. Measured on the head, ⛔ not read off the report:
| reading | admin-import-users.ts |
|---|---|
applyUserManagerLink |
4 |
SetUserManagerRefusalReason |
3 |
idp_provisioned · self_assignment · max_depth_exceeded · cross_organization |
0 each |
⭐ And the seam was extracted rather than the predicates copied — runSetUserManager is now applyUserManagerLink (the decision) plus body parsing and an HTTP envelope, with the reason written into the source:
"A refusal BEFORE it is dressed as an HTTP envelope … so a caller that is not an HTTP request — the bulk importer's second pass — routes the very same predicates onto its own per-row channel. ⛔ A second copy of the five refusals inside the importer is the drift #15706 already cost this platform once; there is ONE derivation and this is it."
⭐ The ablation proves the single derivation instead of asserting it: neutralising the delegate's self_assignment predicate turns one test red in each suite — importer and endpoint — from one mutation.
③ packages/spec — the red line held, and it held the EXPENSIVE way
A row-level error.code (MANAGER_UNRESOLVED / MANAGER_REFUSED) was written first and check:dispatcher-error-vocabulary refused it as unregistered. ⛔ The code half was removed rather than registered in the fenced packages/spec ledger, and ⛔ rather than faked with a mismatched registered code — the cheap exit that would have shipped a lie. The absence is pinned by a test, and the successor is named (#17995 item 2, same file, one visit).
⇒ packages/spec hits: 0. content/docs/releases/**: 0. Both measured from GET /pulls/18046/files.
④ The second pass is proved by a DISCRIMINATING case, ⛔ not by a happy path
The fixture is deliberately out of dependency order — row 1 names a manager that row 2 creates — with two controls a single-pass implementation cannot satisfy: the harness snapshots sys_user at each createUser and the manager is absent from row 1's snapshot, and the link's update invocationCallOrder is asserted greater than the last createUser's.
Per-row error pinned both directions: the offending row reports AND summary.created is 2 with summary.errors 0, the other row carries no code, and the unlinked user exists with manager_id null. ⛔ A one-directional pin would pass against an implementation that aborts the whole import.
⭐ Ablation leg A came back GREEN and was reported as MEASURED rather than as expected — the in-batch index is an optimisation; the mechanism is the pass's position, which legs C and the two ordering controls measure. Reporting a negative ablation honestly is worth more than the leg itself.
⑤ Gates — one NOT MEASURED, correctly refused as neither pass nor finding
69 derived families, 68 green, 0 unrun, 1 NOT MEASURED: check:type-check-debt exited 3 (PREREQUISITE NOT MET); the closure it named was built (turbo run build, exit 0), after which it dies differently — its own pinned tsc --max-old-space-size=6144 is OOM-killed on this container, reproduced 3×. ⛔ Not recorded as a pass. Its sibling check:type-check-coverage is exit 0. Whole-repo eslint . --no-inline-config exit 0 — a full scan, ⛔ not a narrowed one.
⭐ A false red was recorded so it is not re-diagnosed: plugin-auth typecheck reported 25 errors in admin-import-users.ts while the closure build was mid-flight rewriting dist/; re-run against the finished closure it is exit 0.
Verdict: PASS WITH ONE FINDING at 2be67d2d4
The finding in ① is a published-surface nameability question, ⛔ not a fence breach — every fence this card set held, and the one that mattered held by extraction rather than by copying.
- Queue-flake anchor: test/format-zod-union.test.ts #18032 — the
domain:cliformat-zod-uniondefect ejects every PR that reaches the queue. - Serial relay — this PR and fix(plugin-security): a permission-set name collision now reaches the author #18022 each add one row to
scripts/engine-double-contract.pinned.json(both heads 1492,main1491). One baton at a time; the second to land must regenerate and read 1493. See comment5654081472. - CI —
CIandLint & Type Checkare still in progress at this writing, so pre-check ③ is NOT MEASURED, ⛔ not a pass.
Generated by Claude Code
🟢 Baton taken — #18022 is the FIRST of the two to go; #18046 holdsThe blocker cleared: #18043 merged at Re-measured on
⇒ the arithmetic is unchanged: the first lander takes #18022 is armed (ready-flipped via MCP with a Generated by Claude Code |
Contract review — independent clause-② record at
|
| symbol | declared in | new in this PR | re-export path to src/index.ts |
in built dist/index.d.ts export { … } clause |
clause-② reading |
|---|---|---|---|---|---|
ImportManagerOutcome (type) |
admin-import-users.ts:312 |
yes | index.ts:26 export * |
yes — type ImportManagerOutcome in the clause; declared at dist :4319 (3 occurrences total) |
limb (1) HIT — new exported symbol, reachable |
IdentityImportRowResult (interface) |
admin-import-users.ts:320 |
yes (replaces an inline anonymous type on the results array) |
index.ts:26 export * |
yes — type IdentityImportRowResult in the clause (2 occurrences) |
limb (1) HIT — new exported symbol, reachable |
SetUserManagerRefusal (interface) |
admin-set-user-manager.ts |
yes | none — 0 export … from './admin-set-user-manager' in src/ |
no — 0 occurrences anywhere in dist/index.d.ts |
not reachable by name; no limb |
applyUserManagerLink (function) |
admin-set-user-manager.ts |
yes | none | no — 1 occurrence, a docblock sentence at dist :4248; 0 in the clause |
not reachable; no limb |
SetUserManagerRefusalReason (type) |
admin-set-user-manager.ts:134 (pre-existing since #16678) |
no | none | no — 3 occurrences: an unexported local type SetUserManagerRefusalReason = … at dist :4164 with all nine members inlined, the reference inside ImportManagerOutcome at :4319, and one {@link}; 0 in the clause |
not reachable by name; its members arrive structurally through ImportManagerOutcome — see ④ |
runSetUserManager, MAX_MANAGER_CHAIN_DEPTH |
admin-set-user-manager.ts (pre-existing) |
no | none (only consumer is a dynamic import() in auth-plugin.ts:2359, a value import, not a re-export — same at merge-base) |
no — 0 occurrences each | unchanged; no limb |
resolveManagerKey, noteManagerFailure, identityKey, ManagerKeyRef |
admin-import-users.ts (module-private) |
yes | not exported from their module | 0 / 1 (a {@link noteManagerFailure} in a docblock at dist :4257) / 0 / 0; none in the clause |
NEGATIVE controls |
IMPORT_USERS_MAX_ROWS, runAdminImportUsers |
admin-import-users.ts (pre-existing) |
no | index.ts:26 |
yes — both in the clause (3 and 2 occurrences) | POSITIVE controls — known-published symbols |
Built-entry control — dist/index.d.ts at merge-base vs head, each from a real turbo run build --filter='@objectstack/plugin-auth...' (27/27 tasks, exit 0) in its own detached worktree. occ = whole-word occurrences in the file; clause = occurrences inside the final export { … } line (base line 6986, head line 7117).
| symbol | base occ | head occ | base clause | head clause |
|---|---|---|---|---|
ImportManagerOutcome |
0 | 3 | 0 | 1 |
IdentityImportRowResult |
0 | 2 | 0 | 1 |
SetUserManagerRefusal |
0 | 0 | 0 | 0 |
applyUserManagerLink |
0 | 1 (docblock) | 0 | 0 |
SetUserManagerRefusalReason |
0 | 3 (local unexported type, one reference, one {@link}) |
0 | 0 |
runSetUserManager · MAX_MANAGER_CHAIN_DEPTH |
0 | 0 | 0 | 0 |
resolveManagerKey · identityKey · ManagerKeyRef (NEGATIVE) |
0 | 0 | 0 | 0 |
noteManagerFailure (NEGATIVE) |
0 | 1 ({@link} in a docblock) |
0 | 0 |
IMPORT_USERS_MAX_ROWS (POSITIVE) |
2 | 3 | 1 | 1 |
runAdminImportUsers (POSITIVE) |
2 | 2 | 1 | 1 |
diff of the two export { … } lines, split on commas: +type IdentityImportRowResult, +type ImportManagerOutcome, nothing removed. That is the whole named widening of the published entry, and it is exactly what the declaration and the minor grade admit.
Limb (2) — new keys on an already-published payload. The 200 body of POST /api/v1/auth/admin/import-users at merge-base is data.summary{total, created, updated, skipped, errors, dryRun, passwordPolicy, delivery, mode, matchBy} and data.rows[] typed ImportRowResult & { temporaryPassword?, delivery? }. At head, data.summary gains manager: { linked, unresolved, refused } and data.rows[] gains manager?: ImportManagerOutcome. Two new keys on a published payload ⇒ limb (2) HIT. ⛔ No rows[].code is added — the diff removes the MANAGER_UNRESOLVED / MANAGER_REFUSED stamping that check:dispatcher-error-vocabulary refused, and the test pins the absence; no new key lands on the error-code vocabulary.
The remaining two files carry no surface: .changeset/18028-import-users-manager-second-pass.md (the grade) and scripts/engine-double-contract.pinned.json (+1 ledger row naming the new test file; not a published package).
② Semver grade
minor on @objectstack/plugin-auth. AGENTS.md: a Clause-②: yes PR takes at least minor. Every change is additive (two new named types, two new optional/new keys, no removal, no rename, no accept-set narrowing — SYS_USER_PROFILE_EDIT_FIELDS / SYS_USER_IMPORT_UPDATE_FIELDS untouched and pinned), so major is not owed. Consistent.
③ Boundary flags
os-dev-report on #18028 carries open_questions: [] and three out_of_scope_findings, each with a named successor: the row-level error.code registration → #17995 item 2 (same file, same five refusals); the parent's patch-graded .changeset/16678-admin-set-user-manager.md → the release seat at version time (this PR's minor on the same package already governs the next release); the platform-appended second footer → left as-is per AGENTS.md. No flag is unanswered; none needs escalation.
④ Independent judgement on the commissioning seat's finding — "a published type names an unpublished one"
Re-derived, and it is factually right: ImportManagerOutcome is reachable from the entry; the SetUserManagerRefusalReason arm it references is not reachable by name (nor is anything else from admin-set-user-manager.ts). Measured, not inherited: 0 export … from './admin-set-user-manager' in src/ (positive control: the same regex finds index.ts:26 for ./admin-import-users), 0 consumers anywhere in the repo naming SetUserManagerRefusalReason / ImportManagerOutcome / IdentityImportRowResult / applyUserManagerLink outside the two modules and their tests.
What it means for clause-②, decided explicitly:
-
Clause-② speaks to the exported alias, not to the type it references. The floor is "a new exported symbol reachable from the entry" —
ImportManagerOutcomeis one, and theyesstands on it (plusIdentityImportRowResultand the two payload keys) whether or not its arm is nameable. The finding neither adds a hit nor removes one: the verdict and the grade are the same with the arm exported or not. -
The PR does not change the manager module's entry status.
admin-set-user-manager.tswas landed by{ type: 'manager' }resolves a column no product surface can write:sys_user.manager_idis refused by the data API and absent from the auth admin endpoints #16678 withindex.tsnot re-exporting it, and this PR leavesindex.tsbyte-identical. Whether that seam module should be on the published face is the parent card's decision (and spec(approvals):ApproverType.describe()still says the manager column has no product write surface — PR #17993 gives it one, and #17640 left the sentence behind #17995 item 2 is about to reshape the vocabulary), not a regression this PR introduces. Its NEW exports (applyUserManagerLink,SetUserManagerRefusal) inherit the same non-published status, so they are not clause-② hits either — which is the reading a "the wordexportappears in the diff" test would have got wrong. -
A consumer is not blinded. The built
dist/index.d.tsdoes not leave a dangling reference: tsup inlinestype SetUserManagerRefusalReason = 'invalid_body' | 'user_not_found' | 'manager_not_found' | 'self_assignment' | 'cycle' | 'max_depth_exceeded' | 'cross_organization' | 'idp_provisioned' | 'engine_unavailable'at:4164as a local, unexported declaration, so every member ofImportManagerOutcomeis visible and narrowable from the entry — only the NAME of the arm is not importable. The arm is nameable today asExclude<ImportManagerOutcome, 'linked' | 'unresolved'>from the entry alone. -
Not a defect under the filing bar (Prime Directive chore: version packages #10: repro, violated contract text, or authoring trap — none applies), and not a fence breach of this card. It is a nameability note. If the seat wants the reason type nameable end-to-end, the fix is one line in
src/index.ts—export type { SetUserManagerRefusalReason } from './admin-set-user-manager.js'— and that line is itself a clause-② widening that would need its own declaration; ⛔ it is not owed for this landing. -
One adjacent nit the seat did not raise, measured here and stated as a NOTE, not a finding.
SetUserManagerRefusalReasonhas nine members at merge-base and at head (unchanged by this PR), not the five the card lists: the four extra are the endpoint's owninvalid_body,user_not_found,manager_not_found,engine_unavailable. Three of those can genuinely reach a row throughapplyUserManagerLink(the engine gone, a user deleted mid-batch, a manager id that vanished between resolve and link), so admitting them onrows[].manageris right.invalid_bodycannot — the body-parse refusals stay inrunSetUserManagerbefore the seam is called — so the published type over-claims by exactly one member a consumer will never receive. Nothing is widened and no accept-set changes; if the delivery wants the type exact,Exclude<SetUserManagerRefusalReason, 'invalid_body'>on the alias is the one-token fix, and spec(approvals):ApproverType.describe()still says the manager column has no product write surface — PR #17993 gives it one, and #17640 left the sentence behind #17995 item 2 is about to revisit this vocabulary anyway. ⛔ Not blocking.
⇒ The finding does not turn the verdict. PASS stands.
Commands run (all on the detached worktree at 2be67d2d4, sibling of the repo checkout, never inside it)
git worktree add --detach ../objectstack-review-18046 2be67d2d4a7bdfc43fe674ab343ea462ba0ac885
git merge-base HEAD origin/main # 1e20f816e
git diff --quiet 1e20f816e HEAD -- packages/plugins/plugin-auth/src/index.ts # exit 0 (identical)
git diff 1e20f816e HEAD -- …/admin-import-users.ts …/admin-set-user-manager.ts | grep -E '^[+-].*\bexport\b'
grep -rnE "^\s*export\s+(\*|\{[^}]*\}|type\s+\{[^}]*\})\s+from\s+['\"]\./admin-set-user-manager(\.js)?['\"]" src/ # 0
grep -rnE "…/admin-import-users(\.js)?['\"]" src/ # index.ts:26 (positive control)
git grep -n admin-set-user-manager HEAD 1e20f816e -- 'packages/plugins/plugin-auth/src/*.ts'
python3 -c '…package.json exports/files…'
node scripts/pm/check-widening-tells.mjs --declaration no --diff pr18046.diff # 5 files NOT MEASURED — no declared surface covers plugin-auth; the gate saw nothing
node scripts/pm/check-widening-tells.mjs --declaration yes --diff pr18046.diff # control: never blocks a yes
pnpm install --frozen-lockfile && turbo run build --filter='@objectstack/plugin-auth...' # head, real dts build
turbo run build --filter='@objectstack/plugin-auth...' # merge-base worktree (../objectstack-review-18046-base), same deps
for s in …; do grep -ow "$s" dist/index.d.ts | wc -l; done # both trees; plus the same count restricted to the final `export {` line
diff <(base export-clause names) <(head export-clause names) # +2 / -0
Not measured, stated plainly
node scripts/pm/check-clause2-carriers.mjs --pair 18046— NOT MEASURED here; it is the landing seat's pre-check and reads this very comment, so it is meaningful only after this record is posted.- CI on the head, the serial-relay row count on
scripts/engine-double-contract.pinned.json(1492 vs the 1493 owed after fix(plugin-security): a permission-set name collision now reaches the author #18022), and the merge ofmain— NOT MEASURED and out of this record's scope; the seat owns them. pnpm --filter @objectstack/plugin-auth test— NOT MEASURED in this session; the reviewed head's own tests are the seat's ③ pre-check, and this record judges clause-② only.
⛔ Labels, draft state, auto-merge and the branch were not touched.
Generated by Claude Code
|
Served-tier: claude-fable-5-1 Contract review — independent clause-② record at
|
| file | blob at 2be67d2d4 |
blob at 6ff3e0b78 |
|---|---|---|
packages/plugins/plugin-auth/src/admin-import-users.ts |
0488d3b1 |
0488d3b1 — SAME |
packages/plugins/plugin-auth/src/admin-set-user-manager.ts |
513ad5d0 |
513ad5d0 — SAME |
packages/plugins/plugin-auth/src/index.ts |
e1612f1b |
e1612f1b — SAME |
packages/plugins/plugin-auth/package.json |
cea37b9e |
cea37b9e — SAME |
git diff --stat 2be67d2d4 6ff3e0b78 -- <the PR's 5 files> touches exactly one: scripts/engine-double-contract.pinned.json, +5 lines (one ledger row naming plugin-security/src/permission-set-name-collision.test.ts, which main brought in). Row counts: origin/main 1492 · 2be67d2d4 1492 · 6ff3e0b78 1493. That file is not a published package and carries no clause-② surface. ⇒ The surface is the same as the prior record's, and this record now binds it to 6ff3e0b78.
① Symbol-by-symbol reachability from the published entry
The published entry is package.json exports["."].types → dist/index.d.ts, built by tsup from src/index.ts (files = dist, README.md, CHANGELOG.md; package.json unchanged by the PR). The only other entry, ./rate-limit-storage, imports one type from @better-auth/core and nothing from src/; its built .d.ts has 0 hits for any diff identifier. src/index.ts is unchanged by the PR: it carries export * from './admin-import-users.js' at :26 and no export … from './admin-set-user-manager.js' on any line. The full-src/ scan for ^export (*|{…}|type {…}) from finds no module re-exporting admin-set-user-manager (the only non-test references are the type/value import at admin-import-users.ts:117 and a dynamic value import() at auth-plugin.ts:2359 — neither is a re-export; export * carries what a module exports, ⛔ never what it imports).
Built-entry probe on this head: pnpm install --frozen-lockfile then turbo run build --filter='@objectstack/plugin-auth...' (27/27 tasks, exit 0). file = whole-word occurrences in dist/index.d.ts (7117 lines); clause = occurrences inside the final export { … } line (line 7117).
| symbol | declared in | new in PR | path to src/index.ts |
file | clause | clause-② reading |
|---|---|---|---|---|---|---|
ImportManagerOutcome (type) |
admin-import-users.ts:312 |
yes | index.ts:26 |
3 | 1 | limb (1) HIT — new exported symbol, reachable |
IdentityImportRowResult (interface) |
admin-import-users.ts:320 |
yes (base had an inline anonymous type on results) |
index.ts:26 |
2 | 1 | limb (1) HIT — new exported symbol, reachable |
SetUserManagerRefusal (interface) |
admin-set-user-manager.ts:191 |
yes | none | 0 | 0 | not reachable; no limb |
applyUserManagerLink (function) |
admin-set-user-manager.ts:418 |
yes | none | 1 (docblock at dist :4248) |
0 | not reachable; no limb |
SetUserManagerRefusalReason (type) |
admin-set-user-manager.ts:134 (pre-existing) |
no | none | 3 (local unexported type at dist :4164 with all 9 members inlined; the reference at :4319; one {@link}) |
0 | not reachable by name; members arrive structurally — see ② |
SetUserManagerDeps, runSetUserManager, MAX_MANAGER_CHAIN_DEPTH |
admin-set-user-manager.ts (pre-existing) |
no | none | 0 / 0 / 0 | 0 | unchanged; no limb |
resolveManagerKey, noteManagerFailure, identityKey, ManagerKeyRef |
admin-import-users.ts (module-private) |
yes | not exported from their module | 0 / 1 ({@link}) / 0 / 0 |
0 | NEGATIVE controls |
IMPORT_USERS_MAX_ROWS, runAdminImportUsers, IdentityImportEngine |
admin-import-users.ts (pre-existing) |
no | index.ts:26 |
3 / 2 / 3 | 1 / 1 / 1 | POSITIVE controls — known-published symbols, seen by the probe |
Limb (2) — new keys on an already-published payload (measured in source, since the 200 body rides the generic EndpointResult { status, body } and its keys are not named in the .d.ts): at merge-base admin-import-users.ts contains the word manager 0 times. At head, data.rows[] gains manager?: ImportManagerOutcome (:329, and visible in dist :4335) and data.summary gains manager: managerLinks = { linked, unresolved, refused } (:861 dry-run branch, :913 write branch). ⇒ limb (2) HIT. ⛔ No rows[].code for the manager outcome: MANAGER_UNRESOLVED / MANAGER_REFUSED occur only in one docblock (:342), never as a stamped value.
② The ImportManagerOutcome → SetUserManagerRefusalReason question, decided explicitly
Re-derived on this head: index.ts re-exports ./admin-import-users.js and not ./admin-set-user-manager.js; nothing else re-exports it. So the alias is reachable and the type it references is not reachable by name. What that means for clause ②:
- Clause ② speaks to the exported alias, not to the type it references.
ImportManagerOutcomeis a new exported symbol reachable from the entry, so limb (1) is hit by it regardless of whether its arm is nameable.SetUserManagerRefusalReasonis neither new nor reachable, so it contributes no hit on its own. The verdict and the grade are identical whether or not that arm were re-exported. - A consumer of the entry is not left with a dangling reference. tsup inlined the arm as a local unexported
type SetUserManagerRefusalReason = 'invalid_body' | … | 'engine_unavailable'at dist:4164, so every member ofImportManagerOutcomeis visible and narrowable from the entry; only the arm's name is not importable (it remains nameable asExclude<ImportManagerOutcome, 'linked' | 'unresolved'>). - The PR does not change the manager module's entry status.
admin-set-user-manager.tswas off the published face before this PR andindex.tsis byte-identical; its two new exports inherit that status and are not clause-② hits. Re-exporting the reason type would itself be a further widening owed its own declaration; ⛔ not owed for this landing. A nameability note, not a defect and not a fence breach.
Commands run (repo clone at /home/user/objectstack, PR head checked out; ⛔ no worktree)
mcp: get_session (session_id omitted) # Step 0, verbatim above
git fetch origin pull/18046/head:pr18046 && git checkout pr18046 # 6ff3e0b783a78b35ed3a02d53d5eebc344007440
git merge-base pr18046 origin/main # c54d8d67b
git rev-list --parents -n1 pr18046 # 6ff3e0b78 2be67d2d4 c54d8d67b
git diff --stat c54d8d67b pr18046 # 5 files, +994 −22
git diff c54d8d67b pr18046 -- …/admin-import-users.ts …/admin-set-user-manager.ts | grep -E '^[+-].*\bexport\b'
git diff --stat c54d8d67b pr18046 -- …/src/index.ts …/package.json # empty (unchanged)
for f in <4 files>; do git rev-parse 2be67d2d4:$f; git rev-parse pr18046:$f; done # SAME-BLOB ×4
git diff --stat 2be67d2d4 pr18046 -- <the PR's 5 files> # only pinned.json, +5
git show <ref>:scripts/engine-double-contract.pinned.json | python3 -c '…row count…' # 1492 / 1492 / 1493
grep -rnE "from ['\"]\./admin-set-user-manager(\.js)?['\"]" src # only admin-import-users.ts:117 (an import)
grep -rnE "^export (\*|\{[^}]*\}|type \{[^}]*\}) from" src # no re-export of admin-set-user-manager
grep -rnE "admin-set-user-manager" src --include=*.ts # + auth-plugin.ts:2359 dynamic import()
git show c54d8d67b:…/admin-import-users.ts | grep -cE '\bmanager\b' # 0
node -e '…package.json exports/files…'
pnpm install --frozen-lockfile # exit 0
pnpm turbo run build --filter='@objectstack/plugin-auth...' # 27/27, exit 0
for s in …; do grep -ow "$s" dist/index.d.ts | wc -l; sed -n '7117p' dist/index.d.ts | grep -ow "$s" | wc -l; done
grep -nwE SetUserManagerRefusalReason dist/index.d.ts # :4164 local type, :4315 link, :4319 reference
grep -cE 'ImportManagerOutcome|IdentityImportRowResult|SetUserManager|applyUserManagerLink' dist/rate-limit-storage.d.ts # 0
cat .changeset/18028-import-users-manager-second-pass.md # '@objectstack/plugin-auth': minor
Not measured, stated plainly
- A merge-base build of
dist/index.d.tsfor a before/after diff of theexport { … }clause — NOT MEASURED in this session. The head-only probe carries its own positive controls (three known-published symbols seen in the clause) and negative controls (four module-private identifiers absent from it), and the base-side absence of the new names is established at source level (manager0 at base;index.tsunchanged), which is sufficient for a positive verdict. - Tests, typecheck, lint, CI on the head, the serial-relay regeneration proof (
--write,0 lost, byte-identical) — NOT MEASURED; out of this record's scope and the seat's. node scripts/pm/check-clause2-carriers.mjs --pair 18046— NOT MEASURED; it reads this comment and is meaningful only after it is posted.
⛔ Labels, draft state, auto-merge and the branch were not touched.
Generated by Claude Code
Carrier cleared on both carriers — provenanceRecord cited: ⭐ That record replaces Commissioned under the maintainer's ruling of 2026-09-13T22:2xZ, 「你可以直接派fable复核」.
The gate, run on BOTH sides of the strip
Landing state
⭐ Serial relay — second baton, proven
Generated by Claude Code |
Fixes #18028
Clause-②: yes
Ruling
5651634638row 7, split out of #16678 by its Phase 3 landing report (5653051072):manager_idis 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:
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.
runAdminImportUserscalls the shared row engine ONCE over the whole batch (runImport, one call) and already carries a post-write section that walksresultsafter it returns — the delivery pass (invitation email / SMS / temporary password) and the run-level audit row both live there, both gated on!prepared.dryRun.createDatadoes commit per row, but nothing observes those commits untilrunImporthas 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[]isImportRowResult(row,ok,action,id,code,error) plus this endpoint's own extensions, of whichrows[].deliveryis the precedent: a small string union stamped by a post-write phase. The sibling post-write FAILURE (INVITE_EMAIL_FAILED/INVITE_SMS_FAILED) ridescode+erroron a row that stayscreated. This change follows both:rows[].managercarries the machine-readable outcome indelivery's shape,rows[].errorcarries 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
runSetUserManagercalls 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 1sys_userread for the user, 1 for the proposed manager, at most 2sys_memberreads, 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. AgainstIMPORT_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 HTTPRequest, so it could not be driven row-wise as it stood. Rather than fork the predicates, the refusal core is extracted:runSetUserManageris 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
applyUserManagerLinkand reports theSetUserManagerRefusalit gets back — status, code and thereasondiscriminator — 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'ssys_memberscreen 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
applyUserManagerLinkand the delegate's module specifier, and to contain zero occurrences of each of the five reason literals, ofMAX_MANAGER_CHAIN_DEPTH, and ofsys_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:
sys_userat the instant each row was written, and the manager is NOT in the snapshot taken for row 1updatehas a laterinvocationCallOrderthan the LASTcreateUserBoth 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', anerrornaming the column), ANDsummary.createdis 2,summary.errorsis 0, the other row carries no code, and the unlinked user exists withmanager_idnull — ⛔ 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 againstHEADplus a cleangit diff HEAD. All three legs restored cleanly; the tree ended atgit diff HEADclean.false)self_assignmentpredicateadmin-import-users-manager-pass.test.tsandadmin-set-user-manager.test.ts. One mutation, two callers: that is the single derivation, demonstrated rather than assertedidByKey)sys_userby 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 measureControl run on the unmutated tree: both suites, 47 passed.
What is fenced out, and routed rather than ridden in
⛔
packages/specis untouched, and one thing wanted to reach into it. The obvious symmetry withINVITE_EMAIL_FAILEDwould berows[].code = 'MANAGER_UNRESOLVED' | 'MANAGER_REFUSED'. It was written that way first, andpnpm check:dispatcher-error-vocabularyrefused it:That registration is a
packages/specedit 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 onrows[].manager(which discriminatesunresolvedfrom each refusalreason— finer than two codes would have been) and the sentence onrows[].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_FIELDSandSYS_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 reachesphone_numberandrole);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:26carriesexport * from './admin-import-users.js', so the importer's exports reach the package entry. Measured by occurrence count indist/index.d.tsafter a real build, before and after this change:IMPORT_USERS_MAX_ROWSrunAdminImportUsersIdentityImportRowResultexport { … }clauseImportManagerOutcomeexport { … }clauseSetUserManagerRefusalReasonImportManagerOutcome's union), not by nameapplyUserManagerLinkadmin-set-user-manager.jsis NOT inindex.tsSetUserManagerRefusal,runSetUserManager,MAX_MANAGER_CHAIN_DEPTHresolveManagerKey/noteManagerFailure/identityKey/ManagerKeyRef⇒ the card's
Clause-②: yesis CONFIRMED, not merely inherited: two named type exports land on the published entry and a third union arrives structurally. The changeset is gradedminoraccordingly (AGENTS.md: a PR declaringClause-②: yestakes at leastminor), whichcheck-changeset-no-major --base origin/mainreads 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 (includescheck:test-typecheckagainsttsconfig.test.json).pnpm lint(the whole repo,eslint . --no-inline-config) — exit 0, 2m59s. Not a narrowed run: the full scan.node scripts/pm/dispatch-gates.mjs --commandsand 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 --filter @objectstack/plugin-auth typecheckreported 25 errors inadmin-import-users.tswhile the workspace closure build was mid-flight rewritingdist/. Re-run against the finished closure: exit 0.The full
Lint & Repo Gatesstep sequence is CI's — it is ~200 sequential steps and the derived-family set above is what this seat can measure locally without running the whole farm twice.Acceptance notes
Noted, not filed — neither is a reproducible defect, a contract violation, or a metadata-authoring trap, so neither meets the filing bar:
error.codefor the manager outcome is owed to the ledger, not to this lane.MANAGER_UNRESOLVED/MANAGER_REFUSEDwould need registering inpackages/spec/src/api/error-code-ledger.zod.tsunder@objectstack/plugin-auth(where the endpoint's sibling row codesINVITE_EMAIL_FAILED,NO_IDENTITY,CREATE_FAILEDalready sit). Handover: spec(approvals):ApproverType.describe()still says the manager column has no product write surface — PR #17993 gives it one, and #17640 left the sentence behind #17995 item 2, which is already queued to decide dedicated codes for these same refusals in the same file — one visit to one package..changeset/16678-admin-set-user-manager.md(the sibling from the parent card, still unreleased at HEAD) grades@objectstack/plugin-auth: patchwhile{ type: 'manager' }resolves a column no product surface can write:sys_user.manager_idis refused by the data API and absent from the auth admin endpoints #16678 declaredClause-②: yes. Consequence is already neutralized: this PR'sminoron the same package governs the next release of the fixed group. Handover: the release seat reading grades at version time.⛔ Draft, not flipped, no auto-merge armed — landing is the PM seat's.
Generated by Claude Code
Generated by Claude Code