Skip to content

fix(plugin-auth)!: the global mfa_required switch cannot be saved on while OS_AUTH_TWO_FACTOR=false keeps two-factor enrollment off (#30302) - #30376

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

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/project-thread-dw6hfs

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Bill · project thread

domain:server#2 · os-bill · session_01Utp6Cbvcmv8YWFhFJwzjKL

维护者速读

  • 改了什么:部署用环境变量 OS_AUTH_TWO_FACTOR=false 把两步验证钉成关时,Setup → 认证里的全局「强制多因素认证」(auth.mfa_required)从关改成开会被拒存(400 SETTINGS_VALIDATION,字段级本地化提示,点名这个环境变量和修法),整次保存什么都不写。另外改正两处文档:组织级 require_mfa 只有两步验证开着才能开,全局开关在上述钉死情况下拒存。
  • 为什么改:全局开关打开时会连同两步验证插件一起开,但环境变量优先级更高,钉成关时插件开不了。于是开关存上、界面显示「开」,却没人能绑定验证器,闸门也什么都不拦,管理员以为开了强制 MFA,实际没有。组织级字段在 Organization require-MFA can be turned on with two-factor off, and after the grace period everyone is locked out with no way to enroll #30201 已经拒存这种状态,全局开关这次补齐。
  • 风险与回滚:这是一次收窄(Clause-② no (narrowing)):只在 OS_AUTH_TWO_FACTOR 设为关的部署上,以前能存的「开」现在被拒。环境变量未设或设为开时行为不变;关掉开关、原值回存永远放行;升级前已经存成「开」的值原样保留(照旧不生效)。回滚即撤本 PR。
  • 先看:packages/plugins/plugin-auth/src/mfa-required-enrollment-predicate.ts(规则与四语文案);auth-plugin.ts 里 bindAuthSettings 的注册一行(紧挨 allowed_ip_ranges 那条写谓词);content/docs/permissions/authentication.mdx 的 mfa_required 行。
  • 两分钟验证:pnpm --filter @objectstack/plugin-auth exec vitest run src/mfa-required-enrollment-predicate.test.ts,预期 7/7 通过,其中一条断言 OS_AUTH_TWO_FACTOR=false 下保存被拒且什么都没写、features.twoFactor 仍为 false。
  • 需要你做的:无,席位落地。「未强制」的信号不新增字段:GET /api/v1/auth/config 的 features.twoFactor 已与闸门同一判定,界面半边(Setup: organization 强制多因素认证 says nothing when two-factor is off, and the enrollment screen's refusal is an untranslated 404 #30295)读它即可。

Fixes #30302

Clause-②: no (narrowing) — while OS_AUTH_TWO_FACTOR is set to off, a save that turns the auth.mfa_required setting on is refused 400 SETTINGS_VALIDATION (invalid_value at mfa_required), a value an administrator could save before; saving it off, or re-saving a value already in force, passes. No published export, error code, settings key or /auth/config member is added; nothing is widened.
Metadata-protocol: none
Skills-docs: updated here — content/docs/permissions/authentication.mdx (the mfa_required row) and docs/HARDENING.md (the MFA row); skills/** teaches neither mfa_required nor require_mfa, so no skill or eval moves.

Why

Three should-fix items of the CONTRACT_REVIEW_TIER review of PR #30293 (record 6650579682, carried in 6651713006), filed as this card.

  1. The global switch saved unenforced. bindAuthSettings applies mfa_required = true as one refusable piece, { mfaRequired: true, plugins: { twoFactor: true } }, so the requirement is never in force without its enrollment path. But resolveTwoFactorEnabled reads OS_AUTH_TWO_FACTOR first, so under OS_AUTH_TWO_FACTOR=false the piece's plugins.twoFactor: true changes nothing: the switch saved and read on, nobody could enroll, and the gate (which since Organization require-MFA can be turned on with two-factor off, and after the grace period everyone is locked out with no way to enroll #30201 holds only a user who can enroll) enforced nothing. Measured: OS_AUTH_TWO_FACTOR is not a settings env override (the auth manifest has no two-factor key; the override for mfa_required itself would be OS_AUTH_MFA_REQUIRED); it is read only by resolveTwoFactorEnabled, i.e. on plugins.twoFactor.
  2. No "not enforced" signal. A stored requirement reads as required while two-factor is off.
  3. Docs. Both docs said require_mfa is settable per organization with no condition.

What changes

  • The refusal (item 1). mfa-required-enrollment-predicate.ts registers a settings write predicate on auth.mfa_required (SettingsService.registerWritePredicate, the seam allowed_ip_ranges and localization.currency use), from bindAuthSettings, beside the allowed_ip_ranges predicate and before the first settings pass. The settings service asks it before any row is written, only when the save changes the key. It refuses a change from off to on (stored toggles read as bindAuthSettings reads them) while resolveTwoFactorEnabled({ twoFactor: true }) answers false, which is the gate's own decision point over the plugin config the piece applies, so exactly an OS_AUTH_TWO_FACTOR value that reads as off. The refusal refuses the whole save (SettingsValidationError, SETTINGS_VALIDATION) with one field error at mfa_required, invalid_value, constraint { twoFactor: false, overriddenBy: 'OS_AUTH_TWO_FACTOR' }, and a message in the caller's language (en, zh-CN, ja-JP, es-ES; others fall back to English) naming the env var and the remedy (set it to true or remove it, restart, then turn the switch on). The rule reads the env var alone, so a sibling key in the same save cannot let it through (no batch hole). A settings service without write predicates is said once at warn, only while the pin makes it matter.
  • The signal (item 2): already there, documented. GET /api/v1/auth/config serves features.twoFactor from resolveTwoFactorEnabled(this.config.plugins) (since Organization require-MFA can be turned on with two-factor off, and after the grace period everyone is locked out with no way to enroll #30201), the same function the gate, the plugin list and both refusals read, live per request. features.twoFactor: false beside a stored require_mfa = true (or a stored global switch) is exactly "required but not enforced", and it is spec-typed already (AuthFeaturesConfigSchema.twoFactor) and readable from page predicates (features.twoFactor, as sys-user.page.ts does). So no /auth/config member is added, packages/spec is untouched, and the new test pins the reading on the refused path and on a switch stored on before the check. The UI half (Setup: organization 强制多因素认证 says nothing when two-factor is off, and the enrollment screen's refusal is an untranslated 404 #30295) reads it.
  • Docs (item 3). The mfa_required row of content/docs/permissions/authentication.mdx and the MFA row of docs/HARDENING.md now say the per-organization require_mfa needs two-factor enrollment on, that the global switch is refused under OS_AUTH_TWO_FACTOR=false, and (mdx) that features.twoFactor: false reports a stored requirement as not enforced.
  • Unchanged. With OS_AUTH_TWO_FACTOR unset or on, the switch saves and the pass turns the requirement and enrollment on together, as before. A switch saved on before this release is not touched (the settings service asks no predicate about a value already in force) and stays unenforced, which features.twoFactor says.

Tests

packages/plugins/plugin-auth/src/mfa-required-enrollment-predicate.test.ts, 7 cases, through the real SettingsService and the real AuthPlugin / AuthManager boot (no data engine composed):

  1. OS_AUTH_TWO_FACTOR=false: setMany('auth', { mfa_required: true, mfa_grace_period_days: 3 }) is refused, code + field + constraint asserted; nothing in the batch is stored; the runtime requirement, isTwoFactorEnabled() and features.twoFactor stay off; saving off passes.
  2. The real PUT /api/settings/:namespace route answers 400 SETTINGS_VALIDATION with the field error in zh-CN (OS_AUTH_TWO_FACTOR=0).
  3. Controls (env unset, env true): the save is accepted and the pass turns the requirement, enrollment and features.twoFactor on.
  4. A switch stored on before the check is not re-judged; it reads on, features.twoFactor reads false.
  5. The predicate alone: only off-to-on is judged, stored-toggle spellings, the English fallback.
  6. The registration warn fires only under the pin.

Ablations (each through scripts/ablation-replace.mjs, anchor hit 1 → 0, restored to the HEAD blob, git diff HEAD empty):

  • A: delete the registration call in auth-plugin.ts → cases 1 and 2 red (the save went through), 2 failed / 5 passed.
  • B: delete if (enrollable()) return undefined; → both controls and the predicate case red, 3 failed / 4 passed.

Acceptance notes

Verification

All readings on head 7e672312 (this branch merged with origin/main at d1d90b70, which carries #30309), under scripts/pm/os-verify-lock.sh, shared box.

Suites and typecheck
  • pnpm --filter '@objectstack/plugin-auth...' build && pnpm --filter @objectstack/plugin-auth test && pnpm --filter @objectstack/plugin-auth typecheck: VERDICT command-exit 0; Test Files 223 passed (223), Tests 3925 passed, 10 skipped (3935); check:test-typecheck: OK.
  • The new file alone: 7/7. Ablations A and B as above, each restored with git diff HEAD empty.
  • pnpm lint: exit 0.
Full dogfood battery (owed by the narrowing arm)

pnpm --filter '@objectstack/dogfood^...' build && pnpm --filter @objectstack/dogfood test (no shard, no path): VERDICT command-exit 0, held the lock 27m01s; Test Files 419 passed, 3 skipped (422); Tests 3492 passed, 36 skipped (3528); no failure.

Gates

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 95 commands on 7e672312; each was run with its exit code recorded before any pipe. --ran: 95 derived, 94 run, 1 NOT-MEASURED, 0 UNRUN. NOT MEASURED: pnpm check:dual-build-cjs-loads (exit 3, PREREQUISITE NOT MET: it needs a whole-workspace build; CI runs it). check:skill-examples first exited 3 (client packages unbuilt) and passed (exit 0, 283 examples) after building @objectstack/client and @objectstack/client-react. Every other command exited 0.


Generated by Claude Code

…_TWO_FACTOR=false pin

Claude-Session: https://claude.ai/code/session_01Utp6CZsesqReyo7ausntP8
Co-authored-by: Claude <noreply@anthropic.com>
…wed_ip_ranges one, before the first pass

Claude-Session: https://claude.ai/code/session_01Utp6CZsesqReyo7ausntP8
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added the size/m label Nov 5, 2026
@github-actions

github-actions Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 19 documentable anchor(s).

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

  • content/docs/deployment/environment-variables.mdx (via OS_AUTH_TWO_FACTOR (literal, a string literal in TWO_FACTOR_ENV))
  • content/docs/deployment/self-hosting.mdx (via /api/v1/auth/config (route, a path literal in a comment on a changed line))
  • content/docs/permissions/authentication.mdx (via OS_AUTH_TWO_FACTOR (literal, a string literal in TWO_FACTOR_ENV), mfa_required (literal, a string literal in MFA_REQUIRED_SETTING_KEY), /api/v1/auth/config (route, a path literal in a comment on a changed line))
  • content/docs/permissions/sso.mdx (via /api/v1/auth/config (route, a path literal in a comment on a changed line))
  • content/docs/protocol/objectui/actions.mdx (via /api/v1/auth/config (route, a path literal in a comment on a changed line))

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

  • content/docs/releases/v15.mdx (via /api/v1/auth/config (route, a path literal in a comment on a changed line))
  • content/docs/releases/v17/17-0.mdx (via getConfig (sdk, the bare tail of client method auth.getConfig, bound to GET /api/v1/auth/config))
  • content/docs/releases/v18/18-1.mdx (via /api/v1/auth/config (route, a path literal in a comment on a changed line))
  • content/docs/releases/v18/18-3.mdx (via /api/v1/auth/config (route, a path literal in a comment on a changed line))

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
  • 4 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 — 22 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 870ebce333bf0a5794307febe077ab5652cfbfe9 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 870ebce333bf0a5794307febe077ab5652cfbfe9

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

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Nov 5, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-bill · session_01Utp6Cbvcmv8YWFhFJwzjKL

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 7e67231284e874a891f47958bc66794ed1251327
Local-runs: none
Scope: packages/plugins/plugin-auth/src/mfa-required-enrollment-predicate.ts, packages/plugins/plugin-auth/src/mfa-required-enrollment-predicate.test.ts, packages/plugins/plugin-auth/src/auth-plugin.ts, docs/HARDENING.md + packages/plugins/plugin-auth/src/auth-manager.ts (resolveTwoFactorEnabled, readBooleanEnv, isTwoFactorEnabled, getPublicConfig), packages/plugins/plugin-auth/src/index.ts, packages/plugins/plugin-auth/src/organization-mfa-enrollment-guard.ts, packages/services/service-settings/src/settings-service.ts (registerWritePredicate, refuseByWritePredicates), packages/services/service-settings/src/settings-service.types.ts, packages/services/service-settings/src/settings-routes.ts, packages/services/service-settings/src/manifests/auth.manifest.ts, packages/core/src/fallbacks/memory-i18n.ts. The prose faces (.changeset/30302-mfa-enforcement-follow-ups.md, content/docs/permissions/authentication.mdx) were read sentence by sentence for ② and for the claims they make about the code; they are named here, not left as a remainder.

Face: narrowing — Clause-②: no (narrowing) at line start in the PR body and in the committed changeset, byte-identical tails; no packages/spec/src/** path and no governed path in the file list (6 files, +435/−2, author objectstack-fleet[bot], draft, base main, Fixes #30302 is the only closing keyword and it names this card). Card #30302 carries the triage audit 6650893079 and the seat's Claim 6652230132 naming the dev's branch; the dev's report is 6653256463.

① Derived judgments

  1. The refusal exists and sits at the save door. registerMfaRequiredEnrollmentPredicate(settings, ctx.logger) is called in bindAuthSettings after the allowed_ip_ranges predicate block and before the first applySettings(). It registers mfaRequiredEnrollmentPredicate() on ('auth', 'mfa_required') through SettingsService.registerWritePredicate, which the service asks in refuseByWritePredicates after validatePatch and before any row is written, only when stableStringify(next) !== stableStringify(current) where current is the caller's cascade value. So: a refused batch writes nothing (the test pins mfa_grace_period_days staying source: 'default'), re-saving a value in force is never asked (test 4), and the refusal is one FieldError with code: 'invalid_value', the predicate's message and constraint, thrown as SettingsValidationError (code = 'SETTINGS_VALIDATION'). Right.
  2. The rule judges what the gate will do. The predicate refuses iff readToggle(next) && !readToggle(current) && !resolveTwoFactorEnabled({ twoFactor: true }). resolveTwoFactorEnabled is readBooleanEnv('OS_AUTH_TWO_FACTOR') ?? pluginConfig?.twoFactor ?? false, so over { twoFactor: true } it answers false exactly when the env var reads as off ('0', 'false', 'off', 'no', trimmed, case-folded). That is the same decision point isTwoFactorEnabled(), the plugin list and getPublicConfig().features.twoFactor (this.config.plugins ?? {}, read live per call) use. The dev's measurement holds: the auth manifest has no two-factor key, so OS_AUTH_TWO_FACTOR is not a settings env override of mfa_required; it is read only inside resolveTwoFactorEnabled. readToggle is the same reading as the asBoolean closure in bindAuthSettings (boolean, string not 'false', non-zero number), so a stored 'true' / 1 is judged as the pass would read it. Right.
  3. Scope of the narrowing is as declared. Only a change from off (stored, default or cleared) to on is judged; off, unchanged, env unset and env on all pass. With the env unset or on the existing one-piece patch { mfaRequired: true, plugins: { twoFactor: true } } still turns enrollment on with the requirement (controls pin isTwoFactorEnabled() and features.twoFactor true after the pass). A switch stored on before this release is not re-judged and stays unenforced, which features.twoFactor: false says. The Before / Now / Unchanged / Remedy paragraphs of the changeset state exactly this. Right.
  4. No published surface moves. mfa-required-enrollment-predicate.ts exports five names, none re-exported from packages/plugins/plugin-auth/src/index.ts (the auth-manager allow-list is unchanged), and package.json exports maps only . to dist/index.*, so none of them is published. No error code, settings key, manifest specifier or /auth/config member is added; packages/spec is untouched. The PR body's "No published export … is added; nothing is widened" holds on this head. Right.
  5. The /auth/config signal (item 2). features.twoFactor is served from resolveTwoFactorEnabled(this.config.plugins) in getPublicConfig(), the same function the gate and both refusals read, and is spec-typed already (AuthFeaturesConfigSchema.twoFactor). A stored requirement beside features.twoFactor: false is the "required but not enforced" reading; the new test pins it on the refused path (test 1) and on a switch stored on before the check (test 4). The dispatch order allowed exactly this reduction ("if /auth/config already advertises whether two-factor is on … this item reduces to documenting it"), and the dev re-declared it in deviations. Right.
  6. Docs claims against the code. authentication.mdx mfa_required row: "saving it on also turns two-factor enrollment on" (the one-piece patch), "the save is refused while OS_AUTH_TWO_FACTOR=false pins enrollment off" (this predicate), "an organization can require it on its own (require_mfa) only while two-factor enrollment is on" (organization-mfa-enrollment-guard.ts, refuses while isTwoFactorEnabled() is false), "a requirement stored while enrollment is off is not enforced, which GET /api/v1/auth/config reports as features.twoFactor: false" (the gate's fail-safe from Organization require-MFA can be turned on with two-factor off, and after the grace period everyone is locked out with no way to enroll #30201 plus getPublicConfig). HARDENING.md MFA row: "per-org require_mfa, which needs two-factor enrollment on" and "neither saves while OS_AUTH_TWO_FACTOR=false": both refusals fire under that pin (the org guard through isTwoFactorEnabled(), which reads the env first). Every sentence holds on this head.
  7. The refusal leaks nothing. The message and constraint name the env var's NAME and the remedy; no value, no path, no stored state beyond the one field the caller submitted. It reaches only a caller the settings route already admitted to write auth (manage_platform_settings), and the HTTP envelope is the service's standard 400 SETTINGS_VALIDATION with details.fields (ADR-0114 shape). The four-locale copy is picked through resolveLocale from @objectstack/core (exact, then language, else English; test pins fr-FR falling to en and the route test pins zh-CN from Accept-Language).
  8. Tests go red under ablation, by construction. Ablation A (registration call deleted): tests 1 and 2 call refusalOf, whose first assertion is toBeInstanceOf(SettingsValidationError) with the label 'the save went through'; without the predicate setMany resolves and both fail, the controls, test 4, the predicate-alone case and the warn case are untouched: 2 failed / 5 passed, as reported. Ablation B (if (enrollable()) return undefined; deleted): both controls refuse (setMany throws), and the predicate-alone case asserts mfaRequiredEnrollmentPredicate(() => true)(…) is undefined: 3 failed / 4 passed, as reported. Both refusal pins assert code (SETTINGS_VALIDATION), the field entry (mfa_required / invalid_value / the constraint object) and, on the HTTP door, status 400. Checklist met.
  9. Degradation level. A settings service without registerWritePredicate is said once at warn, only while the pin makes the gap real; the save still lands and reads on, which is a functional degradation named with its remedy, so warn is the right level. The catch {} around registration swallows only UnknownNamespaceError / UnknownKeyError, states where nothing can save the key; observation, see ③.

② Semver level

Changeset .changeset/30302-mfa-enforcement-follow-ups.md: @objectstack/plugin-auth: patch, the only package the diff moves and a published one (private absent, exports present). Summary line carries the ! marker. Clause-②: no (narrowing) and Metadata-protocol: none at line start, identical to the PR body's lines. Skills-docs: updated here — … present, and true on the tree: skills/** names neither mfa_required nor require_mfa (0 hits on the head), the two docs rows are the teaching, and plugin-auth has no evals directory to move. Level follows the kind: none takes patch; a non-protocol narrowing ships patch and keeps its BREAKING banner (**BREAKING** accept-set narrowing … present) and its ADR-0087 disposition. Exactly one marker, adr-0087: not-required (no-migration-prescription) …, the spelling check-adr-0087-registration.mjs reads, and its justification holds against the tree: no spec key, authorable spelling, export or stored shape is removed, renamed or re-shaped, no stored row is read or rewritten, the remedy is a deployment env change, the package publishes, no ADR-0087 id covers the key; the same disposition the sibling narrowing (30203-allowed-ip-ranges-live.md) carried. Check Changeset and the Lint · CI, release & engine gates job are green on this head. Level and declarations are right.

③ Boundary flags

  • Premise: premise_still_valid: true; item 2 partly falsified (the signal exists) inside the dispatch order's own allowance; recorded in deviations and the PR body. No open_questions.
  • Branch claude/project-thread-dw6hfs is the harness-pinned thread branch, declared as the platform exception in the seat's Claim; the dev posted no second Claim and wrote no assignee. Implemented-by is therefore the dev session.
  • Surface: six files, all inside the claimed file surface (plugin-auth/src, the two docs rows, one changeset); none of the forbidden trees. content/docs/releases/** untouched.
  • check:dual-build-cjs-loads NOT MEASURED locally (exit 3, whole-workspace build prerequisite) and left to CI; check:skill-examples passed after building the client packages. --ran: 95 derived, 94 run, 1 NOT MEASURED, 0 UNRUN. Accepted as reported; CI's Lint jobs on this head are green.
  • Dogfood battery owed by the narrowing arm: pinned to 7e672312, full (no shard, no path), 419 files / 3492 tests passed, no failure, under the verify lock. Present and whole.
  • Serial constraints: the branch merged origin/main at d1d90b70, which carries fix(plugin-auth)!: clearing the allowed IP ranges applies at once, and a console save that leaves out the saving address is refused #30309 (PR 30309's allowed_ip_ranges predicate); the registration sits beside it as the order asked. main has since moved to fdad1786 (one commit, metadata-protocol/rest, not plugin-auth): the seat re-pulls before landing per the order.
  • Acceptance notes, both read: the auth manifest's mfa_required description lives in service-settings, outside this surface, and stays true for every save that lands; OS_AUTH_MFA_REQUIRED=true beside OS_AUTH_TWO_FACTOR=false is a deployment contradiction no save door sees, named by the gate's once-per-process warning. Neither is a defect this PR introduces; Acceptance notes is the right carrier.
  • Author objectstack-fleet[bot] (relay write); mcp_calls 0; api_writes 2 as reported.
  • Check-runs on 7e67231284e874a891f47958bc66794ed1251327: every completed run is success except Spec property liveness, whose failure is the known main red — the same signature (Check spec liveness, query/searchFields → packages/objectql/src/engine.ts, 1 UNANCHORED) on origin/main fdad1786 (run 41988465827, job 127114737742), card main red: spec check:liveness — query/searchFields cites engine.ts, which no longer mentions searchFields after #30341 #30367, fix PR fix(spec): repoint the query.searchFields liveness row at engine-find.ts#expandSearchOnAst #30368; a merge-base red of equal signature is not counted against this PR. Every other check-run on the head has completed success (read at 08:44Z: Build Core, all eight Test Core shards, the four Type Check jobs, every Lint job, Check Changeset, Governed Surface Queue Guard, Dogfood Regression Gate, Build Docs, Test UI, Live E2E UI), the rest skipped by path filter; the head is unchanged at 7e672312.
  • Observations, none a must-fix: (a) readToggle restates the asBoolean closure of bindAuthSettings rather than sharing it; the two must stay equal, and today they are. (b) The registration's catch {} is silent where the allowed_ip_ranges sibling logs a warn; the docblock's reason (a service without the auth namespace or the key cannot save it) holds, so the silence hides no save that lands. (c) The authentication.mdx row is one long table cell; it is accurate.

④ Design judgment

On the narrowing face, judged against the published spec and the runtime on main: this takes no declared capability away at a consumer; it makes a refusal real that the spec already implied. The auth manifest describes mfa_required as "Enabling this also turns on the two-factor feature so users can enroll", and ADR-0069 D3 ties the requirement to an enrollment path; under an OS_AUTH_TWO_FACTOR=false pin that description was false and the switch advertised an enforcement the runtime did not deliver (declared ≠ enforced, AGENTS.md PD #10). Refusing the save at the door, with the remedy, is the contract-first shape AGENTS.md PD #12 asks for, and it is the same shape the organization column took in #30201 (organization-mfa-enrollment-guard.ts), so the two doors now agree. The seam is the right one: a settings write predicate (registerWritePredicate, the mechanism allowed_ip_ranges and localization.currency use), asked before any row is written, so the console never shows a saved value the runtime ignores, which is the exact failure a refusal inside the settings pass would have left. The rule reads the env var alone, so no sibling key in the same batch can let it through. Judging the transition (off to on) rather than the echoed value avoids the full-row-client trap the parent review flagged on the org guard. The "not enforced" signal reuses the one decision point (resolveTwoFactorEnabled) rather than adding a second member that could drift from it; that is the smaller and more honest surface, and it is what the UI half (#30295) should read. Four-locale copy through resolveLocale matches the project's refusal-text practice. The change holds against the code it cites and the scenario the card accepts it against.

Findings, each named:

  • must-fix: none.
  • should-fix: none that blocks; observations (a)–(c) in ③ are for the seat's ACCEPT, not for a rework.

Implemented-by: session_01Utp6CZsesqReyo7ausntP8
Reviewed-by: session_01Utp6Cbvcmv8YWFhFJwzjKL

VERDICT: PASS

Record written 2026-11-05T08:44Z.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-bill · session_01Utp6Cbvcmv8YWFhFJwzjKL

维护者速读

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

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/m tests tooling

Projects

None yet

1 participant