From a2c9e5fe3275b8b2cec6abbfe17086ea17b28ece Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 03:59:32 +0000 Subject: [PATCH 1/2] docs(email): three shipped carriers said 'best-matching locale'; the resolver matches (name, locale) exactly Claude-Session: https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF Co-authored-by: Claude --- .../areas/integration-system.json | 12 +++++++++--- .../src/audit/sys-email-template.object.ts | 11 +++++++++-- .../plugin-email/src/template-loader.ts | 18 +++++++++++++----- .../objects/notification-template.object.ts | 8 +++++++- 4 files changed, 38 insertions(+), 11 deletions(-) diff --git a/docs/qa/platform-checklist/areas/integration-system.json b/docs/qa/platform-checklist/areas/integration-system.json index e7a03c90136..6ccc271d9a3 100644 --- a/docs/qa/platform-checklist/areas/integration-system.json +++ b/docs/qa/platform-checklist/areas/integration-system.json @@ -759,7 +759,7 @@ "title": "Email templates materialize to sys_email_template, resolve (name, locale) with en-US fallback, render {{path}} holes, gate on required variables and active:false, survive admin edits across redeploys — and the raw POST /api/v1/email/send door authenticates, refuses anonymous, and 400s malformed input", "since": "v15", "status": "active", - "revision": 4, + "revision": 5, "priority": "P2", "surface": "mixed", "fixtures": { @@ -815,9 +815,9 @@ "evidence": "the error" }, { - "clause": "(name, locale) resolution picks the best locale row and falls back to en-US — two rows with one name are an i18n bundle, both reachable by recipient locale", + "clause": "(name, locale) resolution is an EXACT match with no language-subtag folding — two rows with one name are an i18n bundle, each reachable ONLY from its own exact tag; a call naming a locale with no row retries the single literal rung en-US and then raises TEMPLATE_NOT_FOUND, and a call naming no locale starts at en-US", "oracle": "log", - "verify": "the zh-CN recipient gets the zh-CN render; the unmatched recipient gets the fallback", + "verify": "the zh-CN recipient gets the zh-CN render; a recipient whose locale has no row (e.g. zh) gets the en-US row, NOT the zh-CN one", "evidence": "the two renders" }, { @@ -891,6 +891,12 @@ "date": "2026-08-11", "change": "CORRECTION from run #7690: the raw-send step named a { to, subject, bodyHtml } message body, but those are the TEMPLATE authoring fields — the wire keys are `html`/`text` (SendEmailInput, email-service.ts), and the literal shape the step named is refused 400 'at least one of text or html is required'. Corrected the step and the clause, added the wrong-vocabulary probe that pins the two vocabularies apart, added a NOT-a-FAIL negative so the refusal is not filed as a defect next run, and cited the contract + normalizeMessage in source", "ref": "#7745" + }, + { + "revision": 5, + "date": "2026-09-21", + "change": "CORRECTION: the locale clause said resolution \"picks the best locale row\", a sentence packages/plugins/plugin-email/src/template-loader.ts enumerates as one of three false declarations. The resolver matches (name, locale) exactly, folds no language subtag, retries the single literal rung en-US for a call that named a locale and then raises TEMPLATE_NOT_FOUND. Corrected the clause and its verify line so the run cannot tick a best-match behaviour the code has never had", + "ref": "#18499" } ] }, diff --git a/packages/platform-objects/src/audit/sys-email-template.object.ts b/packages/platform-objects/src/audit/sys-email-template.object.ts index c8183e48e01..7d7fb971314 100644 --- a/packages/platform-objects/src/audit/sys-email-template.object.ts +++ b/packages/platform-objects/src/audit/sys-email-template.object.ts @@ -7,8 +7,15 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; * * Backing persistence for the `email_template` metadata type. Each * row is the runtime representation of an `EmailTemplate` Zod - * envelope. Resolved by `(name, locale)`; the EmailService picks the - * best-matching locale for the recipient, falling back to `en-US`. + * envelope. Resolved by an EXACT `(name, locale)` match — the + * EmailService does NO language-subtag folding, so `en-US` never + * reaches an `en` row and there is no "best match". A call that NAMES + * a locale retries exactly one rung, the literal `en-US`, and then + * raises `TEMPLATE_NOT_FOUND`; a call that names none starts AT + * `en-US` and, only when the bundle carries no `en-US` row at all, + * takes the bundle's lowest locale tag. See + * `@objectstack/plugin-email`'s `createSysEmailTemplateLoader` and + * `SendTemplateInput.locale` for the ladder of record. * * Authoring: built-in templates are seeded by `EmailServicePlugin` * on `kernel:ready`; administrators may edit subject/body in Studio diff --git a/packages/plugins/plugin-email/src/template-loader.ts b/packages/plugins/plugin-email/src/template-loader.ts index b34a67cbde5..759d2d82e63 100644 --- a/packages/plugins/plugin-email/src/template-loader.ts +++ b/packages/plugins/plugin-email/src/template-loader.ts @@ -15,11 +15,19 @@ * that; three separate places declare the opposite: * * - `SendTemplateInput.locale` (spec contract): *"Falls back to `'en-US'`"*. - * - `EmailTemplateDefinitionSchema.locale`: *"the service picks the best match - * for the recipient's locale, falling back to `en-US`"*. - * - `sys_email_template`'s own object doc: *"Resolved by `(name, locale)`; the - * EmailService picks the best-matching locale for the recipient, falling - * back to `en-US`"*. + * - `EmailTemplateDefinitionSchema.locale`: the resolver *"matches + * `(name, locale)` **exactly**"* and, for a call that named a locale, + * retries exactly one rung — `EMAIL_TEMPLATE_FLOOR_LOCALE`. + * - `sys_email_template`'s own object doc: resolved by an exact + * `(name, locale)` match with that same single `en-US` rung. + * + * Those last two bullets are CITED, not quoted in full, and deliberately: + * both sentences once read "picks the best match for the recipient's locale" + * / "picks the best-matching locale for the recipient" — the claim the rule + * below says the resolver has never implemented. They were corrected to the + * ladder as built (#18499), together with `sys_notification_template.locale` + * and the platform checklist's locale clause; a quotation is what stranded + * them here in the first place. * * ## The rule this implements * diff --git a/packages/services/service-messaging/src/objects/notification-template.object.ts b/packages/services/service-messaging/src/objects/notification-template.object.ts index 1027c0ef7e3..b770df5e507 100644 --- a/packages/services/service-messaging/src/objects/notification-template.object.ts +++ b/packages/services/service-messaging/src/objects/notification-template.object.ts @@ -62,7 +62,13 @@ export const NotificationTemplate = ObjectSchema.create({ defaultValue: 'en', // [#12978] Sibling-declaration bound (#11374 route A): the same // BCP-47 tag family `sys_email_template.locale` stores, bounded 16 - // there; both resolve a template by best-matching locale. + // there. The BOUND is shared; the RESOLUTION is not, and neither + // side picks a "best-matching" locale. This object is loaded by + // `NotificationTemplateStore` on `(topic, channel, locale)` and + // walks a fixed candidate list — the named tag, then its primary + // subtag (`zh-CN` -> `zh`), then `DEFAULT_LOCALE` ('en'). + // `sys_email_template` matches `(name, locale)` EXACTLY, folds no + // subtag at all, and retries the single literal rung `en-US`. maxLength: 16, description: "BCP-47 locale, e.g. 'en' / 'en-US' / 'zh-CN'.", }), From 380636ac44cfa984e26d71c332acd74da2da612a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 04:19:50 +0000 Subject: [PATCH 2/2] docs(email): changeset for the three corrected locale carriers Claude-Session: https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF Co-authored-by: Claude --- .changeset/18499-email-locale-docblocks.md | 46 ++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 .changeset/18499-email-locale-docblocks.md diff --git a/.changeset/18499-email-locale-docblocks.md b/.changeset/18499-email-locale-docblocks.md new file mode 100644 index 00000000000..19ac20e9323 --- /dev/null +++ b/.changeset/18499-email-locale-docblocks.md @@ -0,0 +1,46 @@ +--- +'@objectstack/platform-objects': patch +'@objectstack/plugin-email': patch +'@objectstack/service-messaging': patch +--- + +docs(email): the shipped carriers said "best-matching locale"; the resolver matches `(name, locale)` exactly (#18499) + +Clause-②: no — no accept set moves and no published payload key changes; the +corrected prose ships as JSDoc in each package's `dist/*.d.ts` (and, for +`@objectstack/service-messaging`, inside the bundled `dist/index.js`), which is +why this is a changeset rather than `skip-changeset`. + +`packages/plugins/plugin-email/src/template-loader.ts` already enumerates +"the EmailService picks the best-matching locale" as a FALSE declaration, and +three shipped carriers still stated it. Measured against the code at this +branch's base rather than against the card's transcription: + +- `createSysEmailTemplateLoader.load` — `locale` given ⇒ exact `{ name, locale }` + match ordered by `id`, or `null`; `locale` absent ⇒ `{ name, locale: 'en-US' }` + first, and only if that misses `{ name }` ordered by `locale` ascending; +- `EmailService.resolveAndRenderTemplate` — `wanted = input.locale?.trim() || + 'en-US'`, then exactly one retry at the literal `'en-US'` when the call NAMED a + locale, then `TEMPLATE_NOT_FOUND`; the unpinned rung is reachable only for a + call that named no locale. + +No language-subtag folding anywhere on that path, and nothing that could be +called a "best match". Corrected: + +- `sys_email_template`'s object doc (`@objectstack/platform-objects`) now states + the exact match, the single `en-US` rung and the no-locale last resort; +- `sys_notification_template.locale`'s sibling-declaration comment + (`@objectstack/service-messaging`) said "both resolve a template by + best-matching locale", which was false in a second way: the two resolvers do + not agree. `NotificationTemplateStore.load` walks `(topic, channel, locale)` + through a candidate list — the named tag, its primary subtag, then + `DEFAULT_LOCALE` (`'en'`) — so it DOES fold a subtag, where + `sys_email_template` does not. Only the shared 16-char BCP-47 bound is shared; + the resolution is not, and the comment now says so; +- `template-loader.ts`'s own "What was wrong" block quoted two sentences it can + no longer quote — one was already stale at this base (the + `EmailTemplateDefinitionSchema.locale` text it reproduces has zero occurrences + in `packages/spec` today) and the other is corrected above. Both bullets are + now cited rather than quoted, so a later rewording cannot strand them again. + +No resolution behaviour changes: every edit in this changeset is prose.