From 07ebe1876ad769af1d2fcd2202ee7ec019e4b676 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 14:32:27 +0000 Subject: [PATCH 1/4] docs(spec): scope the email-template locale-floor claims to a named-locale call `email-template.zod.ts` published one rung and "no fallback floor at all"; `contracts/email-service.ts` documents a three-rung ladder whose third rung the runtime and its CI pin both perform. Measured against `EmailService.resolveAndRenderTemplate` and `createSysEmailTemplateLoader`: the three-rung text is correct and the one-rung text is over-broad, so the floor claims are scoped to a call that names a locale and the no-locale rung is stated beside them. Also declares what `warnEmailTemplateLocaleFloor` deliberately does not examine, and corrects the "best-matching locale row" wording the resolver has never implemented. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude --- packages/spec/src/contracts/email-service.ts | 5 +- packages/spec/src/stack.zod.ts | 70 +++++++++++++++---- .../spec/src/system/email-template.zod.ts | 64 +++++++++++------ 3 files changed, 102 insertions(+), 37 deletions(-) diff --git a/packages/spec/src/contracts/email-service.ts b/packages/spec/src/contracts/email-service.ts index 45b2961c6c5..cf03feb471a 100644 --- a/packages/spec/src/contracts/email-service.ts +++ b/packages/spec/src/contracts/email-service.ts @@ -150,8 +150,9 @@ export interface SendEmailResult { export interface SendTemplateInput { /** * Template identifier (matches `sys_email_template.name`), e.g. - * `'auth.password_reset'`. The service picks the best-matching - * locale row (falls back to `en-US`). + * `'auth.password_reset'`. There is no "best match" and no language-subtag + * folding: the locale row is resolved by the exact ladder documented on + * `locale` below. */ template: string; /** Envelope recipients. */ diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index 6a3a622ffc4..a9f4539211d 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -2853,15 +2853,24 @@ const warnedEmailTemplateFloors = new Set(); * * ## What goes wrong without this * - * `IEmailService.sendTemplate` matches `(name, locale)` EXACTLY and retries - * exactly one rung — the literal `en-US`. There is no language-subtag folding, - * so a bundle whose English row is tagged `en` is unreachable from `en-US` and - * from every other tag it does not itself carry: each such delivery raises - * `TEMPLATE_NOT_FOUND`, which classifies **permanent**, so it dead-letters with - * no retry. `sys_user.locale` is user-editable free-text BCP-47 and is NOT - * constrained to `supportedLocales`, so the locales that can reach the lookup - * are not the ones the author enumerated — a recipient can break their own mail - * by setting a legal tag. + * `IEmailService.sendTemplate` matches `(name, locale)` EXACTLY and, for a call + * that NAMES a locale, retries exactly one rung — the literal `en-US` — and + * stops. There is no language-subtag folding, so a bundle whose English row is + * tagged `en` is unreachable from `en-US` and from every other tag it does not + * itself carry: each such delivery raises `TEMPLATE_NOT_FOUND`, which + * classifies **permanent**, so it dead-letters with no retry. `sys_user.locale` + * is user-editable free-text BCP-47 and is NOT constrained to + * `supportedLocales`, so the locales that can reach the lookup are not the ones + * the author enumerated — a recipient can break their own mail by setting a + * legal tag. + * + * ⛔ A call that names NO locale is the other case, and it does not fail: it + * starts at `en-US` by name and, when the bundle carries no `en-US` row, drops + * to that bundle's lowest locale tag and renders it silently. So one floorless + * bundle dead-letters the recipients whose locale was named and quietly fills + * for the ones whose was not. The full three-rung ladder is on + * `SendTemplateInput.locale` in `packages/spec/src/contracts/email-service.ts` + * and is not restated here. * * ⭐ The trap is that the author does the CONSISTENT thing: a stack declaring * `defaultLocale: 'en'` whose English row says `locale: 'en'` agrees with @@ -2884,6 +2893,37 @@ const warnedEmailTemplateFloors = new Set(); * not be reported — the reader below mirrors that default rather than relying * on the call site for it, so the two agree wherever this is called from. * Warn-once per bundle, keyed by name plus the tags it actually carries. + * + * ## What this deliberately does NOT examine + * + * Two shapes leave here silently and both can still ship a floorless bundle. + * They are written down because the summary line above is the only place the + * scope was ever stated, while the hazard section reads as a promise to catch + * every floorless bundle — which this does not do: + * + * 1. **A stack whose `i18n.supportedLocales` is absent or empty.** Measured: + * `i18n` is optional but `supportedLocales` is REQUIRED inside it, so the + * absent arm is reached only by a stack carrying no `i18n` block at all, + * and the empty arm only by a literal `supportedLocales: []`. Either way + * there is nothing to measure "carries rows for this stack's own supported + * locales" against, so the function returns before building anything. + * ⚠️ This early return is not a second scope decision: with no supported + * set every bundle's `declared` list below is empty and shape 2 would skip + * it anyway, so what the return actually buys is not reading `.map` off + * `undefined`. + * 2. **A bundle whose tags are ALL outside `supportedLocales`.** `declared` is + * empty, so the bundle is skipped one line after the floor check + * established that it carries no floor row. A stack supporting `en-US` + * whose bundle is tagged `en` alone is exactly that case: floorless, and + * silent here. + * + * ⚠️ Whether either shape SHOULD warn is the ADR-0049 enforce-or-remove + * question, and it is deliberately not answered here: widening a `defineStack` + * diagnostic is a behaviour change on an authoring surface, which the posture + * note above puts on a scheduled migration rather than behind a lint. What is + * closed is the silence being UNDECLARED — both shapes are pinned in + * `stack-email-template-locale-floor.test.ts` against a warning control, so + * neither can start or stop returning without a test saying so. */ function warnEmailTemplateLocaleFloor(data: ObjectStackDefinition): void { const supported = data.i18n?.supportedLocales; @@ -2914,11 +2954,13 @@ function warnEmailTemplateLocaleFloor(data: ObjectStackDefinition): void { warnedEmailTemplateFloors.add(key); console.warn( `defineStack: emailTemplates '${name}' carries rows for ${declared.map((t) => `'${t}'`).join(', ')} ` + - `but none tagged '${EMAIL_TEMPLATE_FLOOR_LOCALE}', so this bundle has no fallback floor. ` + - `sendTemplate matches (name, locale) exactly and retries only the literal ` + - `'${EMAIL_TEMPLATE_FLOOR_LOCALE}' — there is no language-subtag folding, so every recipient ` + - `locale this bundle does not carry a row for raises TEMPLATE_NOT_FOUND, which is permanent ` + - `(dead-letter, no retry). Your stack's own i18n.defaultLocale is the wrong tag here unless ` + + `but none tagged '${EMAIL_TEMPLATE_FLOOR_LOCALE}', so this bundle has no fallback floor for a ` + + `send that names a locale. sendTemplate matches (name, locale) exactly and retries only the ` + + `literal '${EMAIL_TEMPLATE_FLOOR_LOCALE}' — there is no language-subtag folding, so every ` + + `recipient locale this bundle does not carry a row for raises TEMPLATE_NOT_FOUND, which is ` + + `permanent (dead-letter, no retry). A send naming NO locale does not fail: it drops to this ` + + `bundle's lowest tag and renders that silently, so one gap is loud for some recipients and ` + + `invisible for others. Your stack's own i18n.defaultLocale is the wrong tag here unless ` + `it is spelled '${EMAIL_TEMPLATE_FLOOR_LOCALE}': tag the English row '${EMAIL_TEMPLATE_FLOOR_LOCALE}' ` + `and keep the other tags beside it.`, ); diff --git a/packages/spec/src/system/email-template.zod.ts b/packages/spec/src/system/email-template.zod.ts index c800b79ea1b..915c2e31267 100644 --- a/packages/spec/src/system/email-template.zod.ts +++ b/packages/spec/src/system/email-template.zod.ts @@ -24,14 +24,23 @@ import { strictObject } from '../shared/strict-object'; /** * The one locale tag with standing in an email-template bundle: the schema - * default for {@link EmailTemplateDefinitionSchema}.`locale` AND the sole rung - * `IEmailService.sendTemplate` retries after an exact `(name, locale)` miss. + * default for {@link EmailTemplateDefinitionSchema}.`locale` AND the only rung + * `IEmailService.sendTemplate` retries after an exact `(name, locale)` miss on + * a call that NAMED a locale. * * Named because those two roles are the same fact and authors keep reading it * as neither: the resolver does no language-subtag folding, so this literal — * not the stack's `i18n.defaultLocale`, not a bare `en` — is what makes a * bundle reachable from a recipient locale nobody authored a row for. A bundle - * without a row at this tag has no fallback floor at all. + * without a row at this tag has no fallback floor for any call that names one. + * + * ⛔ That scope is the sentence, not a hedge on it. A call naming NO locale has + * a rung BELOW this constant — the bundle's lowest locale tag — so the same + * floorless bundle still renders for it, silently, in whichever language sorts + * first. Loud refusal and silent fill are selected by the CALL, not by the + * bundle. `SendTemplateInput.locale` in + * `packages/spec/src/contracts/email-service.ts` carries the full three-rung + * ladder and is the one place to read it; this constant is its rung 2. * * ⚠️ `@objectstack/plugin-email` spells the same value as its own * `DEFAULT_TEMPLATE_LOCALE` (that package implements the ladder; this one only @@ -99,20 +108,31 @@ export const EmailTemplateDefinitionSchema = lazySchema(() => strictObject({ /** * IETF BCP-47 locale tag — the second half of the bundle key. * - * Multiple rows sharing one `name` form an i18n bundle. The resolver - * (`IEmailService.sendTemplate`) matches `(name, locale)` **exactly** and - * then retries exactly one rung: the **literal** string - * {@link EMAIL_TEMPLATE_FLOOR_LOCALE}. There is no language-subtag folding - * on that path — `en-US` does not fall back to `en`, and `en` is not - * reachable from `en-US`. + * Multiple rows sharing one `name` form an i18n bundle, so `en` and `en-US` + * are two ROWS of one bundle rather than two bundles — and neither reaches + * the other. The resolver (`IEmailService.sendTemplate`) matches + * `(name, locale)` **exactly**; for a call that NAMES a locale it then + * retries exactly one rung — the **literal** string + * {@link EMAIL_TEMPLATE_FLOOR_LOCALE} — and stops. There is no + * language-subtag folding on that path: `en-US` does not fall back to `en`, + * and `en` is not reachable from `en-US`. + * + * ⚠️ So `en-US` is the bundle's FLOOR for every call that names a locale, + * and a bundle carrying no `en-US` row has none for them: every recipient + * locale the bundle does not itself carry a row for raises + * `TEMPLATE_NOT_FOUND`, which classifies **permanent** — the delivery + * dead-letters with no retry. The reachable locale set is not the set the + * author enumerated either: `sys_user.locale` is user-editable free-text + * BCP-47, unconstrained by the stack's `i18n.supportedLocales`, so a + * recipient can select a legal tag nobody authored. * - * ⚠️ So `en-US` is the bundle's FLOOR, and a bundle carrying no `en-US` row - * has none: every recipient locale the bundle does not itself carry a row - * for raises `TEMPLATE_NOT_FOUND`, which classifies **permanent** — the - * delivery dead-letters with no retry. The reachable locale set is not the - * set the author enumerated either: `sys_user.locale` is user-editable - * free-text BCP-47, unconstrained by the stack's `i18n.supportedLocales`, - * so a recipient can select a legal tag nobody authored. + * ⛔ A call that names NO locale is the OTHER case and does NOT dead-letter. + * It starts at `en-US` by name, and when the bundle carries no `en-US` row + * it drops to the bundle's lowest locale tag and renders that — a silent + * fill, in whichever language sorts first, where the paragraph above + * promises a permanent refusal. The full three-rung ladder lives on + * `SendTemplateInput.locale` in + * `packages/spec/src/contracts/email-service.ts`; do not restate it here. * * ⛔ The stack's own declared `i18n.defaultLocale` is the WRONG tag here * whenever it is not spelled `en-US`. An app that declares @@ -123,11 +143,13 @@ export const EmailTemplateDefinitionSchema = lazySchema(() => strictObject({ * bundle that carries `supportedLocales` rows without an `en-US` one. */ locale: z.string().default(EMAIL_TEMPLATE_FLOOR_LOCALE).describe( - 'BCP-47 locale (e.g. en-US, zh-CN) — the bundle key the resolver matches EXACTLY, with one ' - + 'retry rung: the literal `en-US`. No language-subtag folding, so `en` and `en-US` are ' - + 'different bundles and neither reaches the other. A bundle with no `en-US` row therefore has ' - + 'no fallback floor: any recipient locale it does not carry a row for raises ' - + 'TEMPLATE_NOT_FOUND, which is permanent — the delivery dead-letters with no retry. Your ' + '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`.", ), From c50c54efae7953d65d6c7317046b992f7dd045c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 14:34:33 +0000 Subject: [PATCH 2/4] test(spec): pin the email-template floor guard's two declared scope boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two shapes `warnEmailTemplateLocaleFloor` returns early on were filed under a describe titled "stays silent where the bundle HAS a floor" — false for both: each bundle is floorless and silent because it is outside the guard's scope. Splits them into their own block, pairs every silent case with a warning discriminator so silence cannot be read out of a dead harness, and pins that early return 1 also guards the `supported.map` read. Adds a pin holding the published `locale` describe to naming both call shapes. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude --- .../stack-email-template-locale-floor.test.ts | 117 ++++++++++++++++-- 1 file changed, 108 insertions(+), 9 deletions(-) diff --git a/packages/spec/src/stack-email-template-locale-floor.test.ts b/packages/spec/src/stack-email-template-locale-floor.test.ts index 0fb2a1b9ecb..532d35ece69 100644 --- a/packages/spec/src/stack-email-template-locale-floor.test.ts +++ b/packages/spec/src/stack-email-template-locale-floor.test.ts @@ -77,6 +77,23 @@ describe('#17614 — the floor tag is one named constant, not two spellings', () expect(text).toContain('en-US'); expect(text).toContain('TEMPLATE_NOT_FOUND'); }); + + // [#18056] The same `describe` used to promise that a floorless bundle + // dead-letters, full stop. Measured against `EmailService`'s ladder + // (`resolveAndRenderTemplate` → `createSysEmailTemplateLoader`), that is true + // only for a call that NAMED a locale: a call naming none drops to the + // bundle's lowest tag and renders it, which `plugin-email`'s + // `template-locale-resolution.test.ts` pins as resolving, not dead-lettering. + // A published declaration promising a loud permanent refusal where the + // runtime performs a silent fill is the defect; both call shapes must stay + // named, so the promise cannot quietly go unconditional again. + it('[#18056] scopes the dead-letter promise to a call that NAMES a locale, and states the other case', () => { + const text = String(EmailTemplateDefinitionSchema.shape.locale.description ?? ''); + expect(text).toMatch(/NAMES a locale/); + expect(text).toMatch(/lowest locale tag/); + // ⛔ The unscoped promise itself, in the spelling it shipped in. + expect(text).not.toMatch(/therefore has no fallback floor: any recipient locale/); + }); }); describe('#17614 — defineStack reports a bundle with no `en-US` floor', () => { @@ -111,6 +128,11 @@ describe('#17614 — defineStack reports a bundle with no `en-US` floor', () => }); describe('#17614 — and stays silent where the bundle HAS a floor (the controls)', () => { + // ⛔ Every case in THIS block is a bundle that genuinely carries the floor + // row. The shapes that are floorless and silent anyway are the guard's scope + // boundary and live in their own block below — filing them here read as + // "these have a floor", which is exactly the kind of sentence about runtime + // behaviour nobody re-reads (#18056). it('silent when the bundle carries an en-US row beside the supported tags', () => { const { warns, value } = warningsOf(stack( ['en-US', 'zh-CN', 'ja-JP', 'es-ES'].map((l) => tpl(l)), THE_TRAP, @@ -129,16 +151,9 @@ describe('#17614 — and stays silent where the bundle HAS a floor (the controls expect(value.emailTemplates?.[0]?.locale).toBe(EMAIL_TEMPLATE_FLOOR_LOCALE); }); - it('silent when the stack declares no supportedLocales to measure against', () => { - const { warns } = warningsOf(stack([tpl('en')])); - expect(floorWarns(warns)).toEqual([]); - }); - - it('silent when no authored tag is one this stack claims to support', () => { - const { warns } = warningsOf(stack([tpl('fr-CA')], { defaultLocale: 'en', supportedLocales: ['en'] })); - expect(floorWarns(warns)).toEqual([]); - }); +}); +describe('#17614 — warn-once bookkeeping', () => { it('warns once for one bundle, however many times the same stack is defined', () => { const first = warningsOf(stack([tpl('pt-BR')], { defaultLocale: 'pt-BR', supportedLocales: ['pt-BR'] })); const second = warningsOf(stack([tpl('pt-BR')], { defaultLocale: 'pt-BR', supportedLocales: ['pt-BR'] })); @@ -146,3 +161,87 @@ describe('#17614 — and stays silent where the bundle HAS a floor (the controls expect(floorWarns(second.warns)).toEqual([]); }); }); + +// ── #18056 — the two shapes the guard returns early on ────────────────────── +// +// ⛔ NOT controls. Every bundle below genuinely carries NO `en-US` row, so the +// hazard is real and the silence is this guard's DECLARED SCOPE, not a pass. +// `warnEmailTemplateLocaleFloor`'s docblock now states both; these hold it to +// that, in both directions — each silent case is paired with a DISCRIMINATOR +// that warns, so "silent" can never be read out of a harness that had simply +// stopped reporting. Whether either shape SHOULD warn is the ADR-0049 +// enforce-or-remove question and is not decided here; what is closed is the +// silence being undeclared and unpinned. + +describe('#18056 — the guard\'s declared scope boundary', () => { + it('early return 1: a stack with no `i18n` block is never examined, floorless or not', () => { + const { warns, value } = warningsOf(stack([tpl('en', 'acme.scope_no_i18n')])); + expect(floorWarns(warns)).toEqual([]); + // …and the bundle really is floorless: one row, tagged `en`, no `en-US`. + expect(value.emailTemplates?.map((t) => t.locale)).toEqual(['en']); + + // DISCRIMINATOR — the identical floorless bundle, under a stack that does + // declare the tag. The guard is awake; shape alone decides. + const seen = warningsOf(stack( + [tpl('en', 'acme.scope_no_i18n_disc')], { defaultLocale: 'en', supportedLocales: ['en'] }, + )); + expect(floorWarns(seen.warns)).toHaveLength(1); + }); + + it('early return 1: `supportedLocales: []` is the other arm — `i18n` cannot omit the key', () => { + // Measured: `supportedLocales` is REQUIRED inside `i18n`, so the + // `!Array.isArray` arm is reachable only by omitting `i18n` entirely and + // the `length === 0` arm only by a literal empty array. Both are silent. + const { warns } = warningsOf(stack( + [tpl('en', 'acme.scope_empty_supported')], { defaultLocale: 'en', supportedLocales: [] }, + )); + expect(floorWarns(warns)).toEqual([]); + }); + + it('early return 1 guards the read as much as it scopes — defineStack must not throw', () => { + // What deleting it actually costs. `supported.map(...)` off an absent + // `i18n` is a TypeError out of `defineStack` itself, and BOTH in-tree + // stacks that declare `emailTemplates` would take it (measured 2026-09-16: + // examples/app-showcase and the qa/dogfood materialization fixture). + expect(() => defineStack(stack([tpl('en', 'acme.scope_nothrow')]))).not.toThrow(); + }); + + it('early return 2: a bundle whose tags are ALL outside supportedLocales is skipped', () => { + const i18n = { defaultLocale: 'en-GB', supportedLocales: ['en-GB'] }; + const { warns, value } = warningsOf(stack([tpl('fr-CA', 'acme.scope_outside')], i18n)); + expect(floorWarns(warns)).toEqual([]); + expect(value.emailTemplates?.map((t) => t.locale)).toEqual(['fr-CA']); + + // DISCRIMINATOR — same stack, same floorlessness, one tag moved INSIDE + // `supportedLocales`. So the silence above is `declared` being empty, one + // line after the floor check established the bundle has no floor row. + const seen = warningsOf(stack([tpl('en-GB', 'acme.scope_inside')], i18n)); + expect(floorWarns(seen.warns)).toHaveLength(1); + expect(floorWarns(seen.warns)[0]).toContain("carries rows for 'en-GB'"); + }); + + it('early return 1 decides nothing early return 2 would not — the outcomes are equal', () => { + // Measured (#18056): with no supported set every bundle's `declared` list + // is empty, so shape 2 skips exactly what shape 1 returns before reaching. + // Pinning the OUTCOMES equal means a future change that gives shape 1 its + // own meaning has to come and say so here rather than landing silently. + const tags = (n: string) => [tpl('en', n), tpl('zh-CN', n)]; + const noI18n = warningsOf(stack(tags('acme.scope_shadow_a'))); + const emptySupported = warningsOf(stack( + tags('acme.scope_shadow_b'), { defaultLocale: 'en', supportedLocales: [] }, + )); + const allOutside = warningsOf(stack( + tags('acme.scope_shadow_c'), { defaultLocale: 'ja-JP', supportedLocales: ['ja-JP'] }, + )); + expect(floorWarns(noI18n.warns)).toEqual([]); + expect(floorWarns(emptySupported.warns)).toEqual([]); + expect(floorWarns(allOutside.warns)).toEqual([]); + + // DISCRIMINATOR for all three: the same two-row floorless bundle, with its + // tags declared. One `supportedLocales` edit is the whole difference. + const seen = warningsOf(stack( + tags('acme.scope_shadow_d'), { defaultLocale: 'en', supportedLocales: ['en', 'zh-CN'] }, + )); + expect(floorWarns(seen.warns)).toHaveLength(1); + }); +}); From 708595dc9c453142211eba1a763de4c976da16e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 15:15:32 +0000 Subject: [PATCH 3/4] docs(spec): regenerate the email-template reference page and add the changeset `gen:docs` projects `EmailTemplateDefinitionSchema.locale`'s describe into content/docs/references/system/email-template.mdx, so the source correction without this regeneration would have shipped the old sentence to every reader of the reference page. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude --- .../18056-email-template-locale-rungs.md | 46 +++++++++++++++++++ .../docs/references/system/email-template.mdx | 2 +- 2 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 .changeset/18056-email-template-locale-rungs.md diff --git a/.changeset/18056-email-template-locale-rungs.md b/.changeset/18056-email-template-locale-rungs.md new file mode 100644 index 00000000000..21dd3261497 --- /dev/null +++ b/.changeset/18056-email-template-locale-rungs.md @@ -0,0 +1,46 @@ +--- +'@objectstack/spec': minor +--- + +docs(spec): scope the email-template locale-floor claims to a call that NAMES a locale (#18056) + +Clause-②: yes — no accept set moves (no key is added, removed or revalidated), +but what a PUBLISHED package states about its own resolution contract is +corrected, which is a contract act in substance. + +`packages/spec` stated two different rung counts for one resolution. +`EmailTemplateDefinitionSchema.locale`'s `describe` and the +`EMAIL_TEMPLATE_FLOOR_LOCALE` docblock published **one** retry rung and an +explicit "no fallback floor at all"; `SendTemplateInput.locale` in +`contracts/email-service.ts`, same package, documents a **three-rung** ladder +whose third rung is reachable exactly on the path the first says cannot exist. + +Measured against the runtime rather than reconciled by preference — +`EmailService.resolveAndRenderTemplate` and `createSysEmailTemplateLoader` in +`@objectstack/plugin-email`, and the CI pins in +`template-locale-resolution.test.ts` — the three-rung text is the correct one: + +1. the named locale, matched exactly (no language-subtag folding); +2. the literal `en-US`, which is also where a call naming no locale starts; +3. **only for a call that named no locale**, and only when the bundle carries + no `en-US` row: the bundle's lowest locale tag. + +So a bundle with no `en-US` row dead-letters (`TEMPLATE_NOT_FOUND`, permanent) +for every recipient whose locale was NAMED, and silently renders whichever +language sorts first for every call that named none. The shipped declaration +promised the loud permanent refusal on the path where the runtime performs the +silent fill; an author reading it was told a missing locale always +dead-letters. Both call shapes are now named wherever the floor is claimed, and +the ladder itself is stated in one place only. + +Also corrected: `SendTemplateInput.template` said the service "picks the +best-matching locale row", which the resolver has never done — there is no +best match and no folding, only the ladder above. + +`defineStack`'s `warnEmailTemplateLocaleFloor` gains a declaration of the two +shapes it deliberately does NOT examine (a stack whose `i18n.supportedLocales` +is absent or empty; a bundle whose tags all fall outside `supportedLocales`) — +both can still ship a floorless bundle. Its logic is unchanged and its warning +stays advisory; whether either shape should warn is the ADR-0049 +enforce-or-remove question and is not answered here. Both shapes are now pinned +against a warning discriminator so neither can change without a test saying so. diff --git a/content/docs/references/system/email-template.mdx b/content/docs/references/system/email-template.mdx index d043257e2e6..9e8a637991b 100644 --- a/content/docs/references/system/email-template.mdx +++ b/content/docs/references/system/email-template.mdx @@ -44,7 +44,7 @@ const result = EmailTemplateDefinitionSchema.parse(data); | **name** | `string` | ✅ | Template identifier (dotted snake_case) | | **label** | `string` | ✅ | Display label | | **category** | `Enum<'auth' \| 'notification' \| 'workflow' \| 'marketing' \| 'custom'>` | optional (default: `"custom"`) | | -| **locale** | `string` | optional (default: `"en-US"`) | BCP-47 locale (e.g. en-US, zh-CN) — the bundle key the resolver matches EXACTLY, with one retry rung: the literal `en-US`. No language-subtag folding, so `en` and `en-US` are different bundles and neither reaches the other. A bundle with no `en-US` row therefore has no fallback floor: any recipient locale it does not carry a row for raises TEMPLATE_NOT_FOUND, which is permanent — the delivery dead-letters with no retry. Your stack's own `i18n.defaultLocale` is the wrong tag here unless it is spelled `en-US`. | +| **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`. | | **subject** | `string` | ✅ | Subject template | | **bodyHtml** | `string` | ✅ | HTML body template | | **bodyText** | `string` | optional | Plain-text body template (auto-derived from HTML when omitted) | From 642da7fda54c4a36f7178367fa902ba12430a9d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 16:06:24 +0000 Subject: [PATCH 4/4] docs(spec): scope the surviving one-rung claims to a call that names a locale Text-only remediation on the contract-review record for this PR. No logic line moves: both .ts files reprint byte-identical through the TypeScript printer with removeComments (stripped sha256 equal before and after), and a dark control on a pair that really does move code reads DIFFERENT, so the instrument is not blind. - .changeset/email-template-locale-floor.md (pending and unreleased, from #17884): "retries exactly one rung - the literal en-US" and "the resolver's sole retry rung" were unscoped, and would have compiled verbatim into the published packages/spec CHANGELOG.md beside this PR's correction of them -- the erratum-in-a-later-entry form AGENTS.md forbids. Both are now scoped to a call that NAMES a locale, with a pointer to SendTemplateInput.locale for the full ladder. The same file's "the single literal en-US rung" is scoped too. - stack-email-template-locale-floor.test.ts: the header sentence now scopes the one-rung claim and names the no-locale case; the title line's bare "no fallback floor" is scoped the same way. - email-template-floor-locale-parity.pin.test.ts: "its single retry rung" now names both rungs and which call shape reaches each. - .changeset/18056-email-template-locale-rungs.md: states that the guard's emitted warning TEXT changed and now names both call shapes, keeping "control flow is unchanged" and dropping any byte-for-byte claim. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude --- .../18056-email-template-locale-rungs.md | 13 +++++++---- .changeset/email-template-locale-floor.md | 23 +++++++++++-------- .../stack-email-template-locale-floor.test.ts | 13 +++++++---- ...l-template-floor-locale-parity.pin.test.ts | 11 +++++---- 4 files changed, 36 insertions(+), 24 deletions(-) diff --git a/.changeset/18056-email-template-locale-rungs.md b/.changeset/18056-email-template-locale-rungs.md index 21dd3261497..e956d5dd8a2 100644 --- a/.changeset/18056-email-template-locale-rungs.md +++ b/.changeset/18056-email-template-locale-rungs.md @@ -40,7 +40,12 @@ best match and no folding, only the ladder above. `defineStack`'s `warnEmailTemplateLocaleFloor` gains a declaration of the two shapes it deliberately does NOT examine (a stack whose `i18n.supportedLocales` is absent or empty; a bundle whose tags all fall outside `supportedLocales`) — -both can still ship a floorless bundle. Its logic is unchanged and its warning -stays advisory; whether either shape should warn is the ADR-0049 -enforce-or-remove question and is not answered here. Both shapes are now pinned -against a warning discriminator so neither can change without a test saying so. +both can still ship a floorless bundle. Its control flow is unchanged — the same +bundles warn, once each, and the warning stays advisory — but the emitted warning +TEXT did change, and now names BOTH call shapes: it says the bundle has no +fallback floor *for a send that names a locale*, and adds that a send naming NO +locale does not fail but drops to that bundle's lowest tag and renders it +silently. A test asserting on the old wording needs updating. Whether either +undeclared shape should warn is the ADR-0049 enforce-or-remove question and is +not answered here. Both shapes are now pinned against a warning discriminator so +neither can change without a test saying so. diff --git a/.changeset/email-template-locale-floor.md b/.changeset/email-template-locale-floor.md index 13881152eed..ca24d44dae0 100644 --- a/.changeset/email-template-locale-floor.md +++ b/.changeset/email-template-locale-floor.md @@ -4,19 +4,22 @@ Email templates: say where the `en-US` fallback floor is, and report a bundle that has none. -`IEmailService.sendTemplate` matches `(name, locale)` exactly and retries exactly one rung — -the literal `en-US`. There is no language-subtag folding, so a bundle whose English row is -tagged `en` is unreachable from `en-US` and from every other tag it does not itself carry; -each such delivery raises `TEMPLATE_NOT_FOUND`, which classifies permanent, so it dead-letters -with no retry. An app declaring `i18n.defaultLocale: 'en'` and authoring `locale: 'en'` has -done the consistent thing throughout and still shipped a bundle with no floor — and it -validated, built and installed clean. +`IEmailService.sendTemplate` matches `(name, locale)` exactly and, for a call that NAMES a +locale, retries exactly one rung — the literal `en-US` — and stops. There is no language-subtag +folding, so a bundle whose English row is tagged `en` is unreachable from `en-US` and from every +other tag it does not itself carry; each such delivery raises `TEMPLATE_NOT_FOUND`, which +classifies permanent, so it dead-letters with no retry. An app declaring +`i18n.defaultLocale: 'en'` and authoring `locale: 'en'` has done the consistent thing throughout +and still shipped a bundle with no floor for those calls — and it validated, built and installed +clean. - `EmailTemplateDefinitionSchema.locale`'s `describe` and TSDoc now state the exact match, the - single literal `en-US` rung, the absence of folding, and that the stack's own declared default - locale is the wrong tag whenever it is not spelled `en-US`. + one literal `en-US` rung a call that NAMES a locale gets, the absence of folding, and that the + stack's own declared default locale is the wrong tag whenever it is not spelled `en-US`. - New exported `EMAIL_TEMPLATE_FLOOR_LOCALE` names that tag once: it is both the schema default - and the resolver's sole retry rung. + and the rung `sendTemplate` retries for a call that NAMES a locale. The full ladder — including + the lowest-tag rung reachable only by a call that names NO locale — is on + `SendTemplateInput.locale` in `packages/spec/src/contracts/email-service.ts`. - `defineStack` now reports (advisory `console.warn`, warn-once per bundle) an `emailTemplates` bundle that carries rows for the stack's own `i18n.supportedLocales` but none tagged `en-US`. diff --git a/packages/spec/src/stack-email-template-locale-floor.test.ts b/packages/spec/src/stack-email-template-locale-floor.test.ts index 532d35ece69..f1dcd3f61a3 100644 --- a/packages/spec/src/stack-email-template-locale-floor.test.ts +++ b/packages/spec/src/stack-email-template-locale-floor.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // // #17614 — an `emailTemplates` bundle tagged with the stack's OWN -// `i18n.defaultLocale` has no fallback floor. +// `i18n.defaultLocale` has no fallback floor for a send that names a locale. // // Measured before this landed, on an unmodified tree: a stack declaring // `defaultLocale: 'en'`, `supportedLocales: ['en','zh-CN','ja-JP','es-ES']` @@ -10,10 +10,13 @@ // PERMANENT, so the delivery dead-letters with no retry — for `de-DE` and for // the literal `en-US`. The identical bundle with its English row tagged // `en-US` delivered for `de-DE`. `sendTemplate` matches `(name, locale)` -// exactly and retries exactly one rung, the literal `en-US`; there is no -// language-subtag folding, and that ladder's shape is a settled ruling this -// change deliberately does not touch. The remedy is the bundle, so the -// diagnostic is where the author is standing. +// exactly and, for a call that NAMES a locale, retries exactly one rung — the +// literal `en-US` — and stops; there is no language-subtag folding. A call that +// names NO locale is the other case and does NOT dead-letter: it starts at +// `en-US` by name and, when the bundle carries no `en-US` row, drops to that +// bundle's lowest locale tag and renders it silently. That ladder's shape is a +// settled ruling this change deliberately does not touch. The remedy is the +// bundle, so the diagnostic is where the author is standing. // // These pin the diagnostic ADVISORY: every case asserts the parse still // succeeds and the stack comes back unchanged. The diagnostic narrows what diff --git a/packages/spec/src/system/email-template-floor-locale-parity.pin.test.ts b/packages/spec/src/system/email-template-floor-locale-parity.pin.test.ts index 6eafecb237e..8b5a4c36cec 100644 --- a/packages/spec/src/system/email-template-floor-locale-parity.pin.test.ts +++ b/packages/spec/src/system/email-template-floor-locale-parity.pin.test.ts @@ -29,11 +29,12 @@ * and turbo's input hashing both see the escape. * * ⛔ Scope: the VALUE, not the resolver. This asserts two literals agree. It - * asserts nothing about the ladder's shape — the exact `(name, locale)` match - * and its single retry rung are a settled ruling, and changing them is not this - * pin's business. Renaming either constant turns this red on purpose: the - * docblock names `DEFAULT_TEMPLATE_LOCALE` specifically, so a rename is an edit - * to the published claim and has to be made in both places. + * asserts nothing about the ladder's shape — the exact `(name, locale)` match, + * the one retry rung a call that NAMES a locale gets, and the lowest-tag rung + * reachable only by a call that names NO locale are a settled ruling, and + * changing them is not this pin's business. Renaming either constant turns this + * red on purpose: the docblock names `DEFAULT_TEMPLATE_LOCALE` specifically, so + * a rename is an edit to the published claim and has to be made in both places. * * ⛔ A missing or unreadable declaration is a FAILURE, never a silent pass — * that is the whole failure mode a text-reading pin has to defend against.