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
51 changes: 51 additions & 0 deletions .changeset/notify-template-locale-is-deployment-default.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
---
'@objectstack/spec': patch
---

`NotifyConfigSchema.template` now states the locale semantics the delivery path actually enforces — the deployment default, not a per-recipient locale

The `notify` node's localizable path (`template` → a `sys_email_template` bundle)
was documented in `packages/spec/src/automation/io-node-config.zod.ts` as
resolving `(name, recipient locale)` **per recipient** at delivery time, and the
`template` `.describe()` added that it "renders subject/body per recipient".
Read plainly — and it is the text a consuming app's author reads — that says the
recipient's own language selects the template row.

It does not, and deliberately does not. The delivery path
(`service-messaging/src/email-channel.ts`) has said so honestly at its own
`getDefaultTemplateLocale` all along: the platform has no per-user locale
(`sys_user` carries no locale column), and request-scoped locale
(`Accept-Language` → `ExecutionContext.requestLocale`) does not exist at async
delivery time, so "recipient locale" resolves to the **deployment default**,
`II18nService.getDefaultLocale()` — the same ruled source the auth emails use.
The one lever is `payload.locale`, and that is interpolated **once, before
fan-out**, so it is a single value for the whole notification at all three
`channel.send` call sites (`fanOut`, the outbox single-delivery path, and
`processDigestGroup`).

The gap mattered because the wording licensed exactly one conclusion — "convert
the nodes and non-English users get non-English notifications" — which is false,
and acting on it is a **net regression**: `TEMPLATE_*` failures classify
`permanent` and dead-letter, and the inbox channel starts requiring an email
service with `renderTemplate()` where inline text needed none. So the drift was
not a cosmetic imprecision; it was an instruction to make a change that loses
deliveries.

Per the maintainer ruling of **2026-08-13**, the behaviour is the settled side —
a per-user locale is deferred until measured pull — so the prose is the side that
moves. All five "recipient locale" sites in the file now name the resolved value:
the schema doc block, the `template` field's JSDoc and `.describe()`, and both
`superRefine` refusal messages. Each says the locale is `payload.locale` if the
producer set one, else the deployment default, and that it is **one value per
notification, not one per recipient**, with the 2026-08-13 deferral dated in
place so the limitation reads as a decision with provenance rather than a
permanent property of the design — a per-user locale layers in as an override at
that same seam when it lands.

Text only. No schema accepts or refuses anything it did not before, no delivery
behaviour moves, and no wire value changes — `packages/spec` publishes
`src/**/*.zod.ts` and the generated reference page, so the corrected wording
ships to consumers reading either. The pins in
`io-node-config.test.ts` that asserted the old `/recipient locale/` string now
assert the qualification itself, and refuse a bare "recipient locale", so a
future edit cannot quietly restore the promise.
2 changes: 1 addition & 1 deletion content/docs/references/automation/io-node-config.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -98,7 +98,7 @@ const result = HttpConfigSchema.parse(data);
| **recipients** | `string \| string[]` | ✅ | Recipient user id(s) / audience selector(s); `{token}` templates resolve per run |
| **title** | `string` | optional | Notification title, sent to every recipient verbatim (not localizable — use `template` for per-locale content). Either this or `template` is required; the two are mutually exclusive. |
| **message** | `string` | optional | Notification body, sent verbatim like `title` (not localizable). Only valid with inline `title`, never with `template`. |
| **template** | `string` | optional | Email template name (`sys_email_template.name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, recipient locale)` at delivery time and renders subject/body per recipient. Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation. |
| **template** | `string` | optional | Email template name (`sys_email_template.name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, locale)` against sys_email_template at delivery time and renders subject/body from that row. The locale is ONE value for the whole notification, not one per recipient: `payload.locale` if the producer set one, else the deployment default (`II18nService.getDefaultLocale()`) — the platform has no per-user locale, so recipients with different personal languages all receive the same row (deferred by the 2026-08-13 ruling; it layers in as an override when it lands). Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation. |
| **templateData** | `Record<string, any>` | optional | Render context for the referenced template's `{{var}}` placeholders; values interpolate `{token}` templates per run. Only valid together with `template`. |
| **channels** | `string \| string[]` | optional | Channels to fan out to (default: inbox) |
| **topic** | `string` | optional | Event topic (default: "notify") |
Expand Down
38 changes: 34 additions & 4 deletions packages/spec/src/automation/io-node-config.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -233,10 +233,22 @@ describe('NotifyConfigSchema — strict as of #4001 批 9', () => {
//
// Ruled 「立项,走 emailTemplates 路线」: a notify node references a
// `sys_email_template` bundle by name and the delivery path resolves
// `(name, recipient locale)` at delivery time. Inline `title`/`message`
// `(name, locale)` at delivery time. Inline `title`/`message`
// stay fully valid (the acceptance faces above) as the non-localizable
// path; the two paths are mutually exclusive — loud refusal over silent
// precedence, following `objectNavTargetExclusivity` (ui/app.zod.ts).
//
// The `locale` half of that pair is pinned below to the DEPLOYMENT DEFAULT,
// not to a per-recipient value. These strings previously said "recipient
// locale" unqualified, which reads as "each recipient's own language selects
// the row" — the delivery path does not do that and deliberately does not
// (maintainer ruling 2026-08-13: no per-user locale until measured pull;
// `sys_user` carries no locale column, and `payload.locale` is interpolated
// once before fan-out, so it is one value for the whole notification). The
// assertions therefore pin the qualification itself: a future edit that
// drops it back to a bare "recipient locale" turns these RED, because the
// wording an author reads is the whole contract here — declared must equal
// enforced.
describe('template reference (#9205)', () => {
/** Custom (superRefine) issues at exactly `path`, or `[]` when accepted. */
function customIssuesAt(value: unknown, path: string): ReadonlyArray<{ code: string; message: string }> {
Expand DownExpand Up@@ -274,7 +286,13 @@ describe('NotifyConfigSchema — strict as of #4001 批 9', () => {
// identified, and the fix stated.
expect(msg).toContain('`template`');
expect(msg).toContain('`title`');
expect(msg).toMatch(/recipient locale/);
// The localizable path is identified by what it actually resolves —
// `(name, locale)` with the locale qualified — never a bare
// "recipient locale", which promises per-recipient selection.
expect(msg).toMatch(/\(name, locale\)/);
expect(msg).toMatch(/deployment default/);
expect(msg).toMatch(/not per recipient/);
expect(msg).not.toMatch(/recipient locale/);
expect(msg).toMatch(/delete `title`\/`message`/);
expect(msg).toMatch(/silently ignore/);
}
Expand All@@ -299,10 +317,22 @@ describe('NotifyConfigSchema — strict as of #4001 批 9', () => {
// Non-empty arms first, so the pattern arms cannot pass vacuously (#6918).
const templateDoc = shape.template!.description ?? '';
expect(templateDoc.length, 'template .describe() must not be empty').toBeGreaterThan(0);
// The contract: resolves by (name, recipient locale) at delivery time…
expect(templateDoc).toMatch(/recipient locale/);
// The contract: resolves by (name, locale) at delivery time…
expect(templateDoc).toMatch(/\(name, locale\)/);
expect(templateDoc).toMatch(/delivery time/);
expect(templateDoc).toContain('sys_email_template');
// …with the locale named as what it IS — the deployment default, one
// value per notification. A bare "recipient locale" here is the defect
// this pin exists to catch: it licenses "convert the nodes and non-English
// users get non-English mail", which is false and is a net regression when
// acted on (TEMPLATE_* failures classify `permanent` and dead-letter).
expect(templateDoc).not.toMatch(/recipient locale/);
expect(templateDoc).toMatch(/deployment default/);
expect(templateDoc).toContain('II18nService.getDefaultLocale()');
expect(templateDoc).toMatch(/not one per recipient/);
// The deferral is dated, so the text carries its own provenance rather
// than reading as a permanent limitation of the design.
expect(templateDoc).toContain('2026-08-13');
// …and it is a RAW cross-reference, like topic/channels.
expect(templateDoc).toMatch(/no `\{token\}` interpolation/i);

Expand Down
48 changes: 37 additions & 11 deletions packages/spec/src/automation/io-node-config.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -132,8 +132,24 @@ const NOTIFY_KEY_GUIDANCE: Readonly<Record<string, string>> = {
* - **Localization contract (#9205, ruled 「走 emailTemplates 路线」):**
* `template` names a `sys_email_template` bundle
* (`EmailTemplateDefinitionSchema`, `system/email-template.zod.ts`), and the
* delivery path resolves `(name, recipient locale)` per recipient at
* delivery time via `IEmailService.sendTemplate({ template, locale })`.
* delivery path resolves `(name, locale)` at delivery time via
* `IEmailService.sendTemplate({ template, locale })`.
*
* That `locale` is **ONE value for the whole notification, not one per
* recipient** — declared here exactly as the delivery path enforces it
* (`service-messaging/src/email-channel.ts`, which says the same thing at
* its own `getDefaultTemplateLocale`). It is `payload.locale` when the
* producer set one — interpolated ONCE, before fan-out, so every recipient
* of a node gets that single value — and otherwise the **deployment
* default**, `II18nService.getDefaultLocale()`, the same ruled source the
* auth emails use (#8195). The platform has no per-user locale to read:
* `sys_user` carries no locale column, and request-scoped locale
* (`Accept-Language` → `ExecutionContext.requestLocale`) does not exist at
* async delivery time. Per the maintainer ruling of **2026-08-13** a
* per-user locale is DEFERRED until measured pull; when it lands it layers
* in as an override at that same seam. So do not author on the belief that
* two recipients with different personal languages will receive different
* rows — today they receive the same one.
* Inline `title`/`message` are the NON-localizable path — raw strings sent
* to every recipient verbatim. The two paths are mutually exclusive on one
* node (see the `superRefine` below): runtime precedence would silently
Expand DownExpand Up@@ -178,14 +194,22 @@ export const NotifyConfigSchema = lazySchema(() => strictObject({
.describe('Notification body, sent verbatim like `title` (not localizable). Only valid with inline `title`, never with `template`.'),
/**
* The localizable content path (#9205): name of a `sys_email_template`
* bundle. Resolved by `(name, recipient locale)` AT DELIVERY TIME —
* `IEmailService.sendTemplate({ template, locale })` picks the recipient
* locale's row with the documented en-US fallback ladder. Read RAW like
* `topic`/`channels`: a static metadata cross-reference, never interpolated.
* Mutually exclusive with inline `title`/`message`.
* bundle. Resolved by `(name, locale)` AT DELIVERY TIME —
* `IEmailService.sendTemplate({ template, locale })` picks that locale's row
* with the documented en-US fallback ladder.
*
* The `locale` is ONE value for the whole notification, NOT one per
* recipient: `payload.locale` when the producer set one (interpolated once,
* before fan-out), else the DEPLOYMENT DEFAULT —
* `II18nService.getDefaultLocale()`. There is no per-user locale to read
* (`sys_user` has no locale column); the 2026-08-13 ruling defers one until
* measured pull, and it layers in as an override when it lands.
*
* Read RAW like `topic`/`channels`: a static metadata cross-reference, never
* interpolated. Mutually exclusive with inline `title`/`message`.
*/
template: z.string().optional()
.describe('Email template name (`sys_email_template.name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, recipient locale)` at delivery time and renders subject/body per recipient. Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation.'),
.describe('Email template name (`sys_email_template.name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, locale)` against sys_email_template at delivery time and renders subject/body from that row. The locale is ONE value for the whole notification, not one per recipient: `payload.locale` if the producer set one, else the deployment default (`II18nService.getDefaultLocale()`) — the platform has no per-user locale, so recipients with different personal languages all receive the same row (deferred by the 2026-08-13 ruling; it layers in as an override when it lands). Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation.'),
/**
* Render context for the referenced template's `{{var}}` holes. Values are
* interpolated per run (`{record.x}` resolves), so flow state can feed the
Expand DownExpand Up@@ -248,7 +272,8 @@ export const NotifyConfigSchema = lazySchema(() => strictObject({
path: ['template'],
message:
'`template` cannot be combined with inline `title`/`message` — pick ONE content path: '
+ '`template` (localizable: resolves `(name, recipient locale)` from sys_email_template at delivery) '
+ '`template` (localizable: resolves `(name, locale)` from sys_email_template at delivery, the locale being '
+ '`payload.locale` or the deployment default — one locale per notification, not per recipient) '
+ 'or inline `title` + `message` (sent verbatim, not localizable). To localize, keep `template`, move '
+ 'the text into the template bundle\'s rows, and delete `title`/`message`; runtime precedence would '
+ 'silently ignore one of them.',
Expand All@@ -269,8 +294,9 @@ export const NotifyConfigSchema = lazySchema(() => strictObject({
path: ['title'],
message:
'A notify node needs one content source: inline `title` (+ optional `message`), or a `template` '
+ 'reference resolving a sys_email_template bundle per recipient locale at delivery. Neither was given, '
+ 'so there is nothing to deliver.',
+ 'reference resolving a sys_email_template bundle at delivery in the notification\'s locale '
+ '(`payload.locale` or the deployment default — one locale per notification, not per recipient). '
+ 'Neither was given, so there is nothing to deliver.',
});
}
}));
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
51 changes: 51 additions & 0 deletions .changeset/notify-template-locale-is-deployment-default.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
---
'@objectstack/spec': patch
---

`NotifyConfigSchema.template` now states the locale semantics the delivery path actually enforces — the deployment default, not a per-recipient locale

The `notify` node's localizable path (`template` → a `sys_email_template` bundle)
was documented in `packages/spec/src/automation/io-node-config.zod.ts` as
resolving `(name, recipient locale)` **per recipient** at delivery time, and the
`template` `.describe()` added that it "renders subject/body per recipient".
Read plainly — and it is the text a consuming app's author reads — that says the
recipient's own language selects the template row.

It does not, and deliberately does not. The delivery path
(`service-messaging/src/email-channel.ts`) has said so honestly at its own
`getDefaultTemplateLocale` all along: the platform has no per-user locale
(`sys_user` carries no locale column), and request-scoped locale
(`Accept-Language` → `ExecutionContext.requestLocale`) does not exist at async
delivery time, so "recipient locale" resolves to the **deployment default**,
`II18nService.getDefaultLocale()` — the same ruled source the auth emails use.
The one lever is `payload.locale`, and that is interpolated **once, before
fan-out**, so it is a single value for the whole notification at all three
`channel.send` call sites (`fanOut`, the outbox single-delivery path, and
`processDigestGroup`).

The gap mattered because the wording licensed exactly one conclusion — "convert
the nodes and non-English users get non-English notifications" — which is false,
and acting on it is a **net regression**: `TEMPLATE_*` failures classify
`permanent` and dead-letter, and the inbox channel starts requiring an email
service with `renderTemplate()` where inline text needed none. So the drift was
not a cosmetic imprecision; it was an instruction to make a change that loses
deliveries.

Per the maintainer ruling of **2026-08-13**, the behaviour is the settled side —
a per-user locale is deferred until measured pull — so the prose is the side that
moves. All five "recipient locale" sites in the file now name the resolved value:
the schema doc block, the `template` field's JSDoc and `.describe()`, and both
`superRefine` refusal messages. Each says the locale is `payload.locale` if the
producer set one, else the deployment default, and that it is **one value per
notification, not one per recipient**, with the 2026-08-13 deferral dated in
place so the limitation reads as a decision with provenance rather than a
permanent property of the design — a per-user locale layers in as an override at
that same seam when it lands.

Text only. No schema accepts or refuses anything it did not before, no delivery
behaviour moves, and no wire value changes — `packages/spec` publishes
`src/**/*.zod.ts` and the generated reference page, so the corrected wording
ships to consumers reading either. The pins in
`io-node-config.test.ts` that asserted the old `/recipient locale/` string now
assert the qualification itself, and refuse a bare "recipient locale", so a
future edit cannot quietly restore the promise.
2 changes: 1 addition & 1 deletion content/docs/references/automation/io-node-config.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -98,7 +98,7 @@ const result = HttpConfigSchema.parse(data);
| **recipients** | `string \| string[]` | ✅ | Recipient user id(s) / audience selector(s); `{token}` templates resolve per run |
| **title** | `string` | optional | Notification title, sent to every recipient verbatim (not localizable — use `template` for per-locale content). Either this or `template` is required; the two are mutually exclusive. |
| **message** | `string` | optional | Notification body, sent verbatim like `title` (not localizable). Only valid with inline `title`, never with `template`. |
| **template** | `string` | optional | Email template name (`sys_email_template.name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, recipient locale)` at delivery time and renders subject/body per recipient. Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation. |
| **template** | `string` | optional | Email template name (`sys_email_template.name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, locale)` against sys_email_template at delivery time and renders subject/body from that row. The locale is ONE value for the whole notification, not one per recipient: `payload.locale` if the producer set one, else the deployment default (`II18nService.getDefaultLocale()`) — the platform has no per-user locale, so recipients with different personal languages all receive the same row (deferred by the 2026-08-13 ruling; it layers in as an override when it lands). Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation. |
| **templateData** | `Record<string, any>` | optional | Render context for the referenced template's `{{var}}` placeholders; values interpolate `{token}` templates per run. Only valid together with `template`. |
| **channels** | `string \| string[]` | optional | Channels to fan out to (default: inbox) |
| **topic** | `string` | optional | Event topic (default: "notify") |
Expand Down
38 changes: 34 additions & 4 deletions packages/spec/src/automation/io-node-config.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -233,10 +233,22 @@ describe('NotifyConfigSchema — strict as of #4001 批 9', () => {
//
// Ruled 「立项,走 emailTemplates 路线」: a notify node references a
// `sys_email_template` bundle by name and the delivery path resolves
// `(name, recipient locale)` at delivery time. Inline `title`/`message`
// `(name, locale)` at delivery time. Inline `title`/`message`
// stay fully valid (the acceptance faces above) as the non-localizable
// path; the two paths are mutually exclusive — loud refusal over silent
// precedence, following `objectNavTargetExclusivity` (ui/app.zod.ts).
//
// The `locale` half of that pair is pinned below to the DEPLOYMENT DEFAULT,
// not to a per-recipient value. These strings previously said "recipient
// locale" unqualified, which reads as "each recipient's own language selects
// the row" — the delivery path does not do that and deliberately does not
// (maintainer ruling 2026-08-13: no per-user locale until measured pull;
// `sys_user` carries no locale column, and `payload.locale` is interpolated
// once before fan-out, so it is one value for the whole notification). The
// assertions therefore pin the qualification itself: a future edit that
// drops it back to a bare "recipient locale" turns these RED, because the
// wording an author reads is the whole contract here — declared must equal
// enforced.
describe('template reference (#9205)', () => {
/** Custom (superRefine) issues at exactly `path`, or `[]` when accepted. */
function customIssuesAt(value: unknown, path: string): ReadonlyArray<{ code: string; message: string }> {
Expand DownExpand Up@@ -274,7 +286,13 @@ describe('NotifyConfigSchema — strict as of #4001 批 9', () => {
// identified, and the fix stated.
expect(msg).toContain('`template`');
expect(msg).toContain('`title`');
expect(msg).toMatch(/recipient locale/);
// The localizable path is identified by what it actually resolves —
// `(name, locale)` with the locale qualified — never a bare
// "recipient locale", which promises per-recipient selection.
expect(msg).toMatch(/\(name, locale\)/);
expect(msg).toMatch(/deployment default/);
expect(msg).toMatch(/not per recipient/);
expect(msg).not.toMatch(/recipient locale/);
expect(msg).toMatch(/delete `title`\/`message`/);
expect(msg).toMatch(/silently ignore/);
}
Expand All@@ -299,10 +317,22 @@ describe('NotifyConfigSchema — strict as of #4001 批 9', () => {
// Non-empty arms first, so the pattern arms cannot pass vacuously (#6918).
const templateDoc = shape.template!.description ?? '';
expect(templateDoc.length, 'template .describe() must not be empty').toBeGreaterThan(0);
// The contract: resolves by (name, recipient locale) at delivery time…
expect(templateDoc).toMatch(/recipient locale/);
// The contract: resolves by (name, locale) at delivery time…
expect(templateDoc).toMatch(/\(name, locale\)/);
expect(templateDoc).toMatch(/delivery time/);
expect(templateDoc).toContain('sys_email_template');
// …with the locale named as what it IS — the deployment default, one
// value per notification. A bare "recipient locale" here is the defect
// this pin exists to catch: it licenses "convert the nodes and non-English
// users get non-English mail", which is false and is a net regression when
// acted on (TEMPLATE_* failures classify `permanent` and dead-letter).
expect(templateDoc).not.toMatch(/recipient locale/);
expect(templateDoc).toMatch(/deployment default/);
expect(templateDoc).toContain('II18nService.getDefaultLocale()');
expect(templateDoc).toMatch(/not one per recipient/);
// The deferral is dated, so the text carries its own provenance rather
// than reading as a permanent limitation of the design.
expect(templateDoc).toContain('2026-08-13');
// …and it is a RAW cross-reference, like topic/channels.
expect(templateDoc).toMatch(/no `\{token\}` interpolation/i);

Expand Down
48 changes: 37 additions & 11 deletions packages/spec/src/automation/io-node-config.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -132,8 +132,24 @@ const NOTIFY_KEY_GUIDANCE: Readonly<Record<string, string>> = {
* - **Localization contract (#9205, ruled 「走 emailTemplates 路线」):**
* `template` names a `sys_email_template` bundle
* (`EmailTemplateDefinitionSchema`, `system/email-template.zod.ts`), and the
* delivery path resolves `(name, recipient locale)` per recipient at
* delivery time via `IEmailService.sendTemplate({ template, locale })`.
* delivery path resolves `(name, locale)` at delivery time via
* `IEmailService.sendTemplate({ template, locale })`.
*
* That `locale` is **ONE value for the whole notification, not one per
* recipient** — declared here exactly as the delivery path enforces it
* (`service-messaging/src/email-channel.ts`, which says the same thing at
* its own `getDefaultTemplateLocale`). It is `payload.locale` when the
* producer set one — interpolated ONCE, before fan-out, so every recipient
* of a node gets that single value — and otherwise the **deployment
* default**, `II18nService.getDefaultLocale()`, the same ruled source the
* auth emails use (#8195). The platform has no per-user locale to read:
* `sys_user` carries no locale column, and request-scoped locale
* (`Accept-Language` → `ExecutionContext.requestLocale`) does not exist at
* async delivery time. Per the maintainer ruling of **2026-08-13** a
* per-user locale is DEFERRED until measured pull; when it lands it layers
* in as an override at that same seam. So do not author on the belief that
* two recipients with different personal languages will receive different
* rows — today they receive the same one.
* Inline `title`/`message` are the NON-localizable path — raw strings sent
* to every recipient verbatim. The two paths are mutually exclusive on one
* node (see the `superRefine` below): runtime precedence would silently
Expand DownExpand Up@@ -178,14 +194,22 @@ export const NotifyConfigSchema = lazySchema(() => strictObject({
.describe('Notification body, sent verbatim like `title` (not localizable). Only valid with inline `title`, never with `template`.'),
/**
* The localizable content path (#9205): name of a `sys_email_template`
* bundle. Resolved by `(name, recipient locale)` AT DELIVERY TIME —
* `IEmailService.sendTemplate({ template, locale })` picks the recipient
* locale's row with the documented en-US fallback ladder. Read RAW like
* `topic`/`channels`: a static metadata cross-reference, never interpolated.
* Mutually exclusive with inline `title`/`message`.
* bundle. Resolved by `(name, locale)` AT DELIVERY TIME —
* `IEmailService.sendTemplate({ template, locale })` picks that locale's row
* with the documented en-US fallback ladder.
*
* The `locale` is ONE value for the whole notification, NOT one per
* recipient: `payload.locale` when the producer set one (interpolated once,
* before fan-out), else the DEPLOYMENT DEFAULT —
* `II18nService.getDefaultLocale()`. There is no per-user locale to read
* (`sys_user` has no locale column); the 2026-08-13 ruling defers one until
* measured pull, and it layers in as an override when it lands.
*
* Read RAW like `topic`/`channels`: a static metadata cross-reference, never
* interpolated. Mutually exclusive with inline `title`/`message`.
*/
template: z.string().optional()
.describe('Email template name (`sys_email_template.name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, recipient locale)` at delivery time and renders subject/body per recipient. Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation.'),
.describe('Email template name (`sys_email_template.name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, locale)` against sys_email_template at delivery time and renders subject/body from that row. The locale is ONE value for the whole notification, not one per recipient: `payload.locale` if the producer set one, else the deployment default (`II18nService.getDefaultLocale()`) — the platform has no per-user locale, so recipients with different personal languages all receive the same row (deferred by the 2026-08-13 ruling; it layers in as an override when it lands). Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation.'),
/**
* Render context for the referenced template's `{{var}}` holes. Values are
* interpolated per run (`{record.x}` resolves), so flow state can feed the
Expand DownExpand Up@@ -248,7 +272,8 @@ export const NotifyConfigSchema = lazySchema(() => strictObject({
path: ['template'],
message:
'`template` cannot be combined with inline `title`/`message` — pick ONE content path: '
+ '`template` (localizable: resolves `(name, recipient locale)` from sys_email_template at delivery) '
+ '`template` (localizable: resolves `(name, locale)` from sys_email_template at delivery, the locale being '
+ '`payload.locale` or the deployment default — one locale per notification, not per recipient) '
+ 'or inline `title` + `message` (sent verbatim, not localizable). To localize, keep `template`, move '
+ 'the text into the template bundle\'s rows, and delete `title`/`message`; runtime precedence would '
+ 'silently ignore one of them.',
Expand All@@ -269,8 +294,9 @@ export const NotifyConfigSchema = lazySchema(() => strictObject({
path: ['title'],
message:
'A notify node needs one content source: inline `title` (+ optional `message`), or a `template` '
+ 'reference resolving a sys_email_template bundle per recipient locale at delivery. Neither was given, '
+ 'so there is nothing to deliver.',
+ 'reference resolving a sys_email_template bundle at delivery in the notification\'s locale '
+ '(`payload.locale` or the deployment default — one locale per notification, not per recipient). '
+ 'Neither was given, so there is nothing to deliver.',
});
}
}));
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
51 changes: 51 additions & 0 deletions .changeset/notify-template-locale-is-deployment-default.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
---
'@objectstack/spec': patch
---

`NotifyConfigSchema.template` now states the locale semantics the delivery path actually enforces — the deployment default, not a per-recipient locale

The `notify` node's localizable path (`template` → a `sys_email_template` bundle)
was documented in `packages/spec/src/automation/io-node-config.zod.ts` as
resolving `(name, recipient locale)` **per recipient** at delivery time, and the
`template` `.describe()` added that it "renders subject/body per recipient".
Read plainly — and it is the text a consuming app's author reads — that says the
recipient's own language selects the template row.

It does not, and deliberately does not. The delivery path
(`service-messaging/src/email-channel.ts`) has said so honestly at its own
`getDefaultTemplateLocale` all along: the platform has no per-user locale
(`sys_user` carries no locale column), and request-scoped locale
(`Accept-Language` → `ExecutionContext.requestLocale`) does not exist at async
delivery time, so "recipient locale" resolves to the **deployment default**,
`II18nService.getDefaultLocale()` — the same ruled source the auth emails use.
The one lever is `payload.locale`, and that is interpolated **once, before
fan-out**, so it is a single value for the whole notification at all three
`channel.send` call sites (`fanOut`, the outbox single-delivery path, and
`processDigestGroup`).

The gap mattered because the wording licensed exactly one conclusion — "convert
the nodes and non-English users get non-English notifications" — which is false,
and acting on it is a **net regression**: `TEMPLATE_*` failures classify
`permanent` and dead-letter, and the inbox channel starts requiring an email
service with `renderTemplate()` where inline text needed none. So the drift was
not a cosmetic imprecision; it was an instruction to make a change that loses
deliveries.

Per the maintainer ruling of **2026-08-13**, the behaviour is the settled side —
a per-user locale is deferred until measured pull — so the prose is the side that
moves. All five "recipient locale" sites in the file now name the resolved value:
the schema doc block, the `template` field's JSDoc and `.describe()`, and both
`superRefine` refusal messages. Each says the locale is `payload.locale` if the
producer set one, else the deployment default, and that it is **one value per
notification, not one per recipient**, with the 2026-08-13 deferral dated in
place so the limitation reads as a decision with provenance rather than a
permanent property of the design — a per-user locale layers in as an override at
that same seam when it lands.

Text only. No schema accepts or refuses anything it did not before, no delivery
behaviour moves, and no wire value changes — `packages/spec` publishes
`src/**/*.zod.ts` and the generated reference page, so the corrected wording
ships to consumers reading either. The pins in
`io-node-config.test.ts` that asserted the old `/recipient locale/` string now
assert the qualification itself, and refuse a bare "recipient locale", so a
future edit cannot quietly restore the promise.
2 changes: 1 addition & 1 deletion content/docs/references/automation/io-node-config.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -98,7 +98,7 @@ const result = HttpConfigSchema.parse(data);
| **recipients** | `string \| string[]` | ✅ | Recipient user id(s) / audience selector(s); `{token}` templates resolve per run |
| **title** | `string` | optional | Notification title, sent to every recipient verbatim (not localizable — use `template` for per-locale content). Either this or `template` is required; the two are mutually exclusive. |
| **message** | `string` | optional | Notification body, sent verbatim like `title` (not localizable). Only valid with inline `title`, never with `template`. |
| **template** | `string` | optional | Email template name (`sys_email_template.name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, recipient locale)` at delivery time and renders subject/body per recipient. Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation. |
| **template** | `string` | optional | Email template name (`sys_email_template.name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, locale)` against sys_email_template at delivery time and renders subject/body from that row. The locale is ONE value for the whole notification, not one per recipient: `payload.locale` if the producer set one, else the deployment default (`II18nService.getDefaultLocale()`) — the platform has no per-user locale, so recipients with different personal languages all receive the same row (deferred by the 2026-08-13 ruling; it layers in as an override when it lands). Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation. |
| **templateData** | `Record<string, any>` | optional | Render context for the referenced template's `{{var}}` placeholders; values interpolate `{token}` templates per run. Only valid together with `template`. |
| **channels** | `string \| string[]` | optional | Channels to fan out to (default: inbox) |
| **topic** | `string` | optional | Event topic (default: "notify") |
Expand Down
38 changes: 34 additions & 4 deletions packages/spec/src/automation/io-node-config.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -233,10 +233,22 @@ describe('NotifyConfigSchema — strict as of #4001 批 9', () => {
//
// Ruled 「立项,走 emailTemplates 路线」: a notify node references a
// `sys_email_template` bundle by name and the delivery path resolves
// `(name, recipient locale)` at delivery time. Inline `title`/`message`
// `(name, locale)` at delivery time. Inline `title`/`message`
// stay fully valid (the acceptance faces above) as the non-localizable
// path; the two paths are mutually exclusive — loud refusal over silent
// precedence, following `objectNavTargetExclusivity` (ui/app.zod.ts).
//
// The `locale` half of that pair is pinned below to the DEPLOYMENT DEFAULT,
// not to a per-recipient value. These strings previously said "recipient
// locale" unqualified, which reads as "each recipient's own language selects
// the row" — the delivery path does not do that and deliberately does not
// (maintainer ruling 2026-08-13: no per-user locale until measured pull;
// `sys_user` carries no locale column, and `payload.locale` is interpolated
// once before fan-out, so it is one value for the whole notification). The
// assertions therefore pin the qualification itself: a future edit that
// drops it back to a bare "recipient locale" turns these RED, because the
// wording an author reads is the whole contract here — declared must equal
// enforced.
describe('template reference (#9205)', () => {
/** Custom (superRefine) issues at exactly `path`, or `[]` when accepted. */
function customIssuesAt(value: unknown, path: string): ReadonlyArray<{ code: string; message: string }> {
Expand DownExpand Up@@ -274,7 +286,13 @@ describe('NotifyConfigSchema — strict as of #4001 批 9', () => {
// identified, and the fix stated.
expect(msg).toContain('`template`');
expect(msg).toContain('`title`');
expect(msg).toMatch(/recipient locale/);
// The localizable path is identified by what it actually resolves —
// `(name, locale)` with the locale qualified — never a bare
// "recipient locale", which promises per-recipient selection.
expect(msg).toMatch(/\(name, locale\)/);
expect(msg).toMatch(/deployment default/);
expect(msg).toMatch(/not per recipient/);
expect(msg).not.toMatch(/recipient locale/);
expect(msg).toMatch(/delete `title`\/`message`/);
expect(msg).toMatch(/silently ignore/);
}
Expand All@@ -299,10 +317,22 @@ describe('NotifyConfigSchema — strict as of #4001 批 9', () => {
// Non-empty arms first, so the pattern arms cannot pass vacuously (#6918).
const templateDoc = shape.template!.description ?? '';
expect(templateDoc.length, 'template .describe() must not be empty').toBeGreaterThan(0);
// The contract: resolves by (name, recipient locale) at delivery time…
expect(templateDoc).toMatch(/recipient locale/);
// The contract: resolves by (name, locale) at delivery time…
expect(templateDoc).toMatch(/\(name, locale\)/);
expect(templateDoc).toMatch(/delivery time/);
expect(templateDoc).toContain('sys_email_template');
// …with the locale named as what it IS — the deployment default, one
// value per notification. A bare "recipient locale" here is the defect
// this pin exists to catch: it licenses "convert the nodes and non-English
// users get non-English mail", which is false and is a net regression when
// acted on (TEMPLATE_* failures classify `permanent` and dead-letter).
expect(templateDoc).not.toMatch(/recipient locale/);
expect(templateDoc).toMatch(/deployment default/);
expect(templateDoc).toContain('II18nService.getDefaultLocale()');
expect(templateDoc).toMatch(/not one per recipient/);
// The deferral is dated, so the text carries its own provenance rather
// than reading as a permanent limitation of the design.
expect(templateDoc).toContain('2026-08-13');
// …and it is a RAW cross-reference, like topic/channels.
expect(templateDoc).toMatch(/no `\{token\}` interpolation/i);

Expand Down
48 changes: 37 additions & 11 deletions packages/spec/src/automation/io-node-config.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -132,8 +132,24 @@ const NOTIFY_KEY_GUIDANCE: Readonly<Record<string, string>> = {
* - **Localization contract (#9205, ruled 「走 emailTemplates 路线」):**
* `template` names a `sys_email_template` bundle
* (`EmailTemplateDefinitionSchema`, `system/email-template.zod.ts`), and the
* delivery path resolves `(name, recipient locale)` per recipient at
* delivery time via `IEmailService.sendTemplate({ template, locale })`.
* delivery path resolves `(name, locale)` at delivery time via
* `IEmailService.sendTemplate({ template, locale })`.
*
* That `locale` is **ONE value for the whole notification, not one per
* recipient** — declared here exactly as the delivery path enforces it
* (`service-messaging/src/email-channel.ts`, which says the same thing at
* its own `getDefaultTemplateLocale`). It is `payload.locale` when the
* producer set one — interpolated ONCE, before fan-out, so every recipient
* of a node gets that single value — and otherwise the **deployment
* default**, `II18nService.getDefaultLocale()`, the same ruled source the
* auth emails use (#8195). The platform has no per-user locale to read:
* `sys_user` carries no locale column, and request-scoped locale
* (`Accept-Language` → `ExecutionContext.requestLocale`) does not exist at
* async delivery time. Per the maintainer ruling of **2026-08-13** a
* per-user locale is DEFERRED until measured pull; when it lands it layers
* in as an override at that same seam. So do not author on the belief that
* two recipients with different personal languages will receive different
* rows — today they receive the same one.
* Inline `title`/`message` are the NON-localizable path — raw strings sent
* to every recipient verbatim. The two paths are mutually exclusive on one
* node (see the `superRefine` below): runtime precedence would silently
Expand DownExpand Up@@ -178,14 +194,22 @@ export const NotifyConfigSchema = lazySchema(() => strictObject({
.describe('Notification body, sent verbatim like `title` (not localizable). Only valid with inline `title`, never with `template`.'),
/**
* The localizable content path (#9205): name of a `sys_email_template`
* bundle. Resolved by `(name, recipient locale)` AT DELIVERY TIME —
* `IEmailService.sendTemplate({ template, locale })` picks the recipient
* locale's row with the documented en-US fallback ladder. Read RAW like
* `topic`/`channels`: a static metadata cross-reference, never interpolated.
* Mutually exclusive with inline `title`/`message`.
* bundle. Resolved by `(name, locale)` AT DELIVERY TIME —
* `IEmailService.sendTemplate({ template, locale })` picks that locale's row
* with the documented en-US fallback ladder.
*
* The `locale` is ONE value for the whole notification, NOT one per
* recipient: `payload.locale` when the producer set one (interpolated once,
* before fan-out), else the DEPLOYMENT DEFAULT —
* `II18nService.getDefaultLocale()`. There is no per-user locale to read
* (`sys_user` has no locale column); the 2026-08-13 ruling defers one until
* measured pull, and it layers in as an override when it lands.
*
* Read RAW like `topic`/`channels`: a static metadata cross-reference, never
* interpolated. Mutually exclusive with inline `title`/`message`.
*/
template: z.string().optional()
.describe('Email template name (`sys_email_template.name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, recipient locale)` at delivery time and renders subject/body per recipient. Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation.'),
.describe('Email template name (`sys_email_template.name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, locale)` against sys_email_template at delivery time and renders subject/body from that row. The locale is ONE value for the whole notification, not one per recipient: `payload.locale` if the producer set one, else the deployment default (`II18nService.getDefaultLocale()`) — the platform has no per-user locale, so recipients with different personal languages all receive the same row (deferred by the 2026-08-13 ruling; it layers in as an override when it lands). Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation.'),
/**
* Render context for the referenced template's `{{var}}` holes. Values are
* interpolated per run (`{record.x}` resolves), so flow state can feed the
Expand DownExpand Up@@ -248,7 +272,8 @@ export const NotifyConfigSchema = lazySchema(() => strictObject({
path: ['template'],
message:
'`template` cannot be combined with inline `title`/`message` — pick ONE content path: '
+ '`template` (localizable: resolves `(name, recipient locale)` from sys_email_template at delivery) '
+ '`template` (localizable: resolves `(name, locale)` from sys_email_template at delivery, the locale being '
+ '`payload.locale` or the deployment default — one locale per notification, not per recipient) '
+ 'or inline `title` + `message` (sent verbatim, not localizable). To localize, keep `template`, move '
+ 'the text into the template bundle\'s rows, and delete `title`/`message`; runtime precedence would '
+ 'silently ignore one of them.',
Expand All@@ -269,8 +294,9 @@ export const NotifyConfigSchema = lazySchema(() => strictObject({
path: ['title'],
message:
'A notify node needs one content source: inline `title` (+ optional `message`), or a `template` '
+ 'reference resolving a sys_email_template bundle per recipient locale at delivery. Neither was given, '
+ 'so there is nothing to deliver.',
+ 'reference resolving a sys_email_template bundle at delivery in the notification\'s locale '
+ '(`payload.locale` or the deployment default — one locale per notification, not per recipient). '
+ 'Neither was given, so there is nothing to deliver.',
});
}
}));
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
51 changes: 51 additions & 0 deletions .changeset/notify-template-locale-is-deployment-default.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
---
'@objectstack/spec': patch
---

`NotifyConfigSchema.template` now states the locale semantics the delivery path actually enforces — the deployment default, not a per-recipient locale

The `notify` node's localizable path (`template` → a `sys_email_template` bundle)
was documented in `packages/spec/src/automation/io-node-config.zod.ts` as
resolving `(name, recipient locale)` **per recipient** at delivery time, and the
`template` `.describe()` added that it "renders subject/body per recipient".
Read plainly — and it is the text a consuming app's author reads — that says the
recipient's own language selects the template row.

It does not, and deliberately does not. The delivery path
(`service-messaging/src/email-channel.ts`) has said so honestly at its own
`getDefaultTemplateLocale` all along: the platform has no per-user locale
(`sys_user` carries no locale column), and request-scoped locale
(`Accept-Language` → `ExecutionContext.requestLocale`) does not exist at async
delivery time, so "recipient locale" resolves to the **deployment default**,
`II18nService.getDefaultLocale()` — the same ruled source the auth emails use.
The one lever is `payload.locale`, and that is interpolated **once, before
fan-out**, so it is a single value for the whole notification at all three
`channel.send` call sites (`fanOut`, the outbox single-delivery path, and
`processDigestGroup`).

The gap mattered because the wording licensed exactly one conclusion — "convert
the nodes and non-English users get non-English notifications" — which is false,
and acting on it is a **net regression**: `TEMPLATE_*` failures classify
`permanent` and dead-letter, and the inbox channel starts requiring an email
service with `renderTemplate()` where inline text needed none. So the drift was
not a cosmetic imprecision; it was an instruction to make a change that loses
deliveries.

Per the maintainer ruling of **2026-08-13**, the behaviour is the settled side —
a per-user locale is deferred until measured pull — so the prose is the side that
moves. All five "recipient locale" sites in the file now name the resolved value:
the schema doc block, the `template` field's JSDoc and `.describe()`, and both
`superRefine` refusal messages. Each says the locale is `payload.locale` if the
producer set one, else the deployment default, and that it is **one value per
notification, not one per recipient**, with the 2026-08-13 deferral dated in
place so the limitation reads as a decision with provenance rather than a
permanent property of the design — a per-user locale layers in as an override at
that same seam when it lands.

Text only. No schema accepts or refuses anything it did not before, no delivery
behaviour moves, and no wire value changes — `packages/spec` publishes
`src/**/*.zod.ts` and the generated reference page, so the corrected wording
ships to consumers reading either. The pins in
`io-node-config.test.ts` that asserted the old `/recipient locale/` string now
assert the qualification itself, and refuse a bare "recipient locale", so a
future edit cannot quietly restore the promise.
2 changes: 1 addition & 1 deletion content/docs/references/automation/io-node-config.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -98,7 +98,7 @@ const result = HttpConfigSchema.parse(data);
| **recipients** | `string \| string[]` | ✅ | Recipient user id(s) / audience selector(s); `{token}` templates resolve per run |
| **title** | `string` | optional | Notification title, sent to every recipient verbatim (not localizable — use `template` for per-locale content). Either this or `template` is required; the two are mutually exclusive. |
| **message** | `string` | optional | Notification body, sent verbatim like `title` (not localizable). Only valid with inline `title`, never with `template`. |
| **template** | `string` | optional | Email template name (`sys_email_template.name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, recipient locale)` at delivery time and renders subject/body per recipient. Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation. |
| **template** | `string` | optional | Email template name (`sys_email_template.name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, locale)` against sys_email_template at delivery time and renders subject/body from that row. The locale is ONE value for the whole notification, not one per recipient: `payload.locale` if the producer set one, else the deployment default (`II18nService.getDefaultLocale()`) — the platform has no per-user locale, so recipients with different personal languages all receive the same row (deferred by the 2026-08-13 ruling; it layers in as an override when it lands). Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation. |
| **templateData** | `Record<string, any>` | optional | Render context for the referenced template's `{{var}}` placeholders; values interpolate `{token}` templates per run. Only valid together with `template`. |
| **channels** | `string \| string[]` | optional | Channels to fan out to (default: inbox) |
| **topic** | `string` | optional | Event topic (default: "notify") |
Expand Down
38 changes: 34 additions & 4 deletions packages/spec/src/automation/io-node-config.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -233,10 +233,22 @@ describe('NotifyConfigSchema — strict as of #4001 批 9', () => {
//
// Ruled 「立项,走 emailTemplates 路线」: a notify node references a
// `sys_email_template` bundle by name and the delivery path resolves
// `(name, recipient locale)` at delivery time. Inline `title`/`message`
// `(name, locale)` at delivery time. Inline `title`/`message`
// stay fully valid (the acceptance faces above) as the non-localizable
// path; the two paths are mutually exclusive — loud refusal over silent
// precedence, following `objectNavTargetExclusivity` (ui/app.zod.ts).
//
// The `locale` half of that pair is pinned below to the DEPLOYMENT DEFAULT,
// not to a per-recipient value. These strings previously said "recipient
// locale" unqualified, which reads as "each recipient's own language selects
// the row" — the delivery path does not do that and deliberately does not
// (maintainer ruling 2026-08-13: no per-user locale until measured pull;
// `sys_user` carries no locale column, and `payload.locale` is interpolated
// once before fan-out, so it is one value for the whole notification). The
// assertions therefore pin the qualification itself: a future edit that
// drops it back to a bare "recipient locale" turns these RED, because the
// wording an author reads is the whole contract here — declared must equal
// enforced.
describe('template reference (#9205)', () => {
/** Custom (superRefine) issues at exactly `path`, or `[]` when accepted. */
function customIssuesAt(value: unknown, path: string): ReadonlyArray<{ code: string; message: string }> {
Expand DownExpand Up@@ -274,7 +286,13 @@ describe('NotifyConfigSchema — strict as of #4001 批 9', () => {
// identified, and the fix stated.
expect(msg).toContain('`template`');
expect(msg).toContain('`title`');
expect(msg).toMatch(/recipient locale/);
// The localizable path is identified by what it actually resolves —
// `(name, locale)` with the locale qualified — never a bare
// "recipient locale", which promises per-recipient selection.
expect(msg).toMatch(/\(name, locale\)/);
expect(msg).toMatch(/deployment default/);
expect(msg).toMatch(/not per recipient/);
expect(msg).not.toMatch(/recipient locale/);
expect(msg).toMatch(/delete `title`\/`message`/);
expect(msg).toMatch(/silently ignore/);
}
Expand All@@ -299,10 +317,22 @@ describe('NotifyConfigSchema — strict as of #4001 批 9', () => {
// Non-empty arms first, so the pattern arms cannot pass vacuously (#6918).
const templateDoc = shape.template!.description ?? '';
expect(templateDoc.length, 'template .describe() must not be empty').toBeGreaterThan(0);
// The contract: resolves by (name, recipient locale) at delivery time…
expect(templateDoc).toMatch(/recipient locale/);
// The contract: resolves by (name, locale) at delivery time…
expect(templateDoc).toMatch(/\(name, locale\)/);
expect(templateDoc).toMatch(/delivery time/);
expect(templateDoc).toContain('sys_email_template');
// …with the locale named as what it IS — the deployment default, one
// value per notification. A bare "recipient locale" here is the defect
// this pin exists to catch: it licenses "convert the nodes and non-English
// users get non-English mail", which is false and is a net regression when
// acted on (TEMPLATE_* failures classify `permanent` and dead-letter).
expect(templateDoc).not.toMatch(/recipient locale/);
expect(templateDoc).toMatch(/deployment default/);
expect(templateDoc).toContain('II18nService.getDefaultLocale()');
expect(templateDoc).toMatch(/not one per recipient/);
// The deferral is dated, so the text carries its own provenance rather
// than reading as a permanent limitation of the design.
expect(templateDoc).toContain('2026-08-13');
// …and it is a RAW cross-reference, like topic/channels.
expect(templateDoc).toMatch(/no `\{token\}` interpolation/i);

Expand Down
48 changes: 37 additions & 11 deletions packages/spec/src/automation/io-node-config.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -132,8 +132,24 @@ const NOTIFY_KEY_GUIDANCE: Readonly<Record<string, string>> = {
* - **Localization contract (#9205, ruled 「走 emailTemplates 路线」):**
* `template` names a `sys_email_template` bundle
* (`EmailTemplateDefinitionSchema`, `system/email-template.zod.ts`), and the
* delivery path resolves `(name, recipient locale)` per recipient at
* delivery time via `IEmailService.sendTemplate({ template, locale })`.
* delivery path resolves `(name, locale)` at delivery time via
* `IEmailService.sendTemplate({ template, locale })`.
*
* That `locale` is **ONE value for the whole notification, not one per
* recipient** — declared here exactly as the delivery path enforces it
* (`service-messaging/src/email-channel.ts`, which says the same thing at
* its own `getDefaultTemplateLocale`). It is `payload.locale` when the
* producer set one — interpolated ONCE, before fan-out, so every recipient
* of a node gets that single value — and otherwise the **deployment
* default**, `II18nService.getDefaultLocale()`, the same ruled source the
* auth emails use (#8195). The platform has no per-user locale to read:
* `sys_user` carries no locale column, and request-scoped locale
* (`Accept-Language` → `ExecutionContext.requestLocale`) does not exist at
* async delivery time. Per the maintainer ruling of **2026-08-13** a
* per-user locale is DEFERRED until measured pull; when it lands it layers
* in as an override at that same seam. So do not author on the belief that
* two recipients with different personal languages will receive different
* rows — today they receive the same one.
* Inline `title`/`message` are the NON-localizable path — raw strings sent
* to every recipient verbatim. The two paths are mutually exclusive on one
* node (see the `superRefine` below): runtime precedence would silently
Expand DownExpand Up@@ -178,14 +194,22 @@ export const NotifyConfigSchema = lazySchema(() => strictObject({
.describe('Notification body, sent verbatim like `title` (not localizable). Only valid with inline `title`, never with `template`.'),
/**
* The localizable content path (#9205): name of a `sys_email_template`
* bundle. Resolved by `(name, recipient locale)` AT DELIVERY TIME —
* `IEmailService.sendTemplate({ template, locale })` picks the recipient
* locale's row with the documented en-US fallback ladder. Read RAW like
* `topic`/`channels`: a static metadata cross-reference, never interpolated.
* Mutually exclusive with inline `title`/`message`.
* bundle. Resolved by `(name, locale)` AT DELIVERY TIME —
* `IEmailService.sendTemplate({ template, locale })` picks that locale's row
* with the documented en-US fallback ladder.
*
* The `locale` is ONE value for the whole notification, NOT one per
* recipient: `payload.locale` when the producer set one (interpolated once,
* before fan-out), else the DEPLOYMENT DEFAULT —
* `II18nService.getDefaultLocale()`. There is no per-user locale to read
* (`sys_user` has no locale column); the 2026-08-13 ruling defers one until
* measured pull, and it layers in as an override when it lands.
*
* Read RAW like `topic`/`channels`: a static metadata cross-reference, never
* interpolated. Mutually exclusive with inline `title`/`message`.
*/
template: z.string().optional()
.describe('Email template name (`sys_email_template.name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, recipient locale)` at delivery time and renders subject/body per recipient. Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation.'),
.describe('Email template name (`sys_email_template.name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, locale)` against sys_email_template at delivery time and renders subject/body from that row. The locale is ONE value for the whole notification, not one per recipient: `payload.locale` if the producer set one, else the deployment default (`II18nService.getDefaultLocale()`) — the platform has no per-user locale, so recipients with different personal languages all receive the same row (deferred by the 2026-08-13 ruling; it layers in as an override when it lands). Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation.'),
/**
* Render context for the referenced template's `{{var}}` holes. Values are
* interpolated per run (`{record.x}` resolves), so flow state can feed the
Expand DownExpand Up@@ -248,7 +272,8 @@ export const NotifyConfigSchema = lazySchema(() => strictObject({
path: ['template'],
message:
'`template` cannot be combined with inline `title`/`message` — pick ONE content path: '
+ '`template` (localizable: resolves `(name, recipient locale)` from sys_email_template at delivery) '
+ '`template` (localizable: resolves `(name, locale)` from sys_email_template at delivery, the locale being '
+ '`payload.locale` or the deployment default — one locale per notification, not per recipient) '
+ 'or inline `title` + `message` (sent verbatim, not localizable). To localize, keep `template`, move '
+ 'the text into the template bundle\'s rows, and delete `title`/`message`; runtime precedence would '
+ 'silently ignore one of them.',
Expand All@@ -269,8 +294,9 @@ export const NotifyConfigSchema = lazySchema(() => strictObject({
path: ['title'],
message:
'A notify node needs one content source: inline `title` (+ optional `message`), or a `template` '
+ 'reference resolving a sys_email_template bundle per recipient locale at delivery. Neither was given, '
+ 'so there is nothing to deliver.',
+ 'reference resolving a sys_email_template bundle at delivery in the notification\'s locale '
+ '(`payload.locale` or the deployment default — one locale per notification, not per recipient). '
+ 'Neither was given, so there is nothing to deliver.',
});
}
}));
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
51 changes: 51 additions & 0 deletions .changeset/notify-template-locale-is-deployment-default.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
---
'@objectstack/spec': patch
---

`NotifyConfigSchema.template` now states the locale semantics the delivery path actually enforces — the deployment default, not a per-recipient locale

The `notify` node's localizable path (`template` → a `sys_email_template` bundle)
was documented in `packages/spec/src/automation/io-node-config.zod.ts` as
resolving `(name, recipient locale)` **per recipient** at delivery time, and the
`template` `.describe()` added that it "renders subject/body per recipient".
Read plainly — and it is the text a consuming app's author reads — that says the
recipient's own language selects the template row.

It does not, and deliberately does not. The delivery path
(`service-messaging/src/email-channel.ts`) has said so honestly at its own
`getDefaultTemplateLocale` all along: the platform has no per-user locale
(`sys_user` carries no locale column), and request-scoped locale
(`Accept-Language` → `ExecutionContext.requestLocale`) does not exist at async
delivery time, so "recipient locale" resolves to the **deployment default**,
`II18nService.getDefaultLocale()` — the same ruled source the auth emails use.
The one lever is `payload.locale`, and that is interpolated **once, before
fan-out**, so it is a single value for the whole notification at all three
`channel.send` call sites (`fanOut`, the outbox single-delivery path, and
`processDigestGroup`).

The gap mattered because the wording licensed exactly one conclusion — "convert
the nodes and non-English users get non-English notifications" — which is false,
and acting on it is a **net regression**: `TEMPLATE_*` failures classify
`permanent` and dead-letter, and the inbox channel starts requiring an email
service with `renderTemplate()` where inline text needed none. So the drift was
not a cosmetic imprecision; it was an instruction to make a change that loses
deliveries.

Per the maintainer ruling of **2026-08-13**, the behaviour is the settled side —
a per-user locale is deferred until measured pull — so the prose is the side that
moves. All five "recipient locale" sites in the file now name the resolved value:
the schema doc block, the `template` field's JSDoc and `.describe()`, and both
`superRefine` refusal messages. Each says the locale is `payload.locale` if the
producer set one, else the deployment default, and that it is **one value per
notification, not one per recipient**, with the 2026-08-13 deferral dated in
place so the limitation reads as a decision with provenance rather than a
permanent property of the design — a per-user locale layers in as an override at
that same seam when it lands.

Text only. No schema accepts or refuses anything it did not before, no delivery
behaviour moves, and no wire value changes — `packages/spec` publishes
`src/**/*.zod.ts` and the generated reference page, so the corrected wording
ships to consumers reading either. The pins in
`io-node-config.test.ts` that asserted the old `/recipient locale/` string now
assert the qualification itself, and refuse a bare "recipient locale", so a
future edit cannot quietly restore the promise.
2 changes: 1 addition & 1 deletion content/docs/references/automation/io-node-config.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -98,7 +98,7 @@ const result = HttpConfigSchema.parse(data);
| **recipients** | `string \| string[]` | ✅ | Recipient user id(s) / audience selector(s); `{token}` templates resolve per run |
| **title** | `string` | optional | Notification title, sent to every recipient verbatim (not localizable — use `template` for per-locale content). Either this or `template` is required; the two are mutually exclusive. |
| **message** | `string` | optional | Notification body, sent verbatim like `title` (not localizable). Only valid with inline `title`, never with `template`. |
| **template** | `string` | optional | Email template name (`sys_email_template.name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, recipient locale)` at delivery time and renders subject/body per recipient. Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation. |
| **template** | `string` | optional | Email template name (`sys_email_template.name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, locale)` against sys_email_template at delivery time and renders subject/body from that row. The locale is ONE value for the whole notification, not one per recipient: `payload.locale` if the producer set one, else the deployment default (`II18nService.getDefaultLocale()`) — the platform has no per-user locale, so recipients with different personal languages all receive the same row (deferred by the 2026-08-13 ruling; it layers in as an override when it lands). Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation. |
| **templateData** | `Record<string, any>` | optional | Render context for the referenced template's `{{var}}` placeholders; values interpolate `{token}` templates per run. Only valid together with `template`. |
| **channels** | `string \| string[]` | optional | Channels to fan out to (default: inbox) |
| **topic** | `string` | optional | Event topic (default: "notify") |
Expand Down
38 changes: 34 additions & 4 deletions packages/spec/src/automation/io-node-config.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -233,10 +233,22 @@ describe('NotifyConfigSchema — strict as of #4001 批 9', () => {
//
// Ruled 「立项,走 emailTemplates 路线」: a notify node references a
// `sys_email_template` bundle by name and the delivery path resolves
// `(name, recipient locale)` at delivery time. Inline `title`/`message`
// `(name, locale)` at delivery time. Inline `title`/`message`
// stay fully valid (the acceptance faces above) as the non-localizable
// path; the two paths are mutually exclusive — loud refusal over silent
// precedence, following `objectNavTargetExclusivity` (ui/app.zod.ts).
//
// The `locale` half of that pair is pinned below to the DEPLOYMENT DEFAULT,
// not to a per-recipient value. These strings previously said "recipient
// locale" unqualified, which reads as "each recipient's own language selects
// the row" — the delivery path does not do that and deliberately does not
// (maintainer ruling 2026-08-13: no per-user locale until measured pull;
// `sys_user` carries no locale column, and `payload.locale` is interpolated
// once before fan-out, so it is one value for the whole notification). The
// assertions therefore pin the qualification itself: a future edit that
// drops it back to a bare "recipient locale" turns these RED, because the
// wording an author reads is the whole contract here — declared must equal
// enforced.
describe('template reference (#9205)', () => {
/** Custom (superRefine) issues at exactly `path`, or `[]` when accepted. */
function customIssuesAt(value: unknown, path: string): ReadonlyArray<{ code: string; message: string }> {
Expand DownExpand Up@@ -274,7 +286,13 @@ describe('NotifyConfigSchema — strict as of #4001 批 9', () => {
// identified, and the fix stated.
expect(msg).toContain('`template`');
expect(msg).toContain('`title`');
expect(msg).toMatch(/recipient locale/);
// The localizable path is identified by what it actually resolves —
// `(name, locale)` with the locale qualified — never a bare
// "recipient locale", which promises per-recipient selection.
expect(msg).toMatch(/\(name, locale\)/);
expect(msg).toMatch(/deployment default/);
expect(msg).toMatch(/not per recipient/);
expect(msg).not.toMatch(/recipient locale/);
expect(msg).toMatch(/delete `title`\/`message`/);
expect(msg).toMatch(/silently ignore/);
}
Expand All@@ -299,10 +317,22 @@ describe('NotifyConfigSchema — strict as of #4001 批 9', () => {
// Non-empty arms first, so the pattern arms cannot pass vacuously (#6918).
const templateDoc = shape.template!.description ?? '';
expect(templateDoc.length, 'template .describe() must not be empty').toBeGreaterThan(0);
// The contract: resolves by (name, recipient locale) at delivery time…
expect(templateDoc).toMatch(/recipient locale/);
// The contract: resolves by (name, locale) at delivery time…
expect(templateDoc).toMatch(/\(name, locale\)/);
expect(templateDoc).toMatch(/delivery time/);
expect(templateDoc).toContain('sys_email_template');
// …with the locale named as what it IS — the deployment default, one
// value per notification. A bare "recipient locale" here is the defect
// this pin exists to catch: it licenses "convert the nodes and non-English
// users get non-English mail", which is false and is a net regression when
// acted on (TEMPLATE_* failures classify `permanent` and dead-letter).
expect(templateDoc).not.toMatch(/recipient locale/);
expect(templateDoc).toMatch(/deployment default/);
expect(templateDoc).toContain('II18nService.getDefaultLocale()');
expect(templateDoc).toMatch(/not one per recipient/);
// The deferral is dated, so the text carries its own provenance rather
// than reading as a permanent limitation of the design.
expect(templateDoc).toContain('2026-08-13');
// …and it is a RAW cross-reference, like topic/channels.
expect(templateDoc).toMatch(/no `\{token\}` interpolation/i);

Expand Down
48 changes: 37 additions & 11 deletions packages/spec/src/automation/io-node-config.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -132,8 +132,24 @@ const NOTIFY_KEY_GUIDANCE: Readonly<Record<string, string>> = {
* - **Localization contract (#9205, ruled 「走 emailTemplates 路线」):**
* `template` names a `sys_email_template` bundle
* (`EmailTemplateDefinitionSchema`, `system/email-template.zod.ts`), and the
* delivery path resolves `(name, recipient locale)` per recipient at
* delivery time via `IEmailService.sendTemplate({ template, locale })`.
* delivery path resolves `(name, locale)` at delivery time via
* `IEmailService.sendTemplate({ template, locale })`.
*
* That `locale` is **ONE value for the whole notification, not one per
* recipient** — declared here exactly as the delivery path enforces it
* (`service-messaging/src/email-channel.ts`, which says the same thing at
* its own `getDefaultTemplateLocale`). It is `payload.locale` when the
* producer set one — interpolated ONCE, before fan-out, so every recipient
* of a node gets that single value — and otherwise the **deployment
* default**, `II18nService.getDefaultLocale()`, the same ruled source the
* auth emails use (#8195). The platform has no per-user locale to read:
* `sys_user` carries no locale column, and request-scoped locale
* (`Accept-Language` → `ExecutionContext.requestLocale`) does not exist at
* async delivery time. Per the maintainer ruling of **2026-08-13** a
* per-user locale is DEFERRED until measured pull; when it lands it layers
* in as an override at that same seam. So do not author on the belief that
* two recipients with different personal languages will receive different
* rows — today they receive the same one.
* Inline `title`/`message` are the NON-localizable path — raw strings sent
* to every recipient verbatim. The two paths are mutually exclusive on one
* node (see the `superRefine` below): runtime precedence would silently
Expand DownExpand Up@@ -178,14 +194,22 @@ export const NotifyConfigSchema = lazySchema(() => strictObject({
.describe('Notification body, sent verbatim like `title` (not localizable). Only valid with inline `title`, never with `template`.'),
/**
* The localizable content path (#9205): name of a `sys_email_template`
* bundle. Resolved by `(name, recipient locale)` AT DELIVERY TIME —
* `IEmailService.sendTemplate({ template, locale })` picks the recipient
* locale's row with the documented en-US fallback ladder. Read RAW like
* `topic`/`channels`: a static metadata cross-reference, never interpolated.
* Mutually exclusive with inline `title`/`message`.
* bundle. Resolved by `(name, locale)` AT DELIVERY TIME —
* `IEmailService.sendTemplate({ template, locale })` picks that locale's row
* with the documented en-US fallback ladder.
*
* The `locale` is ONE value for the whole notification, NOT one per
* recipient: `payload.locale` when the producer set one (interpolated once,
* before fan-out), else the DEPLOYMENT DEFAULT —
* `II18nService.getDefaultLocale()`. There is no per-user locale to read
* (`sys_user` has no locale column); the 2026-08-13 ruling defers one until
* measured pull, and it layers in as an override when it lands.
*
* Read RAW like `topic`/`channels`: a static metadata cross-reference, never
* interpolated. Mutually exclusive with inline `title`/`message`.
*/
template: z.string().optional()
.describe('Email template name (`sys_email_template.name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, recipient locale)` at delivery time and renders subject/body per recipient. Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation.'),
.describe('Email template name (`sys_email_template.name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, locale)` against sys_email_template at delivery time and renders subject/body from that row. The locale is ONE value for the whole notification, not one per recipient: `payload.locale` if the producer set one, else the deployment default (`II18nService.getDefaultLocale()`) — the platform has no per-user locale, so recipients with different personal languages all receive the same row (deferred by the 2026-08-13 ruling; it layers in as an override when it lands). Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation.'),
/**
* Render context for the referenced template's `{{var}}` holes. Values are
* interpolated per run (`{record.x}` resolves), so flow state can feed the
Expand DownExpand Up@@ -248,7 +272,8 @@ export const NotifyConfigSchema = lazySchema(() => strictObject({
path: ['template'],
message:
'`template` cannot be combined with inline `title`/`message` — pick ONE content path: '
+ '`template` (localizable: resolves `(name, recipient locale)` from sys_email_template at delivery) '
+ '`template` (localizable: resolves `(name, locale)` from sys_email_template at delivery, the locale being '
+ '`payload.locale` or the deployment default — one locale per notification, not per recipient) '
+ 'or inline `title` + `message` (sent verbatim, not localizable). To localize, keep `template`, move '
+ 'the text into the template bundle\'s rows, and delete `title`/`message`; runtime precedence would '
+ 'silently ignore one of them.',
Expand All@@ -269,8 +294,9 @@ export const NotifyConfigSchema = lazySchema(() => strictObject({
path: ['title'],
message:
'A notify node needs one content source: inline `title` (+ optional `message`), or a `template` '
+ 'reference resolving a sys_email_template bundle per recipient locale at delivery. Neither was given, '
+ 'so there is nothing to deliver.',
+ 'reference resolving a sys_email_template bundle at delivery in the notification\'s locale '
+ '(`payload.locale` or the deployment default — one locale per notification, not per recipient). '
+ 'Neither was given, so there is nothing to deliver.',
});
}
}));
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
51 changes: 51 additions & 0 deletions .changeset/notify-template-locale-is-deployment-default.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
---
'@objectstack/spec': patch
---

`NotifyConfigSchema.template` now states the locale semantics the delivery path actually enforces — the deployment default, not a per-recipient locale

The `notify` node's localizable path (`template` → a `sys_email_template` bundle)
was documented in `packages/spec/src/automation/io-node-config.zod.ts` as
resolving `(name, recipient locale)` **per recipient** at delivery time, and the
`template` `.describe()` added that it "renders subject/body per recipient".
Read plainly — and it is the text a consuming app's author reads — that says the
recipient's own language selects the template row.

It does not, and deliberately does not. The delivery path
(`service-messaging/src/email-channel.ts`) has said so honestly at its own
`getDefaultTemplateLocale` all along: the platform has no per-user locale
(`sys_user` carries no locale column), and request-scoped locale
(`Accept-Language` → `ExecutionContext.requestLocale`) does not exist at async
delivery time, so "recipient locale" resolves to the **deployment default**,
`II18nService.getDefaultLocale()` — the same ruled source the auth emails use.
The one lever is `payload.locale`, and that is interpolated **once, before
fan-out**, so it is a single value for the whole notification at all three
`channel.send` call sites (`fanOut`, the outbox single-delivery path, and
`processDigestGroup`).

The gap mattered because the wording licensed exactly one conclusion — "convert
the nodes and non-English users get non-English notifications" — which is false,
and acting on it is a **net regression**: `TEMPLATE_*` failures classify
`permanent` and dead-letter, and the inbox channel starts requiring an email
service with `renderTemplate()` where inline text needed none. So the drift was
not a cosmetic imprecision; it was an instruction to make a change that loses
deliveries.

Per the maintainer ruling of **2026-08-13**, the behaviour is the settled side —
a per-user locale is deferred until measured pull — so the prose is the side that
moves. All five "recipient locale" sites in the file now name the resolved value:
the schema doc block, the `template` field's JSDoc and `.describe()`, and both
`superRefine` refusal messages. Each says the locale is `payload.locale` if the
producer set one, else the deployment default, and that it is **one value per
notification, not one per recipient**, with the 2026-08-13 deferral dated in
place so the limitation reads as a decision with provenance rather than a
permanent property of the design — a per-user locale layers in as an override at
that same seam when it lands.

Text only. No schema accepts or refuses anything it did not before, no delivery
behaviour moves, and no wire value changes — `packages/spec` publishes
`src/**/*.zod.ts` and the generated reference page, so the corrected wording
ships to consumers reading either. The pins in
`io-node-config.test.ts` that asserted the old `/recipient locale/` string now
assert the qualification itself, and refuse a bare "recipient locale", so a
future edit cannot quietly restore the promise.
2 changes: 1 addition & 1 deletion content/docs/references/automation/io-node-config.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -98,7 +98,7 @@ const result = HttpConfigSchema.parse(data);
| **recipients** | `string \| string[]` | ✅ | Recipient user id(s) / audience selector(s); `{token}` templates resolve per run |
| **title** | `string` | optional | Notification title, sent to every recipient verbatim (not localizable — use `template` for per-locale content). Either this or `template` is required; the two are mutually exclusive. |
| **message** | `string` | optional | Notification body, sent verbatim like `title` (not localizable). Only valid with inline `title`, never with `template`. |
| **template** | `string` | optional | Email template name (`sys_email_template.name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, recipient locale)` at delivery time and renders subject/body per recipient. Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation. |
| **template** | `string` | optional | Email template name (`sys_email_template.name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, locale)` against sys_email_template at delivery time and renders subject/body from that row. The locale is ONE value for the whole notification, not one per recipient: `payload.locale` if the producer set one, else the deployment default (`II18nService.getDefaultLocale()`) — the platform has no per-user locale, so recipients with different personal languages all receive the same row (deferred by the 2026-08-13 ruling; it layers in as an override when it lands). Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation. |
| **templateData** | `Record<string, any>` | optional | Render context for the referenced template's `{{var}}` placeholders; values interpolate `{token}` templates per run. Only valid together with `template`. |
| **channels** | `string \| string[]` | optional | Channels to fan out to (default: inbox) |
| **topic** | `string` | optional | Event topic (default: "notify") |
Expand Down
38 changes: 34 additions & 4 deletions packages/spec/src/automation/io-node-config.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -233,10 +233,22 @@ describe('NotifyConfigSchema — strict as of #4001 批 9', () => {
//
// Ruled 「立项,走 emailTemplates 路线」: a notify node references a
// `sys_email_template` bundle by name and the delivery path resolves
// `(name, recipient locale)` at delivery time. Inline `title`/`message`
// `(name, locale)` at delivery time. Inline `title`/`message`
// stay fully valid (the acceptance faces above) as the non-localizable
// path; the two paths are mutually exclusive — loud refusal over silent
// precedence, following `objectNavTargetExclusivity` (ui/app.zod.ts).
//
// The `locale` half of that pair is pinned below to the DEPLOYMENT DEFAULT,
// not to a per-recipient value. These strings previously said "recipient
// locale" unqualified, which reads as "each recipient's own language selects
// the row" — the delivery path does not do that and deliberately does not
// (maintainer ruling 2026-08-13: no per-user locale until measured pull;
// `sys_user` carries no locale column, and `payload.locale` is interpolated
// once before fan-out, so it is one value for the whole notification). The
// assertions therefore pin the qualification itself: a future edit that
// drops it back to a bare "recipient locale" turns these RED, because the
// wording an author reads is the whole contract here — declared must equal
// enforced.
describe('template reference (#9205)', () => {
/** Custom (superRefine) issues at exactly `path`, or `[]` when accepted. */
function customIssuesAt(value: unknown, path: string): ReadonlyArray<{ code: string; message: string }> {
Expand DownExpand Up@@ -274,7 +286,13 @@ describe('NotifyConfigSchema — strict as of #4001 批 9', () => {
// identified, and the fix stated.
expect(msg).toContain('`template`');
expect(msg).toContain('`title`');
expect(msg).toMatch(/recipient locale/);
// The localizable path is identified by what it actually resolves —
// `(name, locale)` with the locale qualified — never a bare
// "recipient locale", which promises per-recipient selection.
expect(msg).toMatch(/\(name, locale\)/);
expect(msg).toMatch(/deployment default/);
expect(msg).toMatch(/not per recipient/);
expect(msg).not.toMatch(/recipient locale/);
expect(msg).toMatch(/delete `title`\/`message`/);
expect(msg).toMatch(/silently ignore/);
}
Expand All@@ -299,10 +317,22 @@ describe('NotifyConfigSchema — strict as of #4001 批 9', () => {
// Non-empty arms first, so the pattern arms cannot pass vacuously (#6918).
const templateDoc = shape.template!.description ?? '';
expect(templateDoc.length, 'template .describe() must not be empty').toBeGreaterThan(0);
// The contract: resolves by (name, recipient locale) at delivery time…
expect(templateDoc).toMatch(/recipient locale/);
// The contract: resolves by (name, locale) at delivery time…
expect(templateDoc).toMatch(/\(name, locale\)/);
expect(templateDoc).toMatch(/delivery time/);
expect(templateDoc).toContain('sys_email_template');
// …with the locale named as what it IS — the deployment default, one
// value per notification. A bare "recipient locale" here is the defect
// this pin exists to catch: it licenses "convert the nodes and non-English
// users get non-English mail", which is false and is a net regression when
// acted on (TEMPLATE_* failures classify `permanent` and dead-letter).
expect(templateDoc).not.toMatch(/recipient locale/);
expect(templateDoc).toMatch(/deployment default/);
expect(templateDoc).toContain('II18nService.getDefaultLocale()');
expect(templateDoc).toMatch(/not one per recipient/);
// The deferral is dated, so the text carries its own provenance rather
// than reading as a permanent limitation of the design.
expect(templateDoc).toContain('2026-08-13');
// …and it is a RAW cross-reference, like topic/channels.
expect(templateDoc).toMatch(/no `\{token\}` interpolation/i);

Expand Down
48 changes: 37 additions & 11 deletions packages/spec/src/automation/io-node-config.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -132,8 +132,24 @@ const NOTIFY_KEY_GUIDANCE: Readonly<Record<string, string>> = {
* - **Localization contract (#9205, ruled 「走 emailTemplates 路线」):**
* `template` names a `sys_email_template` bundle
* (`EmailTemplateDefinitionSchema`, `system/email-template.zod.ts`), and the
* delivery path resolves `(name, recipient locale)` per recipient at
* delivery time via `IEmailService.sendTemplate({ template, locale })`.
* delivery path resolves `(name, locale)` at delivery time via
* `IEmailService.sendTemplate({ template, locale })`.
*
* That `locale` is **ONE value for the whole notification, not one per
* recipient** — declared here exactly as the delivery path enforces it
* (`service-messaging/src/email-channel.ts`, which says the same thing at
* its own `getDefaultTemplateLocale`). It is `payload.locale` when the
* producer set one — interpolated ONCE, before fan-out, so every recipient
* of a node gets that single value — and otherwise the **deployment
* default**, `II18nService.getDefaultLocale()`, the same ruled source the
* auth emails use (#8195). The platform has no per-user locale to read:
* `sys_user` carries no locale column, and request-scoped locale
* (`Accept-Language` → `ExecutionContext.requestLocale`) does not exist at
* async delivery time. Per the maintainer ruling of **2026-08-13** a
* per-user locale is DEFERRED until measured pull; when it lands it layers
* in as an override at that same seam. So do not author on the belief that
* two recipients with different personal languages will receive different
* rows — today they receive the same one.
* Inline `title`/`message` are the NON-localizable path — raw strings sent
* to every recipient verbatim. The two paths are mutually exclusive on one
* node (see the `superRefine` below): runtime precedence would silently
Expand DownExpand Up@@ -178,14 +194,22 @@ export const NotifyConfigSchema = lazySchema(() => strictObject({
.describe('Notification body, sent verbatim like `title` (not localizable). Only valid with inline `title`, never with `template`.'),
/**
* The localizable content path (#9205): name of a `sys_email_template`
* bundle. Resolved by `(name, recipient locale)` AT DELIVERY TIME —
* `IEmailService.sendTemplate({ template, locale })` picks the recipient
* locale's row with the documented en-US fallback ladder. Read RAW like
* `topic`/`channels`: a static metadata cross-reference, never interpolated.
* Mutually exclusive with inline `title`/`message`.
* bundle. Resolved by `(name, locale)` AT DELIVERY TIME —
* `IEmailService.sendTemplate({ template, locale })` picks that locale's row
* with the documented en-US fallback ladder.
*
* The `locale` is ONE value for the whole notification, NOT one per
* recipient: `payload.locale` when the producer set one (interpolated once,
* before fan-out), else the DEPLOYMENT DEFAULT —
* `II18nService.getDefaultLocale()`. There is no per-user locale to read
* (`sys_user` has no locale column); the 2026-08-13 ruling defers one until
* measured pull, and it layers in as an override when it lands.
*
* Read RAW like `topic`/`channels`: a static metadata cross-reference, never
* interpolated. Mutually exclusive with inline `title`/`message`.
*/
template: z.string().optional()
.describe('Email template name (`sys_email_template.name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, recipient locale)` at delivery time and renders subject/body per recipient. Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation.'),
.describe('Email template name (`sys_email_template.name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, locale)` against sys_email_template at delivery time and renders subject/body from that row. The locale is ONE value for the whole notification, not one per recipient: `payload.locale` if the producer set one, else the deployment default (`II18nService.getDefaultLocale()`) — the platform has no per-user locale, so recipients with different personal languages all receive the same row (deferred by the 2026-08-13 ruling; it layers in as an override when it lands). Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation.'),
/**
* Render context for the referenced template's `{{var}}` holes. Values are
* interpolated per run (`{record.x}` resolves), so flow state can feed the
Expand DownExpand Up@@ -248,7 +272,8 @@ export const NotifyConfigSchema = lazySchema(() => strictObject({
path: ['template'],
message:
'`template` cannot be combined with inline `title`/`message` — pick ONE content path: '
+ '`template` (localizable: resolves `(name, recipient locale)` from sys_email_template at delivery) '
+ '`template` (localizable: resolves `(name, locale)` from sys_email_template at delivery, the locale being '
+ '`payload.locale` or the deployment default — one locale per notification, not per recipient) '
+ 'or inline `title` + `message` (sent verbatim, not localizable). To localize, keep `template`, move '
+ 'the text into the template bundle\'s rows, and delete `title`/`message`; runtime precedence would '
+ 'silently ignore one of them.',
Expand All@@ -269,8 +294,9 @@ export const NotifyConfigSchema = lazySchema(() => strictObject({
path: ['title'],
message:
'A notify node needs one content source: inline `title` (+ optional `message`), or a `template` '
+ 'reference resolving a sys_email_template bundle per recipient locale at delivery. Neither was given, '
+ 'so there is nothing to deliver.',
+ 'reference resolving a sys_email_template bundle at delivery in the notification\'s locale '
+ '(`payload.locale` or the deployment default — one locale per notification, not per recipient). '
+ 'Neither was given, so there is nothing to deliver.',
});
}
}));
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
51 changes: 51 additions & 0 deletions .changeset/notify-template-locale-is-deployment-default.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
---
'@objectstack/spec': patch
---

`NotifyConfigSchema.template` now states the locale semantics the delivery path actually enforces — the deployment default, not a per-recipient locale

The `notify` node's localizable path (`template` → a `sys_email_template` bundle)
was documented in `packages/spec/src/automation/io-node-config.zod.ts` as
resolving `(name, recipient locale)` **per recipient** at delivery time, and the
`template` `.describe()` added that it "renders subject/body per recipient".
Read plainly — and it is the text a consuming app's author reads — that says the
recipient's own language selects the template row.

It does not, and deliberately does not. The delivery path
(`service-messaging/src/email-channel.ts`) has said so honestly at its own
`getDefaultTemplateLocale` all along: the platform has no per-user locale
(`sys_user` carries no locale column), and request-scoped locale
(`Accept-Language` → `ExecutionContext.requestLocale`) does not exist at async
delivery time, so "recipient locale" resolves to the **deployment default**,
`II18nService.getDefaultLocale()` — the same ruled source the auth emails use.
The one lever is `payload.locale`, and that is interpolated **once, before
fan-out**, so it is a single value for the whole notification at all three
`channel.send` call sites (`fanOut`, the outbox single-delivery path, and
`processDigestGroup`).

The gap mattered because the wording licensed exactly one conclusion — "convert
the nodes and non-English users get non-English notifications" — which is false,
and acting on it is a **net regression**: `TEMPLATE_*` failures classify
`permanent` and dead-letter, and the inbox channel starts requiring an email
service with `renderTemplate()` where inline text needed none. So the drift was
not a cosmetic imprecision; it was an instruction to make a change that loses
deliveries.

Per the maintainer ruling of **2026-08-13**, the behaviour is the settled side —
a per-user locale is deferred until measured pull — so the prose is the side that
moves. All five "recipient locale" sites in the file now name the resolved value:
the schema doc block, the `template` field's JSDoc and `.describe()`, and both
`superRefine` refusal messages. Each says the locale is `payload.locale` if the
producer set one, else the deployment default, and that it is **one value per
notification, not one per recipient**, with the 2026-08-13 deferral dated in
place so the limitation reads as a decision with provenance rather than a
permanent property of the design — a per-user locale layers in as an override at
that same seam when it lands.

Text only. No schema accepts or refuses anything it did not before, no delivery
behaviour moves, and no wire value changes — `packages/spec` publishes
`src/**/*.zod.ts` and the generated reference page, so the corrected wording
ships to consumers reading either. The pins in
`io-node-config.test.ts` that asserted the old `/recipient locale/` string now
assert the qualification itself, and refuse a bare "recipient locale", so a
future edit cannot quietly restore the promise.
2 changes: 1 addition & 1 deletion content/docs/references/automation/io-node-config.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -98,7 +98,7 @@ const result = HttpConfigSchema.parse(data);
| **recipients** | `string \| string[]` | ✅ | Recipient user id(s) / audience selector(s); `{token}` templates resolve per run |
| **title** | `string` | optional | Notification title, sent to every recipient verbatim (not localizable — use `template` for per-locale content). Either this or `template` is required; the two are mutually exclusive. |
| **message** | `string` | optional | Notification body, sent verbatim like `title` (not localizable). Only valid with inline `title`, never with `template`. |
| **template** | `string` | optional | Email template name (`sys_email_template.name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, recipient locale)` at delivery time and renders subject/body per recipient. Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation. |
| **template** | `string` | optional | Email template name (`sys_email_template.name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, locale)` against sys_email_template at delivery time and renders subject/body from that row. The locale is ONE value for the whole notification, not one per recipient: `payload.locale` if the producer set one, else the deployment default (`II18nService.getDefaultLocale()`) — the platform has no per-user locale, so recipients with different personal languages all receive the same row (deferred by the 2026-08-13 ruling; it layers in as an override when it lands). Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation. |
| **templateData** | `Record<string, any>` | optional | Render context for the referenced template's `{{var}}` placeholders; values interpolate `{token}` templates per run. Only valid together with `template`. |
| **channels** | `string \| string[]` | optional | Channels to fan out to (default: inbox) |
| **topic** | `string` | optional | Event topic (default: "notify") |
Expand Down
38 changes: 34 additions & 4 deletions packages/spec/src/automation/io-node-config.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -233,10 +233,22 @@ describe('NotifyConfigSchema — strict as of #4001 批 9', () => {
//
// Ruled 「立项,走 emailTemplates 路线」: a notify node references a
// `sys_email_template` bundle by name and the delivery path resolves
// `(name, recipient locale)` at delivery time. Inline `title`/`message`
// `(name, locale)` at delivery time. Inline `title`/`message`
// stay fully valid (the acceptance faces above) as the non-localizable
// path; the two paths are mutually exclusive — loud refusal over silent
// precedence, following `objectNavTargetExclusivity` (ui/app.zod.ts).
//
// The `locale` half of that pair is pinned below to the DEPLOYMENT DEFAULT,
// not to a per-recipient value. These strings previously said "recipient
// locale" unqualified, which reads as "each recipient's own language selects
// the row" — the delivery path does not do that and deliberately does not
// (maintainer ruling 2026-08-13: no per-user locale until measured pull;
// `sys_user` carries no locale column, and `payload.locale` is interpolated
// once before fan-out, so it is one value for the whole notification). The
// assertions therefore pin the qualification itself: a future edit that
// drops it back to a bare "recipient locale" turns these RED, because the
// wording an author reads is the whole contract here — declared must equal
// enforced.
describe('template reference (#9205)', () => {
/** Custom (superRefine) issues at exactly `path`, or `[]` when accepted. */
function customIssuesAt(value: unknown, path: string): ReadonlyArray<{ code: string; message: string }> {
Expand DownExpand Up@@ -274,7 +286,13 @@ describe('NotifyConfigSchema — strict as of #4001 批 9', () => {
// identified, and the fix stated.
expect(msg).toContain('`template`');
expect(msg).toContain('`title`');
expect(msg).toMatch(/recipient locale/);
// The localizable path is identified by what it actually resolves —
// `(name, locale)` with the locale qualified — never a bare
// "recipient locale", which promises per-recipient selection.
expect(msg).toMatch(/\(name, locale\)/);
expect(msg).toMatch(/deployment default/);
expect(msg).toMatch(/not per recipient/);
expect(msg).not.toMatch(/recipient locale/);
expect(msg).toMatch(/delete `title`\/`message`/);
expect(msg).toMatch(/silently ignore/);
}
Expand All@@ -299,10 +317,22 @@ describe('NotifyConfigSchema — strict as of #4001 批 9', () => {
// Non-empty arms first, so the pattern arms cannot pass vacuously (#6918).
const templateDoc = shape.template!.description ?? '';
expect(templateDoc.length, 'template .describe() must not be empty').toBeGreaterThan(0);
// The contract: resolves by (name, recipient locale) at delivery time…
expect(templateDoc).toMatch(/recipient locale/);
// The contract: resolves by (name, locale) at delivery time…
expect(templateDoc).toMatch(/\(name, locale\)/);
expect(templateDoc).toMatch(/delivery time/);
expect(templateDoc).toContain('sys_email_template');
// …with the locale named as what it IS — the deployment default, one
// value per notification. A bare "recipient locale" here is the defect
// this pin exists to catch: it licenses "convert the nodes and non-English
// users get non-English mail", which is false and is a net regression when
// acted on (TEMPLATE_* failures classify `permanent` and dead-letter).
expect(templateDoc).not.toMatch(/recipient locale/);
expect(templateDoc).toMatch(/deployment default/);
expect(templateDoc).toContain('II18nService.getDefaultLocale()');
expect(templateDoc).toMatch(/not one per recipient/);
// The deferral is dated, so the text carries its own provenance rather
// than reading as a permanent limitation of the design.
expect(templateDoc).toContain('2026-08-13');
// …and it is a RAW cross-reference, like topic/channels.
expect(templateDoc).toMatch(/no `\{token\}` interpolation/i);

Expand Down
48 changes: 37 additions & 11 deletions packages/spec/src/automation/io-node-config.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -132,8 +132,24 @@ const NOTIFY_KEY_GUIDANCE: Readonly<Record<string, string>> = {
* - **Localization contract (#9205, ruled 「走 emailTemplates 路线」):**
* `template` names a `sys_email_template` bundle
* (`EmailTemplateDefinitionSchema`, `system/email-template.zod.ts`), and the
* delivery path resolves `(name, recipient locale)` per recipient at
* delivery time via `IEmailService.sendTemplate({ template, locale })`.
* delivery path resolves `(name, locale)` at delivery time via
* `IEmailService.sendTemplate({ template, locale })`.
*
* That `locale` is **ONE value for the whole notification, not one per
* recipient** — declared here exactly as the delivery path enforces it
* (`service-messaging/src/email-channel.ts`, which says the same thing at
* its own `getDefaultTemplateLocale`). It is `payload.locale` when the
* producer set one — interpolated ONCE, before fan-out, so every recipient
* of a node gets that single value — and otherwise the **deployment
* default**, `II18nService.getDefaultLocale()`, the same ruled source the
* auth emails use (#8195). The platform has no per-user locale to read:
* `sys_user` carries no locale column, and request-scoped locale
* (`Accept-Language` → `ExecutionContext.requestLocale`) does not exist at
* async delivery time. Per the maintainer ruling of **2026-08-13** a
* per-user locale is DEFERRED until measured pull; when it lands it layers
* in as an override at that same seam. So do not author on the belief that
* two recipients with different personal languages will receive different
* rows — today they receive the same one.
* Inline `title`/`message` are the NON-localizable path — raw strings sent
* to every recipient verbatim. The two paths are mutually exclusive on one
* node (see the `superRefine` below): runtime precedence would silently
Expand DownExpand Up@@ -178,14 +194,22 @@ export const NotifyConfigSchema = lazySchema(() => strictObject({
.describe('Notification body, sent verbatim like `title` (not localizable). Only valid with inline `title`, never with `template`.'),
/**
* The localizable content path (#9205): name of a `sys_email_template`
* bundle. Resolved by `(name, recipient locale)` AT DELIVERY TIME —
* `IEmailService.sendTemplate({ template, locale })` picks the recipient
* locale's row with the documented en-US fallback ladder. Read RAW like
* `topic`/`channels`: a static metadata cross-reference, never interpolated.
* Mutually exclusive with inline `title`/`message`.
* bundle. Resolved by `(name, locale)` AT DELIVERY TIME —
* `IEmailService.sendTemplate({ template, locale })` picks that locale's row
* with the documented en-US fallback ladder.
*
* The `locale` is ONE value for the whole notification, NOT one per
* recipient: `payload.locale` when the producer set one (interpolated once,
* before fan-out), else the DEPLOYMENT DEFAULT —
* `II18nService.getDefaultLocale()`. There is no per-user locale to read
* (`sys_user` has no locale column); the 2026-08-13 ruling defers one until
* measured pull, and it layers in as an override when it lands.
*
* Read RAW like `topic`/`channels`: a static metadata cross-reference, never
* interpolated. Mutually exclusive with inline `title`/`message`.
*/
template: z.string().optional()
.describe('Email template name (`sys_email_template.name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, recipient locale)` at delivery time and renders subject/body per recipient. Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation.'),
.describe('Email template name (`sys_email_template.name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, locale)` against sys_email_template at delivery time and renders subject/body from that row. The locale is ONE value for the whole notification, not one per recipient: `payload.locale` if the producer set one, else the deployment default (`II18nService.getDefaultLocale()`) — the platform has no per-user locale, so recipients with different personal languages all receive the same row (deferred by the 2026-08-13 ruling; it layers in as an override when it lands). Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation.'),
/**
* Render context for the referenced template's `{{var}}` holes. Values are
* interpolated per run (`{record.x}` resolves), so flow state can feed the
Expand DownExpand Up@@ -248,7 +272,8 @@ export const NotifyConfigSchema = lazySchema(() => strictObject({
path: ['template'],
message:
'`template` cannot be combined with inline `title`/`message` — pick ONE content path: '
+ '`template` (localizable: resolves `(name, recipient locale)` from sys_email_template at delivery) '
+ '`template` (localizable: resolves `(name, locale)` from sys_email_template at delivery, the locale being '
+ '`payload.locale` or the deployment default — one locale per notification, not per recipient) '
+ 'or inline `title` + `message` (sent verbatim, not localizable). To localize, keep `template`, move '
+ 'the text into the template bundle\'s rows, and delete `title`/`message`; runtime precedence would '
+ 'silently ignore one of them.',
Expand All@@ -269,8 +294,9 @@ export const NotifyConfigSchema = lazySchema(() => strictObject({
path: ['title'],
message:
'A notify node needs one content source: inline `title` (+ optional `message`), or a `template` '
+ 'reference resolving a sys_email_template bundle per recipient locale at delivery. Neither was given, '
+ 'so there is nothing to deliver.',
+ 'reference resolving a sys_email_template bundle at delivery in the notification\'s locale '
+ '(`payload.locale` or the deployment default — one locale per notification, not per recipient). '
+ 'Neither was given, so there is nothing to deliver.',
});
}
}));
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
51 changes: 51 additions & 0 deletions .changeset/notify-template-locale-is-deployment-default.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
---
'@objectstack/spec': patch
---

`NotifyConfigSchema.template` now states the locale semantics the delivery path actually enforces — the deployment default, not a per-recipient locale

The `notify` node's localizable path (`template` → a `sys_email_template` bundle)
was documented in `packages/spec/src/automation/io-node-config.zod.ts` as
resolving `(name, recipient locale)` **per recipient** at delivery time, and the
`template` `.describe()` added that it "renders subject/body per recipient".
Read plainly — and it is the text a consuming app's author reads — that says the
recipient's own language selects the template row.

It does not, and deliberately does not. The delivery path
(`service-messaging/src/email-channel.ts`) has said so honestly at its own
`getDefaultTemplateLocale` all along: the platform has no per-user locale
(`sys_user` carries no locale column), and request-scoped locale
(`Accept-Language` → `ExecutionContext.requestLocale`) does not exist at async
delivery time, so "recipient locale" resolves to the **deployment default**,
`II18nService.getDefaultLocale()` — the same ruled source the auth emails use.
The one lever is `payload.locale`, and that is interpolated **once, before
fan-out**, so it is a single value for the whole notification at all three
`channel.send` call sites (`fanOut`, the outbox single-delivery path, and
`processDigestGroup`).

The gap mattered because the wording licensed exactly one conclusion — "convert
the nodes and non-English users get non-English notifications" — which is false,
and acting on it is a **net regression**: `TEMPLATE_*` failures classify
`permanent` and dead-letter, and the inbox channel starts requiring an email
service with `renderTemplate()` where inline text needed none. So the drift was
not a cosmetic imprecision; it was an instruction to make a change that loses
deliveries.

Per the maintainer ruling of **2026-08-13**, the behaviour is the settled side —
a per-user locale is deferred until measured pull — so the prose is the side that
moves. All five "recipient locale" sites in the file now name the resolved value:
the schema doc block, the `template` field's JSDoc and `.describe()`, and both
`superRefine` refusal messages. Each says the locale is `payload.locale` if the
producer set one, else the deployment default, and that it is **one value per
notification, not one per recipient**, with the 2026-08-13 deferral dated in
place so the limitation reads as a decision with provenance rather than a
permanent property of the design — a per-user locale layers in as an override at
that same seam when it lands.

Text only. No schema accepts or refuses anything it did not before, no delivery
behaviour moves, and no wire value changes — `packages/spec` publishes
`src/**/*.zod.ts` and the generated reference page, so the corrected wording
ships to consumers reading either. The pins in
`io-node-config.test.ts` that asserted the old `/recipient locale/` string now
assert the qualification itself, and refuse a bare "recipient locale", so a
future edit cannot quietly restore the promise.
2 changes: 1 addition & 1 deletion content/docs/references/automation/io-node-config.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -98,7 +98,7 @@ const result = HttpConfigSchema.parse(data);
| **recipients** | `string \| string[]` | ✅ | Recipient user id(s) / audience selector(s); `{token}` templates resolve per run |
| **title** | `string` | optional | Notification title, sent to every recipient verbatim (not localizable — use `template` for per-locale content). Either this or `template` is required; the two are mutually exclusive. |
| **message** | `string` | optional | Notification body, sent verbatim like `title` (not localizable). Only valid with inline `title`, never with `template`. |
| **template** | `string` | optional | Email template name (`sys_email_template.name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, recipient locale)` at delivery time and renders subject/body per recipient. Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation. |
| **template** | `string` | optional | Email template name (`sys_email_template.name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, locale)` against sys_email_template at delivery time and renders subject/body from that row. The locale is ONE value for the whole notification, not one per recipient: `payload.locale` if the producer set one, else the deployment default (`II18nService.getDefaultLocale()`) — the platform has no per-user locale, so recipients with different personal languages all receive the same row (deferred by the 2026-08-13 ruling; it layers in as an override when it lands). Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation. |
| **templateData** | `Record<string, any>` | optional | Render context for the referenced template's `{{var}}` placeholders; values interpolate `{token}` templates per run. Only valid together with `template`. |
| **channels** | `string \| string[]` | optional | Channels to fan out to (default: inbox) |
| **topic** | `string` | optional | Event topic (default: "notify") |
Expand Down
38 changes: 34 additions & 4 deletions packages/spec/src/automation/io-node-config.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -233,10 +233,22 @@ describe('NotifyConfigSchema — strict as of #4001 批 9', () => {
//
// Ruled 「立项,走 emailTemplates 路线」: a notify node references a
// `sys_email_template` bundle by name and the delivery path resolves
// `(name, recipient locale)` at delivery time. Inline `title`/`message`
// `(name, locale)` at delivery time. Inline `title`/`message`
// stay fully valid (the acceptance faces above) as the non-localizable
// path; the two paths are mutually exclusive — loud refusal over silent
// precedence, following `objectNavTargetExclusivity` (ui/app.zod.ts).
//
// The `locale` half of that pair is pinned below to the DEPLOYMENT DEFAULT,
// not to a per-recipient value. These strings previously said "recipient
// locale" unqualified, which reads as "each recipient's own language selects
// the row" — the delivery path does not do that and deliberately does not
// (maintainer ruling 2026-08-13: no per-user locale until measured pull;
// `sys_user` carries no locale column, and `payload.locale` is interpolated
// once before fan-out, so it is one value for the whole notification). The
// assertions therefore pin the qualification itself: a future edit that
// drops it back to a bare "recipient locale" turns these RED, because the
// wording an author reads is the whole contract here — declared must equal
// enforced.
describe('template reference (#9205)', () => {
/** Custom (superRefine) issues at exactly `path`, or `[]` when accepted. */
function customIssuesAt(value: unknown, path: string): ReadonlyArray<{ code: string; message: string }> {
Expand DownExpand Up@@ -274,7 +286,13 @@ describe('NotifyConfigSchema — strict as of #4001 批 9', () => {
// identified, and the fix stated.
expect(msg).toContain('`template`');
expect(msg).toContain('`title`');
expect(msg).toMatch(/recipient locale/);
// The localizable path is identified by what it actually resolves —
// `(name, locale)` with the locale qualified — never a bare
// "recipient locale", which promises per-recipient selection.
expect(msg).toMatch(/\(name, locale\)/);
expect(msg).toMatch(/deployment default/);
expect(msg).toMatch(/not per recipient/);
expect(msg).not.toMatch(/recipient locale/);
expect(msg).toMatch(/delete `title`\/`message`/);
expect(msg).toMatch(/silently ignore/);
}
Expand All@@ -299,10 +317,22 @@ describe('NotifyConfigSchema — strict as of #4001 批 9', () => {
// Non-empty arms first, so the pattern arms cannot pass vacuously (#6918).
const templateDoc = shape.template!.description ?? '';
expect(templateDoc.length, 'template .describe() must not be empty').toBeGreaterThan(0);
// The contract: resolves by (name, recipient locale) at delivery time…
expect(templateDoc).toMatch(/recipient locale/);
// The contract: resolves by (name, locale) at delivery time…
expect(templateDoc).toMatch(/\(name, locale\)/);
expect(templateDoc).toMatch(/delivery time/);
expect(templateDoc).toContain('sys_email_template');
// …with the locale named as what it IS — the deployment default, one
// value per notification. A bare "recipient locale" here is the defect
// this pin exists to catch: it licenses "convert the nodes and non-English
// users get non-English mail", which is false and is a net regression when
// acted on (TEMPLATE_* failures classify `permanent` and dead-letter).
expect(templateDoc).not.toMatch(/recipient locale/);
expect(templateDoc).toMatch(/deployment default/);
expect(templateDoc).toContain('II18nService.getDefaultLocale()');
expect(templateDoc).toMatch(/not one per recipient/);
// The deferral is dated, so the text carries its own provenance rather
// than reading as a permanent limitation of the design.
expect(templateDoc).toContain('2026-08-13');
// …and it is a RAW cross-reference, like topic/channels.
expect(templateDoc).toMatch(/no `\{token\}` interpolation/i);

Expand Down
48 changes: 37 additions & 11 deletions packages/spec/src/automation/io-node-config.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -132,8 +132,24 @@ const NOTIFY_KEY_GUIDANCE: Readonly<Record<string, string>> = {
* - **Localization contract (#9205, ruled 「走 emailTemplates 路线」):**
* `template` names a `sys_email_template` bundle
* (`EmailTemplateDefinitionSchema`, `system/email-template.zod.ts`), and the
* delivery path resolves `(name, recipient locale)` per recipient at
* delivery time via `IEmailService.sendTemplate({ template, locale })`.
* delivery path resolves `(name, locale)` at delivery time via
* `IEmailService.sendTemplate({ template, locale })`.
*
* That `locale` is **ONE value for the whole notification, not one per
* recipient** — declared here exactly as the delivery path enforces it
* (`service-messaging/src/email-channel.ts`, which says the same thing at
* its own `getDefaultTemplateLocale`). It is `payload.locale` when the
* producer set one — interpolated ONCE, before fan-out, so every recipient
* of a node gets that single value — and otherwise the **deployment
* default**, `II18nService.getDefaultLocale()`, the same ruled source the
* auth emails use (#8195). The platform has no per-user locale to read:
* `sys_user` carries no locale column, and request-scoped locale
* (`Accept-Language` → `ExecutionContext.requestLocale`) does not exist at
* async delivery time. Per the maintainer ruling of **2026-08-13** a
* per-user locale is DEFERRED until measured pull; when it lands it layers
* in as an override at that same seam. So do not author on the belief that
* two recipients with different personal languages will receive different
* rows — today they receive the same one.
* Inline `title`/`message` are the NON-localizable path — raw strings sent
* to every recipient verbatim. The two paths are mutually exclusive on one
* node (see the `superRefine` below): runtime precedence would silently
Expand DownExpand Up@@ -178,14 +194,22 @@ export const NotifyConfigSchema = lazySchema(() => strictObject({
.describe('Notification body, sent verbatim like `title` (not localizable). Only valid with inline `title`, never with `template`.'),
/**
* The localizable content path (#9205): name of a `sys_email_template`
* bundle. Resolved by `(name, recipient locale)` AT DELIVERY TIME —
* `IEmailService.sendTemplate({ template, locale })` picks the recipient
* locale's row with the documented en-US fallback ladder. Read RAW like
* `topic`/`channels`: a static metadata cross-reference, never interpolated.
* Mutually exclusive with inline `title`/`message`.
* bundle. Resolved by `(name, locale)` AT DELIVERY TIME —
* `IEmailService.sendTemplate({ template, locale })` picks that locale's row
* with the documented en-US fallback ladder.
*
* The `locale` is ONE value for the whole notification, NOT one per
* recipient: `payload.locale` when the producer set one (interpolated once,
* before fan-out), else the DEPLOYMENT DEFAULT —
* `II18nService.getDefaultLocale()`. There is no per-user locale to read
* (`sys_user` has no locale column); the 2026-08-13 ruling defers one until
* measured pull, and it layers in as an override when it lands.
*
* Read RAW like `topic`/`channels`: a static metadata cross-reference, never
* interpolated. Mutually exclusive with inline `title`/`message`.
*/
template: z.string().optional()
.describe('Email template name (`sys_email_template.name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, recipient locale)` at delivery time and renders subject/body per recipient. Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation.'),
.describe('Email template name (`sys_email_template.name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, locale)` against sys_email_template at delivery time and renders subject/body from that row. The locale is ONE value for the whole notification, not one per recipient: `payload.locale` if the producer set one, else the deployment default (`II18nService.getDefaultLocale()`) — the platform has no per-user locale, so recipients with different personal languages all receive the same row (deferred by the 2026-08-13 ruling; it layers in as an override when it lands). Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation.'),
/**
* Render context for the referenced template's `{{var}}` holes. Values are
* interpolated per run (`{record.x}` resolves), so flow state can feed the
Expand DownExpand Up@@ -248,7 +272,8 @@ export const NotifyConfigSchema = lazySchema(() => strictObject({
path: ['template'],
message:
'`template` cannot be combined with inline `title`/`message` — pick ONE content path: '
+ '`template` (localizable: resolves `(name, recipient locale)` from sys_email_template at delivery) '
+ '`template` (localizable: resolves `(name, locale)` from sys_email_template at delivery, the locale being '
+ '`payload.locale` or the deployment default — one locale per notification, not per recipient) '
+ 'or inline `title` + `message` (sent verbatim, not localizable). To localize, keep `template`, move '
+ 'the text into the template bundle\'s rows, and delete `title`/`message`; runtime precedence would '
+ 'silently ignore one of them.',
Expand All@@ -269,8 +294,9 @@ export const NotifyConfigSchema = lazySchema(() => strictObject({
path: ['title'],
message:
'A notify node needs one content source: inline `title` (+ optional `message`), or a `template` '
+ 'reference resolving a sys_email_template bundle per recipient locale at delivery. Neither was given, '
+ 'so there is nothing to deliver.',
+ 'reference resolving a sys_email_template bundle at delivery in the notification\'s locale '
+ '(`payload.locale` or the deployment default — one locale per notification, not per recipient). '
+ 'Neither was given, so there is nothing to deliver.',
});
}
}));
Expand Down
Loading