Skip to content

Commit a61ae59

Browse files
os-billclaude
andauthored
fix(spec): give the email-template locale bundle a stated en-US floor, and report one that has none (#17884)
Fixes #17614 Clause-②: yes Triage ruled BOTH remedies in, with an order (comment 5642916137): **「do both, option 1 first.」** Both land here. ## The trap, reproduced on this branch's own head before anything changed An app declares `i18n: { defaultLocale: 'en', supportedLocales: ['en','zh-CN','ja-JP','es-ES'] }` and authors its English `emailTemplates` row as `locale: 'en'` — the consistent thing. Measured on an unmodified tree: | probe | reading | |---|---| | `defineStack` on that stack | `parsed-ok=true warnings=0`, stored locales `["en","zh-CN","ja-JP","es-ES"]` | | `sendTemplate({ locale: 'de-DE' })` | `TEMPLATE_NOT_FOUND: acme.welcome (locale=de-DE)` | | `sendTemplate({ locale: 'en-US' })` | `TEMPLATE_NOT_FOUND: acme.welcome (locale=en-US)` | | **lit control** — same bundle, English row tagged `en-US` | **DELIVERED**, `subject=[en-US] Hi` | The control is aimed at the resolver ladder itself: it proves the probe can come back "delivered", so the two `TEMPLATE_NOT_FOUND` readings are readings and not a broken harness. A second lit control covers the `defineStack` half — an `objectz` typo on the same stack is refused, so `warnings=0` is a measurement rather than a dead capture. `TEMPLATE_NOT_FOUND` classifies **permanent**, so each of those 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. ## Premise re-read on today's tree, not on the card The card measured against installed 17.4.0. Re-read at this branch's base: the ladder in `packages/plugins/plugin-email/src/email-service.ts` is byte-for-byte what the card quotes — exact `(name, locale)`, one retry rung at the literal `DEFAULT_TEMPLATE_LOCALE = 'en-US'`, no folding — and `EmailTemplateDefinitionSchema.locale` was still `z.string().default('en-US').describe('BCP-47 locale (e.g. en-US, zh-CN)')`. **The premise holds; the trap is open.** Its TSDoc was also actively misleading — "the service picks the best match for the recipient's locale" reads as folding, which does not exist on that path. ## ① Say it where the author is standing `EmailTemplateDefinitionSchema.locale`'s `describe` and TSDoc now state the exact match, the single literal `en-US` rung, the absence of language-subtag folding, that a bundle with no `en-US` row has no floor, and that the stack's own declared default locale is the wrong tag whenever it is not spelled `en-US`. The `describe` is the published authoring surface — it lands in `content/docs/references/system/email-template.mdx`, regenerated here. New exported `EMAIL_TEMPLATE_FLOOR_LOCALE` names the tag once, in the one place that is both the schema default and the resolver's sole retry rung. ## ② The author-time diagnostic `defineStack` now reports an `emailTemplates` bundle that carries rows for the stack's own `i18n.supportedLocales` but none tagged `en-US`. Advisory `console.warn`, warn-once per bundle, post-parse — the same seam and posture as the existing `warnUnknownAuthoringKeys` reporter. ## ⛔ The fence held Language-subtag folding was not added, no third rung was added, and `EmailService.resolveAndRenderTemplate` is not touched — the diff contains no file under `packages/plugins/plugin-email`. The ladder's shape stays as #13881 settled it; the remedy this points at is the bundle. ## Clause-② self-check — `yes`, and the rationale is corrected **`yes` is right, and it is a mechanical call, not a measurement.** The carrier that fires is the **new exported symbol** `EMAIL_TEMPLATE_FLOOR_LOCALE` — a new export on a published surface is always `yes`, decided by looking at the export list. It is registered at `packages/spec/api-surface/system.json` and `packages/spec/export-origins/system.json`; a changed published `describe` moves `content/docs/references/system/email-template.mdx` alongside it. ⚠️ Correcting the rationale this PR shipped with: the dispatch and this body's first version justified `yes` on the ground that remedy ② *narrows* what passes silently, and **that rule does not exist** — the lane text says the opposite, that narrowing is still a semantic surface and does not trip clause ②. The declaration was over-determined: the right answer reached through a limb that does not fire. The accept-set measurement below stands and is **not** the basis for the `yes`; it is reported because it is true and reviewable. The final diff **moves no accept set**: same probe, same tree, before and after, `parsed-ok=true` on both sides and the returned stack carries the identical tags `["en","zh-CN","ja-JP","es-ES"]`; only the warning count goes 0 to 1. No Zod refinement added, no schema key added or removed, no closed set narrowed. Carrier state: PR and card both carry `needs:contract-review`; `scripts/pm/check-clause2-carriers.mjs --pair 17884` exits 0, both carriers agree. Draft, not enqueued, auto-merge not armed — the seat lands it. ## Contract review — C1 resolved, option (a) Review verdict: PASS WITH CONDITIONS (comment 5647795889). C1 said the new constant's docblock **publishes** an invariant — that `@objectstack/plugin-email`'s `DEFAULT_TEMPLATE_LOCALE` and `EMAIL_TEMPLATE_FLOOR_LOCALE` "must stay equal" — with nothing holding them equal, the same shape as this package's existing `position-delegatable-enforcer.pin.test.ts` and its own words, "prose costs nothing to add and no compiler checks it." **Chose (a), add the pin, over (b), delete the sentence.** The sentence is *true and useful*: both constants are published (`plugin-email` re-exports `DEFAULT_TEMPLATE_LOCALE` from its `index.ts`), they are genuinely one settled value in two packages, and an author or a future round needs to know they are coupled. Deleting it would buy honesty by removing information, leaving the drift seam just as real and now undocumented. (a) keeps the claim and makes it checkable, and it costs nothing this PR was not already allowed to spend — so (b) is the retreat, not the equal. `packages/spec/src/system/email-template-floor-locale-parity.pin.test.ts` reads `packages/plugins/plugin-email/src/email-service.ts` **as text** and asserts its `DEFAULT_TEMPLATE_LOCALE` literal equals `EMAIL_TEMPLATE_FLOOR_LOCALE`. `packages/spec` cannot import `plugin-email` (its dependencies are `zod` and `pg-connection-string`) and must not start to; the `repo` vitest project exists for exactly this, and the path is registered in `packages/spec/vitest.repo-tests.json`. A missing or renamed declaration fails loudly rather than passing vacuously. ⛔ **The fence is still intact.** The delivered diff contains no file under `packages/plugins/plugin-email`; no rung was added, nothing folds, and `EmailService.resolveAndRenderTemplate` is untouched. The pin only reads that package. ## Successor-card seed (C2) — the second silent, floorless case `packages/spec/src/stack.zod.ts`, the `declared.length === 0` branch of `warnEmailTemplateLocaleFloor`. A stack that **does** declare `i18n.supportedLocales` but whose bundle tags **none** of them is equally floorless and equally silent — `supportedLocales: ['en']` with a lone `fr-CA` row. This PR's own test pins that green ("silent when no authored tag is one this stack claims to support"), so the gap is deliberate and recorded rather than accidental. ⛔ Not fixed here: widening the condition is a second decision about who gets warned, and it is left as the seed of a successor card together with the no-`i18n`-block case already noted below. A third, named by the review and recorded here for the same successor: a `console.warn` at `defineStack` is heard only by whoever runs the build and reads stdout, while this card's own premise is that the trap survives because people do not read. The stronger rung that stays inside the fence is an `os lint` / `os validate` rule — a separate card, not this one. ## Tests, and the ablation that proves each one could fail New: `packages/spec/src/stack-email-template-locale-floor.test.ts` (10 tests). Every case asserts the parse still succeeds and the stack returns unchanged, so the pins are on the advisory posture too. Proven by on-disk mutation, each with its injected-text count verified before the run and the restore verified by `git hash-object` against the HEAD blob: | mutation | result | |---|---| | MUT-1 — the diagnostic never reports | **RED**, 4 failed / 6 passed | | MUT-2 — the `describe` loses `TEMPLATE_NOT_FOUND` | **RED**, 1 failed / 9 passed | | MUT-3 — call the diagnostic pre-parse on raw input | **GREEN, 10 passed — reported, not hidden** | | restored (hashes MATCH both files) | **GREEN**, 10 passed | MUT-3 falsified a claim I had written into the test: the reader mirrors the schema default for a missing `locale`, so pre-parse and post-parse agree and the call site is not what that case pins. The comment and the function's rationale were corrected to say what was actually measured, rather than the reverse. ### C1 parity pin — ablated, because an un-ablated pin is not a pin Mutated `plugin-email`'s literal on disk from `en-US` to `en-GB` (restored immediately; the delivered diff does not touch that file): | leg | evidence | |---|---| | landed on disk | original literal occurrences 1 to 0, injected literal 1, and the blob hash moved off the HEAD blob (`0198f7ac` to `108eb1b8`) | | RED | exit 1 — `AssertionError: expected 'en-GB' to be 'en-US'`, 1 failed / 2 passed | | restored + GREEN | hash back to `0198f7ac`, MATCHES the HEAD blob, `git diff HEAD` empty; re-run exit 0, 3 passed | `pnpm check:cross-package-test-inputs` exits 0 with the new escape declared. That zero is a reading, not a dead gate: unregistering the path from `vitest.repo-tests.json` reds it (exit 1) naming this exact file, and it was then re-registered. ### Commands and exit codes Both `packages/spec` vitest projects were run and their exit codes captured **separately** (the wrapper's own verdict line covers only the last part, so each was captured inside the run): | command | reading | |---|---| | `pnpm --filter @objectstack/spec test` (project `local`) | `LOCAL_EXIT=0` — 473 files / 13444 tests passed | | `pnpm --filter @objectstack/spec test:repo` (project `repo`) | `REPO_EXIT=0` — 30 files / 520 tests passed | | `pnpm --filter @objectstack/spec typecheck` | `TC_EXIT=0` | | `pnpm --filter @objectstack/spec check:generated` | all 15 artifacts up to date after `--fix` | | `eslint . --no-inline-config --format json` | exit 0 — 6660 files (eslint's own population), 0 errors, 0 warnings, at `d81bcb4ae2` | 24 of the 107 gate families `scripts/pm/dispatch-gates.mjs` derives for this change set were run locally, all exit 0; the remaining 83 are declared to CI rather than run on the shared box. This is a **declared** narrowing, not a skipped one. ## Changeset `.changeset/email-template-locale-floor.md`, `minor` on `@objectstack/spec`. One is owed and is not skippable: ① changes a published `describe` (it moves `content/docs/references/`) and the diff adds a published export. ## Corpus census No in-repo stack begins warning. The one example app that declares both halves — `examples/app-showcase` (`defaultLocale: 'en'`, `supportedLocales: ['en','zh-CN']`) — already tags its bundle `en-US`, and its own header comment explains why. That is the "one careful reading" triage named as the only thing standing between this trap and a live outage; it is now the schema's job instead. ## Acceptance notes (out of scope, noted, not filed) - `@objectstack/plugin-email` spells the floor as its own `DEFAULT_TEMPLATE_LOCALE`; this PR adds a second spelling in spec rather than making the two one constant, because collapsing them means editing the ladder's own file, which the fence puts out of reach for this round. The new constant's docblock states the dependency direction. No parity pin is added here. Successor: whoever next opens `email-service.ts` under a ruling that lifts the fence. - The diagnostic's condition is the ruling's wording verbatim — it requires `i18n.supportedLocales` to be declared. A stack that declares no `i18n` block and authors a lone `en` row is equally floorless and stays silent. Named as a deliberate boundary, not widened here. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 29dd1a6 commit a61ae59

9 files changed

Lines changed: 401 additions & 6 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
Email templates: say where the `en-US` fallback floor is, and report a bundle that has none.
6+
7+
`IEmailService.sendTemplate` matches `(name, locale)` exactly and retries exactly one rung —
8+
the literal `en-US`. There is no language-subtag folding, so a bundle whose English row is
9+
tagged `en` is unreachable from `en-US` and from every other tag it does not itself carry;
10+
each such delivery raises `TEMPLATE_NOT_FOUND`, which classifies permanent, so it dead-letters
11+
with no retry. An app declaring `i18n.defaultLocale: 'en'` and authoring `locale: 'en'` has
12+
done the consistent thing throughout and still shipped a bundle with no floor — and it
13+
validated, built and installed clean.
14+
15+
- `EmailTemplateDefinitionSchema.locale`'s `describe` and TSDoc now state the exact match, the
16+
single literal `en-US` rung, the absence of folding, and that the stack's own declared default
17+
locale is the wrong tag whenever it is not spelled `en-US`.
18+
- New exported `EMAIL_TEMPLATE_FLOOR_LOCALE` names that tag once: it is both the schema default
19+
and the resolver's sole retry rung.
20+
- `defineStack` now reports (advisory `console.warn`, warn-once per bundle) an `emailTemplates`
21+
bundle that carries rows for the stack's own `i18n.supportedLocales` but none tagged `en-US`.
22+
23+
Advisory only — no accept set moves. The stack still parses and is returned unchanged; the
24+
resolver's ladder is unchanged.

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ const result = EmailTemplateDefinitionSchema.parse(data);
4444
| **name** | `string` | ✅ | Template identifier (dotted snake_case) |
4545
| **label** | `string` | ✅ | Display label |
4646
| **category** | `Enum<'auth' \| 'notification' \| 'workflow' \| 'marketing' \| 'custom'>` | optional (default: `"custom"`) | |
47-
| **locale** | `string` | optional (default: `"en-US"`) | BCP-47 locale (e.g. en-US, zh-CN) |
47+
| **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`. |
4848
| **subject** | `string` | ✅ | Subject template |
4949
| **bodyHtml** | `string` | ✅ | HTML body template |
5050
| **bodyText** | `string` | optional | Plain-text body template (auto-derived from HTML when omitted) |

‎packages/spec/api-surface/system.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@
200200
"Doc (type)",
201201
"DocSchema (const)",
202202
"DocTranslation (type)",
203+
"EMAIL_TEMPLATE_FLOOR_LOCALE (const)",
203204
"ENVIRONMENT_ARTIFACT_SCHEMA_VERSION (const)",
204205
"EmailAddressConfig (type)",
205206
"EmailAddressConfigSchema (const)",

‎packages/spec/export-origins/system.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,7 @@
194194
"Doc": "src/system/doc.zod.ts#Doc (type)",
195195
"DocSchema": "src/system/doc.zod.ts#DocSchema (const)",
196196
"DocTranslation": "src/system/doc.zod.ts#DocTranslation (type)",
197+
"EMAIL_TEMPLATE_FLOOR_LOCALE": "src/system/email-template.zod.ts#EMAIL_TEMPLATE_FLOOR_LOCALE (const)",
197198
"ENVIRONMENT_ARTIFACT_SCHEMA_VERSION": "src/system/environment-artifact.zod.ts#ENVIRONMENT_ARTIFACT_SCHEMA_VERSION (const)",
198199
"EmailAddressConfig": "src/system/email-config.zod.ts#EmailAddressConfig (type)",
199200
"EmailAddressConfigSchema": "src/system/email-config.zod.ts#EmailAddressConfigSchema (const)",
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// #17614 — an `emailTemplates` bundle tagged with the stack's OWN
4+
// `i18n.defaultLocale` has no fallback floor.
5+
//
6+
// Measured before this landed, on an unmodified tree: a stack declaring
7+
// `defaultLocale: 'en'`, `supportedLocales: ['en','zh-CN','ja-JP','es-ES']`
8+
// and one row per those tags parsed with ZERO warnings and ZERO errors, and
9+
// the resolver then answered `TEMPLATE_NOT_FOUND` — which classifies
10+
// PERMANENT, so the delivery dead-letters with no retry — for `de-DE` and for
11+
// 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 retries exactly one rung, the literal `en-US`; there is no
14+
// language-subtag folding, and that ladder's shape is a settled ruling this
15+
// change deliberately does not touch. The remedy is the bundle, so the
16+
// diagnostic is where the author is standing.
17+
//
18+
// These pin the diagnostic ADVISORY: every case asserts the parse still
19+
// succeeds and the stack comes back unchanged. The diagnostic narrows what
20+
// passes SILENTLY; it moves no accept set.
21+
22+
import { describe, it, expect, vi, afterEach } from 'vitest';
23+
import { defineStack } from './stack.zod';
24+
import { EmailTemplateDefinitionSchema, EMAIL_TEMPLATE_FLOOR_LOCALE } from './system/email-template.zod';
25+
26+
const MANIFEST = { id: 'acme', name: 'acme', version: '1.0.0', namespace: 'acme', type: 'app' as const };
27+
28+
const tpl = (locale: string | undefined, name = 'acme.welcome') => ({
29+
name,
30+
label: `Welcome ${locale ?? '(default)'}`,
31+
...(locale === undefined ? {} : { locale }),
32+
subject: `[${locale ?? 'default'}] Hi {{name}}`,
33+
bodyHtml: `<p>[${locale ?? 'default'}] Hi {{name}}</p>`,
34+
});
35+
36+
const stack = (tpls: unknown[], i18n?: unknown) => ({
37+
manifest: MANIFEST,
38+
...(i18n === undefined ? {} : { i18n }),
39+
emailTemplates: tpls,
40+
}) as never;
41+
42+
const THE_TRAP = { defaultLocale: 'en', supportedLocales: ['en', 'zh-CN', 'ja-JP', 'es-ES'], fallbackLocale: 'en' };
43+
44+
/** Run `defineStack` capturing every `console.warn` it emits. */
45+
function warningsOf(config: unknown): { warns: string[]; value: ReturnType<typeof defineStack> } {
46+
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
47+
try {
48+
const value = defineStack(config as never);
49+
return { warns: spy.mock.calls.map((c) => c.map(String).join(' ')), value };
50+
} finally {
51+
spy.mockRestore();
52+
}
53+
}
54+
55+
const floorWarns = (warns: string[]) => warns.filter((w) => w.includes('no fallback floor'));
56+
57+
afterEach(() => {
58+
// The reporter is warn-once per (bundle, tags); each test authors its own
59+
// tag set so the cache cannot mask a case, but keep runs independent.
60+
vi.restoreAllMocks();
61+
});
62+
63+
describe('#17614 — the floor tag is one named constant, not two spellings', () => {
64+
it('is the schema default for `locale`, so a row that omits the key HAS the floor', () => {
65+
expect(EMAIL_TEMPLATE_FLOOR_LOCALE).toBe('en-US');
66+
const parsed = EmailTemplateDefinitionSchema.parse({
67+
name: 'acme.welcome', label: 'W', subject: 's', bodyHtml: '<p>b</p>',
68+
});
69+
expect(parsed.locale).toBe(EMAIL_TEMPLATE_FLOOR_LOCALE);
70+
});
71+
72+
it("states the two facts an author needs in the published `describe` — the floor tag and what a miss costs", () => {
73+
// The `describe` is the authoring surface (it lands in the generated
74+
// reference docs). The rule used to live only in a docstring two packages
75+
// away, under a heading about a different subject — which is the finding.
76+
const text = String(EmailTemplateDefinitionSchema.shape.locale.description ?? '');
77+
expect(text).toContain('en-US');
78+
expect(text).toContain('TEMPLATE_NOT_FOUND');
79+
});
80+
});
81+
82+
describe('#17614 — defineStack reports a bundle with no `en-US` floor', () => {
83+
it('reports the trap: every supportedLocales tag authored, none of them the floor', () => {
84+
const { warns, value } = warningsOf(stack(['en', 'zh-CN', 'ja-JP', 'es-ES'].map((l) => tpl(l)), THE_TRAP));
85+
const hits = floorWarns(warns);
86+
expect(hits).toHaveLength(1);
87+
expect(hits[0]).toContain("emailTemplates 'acme.welcome'");
88+
expect(hits[0]).toContain("none tagged 'en-US'");
89+
expect(hits[0]).toContain('TEMPLATE_NOT_FOUND');
90+
// ADVISORY — the accept set did not move: the stack parsed and came back
91+
// carrying exactly the tags the author wrote.
92+
expect(value.emailTemplates?.map((t) => t.locale)).toEqual(['en', 'zh-CN', 'ja-JP', 'es-ES']);
93+
});
94+
95+
it('reports a single-row bundle tagged with the stack default — the consistent thing an author writes', () => {
96+
const { warns, value } = warningsOf(stack([tpl('en')], THE_TRAP));
97+
expect(floorWarns(warns)).toHaveLength(1);
98+
expect(value.emailTemplates).toHaveLength(1);
99+
});
100+
101+
it('reports each bundle separately, by name', () => {
102+
const { warns } = warningsOf(stack(
103+
[tpl('en', 'acme.welcome'), tpl('zh-CN', 'acme.welcome'), tpl('en', 'acme.reset')],
104+
THE_TRAP,
105+
));
106+
const hits = floorWarns(warns);
107+
expect(hits).toHaveLength(2);
108+
expect(hits.some((w) => w.includes("'acme.welcome'"))).toBe(true);
109+
expect(hits.some((w) => w.includes("'acme.reset'"))).toBe(true);
110+
});
111+
});
112+
113+
describe('#17614 — and stays silent where the bundle HAS a floor (the controls)', () => {
114+
it('silent when the bundle carries an en-US row beside the supported tags', () => {
115+
const { warns, value } = warningsOf(stack(
116+
['en-US', 'zh-CN', 'ja-JP', 'es-ES'].map((l) => tpl(l)), THE_TRAP,
117+
));
118+
expect(floorWarns(warns)).toEqual([]);
119+
expect(value.emailTemplates).toHaveLength(4);
120+
});
121+
122+
it('silent when the row omits `locale` entirely — the schema default already IS the floor', () => {
123+
// Measured, by ablation: moving the call pre-parse does NOT break this —
124+
// the reader mirrors the schema default for a missing key, so the two
125+
// agree and the case is silent either way. The pin is the behaviour (an
126+
// omitted `locale` is never reported), not the call site.
127+
const { warns, value } = warningsOf(stack([tpl(undefined)], THE_TRAP));
128+
expect(floorWarns(warns)).toEqual([]);
129+
expect(value.emailTemplates?.[0]?.locale).toBe(EMAIL_TEMPLATE_FLOOR_LOCALE);
130+
});
131+
132+
it('silent when the stack declares no supportedLocales to measure against', () => {
133+
const { warns } = warningsOf(stack([tpl('en')]));
134+
expect(floorWarns(warns)).toEqual([]);
135+
});
136+
137+
it('silent when no authored tag is one this stack claims to support', () => {
138+
const { warns } = warningsOf(stack([tpl('fr-CA')], { defaultLocale: 'en', supportedLocales: ['en'] }));
139+
expect(floorWarns(warns)).toEqual([]);
140+
});
141+
142+
it('warns once for one bundle, however many times the same stack is defined', () => {
143+
const first = warningsOf(stack([tpl('pt-BR')], { defaultLocale: 'pt-BR', supportedLocales: ['pt-BR'] }));
144+
const second = warningsOf(stack([tpl('pt-BR')], { defaultLocale: 'pt-BR', supportedLocales: ['pt-BR'] }));
145+
expect(floorWarns(first.warns)).toHaveLength(1);
146+
expect(floorWarns(second.warns)).toEqual([]);
147+
});
148+
});

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

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ import { CubeSchema } from './data/analytics.zod';
6060
import { WebhookSchema } from './automation/webhook.zod';
6161

6262
// System Protocol (additional)
63-
import { EmailTemplateDefinitionSchema } from './system/email-template.zod';
63+
import { EmailTemplateDefinitionSchema, EMAIL_TEMPLATE_FLOOR_LOCALE } from './system/email-template.zod';
6464
import { DocSchema } from './system/doc.zod';
6565
import { BookSchema } from './system/book.zod';
6666

@@ -2663,6 +2663,86 @@ function warnUnknownAuthoringKeys(raw: unknown): void {
26632663
}
26642664
}
26652665

2666+
const warnedEmailTemplateFloors = new Set<string>();
2667+
2668+
/**
2669+
* Report an `emailTemplates` bundle that carries rows for this stack's own
2670+
* `i18n.supportedLocales` but none tagged {@link EMAIL_TEMPLATE_FLOOR_LOCALE}.
2671+
*
2672+
* ## What goes wrong without this
2673+
*
2674+
* `IEmailService.sendTemplate` matches `(name, locale)` EXACTLY and retries
2675+
* exactly one rung — the literal `en-US`. There is no language-subtag folding,
2676+
* so a bundle whose English row is tagged `en` is unreachable from `en-US` and
2677+
* from every other tag it does not itself carry: each such delivery raises
2678+
* `TEMPLATE_NOT_FOUND`, which classifies **permanent**, so it dead-letters with
2679+
* no retry. `sys_user.locale` is user-editable free-text BCP-47 and is NOT
2680+
* constrained to `supportedLocales`, so the locales that can reach the lookup
2681+
* are not the ones the author enumerated — a recipient can break their own mail
2682+
* by setting a legal tag.
2683+
*
2684+
* ⭐ The trap is that the author does the CONSISTENT thing: a stack declaring
2685+
* `defaultLocale: 'en'` whose English row says `locale: 'en'` agrees with
2686+
* itself everywhere, validates, builds and installs clean, and still ships a
2687+
* bundle with no floor. The stack's own declared default locale is the wrong
2688+
* answer whenever it is not spelled `en-US`.
2689+
*
2690+
* ## Posture — advisory, and deliberately so
2691+
*
2692+
* Same seam and same posture as {@link warnUnknownAuthoringKeys}: this only
2693+
* WARNS. It moves no accept set — the parse above already succeeded and its
2694+
* result is returned unchanged — because refusing an `emailTemplates` shape
2695+
* that ships today would be a behaviour change on an authoring surface, which
2696+
* is a scheduled migration rather than something to slip in behind a lint. The
2697+
* resolver's ladder is fenced by a standing ruling and is NOT touched here: the
2698+
* remedy this points at is the bundle.
2699+
*
2700+
* Runs post-parse, on the value the schema produced. `locale` defaults to
2701+
* `en-US`, so a row that omits the key entirely already has the floor and must
2702+
* not be reported — the reader below mirrors that default rather than relying
2703+
* on the call site for it, so the two agree wherever this is called from.
2704+
* Warn-once per bundle, keyed by name plus the tags it actually carries.
2705+
*/
2706+
function warnEmailTemplateLocaleFloor(data: ObjectStackDefinition): void {
2707+
const supported = data.i18n?.supportedLocales;
2708+
if (!Array.isArray(supported) || supported.length === 0) return;
2709+
const templates = data.emailTemplates;
2710+
if (!Array.isArray(templates) || templates.length === 0) return;
2711+
2712+
const supportedSet = new Set(supported.map((l) => String(l)));
2713+
2714+
/** name → the locale tags that bundle carries, in authored order. */
2715+
const bundles = new Map<string, string[]>();
2716+
for (const tpl of templates) {
2717+
const name = typeof tpl?.name === 'string' ? tpl.name : '';
2718+
if (!name) continue;
2719+
const locale = typeof tpl?.locale === 'string' ? tpl.locale : EMAIL_TEMPLATE_FLOOR_LOCALE;
2720+
const tags = bundles.get(name);
2721+
if (tags) tags.push(locale);
2722+
else bundles.set(name, [locale]);
2723+
}
2724+
2725+
for (const [name, tags] of bundles) {
2726+
if (tags.includes(EMAIL_TEMPLATE_FLOOR_LOCALE)) continue;
2727+
const declared = tags.filter((t) => supportedSet.has(t));
2728+
if (declared.length === 0) continue;
2729+
2730+
const key = `${name} ${tags.join(',')}`;
2731+
if (warnedEmailTemplateFloors.has(key)) continue;
2732+
warnedEmailTemplateFloors.add(key);
2733+
console.warn(
2734+
`defineStack: emailTemplates '${name}' carries rows for ${declared.map((t) => `'${t}'`).join(', ')} ` +
2735+
`but none tagged '${EMAIL_TEMPLATE_FLOOR_LOCALE}', so this bundle has no fallback floor. ` +
2736+
`sendTemplate matches (name, locale) exactly and retries only the literal ` +
2737+
`'${EMAIL_TEMPLATE_FLOOR_LOCALE}' — there is no language-subtag folding, so every recipient ` +
2738+
`locale this bundle does not carry a row for raises TEMPLATE_NOT_FOUND, which is permanent ` +
2739+
`(dead-letter, no retry). Your stack's own i18n.defaultLocale is the wrong tag here unless ` +
2740+
`it is spelled '${EMAIL_TEMPLATE_FLOOR_LOCALE}': tag the English row '${EMAIL_TEMPLATE_FLOOR_LOCALE}' ` +
2741+
`and keep the other tags beside it.`,
2742+
);
2743+
}
2744+
}
2745+
26662746
export function defineStack(
26672747
config: ObjectStackDefinitionInput,
26682748
options?: DefineStackOptions,
@@ -2757,6 +2837,11 @@ export function defineStack(
27572837
throw new StackTriggerCapabilityRequiredError(`${header}\n\n${lines.join('\n')}`, triggerErrors);
27582838
}
27592839

2840+
// Post-parse and advisory: the stack is valid and is returned unchanged.
2841+
// `locale` carries a default, so this has to run AFTER the parse or a row
2842+
// that omits the key would read as a missing floor it actually has.
2843+
warnEmailTemplateLocaleFloor(data);
2844+
27602845
return mergeActionsIntoObjects(data);
27612846
}
27622847

0 commit comments

Comments
 (0)