Skip to content

Commit e5703af

Browse files
committed
docs: name resolveLocaleAmong's five-step ladder where comments still restate four steps
PR #30601 put both server locale resolvers on resolveLocaleAmong (exact, case-insensitive, platform locale table language, base language, first registered variant). Comments in objectql, service-automation, plugin-auth and plugin-email, the translations docs page and the email-template-render checklist item still listed the old four steps. They now name the resolver and its five steps, and say the table step only adds answers for the table's aliases. Comment, docs and checklist text only; no behaviour change. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4cytQjG9X1CrCgytUM5
1 parent b77c160 commit e5703af

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)