The trap
An app declares its locale vocabulary once, in defineStack:
i18n: { defaultLocale: 'en', supportedLocales: ['en', 'zh-CN', 'ja-JP', 'es-ES'], fallbackLocale: 'en' }
So when that app authors an emailTemplates bundle, the obvious spelling of the English row is locale: 'en' — the same tag the stack already calls its default. That row validates, builds and installs clean. There is no author-time diagnostic anywhere.
At delivery it permanently dead-letters for any recipient the bundle does not carry a row for.
Measured, on the installed 17.4.0
EmailService.resolveAndRenderTemplate (@objectstack/plugin-email) matches (name, locale) exactly, and the single fallback rung is the literal string en-US:
const preferred = input.locale && String(input.locale).trim();
const wanted = preferred || DEFAULT_TEMPLATE_LOCALE; // DEFAULT_TEMPLATE_LOCALE = "en-US"
let row = await loader.load(input.template, wanted);
if (!row && wanted !== DEFAULT_TEMPLATE_LOCALE) {
row = await loader.load(input.template, DEFAULT_TEMPLATE_LOCALE);
}
if (!row && !preferred) { row = await loader.load(input.template, void 0); }
if (!row) { throw new Error(`TEMPLATE_NOT_FOUND: ${input.template} (locale=${wanted})`); }
There is no language-subtag folding on this path: en-US does not fall back to en, and en is not reachable from en-US. TEMPLATE_NOT_FOUND classifies permanent, so the delivery dead-letters without retry.
sys_user.locale is a free-text BCP-47 column — maxLength: 35, pattern ^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$, no default, no enum, user-editable. Nothing constrains it to the app's supportedLocales.
Cross that with resolveRecipientLocale's ruled chain (recipient's own sys_user.locale, then the deployment default) and a bundle whose English row is tagged en behaves like this:
recipient's sys_user.locale |
bundle tagged en |
bundle tagged en-US |
unset — deployment default en applies |
delivers |
delivers, via the en-US retry |
en-US |
TEMPLATE_NOT_FOUND (permanent) |
delivers |
de-DE — legal tag, not in supportedLocales |
TEMPLATE_NOT_FOUND (permanent) |
delivers |
Same metadata, same gates, same green build. The only difference is a three-character spelling the authoring surface never mentions.
Why an author picks the wrong one
EmailTemplateDefinitionSchema.locale is z.ZodDefault(z.ZodString) — any string, defaulting to en-US. Its describe and the schema module's header say the template is resolved by (name, locale); neither says that en-US is a floor with special standing, nor that an explicitly-authored en forfeits the fallback the default would have given.
The one place the rule is written down is a docstring two packages away, in service-messaging's recipient-locale module, under a heading about a different subject:
The one asymmetry: such a bundle that carries the deployment default's row but not en-US delivered under the old single value and fails for a recipient whose own locale is a third tag. That bundle is off the documented contract (en-US is the ladder's floor), so the fix is the bundle, not a third rung here.
That paragraph is correct and it is where this behaviour was reasoned about. It is not where an app author is standing when they type locale:.
⭐ The asymmetry is sharper than that paragraph suggests, because it is not only "a third tag": the app's own declared default locale is the wrong answer whenever it is not spelled en-US. An app that says defaultLocale: 'en' and authors locale: 'en' has done the consistent thing throughout and still shipped a bundle with no floor.
Carrier
objectstack-ai/hotcrm#1185 — 15 notify nodes converted to template references, four locales each (objectstack-ai/hotcrm PR 1893). That app declares defaultLocale: 'en'; its bundles are tagged en-US only because this path was read end to end first. The card's own scope line, and the app's supportedLocales, both say en.
What would close this
Either of these; whichever the maintainer prefers. ⛔ Not proposing a behaviour change without a ruling — the ladder's shape is settled by 2026-09-01 (#13881).
- Say it where the author is.
EmailTemplateDefinitionSchema.locale's describe states that the resolver matches exactly, retries only en-US, and that a bundle without an en-US row has no floor. Cheapest, no behaviour change.
- Make it unrepresentable or diagnosable. An author-time rule that a bundle carrying rows for a stack's
supportedLocales but none tagged en-US is reported, the way other declared-but-unreachable metadata is reported today.
Language-subtag folding in the resolver is deliberately not proposed: it would change delivery behaviour for every existing bundle, which is a ruling and not a finding.
The trap
An app declares its locale vocabulary once, in
defineStack:So when that app authors an
emailTemplatesbundle, the obvious spelling of the English row islocale: 'en'— the same tag the stack already calls its default. That row validates, builds and installs clean. There is no author-time diagnostic anywhere.At delivery it permanently dead-letters for any recipient the bundle does not carry a row for.
Measured, on the installed 17.4.0
EmailService.resolveAndRenderTemplate(@objectstack/plugin-email) matches(name, locale)exactly, and the single fallback rung is the literal stringen-US:There is no language-subtag folding on this path:
en-USdoes not fall back toen, andenis not reachable fromen-US.TEMPLATE_NOT_FOUNDclassifiespermanent, so the delivery dead-letters without retry.sys_user.localeis a free-text BCP-47 column —maxLength: 35, pattern^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$, no default, no enum, user-editable. Nothing constrains it to the app'ssupportedLocales.Cross that with
resolveRecipientLocale's ruled chain (recipient's ownsys_user.locale, then the deployment default) and a bundle whose English row is taggedenbehaves like this:sys_user.localeenen-USenappliesen-USretryen-USde-DE— legal tag, not insupportedLocalesSame metadata, same gates, same green build. The only difference is a three-character spelling the authoring surface never mentions.
Why an author picks the wrong one
EmailTemplateDefinitionSchema.localeisz.ZodDefault(z.ZodString)— any string, defaulting toen-US. Itsdescribeand the schema module's header say the template is resolved by(name, locale); neither says thaten-USis a floor with special standing, nor that an explicitly-authoredenforfeits the fallback the default would have given.The one place the rule is written down is a docstring two packages away, in
service-messaging'srecipient-localemodule, under a heading about a different subject:That paragraph is correct and it is where this behaviour was reasoned about. It is not where an app author is standing when they type
locale:.⭐ The asymmetry is sharper than that paragraph suggests, because it is not only "a third tag": the app's own declared default locale is the wrong answer whenever it is not spelled
en-US. An app that saysdefaultLocale: 'en'and authorslocale: 'en'has done the consistent thing throughout and still shipped a bundle with no floor.Carrier
objectstack-ai/hotcrm#1185 — 15
notifynodes converted to template references, four locales each (objectstack-ai/hotcrm PR 1893). That app declaresdefaultLocale: 'en'; its bundles are taggeden-USonly because this path was read end to end first. The card's own scope line, and the app'ssupportedLocales, both sayen.What would close this
Either of these; whichever the maintainer prefers. ⛔ Not proposing a behaviour change without a ruling — the ladder's shape is settled by 2026-09-01 (#13881).
EmailTemplateDefinitionSchema.locale'sdescribestates that the resolver matches exactly, retries onlyen-US, and that a bundle without anen-USrow has no floor. Cheapest, no behaviour change.supportedLocalesbut none taggeden-USis reported, the way other declared-but-unreachable metadata is reported today.Language-subtag folding in the resolver is deliberately not proposed: it would change delivery behaviour for every existing bundle, which is a ruling and not a finding.