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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .changeset/22054-notify-title-template-input.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
"@objectstack/spec": minor
"@objectstack/service-automation": patch
---

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 (narrowing)

<!-- 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. -->

**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".
14 changes: 7 additions & 7 deletions content/docs/references/automation/io-node-config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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<string, any>` | optional | Render context for the referenced template's `{{var}}` placeholders; values interpolate `{token}` templates per run. Only valid together with `template`. |
| **channels** | `string \| string[]` | optional | Channels to fan out to (default: inbox) |
Expand Down
13 changes: 13 additions & 0 deletions packages/qa/dogfood/test/expression-conformance.ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
27 changes: 21 additions & 6 deletions packages/services/service-automation/src/builtin/notify-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<NotifyConfigParsed>('notify', node.id, NotifyConfigSchema, node.config);
Expand All @@ -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
Expand Down
Loading
Loading