Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .changeset/notify-node-template-locale-is-not-per-recipient.md
Original file line numberDiff line numberDiff line change
@@ -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.
12 changes: 9 additions & 3 deletions content/docs/automation/email-templates.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<typeof fakeMessaging>;
Expand DownExpand Up@@ -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
Expand Down
17 changes: 11 additions & 6 deletions packages/services/service-automation/src/builtin/notify-node.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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',
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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 ''.
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)');
}
});

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .changeset/notify-node-template-locale-is-not-per-recipient.md
Original file line numberDiff line numberDiff line change
@@ -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.
12 changes: 9 additions & 3 deletions content/docs/automation/email-templates.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<typeof fakeMessaging>;
Expand DownExpand Up@@ -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
Expand Down
17 changes: 11 additions & 6 deletions packages/services/service-automation/src/builtin/notify-node.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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',
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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 ''.
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)');
}
});

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .changeset/notify-node-template-locale-is-not-per-recipient.md
Original file line numberDiff line numberDiff line change
@@ -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.
12 changes: 9 additions & 3 deletions content/docs/automation/email-templates.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<typeof fakeMessaging>;
Expand DownExpand Up@@ -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
Expand Down
17 changes: 11 additions & 6 deletions packages/services/service-automation/src/builtin/notify-node.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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',
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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 ''.
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)');
}
});

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .changeset/notify-node-template-locale-is-not-per-recipient.md
Original file line numberDiff line numberDiff line change
@@ -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.
12 changes: 9 additions & 3 deletions content/docs/automation/email-templates.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<typeof fakeMessaging>;
Expand DownExpand Up@@ -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
Expand Down
17 changes: 11 additions & 6 deletions packages/services/service-automation/src/builtin/notify-node.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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',
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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 ''.
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)');
}
});

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .changeset/notify-node-template-locale-is-not-per-recipient.md
Original file line numberDiff line numberDiff line change
@@ -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.
12 changes: 9 additions & 3 deletions content/docs/automation/email-templates.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<typeof fakeMessaging>;
Expand DownExpand Up@@ -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
Expand Down
17 changes: 11 additions & 6 deletions packages/services/service-automation/src/builtin/notify-node.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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',
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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 ''.
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)');
}
});

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .changeset/notify-node-template-locale-is-not-per-recipient.md
Original file line numberDiff line numberDiff line change
@@ -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.
12 changes: 9 additions & 3 deletions content/docs/automation/email-templates.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<typeof fakeMessaging>;
Expand DownExpand Up@@ -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
Expand Down
17 changes: 11 additions & 6 deletions packages/services/service-automation/src/builtin/notify-node.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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',
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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 ''.
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)');
}
});

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .changeset/notify-node-template-locale-is-not-per-recipient.md
Original file line numberDiff line numberDiff line change
@@ -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.
12 changes: 9 additions & 3 deletions content/docs/automation/email-templates.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<typeof fakeMessaging>;
Expand DownExpand Up@@ -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
Expand Down
17 changes: 11 additions & 6 deletions packages/services/service-automation/src/builtin/notify-node.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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',
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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 ''.
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)');
}
});

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .changeset/notify-node-template-locale-is-not-per-recipient.md
Original file line numberDiff line numberDiff line change
@@ -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.
12 changes: 9 additions & 3 deletions content/docs/automation/email-templates.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<typeof fakeMessaging>;
Expand DownExpand Up@@ -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
Expand Down
17 changes: 11 additions & 6 deletions packages/services/service-automation/src/builtin/notify-node.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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',
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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 ''.
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)');
}
});

Expand Down
Loading