Skip to content

fix(plugin-auth,lint): sys_user.manager_id gains an admin write surface, and the manager rung stops resolving to nobody - #17993

Merged
os-project-manager merged 5 commits into
mainfrom
claude/issue-16678-manager-admin-write-surface
Sep 13, 2026
Merged

os-project-manager merged 5 commits into
mainfrom
claude/issue-16678-manager-admin-write-surface

Conversation

@claude

@claude claude Bot commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Fixes #16678

Clause-②: no

Phase 3 of the manager relationship, executed against the director-seat ruling (comment 5651634638, decision batch #127 item 1, option B) on the Phase 2 design (comment 5648180780). Maintainer, verbatim — kept in the original, because rewriting the quotation rewrites the ruling:

同意 经理 = 管理员在用户上显式设置的 manager_id;部门负责人 = 单元上的 manager_user_id,两者独立。

The hole, reproduced before it was closed

sys_user.manager_id drives the approvals { type: 'manager' } rung and the own_and_reports read scope, and no product surface could write it. Measured on the branch point:

route reading control
admin-import-users.ts matches manager_id 0 phone_number 8, same file, same scan
whole of plugin-auth/src writing manager_id 0 11 total matches, every one a test assertion, a comment, or managed-extension-fields.ts listing it as not editable
ADR-0092 D2 managed-update whitelist for sys_user {name, image, locale}

So on any install without a directory sync the rung expanded to nobody.

What this delivers

1. The endpoint. POST /api/v1/auth/admin/set-user-manager, body { userId, managerId }, managerId: null clears. An ObjectStack mount on the raw app ahead of the better-auth catch-all — the unlock-user / import-users family — platform-admin gated (ADR-0068), ledgered in auth-route-ledger.ts and added to the conformance pin that asserts the source: 'objectstack' set exactly.

2. It reaches the column by CONTEXT, never by whitelist. The handler runs under a system context, exactly as admin-import-users already reaches phone_number and role; both write guards gate on isUserContextWrite, spelled Boolean(userId) && isSystem !== true. So SYS_USER_PROFILE_EDIT_FIELDS, MANAGED_EXTENSION_EDITABLE_FIELDS.sys_user and manager_id's readonly: true are all unmoved, and ADR-0092 D4 holds by construction. A test asserts the whitelist is still exactly {name, image, locale}, so a future change that "fixes" this by widening Tier 1 goes red here rather than quietly.

3. Five refusals, every one enforced at the write, each with its reason in the code:

refusal error.code status error.details.reason
body shape INVALID_REQUEST 400 invalid_body
target user missing RESOURCE_NOT_FOUND 404 user_not_found
proposed manager missing INVALID_REFERENCE 400 manager_not_found
self-assignment INVALID_FIELD 400 self_assignment
cycle RESOURCE_CONFLICT 409 cycle
depth cap VALUE_OUT_OF_RANGE 400 max_depth_exceeded
cross-organization INVALID_REFERENCE 400 cross_organization
directory-owned identity PERMISSION_DENIED 403 idp_provisioned

The cycle check has to be here: the only manager-chain walkers in the open tree are single-hop (ApprovalService.lookupManager, TeamGraphService.managerOf, one row each) and the multi-hop resolver ships outside this repo, so nothing downstream catches a loop this endpoint lets in. The walk carries its own seen set, so a loop that already exists upstream is reported rather than hung on.

The cross-organization refusal is the write-time half of managerIsProvablyOutsideOrg, and deliberately shares its "provably" shape: sys_user carries no organization_id, so sys_member rows are the only tenancy fact, and an absent fact is not a negative one. Both halves are wanted — data drifts after a write.

The idp_provisioned refusal is the ruling's row 5 (P1, directory wins per identity), keyed on the sys_user.source column the platform already stamps. It applies to the clear as well as the set, because both are writes the next sync would revert — the shape ADR-0049 exists to refuse.

Why these codes and not dedicated ones: error.code is a closed vocabulary (StandardErrorCode union ERROR_CODE_LEDGER) and both live in packages/spec, which this lane is fenced out of. So every code is one this package may already emit, and error.details.reason carries the machine-readable discrimination, pinned per refusal by the tests. A dedicated code per refusal is the better shape and is reported as a follow-up rather than reached over the fence for.

4. The lint rule keeps its finding; only its remedy changed. approval-approvers-may-resolve-empty and stackWiresManagerChain are untouched as a rule — the dead end it reports survives the write surface, because a static check still cannot read the column; only its cause became recoverable. MANAGER_ONLY_REMEDY / MANAGER_ONLY_ROUTES now name the endpoint, its body, how to clear the link and what it refuses; the docblock's graded routes are re-taken, and the two "NOT here" grades (SCIM, bulk import) re-measured unchanged.

5. The docs callout in content/docs/automation/approvals.mdx carries the same rewrite in prose, including the directory-precedence rule.

STOPPED AT THE packages/spec FENCE — exactly one part

ApproverType.describe() in packages/spec/src/automation/approval.zod.ts still asserts "that column has no product write surface", and that sentence stops being true with this PR. Nothing is pushed for it here.

Verified on origin/main rather than assumed, as the dispatch required: commit 2eb472418 (#17640) is an ancestor of HEAD — git merge-base --is-ancestor exit 0, which is self-certifying and needs no control leg. But what that commit landed is a .describe() that points at the remedy while still carrying the now-false assertion. So the third dependent is landed and stale, not landed and fixed. The generated content/docs/references/automation/approval.mdx renders the string verbatim and is auto-generated, so it is downstream of the same fenced edit.

⇒ For the domain:spec seat. It is the only part stopped at the fence.

Acceptance notes

  • Ruling row 7 (bulk import) — admitting manager_id to the import tier is ruled but is not in this lane's scope fence. admin-import-users.ts is untouched and its 0-match reading is re-stated in the lint docblock as still current.
  • Ruling row 4 (org-unit derivation) is cut by the ruling. sys_business_unit.manager_user_id and sys_user.manager_id stay independent; BusinessUnitGraphService.headOf is not consumed.
  • The delegated-admin axis (design §3.3) is not declared. ADR-0068 platform-admin is the floor; a fourth axis on delegated-admin-gate.ts is a governed permission-model change and ADR-0049 forbids declaring what is not enforced. Recorded as a follow-up, with the Entra precedent attached in the design.
  • The Console affordance is objectui#9361, a different repo. The design fixes its shape: a read-only lookup in the existing Organization group plus a row action posting this endpoint, never a field in the standard edit form.
  • Design §8.1 observations, noted and not filed — this card's own material rather than new defects, and the ruling assigns them to whichever change touches the file first: the dead-slot literal reads manager:undefined because value is omitted for manager; and the approvals: a department approver never resolves when the business unit has organization_id = null (every seeded BU) #3807 "expanded to nobody" pin table in approval-service.test.ts covers team / department / position / org_membership_level and omits manager, so that behaviour is real by set membership and not by test. Neither file is touched here.
  • content/docs/permissions/tenant-audit-census.mdx and its counts ledger moved 222 to 223 write call sites. That is this PR's own new engine.update, regenerated with scripts/tenant-audit-census.mjs --write, with the seven hand-written prose figures brought along.

Clause-② — re-derived from the delivered diff, not predicted

New exported symbols in the diff, and whether each is reachable from a published entry. plugin-auth's exports map has exactly two entries (. to dist/index.*, ./rate-limit-storage to dist/rate-limit-storage.*); admin-set-user-manager.ts is deliberately not re-exported from src/index.ts (0 matches). Measured on the rebuilt dist, with two positive controls that ARE re-exported, so the scan discriminates:

symbol dist/index.d.ts dist/rate-limit-storage.d.ts
runSetUserManager 0 0
MAX_MANAGER_CHAIN_DEPTH 0 0
SetUserManagerDeps 0 0
SetUserManagerEngine 0 0
SetUserManagerResult 0 0
SetUserManagerRefusalReason 0 0
runAdminBanUser (control) 2 0
SYS_USER_PROFILE_EDIT_FIELDS (control) 3 0

No already-published payload gains a key — the endpoint's response is a new payload, not a new field on an old one. A new route is wire, and wire compatibility is not the Clause-② floor. ⇒ no, changeset patch.

scripts/pm/check-widening-tells.mjs --declaration no exits 0 but reports 8 of 8 files NOT MEASURED ("no declared surface covers it"), so it is not a reading in either direction here and is not offered as one.

⚠️ Declared conflict, not silently resolved. The ruling's own state paragraph says "the endpoint carries Clause-②: yes and goes through contract review". The PM claim declares Clause-②: no, explicitly as a prediction to be re-derived from the delivered diff, and names the exact flip condition — a re-export from plugin-auth/src/index.ts, or a new key on a published payload. Neither holds, so both carriers now read no and agree. If the seat that owns the vocabulary wants the tier anyway on the strength of the ruling's sentence, that is its call to make, and this note is here so it is made rather than inherited.

Checks

Run on 614981107, which is this branch merged with origin/main via scripts/pm/os-regen-merge.sh, deps refreshed and every package rebuilt (72/72 turbo tasks) afterwards.

check result
pnpm --filter @objectstack/plugin-auth test 110 files, 2340 tests passed
pnpm --filter @objectstack/lint test 103 files, 3780 tests passed
pnpm --filter @objectstack/plugin-auth typecheck exit 0
pnpm --filter @objectstack/lint typecheck exit 0
derived gate families (scripts/pm/dispatch-gates.mjs --commands) 94 derived, 94 run, 0 NOT-MEASURED, 0 UNRUN — reconciled with --ran carrying an exit code per family, so the zero is derived rather than claimed
pnpm --filter @objectstack/spec check:generated all 15 generated artifacts up to date after the merge
pnpm exec eslint --no-inline-config (narrowed) 7 files, 0 errors, 0 warnings

Three gate families were red on the first sweep and every one was a real finding of this diff, fixed rather than baselined: check:doc-authoring (the ledger note carried a tracker id — a runtime string no author can resolve; moved to an adjacent comment), check:where-matcher (the new test engine double read a WHERE combinator as a field name; it now refuses loudly), and check:tenant-audit-census (the new engine write moved the ratchet). Three more answered exit 3, PREREQUISITE NOT METcheck:skill-examples, check:dual-build-cjs-loads, check:type-check-debt — which is a statement about the tree and never a pass; the closure they name was built and all three then exited 0.

The eslint narrowing is a measurement, not a skipped run, so its three pieces of evidence are stated rather than implied: (i) the population comes from eslint.config.mjs's own files globs (**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}), not from a guess; (ii) the file count is read from --format json output, 7 entries; (iii) this repo runs one eslint.config.mjs which never enables type-aware linting for any file — no parserOptions.project, no typed rules, stated in that file's own header with a recorded positive control — so nothing in this diff can move the verdict on a file it does not touch. The repo-wide pnpm lint is CI's run.


Generated by Claude Code

WIP: endpoint, mount, ledger row and tests. Verification pending.

Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj
Co-authored-by: Claude <noreply@anthropic.com>
… remedy names the route

Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj
Co-authored-by: Claude <noreply@anthropic.com>
…l, tenant census

- auth-route-ledger note carried a tracker id (check:doc-authoring Rule 3)
- the test engine double read a WHERE combinator as a field name
- the write-call-site census moved 222 -> 223 with the new engine write

Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Sep 13, 2026
@github-actions

github-actions Bot commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/lint, @objectstack/plugin-auth, touching 48 documentable anchor(s).

51 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 65767d2340dabce16953908699082dd97032da3b.

9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 2 anchor(s) matched too much of the corpus to be a work list: organization_id (literal, 31 pages), sys_user (literal, 35 pages)
  • 9 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 18 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 65767d2340dabce16953908699082dd97032da3bpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 1890772821f44c3fd0fbff224c635c7562b2ce03 — the merge of head 02a036ad344290dad8565776ec5016637e722635 into base 65767d2340dabce16953908699082dd97032da3b, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 1890772821f44c3fd0fbff224c635c7562b2ce03 && git checkout 1890772821f44c3fd0fbff224c635c7562b2ce03
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 65767d2340dabce16953908699082dd97032da3b 02a036ad344290dad8565776ec5016637e722635 && git checkout -B drift-repro 65767d2340dabce16953908699082dd97032da3b && git merge --no-ff 02a036ad344290dad8565776ec5016637e722635

node scripts/docs-audit/affected-docs.mjs --json 65767d2340dabce16953908699082dd97032da3b

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 65767d2340dabce16953908699082dd97032da3b → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@claude

claude Bot commented Sep 13, 2026

Copy link
Copy Markdown
Contributor Author

Contract review

Head reviewed: 6149811071a33811038a7dc5b5d5130a19ff9b2f

Implemented-by: claude/issue-16678-manager-admin-write-surface (mode:subagent — the branch, not a session)
Reviewed-by: session_01URLHobLUJB9K1ABV6ofdjj (domain:services execution seat)

① The declared conflict with the ruling — adjudicated, ⛔ not papered over

The delivery declined to resolve this silently and named it instead. Good. Ruling 5651634638's state paragraph says "the endpoint carries Clause-②: yes and goes through contract review"; the claim 5652065130 declared no, explicitly as a prediction with its flip conditions named.

Adjudication: the measurement stands — no. Clause-② is a mechanical floor measured on the delivered diff, ⛔ not a tier assigned in advance. Re-derived in-seat at this head:

symbol (new, in admin-set-user-manager.ts) reachable from a published entry
runSetUserManager, MAX_MANAGER_CHAIN_DEPTH, SetUserManagerDeps, SetUserManagerEngine, SetUserManagerResult, SetUserManagerRefusalReason no — all six

src/index.ts contains 0 references to the module and is itself absent from the changed-file set; package.json is absent too, so the exports map (. and ./rate-limit-storage) did not move. ⭐ The scan discriminates: the modules carrying the positive controls runAdminBanUser and SYS_USER_PROFILE_EDIT_FIELDS are re-exported from index.ts (2 hits each), and the delivery counted the same controls in the rebuilt dist/*.d.tsrunAdminBanUser 2, SYS_USER_PROFILE_EDIT_FIELDS 3, against 0 for each of the six. ⛔ A zero whose control also reads zero is not evidence; these have theirs.

Limb (b): the endpoint's response is a new payload, ⛔ not a new key on an already-published one. A new route is wire, and wire compatibility is ⛔ not the floor.

⇒ reporting yes here would record a false measurement to satisfy a sentence. ⭐ And the tier's substantive consequence — "goes through contract review" — is satisfied regardless: this seat contract-reviews every PR it lands, and this is that review. ⚠️ If the maintainer wants the label imposed on the ruling's authority rather than the measurement's, that is a card-state act for the seat owning the vocabulary, and the PR body correctly leaves the door open for it.

check-widening-tells --declaration no exits 0 but reports 8 of 8 files NOT MEASURED ("no declared surface covers it"), and the delivery reported that as NOT MEASURED, ⛔ not as a clean reading. That is the correct handling: exit 0 from an instrument that could not see the question is not a pass.

⚠️ The packages/spec fence — stopped correctly, and the stop CHANGED A FACT

One part stopped: ApproverType.describe() still asserts "that column has no product write surface", which this PR falsifies. Nothing was pushed for it. ✅ Routed out as #17995, as #17909 was routed out of #15556.

And the verification the dispatch demanded overturned the design's own note. §8.3 recorded that dependent as "landed since in 2eb472418 / #17640#17579 is closed in substance". Measured rather than assumed: git merge-base --is-ancestor 2eb472418 HEADexit 0 (self-certifying; the checkout is not shallow, so the reading is not a clone artefact) — the commit really is an ancestor — but what it landed is a describe that POINTS at the remedy while still carrying the false sentence. ⇒ landed AND stale, ⛔ not landed and fixed. A reader trusting "closed in substance" would have skipped the one edit actually owed. ⛔ "Verify rather than assume" earned its keep here in the direction that costs.

A second spec consequence was recorded, ⛔ not reached for: error.code is a closed vocabulary (StandardErrorCode / ERROR_CODE_LEDGER), so the five refusals reuse existing codes plus an error.details.reason discriminator. Both items are one visit to the same package — #17995 carries them together.

③ The scope fence — held on every arm

§4 org-unit derivation: CUT. BusinessUnitGraphService.headOf is not consumed; sys_business_unit.manager_user_id and sys_user.manager_id stay independent, per the ruling.
No delegated-admin axis. plugin-security/src/delegated-admin-gate.ts untouched — ADR-0049 forbids declaring what is not enforced; recorded as the design's named follow-up.
The lint rule is NOT deleted. stackWiresManagerChain stays the silencer, the finding stays, and only MANAGER_ONLY_REMEDY / MANAGER_ONLY_ROUTES values change to name the endpoint — module-private consts, so packages/lint adds no new export at all.
Tier 1 unmoved. SYS_USER_PROFILE_EDIT_FIELDS, MANAGED_EXTENSION_EDITABLE_FIELDS.sys_user and manager_id's readonly: true are all unchanged — the endpoint reaches the column by system context, ⛔ not by whitelist — and that is asserted by a test, so a future "fix" that widens Tier 1 goes red instead of landing quietly. ⭐ Pinning the absence of a change is the part most deliveries skip.
content/docs/releases/**: zero. ⛔ packages/spec: zero files.

④ Evidence

Reproduced the hole first: admin-import-users.ts matches manager_id 0 times against a control of phone_number 8, and the ADR-0092 D2 managed-update whitelist for sys_user is {name, image, locale}. ⇒ no product surface could write the column that both the manager rung and own_and_reports read.

Three gates were genuinely RED on the first sweep and all were FIXED, ⛔ none baselined: check:doc-authoring (an unresolvable issue-id in sibling-package prose → moved to an adjacent comment); check:where-matcher (the new engine double read a combinator as a field name → now refuses combinators loudly); check:tenant-audit-census (the new engine.update moved the write-call-site ratchet 222 → 223 → regenerated with --write, 23 prose figures brought along). Three more answered exit 3 (PREREQUISITE NOT MET) — closure built, all three re-run to exit 0. 94 derived gate families, 94 run, 0 NOT-MEASURED, 0 UNRUN, reconciled with --ran so the zero is derived.

Tests: plugin-auth 110 files / 2340 tests; lint 103 files / 3780 tests; typecheck 0 both. ⭐ ESLint was narrowed, and all three pieces of evidence are supplied rather than the run being skipped — population read from the config's own globs, file count read from --format json, and an invariance argument (this repo's single config enables type-aware linting for no file, with a recorded positive control) showing the diff cannot move the verdict on an untouched file.

No ablation, and none claimed — correctly: this PR adds an endpoint and rewrites prose; it introduces no guard whose ability to fail needed proving. The discriminating-control pattern stands in its place, and each refusal suite carries an admitting control beside it (a diamond is not a cycle; a shared organization admits; an absent tenancy fact admits; the depth boundary pinned at MAX and MAX+1).

⚠️ One boundary declared rather than smoothed: the cross-organization and idp_provisioned refusals fail open when the underlying read throws — deliberately mirroring the ruled posture of managerIsProvablyOutsideOrg, logged once with consequence and remedy, and pinned by a test. ✅ Declared, not silent.

Verdict: PASS at 6149811071

Carrier gate: check-clause2-carriers --pair 17993exit 0, captured before any pipe.

⚠️ Binds to the head it names. ⚠️ Landing waits on pre-check ③ measured at the workflow-run level (correction 162). ⚠️ Serial relay: this PR holds the tenant-audit-census generated pair; swept across every open PR — one holder, and ⛔ not the same PR that holds engine-double-contract.pinned.json (#17988). Two batons, two carriers, no collision.


Generated by Claude Code

@claude

claude Bot commented Sep 13, 2026

Copy link
Copy Markdown
Contributor Author

🔴 Dogfood Regression Gate is red, it is THIS PR's, and a rework is in flight

Read by job id (103706904743), ⛔ not by the check's name. Failing step #10 Boot example apps and exercise real user flows:

FAIL isolated test/admin-route-nonadmin-refusal.dogfood.test.ts > #9482 C9
AssertionError: the stack serves /admin/ route(s) this suite does not classify.
  A new admin route is IN SCOPE the moment it is mounted: add it to EXPECTED
  with the answer a non-admin must get, and it will be swept from then on.
POST /api/v1/auth/admin/set-user-manager: expected [ Array(1) ] to deeply equal []

Test Files 1 failed | 45 passed; Tests 1 failed | 353 passed.

Ruled out as "not this PR's" with a control, ⛔ not by assertion: Dogfood Regression Gate is green on both sibling PRs against the same base#17988 (76ee9926e) and #17982 (7222252f8), every shard plus Dogfood Verify CLI. ⇒ ⛔ not a platform blip, ⛔ not a base-branch break, ⛔ not a flake. This diff mounts a new /admin/ route and did not classify it; the gate caught exactly what it exists to catch.

The gate is correct and will not be weakened. It checks both directions on purpose — an unclassified route fails and a stale entry fails — which is the mechanism that puts route N+1 in scope automatically. ⛔ No baseline, ⛔ no skip, ⛔ no entry with a guessed answer.

🔴 And the remedy has a trap the rework was warned about explicitly. That suite documents its own sharpest edge: better-auth validates the request body BEFORE the admin check, as do the /admin/unlock-user and /admin/oauth2/toggle-disabled mounts. Its measured example — an empty body at /admin/ban-user returns a plain member 400 VALIDATION_ERROR byte-identical to what a platform admin receives. ⇒ the entry must record the measured non-admin answer for this endpoint's own ordering, ⛔ not an assumed 403.

⚠️ Shard 2/3 also failed (job 103706904761, same step). It is being checked separately — ⛔ one fix is not assumed to cover both.

⇒ rework dispatched to the implementing dev with the diagnosis above. The push will move the head, so this seat re-issues the contract review against the new head and re-hangs/re-clears the dual carrier (correction 160). ⛔ No re-run was spent here: a re-run cannot fix an unclassified route.


Generated by Claude Code

…sweeps

Both /admin/ sweeps derive their population from the running stack, so the new
mount is in scope the moment it exists and must be classified with the answer a
non-admin actually gets. Measured against the live stack rather than assumed:
this mount runs gateAdmin() as its first statement, ahead of every body read, so
anon gets 401 UNAUTHENTICATED and a member 403 PERMISSION_DENIED on ANY body --
including an empty one, which is the opposite of unlock-user's ordering. The
platform admin gets 200 from the idempotent clear.

- admin-route-nonadmin-refusal: objectstack-gate, body { userId, managerId: null }
- admin-platform-admin-standing: ADMITTED + the same sweep payload

Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj
Co-authored-by: Claude <noreply@anthropic.com>
@claude

claude Bot commented Sep 13, 2026

Copy link
Copy Markdown
Contributor Author

Contract review — RE-ISSUED for the post-rework head

Head reviewed: 02a036ad344290dad8565776ec5016637e722635

Implemented-by: claude/issue-16678-manager-admin-write-surface (mode:subagent — the branch, not a session)
Reviewed-by: session_01URLHobLUJB9K1ABV6ofdjj (domain:services execution seat)

⚠️ Supersedes 5652622296, which names head 6149811071. A record binds to the head it names; re-verifying at a new head is necessary and ⛔ not sufficient. ✅ The needs:contract-review dual carrier needs no re-cycle here — Clause-② is no, so no carrier is hung; check-clause2-carriers --pair 17993 reads exit 0 at this head.

① What the rework changed — and what it did not

git diff --name-only 6149811071..02a036ad3two files, both *.test.ts; non-test files in the delta: 0.

Clause-② is unchanged: no. The whole adjudication in 5652622296 stands on its own terms — six new exported symbols in admin-set-user-manager.ts, none reachable from a published entry, positive controls discriminating, a new payload rather than a new key on a published one. Nothing in this delta touches any of it.

② The rework answered the question it was sent to answer — by MEASURING, and the measurement cut against the file's own edge

The dispatch warned that admin-route-nonadmin-refusal.dogfood.test.ts documents a trap: better-auth, /admin/unlock-user and /admin/oauth2/toggle-disabled all read and shape-check body before calling getSession, so a plain member can receive a 400 VALIDATION_ERROR byte-identical to an admin's — ⛔ therefore do not assume 403.

Measured, the edge cuts the other way for this endpoint, and the entry records why: this mount runs gateAdmin(c) as its first statement, ahead of every body read, unlike unlock-user's. Fired three ways at the live stack:

caller answer
anon 401 UNAUTHENTICATED
member 403 PERMISSION_DENIED
platform admin 400 INVALID_REQUEST "userId is required" (empty body) / 200 {"success":true,…,"managerId":null} (sweep payload)

⇒ the answer is a 403 — but it is now a 403 for a recorded reason, with the divergence from its siblings written down beside it. ⛔ The right value reached by assumption would have been indistinguishable in the file and worthless to the next reader.

And it caught a vacuity trap the dispatch did not name. The payload entry is not optional even though the gate runs first: with no entry the handler answers 400 INVALID_REQUEST "userId is required", which is neither a gate refusal nor a match for the diedBeforeTheGate predicate (that reads VALIDATION_ERROR, or an "Invalid input" / "body." message — and this is neither). ⇒ "a missing payload would slip through every assertion below as a silent void." That is a test that would have passed while measuring nothing, found and closed by the person adding it.

⭐ The allowed-side contrast was added too (admin-platform-admin-standing.dogfood.test.ts), with the clear — ⛔ not a set — chosen because it is idempotent: the sweep leaves manager_id as it found it and still reaches a 2xx rather than a semantic error. ⇒ the both-sides half of the suite has somewhere to run for this route, which is what the file's own final assertion demands.

③ The gate was satisfied, ⛔ not weakened

⛔ No baseline, ⛔ no skip, ⛔ no entry with a guessed answer, ⛔ no re-run spent — a re-run cannot classify a route. The suite's bidirectionality (unclassified fails and stale fails) is intact, and this PR's route is now swept from here on.

Verdict: PASS at 02a036ad3

⚠️ Binds to the head it names. ⚠️ Landing waits on pre-check ③ at the workflow-run level (correction 162) — CI is re-running on this head, with the Dogfood shards the ones that matter. ⚠️ The packages/spec fence item is unchanged and still routed out as #17995.


Generated by Claude Code

@os-project-manager
os-project-manager marked this pull request as ready for review September 13, 2026 11:13
@os-project-manager
os-project-manager added this pull request to the merge queue Sep 13, 2026
Merged via the queue into main with commit 4f1a56b Sep 13, 2026
37 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-16678-manager-admin-write-surface branch September 13, 2026 11:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants