From e5703af2ca0beaf87f8fdd3b07430a74e0a05bdd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 22:00:23 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_016WV4cytQjG9X1CrCgytUM5 --- content/docs/ui/translations.mdx | 9 +++++++-- .../platform-checklist/areas/integration-system.json | 12 +++++++++--- .../src/engine-validation-locale-negotiation.test.ts | 6 ++++-- packages/objectql/src/engine-validation-messages.ts | 6 ++++-- .../src/organization-mfa-enrollment-guard.ts | 10 +++++++--- packages/plugins/plugin-email/src/template-loader.ts | 6 ++++-- packages/services/service-automation/src/engine.ts | 6 ++++-- 7 files changed, 39 insertions(+), 16 deletions(-) diff --git a/content/docs/ui/translations.mdx b/content/docs/ui/translations.mdx index 4269bfa009f..fe508d05e3e 100644 --- a/content/docs/ui/translations.mdx +++ b/content/docs/ui/translations.mdx @@ -174,8 +174,13 @@ Per request, in this order: 2. A `?locale=` query parameter 3. The stack's `defaultLocale` -Within the bundle, matching walks: exact (`zh-CN`) → case-insensitive → -**base language** (`zh-CN` → `zh`) → variant expansion (`zh` → `zh-CN`). If +Within the bundle, matching walks `resolveLocaleAmong`'s five steps +(`@objectstack/spec/system`): exact (`zh-CN`) → case-insensitive → the +**platform locale table** language (`zh-TW` / `zh-HK` / `zh-MO` → `zh-Hant`, +`zh` → `zh-CN`) → **base language** (`zh-CN` → `zh`) → variant expansion +(`es-MX` → `es-ES`, the first registered variant). The table step only adds +answers for the table's aliases: a bundle with no `zh-Hant` still serves +`zh-TW` its `zh-CN` strings. If nothing matches, the request walks the stack's **declared** fallback locale (`fallbackLocale`, else `defaultLocale`), and finally the literal label on the metadata. **Translation lookup never throws** — a missing string degrades to diff --git a/docs/qa/platform-checklist/areas/integration-system.json b/docs/qa/platform-checklist/areas/integration-system.json index 1d4984a4750..0774f4ff2fe 100644 --- a/docs/qa/platform-checklist/areas/integration-system.json +++ b/docs/qa/platform-checklist/areas/integration-system.json @@ -897,7 +897,7 @@ "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", "since": "v15", "status": "active", - "revision": 15, + "revision": 16, "priority": "P2", "surface": "mixed", "fixtures": { @@ -954,9 +954,9 @@ "evidence": "the error" }, { - "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)", + "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)", "oracle": "log", - "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", + "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", "evidence": "the two renders" }, { @@ -1096,6 +1096,12 @@ "date": "2026-11-04", "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", "ref": "#30129" + }, + { + "revision": 16, + "date": "2026-11-05", + "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", + "ref": "#30617" } ] }, diff --git a/packages/objectql/src/engine-validation-locale-negotiation.test.ts b/packages/objectql/src/engine-validation-locale-negotiation.test.ts index 33f5854072a..1d3c3e9b534 100644 --- a/packages/objectql/src/engine-validation-locale-negotiation.test.ts +++ b/packages/objectql/src/engine-validation-locale-negotiation.test.ts @@ -9,8 +9,10 @@ import { SchemaRegistry } from './registry'; * #15757 — ONE negotiation rule, not two. * * `@objectstack/spec`'s `resolveBundleLocale` already decides which bundle a - * requested tag reaches (exact → case-insensitive → base language → VARIANT - * expansion, which is where a bare `zh` reaches a `zh-CN` bundle). `pickData` + * requested tag reaches (`resolveLocaleAmong`'s five steps: exact → + * case-insensitive → platform locale table language, which is where a bare + * `zh` reaches a `zh-CN` bundle → base language → VARIANT expansion, which + * is where a `de-AT` reaches a `de-DE` bundle). `pickData` * calls it, and every document translator — `translateObject`, * `translateView`, `translateDataset`, … — goes through `pickData`. * diff --git a/packages/objectql/src/engine-validation-messages.ts b/packages/objectql/src/engine-validation-messages.ts index 480261045d2..499a0f0a28d 100644 --- a/packages/objectql/src/engine-validation-messages.ts +++ b/packages/objectql/src/engine-validation-messages.ts @@ -169,8 +169,10 @@ function packagedRuleLookup( * * ⛔ The rule is NOT re-implemented here. `resolveBundleLocale` * (`@objectstack/spec/system`) is the single negotiation rule the document - * translators already run — exact → case-insensitive → base language → - * variant expansion, which is the step that reaches `zh-CN` from `zh` — and + * translators already run — `resolveLocaleAmong`'s five steps: exact → + * case-insensitive → platform locale table language (`platformLocaleTagOf`, + * which reaches `zh-CN` from `zh` and `zh-Hant` from `zh-TW`) → base + * language → variant expansion (the first registered variant) — and * this asks it the same question about a different set of available locales: * the translators' set is the bundle's keys, and the service's set is what * `getLocales()` reports it holds. A second rule living here is the defect, diff --git a/packages/plugins/plugin-auth/src/organization-mfa-enrollment-guard.ts b/packages/plugins/plugin-auth/src/organization-mfa-enrollment-guard.ts index 862fa2ec9e9..129243277e5 100644 --- a/packages/plugins/plugin-auth/src/organization-mfa-enrollment-guard.ts +++ b/packages/plugins/plugin-auth/src/organization-mfa-enrollment-guard.ts @@ -74,8 +74,12 @@ * Both answers are in the caller's language: the locale the request asked for * (`Accept-Language`, carried as `ExecutionContext.locale` and handed to every * hook as `ctx.session.locale`), matched against the four the copy carries - * with core's `resolveLocale` — exact, then by language (`zh` reaches - * `zh-CN`), else English. A write with no caller locale (a system write, a + * with core's `resolveLocale`, which is `resolveLocaleAmong`'s five steps + * (exact, case-insensitive, platform locale table language, so `zh` reaches + * `zh-CN`; base language; first registered variant), else English. The + * table step only adds answers for the table's aliases: the copy carries no + * `zh-Hant`, so `zh-TW` still reads `zh-CN`. A write with no caller + * locale (a system write, a * seed) reads English. This is the same path the global switch's refusal takes * (`mfa-required-enrollment-predicate.ts`). * @@ -234,7 +238,7 @@ export const ORGANIZATION_MFA_REQUIRED_NOTICE_COPY: Readonly [...new Set((await readBundle(name)).map(itemLocale))].sort(); diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index 660a2912efa..12b95f6db2f 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -72,8 +72,10 @@ import { summarizeRun } from './run-summary.js'; // interpolating it into the log message. See ./thrown-cause-diagnostics.ts. import { describeThrownForLog } from './thrown-cause-diagnostics.js'; import { defaultEnvCredentialResolver, type CredentialResolver } from './credential-resolver.js'; -// [#22450] The one bundle-locale negotiation rule (exact → case-insensitive → -// base language → variant expansion), asked about the locales the i18n service +// [#22450] The one bundle-locale negotiation rule (`resolveLocaleAmong`'s five +// steps: exact → case-insensitive → platform locale table language, so `zh-TW` +// reaches `zh-Hant` → base language → variant expansion), asked about the +// locales the i18n service // holds — the same question objectql's `negotiatedMessageLocale` asks it. import { resolveBundleLocale, type FlowTerminalMessageKey } from '@objectstack/spec/system'; // [#25222] The registration-time checks' bodies, and the declarations only