Skip to content

Commit 92cb3d9

Browse files
docs: name resolveLocaleAmong's five-step locale ladder where comments still restate four steps (#30694)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DBFhkQHTZPTwLSqiWCH7wN5)_ Fixes #30617 Clause-②: no Metadata-protocol: none Skills-docs: none — comment/docs text only; skills/** carried by #30603 ## 维护者速读 - 改了什么:服务端注释、翻译文档页与一条 QA 清单条目里的语言匹配说明,从旧的四步改为 `resolveLocaleAmong` 的五步(多了平台语言表一步)。 - 为什么改:#30601 已把两个解析器统一到五步;旧说明会让人以为繁体请求(zh-TW)落到第一个 zh-* 文案,加繁体包时会误判。 - 风险与回滚:零运行时改动,只动注释/文档/清单文本;回滚即 revert 本 PR。 - 先看:`content/docs/ui/translations.mdx`(对外文档)、`docs/qa/platform-checklist/areas/integration-system.json`(清单条款 revision 15→16)、`organization-mfa-enrollment-guard.ts` 注释。 - 两分钟验证:`node scripts/check-platform-checklist.mjs`,预期打印 OK。 - 需要你做的:无,席位落地。 ## What changes Comment, docs and checklist text only; no code or behaviour change. Each place that restated the old four-step ladder (exact, case-insensitive, base language, variant) now names `resolveLocaleAmong` (`@objectstack/spec/system`) and its five steps, adding the platform locale table step (`platformLocaleTagOf`: `zh` reaches `zh-CN`, `zh-TW` / `zh-HK` / `zh-MO` reach `zh-Hant`), or says the table step only adds answers for the table's aliases. - `packages/objectql/src/engine-validation-messages.ts`: the `negotiatedMessageLocale` doc comment. - `packages/objectql/src/engine-validation-locale-negotiation.test.ts`: the header comment. - `packages/services/service-automation/src/engine.ts`: the comment on the `resolveBundleLocale` import. - `packages/plugins/plugin-auth/src/organization-mfa-enrollment-guard.ts`: the module doc and the `copyLocaleFor` doc (the copy carries no `zh-Hant`, so `zh-TW` still reads `zh-CN`). - `packages/plugins/plugin-email/src/template-loader.ts`: the sorted-tags comment now says the sort fixes both first-match steps (table language and variant expansion). - `content/docs/ui/translations.mdx`: the "Within the bundle, matching walks" paragraph. - `docs/qa/platform-checklist/areas/integration-system.json`: `integration-system.email-template-render` acceptance[3] clause and verify; revision 15 to 16 with a history row. The showcase bundle holds no `zh-Hant` tag, so no verdict changes. Dropped from the card's list: `packages/plugins/plugin-auth/src/idp-locale-claim.ts` already names `resolveOfferedLocale` and the five steps on `origin/main` (b77c160), so it is unchanged. ## Acceptance notes - `packages/spec/src/stack.zod.ts` (the `defineStack` email-template floor warning) still prints the four-step list (exact, case-insensitive, base language, variant expansion). It is in spec and outside this card's file list, so it is not touched here. Noted, not filed. - No changeset: the diff publishes nothing — comment text in published packages and an unpublished test file, docs and checklist. `skip-changeset` label. ## Verification Gates run at e5703af (`node scripts/pm/dispatch-gates.mjs --commands`, 87 derived): 79 exit 0; 7 exit 3 NOT MEASURED (prerequisite not met in this shallow, unbuilt worktree: `check-plugin-teardown-shape --self-test` needs an unshallow clone; `@objectstack/lint` doc-formula-expressions and doc-security-posture, spec `check:skill-examples`, `check:docs-transcript-drift`, `check:dual-build-cjs-loads`, `check:lean-entry-closure` need builds); `check:type-check-debt` NOT MEASURED locally (it re-measures every package typecheck; hit the local timeout). CI's Lint & Repo Gates runs all of them. `node scripts/check-platform-checklist.mjs` exit 0. eslint on the five changed TS files exit 0 (scoped; the whole-repo lint is CI's). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude <noreply@anthropic.com>
1 parent 55a00ba commit 92cb3d9

7 files changed

Lines changed: 39 additions & 16 deletions

File tree

‎content/docs/ui/translations.mdx‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,8 +174,13 @@ Per request, in this order:
174174
2. A `?locale=` query parameter
175175
3. The stack's `defaultLocale`
176176

177-
Within the bundle, matching walks: exact (`zh-CN`) → case-insensitive →
178-
**base language** (`zh-CN` → `zh`) → variant expansion (`zh` → `zh-CN`). If
177+
Within the bundle, matching walks `resolveLocaleAmong`'s five steps
178+
(`@objectstack/spec/system`): exact (`zh-CN`) → case-insensitive → the
179+
**platform locale table** language (`zh-TW` / `zh-HK` / `zh-MO` → `zh-Hant`,
180+
`zh` → `zh-CN`) → **base language** (`zh-CN` → `zh`) → variant expansion
181+
(`es-MX` → `es-ES`, the first registered variant). The table step only adds
182+
answers for the table's aliases: a bundle with no `zh-Hant` still serves
183+
`zh-TW` its `zh-CN` strings. If
179184
nothing matches, the request walks the stack's **declared** fallback locale
180185
(`fallbackLocale`, else `defaultLocale`), and finally the literal label on the
181186
metadata. **Translation lookup never throws** — a missing string degrades to

‎docs/qa/platform-checklist/areas/integration-system.json‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -897,7 +897,7 @@
897897
"title": "Email templates resolve from the registry (no sys_email_template row since ADR-0131 D6/D10), resolve (name, locale) with en-US fallback, render {{path}} holes, gate on required variables and active:false, survive admin edits across redeploys — and the raw POST /api/v1/email/send door authenticates, requires manage_platform_settings, bounds sentBy, from, headers, organizationId and identityMail, refuses anonymous, and 400s malformed input",
898898
"since": "v15",
899899
"status": "active",
900-
"revision": 15,
900+
"revision": 16,
901901
"priority": "P2",
902902
"surface": "mixed",
903903
"fixtures": {
@@ -954,9 +954,9 @@
954954
"evidence": "the error"
955955
},
956956
{
957-
"clause": "(name, locale) resolution walks the ladder of record, SendTemplateInput.locale in packages/spec/src/contracts/email-service.ts: two rows with one name are an i18n bundle, the name is matched exactly and only the locale member is resolved, and every rung loads one row at one tag exactly, so en and en-US stay two distinct rows (folding chooses which row is read, it never merges two). A call that NAMES a locale, over the built-in registry template loader, walks: (1) this locale, folded against the bundle's locale tags by resolveBundleLocale — exact, then case-insensitive (zh-cn → zh-CN), then base language (en-GB → en), then variant expansion (zh and zh-TW → zh-CN), the first match in ascending tag order; (2) the stack's declared fallback locale (II18nService.getFallbackLocale(): i18n.fallbackLocale, else defaultLocale), folded the same way, skipped when none is declared; (3) en-US, exactly; (4) TEMPLATE_NOT_FOUND, which is permanent. A rung that resolves to a tag already tried is skipped; a replacement TemplateLoader keeps the exact ladder. A call that names no locale starts at en-US exactly, with no fold and no declared fallback, then takes the loader's own no-locale answer (the built-in loader: the bundle's lowest locale tag)",
957+
"clause": "(name, locale) resolution walks the ladder of record, SendTemplateInput.locale in packages/spec/src/contracts/email-service.ts: two rows with one name are an i18n bundle, the name is matched exactly and only the locale member is resolved, and every rung loads one row at one tag exactly, so en and en-US stay two distinct rows (folding chooses which row is read, it never merges two). A call that NAMES a locale, over the built-in registry template loader, walks: (1) this locale, folded against the bundle's locale tags by resolveBundleLocale, which runs resolveLocaleAmong's five steps — exact, then case-insensitive (zh-cn → zh-CN), then the platform locale table language (platformLocaleTagOf: zh → zh-CN, zh-TW / zh-HK / zh-MO → a zh-Hant tag), then base language (en-GB → en), then variant expansion (zh-TW → zh-CN when the bundle holds no zh-Hant tag), the first match in ascending tag order; (2) the stack's declared fallback locale (II18nService.getFallbackLocale(): i18n.fallbackLocale, else defaultLocale), folded the same way, skipped when none is declared; (3) en-US, exactly; (4) TEMPLATE_NOT_FOUND, which is permanent. A rung that resolves to a tag already tried is skipped; a replacement TemplateLoader keeps the exact ladder. A call that names no locale starts at en-US exactly, with no fold and no declared fallback, then takes the loader's own no-locale answer (the built-in loader: the bundle's lowest locale tag)",
958958
"oracle": "log",
959-
"verify": "the zh-CN recipient gets the zh-CN render; the zh recipient gets the zh-CN render too (variant expansion); a recipient whose language the bundle lacks (e.g. ja-JP) gets the en-US row, through the showcase's declared fallback en folding to en-US. A zh recipient getting the en-US row is the FAIL. The clause's no-locale branch cannot be driven from the HTTP door: the email channel always names a locale, the recipient's sys_user.locale or else the deployment default (resolveRecipientLocale, packages/services/service-messaging/src/recipient-locale.ts), so score that branch from source, never as a live FAIL",
959+
"verify": "the zh-CN recipient gets the zh-CN render; the zh recipient gets the zh-CN render too (the platform locale table step); a recipient whose language the bundle lacks (e.g. ja-JP) gets the en-US row, through the showcase's declared fallback en folding to en-US. A zh recipient getting the en-US row is the FAIL. The clause's no-locale branch cannot be driven from the HTTP door: the email channel always names a locale, the recipient's sys_user.locale or else the deployment default (resolveRecipientLocale, packages/services/service-messaging/src/recipient-locale.ts), so score that branch from source, never as a live FAIL",
960960
"evidence": "the two renders"
961961
},
962962
{
@@ -1096,6 +1096,12 @@
10961096
"date": "2026-11-04",
10971097
"change": "fixtures.requires[2] corrected: the 'same name, locale zh-CN' scratch copy was written package-less onto the shipped name, which ADR-0146 D11 refuses 403 NOT_OVERRIDABLE. The zh-CN member is now authored into com.example.showcase.custom with no `overrides`, since the base ships no zh-CN member and a declared override of one is refused 403 NOT_OVERRIDABLE (declared_package_ships_no_such_item). Measured on a fresh showcase boot at base 232d3bb5ef: package-less zh-CN PUT 403; ensure-customization 201; pair PUT with overrides 403; pair PUT without overrides 200, served by ?member=zh-CN; a scratch api notify flow then sent the zh-CN wording to a zh-CN-locale recipient and the en-US wording to an en-US one (sys_email 'sent'); DELETE ?package=…custom&member=zh-CN 200, then ?member=zh-CN 404. No clause changed",
10981098
"ref": "#30129"
1099+
},
1100+
{
1101+
"revision": 16,
1102+
"date": "2026-11-05",
1103+
"change": "acceptance[3].clause and .verify name the fold ladder as resolveLocaleAmong's five steps: PR #30601 put a platform locale table step (platformLocaleTagOf) between case-insensitive and base language, so zh reaches zh-CN at that step and zh-TW / zh-HK / zh-MO reach a zh-Hant tag when the bundle holds one, else zh-CN by variant expansion. The showcase bundle holds no zh-Hant tag, so no verdict this item scores changes. Read on main b77c160a",
1104+
"ref": "#30617"
10991105
}
11001106
]
11011107
},

‎packages/objectql/src/engine-validation-locale-negotiation.test.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@ import { SchemaRegistry } from './registry';
99
* #15757 — ONE negotiation rule, not two.
1010
*
1111
* `@objectstack/spec`'s `resolveBundleLocale` already decides which bundle a
12-
* requested tag reaches (exact → case-insensitive → base language → VARIANT
13-
* expansion, which is where a bare `zh` reaches a `zh-CN` bundle). `pickData`
12+
* requested tag reaches (`resolveLocaleAmong`'s five steps: exact →
13+
* case-insensitive → platform locale table language, which is where a bare
14+
* `zh` reaches a `zh-CN` bundle → base language → VARIANT expansion, which
15+
* is where a `de-AT` reaches a `de-DE` bundle). `pickData`
1416
* calls it, and every document translator — `translateObject`,
1517
* `translateView`, `translateDataset`, … — goes through `pickData`.
1618
*

‎packages/objectql/src/engine-validation-messages.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,8 +169,10 @@ function packagedRuleLookup(
169169
*
170170
* ⛔ The rule is NOT re-implemented here. `resolveBundleLocale`
171171
* (`@objectstack/spec/system`) is the single negotiation rule the document
172-
* translators already run — exact → case-insensitive → base language →
173-
* variant expansion, which is the step that reaches `zh-CN` from `zh` — and
172+
* translators already run — `resolveLocaleAmong`'s five steps: exact →
173+
* case-insensitive → platform locale table language (`platformLocaleTagOf`,
174+
* which reaches `zh-CN` from `zh` and `zh-Hant` from `zh-TW`) → base
175+
* language → variant expansion (the first registered variant) — and
174176
* this asks it the same question about a different set of available locales:
175177
* the translators' set is the bundle's keys, and the service's set is what
176178
* `getLocales()` reports it holds. A second rule living here is the defect,

‎packages/plugins/plugin-auth/src/organization-mfa-enrollment-guard.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,12 @@
7474
* Both answers are in the caller's language: the locale the request asked for
7575
* (`Accept-Language`, carried as `ExecutionContext.locale` and handed to every
7676
* hook as `ctx.session.locale`), matched against the four the copy carries
77-
* with core's `resolveLocale` — exact, then by language (`zh` reaches
78-
* `zh-CN`), else English. A write with no caller locale (a system write, a
77+
* with core's `resolveLocale`, which is `resolveLocaleAmong`'s five steps
78+
* (exact, case-insensitive, platform locale table language, so `zh` reaches
79+
* `zh-CN`; base language; first registered variant), else English. The
80+
* table step only adds answers for the table's aliases: the copy carries no
81+
* `zh-Hant`, so `zh-TW` still reads `zh-CN`. A write with no caller
82+
* locale (a system write, a
7983
* seed) reads English. This is the same path the global switch's refusal takes
8084
* (`mfa-required-enrollment-predicate.ts`).
8185
*
@@ -234,7 +238,7 @@ export const ORGANIZATION_MFA_REQUIRED_NOTICE_COPY: Readonly<Record<CopyLocale,
234238

235239
const COPY_LOCALES = Object.keys(ORGANIZATION_MFA_REFUSAL_COPY);
236240

237-
/** The copy language for a caller's locale: exact, then by language, else English. */
241+
/** The copy language for a caller's locale: `resolveLocaleAmong`'s five steps, else English. */
238242
function copyLocaleFor(locale: string | undefined): CopyLocale {
239243
const picked = typeof locale === 'string' && locale ? resolveLocale(locale, COPY_LOCALES) : undefined;
240244
return (picked ?? 'en') as CopyLocale;

‎packages/plugins/plugin-email/src/template-loader.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,8 +172,10 @@ export function createRegistryEmailTemplateLoader(
172172
};
173173

174174
// [#28880] The bundle's tags, for the service's fold (module-private key,
175-
// not on `TemplateLoader`): sorted, so variant expansion's "first match" is
176-
// the lowest tag, whatever order the list carries.
175+
// not on `TemplateLoader`): sorted, so each first-match step of
176+
// `resolveLocaleAmong` (the table language's first mapped tag, variant
177+
// expansion's first variant) answers the lowest tag, whatever order the
178+
// list carries.
177179
const bundleLocales: TemplateBundleLocales = async (name) =>
178180
[...new Set((await readBundle(name)).map(itemLocale))].sort();
179181

‎packages/services/service-automation/src/engine.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,10 @@ import { summarizeRun } from './run-summary.js';
7272
// interpolating it into the log message. See ./thrown-cause-diagnostics.ts.
7373
import { describeThrownForLog } from './thrown-cause-diagnostics.js';
7474
import { defaultEnvCredentialResolver, type CredentialResolver } from './credential-resolver.js';
75-
// [#22450] The one bundle-locale negotiation rule (exact → case-insensitive →
76-
// base language → variant expansion), asked about the locales the i18n service
75+
// [#22450] The one bundle-locale negotiation rule (`resolveLocaleAmong`'s five
76+
// steps: exact → case-insensitive → platform locale table language, so `zh-TW`
77+
// reaches `zh-Hant` → base language → variant expansion), asked about the
78+
// locales the i18n service
7779
// holds — the same question objectql's `negotiatedMessageLocale` asks it.
7880
import { resolveBundleLocale, type FlowTerminalMessageKey } from '@objectstack/spec/system';
7981
// [#25222] The registration-time checks' bodies, and the declarations only

0 commit comments

Comments
 (0)