From 482326807ba69c19c481da1c464e1968d8914cff Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 03:01:22 +0000 Subject: [PATCH] spec(automation): notify `template` locale is the deployment default, not per-recipient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `NotifyConfigSchema`'s doc block, the `template` field JSDoc/`.describe()`, and both `superRefine` refusal messages said the delivery path resolves `(name, recipient locale)` "per recipient" and "renders subject/body per recipient". The delivery path deliberately does not: `sys_user` carries no locale column and request-scoped locale does not exist at async delivery time, so the locale is `payload.locale` (interpolated once, before fan-out) or the deployment default `II18nService.getDefaultLocale()` — one value for the whole notification. `service-messaging/src/email-channel.ts` already documents this honestly; spec was the one place it was unqualified. Per the maintainer ruling of 2026-08-13 the behaviour is settled (no per-user locale until measured pull), so the prose moves. All five sites in the file now name the resolved value and date the deferral. The two test pins that asserted the old `/recipient locale/` string now assert the qualification and refuse a bare "recipient locale". Text only — no acceptance, refusal or delivery behaviour changes. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01E5LFCYBJ3q2s6yW6oMLxwy --- ...y-template-locale-is-deployment-default.md | 51 +++++++++++++++++++ .../references/automation/io-node-config.mdx | 2 +- .../src/automation/io-node-config.test.ts | 38 ++++++++++++-- .../spec/src/automation/io-node-config.zod.ts | 48 +++++++++++++---- 4 files changed, 123 insertions(+), 16 deletions(-) create mode 100644 .changeset/notify-template-locale-is-deployment-default.md 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.', }); } }));