From 22d4d065457121586083bd20e085b7de1703fa28 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 12:24:23 +0000 Subject: [PATCH 01/17] wip(spec,service-automation,lint,formula): flow text slots read {{ }} holes (#22110) Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- packages/formula/src/template-engine.test.ts | 13 + packages/formula/src/template-engine.ts | 13 +- .../scripts/check-doc-formula-expressions.mjs | 8 +- packages/lint/src/lint-flow-patterns.ts | 57 ++++- packages/lint/src/validate-expressions.ts | 21 ++ .../src/builtin/notify-node.ts | 36 +-- .../src/builtin/screen-nodes.ts | 38 +-- .../src/builtin/template.ts | 139 ++++++++--- .../services/service-automation/src/engine.ts | 33 ++- .../src/automation/builtin-node-config.zod.ts | 59 +++-- .../src/automation/flow-template-token.ts | 93 +++++++ .../src/automation/flow-text-slot-template.ts | 234 ++++++++++++++++++ .../automation/flow-value-slot-template.ts | 82 +----- packages/spec/src/automation/index.ts | 4 + .../spec/src/automation/io-node-config.zod.ts | 87 ++++--- .../spec/src/shared/typed-expression-input.ts | 9 +- 16 files changed, 719 insertions(+), 207 deletions(-) create mode 100644 packages/spec/src/automation/flow-template-token.ts create mode 100644 packages/spec/src/automation/flow-text-slot-template.ts diff --git a/packages/formula/src/template-engine.test.ts b/packages/formula/src/template-engine.test.ts index 1761b57457b..448022e0838 100644 --- a/packages/formula/src/template-engine.test.ts +++ b/packages/formula/src/template-engine.test.ts @@ -48,6 +48,19 @@ describe('templateEngine', () => { expect(r.ok).toBe(false); }); + // #22110 — a host scope's `$`-named variable (a flow's engine-set `$error`) + // is a path like any other: the hole compiles, resolves, and an unbound one + // renders nothing. + it('reads a `$`-named variable as a path', () => { + const r = templateEngine.evaluate( + { dialect: 'template', source: 'Failed: {{ $error.message }}' }, + { extra: { $error: { nodeId: 'n1', message: 'boom' } } }, + ); + expect(r).toEqual({ ok: true, value: 'Failed: boom' }); + expect(templateEngine.compile('{{ $runId }}').ok).toBe(true); + expect(templateEngine.evaluate({ dialect: 'template', source: '[{{ $nope.x }}]' }, {})).toEqual({ ok: true, value: '[]' }); + }); + it('handles bracket notation', () => { const r = templateEngine.evaluate( { dialect: 'template', source: '{{record.tags[0]}}' }, diff --git a/packages/formula/src/template-engine.ts b/packages/formula/src/template-engine.ts index 6df42dddc5b..293f1aaa996 100644 --- a/packages/formula/src/template-engine.ts +++ b/packages/formula/src/template-engine.ts @@ -184,7 +184,18 @@ interface ParsedHole { filter?: { name: string; arg?: string }; } -const PATH_ONLY_RE = /^[\w.[\]]+$/; +/** + * A hole's path: identifier characters, `.` segments and `[i]` indices. + * + * `$` is an identifier character here (#22110): a host scope may name a + * variable with it — a flow's engine-set `$error` (the fault a `try_catch` or + * fault edge caught), `$runId`, `$flowLabel` — and since protocol 18 a flow's + * text slots render through this engine, so `{{ $error.message }}` is how a + * fault-handler notification names the error. Without it the hole failed to + * compile and the variable had no template spelling at all. A `$` name that no + * scope binds resolves to nothing, like every other unknown path. + */ +const PATH_ONLY_RE = /^[\w$.[\]]+$/; /** * Parse a hole's inner content into a path + optional single formatter. diff --git a/packages/lint/scripts/check-doc-formula-expressions.mjs b/packages/lint/scripts/check-doc-formula-expressions.mjs index 9cb7159e4df..30f5a9cdac4 100644 --- a/packages/lint/scripts/check-doc-formula-expressions.mjs +++ b/packages/lint/scripts/check-doc-formula-expressions.mjs @@ -1003,9 +1003,15 @@ const SPEC_EXAMPLE_SLOTS = [ * tripwire — a slot of one of these types that carries an `@example` and is not * registered is reported, never judged, because its scope is not knowable from * the type (header, point 3). + * + * Includes the two package-internal constructors the public typed inputs are + * built from (`packages/spec/src/shared/typed-expression-input.ts`): a slot + * that builds its own copy to carry its own refusal text — a notify `title` / + * `message` is `templateExpressionInput(…)` — names neither public schema, and + * without these two names an `@example` added to it later would go unreported. */ const EXPRESSION_SLOT_TYPES = - /\b(ExpressionInputSchema|PredicateInputSchema|CronExpressionInputSchema|TemplateExpressionInputSchema)\b/; + /\b(ExpressionInputSchema|PredicateInputSchema|CronExpressionInputSchema|TemplateExpressionInputSchema|templateExpressionInput|cronExpressionInput)\b/; /** * Sites deliberately not judged, each with its reason. See the header: named, diff --git a/packages/lint/src/lint-flow-patterns.ts b/packages/lint/src/lint-flow-patterns.ts index 57028a731c9..155cef16396 100644 --- a/packages/lint/src/lint-flow-patterns.ts +++ b/packages/lint/src/lint-flow-patterns.ts @@ -157,6 +157,8 @@ import { APPROVAL_NODE_TYPE, APPROVAL_REVISE_NODE_TYPE, collectFlowGraphs, + FLOW_NODE_TEXT_SLOTS, + flowNodeTextSlotSources, } from '@objectstack/spec/automation'; import type { FlowNodeParsed, FlowEdgeParsed } from '@objectstack/spec/automation'; // [#15429] The decision's `mode` contract, parsed here so `os validate` and @@ -649,11 +651,20 @@ function scanFilterForDateEquality( } // Flow node VALUES interpolate with SINGLE braces (`{var}` / `{rec.field}` / -// `{$User.Id}`). Two wrong-syntax mistakes AI/human authors carry over from the -// *formula* template dialect (`{{ path }}`) or other platforms: -// - `{{ai_reply}}` — double-brace (verified: no flow node uses `{{ }}`). +// `{$User.Id}`) — every config string EXCEPT the text slots (#22110, ADR-0032 +// D3: a notify `title` / `message`, a screen `title` / `description`, an `end` +// `message` — `FLOW_NODE_TEXT_SLOTS`), which render the `{{ }}` holes of the +// formula template dialect. Two wrong-syntax mistakes AI/human authors carry +// over between the two, or from other platforms: +// - `{{ai_reply}}` — double-brace on a single-brace value. NOT flagged on a +// text slot, where it is the spelling; the reverse +// mistake there — a single-brace token on a text slot — +// is refused at `error` by the build door +// (`validate-expressions`, the spec's one judge), so this +// rule does not repeat it. // - `$source.id` — a `$`-prefixed reference written bare (resolves as a -// literal string), instead of `{source.id}`. +// literal string), instead of `{source.id}` — or +// `{{ source.id }}` on a text slot. const DOUBLE_BRACE = /\{\{\s*[\w$][\w$.\s]*\}\}/; // A `$Ident.field` not immediately inside a `{` (so `{$User.Id}` is NOT flagged). // Require a letter/_ after `$` so currency like `$5.00` is never matched. @@ -662,6 +673,16 @@ const BARE_DOLLAR_REF = /(?:^|[^{])\$[A-Za-z_]\w*\.[A-Za-z_]/; /** Config keys whose string values are CEL predicates, not interpolated templates. */ const CEL_KEYS = new Set(['condition', 'expression', 'conditions']); +/** `config` without its node type's `{{ }}` text slots — the strings that keep the single-brace dialect. */ +function withoutTextSlots(nodeType: unknown, config: unknown): unknown { + if (!config || typeof config !== 'object' || Array.isArray(config)) return config; + const keys = FLOW_NODE_TEXT_SLOTS.filter((slot) => slot.nodeType === nodeType).map((slot) => slot.key); + if (keys.length === 0) return config; + const rest: AnyRec = { ...(config as AnyRec) }; + for (const key of keys) delete rest[key]; + return rest; +} + /** Collect every interpolated-template string value in a node config (skips CEL keys). */ function collectTemplateStrings(value: unknown, key: string | undefined, out: string[]): void { if (key && CEL_KEYS.has(key)) return; @@ -1706,17 +1727,22 @@ export function lintFlowPatterns(stack: AnyRec): FlowLintFinding[] { // there ships to the endpoint as literal text. Remove fewer than the // node's own slots and the double-count returns; remove more and a key // that was never a region is deleted unread. + // + // [#22110] And WITHOUT the node's text slots, which read `{{ }}`: a + // double brace there is the spelling, and their own bare-`$` check + // below prescribes the hole. const strings: string[] = []; - collectTemplateStrings(stripRegions(node.config, ownRegionKeys(node.type)), undefined, strings); + collectTemplateStrings(withoutTextSlots(node.type, stripRegions(node.config, ownRegionKeys(node.type))), undefined, strings); for (const str of strings) { if (DOUBLE_BRACE.test(str)) { findings.push({ where: nodeWhere, - message: `double-brace interpolation \`${str.trim().slice(0, 80)}\` — flow node values use SINGLE braces.`, + message: `double-brace interpolation \`${str.trim().slice(0, 80)}\` — this flow node value uses SINGLE braces.`, hint: - `Use \`{var}\` (e.g. \`{record.title}\`): a flow node value is a string template in which only ` + + `Use \`{var}\` (e.g. \`{record.title}\`): this flow node value is a string template in which only ` + `single-brace \`{…}\` tokens resolve and all other text is literal. Double-brace \`{{ }}\` is ` + - `the formula/template-field dialect, not flow node values.`, + `the template dialect of the text slots only — a notify \`title\` / \`message\`, a screen ` + + `\`title\` / \`description\`, an \`end\` \`message\`.`, rule: FLOW_DOUBLE_BRACE_INTERP, }); } @@ -1732,6 +1758,21 @@ export function lintFlowPatterns(stack: AnyRec): FlowLintFinding[] { }); } } + // [#22110] The text slots' own bare-`$` check: read OUTSIDE their + // `{{ }}` holes, where a `$name.path` is the hole's correct content. + for (const slot of flowNodeTextSlotSources(node.type, node.config)) { + const outsideHoles = slot.source.replace(/\{\{[^}]*\}\}/g, ''); + if (BARE_DOLLAR_REF.test(outsideHoles)) { + findings.push({ + where: nodeWhere, + message: `\`${slot.source.trim().slice(0, 80)}\` looks like a reference written as a literal — a bare \`$ref.field\` in the ${slot.label} is NOT rendered.`, + hint: + `Write it as a hole: \`{{ $ref.field }}\` (e.g. \`{{ $error.message }}\`) — a text slot renders only ` + + `\`{{ }}\` holes, and all other text is literal.`, + rule: FLOW_BARE_DOLLAR_REF, + }); + } + } } // (c) ADR-0044 — approval send-back-for-revision loop footguns. diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts index 57a26e78165..70efd178ef6 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -91,6 +91,8 @@ import { predicateSlotRefusal, resolveFlowNodeExpressions, flowNodeValueTemplateRefusals, + flowNodeTextSlotSources, + textSlotTemplateRefusal, structuralConditionRefusal, } from '@objectstack/spec/automation'; // [#15137] The `value`-role half. Same two published primitives the engine @@ -1751,6 +1753,25 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression severity: 'error', }); } + // [#22110] The TEXT slots (a notify `title` / `message`, a screen + // `title` / `description`, an `end` `message`) render ADR-0032 §3's + // `{{ }}` holes. The same two checks `registerFlow` runs on the same + // config, in the same order: a single-brace token left from the 17.x + // dialect is refused with its hole spelling (the spec's one judge), + // and a slot carrying none is compiled as the `template` it is — a + // hole holding logic or an unknown formatter is an `error` here, not a + // throw at the node mid-run. + for (const slot of flowNodeTextSlotSources(nodeType, cfg)) { + const slotWhere = `${at} · node '${node.id}' (${nodeType}) ${slot.label} at config.${slot.path}`; + const tokenRefusal = textSlotTemplateRefusal(slot.source); + if (tokenRefusal !== undefined) { + issues.push({ where: slotWhere, message: tokenRefusal, source: slot.source, severity: 'error' }); + continue; + } + for (const e of validateExpression('template', slot.source).errors) { + issues.push({ where: slotWhere, message: e.message, source: e.source, severity: 'error' }); + } + } // #1870 — a `script` node must name a callable, and since #4343 that is // the whole of what the node does: `config.function`. A node without one // is a silent no-op that otherwise passes build. (Function *existence* diff --git a/packages/services/service-automation/src/builtin/notify-node.ts b/packages/services/service-automation/src/builtin/notify-node.ts index f479340939e..812c38ad6cc 100644 --- a/packages/services/service-automation/src/builtin/notify-node.ts +++ b/packages/services/service-automation/src/builtin/notify-node.ts @@ -5,7 +5,7 @@ import type { AutomationContext } from '@objectstack/spec/contracts'; import { defineActionDescriptor, NotifyConfigSchema } from '@objectstack/spec/automation'; import type { NotifyConfigParsed } from '@objectstack/spec/automation'; import type { AutomationEngine } from '../engine.js'; -import { interpolate, stringifyForTemplate, type VariableMap } from './template.js'; +import { interpolate, renderTextSlot, type VariableMap } from './template.js'; import { parseNodeConfig } from './parse-config.js'; /** @@ -187,11 +187,11 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext) // a text box: it is the authoring surface, not the contract. title: { type: 'string', - 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.', + description: 'Notification title, rendered per run with {{ }} placeholders: a variable path with an optional formatter (e.g. {{ record.name }}, {{ record.amount | currency }}). A single-brace {token} is refused. Not localizable — use template for per-locale content. Either this or template is required; mutually exclusive with template.', }, message: { type: 'string', - description: 'Notification body, interpolated per run with {token} placeholders like title. Not localizable. Only valid with inline title, never with template.', + description: 'Notification body, rendered per run with {{ }} placeholders like title. Not localizable. Only valid with inline title, never with template.', }, // ── Localizable content path (#9205) ───────────────────── // Mirrors `NotifyConfigSchema.template`/`templateData`; the @@ -256,8 +256,9 @@ 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- or template-typed, - // so `{token}` templates pass; the post-interpolation guards below still own + // rendering — the contract's slots are string- or template-typed, so + // templates pass (and a single-brace token in `title` / `message` is + // refused, #22110); the post-rendering 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); @@ -266,20 +267,21 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext) const recipientCfg = cfg.recipients ?? []; const recipients = toStringList(interpolate(recipientCfg, variables, context)); - // 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. - // - // `title`/`message` are template slots (`TemplateExpressionInputSchema`): - // the parse above normalizes a bare string to the + // `title`/`message` are template slots (`templateExpressionInput`, + // the input `TemplateExpressionInputSchema` is built from): 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)); + // cases and the text is its `source` — never the envelope itself. + // The contract also refuses an envelope whose `source` is absent or + // blank, so a present slot always has text. + // + // [#22110] Rendered through the ONE text renderer the screen and + // `end` text share — the formula template engine's `{{ }}` holes + // (ADR-0032 D3). An object a hole reaches (`{{ $error }}`) renders as + // JSON, never `[object Object]` (#3450). + const title = renderTextSlot(cfg.title?.source, variables) ?? ''; + const body = renderTextSlot(cfg.message?.source, variables) ?? ''; // #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/screen-nodes.ts b/packages/services/service-automation/src/builtin/screen-nodes.ts index 3f29f606d59..f0b6d99ddfb 100644 --- a/packages/services/service-automation/src/builtin/screen-nodes.ts +++ b/packages/services/service-automation/src/builtin/screen-nodes.ts @@ -4,7 +4,7 @@ import type { PluginContext } from '@objectstack/core'; import { defineActionDescriptor, ScreenConfigSchema, ScriptConfigSchema } from '@objectstack/spec/automation'; import type { ScreenConfigParsed, ScriptConfigParsed } from '@objectstack/spec/automation'; import type { AutomationEngine } from '../engine.js'; -import { interpolate, interpolateText } from './template.js'; +import { interpolate, renderTextSlot } from './template.js'; import { parseNodeConfig } from './parse-config.js'; import { judgeHeadlessScreen } from '../screen-input-contract.js'; @@ -158,17 +158,23 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext const parsedCfg = parseNodeConfig('screen', node.id, ScreenConfigSchema, node.config); if (!parsedCfg.ok) return parsedCfg.refusal; const cfg = parsedCfg.config; - // `{var}` tokens in screen config resolve against the live flow - // variables here (the engine does NOT pre-interpolate node config) — so - // a step's title/description/field-default/object-form-default can pull - // from prior nodes (e.g. `{lead_record.company}`, `{account_id}`). + // Screen config resolves against the live flow variables here (the + // engine does NOT pre-interpolate node config) — so a step's + // title/description/record id/field-default/object-form-default can pull + // from prior nodes. // - // [#15788] The body of this closure now lives in `template.ts` as - // {@link interpolateText}, because a second authored-text slot — the - // refusing `end` node's `message` (#14945 lane 2) — has to render - // through THE SAME implementation, not a copy of it. Same bytes in, - // same bytes out; the only change is where the four lines live. - const interp = (v: unknown): string | undefined => interpolateText(v, variables, context); + // [#22110] The two TEXT slots, `title` and `description`, render + // `{{ }}` holes (`{{ lead_record.company }}`) through + // {@link renderTextSlot} — the ONE text renderer the refusing `end` + // node's `message` and a notification share (#15788: never a copy). + // The rest keep the single-brace dialect (`{account_id}`): `recordId` + // names a record and `defaults` / a field's `defaultValue` hand their + // resolved values over, not text. + const text = (v: unknown): string | undefined => renderTextSlot(v, variables); + const ref = (v: unknown): string | undefined => { + const resolved = interpolate(v, variables, context); + return resolved == null ? undefined : String(resolved); + }; // ── Object-form screen (master-detail wizards) ────────────────────── // When the step names an `objectName`, render that object's FULL @@ -192,11 +198,11 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext screen: { nodeId: node.id, kind: 'object-form', - title: interp(cfg.title) ?? node.label ?? objectName, - description: interp(cfg.description), + title: text(cfg.title) ?? node.label ?? objectName, + description: text(cfg.description), objectName, mode: cfg.mode === 'edit' ? 'edit' : 'create', - recordId: cfg.recordId != null ? interp(cfg.recordId) : undefined, + recordId: cfg.recordId != null ? ref(cfg.recordId) : undefined, defaults, idVariable, fields: [], @@ -276,8 +282,8 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext suspend: true, screen: { nodeId: node.id, - title: interp(cfg.title) ?? node.label ?? 'Input', - description: interp(cfg.description), + title: text(cfg.title) ?? node.label ?? 'Input', + description: text(cfg.description), fields, }, }; diff --git a/packages/services/service-automation/src/builtin/template.ts b/packages/services/service-automation/src/builtin/template.ts index 4db5ac50af0..db37cb84f0f 100644 --- a/packages/services/service-automation/src/builtin/template.ts +++ b/packages/services/service-automation/src/builtin/template.ts @@ -31,16 +31,21 @@ * and nothing said why. * * The interpolator walks objects, arrays, and primitives recursively so it - * can be applied wholesale to a node's `config.filter` block and its text - * slots. The value slots (`fields.*`, `assignments.*`) no longer read this - * dialect (#19939): a `{…}` token there is refused before it gets here, except - * the date macros and `$User` paths, which CEL cannot spell yet - * (`@objectstack/spec/automation`'s `flow-value-slot-template.ts`). + * can be applied wholesale to a node's `config.filter` block and its other + * value-like positions. The value slots (`fields.*`, `assignments.*`) no + * longer read this dialect (#19939): a `{…}` token there is refused before it + * gets here, except the date macros and `$User` paths, which CEL cannot spell + * yet (`@objectstack/spec/automation`'s `flow-value-slot-template.ts`). Nor do + * the TEXT slots — a notify `title` / `message`, a screen `title` / + * `description`, a refusing `end` node's `message` (#22110, ADR-0032 D3): + * they render ADR-0032 §3's `{{ }}` holes through the formula template engine, + * via {@link renderTextSlot} below, and a `{…}` token there is refused + * (`flow-text-slot-template.ts`). */ import type { AutomationContext } from '@objectstack/spec/contracts'; import { isKnownFilterToken } from '@objectstack/spec/data'; -import { nearestName } from '@objectstack/formula'; +import { nearestName, templateEngine } from '@objectstack/formula'; import { markGuardRefusal } from '../guard-refusal.js'; export type VariableMap = Map; @@ -366,33 +371,103 @@ export function interpolateString( } /** - * Render an authored TEXT slot — a screen `title` / `description`, an `end` - * node's refusal `message` — through {@link interpolate}, coerced to a string. + * A text slot's `{{ }}` template that does not compile (#22110) — a hole + * holding logic or an unknown formatter (`{{ a + b }}`, `{{ x | bogus }}`), or + * unbalanced delimiters. The doors (`registerFlow`, `objectstack validate`) + * compile every text slot first, so this is met only by a flow that reached the + * executor without them. A guard refusal (#3863): the metadata is wrong, + * re-running the flow unchanged can never succeed, and a `fault` edge must not + * swallow it into a notification sent with its holes unfilled. + */ +export class FlowTextTemplateError extends Error { + /** The template text as authored. */ + readonly source: string; + + constructor(source: string, message: string) { + super(message); + this.name = 'FlowTextTemplateError'; + this.source = source; + markGuardRefusal(this); + } +} + +/** + * The flow's variables as the scope a text slot's `{{ }}` holes read — each + * variable a root (`{{ record.name }}`, `{{ summary }}`, `{{ $error.message }}`), + * and a node output stored under a flat dotted key (`n1.result`) reachable as + * the path it spells (`{{ n1.result }}`). + * + * The nesting follows the interpolator's precedence: a declared variable wins + * over a flat key sharing its head (`{a.b}` walked `a`'s value and never read a + * flat `a.b`), so such a key is not merged into the variable. And it builds + * fresh containers only — a variable's own object is never written into. + */ +export function textTemplateScope(variables: VariableMap): Record { + const scope: Record = {}; + const fresh = new WeakSet(); + const dotted: Array<[string, unknown]> = []; + for (const [key, value] of variables) { + if (key.includes('.')) dotted.push([key, value]); + else scope[key] = value; + } + for (const [key, value] of dotted) { + const segments = key.split('.'); + let cursor: Record | undefined = scope; + for (let i = 0; i < segments.length - 1 && cursor; i++) { + const next: unknown = cursor[segments[i]!]; + if (next === undefined) { + const container: Record = {}; + fresh.add(container); + cursor[segments[i]!] = container; + cursor = container; + } else { + cursor = typeof next === 'object' && next !== null && fresh.has(next) + ? (next as Record) + : undefined; + } + } + if (cursor) cursor[segments[segments.length - 1]!] = value; + } + return scope; +} + +/** + * Render a flow TEXT slot — a notify `title` / `message`, a screen `title` / + * `description`, a refusing `end` node's `message` — through the formula + * template engine (#22110, ADR-0032 D3): `{{ path }}` and + * `{{ path | formatter[:arg] }}` holes over {@link textTemplateScope}, every + * other character literal. ONE renderer for every text slot — ⛔ never a second + * template engine: the refusing `end` node's message "goes through the same + * interpolation a screen `description` gets" (#15788), and so does a + * notification. * - * [#15788] Hoisted out of `screen-nodes.ts`'s local `interp` closure so the - * refusing `end` node (#14945 lane 2) renders through the SAME implementation - * rather than a second one. The ruling's words are the requirement: the - * refusal message "goes through the same interpolation a screen `description` - * gets" — ⛔ never a second template engine. A second spelling would start - * byte-identical and drift on the first fix that landed in only one of them, - * and the drift would be invisible from either side: both would still - * substitute `{record.name}`. + * Value→string is the engine's, defined per value (ADR-0032 §3): `null` / + * absent renders nothing, an object or array renders as JSON, a `Date` as its + * ISO text, and a formatter makes the rest explicit (`{{ amount | currency }}`, + * `{{ due | date:iso }}`). A flow variable named `locale` is also the + * formatters' locale — the engine reads its scope's `locale` for that. * - * Absent in, absent out — a slot the author left unset renders nothing rather - * than the string `"undefined"`, and a whole-string token that resolved to - * `null` is the same "nothing" ({@link interpolateString} preserves the raw - * value for a single-token string, so an unresolved `{missing}` arrives here as - * `null`). Every other value is stringified exactly as an embedded - * substitution would be, which is what keeps ONE rendering for both slots. + * Absent in, absent out: a slot the author left unset — or one that renders + * no text at all — returns `undefined`, so a screen falls back to its node + * label and a notification with no title fails its own guard. + * + * A template that does not compile throws {@link FlowTextTemplateError}, a + * guard refusal. A single-brace `{token}` is NOT read here: it is refused by + * every door before a run starts (`flow-text-slot-template.ts`), and reaching + * this renderer it is literal text. */ -export function interpolateText( - value: unknown, - variables: VariableMap, - context: AutomationContext, -): string | undefined { +export function renderTextSlot(value: unknown, variables: VariableMap): string | undefined { if (value == null) return undefined; - const rendered = interpolate(value, variables, context); - return rendered == null ? undefined : String(rendered); + const source = String(value); + const result = templateEngine.evaluate( + { dialect: 'template', source }, + { extra: textTemplateScope(variables) }, + ); + if (!result.ok) { + throw new FlowTextTemplateError(source, `flow text template \`${source}\`: ${result.error.message}`); + } + const text = String(result.value); + return text === '' ? undefined : text; } /** @@ -438,9 +513,9 @@ export function interpolate( * when both could match, and a token belonging to neither dialect is left to * the caller's collapse guard to report. * - * Only filter blocks use this. Everywhere else (`title`, `message`, `fields`, - * `url`) keeps plain {@link interpolate}, where a bare `{current_year_start}` - * is a nonsense reference rather than a query bound. + * Only filter blocks use this. Every other single-brace position (`url`, + * `recipients`, …) keeps plain {@link interpolate}, where a bare + * `{current_year_start}` is a nonsense reference rather than a query bound. */ export function interpolateFilter( value: T, diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index ab35572c826..6d7681f13d6 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -48,6 +48,10 @@ import { FlowValueSlotSchema, VALUE_ENVELOPE_REFUSAL } from '@objectstack/spec/a // judge, shared with `objectstack validate` (`@objectstack/lint` calls the same // function on the same config), so build and registration give one verdict. import { flowNodeValueTemplateRefusals } from '@objectstack/spec/automation'; +// [#22110] The flow TEXT slots read ADR-0032 §3's `{{ }}` holes — the one judge +// of the single-brace tokens they may still carry, and the one list of where +// those slots are, both shared with `objectstack validate`. +import { flowNodeTextSlotSources, textSlotTemplateRefusal } from '@objectstack/spec/automation'; // [#17322] The EVALUATED-slot rule, IMPORTED rather than re-derived. It is the // rule `FlowEdgeSchema.condition` already composes since #15807, so a node's // `config.condition` — which no schema stands in front of — is held to the same @@ -232,7 +236,7 @@ import { describeThrownForLog } from './thrown-cause-diagnostics.js'; // `./builtin/` is safe in this direction: `template.ts` imports only // `../guard-refusal.js` and package-external contracts, so nothing it pulls in // reaches back here. -import { interpolateText } from './builtin/template.js'; +import { renderTextSlot } from './builtin/template.js'; /** * Does this `decision` take EVERY out-edge whose condition holds (#15429)? @@ -10684,6 +10688,26 @@ export class AutomationEngine implements IAutomationService { ); } + // [#22110] The TEXT slots (a notify `title` / `message`, a screen + // `title` / `description`, an `end` `message`) render ADR-0032 + // §3's `{{ }}` holes. A single-brace token left from the 17.x + // dialect is refused with its hole spelling (the spec's one + // judge — the node contracts refuse the same set at parse), and + // a slot carrying none is compiled as the template it is, so a + // hole holding logic or an unknown formatter stops the flow here + // instead of throwing at the node mid-run. + for (const slot of flowNodeTextSlotSources(node.type, node.config)) { + const slotWhere = `${at}node '${node.id}' (${node.type}) ${slot.label} at config.${slot.path}`; + const tokenRefusal = textSlotTemplateRefusal(slot.source); + if (tokenRefusal !== undefined) { + failures.push(` • ${slotWhere}: ${tokenRefusal}\n source: \`${slot.source}\``); + continue; + } + for (const e of validateExpression('template', slot.source).errors) { + failures.push(` • ${slotWhere}: ${e.message}\n source: \`${slot.source}\``); + } + } + // Descriptor-declared expression slots (#4027). The ledger names them // per node type and carries the dialect each one takes, so a declared // key like `screen.fields[].visibleWhen` is checked as the bare CEL it @@ -10936,12 +10960,13 @@ export class AutomationEngine implements IAutomationService { if (endConfig?.outcome === 'refused') { throw new FlowRefusalSignal( node.id, - // The SAME interpolation a screen `description` gets — the - // ruling's words, one implementation (`interpolateText`). + // The SAME rendering a screen `description` gets — the + // ruling's words, one implementation (`renderTextSlot`, the + // `{{ }}` text renderer since #22110). // Rendered HERE, against the live variable map, because // that is what makes the text per-record; a template on the // wire would put the rendering in every runner. - interpolateText(endConfig.message, variables, context), + renderTextSlot(endConfig.message, variables), ); } return; diff --git a/packages/spec/src/automation/builtin-node-config.zod.ts b/packages/spec/src/automation/builtin-node-config.zod.ts index 75508b6f5c6..fbbfe745684 100644 --- a/packages/spec/src/automation/builtin-node-config.zod.ts +++ b/packages/spec/src/automation/builtin-node-config.zod.ts @@ -96,6 +96,10 @@ import { isExpressionEnvelopeShaped } from './flow-node-expression-paths'; // one judge, composed into every value slot's contract below rather than // re-spelled here. import { valueSlotTemplateRefusals } from './flow-value-slot-template'; +// [#22110] ADR-0032 §3's `{{ }}` delimiter in the text slots — the one judge of +// the single-brace tokens they still carry, composed into the screen and end +// contracts below rather than re-spelled here. +import { textSlotTemplateRefusal } from './flow-text-slot-template'; /** What a rejected key on these contracts silently did before #4001 批 9. */ const BUILTIN_NODE_CONFIG_HISTORY = @@ -739,6 +743,14 @@ export type ScreenFieldConfig = z.input; * screen (`objectName` set → the object's full create/edit form; `mode`, * `recordId`, `defaults`, `idVariable` apply only there). `recordId` is what * makes `mode: 'edit'` usable — it names the record the form edits. + * + * `title` and `description` are TEXT slots (#22110, ADR-0032 D3): they render + * through the formula template engine, so their placeholders are `{{ }}` + * holes over the flow's variables (`{{ record.name }}`), and a single-brace + * `{token}` left from the 17.x dialect is refused by the `superRefine` below — + * the one text-slot judge (`flow-text-slot-template.ts`) `registerFlow` and + * `objectstack validate` share. `recordId` and `defaults` keep the flow's + * single-brace `{token}` dialect: each hands its resolved value over, not text. */ export const ScreenConfigSchema = lazySchema(() => strictObject({ surface: 'this screen node config', @@ -751,10 +763,12 @@ export const ScreenConfigSchema = lazySchema(() => strictObject({ // be said out loud. aliases: { object: 'objectName' }, }, { - /** Heading (falls back to the node label). Interpolates `{token}`. */ - title: z.string().optional().describe('Heading shown above the screen'), - /** Body text. Interpolates `{token}`. */ - description: z.string().optional().describe('Body text shown under the heading'), + /** Heading (falls back to the node label when it renders nothing). A `{{ }}` template. */ + title: z.string().optional() + .describe('Heading shown above the screen — a template rendered per run with `{{ }}` holes over the flow\'s variables (`{{ record.name }}`); falls back to the node label when it renders nothing'), + /** Body text. A `{{ }}` template. */ + description: z.string().optional() + .describe('Body text shown under the heading — a template rendered per run with `{{ }}` holes (`{{ record.name }}`)'), /** Input fields for a flat screen; empty means message-only. */ fields: z.array(ScreenFieldConfigSchema).optional() .describe('Input fields collected on this screen'), @@ -799,6 +813,15 @@ export const ScreenConfigSchema = lazySchema(() => strictObject({ /** Object form only: prefilled values; interpolates `{token}` templates. */ defaults: z.record(z.string(), z.unknown()).optional() .describe('Object form only: prefilled values'), +}).superRefine((config, ctx) => { + // [#22110] The two text slots render `{{ }}` holes; a single-brace token is + // no placeholder any more and would show as literal text. + for (const key of ['title', 'description'] as const) { + const value = config[key]; + if (typeof value !== 'string') continue; + const refusal = textSlotTemplateRefusal(value); + if (refusal !== undefined) ctx.addIssue({ code: 'custom', path: [key], message: refusal }); + } })); export type ScreenConfig = z.input; @@ -842,10 +865,13 @@ export type ScreenConfigParsed = z.infer; * * ## `message` — required by a refusal, refused by a completion * - * `message` is a `{token}` template rendered at the engine's existing - * interpolation points, exactly as a `screen` node's `description` is — - * `'Refused: {record.name} is a confirmed duplicate'` yields per-record text at - * run time. Two refinements keep the pair honest, in both directions: + * `message` is a `{{ }}` template rendered by the engine's one text renderer, + * exactly as a `screen` node's `description` is — + * `'Refused: {{ record.name }} is a confirmed duplicate'` yields per-record text + * at run time (#22110, ADR-0032 D3: a single-brace `{token}` left from the 17.x + * dialect is refused, through the one text-slot judge, + * `flow-text-slot-template.ts`). Two refinements keep the pair honest, in both + * directions: * * - `outcome: 'refused'` with no `message` is REFUSED — a refusal without * text is exactly the shape this contract exists to make expressible, and @@ -882,11 +908,12 @@ export const EndConfigSchema = lazySchema(() => strictObject({ + 'successful evaluation that says no — carries the rendered `message`, is never resumed, and a runner shows ' + 'the message with Close only: no Submit, no completion toast.', ), - /** Why the run was refused. Interpolates `{token}` like a screen `description`. */ + /** Why the run was refused. A `{{ }}` template, rendered like a screen `description`. */ message: z.string().min(1).optional().describe( - 'Why the run was refused, as a `{token}` template interpolated at run time exactly like a screen ' - + '`description` (`{record.name}` etc.), so the text names the record. Required when `outcome` is `refused`; ' - + 'refused when it is `completed` — a completion renders nothing, so the key would be a silent no-op.', + 'Why the run was refused, as a template rendered at run time exactly like a screen `description` — `{{ }}` ' + + 'holes over the flow\'s variables (`{{ record.name }}`), so the text names the record. Required when ' + + '`outcome` is `refused`; refused when it is `completed` — a completion renders nothing, so the key would be ' + + 'a silent no-op.', ), }).superRefine((config, ctx) => { if (config.outcome === 'refused') { @@ -896,10 +923,14 @@ export const EndConfigSchema = lazySchema(() => strictObject({ path: ['message'], message: "`outcome: 'refused'` requires a `message` — a refusal with no text is the shape this contract exists to " - + 'replace (a screen pretending to be a notice). Say why, as a `{token}` template so the text names the ' - + "record: `message: 'Refused: {record.name} is a confirmed duplicate'`.", + + 'replace (a screen pretending to be a notice). Say why, as a `{{ }}` template so the text names the ' + + "record: `message: 'Refused: {{ record.name }} is a confirmed duplicate'`.", }); + return; } + // [#22110] A text slot: `{{ }}` holes, never a single-brace token. + const refusal = textSlotTemplateRefusal(config.message); + if (refusal !== undefined) ctx.addIssue({ code: 'custom', path: ['message'], message: refusal }); return; } if (config.message !== undefined) { diff --git a/packages/spec/src/automation/flow-template-token.ts b/packages/spec/src/automation/flow-template-token.ts new file mode 100644 index 00000000000..0741344afb3 --- /dev/null +++ b/packages/spec/src/automation/flow-template-token.ts @@ -0,0 +1,93 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * @module automation/flow-template-token + * + * **The single-brace `{…}` token grammar of the 17.x flow interpolator** — the + * one reading of it the spec carries, shared by the two judges that retire it: + * `flow-value-slot-template.ts` (the value slots, #19939) and + * `flow-text-slot-template.ts` (the text slots, #22110). + * + * Which `{…}` the interpolator substitutes (`/\{([^{}]+)\}/`), and how it + * dispatches a token — date macro, then `$User.`, then a variable path, then + * arithmetic — are the regexes of `resolveToken` in `service-automation`'s + * `builtin/template.ts`, copied here because the spec cannot import a + * runtime. `value-slot-template-grammar.test.ts` in that package drives the + * interpolator over the kept spellings and the refused ones, so the two + * readings cannot drift apart unnoticed. + * + * ## ⛔ Package-internal — NOT a public export + * + * Deliberately absent from `automation/index.ts`: its callers are the two + * judges above, and a published copy of a retired dialect's grammar would be a + * new public surface for a spelling the platform is deleting. + */ + +/** The interpolator's token — `interpolateString`'s `/\{([^{}]+)\}/g`. */ +export const TEMPLATE_TOKEN = /\{([^{}]+)\}/g; + +/** `resolveToken`'s `dateFnMatch`, verbatim: `NOW()` / `TODAY()` with an optional `± N` day offset. */ +const DATE_MACRO = /^(NOW|TODAY)\s*\(\s*\)\s*(?:([+\-])\s*(\S+))?$/; + +/** `resolveToken`'s direct-path test, verbatim: a variable and its dotted path, numeric segments included. */ +const VARIABLE_PATH = /^[A-Za-z_$][\w$]*(?:\.(?:[A-Za-z_$][\w$]*|\d+))*$/; + +/** `resolveToken`'s arithmetic character set, verbatim — outside it a token resolves to nothing. */ +const ARITHMETIC_CHARSET = /^[\w\s+\-*/%().,?:<>=!&|"'$]+$/; + +/** + * An operator, or a call — any name in call position. The dialect resolves + * only its six CEL-mirrored functions and refuses every other name at run + * time, but either way the token is an expression, and its CEL spelling is + * the same text: an unknown name is then refused by the envelope's own CEL + * check, with a did-you-mean. + */ +const EXPRESSION_SHAPE = /[+\-*/%<>=!&|?]|[A-Za-z_$][\w$.]*\s*\(/; + +/** What the interpolator does with one token — the dispatch order of `resolveToken`. */ +export type TemplateTokenKind = 'date-macro' | 'user' | 'path' | 'expression' | 'unresolvable'; + +/** Classify one token's inner text the way `resolveToken` dispatches it. */ +export function templateTokenKind(inner: string): TemplateTokenKind { + const trimmed = inner.trim(); + if (!trimmed) return 'unresolvable'; + if (DATE_MACRO.test(trimmed)) return 'date-macro'; + if (trimmed.startsWith('$User.')) return 'user'; + if (VARIABLE_PATH.test(trimmed)) return 'path'; + if (ARITHMETIC_CHARSET.test(trimmed) && EXPRESSION_SHAPE.test(trimmed)) return 'expression'; + return 'unresolvable'; +} + +/** One `{…}` token of a string, as authored. */ +export interface TemplateToken { + /** The token with its braces, as written (`{record.name}`). */ + readonly text: string; + /** The text between the braces, trimmed. */ + readonly inner: string; + /** How the interpolator dispatches it. */ + readonly kind: TemplateTokenKind; + /** Offset of {@link text} in the string. */ + readonly index: number; +} + +/** Every `{…}` token the interpolator would substitute in `value`, in order. */ +export function templateTokensOf(value: string): TemplateToken[] { + const out: TemplateToken[] = []; + for (const match of value.matchAll(TEMPLATE_TOKEN)) { + out.push({ text: match[0], inner: match[1]!.trim(), kind: templateTokenKind(match[1]!), index: match.index ?? 0 }); + } + return out; +} + +/** A template path as CEL: `a.b.0` → `a.b[0]`; a `$`-named variable is read through `vars`. */ +export function celPath(path: string): string { + const [head, ...rest] = path.split('.'); + let out = head!.startsWith('$') ? `vars["${head}"]` : head!; + for (const segment of rest) out += /^\d+$/.test(segment) ? `[${segment}]` : `.${segment}`; + return out; +} + +/** A template expression as CEL: every integer divisor written as a double, so CEL divides as the template did. */ +export function celExpression(inner: string): string { + return inner.replace(/\/\s*(\d+)(?![\d.])/g, '/ $1.0'); +} diff --git a/packages/spec/src/automation/flow-text-slot-template.ts b/packages/spec/src/automation/flow-text-slot-template.ts new file mode 100644 index 00000000000..624731cedf9 --- /dev/null +++ b/packages/spec/src/automation/flow-text-slot-template.ts @@ -0,0 +1,234 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * @module automation/flow-text-slot-template + * + * **The flow TEXT slots read ADR-0032 §3's `{{ }}` delimiter** (#22110 — + * Decision 3, "One delimiter, `{{ }}`; single `{ }` deleted", executed on the + * protocol-18 line). + * + * A text slot is a config position whose rendered value is definitionally + * text for a person to read: a `notify` node's `title` and `message`, a + * `screen` node's `title` and `description`, and a refusing `end` node's + * `message` ({@link FLOW_NODE_TEXT_SLOTS}). Until 18 the flow interpolator + * rendered them with its single-brace `{token}` dialect while the spec already + * typed the notify pair as `template` slots — the dialect whose delimiter §3 + * fixes as `{{ }}`. They now render through the formula template engine: + * `{{ path }}` and `{{ path | formatter[:arg] }}` holes over the flow's + * variables (`{{ record.name }}`, `{{ $error.message }}`, + * `{{ amount | currency }}`), never logic. + * + * This module is the ONE judge of what the slots still carry from the old + * dialect: a single-brace `{…}` token is refused, and the refusal names the + * `{{ }}` spelling of each token or, for a token no hole can spell, where to + * compute it. The contracts compose it (`NotifyConfigSchema`, + * `ScreenConfigSchema`, `EndConfigSchema`), and `AutomationEngine.registerFlow` + * and `objectstack validate` call {@link flowNodeTextTemplateRefusals} — ⛔ + * never a second reading of the dialect anywhere. Those two doors also compile + * every slot's `{{ }}` holes (`validateExpression('template', …)`), which the + * spec cannot do itself: the template engine is a runtime. + * + * ## Refused, not converted (ADR-0087 D2) + * + * D2 lets a conversion rewrite only what maps LOSSLESSLY. Every token spelling + * was rendered through the 17.x interpolator and through the template engine + * over the same variables (#22110's measurement). A path — `{x}`, `{a.b}`, + * `{list.0}`, a node output `{n1.result}` — renders the same text for a + * string, a number, a boolean, `null`, an absent key or variable, an ISO date + * string, an object or an array, but not for every input: + * + * - a `Date` value (what a CEL `now()` / `today()` assignment stores): + * the interpolator wrote it JSON-quoted (`"2026-10-08T09:30:00.000Z"`, + * quotes included), the engine writes the ISO text; + * - in a screen `title` / `description` or an `end` `message`, a slot that + * is ONE token holding an object, an array or a `Date`: the interpolator + * wrote `String(value)` (`[object Object]`, `a,b`, the locale date), the + * engine writes JSON or the ISO text; + * - a `$`-named variable (`{$error.message}`) had no `{{ }}` spelling at all + * until the engine's hole grammar admitted `$` in a name (this card). + * + * The other kinds have no hole spelling: arithmetic and function calls + * (`{amount * 2}`, `{round(x)}`) are logic, which §3 keeps out of holes; the + * date macros (`{NOW()}`, `{TODAY() + 1}`) and the run user (`{$User.Id}`) + * are not variables. So no spelling is converted: each is refused with its + * remedy, and the author re-reads the text the slot will send. + * + * ## No spelling is kept + * + * Unlike the value slots, which keep the date macros and `{$User.*}` because + * CEL cannot write them yet, a text slot keeps nothing: each of those has a + * remedy that renders the same text — compute it into a variable with an + * `assignment` node, whose value slot still reads that spelling, and write the + * variable as a hole. So the single brace is deleted from the text slots + * whole, and the two dialects never share one string. + */ + +import { celExpression, templateTokensOf, type TemplateToken } from './flow-template-token'; + +/** + * The one sentence every refusal of a `{…}` token in a text slot leads with — + * the same words in every text slot and at every door (the three node + * contracts, `registerFlow`, `objectstack validate`), so an author (or an + * agent reading the failure) meets the rule before the per-token remedy. + */ +export const TEXT_SLOT_TEMPLATE_REFUSAL = + 'A flow text slot reads `{{ }}` template holes (ADR-0032 §3), not the single-brace `{…}` dialect: a `{…}` token ' + + 'here is no placeholder any more and would be sent as literal text, so it is refused.'; + +/** One text slot of a builtin flow node — a config key whose value renders to text. */ +export interface FlowNodeTextSlot { + /** Registry node type the slot belongs to (`node.type`). */ + readonly nodeType: string; + /** The config key (`config.`). */ + readonly key: string; + /** Author-facing label for diagnostics, e.g. `notify title`. */ + readonly label: string; +} + +/** + * Every flow node text slot — the positions the template engine renders. + * + * Measured from the executors, not assumed: these are the keys whose rendered + * value is only ever used as text (`notify-node.ts` stringifies `title` / + * `message`; `screen-nodes.ts` and the engine's `end` handling render through + * one text renderer). Every OTHER string the flow interpolator reads keeps its + * single-brace dialect: a value-like position whose single token hands its + * resolved value over with its TYPE (`recipients`, `actionUrl`, `sourceId`, + * `payload`, `templateData`, a screen's `recordId` / `defaults` / field + * `defaultValue`, `subflow.input`, `script.inputs`, `map.input`, `http`, the + * `loop` / `map` `collection`, a `filter`) is not a text template, and the + * value slots (`fields.*`, `assignments.*`) are CEL's (#19939). + */ +export const FLOW_NODE_TEXT_SLOTS: readonly FlowNodeTextSlot[] = [ + { nodeType: 'notify', key: 'title', label: 'notify title' }, + { nodeType: 'notify', key: 'message', label: 'notify message' }, + { nodeType: 'screen', key: 'title', label: 'screen title' }, + { nodeType: 'screen', key: 'description', label: 'screen description' }, + { nodeType: 'end', key: 'message', label: 'end message' }, +]; + +/** + * The text a text-slot value carries — the string itself, or the `source` of a + * `{ dialect: 'template', source }` envelope (what the `tmpl` helper builds + * for the notify pair). Anything else carries no text this judge reads: the + * slot's own contract refuses its shape. + */ +export function textSlotSource(value: unknown): string | undefined { + if (typeof value === 'string') return value; + if (value !== null && typeof value === 'object' && !Array.isArray(value)) { + const envelope = value as { dialect?: unknown; source?: unknown }; + if (envelope.dialect === 'template' && typeof envelope.source === 'string') return envelope.source; + } + return undefined; +} + +/** The single-brace tokens of `text` — every `{…}` the 17.x interpolator substituted, minus the inside of a `{{ }}` hole. */ +function singleBraceTokens(text: string): TemplateToken[] { + return templateTokensOf(text).filter((token) => + !(text[token.index - 1] === '{' && text[token.index + token.text.length] === '}')); +} + +/** `text` with each single-brace PATH token written as a hole — the spelling a refusal prescribes for it. */ +function doubled(text: string, tokens: readonly TemplateToken[]): string { + let out = ''; + let at = 0; + for (const token of tokens) { + out += text.slice(at, token.index) + (token.kind === 'path' ? `{{ ${token.inner} }}` : token.text); + at = token.index + token.text.length; + } + return out + text.slice(at); +} + +/** The remedy for one token no `{{ }}` hole can spell. */ +function unspellableRemedy(token: TemplateToken): string { + switch (token.kind) { + case 'date-macro': + case 'user': + return ( + `\`${token.text}\` is not a variable, so no hole spells it: compute it into a variable with an \`assignment\` ` + + `node, whose value slot still reads it (\`assignments: { v: '${token.text}' }\`), and write \`{{ v }}\` here.` + ); + case 'expression': + return ( + `\`${token.text}\` is logic, and a hole is a variable path with an optional formatter (\`{{ v | number:2 }}\`), ` + + 'never logic: compute it into a variable with an `assignment` node\'s CEL value envelope ' + + `(\`assignments: { v: { dialect: 'cel', source: ${JSON.stringify(celExpression(token.inner))} } }\`) and ` + + 'write `{{ v }}` here.' + ); + default: + return ( + `\`${token.text}\` is neither a variable path nor an expression, and the 17.x renderer wrote nothing in its ` + + 'place: delete it (a text slot has no escape for literal braces).' + ); + } +} + +/** + * Why `text` — a text slot's template — is refused, or `undefined` when it + * carries no single-brace token. The message leads with + * {@link TEXT_SLOT_TEMPLATE_REFUSAL}, then names the `{{ }}` spelling of every + * path token (the whole text rewritten) and the remedy for every token no hole + * can spell. + */ +export function textSlotTemplateRefusal(text: string): string | undefined { + const tokens = singleBraceTokens(text); + if (tokens.length === 0) return undefined; + const parts: string[] = []; + if (tokens.some((token) => token.kind === 'path')) { + parts.push(`Write \`${text}\` as \`${doubled(text, tokens)}\`.`); + } + const seen = new Set(); + for (const token of tokens) { + if (token.kind === 'path' || seen.has(token.text)) continue; + seen.add(token.text); + parts.push(unspellableRemedy(token)); + } + return `${TEXT_SLOT_TEMPLATE_REFUSAL} ${parts.join(' ')}`; +} + +/** One text slot present in a node's config, with the text it carries. */ +export interface FlowNodeTextSlotSource { + /** Path into `node.config` — the slot's key. */ + readonly path: string; + /** The slot, as a door names it — `notify title`. */ + readonly label: string; + /** The template text. */ + readonly source: string; +} + +/** + * The text slots one node's `config` carries, with their template text — what + * a door compiles as a `template` (`validateExpression('template', …)`) and + * what {@link flowNodeTextTemplateRefusals} judges. A slot that is absent, or + * whose value is neither a string nor a template envelope, is skipped. + */ +export function flowNodeTextSlotSources(nodeType: string, config: unknown): FlowNodeTextSlotSource[] { + if (config === null || typeof config !== 'object' || Array.isArray(config)) return []; + const out: FlowNodeTextSlotSource[] = []; + for (const slot of FLOW_NODE_TEXT_SLOTS) { + if (slot.nodeType !== nodeType) continue; + const source = textSlotSource((config as Record)[slot.key]); + if (source !== undefined) out.push({ path: slot.key, label: slot.label, source }); + } + return out; +} + +/** One refused text slot in a node's config, located for a door's report. */ +export interface FlowNodeTextTemplateRefusal extends FlowNodeTextSlotSource { + /** {@link TEXT_SLOT_TEMPLATE_REFUSAL}, then the remedy. */ + readonly message: string; +} + +/** + * Every single-brace refusal in one node's `config` — the call the build door + * (`objectstack validate`) and `AutomationEngine.registerFlow` share, so the + * two give one verdict, and the one the three node contracts give at parse. + */ +export function flowNodeTextTemplateRefusals(nodeType: string, config: unknown): FlowNodeTextTemplateRefusal[] { + const out: FlowNodeTextTemplateRefusal[] = []; + for (const slot of flowNodeTextSlotSources(nodeType, config)) { + const message = textSlotTemplateRefusal(slot.source); + if (message !== undefined) out.push({ ...slot, message }); + } + return out; +} diff --git a/packages/spec/src/automation/flow-value-slot-template.ts b/packages/spec/src/automation/flow-value-slot-template.ts index 9c164afcc48..46a4666aea2 100644 --- a/packages/spec/src/automation/flow-value-slot-template.ts +++ b/packages/spec/src/automation/flow-value-slot-template.ts @@ -62,16 +62,22 @@ * * ## The token grammar is the interpolator's * - * Which `{…}` the interpolator substitutes (`/\{([^{}]+)\}/`), and how it - * dispatches a token — date macro, then `$User.`, then a variable path, then - * arithmetic — are the regexes of `resolveToken` in `service-automation`'s - * `builtin/template.ts`, copied here because the spec cannot import a - * runtime. `value-slot-template-grammar.test.ts` in that package drives the - * interpolator over the kept spellings and the refused ones, so the two - * readings cannot drift apart unnoticed. + * Which `{…}` the interpolator substitutes, and how it dispatches a token, is + * read in ONE place, `flow-template-token.ts` — shared with the text-slot + * judge (`flow-text-slot-template.ts`, #22110), so the two retirements cannot + * read the dialect two ways. */ import { isExpressionEnvelopeShaped, resolveFlowNodeValueSlots } from './flow-node-expression-paths'; +import { + TEMPLATE_TOKEN, + celExpression, + celPath, + templateTokenKind as tokenKind, + templateTokensOf as tokensOf, + type TemplateToken as Token, + type TemplateTokenKind as TokenKind, +} from './flow-template-token'; /** * The one sentence every refusal of a `{…}` token in a value slot leads with — @@ -84,71 +90,9 @@ export const VALUE_SLOT_TEMPLATE_REFUSAL = + '`{…}` token in it is refused rather than stored with its braces. Compute the value with a CEL value envelope, ' + '`{ dialect: \'cel\', source: \'…\' }`.'; -/** The interpolator's token — `interpolateString`'s `/\{([^{}]+)\}/g`. */ -const TEMPLATE_TOKEN = /\{([^{}]+)\}/g; - -/** `resolveToken`'s `dateFnMatch`, verbatim: `NOW()` / `TODAY()` with an optional `± N` day offset. */ -const DATE_MACRO = /^(NOW|TODAY)\s*\(\s*\)\s*(?:([+\-])\s*(\S+))?$/; - -/** `resolveToken`'s direct-path test, verbatim: a variable and its dotted path, numeric segments included. */ -const VARIABLE_PATH = /^[A-Za-z_$][\w$]*(?:\.(?:[A-Za-z_$][\w$]*|\d+))*$/; - -/** `resolveToken`'s arithmetic character set, verbatim — outside it a token resolves to nothing. */ -const ARITHMETIC_CHARSET = /^[\w\s+\-*/%().,?:<>=!&|"'$]+$/; - -/** - * An operator, or a call — any name in call position. The dialect resolves - * only its six CEL-mirrored functions and refuses every other name at run - * time, but either way the token is an expression, and its CEL spelling is - * the same text: an unknown name is then refused by the envelope's own CEL - * check, with a did-you-mean. - */ -const EXPRESSION_SHAPE = /[+\-*/%<>=!&|?]|[A-Za-z_$][\w$.]*\s*\(/; - -/** What the interpolator does with one token — the dispatch order of `resolveToken`. */ -type TokenKind = 'date-macro' | 'user' | 'path' | 'expression' | 'unresolvable'; - -function tokenKind(inner: string): TokenKind { - const trimmed = inner.trim(); - if (!trimmed) return 'unresolvable'; - if (DATE_MACRO.test(trimmed)) return 'date-macro'; - if (trimmed.startsWith('$User.')) return 'user'; - if (VARIABLE_PATH.test(trimmed)) return 'path'; - if (ARITHMETIC_CHARSET.test(trimmed) && EXPRESSION_SHAPE.test(trimmed)) return 'expression'; - return 'unresolvable'; -} - /** The kinds CEL cannot spell yet, kept until it can (see the module docblock). */ const KEPT_KINDS: ReadonlySet = new Set(['date-macro', 'user']); -/** One `{…}` token of a string, as authored. */ -interface Token { - readonly text: string; - readonly inner: string; - readonly kind: TokenKind; -} - -function tokensOf(value: string): Token[] { - const out: Token[] = []; - for (const match of value.matchAll(TEMPLATE_TOKEN)) { - out.push({ text: match[0], inner: match[1]!.trim(), kind: tokenKind(match[1]!) }); - } - return out; -} - -/** A template path as CEL: `a.b.0` → `a.b[0]`; a `$`-named variable is read through `vars`. */ -function celPath(path: string): string { - const [head, ...rest] = path.split('.'); - let out = head!.startsWith('$') ? `vars["${head}"]` : head!; - for (const segment of rest) out += /^\d+$/.test(segment) ? `[${segment}]` : `.${segment}`; - return out; -} - -/** A template expression as CEL: every integer divisor written as a double, so CEL divides as the template did. */ -function celExpression(inner: string): string { - return inner.replace(/\/\s*(\d+)(?![\d.])/g, '/ $1.0'); -} - /** A CEL single-quoted string literal. */ function celString(text: string): string { return `'${text.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`; diff --git a/packages/spec/src/automation/index.ts b/packages/spec/src/automation/index.ts index 2db0ad53106..2713a3c4b91 100644 --- a/packages/spec/src/automation/index.ts +++ b/packages/spec/src/automation/index.ts @@ -80,5 +80,9 @@ export * from './flow-node-config-refusals'; // judge `FlowValueSlotSchema`, `registerFlow`, `objectstack validate` and the // executors share. export * from './flow-value-slot-template'; +// [#22110] ADR-0032 §3's `{{ }}` delimiter in the flow TEXT slots — the one +// judge of the single-brace `{…}` tokens they still carry, shared by the three +// node contracts, `registerFlow` and `objectstack validate`. +export * from './flow-text-slot-template'; export * from './bpmn-interop.zod'; export * from './bpmn-mapping'; diff --git a/packages/spec/src/automation/io-node-config.zod.ts b/packages/spec/src/automation/io-node-config.zod.ts index 2415f15f161..93f9cf7da49 100644 --- a/packages/spec/src/automation/io-node-config.zod.ts +++ b/packages/spec/src/automation/io-node-config.zod.ts @@ -22,9 +22,10 @@ * 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 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 + * config — its slots are string-typed or template-typed, so templates pass + * (`{{ }}` holes in its two text slots, `{token}` in the rest) and the + * post-rendering 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. * @@ -61,6 +62,7 @@ import { strictObject } from '../shared/strict-object'; // The package-internal constructor `TemplateExpressionInputSchema` itself is // built with — never re-exported, so the notify sentences add no public surface. import { templateExpressionInput, type TypedExpressionRefusals } from '../shared/typed-expression-input'; +import { textSlotTemplateRefusal } from './flow-text-slot-template'; /** * What a rejected key on these contracts silently did before #4001 批 9. @@ -122,30 +124,24 @@ const NOTIFY_KEY_GUIDANCE: Readonly> = { /** * The placeholder every notify `title` / `message` refusal prescribes — the - * ONE place it is written. The notify executor renders both slots with the - * flow's `interpolate()`, which substitutes single-brace `{token}` only (a - * `{{var}}` keeps its outer braces), and the build's - * `flow-double-brace-interpolation` rule flags a doubled brace on a flow node - * value; so this is the spelling both read today. The shared - * `TemplateExpressionInputSchema` prescribes `{{record.name}}` instead, which - * is why these two slots take the same input from `templateExpressionInput` - * (`shared/typed-expression-input.ts`, package-internal) and carry the - * sentences below (#22081). - * - * The 17.x prescription, ⛔ not an end-state ruling on braces: executing - * ADR-0032 D3 on the v18 line (#22110) flips the notify convention, and this - * constant is the prescription that flips with it. + * ONE place it is written. Since protocol 18 (#22110, executing ADR-0032 D3) + * the notify executor renders both slots through the formula template + * engine, whose holes are `{{ }}`; a single-brace `{token}` is refused at + * every door (`flow-text-slot-template.ts`). These two slots still take their + * input from `templateExpressionInput` (`shared/typed-expression-input.ts`, + * package-internal) rather than `TemplateExpressionInputSchema`, because the + * sentences below name the slot ("A notify node's title …") where the + * shared ones cannot (#22081). */ -const NOTIFY_TEMPLATE_PLACEHOLDER = '{record.name}'; +const NOTIFY_TEMPLATE_PLACEHOLDER = '{{ record.name }}'; /** - * Why the notify prescription is single-brace, in the words every notify - * template refusal ends on. Spelled without a doubled brace, so the refusal - * carries no spelling the build would flag. + * Why the notify prescription is a `{{ }}` hole, in the words every notify + * template refusal ends on. */ const NOTIFY_TEMPLATE_RENDERER = - 'the notify executor interpolates single-brace `{token}` placeholders, and a doubled brace is not one — ' - + 'its inner `{token}` resolves and the outer braces stay in the sent text.'; + 'the notify executor renders `{{ }}` template holes (ADR-0032 §3) — a variable path with an optional formatter, ' + + '`{{ amount | currency }}` — and a single-brace token is not one.'; /** * The two sentences a notify `title` / `message` refuses a malformed value @@ -173,8 +169,7 @@ function notifyTemplateRefusals(key: 'title' | 'message'): TypedExpressionRefusa * 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 - * ({@link NOTIFY_TEMPLATE_PLACEHOLDER}), not the `{{var}}` the shared template - * prose shows: this slot's renderer is the flow's `interpolate()`. + * ({@link NOTIFY_TEMPLATE_PLACEHOLDER}). */ function notifyTemplateSourceRequired(key: 'title' | 'message'): string { const consequence = key === 'title' @@ -182,7 +177,7 @@ function notifyTemplateSourceRequired(key: 'title' | 'message'): string { : '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 ' + + 'filling its `{{ }}` holes 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 ${NOTIFY_TEMPLATE_PLACEHOLDER} won' }\`), or write it as a bare string.` ); @@ -231,8 +226,10 @@ function notifyTemplateSourceRequired(key: 'title' | 'message'): string { * 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 + * - `title` and `message` render through the formula template engine, so + * `{{ record.x }}` holes are legal in them (below). `recipients`, + * `actionUrl` and `payload` pass through the flow's `interpolate()`, so + * `{record.x}` templates are legal in them. So do * `templateData` VALUES (they are per-run render inputs). `channels`, * `topic`, `severity` and `template` are read RAW — a `{token}` in those is * forwarded verbatim, never resolved (channel ids are static routing, @@ -246,15 +243,16 @@ function notifyTemplateSourceRequired(key: 'title' | 'message'): string { * 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. So the input is built with `templateExpressionInput` (the - * package-internal constructor of `TemplateExpressionInputSchema`) rather - * than taken as `TemplateExpressionInputSchema`, whose refusals prescribe - * `{{record.name}}`: a malformed value here is refused with sentences that - * prescribe `{record.name}` ({@link NOTIFY_TEMPLATE_PLACEHOLDER}). + * both spellings of one text render the same notification. Since protocol + * 18 (#22110, ADR-0032 D3) the renderer is the formula template engine, + * so the placeholder spelling is a `{{ }}` hole — a variable path with an + * optional formatter (`{{ record.name }}`, `{{ amount | currency }}`). A + * single-brace `{token}` is no placeholder any more and is refused by the + * `superRefine` below, through the one text-slot judge + * (`flow-text-slot-template.ts`), with the hole spelling of each token. + * The input is built with `templateExpressionInput` (the package-internal + * constructor of `TemplateExpressionInputSchema`) so its refusals name the + * slot; they prescribe {@link NOTIFY_TEMPLATE_PLACEHOLDER}. * 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. @@ -281,10 +279,10 @@ export const NotifyConfigSchema = lazySchema(() => strictObject({ * (the superRefine below owes one of the two). */ title: templateExpressionInput(ExpressionSchema, notifyTemplateRefusals('title')).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.'), + .describe('Notification title — a template: a bare string, or a `{ dialect: \'template\', source }` envelope (the `tmpl` helper) carrying the same text. It is rendered per run with `{{ }}` holes over the flow\'s variables — a variable path with an optional formatter (`{{ record.name }}`, `{{ record.amount | currency }}`); a single-brace `{token}` is refused. 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: templateExpressionInput(ExpressionSchema, notifyTemplateRefusals('message')).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`.'), + .describe('Notification body — the same template input as `title` (a bare string or a `{ dialect: \'template\', source }` envelope), rendered per run with `{{ }}` holes like `title`; not localizable. Only valid with inline `title`, never with `template`.'), /** * The localizable content path (#9205): name of a `sys_email_template` * bundle. Resolved by `(name, locale)` AT DELIVERY TIME — @@ -398,18 +396,27 @@ export const NotifyConfigSchema = lazySchema(() => strictObject({ }); } // The two template slots render `source` (see the docblock): the executor - // interpolates it per run and has no renderer for `ast`. The shared template + // renders it per run and has no renderer for `ast`. The shared template // input (`templateExpressionInput`) 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. + // + // [#22110] And the text it carries is a `{{ }}` template: a single-brace + // `{token}` left from the 17.x dialect would go out as literal text, so it + // is refused here — the same judge, and the same words, as `registerFlow` + // and `objectstack validate` (`flow-text-slot-template.ts`). for (const key of ['title', 'message'] as const) { const value = cfg[key]; - if (value !== undefined && !(typeof value.source === 'string' && NON_BLANK_STRING(value.source))) { + if (value === undefined) continue; + if (!(typeof value.source === 'string' && NON_BLANK_STRING(value.source))) { ctx.addIssue({ code: 'custom', path: [key], message: notifyTemplateSourceRequired(key) }); + continue; } + const refusal = textSlotTemplateRefusal(value.source); + if (refusal !== undefined) ctx.addIssue({ code: 'custom', path: [key], message: refusal }); } })); diff --git a/packages/spec/src/shared/typed-expression-input.ts b/packages/spec/src/shared/typed-expression-input.ts index 6222e3e7f5b..2c8a463eb68 100644 --- a/packages/spec/src/shared/typed-expression-input.ts +++ b/packages/spec/src/shared/typed-expression-input.ts @@ -50,10 +50,9 @@ export interface TypedExpressionRefusals { * The accept set is fixed by the dialect alone; `refusals` moves only the text. * That is the point of taking them as an argument: a refusal is the one place * an author is told exactly what to write, so it must prescribe the spelling - * the slot's renderer reads. The shared `template` sentence prescribes - * `{{record.name}}`, which the notify executor's single-brace interpolator - * would leave inside a stray pair of braces, so the notify `title` / `message` - * pass sentences prescribing `{record.name}` instead. + * the slot's renderer reads, and may name the slot. The notify `title` / + * `message` pass sentences that name the key; since protocol 18 (#22110) they + * prescribe the same `{{ }}` hole the shared `template` sentence does. * * The refusal shape is measured, not assumed (zod 4.4): a union reports the * one arm that did not abort, else `invalid_union`. Both arms abort on a @@ -109,7 +108,7 @@ export function cronExpressionInput(expression: typeof ExpressionSchema, refusal * The template-typed input, refusing with `refusals` — * `TemplateExpressionInputSchema` is this with the shared `{{record.name}}` * sentences, and a notify node's `title` / `message` are this with sentences - * prescribing `{record.name}`. + * naming the key. * * @param expression - `ExpressionSchema` (see the module note for why it is passed in). */ From 105a1cab0de853babc9029ab864f1b6e100057e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 12:26:55 +0000 Subject: [PATCH 02/17] wip: convert in-tree text-slot sites, docs, D3 entry (#22110) Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- content/docs/automation/flows.mdx | 86 ++++++++++++++++--- .../docs/getting-started/common-patterns.mdx | 13 +-- .../src/automation/flows/index.ts | 46 +++++----- examples/app-todo/src/flows/task.flow.ts | 10 +-- .../services/service-automation/README.md | 1 + .../18.flow-text-slot-single-brace-refused.ts | 41 +++++++++ packages/spec/src/migrations/registry.ts | 53 ++++++++++++ 7 files changed, 202 insertions(+), 48 deletions(-) create mode 100644 packages/spec/src/migrations/entries/semantic/18.flow-text-slot-single-brace-refused.ts diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index 23ec0599a84..839dfe74aba 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -212,7 +212,7 @@ missing, empty, whitespace-only or non-string `source`, an `ast` with no Everything else is a **literal**, assigned as it is: a string is the text it spells (a bare `a + b` is the literal text `a + b`, not CEL), and numbers, booleans, arrays and plain objects are what they look like. A later `notify` -node renders the variable as any other: `message: '{digest}'`, and it renders +node renders the variable as any other: `message: '{{ digest }}'`, and it renders the **evaluated** value. The same rules hold for the field values of `create_record` / `update_record` @@ -271,6 +271,54 @@ one) and the run user (`'{$User.Id}'` — the flow's CEL scope binds no user). +### Text slots read double-brace holes + +A flow's **text** slots — a `notify` node's `title` and `message`, a `screen` +node's `title` and `description`, and a refusing `end` node's `message` — +render through the formula template engine ([#22110], ADR-0032 §3): each +`{{ }}` hole is a **variable path** with an optional **formatter**, and every +other character is literal. + +```typescript +{ + id: 'notify_won', type: 'notify', label: 'Deal won', + config: { + recipients: '{record.owner}', // not a text slot: single brace + title: 'Deal won: {{ record.name }}', + message: '{{ record.amount | currency }} closed on {{ record.close_date | date:long }}.', + }, +} +``` + +- A hole reads the flow's variables by name — `{{ record.name }}`, a node + output `{{ lookup.result }}`, an index `{{ rows.0.subject }}` or + `{{ rows[0].subject }}`, and the engine-set `$`-named ones: + `{{ $error.message }}` in a fault handler. +- Value → text is defined per value: `null` and an absent path render nothing, + an object or array renders as JSON, a date object as its ISO text. A + formatter makes the rest explicit: `upper`, `lower`, `trim`, `number[:digits]`, + `currency[:CODE]`, `percent[:digits]`, `date[:short|medium|long|iso]`, + `datetime[:…]`, `truncate[:n]`, `default:'fallback'`, `json`. +- A hole holds **no logic** — `{{ a + b }}` or `{{ round(x) }}` is refused at + `objectstack validate` and `registerFlow`. Compute the value first, in an + `assignment` node's CEL value envelope, and write the variable as a hole. + +A **single-brace** `{…}` token in a text slot is refused — at `objectstack +validate`, at `registerFlow` and by the node's own contract — with its `{{ }}` +spelling. Nothing converts it for you: the two renderers answer differently for +some values (a date object rendered JSON-quoted under the old dialect; a screen +title holding one object rendered `[object Object]`), so the rewrite is yours to +check. + +| you wrote | write instead | +|:---|:---| +| `'Deal won: {record.name}'` | `'Deal won: {{ record.name }}'` | +| `'Failed: {$error.message}'` | `'Failed: {{ $error.message }}'` | +| `'Total {amount * 2}'`, `'{round(x)}'` | compute it into a variable (`assignments: { v: { dialect: 'cel', source: 'amount * 2' } }`), then `'Total {{ v }}'` | +| `'Due {TODAY() + 7}'`, `'By {$User.Id}'` | compute it into a variable with an `assignment` node, whose value slot still reads that spelling (`assignments: { due: '{TODAY() + 7}' }`), then `'Due {{ due }}'` | + +[#22110]: https://github.com/objectstack-ai/objectstack/issues/22110 + **Create Record:** ```typescript @@ -581,7 +629,7 @@ second terminal node type). label: 'Refused — duplicate', config: { outcome: 'refused', // 'completed' (default) | 'refused' - message: 'Refused: {record.name} is a confirmed duplicate of {duplicate.name}', + message: 'Refused: {{ record.name }} is a confirmed duplicate of {{ duplicate.name }}', }, } ``` @@ -592,8 +640,9 @@ second terminal node type). text as `refusalMessage`, and the trigger / resume response carries the same (`success: true`, `status: 'refused'`, `refusalMessage` — and **no** `successMessage`, so there is nothing to toast). -- `message` is a `{token}` template interpolated at run time **exactly like a - `screen` node's `description`**, so the text names the record. It is +- `message` is a `{{ }}` template rendered at run time **exactly like a + `screen` node's `description`** (see [text slots](#text-slots-read-double-brace-holes)), + so the text names the record. It is **required** when `outcome` is `refused` (a refusal without text is the shape this replaces) and **refused** on a completed end (nothing would ever render it — the key would be a silent no-op). The config is strict: an undeclared key @@ -1916,8 +1965,12 @@ failures so one broken flow does not abort startup. A flow computes with **one expression dialect, CEL**: every condition is bare CEL, and every computed value (a field value, an assignment value) is a CEL -value envelope. Braces are for **text** slots — a `notify` title or message, a -screen description — and the date macros a value slot still reads. +value envelope. **Text** slots — a `notify` title or message, a screen title or +description, a refusing `end` node's message — are `{{ }}` templates (see +[text slots](#text-slots-read-double-brace-holes)). Single braces remain only on the +value-like positions that hand a resolved value over (`recipients`, +`sourceId`, a `subflow` input, an `http` body, …) and in the date macros a value +slot still reads. | Where | Dialect | Write it like | Bindings | |:---|:---|:---|:---| @@ -1926,6 +1979,7 @@ screen description — and the date macros a value slot still reads. | Decision-node `conditions[].expression` | **CEL** (bare, no braces) | `order_amount > 10000` | flow variables by name, and `vars.*` | | Field values and assignment values, as a **literal** | none — written as it is | `'open'`, `42`, `true`, `['a', 'b']` | — (a `{…}` template token is refused here since [#19939](https://github.com/objectstack-ai/objectstack/issues/19939), except the date macros `{NOW()}` / `{TODAY() ± N}` and `{$User.*}`, which still resolve until CEL can write them) | | Field values and assignment values, as a **CEL value envelope** | **CEL** (in an envelope) | `{ dialect: 'cel', source: 'round(price * 100.0) / 100.0' }` | flow variables by name, and `vars.*` — the whole CEL stdlib (`joinNonEmpty`, …) | +| Text slots — `notify` `title` / `message`, `screen` `title` / `description`, `end` `message` | **template** (`{{ }}` holes) | `'Deal won: {{ record.name }} ({{ record.amount \| currency }})'` | flow variables by name (`$`-named ones too: `{{ $error.message }}`) — a path and an optional formatter, never logic | A value slot takes either form, chosen by shape: an object naming a `dialect` is a CEL envelope, everything else is a literal. The `{token}` template dialect @@ -1948,13 +2002,17 @@ user. braces. 3. **A `{…}` template in a value slot** — `total: '{round(x * 100) / 100}'`, `owner: '{record.owner}'` — is refused with the CEL spelling to write - instead. In a text slot, an unsupported function in braces — - `'{ROUND(x, 2)}'`, `'{Math.round(x)}'`, `'{(x).toFixed(2)}'` — fails the node - with a **named error** listing the supported set and, where one is close, - the spelling you meant. Before #11060 this was a *silent* failure: the - unknown name was rewritten to `null` and the field was simply written - `undefined`. A `fault` edge does not catch this error — the expression - itself is wrong, so re-running can never succeed; fix the spelling. + instead. In a single-brace position (an `http` body, a `subflow` input), an + unsupported function in braces — `'{ROUND(x, 2)}'`, `'{Math.round(x)}'`, + `'{(x).toFixed(2)}'` — fails the node with a **named error** listing the + supported set and, where one is close, the spelling you meant. Before #11060 + this was a *silent* failure: the unknown name was rewritten to `null` and the + field was simply written `undefined`. A `fault` edge does not catch this + error — the expression itself is wrong, so re-running can never succeed; fix + the spelling. +4. **A single-brace token in a text slot** — `title: 'Deal won: {record.name}'` + — is refused with its `{{ }}` spelling ([text slots](#text-slots-read-double-brace-holes)): it is no placeholder there, + and would go out as literal text. @@ -2194,7 +2252,7 @@ export const hotLeadFollowUp: Flow = { config: { recipients: '{record.owner}', title: 'New hot lead', - message: 'Hot lead created: {record.name}', + message: 'Hot lead created: {{ record.name }}', channels: ['inbox'], }, }, diff --git a/content/docs/getting-started/common-patterns.mdx b/content/docs/getting-started/common-patterns.mdx index 08a5fe15c3c..eba41b4d4c9 100644 --- a/content/docs/getting-started/common-patterns.mdx +++ b/content/docs/getting-started/common-patterns.mdx @@ -276,11 +276,12 @@ export const assignmentNotification = defineFlow({ type: 'notify', label: 'Notify Assignee', config: { - // String fields use single-brace {…} templates, not {{…}}. - // `recipients` takes recipient USER IDs — a lookup value interpolates to the id. + // `recipients` takes recipient USER IDs — a single-brace {…} token hands + // the lookup value (the id) over. `title` / `message` are TEXT slots: + // `{{ }}` template holes, a path with an optional formatter. recipients: '{record.assigned_to}', - title: 'Task Assigned: {record.title}', - message: 'You have been assigned to task "{record.title}".' + title: 'Task Assigned: {{ record.title }}', + message: 'You have been assigned to task "{{ record.title }}".' } }, { id: 'end', type: 'end', label: 'End' } @@ -356,8 +357,8 @@ Define a multi-step approval flow for records. label: 'Request Approval', config: { recipients: '{record.submitted_by}', - title: 'Expense approval needed: {record.title}', - message: 'Expense "{record.title}" needs manual approval.' + title: 'Expense approval needed: {{ record.title }}', + message: 'Expense "{{ record.title }}" needs manual approval.' } }, { id: 'end', type: 'end', label: 'End' } diff --git a/examples/app-showcase/src/automation/flows/index.ts b/examples/app-showcase/src/automation/flows/index.ts index 562e884310e..12df18de8c0 100644 --- a/examples/app-showcase/src/automation/flows/index.ts +++ b/examples/app-showcase/src/automation/flows/index.ts @@ -63,8 +63,8 @@ export const TaskCompletedFlow = defineFlow({ // start node declared `expand: ['project']`; the sibling // `showcase_task_done_notify_owner` is where that hop is demonstrated. recipients: '{record.assignee}', - title: '✅ Task done: {record.title}', - message: '{summary}', + title: '✅ Task done: {{ record.title }}', + message: '{{ summary }}', sourceObject: 'showcase_task', sourceId: '{record.id}', }, @@ -165,8 +165,8 @@ export const TaskAssignedNotifyFlow = defineFlow({ recipients: ['{record.assignee}'], channels: ['inbox'], severity: 'info', - title: 'New task assigned: {record.title}', - message: 'You have been assigned "{record.title}".', + title: 'New task assigned: {{ record.title }}', + message: 'You have been assigned "{{ record.title }}".', actionUrl: '/showcase_task/{record.id}', }, }, @@ -645,7 +645,7 @@ export const TaskFollowUpFlow = defineFlow({ recipients: ['{record.assignee}'], channels: ['inbox'], severity: 'info', - title: 'Follow up on: {record.title}', + title: 'Follow up on: {{ record.title }}', message: 'This task has been open for a while — please update its status.', actionUrl: '/showcase_task/{record.id}', }, @@ -686,7 +686,7 @@ export const NotifyOwnerSubflow = defineFlow({ channels: ['inbox'], severity: 'info', title: 'Project update', - message: '{message}', + message: '{{ message }}', }, }, { id: 'end', type: 'end', label: 'End' }, @@ -857,8 +857,8 @@ export const ProjectClosureFlow = defineFlow({ recipients: ['{record.owner}'], channels: ['inbox'], severity: 'info', - title: 'Closure sign-off: {record.name}', - message: 'Closure sign-off decision for "{record.name}": {signoffResult.decision}.', + title: 'Closure sign-off: {{ record.name }}', + message: 'Closure sign-off decision for "{{ record.name }}": {{ signoffResult.decision }}.', actionUrl: '/showcase_project/{record.id}', }, }, @@ -931,7 +931,7 @@ export const BatchRemindersFlow = defineFlow({ label: 'Send Reminder', config: { recipients: '{task.owner}', - title: 'Reminder ({taskIndex}): {task.title}', + title: 'Reminder ({{ taskIndex }}): {{ task.title }}', sourceObject: 'showcase_task', sourceId: '{task.id}', }, @@ -1008,7 +1008,7 @@ export const FanOutNotifyFlow = defineFlow({ label: 'Notify Owner', config: { recipients: '{record.assignee}', - title: '✅ Done: {record.title}', + title: '✅ Done: {{ record.title }}', sourceObject: 'showcase_task', sourceId: '{record.id}', }, @@ -1130,7 +1130,7 @@ export const NestedFanOutRemindersFlow = defineFlow({ label: 'Notify Owner', config: { recipients: '{task.owner}', - title: 'Overdue ({taskIndex}): {task.title}', + title: 'Overdue ({{ taskIndex }}): {{ task.title }}', sourceObject: 'showcase_task', sourceId: '{task.id}', }, @@ -1147,7 +1147,7 @@ export const NestedFanOutRemindersFlow = defineFlow({ label: 'Notify Watcher', config: { recipients: '{task.watcher}', - title: 'Watching ({taskIndex}): {task.title}', + title: 'Watching ({{ taskIndex }}): {{ task.title }}', sourceObject: 'showcase_task', sourceId: '{task.id}', }, @@ -1336,8 +1336,8 @@ export const InvoiceDualSignoffFlow = defineFlow({ // The expanded relation is read HERE — `{record.account.name}` is what // makes the start node's `expand: ['account']` live rather than inert, // and it is the hydration path this kitchen-sink flow exists to teach. - title: 'Invoice cleared: {record.name}', - message: 'Invoice "{record.name}" for {record.account.name} passed finance + legal sign-off and is on its way.', + title: 'Invoice cleared: {{ record.name }}', + message: 'Invoice "{{ record.name }}" for {{ record.account.name }} passed finance + legal sign-off and is on its way.', actionUrl: '/showcase_invoice/{record.id}', }, }, @@ -1405,12 +1405,12 @@ export const ProjectEscalationFlow = defineFlow({ branches: [ { name: 'Owner', - nodes: [{ id: 'alert_owner', type: 'notify', label: 'Alert Owner', config: { recipients: '{record.owner}', title: '🔴 Critical: {record.name}', severity: 'critical', sourceObject: 'showcase_project', sourceId: '{record.id}' } }], + nodes: [{ id: 'alert_owner', type: 'notify', label: 'Alert Owner', config: { recipients: '{record.owner}', title: '🔴 Critical: {{ record.name }}', severity: 'critical', sourceObject: 'showcase_project', sourceId: '{record.id}' } }], edges: [], }, { name: 'Exec', - nodes: [{ id: 'alert_exec', type: 'notify', label: 'Alert Exec', config: { recipients: 'exec@example.com', title: '🔴 Critical project: {record.name}', severity: 'critical', sourceObject: 'showcase_project', sourceId: '{record.id}' } }], + nodes: [{ id: 'alert_exec', type: 'notify', label: 'Alert Exec', config: { recipients: 'exec@example.com', title: '🔴 Critical project: {{ record.name }}', severity: 'critical', sourceObject: 'showcase_project', sourceId: '{record.id}' } }], edges: [], }, ], @@ -1429,7 +1429,7 @@ export const ProjectEscalationFlow = defineFlow({ edges: [], }, catch: { - nodes: [{ id: 'log_fail', type: 'notify', label: 'Log push failure', config: { topic: 'project.escalation', recipients: ['admin@objectos.ai'], channels: ['inbox'], severity: 'warning', title: 'Incident push failed: {record.name}', message: 'Could not reach the incident system: {$error.message}' } }], + nodes: [{ id: 'log_fail', type: 'notify', label: 'Log push failure', config: { topic: 'project.escalation', recipients: ['admin@objectos.ai'], channels: ['inbox'], severity: 'warning', title: 'Incident push failed: {{ record.name }}', message: 'Could not reach the incident system: {{ $error.message }}' } }], edges: [], }, }, @@ -1438,13 +1438,13 @@ export const ProjectEscalationFlow = defineFlow({ id: 'notify_normal', type: 'notify', label: 'Notify Owner', - config: { topic: 'project.escalation', recipients: ['{record.owner}'], channels: ['inbox'], severity: 'info', title: 'Project needs attention: {record.name}', message: 'Health dropped to red — please review.' }, + config: { topic: 'project.escalation', recipients: ['{record.owner}'], channels: ['inbox'], severity: 'info', title: 'Project needs attention: {{ record.name }}', message: 'Health dropped to red — please review.' }, }, { id: 'converge', type: 'notify', label: 'Escalation Handled', - config: { topic: 'project.escalation', recipients: ['{record.owner}'], channels: ['inbox'], severity: 'info', title: 'Escalation handled: {record.name}', message: 'The red-health escalation has been processed.' }, + config: { topic: 'project.escalation', recipients: ['{record.owner}'], channels: ['inbox'], severity: 'info', title: 'Escalation handled: {{ record.name }}', message: 'The red-health escalation has been processed.' }, }, { id: 'end', type: 'end', label: 'End' }, ], @@ -1868,8 +1868,8 @@ export const TaskDueReminderFlow = defineFlow({ recipients: ['{record.assignee}'], channels: ['inbox'], severity: 'warning', - title: 'Task due soon: {record.title}', - message: 'Your task "{record.title}" is due on {record.due_date}.', + title: 'Task due soon: {{ record.title }}', + message: 'Your task "{{ record.title }}" is due on {{ record.due_date }}.', actionUrl: '/showcase_task', }, }, @@ -1926,8 +1926,8 @@ export const UrgentTaskAlertFlow = defineFlow({ recipients: ['{record.assignee}', '{$User.Id}'], channels: ['inbox'], severity: 'warning', - title: 'Urgent task: {record.title}', - message: 'Task "{record.title}" is now Urgent — it needs attention.', + title: 'Urgent task: {{ record.title }}', + message: 'Task "{{ record.title }}" is now Urgent — it needs attention.', actionUrl: '/showcase_task', }, }, diff --git a/examples/app-todo/src/flows/task.flow.ts b/examples/app-todo/src/flows/task.flow.ts index 6c22a94d5ce..c6d25924506 100644 --- a/examples/app-todo/src/flows/task.flow.ts +++ b/examples/app-todo/src/flows/task.flow.ts @@ -99,8 +99,8 @@ export const TaskReminderFlow: Flow = { id: 'send_reminder', type: 'notify', label: 'Send Reminder', config: { recipients: '{currentTask.owner}', - title: 'Task due tomorrow: {currentTask.subject}', - message: 'Due {currentTask.due_date} · priority {currentTask.priority}.', + title: 'Task due tomorrow: {{ currentTask.subject }}', + message: 'Due {{ currentTask.due_date }} · priority {{ currentTask.priority }}.', sourceObject: 'todo_task', sourceId: '{currentTask.id}', }, @@ -211,11 +211,11 @@ export const OverdueEscalationFlow: Flow = { id: 'notify_owner', type: 'notify', label: 'Notify Task Owner', config: { recipients: '{currentTask.owner}', - title: 'URGENT: task overdue — {currentTask.subject}', + title: 'URGENT: task overdue — {{ currentTask.subject }}', // `days_overdue` is the formula field the record // projection carries (#18584); the loop binding this // template reads it from is what #19206 restores. - message: 'Due {currentTask.due_date}, {currentTask.days_overdue} day(s) overdue.', + message: 'Due {{ currentTask.due_date }}, {{ currentTask.days_overdue }} day(s) overdue.', severity: 'critical', sourceObject: 'todo_task', sourceId: '{currentTask.id}', @@ -494,7 +494,7 @@ export const QuickAddTaskFlow: Flow = { // is the declared way to pause on a message-only confirmation screen. config: { title: 'Task Created', - description: 'Task "{subject}" created successfully!', + description: 'Task "{{ subject }}" created successfully!', waitForInput: true, }, }, diff --git a/packages/services/service-automation/README.md b/packages/services/service-automation/README.md index c29b4d3cdf0..9b50dce5ba3 100644 --- a/packages/services/service-automation/README.md +++ b/packages/services/service-automation/README.md @@ -189,6 +189,7 @@ braces are for values.** | Edge `condition` | CEL — bare, no braces | `record.status == 'open'` | | Decision `conditions[].expression` | CEL — bare, no braces | `order_amount > 10000` | | Field values in `create_record` / `update_record` | Interpolation — braces required | `'Follow up on {record.name}'`, `'{TODAY() + 7}'` | +| Text slots — `notify` `title` / `message`, `screen` `title` / `description`, `end` `message` | Template — `{{ }}` holes (ADR-0032 §3; a single-brace token is refused) | `'Deal won: {{ record.name }}'`, `'{{ record.amount \| currency }}'` | Value bindings: `{var}`, `{var.path}`, `{$User.Id}`, `{$User.Email}`, `{NOW()}`, `{TODAY()}`, `{TODAY() + 90}`. diff --git a/packages/spec/src/migrations/entries/semantic/18.flow-text-slot-single-brace-refused.ts b/packages/spec/src/migrations/entries/semantic/18.flow-text-slot-single-brace-refused.ts new file mode 100644 index 00000000000..ebc846fe420 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.flow-text-slot-single-brace-refused.ts @@ -0,0 +1,41 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// The flow text slots read ADR-0032 §3's double-brace template holes, rendered +// by the formula template engine; the single brace is deleted from them. +// Semantic-only — the two renderers answer differently for some value of every +// token spelling, so no D2 conversion rewrites any of them, and no spelling is +// kept. +export const entry: SemanticMigration = { + id: 'flow-text-slot-single-brace-refused', + // No backticks in `surface` — build-upgrade-guide renders it inside a code + // span already, and a nested backtick would close it. + surface: + 'flows[].nodes[].config of a notify node (title, message), a screen node (title, description) and an end node ' + + '(message) — a string, or the source of a template envelope, carrying a single-brace template token', + replacement: + 'a double-brace template hole, rendered by the formula template engine over the flow\'s variables: a variable ' + + 'path with an optional formatter, {{ record.name }}, {{ $error.message }}, {{ rows.0.subject }}, ' + + '{{ record.amount | currency }}. A token no hole can spell is computed into a variable first, with an ' + + 'assignment node — arithmetic and functions as a CEL value envelope, the date macros and the run-user paths ' + + 'as the value-slot spelling that still reads them — and written as {{ variable }}', + reason: + 'ADR-0032 Decision 3 fixes one template delimiter, double braces, and deletes the single brace: it collides ' + + 'with CEL map literals, and an author who meets both dialects in one flow mixes them. The 17.x interpolator ' + + 'and the template engine render the same text for a path holding a string, a number, a boolean, null, an ' + + 'absent key or variable, an ISO date string, an object or an array, but not for every value — a Date ' + + 'rendered JSON-quoted under the interpolator and as its ISO text under the engine, and a screen title, ' + + 'screen description or end message that was one token holding an object, an array or a Date rendered ' + + 'String(value) — so no conversion is lossless (ADR-0087 D2) and none is applied. Arithmetic, function ' + + 'calls, the date macros and the run-user paths have no hole spelling: a hole is a path with a formatter, ' + + 'never logic. A flow carrying a single-brace token in a text slot is refused at registration, by ' + + 'objectstack validate and by the node contract; a stored flow carrying one is skipped at boot with a warn ' + + 'naming it.', + acceptanceCriteria: + 'Run objectstack validate: it reports each refused text slot as expression-invalid at the node and the ' + + 'slot\'s key, with the double-brace spelling of every path token. Rewrite each slot as that spelling; for ' + + 'a token no hole can spell, add the assignment the refusal names and write its variable as a hole. Re-run ' + + 'the flow paths that send those notifications or show those screens and compare the text with the text ' + + 'the 17.x renderer produced — in particular any slot that renders a date value or a whole object.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 989c71ffef8..52f329ae243 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5716,6 +5716,22 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'an undeclared key was meant to be. Its D3 record is the semantic entry ' + '`flow-script-subflow-config-undeclared-keys-refused`.', }, + { + id: 'flow-text-slot-single-brace-refused', + order: 89, + text: + 'It executes ADR-0032 Decision 3 in the flow TEXT slots — a `notify` node\'s `title` and `message`, ' + + 'a `screen` node\'s `title` and `description`, a refusing `end` node\'s `message`: they render ' + + 'through the formula template engine, so their placeholders are `{{ }}` holes, a variable path with ' + + 'an optional formatter (the engine\'s hole grammar now admits a `$`-named variable, so ' + + '`{{ $error.message }}` is a hole). A single-brace `{…}` token there is refused — by the node contract, ' + + '`registerFlow` and `objectstack validate` alike — with the hole spelling of each path token, or, for ' + + 'arithmetic, a function, a date macro or a run-user path, the `assignment` that computes it into a ' + + 'variable. No D2 conversion exists: the 17.x interpolator and the engine render a `Date` differently ' + + '(JSON-quoted against ISO text), and a whole-slot object differently in a screen or `end` text, so ' + + 'the rewrite is the author\'s to check. Every other flow string keeps the single-brace dialect. Its ' + + 'D3 record is the semantic entry `flow-text-slot-single-brace-refused`.', + }, { id: 'flow-value-slot-template-dialect-refused', order: 88, @@ -14021,6 +14037,43 @@ const step18: MigrationStep = { + '`sys_metadata`. A `script` or `subflow` node whose keys its contract declares parses and registers ' + 'byte-identically to before.', }, + // The flow text slots read ADR-0032 §3's double-brace template holes, rendered + // by the formula template engine; the single brace is deleted from them. + // Semantic-only — the two renderers answer differently for some value of every + // token spelling, so no D2 conversion rewrites any of them, and no spelling is + // kept. + { + id: 'flow-text-slot-single-brace-refused', + // No backticks in `surface` — build-upgrade-guide renders it inside a code + // span already, and a nested backtick would close it. + surface: + 'flows[].nodes[].config of a notify node (title, message), a screen node (title, description) and an end node ' + + '(message) — a string, or the source of a template envelope, carrying a single-brace template token', + replacement: + 'a double-brace template hole, rendered by the formula template engine over the flow\'s variables: a variable ' + + 'path with an optional formatter, {{ record.name }}, {{ $error.message }}, {{ rows.0.subject }}, ' + + '{{ record.amount | currency }}. A token no hole can spell is computed into a variable first, with an ' + + 'assignment node — arithmetic and functions as a CEL value envelope, the date macros and the run-user paths ' + + 'as the value-slot spelling that still reads them — and written as {{ variable }}', + reason: + 'ADR-0032 Decision 3 fixes one template delimiter, double braces, and deletes the single brace: it collides ' + + 'with CEL map literals, and an author who meets both dialects in one flow mixes them. The 17.x interpolator ' + + 'and the template engine render the same text for a path holding a string, a number, a boolean, null, an ' + + 'absent key or variable, an ISO date string, an object or an array, but not for every value — a Date ' + + 'rendered JSON-quoted under the interpolator and as its ISO text under the engine, and a screen title, ' + + 'screen description or end message that was one token holding an object, an array or a Date rendered ' + + 'String(value) — so no conversion is lossless (ADR-0087 D2) and none is applied. Arithmetic, function ' + + 'calls, the date macros and the run-user paths have no hole spelling: a hole is a path with a formatter, ' + + 'never logic. A flow carrying a single-brace token in a text slot is refused at registration, by ' + + 'objectstack validate and by the node contract; a stored flow carrying one is skipped at boot with a warn ' + + 'naming it.', + acceptanceCriteria: + 'Run objectstack validate: it reports each refused text slot as expression-invalid at the node and the ' + + 'slot\'s key, with the double-brace spelling of every path token. Rewrite each slot as that spelling; for ' + + 'a token no hole can spell, add the assignment the refusal names and write its variable as a hole. Re-run ' + + 'the flow paths that send those notifications or show those screens and compare the text with the text ' + + 'the 17.x renderer produced — in particular any slot that renders a date value or a whole object.', + }, // A value a flow reads, not an authorable key: there is no D2 conversion and // nothing for `objectstack migrate meta` to rewrite. The sibling of // `18.by-id-write-unreadable-row-not-found` in kind — the entry carries the From f7787bdf0f23eaabca2c222c4bc7bcb24b6a0337 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 12:37:35 +0000 Subject: [PATCH 03/17] wip: spec judge tests, regenerated artifacts (#22110) Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- .../automation/builtin-node-config.mdx | 6 +- .../references/automation/io-node-config.mdx | 11 +- packages/spec/api-surface/automation.json | 9 ++ .../spec/dropped-refinements.baseline.json | 3 +- packages/spec/export-origins/automation.json | 9 ++ .../src/automation/end-node-outcome.test.ts | 18 ++- .../flow-text-slot-template.test.ts | 135 ++++++++++++++++++ .../src/automation/flow-text-slot-template.ts | 35 ++--- .../src/automation/io-node-config.test.ts | 60 +++++--- 9 files changed, 226 insertions(+), 60 deletions(-) create mode 100644 packages/spec/src/automation/flow-text-slot-template.test.ts diff --git a/content/docs/references/automation/builtin-node-config.mdx b/content/docs/references/automation/builtin-node-config.mdx index bb7662cd8a2..0a3d5b89ac8 100644 --- a/content/docs/references/automation/builtin-node-config.mdx +++ b/content/docs/references/automation/builtin-node-config.mdx @@ -171,7 +171,7 @@ Value the variable takes: a CEL value envelope `{ dialect: 'cel', source }` eval | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **outcome** | `Enum<'completed' \| 'refused'>` | optional (default: `"completed"`) | How the run ends when it reaches this node. `completed` (the default) is the ordinary terminal. `refused` is a first-class refusal: the run records `refused` — distinct from `failed`, a refusal is a successful evaluation that says no — carries the rendered `message`, is never resumed, and a runner shows the message with Close only: no Submit, no completion toast. | -| **message** | `string` | optional | Why the run was refused, as a `{token}` template interpolated at run time exactly like a screen `description` (`{record.name}` etc.), so the text names the record. Required when `outcome` is `refused`; refused when it is `completed` — a completion renders nothing, so the key would be a silent no-op. | +| **message** | `string` | optional | Why the run was refused, as a template rendered at run time exactly like a screen `description` — `{{ }}` holes over the flow's variables (`{{ record.name }}`), so the text names the record. Required when `outcome` is `refused`; refused when it is `completed` — a completion renders nothing, so the key would be a silent no-op. | --- @@ -221,8 +221,8 @@ A value: a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expr | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **title** | `string` | optional | Heading shown above the screen | -| **description** | `string` | optional | Body text shown under the heading | +| **title** | `string` | optional | Heading shown above the screen — a template rendered per run with `{{ }}` holes over the flow's variables (`{{ record.name }}`); falls back to the node label when it renders nothing | +| **description** | `string` | optional | Body text shown under the heading — a template rendered per run with `{{ }}` holes (`{{ record.name }}`) | | **fields** | `{ name: string; label?: string; type?: string; required?: boolean; … }[]` | optional | Input fields collected on this screen | | **waitForInput** | `boolean` | optional | Pause to show the screen even with no fields; false forces a server pass-through | | **objectName** | `string` | optional | Render this object's full create/edit form instead of a flat field list | diff --git a/content/docs/references/automation/io-node-config.mdx b/content/docs/references/automation/io-node-config.mdx index 08809da6a9d..f7eaee13fe4 100644 --- a/content/docs/references/automation/io-node-config.mdx +++ b/content/docs/references/automation/io-node-config.mdx @@ -25,9 +25,10 @@ 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 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 +config — its slots are string-typed or template-typed, so templates pass +(`{{ }}` holes in its two text slots, `{token}` in the rest) and the +post-rendering 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. @@ -95,8 +96,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 \| { 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`. | +| **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 rendered per run with `{{ }}` holes over the flow's variables — a variable path with an optional formatter (`{{ record.name }}`, `{{ record.amount \| currency }}`); a single-brace `{token}` is refused. 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), rendered per run with `{{ }}` holes like `title`; not localizable. Only valid with inline `title`, never with `template`. | | **template** | `string` | optional | Email template name (`sys_email_template.name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, 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 locale — so recipients whose personal languages differ receive different rows of the same bundle. The node's `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) | diff --git a/packages/spec/api-surface/automation.json b/packages/spec/api-surface/automation.json index 9c3731702f2..c3d36320425 100644 --- a/packages/spec/api-surface/automation.json +++ b/packages/spec/api-surface/automation.json @@ -110,6 +110,7 @@ "ExecutionStepSkipReasonSchema (const)", "FLOW_BUILTIN_NODE_TYPES (const)", "FLOW_NODE_EXPRESSION_PATHS (const)", + "FLOW_NODE_TEXT_SLOTS (const)", "FLOW_PAUSE_CAPABLE_NODE_TYPES (const)", "FLOW_REGION_CONFIG_KEYS (const)", "FLOW_REGION_SLOTS (const)", @@ -143,6 +144,9 @@ "FlowNodeExpressionRole (type)", "FlowNodeParsed (type)", "FlowNodeSchema (const)", + "FlowNodeTextSlot (interface)", + "FlowNodeTextSlotSource (interface)", + "FlowNodeTextTemplateRefusal (interface)", "FlowNodeValueTemplateRefusal (interface)", "FlowParsed (type)", "FlowRegion (type)", @@ -234,6 +238,7 @@ "SubflowConfig (type)", "SubflowConfigParsed (type)", "SubflowConfigSchema (const)", + "TEXT_SLOT_TEMPLATE_REFUSAL (const)", "TIME_RELATIVE_DEFAULT_CRON (const)", "TIME_RELATIVE_DEFAULT_MAX_RECORDS (const)", "TRY_CATCH_NODE_TYPE (const)", @@ -279,6 +284,8 @@ "findRegionEntry (function)", "flowForm (const)", "flowNodeConfigRefusals (function)", + "flowNodeTextSlotSources (function)", + "flowNodeTextTemplateRefusals (function)", "flowNodeValueTemplateRefusals (function)", "getApprovalNodeConfigJsonSchema (function)", "getBuiltinNodeConfigContracts (function)", @@ -295,6 +302,8 @@ "resolveFlowTriggerKind (function)", "resolveScheduleOrganization (function)", "structuralConditionRefusal (function)", + "textSlotSource (function)", + "textSlotTemplateRefusal (function)", "validateControlFlow (function)", "valueSlotTemplateRefusals (function)" ] diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index a2da918e93c..cb8e909fb59 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -3,7 +3,7 @@ "measured": { "zod": "4.4.3", "publishedSchemasWithDroppedRefinements": 220, - "droppedRefinementSites": 679, + "droppedRefinementSites": 680, "refinementSitesThatDidProject": 369, "refinementSitesWithNoJsonFormToCompare": 0 }, @@ -524,6 +524,7 @@ }, "automation/ScreenConfig": { "sites": [ + "", "fields.element" ] }, diff --git a/packages/spec/export-origins/automation.json b/packages/spec/export-origins/automation.json index f02aea90b14..8b186d116c1 100644 --- a/packages/spec/export-origins/automation.json +++ b/packages/spec/export-origins/automation.json @@ -106,6 +106,7 @@ "ExecutionStepSkipReasonSchema": "src/automation/execution.zod.ts#ExecutionStepSkipReasonSchema (const)", "FLOW_BUILTIN_NODE_TYPES": "src/automation/flow.zod.ts#FLOW_BUILTIN_NODE_TYPES (const)", "FLOW_NODE_EXPRESSION_PATHS": "src/automation/flow-node-expression-paths.ts#FLOW_NODE_EXPRESSION_PATHS (const)", + "FLOW_NODE_TEXT_SLOTS": "src/automation/flow-text-slot-template.ts#FLOW_NODE_TEXT_SLOTS (const)", "FLOW_PAUSE_CAPABLE_NODE_TYPES": "src/automation/flow.zod.ts#FLOW_PAUSE_CAPABLE_NODE_TYPES (const)", "FLOW_REGION_CONFIG_KEYS": "src/automation/region-slots.ts#FLOW_REGION_CONFIG_KEYS (const)", "FLOW_REGION_SLOTS": "src/automation/region-slots.ts#FLOW_REGION_SLOTS (const)", @@ -138,6 +139,9 @@ "FlowNodeExpressionRole": "src/automation/flow-node-expression-paths.ts#FlowNodeExpressionRole (type)", "FlowNodeParsed": "src/automation/flow.zod.ts#FlowNodeParsed (type)", "FlowNodeSchema": "src/automation/flow.zod.ts#FlowNodeSchema (const)", + "FlowNodeTextSlot": "src/automation/flow-text-slot-template.ts#FlowNodeTextSlot (interface)", + "FlowNodeTextSlotSource": "src/automation/flow-text-slot-template.ts#FlowNodeTextSlotSource (interface)", + "FlowNodeTextTemplateRefusal": "src/automation/flow-text-slot-template.ts#FlowNodeTextTemplateRefusal (interface)", "FlowNodeValueTemplateRefusal": "src/automation/flow-value-slot-template.ts#FlowNodeValueTemplateRefusal (interface)", "FlowParsed": "src/automation/flow.zod.ts#FlowParsed (type)", "FlowRegion": "src/automation/control-flow.zod.ts#FlowRegion (type)", @@ -229,6 +233,7 @@ "SubflowConfig": "src/automation/schemaless-node-config.zod.ts#SubflowConfig (type)", "SubflowConfigParsed": "src/automation/schemaless-node-config.zod.ts#SubflowConfigParsed (type)", "SubflowConfigSchema": "src/automation/schemaless-node-config.zod.ts#SubflowConfigSchema (const)", + "TEXT_SLOT_TEMPLATE_REFUSAL": "src/automation/flow-text-slot-template.ts#TEXT_SLOT_TEMPLATE_REFUSAL (const)", "TIME_RELATIVE_DEFAULT_CRON": "src/automation/time-relative-trigger.zod.ts#TIME_RELATIVE_DEFAULT_CRON (const)", "TIME_RELATIVE_DEFAULT_MAX_RECORDS": "src/automation/time-relative-trigger.zod.ts#TIME_RELATIVE_DEFAULT_MAX_RECORDS (const)", "TRY_CATCH_NODE_TYPE": "src/automation/control-flow.zod.ts#TRY_CATCH_NODE_TYPE (const)", @@ -273,6 +278,8 @@ "findRegionEntry": "src/automation/control-flow.zod.ts#findRegionEntry (function)", "flowForm": "src/automation/flow.form.ts#flowForm (const)", "flowNodeConfigRefusals": "src/automation/flow-node-config-refusals.ts#flowNodeConfigRefusals (function)", + "flowNodeTextSlotSources": "src/automation/flow-text-slot-template.ts#flowNodeTextSlotSources (function)", + "flowNodeTextTemplateRefusals": "src/automation/flow-text-slot-template.ts#flowNodeTextTemplateRefusals (function)", "flowNodeValueTemplateRefusals": "src/automation/flow-value-slot-template.ts#flowNodeValueTemplateRefusals (function)", "getApprovalNodeConfigJsonSchema": "src/automation/approval.zod.ts#getApprovalNodeConfigJsonSchema (function)", "getBuiltinNodeConfigContracts": "src/automation/flow-node-config-refusals.ts#getBuiltinNodeConfigContracts (function)", @@ -289,6 +296,8 @@ "resolveFlowTriggerKind": "src/automation/flow-trigger-kind.ts#resolveFlowTriggerKind (function)", "resolveScheduleOrganization": "src/automation/schedule-organization.zod.ts#resolveScheduleOrganization (function)", "structuralConditionRefusal": "src/automation/flow-node-expression-paths.ts#structuralConditionRefusal (function)", + "textSlotSource": "src/automation/flow-text-slot-template.ts#textSlotSource (function)", + "textSlotTemplateRefusal": "src/automation/flow-text-slot-template.ts#textSlotTemplateRefusal (function)", "validateControlFlow": "src/automation/control-flow.zod.ts#validateControlFlow (function)", "valueSlotTemplateRefusals": "src/automation/flow-value-slot-template.ts#valueSlotTemplateRefusals (function)" } diff --git a/packages/spec/src/automation/end-node-outcome.test.ts b/packages/spec/src/automation/end-node-outcome.test.ts index 61675d61528..b6612ea6039 100644 --- a/packages/spec/src/automation/end-node-outcome.test.ts +++ b/packages/spec/src/automation/end-node-outcome.test.ts @@ -23,8 +23,9 @@ import { EndConfigSchema } from './builtin-node-config.zod'; import { FlowSchema, FlowNodeSchema, defineFlow, type Flow } from './flow.zod'; import { ExecutionLogSchema, ExecutionStatus } from './execution.zod'; import { formatZodError } from '../shared/error-map.zod'; +import { TEXT_SLOT_TEMPLATE_REFUSAL } from './flow-text-slot-template'; -const REFUSAL = 'Refused: {record.name} is a confirmed duplicate of {duplicate.name}'; +const REFUSAL = 'Refused: {{ record.name }} is a confirmed duplicate of {{ duplicate.name }}'; /** The card-shape flow: start → end, with the end node's config under test. */ const flowEndingWith = (config: Record | undefined): Flow => ({ @@ -60,7 +61,20 @@ describe('EndConfigSchema — the `end` node contract: it may refuse the run wit expect(issue.code).toBe('custom'); expect(issue.path).toEqual(['message']); expect(issue.message).toContain("`outcome: 'refused'` requires a `message`"); - expect(issue.message).toContain('{record.name}'); + expect(issue.message).toContain('{{ record.name }}'); + }); + + it('REFUSES a single-brace token in the `message` — a text slot reads `{{ }}` holes (#22110)', () => { + const result = EndConfigSchema.safeParse({ outcome: 'refused', message: 'Refused: {record.name}' }); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.issues.map((i) => [i.code, i.path])).toEqual([['custom', ['message']]]); + expect(result.error.issues[0]!.message.startsWith(TEXT_SLOT_TEMPLATE_REFUSAL)).toBe(true); + expect(result.error.issues[0]!.message).toContain('`Refused: {{ record.name }}`'); + // …and the flow parse — the structural node's only door — refuses it at the node. + const flow = FlowSchema.safeParse(flowEndingWith({ outcome: 'refused', message: 'Refused: {record.name}' })); + expect(flow.success).toBe(false); + expect(flow.error?.issues.map((i) => i.path.join('.'))).toEqual(['nodes.1.config.message']); }); it('REFUSES an empty `message` on a refusal — a refusal without text, spelled as an empty string', () => { diff --git a/packages/spec/src/automation/flow-text-slot-template.test.ts b/packages/spec/src/automation/flow-text-slot-template.test.ts new file mode 100644 index 00000000000..eb56a7dce8b --- /dev/null +++ b/packages/spec/src/automation/flow-text-slot-template.test.ts @@ -0,0 +1,135 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #22110 — the flow TEXT slots read ADR-0032 §3's `{{ }}` holes, and a + * single-brace `{…}` token left from the 17.x dialect is refused with its + * remedy. These pins hold the one judge (`flow-text-slot-template.ts`): where + * the text slots are, what it refuses, what it prescribes for each token kind, + * and that the node contracts compose it. The renderer half (the template + * engine over the flow's variables) is pinned in `service-automation`'s + * `text-slot-template.test.ts`; the two doors in that package's registration + * tests and in `@objectstack/lint`. + */ +import { describe, expect, it } from 'vitest'; + +import { + FLOW_NODE_TEXT_SLOTS, + TEXT_SLOT_TEMPLATE_REFUSAL, + flowNodeTextSlotSources, + textSlotTemplateRefusal, +} from './flow-text-slot-template'; +import { valueSlotTemplateRefusals } from './flow-value-slot-template'; +import { ScreenConfigSchema } from './builtin-node-config.zod'; +import { tmpl } from '../shared/expression.zod'; + +describe('FLOW_NODE_TEXT_SLOTS — where the `{{ }}` text slots are', () => { + it('names exactly the five slots whose rendered value is only ever text', () => { + expect(FLOW_NODE_TEXT_SLOTS.map((s) => `${s.nodeType}.${s.key}`)).toEqual([ + 'notify.title', + 'notify.message', + 'screen.title', + 'screen.description', + 'end.message', + ]); + }); +}); + +describe('textSlotTemplateRefusal — the one judge of the single brace in a text slot', () => { + it('passes text with no single-brace token: plain text, `{{ }}` holes, a `$`-named hole, a formatter', () => { + for (const text of [ + 'Health dropped to red — please review.', + 'Deal won: {{ record.name }}', + '{{record.name}}', + 'Failed: {{ $error.message }}', + '{{ record.amount | currency:EUR }} on {{ rows.0.close_date | date:iso }}', + ]) { + expect(textSlotTemplateRefusal(text), text).toBeUndefined(); + } + }); + + it('refuses a path token, leading with the rule and naming the whole text rewritten with holes', () => { + const message = textSlotTemplateRefusal('Invoice "{record.name}" for {record.account.name}')!; + expect(message.startsWith(TEXT_SLOT_TEMPLATE_REFUSAL)).toBe(true); + expect(message).toContain('`Invoice "{{ record.name }}" for {{ record.account.name }}`'); + }); + + it('spells a `$`-named variable, an index and a node output as the same path in a hole', () => { + expect(textSlotTemplateRefusal('Failed: {$error.message}')).toContain('`Failed: {{ $error.message }}`'); + expect(textSlotTemplateRefusal('{rows.0}')).toContain('`{{ rows.0 }}`'); + expect(textSlotTemplateRefusal('{ lookup.result }')).toContain('`{{ lookup.result }}`'); + }); + + it('judges a single-brace token beside a hole, and leaves the hole alone', () => { + const message = textSlotTemplateRefusal('{{ record.name }} owes {amount}')!; + expect(message).toContain('`{{ record.name }} owes {{ amount }}`'); + }); + + it('names an assignment with a CEL value envelope for logic — every integer divisor kept a double', () => { + const message = textSlotTemplateRefusal('Total {round(amount * 100) / 100}')!; + expect(message.startsWith(TEXT_SLOT_TEMPLATE_REFUSAL)).toBe(true); + expect(message).toContain("dialect: 'cel', source: \"round(amount * 100) / 100.0\""); + expect(message).toContain('`{{ v }}`'); + // No path token, so no "Write … as …" rewrite. + expect(message).not.toContain('Write `'); + }); + + it('names an assignment whose value slot still reads it for a date macro or a run-user path', () => { + for (const token of ['{TODAY() + 7}', '{NOW()}', '{$User.Id}']) { + const message = textSlotTemplateRefusal(`Due ${token}`)!; + expect(message.startsWith(TEXT_SLOT_TEMPLATE_REFUSAL), token).toBe(true); + expect(message, token).toContain(`assignments: { v: '${token}' }`); + // …and that value-slot spelling is one the value slot does keep (#19939). + expect(valueSlotTemplateRefusals(token), token).toEqual([]); + } + }); + + it('tells an author to delete a token that is neither a path nor an expression', () => { + expect(textSlotTemplateRefusal('JSON {"a": 1}')).toContain('neither a variable path nor an expression'); + }); + + it('names each unspellable token once, after the rewrite of the paths', () => { + const message = textSlotTemplateRefusal('{name}: {NOW()} / {NOW()}')!; + expect(message.indexOf('`{{ name }}: {NOW()} / {NOW()}`')).toBeGreaterThan(0); + expect(message.split("assignments: { v: '{NOW()}' }")).toHaveLength(2); + }); +}); + +describe('flowNodeTextSlotSources — the text slots one node config carries', () => { + it('reads the notify pair as a bare string or a template envelope, and nothing else on the node', () => { + expect(flowNodeTextSlotSources('notify', { + recipients: ['{record.owner}'], + sourceId: '{record.id}', + title: 'Hi {record.name}', + message: tmpl`Bye {{ record.name }}`, + })).toEqual([ + { path: 'title', label: 'notify title', source: 'Hi {record.name}' }, + { path: 'message', label: 'notify message', source: 'Bye {{ record.name }}' }, + ]); + }); + + it('reads a screen title / description and an end message, and no slot of any other node', () => { + expect(flowNodeTextSlotSources('screen', { title: 'T', description: 'D', recordId: '{x}' }).map((s) => s.path)) + .toEqual(['title', 'description']); + expect(flowNodeTextSlotSources('end', { outcome: 'refused', message: 'M' }).map((s) => s.label)).toEqual(['end message']); + expect(flowNodeTextSlotSources('http', { url: '/x/{id}', title: 'T' })).toEqual([]); + expect(flowNodeTextSlotSources('notify', { title: 42, message: { dialect: 'cel', source: 'x' } })).toEqual([]); + expect(flowNodeTextSlotSources('notify', undefined)).toEqual([]); + }); +}); + +describe('ScreenConfigSchema — its two text slots compose the judge', () => { + it('refuses a single-brace token in `title` or `description` at the key, and accepts the hole', () => { + for (const key of ['title', 'description'] as const) { + const refused = ScreenConfigSchema.safeParse({ [key]: 'Task "{subject}" created' }); + expect(refused.success, key).toBe(false); + expect(refused.error?.issues.map((i) => [i.code, i.path.join('.')]), key).toEqual([['custom', key]]); + expect(refused.error?.issues[0]!.message, key).toContain('`Task "{{ subject }}" created`'); + expect(ScreenConfigSchema.safeParse({ [key]: 'Task "{{ subject }}" created' }).success, key).toBe(true); + } + }); + + it('keeps the single-brace dialect on the value-like keys — a `recordId` names a record, not text', () => { + expect(ScreenConfigSchema.safeParse({ objectName: 'account', mode: 'edit', recordId: '{account_id}', defaults: { name: '{lead.company}' } }).success) + .toBe(true); + }); +}); diff --git a/packages/spec/src/automation/flow-text-slot-template.ts b/packages/spec/src/automation/flow-text-slot-template.ts index 624731cedf9..3d87b6c8843 100644 --- a/packages/spec/src/automation/flow-text-slot-template.ts +++ b/packages/spec/src/automation/flow-text-slot-template.ts @@ -23,10 +23,11 @@ * `{{ }}` spelling of each token or, for a token no hole can spell, where to * compute it. The contracts compose it (`NotifyConfigSchema`, * `ScreenConfigSchema`, `EndConfigSchema`), and `AutomationEngine.registerFlow` - * and `objectstack validate` call {@link flowNodeTextTemplateRefusals} — ⛔ - * never a second reading of the dialect anywhere. Those two doors also compile - * every slot's `{{ }}` holes (`validateExpression('template', …)`), which the - * spec cannot do itself: the template engine is a runtime. + * and `objectstack validate` call it on every slot + * {@link flowNodeTextSlotSources} finds — ⛔ never a second reading of the + * dialect anywhere. Those two doors then compile each slot's `{{ }}` holes + * (`validateExpression('template', …)`), which the spec cannot do itself: the + * template engine is a runtime. * * ## Refused, not converted (ADR-0087 D2) * @@ -113,7 +114,7 @@ export const FLOW_NODE_TEXT_SLOTS: readonly FlowNodeTextSlot[] = [ * for the notify pair). Anything else carries no text this judge reads: the * slot's own contract refuses its shape. */ -export function textSlotSource(value: unknown): string | undefined { +function textSlotSource(value: unknown): string | undefined { if (typeof value === 'string') return value; if (value !== null && typeof value === 'object' && !Array.isArray(value)) { const envelope = value as { dialect?: unknown; source?: unknown }; @@ -198,8 +199,8 @@ export interface FlowNodeTextSlotSource { /** * The text slots one node's `config` carries, with their template text — what - * a door compiles as a `template` (`validateExpression('template', …)`) and - * what {@link flowNodeTextTemplateRefusals} judges. A slot that is absent, or + * a door judges with {@link textSlotTemplateRefusal} and then compiles as a + * `template` (`validateExpression('template', …)`). A slot that is absent, or * whose value is neither a string nor a template envelope, is skipped. */ export function flowNodeTextSlotSources(nodeType: string, config: unknown): FlowNodeTextSlotSource[] { @@ -212,23 +213,3 @@ export function flowNodeTextSlotSources(nodeType: string, config: unknown): Flow } return out; } - -/** One refused text slot in a node's config, located for a door's report. */ -export interface FlowNodeTextTemplateRefusal extends FlowNodeTextSlotSource { - /** {@link TEXT_SLOT_TEMPLATE_REFUSAL}, then the remedy. */ - readonly message: string; -} - -/** - * Every single-brace refusal in one node's `config` — the call the build door - * (`objectstack validate`) and `AutomationEngine.registerFlow` share, so the - * two give one verdict, and the one the three node contracts give at parse. - */ -export function flowNodeTextTemplateRefusals(nodeType: string, config: unknown): FlowNodeTextTemplateRefusal[] { - const out: FlowNodeTextTemplateRefusal[] = []; - for (const slot of flowNodeTextSlotSources(nodeType, config)) { - const message = textSlotTemplateRefusal(slot.source); - if (message !== undefined) out.push({ ...slot, message }); - } - return out; -} diff --git a/packages/spec/src/automation/io-node-config.test.ts b/packages/spec/src/automation/io-node-config.test.ts index 59dd5d5612b..0bd8e4ac802 100644 --- a/packages/spec/src/automation/io-node-config.test.ts +++ b/packages/spec/src/automation/io-node-config.test.ts @@ -24,6 +24,7 @@ import { TYPED_EXPRESSION_SOURCE_REQUIRED, tmpl, } from '../shared/expression.zod.js'; +import { TEXT_SLOT_TEMPLATE_REFUSAL } from './flow-text-slot-template.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 { @@ -381,7 +382,7 @@ describe('NotifyConfigSchema — an unknown key is refused, not stripped', () => // 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}'; + 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 }> { @@ -397,7 +398,7 @@ describe('NotifyConfigSchema — an unknown key is refused, not stripped', () => const envelope = NotifyConfigSchema.safeParse({ recipients: ['u1'], title: { dialect: 'template', source: TEXT }, - message: tmpl`[{record.priority}] {record.subject}`, + 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); @@ -412,15 +413,16 @@ describe('NotifyConfigSchema — an unknown key is refused, not stripped', () => }); // A refusal is the one place an author is told exactly what to write, and - // an AI author writes it verbatim. These two slots' renderer is the flow - // interpolator, which reads single-brace `{token}` only, so their refusals - // prescribe `{record.name}` — never the shared template input's - // `{{record.name}}`, which the build's `flow-double-brace-interpolation` - // rule flags on this very node and the executor would send with a stray - // pair of braces (#22081). The lint round trip of each prescribed spelling - // is pinned in `@objectstack/lint` (`lint-flow-patterns.test.ts`). - const BARE_PRESCRIPTION = "`'{record.name}'`"; - const ENVELOPE_PRESCRIPTION = "`{ dialect: 'template', source: '{record.name}' }`"; + // an AI author writes it verbatim. Since protocol 18 these two slots' + // renderer is the formula template engine (#22110, ADR-0032 D3), so their + // refusals prescribe its `{{ }}` hole — never the 17.x single-brace + // `{record.name}`, which is now refused on this very slot. The lint round + // trip of each prescribed spelling is pinned in `@objectstack/lint` + // (`lint-flow-patterns.test.ts`). + const BARE_PRESCRIPTION = "`'{{ record.name }}'`"; + const ENVELOPE_PRESCRIPTION = "`{ dialect: 'template', source: '{{ record.name }}' }`"; + /** A single-brace `{record.name}` placeholder — the 17.x prescription. */ + const SINGLE_BRACE_PLACEHOLDER = /(^|[^{])\{record\.name\}(?!\})/; const BLANK = ['', ' ']; const FOREIGN = [42, true, ['a'], { source: TEXT }, { dialect: 'cel', source: 'record.x' }]; @@ -430,7 +432,7 @@ describe('NotifyConfigSchema — an unknown key is refused, not stripped', () => return [issue.message, ...nested.flatMap(messagesIn)]; } - it('refuses a blank bare string, or a value that is neither a string nor a template envelope, with one issue prescribing `{record.name}`', () => { + it('refuses a blank bare string, or a value that is neither a string nor a template envelope, with one issue prescribing `{{ record.name }}`', () => { for (const key of ['title', 'message'] as const) { const sentences = new Map<'blank' | 'foreign', Set>([['blank', new Set()], ['foreign', new Set()]]); for (const [kind, values] of [['blank', BLANK], ['foreign', FOREIGN]] as const) { @@ -452,21 +454,35 @@ describe('NotifyConfigSchema — an unknown key is refused, not stripped', () => } }); - it('carries no doubled brace anywhere in the refusal — the branch issues the formatters expand included', () => { + it('prescribes no single-brace placeholder anywhere in the refusal — the branch issues the formatters expand included', () => { // `formatZodIssue` and the wire mapper both expand an `invalid_union`'s - // branches beneath its own line, so a branch still naming the shared - // input's `{{record.name}}` would reach the author under the right one. + // branches beneath its own line, so a branch still naming the 17.x + // `{record.name}` would reach the author under the right one. for (const key of ['title', 'message'] as const) { for (const value of [...BLANK, ...FOREIGN]) { const result = NotifyConfigSchema.safeParse({ recipients: ['u1'], title: 'x', [key]: value }); const refusal = result.error!.issues.find((i) => i.path.length === 1 && i.path[0] === key)!; const messages = messagesIn(refusal as unknown as { message: string }); expect(messages.length, `${key} = ${JSON.stringify(value)}: the tree was not read`).toBeGreaterThan(0); - expect(messages.filter((m) => m.includes('{{')), `${key} = ${JSON.stringify(value)}`).toEqual([]); + expect(messages.filter((m) => SINGLE_BRACE_PLACEHOLDER.test(m)), `${key} = ${JSON.stringify(value)}`).toEqual([]); } } }); + it('refuses a single-brace token left from the 17.x dialect, in either spelling, naming its hole spelling (#22110)', () => { + for (const key of ['title', 'message'] as const) { + for (const value of ['Deal {record.name} won', tmpl`Deal {record.name} won`]) { + const label = `${key} = ${JSON.stringify(value)}`; + const issues = issuesAt({ recipients: ['u1'], title: 'x', [key]: value }, key); + expect(issues.map((i) => i.code), label).toEqual(['custom']); + expect(issues[0]!.message.startsWith(TEXT_SLOT_TEMPLATE_REFUSAL), label).toBe(true); + expect(issues[0]!.message, label).toContain('`Deal {{ record.name }} won`'); + } + } + // A hole is the spelling, and a hole whose path starts with `$` is one. + expect(NotifyConfigSchema.safeParse({ recipients: ['u1'], title: 'Failed: {{ $error.message }}' }).success).toBe(true); + }); + it('reaches the build with the same prescription — the flow judge `FlowSchema`, `registerFlow` and `os validate` share', () => { for (const key of ['title', 'message'] as const) { for (const value of [' ', 42]) { @@ -474,13 +490,13 @@ describe('NotifyConfigSchema — an unknown key is refused, not stripped', () => .filter((r) => r.path === key); expect(refusals.map((r) => r.code), `${key} = ${JSON.stringify(value)}`).toEqual(['node-config-refused-by-contract']); expect(refusals[0]!.message).toContain(BARE_PRESCRIPTION); - expect(refusals[0]!.message).not.toContain('{{'); + expect(refusals[0]!.message).not.toMatch(SINGLE_BRACE_PLACEHOLDER); } } }); - it('control — the shared template input, whose renderers read `{{var}}`, keeps prescribing `{{record.name}}`', () => { - // The notify slots took their own sentences; the shared one did not + it('control — the shared template input keeps its own sentences: the notify ones name the slot', () => { + // The notify slots take their own sentences; the shared one did not // move. `typed-expression-envelope-dialect.test.ts` pins it at the slots // that answer with it (`titleFormat`, the prompt template). expect(TYPED_EXPRESSION_SOURCE_REQUIRED.template).toContain("`'{{record.name}}'`"); @@ -512,11 +528,11 @@ describe('NotifyConfigSchema — an unknown key is refused, not stripped', () => 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` }, + { 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) + expect(NotifyConfigSchema.safeParse({ recipients: ['u1'], title: tmpl`Deal {{ record.name }} won` }).success) .toBe(true); }); @@ -526,7 +542,7 @@ describe('NotifyConfigSchema — an unknown key is refused, not stripped', () => 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}`'); + expect(doc).toContain('`{{ }}`'); // The text is interpolated, so "sent verbatim" was never true of it. expect(doc).not.toMatch(/verbatim/); } From 449986344761402198e04878b79e5878ccbc75e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 12:41:00 +0000 Subject: [PATCH 04/17] wip: regenerate api-surface/export-origins (#22110) Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- packages/spec/api-surface/automation.json | 3 --- packages/spec/export-origins/automation.json | 3 --- 2 files changed, 6 deletions(-) diff --git a/packages/spec/api-surface/automation.json b/packages/spec/api-surface/automation.json index c3d36320425..7f8ac8abdb0 100644 --- a/packages/spec/api-surface/automation.json +++ b/packages/spec/api-surface/automation.json @@ -146,7 +146,6 @@ "FlowNodeSchema (const)", "FlowNodeTextSlot (interface)", "FlowNodeTextSlotSource (interface)", - "FlowNodeTextTemplateRefusal (interface)", "FlowNodeValueTemplateRefusal (interface)", "FlowParsed (type)", "FlowRegion (type)", @@ -285,7 +284,6 @@ "flowForm (const)", "flowNodeConfigRefusals (function)", "flowNodeTextSlotSources (function)", - "flowNodeTextTemplateRefusals (function)", "flowNodeValueTemplateRefusals (function)", "getApprovalNodeConfigJsonSchema (function)", "getBuiltinNodeConfigContracts (function)", @@ -302,7 +300,6 @@ "resolveFlowTriggerKind (function)", "resolveScheduleOrganization (function)", "structuralConditionRefusal (function)", - "textSlotSource (function)", "textSlotTemplateRefusal (function)", "validateControlFlow (function)", "valueSlotTemplateRefusals (function)" diff --git a/packages/spec/export-origins/automation.json b/packages/spec/export-origins/automation.json index 8b186d116c1..dc94e10c719 100644 --- a/packages/spec/export-origins/automation.json +++ b/packages/spec/export-origins/automation.json @@ -141,7 +141,6 @@ "FlowNodeSchema": "src/automation/flow.zod.ts#FlowNodeSchema (const)", "FlowNodeTextSlot": "src/automation/flow-text-slot-template.ts#FlowNodeTextSlot (interface)", "FlowNodeTextSlotSource": "src/automation/flow-text-slot-template.ts#FlowNodeTextSlotSource (interface)", - "FlowNodeTextTemplateRefusal": "src/automation/flow-text-slot-template.ts#FlowNodeTextTemplateRefusal (interface)", "FlowNodeValueTemplateRefusal": "src/automation/flow-value-slot-template.ts#FlowNodeValueTemplateRefusal (interface)", "FlowParsed": "src/automation/flow.zod.ts#FlowParsed (type)", "FlowRegion": "src/automation/control-flow.zod.ts#FlowRegion (type)", @@ -279,7 +278,6 @@ "flowForm": "src/automation/flow.form.ts#flowForm (const)", "flowNodeConfigRefusals": "src/automation/flow-node-config-refusals.ts#flowNodeConfigRefusals (function)", "flowNodeTextSlotSources": "src/automation/flow-text-slot-template.ts#flowNodeTextSlotSources (function)", - "flowNodeTextTemplateRefusals": "src/automation/flow-text-slot-template.ts#flowNodeTextTemplateRefusals (function)", "flowNodeValueTemplateRefusals": "src/automation/flow-value-slot-template.ts#flowNodeValueTemplateRefusals (function)", "getApprovalNodeConfigJsonSchema": "src/automation/approval.zod.ts#getApprovalNodeConfigJsonSchema (function)", "getBuiltinNodeConfigContracts": "src/automation/flow-node-config-refusals.ts#getBuiltinNodeConfigContracts (function)", @@ -296,7 +294,6 @@ "resolveFlowTriggerKind": "src/automation/flow-trigger-kind.ts#resolveFlowTriggerKind (function)", "resolveScheduleOrganization": "src/automation/schedule-organization.zod.ts#resolveScheduleOrganization (function)", "structuralConditionRefusal": "src/automation/flow-node-expression-paths.ts#structuralConditionRefusal (function)", - "textSlotSource": "src/automation/flow-text-slot-template.ts#textSlotSource (function)", "textSlotTemplateRefusal": "src/automation/flow-text-slot-template.ts#textSlotTemplateRefusal (function)", "validateControlFlow": "src/automation/control-flow.zod.ts#validateControlFlow (function)", "valueSlotTemplateRefusals": "src/automation/flow-value-slot-template.ts#valueSlotTemplateRefusals (function)" From b31e69bfc5dd93be1f09fdf19370a9af0a35b500 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 12:47:50 +0000 Subject: [PATCH 05/17] wip: service-automation text-slot renderer pins and test fixtures (#22110) Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- .../src/builtin/config-parse.test.ts | 4 +- .../src/builtin/map-refused-rollup.test.ts | 6 +- .../src/builtin/notify-node.test.ts | 6 +- .../src/builtin/notify-template-slots.test.ts | 28 +- .../builtin/subflow-refused-rollup.test.ts | 4 +- .../src/builtin/text-slot-template.test.ts | 264 ++++++++++++++++++ .../src/end-node-refused-outcome.test.ts | 40 ++- .../src/resumed-leg-refusal-rollup.test.ts | 4 +- 8 files changed, 309 insertions(+), 47 deletions(-) create mode 100644 packages/services/service-automation/src/builtin/text-slot-template.test.ts diff --git a/packages/services/service-automation/src/builtin/config-parse.test.ts b/packages/services/service-automation/src/builtin/config-parse.test.ts index 5d1543d4b85..cb878a60b01 100644 --- a/packages/services/service-automation/src/builtin/config-parse.test.ts +++ b/packages/services/service-automation/src/builtin/config-parse.test.ts @@ -168,10 +168,10 @@ describe('execute-time config parse (#4277)', () => { expect(result.error).toContain('config.title'); }); - it('string slots parse RAW templates — a `{token}` recipients/title passes', async () => { + it('string slots parse RAW templates — a `{token}` recipient and a `{{ }}` title pass', async () => { const engine = engineWith(); engine.registerFlow('f', flowWith('notify', { - recipients: '{who}', title: 'Hi {who}', + recipients: '{who}', title: 'Hi {{ who }}', }, { variables: [{ name: 'who', type: 'text', isInput: true }] })); // No messaging service wired → the node degrades to a skipped success; diff --git a/packages/services/service-automation/src/builtin/map-refused-rollup.test.ts b/packages/services/service-automation/src/builtin/map-refused-rollup.test.ts index 6a75c28a681..c0130a57130 100644 --- a/packages/services/service-automation/src/builtin/map-refused-rollup.test.ts +++ b/packages/services/service-automation/src/builtin/map-refused-rollup.test.ts @@ -41,12 +41,12 @@ function pluginCtx(): any { /** The parent's own completion toast — it must never ride an item's refusal. */ const PARENT_TOAST = 'Batch complete!'; /** - * The authored refusal template. `{val}` is the child's own declared INPUT + * The authored refusal template. `{{ val }}` is the child's own declared INPUT * variable (the mapped item, handed down as `params.val`) — what makes the - * rendered reason per-item. ⛔ Not the parent's `{item}` iterator: that lives in + * rendered reason per-item. ⛔ Not the parent's `item` iterator: that lives in * the PARENT's variable map and resolves to the empty string down here. */ -const REFUSAL_TEMPLATE = 'Refused: {val} is not eligible'; +const REFUSAL_TEMPLATE = 'Refused: {{ val }} is not eligible'; /** Per-item #4354 work, so the rollup on the refusal path is assertable. */ const ITEM_METRICS = { selected: 2, acted: 1 } as const; diff --git a/packages/services/service-automation/src/builtin/notify-node.test.ts b/packages/services/service-automation/src/builtin/notify-node.test.ts index ea87828abde..946a3fb19dc 100644 --- a/packages/services/service-automation/src/builtin/notify-node.test.ts +++ b/packages/services/service-automation/src/builtin/notify-node.test.ts @@ -119,8 +119,8 @@ describe('notify (baseline node)', () => { engine.registerFlow('notify_flow', notifyFlow({ topic: 'deal.won', recipients: ['user_1', 'user_2'], - title: 'Deal {dealName} closed', - message: 'Congrats on {dealName}', + title: 'Deal {{ dealName }} closed', + message: 'Congrats on {{ dealName }}', channels: ['inbox', 'email'], severity: 'info', actionUrl: '/opps/{dealId}', @@ -149,7 +149,7 @@ describe('notify (baseline node)', () => { it('forwards a click-through target via sourceObject/sourceId, interpolating the id (#2675)', async () => { engine.registerFlow('notify_flow', notifyFlow({ recipients: ['user_1'], - title: 'Quote {dealName} approved', + title: 'Quote {{ dealName }} approved', message: 'Fill in the line items', channels: ['inbox'], sourceObject: 'mtc_quotation', 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 06ced6ffa1c..434296c34eb 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 @@ -4,16 +4,16 @@ * `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. + * `NotifyConfigSchema` types both keys with the template input + * (`templateExpressionInput`), 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 it renders the + * envelope's `source` — never the envelope serialized. Since #22110 that text + * is a `{{ }}` template (ADR-0032 D3), rendered by the formula template engine + * over the flow's variables; these pins hold the two spellings to the same + * delivered `payload.title` / `payload.body`. * * 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. @@ -68,8 +68,8 @@ function notifyFlow(config: Record) { } const PARAMS = { dealName: 'Acme', stage: 'won' }; -const TITLE = '[{stage}] Deal {dealName}'; -const BODY = 'Congrats on {dealName}'; +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', () => { @@ -91,7 +91,7 @@ describe('notify — title / message are template slots', () => { return { result, payload: messaging.emitted[messaging.emitted.length - 1]?.payload }; } - it('renders the bare string exactly as before the slots were typed', async () => { + it('renders the bare string\'s `{{ }}` holes against the flow variables', async () => { const { result, payload } = await deliveredFor({ title: TITLE, message: BODY }); expect(result.success, JSON.stringify(result)).toBe(true); expect(payload).toMatchObject(RENDERED); @@ -99,7 +99,7 @@ describe('notify — title / message are template slots', () => { 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}`, + title: tmpl`[{{ stage }}] Deal {{ dealName }}`, message: { dialect: 'template', source: BODY }, }); expect(result.success, JSON.stringify(result)).toBe(true); diff --git a/packages/services/service-automation/src/builtin/subflow-refused-rollup.test.ts b/packages/services/service-automation/src/builtin/subflow-refused-rollup.test.ts index ce390aafd4e..0e786146d2f 100644 --- a/packages/services/service-automation/src/builtin/subflow-refused-rollup.test.ts +++ b/packages/services/service-automation/src/builtin/subflow-refused-rollup.test.ts @@ -42,8 +42,8 @@ function pluginCtx(): any { /** The parent's own completion toast — it must never ride a child's refusal. */ const PARENT_TOAST = 'Parent completed!'; -/** The authored refusal template. `{record.name}` is what makes it per-record. */ -const REFUSAL_TEMPLATE = 'Refused: {record.name} is a confirmed duplicate'; +/** The authored refusal template. `{{ record.name }}` is what makes it per-record. */ +const REFUSAL_TEMPLATE = 'Refused: {{ record.name }} is a confirmed duplicate'; const ACME = { id: 'rec_1', name: 'Acme Corp' } as const; /** diff --git a/packages/services/service-automation/src/builtin/text-slot-template.test.ts b/packages/services/service-automation/src/builtin/text-slot-template.test.ts new file mode 100644 index 00000000000..ebc7eaef76a --- /dev/null +++ b/packages/services/service-automation/src/builtin/text-slot-template.test.ts @@ -0,0 +1,264 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #22110 — the flow TEXT slots read ADR-0032 §3's `{{ }}` delimiter. + * + * A notify `title` / `message`, a screen `title` / `description` and a refusing + * `end` node's `message` render through ONE renderer, `renderTextSlot`: the + * formula template engine's `{{ path }}` / `{{ path | formatter }}` holes over + * the flow's variables. A single-brace `{token}` left from the 17.x dialect is + * refused at registration with its hole spelling — never converted: the two + * renderers answer differently for some value of every token spelling (a + * `Date` rendered JSON-quoted, a whole-slot object `[object Object]`), which + * the renderer pins below hold. + * + * The card's three pins are the first describe block. + */ + +import { describe, expect, it } from 'vitest'; +import type { AutomationContext } from '@objectstack/spec/contracts'; +import { TEXT_SLOT_TEMPLATE_REFUSAL } from '@objectstack/spec/automation'; + +import { AutomationEngine } from '../engine.js'; +import { InMemorySuspendedRunStore } from '../suspended-run-store.js'; +import { isGuardRefusal } from '../guard-refusal.js'; +import { installBuiltinNodes } from './index.js'; +import type { MessagingServiceSurface } from './notify-node.js'; +import { + FlowTextTemplateError, + interpolateString, + renderTextSlot, + stringifyForTemplate, + textTemplateScope, + type VariableMap, +} from './template.js'; + +function createTestLogger(): any { + return { info: () => {}, warn: () => {}, error: () => {}, debug: () => {}, child: () => createTestLogger() }; +} + +function harness() { + const emitted: Array[0]> = []; + const messaging: MessagingServiceSurface = { + async emit(n) { + emitted.push(n); + return { notificationId: 'evt_1', delivered: n.audience.length, failed: 0 }; + }, + }; + const engine = new AutomationEngine(createTestLogger(), new InMemorySuspendedRunStore()); + installBuiltinNodes(engine, { + logger: createTestLogger(), + getService: (name: string) => (name === 'messaging' ? messaging : undefined), + } as never); + return { engine, emitted }; +} + +function notifyFlow(name: string, config: Record) { + return { + name, + label: name, + type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'notify', type: 'notify', label: 'Notify', config: { recipients: ['user_1'], ...config } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'notify' }, + { id: 'e2', source: 'notify', target: 'end' }, + ], + }; +} + +const ACME = { id: 'rec_1', name: 'Acme Corp', amount: 1234.5, tags: ['vip', 'eu'], meta: { tier: 'gold' } }; +const ctx = (record: Record = ACME) => + ({ event: 'manual', object: 'account', record, userId: 'usr_7' }) as unknown as AutomationContext; + +/** The message `registerFlow` threw, or `undefined` when it registered. */ +function registrationRefusal(engine: AutomationEngine, name: string, flow: unknown): string | undefined { + try { + engine.registerFlow(name, flow as never); + return undefined; + } catch (err) { + return (err as Error).message; + } +} + +describe('#22110 — the card\'s pins', () => { + it('a notify title `Hello {{ record.name }}` renders the name', async () => { + const { engine, emitted } = harness(); + engine.registerFlow('hello', notifyFlow('hello', { title: 'Hello {{ record.name }}' }) as never); + const result = await engine.execute('hello', ctx()); + expect(result.success, JSON.stringify(result)).toBe(true); + expect(emitted).toHaveLength(1); + expect(emitted[0]!.payload).toMatchObject({ title: 'Hello Acme Corp' }); + }); + + it('a stored `Hello {record.name}` is REFUSED at registration with its hole spelling — no spelling converts', () => { + const { engine } = harness(); + const refusal = registrationRefusal(engine, 'legacy', notifyFlow('legacy', { title: 'Hello {record.name}' })); + expect(refusal).toBeDefined(); + expect(refusal).toContain("node 'notify' (notify) notify title at config.title"); + expect(refusal).toContain(TEXT_SLOT_TEMPLATE_REFUSAL); + expect(refusal).toContain('`Hello {{ record.name }}`'); + }); + + it('control: `{{ }}` pasted into a CEL predicate still fails to parse, loudly, at registration', () => { + const { engine } = harness(); + const flow = notifyFlow('pasted', { title: 'Hello {{ record.name }}' }); + (flow.edges[0] as Record).condition = "{{ record.name }} == 'Acme Corp'"; + const refusal = registrationRefusal(engine, 'pasted', flow); + expect(refusal).toBeDefined(); + expect(refusal).toContain('condition'); + }); +}); + +describe('#22110 — the doors around a text slot', () => { + it('refuses a single-brace token in every text slot at registration — a screen title / description and an end message too', () => { + const { engine } = harness(); + const screen = { + name: 's', label: 's', type: 'screen', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'ask', type: 'screen', label: 'Ask', config: { waitForInput: true, title: 'Hi {name}', description: 'About {record.name}' } }, + ], + edges: [{ id: 'e0', source: 'start', target: 'ask' }], + }; + const screenRefusal = registrationRefusal(engine, 's', screen)!; + expect(screenRefusal).toContain('`Hi {{ name }}`'); + expect(screenRefusal).toContain('`About {{ record.name }}`'); + const end = { + name: 'e', label: 'e', type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'finish', type: 'end', label: 'Finish', config: { outcome: 'refused', message: 'No: {record.name}' } }, + ], + edges: [{ id: 'e0', source: 'start', target: 'finish' }], + }; + expect(registrationRefusal(engine, 'e', end)).toContain('`No: {{ record.name }}`'); + }); + + it('compiles a text slot at registration — a hole holding logic or an unknown formatter is refused there, not mid-run', () => { + const { engine } = harness(); + for (const title of ['Total {{ amount * 2 }}', 'Total {{ amount | bogus }}', 'Total {{ amount']) { + const refusal = registrationRefusal(engine, 'compile', notifyFlow('compile', { title })); + expect(refusal, title).toContain('notify title at config.title'); + expect(refusal, title).toContain('invalid template'); + } + }); + + it('past the doors, the executor refuses the single-brace token at its contract parse, and a hole that does not compile as a guard — nothing is sent', async () => { + for (const [title, expected] of [ + ['Hello {record.name}', TEXT_SLOT_TEMPLATE_REFUSAL], + ['Hello {{ record.name | bogus }}', 'invalid template hole'], + ] as const) { + const { engine, emitted } = harness(); + const stored = engine.registerFlow('past', notifyFlow('past', { title: 'Hello' }) as never); + const node = stored.nodes.find((n) => n.id === 'notify')!; + node.config = { ...node.config, title }; + const result = await engine.execute('past', ctx()); + expect(result.success, title).toBe(false); + expect(String(result.error), title).toContain(expected); + expect(emitted, title).toHaveLength(0); + } + }); + + it('a fault handler names the caught error with `{{ $error.message }}` — a `$`-named variable is a hole path', async () => { + const { engine, emitted } = harness(); + engine.registerNodeExecutor({ + type: 'boom', + async execute() { + return { success: false, error: 'connection refused' }; + }, + } as never); + engine.registerFlow('faulty', { + name: 'faulty', label: 'faulty', type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'push', type: 'boom', label: 'Push' }, + { id: 'notify', type: 'notify', label: 'Notify', config: { recipients: ['user_1'], title: 'Push failed for {{ record.name }}', message: 'Reason: {{ $error.message }}' } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'push' }, + { id: 'e2', source: 'push', target: 'end' }, + { id: 'e3', source: 'push', target: 'notify', type: 'fault' }, + ], + } as never); + await engine.execute('faulty', ctx()); + expect(emitted).toHaveLength(1); + expect(emitted[0]!.payload).toMatchObject({ title: 'Push failed for Acme Corp', body: 'Reason: connection refused' }); + }); + + it('a screen renders its title / description holes, and its `recordId` keeps the single-brace dialect', async () => { + const { engine } = harness(); + engine.registerFlow('edit', { + name: 'edit', label: 'edit', type: 'screen', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'form', type: 'screen', label: 'Edit', config: { objectName: 'account', mode: 'edit', recordId: '{record.id}', title: 'Edit {{ record.name }}', description: 'Tier {{ record.meta.tier | upper }}' } }, + ], + edges: [{ id: 'e0', source: 'start', target: 'form' }], + } as never); + const paused = await engine.execute('edit', ctx()); + expect(paused.screen).toMatchObject({ title: 'Edit Acme Corp', description: 'Tier GOLD', recordId: 'rec_1' }); + }); +}); + +describe('#22110 — renderTextSlot, the one text renderer', () => { + const vars = (entries: Record): VariableMap => new Map(Object.entries(entries)); + + it('reads every flow variable as a root, a node output by its dotted key, an index either way', () => { + const variables = vars({ record: ACME, 'lookup.result': 'found', rows: [{ subject: 'S0' }] }); + expect(renderTextSlot('{{ record.name }}|{{ lookup.result }}|{{ rows.0.subject }}|{{ rows[0].subject }}', variables)) + .toBe('Acme Corp|found|S0|S0'); + }); + + it('lets a declared variable win over a flat key sharing its head, and never writes into a variable\'s object', () => { + const record = { name: 'Acme Corp' }; + const scope = textTemplateScope(vars({ record, 'record.name': 'shadow', 'n1.out': 1 })); + expect(renderTextSlot('{{ record.name }}', vars({ record, 'record.name': 'shadow' }))).toBe('Acme Corp'); + expect(record).toEqual({ name: 'Acme Corp' }); + expect(scope.n1).toEqual({ out: 1 }); + }); + + it('renders absent in, absent out — and a slot rendering no text at all as nothing', () => { + expect(renderTextSlot(undefined, vars({}))).toBeUndefined(); + expect(renderTextSlot('{{ missing }}', vars({}))).toBeUndefined(); + expect(renderTextSlot('[{{ missing }}]', vars({}))).toBe('[]'); + }); + + it('measured against the 17.x interpolator: SAME on a string, number, boolean, null and an ISO date string, DIFF on a Date and a whole-slot object', () => { + const old = (source: string, variables: VariableMap) => + stringifyForTemplate(interpolateString(source, variables, {} as AutomationContext)); + for (const value of ['Acme', 1234.5, 0, true, null, '2026-10-08']) { + const variables = vars({ x: value }); + expect(renderTextSlot('v={{ x }}', variables), String(value)).toBe(old('v={x}', variables)); + } + // The two inputs that make the path spelling lossy (ADR-0087 D2), so no + // conversion rewrites `{x}` to `{{ x }}`: a Date rendered JSON-quoted… + const date = new Date('2026-10-08T09:30:00.000Z'); + expect(old('v={x}', vars({ x: date }))).toBe('v="2026-10-08T09:30:00.000Z"'); + expect(renderTextSlot('v={{ x }}', vars({ x: date }))).toBe('v=2026-10-08T09:30:00.000Z'); + // …and a screen / end text that was one token holding an object rendered `String(value)`. + expect(String(interpolateString('{x}', vars({ x: { a: 1 } }), {} as AutomationContext))).toBe('[object Object]'); + expect(renderTextSlot('{{ x }}', vars({ x: { a: 1 } }))).toBe('{"a":1}'); + }); + + it('refuses a template that does not compile with a guard refusal, never a half-filled text', () => { + for (const source of ['{{ a + b }}', '{{ x | bogus }}', '{{ x']) { + let thrown: unknown; + try { + renderTextSlot(source, vars({ a: 1, b: 2, x: 'v' })); + } catch (err) { + thrown = err; + } + expect(thrown, source).toBeInstanceOf(FlowTextTemplateError); + expect(isGuardRefusal(thrown), source).toBe(true); + } + }); + + it('leaves a single-brace token as literal text — the doors refuse it before a run, so the renderer never reads it', () => { + expect(renderTextSlot('Hello {record.name}', vars({ record: ACME }))).toBe('Hello {record.name}'); + }); +}); diff --git a/packages/services/service-automation/src/end-node-refused-outcome.test.ts b/packages/services/service-automation/src/end-node-refused-outcome.test.ts index e3996301b87..036bd66574b 100644 --- a/packages/services/service-automation/src/end-node-refused-outcome.test.ts +++ b/packages/services/service-automation/src/end-node-refused-outcome.test.ts @@ -43,8 +43,8 @@ function createTestLogger(): any { /** The flow author's completion toast — must never ride a refusal. */ const SUCCESS_TEXT = 'Account created — the owner has been notified.'; -/** The authored refusal template. `{record.name}` is what makes it per-record. */ -const REFUSAL_TEMPLATE = 'Refused: {record.name} is a confirmed duplicate'; +/** The authored refusal template. `{{ record.name }}` is what makes it per-record. */ +const REFUSAL_TEMPLATE = 'Refused: {{ record.name }} is a confirmed duplicate'; /** * A two-node flow whose `end` node carries the config under test. @@ -115,7 +115,7 @@ describe('#15788 — the defect: a refusing `end` ran as a plain completion', () expect(second.refusalMessage).toBe('Refused: Globex Industries is a confirmed duplicate'); // The template itself never reaches a caller — that is the whole point // of rendering it here rather than on the wire. - expect(first.refusalMessage).not.toContain('{record.name}'); + expect(first.refusalMessage).not.toContain('{{'); }); it('the run RECORD carries the outcome and the rendered message', async () => { @@ -315,29 +315,27 @@ describe('#15788 — the boundary: what must NOT change', () => { }); }); -describe('#15788 — one interpolator, not a second template engine', () => { +describe('#15788 — one text renderer, not a second template engine', () => { /** * The ruling: the refusal message goes through *the same interpolation a - * screen `description` gets*. Asserting "it substitutes `{record.name}`" is - * far too weak — a hand-rolled `replace` would pass it. These drive both - * slots with templates whose behaviour is SPECIFIC to - * `builtin/template.ts`, and compare the two renderings for equality. + * screen `description` gets* — since #22110 the formula template engine's + * `{{ }}` holes, through `renderTextSlot`. Asserting "it substitutes + * `{{ record.name }}`" is far too weak — a hand-rolled `replace` would pass + * it. These drive both slots with templates whose behaviour is SPECIFIC to + * that engine, and compare the two renderings for equality. */ const PROBES = [ // Dotted path walk. - '{record.name}', - // Numeric segment indexing into an array. - 'first={record.tags.0}', - // Context token, resolved from `AutomationContext`, not from variables. - 'by {$User.Id}', - // The CEL-mirrored numeric stdlib (commit 815585513) — nothing a naive - // substitution implements. - 'score {round(record.score)}', - // Unresolvable embedded token renders as the empty string, not the - // literal token and not `undefined`. - 'missing[{record.nope}]', - // Object-valued token is JSON-serialized, never `[object Object]` (#3450). - 'blob {record.meta}', + '{{ record.name }}', + // Numeric segment indexing into an array, both spellings. + 'first={{ record.tags.0 }} second={{ record.tags[1] }}', + // A whitelisted formatter (ADR-0032 §3) — nothing a naive substitution implements. + 'score {{ record.score | number:2 }} {{ record.name | upper }}', + // Unresolvable embedded hole renders as the empty string, not the + // literal hole and not `undefined`. + 'missing[{{ record.nope }}]', + // Object-valued hole is JSON-serialized, never `[object Object]` (#3450). + 'blob {{ record.meta }}', ]; it('renders a refusal `message` byte-identically to a screen `description`', async () => { diff --git a/packages/services/service-automation/src/resumed-leg-refusal-rollup.test.ts b/packages/services/service-automation/src/resumed-leg-refusal-rollup.test.ts index 3e3196b2e0d..25073de8dd4 100644 --- a/packages/services/service-automation/src/resumed-leg-refusal-rollup.test.ts +++ b/packages/services/service-automation/src/resumed-leg-refusal-rollup.test.ts @@ -55,8 +55,8 @@ function pluginCtx() { /** The parent's own completion toast — it must never ride a child's refusal. */ const PARENT_TOAST = 'Parent completed!'; -/** The authored refusal template. `{kind}` is what makes the text per-record. */ -const REFUSAL_TEMPLATE = 'Refused: {kind} is not eligible'; +/** The authored refusal template. `{{ kind }}` is what makes the text per-record. */ +const REFUSAL_TEMPLATE = 'Refused: {{ kind }} is not eligible'; /** …rendered in the CHILD against the value the screen collected. */ const RENDERED_REFUSAL = 'Refused: vip is not eligible'; /** From e2fcb5645e1d02a7e2aafb906f644b9bb82bb501 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 12:52:34 +0000 Subject: [PATCH 06/17] wip: lint door/test pins, dogfood fixtures, descriptor help (#22110) Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- packages/lint/src/lint-flow-patterns.test.ts | 57 ++++++--- .../lint/src/validate-expressions.test.ts | 5 + .../validate-expressions.text-slot.test.ts | 108 ++++++++++++++++++ .../src/validate-flow-template-paths.test.ts | 16 +++ .../test/expression-conformance.ledger.ts | 6 +- .../fixtures/schedule-organization-fixture.ts | 4 +- ...d-ownership-claim-dispatch.dogfood.test.ts | 4 +- .../src/builtin/screen-nodes.ts | 4 +- .../src/automation/builtin-node-config.zod.ts | 4 +- .../automation/flow-node-expression-paths.ts | 6 +- .../flow-region-pause-and-end.test.ts | 2 +- packages/spec/src/shared/expression.zod.ts | 14 +-- 12 files changed, 196 insertions(+), 34 deletions(-) create mode 100644 packages/lint/src/validate-expressions.text-slot.test.ts diff --git a/packages/lint/src/lint-flow-patterns.test.ts b/packages/lint/src/lint-flow-patterns.test.ts index 0515327c7fe..3118084f7a6 100644 --- a/packages/lint/src/lint-flow-patterns.test.ts +++ b/packages/lint/src/lint-flow-patterns.test.ts @@ -1651,7 +1651,9 @@ describe('#5383 — the branch-routing family reads the region’s own edges', ( describe('#5383 — a recursive config scan does not double-report the container', () => { it('moves a nested double-brace finding onto the node carrying it, still exactly once', () => { const fnds = lintFlowPatterns(loopBodyFlow({ - nodes: [{ id: 'send_reminder', type: 'notify', config: { title: 'Reminder: {{lead.name}}' } }], + // A single-brace position (`recipients`): the notify `title` reads + // `{{ }}` since #22110, so a doubled brace there is no finding. + nodes: [{ id: 'send_reminder', type: 'notify', config: { title: 'Reminder: {{ lead.name }}', recipients: ['{{lead.owner}}'] } }], edges: [], // Scoped to this rule: the body's `notify` also trips the #14394 // containment warning. @@ -2422,15 +2424,16 @@ describe('#16405 — an `http` node payload is not a region, and both #1315 rule /** * [#22081] The round trip an author makes after a refusal: write what it * prescribes, then build. A notify node's `title` / `message` refusal - * (`NotifyConfigSchema`, `@objectstack/spec`) used to prescribe the shared - * template input's `{{record.name}}` — which this rule then flagged on the - * same node. Every spelling the refusal prescribes, read out of the refusal - * text itself rather than re-spelled here, must parse and draw no finding. - * The control is the shared sentence the notify slots used to answer with: - * its prescriptions still draw the finding, so this pin can fail. + * (`NotifyConfigSchema`, `@objectstack/spec`) must never prescribe a + * spelling this rule then flags on the same node. Every spelling the + * refusal prescribes, read out of the refusal text itself rather than + * re-spelled here, must parse and draw no finding. Since #22110 the two + * slots read `{{ }}` and the prescription is a hole; the control is the + * same spelling on the node's single-brace `actionUrl`, which still draws + * the finding, so this pin can fail. */ describe('a notify slot refusal prescribes only spellings this rule passes', () => { - function notifyFlow(key: 'title' | 'message', value: unknown) { + function notifyFlow(key: 'title' | 'message' | 'actionUrl', value: unknown) { return { flows: [{ name: 'deal_won_notice', @@ -2453,7 +2456,7 @@ describe('#16405 — an `http` node payload is not a region, and both #1315 rule return [bare, envelope === undefined ? undefined : { dialect: 'template', source: envelope }]; } - function doubleBraceFindings(key: 'title' | 'message', value: unknown) { + function doubleBraceFindings(key: 'title' | 'message' | 'actionUrl', value: unknown) { return lintFlowPatterns(notifyFlow(key, value)).filter((f) => f.rule === FLOW_DOUBLE_BRACE_INTERP); } @@ -2476,13 +2479,13 @@ describe('#16405 — an `http` node payload is not a region, and both #1315 rule } }); - it('control: the shared template sentence\'s prescriptions draw the finding on a notify node', () => { + it('control: the same prescribed hole draws the finding on the node\'s single-brace `actionUrl`', () => { for (const sentence of [TYPED_EXPRESSION_SOURCE_REQUIRED.template, TYPED_EXPRESSION_DIALECT_ONLY.template]) { const prescribed = prescribedIn(sentence); expect(prescribed.every((p) => p !== undefined), sentence).toBe(true); - for (const spelling of prescribed) { - expect(doubleBraceFindings('title', spelling), JSON.stringify(spelling)).toHaveLength(1); - } + const bare = prescribed[0] as string; + expect(doubleBraceFindings('actionUrl', `/deals/${bare}`), bare).toHaveLength(1); + expect(doubleBraceFindings('title', bare), bare).toEqual([]); } }); }); @@ -2503,6 +2506,34 @@ describe('#16405 — an `http` node payload is not a region, and both #1315 rule expect(fnds).toHaveLength(1); expect(fnds[0].where).toBe("flow 'incident_push' · try_catch 'guard' try · node 'push' (http)"); }); + + // [#22110] A text slot reads `{{ }}` holes, where `$error.message` is a + // hole's correct content; a bare one OUTSIDE a hole is still a literal. + describe('in a text slot', () => { + function notifyText(title: string) { + return lintFlowPatterns({ + flows: [{ + name: 'fault_notice', label: 'Fault notice', type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'tell', type: 'notify', label: 'Tell', config: { recipients: ['u1'], title } }, + ], + edges: [{ id: 'e1', source: 'start', target: 'tell' }], + }], + }).filter((f) => f.rule === FLOW_BARE_DOLLAR_REF || f.rule === FLOW_DOUBLE_BRACE_INTERP); + } + + it('raises nothing for a `$`-named variable inside a hole', () => { + expect(notifyText('Failed: {{ $error.message }}')).toEqual([]); + }); + + it('flags a bare `$ref.field` outside the holes, prescribing the hole', () => { + const fnds = notifyText('Failed: $error.message ({{ record.name }})'); + expect(fnds.map((f) => f.rule)).toEqual([FLOW_BARE_DOLLAR_REF]); + expect(fnds[0].message).toContain('notify title'); + expect(fnds[0].hint).toContain('{{ $error.message }}'); + }); + }); }); it('still raises nothing for a correct single-brace payload', () => { diff --git a/packages/lint/src/validate-expressions.test.ts b/packages/lint/src/validate-expressions.test.ts index 320e3db3fc7..0f3ad186306 100644 --- a/packages/lint/src/validate-expressions.test.ts +++ b/packages/lint/src/validate-expressions.test.ts @@ -3033,6 +3033,11 @@ describe('validateStackExpressions — reads only keys the spec declares (meta-t // `grammar` excuse #19938 added here left with the import it excused: // this file no longer imports `'./flow-template-grammar.js'`.) 'templateRefusal', + // [#22110] The spec's text-slot locator, one slot at a time. Its keys are + // that helper's own `{ path, label, source }` — never metadata keys: the + // metadata keys it reads (`title`, `message`, `description`) are named in + // the spec's `FLOW_NODE_TEXT_SLOTS`, not by name here. + 'slot', ]); expect(receivers.filter((r) => !tabled.has(r) && !PLUMBING.has(r))).toEqual([]); }); diff --git a/packages/lint/src/validate-expressions.text-slot.test.ts b/packages/lint/src/validate-expressions.text-slot.test.ts new file mode 100644 index 00000000000..0123bdf1b7d --- /dev/null +++ b/packages/lint/src/validate-expressions.text-slot.test.ts @@ -0,0 +1,108 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #22110 — the `objectstack validate` half of the flow TEXT slots reading + * ADR-0032 §3's `{{ }}` holes: a notify `title` / `message`, a screen `title` / + * `description`, a refusing `end` node's `message`. + * + * Driven through the REGISTRY, the way `os validate` runs it (normalize, parse + * with `ObjectStackDefinitionSchema`, `runAuthoringRules('validate', …)`), so a + * registry adapter that dropped or re-graded the finding reddens here. Two + * checks, in the order `registerFlow` runs them on the same config: + * + * 1. a single-brace `{…}` token left from the 17.x dialect — a located `error` + * under `expression-invalid`, led by `TEXT_SLOT_TEMPLATE_REFUSAL` and naming + * the hole spelling of each token; + * 2. a slot with none is compiled as a `template` — a hole holding logic or an + * unknown formatter is an `error` too. + * + * Every other notify / screen string keeps the single-brace dialect and gets + * nothing here. The `end` message is refused one door earlier, at the flow + * parse (`EndConfigSchema`), so it is pinned on `validateStackExpressions` + * directly — the pass that answers for a stack handed to it with no parse in + * front. + */ + +import { describe, expect, it } from 'vitest'; +import { ObjectStackDefinitionSchema, normalizeStackInput } from '@objectstack/spec'; +import { TEXT_SLOT_TEMPLATE_REFUSAL } from '@objectstack/spec/automation'; +import { runAuthoringRules, EXPRESSION_INVALID } from './authoring-rules.js'; +import { validateStackExpressions } from './validate-expressions.js'; + +function stackWith(nodeType: string, config: Record) { + return { + objects: [{ name: 'deal', label: 'Deal', fields: { name: { type: 'text', label: 'Name' }, amount: { type: 'number', label: 'Amount' } } }], + flows: [{ + name: 'deal_won', + label: 'Deal won', + type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'w', type: nodeType, label: 'Text', config }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'w' }, + { id: 'e2', source: 'w', target: 'end' }, + ], + }], + }; +} + +/** `os validate`'s own sequence, minus the file loader — this node's `expression-invalid` findings. */ +function validate(nodeType: string, config: Record) { + const normalized = normalizeStackInput(stackWith(nodeType, config) as Record); + const parsed = ObjectStackDefinitionSchema.parse(normalized); + return runAuthoringRules('validate', { + normalized: normalized as Record, + parsed: parsed as Record, + }).filter((f) => f.rule === EXPRESSION_INVALID && f.where.includes("node 'w'")); +} + +describe('`objectstack validate` — a flow text slot reads `{{ }}` holes (#22110)', () => { + it('refuses a single-brace token in a notify title / message and a screen title / description, at `error`, with the hole spelling', () => { + const cases: Array<[string, Record, string, string]> = [ + ['notify', { recipients: ['u1'], title: 'Deal {record.name} won' }, 'notify title at config.title', '`Deal {{ record.name }} won`'], + ['notify', { recipients: ['u1'], title: 'Won', message: { dialect: 'template', source: 'By {$error.message}' } }, 'notify message at config.message', '`By {{ $error.message }}`'], + ['screen', { waitForInput: true, title: 'Hi {name}' }, 'screen title at config.title', '`Hi {{ name }}`'], + ['screen', { waitForInput: true, description: 'About {record.name}' }, 'screen description at config.description', '`About {{ record.name }}`'], + ]; + for (const [nodeType, config, where, spelling] of cases) { + const findings = validate(nodeType, config); + expect(findings, JSON.stringify(config)).toHaveLength(1); + expect(findings[0]!.severity).toBe('error'); + expect(findings[0]!.where).toContain(where); + expect(findings[0]!.message.startsWith(TEXT_SLOT_TEMPLATE_REFUSAL)).toBe(true); + expect(findings[0]!.message).toContain(spelling); + } + }); + + it('compiles a slot with no single-brace token — logic, an unknown formatter or an unbalanced hole is an `error`', () => { + for (const title of ['Total {{ amount * 2 }}', 'Total {{ amount | bogus }}', 'Total {{ amount']) { + const findings = validate('notify', { recipients: ['u1'], title }); + expect(findings, title).toHaveLength(1); + expect(findings[0]!.severity, title).toBe('error'); + expect(findings[0]!.message, title).toContain('invalid template'); + } + }); + + it('passes holes, formatters and a `$`-named variable — and every single-brace slot that is not text', () => { + expect(validate('notify', { + recipients: ['{record.owner}'], + sourceObject: 'deal', + sourceId: '{record.id}', + actionUrl: '/deal/{record.id}', + title: 'Deal {{ record.name }} won: {{ record.amount | currency }}', + message: 'Failed: {{ $error.message }}', + })).toEqual([]); + expect(validate('screen', { objectName: 'deal', mode: 'edit', recordId: '{record.id}', title: 'Edit {{ record.name }}' })).toEqual([]); + }); + + it('judges an `end` message too, for a stack handed to `validateStackExpressions` with no parse in front of it', () => { + const issues = validateStackExpressions(stackWith('end', { outcome: 'refused', message: 'No: {record.name}' }) as never) + .filter((i) => i.where.includes("node 'w'")); + expect(issues.map((i) => i.severity)).toEqual(['error']); + expect(issues[0]!.where).toContain('end message at config.message'); + expect(issues[0]!.message).toContain('`No: {{ record.name }}`'); + }); +}); diff --git a/packages/lint/src/validate-flow-template-paths.test.ts b/packages/lint/src/validate-flow-template-paths.test.ts index 526a3c8238f..1d4af09d72e 100644 --- a/packages/lint/src/validate-flow-template-paths.test.ts +++ b/packages/lint/src/validate-flow-template-paths.test.ts @@ -893,6 +893,22 @@ describe('validateFlowTemplatePaths — variable roots (#17305)', () => { expect(findings[0].rule).toBe(FLOW_TEMPLATE_UNKNOWN_FIELD); }); + // [#22110] A text slot reads `{{ }}` holes now; the path inside one is the + // same reference, and judged the same way — the switch of delimiter must not + // blind this check on the slots where most references live. + it('judges a path inside a `{{ }}` hole on a text slot the same way', () => { + const findings = validateFlowTemplatePaths( + scheduleFlow([FETCH_ONE, { id: 'note', type: 'notify', config: { title: 'Case {{ caseRecord.subjcet }}' } }]), + ); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(FLOW_TEMPLATE_UNKNOWN_FIELD); + expect( + validateFlowTemplatePaths( + scheduleFlow([FETCH_ONE, { id: 'note', type: 'notify', config: { title: 'Case {{ caseRecord.subject }}' } }]), + ), + ).toEqual([]); + }); + it('resolves the declared-variable + loop shape examples/app-todo ships', () => { const findings = validateFlowTemplatePaths( scheduleFlow( diff --git a/packages/qa/dogfood/test/expression-conformance.ledger.ts b/packages/qa/dogfood/test/expression-conformance.ledger.ts index 9a3c924c847..2abde789262 100644 --- a/packages/qa/dogfood/test/expression-conformance.ledger.ts +++ b/packages/qa/dogfood/test/expression-conformance.ledger.ts @@ -599,16 +599,16 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [ }, { id: 'template-notify-content', - summary: 'flow `notify` node inline content (NotifyConfig.title, .message) — single-brace `{token}` interpolation per run', + summary: 'flow `notify` node inline content (NotifyConfig.title, .message) — `{{ }}` template holes rendered per run by the formula template engine', 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`)', + '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}` and refuses a single-brace `{token}` left from the 17.x dialect (`flow-text-slot-template.ts`, #22110); then `renderTextSlot(cfg.title?.source, variables)` and the same for `message` — builtin/template.ts `renderTextSlot` → `@objectstack/formula` `templateEngine.evaluate` over `textTemplateScope(variables)` (each flow variable a root, a dotted node-output key nested), which fills `{{ path }}` / `{{ path | formatter }}` holes only. 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`, a single-brace token) is refused by that parse as a guard (`refuseNode`), so the run fails at the node and no `fault` edge routes it; a hole that does not compile (logic, an unknown formatter, unbalanced delimiters) THROWS `FlowTextTemplateError`, 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 hole whose path resolves to nothing renders as empty text with no log, so a `message` goes out with the gap. `registerFlow` and `os validate` (`expression-invalid`, error) refuse a single-brace token with its hole spelling and compile every hole (`validateExpression(\'template\', …)`), so a flow that registers is one whose text slots compile; `os validate` also warns 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.', + note: 'The renderer is `@objectstack/formula` templateEngine since #22110 (ADR-0032 D3) — the `{{ }}` dialect the messaging/email renderers also read — through `renderTextSlot`, the one text renderer a screen `title` / `description` and a refusing `end` `message` share. `throw` is the ADR-0058 D5 flow tier and describes the faults the cell names as refusals; the silent half (an unresolved hole rendering empty) is stated in the cell rather than rounded into the tier.', }, { id: 'cel-advanced-policy', diff --git a/packages/qa/dogfood/test/fixtures/schedule-organization-fixture.ts b/packages/qa/dogfood/test/fixtures/schedule-organization-fixture.ts index 724ad875b6a..00aa5fdc693 100644 --- a/packages/qa/dogfood/test/fixtures/schedule-organization-fixture.ts +++ b/packages/qa/dogfood/test/fixtures/schedule-organization-fixture.ts @@ -159,8 +159,8 @@ export function declaringTimeRelativeFlow(organizationId: string, recipientId: s config: { topic: 'sched.due', recipients: [recipientId], - title: 'Due soon: {record.name}', - message: '{record.name} is due.', + title: 'Due soon: {{ record.name }}', + message: '{{ record.name }} is due.', channels: ['inbox'], sourceObject: 'sched_org_target', sourceId: '{record.id}', diff --git a/packages/qa/dogfood/test/seed-ownership-claim-dispatch.dogfood.test.ts b/packages/qa/dogfood/test/seed-ownership-claim-dispatch.dogfood.test.ts index 64e8bcbbcb5..4985f5ebc18 100644 --- a/packages/qa/dogfood/test/seed-ownership-claim-dispatch.dogfood.test.ts +++ b/packages/qa/dogfood/test/seed-ownership-claim-dispatch.dogfood.test.ts @@ -115,8 +115,8 @@ const ScdFlow = defineFlow({ config: { topic: 'scd.deal_updated', recipients: '{record.owner_id}', - title: 'Deal updated: {record.name}', - message: '{record.name} was updated.', + title: 'Deal updated: {{ record.name }}', + message: '{{ record.name }} was updated.', channels: ['inbox'], sourceObject: OBJECT, sourceId: '{record.id}', diff --git a/packages/services/service-automation/src/builtin/screen-nodes.ts b/packages/services/service-automation/src/builtin/screen-nodes.ts index f0b6d99ddfb..8719d666932 100644 --- a/packages/services/service-automation/src/builtin/screen-nodes.ts +++ b/packages/services/service-automation/src/builtin/screen-nodes.ts @@ -93,8 +93,8 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext configSchema: { type: 'object', properties: { - title: { type: 'string', title: 'Title', description: 'Heading shown above the screen.' }, - description: { type: 'string', format: 'multiline', title: 'Description', description: 'Body text. Interpolates {var} references (e.g. {approval_path}).' }, + title: { type: 'string', title: 'Title', description: 'Heading shown above the screen. Renders {{ }} placeholders (e.g. {{ record.name }}).' }, + description: { type: 'string', format: 'multiline', title: 'Description', description: 'Body text. Renders {{ }} placeholders: a variable path with an optional formatter (e.g. {{ approval_path }}, {{ record.amount | currency }}).' }, fields: { type: 'array', title: 'Fields', diff --git a/packages/spec/src/automation/builtin-node-config.zod.ts b/packages/spec/src/automation/builtin-node-config.zod.ts index fbbfe745684..8b73af9fb3b 100644 --- a/packages/spec/src/automation/builtin-node-config.zod.ts +++ b/packages/spec/src/automation/builtin-node-config.zod.ts @@ -37,7 +37,9 @@ * where values interpolate), so `{token}` templates pass and resolve at the * executor's existing interpolation points. The value slots are the exception * since #19939: the `{token}` dialect is retired there (the "value slots" - * section below). + * section below). So are the text slots since #22110: a screen `title` / + * `description` and an `end` `message` render `{{ }}` holes, and a `{token}` + * there is refused (`flow-text-slot-template.ts`). * * ## Unknown keys — closed here too, as of #4001 批 9 * diff --git a/packages/spec/src/automation/flow-node-expression-paths.ts b/packages/spec/src/automation/flow-node-expression-paths.ts index 50046157cef..bc0254ec286 100644 --- a/packages/spec/src/automation/flow-node-expression-paths.ts +++ b/packages/spec/src/automation/flow-node-expression-paths.ts @@ -193,8 +193,10 @@ export interface FlowNodeExpressionPath { * declared config properties, and both validators already walk them. * * Also deliberately absent: config values that merely INTERPOLATE `{token}` - * templates — `script.inputs` / `script.variables` / `subflow.input`, - * `notify.body` and so on. Those are text-with-holes, + * templates — `script.inputs` / `script.variables` / `subflow.input` and so + * on — and the TEXT slots, which render `{{ }}` holes since #22110 (a notify + * `title` / `message`, a screen `title` / `description`, an `end` `message`; + * their one judge and list is `flow-text-slot-template.ts`). Those are text-with-holes, * the shape essentially every node config string has, already covered * generically (`validate-flow-template-paths`, the CLI flow linter's * `collectTemplateStrings`). A `flow-template` ledger entry means something diff --git a/packages/spec/src/automation/flow-region-pause-and-end.test.ts b/packages/spec/src/automation/flow-region-pause-and-end.test.ts index 38aed536003..68a9328f249 100644 --- a/packages/spec/src/automation/flow-region-pause-and-end.test.ts +++ b/packages/spec/src/automation/flow-region-pause-and-end.test.ts @@ -218,7 +218,7 @@ describe('a region body refuses an `end` node', () => { }); it('refuses it whatever the `outcome` — a plain terminal is a no-op there, a refusing one is converted to a region error', () => { - for (const config of [undefined, { outcome: 'completed' as const }, { outcome: 'refused' as const, message: 'Refused: {record.name}' }]) { + for (const config of [undefined, { outcome: 'completed' as const }, { outcome: 'refused' as const, message: 'Refused: {{ record.name }}' }]) { const issues = issuesOf(flowWith([loopOver([{ id: 'stop', type: 'end', label: 'Stop', config }])])); expect(issues.map(([p]) => p), JSON.stringify(config)).toEqual(['nodes.1.config.body.nodes.0.type']); } diff --git a/packages/spec/src/shared/expression.zod.ts b/packages/spec/src/shared/expression.zod.ts index 55ab3ce07d6..cf1a2563190 100644 --- a/packages/spec/src/shared/expression.zod.ts +++ b/packages/spec/src/shared/expression.zod.ts @@ -392,14 +392,12 @@ export type CronExpressionInput = z.input; * `@objectstack/metadata-protocol`). Both spellings resolve identically * there — single-brace `titleFormat` values are legal by construction, not * a grammar this schema failed to enforce. - * - A notify flow node's `title` / `message` read the other way: their - * renderer is the flow interpolator (`interpolate()` in - * `@objectstack/service-automation`), which substitutes single-brace `{var}` - * only — a `{{var}}` keeps its outer braces in the sent text, and the - * build's `flow-double-brace-interpolation` rule flags it on a flow node. - * Those two slots take this same input from the package-internal - * constructor (`./typed-expression-input.ts`), so their refusals prescribe - * `{record.name}` instead of this schema's `{{record.name}}`. + * - A notify flow node's `title` / `message` read `{{var}}` too, and only that: + * since protocol 18 (#22110, ADR-0032 D3) their renderer is the formula + * template engine, and a single-brace `{var}` there is refused at every door + * (`automation/flow-text-slot-template.ts`). Those two slots take this same + * input from the package-internal constructor (`./typed-expression-input.ts`) + * so their refusals can name the slot. * * So write the spelling the slot's renderer reads — `{{var}}` on this schema's * slots, `{var}` on a notify node's — and do not read either spelling as From cf93a6355bdbc4e5403f493b804f5340f17ac342 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 13:04:18 +0000 Subject: [PATCH 07/17] wip: lint type fix (#22110) Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- packages/lint/src/lint-flow-patterns.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/lint/src/lint-flow-patterns.ts b/packages/lint/src/lint-flow-patterns.ts index 155cef16396..6a7ccaca59d 100644 --- a/packages/lint/src/lint-flow-patterns.ts +++ b/packages/lint/src/lint-flow-patterns.ts @@ -1760,7 +1760,7 @@ export function lintFlowPatterns(stack: AnyRec): FlowLintFinding[] { } // [#22110] The text slots' own bare-`$` check: read OUTSIDE their // `{{ }}` holes, where a `$name.path` is the hole's correct content. - for (const slot of flowNodeTextSlotSources(node.type, node.config)) { + for (const slot of flowNodeTextSlotSources(String(node.type), node.config)) { const outsideHoles = slot.source.replace(/\{\{[^}]*\}\}/g, ''); if (BARE_DOLLAR_REF.test(outsideHoles)) { findings.push({ From a6a930948b9ad979db871a5601a20805e6f9585b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 13:13:49 +0000 Subject: [PATCH 08/17] wip: todo example test reads {{ }} holes (#22110) Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- .../test/overdue-escalation-days-overdue.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/examples/app-todo/test/overdue-escalation-days-overdue.test.ts b/examples/app-todo/test/overdue-escalation-days-overdue.test.ts index cf0e96b33db..bdedcee3088 100644 --- a/examples/app-todo/test/overdue-escalation-days-overdue.test.ts +++ b/examples/app-todo/test/overdue-escalation-days-overdue.test.ts @@ -122,7 +122,7 @@ describe('#18584 — the overdue escalation projection carries a day count', () expect(cfg.objectName).toBe('todo_task'); }); - it('every `{currentTask.…}` token in the escalation body names a key the row carries', async () => { + it('every `{{ currentTask.… }}` hole in the escalation body names a key the row carries', async () => { const data = await bootTodoData(); const due = daysAgoIso(5); await data.insert('todo_task', { @@ -146,11 +146,12 @@ describe('#18584 — the overdue escalation projection carries a day count', () // notify body's tokens are resolved against this row, so a token naming a // key the row does not carry renders as a blank — no error, no refusal. const message = String(node('notify_owner').config?.message ?? ''); - const tokens = [...message.matchAll(/\{currentTask\.([a-z_]+)\}/g)].map((m) => m[1]); + // A text slot's holes are `{{ currentTask. }}` since #22110. + const tokens = [...message.matchAll(/\{\{\s*currentTask\.([a-z_]+)\s*\}\}/g)].map((m) => m[1]); expect(tokens).toContain('days_overdue'); for (const token of tokens) { - expect(row, `notify body names {currentTask.${token}}`).toHaveProperty(token); - expect(String(row[token] ?? ''), `{currentTask.${token}} renders blank`).not.toBe(''); + expect(row, `notify body names {{ currentTask.${token} }}`).toHaveProperty(token); + expect(String(row[token] ?? ''), `{{ currentTask.${token} }} renders blank`).not.toBe(''); } // And the value is the real span, not merely "something present". From c3297caf77de733bfad1230151fd2ec0931e1d28 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 13:21:13 +0000 Subject: [PATCH 09/17] wip: changeset (#22110) Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- .../22110-flow-text-slot-double-brace.md | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .changeset/22110-flow-text-slot-double-brace.md diff --git a/.changeset/22110-flow-text-slot-double-brace.md b/.changeset/22110-flow-text-slot-double-brace.md new file mode 100644 index 00000000000..0ae296f7c89 --- /dev/null +++ b/.changeset/22110-flow-text-slot-double-brace.md @@ -0,0 +1,39 @@ +--- +'@objectstack/spec': major +'@objectstack/service-automation': major +'@objectstack/lint': major +'@objectstack/formula': minor +--- + +A flow TEXT slot — a `notify` node's `title` and `message`, a `screen` node's `title` and `description`, a refusing `end` node's `message` — reads ADR-0032 §3's `{{ }}` template holes, rendered by the formula template engine over the flow's variables. A single-brace `{…}` token in one is refused at `objectstack validate`, at `registerFlow` and by the node's contract, with the `{{ }}` spelling of each token. + +Clause-②: yes + + + +**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + +**Why.** ADR-0032 Decision 3 fixes one template delimiter: "One delimiter, `{{ }}`; single `{ }` deleted." The notify pair was already typed as `template` slots, while the executor read the single brace, so a flow carried two template dialects and an author who met both mixed them. + +**What changes.** + +- **The renderer.** The five text slots render through one renderer (`renderTextSlot`, `@objectstack/service-automation`): `{{ path }}` and `{{ path | formatter[:arg] }}` holes, every other character literal. A hole reads a flow variable by name (`{{ record.name }}`, a node output `{{ lookup.result }}`, an index `{{ rows.0.subject }}` or `{{ rows[0].subject }}`, and a `$`-named one: `{{ $error.message }}`). `null` and an absent path render nothing, an object or array renders as JSON, a `Date` as its ISO text; the ADR-0032 formatters make the rest explicit (`{{ amount | currency }}`, `{{ due | date:long }}`). A hole holds no logic. +- **The refusal.** One judge in `@objectstack/spec/automation` (`textSlotTemplateRefusal`, `flowNodeTextSlotSources`, `FLOW_NODE_TEXT_SLOTS`, `TEXT_SLOT_TEMPLATE_REFUSAL`): `NotifyConfigSchema`, `ScreenConfigSchema` and `EndConfigSchema` refuse a single-brace token at the slot's key; `registerFlow` and `objectstack validate` (`expression-invalid`, `error`) refuse it with the same words and also compile every slot's holes, so `{{ a + b }}` or an unknown formatter is refused before a run. A stored flow carrying one is skipped at boot with a warn naming it. Past the doors, a hole that does not compile throws `FlowTextTemplateError`, a guard refusal. +- **`@objectstack/formula`.** A template hole's path may contain `$` (`{{ $error.message }}`): a widening of the hole grammar, so a host scope's `$`-named variable has a template spelling. An unbound one renders nothing, like any unknown path. +- **`flow-double-brace-interpolation`** no longer flags `{{ }}` on the text slots, and its hint names them as the only `{{ }}` positions; a bare `$ref.x` outside the holes of a text slot is flagged with the hole spelling. +- **Unchanged:** every other flow string keeps the single-brace dialect — `recipients`, `actionUrl`, `sourceId`, `payload`, `templateData` values, a screen's `recordId` / `defaults` / field `defaultValue`, `subflow.input`, `script.inputs`, `map.input`, `http`, `loop` / `map` `collection`, `filter` — and the value slots are CEL's. + +**Not converted.** No ADR-0087 D2 conversion rewrites `{x}` to `{{ x }}`. The two renderers were run over the same variables: a path renders the same text for a string, a number, a boolean, `null`, an absent key or variable, an ISO date string, an object or an array, but a `Date` value rendered JSON-quoted under the 17.x interpolator (`"2026-10-08T09:30:00.000Z"`, quotes included) and as its ISO text now, and a screen or `end` text that was one token holding an object, an array or a `Date` rendered `String(value)` (`[object Object]`). So the rewrite is the author's to check; the D3 record is the semantic entry `flow-text-slot-single-brace-refused`. + +## FROM → TO + +| you wrote | write instead | +|:--|:--| +| `title: 'Deal won: {record.name}'` | `title: 'Deal won: {{ record.name }}'` | +| `message: 'Failed: {$error.message}'` | `message: 'Failed: {{ $error.message }}'` | +| `message: 'Total {amount * 2}'`, `'{round(x)}'` | compute it first — `assignments: { v: { dialect: 'cel', source: 'amount * 2' } }` — then `'Total {{ v }}'` (a number format is a formatter: `{{ v \| number:2 }}`) | +| `message: 'Due {TODAY() + 7}'`, `'By {$User.Id}'` | compute it first with an `assignment` node, whose value slot still reads that spelling — `assignments: { due: '{TODAY() + 7}' }` — then `'Due {{ due }}'` | + +**The one-line fix: double the braces of every path token in a text slot (`{x}` → `{{ x }}`), and compute anything else into a variable first.** + +**Who is affected, measured.** This repository's in-tree text-slot sites — `examples/app-showcase` (24 strings, 30 tokens) and `examples/app-todo` (5 strings, 7 tokens), all of them paths — are rewritten in this change, with the docs pages that taught the single brace (`content/docs/automation/flows.mdx`, `content/docs/getting-started/common-patterns.mdx`). Other repositories and deployed metadata were not measured here. From f94f922506b0d19788d1b6f5e65b625f15f05d45 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 13:28:42 +0000 Subject: [PATCH 10/17] chore(spec): regenerate the reference docs after merging main (#22110) Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- content/docs/references/automation/builtin-node-config.mdx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/content/docs/references/automation/builtin-node-config.mdx b/content/docs/references/automation/builtin-node-config.mdx index 0a3d5b89ac8..016c11e5687 100644 --- a/content/docs/references/automation/builtin-node-config.mdx +++ b/content/docs/references/automation/builtin-node-config.mdx @@ -40,7 +40,9 @@ parse the RAW stored config — their typed slots are strings (or `unknown` where values interpolate), so `{token}` templates pass and resolve at the executor's existing interpolation points. The value slots are the exception since #19939: the `{token}` dialect is retired there (the "value slots" -section below). +section below). So are the text slots since #22110: a screen `title` / +`description` and an `end` `message` render `{{ }}` holes, and a `{token}` +there is refused (`flow-text-slot-template.ts`). ## Unknown keys — closed here too, as of #4001 批 9 From d54cc713dddabfec0a540b0b90eaa38d9b42341b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 14:24:53 +0000 Subject: [PATCH 11/17] fix(dogfood): the conformance ledger's notify prose names no tracker id (#22110) Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- packages/qa/dogfood/test/expression-conformance.ledger.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/qa/dogfood/test/expression-conformance.ledger.ts b/packages/qa/dogfood/test/expression-conformance.ledger.ts index 2abde789262..c93c8377a84 100644 --- a/packages/qa/dogfood/test/expression-conformance.ledger.ts +++ b/packages/qa/dogfood/test/expression-conformance.ledger.ts @@ -602,13 +602,13 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [ summary: 'flow `notify` node inline content (NotifyConfig.title, .message) — `{{ }}` template holes rendered per run by the formula template engine', 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}` and refuses a single-brace `{token}` left from the 17.x dialect (`flow-text-slot-template.ts`, #22110); then `renderTextSlot(cfg.title?.source, variables)` and the same for `message` — builtin/template.ts `renderTextSlot` → `@objectstack/formula` `templateEngine.evaluate` over `textTemplateScope(variables)` (each flow variable a root, a dotted node-output key nested), which fills `{{ path }}` / `{{ path | formatter }}` holes only. 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`, a single-brace token) is refused by that parse as a guard (`refuseNode`), so the run fails at the node and no `fault` edge routes it; a hole that does not compile (logic, an unknown formatter, unbalanced delimiters) THROWS `FlowTextTemplateError`, 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 hole whose path resolves to nothing renders as empty text with no log, so a `message` goes out with the gap. `registerFlow` and `os validate` (`expression-invalid`, error) refuse a single-brace token with its hole spelling and compile every hole (`validateExpression(\'template\', …)`), so a flow that registers is one whose text slots compile; `os validate` also warns on an unknown `{{ record.x }}` head (`validate-flow-template-paths`)', + '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}` and refuses a single-brace `{token}` left from the 17.x dialect (`flow-text-slot-template.ts`, ADR-0032 D3); then `renderTextSlot(cfg.title?.source, variables)` and the same for `message` — builtin/template.ts `renderTextSlot` → `@objectstack/formula` `templateEngine.evaluate` over `textTemplateScope(variables)` (each flow variable a root, a dotted node-output key nested), which fills `{{ path }}` / `{{ path | formatter }}` holes only. 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`, a single-brace token) is refused by that parse as a guard (`refuseNode`), so the run fails at the node and no `fault` edge routes it; a hole that does not compile (logic, an unknown formatter, unbalanced delimiters) THROWS `FlowTextTemplateError`, 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 hole whose path resolves to nothing renders as empty text with no log, so a `message` goes out with the gap. `registerFlow` and `os validate` (`expression-invalid`, error) refuse a single-brace token with its hole spelling and compile every hole (`validateExpression(\'template\', …)`), so a flow that registers is one whose text slots compile; `os validate` also warns 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 `@objectstack/formula` templateEngine since #22110 (ADR-0032 D3) — the `{{ }}` dialect the messaging/email renderers also read — through `renderTextSlot`, the one text renderer a screen `title` / `description` and a refusing `end` `message` share. `throw` is the ADR-0058 D5 flow tier and describes the faults the cell names as refusals; the silent half (an unresolved hole rendering empty) is stated in the cell rather than rounded into the tier.', + note: 'The renderer is `@objectstack/formula` templateEngine since protocol 18 (ADR-0032 D3) — the `{{ }}` dialect the messaging/email renderers also read — through `renderTextSlot`, the one text renderer a screen `title` / `description` and a refusing `end` `message` share. `throw` is the ADR-0058 D5 flow tier and describes the faults the cell names as refusals; the silent half (an unresolved hole rendering empty) is stated in the cell rather than rounded into the tier.', }, { id: 'cel-advanced-policy', From 73b682847b6ac2b4be13e9a650eb0ea13bf45794 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 15:25:08 +0000 Subject: [PATCH 12/17] chore(changeset): declare the text-slot change as a narrowing (#22110) Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- .changeset/22110-flow-text-slot-double-brace.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/22110-flow-text-slot-double-brace.md b/.changeset/22110-flow-text-slot-double-brace.md index 0ae296f7c89..68fc0a198fc 100644 --- a/.changeset/22110-flow-text-slot-double-brace.md +++ b/.changeset/22110-flow-text-slot-double-brace.md @@ -7,7 +7,7 @@ A flow TEXT slot — a `notify` node's `title` and `message`, a `screen` node's `title` and `description`, a refusing `end` node's `message` — reads ADR-0032 §3's `{{ }}` template holes, rendered by the formula template engine over the flow's variables. A single-brace `{…}` token in one is refused at `objectstack validate`, at `registerFlow` and by the node's contract, with the `{{ }}` spelling of each token. -Clause-②: yes +Clause-②: yes (narrowing) From 0b094026dcb0c8fef22e0572d510a8a8c9d4d8e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 15:52:59 +0000 Subject: [PATCH 13/17] =?UTF-8?q?fix(spec,lint):=20patch=20round=202=20?= =?UTF-8?q?=E2=80=94=20notify=20canon,=20one-stray-brace=20holes,=20format?= =?UTF-8?q?ted-hole=20paths,=20changeset=20wording=20(#22110)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- .../22110-flow-text-slot-double-brace.md | 4 +- .../validate-expressions.text-slot.test.ts | 19 ++++++++ .../src/validate-flow-template-paths.test.ts | 17 +++++++ .../lint/src/validate-flow-template-paths.ts | 24 ++++++++-- .../flow-text-slot-template.test.ts | 15 +++++++ .../src/automation/flow-text-slot-template.ts | 13 +++++- packages/spec/src/shared/expression.zod.ts | 4 +- .../template-expression-input-canon.test.ts | 44 +++++++++++++++++++ 8 files changed, 130 insertions(+), 10 deletions(-) create mode 100644 packages/spec/src/shared/template-expression-input-canon.test.ts diff --git a/.changeset/22110-flow-text-slot-double-brace.md b/.changeset/22110-flow-text-slot-double-brace.md index 68fc0a198fc..096896b13be 100644 --- a/.changeset/22110-flow-text-slot-double-brace.md +++ b/.changeset/22110-flow-text-slot-double-brace.md @@ -17,8 +17,8 @@ Clause-②: yes (narrowing) **What changes.** -- **The renderer.** The five text slots render through one renderer (`renderTextSlot`, `@objectstack/service-automation`): `{{ path }}` and `{{ path | formatter[:arg] }}` holes, every other character literal. A hole reads a flow variable by name (`{{ record.name }}`, a node output `{{ lookup.result }}`, an index `{{ rows.0.subject }}` or `{{ rows[0].subject }}`, and a `$`-named one: `{{ $error.message }}`). `null` and an absent path render nothing, an object or array renders as JSON, a `Date` as its ISO text; the ADR-0032 formatters make the rest explicit (`{{ amount | currency }}`, `{{ due | date:long }}`). A hole holds no logic. -- **The refusal.** One judge in `@objectstack/spec/automation` (`textSlotTemplateRefusal`, `flowNodeTextSlotSources`, `FLOW_NODE_TEXT_SLOTS`, `TEXT_SLOT_TEMPLATE_REFUSAL`): `NotifyConfigSchema`, `ScreenConfigSchema` and `EndConfigSchema` refuse a single-brace token at the slot's key; `registerFlow` and `objectstack validate` (`expression-invalid`, `error`) refuse it with the same words and also compile every slot's holes, so `{{ a + b }}` or an unknown formatter is refused before a run. A stored flow carrying one is skipped at boot with a warn naming it. Past the doors, a hole that does not compile throws `FlowTextTemplateError`, a guard refusal. +- **The renderer.** The five text slots render through one renderer inside `@objectstack/service-automation` (package-internal; the package's public exports do not change): `{{ path }}` and `{{ path | formatter[:arg] }}` holes, every other character literal. A hole reads a flow variable by name (`{{ record.name }}`, a node output `{{ lookup.result }}`, an index `{{ rows.0.subject }}` or `{{ rows[0].subject }}`, and a `$`-named one: `{{ $error.message }}`). `null` and an absent path render nothing, an object or array renders as JSON, a `Date` as its ISO text; the ADR-0032 formatters make the rest explicit (`{{ amount | currency }}`, `{{ due | date:long }}`). A hole holds no logic. +- **The refusal.** One judge in `@objectstack/spec/automation` (`textSlotTemplateRefusal`, `flowNodeTextSlotSources`, `FLOW_NODE_TEXT_SLOTS`, `TEXT_SLOT_TEMPLATE_REFUSAL`): `NotifyConfigSchema`, `ScreenConfigSchema` and `EndConfigSchema` refuse a single-brace token at the slot's key; `registerFlow` and `objectstack validate` (`expression-invalid`, `error`) refuse it with the same words and also compile every slot's holes, so `{{ a + b }}` or an unknown formatter is refused before a run. A stored flow carrying one is skipped at boot with a warn naming it. Past the doors, a hole that does not compile fails the node with a guard refusal, which a `fault` edge does not route. - **`@objectstack/formula`.** A template hole's path may contain `$` (`{{ $error.message }}`): a widening of the hole grammar, so a host scope's `$`-named variable has a template spelling. An unbound one renders nothing, like any unknown path. - **`flow-double-brace-interpolation`** no longer flags `{{ }}` on the text slots, and its hint names them as the only `{{ }}` positions; a bare `$ref.x` outside the holes of a text slot is flagged with the hole spelling. - **Unchanged:** every other flow string keeps the single-brace dialect — `recipients`, `actionUrl`, `sourceId`, `payload`, `templateData` values, a screen's `recordId` / `defaults` / field `defaultValue`, `subflow.input`, `script.inputs`, `map.input`, `http`, `loop` / `map` `collection`, `filter` — and the value slots are CEL's. diff --git a/packages/lint/src/validate-expressions.text-slot.test.ts b/packages/lint/src/validate-expressions.text-slot.test.ts index 0123bdf1b7d..9e26f905f42 100644 --- a/packages/lint/src/validate-expressions.text-slot.test.ts +++ b/packages/lint/src/validate-expressions.text-slot.test.ts @@ -26,6 +26,7 @@ import { describe, expect, it } from 'vitest'; import { ObjectStackDefinitionSchema, normalizeStackInput } from '@objectstack/spec'; import { TEXT_SLOT_TEMPLATE_REFUSAL } from '@objectstack/spec/automation'; +import { validateExpression } from '@objectstack/formula'; import { runAuthoringRules, EXPRESSION_INVALID } from './authoring-rules.js'; import { validateStackExpressions } from './validate-expressions.js'; @@ -86,6 +87,24 @@ describe('`objectstack validate` — a flow text slot reads `{{ }}` holes (#2211 } }); + // A hole with one brace missing is an unbalanced hole, not an old single-brace + // token: the judge prescribes no rewrite (a doubled `{{{ amount }}` would be + // refused too) and the compile step names it — one finding, the template's. + it('reports a hole touching exactly one brace as the compile step\'s unbalanced hole — no three-brace rewrite', () => { + for (const title of ['Total {{ amount }', 'Total { amount }}']) { + const findings = validate('notify', { recipients: ['u1'], title }); + expect(findings, title).toHaveLength(1); + expect(findings[0]!.rule, title).toBe(EXPRESSION_INVALID); + expect(findings[0]!.severity, title).toBe('error'); + expect(findings[0]!.where, title).toContain("node 'w' (notify) notify title at config.title"); + expect(findings[0]!.message, title).toContain('unbalanced'); + expect(findings[0]!.message.startsWith(TEXT_SLOT_TEMPLATE_REFUSAL), title).toBe(false); + expect(findings[0]!.message, title).not.toMatch(/\{\{\{|\}\}\}/); + // The refusal the door prints is the template compile's, by its code. + expect(validateExpression('template', title).errors.map((e) => e.code), title).toEqual(['invalid-template']); + } + }); + it('passes holes, formatters and a `$`-named variable — and every single-brace slot that is not text', () => { expect(validate('notify', { recipients: ['{record.owner}'], diff --git a/packages/lint/src/validate-flow-template-paths.test.ts b/packages/lint/src/validate-flow-template-paths.test.ts index 1d4af09d72e..ecbd2b55fd4 100644 --- a/packages/lint/src/validate-flow-template-paths.test.ts +++ b/packages/lint/src/validate-flow-template-paths.test.ts @@ -909,6 +909,23 @@ describe('validateFlowTemplatePaths — variable roots (#17305)', () => { ).toEqual([]); }); + // …and a hole carrying a formatter is judged by its path: before #22110 a text + // slot had no formatter syntax, so a formatter must not become a place a + // misspelt field hides. + it('judges the path of a `{{ path | formatter }}` hole like a bare one', () => { + const findings = validateFlowTemplatePaths( + scheduleFlow([FETCH_ONE, { id: 'note', type: 'notify', config: { title: 'Case {{ caseRecord.subjcet | upper }}' } }]), + ); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(FLOW_TEMPLATE_UNKNOWN_FIELD); + expect(findings[0].message).toContain('subjcet'); + expect( + validateFlowTemplatePaths( + scheduleFlow([FETCH_ONE, { id: 'note', type: 'notify', config: { title: "Case {{ caseRecord.subject | truncate:'40' }}" } }]), + ), + ).toEqual([]); + }); + it('resolves the declared-variable + loop shape examples/app-todo ships', () => { const findings = validateFlowTemplatePaths( scheduleFlow( diff --git a/packages/lint/src/validate-flow-template-paths.ts b/packages/lint/src/validate-flow-template-paths.ts index e7b8b6bb402..d7755d761c1 100644 --- a/packages/lint/src/validate-flow-template-paths.ts +++ b/packages/lint/src/validate-flow-template-paths.ts @@ -188,9 +188,20 @@ interface TemplateRef { } /** - * Extract the dotted `{root.}` references from a template string. Mirrors - * the runtime interpolator's token grammar (service-automation - * builtin/template.ts): a `{...}` token whose body is a plain dotted path. + * Extract the dotted `{root.}` references from a template string, in + * either of the two dialects a flow config string carries: + * + * - a single-brace `{...}` token — the grammar of the flow interpolator that + * still renders every value-like position (service-automation + * `builtin/template.ts`, `interpolate`): a token whose body is a plain + * dotted path; + * - a `{{ ... }}` hole — the formula template engine's grammar, which the + * text slots render since #22110 (a notify `title` / `message`, a screen + * `title` / `description`, an `end` `message`): a path with an optional + * `| formatter[:arg]`. The path before the pipe is judged exactly like a + * bare one, so a formatter does not hide a misspelt field — before #22110 + * a text slot had no formatter syntax, and every path in one was judged. + * * Arithmetic / function tokens (`{NOW()}`, `{a + b}`) are ignored, and so is a * single-segment token — there is no `.` hop in it to judge. * @@ -203,7 +214,12 @@ function templateRefsIn(text: string): TemplateRef[] { const tokenRe = /\{([^{}]+)\}/g; let m: RegExpExecArray | null; while ((m = tokenRe.exec(text)) !== null) { - const body = m[1].trim(); + let body = m[1].trim(); + // Inside a `{{ }}` hole — a brace on both sides — the path is the text + // before an optional `| formatter`. (In a single-brace token a `|` is the + // interpolator's arithmetic, so it stays unjudged there, as before.) + const inHole = text[m.index - 1] === '{' && text[m.index + m[0].length] === '}'; + if (inHole && body.includes('|')) body = body.slice(0, body.indexOf('|')).trim(); // Pure dotted path only (same shape the interpolator's fast path accepts): // identifier head, then identifier-or-numeric segments. Anything with // operators / spaces / quotes is an arithmetic token — not a bare field ref. diff --git a/packages/spec/src/automation/flow-text-slot-template.test.ts b/packages/spec/src/automation/flow-text-slot-template.test.ts index eb56a7dce8b..dc2480d404d 100644 --- a/packages/spec/src/automation/flow-text-slot-template.test.ts +++ b/packages/spec/src/automation/flow-text-slot-template.test.ts @@ -83,6 +83,21 @@ describe('textSlotTemplateRefusal — the one judge of the single brace in a tex } }); + // A hole with one brace missing is not an old single-brace token: doubling it + // would prescribe `{{{ amount }}` / `{{ amount }}}`, which the engine refuses + // too. The judge stays silent and the compile step every door runs next names + // the unbalanced hole (pinned at the `objectstack validate` door in + // `@objectstack/lint`'s `validate-expressions.text-slot.test.ts`). + it('prescribes no rewrite for a token touching exactly one brace — an unbalanced hole is the compile step\'s', () => { + for (const text of ['Total {{ amount }', 'Total { amount }}', '{{x}', '{x}}']) { + expect(textSlotTemplateRefusal(text), text).toBeUndefined(); + } + // …while a genuine single-brace token beside one still gets its rewrite, and nothing three-braced. + const message = textSlotTemplateRefusal('Total {{ amount } by {owner}')!; + expect(message).toContain('`Total {{ amount } by {{ owner }}`'); + expect(message).not.toMatch(/\{\{\{|\}\}\}/); + }); + it('tells an author to delete a token that is neither a path nor an expression', () => { expect(textSlotTemplateRefusal('JSON {"a": 1}')).toContain('neither a variable path nor an expression'); }); diff --git a/packages/spec/src/automation/flow-text-slot-template.ts b/packages/spec/src/automation/flow-text-slot-template.ts index 3d87b6c8843..a6ae3872dfe 100644 --- a/packages/spec/src/automation/flow-text-slot-template.ts +++ b/packages/spec/src/automation/flow-text-slot-template.ts @@ -123,10 +123,19 @@ function textSlotSource(value: unknown): string | undefined { return undefined; } -/** The single-brace tokens of `text` — every `{…}` the 17.x interpolator substituted, minus the inside of a `{{ }}` hole. */ +/** + * The single-brace tokens of `text` — every `{…}` the 17.x interpolator + * substituted, minus any that touch another brace. + * + * Touching on BOTH sides is the inside of a `{{ }}` hole. Touching on ONE side + * (`Total {{ amount }`, `Total { amount }}`) is a hole with a brace missing, + * not an old token: doubling it would prescribe a three-brace "fix" the engine + * refuses too. So it is left to the compile step every door runs next + * (`validateExpression('template', …)`), which names the unbalanced hole. + */ function singleBraceTokens(text: string): TemplateToken[] { return templateTokensOf(text).filter((token) => - !(text[token.index - 1] === '{' && text[token.index + token.text.length] === '}')); + text[token.index - 1] !== '{' && text[token.index + token.text.length] !== '}'); } /** `text` with each single-brace PATH token written as a hole — the spelling a refusal prescribes for it. */ diff --git a/packages/spec/src/shared/expression.zod.ts b/packages/spec/src/shared/expression.zod.ts index cf1a2563190..0d14f7cf4bb 100644 --- a/packages/spec/src/shared/expression.zod.ts +++ b/packages/spec/src/shared/expression.zod.ts @@ -400,8 +400,8 @@ export type CronExpressionInput = z.input; * so their refusals can name the slot. * * So write the spelling the slot's renderer reads — `{{var}}` on this schema's - * slots, `{var}` on a notify node's — and do not read either spelling as - * declared, preferred or rejected here. + * slots and on a notify node's `title` / `message` alike — and do not read + * either spelling as declared, preferred or rejected here. */ export const TemplateExpressionInputSchema = templateExpressionInput(ExpressionSchema, typedExpressionRefusals('template')); export type TemplateExpressionInput = z.input; diff --git a/packages/spec/src/shared/template-expression-input-canon.test.ts b/packages/spec/src/shared/template-expression-input-canon.test.ts new file mode 100644 index 00000000000..4292ac09824 --- /dev/null +++ b/packages/spec/src/shared/template-expression-input-canon.test.ts @@ -0,0 +1,44 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The canon a `TemplateExpressionInputSchema` hover shows (ADR-0032 Decision 4: + * fix the canon first — the model emits what it is shown). + * + * The schema's docblock ships as the `.d.ts` hover text of the spec tarball, + * and it is where an author (or an agent) reads which placeholder spelling a + * template slot takes. Since protocol 18 a notify node's `title` / `message` + * read `{{var}}` only (#22110), so no sentence of that docblock may still + * prescribe the single-brace `{var}` for a notify slot. The one sentence that + * may name `{var}` there is the one saying it is REFUSED. + */ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +const SOURCE = readFileSync(new URL('./expression.zod.ts', import.meta.url), 'utf8'); + +/** The `/** … *\/` block directly above `marker`, as prose (comment gutters removed). */ +function docblockAbove(source: string, marker: string): string { + const at = source.indexOf(marker); + if (at === -1) throw new Error(`marker not found: ${marker}`); + const end = source.lastIndexOf('*/', at); + const start = source.lastIndexOf('/**', end); + return source.slice(start + 3, end).replace(/\n\s*\* ?/g, ' '); +} + +/** A single-brace `{var}` — not the inside of `{{var}}`. */ +const SINGLE_BRACE_VAR = /(^|[^{])`?\{var\}`?(?!\})/; + +describe('TemplateExpressionInputSchema docblock — the canon on a notify slot', () => { + const doc = docblockAbove(SOURCE, 'export const TemplateExpressionInputSchema'); + const sentences = doc.split(/(?<=\.)\s+/); + + it('reads the docblock it means to — the notify bullet and the closing prescription are there', () => { + expect(sentences.some((s) => /notify/i.test(s) && s.includes('{{var}}'))).toBe(true); + expect(sentences.some((s) => s.includes('So write the spelling the slot\'s renderer reads'))).toBe(true); + }); + + it('prescribes no single-brace `{var}` for a notify slot — only the sentence that refuses it names one', () => { + const offending = sentences.filter((s) => /notify/i.test(s) && SINGLE_BRACE_VAR.test(s) && !/refused/.test(s)); + expect(offending).toEqual([]); + }); +}); From 171cea00293b3cb148ba74c7d453ed101db76a33 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 18:03:27 +0000 Subject: [PATCH 14/17] =?UTF-8?q?fix(spec,lint):=20patch=20round=203=20?= =?UTF-8?q?=E2=80=94=20the=20tmpl=20and=20typed-refusal=20canon,=20the=20c?= =?UTF-8?q?anon=20pin=20as=20one=20set,=20bracket-indexed=20hole=20paths?= =?UTF-8?q?=20(#22110)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- .../src/validate-flow-template-paths.test.ts | 15 +++++ .../lint/src/validate-flow-template-paths.ts | 12 +++- packages/spec/src/shared/expression.zod.ts | 21 ++++--- .../template-expression-input-canon.test.ts | 57 ++++++++++++------- 4 files changed, 70 insertions(+), 35 deletions(-) diff --git a/packages/lint/src/validate-flow-template-paths.test.ts b/packages/lint/src/validate-flow-template-paths.test.ts index ecbd2b55fd4..15c56f9bd31 100644 --- a/packages/lint/src/validate-flow-template-paths.test.ts +++ b/packages/lint/src/validate-flow-template-paths.test.ts @@ -926,6 +926,21 @@ describe('validateFlowTemplatePaths — variable roots (#17305)', () => { ).toEqual([]); }); + // …and so is a bracket-indexed path in a hole (`{{ rows[0].subject }}` is a + // spelling this card's own docs teach): `[i]` reads as `.i`, the way the + // engine resolves it, so an index does not hide a misspelt field either. + it('judges a bracket-indexed path in a `{{ }}` hole like its dotted form', () => { + const withTags = (title: string): AnyRec => ({ + ...scheduleFlow([FETCH_ONE, { id: 'note', type: 'notify', config: { title } }]), + objects: [{ ...CASE_OBJECT, fields: { ...CASE_OBJECT.fields, tags: { name: 'tags', type: 'multiselect' } } }], + }); + const findings = validateFlowTemplatePaths(withTags('First tag: {{ caseRecord.tagz[0] }}')); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(FLOW_TEMPLATE_UNKNOWN_FIELD); + expect(findings[0].message).toContain('tagz'); + expect(validateFlowTemplatePaths(withTags('First tag: {{ caseRecord.tags[0] | upper }}'))).toEqual([]); + }); + it('resolves the declared-variable + loop shape examples/app-todo ships', () => { const findings = validateFlowTemplatePaths( scheduleFlow( diff --git a/packages/lint/src/validate-flow-template-paths.ts b/packages/lint/src/validate-flow-template-paths.ts index d7755d761c1..8b447a926d3 100644 --- a/packages/lint/src/validate-flow-template-paths.ts +++ b/packages/lint/src/validate-flow-template-paths.ts @@ -198,9 +198,12 @@ interface TemplateRef { * - a `{{ ... }}` hole — the formula template engine's grammar, which the * text slots render since #22110 (a notify `title` / `message`, a screen * `title` / `description`, an `end` `message`): a path with an optional - * `| formatter[:arg]`. The path before the pipe is judged exactly like a - * bare one, so a formatter does not hide a misspelt field — before #22110 - * a text slot had no formatter syntax, and every path in one was judged. + * `| formatter[:arg]`, whose segments may be bracket-indexed + * (`{{ rows[0].subject }}`). The path before the pipe, with each `[i]` + * read as `.i` the way the engine resolves it, is judged exactly like a + * bare one, so neither a formatter nor an index hides a misspelt field — + * before #22110 a text slot had neither syntax, and every path in one was + * judged. * * Arithmetic / function tokens (`{NOW()}`, `{a + b}`) are ignored, and so is a * single-segment token — there is no `.` hop in it to judge. @@ -220,6 +223,9 @@ function templateRefsIn(text: string): TemplateRef[] { // interpolator's arithmetic, so it stays unjudged there, as before.) const inHole = text[m.index - 1] === '{' && text[m.index + m[0].length] === '}'; if (inHole && body.includes('|')) body = body.slice(0, body.indexOf('|')).trim(); + // …and an index segment is the engine's `[i]` → `.i` (formula's + // `template-engine.ts` `resolvePath` normalises the same way). + if (inHole) body = body.replace(/\[(\w+)\]/g, '.$1'); // Pure dotted path only (same shape the interpolator's fast path accepts): // identifier head, then identifier-or-numeric segments. Anything with // operators / spaces / quotes is an arithmetic token — not a bare field ref. diff --git a/packages/spec/src/shared/expression.zod.ts b/packages/spec/src/shared/expression.zod.ts index 0d14f7cf4bb..2fb7b4000ba 100644 --- a/packages/spec/src/shared/expression.zod.ts +++ b/packages/spec/src/shared/expression.zod.ts @@ -291,12 +291,12 @@ export type TypedExpressionDialect = Extract> = { cron: @@ -508,11 +508,10 @@ export const P = cel; * * - `{{record.x}}` — the `@objectstack/formula` template engine and the * messaging, email and i18n renderers read double braces only, and leave a - * `{record.x}` in their output verbatim; - * - `{record.x}` — a notify flow node's `title` / `message` are rendered by the - * flow interpolator, which reads single braces only: a `{{record.x}}` keeps - * its outer braces in the sent text, and the build's - * `flow-double-brace-interpolation` rule flags it; + * `{record.x}` in their output verbatim. Since protocol 18 a notify flow + * node's `title` / `message` read `{{record.x}}` too — the formula template + * engine renders them — and a single-brace token there is refused at every + * door (`automation/flow-text-slot-template.ts`); * - either — `titleFormat`'s renderers normalize `{{record.x}}` to `{record.x}`. */ export function tmpl(strings: TemplateStringsArray, ...values: unknown[]): EvaluatedExpression { diff --git a/packages/spec/src/shared/template-expression-input-canon.test.ts b/packages/spec/src/shared/template-expression-input-canon.test.ts index 4292ac09824..2c776882c1e 100644 --- a/packages/spec/src/shared/template-expression-input-canon.test.ts +++ b/packages/spec/src/shared/template-expression-input-canon.test.ts @@ -1,15 +1,19 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * The canon a `TemplateExpressionInputSchema` hover shows (ADR-0032 Decision 4: - * fix the canon first — the model emits what it is shown). + * The canon this file's template docblocks show (ADR-0032 Decision 4: fix the + * canon first — the model emits what it is shown). * - * The schema's docblock ships as the `.d.ts` hover text of the spec tarball, - * and it is where an author (or an agent) reads which placeholder spelling a - * template slot takes. Since protocol 18 a notify node's `title` / `message` - * read `{{var}}` only (#22110), so no sentence of that docblock may still - * prescribe the single-brace `{var}` for a notify slot. The one sentence that - * may name `{var}` there is the one saying it is REFUSED. + * Three exported symbols of `expression.zod.ts` tell an author (or an agent) + * which placeholder spelling a template slot takes, and each docblock ships as + * the `.d.ts` hover text of the spec tarball: `TemplateExpressionInputSchema`, + * the `tmpl` helper the notify `title` / `message` `.describe()` names as the + * way to write the envelope, and `TYPED_EXPRESSION_SOURCE_REQUIRED`. Since + * protocol 18 a notify node's `title` / `message` read `{{var}}` only (#22110), + * so no sentence of those docblocks may still prescribe a single-brace + * placeholder (`{var}`, `{record.x}`, `{record.name}`) for a notify slot. The + * one sentence that may name one there is the one saying it is REFUSED. The + * three are held as one set: the canon in this file is one canon. */ import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; @@ -25,20 +29,31 @@ function docblockAbove(source: string, marker: string): string { return source.slice(start + 3, end).replace(/\n\s*\* ?/g, ' '); } -/** A single-brace `{var}` — not the inside of `{{var}}`. */ -const SINGLE_BRACE_VAR = /(^|[^{])`?\{var\}`?(?!\})/; +/** A single-brace placeholder — `{var}`, `{record.x}` — not the inside of a `{{ }}` hole. */ +const SINGLE_BRACE_PLACEHOLDER = /(^|[^{])\{[A-Za-z_$][\w.$]*\}(?!\})/; -describe('TemplateExpressionInputSchema docblock — the canon on a notify slot', () => { - const doc = docblockAbove(SOURCE, 'export const TemplateExpressionInputSchema'); - const sentences = doc.split(/(?<=\.)\s+/); +/** The docblocks held as one canon, each with a sentence that proves the read reached it. */ +const DOCBLOCKS: ReadonlyArray<{ marker: string; reached: RegExp }> = [ + { marker: 'export const TemplateExpressionInputSchema', reached: /So write the spelling the slot's renderer reads/ }, + { marker: 'export function tmpl', reached: /Write the spelling the slot's renderer reads/ }, + { marker: 'export const TYPED_EXPRESSION_SOURCE_REQUIRED', reached: /The `template` sentence prescribes/ }, +]; - it('reads the docblock it means to — the notify bullet and the closing prescription are there', () => { - expect(sentences.some((s) => /notify/i.test(s) && s.includes('{{var}}'))).toBe(true); - expect(sentences.some((s) => s.includes('So write the spelling the slot\'s renderer reads'))).toBe(true); - }); +describe('expression.zod.ts template docblocks — the canon on a notify slot', () => { + for (const { marker, reached } of DOCBLOCKS) { + const doc = docblockAbove(SOURCE, marker); + const sentences = doc.split(/(?<=\.)\s+/); - it('prescribes no single-brace `{var}` for a notify slot — only the sentence that refuses it names one', () => { - const offending = sentences.filter((s) => /notify/i.test(s) && SINGLE_BRACE_VAR.test(s) && !/refused/.test(s)); - expect(offending).toEqual([]); - }); + describe(marker, () => { + it('reads the docblock it means to — it names a notify slot, and its prescription sentence is there', () => { + expect(sentences.some((s) => /notify/i.test(s))).toBe(true); + expect(reached.test(doc)).toBe(true); + }); + + it('prescribes no single-brace placeholder for a notify slot — only a sentence that refuses one names it', () => { + const offending = sentences.filter((s) => /notify/i.test(s) && SINGLE_BRACE_PLACEHOLDER.test(s) && !/refused/.test(s)); + expect(offending).toEqual([]); + }); + }); + } }); From c673c8635516b3427a9081d84ff073d9687fdacc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 18:15:07 +0000 Subject: [PATCH 15/17] fix(lint): the bracket-path pin spreads a typed record, so the test typecheck holds (#22110) Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- packages/lint/src/validate-flow-template-paths.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/lint/src/validate-flow-template-paths.test.ts b/packages/lint/src/validate-flow-template-paths.test.ts index 15c56f9bd31..a7b253898ca 100644 --- a/packages/lint/src/validate-flow-template-paths.test.ts +++ b/packages/lint/src/validate-flow-template-paths.test.ts @@ -932,7 +932,7 @@ describe('validateFlowTemplatePaths — variable roots (#17305)', () => { it('judges a bracket-indexed path in a `{{ }}` hole like its dotted form', () => { const withTags = (title: string): AnyRec => ({ ...scheduleFlow([FETCH_ONE, { id: 'note', type: 'notify', config: { title } }]), - objects: [{ ...CASE_OBJECT, fields: { ...CASE_OBJECT.fields, tags: { name: 'tags', type: 'multiselect' } } }], + objects: [{ ...CASE_OBJECT, fields: { ...(CASE_OBJECT.fields as AnyRec), tags: { name: 'tags', type: 'multiselect' } } }], }); const findings = validateFlowTemplatePaths(withTags('First tag: {{ caseRecord.tagz[0] }}')); expect(findings).toHaveLength(1); From b5d3cd532304e453390c8d6c481075c955dc515f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 18:28:51 +0000 Subject: [PATCH 16/17] =?UTF-8?q?test(spec):=20the=20tmpl=20docblock=20pin?= =?UTF-8?q?=20holds=20the=20protocol-18=20canon=20=E2=80=94=20notify=20rea?= =?UTF-8?q?ds=20double=20braces=20(#22110)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- .../expression-dialect-docs.pin.test.ts | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/spec/src/shared/expression-dialect-docs.pin.test.ts b/packages/spec/src/shared/expression-dialect-docs.pin.test.ts index 61cb805d6b9..2bb0e89c730 100644 --- a/packages/spec/src/shared/expression-dialect-docs.pin.test.ts +++ b/packages/spec/src/shared/expression-dialect-docs.pin.test.ts @@ -95,14 +95,14 @@ describe('expression.zod.ts dialect table === ExpressionDialect', () => { }); /** - * [#22081] The `tmpl` docblock says which renderers read which braces. + * [#22081, #22110] The `tmpl` docblock says which renderers read which braces. * - * `tmpl` is the helper an author reaches for on every template slot, and its - * docblock used to call the envelope "Mustache-template" and show only - * `{{record.x}}` — the spelling a notify node's `title` / `message` renderer - * (the flow interpolator) leaves inside a stray pair of braces, and the build's - * `flow-double-brace-interpolation` rule flags. The helper judges no spelling, - * so its docblock has to name the renderer for each one. + * `tmpl` is the helper an author reaches for on every template slot, and the + * helper judges no spelling, so its docblock has to name the renderer for each + * one. Since protocol 18 every renderer it names for a single-spelling slot + * reads `{{record.x}}` — the notify node's `title` / `message` included, where + * a single-brace token is refused — so no bullet may still prescribe + * `{record.x}` on its own; `titleFormat` is the one slot that takes either. * * ⛔ Scope: the relation, not the wording — which spelling is listed against * which renderer. Rewording a bullet is free. @@ -124,19 +124,19 @@ describe('the `tmpl` docblock names the renderer behind each brace spelling', () const bullets = tmplBullets(); const doubled = bullets.find((b) => b.startsWith('`{{record.x}}`')); - const single = bullets.find((b) => b.startsWith('`{record.x}`')); + const either = bullets.find((b) => b.startsWith('either')); - it('finds a bullet for each spelling (anti-vacuity)', () => { + it('finds the double-brace bullet and the either bullet (anti-vacuity)', () => { expect(bullets.length, 'no `- ` bullets parsed out of the `tmpl` docblock').toBeGreaterThan(0); expect(doubled, 'no bullet opens with `{{record.x}}`').toBeDefined(); - expect(single, 'no bullet opens with `{record.x}`').toBeDefined(); + expect(either, 'no bullet opens with `either`').toBeDefined(); }); - it('lists the double-brace renderers against `{{record.x}}`, and the notify slots against `{record.x}`', () => { - for (const renderer of ['messaging', 'email']) expect(doubled).toContain(renderer); - expect(doubled).not.toContain('notify'); - expect(single).toContain('notify'); - expect(single).toContain('`flow-double-brace-interpolation`'); - for (const renderer of ['messaging', 'email']) expect(single).not.toContain(renderer); + it('lists the notify slots with the double-brace renderers, the single brace there as refused, and no `{record.x}`-only bullet', () => { + for (const renderer of ['messaging', 'email', 'notify']) expect(doubled).toContain(renderer); + expect(doubled).toContain('refused'); + expect(bullets.filter((b) => b.startsWith('`{record.x}`'))).toEqual([]); + expect(either).toContain('titleFormat'); + expect(either).not.toContain('notify'); }); }); From 5bfa9ae1f841c8031ad34cd24c3ddc6cfb78d4dd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 19:21:02 +0000 Subject: [PATCH 17/17] =?UTF-8?q?chore(spec):=20regenerate=20api-surface?= =?UTF-8?q?=20and=20export-origins=20on=20the=20merged=20tree=20=E2=80=94?= =?UTF-8?q?=20main's=20builtinNodeConfigKeysJudged=20restored=20beside=20t?= =?UTF-8?q?his=20branch's=20text-slot=20exports=20(#22110)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- packages/spec/api-surface/automation.json | 1 + packages/spec/export-origins/automation.json | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/spec/api-surface/automation.json b/packages/spec/api-surface/automation.json index 7f8ac8abdb0..b8504c83aab 100644 --- a/packages/spec/api-surface/automation.json +++ b/packages/spec/api-surface/automation.json @@ -273,6 +273,7 @@ "WebhookTriggerType (type)", "analyzeRegion (function)", "approverTypeIsOrgScoped (function)", + "builtinNodeConfigKeysJudged (function)", "canonicalApproverType (function)", "collectFlowGraphs (function)", "defineActionDescriptor (function)", diff --git a/packages/spec/export-origins/automation.json b/packages/spec/export-origins/automation.json index dc94e10c719..3c464a0d702 100644 --- a/packages/spec/export-origins/automation.json +++ b/packages/spec/export-origins/automation.json @@ -267,6 +267,7 @@ "WebhookTriggerType": "src/automation/webhook.zod.ts#WebhookTriggerType (type)", "analyzeRegion": "src/automation/control-flow.zod.ts#analyzeRegion (function)", "approverTypeIsOrgScoped": "src/automation/approval.zod.ts#approverTypeIsOrgScoped (function)", + "builtinNodeConfigKeysJudged": "src/automation/flow-node-config-refusals.ts#builtinNodeConfigKeysJudged (function)", "canonicalApproverType": "src/automation/approval.zod.ts#canonicalApproverType (function)", "collectFlowGraphs": "src/automation/control-flow.zod.ts#collectFlowGraphs (function)", "defineActionDescriptor": "src/automation/node-executor.zod.ts#defineActionDescriptor (function)",