Skip to content

Commit 4297fe7

Browse files
huangyiireneclaude
andauthored
spec(automation): notify template locale is the deployment default, not per-recipient (#12447)
`NotifyConfigSchema`'s doc block, the `template` field JSDoc/`.describe()`, and both `superRefine` refusal messages said the delivery path resolves `(name, recipient locale)` "per recipient" and "renders subject/body per recipient". The delivery path deliberately does not: `sys_user` carries no locale column and request-scoped locale does not exist at async delivery time, so the locale is `payload.locale` (interpolated once, before fan-out) or the deployment default `II18nService.getDefaultLocale()` — one value for the whole notification. `service-messaging/src/email-channel.ts` already documents this honestly; spec was the one place it was unqualified. Per the maintainer ruling of 2026-08-13 the behaviour is settled (no per-user locale until measured pull), so the prose moves. All five sites in the file now name the resolved value and date the deferral. The two test pins that asserted the old `/recipient locale/` string now assert the qualification and refuse a bare "recipient locale". Text only — no acceptance, refusal or delivery behaviour changes. Claude-Session: https://claude.ai/code/session_01E5LFCYBJ3q2s6yW6oMLxwy Co-authored-by: Claude <noreply@anthropic.com>
1 parent b6c96bc commit 4297fe7

4 files changed

Lines changed: 123 additions & 16 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
`NotifyConfigSchema.template` now states the locale semantics the delivery path actually enforces — the deployment default, not a per-recipient locale
6+
7+
The `notify` node's localizable path (`template` → a `sys_email_template` bundle)
8+
was documented in `packages/spec/src/automation/io-node-config.zod.ts` as
9+
resolving `(name, recipient locale)` **per recipient** at delivery time, and the
10+
`template` `.describe()` added that it "renders subject/body per recipient".
11+
Read plainly — and it is the text a consuming app's author reads — that says the
12+
recipient's own language selects the template row.
13+
14+
It does not, and deliberately does not. The delivery path
15+
(`service-messaging/src/email-channel.ts`) has said so honestly at its own
16+
`getDefaultTemplateLocale` all along: the platform has no per-user locale
17+
(`sys_user` carries no locale column), and request-scoped locale
18+
(`Accept-Language``ExecutionContext.requestLocale`) does not exist at async
19+
delivery time, so "recipient locale" resolves to the **deployment default**,
20+
`II18nService.getDefaultLocale()` — the same ruled source the auth emails use.
21+
The one lever is `payload.locale`, and that is interpolated **once, before
22+
fan-out**, so it is a single value for the whole notification at all three
23+
`channel.send` call sites (`fanOut`, the outbox single-delivery path, and
24+
`processDigestGroup`).
25+
26+
The gap mattered because the wording licensed exactly one conclusion — "convert
27+
the nodes and non-English users get non-English notifications" — which is false,
28+
and acting on it is a **net regression**: `TEMPLATE_*` failures classify
29+
`permanent` and dead-letter, and the inbox channel starts requiring an email
30+
service with `renderTemplate()` where inline text needed none. So the drift was
31+
not a cosmetic imprecision; it was an instruction to make a change that loses
32+
deliveries.
33+
34+
Per the maintainer ruling of **2026-08-13**, the behaviour is the settled side —
35+
a per-user locale is deferred until measured pull — so the prose is the side that
36+
moves. All five "recipient locale" sites in the file now name the resolved value:
37+
the schema doc block, the `template` field's JSDoc and `.describe()`, and both
38+
`superRefine` refusal messages. Each says the locale is `payload.locale` if the
39+
producer set one, else the deployment default, and that it is **one value per
40+
notification, not one per recipient**, with the 2026-08-13 deferral dated in
41+
place so the limitation reads as a decision with provenance rather than a
42+
permanent property of the design — a per-user locale layers in as an override at
43+
that same seam when it lands.
44+
45+
Text only. No schema accepts or refuses anything it did not before, no delivery
46+
behaviour moves, and no wire value changes — `packages/spec` publishes
47+
`src/**/*.zod.ts` and the generated reference page, so the corrected wording
48+
ships to consumers reading either. The pins in
49+
`io-node-config.test.ts` that asserted the old `/recipient locale/` string now
50+
assert the qualification itself, and refuse a bare "recipient locale", so a
51+
future edit cannot quietly restore the promise.

content/docs/references/automation/io-node-config.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ const result = HttpConfigSchema.parse(data);
9898
| **recipients** | `string \| string[]` || Recipient user id(s) / audience selector(s); `{token}` templates resolve per run |
9999
| **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. |
100100
| **message** | `string` | optional | Notification body, sent verbatim like `title` (not localizable). Only valid with inline `title`, never with `template`. |
101-
| **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. |
101+
| **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. |
102102
| **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`. |
103103
| **channels** | `string \| string[]` | optional | Channels to fan out to (default: inbox) |
104104
| **topic** | `string` | optional | Event topic (default: "notify") |

packages/spec/src/automation/io-node-config.test.ts

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -233,10 +233,22 @@ describe('NotifyConfigSchema — strict as of #4001 批 9', () => {
233233
//
234234
// Ruled 「立项,走 emailTemplates 路线」: a notify node references a
235235
// `sys_email_template` bundle by name and the delivery path resolves
236-
// `(name, recipient locale)` at delivery time. Inline `title`/`message`
236+
// `(name, locale)` at delivery time. Inline `title`/`message`
237237
// stay fully valid (the acceptance faces above) as the non-localizable
238238
// path; the two paths are mutually exclusive — loud refusal over silent
239239
// precedence, following `objectNavTargetExclusivity` (ui/app.zod.ts).
240+
//
241+
// The `locale` half of that pair is pinned below to the DEPLOYMENT DEFAULT,
242+
// not to a per-recipient value. These strings previously said "recipient
243+
// locale" unqualified, which reads as "each recipient's own language selects
244+
// the row" — the delivery path does not do that and deliberately does not
245+
// (maintainer ruling 2026-08-13: no per-user locale until measured pull;
246+
// `sys_user` carries no locale column, and `payload.locale` is interpolated
247+
// once before fan-out, so it is one value for the whole notification). The
248+
// assertions therefore pin the qualification itself: a future edit that
249+
// drops it back to a bare "recipient locale" turns these RED, because the
250+
// wording an author reads is the whole contract here — declared must equal
251+
// enforced.
240252
describe('template reference (#9205)', () => {
241253
/** Custom (superRefine) issues at exactly `path`, or `[]` when accepted. */
242254
function customIssuesAt(value: unknown, path: string): ReadonlyArray<{ code: string; message: string }> {
@@ -274,7 +286,13 @@ describe('NotifyConfigSchema — strict as of #4001 批 9', () => {
274286
// identified, and the fix stated.
275287
expect(msg).toContain('`template`');
276288
expect(msg).toContain('`title`');
277-
expect(msg).toMatch(/recipient locale/);
289+
// The localizable path is identified by what it actually resolves —
290+
// `(name, locale)` with the locale qualified — never a bare
291+
// "recipient locale", which promises per-recipient selection.
292+
expect(msg).toMatch(/\(name, locale\)/);
293+
expect(msg).toMatch(/deployment default/);
294+
expect(msg).toMatch(/not per recipient/);
295+
expect(msg).not.toMatch(/recipient locale/);
278296
expect(msg).toMatch(/delete `title`\/`message`/);
279297
expect(msg).toMatch(/silently ignore/);
280298
}
@@ -299,10 +317,22 @@ describe('NotifyConfigSchema — strict as of #4001 批 9', () => {
299317
// Non-empty arms first, so the pattern arms cannot pass vacuously (#6918).
300318
const templateDoc = shape.template!.description ?? '';
301319
expect(templateDoc.length, 'template .describe() must not be empty').toBeGreaterThan(0);
302-
// The contract: resolves by (name, recipient locale) at delivery time…
303-
expect(templateDoc).toMatch(/recipient locale/);
320+
// The contract: resolves by (name, locale) at delivery time…
321+
expect(templateDoc).toMatch(/\(name, locale\)/);
304322
expect(templateDoc).toMatch(/delivery time/);
305323
expect(templateDoc).toContain('sys_email_template');
324+
// …with the locale named as what it IS — the deployment default, one
325+
// value per notification. A bare "recipient locale" here is the defect
326+
// this pin exists to catch: it licenses "convert the nodes and non-English
327+
// users get non-English mail", which is false and is a net regression when
328+
// acted on (TEMPLATE_* failures classify `permanent` and dead-letter).
329+
expect(templateDoc).not.toMatch(/recipient locale/);
330+
expect(templateDoc).toMatch(/deployment default/);
331+
expect(templateDoc).toContain('II18nService.getDefaultLocale()');
332+
expect(templateDoc).toMatch(/not one per recipient/);
333+
// The deferral is dated, so the text carries its own provenance rather
334+
// than reading as a permanent limitation of the design.
335+
expect(templateDoc).toContain('2026-08-13');
306336
// …and it is a RAW cross-reference, like topic/channels.
307337
expect(templateDoc).toMatch(/no `\{token\}` interpolation/i);
308338

packages/spec/src/automation/io-node-config.zod.ts

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -132,8 +132,24 @@ const NOTIFY_KEY_GUIDANCE: Readonly<Record<string, string>> = {
132132
* - **Localization contract (#9205, ruled 「走 emailTemplates 路线」):**
133133
* `template` names a `sys_email_template` bundle
134134
* (`EmailTemplateDefinitionSchema`, `system/email-template.zod.ts`), and the
135-
* delivery path resolves `(name, recipient locale)` per recipient at
136-
* delivery time via `IEmailService.sendTemplate({ template, locale })`.
135+
* delivery path resolves `(name, locale)` at delivery time via
136+
* `IEmailService.sendTemplate({ template, locale })`.
137+
*
138+
* That `locale` is **ONE value for the whole notification, not one per
139+
* recipient** — declared here exactly as the delivery path enforces it
140+
* (`service-messaging/src/email-channel.ts`, which says the same thing at
141+
* its own `getDefaultTemplateLocale`). It is `payload.locale` when the
142+
* producer set one — interpolated ONCE, before fan-out, so every recipient
143+
* of a node gets that single value — and otherwise the **deployment
144+
* default**, `II18nService.getDefaultLocale()`, the same ruled source the
145+
* auth emails use (#8195). The platform has no per-user locale to read:
146+
* `sys_user` carries no locale column, and request-scoped locale
147+
* (`Accept-Language` → `ExecutionContext.requestLocale`) does not exist at
148+
* async delivery time. Per the maintainer ruling of **2026-08-13** a
149+
* per-user locale is DEFERRED until measured pull; when it lands it layers
150+
* in as an override at that same seam. So do not author on the belief that
151+
* two recipients with different personal languages will receive different
152+
* rows — today they receive the same one.
137153
* Inline `title`/`message` are the NON-localizable path — raw strings sent
138154
* to every recipient verbatim. The two paths are mutually exclusive on one
139155
* node (see the `superRefine` below): runtime precedence would silently
@@ -178,14 +194,22 @@ export const NotifyConfigSchema = lazySchema(() => strictObject({
178194
.describe('Notification body, sent verbatim like `title` (not localizable). Only valid with inline `title`, never with `template`.'),
179195
/**
180196
* The localizable content path (#9205): name of a `sys_email_template`
181-
* bundle. Resolved by `(name, recipient locale)` AT DELIVERY TIME —
182-
* `IEmailService.sendTemplate({ template, locale })` picks the recipient
183-
* locale's row with the documented en-US fallback ladder. Read RAW like
184-
* `topic`/`channels`: a static metadata cross-reference, never interpolated.
185-
* Mutually exclusive with inline `title`/`message`.
197+
* bundle. Resolved by `(name, locale)` AT DELIVERY TIME —
198+
* `IEmailService.sendTemplate({ template, locale })` picks that locale's row
199+
* with the documented en-US fallback ladder.
200+
*
201+
* The `locale` is ONE value for the whole notification, NOT one per
202+
* recipient: `payload.locale` when the producer set one (interpolated once,
203+
* before fan-out), else the DEPLOYMENT DEFAULT —
204+
* `II18nService.getDefaultLocale()`. There is no per-user locale to read
205+
* (`sys_user` has no locale column); the 2026-08-13 ruling defers one until
206+
* measured pull, and it layers in as an override when it lands.
207+
*
208+
* Read RAW like `topic`/`channels`: a static metadata cross-reference, never
209+
* interpolated. Mutually exclusive with inline `title`/`message`.
186210
*/
187211
template: z.string().optional()
188-
.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.'),
212+
.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.'),
189213
/**
190214
* Render context for the referenced template's `{{var}}` holes. Values are
191215
* interpolated per run (`{record.x}` resolves), so flow state can feed the
@@ -248,7 +272,8 @@ export const NotifyConfigSchema = lazySchema(() => strictObject({
248272
path: ['template'],
249273
message:
250274
'`template` cannot be combined with inline `title`/`message` — pick ONE content path: '
251-
+ '`template` (localizable: resolves `(name, recipient locale)` from sys_email_template at delivery) '
275+
+ '`template` (localizable: resolves `(name, locale)` from sys_email_template at delivery, the locale being '
276+
+ '`payload.locale` or the deployment default — one locale per notification, not per recipient) '
252277
+ 'or inline `title` + `message` (sent verbatim, not localizable). To localize, keep `template`, move '
253278
+ 'the text into the template bundle\'s rows, and delete `title`/`message`; runtime precedence would '
254279
+ 'silently ignore one of them.',
@@ -269,8 +294,9 @@ export const NotifyConfigSchema = lazySchema(() => strictObject({
269294
path: ['title'],
270295
message:
271296
'A notify node needs one content source: inline `title` (+ optional `message`), or a `template` '
272-
+ 'reference resolving a sys_email_template bundle per recipient locale at delivery. Neither was given, '
273-
+ 'so there is nothing to deliver.',
297+
+ 'reference resolving a sys_email_template bundle at delivery in the notification\'s locale '
298+
+ '(`payload.locale` or the deployment default — one locale per notification, not per recipient). '
299+
+ 'Neither was given, so there is nothing to deliver.',
274300
});
275301
}
276302
}));

0 commit comments

Comments
 (0)