diff --git a/.changeset/notify-template-locale-is-deployment-default.md b/.changeset/notify-template-locale-is-deployment-default.md new file mode 100644 index 0000000000..8b04d7a0c3 --- /dev/null +++ b/.changeset/notify-template-locale-is-deployment-default.md @@ -0,0 +1,51 @@ +--- +'@objectstack/spec': patch +--- + +`NotifyConfigSchema.template` now states the locale semantics the delivery path actually enforces — the deployment default, not a per-recipient locale + +The `notify` node's localizable path (`template` → a `sys_email_template` bundle) +was documented in `packages/spec/src/automation/io-node-config.zod.ts` as +resolving `(name, recipient locale)` **per recipient** at delivery time, and the +`template` `.describe()` added that it "renders subject/body per recipient". +Read plainly — and it is the text a consuming app's author reads — that says the +recipient's own language selects the template row. + +It does not, and deliberately does not. The delivery path +(`service-messaging/src/email-channel.ts`) has said so honestly at its own +`getDefaultTemplateLocale` all along: the platform has no per-user locale +(`sys_user` carries no locale column), and request-scoped locale +(`Accept-Language` → `ExecutionContext.requestLocale`) does not exist at async +delivery time, so "recipient locale" resolves to the **deployment default**, +`II18nService.getDefaultLocale()` — the same ruled source the auth emails use. +The one lever is `payload.locale`, and that is interpolated **once, before +fan-out**, so it is a single value for the whole notification at all three +`channel.send` call sites (`fanOut`, the outbox single-delivery path, and +`processDigestGroup`). + +The gap mattered because the wording licensed exactly one conclusion — "convert +the nodes and non-English users get non-English notifications" — which is false, +and acting on it is a **net regression**: `TEMPLATE_*` failures classify +`permanent` and dead-letter, and the inbox channel starts requiring an email +service with `renderTemplate()` where inline text needed none. So the drift was +not a cosmetic imprecision; it was an instruction to make a change that loses +deliveries. + +Per the maintainer ruling of **2026-08-13**, the behaviour is the settled side — +a per-user locale is deferred until measured pull — so the prose is the side that +moves. All five "recipient locale" sites in the file now name the resolved value: +the schema doc block, the `template` field's JSDoc and `.describe()`, and both +`superRefine` refusal messages. Each says the locale is `payload.locale` if the +producer set one, else the deployment default, and that it is **one value per +notification, not one per recipient**, with the 2026-08-13 deferral dated in +place so the limitation reads as a decision with provenance rather than a +permanent property of the design — a per-user locale layers in as an override at +that same seam when it lands. + +Text only. No schema accepts or refuses anything it did not before, no delivery +behaviour moves, and no wire value changes — `packages/spec` publishes +`src/**/*.zod.ts` and the generated reference page, so the corrected wording +ships to consumers reading either. The pins in +`io-node-config.test.ts` that asserted the old `/recipient locale/` string now +assert the qualification itself, and refuse a bare "recipient locale", so a +future edit cannot quietly restore the promise. diff --git a/content/docs/references/automation/io-node-config.mdx b/content/docs/references/automation/io-node-config.mdx index d85bbb6ae2..be53ac6bb6 100644 --- a/content/docs/references/automation/io-node-config.mdx +++ b/content/docs/references/automation/io-node-config.mdx @@ -98,7 +98,7 @@ const result = HttpConfigSchema.parse(data); | **recipients** | `string \| string[]` | ✅ | Recipient user id(s) / audience selector(s); `{token}` templates resolve per run | | **title** | `string` | optional | Notification title, sent to every recipient verbatim (not localizable — use `template` for per-locale content). Either this or `template` is required; the two are mutually exclusive. | | **message** | `string` | optional | Notification body, sent verbatim like `title` (not localizable). Only valid with inline `title`, never with `template`. | -| **template** | `string` | optional | Email template name (`sys_email_template.name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, recipient locale)` at delivery time and renders subject/body per recipient. Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation. | +| **template** | `string` | optional | Email template name (`sys_email_template.name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, locale)` against sys_email_template at delivery time and renders subject/body from that row. The locale is ONE value for the whole notification, not one per recipient: `payload.locale` if the producer set one, else the deployment default (`II18nService.getDefaultLocale()`) — the platform has no per-user locale, so recipients with different personal languages all receive the same row (deferred by the 2026-08-13 ruling; it layers in as an override when it lands). Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation. | | **templateData** | `Record` | optional | Render context for the referenced template's `{{var}}` placeholders; values interpolate `{token}` templates per run. Only valid together with `template`. | | **channels** | `string \| string[]` | optional | Channels to fan out to (default: inbox) | | **topic** | `string` | optional | Event topic (default: "notify") | diff --git a/packages/spec/src/automation/io-node-config.test.ts b/packages/spec/src/automation/io-node-config.test.ts index 1e9f2b02b3..60af781102 100644 --- a/packages/spec/src/automation/io-node-config.test.ts +++ b/packages/spec/src/automation/io-node-config.test.ts @@ -233,10 +233,22 @@ describe('NotifyConfigSchema — strict as of #4001 批 9', () => { // // Ruled 「立项,走 emailTemplates 路线」: a notify node references a // `sys_email_template` bundle by name and the delivery path resolves - // `(name, recipient locale)` at delivery time. Inline `title`/`message` + // `(name, locale)` at delivery time. Inline `title`/`message` // stay fully valid (the acceptance faces above) as the non-localizable // path; the two paths are mutually exclusive — loud refusal over silent // precedence, following `objectNavTargetExclusivity` (ui/app.zod.ts). + // + // The `locale` half of that pair is pinned below to the DEPLOYMENT DEFAULT, + // not to a per-recipient value. These strings previously said "recipient + // locale" unqualified, which reads as "each recipient's own language selects + // the row" — the delivery path does not do that and deliberately does not + // (maintainer ruling 2026-08-13: no per-user locale until measured pull; + // `sys_user` carries no locale column, and `payload.locale` is interpolated + // once before fan-out, so it is one value for the whole notification). The + // assertions therefore pin the qualification itself: a future edit that + // drops it back to a bare "recipient locale" turns these RED, because the + // wording an author reads is the whole contract here — declared must equal + // enforced. describe('template reference (#9205)', () => { /** Custom (superRefine) issues at exactly `path`, or `[]` when accepted. */ function customIssuesAt(value: unknown, path: string): ReadonlyArray<{ code: string; message: string }> { @@ -274,7 +286,13 @@ describe('NotifyConfigSchema — strict as of #4001 批 9', () => { // identified, and the fix stated. expect(msg).toContain('`template`'); expect(msg).toContain('`title`'); - expect(msg).toMatch(/recipient locale/); + // The localizable path is identified by what it actually resolves — + // `(name, locale)` with the locale qualified — never a bare + // "recipient locale", which promises per-recipient selection. + expect(msg).toMatch(/\(name, locale\)/); + expect(msg).toMatch(/deployment default/); + expect(msg).toMatch(/not per recipient/); + expect(msg).not.toMatch(/recipient locale/); expect(msg).toMatch(/delete `title`\/`message`/); expect(msg).toMatch(/silently ignore/); } @@ -299,10 +317,22 @@ describe('NotifyConfigSchema — strict as of #4001 批 9', () => { // Non-empty arms first, so the pattern arms cannot pass vacuously (#6918). const templateDoc = shape.template!.description ?? ''; expect(templateDoc.length, 'template .describe() must not be empty').toBeGreaterThan(0); - // The contract: resolves by (name, recipient locale) at delivery time… - expect(templateDoc).toMatch(/recipient locale/); + // The contract: resolves by (name, locale) at delivery time… + expect(templateDoc).toMatch(/\(name, locale\)/); expect(templateDoc).toMatch(/delivery time/); expect(templateDoc).toContain('sys_email_template'); + // …with the locale named as what it IS — the deployment default, one + // value per notification. A bare "recipient locale" here is the defect + // this pin exists to catch: it licenses "convert the nodes and non-English + // users get non-English mail", which is false and is a net regression when + // acted on (TEMPLATE_* failures classify `permanent` and dead-letter). + expect(templateDoc).not.toMatch(/recipient locale/); + expect(templateDoc).toMatch(/deployment default/); + expect(templateDoc).toContain('II18nService.getDefaultLocale()'); + expect(templateDoc).toMatch(/not one per recipient/); + // The deferral is dated, so the text carries its own provenance rather + // than reading as a permanent limitation of the design. + expect(templateDoc).toContain('2026-08-13'); // …and it is a RAW cross-reference, like topic/channels. expect(templateDoc).toMatch(/no `\{token\}` interpolation/i); diff --git a/packages/spec/src/automation/io-node-config.zod.ts b/packages/spec/src/automation/io-node-config.zod.ts index a64c6e2e57..b8e2344fcd 100644 --- a/packages/spec/src/automation/io-node-config.zod.ts +++ b/packages/spec/src/automation/io-node-config.zod.ts @@ -132,8 +132,24 @@ const NOTIFY_KEY_GUIDANCE: Readonly> = { * - **Localization contract (#9205, ruled 「走 emailTemplates 路线」):** * `template` names a `sys_email_template` bundle * (`EmailTemplateDefinitionSchema`, `system/email-template.zod.ts`), and the - * delivery path resolves `(name, recipient locale)` per recipient at - * delivery time via `IEmailService.sendTemplate({ template, locale })`. + * delivery path resolves `(name, locale)` at delivery time via + * `IEmailService.sendTemplate({ template, locale })`. + * + * That `locale` is **ONE value for the whole notification, not one per + * recipient** — declared here exactly as the delivery path enforces it + * (`service-messaging/src/email-channel.ts`, which says the same thing at + * its own `getDefaultTemplateLocale`). It is `payload.locale` when the + * producer set one — interpolated ONCE, before fan-out, so every recipient + * of a node gets that single value — and otherwise the **deployment + * default**, `II18nService.getDefaultLocale()`, the same ruled source the + * auth emails use (#8195). The platform has no per-user locale to read: + * `sys_user` carries no locale column, and request-scoped locale + * (`Accept-Language` → `ExecutionContext.requestLocale`) does not exist at + * async delivery time. Per the maintainer ruling of **2026-08-13** a + * per-user locale is DEFERRED until measured pull; when it lands it layers + * in as an override at that same seam. So do not author on the belief that + * two recipients with different personal languages will receive different + * rows — today they receive the same one. * Inline `title`/`message` are the NON-localizable path — raw strings sent * to every recipient verbatim. The two paths are mutually exclusive on one * node (see the `superRefine` below): runtime precedence would silently @@ -178,14 +194,22 @@ export const NotifyConfigSchema = lazySchema(() => strictObject({ .describe('Notification body, sent verbatim like `title` (not localizable). Only valid with inline `title`, never with `template`.'), /** * The localizable content path (#9205): name of a `sys_email_template` - * bundle. Resolved by `(name, recipient locale)` AT DELIVERY TIME — - * `IEmailService.sendTemplate({ template, locale })` picks the recipient - * locale's row with the documented en-US fallback ladder. Read RAW like - * `topic`/`channels`: a static metadata cross-reference, never interpolated. - * Mutually exclusive with inline `title`/`message`. + * bundle. Resolved by `(name, locale)` AT DELIVERY TIME — + * `IEmailService.sendTemplate({ template, locale })` picks that locale's row + * with the documented en-US fallback ladder. + * + * The `locale` is ONE value for the whole notification, NOT one per + * recipient: `payload.locale` when the producer set one (interpolated once, + * before fan-out), else the DEPLOYMENT DEFAULT — + * `II18nService.getDefaultLocale()`. There is no per-user locale to read + * (`sys_user` has no locale column); the 2026-08-13 ruling defers one until + * measured pull, and it layers in as an override when it lands. + * + * Read RAW like `topic`/`channels`: a static metadata cross-reference, never + * interpolated. Mutually exclusive with inline `title`/`message`. */ template: z.string().optional() - .describe('Email template name (`sys_email_template.name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, recipient locale)` at delivery time and renders subject/body per recipient. Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation.'), + .describe('Email template name (`sys_email_template.name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, locale)` against sys_email_template at delivery time and renders subject/body from that row. The locale is ONE value for the whole notification, not one per recipient: `payload.locale` if the producer set one, else the deployment default (`II18nService.getDefaultLocale()`) — the platform has no per-user locale, so recipients with different personal languages all receive the same row (deferred by the 2026-08-13 ruling; it layers in as an override when it lands). Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation.'), /** * Render context for the referenced template's `{{var}}` holes. Values are * interpolated per run (`{record.x}` resolves), so flow state can feed the @@ -248,7 +272,8 @@ export const NotifyConfigSchema = lazySchema(() => strictObject({ path: ['template'], message: '`template` cannot be combined with inline `title`/`message` — pick ONE content path: ' - + '`template` (localizable: resolves `(name, recipient locale)` from sys_email_template at delivery) ' + + '`template` (localizable: resolves `(name, locale)` from sys_email_template at delivery, the locale being ' + + '`payload.locale` or the deployment default — one locale per notification, not per recipient) ' + 'or inline `title` + `message` (sent verbatim, not localizable). To localize, keep `template`, move ' + 'the text into the template bundle\'s rows, and delete `title`/`message`; runtime precedence would ' + 'silently ignore one of them.', @@ -269,8 +294,9 @@ export const NotifyConfigSchema = lazySchema(() => strictObject({ path: ['title'], message: 'A notify node needs one content source: inline `title` (+ optional `message`), or a `template` ' - + 'reference resolving a sys_email_template bundle per recipient locale at delivery. Neither was given, ' - + 'so there is nothing to deliver.', + + 'reference resolving a sys_email_template bundle at delivery in the notification\'s locale ' + + '(`payload.locale` or the deployment default — one locale per notification, not per recipient). ' + + 'Neither was given, so there is nothing to deliver.', }); } }));