Skip to content

fix(plugin-auth)!: saving the auth settings refuses to turn off the last way to sign in - #30298

Merged
objectstack-fleet[bot] merged 12 commits into
mainfrom
claude/project-thread-bg54o3
Nov 5, 2026
Merged

objectstack-fleet[bot] merged 12 commits into
mainfrom
claude/project-thread-bg54o3

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Bill · project thread

domain:server#2 · os-bill · session_01Utp6CXhZcHuALPAjkDCiiT

维护者速读

  • 改了什么:保存认证设置时,按「这次保存写完以后」的状态判断:如果邮箱/密码登录关着,又没有任何其他能用的登录方式(凭据齐全的社交或 OIDC 登录、已登记身份提供方的企业 SSO、手机号登录、宿主声明的登录交接),这次保存被拒,整批都不写入,字段下用中英日西四语说明先配好哪一种。同一次保存里既关邮箱/密码又关 Google 会被拒;同一次保存里填好 Google 再关邮箱/密码可以通过。设置服务的写入谓词入参多了一个 batch 字段,里面是这次保存的全部键值。
  • 为什么改:Authentication settings: turning off email and password with no other sign-in method saves without warning, and everyone is locked out #30202 实测:没配好 SSO 就关掉邮箱/密码,提示「设置已保存」,下次退出后所有人(包括管理员)都登不进来,只能改环境变量或数据库。契约复审还指出,逐键判断时,一次同时关掉邮箱/密码和 Google 的保存会被放行。
  • 风险与回滚:只在保存门加一道拒收,不改已存的值,不改登录页和 /auth/config。这是收窄:以前能存下的「关掉最后一种登录方式」现在存不下;batch 是可选的新增字段,已有谓词不用改。已经锁死的部署用 OS_AUTH_EMAIL_PASSWORD_ENABLED=true 重启即可恢复。回滚即撤本 PR。
  • 先看:packages/plugins/plugin-auth/src/sign-in-method-floor.ts 的 signInMethodFloorPredicate 与 googleAfter(保存后的状态怎么算)、otherSignInMethods(哪些算其他登录方式);service-settings 的 refuseByWritePredicates(batch 从哪来)。
  • 两分钟验证:pnpm --filter @objectstack/plugin-auth exec vitest run src/sign-in-method-floor.test.ts 预期 17/17;pnpm --filter @objectstack/service-settings exec vitest run src/settings-write-predicate.test.ts 预期 9/9。
  • 需要你做的:无,席位落地。

Fixes #30202

Clause-②: yes (narrowing) — a save an administrator could make before is now refused at the settings save door with the existing SETTINGS_VALIDATION / invalid_value field error: a save after which email and password is off and nothing else signs anyone in, judged on the whole save, including the Google change that removes the last provider while email and password is off. No new error code or settings key. The widening half: SettingsWritePredicateInput, exported from @objectstack/service-settings, gains an optional batch field, the whole write a predicate is asked about.
Metadata-protocol: none
Skills-docs: none — no file under skills/** teaches auth.email_password_enabled or the settings write predicate; content/docs/deployment/environment-variables.mdx already names the OS_AUTH_EMAIL_PASSWORD_ENABLED override the recovery uses.

What changes

  • The refusal sits at the save door, before anything is written. bindAuthSettings applies a value after it is stored, so a refusal there (the refusable pieces, plugin-auth: one refused key in the auth settings pass drops every sibling setting, and says so only at warn #20412) would leave the console showing "saved" while the runtime ignores the value. The settings service already has the door for this: a write predicate (SettingsService.registerWritePredicate, the ADR-0144 D4 currency-lock seam), asked by setMany after the declared constraints and before any row or secret is written, refusing the whole batch with SettingsValidationError (400 SETTINGS_VALIDATION, details.fields[] ADR-0114 field errors). AuthPlugin.bindAuthSettings registers one predicate, from the new sign-in-method-floor.ts, on auth.email_password_enabled, auth.google_enabled, auth.google_client_id and auth.google_client_secret.
  • The whole save is judged (service-settings). SettingsWritePredicateInput gains batch?: Readonly<Record<string, unknown>>: every key the write sets in the namespace, the asked key among them, with the value it would store (null clears). refuseByWritePredicates builds it once from the patch, frozen, and passes it to every predicate it asks. Optional, so a caller that builds an input by hand and every existing predicate (the currency lock) are unchanged. It is reachable from the package entry (index.ts re-exports the type; the built dist/index.d.ts carries batch?: Readonly<Record<string, unknown>>), pinned by an expectTypeOf case that imports it from ./index.js.
  • The floor reads the state after the save. Asked one key at a time against the configuration in force, { email_password_enabled: false, google_enabled: false } on a deployment with email and password plus Google passed both keys, since each saw the other way in still standing, and wrote both (the review's must-fix). Now each key's value after the save is: the environment override when one is in force for it (it wins over what is saved); else the batch's value; else the value as it stands (the running configuration for email_password_enabled, the resolved setting for the google_* keys, read with SettingsService.get). A cleared key reads as its default (on, for both toggles), as validatePatch's own post-write view does. Google after the save follows the rule the apply uses: off when google_enabled is off; else kept when declared at boot; else in force with a client ID and secret, each from the setting or its GOOGLE_CLIENT_* variable. A save that sets no google_* key leaves Google as it stands.
  • Where the refusal lands. At email_password_enabled when the save turns it off and nothing is in force after it; at a google_* key when, with email and password off after the save, the save removes a Google provider that was in force and leaves nothing else. The batch above is refused at both keys.
  • What counts as another way in, read live at save time from the running manager: the methods the login page still offers with email and password off, the same facts its "No sign-in method is available" state reads (ui/packages/auth/src/LoginForm.tsx, console login: with email+password sign-in off and no SSO provider, the card says "Sign in with your organization's single sign-on" but shows no SSO button and no working method #29159), plus the one path the host states:
    • a social or OIDC provider in force (getPublicConfig().socialProviders) whose client ID and client secret are both non-blank in the configuration it came from (the review's should-fix). AuthPlugin indexes the providers declared at boot (stack config, the GOOGLE_CLIENT_* fallback, auth:configure handlers) with providerCredentialIndex; Google added from the settings has a pair by construction, since the apply adds it only with both. A provider listed without them draws a button that signs nobody in;
    • enterprise SSO switched on (plugins.sso / OS_SSO_ENABLED) AND at least one sys_sso_provider row, read with the boot report's probeSsoProvidersPresence. The switch alone signs nobody in, and an unreadable table proves no row, so neither counts;
    • phone-number sign-in (features.phoneNumber): better-auth's /sign-in/phone-number does not check emailAndPassword.enabled, and the code sign-in works when SMS can deliver;
    • a host sign-in handoff the host declared (AuthPluginOptions.hostSignInHandoff).
    • Not counted: ssoOnlyMode alone (it says an identity provider is in front; that provider is one of the above or nothing), and magic link and passkeys, which no login page offers (PUBLIC_AUTH_FEATURES_NOT_ADVERTISED, public-auth-features: passkeys/magicLink exempt.reason points at objectui#2514, which closes as "documented reserved" — decide stop-advertising vs keep-as-reserved #7481).
  • The refusal. code: invalid_value at the key, constraint: { lastSignInMethod: 'email_password' | 'google' }, and a message in the caller's language (en, zh-CN, ja-JP, es-ES, the settings bundle's locales, picked with resolveLocale as the currency lock does) saying to keep or set up another sign-in method before or in that save. Turning email and password on, clearing it (its default is on), or re-saving a value in force is never refused.
  • Recovery for a deployment already locked out. No new variable: OS_AUTH_EMAIL_PASSWORD_ENABLED=true is the settings env override for the key, it wins over the stored false (source env), and the apply turns email and password back on at the next boot. Named in the changeset and pinned below; the refusal message speaks to the administrator in Setup, who is not locked out, so it names what to configure instead.
  • The edits in auth-plugin.ts: the import, the registration at the top of bindAuthSettings, and one field (bootProviderCredentials) set beside configuredSocialProviders after auth:configure, clear of the mfa_required and allowed_ip_ranges hunks and of isExplicit.
  • scripts/engine-double-contract.pinned.json: one row for the new test's findOne double, written by check-engine-double-contract.mjs --write (outside the claimed file surface; the gate requires it for a new test file with an engine double).
  • Changeset .changeset/30202-keep-a-sign-in-method.md: @objectstack/plugin-auth and @objectstack/service-settings, both patch.

Tests

packages/plugins/plugin-auth/src/sign-in-method-floor.test.ts (new, 17 cases). The real SettingsService (aliased to service-settings/src by this package's vitest config), the real AuthPlugin and AuthManager; a stub engine answers sys_sso_provider from each case's rows.

  1. Email and password off with nothing else: setMany rejects with SettingsValidationError (SETTINGS_VALIDATION), one field error at email_password_enabled (invalid_value, lastSignInMethod: email_password); the stored value stays the default true, a sibling in the same batch (password_min_length) is not written, the manager keeps email and password on; turning it on is accepted.
  2. The HTTP door, through registerSettingsRoutes: PUT /api/settings/auth with Accept-Language: zh-CN answers 400, error.code: SETTINGS_VALIDATION, the field error at the key, its message the zh-CN copy.
  3. Each way in that counts lets it turn off (stack-config GitHub, phone-number sign-in, host handoff, SSO with a registered provider; and Google saved on the page first).
  4. The SSO switch with no provider row, and with a provider table that throws, is refused.
  5. With email and password off and Google the last way in: google_enabled: false, google_client_secret: null and google_client_id: '' are each refused (lastSignInMethod: google), Google stays in force; with email and password back on, Google can go.
  6. The same Google change is accepted where a stack-config GitHub keeps a way in, and a cleared secret that GOOGLE_CLIENT_SECRET still supplies is accepted and removes nothing.
  7. Recovery: a false stored before the predicate exists rules at the next boot (control), and with OS_AUTH_EMAIL_PASSWORD_ENABLED=true in the settings env the manager has email and password on.
  8. The whole save: { email_password_enabled: false, google_enabled: false } and { email_password_enabled: false, google_client_secret: null }, with email and password plus Google in force, are each refused with exactly two field errors (email_password_enabled / email_password, the Google key / google); nothing is written and both stay on.
  9. Google filled in (google_enabled, client ID and secret) in the same save as email_password_enabled: false is accepted: email and password off, Google on.
  10. { google_enabled: true, email_password_enabled: false } over a client ID and secret already stored is accepted (the pair is read as stored).
  11. { google_enabled: true, google_client_id, email_password_enabled: false } with no secret anywhere is refused at email_password_enabled.
  12. A stack-config GitHub listed with a blank secret is no way in: email and password off is refused.
  13. providerCredentialIndex keys social and OIDC boot providers by whether both credentials are set.

packages/services/service-settings/src/settings-write-predicate.test.ts (+2): setMany hands a predicate the whole write as batch, frozen, a clear as null; and SettingsWritePredicateInput['batch'], imported from the package entry, is Readonly<Record<string, unknown>> | undefined.

Acceptance notes

  • A cleared key reads as its default. Clearing a key at organization scope can fall back to a global row rather than the default; the floor, like validatePatch's post-write view, reads the default (on, for both toggles). Measured cases do not reach it; named so a review can weigh it.
  • The ui half (domain:ui, the lane is off duty; for the seat to file). The Setup page shows no confirmation before the switch and shows the refusal only as the field error the console's extractor already reads. The card also saw the login page draw the email and password form under os dev, with better-auth's English "Email and password is not enabled" on submit; at 797f9257 LoginForm.tsx has the no-method state from fix(auth): the sign-in card says no sign-in method is available instead of SSO-only copy when email+password is off #29257 (noSignInMethod, :351), so the remaining shape is what the console dev server draws there. Not reproduced here.
  • Other doors to the same lockout, not covered (read, not measured). Deleting the last sys_sso_provider row, or a redeploy that drops the last stack-config provider or plugins.phoneNumber, while email and password is off. They are not settings saves, so this predicate cannot see them.
  • Dogfood reds that were not this change. On the first head, permission-set-clone-door.dogfood.test.ts was main's red main red: Dogfood Regression Gate (2/3) fails permission-set-clone-door after PR #30222 (member_default grants sys_comment) #30255 (reverted by revert: PR #30222 (main red #30255) #30262; red with the floor neutralized too). On this head, four package-commit-revert-path-package.dogfood.test.ts cases timed out at 5000 ms while the 73 gate families ran beside the battery; the same file alone under the lock passes 12/12.
  • /auth/config and the login page are unchanged: emailPassword.enabled reports the runtime truthfully.

Verification

Final head 0b89bdf1 (main 977be380 merged in, cleanly). main has three commits since, none touching service-settings, plugin-auth or the ledger; a trial merge is clean.

Tests
Ablations (each negative pin)

scripts/ablation-replace.mjs, each anchor 1 to 0 on disk, each run restored to the HEAD blob with git diff HEAD empty. The test imports ./auth-plugin.js and ./sign-in-method-floor.js from src, and @objectstack/service-settings is aliased to its src, so no dist leg applies.

  • D, setMany passes no batch: service-settings 1 red (the batch case); plugin-auth 3 red (both-keys refusal, Google filled in the same save, switch over a stored pair).
  • E, the floor ignores batch (judges the asked key alone): 3 red (the same three).
  • F, a provider counts without its credential pair: 1 red (the blank-secret GitHub).
  • G, a key the save does not set reads as nothing instead of as stored: 2 red (the env-supplied secret case, the switch over a stored pair).
  • A, the email and password refusal returns nothing: 6 red.
  • B, the Google refusal returns nothing: 2 red (the last-provider case, the both-keys case).
  • C, the SSO switch counts without a provider row: 1 red.
Gates
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack on 0b89bdf1: 73 derived families (the first head's 72 plus check:package-naming, from the service-settings path). Each run with its exit code recorded before any pipe, after a full turbo run build --filter='!@objectstack/docs'; all 73 exit 0, and --ran answers 73 derived, 73 run, 0 NOT-MEASURED, 0 UNRUN.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic: packages/spec untouched.
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes.
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: n/a.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English (the 维护者速读 block is Chinese by the Communication rule).

Generated by Claude Code

… sign-in method

Turning email and password off while no other sign-in method is in force
used to save, and the next sign-out locked everyone out. The plugin now
registers write predicates on the settings service for
email_password_enabled and the Google keys, so such a save is refused
before anything is written, with a localized reason naming what to set
up first.

Co-Authored-By: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01Utp6CXhZcHuALPAjkDCiiT
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 5, 2026
@github-actions

github-actions Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

20 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 d1d90b7062af18eda616f875f9efc1ffeaac645a.

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

What this run could not see
  • 8 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 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; 103 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 26 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 d1d90b7062af18eda616f875f9efc1ffeaac645a → packageMentionDocs.

Which tree this was computed on

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

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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-bill · session_01Utp6Cbvcmv8YWFhFJwzjKL

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 249ec4c4895c48ae38d1333f9df55d9336b896ac
Local-runs: none
Scope: packages/plugins/plugin-auth/src/sign-in-method-floor.ts, packages/plugins/plugin-auth/src/sign-in-method-floor.test.ts, packages/plugins/plugin-auth/src/auth-plugin.ts, scripts/engine-double-contract.pinned.json + packages/services/service-settings/src/settings-service.ts (setMany, refuseByWritePredicates, registerWritePredicate, effectiveEnvOverride), packages/services/service-settings/src/settings-service.types.ts (SettingsWritePredicateInput, envKeyOf), packages/services/service-settings/src/settings-routes.ts (PUT locale), packages/services/service-settings/src/manifests/auth.manifest.ts, packages/services/service-settings/src/localization-currency-lock.ts (the D4 precedent), packages/plugins/plugin-auth/src/auth-manager.ts (getPublicConfig, isSsoWired), packages/plugins/plugin-auth/src/boot-sign-in-reachability.ts (probeSsoProvidersPresence, SignInPathWiring), packages/plugins/plugin-auth/src/auth-settings-refusal-isolation.test.ts, ui/apps/console/src/pages/settings/SettingsView.tsx (dirty-key save), .changeset/30202-keep-a-sign-in-method.md

Inputs: card #30202 (body; comments 6648434001, 6648660209, 6650530417), PR #30298 body, file list and the net diff against main (merge base 4be97342, 5 files, +641 / -0), and the check-runs on 249ec4c4. Read-only: every file read with git show on the fetched ref; nothing built, run or re-run.

① Derived judgments

Accept-set changes the diff implies, each judged against the cited code:

  • RIGHT — PUT /api/settings/auth and SettingsService.setMany('auth', …) now refuse email_password_enabled: false when the running AuthManager offers no other way in. The refusal rides the existing seam: setMany calls refuseByWritePredicates after validatePatch and before the crypto pass and every row write, so the whole batch is refused and nothing lands (the test's sibling password_min_length pin reads this correctly). The shape is the existing SettingsValidationError → 400 SETTINGS_VALIDATION, error.details.fields[] one ADR-0114 field error { field, code: 'invalid_value', label, message, constraint: { lastSignInMethod } }. No new code, key or export; the module is not re-exported from the package barrel. The declared "narrowing, nothing widened" is accurate for the surface.
  • RIGHT — "re-saving the value in force is never refused": refuseByWritePredicates skips a key whose stableStringify(next) equals the cascade-resolved current; the console posts only dirtyKeys (SettingsView.tsx:342). So a deployment already in the dead-end state (stored false, nothing else) is NOT trapped: an unrelated auth key saves, re-posting email_password_enabled: false is skipped, and saving Google credentials while email and password is off is accepted (the Google-side predicate returns early when Google is not yet in force). Not pinned by a test, but read true.
  • RIGHT — clearing / restore-default: next arrives as null; readToggle(null, true) is true for both toggles (manifest defaults are true), so clearing email_password_enabled or google_enabled is never refused; clearing a Google credential is judged against the apply's own fallbacks (asTrimmedString(values.x) ?? env.GOOGLE_CLIENT_*, and configuredSocialProviders.google which already includes the env-fallback Google from applyEnvSocialProviderFallbacks). removesGoogle mirrors the apply at auth-plugin.ts ~2365-2392 exactly, including google_enabled: false deleting a stack-declared Google.
  • RIGHT — env-forced email/password: OS_AUTH_EMAIL_PASSWORD_ENABLED is envKeyOf('auth','email_password_enabled'), and setMany throws SettingsLockedError for a key whose env override is in force before any predicate runs, so an env-forced ON with a stored OFF never reaches the floor; the recovery path (env wins over the stored false, pinned in case 7) is the existing override. OS_AUTH_GOOGLE_ENABLED is likewise the settings env name for google_enabled, so the apply's googleEnabledFromEnv and the floor can never disagree on a write (the write is locked).
  • RIGHT — the list of other sign-in methods matches what the running manager serves, with two qualifications below: getPublicConfig().socialProviders (social entries with enabled !== false, plus OIDC entries, the same array the login page and the boot report's SignInPathWiring.socialSignIn read); features.sso = isSsoWired() (plugins.sso ?? OS_SSO_ENABLED) AND a sys_sso_provider row via probeSsoProvidersPresence ('unknown' on an unusable or throwing engine, read as absent — fail-safe); features.phoneNumber = plugins.phoneNumber ?? false; hostSignInHandoff from the option alone. Magic link and passkeys are not advertised by the login page and correctly do not count; ssoOnlyMode alone correctly does not count (its own contract keeps emailPassword.enabled true as break-glass).
  • QUALIFICATION (should-fix, ④-3) — a social provider is counted on enabled !== false alone; getPublicConfig never checks that clientId / clientSecret resolve, so a stack-config provider whose credentials are blank (an unset env read in objectstack.config.ts) counts as a way in and lets email and password go off. The boot report's socialSignIn doc says "credentials present" but reads the same array, so the floor is consistent with the boot report; both are weaker than the claim.
  • QUALIFICATION (not re-verified) — that better-auth's /sign-in/phone-number ignores emailAndPassword.enabled is the PR's claim; no node_modules is present on this checkout and the review is read-only, so it is taken as consistent with the plugin's shape, not proven here.
  • WRONG — the predicate's claim "a change that would leave no way to sign in is refused" does not hold for a batch that removes two methods at once. See ④-1: it is the card's lockout, reachable in ONE save from the same Setup page.

Tests (11 cases, real SettingsService + AuthPlugin + AuthManager, dist-free since service-settings is aliased to src): they pin the single-key refusals, the HTTP envelope and locale, each counted method, the SSO-switch-alone and unreadable-table cases, the Google direction, the env-still-supplies case and the recovery override. They pin NO multi-key batch: neither the declared over-refusal (Google filled in and the switch off together) nor the both-off batch below. The scripts/engine-double-contract.pinned.json row is the ledger entry the gate requires for the test's findOne double — mechanical, correct.

② Semver level

  • Changeset .changeset/30202-keep-a-sign-in-method.md: '@objectstack/plugin-auth': patch; title fix(plugin-auth)!: … (the ! present on PR title and changeset); Clause-②: no (narrowing); Metadata-protocol: none; **BREAKING** accept-set narrowing … banner; exactly one ADR-0087 marker, adr-0087: not-required (no-migration-prescription), with its reason. Under AGENTS.md's level rule (Post-Task Checklist 3) a non-protocol narrowing ships patch and keeps its BREAKING banner and ADR-0087 disposition: the level is right and justified. The Check Changeset and all Lint check-runs on this head are success, so the lines parse.
  • Declaration lines PR body vs changeset: the value tokens are identical (no (narrowing); none). The trailing prose on the Clause-②: line is NOT byte-identical — the PR body adds "; the new module is not exported from the package." The reader (scripts/pm/clause2-line.mjs) judges the value class and tolerates decoration, so this parses on both carriers; noted, no finding.
  • The changeset's Before / Now text overstates the enforcement (see ④-1) and omits the two-saves limitation the PR body declares; the CHANGELOG sentence an upgrading agent greps must say what the door actually refuses. Folded into ④-1's fix.

③ Boundary flags

  • Deviation 1 (scripts/engine-double-contract.pinned.json outside the claimed surface): accepted — one ledger row the gate requires for a new test file with an engine double, nothing else in the file moves.
  • Deviation 2 (refusal message names what to configure, not the recovery variable): accepted — the reader is the signed-in administrator; the variable is in the changeset.
  • Deviations 3-4 (harness assign step skipped per order; only the 18 changeset-derived families re-run after the changeset-only commit): accepted; CI on this head is the authority and its gate-carrying runs are green or still running (see verdict).
  • Dev flag "other doors to the same lockout" (deleting the last sys_sso_provider row, a redeploy dropping the last provider): out of this card's door, correctly noted, not a defect here.
  • OPEN QUESTION — "a provider filled in and email and password turned off in the same save is refused; two saves acceptable?" Dev recommends A (keep). Judged against the card text ("The save is refused while it would leave no sign-in method"): the predicate evaluates each key against the RUNNING manager config (deps.publicConfig()) — not the post-save state of the batch, and not even the stored state. Over-refusal (A's friction): a batch { google_enabled, google_client_id, google_client_secret, email_password_enabled: false } would NOT leave the deployment without a sign-in method, yet is refused; the mirror { email_password_enabled: true, google_enabled: false } while email is off and Google last is refused too, with a message telling the admin to do the very thing the batch does. On its own that is a should-fix (fail-safe, declared, message explains). But the same per-key-against-live reading also UNDER-refuses — ④-1 — and that is the card's defect, so A is not acceptable as the resolution: the batch must be visible to the judgment. Option B (the batch on SettingsWritePredicateInput, an additive service-settings contract change, Clause-②: yes) or an equivalent whole-batch seam is required, in this PR or as a blocking prerequisite the card stays open on. Verdict on the question: A is a defect against the card — must-fix, because of ④-1; the over-refusal half alone would have been should-fix.

④ Design judgment

The narrowing against the published spec on main: it makes the spec's own refusal real at the right door (the ADR-0144 D4 write predicate, the same seam localization.currency uses; before any write; the console already renders the field error), takes no declared capability away from the login page or /auth/config, and registers at kernel:ready so no boot-time seed or recordApiKeyPolicyStart write ever meets it (no non-test setMany caller exists outside the routes; set delegates to setMany). The scenarios tried:

  1. MUST-FIX — both toggles off in one save is accepted and locks everyone out. Patch { email_password_enabled: false, google_enabled: false } (or google_client_secret: null in place of the toggle) on a deployment whose only ways in are email/password and Google: refuseByWritePredicates asks the email_password_enabled predicate, which reads the LIVE socialProviders, sees Google, returns undefined; it then asks the google_enabled predicate, which reads the LIVE emailPassword.enabled === true at sign-in-method-floor.ts:218 and returns undefined. Both rows are written; the apply pass turns email and password off and delete socialProviders.google (auth-plugin ~2384) — no sign-in method, "Settings saved". Both toggles sit on the same Authentication page and SettingsView posts all dirty keys in one PUT, so this is one click away in the card's own scenario ("moving to SSO, turn the local methods off"). The changeset's "a change that would leave no way to sign in is refused" is then a claim the runtime does not deliver (Prime Directive 10). Fix: judge the batch as a whole (option B above: hand the predicate the patch, or ask one batch-level predicate per namespace), and pin both batch shapes — the both-off batch refused, and the provider-plus-switch batch accepted — in sign-in-method-floor.test.ts; then make the changeset's Now paragraph say exactly what is refused.
  2. HOLDS — env-forced email/password with a stored OFF: the write is SETTINGS_LOCKED before the predicate; recovery override pinned (case 7).
  3. SHOULD-FIX — SSO on with no provider rows: refused, correct. A Google (or any social) entry with empty credentials: counted, because getPublicConfig lists on enabled !== false alone. Read the credential pair where the floor counts a stack-declared provider (the same gap sits in the boot report's socialSignIn), or state in the module doc that a declared provider counts whether or not its credentials resolve. Low: the settings-managed Google path already requires both credentials to resolve before it is in force.
  4. HOLDS — restore-default / clearing: null reads as the manifest default (true) for both toggles; credential clears follow the apply's env and stack fallbacks (pinned: env still supplies the secret → accepted, nothing removed).
  5. HOLDS — seeded settings at boot: the predicate is registered inside bindAuthSettings at kernel:ready and never refuses a read or an apply; a stored false from before the floor still rules at boot (pinned as the control in case 7), so no boot, seed or import is broken.
  6. HOLDS — already-locked deployment saving an unrelated auth key: predicates run only on patched floor keys whose value changes; Google credentials can be added while email is off; the admin is not trapped.
  7. SHOULD-FIX (test) — the declared limitation ("two saves") is not pinned; once ④-1 is fixed, the accepted provider-plus-switch batch is the pin that replaces it.

Must-fix 1 is a FAIL. Check-runs on 249ec4c4, read 2026-11-05T05:44Z: 30 success (every Lint job, Check Changeset, every Type Check job, Build Core, Test Core 5-8 among them), 13 skipped (UI and opt-in lanes, no ui/ paths), and Test Core 1-4 still in_progress at the end of the review's ten-minute polling window; none red. The verdict does not rest on them: ④-1 fails it on the tree.

Implemented-by: session_01Utp6CXhZcHuALPAjkDCiiT
Reviewed-by: session_01Utp6Cbvcmv8YWFhFJwzjKL

VERDICT: FAIL

…le save, and count a provider only with its credential pair

A write predicate is now handed the whole write as batch, so the auth sign-in
floor judges the state after the save: email and password off together with
Google off is refused at both keys, and Google filled in within the same save
is accepted. A social or OIDC provider counts only with a client ID and secret.

Co-Authored-By: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01Utp6CXhZcHuALPAjkDCiiT
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-bill · session_01Utp6Cbvcmv8YWFhFJwzjKL

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 0b89bdf1dd9a83ee5aae5a83ec80f5dd0de8a61c
Local-runs: none
Scope: packages/plugins/plugin-auth/src/sign-in-method-floor.ts, packages/plugins/plugin-auth/src/sign-in-method-floor.test.ts, packages/plugins/plugin-auth/src/auth-plugin.ts, packages/services/service-settings/src/settings-service.ts, packages/services/service-settings/src/settings-service.types.ts, packages/services/service-settings/src/settings-write-predicate.test.ts, .changeset/30202-keep-a-sign-in-method.md, scripts/engine-double-contract.pinned.json + packages/services/service-settings/src/settings-routes.ts (PUT, dropEchoedSecretMasks, locale), packages/services/service-settings/src/settings-secret-redaction.ts, packages/services/service-settings/src/manifests/auth.manifest.ts (scope: 'global', the four floor keys, google_client_secret encrypted), packages/services/service-settings/src/index.ts (the type's re-export on main), packages/plugins/plugin-auth/src/auth-manager.ts (getPublicConfig: social ids, oidc entries keyed providerId), packages/plugins/plugin-auth/src/boot-sign-in-reachability.ts (probeSsoProvidersPresence); for the compose flag, PR #30309 at 8767d6aa (settings-service.ts, settings-service.types.ts, index.ts, auth-plugin.ts)

Delta review of the re-fix after record 6650756385 (FAIL on 249ec4c4). Inputs: card #30202 body and every comment (6648434001, 6648660209, 6650530417, 6650553927, 6652019341), PR #30298 body as it stands, file list, the net diff against main (merge base c1240a38, 8 files, +886 / -3, identical against current origin/main 8c9f48f8), the delta 249ec4c4..0b89bdf1, record 6650756385, and the check-runs on this head. Read-only: every file read with git show on the fetched ref; nothing built, run or re-run. The one script run was record-recognisers.mjs --template, which reads no board.

The delta is two commits on the first-parent line: the fix 5cc78ec5 (7 files, +316 / -74) and the merge 0b89bdf1 of main c1240a38 (which carries 977be380, fccde75e, c1240a38 and their ancestors). git diff 5cc78ec5 0b89bdf1 restricted to the PR's eight paths is empty, and git diff 4be97342 c1240a38 on the same eight paths is empty: the merge touches no path of this PR, and the 73 other files in the delta stat are main's own (the Feature Flags removal in service-settings translations, ui hitl work, record timeline). The ledger row in scripts/engine-double-contract.pinned.json is unchanged since 249ec4c4.

① Derived judgments

Accept-set and public-surface changes of the delta, each judged against the cited code:

  • RIGHT — SettingsWritePredicateInput gains batch, optional, typed as a readonly record of string to unknown, with TSDoc. refuseByWritePredicates builds it once per call, from the whole patch (submitted ?? null, so a cleared key is null), Object.freezes it, and passes the same object to every predicate it asks. It is additive: localization-currency-lock.ts ignores it, and a hand-built input omits it. The type was already re-exported from the package entry on main (index.ts:59), so "reachable from @objectstack/service-settings" is true without an index.ts change; the expectTypeOf case imports it from ./index.js. The declared "widening half" names exactly this, nothing more.
  • RIGHT — the floor now judges the post-save state. signInMethodFloorPredicate reads batch (falling back to { [key]: next } for a host that passes none), takes email_password_enabled after the save from the batch when the save sets it, else from the running manager; googleAfter applies the same rule bindAuthSettings applies (auth-plugin.ts ~2371-2397): off when google_enabled is off; else a stack-declared Google counts only with its boot credential pair; else in force with a client ID and secret each from the batch, the stored value (SettingsService.get, which decrypts in-process, materialiseRow), or the GOOGLE_CLIENT_* variable. A save that sets no google_* key leaves Google as it stands. The must-fix batch { email_password_enabled: false, google_enabled: false } on email+Google: the email key sees Google gone after the save and refuses; the Google key sees email off after the save, Google in force now, nothing else, Google gone after, and refuses. Two field errors, nothing written. Pinned at test lines 188-207 for both batch shapes (google_enabled: false and google_client_secret: null); the accepted batches (Google filled in with the switch off; the switch turned on over a stored pair) at 209-226; the half-pair batch refused at 228-239. Ablations D and E (no batch passed; floor ignores batch) each red those pins per the report; not re-run here.
  • RIGHT — the prior record's over-refusal mirror ({ email_password_enabled: true, google_enabled: false } while email was off and Google last) is now accepted: emailPasswordAfter is read from the batch as true and both keys return early. Not pinned by name; read true.
  • RIGHT — a social or OIDC provider counts only with a credential pair. providerCredentialIndex is built in init after applyEnvSocialProviderFallbacks and the auth:configure trigger, from the resolved authConfig.socialProviders / oidcProviders, keyed social:id and oidc:providerId; getPublicConfig emits social entries under their config key and OIDC entries with id: p.providerId, so the lookup keys match. A blank or whitespace secret is "no pair" (trim() !== ''). Pinned at 241-249 (stack GitHub with a blank secret is refused) and 251-265 (the index itself). The boot report's socialSignIn keeps the weaker reading; the floor no longer depends on it.
  • RIGHT — the providerHasCredentials default for a provider absent from the boot index is true, which is what makes the settings-added Google count (the apply adds it only with both credentials). See ④-4 for its breadth.
  • RIGHT — "re-saving the value in force is never refused" still holds: refuseByWritePredicates skips a key whose stableStringify(next) equals the resolved current before asking; the batch still carries that key, so a predicate asked about another key sees the full post-save picture. An unchanged secret never reaches the batch from the console: the PUT route drops an echoed mask (dropEchoedSecretMasks) before setMany, so the floor reads it as stored.
  • RIGHT — declaration completeness: "No new error code or settings key" true; the module is still not re-exported from the plugin-auth barrel; the auth manifest is scope: 'global', so the acceptance note about organization-scope fallback names a case the namespace cannot reach.

② Semver level

  • Changeset .changeset/30202-keep-a-sign-in-method.md: '@objectstack/plugin-auth': patch and '@objectstack/service-settings': patch; title fix(plugin-auth)!: with the !; Clause-②: yes (narrowing) (one arm from the closed pair); Metadata-protocol: none; exactly one ADR-0087 marker, adr-0087: not-required (no-migration-prescription), its reason now naming the additive batch field; the **BREAKING** banner once. Under Post-Task Checklist 3 a non-protocol change, widening or breaking, ships patch and keeps its banner and disposition: both levels are right (ruling A).
  • The two declaration lines are byte-identical between the PR body and the changeset (checked by digest of each first Clause-②: and Metadata-protocol: line). Check Changeset is success on this head.
  • The changeset's Now / "The whole save is judged" / "What counts" paragraphs now say what the door refuses, including the two-field refusal and the credential-pair rule; the sentence an upgrading agent greps matches the runtime.

③ Boundary flags

  • Must-fix ④-1 of 6650756385 (both toggles off in one save accepted): CLOSED, on the tree and by pin (①, lines 188-207), through the seat's ruling B seam.
  • Should-fix ④-3 (provider counted without credentials): CLOSED (①, lines 241-265).
  • Should-fix ④-7 (the two-saves limitation unpinned): CLOSED by replacement, the accepted-batch pins at 209-226.
  • Open question of 6650530417 (A vs B): resolved B, as ruled; the dev's report 6652019341 declares the service-settings surface as the seat-ordered deviation and the PR body rewrite as the seat's. Accepted.
  • Dogfood: on 0b89bdf1, 4 cases of packages/qa/dogfood/test/package-commit-revert-path-package.dogfood.test.ts timed out at 5000 ms while 73 gate families ran beside the battery; the same file alone under the lock passed 12/12. The file names neither settings nor plugin-auth (zero occurrences on main), its subject is the package commit/revert path, and the four are timeouts, not assertion failures, under a shared box. Judged plausibly not this PR's; the full battery was not re-run clean on this head, which is said here rather than hidden. CI's Dogfood Regression Gate on this head is success with its shards skipped by path, so the local run is the (narrowing) arm's evidence.
  • Deviations carried from the first head (ledger row; refusal message names the configuration, not the variable; assign step skipped) stand accepted. No new deviation beyond the two the report declares.
  • COMPOSE FLAG for the seat, not a finding against this head. PR fix(plugin-auth)!: clearing the allowed IP ranges applies at once, and a console save that leaves out the saving address is refused #30309 (8767d6aa, in the queue) edits the same two files: it changes writePredicates to a map of { predicate, requestHeaders }, rewrites the predicate-call line in refuseByWritePredicates into a multi-line object with a spread transport: narrowTransport(...), retypes setMany's and refuseByWritePredicates's display as SettingsWriteOptions, and inserts transport? directly after locale? in SettingsWritePredicateInput. This PR appends , batch to that same call line and inserts batch? after that same locale?. Both insert a private field after auth-plugin.ts line 532-533. A textual conflict is therefore near-certain in three places, all mechanical. The two compose: batch is built from patch before the loop and has nothing to do with the request, so narrowTransport must not and need not touch it. A correct merge keeps, in refuseByWritePredicates, the frozen batch built once before the loop, the registered.predicate destructure from fix(plugin-auth)!: clearing the allowed IP ranges applies at once, and a console save that leaves out the saving address is refused #30309, and one call object carrying locale, batch and the conditional transport; keeps both optional fields on the interface; keeps the SettingsWriteOptions type on display; and keeps the floor's structural WritePredicateHost.registerWritePredicate (three parameters) which stays assignable to the four-parameter method whose fourth is optional. The expectations that must still hold after the merge: this PR's batch test (toHaveBeenCalledTimes(1), batch frozen, a clear as null) and the expectTypeOf on the package entry; fix(plugin-auth)!: clearing the allowed IP ranges applies at once, and a console save that leaves out the saving address is refused #30309's settings-write-predicate-transport.test.ts. No generated artifact is on either side.

④ Design judgment

The narrowing against the published spec on main: it makes the spec's own refusal real at the save door (ADR-0144 D4 write predicate, before any row or secret write), now judged on the state the save produces, and takes nothing from the login page or /auth/config. The scenarios tried on this head:

  1. HOLDS — a batch of only secret keys: { google_client_secret: null } while email is off and Google last. next is null, current the decrypted plaintext, so the key is asked; googleAfter reads the batch's null, no env, not stack, returns false; refused at the key (pinned 143-157). A batch that leaves the secret unchanged never names it (the console posts dirty keys and the route drops an echoed mask), so valueAfter reads the stored plaintext through get and the pair stands. A decrypt failure reads as null and so as no pair: an over-refusal that is fail-safe and matches what the apply would do with the same null.
  2. HOLDS — email off in one save, the provider off in the next: the second save is the single-key Google refusal (143-157). The reverse order, Google off first while email is on, is accepted, and the later email_password_enabled: false is then refused (31-51).
  3. HOLDS — env-pinned keys. A key with a settings env override in force (OS_AUTH_EMAIL_PASSWORD_ENABLED, OS_AUTH_GOOGLE_ENABLED, OS_AUTH_GOOGLE_CLIENT_*) throws SettingsLockedError in setMany before any predicate, so valueAfter's source === 'env' branch is reached only for keys the save does not set, where it is the value in force; the branch for an in-batch key is unreachable and harmless. OS_AUTH_GOOGLE_ENABLED=false also makes the apply delete Google, and the floor sees it absent from getPublicConfig, so the two agree.
  4. SHOULD-FIX (low, documentation or a narrower default) — the providerHasCredentials default of true for any provider absent from the boot index. The comment at auth-plugin.ts ~2075 names the settings-added Google, but the default covers every provider the manager lists that no boot config declared. In this repository nothing but the settings apply adds a provider after init, so no lockout path exists today; a host or a product package that patches socialProviders at runtime without credentials would be counted as a way in. Either say so in the module doc beside the Google sentence, or default to true only for social:google. Not blocking.
  5. HOLDS — a provider configured only in boot config with credentials from env read in objectstack.config.ts: the index is built from the resolved config after auth:configure, so it counts exactly when both resolved non-blank, which is when its button signs anyone in.
  6. HOLDS — phone / passkey / magic link / SSO rows. Phone counts on features.phoneNumber, passkeys and magic link do not (the login page offers neither), SSO counts only with the switch and a sys_sso_provider row, an unreadable table as no row (130-141). That better-auth's phone sign-in ignores emailAndPassword.enabled remains the PR's claim, consistent with the plugin's shape and not re-verified here, as before.
  7. HOLDS — restore-default and reset. resetNamespace (the built-in reset action, and any plugin override that calls it) goes through setMany with an all-null patch, so the predicate runs and never refuses: both toggles read as their default true, so emailPasswordAfter is true and every key returns early. A deployment is never prevented from clearing to defaults, which brings email and password back.
  8. NOTE, not a finding against this PR — a save that leaves all three google_* keys at source default with no env and no stack Google: the apply skips its Google branch entirely (isExplicit all false), so the manager keeps the previously applied Google until the next boot, while the floor judges Google gone and refuses turning email off. Fail-safe, and it is the post-restart truth; the apply's own gap predates this PR.
  9. HOLDS — the key skipped as unchanged still shapes the judgment: { email_password_enabled: false, google_enabled: false } where google_enabled is already stored false asks only the email key, which reads the batch's false for Google and refuses with one field error, the right one.

No must-fix. Check-runs on 0b89bdf1, last read 2026-11-05T07:20:24Z at the end of the ten-minute polling window (four reads, two minutes or more apart): 33 success (every Lint job and Lint & Repo Gates, Check Changeset, every Type Check job including workspace, Build Core, Governed Surface Queue Guard, Test Core 2, 5, 6, 7 and 8 of 8 among them), 15 skipped (UI and opt-in lanes, no ui/ paths), and 3 still in_progress (Test Core 1, 3 and 4 of 8); none red. The verdict rests on the tree and the pins read above; the landing waits on the required set going green as always.

Implemented-by: session_01Utp6CXhZcHuALPAjkDCiiT
Reviewed-by: session_01Utp6Cbvcmv8YWFhFJwzjKL

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-bill · session_01Utp6Cbvcmv8YWFhFJwzjKL

维护者速读

  • 改了什么: 认证设置保存时加了一道「至少留一种登录方式」的底线:按整批保存后的状态判断。一次保存里既关邮箱密码又关 Google(或清掉 Google 密钥),两个键都会被拒;同一次保存里填好 Google 再关邮箱密码,可以通过。社交 / OIDC 登录只有 clientId 和 clientSecret 都不为空才算一种登录方式。为此 service-settings 的写入谓词多拿到一份 batch(本次写入的全部键值)。
  • 为什么改: 原来关掉邮箱密码、又没有别的登录方式时,保存照样成功,全员被锁在门外(Authentication settings: turning off email and password with no other sign-in method saves without warning, and everyone is locked out #30202)。
  • 风险与回滚: 只收窄一类保存:会让部署失去最后一种登录方式的保存(BREAKING,changeset 写明,带 ADR-0087 标记)。还有登录方式的保存行为不变;启动、种子数据、环境变量锁定的键不受影响。回滚即撤本 PR。
  • 先看: packages/plugins/plugin-auth/src/sign-in-method-floor.ts,再看 settings-service.ts 的 refuseByWritePredicates(batch 在循环外建一次、冻结)。
  • 两分钟验证: pnpm --filter @objectstack/plugin-auth exec vitest run src/sign-in-method-floor.test.ts。
  • 需要您做的: 无,席位经合并队列落地(非受管面,912 行)。
  • 席位意见: ACCEPT。契约复审第一轮 FAIL(6650756385,逐键判、未判整批),已改;增量复审 PASS(6652218570);和 fix(plugin-auth)!: clearing the allowed IP ranges applies at once, and a console save that leaves out the saving address is refused #30309 的合并改动席位已逐行核对。

席位核对 — ACCEPT · domain:server#2 · head ba1977cb24eb0f34a1dff1b7237958f11618885e

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 5, 2026 07:43
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 5, 2026 07:43
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 5, 2026
@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 41991741368 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

分类:failure —— 按下面的日志分诊。

失败的 job(日志抽取,best effort):

  • Test Core (2/8) — 失败步骤: Run this shard's tests

    @objectstack/spec:test:      ✓ inverts the card's measured control: the three bogus keys now FAIL parse instead of adding zero warnings  325ms
      ↳ 失败原因: (这条 FAIL 之后 12 行内没有可识别的原因行 —— 点进 job 看)
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — evidence pointers (#5623) > is green against a verbatim copy of the shipped ledgers
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — evidence pointers (#5623) > stays green when the missing path is attributed to ANOTHER repo
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — evidence pointers (#5623) > never bounds a citation attributed to ANOTHER repo
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — evidence pointers (#5623) > prints the citation count and how many are in range, in the documented two
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — symbol anchors (#12516) > stays GREEN on the drifted BEFORE-state — the honest residual this grammar e
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — symbol anchors (#12516) > prints the anchor count and how many resolve, equal on a green run
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — the evidence-scan population (#13041) > stays GREEN when a `dead` entry carries the SAME rotted pointe
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — the evidence-scan population (#13041) > declares every status either scanned or explicitly unscanned, 
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — the live-elsewhere criteria (#13483) > is green on the shipped ledgers and publishes the population be
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — the live-elsewhere criteria (#13483) > is green on a planted live-elsewhere row and counts it as carry
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — the README state table (#7257) > is green against a verbatim copy, and says how many rows it checked
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    

↳ 失败原因 是判读的关键:超时(Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言(AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️ 断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError。 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 13 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

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/l tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Authentication settings: turning off email and password with no other sign-in method saves without warning, and everyone is locked out

1 participant