From d86c968222b0c5377baea4ea7af71fb968bd52cc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 05:39:52 +0000 Subject: [PATCH] docs(services): state the notify template locale the delivery path resolves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `notify` node's `template` path resolves `(name, locale)` with ONE locale for the whole notification: `payload.locale`, interpolated once before fan-out, else the deployment default (`II18nService.getDefaultLocale()`). `sys_user` carries no locale column and no request exists at async delivery time, so there is no per-recipient source to read; a per-user locale is deferred until measured pull (maintainer ruling, 2026-08-13). `packages/spec` was corrected to say so. The same retired promise survived in five sites outside it, two of them the ones an app author actually reads: - the `template` field's `configSchema` description — the Studio form text — which said the row is "resolved by (name, recipient locale) ... and rendered per recipient"; - `content/docs/automation/email-templates.mdx`, the only site to state the conclusion outright: "so one node mails each person in their own language"; - the messaging channel-registration log line; - two internal comments in `notify-node.ts` (the execute-time guard and the payload the outbox snapshots) and the sibling comment in its test. Text only — no schema, delivery behaviour or wire value moves. A new pin asserts the form description names `payload.locale` and the deployment default and refuses a bare "recipient locale". Part of #12446 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0157mMVAq9fjGe2kaSD2aJC8 --- ...de-template-locale-is-not-per-recipient.md | 45 +++++++++++++++++++ content/docs/automation/email-templates.mdx | 12 +++-- .../src/builtin/notify-node.test.ts | 28 +++++++++++- .../src/builtin/notify-node.ts | 17 ++++--- .../src/messaging-service-plugin.ts | 2 +- 5 files changed, 93 insertions(+), 11 deletions(-) create mode 100644 .changeset/notify-node-template-locale-is-not-per-recipient.md diff --git a/.changeset/notify-node-template-locale-is-not-per-recipient.md b/.changeset/notify-node-template-locale-is-not-per-recipient.md new file mode 100644 index 0000000000..fbc4435b45 --- /dev/null +++ b/.changeset/notify-node-template-locale-is-not-per-recipient.md @@ -0,0 +1,45 @@ +--- +'@objectstack/service-automation': patch +'@objectstack/service-messaging': patch +--- + +The `notify` node's Studio form and the messaging registration log now state the locale the delivery path actually resolves — one per notification, not one per recipient + +`NotifyConfigSchema` was corrected in `packages/spec` to say that the `template` +path resolves `(name, locale)` with **one** locale for the whole notification. +The same retired promise survived outside the spec file, in the places an app +author is most likely to read it: + +- `service-automation/src/builtin/notify-node.ts` — the `template` field's + `configSchema` description, i.e. the text rendered in the **Studio form** the + author fills in. It said the row is "resolved by (name, recipient locale) at + delivery time and rendered per recipient". +- `content/docs/automation/email-templates.mdx` — the only site that stated the + conclusion outright rather than merely licensing it: "so one node mails each + person in their own language". +- `service-messaging/src/messaging-service-plugin.ts` — the channel-registration + log line, which advertised "resolve sys_email_template per recipient locale". +- Two internal comments in `notify-node.ts` and one in its test, describing the + payload the outbox snapshots as carrying a per-recipient-locale resolution. + +None of that is what the delivery path does. `payload.locale` is interpolated +**once, before fan-out**, so it is a single value for the whole notification, and +its fallback is the deployment default (`II18nService.getDefaultLocale()`). The +platform has no per-user locale to read — `sys_user` carries no locale column, +and request-scoped locale does not exist at async delivery time — so recipients +whose personal languages differ all receive the same template row. A per-user +locale is deferred until measured pull (maintainer ruling, 2026-08-13) and layers +in as an override at that same seam when it lands; the corrected wording dates +the deferral so it reads as a decision with provenance rather than an oversight. + +The gap was worth correcting because the wording licensed exactly one action — +convert `notify` nodes on the belief that non-English recipients get non-English +mail — and that action 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. + +Text only: no schema accepts or refuses anything it did not before, no delivery +behaviour moves, and no wire value changes. A new pin in `notify-node.test.ts` +asserts the form description names `payload.locale` and the deployment default +and refuses a bare "recipient locale", so a later edit cannot quietly restore the +promise. diff --git a/content/docs/automation/email-templates.mdx b/content/docs/automation/email-templates.mdx index b127636ca3..54ada24c87 100644 --- a/content/docs/automation/email-templates.mdx +++ b/content/docs/automation/email-templates.mdx @@ -162,9 +162,15 @@ the localizable path: } ``` -- `template` names the bundle. The delivery path resolves - `(name, recipient locale)` **per recipient, at delivery time**, so one node - mails each person in their own language. +- `template` names the bundle. 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 — interpolated once, + before fan-out — else the deployment default (`II18nService.getDefaultLocale()`). + The platform has no per-user locale (`sys_user` carries no locale column), so + recipients whose personal languages differ all receive the same row. A per-user + locale is deferred until measured pull (maintainer ruling, 2026-08-13); it + layers in as an override at that same seam when it lands. - Inline `title` / `message` are the **non-localizable** path: raw strings sent to every recipient verbatim. The two paths cannot be combined on one node — the schema refuses the ambiguous shape rather than letting a runtime diff --git a/packages/services/service-automation/src/builtin/notify-node.test.ts b/packages/services/service-automation/src/builtin/notify-node.test.ts index c39de36fc8..52f51f8be3 100644 --- a/packages/services/service-automation/src/builtin/notify-node.test.ts +++ b/packages/services/service-automation/src/builtin/notify-node.test.ts @@ -76,6 +76,31 @@ describe('notify (baseline node)', () => { ); }); + it('describes `template` with the locale the delivery path actually resolves, not a per-recipient one', () => { + // This description is rendered in the Studio form an author fills in, so + // it is the shortest path from wording to an authoring mistake. It used + // to say the row is "resolved by (name, recipient locale) ... and + // rendered per recipient", which reads as "each recipient's own language + // selects the row". It does not: `payload.locale` is interpolated once + // BEFORE fan-out and the fallback is the deployment default, so one + // locale serves the whole notification (`sys_user` carries no locale + // column; a per-user locale is deferred by the 2026-08-13 ruling). + // Acting on the old promise is a net regression — TEMPLATE_* failures + // classify `permanent` and dead-letter — so the qualification is pinned + // here, and a bare "recipient locale" is refused, to keep a later edit + // from quietly restoring it. + const engine = new AutomationEngine(createTestLogger()); + registerNotifyNode(engine, createCtx()); + const schema = engine.getActionDescriptor('notify')?.configSchema as + | { properties?: { template?: { description?: string } } } + | undefined; + const description = schema?.properties?.template?.description ?? ''; + expect(description).toMatch(/not one per recipient/); + expect(description).toMatch(/payload\.locale/); + expect(description).toMatch(/deployment default/); + expect(description).not.toMatch(/recipient locale/); + }); + describe('with a messaging service registered', () => { let engine: AutomationEngine; let messaging: ReturnType; @@ -229,7 +254,8 @@ describe('notify (baseline node)', () => { const payload = messaging.emitted[0].payload; // The reference rides RAW (a static metadata cross-reference); its // render context is interpolated per run — that pair is what the - // email channel resolves per recipient locale at delivery time. + // email channel resolves at delivery time, against one locale for the + // whole notification (`payload.locale`, else the deployment default). expect(payload.template).toBe('crm.large_deal_won'); expect(payload.templateData).toEqual({ dealName: 'Acme', dealUrl: '/opps/42' }); // No inline content keys on this path: a channel without template diff --git a/packages/services/service-automation/src/builtin/notify-node.ts b/packages/services/service-automation/src/builtin/notify-node.ts index 41f68dd486..3b7f60566e 100644 --- a/packages/services/service-automation/src/builtin/notify-node.ts +++ b/packages/services/service-automation/src/builtin/notify-node.ts @@ -194,7 +194,7 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext) // how requiredness is owned there rather than by the form. template: { type: 'string', - description: 'Email template name (sys_email_template.name) — resolved by (name, recipient locale) at delivery time and rendered per recipient. Mutually exclusive with inline title/message.', + description: 'Email template name (sys_email_template.name) — the localizable content path: resolved by (name, locale) at delivery time, rendering 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 whose personal languages differ 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.', }, templateData: { type: 'object', @@ -285,9 +285,11 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext) const actorId = toStr(interpolate(cfg.actorId, variables, context)); // With a `template` reference the content lives in the template - // bundle, resolved per recipient locale at delivery — no inline - // title to demand (the Zod contract already refused a node carrying - // NEITHER, and one carrying BOTH). + // bundle, resolved by `(name, locale)` at delivery — one locale for + // the whole notification (`payload.locale`, else the deployment + // default), never one per recipient — so there is no inline title to + // demand (the Zod contract already refused a node carrying NEITHER, + // and one carrying BOTH). if (!title && !template) return { success: false, error: 'notify: title is required' }; if (recipients.length === 0) { // Name the templates that came up empty (framework#3582). The @@ -374,8 +376,11 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext) // Content rides in the payload per path (#9205): the inline // strings, or the template reference + its render context — // which the outbox snapshots onto each delivery row, so the - // per-recipient-locale resolution happens at delivery time - // in the channel (email-channel.ts reads payload.template). + // template resolution happens at delivery time in the channel + // (email-channel.ts reads payload.template). The locale it + // resolves with is one value for the whole notification — + // `payload.locale`, interpolated once BEFORE fan-out, else the + // deployment default — never each recipient's own language. // On the template path no inline title/body keys are set: // channels without template support fall back to the topic, // which is the honest degraded rendering, not ''. diff --git a/packages/services/service-messaging/src/messaging-service-plugin.ts b/packages/services/service-messaging/src/messaging-service-plugin.ts index c8369b4d42..9061f70885 100644 --- a/packages/services/service-messaging/src/messaging-service-plugin.ts +++ b/packages/services/service-messaging/src/messaging-service-plugin.ts @@ -247,7 +247,7 @@ export class MessagingServicePlugin implements Plugin { ctx.hook('kernel:ready', async () => { if (getEmail()) { service.registerChannel(createEmailChannel({ getEmail, getData, store: templateStore, getDefaultTemplateLocale })); - ctx.logger.info('[messaging] email channel registered (renders sys_notification_template; notify `template` refs resolve sys_email_template per recipient locale)'); + ctx.logger.info('[messaging] email channel registered (renders sys_notification_template; notify `template` refs resolve sys_email_template by (name, locale) — one locale per notification: payload.locale, else the deployment default)'); } });