From 71033ee0b5c5caec6bc3de952e4a12ebe38fe23b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 06:01:01 +0000 Subject: [PATCH 1/5] feat(spec): notify title/message are template slots (bare string or tmpl envelope) NotifyConfigSchema.title and .message are typed with TemplateExpressionInputSchema, as the expression dialect table lists notification subjects/bodies among the template slots. The notify executor reads the parsed envelope's source, so both spellings render the same text and a bare string renders exactly what it did before. An envelope with no non-blank source is refused at the key: the executor renders source only. Claude-Session: https://claude.ai/code/session_01GV6oYwgc1kWiUCb1YaprQ7 Co-authored-by: Claude --- .../22054-notify-title-template-input.md | 15 +++ .../src/builtin/notify-node.ts | 27 +++- .../src/builtin/notify-template-slots.test.ts | 124 ++++++++++++++++++ .../src/automation/io-node-config.test.ts | 111 +++++++++++++++- .../spec/src/automation/io-node-config.zod.ts | 81 +++++++++--- 5 files changed, 336 insertions(+), 22 deletions(-) create mode 100644 .changeset/22054-notify-title-template-input.md create mode 100644 packages/services/service-automation/src/builtin/notify-template-slots.test.ts diff --git a/.changeset/22054-notify-title-template-input.md b/.changeset/22054-notify-title-template-input.md new file mode 100644 index 00000000000..7dee5890abe --- /dev/null +++ b/.changeset/22054-notify-title-template-input.md @@ -0,0 +1,15 @@ +--- +"@objectstack/spec": minor +"@objectstack/service-automation": patch +--- + +`NotifyConfigSchema.title` and `NotifyConfigSchema.message` are template slots: each takes a bare string or a `{ dialect: 'template', source }` envelope (the `tmpl` helper), as the expression dialect table already listed notification subjects and bodies among the `template` slots + +Clause-②: yes (widening: a published notify slot now accepts the template envelope as well as the bare string) + +- Both keys are typed with `TemplateExpressionInputSchema`, the input every other `template` slot uses. Before, both were `z.string()`, so a notify node written with `` tmpl`…` `` passed `defineFlow` and registration and then failed every run at the execute-time contract parse (`expected string, received object`). +- The parse normalizes a bare string to `{ dialect: 'template', source }`, so `NotifyConfigSchema.parse(...)` now returns the envelope for both spellings. The `notify` executor reads the envelope's `source` and interpolates it as before, so both spellings of one text deliver the same `payload.title` and `payload.body`. A bare string renders exactly what it rendered before. +- The placeholder spelling these two slots read is the flow's single-brace `{token}` (`{record.name}`). A `{{var}}` is not a placeholder here: the inner `{var}` resolves and the outer braces stay in the text, for a bare string and an envelope alike. The `.describe()` on both keys now says so, and no longer says the text is "sent verbatim". +- Still refused at each key: a value that is neither a string nor a template envelope (a number, an array, a `cel` envelope), and a blank bare string (`''` or whitespace), both by the shared template input. A blank `title` used to parse and then fail every run ("title is required"). A blank `message` used to send an empty body, the same as leaving `message` out. +- New, notify-only: a template envelope on either key must carry a non-blank `source`. The executor renders `source` and has nothing to render from `ast` alone, so such an envelope is refused at the key instead of failing every run (`title`) or sending an empty body (`message`). +- `@objectstack/service-automation`: the `notify` executor reads `source` from the two template slots, and the descriptor's `title` / `message` descriptions state the `{token}` interpolation in place of "sent verbatim". diff --git a/packages/services/service-automation/src/builtin/notify-node.ts b/packages/services/service-automation/src/builtin/notify-node.ts index 8826903a187..10f2bf7e913 100644 --- a/packages/services/service-automation/src/builtin/notify-node.ts +++ b/packages/services/service-automation/src/builtin/notify-node.ts @@ -179,13 +179,19 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext) recipients: { description: 'Recipient user id(s) / audience selector(s)', }, + // The form edits the bare-string spelling of these two + // template slots; the contract (`NotifyConfigSchema`) also + // takes the `{ dialect: 'template', source }` envelope that + // code-authored flows write with `tmpl`, carrying the same + // text. `type` stays `string` because the form's control is + // a text box: it is the authoring surface, not the contract. title: { type: 'string', - description: 'Notification title, sent verbatim (not localizable — use template for per-locale content). Either this or template is required; mutually exclusive with template.', + description: 'Notification title, interpolated per run with {token} placeholders (e.g. {record.name}; a {{var}} keeps its outer braces). Not localizable — use template for per-locale content. Either this or template is required; mutually exclusive with template.', }, message: { type: 'string', - description: 'Notification body, sent verbatim (not localizable). Only valid with inline title, never with template.', + description: 'Notification body, interpolated per run with {token} placeholders like title. Not localizable. Only valid with inline title, never with template.', }, // ── Localizable content path (#9205) ───────────────────── // Mirrors `NotifyConfigSchema.template`/`templateData`; the @@ -243,8 +249,8 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext) // The historical aliases (`to`/`subject`/`body`/`url`) are canonicalized // at load by the ADR-0087 D2 conversion 'flow-node-notify-config-aliases' // (#3796), so the parse sees only canonical keys. Parsed BEFORE - // interpolation — the contract's slots are string-typed, so `{token}` - // templates pass; the post-interpolation guards below still own + // interpolation — the contract's slots are string- or template-typed, + // so `{token}` templates pass; the post-interpolation guards below still own // "title/recipients resolved to nothing" (#3582), which no static // parse can see. const parsed = parseNodeConfig('notify', node.id, NotifyConfigSchema, node.config); @@ -256,8 +262,17 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext) // stringifyForTemplate (not String()): a sole-token `{$error}` resolves // to the engine's error OBJECT, which String() would render as the // useless `[object Object]` (#3450). Serialize it readably instead. - const title = stringifyForTemplate(interpolate(cfg.title ?? '', variables, context)); - const body = stringifyForTemplate(interpolate(cfg.message ?? '', variables, context)); + // + // `title`/`message` are template slots (`TemplateExpressionInputSchema`): + // the parse above normalizes a bare string to the + // `{ dialect: 'template', source }` envelope an author may also write + // directly (`tmpl`), so the parsed config holds the envelope in BOTH + // cases and the text is its `source` — never the envelope itself, + // which `interpolate` would walk key by key and `stringifyForTemplate` + // would then serialize as JSON. The contract also refuses an envelope + // whose `source` is absent or blank, so a present slot always has text. + const title = stringifyForTemplate(interpolate(cfg.title?.source ?? '', variables, context)); + const body = stringifyForTemplate(interpolate(cfg.message?.source ?? '', variables, context)); // #9205 — the localizable content path. `template` is read RAW (a // static metadata cross-reference, like `topic`/`channels`); // `templateData` VALUES interpolate per run, so flow state can feed diff --git a/packages/services/service-automation/src/builtin/notify-template-slots.test.ts b/packages/services/service-automation/src/builtin/notify-template-slots.test.ts new file mode 100644 index 00000000000..6e5631b3eb0 --- /dev/null +++ b/packages/services/service-automation/src/builtin/notify-template-slots.test.ts @@ -0,0 +1,124 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `notify`'s `title` / `message` are TEMPLATE slots — the two spellings of one + * text render one notification. + * + * `NotifyConfigSchema` types both keys with `TemplateExpressionInputSchema`, as + * the spec's dialect table lists notification subjects/bodies among the + * `template` slots: an author may write the bare string or the + * `{ dialect: 'template', source }` envelope the `tmpl` helper builds. The + * schema normalizes the bare string to that envelope, so the executor's parsed + * config holds an envelope in BOTH cases — and `interpolate()` walks an object + * key by key, then `stringifyForTemplate` serializes it as JSON. The executor + * therefore reads the envelope's `source`; these pins hold the two spellings to + * the same delivered `payload.title` / `payload.body`, and the bare string to + * exactly what it rendered before the slots were typed. + * + * A separate file from `notify-node.test.ts` on purpose: another in-flight + * change edits that file, and these pins do not need its fixtures. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { tmpl } from '@objectstack/spec'; +import { AutomationEngine } from '../engine.js'; +import { registerNotifyNode } from './notify-node.js'; +import type { MessagingServiceSurface } from './notify-node.js'; + +function createTestLogger() { + return { + info: () => {}, + warn: () => {}, + error: () => {}, + debug: () => {}, + child: () => createTestLogger(), + } as any; +} + +function fakeMessaging() { + const emitted: Array[0]> = []; + const service: MessagingServiceSurface = { + async emit(n) { + emitted.push(n); + return { notificationId: 'evt_1', delivered: n.audience.length, failed: 0 }; + }, + }; + return { service, emitted }; +} + +function notifyFlow(config: Record) { + return { + name: 'notify_template_flow', + label: 'Notify Template Flow', + type: 'autolaunched' as const, + variables: [ + { name: 'dealName', type: 'text' as const, isInput: true }, + { name: 'stage', type: 'text' as const, isInput: true }, + ], + nodes: [ + { id: 'start', type: 'start' as const, label: 'Start' }, + { id: 'notify', type: 'notify' as const, label: 'Notify', config }, + { id: 'end', type: 'end' as const, label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'notify' }, + { id: 'e2', source: 'notify', target: 'end' }, + ], + }; +} + +const PARAMS = { dealName: 'Acme', stage: 'won' }; +const TITLE = '[{stage}] Deal {dealName}'; +const BODY = 'Congrats on {dealName}'; +const RENDERED = { title: '[won] Deal Acme', body: 'Congrats on Acme' }; + +describe('notify — title / message are template slots', () => { + let engine: AutomationEngine; + let messaging: ReturnType; + + beforeEach(() => { + messaging = fakeMessaging(); + engine = new AutomationEngine(createTestLogger()); + registerNotifyNode(engine, { + logger: createTestLogger(), + getService: (name: string) => (name === 'messaging' ? messaging.service : undefined), + } as any); + }); + + async function deliveredFor(config: Record) { + engine.registerFlow('notify_template_flow', notifyFlow({ recipients: ['user_1'], ...config })); + const result = await engine.execute('notify_template_flow', { params: PARAMS } as any); + return { result, payload: messaging.emitted.at(-1)?.payload }; + } + + it('renders the bare string exactly as before the slots were typed', async () => { + const { result, payload } = await deliveredFor({ title: TITLE, message: BODY }); + expect(result.success, JSON.stringify(result)).toBe(true); + expect(payload).toMatchObject(RENDERED); + }); + + it('renders the template envelope to the SAME text — its `source`, never the serialized envelope', async () => { + const { result, payload } = await deliveredFor({ + title: tmpl`[{stage}] Deal {dealName}`, + message: { dialect: 'template', source: BODY }, + }); + expect(result.success, JSON.stringify(result)).toBe(true); + expect(payload).toMatchObject(RENDERED); + // The failure this replaces is specific: reading the parsed slot whole + // delivered `{"dialect":"template","source":"…"}` as the title. + expect(String(payload?.title)).not.toContain('dialect'); + }); + + it('mixes the spellings freely across the two slots', async () => { + const { payload } = await deliveredFor({ title: { dialect: 'template', source: TITLE }, message: BODY }); + expect(payload).toMatchObject(RENDERED); + }); + + it('refuses a value that is neither a string nor a template envelope at the contract parse, before anything is sent', async () => { + const { result } = await deliveredFor({ title: 42 }); + expect(result.success).toBe(false); + expect(String(result.error)).toContain('does not satisfy the notify contract'); + expect(String(result.error)).toContain('config.title'); + expect(messaging.emitted).toHaveLength(0); + }); +}); diff --git a/packages/spec/src/automation/io-node-config.test.ts b/packages/spec/src/automation/io-node-config.test.ts index bffe229c0ac..a6d6dc0c1da 100644 --- a/packages/spec/src/automation/io-node-config.test.ts +++ b/packages/spec/src/automation/io-node-config.test.ts @@ -18,6 +18,11 @@ import { describe, expect, it } from 'vitest'; import { HttpConfigSchema, NotifyConfigSchema } from './io-node-config.zod.js'; +import { + TYPED_EXPRESSION_DIALECT_ONLY, + TYPED_EXPRESSION_SOURCE_REQUIRED, + tmpl, +} from '../shared/expression.zod.js'; /** The unknown-key message, or `undefined` when the shape was accepted. */ function unknownKeyMessage(schema: { safeParse(v: unknown): { success: boolean; error?: { issues: ReadonlyArray<{ code: string; message: string }> } } }, value: unknown): string | undefined { @@ -46,7 +51,14 @@ describe('NotifyConfigSchema — an unknown key is refused, not stripped', () => actionUrl: '/task/{record.id}', payload: { taskName: '{record.name}' }, }; - expect(NotifyConfigSchema.parse(full)).toEqual(full); + // Every key survives the parse; `title`/`message` are template slots, so + // their bare strings come back as the `{ dialect: 'template', source }` + // envelope the slot normalizes to (pinned on its own below). + expect(NotifyConfigSchema.parse(full)).toEqual({ + ...full, + title: { dialect: 'template', source: 'New task' }, + message: { dialect: 'template', source: 'You have been assigned a task.' }, + }); }); it('accepts every declared key (template content path)', () => { @@ -358,6 +370,103 @@ describe('NotifyConfigSchema — an unknown key is refused, not stripped', () => expect(dataDoc).toMatch(/together with `template`/); }); }); + + // The dialect table in `shared/expression.zod.ts` lists notification + // subjects/bodies as `template` slots, and the executor already interpolated + // `title`/`message` — but both were `z.string()`, so the envelope `tmpl` + // builds was refused at the execute-time parse (`expected string, received + // object`). They are typed with the shared template input now: both + // spellings parse, to ONE value, and everything else is still refused. + describe('title / message — template slots (the bare string and the template envelope)', () => { + const TEXT = '[{record.priority}] {record.subject}'; + + /** Issues at exactly `[key]`, as `{ code, message }`, or `[]` when accepted. */ + function issuesAt(value: unknown, key: string): ReadonlyArray<{ code: string; message: string }> { + const result = NotifyConfigSchema.safeParse(value); + if (result.success) return []; + return result.error.issues + .filter((i) => i.path.length === 1 && i.path[0] === key) + .map((i) => ({ code: i.code, message: i.message })); + } + + it('parses both spellings on both keys, and they parse to the same value', () => { + const bare = NotifyConfigSchema.safeParse({ recipients: ['u1'], title: TEXT, message: TEXT }); + const envelope = NotifyConfigSchema.safeParse({ + recipients: ['u1'], + title: { dialect: 'template', source: TEXT }, + message: tmpl`[{record.priority}] {record.subject}`, + }); + expect(bare.success, JSON.stringify(bare.error?.issues)).toBe(true); + expect(envelope.success, JSON.stringify(envelope.error?.issues)).toBe(true); + // The executor reads this parsed value, so one value is what makes the + // two spellings render one notification (pinned end to end in + // service-automation's `notify-template-slots.test.ts`). + const expected = { dialect: 'template', source: TEXT }; + expect(bare.data?.title).toEqual(expected); + expect(bare.data?.message).toEqual(expected); + expect(envelope.data?.title).toEqual(expected); + expect(envelope.data?.message).toEqual(expected); + }); + + it('still refuses a value that is neither a string nor a template envelope — one issue at the key', () => { + for (const key of ['title', 'message'] as const) { + for (const value of [42, true, ['a'], { source: TEXT }, { dialect: 'cel', source: 'record.x' }]) { + const issues = issuesAt({ recipients: ['u1'], title: 'x', [key]: value }, key); + expect(issues, `${key} = ${JSON.stringify(value)}`).toEqual([ + { code: 'invalid_union', message: TYPED_EXPRESSION_DIALECT_ONLY.template }, + ]); + } + } + }); + + it('refuses a blank bare string at the slot (the shared non-blank rule)', () => { + for (const key of ['title', 'message'] as const) { + for (const value of ['', ' ']) { + expect(issuesAt({ recipients: ['u1'], title: 'x', [key]: value }, key), `${key} = ${JSON.stringify(value)}`) + .toEqual([{ code: 'invalid_union', message: TYPED_EXPRESSION_SOURCE_REQUIRED.template }]); + } + } + }); + + it('refuses an envelope with no non-blank `source` — the executor renders `source` and nothing else', () => { + // The shared envelope arm is the persistence contract and admits both of + // these; this slot's executor cannot render either, so the slot refuses + // them by its own rule rather than letting a `title` fail every run or a + // `message` go out empty. + for (const key of ['title', 'message'] as const) { + for (const value of [ + { dialect: 'template', ast: { kind: 'text' } }, + { dialect: 'template', source: ' ' }, + ]) { + const issues = issuesAt({ recipients: ['u1'], title: 'x', [key]: value }, key); + expect(issues.map((i) => i.code), `${key} = ${JSON.stringify(value)}`).toEqual(['custom']); + expect(issues[0]!.message).toContain('`source`'); + } + } + }); + + it('keeps the content-path rules exactly: an envelope title still excludes `template`, and one still satisfies "needs a content source"', () => { + const combined = issuesAt( + { recipients: ['u1'], template: 'crm.large_deal_won', title: tmpl`Deal {record.name} won` }, + 'template', + ); + expect(combined.map((i) => i.code)).toEqual(['custom']); + expect(NotifyConfigSchema.safeParse({ recipients: ['u1'], title: tmpl`Deal {record.name} won` }).success) + .toBe(true); + }); + + it('says what the slot takes and which placeholder spelling its renderer reads', () => { + const shape = (NotifyConfigSchema as unknown as { shape: Record }).shape; + for (const key of ['title', 'message'] as const) { + const doc = shape[key]!.description ?? ''; + expect(doc.length, `${key} .describe() must not be empty`).toBeGreaterThan(0); + expect(doc).toContain('`{ dialect: \'template\', source }`'); + expect(doc).toContain('`{token}`'); + // The text is interpolated, so "sent verbatim" was never true of it. + expect(doc).not.toMatch(/verbatim/); + } + }); + }); }); describe('HttpConfigSchema — an unknown key is refused, not stripped', () => { diff --git a/packages/spec/src/automation/io-node-config.zod.ts b/packages/spec/src/automation/io-node-config.zod.ts index fa146bee607..a50ffb29120 100644 --- a/packages/spec/src/automation/io-node-config.zod.ts +++ b/packages/spec/src/automation/io-node-config.zod.ts @@ -22,8 +22,9 @@ * config against its schema before running (`service-automation`'s * `parse-config.ts`), so type and `required` violations refuse the node as a * guard (not routable via `fault` edges). `notify` parses the RAW stored - * config — its slots are string-typed, so `{token}` templates pass and the - * post-interpolation guards still own "resolved to nothing". `http` parses + * config — its slots are string-typed or template-typed, so `{token}` + * templates pass and the post-interpolation guards still own "resolved to + * nothing". `http` parses * the INTERPOLATED config, because that is the shape its executor reads — * a `{token}` in a typed slot (`timeoutMs`, `durable`) resolves to its real * type first. @@ -54,7 +55,9 @@ */ import { z } from 'zod'; +import { TemplateExpressionInputSchema } from '../shared/expression.zod'; import { lazySchema } from '../shared/lazy-schema'; +import { NON_BLANK_STRING } from '../shared/refinement-projection'; import { strictObject } from '../shared/strict-object'; /** @@ -93,11 +96,11 @@ const NOTIFY_KEY_GUIDANCE: Readonly> = { subject: 'The heading slot is `title`. `subject` is the pre-17 spelling rewritten at load by ' + '`flow-node-notify-config-aliases`; delete it once `title` carries the text. If `title` is also present with ' - + 'DIFFERENT text, the conversion kept both rather than choosing — reconcile them onto `title`.', + + 'a DIFFERENT value, the conversion kept both rather than choosing — reconcile them onto `title`.', body: 'The body slot is `message`. `body` is the pre-17 spelling rewritten at load by ' + '`flow-node-notify-config-aliases`; delete it once `message` carries the text. If `message` is also present ' - + 'with DIFFERENT text, the conversion kept both rather than choosing — reconcile them onto `message`. ' + + 'with a DIFFERENT value, the conversion kept both rather than choosing — reconcile them onto `message`. ' + '(`body` IS canonical on an `http` node — the key is wrong only here.)', url: 'The click-through slot is `actionUrl`. It was renamed at 17 because `url` elsewhere on the platform means ' @@ -115,6 +118,25 @@ const NOTIFY_KEY_GUIDANCE: Readonly> = { + 'dead link.', }; +/** + * The refusal for a `title` / `message` template envelope that carries no + * non-blank `source` — what the slot's executor renders. It names the key and + * the fix, and prescribes the slot's own placeholder spelling (`{token}`), not + * the `{{var}}` the shared template prose shows: this slot's renderer is the + * flow's `interpolate()`. + */ +function notifyTemplateSourceRequired(key: 'title' | 'message'): string { + const consequence = key === 'title' + ? 'every run that reached this node would fail with no title to send' + : 'the notification would go out with an empty body'; + return ( + `\`${key}\` is a template envelope with no non-blank \`source\`. The notify executor renders \`source\` — ` + + 'interpolating its `{token}` placeholders per run — and has nothing to render from `ast` alone or from a ' + + `blank \`source\`, so ${consequence}. Put the text in \`source\` ` + + `(\`{ dialect: 'template', source: 'Deal {record.name} won' }\`), or write it as a bare string.` + ); +} + // ─── notify ────────────────────────────────────────────────────────── /** @@ -152,9 +174,10 @@ const NOTIFY_KEY_GUIDANCE: Readonly> = { * exist at async delivery time and is not part of this chain. So two * recipients with different `sys_user.locale` values DO receive different * rows of the same bundle. - * 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 + * Inline `title`/`message` are the NON-localizable path — one text for + * every recipient, interpolated per run (below), never translated. The two + * paths are mutually exclusive on one node (see the `superRefine` below): + * runtime precedence would silently * ignore one of them, so the ambiguous combination is unrepresentable * instead — the same posture as `objectNavTargetExclusivity` * (`ui/app.zod.ts`). @@ -168,6 +191,19 @@ const NOTIFY_KEY_GUIDANCE: Readonly> = { * `notify-node.ts` for #7086: the previous wording ("every string-ish value * except `channels`") was stale for `topic` and `severity`, and it is what * makes closing the `severity` gate below safe. + * - `title` and `message` are TEMPLATE slots, typed with + * `TemplateExpressionInputSchema` like every other `template` slot in the + * dialect table (`shared/expression.zod.ts`): a bare string, or a + * `{ dialect: 'template', source }` envelope — what the `tmpl` helper + * builds. The parse normalizes the bare string to that envelope, so the + * executor reads one shape and interpolates its `source`; both spellings of + * one text render the same notification. The renderer here is the flow's + * `interpolate()`, so the placeholder spelling is its single-brace + * `{token}` (`{record.name}`). A `{{var}}` is not a placeholder in these two + * slots: the inner `{var}` resolves and the outer braces stay in the text. + * An envelope must carry a non-blank `source` (the `superRefine` below) — + * the executor renders `source` and has nothing to render from `ast` alone, + * which the shared schema's envelope arm would otherwise admit. * - `sourceObject`/`sourceId` only take effect as a PAIR — a half-specified * click-through target is dropped so the inbox never renders a dead link. * The schema keeps both optional rather than refining, because the executor @@ -186,14 +222,15 @@ export const NotifyConfigSchema = lazySchema(() => strictObject({ recipients: z.union([z.string(), z.array(z.string())]) .describe('Recipient user id(s) / audience selector(s); `{token}` templates resolve per run'), /** - * Inline notification title — the NON-localizable content path. Required - * unless `template` is set (the superRefine below owes one of the two). + * Inline notification title — the NON-localizable content path, and a + * template slot (see the docblock above). Required unless `template` is set + * (the superRefine below owes one of the two). */ - title: z.string().optional() - .describe('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.'), - /** Notification body (inline path only). */ - message: z.string().optional() - .describe('Notification body, sent verbatim like `title` (not localizable). Only valid with inline `title`, never with `template`.'), + title: TemplateExpressionInputSchema.optional() + .describe('Notification title — a template: a bare string, or a `{ dialect: \'template\', source }` envelope (the `tmpl` helper) carrying the same text. It is interpolated per run with the flow\'s single-brace `{token}` placeholders (`{record.name}`); a `{{var}}` is not a placeholder here — its inner `{var}` resolves and the outer braces stay in the text. One text for every recipient (not localizable — use `template` for per-locale content). Either this or `template` is required; the two are mutually exclusive.'), + /** Notification body (inline path only) — the same template input as `title`. */ + message: TemplateExpressionInputSchema.optional() + .describe('Notification body — the same template input as `title` (a bare string or a `{ dialect: \'template\', source }` envelope), interpolated per run with single-brace `{token}` placeholders; 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, locale)` AT DELIVERY TIME — @@ -277,7 +314,7 @@ export const NotifyConfigSchema = lazySchema(() => strictObject({ '`template` cannot be combined with inline `title`/`message` — pick ONE content path: ' + '`template` (localizable: resolves `(name, locale)` from sys_email_template at delivery, the locale being ' + 'each recipient\'s own `sys_user.locale` or the deployment default — resolved per recipient, after fan-out) ' - + 'or inline `title` + `message` (sent verbatim, not localizable). To localize, keep `template`, move ' + + 'or inline `title` + `message` (one text for every recipient, 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.', }); @@ -302,6 +339,20 @@ export const NotifyConfigSchema = lazySchema(() => strictObject({ + 'Neither was given, so there is nothing to deliver.', }); } + // The two template slots render `source` (see the docblock): the executor + // interpolates it per run and has no renderer for `ast`. The shared + // `TemplateExpressionInputSchema` is the persistence contract, so its + // envelope arm admits an `ast`-only envelope and a whitespace `source` — + // shapes that parse and then render nothing (a `title` failing every run, a + // `message` going out empty). The slot states what its executor needs + // instead, in the same notion of blank as the bare-string arm. Reached only + // once both values parsed, so `source` is read off a template envelope. + for (const key of ['title', 'message'] as const) { + const value = cfg[key]; + if (value !== undefined && !(typeof value.source === 'string' && NON_BLANK_STRING(value.source))) { + ctx.addIssue({ code: 'custom', path: [key], message: notifyTemplateSourceRequired(key) }); + } + } })); export type NotifyConfig = z.input; From 63eecdd945b4d445fa25dd2b07a2272b7497f299 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 06:05:03 +0000 Subject: [PATCH 2/5] docs(spec): reflow io-node-config docblock lines Claude-Session: https://claude.ai/code/session_01GV6oYwgc1kWiUCb1YaprQ7 Co-authored-by: Claude --- packages/spec/src/automation/io-node-config.zod.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/spec/src/automation/io-node-config.zod.ts b/packages/spec/src/automation/io-node-config.zod.ts index a50ffb29120..ec3eb1259e9 100644 --- a/packages/spec/src/automation/io-node-config.zod.ts +++ b/packages/spec/src/automation/io-node-config.zod.ts @@ -24,10 +24,9 @@ * guard (not routable via `fault` edges). `notify` parses the RAW stored * config — its slots are string-typed or template-typed, so `{token}` * templates pass and the post-interpolation guards still own "resolved to - * nothing". `http` parses - * the INTERPOLATED config, because that is the shape its executor reads — - * a `{token}` in a typed slot (`timeoutMs`, `durable`) resolves to its real - * type first. + * nothing". `http` parses the INTERPOLATED config, because that is the shape + * its executor reads — a `{token}` in a typed slot (`timeoutMs`, `durable`) + * resolves to its real type first. * * ## Unknown keys — closed here too, as of #4001 批 9 * @@ -177,10 +176,9 @@ function notifyTemplateSourceRequired(key: 'title' | 'message'): string { * Inline `title`/`message` are the NON-localizable path — one text for * every recipient, interpolated per run (below), never translated. The two * paths are mutually exclusive on one node (see the `superRefine` below): - * runtime precedence would silently - * ignore one of them, so the ambiguous combination is unrepresentable - * instead — the same posture as `objectNavTargetExclusivity` - * (`ui/app.zod.ts`). + * runtime precedence would silently ignore one of them, so the ambiguous + * combination is unrepresentable instead — the same posture as + * `objectNavTargetExclusivity` (`ui/app.zod.ts`). * - `recipients`, `title`, `message`, `actionUrl` and `payload` pass through * `interpolate()`, so `{record.x}` templates are legal in them. So do * `templateData` VALUES (they are per-run render inputs). `channels`, From ed7166a5e2f3cd6f8970f844d82a0a83c114836d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 06:08:29 +0000 Subject: [PATCH 3/5] docs(spec): regenerate io-node-config reference for the notify template slots Claude-Session: https://claude.ai/code/session_01GV6oYwgc1kWiUCb1YaprQ7 Co-authored-by: Claude --- .../docs/references/automation/io-node-config.mdx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/content/docs/references/automation/io-node-config.mdx b/content/docs/references/automation/io-node-config.mdx index b2220323307..d001908b2eb 100644 --- a/content/docs/references/automation/io-node-config.mdx +++ b/content/docs/references/automation/io-node-config.mdx @@ -25,11 +25,11 @@ these are **live execute-time contracts**: each executor `parse()`s its config against its schema before running (`service-automation`'s `parse-config.ts`), so type and `required` violations refuse the node as a guard (not routable via `fault` edges). `notify` parses the RAW stored -config — its slots are string-typed, so `{token}` templates pass and the -post-interpolation guards still own "resolved to nothing". `http` parses -the INTERPOLATED config, because that is the shape its executor reads — -a `{token}` in a typed slot (`timeoutMs`, `durable`) resolves to its real -type first. +config — its slots are string-typed or template-typed, so `{token}` +templates pass and the post-interpolation guards still own "resolved to +nothing". `http` parses the INTERPOLATED config, because that is the shape +its executor reads — a `{token}` in a typed slot (`timeoutMs`, `durable`) +resolves to its real type first. ## Unknown keys — closed here too, as of #4001 批 9 @@ -95,8 +95,8 @@ const result = HttpConfigSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **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`. | +| **title** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | Notification title — a template: a bare string, or a `{ dialect: 'template', source }` envelope (the `tmpl` helper) carrying the same text. It is interpolated per run with the flow's single-brace `{token}` placeholders (`{record.name}`); a `{{var}}` is not a placeholder here — its inner `{var}` resolves and the outer braces stay in the text. One text for every recipient (not localizable — use `template` for per-locale content). Either this or `template` is required; the two are mutually exclusive. | +| **message** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | Notification body — the same template input as `title` (a bare string or a `{ dialect: 'template', source }` envelope), interpolated per run with single-brace `{token}` placeholders; 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, locale)` against sys_email_template at delivery time and renders subject/body from that row. The locale is resolved per recipient, after fan-out: the recipient's own `sys_user.locale` when set, else the deployment default (`II18nService.getDefaultLocale()`) — so recipients whose personal languages differ receive different rows of the same bundle (maintainer ruling 2026-09-01). A producer-set `payload.locale` is not consulted. Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation. | | **templateData** | `Record` | optional | Render context for the referenced template's `{{var}}` placeholders; values interpolate `{token}` templates per run. Only valid together with `template`. | | **channels** | `string \| string[]` | optional | Channels to fan out to (default: inbox) | From 01ef8368e5ab72c4eba3f207e315e2cc9403cb74 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 06:18:27 +0000 Subject: [PATCH 4/5] test(service-automation): index the last emit without Array.prototype.at Claude-Session: https://claude.ai/code/session_01GV6oYwgc1kWiUCb1YaprQ7 Co-authored-by: Claude --- .../src/builtin/notify-template-slots.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/services/service-automation/src/builtin/notify-template-slots.test.ts b/packages/services/service-automation/src/builtin/notify-template-slots.test.ts index 6e5631b3eb0..da7492f80f8 100644 --- a/packages/services/service-automation/src/builtin/notify-template-slots.test.ts +++ b/packages/services/service-automation/src/builtin/notify-template-slots.test.ts @@ -88,7 +88,7 @@ describe('notify — title / message are template slots', () => { async function deliveredFor(config: Record) { engine.registerFlow('notify_template_flow', notifyFlow({ recipients: ['user_1'], ...config })); const result = await engine.execute('notify_template_flow', { params: PARAMS } as any); - return { result, payload: messaging.emitted.at(-1)?.payload }; + return { result, payload: messaging.emitted[messaging.emitted.length - 1]?.payload }; } it('renders the bare string exactly as before the slots were typed', async () => { From 9bfb746a3594437fa36860bba393156ae47818a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 07:34:20 +0000 Subject: [PATCH 5/5] fix(spec): grade the notify template-slot changeset as the narrowing it ships; classify the two slots in the expression ledger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The changeset now declares what the diff publishes: a widening (the template envelope parses at NotifyConfigSchema.title / .message) plus an accept-set narrowing (a blank bare string is newly refused) and a parse-output change (the parsed value is the template envelope). Clause-② yes (narrowing), feat(spec)!, a BREAKING line and an ADR-0087 not-required marker with its census; minor under the launch-window convention. The ADR-0060 expression ledger gains one row, template-notify-content, for the two template-typed notify slots, its cells measured from notify-node.ts and template.ts. Claude-Session: https://claude.ai/code/session_01GV6oYwgc1kWiUCb1YaprQ7 Co-authored-by: Claude --- .../22054-notify-title-template-input.md | 24 +++++++++++++------ .../test/expression-conformance.ledger.ts | 13 ++++++++++ 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/.changeset/22054-notify-title-template-input.md b/.changeset/22054-notify-title-template-input.md index 7dee5890abe..fa6bac29c69 100644 --- a/.changeset/22054-notify-title-template-input.md +++ b/.changeset/22054-notify-title-template-input.md @@ -3,13 +3,23 @@ "@objectstack/service-automation": patch --- -`NotifyConfigSchema.title` and `NotifyConfigSchema.message` are template slots: each takes a bare string or a `{ dialect: 'template', source }` envelope (the `tmpl` helper), as the expression dialect table already listed notification subjects and bodies among the `template` slots +feat(spec)!: `NotifyConfigSchema.title` and `NotifyConfigSchema.message` are template slots: each takes a bare string or a `{ dialect: 'template', source }` envelope (the `tmpl` helper), as the expression dialect table already listed notification subjects and bodies among the `template` slots, and a blank bare string is now refused there -Clause-②: yes (widening: a published notify slot now accepts the template envelope as well as the bare string) +Clause-②: yes (narrowing) -- Both keys are typed with `TemplateExpressionInputSchema`, the input every other `template` slot uses. Before, both were `z.string()`, so a notify node written with `` tmpl`…` `` passed `defineFlow` and registration and then failed every run at the execute-time contract parse (`expected string, received object`). -- The parse normalizes a bare string to `{ dialect: 'template', source }`, so `NotifyConfigSchema.parse(...)` now returns the envelope for both spellings. The `notify` executor reads the envelope's `source` and interpolates it as before, so both spellings of one text deliver the same `payload.title` and `payload.body`. A bare string renders exactly what it rendered before. -- The placeholder spelling these two slots read is the flow's single-brace `{token}` (`{record.name}`). A `{{var}}` is not a placeholder here: the inner `{var}` resolves and the outer braces stay in the text, for a bare string and an envelope alike. The `.describe()` on both keys now says so, and no longer says the text is "sent verbatim". -- Still refused at each key: a value that is neither a string nor a template envelope (a number, an array, a `cel` envelope), and a blank bare string (`''` or whitespace), both by the shared template input. A blank `title` used to parse and then fail every run ("title is required"). A blank `message` used to send an empty body, the same as leaving `message` out. -- New, notify-only: a template envelope on either key must carry a non-blank `source`. The executor renders `source` and has nothing to render from `ast` alone, so such an envelope is refused at the key instead of failing every run (`title`) or sending an empty body (`message`). + + +**BREAKING** accept-set narrowing at two authorable keys (`automation/NotifyConfig:title`, `automation/NotifyConfig:message`), shipped as `minor` under the repo's launch-window convention for breaking changes. It is the grade `TemplateExpressionInputSchema`'s blank-string rule shipped with when it reached the first twelve typed keys. + +- **What widens.** Both keys are typed with `TemplateExpressionInputSchema`, the input every other `template` slot uses. Before, both were `z.string()`, so a notify node written with `` tmpl`…` `` passed `defineFlow` and registration and then failed every run at the execute-time contract parse (`expected string, received object`). It now parses and runs. +- **What narrows.** A blank bare string (`''` or whitespace-only) at either key is newly refused, by the shared template input's non-blank rule (`invalid_union`, with the `TYPED_EXPRESSION_SOURCE_REQUIRED.template` sentence). Before, every blank value parsed: + - `title: ''` then failed every run at the executor's guard ("notify: title is required"), so it fails either way, now earlier; + - a whitespace-only `title` passed that guard and was delivered as the notification title, and it is now refused; + - `message: ''` or a whitespace-only `message` was delivered as an empty or blank body, and it is now refused. + + The fix is to write the text, or to delete the key (`message` is optional). +- **Parse output.** `NotifyConfigSchema.parse(...).title` and `.message` go from `string` to `{ dialect: 'template', source }`, for both spellings, because the parse normalizes a bare string to that envelope. The exported `NotifyConfigParsed` type changes with them. Code that reads parse output reads `.source`. The `notify` executor, the one reader in this repo, now does, so both spellings of one text deliver the same `payload.title` and `payload.body`, and a bare string renders exactly what it rendered before. +- **Still refused, with a new sentence.** A value that is neither a string nor a template envelope (a number, an array, a `cel` envelope) was refused before (`invalid_type`). It is refused now as `invalid_union`, with the `TYPED_EXPRESSION_DIALECT_ONLY.template` sentence. +- **New, notify-only.** A template envelope on either key must carry a non-blank `source`. The executor renders `source` and has nothing to render from `ast` alone, so such an envelope is refused at the key instead of failing every run (`title`) or sending an empty body (`message`). An envelope never parsed at these keys before, so this refuses nothing that used to parse. +- **Placeholder spelling.** These two slots read the flow's single-brace `{token}` (`{record.name}`). A `{{var}}` is not a placeholder here: the inner `{var}` resolves and the outer braces stay in the text, for a bare string and an envelope alike. The `.describe()` on both keys now says so, and no longer says the text is "sent verbatim". - `@objectstack/service-automation`: the `notify` executor reads `source` from the two template slots, and the descriptor's `title` / `message` descriptions state the `{token}` interpolation in place of "sent verbatim". diff --git a/packages/qa/dogfood/test/expression-conformance.ledger.ts b/packages/qa/dogfood/test/expression-conformance.ledger.ts index 92447a86c24..9a3c924c847 100644 --- a/packages/qa/dogfood/test/expression-conformance.ledger.ts +++ b/packages/qa/dogfood/test/expression-conformance.ledger.ts @@ -597,6 +597,19 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [ covers: ['data/object.zod.ts:ObjectSchemaBase.titleFormat'], note: 'EXPERIMENTAL, and the state is a deliberate split between two questions. `packages/spec/liveness/object.json` classifies the KEY `live` with the note "objectui ({{record.field}} interpolation)" — that ledger asks whether anything READS the key, and the answer is yes. THIS ledger asks what EVALUATES the expression and under which fail policy, and the only interpolation site named is in the sibling repo objectui, which is not in this checkout: ⛔ NOT measured here, so it is not written into `enforcement` as if it had been. Marking the row `enforced` on a second-hand reading is exactly the invented cell this ledger exists to prevent; marking it `removed` would contradict a governed ledger that measured more than I could. Re-state as `enforced` when someone measures the objectui site — or as `removed` when ADR-0079 retires the key.', }, + { + id: 'template-notify-content', + summary: 'flow `notify` node inline content (NotifyConfig.title, .message) — single-brace `{token}` interpolation per run', + dialect: 'template', mode: 'interpret', state: 'enforced', failPolicy: 'throw', + enforcement: + 'service-automation/builtin/notify-node.ts `execute`: `parseNodeConfig` parses the RAW config against `NotifyConfigSchema` first, and the parse normalizes a bare string to `{dialect:"template",source}`; then `stringifyForTemplate(interpolate(cfg.title?.source ?? "", …))` and the same for `message` — builtin/template.ts `interpolate` → `interpolateString`, which substitutes single-brace `{token}` only (`/\\{([^{}]+)\\}/g` → `resolveToken`), so a `{{var}}` keeps its outer braces. The rendered text goes out as `payload.title` / `payload.body` through the messaging service `emit`. On a fault: a malformed value (not a string or a template envelope, a blank bare string, a foreign-dialect envelope, an envelope with no non-blank `source`) is refused by that parse as a guard (`refuseNode`), so the run fails at the node and no `fault` edge routes it; a function-shaped defect in a token (unknown function, wrong arity, argument out of domain) THROWS `FlowExpressionFunctionError`, a marked guard refusal, failing the run the same way; and a `title` that renders empty fails the node (`notify: title is required`). NOT a throw: a token whose path resolves to nothing, or whose arithmetic does not evaluate, renders as empty text with no log, so a `message` goes out with the gap. Neither `FlowSchema.parse` nor registration judges these values (the builtin config arm reports only an ABSENT required key); `os validate` warns on a `{{var}}` (lint `flow-double-brace-interpolation`) and on an unknown `{record.x}` head (`validate-flow-template-paths`)', + covers: [ + 'automation/io-node-config.zod.ts:NotifyConfigSchema.title', + 'automation/io-node-config.zod.ts:NotifyConfigSchema.message', + ], + proof: 'packages/services/service-automation/src/builtin/notify-template-slots.test.ts', + note: 'The renderer is the flow template interpolator, not `@objectstack/formula` templateEngine: the `{{var}}` spelling that engine and the messaging/email renderers read is NOT a placeholder here. `throw` is the ADR-0058 D5 flow tier and describes the faults the cell names as refusals; the silent half (an unresolved token rendering empty) is stated in the cell rather than rounded into the tier.', + }, { id: 'cel-advanced-policy', summary: 'advanced security / versioning policy conditions',