Skip to content

Commit d4680d2

Browse files
feat(spec): notify title/message are template slots — bare string or tmpl envelope (#22063)
Fixes #22054 Clause-②: yes (narrowing: the template envelope is newly accepted, and a blank or whitespace bare string is newly refused at `title` / `message`, with `NotifyConfigParsed` re-shaped; a BREAKING accept-set narrowing, `@objectstack/spec` `minor` under the launch-window convention, per contract review `6033083158`) ## What changed The expression dialect table in `packages/spec/src/shared/expression.zod.ts` lists notification subjects and bodies as `template` slots. `NotifyConfigSchema.title` and `NotifyConfigSchema.message` were `z.string()`, so a notify node written with `` tmpl`…` `` was refused. This PR follows the triage direction (the first of the card's two): - **Spec (`packages/spec/src/automation/io-node-config.zod.ts`).** `title` and `message` are typed `TemplateExpressionInputSchema.optional()`, the input every other `template` slot uses. Both the bare string and the `{ dialect: 'template', source }` envelope parse. The parse normalizes the bare string to that envelope, so both spellings of one text parse to the same value. - The mutual-exclusion rule (`template` against `title`/`message`), the `templateData` rule and the "needs a content source" rule are unchanged. - One rule is added, for this slot only. A template envelope on either key must carry a non-blank `source`. The shared input's envelope arm is the persistence contract and admits an `ast`-only envelope or a whitespace `source`. The executor renders `source` only, so without this rule such a `title` would fail every run and such a `message` would go out empty. - **Executor (`packages/services/service-automation/src/builtin/notify-node.ts`, declared cross-lane file).** The executor reads `cfg.title?.source` and `cfg.message?.source` and interpolates them exactly as before. Before this change it read the slot whole: `interpolate` walks an object key by key, and `stringifyForTemplate` then serialized it as JSON (H1 reading below). The descriptor's `title`/`message` descriptions now state the `{token}` interpolation instead of "sent verbatim". The descriptor keeps `type: 'string'`: the Studio form edits the bare-string spelling. - **Every published sentence about the two keys is now true.** The `.describe()` texts, the schema docblock and the conflict-refusal text said the text is "sent verbatim". The executor interpolates it, so that sentence was already false before this PR. They now say which placeholder spelling the slot's renderer reads: the flow's single-brace `{token}`. - Generated: `content/docs/references/automation/io-node-config.mdx` (`gen:docs`). No other spec artifact moved. `check:generated` reports all 15 up to date. ## Measurements **H1: what the executor did with an envelope.** Measured with `interpolate` and `stringifyForTemplate` from `template.ts`, with `record = { priority: 'P1', subject: 'Server down' }`: | input | before (base `d5a14dd5`) | after | |:---|:---|:---| | bare `'[{record.priority}] {record.subject}'` | `[P1] Server down` | `[P1] Server down` (unchanged) | | `` tmpl`[{record.priority}] {record.subject}` `` through `interpolate` + `stringifyForTemplate` | `{"dialect":"template","source":"[P1] Server down"}` | not reached: the executor reads `source` | | same envelope, through the whole node | refused at the execute-time parse: `config.title: Invalid input: expected string, received object` | delivered `[P1] Server down` | Ablation of the executor read, with the new spec and the old read `interpolate(cfg.title ?? '', …)`: all three render pins in `notify-template-slots.test.ts` go red. The bare string goes red too, because the parse now hands the executor an envelope for both spellings. The delivered title was `{"dialect":"template","source":"[won] Deal Acme"}`. The restore was verified (blob equal to `HEAD`, `git diff HEAD` empty). **Premise check.** The card says `defineFlow` refuses the envelope. On `main` at `d5a14dd5` it does not. `FlowSchema.safeParse` and `defineFlow` accept a notify node with a `tmpl` title, and `AutomationEngine.registerFlow` registers it. The refusal comes only at execute time, from `parseNodeConfig` (`config.title: Invalid input: expected string, received object`). The flow builds and registers, then fails every run. The card's core premise holds: the schema disagrees with the dialect table. **Pins** (spec `io-node-config.test.ts`, executor `notify-template-slots.test.ts`): | value at `title` / `message` | parse | render | |:---|:---|:---| | `'[{stage}] Deal {dealName}'` (bare) | ok, normalized to `{ dialect: 'template', source }` | `[won] Deal Acme` | | `` tmpl`[{stage}] Deal {dealName}` `` / `{ dialect: 'template', source }` | ok, same value | `[won] Deal Acme` (same text) | | `42`, `true`, `['a']`, `{ source }`, `{ dialect: 'cel', source }` | refused, one issue at the key: `invalid_union`, message equal to `TYPED_EXPRESSION_DIALECT_ONLY.template` | node refused before anything is sent | | `''`, `' '` | refused: `invalid_union`, message equal to `TYPED_EXPRESSION_SOURCE_REQUIRED.template` | n/a | | `{ dialect: 'template', ast: … }`, `{ dialect: 'template', source: ' ' }` | refused: `custom` at the key, message naming `source` | n/a | Reverse runs. The new spec pins were run against the base schema file (restored from `d5a14dd5`, trap-restored, blob verified). Result: 7 red (the 6 new pins and the updated "accepts every declared key"), 27 green. Disabling only the new `source` rule turns exactly 1 pin red. A cross-package type check: writing `cfg.title?.trim()` in the executor makes `tsc` red with `TS2339 … on type '{ dialect: "template"; source: string; } | …'`, so `service-automation` reads the rebuilt `.d.ts`. **H3: the `subject` alias conversion.** No change is needed. - ``subject: 'X'`` alongside ``title: tmpl`X` `` are structurally different values, so `flow-node-notify-config-aliases` keeps both. The strict gate then refuses `subject` with its guidance. - `subject` alone, as a bare string or an envelope, still renames onto `title` and parses. - The guidance's "with DIFFERENT text" is now "with a DIFFERENT value", which is true in this case as well. - Nothing is lost silently. No stored pre-17 flow can carry the pair, because an envelope `title` never parsed before this PR. **H5: the expression-slot machinery.** Nothing new sees these keys as expression slots. - They are not on `FLOW_NODE_EXPRESSION_PATHS`, so the lint `validateExpression` walk and the registration expression pass do not visit them. - The generic `{token}` path walk (`validate-flow-template-paths`) recurses into objects, so it reads an envelope's `source` as it read the bare string. - `authorable-surface`, `liveness` and the strictness ledger record keys, and the key set is unchanged. All three gates are green with no artifact moved. - `check:api-surface` is green with no artifact change. ## Acceptance notes - **Placeholder spelling.** These two slots render through the flow's `interpolate()`, so the placeholder is `{record.name}`. A `{{record.name}}` renders with its outer braces left in (`{Acme}`), for a bare string and an envelope alike. That was already true for bare strings. The `.describe()` texts now say it. - The card's own example (`` tmpl`[{{record.priority}}] {{record.subject}}` ``) now parses and renders `[{P1}] {Server down}`. - Three shared texts outside this PR's surface still show `{{var}}` as the spelling to write: the shared template refusals `TYPED_EXPRESSION_SOURCE_REQUIRED.template` and `TYPED_EXPRESSION_DIALECT_ONLY.template`, and the `tmpl` docblock. This is reported to the seat; it is not changed here. - **Blank strings: a BREAKING accept-set narrowing.** A blank bare string (`''` or whitespace-only) at either key was accepted on `main` and is now refused, by the shared template input's non-blank rule. - `title: ''` used to parse and then fail every run with "notify: title is required", so it fails either way, now earlier. - A whitespace-only `title` passed that guard and was delivered. It is now refused. - A blank or whitespace-only `message` was delivered as an empty or blank body. It is now refused. - Measured: zero blank notify `title`/`message` values in the 31 in-repo authoring files and at the objectui pin. objectui's flow inspector deletes a cleared key (`setAtPath`) only for `''`, so a whitespace-only Studio entry is stored. - Graded as the repo graded the same rule in `f81afe3`: `feat(spec)!`, `Clause-②: yes (narrowing)`, an ADR-0087 `not-required (no-migration-prescription)` marker with this census, and a **BREAKING** line, shipped as `minor` under the launch-window convention (contract review `6033083158`; patch round 1, `9bfb746a35`). - The parse output also changes: `NotifyConfigSchema.parse(...).title` / `.message` go from a string to `{ dialect: 'template', source }`, and `NotifyConfigParsed` with them. The notify executor, the one reader of parse output in this repo, reads `.source`. - **Studio form.** The descriptor keeps `type: 'string'` for both keys, so the Studio form authors the bare string. objectui's `FlowNodeConfigField` renders a text control with `String(value)`, so a code-authored envelope would display as `[object Object]` there. That is outside this repo and is noted for the objectui owner. - **Not merged with `main`.** `origin/main` gained 2 commits since `d5a14dd5` (`packages/spec/src/ui/**` and `metadata-protocol`). They share no file with this diff. - PR #21974 also edits `notify-node.test.ts`. The new executor pins live in their own file, `notify-template-slots.test.ts`, so the two PRs do not conflict there. ## Local verification (final commit `01ef8368e5`) Every reading below was taken on this branch. `packages/spec` is byte-identical from `ed7166a5e2` to the final commit `01ef8368e5`. The only later change is one line in `notify-template-slots.test.ts`. - `pnpm --filter @objectstack/spec build` (JS and DTS): exit 0. - `pnpm --filter @objectstack/spec check:generated`: exit 0, all 15 artifacts up to date. `check:api-surface`, `check:authorable-surface`, `check:docs`, `check:liveness` and `check:strictness-ledger` are among them. - `pnpm --filter @objectstack/spec test`: 620 files, 18497 passed, 1 todo, exit 0. Run at `ed7166a5e2`. - `pnpm --filter @objectstack/spec typecheck`: exit 0. `check:test-typecheck` holds its ledger unchanged. - `pnpm --filter @objectstack/service-automation test`: 174 files, 2116 passed, exit 0. Run at `01ef8368e5`. - `pnpm --filter @objectstack/service-automation typecheck`: exit 0. - `pnpm --filter @objectstack/lint test`: 120 files, 5638 passed, exit 0. Run at `01ef8368e5`. - `node scripts/pm/dispatch-gates.mjs --commands`: 111 families, derived with no paths at `01ef8368e5`. All were run and reconciled with `--ran`: 110 run, 1 NOT MEASURED, 0 unrun. - NOT MEASURED, reason: `pnpm check:dual-build-cjs-loads` exited 3 (PREREQUISITE NOT MET: it needs every package's `dist/`). It is declared to CI. - `check:skill-examples` first exited 3 because the client packages had no `dist/`. After building `@objectstack/client` and `@objectstack/client-react` it exited 0 (262 examples type-check). - ESLint, narrowed to the 4 changed TS files and run at `01ef8368e5` with `--no-inline-config --format json`. - Population: `eslint.config.mjs` lints `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` (the `.md`/`.mdx` files in this diff are outside it). - Count: 4 files, 0 errors, 0 warnings, read from the JSON output. - Invariance: the config never enables type-aware linting (no `parserOptions.project`, no `projectService`), so this diff cannot change any untouched file's verdict. - The repo-wide `pnpm lint` is left to CI. --- _Generated by [Claude Code](https://claude.ai/code/session_01GV6oYwgc1kWiUCb1YaprQ7)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e67ba80 commit d4680d2

7 files changed

Lines changed: 370 additions & 35 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/service-automation": patch
4+
---
5+
6+
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
7+
8+
Clause-②: yes (narrowing)
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No authorable key is renamed, retired or re-shaped for an author: `title` and `message` still take every non-blank bare string they took before, and now also the `template` envelope. The one newly refused input is a blank bare string (`''` or whitespace-only) at either key, and it was measured, not assumed. Census: the 31 files in this repo that author a `notify` node at the merge base (examples, docs, skills, the ADR, tests and fixtures) carry zero blank `title` / `message` values. The objectui pin (`a58626c8`) has 9 files that name a `notify` type and zero blank `title` / `message` literals. Not covered by either count: objectui's flow inspector deletes a cleared key only when the committed value is `''` (`setAtPath`), so a whitespace-only entry typed into the Studio form IS stored, and a stored flow carrying one is refused at its next run; hosted tenants' stored flows were not measured. No conversion can repair such a value, because an empty title or body has no intended text to recover: the remedy is authoring intent (write the text, or delete the key), so the ledger has nothing to rewrite. -->
11+
12+
**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.
13+
14+
- **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.
15+
- **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:
16+
- `title: ''` then failed every run at the executor's guard ("notify: title is required"), so it fails either way, now earlier;
17+
- a whitespace-only `title` passed that guard and was delivered as the notification title, and it is now refused;
18+
- `message: ''` or a whitespace-only `message` was delivered as an empty or blank body, and it is now refused.
19+
20+
The fix is to write the text, or to delete the key (`message` is optional).
21+
- **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.
22+
- **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.
23+
- **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.
24+
- **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".
25+
- `@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".

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

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,11 @@ these are **live execute-time contracts**: each executor `parse()`s its
2525
config against its schema before running (`service-automation`'s
2626
`parse-config.ts`), so type and `required` violations refuse the node as a
2727
guard (not routable via `fault` edges). `notify` parses the RAW stored
28-
config — its slots are string-typed, so `{token}` templates pass and the
29-
post-interpolation guards still own "resolved to nothing". `http` parses
30-
the INTERPOLATED config, because that is the shape its executor reads —
31-
a `{token}` in a typed slot (`timeoutMs`, `durable`) resolves to its real
32-
type first.
28+
config — its slots are string-typed or template-typed, so `{token}`
29+
templates pass and the post-interpolation guards still own "resolved to
30+
nothing". `http` parses the INTERPOLATED config, because that is the shape
31+
its executor reads — a `{token}` in a typed slot (`timeoutMs`, `durable`)
32+
resolves to its real type first.
3333

3434
## Unknown keys — closed here too, as of #4001 批 9
3535

@@ -95,8 +95,8 @@ const result = HttpConfigSchema.parse(data);
9595
| Property | Type | Required | Description |
9696
| :--- | :--- | :--- | :--- |
9797
| **recipients** | `string \| string[]` | ✅ | Recipient user id(s) / audience selector(s); `{token}` templates resolve per run |
98-
| **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. |
99-
| **message** | `string` | optional | Notification body, sent verbatim like `title` (not localizable). Only valid with inline `title`, never with `template`. |
98+
| **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. |
99+
| **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`. |
100100
| **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. |
101101
| **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`. |
102102
| **channels** | `string \| string[]` | optional | Channels to fan out to (default: inbox) |

‎packages/qa/dogfood/test/expression-conformance.ledger.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -597,6 +597,19 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [
597597
covers: ['data/object.zod.ts:ObjectSchemaBase.titleFormat'],
598598
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.',
599599
},
600+
{
601+
id: 'template-notify-content',
602+
summary: 'flow `notify` node inline content (NotifyConfig.title, .message) — single-brace `{token}` interpolation per run',
603+
dialect: 'template', mode: 'interpret', state: 'enforced', failPolicy: 'throw',
604+
enforcement:
605+
'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`)',
606+
covers: [
607+
'automation/io-node-config.zod.ts:NotifyConfigSchema.title',
608+
'automation/io-node-config.zod.ts:NotifyConfigSchema.message',
609+
],
610+
proof: 'packages/services/service-automation/src/builtin/notify-template-slots.test.ts',
611+
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.',
612+
},
600613
{
601614
id: 'cel-advanced-policy',
602615
summary: 'advanced security / versioning policy conditions',

‎packages/services/service-automation/src/builtin/notify-node.ts‎

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -179,13 +179,19 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext)
179179
recipients: {
180180
description: 'Recipient user id(s) / audience selector(s)',
181181
},
182+
// The form edits the bare-string spelling of these two
183+
// template slots; the contract (`NotifyConfigSchema`) also
184+
// takes the `{ dialect: 'template', source }` envelope that
185+
// code-authored flows write with `tmpl`, carrying the same
186+
// text. `type` stays `string` because the form's control is
187+
// a text box: it is the authoring surface, not the contract.
182188
title: {
183189
type: 'string',
184-
description: 'Notification title, sent verbatim (not localizable — use template for per-locale content). Either this or template is required; mutually exclusive with template.',
190+
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.',
185191
},
186192
message: {
187193
type: 'string',
188-
description: 'Notification body, sent verbatim (not localizable). Only valid with inline title, never with template.',
194+
description: 'Notification body, interpolated per run with {token} placeholders like title. Not localizable. Only valid with inline title, never with template.',
189195
},
190196
// ── Localizable content path (#9205) ─────────────────────
191197
// Mirrors `NotifyConfigSchema.template`/`templateData`; the
@@ -243,8 +249,8 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext)
243249
// The historical aliases (`to`/`subject`/`body`/`url`) are canonicalized
244250
// at load by the ADR-0087 D2 conversion 'flow-node-notify-config-aliases'
245251
// (#3796), so the parse sees only canonical keys. Parsed BEFORE
246-
// interpolation — the contract's slots are string-typed, so `{token}`
247-
// templates pass; the post-interpolation guards below still own
252+
// interpolation — the contract's slots are string- or template-typed,
253+
// so `{token}` templates pass; the post-interpolation guards below still own
248254
// "title/recipients resolved to nothing" (#3582), which no static
249255
// parse can see.
250256
const parsed = parseNodeConfig<NotifyConfigParsed>('notify', node.id, NotifyConfigSchema, node.config);
@@ -256,8 +262,17 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext)
256262
// stringifyForTemplate (not String()): a sole-token `{$error}` resolves
257263
// to the engine's error OBJECT, which String() would render as the
258264
// useless `[object Object]` (#3450). Serialize it readably instead.
259-
const title = stringifyForTemplate(interpolate(cfg.title ?? '', variables, context));
260-
const body = stringifyForTemplate(interpolate(cfg.message ?? '', variables, context));
265+
//
266+
// `title`/`message` are template slots (`TemplateExpressionInputSchema`):
267+
// the parse above normalizes a bare string to the
268+
// `{ dialect: 'template', source }` envelope an author may also write
269+
// directly (`tmpl`), so the parsed config holds the envelope in BOTH
270+
// cases and the text is its `source` — never the envelope itself,
271+
// which `interpolate` would walk key by key and `stringifyForTemplate`
272+
// would then serialize as JSON. The contract also refuses an envelope
273+
// whose `source` is absent or blank, so a present slot always has text.
274+
const title = stringifyForTemplate(interpolate(cfg.title?.source ?? '', variables, context));
275+
const body = stringifyForTemplate(interpolate(cfg.message?.source ?? '', variables, context));
261276
// #9205 — the localizable content path. `template` is read RAW (a
262277
// static metadata cross-reference, like `topic`/`channels`);
263278
// `templateData` VALUES interpolate per run, so flow state can feed

0 commit comments

Comments
 (0)