Skip to content

feat(spec,plugin-auth): declare plugins.admin tri-state; refuse explicit admin:false beside effective SCIM at construction (ADR-0071 conflict) - #14246

Merged
os-support-ai merged 5 commits into
mainfrom
claude/issue-13816-scim-admin-tristate-conflict
Sep 1, 2026
Merged

feat(spec,plugin-auth): declare plugins.admin tri-state; refuse explicit admin:false beside effective SCIM at construction (ADR-0071 conflict)#14246
os-support-ai merged 5 commits into
mainfrom
claude/issue-13816-scim-admin-tristate-conflict

Conversation

@claude

@claude claude Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Fixes #13816

Executes the 2026-09-01 director-batch-#21 ruling (issue comment 5491056008): Shape 2, immediate — declare plugins.admin tri-state on the auth-config surface, and refuse loudly at construction time when SCIM is effective while admin is explicitly false. The measurement half (better-auth action-granularity + upgrade evaluation) is #14150 and is deliberately not touched here; #14150 remains open.

Clause-②: yes — this PR changes declared-surface parse output (a .default(false) removal) and adds a new construction-time refusal behavior.

What changed

1. Declaration — packages/spec/src/system/auth-config.zod.ts

AuthPluginConfigSchema.admin moves from z.boolean().default(false) to tri-state z.boolean().optional(), the same shape #13439 → PR #14066 gave scim / sso / ssoDomainVerification in the same file. Docblock and .describe() now state the tri-state semantics: unset ⇒ effective SCIM decides (ADR-0071 forces the admin plugin on, because SCIM's active:false deprovisioning runs through admin ban/unban); explicit true ⇒ mounted unconditionally; explicit false ⇒ declined — and beside effective SCIM, a construction-time conflict. The scim docblock's ADR-0071-coupling sentence was amended to match (its #14066 precedence prose is untouched — see boundary flags).

2. Conflict refusal — packages/plugins/plugin-auth/src/auth-manager.ts

  • New resolveScimEnabled(pluginConfig) — the single decision point for "is SCIM effective" (explicit config wins over OS_SCIM_ENABLED, per [finding] plugins.scim / plugins.sso are undeclared config keys that read as configuration but cannot ever change the outcome — the env var always outranks them #13439), now shared by the plugin mount (buildPluginList), the advertised /auth/config features flag, and the new assert, so the three can never disagree.
  • New assertScimAdminCoherence(pluginConfig) — throws a documented conflict when pluginConfig.admin === false and SCIM is effective. The message names both keys, the ADR-0071 coupling, and the two ways out (accept the admin plugin, or disable SCIM). Plain Error, no wire code — same class as the existing boot-entry asserts (assertAudienceConfig, assertOtpCooldownSeconds); it never reaches an HTTP envelope, so no ADR-0112 ledger entry is due (check:dispatcher-error-vocabulary green confirms).
  • Called at three entry points: the AuthManager constructor (boot refusal, the established pattern), applyConfigPatch on the merged result (no smuggling path; the standing config keeps ruling), and buildPluginList (where the env var is actually read — an OS_SCIM_ENABLED appearing between construction and the lazy better-auth build is still refused).
  • The three ?? scimEffective fallbacks are kept: absent admin under effective SCIM still gets the admin plugin forced on (ADR-0071-backed current behavior); this PR only adds the loud exit for the explicit decline.

3. Generated artifacts + ledger

  • packages/spec/scripts/lib/default-changes.ts: the default move system/AuthPluginConfig:admin false → (none) is declared under major 17 with a full reason (the spec 门禁盲区:可作者化 key 的「默认值 / 约束」变更不被任何 gate、tombstone 或 conversion 记录(#4650 / #4659 同族) #4666 ratchet accepted it; the build prints the reason).
  • packages/spec/authorable-defaults/system.json (row removed) and content/docs/references/system/auth-config.mdx regenerated by check:generated --fix; everything else reproduced byte-identical.
  • Changeset: @objectstack/spec minor + @objectstack/plugin-auth minor (launch-window MINOR rule; a declared-surface change plus a new refusal behavior, no removal/rename — ADR-0087 marker: not-required, no-migration-prescription, since a conflicted config needs a human decision between accepting admin and disabling SCIM, nothing mechanical to rewrite).

Derived judgments (Clause-②, itemised)

  1. .default(false) removal — census of every parse consumer of admin. Runtime parse consumers of AuthConfigSchema/AuthPluginConfigSchema: none (grep census; the only non-spec reference is a comment in auth-manager.ts — the runtime receives plain objects, exactly as the audience docblock records). The two live read points of plugins.admin are the ?? chains in auth-manager.ts (buildPluginList and the /auth/config features mirror), both fed raw config, both verified byte-identical in absent-key behavior. Gates on messages naming auth.plugins.admin (admin-import-users, auth-plugin 501 paths) gate on the mounted API (typeof authApi.createUser), not on the config value — unaffected. AuthPluginConfigParsed has zero external consumers (declaration-only). The z.input type is unchanged (admin was already optional on the input side); only z.infer moves from boolean to boolean | undefined, and spec typecheck plus the full spec suite (448 files / 12035 tests) pass with no expectation on the materialized default anywhere.
  2. Tri-state semantics. Absent / true / false are now three distinct authored states; a parse no longer materialises false onto a silent document. Before this change, any authoring path that parsed through the spec schema would hand the runtime an explicit-looking false that silently defeated the ADR-0071 forced coupling; the tri-state makes parse output faithful to the runtime's raw-config contract.
  3. New refusal's trigger surface. Exactly one corner refuses: admin === false AND effective SCIM. Coherent shapes proven accepted by tests: scim: false, admin: false beside an ambient OS_SCIM_ENABLED (explicit config wins — declining both is legal); admin: false with SCIM off everywhere; admin: true anywhere; absent admin under SCIM (forced on, unchanged). Trigger entry points: constructor, config patch (merged result), lazy build (late env). Deployment-visible consequence, named: a deployment already running the contradiction booted with silently broken SCIM deprovisioning (every active:false ban attempt failing at runtime); it now refuses to construct, with the remedy in the message — that loud stop is the ruled behavior.

Verification

Union runs at the final merged head 9238222d3 (all commands re-run there after the last git merge origin/main):

  • pnpm --filter @objectstack/plugin-auth test87 files / 1803 passed (includes 7 new pins: 4 refusal + 3 acceptance).
  • pnpm --filter @objectstack/spec test449 files / 12052 passed.
  • pnpm --filter @objectstack/plugin-auth typecheck and pnpm --filter @objectstack/spec typecheck — both exit 0.
  • Ablation (reverse verification): with assertScimAdminCoherence neutralized (mutation proved on disk, marker grep 0 → 1), exactly the 4 refusal tests go red (4 failed / 263 passed in auth-manager.test.ts) and the acceptance tests stay green; restore proven by blob identity against HEAD (git hash-object equals the HEAD blob, git diff HEAD empty). The test imports in-package src, so no dist-resolution leg applies.
  • Full derived gate family (scripts/pm/dispatch-gates.mjs, no-paths derivation — re-derived at the merged head, list unchanged) run green locally; check:test-completeness is NOT MEASURED locally by its own contract (it grades a saved CI test log; left to CI). check:generated reproduces all 15 artifacts byte-identical on the merged tree. check:type-check-debt re-measured on the merged tree — no entry above its recorded number (ledger untouched).
  • Known measurement gap, stated: plugin-auth's tsconfig.json excludes test files from typecheck and carries no sibling test-typecheck config, so the edited test file's type health is exercised by vitest only — the pre-existing repo-wide gap already tracked by [finding] No plugin package has a tsc program that compiles its tests — plugin-approvals alone hides 345 type errors, and 0 of 14 are in the test-typecheck ledger #14062 (no new filing).

Boundary flags (not re-judged here)

Generated by Claude Code


Generated by Claude Code

…cit admin:false beside effective SCIM (ADR-0071 conflict)

Part of #13816 — WIP before artifact regeneration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mciyv38maJ6HYVMiaM26T1
…in tri-state; declare the default move; add changeset

Part of #13816.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mciyv38maJ6HYVMiaM26T1
…doc-authoring gate); keep it in comments

Part of #13816.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mciyv38maJ6HYVMiaM26T1
@github-actions github-actions Bot added the size/m label Sep 1, 2026
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-auth, @objectstack/spec, touching 7 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/spec/authorable-defaults/system.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/kernel/services-checklist.mdx (via OS_SCIM_ENABLED (literal, a string literal in buildPluginList; a string literal in getPublicConfig; a string literal in resolveScimEnabled))
  • content/docs/permissions/sso.mdx (via getPublicConfig (symbol, a method of class AuthManager))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx (via OS_SCIM_ENABLED (literal, a string literal in buildPluginList; a string literal in getPublicConfig; a string literal in resolveScimEnabled))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/authorable-defaults/system.json) — pages documenting those are invisible to this run
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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 — 132 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 b360cc7d5ca2e9cdb60a12018af20cbc3470cafapackageMentionDocs.

Which tree this was computed on

This run read content/docs from 0890a6356b60fef6264cd255676fb8db1860ca0a — the merge of head 9238222d3b638497b8f1990964025b9691c3ebdf into base b360cc7d5ca2e9cdb60a12018af20cbc3470cafa, 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 0890a6356b60fef6264cd255676fb8db1860ca0a && git checkout 0890a6356b60fef6264cd255676fb8db1860ca0a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b360cc7d5ca2e9cdb60a12018af20cbc3470cafa 9238222d3b638497b8f1990964025b9691c3ebdf && git checkout -B drift-repro b360cc7d5ca2e9cdb60a12018af20cbc3470cafa && git merge --no-ff 9238222d3b638497b8f1990964025b9691c3ebdf

node scripts/docs-audit/affected-docs.mjs --json b360cc7d5ca2e9cdb60a12018af20cbc3470cafa

⚠️ 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 b360cc7d5ca2e9cdb60a12018af20cbc3470cafa → pass the list as
args.docs, on the commit named under Which tree this was computed on.

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 protocol:system size/m tests tooling

Projects

None yet

2 participants