Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions content/docs/ui/translations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 9 additions & 3 deletions docs/qa/platform-checklist/areas/integration-system.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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"
},
{
Expand Down Expand Up @@ -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"
}
]
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
*
Expand Down
6 changes: 4 additions & 2 deletions packages/objectql/src/engine-validation-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
*
Expand Down Expand Up @@ -234,7 +238,7 @@ export const ORGANIZATION_MFA_REQUIRED_NOTICE_COPY: Readonly<Record<CopyLocale,

const COPY_LOCALES = Object.keys(ORGANIZATION_MFA_REFUSAL_COPY);

/** The copy language for a caller's locale: exact, then by language, else English. */
/** The copy language for a caller's locale: `resolveLocaleAmong`'s five steps, else English. */
function copyLocaleFor(locale: string | undefined): CopyLocale {
const picked = typeof locale === 'string' && locale ? resolveLocale(locale, COPY_LOCALES) : undefined;
return (picked ?? 'en') as CopyLocale;
Expand Down
6 changes: 4 additions & 2 deletions packages/plugins/plugin-email/src/template-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,8 +172,10 @@ export function createRegistryEmailTemplateLoader(
};

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

Expand Down
6 changes: 4 additions & 2 deletions packages/services/service-automation/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading