Skip to content

Commit 7f4258f

Browse files
docs(spec): the email template locale docs describe the ladder #28880 landed (#29014)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9T4EekKNS3mPkuvkK2fCJrT)_ Fixes #28896 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:spec 里讲邮件模板 locale 解析的说明文字(`SendTemplateInput` 两段注释、`email-template.zod.ts` 的常量与 `locale` 注释及对外 JSON schema 的 describe、`defineStack` 缺 `en-US` 行时的提醒文字)改成 #28880 落地后的真实规则;只改文字,不改 schema、不改提醒何时触发。 - 为什么改:#28880 之后内置模板加载器会按 BCP-47 折叠(`en-GB`→`en`、`zh-TW`→`zh-CN`),再试栈声明的 fallback,再试 `en-US`;旧文字还说“精确匹配、只重试 `en-US`、不折叠”,作者照着写会做错判断。 - 风险与回滚:只有注释、describe 和一条 `console.warn` 文案,运行行为不变;回滚即 revert 本 PR。 - 先看:`packages/spec/src/system/email-template.zod.ts`(describe 是对外发布的文字)、`packages/spec/src/contracts/email-service.ts`(`SendTemplateInput.locale` 是唯一完整写出两条阶梯的地方)、`packages/spec/src/stack.zod.ts`(提醒文案)。 - 两分钟验证:`pnpm --filter @objectstack/spec exec vitest run src/stack-email-template-locale-floor.test.ts`,预期全绿(现有钉住这段文字的断言都还成立)。 - 需要你做的:无,席位落地(落地前需一次契约复审档复核)。 ## What changes Text only. No schema shape, accept set, export or warning trigger changes. `en` and `en-US` stay two rows of one bundle; only how the resolver picks the row is described anew, as landed in #28917 (`e0dc0771ab`). - **`packages/spec/src/contracts/email-service.ts`**: `SendTemplateInput.template` now says the name is matched exactly and every rung reads one row at one tag exactly. `SendTemplateInput.locale` carries both ladders as the code has them (`EmailService.resolveAndRenderTemplate`): a named locale over the built-in registry loader folds by `resolveBundleLocale` (exact, case-insensitive, base language, variant expansion, first match in ascending tag order), then the declared fallback (`II18nService.getFallbackLocale()`: `i18n.fallbackLocale`, else `defaultLocale`) folded the same way, then `en-US`, then `TEMPLATE_NOT_FOUND`; a rung resolving to a tag already tried is skipped; a replacement `TemplateLoader` keeps the exact ladder; a no-locale call keeps `en-US` then the lowest tag. The two `TEMPLATE_NOT_FOUND` error lines (`sendTemplate`, `renderTemplate`) no longer say `(name, locale|en-US)`. - **`packages/spec/src/system/email-template.zod.ts`**: the `EMAIL_TEMPLATE_FLOOR_LOCALE` docblock (the floor is now the rung that depends on neither the fold nor the declared fallback), the `locale` docblock, and the `locale` `.describe()` served in the JSON schema. - **`packages/spec/src/stack.zod.ts`** (bounded in-place fix, same stale-ladder class, not in the card's list): `warnEmailTemplateLocaleFloor`'s docblock and its `console.warn` text said "matches exactly and retries only `en-US` — there is no language-subtag folding". The text now describes the fold, the declared fallback and the exact ladder a replacement loader keeps. Which bundles it fires on is unchanged (see Acceptance notes). - **`packages/spec/src/stack-email-template-locale-floor.test.ts`**: the file's header comment only (it stated the old ladder in the present tense). No assertion changed. - Regenerated `content/docs/references/system/email-template.mdx` (the `locale` row) and a spec `patch` changeset. ## Tests Forced test edits: none. Every existing pin on this wording still holds (`'en-US'`, `TEMPLATE_NOT_FOUND`, `/NAMES a locale/`, `/lowest locale tag/` in the describe; `'no fallback floor'`, `"none tagged 'en-US'"`, `"carries rows for 'en-GB'"` in the warning). <details><summary>Readings at 862c7bd</summary> - `pnpm --filter @objectstack/spec test` (under `os-verify-lock`): Test Files 789 passed (789), Tests 23325 passed | 1 todo. - Consumer: `pnpm --filter @objectstack/plugin-email test` after `pnpm --filter '@objectstack/plugin-email^...' build`: Test Files 47 passed (47), Tests 764 passed (764), `VERDICT command-exit 0`. This includes `template-locale-fold.test.ts` and `template-locale-resolution.test.ts`, which pin the ladder this text describes. - `pnpm --filter @objectstack/spec check:generated`: 1 of 14 stale (`check:docs`) before regeneration; `--fix` re-check `✓ check:docs`. - The `repo` vitest project (`email-template-floor-locale-parity.pin.test.ts`) is CI's (`Test Core`); not run locally, the file is unchanged. </details> ## Acceptance notes - **The floor warning now also fires on bundles the built-in loader serves.** `THE_TRAP` in the floor test (`defaultLocale: 'en'`, `fallbackLocale: 'en'`, an `en` row, no `en-US` row) reaches every recipient over the built-in loader on a host whose i18n service answers that fallback, yet still warns. The warning still names a real gap (a replacement `TemplateLoader`, or a host that declares no fallback), and its text now says so. Whether its trigger should change is a behaviour question on an authoring diagnostic, left to the seat. - **Text outside `packages/spec` that still states the exact ladder** (not touched here): `content/docs/automation/email-templates.mdx` L217–233 (hand-written docs); `packages/metadata-core/src/item-key-discriminators.ts` L13–19 (a code docblock citing the old ladder); `docs/qa/platform-checklist/areas/integration-system.json` L957 (a checklist clause asserting "EXACT match with no language-subtag folding"). - Card vs code: they agree. One detail the card did not state: a replacement `TemplateLoader` gets neither the fold nor the declared fallback rung (pinned in `template-locale-fold.test.ts`), and the text says so. ## Verification <details><summary>Gate list</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at 862c7bd derived 106 commands; each was run with stdin closed and its exit captured before any pipe. 103 exited 0 on the first sweep; three exited 3 (prerequisite not met, nothing measured) and exited 0 after their prerequisite: `check-plugin-teardown-shape.mjs --self-test` after `git fetch --depth=1 origin 621a487` (its pinned fixture), `check:skill-examples` and `check:dual-build-cjs-loads` after `turbo run build --filter='./packages/**'`. `--ran` reconciles 106 derived, 106 run, 0 NOT-MEASURED, 0 UNRUN. `pnpm --filter @objectstack/spec typecheck`: `VERDICT command-exit 0`. `origin/main` moved 15 commits since the branch point (`135054eb`); none touches a file this PR changes, and `git merge-tree` merges clean, so main was not merged in before opening. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01ADHkmRewLso9227jPeGYag)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0a41075 commit 7f4258f

6 files changed

Lines changed: 166 additions & 96 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
docs(spec): the email template locale docs describe the ladder #28880 landed (#28896)
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The `SendTemplateInput.template` / `.locale` docs, the `EMAIL_TEMPLATE_FLOOR_LOCALE` docblock, the `EmailTemplateDefinitionSchema.locale` docblock and its served `describe`, and the `defineStack` warning for a bundle with no `en-US` row said the resolver matches the named locale exactly and retries only `en-US`, with no language-subtag folding. Since #28880, over the built-in template loader, a call that names a locale folds it against the template's own locales (exact, case-insensitive, base language, variant expansion), then tries the stack's declared fallback locale (`i18n.fallbackLocale`, else `defaultLocale`) folded the same way, then `en-US`, then fails with `TEMPLATE_NOT_FOUND`. The texts now say so, and say that a replacement `TemplateLoader` keeps the exact ladder and that a call naming no locale keeps its own ladder (`en-US`, then the bundle's lowest tag).
11+
12+
Text only: no schema shape, accept set or warning trigger changes. `en` and `en-US` are still two rows of one bundle.

‎content/docs/references/system/email-template.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ const result = EmailTemplateDefinitionSchema.parse(data);
4848
| **name** | `string` | ✅ | Template identifier (dotted snake_case) |
4949
| **label** | `string` | ✅ | Display label |
5050
| **category** | `never` | optional | [REMOVED] `email_template.category` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — nothing read it: the Studio listing never offered the filter facet it promised, and delivery never depended on it. Delete the key. A template is found by its `name`, so group templates with a dotted namespace prefix instead (`auth.password_reset`, `crm.welcome`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
51-
| **locale** | `string` | optional (default: `"en-US"`) | BCP-47 locale (e.g. en-US, zh-CN) — the bundle key the resolver matches EXACTLY. A call that NAMES a locale gets exactly one retry rung, the literal `en-US`, with no language-subtag folding: `en` and `en-US` are different ROWS of one bundle and neither reaches the other, so a bundle with no `en-US` row has no fallback floor for those calls and every recipient locale it does not carry a row for raises TEMPLATE_NOT_FOUND, which is permanent — the delivery dead-letters with no retry. A call naming NO locale is the other case and does not dead-letter: it drops to the bundle's lowest locale tag and renders that silently. Your stack's own `i18n.defaultLocale` is the wrong tag here unless it is spelled `en-US`. |
51+
| **locale** | `string` | optional (default: `"en-US"`) | BCP-47 locale (e.g. en-US, zh-CN) — the bundle key. Each rung of the resolver reads one row at one tag exactly, so `en` and `en-US` are different ROWS of one bundle. A call that NAMES a locale, over the built-in template loader, folds it against the bundle's tags (exact, case-insensitive, base language, then variant expansion: `en-GB` reaches `en`, `zh-TW` reaches `zh-CN`), then folds the stack's declared fallback locale (`i18n.fallbackLocale`, else `defaultLocale`) the same way, then tries the literal `en-US`; a replacement template loader matches the tag exactly and retries only `en-US`. `en-US` is the floor every loader honours: when no rung lands on a row the call raises TEMPLATE_NOT_FOUND, which is permanent — the delivery dead-letters with no retry. A call naming NO locale folds nothing and does not dead-letter: it starts at `en-US` and otherwise drops to the bundle's lowest locale tag and renders that silently. Tag the English row `en-US` so every loader has the floor. |
5252
| **subject** | `string` | ✅ | Subject template. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over the send's `data`, with at most one formatter per hole (a second, chained formatter is refused). A formatter is one word, optionally followed by `:` and its argument (text that is not, such as `date-long`, is refused), and it is one the template engine knows (an unknown name, such as a misspelled `curency`, is refused, naming the known formatters). A `currency` argument is either a literal ISO 4217 code in three capital letters (`currency:EUR`) or, in a mail template only, `@` followed by a path into the send's data whose value is such a code (`currency:@currency_code`). |
5353
| **bodyHtml** | `string` | ✅ | HTML body template. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over the send's `data`, with at most one formatter per hole (a second, chained formatter is refused). A formatter is one word, optionally followed by `:` and its argument (text that is not, such as `date-long`, is refused), and it is one the template engine knows (an unknown name, such as a misspelled `curency`, is refused, naming the known formatters). A `currency` argument is either a literal ISO 4217 code in three capital letters (`currency:EUR`) or, in a mail template only, `@` followed by a path into the send's data whose value is such a code (`currency:@currency_code`). |
5454
| **bodyText** | `string` | optional | Plain-text body template (auto-derived from HTML when omitted). Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over the send's `data`, with at most one formatter per hole (a second, chained formatter is refused). A formatter is one word, optionally followed by `:` and its argument (text that is not, such as `date-long`, is refused), and it is one the template engine knows (an unknown name, such as a misspelled `curency`, is refused, naming the known formatters). A `currency` argument is either a literal ISO 4217 code in three capital letters (`currency:EUR`) or, in a mail template only, `@` followed by a path into the send's data whose value is such a code (`currency:@currency_code`). |

‎packages/spec/src/contracts/email-service.ts‎

Lines changed: 43 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -171,28 +171,50 @@ export interface SendEmailResult {
171171
export interface SendTemplateInput {
172172
/**
173173
* Template identifier (the `email_template` item's `name`), e.g.
174-
* `'auth.password_reset'`. There is no "best match" and no language-subtag
175-
* folding: the locale member is resolved by the exact ladder documented on
176-
* `locale` below.
174+
* `'auth.password_reset'`. The name is matched exactly; only the locale
175+
* member is resolved, by the ladder documented on `locale` below. Every
176+
* rung of it loads one row at one tag exactly, so `en` and `en-US` stay two
177+
* distinct rows of the bundle: folding chooses which row is read, it never
178+
* merges two.
177179
*/
178180
template: string;
179181
/** Envelope recipients. */
180182
to: EmailAddress | EmailAddress[];
181183
/** Render context — placeholders in subject/body are resolved against this object. */
182184
data?: Record<string, unknown>;
183185
/**
184-
* Preferred BCP-47 locale (e.g. user's locale). Falls back to `'en-US'`.
186+
* Preferred BCP-47 locale (e.g. user's locale).
185187
*
186-
* Resolution is exact, then default, then deterministic — never "whichever
187-
* row the store yields first" (#7731):
188+
* Resolution is deterministic, never "whichever row the store yields first"
189+
* (#7731). A call that NAMES a locale, over the built-in registry template
190+
* loader (the one `EmailServicePlugin` wires), walks (#28880):
188191
*
189-
* 1. this locale, matched exactly (no language-only prefix matching: `zh`
190-
* does not resolve `zh-CN`);
191-
* 2. `'en-US'` — which is also where a call that omits `locale` STARTS, so
192-
* "no locale" means the default rather than an arbitrary member;
193-
* 3. only for a call that named no locale, and only when the bundle has no
194-
* `en-US` member at all: its lowest locale tag, so a single-locale
195-
* bundle keeps rendering and does so identically on every boot.
192+
* 1. this locale, folded against the locale tags the template's bundle
193+
* carries by `resolveBundleLocale` (`@objectstack/spec/system`, the
194+
* BCP-47 fold plugin-email imports rather than copying): exact, then
195+
* case-insensitive (`zh-cn` → `zh-CN`), then base language (`en-GB` →
196+
* `en`), then variant expansion (`zh` and `zh-TW` → `zh-CN`), the first
197+
* match in ascending tag order;
198+
* 2. the stack's declared fallback locale, as
199+
* `II18nService.getFallbackLocale()` answers it (`i18n.fallbackLocale`,
200+
* else `defaultLocale`), folded the same way. A host whose i18n service
201+
* declares none, or that has no i18n service, skips this rung;
202+
* 3. `'en-US'`, exactly;
203+
* 4. none of them lands on a row: `TEMPLATE_NOT_FOUND`, which is permanent.
204+
*
205+
* A rung that resolves to a tag already tried is skipped. A replacement
206+
* `TemplateLoader` keeps the exact ladder: this locale matched exactly, then
207+
* `'en-US'`, then `TEMPLATE_NOT_FOUND`, with no fold and no declared
208+
* fallback.
209+
*
210+
* A call that omits `locale` keeps its own ladder. The service's part of it
211+
* holds over any loader: no fold, no declared fallback, and `'en-US'` first,
212+
* exactly, so "no locale" means the default rather than an arbitrary member.
213+
* When that misses, the service asks the loader for its own no-locale
214+
* answer. The built-in registry loader answers the bundle's `en-US` member,
215+
* else its lowest locale tag, so a single-locale bundle keeps rendering and
216+
* does so identically on every boot; a replacement `TemplateLoader` gives
217+
* its own deterministic best answer for the name.
196218
*/
197219
locale?: string;
198220
/**
@@ -279,8 +301,8 @@ export interface RenderTemplateInput {
279301
currency?: string;
280302
/**
281303
* Preferred BCP-47 locale. Resolution is the SAME ladder as
282-
* {@link SendTemplateInput.locale} — exact match, then `'en-US'`, then
283-
* (no-locale calls only) the bundle's deterministic single-locale answer.
304+
* {@link SendTemplateInput.locale}, both the named-locale and the no-locale
305+
* one.
284306
*/
285307
locale?: string;
286308
/**
@@ -330,8 +352,9 @@ export interface IEmailService {
330352
* Locale resolution is the ladder on {@link SendTemplateInput.locale}.
331353
*
332354
* Errors:
333-
* - `TEMPLATE_NOT_FOUND` — no template matches `(name, locale|en-US)`, and
334-
* (for a call that named no locale) the name carries no members at all.
355+
* - `TEMPLATE_NOT_FOUND` — no rung of the locale ladder lands on a row of
356+
* the name's bundle; for a call that named no locale, the name carries no
357+
* members at all.
335358
* - `TEMPLATE_INACTIVE` — the template exists but `active=false`.
336359
* - `MISSING_VARIABLES` — declared `required` variables absent from `data`.
337360
* - `TEMPLATE_INVALID` — the stored template has a hole the authoring rules
@@ -361,8 +384,9 @@ export interface IEmailService {
361384
* Locale resolution is the ladder on {@link SendTemplateInput.locale}.
362385
*
363386
* Errors (same vocabulary as `sendTemplate`, since resolution is shared):
364-
* - `TEMPLATE_NOT_FOUND` — no template matches `(name, locale|en-US)`, and
365-
* (for a call that named no locale) the name carries no members at all.
387+
* - `TEMPLATE_NOT_FOUND` — no rung of the locale ladder lands on a row of
388+
* the name's bundle; for a call that named no locale, the name carries no
389+
* members at all.
366390
* - `TEMPLATE_INACTIVE` — the template exists but `active=false`.
367391
* - `MISSING_VARIABLES` — declared `required` variables absent from `data`.
368392
* - `TEMPLATE_INVALID` — the stored template has a hole the authoring rules

‎packages/spec/src/stack-email-template-locale-floor.test.ts‎

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,17 @@
99
// the resolver then answered `TEMPLATE_NOT_FOUND` — which classifies
1010
// PERMANENT, so the delivery dead-letters with no retry — for `de-DE` and for
1111
// the literal `en-US`. The identical bundle with its English row tagged
12-
// `en-US` delivered for `de-DE`. `sendTemplate` matches `(name, locale)`
13-
// exactly and, for a call that NAMES a locale, retries exactly one rung — the
14-
// literal `en-US` — and stops; there is no language-subtag folding. A call that
15-
// names NO locale is the other case and does NOT dead-letter: it starts at
16-
// `en-US` by name and, when the bundle carries no `en-US` row, drops to that
17-
// bundle's lowest locale tag and renders it silently. That ladder's shape is a
18-
// settled ruling this change deliberately does not touch. The remedy is the
19-
// bundle, so the diagnostic is where the author is standing.
12+
// `en-US` delivered for `de-DE`. At that time `sendTemplate` matched
13+
// `(name, locale)` exactly and retried only the literal `en-US`. Since #28880,
14+
// over the built-in template loader, a call that NAMES a locale folds it
15+
// against the bundle's tags and then tries the stack's declared fallback
16+
// locale folded the same way, before `en-US`; a replacement `TemplateLoader`
17+
// keeps the exact ladder. `en-US` stays the one rung every loader retries,
18+
// which is the floor this diagnostic names. A call that names NO locale is the
19+
// other case and does NOT dead-letter: it starts at `en-US` by name and, when
20+
// the bundle carries no `en-US` row, drops to that bundle's lowest locale tag
21+
// and renders it silently. The remedy is the bundle, so the diagnostic is
22+
// where the author is standing.
2023
//
2124
// These pin the diagnostic ADVISORY: every case asserts the parse still
2225
// succeeds and the stack comes back unchanged. The diagnostic narrows what

‎packages/spec/src/stack.zod.ts‎

Lines changed: 32 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5929,12 +5929,18 @@ const warnedEmailTemplateFloors = new Set<string>();
59295929
*
59305930
* ## What goes wrong without this
59315931
*
5932-
* `IEmailService.sendTemplate` matches `(name, locale)` EXACTLY and, for a call
5933-
* that NAMES a locale, retries exactly one rung — the literal `en-US` — and
5934-
* stops. There is no language-subtag folding, so a bundle whose English row is
5935-
* tagged `en` is unreachable from `en-US` and from every other tag it does not
5936-
* itself carry: each such delivery raises `TEMPLATE_NOT_FOUND`, which
5937-
* classifies **permanent**, so it dead-letters with no retry. `sys_user.locale`
5932+
* `IEmailService.sendTemplate` reads one row at one tag exactly. For a call
5933+
* that NAMES a locale, over the built-in registry template loader, it folds
5934+
* that locale against the bundle's tags (`resolveBundleLocale`: exact,
5935+
* case-insensitive, base language, variant expansion), then the stack's
5936+
* declared fallback locale (`i18n.fallbackLocale`, else `defaultLocale`) the
5937+
* same way, then the literal `en-US` (#28880); a replacement `TemplateLoader`
5938+
* matches the named tag exactly and retries only `en-US`. So a bundle with no
5939+
* `en-US` row is reachable from a recipient tag only when that tag or the
5940+
* declared fallback folds to one of its rows, and behind a replacement loader
5941+
* only from the tags it carries exactly. Every other delivery raises
5942+
* `TEMPLATE_NOT_FOUND`, which classifies **permanent**, so it dead-letters
5943+
* with no retry. `sys_user.locale`
59385944
* is user-editable free-text BCP-47 and is NOT constrained to
59395945
* `supportedLocales`, so the locales that can reach the lookup are not the ones
59405946
* the author enumerated — a recipient can break their own mail by setting a
@@ -5944,15 +5950,17 @@ const warnedEmailTemplateFloors = new Set<string>();
59445950
* starts at `en-US` by name and, when the bundle carries no `en-US` row, drops
59455951
* to that bundle's lowest locale tag and renders it silently. So one floorless
59465952
* bundle dead-letters the recipients whose locale was named and quietly fills
5947-
* for the ones whose was not. The full three-rung ladder is on
5953+
* for the ones whose was not. The full ladders are on
59485954
* `SendTemplateInput.locale` in `packages/spec/src/contracts/email-service.ts`
5949-
* and is not restated here.
5955+
* and are not restated here.
59505956
*
5951-
* ⭐ The trap is that the author does the CONSISTENT thing: a stack declaring
5952-
* `defaultLocale: 'en'` whose English row says `locale: 'en'` agrees with
5953-
* itself everywhere, validates, builds and installs clean, and still ships a
5954-
* bundle with no floor. The stack's own declared default locale is the wrong
5955-
* answer whenever it is not spelled `en-US`.
5957+
* ⭐ The CONSISTENT thing an author writes, a stack declaring
5958+
* `defaultLocale: 'en'` whose English row says `locale: 'en'`, validates,
5959+
* builds and installs clean. Over the built-in loader, on a host whose i18n
5960+
* service answers that declared fallback, it reaches every recipient; behind
5961+
* a replacement loader, or on a host that declares no fallback, it still has
5962+
* no floor. This report reads the bundle alone, so it fires for that shape
5963+
* whichever host serves it: `en-US` is the one rung every loader retries.
59565964
*
59575965
* ## Posture — advisory, and deliberately so
59585966
*
@@ -6030,15 +6038,17 @@ function warnEmailTemplateLocaleFloor(data: ObjectStackDefinition): void {
60306038
warnedEmailTemplateFloors.add(key);
60316039
console.warn(
60326040
`defineStack: emailTemplates '${name}' carries rows for ${declared.map((t) => `'${t}'`).join(', ')} ` +
6033-
`but none tagged '${EMAIL_TEMPLATE_FLOOR_LOCALE}', so this bundle has no fallback floor for a ` +
6034-
`send that names a locale. sendTemplate matches (name, locale) exactly and retries only the ` +
6035-
`literal '${EMAIL_TEMPLATE_FLOOR_LOCALE}' — there is no language-subtag folding, so every ` +
6036-
`recipient locale this bundle does not carry a row for raises TEMPLATE_NOT_FOUND, which is ` +
6037-
`permanent (dead-letter, no retry). A send naming NO locale does not fail: it drops to this ` +
6038-
`bundle's lowest tag and renders that silently, so one gap is loud for some recipients and ` +
6039-
`invisible for others. Your stack's own i18n.defaultLocale is the wrong tag here unless ` +
6040-
`it is spelled '${EMAIL_TEMPLATE_FLOOR_LOCALE}': tag the English row '${EMAIL_TEMPLATE_FLOOR_LOCALE}' ` +
6041-
`and keep the other tags beside it.`,
6041+
`but none tagged '${EMAIL_TEMPLATE_FLOOR_LOCALE}', so this bundle has no fallback floor that ` +
6042+
`holds for every send naming a locale. With the built-in template loader such a send folds its ` +
6043+
`locale against these rows (exact, case-insensitive, base language, variant expansion), then ` +
6044+
`tries the stack's declared fallback locale (i18n.fallbackLocale, else defaultLocale) folded ` +
6045+
`the same way; a replacement TemplateLoader matches exactly and retries only ` +
6046+
`'${EMAIL_TEMPLATE_FLOOR_LOCALE}'. When no rung lands on a row the send raises ` +
6047+
`TEMPLATE_NOT_FOUND, which is permanent (dead-letter, no retry). A send naming NO locale does ` +
6048+
`not fail: it drops to this bundle's lowest tag and renders that silently, so one gap is loud ` +
6049+
`for some recipients and invisible for others. '${EMAIL_TEMPLATE_FLOOR_LOCALE}' is the one rung ` +
6050+
`every loader retries: tag the English row '${EMAIL_TEMPLATE_FLOOR_LOCALE}' and keep the other ` +
6051+
`tags beside it.`,
60426052
);
60436053
}
60446054
}

0 commit comments

Comments
 (0)