Skip to content

Commit 2d892dd

Browse files
os-warrenclaude
andauthored
docs(spec): scope the email-template locale-floor claims to a call that names a locale (#18482)
Fixes #18056 `packages/spec` stated **two different rung counts for one resolution**. This decides which text is wrong **by measurement against the runtime**, not by which was easier to edit, and pins the floor guard's two silent early-returns without changing what it warns about. `Clause-②: yes` — the card's claim comment (`5698961929`) is the carrier; this body restates it for legibility. The changeset is `minor`, which that declaration requires, and the diff genuinely ships (measured below). ## Remediation round (text-only, no logic moved) An isolated at-tier contract review of head `708595dc9c` returned FAIL on the **completeness of the surrounding claims**, not on the work: the three-rung reading, the untouched docs page and the Half B ablation all verified. `642da7fda5` closes the five required items, every one of them text: 1. `.changeset/email-template-locale-floor.md` — the **pending, unreleased** note from #17884 (`a61ae59f93`, 2026-09-12: the only commit that has ever touched that file, and its `--diff-filter=A` add) still said *"retries exactly one rung — the literal `en-US`"* and *"the resolver's sole retry rung"*, unscoped. `changeset version` would have compiled both **verbatim into the published `packages/spec/CHANGELOG.md`** — which that package ships in its `files[]` — beside this PR's correction of them: the erratum-in-a-later-entry form `AGENTS.md:686` 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 for the same reason. 2. `packages/spec/src/stack-email-template-locale-floor.test.ts:12-14` — header sentence scoped, with the no-locale case named beside it. Its title line's bare *"no fallback floor"* is scoped the same way. 3. `packages/spec/src/system/email-template-floor-locale-parity.pin.test.ts:33` — *"its single retry rung"* now names both rungs and which call shape reaches each. 4. The changeset **and** this body now state that the guard's warning TEXT changed — see Half B, where *"byte-for-byte"* is gone and *"control flow unchanged"* stays. 5. The acceptance note on `sys-email-template.object.ts` is corrected — see Acceptance notes. ⛔ **`Check Changeset` is RED on this head by design, and must not be turned green.** The gate refuses because `.changeset/email-template-locale-floor.md` exists on the merge base and was not added by this PR. Item 1 is its **DELIBERATE CORRECTION** arm, ⛔ not its COLLISION arm — so the gate's own step 1, restoring that file from the base, is the one thing not to do here: it would republish the sentence this PR proves false. Restoring it, renaming the edit into a second changeset, and labelling around it were each considered and rejected; the middle one is the erratum shape, and `skip-changeset` suppressing this very refusal is itself an open finding (#18375). The blob is byte-identical on the merge base and on `origin/main` (`13881152ee`), so no concurrent note is being clobbered. The gate stays red until a human confirms the correction; that confirmation is the seat's and is requested in its own comment. **No logic line moved — measured, not asserted.** Both `.ts` files were reprinted through the TypeScript printer with `removeComments` and hashed: stripped sha256 `c026b27d…` and `98e79f85…`, byte-identical before and after, while both raw file hashes changed. Dark control on a pair that really does move code (`8fe5cb8e51..708595d` on `stack.zod.ts`) reads DIFFERENT, so the instrument is not blind. Independently: all 24 changed lines in those two files match a comment-line shape, and the same filter over that known-logic diff finds 12 non-comment lines. ## Half A — which text is wrong Read against `EmailService.resolveAndRenderTemplate` and `createSysEmailTemplateLoader` in `@objectstack/plugin-email`: | rung | condition | outcome | |---|---|---| | 1 | the named locale, matched exactly | that row; no language-subtag folding | | 2 | the literal `en-US` — also where a call naming **no** locale starts | that row | | 3 | **only** when the call named no locale, and the bundle has no `en-US` row | the bundle's **lowest locale tag**, rendered silently | A call that **names** a locale never reaches rung 3: it dead-letters with `TEMPLATE_NOT_FOUND` (permanent). A call that names **none** never dead-letters on a non-empty bundle. **Verdict: `contracts/email-service.ts` was right and `system/email-template.zod.ts` was wrong.** Four independent statements agree with the code, one did not: - ✅ `SendTemplateInput.locale` (`contracts/email-service.ts`) — three rungs, correct. - ✅ `content/docs/automation/email-templates.mdx` — three rungs, correct, including "A call that names a locale with no exact row and no `en-US` row fails with `TEMPLATE_NOT_FOUND`". - ✅ `examples/app-showcase/src/system/emails/index.ts` — "exact match → `en-US` → (no-locale calls only) the bundle's lowest tag". - ✅ The CI pin `plugin-email/src/template-locale-resolution.test.ts` — rung 3 resolving for a no-locale call, and explicitly **not** widened for a named one ("an explicit locale is NOT widened to 'any row' when neither it nor en-US exists"). - ❌ `EmailTemplateDefinitionSchema.locale` + `EMAIL_TEMPLATE_FLOOR_LOCALE` — one rung and "no fallback floor at all". So the published declaration promised a **loud permanent refusal** on exactly the path where the runtime performs a **silent fill**. Fixed text-only, in-fence: **inside `packages/spec`** every floor claim is now scoped to "a call that NAMES a locale", the no-locale rung is stated beside it, and the ladder itself is stated in one place only. Carriers **outside** that package still state the old claim — they are named under Acceptance notes and deliberately not touched here. Also corrected in the same fence, and declared rather than smuggled: the `SendTemplateInput.template` TSDoc said the service "picks the best-matching locale row". Measured — there is no best match and no folding anywhere in the resolver, so that sentence described a behaviour this package has never had. Same defect class, same declared file surface, same gate family. And `en`/`en-US` were called "different bundles" one paragraph after the TSDoc defines a bundle as rows sharing one `name`. They are different **rows** of one bundle; the rewritten sentence says so. ### Both sides of the generated projection, all four numbers That `describe` projects into `content/docs/references/system/email-template.mdx`. A source-only fix would have left the old sentence on the reference page: | | old spelling | new spelling | |---|---|---| | SOURCE `packages/spec/src/system/email-template.zod.ts` | 0 | 1 | | GENERATED `content/docs/references/system/email-template.mdx` | 0 | 1 | Control: `BCP-47 locale` still returns 1 in the generated file, so the zero above is a real absence and not an unreadable path. Before `gen:docs` the generated column read `1 / 0` — the trap, caught and closed. `check:generated` now reports all 15 artifacts up to date. ### It ships, measured on the built artifact `packages/spec` publishes `dist` and `src/**/*.zod.ts`. In the built tree: **16** files carry the new describe string, **16** carry the new TSDoc scope, **0** carry the old spelling; positive control `BCP-47 locale` = 38 files. Hence the changeset, and hence `minor`. ## Half B — declared, pinned, and NOT flipped `warnEmailTemplateLocaleFloor`'s **control flow is unchanged** — the same bundles warn, once each, and the warning stays advisory. What it gains is a declaration of the two shapes it does not examine, and pins that hold both. ⚠️ **Its emitted warning TEXT did change, and now names BOTH call shapes** (5 concatenated lines → 7): 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. The changeset says the same. An earlier revision of this body called the guard unchanged *"byte-for-byte"* — true of the control flow, false of the string — so that phrase is gone. Measured, and sharper than the card had it: **early return 1 decides nothing on its own.** `supportedLocales` is REQUIRED inside `i18n`, so its absent arm is reachable only via a stack with no `i18n` block; and with no supported set every bundle's `declared` list is empty, so early return 2 skips exactly the same shapes. What return 1 actually buys is not reading `.map` off `undefined` — deleting it takes `defineStack` down with a TypeError, which the new pin asserts. **Blast radius, both directions, with a live control.** In-tree stacks declaring `emailTemplates`: two — `examples/app-showcase` (real i18n block parsed from its own config, real `allEmails` module) and `packages/qa/dogfood/.../email-template-materialization-fixture.ts` (whole stack). Both carry an `en-US` row, so both hit the floor check before either early return is reached. - would newly warn if either early return were removed: **0** - currently warning that would stop warning: **0** (no logic changed) - positive control, the #17614 trap shape: **1 warning** — the harness is not blind The enforce-or-remove question (ADR-0049) is **not** answered here; it is recorded in the docblock and reported to the seat. ## Ablation — the pins bite, proven on disk Every leg mutated the tree, proved the mutation by anchor count **before** any result was read, and restored by blob hash plus an empty `git diff HEAD`. Run from the committed state, under a `trap ... EXIT INT TERM` with absolute paths. | leg | mutation (proven on disk) | result | |---|---|---| | 0 | none, clean `c50c54efae` | 14 passed | | 1 | delete early return 1 (anchor 1 → 0, blob `7ba260b6` vs HEAD `a9f45392`) | **3 failed** / 11 passed | | 2 | delete early return 2 (anchor 1 → 0, blob `4c24ea22` vs HEAD `a9f45392`) | **2 failed** / 12 passed | | 3 | put the shipped unscoped `describe` back (`NAMES a locale` 2 → 1, blob `0c068b72` vs HEAD `915c2e31`) | **1 failed** / 13 passed | Restore verified each time: `RESTORED-OK … blob == HEAD`, final `GIT_DIFF_HEAD_EMPTY=true`. No ablation artefact is left in the tree. ## Verification - `pnpm --filter @objectstack/spec test` — **483 files, 13777 tests passed** - `pnpm --filter @objectstack/spec typecheck` — clean - `pnpm --filter @objectstack/spec check:generated` — **all 15 artifacts up to date** - `eslint . --no-inline-config` over the **full** repo population: **6796 files, 0 errors, 0 warnings, exit 0** — run at final commit `708595dc9c`, so no narrowing claim is needed - Gate families derived from the real change set by `scripts/pm/dispatch-gates.mjs` (never a hand-written path list): **109 derived, 107 run green, 2 NOT MEASURED** (`check:dual-build-cjs-loads`, `check:type-check-debt` — both exit 3 `PREREQUISITE NOT MET`, an unbuilt workspace closure, which CI builds fresh) - One gate red, proven **not ours** by a two-leg control on a pristine `origin/main` worktree: `check:cross-package-test-inputs`. Leg A (all six of this PR's paths, no `packages/spec/dist/`) → **exit 0**. Leg B (zero of this PR's paths, plus an empty `packages/spec/dist/`) → **exit 1, identical finding**. It reds on the presence of a local spec build, not on this diff. Already filed as **#18440** (open, 2026-09-16T10:52Z) — ⛔ not re-filed here. Re-measured on this round's worktree with no `packages/spec/dist` present: **exit 0**, the same pre-build leg. ### This round (`642da7fda5`) - `pnpm --filter @objectstack/spec exec vitest run` over the two edited test files — **2 files, 17 tests passed**, exit 0 (under the shared verify lock, `VERDICT command-exit 0`) - `pnpm --filter @objectstack/spec typecheck` — exit 0 - Green, each exit code captured by redirect-then-`$?` and never through a pipe: `check:nul-bytes`, `check:changeset-no-major`, `check:adr-0087-registration`, `check:comment-mask-adoption`, `check:comment-mask-corpus`, `check:spec-docblock-symbol-anchors`, `check:keyed-text-bounds`, `check:test-source-alias`, `check:pm-widening-tells`, `check:objectui-changeset`, `check:pm-changeset-deadline-census`, `check:closing-keyword-parity`, `check:cross-package-test-inputs` - `check:empty-changeset` — **exit 1, RED BY DESIGN** (DELIBERATE CORRECTION arm, above). Its own `--self-test` passes, 159 assertions, so the instrument is sound. - The full CI farm on this head is left to CI and is **in progress**, not assumed green. ## Acceptance notes Noted, not filed — each with who would meet it: - `packages/spec/src/system/email-template.form.ts` says nothing about the floor, so the Studio authoring path teaches none of the above. A gap, not an error. Successor: the next card touching the email-template authoring form. - ⚠️ **Correction to an earlier revision of this note.** `packages/platform-objects/src/audit/sys-email-template.object.ts:10-11` does **not** "say nothing about the floor". Measured on this tree, it carries the false sentence *"Resolved by `(name, locale)`; the EmailService picks the best-matching locale for the recipient, falling back to `en-US`"* — verbatim the third of the three false declarations that `packages/plugins/plugin-email/src/template-loader.ts:20-22` already names. The same claim lives at `packages/services/service-messaging/src/objects/notification-template.object.ts:65` ("both resolve a template by best-matching locale") and at `docs/qa/platform-checklist/areas/integration-system.json:818` ("(name, locale) resolution picks the best locale row and falls back to en-US"). There is no best match and no language-subtag folding anywhere in the resolver, so all three are **false**, not merely silent. **Out of this card's declared surface — named here for a successor and deliberately NOT fixed in this PR.** Successor: a platform-objects / service-messaging stale-carrier card, which the seat holds the finding for. - `packages/metadata-core/src/item-key-discriminators.ts` quotes `email-template.zod.ts` as saying the service "picks the best match for the recipient's locale" — measured 0 hits there (controls `i18n bundle` = 1, `must stay equal` = 1). The sentence lives, in a variant, in `contracts/email-service.ts`, and this PR corrects that variant, so the quotation is now doubly stale. Successor: the next card on email-template identity keying. - A cross-package pin holding the spec's published `describe` equal to the runtime's ladder would close this class mechanically, in the shape `email-template-floor-locale-parity.pin.test.ts` already uses for the constant. Successor: none today — offered to the seat as a follow-up. --- _Generated by [Claude Code](https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6)_ --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent fade3da commit 2d892dd

8 files changed

Lines changed: 289 additions & 67 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
docs(spec): scope the email-template locale-floor claims to a call that NAMES a locale (#18056)
6+
7+
Clause-②: yes — no accept set moves (no key is added, removed or revalidated),
8+
but what a PUBLISHED package states about its own resolution contract is
9+
corrected, which is a contract act in substance.
10+
11+
`packages/spec` stated two different rung counts for one resolution.
12+
`EmailTemplateDefinitionSchema.locale`'s `describe` and the
13+
`EMAIL_TEMPLATE_FLOOR_LOCALE` docblock published **one** retry rung and an
14+
explicit "no fallback floor at all"; `SendTemplateInput.locale` in
15+
`contracts/email-service.ts`, same package, documents a **three-rung** ladder
16+
whose third rung is reachable exactly on the path the first says cannot exist.
17+
18+
Measured against the runtime rather than reconciled by preference —
19+
`EmailService.resolveAndRenderTemplate` and `createSysEmailTemplateLoader` in
20+
`@objectstack/plugin-email`, and the CI pins in
21+
`template-locale-resolution.test.ts` — the three-rung text is the correct one:
22+
23+
1. the named locale, matched exactly (no language-subtag folding);
24+
2. the literal `en-US`, which is also where a call naming no locale starts;
25+
3. **only for a call that named no locale**, and only when the bundle carries
26+
no `en-US` row: the bundle's lowest locale tag.
27+
28+
So a bundle with no `en-US` row dead-letters (`TEMPLATE_NOT_FOUND`, permanent)
29+
for every recipient whose locale was NAMED, and silently renders whichever
30+
language sorts first for every call that named none. The shipped declaration
31+
promised the loud permanent refusal on the path where the runtime performs the
32+
silent fill; an author reading it was told a missing locale always
33+
dead-letters. Both call shapes are now named wherever the floor is claimed, and
34+
the ladder itself is stated in one place only.
35+
36+
Also corrected: `SendTemplateInput.template` said the service "picks the
37+
best-matching locale row", which the resolver has never done — there is no
38+
best match and no folding, only the ladder above.
39+
40+
`defineStack`'s `warnEmailTemplateLocaleFloor` gains a declaration of the two
41+
shapes it deliberately does NOT examine (a stack whose `i18n.supportedLocales`
42+
is absent or empty; a bundle whose tags all fall outside `supportedLocales`) —
43+
both can still ship a floorless bundle. Its control flow is unchanged — the same
44+
bundles warn, once each, and the warning stays advisory — but the emitted warning
45+
TEXT did change, and now names BOTH call shapes: it says the bundle has no
46+
fallback floor *for a send that names a locale*, and adds that a send naming NO
47+
locale does not fail but drops to that bundle's lowest tag and renders it
48+
silently. A test asserting on the old wording needs updating. Whether either
49+
undeclared shape should warn is the ADR-0049 enforce-or-remove question and is
50+
not answered here. Both shapes are now pinned against a warning discriminator so
51+
neither can change without a test saying so.

‎.changeset/email-template-locale-floor.md‎

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,22 @@
44

55
Email templates: say where the `en-US` fallback floor is, and report a bundle that has none.
66

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.
7+
`IEmailService.sendTemplate` matches `(name, locale)` exactly and, for a call that NAMES a
8+
locale, retries exactly one rung — the literal `en-US` — and stops. There is no language-subtag
9+
folding, so a bundle whose English row is tagged `en` is unreachable from `en-US` and from every
10+
other tag it does not itself carry; each such delivery raises `TEMPLATE_NOT_FOUND`, which
11+
classifies permanent, so it dead-letters with no retry. An app declaring
12+
`i18n.defaultLocale: 'en'` and authoring `locale: 'en'` has done the consistent thing throughout
13+
and still shipped a bundle with no floor for those calls — and it validated, built and installed
14+
clean.
1415

1516
- `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`.
17+
one literal `en-US` rung a call that NAMES a locale gets, the absence of folding, and that the
18+
stack's own declared default locale is the wrong tag whenever it is not spelled `en-US`.
1819
- 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+
and the rung `sendTemplate` retries for a call that NAMES a locale. The full ladder — including
21+
the lowest-tag rung reachable only by a call that names NO locale — is on
22+
`SendTemplateInput.locale` in `packages/spec/src/contracts/email-service.ts`.
2023
- `defineStack` now reports (advisory `console.warn`, warn-once per bundle) an `emailTemplates`
2124
bundle that carries rows for the stack's own `i18n.supportedLocales` but none tagged `en-US`.
2225

‎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) — 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`. |
47+
| **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`. |
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/src/contracts/email-service.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,8 +150,9 @@ export interface SendEmailResult {
150150
export interface SendTemplateInput {
151151
/**
152152
* Template identifier (matches `sys_email_template.name`), e.g.
153-
* `'auth.password_reset'`. The service picks the best-matching
154-
* locale row (falls back to `en-US`).
153+
* `'auth.password_reset'`. There is no "best match" and no language-subtag
154+
* folding: the locale row is resolved by the exact ladder documented on
155+
* `locale` below.
155156
*/
156157
template: string;
157158
/** Envelope recipients. */

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

Lines changed: 116 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22
//
33
// #17614 — an `emailTemplates` bundle tagged with the stack's OWN
4-
// `i18n.defaultLocale` has no fallback floor.
4+
// `i18n.defaultLocale` has no fallback floor for a send that names a locale.
55
//
66
// Measured before this landed, on an unmodified tree: a stack declaring
77
// `defaultLocale: 'en'`, `supportedLocales: ['en','zh-CN','ja-JP','es-ES']`
@@ -10,10 +10,13 @@
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
1212
// `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.
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.
1720
//
1821
// These pin the diagnostic ADVISORY: every case asserts the parse still
1922
// succeeds and the stack comes back unchanged. The diagnostic narrows what
@@ -77,6 +80,23 @@ describe('#17614 — the floor tag is one named constant, not two spellings', ()
7780
expect(text).toContain('en-US');
7881
expect(text).toContain('TEMPLATE_NOT_FOUND');
7982
});
83+
84+
// [#18056] The same `describe` used to promise that a floorless bundle
85+
// dead-letters, full stop. Measured against `EmailService`'s ladder
86+
// (`resolveAndRenderTemplate` → `createSysEmailTemplateLoader`), that is true
87+
// only for a call that NAMED a locale: a call naming none drops to the
88+
// bundle's lowest tag and renders it, which `plugin-email`'s
89+
// `template-locale-resolution.test.ts` pins as resolving, not dead-lettering.
90+
// A published declaration promising a loud permanent refusal where the
91+
// runtime performs a silent fill is the defect; both call shapes must stay
92+
// named, so the promise cannot quietly go unconditional again.
93+
it('[#18056] scopes the dead-letter promise to a call that NAMES a locale, and states the other case', () => {
94+
const text = String(EmailTemplateDefinitionSchema.shape.locale.description ?? '');
95+
expect(text).toMatch(/NAMES a locale/);
96+
expect(text).toMatch(/lowest locale tag/);
97+
// ⛔ The unscoped promise itself, in the spelling it shipped in.
98+
expect(text).not.toMatch(/therefore has no fallback floor: any recipient locale/);
99+
});
80100
});
81101

82102
describe('#17614 — defineStack reports a bundle with no `en-US` floor', () => {
@@ -111,6 +131,11 @@ describe('#17614 — defineStack reports a bundle with no `en-US` floor', () =>
111131
});
112132

113133
describe('#17614 — and stays silent where the bundle HAS a floor (the controls)', () => {
134+
// ⛔ Every case in THIS block is a bundle that genuinely carries the floor
135+
// row. The shapes that are floorless and silent anyway are the guard's scope
136+
// boundary and live in their own block below — filing them here read as
137+
// "these have a floor", which is exactly the kind of sentence about runtime
138+
// behaviour nobody re-reads (#18056).
114139
it('silent when the bundle carries an en-US row beside the supported tags', () => {
115140
const { warns, value } = warningsOf(stack(
116141
['en-US', 'zh-CN', 'ja-JP', 'es-ES'].map((l) => tpl(l)), THE_TRAP,
@@ -129,20 +154,97 @@ describe('#17614 — and stays silent where the bundle HAS a floor (the controls
129154
expect(value.emailTemplates?.[0]?.locale).toBe(EMAIL_TEMPLATE_FLOOR_LOCALE);
130155
});
131156

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-
});
157+
});
141158

159+
describe('#17614 — warn-once bookkeeping', () => {
142160
it('warns once for one bundle, however many times the same stack is defined', () => {
143161
const first = warningsOf(stack([tpl('pt-BR')], { defaultLocale: 'pt-BR', supportedLocales: ['pt-BR'] }));
144162
const second = warningsOf(stack([tpl('pt-BR')], { defaultLocale: 'pt-BR', supportedLocales: ['pt-BR'] }));
145163
expect(floorWarns(first.warns)).toHaveLength(1);
146164
expect(floorWarns(second.warns)).toEqual([]);
147165
});
148166
});
167+
168+
// ── #18056 — the two shapes the guard returns early on ──────────────────────
169+
//
170+
// ⛔ NOT controls. Every bundle below genuinely carries NO `en-US` row, so the
171+
// hazard is real and the silence is this guard's DECLARED SCOPE, not a pass.
172+
// `warnEmailTemplateLocaleFloor`'s docblock now states both; these hold it to
173+
// that, in both directions — each silent case is paired with a DISCRIMINATOR
174+
// that warns, so "silent" can never be read out of a harness that had simply
175+
// stopped reporting. Whether either shape SHOULD warn is the ADR-0049
176+
// enforce-or-remove question and is not decided here; what is closed is the
177+
// silence being undeclared and unpinned.
178+
179+
describe('#18056 — the guard\'s declared scope boundary', () => {
180+
it('early return 1: a stack with no `i18n` block is never examined, floorless or not', () => {
181+
const { warns, value } = warningsOf(stack([tpl('en', 'acme.scope_no_i18n')]));
182+
expect(floorWarns(warns)).toEqual([]);
183+
// …and the bundle really is floorless: one row, tagged `en`, no `en-US`.
184+
expect(value.emailTemplates?.map((t) => t.locale)).toEqual(['en']);
185+
186+
// DISCRIMINATOR — the identical floorless bundle, under a stack that does
187+
// declare the tag. The guard is awake; shape alone decides.
188+
const seen = warningsOf(stack(
189+
[tpl('en', 'acme.scope_no_i18n_disc')], { defaultLocale: 'en', supportedLocales: ['en'] },
190+
));
191+
expect(floorWarns(seen.warns)).toHaveLength(1);
192+
});
193+
194+
it('early return 1: `supportedLocales: []` is the other arm — `i18n` cannot omit the key', () => {
195+
// Measured: `supportedLocales` is REQUIRED inside `i18n`, so the
196+
// `!Array.isArray` arm is reachable only by omitting `i18n` entirely and
197+
// the `length === 0` arm only by a literal empty array. Both are silent.
198+
const { warns } = warningsOf(stack(
199+
[tpl('en', 'acme.scope_empty_supported')], { defaultLocale: 'en', supportedLocales: [] },
200+
));
201+
expect(floorWarns(warns)).toEqual([]);
202+
});
203+
204+
it('early return 1 guards the read as much as it scopes — defineStack must not throw', () => {
205+
// What deleting it actually costs. `supported.map(...)` off an absent
206+
// `i18n` is a TypeError out of `defineStack` itself, and BOTH in-tree
207+
// stacks that declare `emailTemplates` would take it (measured 2026-09-16:
208+
// examples/app-showcase and the qa/dogfood materialization fixture).
209+
expect(() => defineStack(stack([tpl('en', 'acme.scope_nothrow')]))).not.toThrow();
210+
});
211+
212+
it('early return 2: a bundle whose tags are ALL outside supportedLocales is skipped', () => {
213+
const i18n = { defaultLocale: 'en-GB', supportedLocales: ['en-GB'] };
214+
const { warns, value } = warningsOf(stack([tpl('fr-CA', 'acme.scope_outside')], i18n));
215+
expect(floorWarns(warns)).toEqual([]);
216+
expect(value.emailTemplates?.map((t) => t.locale)).toEqual(['fr-CA']);
217+
218+
// DISCRIMINATOR — same stack, same floorlessness, one tag moved INSIDE
219+
// `supportedLocales`. So the silence above is `declared` being empty, one
220+
// line after the floor check established the bundle has no floor row.
221+
const seen = warningsOf(stack([tpl('en-GB', 'acme.scope_inside')], i18n));
222+
expect(floorWarns(seen.warns)).toHaveLength(1);
223+
expect(floorWarns(seen.warns)[0]).toContain("carries rows for 'en-GB'");
224+
});
225+
226+
it('early return 1 decides nothing early return 2 would not — the outcomes are equal', () => {
227+
// Measured (#18056): with no supported set every bundle's `declared` list
228+
// is empty, so shape 2 skips exactly what shape 1 returns before reaching.
229+
// Pinning the OUTCOMES equal means a future change that gives shape 1 its
230+
// own meaning has to come and say so here rather than landing silently.
231+
const tags = (n: string) => [tpl('en', n), tpl('zh-CN', n)];
232+
const noI18n = warningsOf(stack(tags('acme.scope_shadow_a')));
233+
const emptySupported = warningsOf(stack(
234+
tags('acme.scope_shadow_b'), { defaultLocale: 'en', supportedLocales: [] },
235+
));
236+
const allOutside = warningsOf(stack(
237+
tags('acme.scope_shadow_c'), { defaultLocale: 'ja-JP', supportedLocales: ['ja-JP'] },
238+
));
239+
expect(floorWarns(noI18n.warns)).toEqual([]);
240+
expect(floorWarns(emptySupported.warns)).toEqual([]);
241+
expect(floorWarns(allOutside.warns)).toEqual([]);
242+
243+
// DISCRIMINATOR for all three: the same two-row floorless bundle, with its
244+
// tags declared. One `supportedLocales` edit is the whole difference.
245+
const seen = warningsOf(stack(
246+
tags('acme.scope_shadow_d'), { defaultLocale: 'en', supportedLocales: ['en', 'zh-CN'] },
247+
));
248+
expect(floorWarns(seen.warns)).toHaveLength(1);
249+
});
250+
});

0 commit comments

Comments
 (0)