Skip to content
Draft
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
51 changes: 51 additions & 0 deletions .changeset/18056-email-template-locale-rungs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
---
'@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 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.
23 changes: 13 additions & 10 deletions .changeset/email-template-locale-floor.md
Original file line number Diff line number Diff line change
@@ -1,22 +1,25 @@
---
'@objectstack/spec': minor
---

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`.

Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/system/email-template.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
5 changes: 3 additions & 2 deletions packages/spec/src/contracts/email-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
130 changes: 116 additions & 14 deletions packages/spec/src/stack-email-template-locale-floor.test.ts
Original file line number Diff line number Diff line change
@@ -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']`
Expand All @@ -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
Expand Down Expand Up @@ -77,6 +80,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', () => {
Expand Down Expand Up @@ -111,6 +131,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,
Expand All @@ -129,20 +154,97 @@ 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'] }));
expect(floorWarns(first.warns)).toHaveLength(1);
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);
});
});
Loading
Loading