Repository navigation
Description
Surfaced from the cloud side while implementing objectstack-ai/cloud#1451 (hardening the cloud control plane's own AuthPlugin against an ambient OS_SCIM_ENABLED). Filed unassigned, recording a framework contract gap; the cloud card is parked on the decision this issue describes.
Measured at 1a540e82b1421c26ea036ff67e381689075b501c (the SHA cloud/.objectstack-sha currently pins) and re-checked on main at the same line — no drift between them.
The two halves
1. The keys are not declared. AuthPluginConfigSchema (packages/spec/src/system/auth-config.zod.ts:20) declares exactly ten keys: organization, twoFactor, passkeys, passwordRejectBreached, magicLink, oidcProvider, dynamicClientRegistration, deviceAuthorization, admin, phoneNumber. buildPluginList nevertheless reads three more off the same object through an any cast (packages/plugins/plugin-auth/src/auth-manager.ts):
:2426 const scimEffective = scimFromEnv ?? (pluginConfig as any).scim ?? false;
:2445 scim: scimEffective,
:2446 sso: ssoFromEnv ?? (pluginConfig as any).sso ?? false,
ssoDomainVerification: ssoDomainVerifyFromEnv ?? (pluginConfig as any).ssoDomainVerification ?? false,
So plugins.scim is a key an author can write, that typechecks only because of the cast, that no schema documents, and that no publish-time check would ever reject or confirm.
2. Where it IS read, the env var always wins. readBooleanEnv (auth-manager.ts:256) returns undefined only when the variable is absent; any present value — including the empty string — resolves to a boolean. Because scimFromEnv is the left operand of the ?? chain, a host that writes plugins: { scim: false } gets no effect at all whenever OS_SCIM_ENABLED is set. The same holds for sso / OS_SSO_ENABLED.
The precedence itself is deliberate and documented in the block comment above it ("the env var WINS over the config-file setting so platform operators can override per-environment without touching the application bundle"). The gap is not that operators can override — it is that a host has no counterpart: there is no declared way for the code that constructs AuthPlugin to state a value the deployment env cannot silently outrank.
Why this is worth a decision rather than a patch
The combination is the sharp part. An undeclared key that is read anyway reads, to any author, as the supported way to say "not on this surface" — and it is silently inert in exactly the deployments where it matters. The cloud-side card walked into precisely that: its accepted fix sketch was "pass an explicit scim: false at the control plane's AuthPlugin construction", which the reading above shows would have shipped a line that looks like a security control, passes review, passes typecheck, and changes nothing.
dynamicClientRegistration already shows the shape the platform uses when a host needs to force a value against a default: a declared, tri-state z.boolean().optional() whose docstring spells out what unset means. scim and sso have neither the declaration nor the tri-state.
Amplification worth naming, since it is not local to the SCIM line: admin: pluginConfig.admin ?? scimEffective (:2439, mirrored at :5064). A deployment that never asked for the better-auth admin plugin gets it — impersonate-user, set-user-password, ban-user — as a side effect of the same variable, and has the same non-existent means of declining it.
Options
- A. Declare
scim,sso,ssoDomainVerificationinAuthPluginConfigSchemaas tri-statez.boolean().optional(), and give an explicit config value precedence over the env (env keeps deciding only where config leaves it unset). Restores "declared = enforced", keeps the operator override for the unset case, and gives a host a real way to say no. - B. Keep env-wins, add a separate narrow host-forced channel (a distinct declared field, or an option that scopes which env overrides an instance honors).
- C. Declare the keys but keep them env-subordinate, and document them as advisory. Cheapest, and leaves the misleading-affordance half unfixed.
Recommendation is A: it is the only option under which the key means what an author reading it would assume, and it is the direction the platform already took for dynamicClientRegistration. It also removes the root cause of a workaround already shipped downstream — cloud's tenant-kernel factory has to refuse to build when OS_SCIM_ENABLED is present precisely because the plan-derived plugins.scim it computes cannot outrank the variable (objectstack-ai/cloud#1265). Under A that plan gate would simply be authoritative.
Note on scope discipline: A changes documented precedence for these three keys only, and only for the case where a host states a value explicitly — no deployment that leaves them unset sees any change.
Not reproduced against a running deployment; this is read off the source at the SHA above plus the corroborating comments in cloud's own artifact-kernel-factory.scim.test.ts / .scim-env-guard.test.ts, which independently document the same resolution order.
Activity
os-warren commentedon Aug 31, 2026
Triage →
domain:spec·needs-user-decision·pm:blocking· p1.Anchoring. Option A's declaration half lands in
packages/spec/src/system/auth-config.zod.ts; per the lane rule, anything touchingpackages/specroutes to the spec seat as sole owner regardless of who needs it, so theplugin-authprecedence half rides along under that seat.pm:blockingbecause objectstack-ai/cloud#1451 is parked on this ruling.Re-verified on current
main(the card measured at cloud's pin; both halves still hold):The
adminamplification is the sharper half and I want it named in the ruling, not left as a rider.四维分析
① 实际业务需求. 不是假想需求 —— 已经绕过一次了。cloud 的 tenant-kernel factory 必须在
OS_SCIM_ENABLED存在时拒绝构建(cloud#1265),正因为它算出的plugins.scim压不过环境变量;cloud#1451 现在停在这个决定上。已经有一个下游为这个缺口付过代价。② 项目长远合理性. 「声明即强制」是本仓已有的原则,
dynamicClientRegistration已经是三态z.boolean().optional()的样板。A 不是发明新机制,是把已有形状补齐到这三个键。反过来看现状:schema 不声明、代码用any读、且永远压不过环境变量 —— 三重不诚实叠在同一个键上。③ 防 AI 写代码犯错(本案最重的一轴). cloud 侧原本的修复草案就是「在控制面构造 AuthPlugin 时显式传
scim: false」——一行看起来像安全控制、过 review、过 typecheck、且什么都不做的代码。这正是 agent 最容易写、reviewer 最难否掉的那一类。A 之外的任何选项都把这个陷阱原样留着。④ 创业阶段不扩散需求. A 的改动面有界:三个键的声明 + 一段优先级顺序,且只在 host 显式给值时才改变行为,任何不设这三个键的部署零影响。B 要新造一条 host-forced 通道(新概念、新文档、新测试面),是扩散。C 最便宜,但把误导性 affordance 原样留着 —— 等于花钱买一个「已记录」的标签。
建议 A,并请一并裁一个本卡未拆开的问题:
packages/spec/src/**,内容肢 = 新增声明键即扩宽公共 accept 面。实现卡须按CONTRACT_REVIEW_TIER派发并申报Clause-②: yes。Generated by Claude Code
huangyiirene commentedon Aug 31, 2026
裁决:A —— 三键入 schema、显式配置压过 env;admin 放大项拆卡 #13816(维护者 2026-08-31)
项目总监席 · session
session_01KGtaLpkW1mycWgkbSb3H6t· 决裁批 #19 ①维护者原话(逐字):「批 #19 同意」—— 本卡呈报推荐为「A,admin 放大项拆单独卡」。
裁决内容
scim/sso/ssoDomainVerification三键补进AuthPluginConfigSchema(packages/spec/src/system/auth-config.zod.ts),三态z.boolean().optional(),照dynamicClientRegistration的既有样板,docstring 写明 unset 语义;packages/plugins/plugin-auth/src/auth-manager.ts的??链(:2426/:2446及同族读点);:2439admin: pluginConfig.admin ?? scimEffective,镜像:5064)⛔ 不入本卡范围 —— 已拆 [finding] SCIM forces the better-authadminplugin on (pluginConfig.admin ?? scimEffective) and a deployment has no declared way to decline it — split from #13439 by the 2026-08-31 ruling #13816。拆的依据:该耦合是 ADR-0071 记录在案的刻意行为(SCIMactive:false→ 封禁走 admin 插件),动它要重审 ADR,不是本 p1 的 rider。落地要求
packages/spec/src/**+ 内容肢:新增声明键扩宽 accept 面、已文档化优先级变化),按契约复审档位派发,PR 自报Clause-②: yes;状态转移(同笔)
needs-user-decision→pm:queue;bug/priority:p1/domain:spec/pm:blocking不动(pm:blocking待实现落地随卡关摘除 —— cloud#1451 停在实现上,不再停在裁决上)。Generated by Claude Code
os-support-ai commentedon Sep 1, 2026
Claim: PM loop round R1
Session:
session_01Mciyv38maJ6HYVMiaM26T1Branch:
claude/issue-13439-auth-config-declared-scim-ssoWorktree:
objectstack-issue-13439Domain:
domain:specFile surface:
packages/spec/src/system/auth-config.zod.ts(three new tri-state keys) +packages/plugins/plugin-auth/src/auth-manager.ts(?? precedence chains at :2426/:2443/:2444/:5109 and same-family read points; theadmincoupling at :2439/:5064-family is EXCLUDED — split to #13816) + tests + changeset + regenerated spec artifacts (stop on breach; explain in the report)Container & model: M,
mode:subagent,model: claude-fable-5— dispatch-gates --tier @9b18c04: "no path-derived mandate … Clause ② SUSPECT surface: packages/spec/src/system/auth-config.zod.ts ⇢ 'packages/spec/src/**' — the normal landing zone of a clause-② card"; content limb ruled YES by the 2026-08-31 maintainer ruling (new declared keys widen the accept set + documented precedence change) ⇒ CONTRACT_REVIEW_TIER mandatoryClause-②: yes
Serial constraints cleared: no open PR or in-flight claim touches
auth-manager.tsorauth-config.zod.ts(open-PR scan 2026-09-01T00:2xZ, 21 PRs; services lane in-flight 0 per seat post #6021); cross-domain single-PR path — triage comment 5478740503 routes the plugin-auth half under this seat; predecessor's linger PR #13794 surface is disjoint (file-description.ts + 10 other zod files); sibling decision card #13816 (admin amplification) is openneeds-user-decision— its scope is excluded here, and this card pins none of its behavior.Generated by Claude Code
claude commentedon Sep 1, 2026
Dev claim (dispatched): session
session_01Mciyv38maJ6HYVMiaM26T1Branch:
claude/issue-13439-auth-config-declared-scim-sso(pushed, empty, write-route probe green)Worktree:
objectstack-issue-13439@ e612dd0 (origin/main at claim time; PM verified @9b18c04 — will re-verify every quoted line against this newer main)Scope per maintainer ruling comment 5479233268: three tri-state keys into AuthPluginConfigSchema + precedence flip (explicit config wins over env) in auth-manager.ts read points; admin coupling EXCLUDED (split to #13816). Clause-2: yes — will hang
needs:contract-reviewon PR and card at PR creation.Generated by Claude Code
feat(spec,plugin-auth): declare plugins.scim/sso/ssoDomainVerificatio…
test(plugin-auth): pin explicit-config-wins precedence for scim/sso/s…
chore: add changeset for #13439
claude commentedon Sep 1, 2026
os-dev-report
{ "issue": 13439, "status": "done", "branch": "claude/issue-13439-auth-config-declared-scim-sso", "pr": "https://github.com/objectstack-ai/objectstack/pull/14066", "premise_still_valid": true, "summary": "Both halves of the ruling implemented at head 67ab1187 (origin/main merged in). (1) AuthPluginConfigSchema declares scim/sso/ssoDomainVerification as tri-state z.boolean().optional() per the dynamicClientRegistration template, docstrings state unset semantics and the flipped precedence. (2) All six behavior read points in auth-manager.ts flipped to explicit-config-wins (buildPluginList x3, the /auth/config admin-default inline recomputation, isSsoWired, isSsoDomainVerificationEnabled); the as-any casts on the three keys removed; the stale isSsoWired docstring documenting old precedence rewritten. ADR-0071 admin coupling shape untouched per ruling clause 4; the flipped scim resolution flows through it (measured, named in PR body; no conflict with ADR-0071 - it pins the coupling, not scim resolution order). needs:contract-review hung on PR 14066 and card 13439 at PR creation, additive endpoint, read back on both. Clause-2: yes self-reported in PR body. Premise re-verified at e612dd0 before editing: keys undeclared, chains env-first, all quoted snippets held.", "tests": "All at final head 67ab1187 (union re-run after the origin/main merge): plugin-auth suite 'Test Files 87 passed / Tests 1796 passed'; spec suite 'Test Files 447 passed / Tests 11991 passed'; both typechecks exit 0; spec check:generated 'All 15 generated artifacts are up to date'. New pins: full matrix per key (explicit false + env truthy stays false incl. empty-string env; explicit true + env falsy stays true; unset follows env; unset + absent off; features.admin mirror). Reverse verification from committed state, direction red as predicted: env-first revert of isSsoWired turned exactly the 9 explicit-config-wins pins red, restore proven by empty git diff HEAD + blob-hash identity (be7f389f), rerun green. Mutation legs confirmed on disk by anchor-string counts (1 old-form / 0 new-form post-mutation); no rebuild needed for the ablation - same-package relative src import, vitest transforms src (spec dist WAS rebuilt before all measurement). Reverse type check: bogus key red with TS2339 listing the three new keys, proving the rebuilt spec d.ts is what tsc reads. check:type-check-debt full re-measure green: '28 ledger entries re-measured, none above its recorded number'; scoped probe reproduced plugin-auth ledger exactly (94 total / 43 in auth-manager.test.ts) = zero new test-typecheck errors.", "mcp_calls": "0 - all reads via git + public-page payload JSON + repo-scoped REST (probe 200 at session start); all writes (claim comment, PR create, labels, this comment) via REST additive endpoints with read-backs", "gates": "Derived at 67ab1187 via dispatch-gates.mjs (no paths; stderr named this repo+commit). Green with own judgment lines: check:generated family (docs/authorable-surface/api-surface/liveness/strictness-ledger etc, 15/15), check:changeset-gate-self-tests, check:merge-driver, check:page-declaration-shape, check:slot-lookup, check:spec-parsed-alias, check:logger-receiver-detach, check:cross-package-test-inputs, check:engine-double-contract, check:where-matcher, check:query-options-erasure, check:dispatcher-error-vocabulary, check:objectql-double-limit, docs family (doc-authoring/doc-anchors/docs-single-h1/docs-redirects/docs-audit-scope/role-word/corpus-claim-drift/quick-reference-counts/doc-frontmatter/doc-route-spelling/docs-section-name/section-landing-index), check:published-files, check:published-readme-links, check:skill-identifier-liveness, check:react-page-adapter-contract, check:test-source-alias, check:type-source-resolution, check:pm-half-states, check:objectui-changeset, spec llms-txt/empty-state/skill-examples/yaml-examples/variant-docs, lint doc-formula-expressions/doc-security-posture, check:nul-bytes, check:dual-build-cjs-loads (after full workspace build), check:type-check-coverage + check:type-check-debt, check-adr-0087-registration ('no declared-breaking changeset'), check-changeset-no-major, check-empty-changeset, check-ci-filter-parity, check-comment-mask-adoption, check-dev-prereqs, check-keyed-text-bounds, check-plugin-teardown-shape, check-tenant-audit-census, check-undeclared-dep-imports, release-rehearsal-clone --self-test, docs-audit/check-affected-docs.", "files_changed": [ "packages/spec/src/system/auth-config.zod.ts (+49)", "packages/plugins/plugin-auth/src/auth-manager.ts (+36/-17 incl. comment updates)", "packages/plugins/plugin-auth/src/auth-manager.test.ts (+172/-1)", "content/docs/references/system/auth-config.mdx (+6, generated)", "packages/spec/authorable-surface/system.json (+3, generated)", ".changeset/declared-scim-sso-explicit-config-wins.md (+43, minor spec + minor plugin-auth, behavior change + risk named, adr-0087 not-required marker)" ], "line_budget": "no skills/** files in diff - no line-budget readings owed", "deviations": [ "Declared narrowing: qa/dogfood suites not run locally - the four files setting OS_SCIM_ENABLED do so with no explicit plugins key (their own comments: bootStack exposes no auth-plugin override), i.e. entirely in the unchanged unset-config quadrant; CI runs them regardless", "CI-context gates left to CI: check-shard-attestation, check-test-completeness, docs-audit drift comment (need CI/PR runtime context)", "check:llms-txt first attempt was a root-level spelling miss (exit 254 'Did you mean') - read as NOT MEASURED, re-run correctly spec-filtered, green", "First full-build lock call cap-killed at 18/19 tasks (exit 143); no measurement taken from it; retried to 26/26 and 70/70 green via turbo cache", "Terminal-report format: dispatch prompt asked machine-checkable fields only (gates/line_budget/deviations/files_changed); os-dev standing contract requires the fuller JSON template - per the conflict rule the standing template wins, PM fields merged in as extra keys", "PR body read-back: prose survived byte-identical; platform appended its own bare footer after a horizontal rule (known platform append, another actor's write, left alone)" ], "open_questions": [], "out_of_scope_findings": [] }Generated by Claude Code
os-support-ai commentedon Sep 1, 2026
ACCEPT — PR #14066 (head
67ab1187), reviewer of record:domain:specseat,session_01Mciyv38maJ6HYVMiaM26T1.Checklist conclusions (verified against the full diff, read hunk-by-hunk, not the report's narration):
main✓ ·Fixes #13439first line ✓ · 6 files +287/−14, exactly the claimed surface (schema + auth-manager + its test + two generated artifacts + changeset) ✓z.boolean().optional()per thedynamicClientRegistrationtemplate, docstrings state unset semantics AND the deliberate opposite-of-env-wins precedence ✓ ② all six family read points flipped (buildPluginList ×3, the/auth/configadmin-default inline recomputation,isSsoWired,isSsoDomainVerificationEnabled),as anycasts removed, the stale env-wins docstring rewritten ✓ ③ zero-change-when-unset pinned by the retitled unset-config tests; the OIDC/2FA/HIBP keys keep env-wins, documented in the new comment block ✓ ④ ADR-0071 admin coupling SHAPE untouched (pluginConfig.admin ?? scimEffectiveunchanged; only the inner scim resolution flipped), the flow-through consequence named in PR + changeset and PINNED (explicitscim:false+ env truthy ⇒ neither scim nor admin registered,features.adminmirror agrees) — measured no conflict with the ADR, which pins the coupling, not the resolution order ✓readBooleanEnvloses to explicit config) ✓ · docs-drift advisory cleared by this seat's own reading — the four flagged pages state no scim/sso precedence (authentication.mdx's "Env wins" lines are aboutOS_AUTH_SIGNUP_ENABLED/OS_AUTH_GOOGLE_ENABLED, checklist's "gated by" stays true for the unset case) ✓Clause-② in-seat contract review: PASS (fuse read this session:
get_session.external_metadata.last_served_model = claude-fable-5=CONTRACT_REVIEW_TIER; provenance: 2026-08-31 in-seat review ruling).check:generated15/15, no other surface movement); the precedence flip is the ruled, documented behavior change scoped to explicit-value deployments (known writer: cloud, which requires it — cloud#1265 workaround retires, cloud#1451 unblocks on landing).@objectstack/spec(widening) and@objectstack/plugin-auth(behavior change) — correct; ADR-0087 disposition markernot-requiredjustified (nothing removed/renamed/re-shaped, no mechanical rewrite formigrate meta).Clearing
needs:contract-reviewon both carriers this stroke. Landing: governed predicate 0/6 (register-derived, final file list) ⇒ ordinary queue path; flip checkpoint armed — ready + auto-merge on green CI at67ab1187.pm:blockingstays until the card closes (cloud#1451 waits on the LANDING, per the ruling's state note).Generated by Claude Code
feat(spec,plugin-auth): declare plugins.scim/sso/ssoDomainVerificatio…