Skip to content

fix(service-settings): judge OS_LOCALIZATION_LOCALE once the boot's locale list is whole, and one locale matcher for Setup and the IdP claim - #30589

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

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

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Bill · project thread

domain:server#4 · os-bill · session_01Utp6CZMkHvuDeVYgTD73qF

维护者速读

  • 改了什么: 三件事。①启动窗口:OS_LOCALIZATION_LOCALE 指定的语言如果要到 kernel:ready 期间才进语言列表(应用自建翻译项就是这时加载的),以前在 kernel:ready 早读的一方(auth 的邮件/短信默认语言)会把它判成 IGNORED 并丢掉;现在设置服务在 kernel:bootstrapped 之前不下"不在列表里"的结论,先让它生效,列表齐了再判一次,真不合法才报一次错并发变更事件让读方重读。②一个匹配器:Setup 默认语言的接受规则和 IdP 登录语言声明都改用 core 新导出的 resolveOfferedLocale,两边接受同一批写法。③更正 auth-manager.ts / auth-plugin.ts 里"邮件模板四种语言就是该设置的选项"的过期注释。
  • 为什么改: Default language in Setup offers the languages the deployment supports, instead of four hard-coded ones #30286 合入后的契约审查发现:启动窗口会把合法的环境变量误判丢弃(AGENTS.md "Startup registry reads");Setup 拒收 fr-FR(部署提供 fr)而 IdP 声明 fr-FR 能落到 fr,两扇门对同一种语言说法不一;注释描述的是 Default language in Setup offers the languages the deployment supports, instead of four hard-coded ones #30286 之前的世界。
  • 风险与回滚: 放宽(Clause-②: yes (widening)):Setup 现在还接受已提供语言的地区/基础写法和大小写变体(fr 提供时的 fr-FR、zh-CN 的 zh-cn),以前接受的值全部照旧。IdP 在同时提供 zh-Hans/zh-Hant 时把 zh-TW 落到繁体(以前落到先列出的简体)。一个真正不合法的 OS_LOCALIZATION_LOCALE 在 kernel:ready 期间会暂时生效,kernel:bootstrapped 时报错并通知读方改回;一次性读方(应用内联种子在 kernel:ready 读)会用到这个暂时值,无法再通知它改回。回滚即撤本 PR。
  • 先看: packages/core/src/fallbacks/memory-i18n.ts 的 resolveOfferedLocale;settings-service.ts 的 holdOptionSourceVerdicts / sealOptionSources / holdsOptionSourceVerdict 三段和 effectiveEnvOverride 里改动的一行;settings-service-plugin.ts 的两处接线。
  • 两分钟验证: pnpm --filter @objectstack/service-settings exec vitest run --maxWorkers=2 src/localization-locale-options.test.ts,预期 18 条全绿,其中"read while the boot is still filling the locale list"三条是启动窗口的钉子。
  • 需要你做的: 无需你本人决定(非 BREAKING,放宽),按席位流程审查合入。

Fixes #30456

Clause-②: yes (widening) — @objectstack/core exports resolveOfferedLocale(requested, offered); SettingsService gains holdOptionSourceVerdicts() and sealOptionSources(). The save of localization.locale (PUT /api/settings/localization, SettingsService.setMany) and an OS_LOCALIZATION_LOCALE override now also accept a region or base of an offered language (fr-FR where fr is offered, fr where fr-FR is) and another case of an offered code (zh-cn for zh-CN); nothing accepted before is refused. The IdP locale claim (resolveIdpLocale) lands a script or region spelling on the offered code the platform locale table says it means (zh-TW on zh-Hant, not on a zh-Hans listed first).
Metadata-protocol: none

What changes

1. The boot window (settings-service.ts, settings-service-plugin.ts)

Measured on origin/main 8e5fabe: localization.locale's option source (#30286) reads the i18n service's getLocales(), and that list grows during kernel:ready when the i18n plugin syncs the app's authored translations (service-i18n/src/i18n-service-plugin.ts, replaceAuthoredTranslations). Auth's applyLocalizationLocale (plugin-auth/src/auth-plugin.ts) reads the key in its own kernel:ready handler. Whichever runs first decides: an OS_LOCALIZATION_LOCALE=vi-VN whose language arrives as an authored translation could be judged against a list without it, reported IGNORED at error, and auth kept the next layer's value for the rest of the process. That is the three-part shape AGENTS.md "Startup registry reads" names (a filling registry, a terminal "absent", recorded in a log and in the reader's state).

Cure 3 of that section, seal then judge:

  • holdOptionSourceVerdicts(): SettingsServicePlugin.init calls it before registering the source. While held, an OS_* override on a sourced key that the source (or the declared table it stands in for, when the list is still empty) does not admit is taken in force, not reported, and remembered. effectiveEnvOverride changes by one condition: if (rejected && !this.holdsOptionSourceVerdict(namespace, key)). The other families (pattern, value domain, bounds, format) do not depend on the list and are judged at once, as before.
  • sealOptionSources(): the plugin calls it from kernel:bootstrapped, which the kernel fires after every kernel:ready handler. Each remembered key is judged again: one the list now admits stays in force and nothing is said; one it still does not admit is reported once through the existing reportRejectedEnvOverride (same message, same dedupe), and a settings change ({ namespace, key, scope: 'global', action: 'set' }) is emitted so a reader that applied the held value reads the key again. Auth's reader already subscribes to localization changes, so it re-applies without a code change.
  • A SettingsService constructed without the plugin is never held and judges at once, as before.

2. One matcher (core, service-settings, plugin-auth)

resolveOfferedLocale(requested, offered) in packages/core/src/fallbacks/memory-i18n.ts, exported beside resolveLocale. It reuses resolveLocale rather than copying it:

  1. resolveLocale's exact and case-insensitive steps;
  2. the platform locale table: an offered code that means the same table language (platformLocaleTagOf, through the read aliases), so zh-TW is zh-Hant, zh-CN is zh-Hans, en-US is en;
  3. resolveLocale itself for the base-language and variant steps.

Step 2 is what Setup's old admits did on its own, and it only changes resolveLocale's answer where two offered codes share a base language and the table tells them apart (zh-Hans/zh-Hant), where resolveLocale takes the first listed.

  • Setup (localization-locale-options.ts): admits(v) is resolveOfferedLocale(v, codes) !== undefined, and a stored second spelling is served in the place of the code the matcher lands it on. The docblock's "fr-FR is not fr" is replaced by the matcher's rules.
  • IdP claim (idp-locale-claim.ts): resolveIdpLocale calls resolveOfferedLocale instead of resolveLocale; the stored value is still the supported code in the column's BCP-47 case.

3. The stale comment (auth-manager.ts, auth-plugin.ts)

AUTH_EMAIL_TEMPLATE_LOCALES' docblock said those four "are also the four options the localization.locale setting offers, so the two ends agree by construction". Since #30286 the setting offers the deployment's list. The docblock now says how they agree: normalizeAuthEmailLocale maps a value onto a row spelling where one of the four means it and passes any other through, and EmailService.sendTemplate resolves it against the bundle's tags and falls back to the en-US row (plugin-email/src/email-service.ts, #28880 ladder). The same claim in the applyLocalizationLocale comment of auth-plugin.ts ("whose four options are exactly AUTH_EMAIL_TEMPLATE_LOCALES") is corrected the same way. Comments only.

Tests

  • service-settings/src/localization-locale-options.test.ts (the component's shared harness, now 18): a new top-level describe boots a LiteKernel with a kernel:ready reader that runs before a stand-in i18n plugin grows its list in its own kernel:ready step:
    • OS_LOCALIZATION_LOCALE=vi-VN, list ['en'] gaining vi-VN: the reader's first read is vi-VN from env, every read stays vi-VN, and nothing is logged for the variable;
    • OS_LOCALIZATION_LOCALE=xx-XX, never offered: held at the first read, then exactly one IGNORED report and a re-read that lands on the manifest default;
    • a bare SettingsService judges at once; sealing it is a no-op.
    • Matcher through the real PUT/GET routes: fr-FR where fr is offered (served in fr's place), fr where fr-FR is, zh-cn where zh-CN is, zh-TW where zh-Hans and zh-Hant are (served in zh-Hant's place), de-DE still refused. The old pin "fr-FR refused where fr is offered" is the narrowing this card removes and is replaced.
  • plugin-auth/src/idp-locale-claim.test.ts: zh-TW → zh-Hant, zh-CN → zh-Hans with the scripts listed either way round, fr-FR → fr, de-DE → unset.
  • core/src/fallbacks/fallbacks.test.ts: resolveOfferedLocale step by step, including resolveLocale('zh-TW', ['zh-Hans','zh-Hant']) answering zh-Hans beside the new answer zh-Hant.

Ablation through scripts/ablation-replace.mjs, each restored after: the hold condition removed ⇒ both boot-window pins red; the seal's emitChange removed ⇒ the re-read pin red; the kernel:bootstrapped hook removed ⇒ 2 red (including the existing "not in force once the list drops it"); Setup's admits back to exact codes ⇒ 2 red; resolveIdpLocale on a first-match matcher ⇒ the zh-Hant pin red; the table step removed from resolveOfferedLocale ⇒ the core table pin red.

Acceptance notes

Verification

All at head 06b4c86f (the branch with origin/main c23eafa merged), after pnpm install --frozen-lockfile (exit 0) and a full build (turbo run build --filter='!@objectstack/docs', 119/119, under the verify lock):

  • pnpm --filter @objectstack/core test: 135 files, 3017 passed; typecheck exit 0.
  • pnpm --filter @objectstack/service-settings test: 69 files, 1175 passed; typecheck exit 0.
  • pnpm --filter @objectstack/plugin-auth test: 224 files, 3985 passed and 10 skipped; typecheck exit 0.
  • node scripts/pm/dispatch-gates.mjs --commands derived 67 families from the change set; each run with its exit recorded before any pipe: 67 of 67 exit 0. --ran: 67 derived, 67 run, 0 NOT-MEASURED, exit 0.
  • pnpm lint: exit 0.
  • pnpm --filter @objectstack/dogfood test under the lock: exit 0, 420 files passed and 3 skipped, 3496 tests passed and 36 skipped.
  • CI check-runs: not yet reported at opening.

Generated by Claude Code

…til kernel:bootstrapped, correct stale auth comment

Claude-Session: https://claude.ai/code/session_01Utp6CZEhtHfQH5TLEVe3TF
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Utp6CZEhtHfQH5TLEVe3TF
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Utp6CZEhtHfQH5TLEVe3TF
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m 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 3 package(s): @objectstack/core, @objectstack/plugin-auth, @objectstack/service-settings, touching 11 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/core/src/fallbacks/index.ts, packages/plugins/plugin-auth/src/auth-manager.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/data-modeling/drivers.mdx (via SettingsService (symbol, a top-level class))
  • content/docs/protocol/kernel/config-resolution.mdx (via SettingsService (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx (via SettingsService (symbol, a top-level class), SettingsServicePlugin (symbol, a top-level class))
What this run could not see
  • 2 changed file(s) yielded no anchor (packages/core/src/fallbacks/index.ts, packages/plugins/plugin-auth/src/auth-manager.ts) — pages documenting those are invisible to this run
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 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 — 53 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 2ffdb649775d20313707d88a8388275f280a0a7f → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 2ffdb649775d20313707d88a8388275f280a0a7f

⚠️ 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 2ffdb649775d20313707d88a8388275f280a0a7f → 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#4 · os-bill · session_01Utp6CZMkHvuDeVYgTD73qF

维护者速读

  1. 改了什么:三件事。① 启动期间语言列表还在加载时,OS_LOCALIZATION_LOCALE 指定的语言不再被误判为非法丢掉,判定推迟到启动完成(kernel:bootstrapped)再做,届时仍非法才报一次 IGNORED 并通知读取方重读。② Setup 默认语言和 IdP 登录带来的语言改用同一个匹配函数 resolveOfferedLocale(core 新导出),fr-FR/fr、大小写不同、zh-TW→zh-Hant 两边一致。③ plugin-auth 两处过期注释更正。
  2. 为什么改:之前设了 vi-VN 这类由应用翻译带来的语言,启动时会被报非法、邮件短信默认语言退回下一层;Setup 和 IdP 两个入口认可的语言写法不一致。
  3. 风险:低。保存只放宽不收紧(Clause-② yes, widening):原来认可的值都照旧认可,多认可同一语言的地区/大小写写法。已申报:启动窗口内一个最终非法的覆盖值会暂时生效,kernel:bootstrapped 时撤回并通知;只在 kernel:ready 读一次不再订阅的读取方可能用到。回滚即 revert。
  4. 先看:packages/services/service-settings/src/settings-service.ts 的 holdOptionSourceVerdicts / sealOptionSources,和 packages/core/src/fallbacks/memory-i18n.ts 的 resolveOfferedLocale。
  5. 两分钟验证:pnpm --filter @objectstack/service-settings exec vitest run localization-locale-options。
  6. 需要你做的:无,席位落地。非 BREAKING,不属 Tier H,不超 3000 行。

席位意见:ACCEPT(#30456)。用环境变量设的默认语言在启动时不再被误丢;Setup 和单点登录对同一种语言的不同写法认法一致。

席位核对

Head-sha: 06b4c86ff25fa6025c44f77054d7ce298cacb487 · read 2026-11-05T17:41Z

Item Reading
Shape Draft → main via relay pr_create; Fixes #30456. 12 files, +325/−32. ⛔ packages/spec/**, ui/** untouched; no service-settings translation key, no auth prelude query-count pin, no setMany env-lock branch, no mail/branding manifest touched (overlap notes in the dispatch order).
Seat diff read Hold applies only to the option family of a key with a registered source; pattern/domain/bounds/format still judge at once. The held key is remembered; sealOptionSources (idempotent, no-op when nothing held) re-judges through effectiveEnvOverride, which reports IGNORED once, then emits a change so subscribed readers re-read. A service built without the plugin judges at once (pinned). resolveOfferedLocale = exact → case → platform-table language → resolveLocale base/variant; admits and the stored-spelling branch of the option source both use it, so the save boundary and the served list agree.
Evidence Gates 67/67 exit 0, --ran 0 NOT-MEASURED, lint 0, core/service-settings/plugin-auth tests and typecheck green, dogfood 3496 pass; 6 ablations red then restored.
Gates (check-runs on this head) 63 completed: 54 success, 9 skipped, 0 failed; mergeable clean.
Semver @objectstack/core, @objectstack/service-settings, @objectstack/plugin-auth: patch. Clause-②: yes (widening) as the Claim's provisional value, Metadata-protocol: none.
Landing Not governed (check-governed-merges --pr 30589: 0 of 12 paths), 357 lines ≤ 3000, not BREAKING, so per pm-dispatch the seat lands it: pr_ready + automerge_enable.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 5, 2026 17:42
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 5, 2026 17:42
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 5, 2026
Merged via the queue into main with commit 1c5cee6 Nov 5, 2026
64 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-g5rn8k branch November 5, 2026 18:05
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 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.

service-settings: the default-language option list has a boot window on OS_LOCALIZATION_LOCALE, and its matcher differs from the IdP locale claim's

1 participant