From 87a3bc460ee91a621e225c40fd96f4eb1c059702 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 05:16:11 +0000 Subject: [PATCH 01/14] wip(spec): value-slot template-dialect judge (#19939) Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude --- .../src/automation/builtin-node-config.zod.ts | 110 ++++--- .../automation/flow-node-expression-paths.ts | 46 +-- .../automation/flow-value-slot-template.ts | 305 ++++++++++++++++++ packages/spec/src/automation/index.ts | 4 + 4 files changed, 405 insertions(+), 60 deletions(-) create mode 100644 packages/spec/src/automation/flow-value-slot-template.ts diff --git a/packages/spec/src/automation/builtin-node-config.zod.ts b/packages/spec/src/automation/builtin-node-config.zod.ts index 74528f36a79..13ee4880960 100644 --- a/packages/spec/src/automation/builtin-node-config.zod.ts +++ b/packages/spec/src/automation/builtin-node-config.zod.ts @@ -35,7 +35,9 @@ * type and `required` violations refuse the node as a guard. All of these * 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. + * executor's existing interpolation points. The value slots are the exception + * since #19939: the `{token}` dialect is retired there (the "value slots" + * section below). * * ## Unknown keys — closed here too, as of #4001 批 9 * @@ -69,8 +71,9 @@ * * The `create_record` / `update_record` `fields` map carries the same value * contract since #19938 (`FlowValueSlotSchema`, the "value slots" section): - * a field value may be a CEL value envelope beside a `{token}` template or a - * literal, and the three maps are the expression ledger's `value`-role slots. + * a field value is a CEL value envelope or a literal — a `{token}` template is + * refused there since #19939 — and the three maps are the expression ledger's + * `value`-role slots. * * Deliberately absent: * - `decision` / `script` / `subflow` / `wait` / `connector_action` — the @@ -89,6 +92,10 @@ import { lazySchema } from '../shared/lazy-schema'; import { strictObject } from '../shared/strict-object'; import { refuseCatchallProtoKey, refuseRecordProtoKey } from '../shared/record-proto-key-guard'; import { isExpressionEnvelopeShaped } from './flow-node-expression-paths'; +// [#19939] The retirement of the `{…}` template dialect from value slots — the +// one judge, composed into every value slot's contract below rather than +// re-spelled here. +import { valueSlotTemplateRefusals } from './flow-value-slot-template'; /** What a rejected key on these contracts silently did before #4001 批 9. */ const BUILTIN_NODE_CONFIG_HISTORY = @@ -282,17 +289,20 @@ export const ASSIGNMENT_VALUE_ENVELOPE_REFUSAL = VALUE_ENVELOPE_REFUSAL; * * A bare string is deliberately NOT accepted as CEL shorthand the way * `ExpressionInputSchema` accepts it elsewhere: in a value slot a plain - * string has always meant `{token}` flow interpolation, and that meaning is - * kept. The envelope is the only CEL spelling in this slot — which is exactly - * what lets the two forms coexist without a mode switch. + * string is the literal text it spells (#19939 retired the `{token}` + * interpolation it used to mean — {@link valueSlotTemplateRefusals}), so + * reading it as CEL source would silently change what every literal writes. + * The envelope is the only CEL spelling in this slot — which is exactly what + * lets literals and expressions coexist without a mode switch. */ export const AssignmentExpressionValueSchema = EvaluatedExpressionSchema .safeExtend({ dialect: z.literal('cel', { error: () => 'A value envelope is evaluated by the expression engine to a value, which only the `cel` dialect does — ' - + '`template` and `cron` envelopes have no meaning here. For text with holes write a plain string ' - + '(`{token}` flow interpolation); for a computed value write `{ dialect: \'cel\', source: \'…\' }`.', + + '`template` and `cron` envelopes have no meaning here. For text with holes write one CEL concatenation ' + + '(`{ dialect: \'cel\', source: "\'Hello \' + name" }`); for any computed value write ' + + '`{ dialect: \'cel\', source: \'…\' }`.', }), }) .meta({ @@ -312,9 +322,12 @@ export type AssignmentExpressionValueParsed = z.infer { - if (!isExpressionEnvelopeShaped(value)) return; + if (!isExpressionEnvelopeShaped(value)) { + // [#19939] A literal: refused only where it still spells the retired + // `{…}` dialect, at the string's own path inside the value. + for (const refusal of valueSlotTemplateRefusals(value)) { + ctx.addIssue({ code: 'custom', path: [...refusal.path], message: refusal.message }); + } + return; + } const result = AssignmentExpressionValueSchema.safeParse(value); if (result.success) return; for (const issue of result.error.issues) { @@ -344,29 +364,33 @@ function celValueSlotSchema(description: string) { } /** - * What a value in a `value`-role slot may be (#14149, generalised in #19938) — - * the two authoring forms, plus literals: + * What a value in a `value`-role slot may be (#14149, generalised in #19938, + * narrowed in #19939) — an expression, or a literal: * - * - a **string** — `{token}` flow interpolation, resolved by `interpolate()` - * against the live variables (a sole token keeps the token's type: - * `'{rows}'` yields the array); text with no tokens is the literal text; * - a **CEL value envelope** — {@link AssignmentExpressionValueSchema}, * evaluated by the expression engine to a value, so the declared stdlib is * authorable from metadata: `joinNonEmpty(rows.map(r, r.subject), "\n")` * builds a digest body from a list, `round(price * 100) / 100.0` a money - * value (`100.0`: CEL divides two integers as integers); - * - any other JSON value — a number, boolean, `null`, array or plain object - * — used as a literal (strings inside it still interpolate). + * value (`100.0`: CEL divides two integers as integers), `record.owner` + * copies a value with its type; + * - any other JSON value — a string, number, boolean, `null`, array or plain + * object — used as the literal it spells. * * The forms are told apart by SHAPE, never by a mode key: an object that names * a `dialect` is an envelope ({@link isExpressionEnvelopeShaped}) and must be a - * valid one, everything else is what it always was. That is the preservation - * half of the contract — every value that parsed before a slot joined the - * `value` role still parses, and the only newly refused shape is a malformed - * envelope (no `source` — `{ dialect: 'cel' }` and an `ast`-only envelope - * alike — a blank `source`, a non-`cel` dialect), which used to be stored - * verbatim as a literal object. Only the TOP-LEVEL value of a slot is judged: - * an envelope-shaped object nested inside an array or a plain object is data. + * valid one — a malformed envelope (no `source` — `{ dialect: 'cel' }` and an + * `ast`-only envelope alike — a blank `source`, a non-`cel` dialect) is + * refused rather than stored verbatim as a literal object. Only the TOP-LEVEL + * value of a slot is an expression: an envelope-shaped object nested inside an + * array or a plain object is data. + * + * **The `{token}` template dialect is retired from these slots** (#19939, the + * C half of #11182 ruling D): a string anywhere in a literal that carries a + * `{…}` token the interpolator would resolve is refused, with the token's CEL + * spelling ({@link valueSlotTemplateRefusals} — every token measured lossy + * under conversion, so none is rewritten, ADR-0087 D2). Two spellings CEL + * cannot write yet keep their 17.x meaning until it can: the date macros + * (`{NOW()}`, `{TODAY() + 7}`) and the run user (`{$User.Id}`). * * The slot-neutral contract. The CRUD `fields` map's values take it * (`CreateRecordConfigSchema` / `UpdateRecordConfigSchema`, #19938), and it is @@ -376,9 +400,9 @@ function celValueSlotSchema(description: string) { * variable-worded description. */ export const FlowValueSlotSchema = celValueSlotSchema( - 'A value: a string (`{token}` flow interpolation — a sole token keeps its type), a CEL value envelope ' - + '`{ dialect: \'cel\', source }` evaluated by the expression engine (the CEL stdlib such as `joinNonEmpty` is ' - + 'reachable), or any other literal', + 'A value: a CEL value envelope `{ dialect: \'cel\', source }` evaluated by the expression engine (the CEL stdlib ' + + 'such as `joinNonEmpty` is reachable), or a literal written as it is — a `{…}` template token in a string is ' + + 'refused (the template dialect is retired from value slots; the date macros and `$User` paths are kept for now)', ); export type FlowValueSlot = z.input; @@ -431,12 +455,12 @@ export const CreateRecordConfigSchema = lazySchema(() => strictObject({ objectName: z.string().describe('Object to insert into'), /** * Field values to write on the new record — a `value`-role slot of the - * expression ledger (`create_record.fields.*`, #19938): each value is a - * `{token}` template, a CEL value envelope, or a literal - * ({@link FlowValueSlotSchema}). + * expression ledger (`create_record.fields.*`, #19938): each value is a CEL + * value envelope or a literal ({@link FlowValueSlotSchema}); a `{token}` + * template is refused there since #19939. */ fields: z.record(z.string(), FlowValueSlotSchema).optional() - .describe('Field values to write on the new record: each key is a field name, each value a `{token}` template, a CEL value envelope, or a literal'), + .describe('Field values to write on the new record: each key is a field name, each value a CEL value envelope or a literal'), /** Flow variable bound to the created record (`{var.id}` works even when the driver returns a bare id). */ outputVariable: z.string().optional() .describe('Flow variable bound to the created record'), @@ -467,11 +491,12 @@ export const UpdateRecordConfigSchema = lazySchema(() => strictObject({ .describe('Field/value pairs identifying the record(s) to update'), /** * Field values to write — a `value`-role slot of the expression ledger - * (`update_record.fields.*`, #19938): each value is a `{token}` template, a - * CEL value envelope, or a literal ({@link FlowValueSlotSchema}). + * (`update_record.fields.*`, #19938): each value is a CEL value envelope or + * a literal ({@link FlowValueSlotSchema}); a `{token}` template is refused + * there since #19939. */ fields: z.record(z.string(), FlowValueSlotSchema).optional() - .describe('Field values to write: each key is a field name, each value a `{token}` template, a CEL value envelope, or a literal'), + .describe('Field values to write: each key is a field name, each value a CEL value envelope or a literal'), /** * Declare BULK intent — this node may update EVERY row `filter` matches. * @@ -956,9 +981,10 @@ export type MapConfigParsed = z.infer; * the value cell. */ export const AssignmentValueSchema = celValueSlotSchema( - 'Value the variable takes: a string (`{token}` flow interpolation — a sole token keeps its type), a CEL value ' - + 'envelope `{ dialect: \'cel\', source }` evaluated by the expression engine (the CEL stdlib such as ' - + '`joinNonEmpty` is reachable), or any other literal', + 'Value the variable takes: a CEL value envelope `{ dialect: \'cel\', source }` evaluated by the expression engine ' + + '(the CEL stdlib such as `joinNonEmpty` is reachable), or a literal written as it is — a `{…}` template token in ' + + 'a string is refused (the template dialect is retired from value slots; the date macros and `$User` paths are ' + + 'kept for now)', ); export type AssignmentValue = z.input; @@ -966,7 +992,7 @@ export type AssignmentValueParsed = z.infer; /** What the refusal of the legacy `assignments: [{ variable, value }]` array says. */ export const ASSIGNMENT_ARRAY_FORM_PRESCRIPTION = - '`assignments` is a map of variable name → value (`{ assignments: { total: \'{amount}\' } }`). The array form ' + '`assignments` is a map of variable name → value (`{ assignments: { total: { dialect: \'cel\', source: \'amount\' } } }`). The array form ' + '`[{ variable, value }]` is a legacy shape the executor still reads but this contract does not describe — ' + 'write the map, which is also the only shape that accepts a CEL value envelope.'; @@ -1020,7 +1046,7 @@ export const AssignmentConfigSchema = lazySchema(() => refuseCatchallProtoKey(z. // `refuseRecordProtoKey`'s docblock), so it alone is refused here. 'assignments', ).optional() - .describe('Variables to set: each key is a variable name, each value a `{token}` template, a CEL value envelope, or a literal'), + .describe('Variables to set: each key is a variable name, each value a CEL value envelope or a literal'), }) // Open by design: the bare legacy `{ : }` config and any // top-level key an author names live here. diff --git a/packages/spec/src/automation/flow-node-expression-paths.ts b/packages/spec/src/automation/flow-node-expression-paths.ts index bd905904d18..50046157cef 100644 --- a/packages/spec/src/automation/flow-node-expression-paths.ts +++ b/packages/spec/src/automation/flow-node-expression-paths.ts @@ -105,12 +105,16 @@ export type FlowNodeExpressionRole = * CEL, any result type). Not a predicate (no boolean expected) and not a * template (no `{token}` holes): the slot's *shape* decides which dialect it * is in. A `value` slot is a config position whose authored value is EITHER - * the `{token}` flow interpolation every node string already gets (a plain - * string — the `flow-template` dialect above, still unvalidated here) OR an + * a literal (a plain string is the text it spells) OR an * `{ dialect: 'cel', source }` expression envelope (`ExpressionSchema` in * `shared/expression.zod.ts`) evaluated to a value. Only the envelope form is * an expression to check, so {@link resolveFlowNodeExpressions} emits - * envelope-shaped objects — never strings — for this role (#14149). + * envelope-shaped objects — never strings — for this role (#14149). Until + * #19939 a plain string here was the `{token}` flow interpolation every node + * string gets; that dialect is retired from value slots, and a string still + * carrying a `{…}` token is refused by `flowNodeValueTemplateRefusals` + * (`flow-value-slot-template.ts`), which walks every value this role holds + * through {@link resolveFlowNodeValueSlots}. * * Declared for the `assignment` node's `assignments` map (maintainer ruling * 2026-09-02, #14149): that is the one slot whose whole job is to compute a @@ -204,9 +208,10 @@ export interface FlowNodeExpressionPath { * 'cel', source }`, a shape no `{token}` interpolation ever produced — and only * that form is resolved. Three maps are such slots: the `assignment` node's * `assignments` (#14149) and the `create_record` / `update_record` `fields` - * (#19938). Their `{token}` strings stay the generic text-with-holes case - * above — a template in `fields.*` means exactly what it meant before the - * slot was declared. Each is declared through the spec Zod channel (the map + * (#19938). Their strings are NOT the generic text-with-holes case above: + * since #19939 a `{token}` in a value slot is refused + * (`flow-value-slot-template.ts`), and a token-free string is a literal. Each + * is declared through the spec Zod channel (the map * value's `.meta({ xExpression: 'value' })` on `AssignmentConfigSchema` / * `CreateRecordConfigSchema` / `UpdateRecordConfigSchema`, exposed to the * ratchet through `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS` in @@ -268,8 +273,10 @@ export const FLOW_NODE_EXPRESSION_PATHS: readonly FlowNodeExpressionPath[] = [ // (`logic-nodes.ts` — the bare `{ : }` config with no // wrapper, and the `assignments: [{ variable, value }]` array). Neither is // declared by the descriptor or offered for new authoring, and their - // values keep today's meaning (a `{token}` template or a literal — an - // envelope-shaped object there is a literal object, as it always was). + // values are literals (an envelope-shaped object there is a literal + // object, as it always was). Since #19939 a `{token}` in one is refused + // like in this map — `flowNodeValueTemplateRefusals` walks both shapes — + // so neither is a way around the retirement. nodeType: 'assignment', path: 'assignments.*', role: 'value', @@ -279,8 +286,8 @@ export const FLOW_NODE_EXPRESSION_PATHS: readonly FlowNodeExpressionPath[] = [ // The CRUD write map (#19938, the contract half of #11182 ruling D): every // value of `fields` — `{ : }`, keys authored by the flow // author — is a `value` slot, the same shape and dialect rules as - // `assignments.*`. A plain string there stays `{token}` interpolation with - // its 17.x meaning unchanged; only the envelope form is new. Declared + // `assignments.*`. Since #19939 a plain string there is a literal and a + // `{token}` in it is refused; the envelope is the expression form. Declared // through the spec Zod channel (`CreateRecordConfigSchema`'s map value, // `FlowValueSlotSchema`): the descriptor's own `fields` is // `additionalProperties: true` and carries no marker, exactly like the @@ -317,7 +324,8 @@ export interface ResolvedFlowNodeExpression { * string `dialect`? * * The recognizer a `value` slot discriminates on (#14149): a plain string in - * such a slot is `{token}` flow interpolation, a plain object is a literal, and + * such a slot is literal text (its `{token}` interpolation retired in #19939), + * a plain object is a literal, and * an object that names a `dialect` is an expression envelope * (`ExpressionSchema`) — the one form the expression engine evaluates. It is * deliberately looser than "a VALID envelope": `{ dialect: 'cel' }` with no @@ -381,8 +389,9 @@ export function isExpressionEnvelopeShaped(value: unknown): value is { dialect: * reached the `predicate` slots and nothing else. * - `value`: the slot holds a VALUE that may be spelled as an expression, so * only envelope-shaped objects ({@link isExpressionEnvelopeShaped}) are - * emitted. A string there is `{token}` interpolation — the `flow-template` - * dialect, not an expression to parse — and every other literal is data. + * emitted. A string there is a literal, not an expression to parse (a + * `{token}` in it is refused by `flowNodeValueTemplateRefusals`, #19939), + * and every other literal is data. * Existing entries resolve byte-identically: no ledger path before #14149 * carries a `*` segment or the `value` role. */ @@ -419,11 +428,12 @@ export function resolveFlowNodeExpressions( * and literals included, not only envelopes (#19938). * * {@link resolveFlowNodeExpressions} emits only the envelope form for this - * role, because only an envelope is an expression to CHECK. Tooling that - * reasons about the other form such a slot accepts — the lint's author-time - * hint that points a `{…}` template expression at the CEL value envelope - * (#11182 ruling D) — needs the strings too, located by the SAME path walk, so - * it can never disagree with the ledger about which positions are value slots. + * role, because only an envelope is an expression to CHECK. The judge of the + * other form such a slot holds — `flowNodeValueTemplateRefusals`, which + * refuses a `{…}` token of the retired template dialect in a literal (#19939, + * the C half of #11182 ruling D) — needs the strings too, located by the SAME + * path walk, so it can never disagree with the ledger about which positions + * are value slots. * An absent (`undefined`) value is skipped; everything else is handed over * verbatim, with its concrete path. * diff --git a/packages/spec/src/automation/flow-value-slot-template.ts b/packages/spec/src/automation/flow-value-slot-template.ts new file mode 100644 index 00000000000..0279aa1a8bf --- /dev/null +++ b/packages/spec/src/automation/flow-value-slot-template.ts @@ -0,0 +1,305 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * @module automation/flow-value-slot-template + * + * **The `{…}` template dialect, retired from flow VALUE slots** (#19939 — the + * C half of #11182 ruling D, on the protocol-18 line). + * + * A value slot is a config position whose authored value BECOMES a value: the + * expression ledger's `value` role (`assignment.assignments.*`, + * `create_record.fields.*`, `update_record.fields.*` — + * `FLOW_NODE_EXPRESSION_PATHS`), plus the two legacy `assignment` shapes the + * executor still reads (the `assignments: [{ variable, value }]` array and the + * bare `{ : }` config). Until this retirement every string + * there ran through `{token}` interpolation, and since #19938 the same slots + * also evaluate a CEL value envelope — two dialects for one job, with two + * function sets and two meanings of `/`. This module is the ONE judge of the + * retirement: a value that carries a `{…}` token the interpolator would + * resolve is REFUSED, and the refusal names the CEL spelling of each token. + * `FlowValueSlotSchema` composes it (so the contract says it), and + * `AutomationEngine.registerFlow`, `objectstack validate` and the executors + * call it — ⛔ never a second reading of the dialect anywhere. + * + * ## Refused, not converted (ADR-0087 D2) + * + * D2 lets a conversion rewrite only what maps LOSSLESSLY. Every token spelling + * that occurs in authored flows was measured through the shipped interpolator + * AND the shipped CEL engine over the same variables, and each one has an + * input on which the two answers differ: + * + * - a path (`{x}`, `{a.b}`, `{list.0}`) — CEL refuses an absent variable, + * key or index where the template wrote nothing; + * - text with holes (`'Hello {o.name}'`) — CEL refuses `+ null` where the + * template rendered nothing; + * - arithmetic (`{round(x * 100) / 100}`) — CEL divides two integers as + * integers (`123.46` becomes `123`); + * - `{NOW()}` / `{TODAY()}` — CEL yields a Timestamp, not the ISO text; + * - `{$User.Id}` — the flow CEL scope binds no user. + * + * So no spelling is converted: each is refused with its remedy, and the author + * judges the absent case the template used to decide silently. Only a token + * with no variable in it (`{100}`) maps losslessly, and none is authored. + * + * ## Two spellings are KEPT, deliberately — not yet refused + * + * A refusal must name what to write instead, and for two spellings CEL has + * nothing to name yet: + * + * - the date macros — `{NOW()}`, `{TODAY()}`, with an optional `± N` day + * offset. CEL's `now()` / `today()` / `daysFromNow()` / `addDays()` yield a + * Timestamp, which reaches the data engine as a `Date` object rather than + * the ISO text the macro wrote, and CEL has no `string(timestamp)` to render + * one; + * - the run user — `{$User.}`. The flow CEL scope binds no user, so + * `current_user.id` is an unknown variable in a flow. + * + * A string whose every token is one of these keeps its 17.x meaning; a string + * that mixes one with any other token keeps it too, because the other half + * could not be moved without it. Refusing them now would remove a capability + * with nothing to replace it. Each is retired when CEL can spell it — a string + * form for a Timestamp, and a user binding in the flow CEL scope. + * + * ## 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. + */ + +import { isExpressionEnvelopeShaped, resolveFlowNodeValueSlots } from './flow-node-expression-paths'; + +/** + * The one sentence every refusal of a `{…}` token in a value slot leads with — + * the same words in every value slot and at every door (`FlowValueSlotSchema`, + * `registerFlow`, `objectstack validate`, the executors), so an author (or an + * agent reading the failure) meets the rule before the per-token remedy. + */ +export const VALUE_SLOT_TEMPLATE_REFUSAL = + 'A value slot no longer reads the `{…}` template dialect: a string here is the literal text it spells, so a ' + + '`{…}` 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 to one of the six functions the dialect mirrors from the CEL stdlib. */ +const EXPRESSION_SHAPE = /[+\-*/%<>=!&|?]|\b(?:round|floor|ceil|abs|min|max)\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, "\\'")}'`; +} + +/** How an envelope with `source` is written in the remedy — double-quoted when the source holds a single quote. */ +function envelopeOf(source: string): string { + const quoted = source.includes("'") ? JSON.stringify(source) : `'${source}'`; + return `{ dialect: 'cel', source: ${quoted} }`; +} + +/** A `has()` guard for a path whose last segment is a key — the absent-key remedy. */ +function guardOf(path: string): string | undefined { + const segments = path.split('.'); + const last = segments[segments.length - 1]!; + if (segments[0]!.startsWith('$') || /^\d+$/.test(last)) return undefined; + const read = segments.length === 1 ? `vars.${path}` : celPath(path); + return `has(${read}) ? ${read} : null`; +} + +const ABSENT_SENTENCE = + 'CEL refuses an absent variable or key where the template wrote nothing, so guard one that may be absent with `has()`'; + +/** The remedy for one string — the CEL spelling of what the template computed. */ +function remedyFor(value: string, tokens: readonly Token[]): string { + const whole = tokens.length === 1 && tokens[0]!.text === value ? tokens[0]! : undefined; + if (tokens.some((t) => t.kind === 'unresolvable')) { + const junk = tokens.find((t) => t.kind === 'unresolvable')!; + return ( + `\`${junk.text}\` resolves to nothing in the template dialect, which wrote nothing for it. If the braces are ` + + `literal text, write the value as a CEL string literal: ${envelopeOf(celString(value))}.` + ); + } + if (whole?.kind === 'path') { + const guard = guardOf(whole.inner); + return ( + `Write \`${whole.text}\` as ${envelopeOf(celPath(whole.inner))}. ${ABSENT_SENTENCE}` + + (guard ? `: \`${guard}\` (the guarded form writes \`null\`).` : '.') + ); + } + if (whole?.kind === 'expression') { + return ( + `Write \`${whole.text}\` as ${envelopeOf(celExpression(whole.inner))}. Every division keeps a decimal operand: ` + + 'CEL divides two integers as integers, so `round(x * 100) / 100` drops the decimals where ' + + '`round(x * 100) / 100.0` keeps them. ' + ABSENT_SENTENCE + '.' + ); + } + // Text with holes: one CEL concatenation. + const parts: string[] = []; + let at = 0; + for (const match of value.matchAll(TEMPLATE_TOKEN)) { + const literal = value.slice(at, match.index); + if (literal) parts.push(celString(literal)); + const inner = match[1]!.trim(); + parts.push(tokenKind(inner) === 'path' ? celPath(inner) : `(${celExpression(inner)})`); + at = (match.index ?? 0) + match[0].length; + } + const tail = value.slice(at); + if (tail) parts.push(celString(tail)); + return ( + `\`${value}\` is text with holes: write it as one CEL concatenation, ${envelopeOf(parts.join(' + '))}. Wrap a ` + + 'hole that is not a string in `string(…)`, and one that may be null in `coalesce(…, \'\')` — the template ' + + 'rendered null as nothing, and CEL refuses `+ null`.' + ); +} + +/** One refused string inside a value slot's value. */ +export interface ValueSlotTemplateRefusal { + /** Where the string sits inside the value — `[]` for the value itself, `['meta', 'note']`, `[0]`. */ + readonly path: readonly (string | number)[]; + /** The rule sentence, then the CEL spelling of this string's tokens. */ + readonly message: string; + /** The refused string, as authored. */ + readonly source: string; +} + +/** + * Every string inside a value-slot value that carries the retired `{…}` + * template dialect — the value itself when it is a string, and every string at + * any depth of an array or a plain object (a literal's strings were + * interpolated too). An envelope-shaped value is not judged here: it is an + * expression, and `FlowValueSlotSchema`'s envelope rule owns it. A string whose + * tokens include a kept spelling (a date macro, a `$User` path) is not refused + * — see the module docblock. Cycle-safe: a flow built in code may hold a + * self-reference. + */ +export function valueSlotTemplateRefusals(value: unknown): ValueSlotTemplateRefusal[] { + if (isExpressionEnvelopeShaped(value)) return []; + const out: ValueSlotTemplateRefusal[] = []; + const seen = new Set(); + const visit = (node: unknown, path: (string | number)[]): void => { + if (typeof node === 'string') { + const tokens = tokensOf(node); + if (tokens.length === 0 || tokens.some((t) => KEPT_KINDS.has(t.kind))) return; + out.push({ path, message: `${VALUE_SLOT_TEMPLATE_REFUSAL} ${remedyFor(node, tokens)}`, source: node }); + return; + } + if (node === null || typeof node !== 'object' || seen.has(node)) return; + seen.add(node); + if (Array.isArray(node)) node.forEach((element, index) => visit(element, [...path, index])); + else for (const [key, element] of Object.entries(node as Record)) visit(element, [...path, key]); + }; + visit(value, []); + return out; +} + +/** One refused string in a node's config, located for a door's report. */ +export interface FlowNodeValueTemplateRefusal { + /** Path into `node.config` — `fields.total`, `assignments.digest`, `assignments[0].value`, `total`, `fields.meta.note`. */ + readonly path: string; + /** The slot, as a door names it — `create_record field value`, `assignment value`. */ + readonly label: string; + /** {@link VALUE_SLOT_TEMPLATE_REFUSAL}, then the remedy. */ + readonly message: string; + /** The refused string, as authored. */ + readonly source: string; +} + +function joinPath(prefix: string, inner: readonly (string | number)[]): string { + let out = prefix; + for (const segment of inner) out += typeof segment === 'number' ? `[${segment}]` : (out ? `.${segment}` : segment); + return out; +} + +/** + * Every `{…}` template-dialect refusal in one node's `config` — the call the + * build door (`objectstack validate`) and `AutomationEngine.registerFlow` + * share, so the two give one verdict. + * + * The positions are the ledger's `value` slots ({@link resolveFlowNodeValueSlots}) + * and, on an `assignment` node, the two legacy shapes its executor still + * reads, normalised exactly as the executor normalises them: an `assignments` + * ARRAY (`[{ variable, value }]` — each element's `value`), and, when + * `assignments` is neither an array nor an object, the bare config whose + * top-level keys are the variables. An envelope there is a literal, as it + * always was; a `{…}` token there is refused like anywhere else, so the legacy + * shapes are no way around the retirement. + */ +export function flowNodeValueTemplateRefusals(nodeType: string, config: unknown): FlowNodeValueTemplateRefusal[] { + const out: FlowNodeValueTemplateRefusal[] = []; + const judge = (path: string, label: string, value: unknown): void => { + for (const refusal of valueSlotTemplateRefusals(value)) { + out.push({ path: joinPath(path, refusal.path), label, message: refusal.message, source: refusal.source }); + } + }; + for (const found of resolveFlowNodeValueSlots(nodeType, config)) judge(found.path, found.entry.label, found.value); + if (nodeType === 'assignment' && config !== null && typeof config === 'object' && !Array.isArray(config)) { + const raw = (config as Record).assignments; + if (Array.isArray(raw)) { + raw.forEach((item, index) => { + if (item !== null && typeof item === 'object' && !Array.isArray(item)) { + judge(`assignments[${index}].value`, 'assignment value', (item as Record).value); + } + }); + } else if (!(raw !== null && typeof raw === 'object')) { + for (const [key, value] of Object.entries(config as Record)) judge(key, 'assignment value', value); + } + } + return out; +} diff --git a/packages/spec/src/automation/index.ts b/packages/spec/src/automation/index.ts index 3321d51425f..2db0ad53106 100644 --- a/packages/spec/src/automation/index.ts +++ b/packages/spec/src/automation/index.ts @@ -76,5 +76,9 @@ export * from './schedule-organization.zod'; export * from './node-executor.zod'; export * from './flow-node-expression-paths'; export * from './flow-node-config-refusals'; +// [#19939] The `{…}` template dialect retired from flow value slots — the one +// judge `FlowValueSlotSchema`, `registerFlow`, `objectstack validate` and the +// executors share. +export * from './flow-value-slot-template'; export * from './bpmn-interop.zod'; export * from './bpmn-mapping'; From dad00626295bbecb7bf0c23893114d669b4e68d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 05:17:53 +0000 Subject: [PATCH 02/14] wip: registerFlow, executors and os validate refuse the value-slot template dialect (#19939) Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude --- .../lint/src/validate-expressions.test.ts | 11 ++- packages/lint/src/validate-expressions.ts | 94 +++---------------- .../src/builtin/crud-nodes.ts | 28 ++++-- .../src/builtin/logic-nodes.ts | 25 ++++- .../src/builtin/template.ts | 6 +- .../services/service-automation/src/engine.ts | 24 ++++- 6 files changed, 88 insertions(+), 100 deletions(-) diff --git a/packages/lint/src/validate-expressions.test.ts b/packages/lint/src/validate-expressions.test.ts index 87d6a5d0c63..d590cce6eac 100644 --- a/packages/lint/src/validate-expressions.test.ts +++ b/packages/lint/src/validate-expressions.test.ts @@ -3026,10 +3026,13 @@ describe('validateStackExpressions — reads only keys the spec declares (meta-t // this file is a local named `scope`; `graph.scope` is a KEY read off the // tabled `graph` receiver, so the metadata guard loses no coverage here. 'scope', - // [#19938] The same import-specifier artefact, of - // `'./flow-template-grammar.js'` (`grammar.j…`): nothing in this file is - // a local named `grammar`. - 'grammar', + // [#19939] The spec's value-slot template judge, one refusal at a time. + // Its keys are that helper's own `{ path, label, message, source }` — + // never metadata keys — and it is named to stay clear of the `message` / + // `source` receivers for the reason the entries above record. (The + // `grammar` excuse #19938 added here left with the import it excused: + // this file no longer imports `'./flow-template-grammar.js'`.) + 'templateRefusal', ]); expect(receivers.filter((r) => !tabled.has(r) && !PLUMBING.has(r))).toEqual([]); }); diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts index 323aff5b3e7..bcfa064d274 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -90,7 +90,7 @@ import { flowNodeConfigRefusals, predicateSlotRefusal, resolveFlowNodeExpressions, - resolveFlowNodeValueSlots, + flowNodeValueTemplateRefusals, structuralConditionRefusal, } from '@objectstack/spec/automation'; // [#15137] The `value`-role half. Same two published primitives the engine @@ -117,69 +117,6 @@ import { injectedColumnsFor, unprovisionedInjectedColumnsFor } from './system-fi import { findUnguardedNullableOperands, nullGuardMessage } from './validate-null-guards.js'; import type { NullGuardOutcome } from './validate-null-guards.js'; import { recordsOf } from './object-graph.js'; -import { classifyFlowTemplateToken, FLOW_TEMPLATE_VALUE_FUNCTIONS, SAFE_EXPRESSION_RE } from './flow-template-grammar.js'; - -/** - * The author-time hint of #11182 ruling D: a `value` slot (`assignments.*`, - * `create_record` / `update_record` `fields.*`) accepts a CEL value envelope, - * so a `{…}` template EXPRESSION authored there is pointed at it — at - * `warning`, never more: the template dialect keeps its 17.x meaning and no - * spelling is refused or rewritten. - * - * Which tokens, and why only those — the hint must never steer an author - * toward metadata the runtime honours but that makes the value worse: - * - * - a template EXPRESSION — arithmetic, a comparison, or a call to one of the - * CEL-mirrored six (`round` / `floor` / `ceil` / `abs` / `min` / `max`) — - * is where the two vocabularies overlap and CEL is a strict superset (the - * whole stdlib). The one conversion trap is stated in the hint itself: CEL - * divides two integers as integers, so `/ 100` must become `/ 100.0`. - * - ⛔ NOT a plain `{var}` / `{var.path}` reference: CEL adds nothing to it, - * and an absent key flips from `undefined` to a fault — a behaviour change - * the hint would be recommending in the dark. - * - ⛔ NOT `{NOW()}` / `{TODAY() ± N}`: CEL's `now()` / `today()` are - * Timestamps, not the ISO strings the macros produce, and CEL has no - * `string(timestamp)` to recover them — the string form is the v18 - * carrier's to add. - * - ⛔ NOT `{$User.*}`: the flow CEL scope binds no user. - * - * The token grammar is the lint package's MIRROR of the template evaluator - * (`flow-template-grammar.ts`, drift-pinned against `template.ts`), consulted - * in the evaluator's own dispatch order — never a second reading of it. - */ -const TEMPLATE_TOKEN_RE = /\{([^{}]+)\}/g; -const TEMPLATE_EXPRESSION_OPERATOR_RE = /[+\-*/%<>=!&|?]/; -const TEMPLATE_VALUE_CALL_RE = new RegExp(`\\b(?:${FLOW_TEMPLATE_VALUE_FUNCTIONS.join('|')})\\s*\\(`); - -/** The first `{…}` token in `value` that is a template EXPRESSION (see above), or `undefined`. */ -function templateExpressionToken(value: string): string | undefined { - // A global regex carries `lastIndex` between calls, so the scan starts from - // zero every time; the loop is synchronous, so nothing interleaves. - TEMPLATE_TOKEN_RE.lastIndex = 0; - for (let match = TEMPLATE_TOKEN_RE.exec(value); match !== null; match = TEMPLATE_TOKEN_RE.exec(value)) { - const inner = match[1]!.trim(); - // A date macro, a `$User` path, a variable path or an unknown call is - // classified away here, in the evaluator's own order. - if (classifyFlowTemplateToken(inner).kind !== 'unresolvable-shape') continue; - // Outside the evaluator's arithmetic character set the token resolves to - // nothing at all — junk, not an expression to move. - if (!SAFE_EXPRESSION_RE.test(inner)) continue; - if (TEMPLATE_EXPRESSION_OPERATOR_RE.test(inner) || TEMPLATE_VALUE_CALL_RE.test(inner)) return match[0]; - } - return undefined; -} - -/** The hint's text — the conversion trap stated where the author reads it. */ -function templateExpressionEnvelopeHint(token: string): string { - return ( - `\`${token}\` is a \`{…}\` template-dialect expression. This slot also accepts a CEL value envelope — ` - + "`{ dialect: 'cel', source: '…' }` — evaluated by the engine flow conditions use, with the whole CEL stdlib; " - + 'the template form keeps working unchanged. When moving arithmetic to CEL, give a division a decimal operand: ' - + 'CEL divides two integers as integers, so `round(x * 100) / 100` drops the decimals there — write ' - + '`round(x * 100) / 100.0`.' - ); -} - export interface ExprIssue { where: string; message: string; @@ -1776,23 +1713,20 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression // (the 2026-09-01 option-C ruling's letter). warnShadowedFieldReads(slotWhere, found.value); } - // [#11182 ruling D] The author-time hint: a `{…}` template EXPRESSION in - // a `value` slot is pointed at the CEL value envelope that slot also - // accepts — `warning` only, the template form keeps its meaning. The - // slots come from the ledger's own walk (`resolveFlowNodeValueSlots`), - // never from a path list re-spelled here; which tokens qualify, and - // why only those, is `templateExpressionToken`'s docblock. - // `found` — the same resolver-result shape the declared-slot loop above - // reads (`entry` / `path` / `value`, the resolver's own keys). - for (const found of resolveFlowNodeValueSlots(nodeType, cfg)) { - if (typeof found.value !== 'string') continue; - const token = templateExpressionToken(found.value); - if (token === undefined) continue; + // [#19939] The `{…}` template dialect is retired from the value slots + // (the C half of #11182 ruling D, which this replaced: until then a + // template EXPRESSION here drew a `warning` pointing at the CEL value + // envelope). A literal that still spells it is refused at `error` — + // the one judge `registerFlow` calls on the same config + // (`flowNodeValueTemplateRefusals`), walking the ledger's `value` slots + // and the two legacy `assignment` shapes, so build and registration + // give one verdict, and each refusal names the token's CEL spelling. + for (const templateRefusal of flowNodeValueTemplateRefusals(nodeType, cfg)) { issues.push({ - where: `${at} · node '${node.id}' (${nodeType}) ${found.entry.label} at config.${found.path}`, - message: templateExpressionEnvelopeHint(token), - source: found.value, - severity: 'warning', + where: `${at} · node '${node.id}' (${nodeType}) ${templateRefusal.label} at config.${templateRefusal.path}`, + message: templateRefusal.message, + source: templateRefusal.source, + severity: 'error', }); } // #1870 — a `script` node must name a callable, and since #4343 that is diff --git a/packages/services/service-automation/src/builtin/crud-nodes.ts b/packages/services/service-automation/src/builtin/crud-nodes.ts index 86ed89180f2..3f13728119c 100644 --- a/packages/services/service-automation/src/builtin/crud-nodes.ts +++ b/packages/services/service-automation/src/builtin/crud-nodes.ts @@ -181,11 +181,16 @@ function writtenRowCount(result: unknown): number { * `registerFlow` makes). A malformed envelope throws rather than degrading * to a literal; a value that faults on the live variables throws with its * source (ADR-0032 §1c/§1d). Neither is written. - * - every other value goes through `interpolate()` exactly as the whole map - * used to: a `{token}` string keeps its 17.x meaning byte-for-byte (ruling - * D: no spelling changes meaning), and a literal — an array, a plain - * object, an envelope-shaped object NESTED inside either — is data, with - * its strings interpolated as before. + * - every other value is a literal — a string, an array, a plain object, an + * envelope-shaped object NESTED inside either — and is written as it is. + * Since #19939 (the C half of #11182 ruling D) a `{token}` of the retired + * template dialect never reaches this point: the executor's + * `parseNodeConfig` refuses it through `FlowValueSlotSchema` (the same + * judge `registerFlow` and `objectstack validate` call), so a literal here + * carries no token — or only the two spellings CEL cannot write yet and + * the retirement keeps, the date macros (`{NOW()}`, `{TODAY() + 7}`) and + * `{$User.*}`, which `interpolate()` still resolves. On every other + * literal `interpolate()` is the identity. * * Before this, the executor handed the whole map to `interpolate()`, which * recursed into an envelope as plain data: a text or JSON column received the @@ -384,9 +389,10 @@ function storedMetadataWriteRefusal( * * Each executor: * 1. Interpolates `{var}` / `{var.path}` / `{$User.*}` / `{NOW()}` tokens in - * `node.config` against the running flow's variable context — and, in the - * `create_record` / `update_record` `fields` map, evaluates a CEL value - * envelope to the value written ({@link resolveFieldValues}). + * `node.config` against the running flow's variable context — except in + * the `create_record` / `update_record` `fields` map, a value slot, where + * a CEL value envelope is evaluated to the value written and the `{…}` + * dialect is retired (#19939; {@link resolveFieldValues}). * 2. Calls the resolved data engine via `ctx.getService('data')`. * 3. Writes the result back to the variable context under `outputVariable` * (or under `.id` / `.records` by default), so downstream @@ -537,7 +543,8 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext): if (familyRefusal) return familyRefusal; // #19938 / #11182 ruling D — a CEL value envelope in `fields.*` is - // evaluated; every other value interpolates exactly as before. + // evaluated; every other value is a literal (#19939 retired the + // `{…}` dialect there — `parseNodeConfig` above refused it). const fields = resolveFieldValues(engine, cfg.fields, variables, context); const outputVariable = cfg.outputVariable; @@ -699,7 +706,8 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext): // `fields` is the single canonical write-map key — no alias (the wrong key // `fieldValues` is corrected at the authoring source + rejected by graph-lint). // #19938 / #11182 ruling D — a CEL value envelope in `fields.*` is - // evaluated; every other value interpolates exactly as before. + // evaluated; every other value is a literal (#19939 retired the + // `{…}` dialect there — `parseNodeConfig` above refused it). const fields = resolveFieldValues(engine, cfg.fields, variables, context); const data = getData(); diff --git a/packages/services/service-automation/src/builtin/logic-nodes.ts b/packages/services/service-automation/src/builtin/logic-nodes.ts index 6042787d92e..b8e9f2938ea 100644 --- a/packages/services/service-automation/src/builtin/logic-nodes.ts +++ b/packages/services/service-automation/src/builtin/logic-nodes.ts @@ -1,8 +1,9 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import type { PluginContext } from '@objectstack/core'; -import { defineActionDescriptor, isExpressionEnvelopeShaped } from '@objectstack/spec/automation'; +import { defineActionDescriptor, flowNodeValueTemplateRefusals, isExpressionEnvelopeShaped } from '@objectstack/spec/automation'; import { DEFAULT_BRANCH_LABEL, type AutomationEngine } from '../engine.js'; +import { refuseNode } from '../guard-refusal.js'; import { interpolate } from './template.js'; /** @@ -99,8 +100,15 @@ export function registerLogicNodes(engine: AutomationEngine, ctx: PluginContext) // • bundled example flows → `{ assignments: [{ variable, value }] }` // • legacy / hand-authored → `{ : }` (config keys ARE // the variables). - // Values interpolate `{var}` against the live flow variables, matching - // the CRUD / screen nodes (so `value: '{record.amount}'` resolves). + // [#19939] Values are literals: the `{var}` template dialect is + // retired from every assignment value, in all three shapes (the C half + // of #11182 ruling D). A value that still spells it is refused before + // any variable is set, by the one judge `registerFlow` and `objectstack + // validate` call (`flowNodeValueTemplateRefusals`) — so the legacy + // shapes are no way around it, and a flow that registered cannot be + // refused here. What reaches `interpolate()` below carries no token, or + // only the two the retirement keeps until CEL can spell them (the date + // macros, `{$User.*}`); on everything else it is the identity. // // [#15137] …with ONE exception, and only in the canonical map: a value // that is envelope-shaped (`isExpressionEnvelopeShaped` — a plain object @@ -137,7 +145,7 @@ export function registerLogicNodes(engine: AutomationEngine, ctx: PluginContext) // Designer form (ADR-0018, #3304): the canonical Studio shape — a // single free-form `assignments` map, rendered by the designer's // flat keyValue editor. Values stay `true`-permissive (literals, - // `{var}` templates, numbers…). The legacy array / bare-config + // CEL value envelopes, numbers…). The legacy array / bare-config // shapes the executor also accepts are read-compatible and not // offered for new authoring. No `required`: an empty node is valid. configSchema: { @@ -149,6 +157,15 @@ export function registerLogicNodes(engine: AutomationEngine, ctx: PluginContext) }), async execute(node, variables, context) { const config = (node.config ?? {}) as Record; + // [#19939] The run-time twin of the registration refusal: the + // same judge on the same config, before anything is assigned. + const templateRefusal = flowNodeValueTemplateRefusals('assignment', config)[0]; + if (templateRefusal) { + return refuseNode( + `assignment '${node.id}': ${templateRefusal.label} at config.${templateRefusal.path}: ` + + `${templateRefusal.message} — source: \`${templateRefusal.source}\``, + ); + } const raw = config.assignments; // `declared` = this pair sits in the ledger's `assignments.*` // slot, so an envelope there is an expression (#15137). False for diff --git a/packages/services/service-automation/src/builtin/template.ts b/packages/services/service-automation/src/builtin/template.ts index 869dd5ba3b6..4db5ac50af0 100644 --- a/packages/services/service-automation/src/builtin/template.ts +++ b/packages/services/service-automation/src/builtin/template.ts @@ -31,7 +31,11 @@ * and nothing said why. * * The interpolator walks objects, arrays, and primitives recursively so it - * can be applied wholesale to a node's `config.fields`/`config.filter` blocks. + * 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`). */ import type { AutomationContext } from '@objectstack/spec/contracts'; diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index 597e506a771..df61aaad4e4 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -44,6 +44,10 @@ import { predicateSlotRefusal, resolveFlowNodeExpressions, structuralConditionRe // declares (`assignment.assignments.*`, `create_record` / `update_record` // `fields.*`). import { FlowValueSlotSchema, VALUE_ENVELOPE_REFUSAL } from '@objectstack/spec/automation'; +// [#19939] The `{…}` template dialect retired from the value slots — the one +// 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'; // [#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 @@ -10663,6 +10667,23 @@ export class AutomationEngine implements IAutomationService { // start-node trigger gate + decision/branch predicates live in config.condition checkStructuralCondition(`${at}node '${node.id}' (${node.type}) condition`, cfg.condition); + // [#19939] The value slots' OTHER form — a literal — refused where + // it still spells the retired `{…}` template dialect (the C half + // of #11182 ruling D). Every value the ledger's `value` role holds + // is walked (`resolveFlowNodeValueSlots`, inside the judge), plus + // the two legacy `assignment` shapes the executor still reads, so + // neither shape is a way around the refusal. The executors refuse + // the same set at run time — `parseNodeConfig` through + // `FlowValueSlotSchema` on the CRUD nodes, the same call on the + // `assignment` node — so a flow that registers is never refused + // there, and vice versa. + for (const refusal of flowNodeValueTemplateRefusals(node.type, node.config)) { + failures.push( + ` • ${at}node '${node.id}' (${node.type}) ${refusal.label} at config.${refusal.path}: ` + + `${refusal.message}\n source: \`${refusal.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 @@ -10737,7 +10758,8 @@ export class AutomationEngine implements IAutomationService { throw new Error( `Flow '${flowName}' has ${failures.length} invalid expression${failures.length > 1 ? 's' : ''} (ADR-0032 §1a). ` + `Predicates — conditions and declared bare-CEL slots such as a screen field's \`visibleWhen\` — ` + - `must not wrap references in \`{…}\` template braces; template slots (e.g. \`loop.collection\`) require them:\n` + + `must not wrap references in \`{…}\` template braces, nor may a value slot (a \`fields\` or ` + + `\`assignments\` value); template slots (e.g. \`loop.collection\`) require them:\n` + `${failures.join('\n')}`, ); } From 133a1479be20cd326e325136465c295309d39ea6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 05:34:12 +0000 Subject: [PATCH 03/14] wip: D3 entry, in-repo site migration and docs for the value-slot template retirement (#19939) Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude --- content/docs/automation/flows.mdx | 121 ++++++---- .../docs/kernel/runtime-services/examples.mdx | 7 +- .../app-crm/src/flows/convert-lead.flow.ts | 7 +- .../src/automation/flows/index.ts | 19 +- examples/app-todo/src/flows/task.flow.ts | 46 +++- .../fixtures/flow-durable-suspend-fixture.ts | 4 +- .../services/service-automation/README.md | 4 +- .../flow-value-slot-template.test.ts | 228 ++++++++++++++++++ ...low-value-slot-template-dialect-refused.ts | 40 +++ packages/spec/src/migrations/registry.ts | 52 ++++ packages/verify/src/handle.fixture.ts | 4 +- 11 files changed, 469 insertions(+), 63 deletions(-) create mode 100644 packages/spec/src/automation/flow-value-slot-template.test.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.flow-value-slot-template-dialect-refused.ts diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index 00f676ba82c..23ec0599a84 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -193,31 +193,33 @@ node type has registered, after the boot pull had registered it. label: 'Build digest', config: { assignments: { - // `{token}` flow interpolation — a sole token keeps the token's type, - // text with holes stays text - owner_name: '{manager.name}', - // CEL value envelope — evaluated by the expression engine to a value, so + // CEL value envelope — evaluated by the expression engine to a value, + // type kept; a path copies the value it names + owner_name: { dialect: 'cel', source: 'manager.name' }, // the declared CEL stdlib is reachable from metadata: one line per task digest: { dialect: 'cel', source: 'joinNonEmpty(overdue_tasks.map(t, t.subject), "\\n")' }, + // anything else is a literal, written as it is + status: 'pending', }, }, } ``` -A value's **shape** selects its form — there is no mode key. A plain string is -always `{token}` interpolation (a bare `a + b` is the literal text `a + b`, not -CEL); an object that names a `dialect` is an expression envelope and must be a -valid `cel` one — a missing, empty, whitespace-only or non-string `source`, an -`ast` with no `source`, or a `template` / `cron` dialect, is refused at the -variable's path. Numbers, booleans, arrays and plain objects are assigned as -literals. A later `notify` node renders the variable as any other: -`message: '{digest}'`, and it renders the **evaluated** value. +A value's **shape** selects its form — there is no mode key. An object that +names a `dialect` is an expression envelope and must be a valid `cel` one — a +missing, empty, whitespace-only or non-string `source`, an `ast` with no +`source`, or a `template` / `cron` dialect, is refused at the variable's path. +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 +the **evaluated** value. The same rules hold for the field values of `create_record` / `update_record` (below): the `assignments` map and the `fields` map are the flow's **value -slots**, and each accepts a CEL value envelope beside `{token}` templates and -literals. Only a slot's top-level value is judged — an object nested inside a -JSON value or an array is data, whatever keys it carries. +slots**, and each takes a CEL value envelope or a literal. Only a slot's +top-level value is an expression — an object nested inside a JSON value or an +array is data, whatever keys it carries. @@ -241,6 +243,34 @@ nothing is assigned or written in its place. + + +A value slot no longer reads `{token}` interpolation ([#19939]): a string there +is the literal text it spells, so one carrying a `{…}` token — `'{record.owner}'`, +`'Hello {name}'`, `'{round(x * 100) / 100}'`, at any depth of an array or object +value, in all three `assignment` shapes — is **refused** at `objectstack +validate`, at `registerFlow` and by the executor, with the CEL spelling of each +token. Nothing converts it for you: every spelling answers differently under CEL +for some input, so the rewrite is yours to judge. + +| you wrote | write instead | what changes | +|:---|:---|:---| +| `'{record.owner}'`, `'{x}'` | `{ dialect: 'cel', source: 'record.owner' }` | CEL refuses an **absent** variable or key where the template wrote nothing — guard one that may be absent: `has(record.owner) ? record.owner : null`, `has(vars.x) ? vars.x : null` (writes `null`) | +| `'{list.0}'` | `source: 'list[0]'` | an empty list fails the run | +| `'{$error.message}'` | `source: 'vars["$error"].message'` | a `$`-named variable is read through `vars` | +| `'{round(x * 100) / 100}'` | `source: 'round(x * 100) / 100.0'` | CEL divides two integers as integers: keep a decimal operand on every division | +| `'Follow up on {record.name}'` | `source: "'Follow up on ' + record.name"` | wrap a non-string hole in `string(…)`, one that may be null in `coalesce(…, '')` | +| braces meant literally, `'{"a": 1}'` | `source: "'{\"a\": 1}'"` | a CEL string literal | + +Two spellings **keep** their meaning for now, because CEL cannot write them yet: +the date macros (`'{NOW()}'`, `'{TODAY() + 7}'` — CEL's `now()` / `today()` are +timestamps, not the ISO text the macros write, and there is no string form for +one) and the run user (`'{$User.Id}'` — the flow's CEL scope binds no user). + +[#19939]: https://github.com/objectstack-ai/objectstack/issues/19939 + + + **Create Record:** ```typescript @@ -251,12 +281,14 @@ nothing is assigned or written in its place. config: { objectName: 'task', fields: { - title: 'Follow up on {record.name}', - assignee: '{record.owner}', - due_date: '{TODAY() + 7}', // braces required — without them this writes the literal text - // CEL value envelope — evaluated to the value written, same rules as an + // CEL value envelopes — evaluated to the value written, same rules as an // assignment value. `100.0`, not `100`: CEL divides two integers as integers. + title: { dialect: 'cel', source: "'Follow up on ' + record.name" }, + assignee: { dialect: 'cel', source: 'has(record.owner) ? record.owner : null' }, estimate: { dialect: 'cel', source: 'round(record.amount * 0.15 * 100.0) / 100.0' }, + // a date macro — one of the two `{…}` spellings a value slot still reads + due_date: '{TODAY() + 7}', + status: 'open', // a literal }, }, } @@ -328,7 +360,7 @@ compile error carrying the same prescription. config: { function: 'scoreLead', // registered via defineStack({ functions }) inputs: { rating: '{record.rating}' }, // {var} templates resolve against flow variables - outputVariable: 'leadScore', // later: fields: { score: '{leadScore}' } + outputVariable: 'leadScore', // later: fields: { score: { dialect: 'cel', source: 'leadScore' } } }, } ``` @@ -1882,42 +1914,47 @@ failures so one broken flow does not abort startup. ## Expressions in flows -A flow mixes **two expression dialects**, and the rule is short: **every -condition is CEL; braces are for values** — and the numeric functions the value -dialect accepts are mirrored from CEL's, so a name you learn in conditions -means the same thing inside braces. +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. | Where | Dialect | Write it like | Bindings | |:---|:---|:---|:---| | Start-node `condition` | **CEL** (bare, no braces) | `record.amount > 500` | `record.*`, `previous.*`, bare field names, `vars.*` | | Edge `condition` | **CEL** (bare, no braces) | `record.status == 'open'` | same as above | | Decision-node `conditions[].expression` | **CEL** (bare, no braces) | `order_amount > 10000` | flow variables by name, and `vars.*` | -| Field values in `create_record` / `update_record` | **Interpolation** (braces required) | `'Follow up on {record.name}'`, `'{TODAY() + 7}'` | `{var}`, `{var.path}`, `{$User.Id}`, `{$User.Email}`, `{NOW()}`, `{TODAY()}`, `{TODAY() + 90}` (whole days), and the CEL-mirrored numeric functions `round`, `floor`, `ceil`, `abs`, `min`, `max` (#11060) — `round` is **integer-only**, exactly like CEL's (there is no `round(x, 2)`); for N decimals write `{round(x * 100) / 100.0}` (scale 2). Keep the decimal point: in CEL `round()` returns an int and `int / int` is integer division, so `round(x * 100) / 100` drops the decimals there — `/ 100.0` is right in both dialects | +| 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`, …) | -A value slot takes either form, chosen by shape: a string is interpolation, an -object naming a `dialect` is a CEL envelope. The template form keeps working -unchanged; `objectstack validate` points a template **expression** — arithmetic -or one of the six functions inside braces — at the envelope with a warning, -never an error. Plain references (`{record.name}`), the date macros and -`{$User.*}` are left alone: CEL's `now()` / `today()` are timestamps, not the -strings the macros write, and the flow's CEL scope binds no user. +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 +it used to read is retired: `objectstack validate`, `registerFlow` and the +executor refuse a `{…}` token in a value slot with its CEL spelling (see *The +`{…}` template dialect is retired from value slots* above). The date macros and +`{$User.*}` are kept until CEL can write them: CEL's `now()` / `today()` are +timestamps, not the strings the macros write, and the flow's CEL scope binds no +user. **The failure modes to memorize:** -1. **Braces missing in a field value** — `due_date: 'TODAY() + 7'` writes the - literal text `TODAY() + 7` into the field. Write `'{TODAY() + 7}'`. +1. **A computed value written as a string** — `due_date: 'TODAY() + 7'` or + `amount: 'price * 2'` writes the literal text into the field. Compute it with + a CEL value envelope (`{ dialect: 'cel', source: 'price * 2' }`); the date + macros keep their braces for now: `'{TODAY() + 7}'`. 2. **Braces put *into* a condition** — `'{record.amount} > 500'`. Conditions fail loudly rather than silently, with an error that tells you to drop the braces. -3. **An unsupported function in a field value** — `total: '{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. +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. @@ -2145,7 +2182,7 @@ export const hotLeadFollowUp: Flow = { objectName: 'task', fields: { subject: 'Follow up on hot lead', - related_to: '{record.id}', + related_to: { dialect: 'cel', source: 'record.id' }, priority: 'high', }, }, diff --git a/content/docs/kernel/runtime-services/examples.mdx b/content/docs/kernel/runtime-services/examples.mdx index ba72095fad6..c8afd09966c 100644 --- a/content/docs/kernel/runtime-services/examples.mdx +++ b/content/docs/kernel/runtime-services/examples.mdx @@ -93,7 +93,12 @@ export const RollUpOrderTotals = defineFlow({ config: { objectName: 'sales_order', filter: { id: '{record.id}' }, - fields: { line_count: '{totals.line_count}', amount_total: '{totals.total}' }, + // A value slot computes with a CEL value envelope; the script's + // output `totals` is bound by the node before this one. + fields: { + line_count: { dialect: 'cel', source: 'totals.line_count' }, + amount_total: { dialect: 'cel', source: 'totals.total' }, + }, }, }, { id: 'end', type: 'end', label: 'End' }, diff --git a/examples/app-crm/src/flows/convert-lead.flow.ts b/examples/app-crm/src/flows/convert-lead.flow.ts index 7246c69eb2f..92eeb7734dc 100644 --- a/examples/app-crm/src/flows/convert-lead.flow.ts +++ b/examples/app-crm/src/flows/convert-lead.flow.ts @@ -143,10 +143,13 @@ export const ConvertLeadScreenFlow = defineFlow({ config: { objectName: 'crm_lead', filter: { id: '{recordId}' }, + // A value slot computes with a CEL value envelope — the `{…}` template + // dialect is retired there. Both ids are bound by the screens above + // (`idVariable`) before this node runs, so no `has()` guard is needed. fields: { status: 'converted', - account: '{account_id}', - converted_opportunity: '{opportunity_id}', + account: { dialect: 'cel', source: 'account_id' }, + converted_opportunity: { dialect: 'cel', source: 'opportunity_id' }, }, }, }, diff --git a/examples/app-showcase/src/automation/flows/index.ts b/examples/app-showcase/src/automation/flows/index.ts index 586ae63f9b2..562e884310e 100644 --- a/examples/app-showcase/src/automation/flows/index.ts +++ b/examples/app-showcase/src/automation/flows/index.ts @@ -112,7 +112,10 @@ export const ReassignWizardFlow = defineFlow({ config: { objectName: 'showcase_task', filter: { id: '{recordId}' }, - fields: { assignee: '{new_assignee}' }, + // A CEL value envelope — the `{…}` template dialect is retired from + // value slots. `new_assignee` is a required screen field, so it is + // always bound here. + fields: { assignee: { dialect: 'cel', source: 'new_assignee' } }, }, }, { id: 'end', type: 'end', label: 'End' }, @@ -1232,7 +1235,9 @@ export const ResilientSyncFlow = defineFlow({ config: { objectName: 'showcase_task', filter: { id: '{record.id}' }, - fields: { sync_status: 'failed', sync_error: '{$error.message}' }, + // `$error` (this try_catch's `errorVariable`) is not a CEL + // identifier, so the envelope reads it through `vars`. + fields: { sync_status: 'failed', sync_error: { dialect: 'cel', source: 'vars["$error"].message' } }, }, }, ], @@ -1598,10 +1603,14 @@ export const InboundTaskWebhookFlow = defineFlow({ label: 'Create Task', config: { objectName: 'showcase_task', + // CEL value envelopes — the `{…}` template dialect is retired from + // value slots. A webhook body may leave `assignee` / `project` out, and + // CEL refuses an absent key where the template wrote nothing, so those + // two are guarded with `has()` and write `null` instead. fields: { - title: '{record.title}', - assignee: '{record.assignee}', - project: '{record.project}', + title: { dialect: 'cel', source: 'record.title' }, + assignee: { dialect: 'cel', source: 'has(record.assignee) ? record.assignee : null' }, + project: { dialect: 'cel', source: 'has(record.project) ? record.project : null' }, status: 'todo', }, outputVariable: 'taskId', diff --git a/examples/app-todo/src/flows/task.flow.ts b/examples/app-todo/src/flows/task.flow.ts index e4a35ee1f31..6c22a94d5ce 100644 --- a/examples/app-todo/src/flows/task.flow.ts +++ b/examples/app-todo/src/flows/task.flow.ts @@ -355,6 +355,11 @@ export const TaskCompletionFlow: Flow = { // mid-flow (#1870), and the pure-function shape — takes `input`, RETURNS a // value, a later declarative node persists it (#4396) — is the one // `showcase_task_completed` already uses. + // + // (History, kept for the reasoning: since #19938 the `fields` / `assignments` + // value slots DO evaluate a CEL value envelope, and since #19939 they no + // longer read the `{…}` template dialect at all — see `create_next_task` + // below. The script stays: the recurrence rule is a function's job.) { id: 'compute_next_due_date', type: 'script', label: 'Compute Next Due Date', config: { @@ -373,15 +378,25 @@ export const TaskCompletionFlow: Flow = { id: 'create_next_task', type: 'create_record', label: 'Create Next Recurring Task', config: { objectName: 'todo_task', + // CEL value envelopes — the `{…}` template dialect is retired from + // value slots. A field the completed task never set is absent from its + // row, and CEL refuses an absent key where the template wrote nothing, + // so every optional one is guarded with `has()` (a `null` on insert is + // "no value", and a field default still applies). fields: { - subject: '{completedTask.subject}', description: '{completedTask.description}', - priority: '{completedTask.priority}', category: '{completedTask.category}', - owner: '{completedTask.owner}', is_recurring: true, - recurrence_type: '{completedTask.recurrence_type}', - recurrence_interval: '{completedTask.recurrence_interval}', - // A whole-string token, so `interpolate()` hands the create the RAW - // value the script node returned instead of a stringified copy. - due_date: '{nextDueDate}', + subject: { dialect: 'cel', source: 'completedTask.subject' }, + description: { dialect: 'cel', source: 'has(completedTask.description) ? completedTask.description : null' }, + priority: { dialect: 'cel', source: 'has(completedTask.priority) ? completedTask.priority : null' }, + category: { dialect: 'cel', source: 'has(completedTask.category) ? completedTask.category : null' }, + owner: { dialect: 'cel', source: 'has(completedTask.owner) ? completedTask.owner : null' }, + is_recurring: true, + recurrence_type: { dialect: 'cel', source: 'has(completedTask.recurrence_type) ? completedTask.recurrence_type : null' }, + recurrence_interval: { + dialect: 'cel', + source: 'has(completedTask.recurrence_interval) ? completedTask.recurrence_interval : null', + }, + // The RAW value the script node returned, type kept. + due_date: { dialect: 'cel', source: 'nextDueDate' }, status: 'not_started', }, outputVariable: 'newTaskId', @@ -454,7 +469,20 @@ export const QuickAddTaskFlow: Flow = { id: 'create_task', type: 'create_record', label: 'Create Task', config: { objectName: 'todo_task', - fields: { subject: '{subject}', priority: '{priority}', due_date: '{dueDate}', category: '{category}', status: 'not_started', owner: '{$User.Id}' }, + // CEL value envelopes — the `{…}` template dialect is retired from + // value slots. `subject` is a required screen field; the others may be + // left empty, and CEL refuses an absent variable where the template + // wrote nothing, so they are guarded with `has()`. `{$User.Id}` is one + // of the two spellings the retirement keeps until CEL can write it (the + // flow CEL scope binds no user yet), so it stays as authored. + fields: { + subject: { dialect: 'cel', source: 'subject' }, + priority: { dialect: 'cel', source: 'has(vars.priority) ? vars.priority : null' }, + due_date: { dialect: 'cel', source: 'has(vars.dueDate) ? vars.dueDate : null' }, + category: { dialect: 'cel', source: 'has(vars.category) ? vars.category : null' }, + status: 'not_started', + owner: '{$User.Id}', + }, outputVariable: 'newTaskId', }, }, diff --git a/packages/qa/dogfood/test/fixtures/flow-durable-suspend-fixture.ts b/packages/qa/dogfood/test/fixtures/flow-durable-suspend-fixture.ts index cb1fe12b57a..eba36f78c72 100644 --- a/packages/qa/dogfood/test/fixtures/flow-durable-suspend-fixture.ts +++ b/packages/qa/dogfood/test/fixtures/flow-durable-suspend-fixture.ts @@ -78,7 +78,9 @@ export const flowDurableSuspend: Flow = { config: { objectName: 'suspend_note', filter: { id: '{noteId}' }, - fields: { status: 'resolved', resolution: '{resolution}' }, + // A CEL value envelope — the `{…}` template dialect is retired from + // value slots; `resolution` is the screen's required field. + fields: { status: 'resolved', resolution: { dialect: 'cel', source: 'resolution' } }, }, }, { id: 'end', type: 'end', label: 'End' }, diff --git a/packages/services/service-automation/README.md b/packages/services/service-automation/README.md index 720c02a949c..c29b4d3cdf0 100644 --- a/packages/services/service-automation/README.md +++ b/packages/services/service-automation/README.md @@ -122,8 +122,8 @@ const escalateCase = { config: { objectName: 'crm_case', filter: { id: '{record.id}' }, - // Field values interpolate — braces required. - fields: { escalated: true, escalation_note: 'Escalated to {owner.name}' }, + // A computed field value is a CEL value envelope; a literal is written as it is. + fields: { escalated: true, escalation_note: { dialect: 'cel', source: "'Escalated to ' + owner.name" } }, }, }, { id: 'end', type: 'end', label: 'End' }, diff --git a/packages/spec/src/automation/flow-value-slot-template.test.ts b/packages/spec/src/automation/flow-value-slot-template.test.ts new file mode 100644 index 00000000000..2c7b211efdf --- /dev/null +++ b/packages/spec/src/automation/flow-value-slot-template.test.ts @@ -0,0 +1,228 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#19939] The `{…}` template dialect retired from flow VALUE slots — the C + * half of #11182 ruling D, on the protocol-18 line. + * + * Pinned here, on the one judge every door calls: + * + * 1. **Refused, each with its remedy.** Every token class the interpolator + * resolves and CEL can spell is refused, led by the rule sentence and + * naming the CEL spelling: a path (`a.b`, `list[0]`, `vars["$error"]`) + * with its `has()` guard, arithmetic with every divisor a double + * (`/ 100.0`), text with holes as one concatenation, a token that + * resolves to nothing with the literal-text escape. + * 2. **Kept.** The date macros and `$User` paths, which CEL cannot spell yet, + * are not refused — alone or beside another token. + * 3. **Controls.** A token-free literal, every non-string literal and a CEL + * envelope pass; only value slots are judged (a `filter` value is not). + * 4. **Every door's contract.** `FlowValueSlotSchema`, `AssignmentValueSchema` + * and the CRUD contracts refuse the same set at the value's path. + * 5. **Not converted (ADR-0087 D2).** The whole conversion chain, retired + * entries included, leaves every measured spelling as authored — none is + * lossless, so the refusal is what meets it. + */ + +import { describe, expect, it } from 'vitest'; + +import { applyConversionsToFlow } from '../conversions/apply'; +import { + AssignmentValueSchema, + CreateRecordConfigSchema, + FlowValueSlotSchema, + UpdateRecordConfigSchema, +} from './builtin-node-config.zod'; +import { + VALUE_SLOT_TEMPLATE_REFUSAL, + flowNodeValueTemplateRefusals, + valueSlotTemplateRefusals, +} from './flow-value-slot-template'; + +/** The one refusal a string draws, or a failure naming what came back. */ +function refusalOf(value: unknown): string { + const refusals = valueSlotTemplateRefusals(value); + expect(refusals, `exactly one refusal for ${JSON.stringify(value)}`).toHaveLength(1); + expect(refusals[0]!.message.startsWith(VALUE_SLOT_TEMPLATE_REFUSAL)).toBe(true); + return refusals[0]!.message; +} + +describe('a value-slot string in the retired `{…}` dialect is refused, with the CEL spelling of its tokens', () => { + it('a bare variable: the path, and a `has(vars.x)` guard for the absent case', () => { + const message = refusalOf('{new_assignee}'); + expect(message).toContain("{ dialect: 'cel', source: 'new_assignee' }"); + expect(message).toContain('`has(vars.new_assignee) ? vars.new_assignee : null`'); + expect(message).toContain('the guarded form writes `null`'); + }); + + it('a dotted path: the same path in CEL, guarded on its last key', () => { + const message = refusalOf('{record.assignee}'); + expect(message).toContain("{ dialect: 'cel', source: 'record.assignee' }"); + expect(message).toContain('`has(record.assignee) ? record.assignee : null`'); + }); + + it('a numeric segment indexes the list: `list.0` becomes `list[0]`', () => { + expect(refusalOf('{userList.0}')).toContain("{ dialect: 'cel', source: 'userList[0]' }"); + }); + + it('a `$`-named variable is read through `vars`, as CEL has no identifier spelling for it', () => { + expect(refusalOf('{$error.message}')).toContain('{ dialect: \'cel\', source: \'vars["$error"].message\' }'); + }); + + it('arithmetic: every integer divisor becomes a double, the `/ 100.0` remedy', () => { + const message = refusalOf('{round(amount * (1 - discount / 100) * 100) / 100}'); + expect(message).toContain("{ dialect: 'cel', source: 'round(amount * (1 - discount / 100.0) * 100) / 100.0' }"); + expect(message).toContain('CEL divides two integers as integers'); + }); + + it('a divisor already written as a double is left as authored', () => { + expect(refusalOf('{price / 100.0}')).toContain("source: 'price / 100.0'"); + }); + + it('text with holes: one CEL concatenation, with the null and non-string advice', () => { + const message = refusalOf('Renewal — {currentContract.contract_number}'); + expect(message).toContain(`{ dialect: 'cel', source: "'Renewal — ' + currentContract.contract_number" }`); + expect(message).toContain("`coalesce(…, '')`"); + expect(message).toContain('`string(…)`'); + }); + + it('a token that resolves to nothing: the literal-text escape, as a CEL string literal', () => { + const message = refusalOf('{"a": 1}'); + expect(message).toContain('resolves to nothing in the template dialect'); + expect(message).toContain(`{ dialect: 'cel', source: "'{\\"a\\": 1}'" }`); + }); + + it('every message leads with the rule sentence, which names no tracker number', () => { + expect(VALUE_SLOT_TEMPLATE_REFUSAL).not.toMatch(/#\d/); + }); +}); + +describe('the two spellings CEL cannot write yet are KEPT — not refused', () => { + it.each([ + '{NOW()}', + '{TODAY()}', + '{TODAY() + 7}', + '{TODAY() - 3}', + '{TODAY() + expirationDays}', + '{NOW() + 2}', + '{$User.Id}', + '{$User.Email}', + 'Due {TODAY()} for {name}', + 'Owner: {$User.Id}', + ])('%s', (value) => { + expect(valueSlotTemplateRefusals(value)).toEqual([]); + }); +}); + +describe('controls — what the judge never refuses', () => { + it.each([ + ['a token-free string', 'converted'], + ['an empty string', ''], + ['empty braces (no token)', 'a {} b'], + ['a number', 42], + ['a boolean', true], + ['null', null], + ['an array of literals', ['a', 'b']], + ['a plain object of literals', { note: 'x' }], + ])('%s', (_label, value) => { + expect(valueSlotTemplateRefusals(value)).toEqual([]); + }); + + it('a CEL value envelope is not judged here — `FlowValueSlotSchema` owns it, and accepts a valid one', () => { + const envelope = { dialect: 'cel', source: 'round(price * 100.0) / 100.0' }; + expect(valueSlotTemplateRefusals(envelope)).toEqual([]); + expect(FlowValueSlotSchema.safeParse(envelope).success).toBe(true); + }); + + it('a string at any depth of a literal is judged, located inside the value', () => { + const refusals = valueSlotTemplateRefusals({ meta: { note: 'for {name}' }, list: ['ok', '{x}'], when: '{NOW()}' }); + expect(refusals.map((r) => r.path)).toEqual([['meta', 'note'], ['list', 1]]); + }); + + it('a self-referencing literal does not loop', () => { + const value: Record = { a: '{x}' }; + value.self = value; + expect(valueSlotTemplateRefusals(value)).toHaveLength(1); + }); +}); + +describe('every value-slot contract refuses the same set, at the value', () => { + it.each([ + ['FlowValueSlotSchema', FlowValueSlotSchema], + ['AssignmentValueSchema', AssignmentValueSchema], + ])('%s', (_name, schema) => { + const refused = schema.safeParse('{record.amount}'); + expect(refused.success).toBe(false); + expect(refused.error!.issues[0]!.message.startsWith(VALUE_SLOT_TEMPLATE_REFUSAL)).toBe(true); + expect(schema.safeParse('{TODAY() + 7}').success).toBe(true); + expect(schema.safeParse('literal text').success).toBe(true); + }); + + it.each([ + ['create_record', CreateRecordConfigSchema], + ['update_record', UpdateRecordConfigSchema], + ])('%s: the CRUD contract refuses it at `fields.`', (_type, schema) => { + const refused = schema.safeParse({ objectName: 'task', fields: { owner: '{record.owner}', status: 'open' } }); + expect(refused.success).toBe(false); + expect(refused.error!.issues.map((i) => i.path)).toEqual([['fields', 'owner']]); + }); +}); + +describe('`flowNodeValueTemplateRefusals` — every value position of a node, located for a door', () => { + it('create_record / update_record `fields`, nested values included; a `filter` value is not a value slot', () => { + for (const nodeType of ['create_record', 'update_record']) { + const refusals = flowNodeValueTemplateRefusals(nodeType, { + objectName: 'task', + filter: { id: '{recordId}' }, + fields: { owner: '{record.owner}', tags: ['{x}'], due: '{TODAY()}', status: 'open' }, + }); + expect(refusals.map((r) => [r.path, r.label])).toEqual([ + ['fields.owner', `${nodeType} field value`], + ['fields.tags[0]', `${nodeType} field value`], + ]); + } + }); + + it('the canonical `assignments` map', () => { + const refusals = flowNodeValueTemplateRefusals('assignment', { assignments: { total: '{amount}', label: 'x' } }); + expect(refusals.map((r) => [r.path, r.label, r.source])).toEqual([['assignments.total', 'assignment value', '{amount}']]); + }); + + it('the legacy `assignments` array — no way around the retirement', () => { + const refusals = flowNodeValueTemplateRefusals('assignment', { + assignments: [{ variable: 'total', value: '{amount}' }, { variable: 'today', value: '{TODAY()}' }], + }); + expect(refusals.map((r) => r.path)).toEqual(['assignments[0].value']); + }); + + it('the legacy bare config — its top-level keys are the variables', () => { + const refusals = flowNodeValueTemplateRefusals('assignment', { total: '{amount}', flag: true }); + expect(refusals.map((r) => r.path)).toEqual(['total']); + }); + + it('a node type with no value slot is not judged', () => { + expect(flowNodeValueTemplateRefusals('notify', { title: 'Hi {name}', message: '{body}' })).toEqual([]); + }); +}); + +describe('ADR-0087 D2 — no measured spelling is converted; the refusal is what meets it', () => { + it('the whole conversion chain, retired entries included, leaves every spelling as authored', () => { + const fields = { + a: '{name}', b: '{oppRecord.amount}', c: '{userList.0}', d: '{$error.message}', + e: 'Hello {o.name}', f: '{round(oppRecord.amount * (discount / 100) * 100) / 100}', g: '{10 / 4}', + h: '{NOW()}', i: '{TODAY()}', j: '{$User.Id}', + }; + const flow = { + name: 'probe', label: 'Probe', type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'w', type: 'create_record', label: 'Write', config: { objectName: 'task', fields } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'w' }, { id: 'e2', source: 'w', target: 'end' }], + }; + const notices: unknown[] = []; + const converted = applyConversionsToFlow(flow, { includeRetired: true, onNotice: (n) => notices.push(n) }); + expect((converted.nodes[1]!.config as { fields: unknown }).fields).toEqual(fields); + expect(JSON.stringify(notices)).not.toMatch(/fields\.[a-j]\b/); + }); +}); diff --git a/packages/spec/src/migrations/entries/semantic/18.flow-value-slot-template-dialect-refused.ts b/packages/spec/src/migrations/entries/semantic/18.flow-value-slot-template-dialect-refused.ts new file mode 100644 index 00000000000..2e98a1e6c90 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.flow-value-slot-template-dialect-refused.ts @@ -0,0 +1,40 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// The template dialect leaves the flow value slots: one dialect for a computed +// value, CEL. Semantic-only — every token spelling authored in flows was +// measured lossy under conversion, so no D2 conversion rewrites any of them, +// and the date macros and run-user paths CEL cannot write yet are kept. +export const entry: SemanticMigration = { + id: 'flow-value-slot-template-dialect-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 an assignment node (the assignments map, the legacy assignments array and the ' + + 'legacy bare config) and of create_record and update_record nodes (the fields map) — a string value, or a ' + + 'string anywhere inside an array or object value, carrying a single-brace template token', + replacement: + 'a CEL value envelope, { dialect: "cel", source: "…" }, evaluated to the value: a path is the same path ' + + '(record.owner; a numeric segment becomes an index, list[0]; a variable whose name starts with $ is read ' + + 'through vars, vars["$error"].message), arithmetic is the same arithmetic with every integer divisor written ' + + 'as a double (round(x * 100) / 100.0), and text with holes is one concatenation (\'Hello \' + o.name). A ' + + 'string with no token is the literal text it spells, and braces meant literally are a CEL string literal', + reason: + 'The interpolator and the CEL engine answer differently for every token spelling authored in flows, so no ' + + 'conversion is lossless (ADR-0087 D2) and none is applied. A path, an absent variable, key or list index ' + + 'wrote nothing under the template and fails the run under CEL; text with a null hole rendered nothing and ' + + 'CEL refuses + null; CEL divides two integers as integers, so round(x * 100) / 100 truncates 123.46 to 123. ' + + 'Where a value may be absent, which of nothing, null or a default the field should take is the author\'s ' + + 'decision — the template decided it silently. Two spellings are kept with their old meaning, because CEL ' + + 'cannot write them yet: the date macros NOW() and TODAY() with a day offset (CEL yields a Timestamp, not the ' + + 'ISO text, and has no string form for one) and the run-user paths beginning $User. (the flow CEL scope binds ' + + 'no user). A flow carrying a refused value is refused at registration, by objectstack validate and by the ' + + 'executor; a stored flow carrying one is skipped at boot with a warn naming it.', + acceptanceCriteria: + 'Run objectstack validate: it reports each refused value as expression-invalid at the node and the value\'s ' + + 'path, with the CEL spelling of its tokens. Rewrite each as that envelope; where a variable or key may be ' + + 'absent, guard it (has(record.owner) ? record.owner : null, has(vars.x) ? vars.x : null for a variable) or ' + + 'route around the node. Re-run the flow paths that write those fields and compare the stored values with ' + + 'the ones the template wrote.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index cd840b3e8cb..927faae2b38 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5701,6 +5701,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-value-slot-template-dialect-refused', + order: 88, + text: + 'It retires the single-brace `{…}` template dialect from the flow VALUE slots (the C half of the ' + + 'maintainer\'s ruling D on the flow expression dialects): the `assignment` node\'s values, in all ' + + 'three shapes, and the `fields` map of `create_record` and `update_record`, where a CEL value ' + + 'envelope is already the expression form. A string there is now the literal ' + + 'text it spells, and one carrying a `{…}` token is refused — by `FlowValueSlotSchema`, ' + + '`registerFlow`, `objectstack validate` and the executor alike — with the CEL spelling of each token. ' + + 'No D2 conversion exists: every authored spelling was measured lossy (an absent key writes nothing ' + + 'under the template and fails under CEL; CEL divides two integers as integers), so which value an ' + + 'absent key should write is the author\'s judgment. The date macros and the `$User` paths keep their ' + + 'meaning until CEL can spell them. Its D3 record is the semantic entry ' + + '`flow-value-slot-template-dialect-refused`.', + }, { id: 'flow-write-node-stored-metadata-target-refused', order: 74, @@ -13954,6 +13970,42 @@ const step18: MigrationStep = { + 'binder; a start or edge condition that compared such a field against a literal is rewritten to ' + 'test whether it is set (not null).', }, + // The template dialect leaves the flow value slots: one dialect for a computed + // value, CEL. Semantic-only — every token spelling authored in flows was + // measured lossy under conversion, so no D2 conversion rewrites any of them, + // and the date macros and run-user paths CEL cannot write yet are kept. + { + id: 'flow-value-slot-template-dialect-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 an assignment node (the assignments map, the legacy assignments array and the ' + + 'legacy bare config) and of create_record and update_record nodes (the fields map) — a string value, or a ' + + 'string anywhere inside an array or object value, carrying a single-brace template token', + replacement: + 'a CEL value envelope, { dialect: "cel", source: "…" }, evaluated to the value: a path is the same path ' + + '(record.owner; a numeric segment becomes an index, list[0]; a variable whose name starts with $ is read ' + + 'through vars, vars["$error"].message), arithmetic is the same arithmetic with every integer divisor written ' + + 'as a double (round(x * 100) / 100.0), and text with holes is one concatenation (\'Hello \' + o.name). A ' + + 'string with no token is the literal text it spells, and braces meant literally are a CEL string literal', + reason: + 'The interpolator and the CEL engine answer differently for every token spelling authored in flows, so no ' + + 'conversion is lossless (ADR-0087 D2) and none is applied. A path, an absent variable, key or list index ' + + 'wrote nothing under the template and fails the run under CEL; text with a null hole rendered nothing and ' + + 'CEL refuses + null; CEL divides two integers as integers, so round(x * 100) / 100 truncates 123.46 to 123. ' + + 'Where a value may be absent, which of nothing, null or a default the field should take is the author\'s ' + + 'decision — the template decided it silently. Two spellings are kept with their old meaning, because CEL ' + + 'cannot write them yet: the date macros NOW() and TODAY() with a day offset (CEL yields a Timestamp, not the ' + + 'ISO text, and has no string form for one) and the run-user paths beginning $User. (the flow CEL scope binds ' + + 'no user). A flow carrying a refused value is refused at registration, by objectstack validate and by the ' + + 'executor; a stored flow carrying one is skipped at boot with a warn naming it.', + acceptanceCriteria: + 'Run objectstack validate: it reports each refused value as expression-invalid at the node and the value\'s ' + + 'path, with the CEL spelling of its tokens. Rewrite each as that envelope; where a variable or key may be ' + + 'absent, guard it (has(record.owner) ? record.owner : null, has(vars.x) ? vars.x : null for a variable) or ' + + 'route around the node. Re-run the flow paths that write those fields and compare the stored values with ' + + 'the ones the template wrote.', + }, // #21654 — the D3 entry for `FlowSchema`'s refusal of a write node aimed at a // stored-metadata table: the save-time half of #21624, which applies #21520's // ruling A (record 5965059068) to flows, whose run-time half refuses the same diff --git a/packages/verify/src/handle.fixture.ts b/packages/verify/src/handle.fixture.ts index ca810941e29..192557c72e1 100644 --- a/packages/verify/src/handle.fixture.ts +++ b/packages/verify/src/handle.fixture.ts @@ -188,7 +188,9 @@ export const resolveNoteFlow: Flow = { config: { objectName: 'hnd_note', filter: { id: '{noteId}' }, - fields: { status: 'resolved', resolution: '{resolution}' }, + // A CEL value envelope — the `{…}` template dialect is retired from + // value slots; `resolution` is the screen's required field. + fields: { status: 'resolved', resolution: { dialect: 'cel', source: 'resolution' } }, }, }, { id: 'end', type: 'end', label: 'End' }, From 0540049eca2e6387eae2642d11360c9dc1d222ec Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 05:36:56 +0000 Subject: [PATCH 04/14] wip(spec): judge legacy assignment shapes as literals; test updates (#19939) Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude --- .../automation/builtin-node-config.test.ts | 76 ++++++++++++------- .../src/automation/builtin-node-config.zod.ts | 18 ++++- .../automation/flow-value-slot-template.ts | 41 +++++++--- packages/spec/src/automation/flow.test.ts | 26 ++++--- 4 files changed, 110 insertions(+), 51 deletions(-) diff --git a/packages/spec/src/automation/builtin-node-config.test.ts b/packages/spec/src/automation/builtin-node-config.test.ts index 1ee043454c6..f2b5d367c40 100644 --- a/packages/spec/src/automation/builtin-node-config.test.ts +++ b/packages/spec/src/automation/builtin-node-config.test.ts @@ -42,6 +42,7 @@ import { SCHEMALESS_NODE_CONFIG_SCHEMAS, getSchemalessNodeConfigJsonSchemas, } from './schemaless-node-config.zod.js'; +import { VALUE_SLOT_TEMPLATE_REFUSAL } from './flow-value-slot-template.js'; interface Parseable { safeParse(v: unknown): { success: boolean; error?: { issues: ReadonlyArray<{ code: string; message: string }> } } } @@ -430,7 +431,7 @@ describe('MapConfigSchema — an unknown key is refused, not stripped', () => { // ─── assignment (#14149) ───────────────────────────────────────────── -describe('assignment value contract — a CEL envelope beside `{token}` interpolation', () => { +describe('assignment value contract — a CEL envelope beside literals (#14149; the `{token}` dialect retired, #19939)', () => { const DIGEST_SOURCE = 'joinNonEmpty(overdue_tasks.map(t, t.subject), "\\n")'; const DIGEST_ENVELOPE = { dialect: 'cel', source: DIGEST_SOURCE }; @@ -452,21 +453,22 @@ describe('assignment value contract — a CEL envelope beside `{token}` interpol it('accepts the two forms side by side in one node', () => { expect(AssignmentConfigSchema.safeParse({ - assignments: { owner_name: '{manager.name}', digest: DIGEST_ENVELOPE }, + assignments: { owner_name: 'Ada Lovelace', digest: DIGEST_ENVELOPE }, }).success).toBe(true); }); - it('PRESERVATION: every value that parsed before still parses — strings, scalars, arrays, plain objects', () => { + it('PRESERVATION: every literal still parses — strings, scalars, arrays, plain objects', () => { expect(AssignmentConfigSchema.safeParse({ assignments: { decision: 'approved', // the showcase's own assignment - owner: '{record.owner}', // sole-token interpolation - greeting: 'Hi {record.name}!', // text with holes cel_looking_text: 'a + b', // a STRING is never CEL here n: 3, ok: true, nothing: null, - list: ['{a}', 2], - obj: { nested: '{x}', source: 'not an envelope without a dialect' }, + list: ['a', 2], + obj: { nested: 'x', source: 'not an envelope without a dialect' }, empty: '', + // The two `{…}` spellings the retirement keeps until CEL can write them. + due: '{TODAY() + 7}', + by: '{$User.Id}', }, }).success).toBe(true); // An envelope-shaped object with a non-string `dialect` is a literal, as it always was. @@ -480,6 +482,29 @@ describe('assignment value contract — a CEL envelope beside `{token}` interpol expect(AssignmentConfigSchema.safeParse({ decision: 'approved', digest: { dialect: 'cel' } }).success).toBe(true); }); + it('[#19939] REFUSES the retired `{token}` dialect at the value\'s path — in the map, nested, and in a bare legacy key', () => { + const result = AssignmentConfigSchema.safeParse({ + assignments: { + owner: '{record.owner}', // sole token + greeting: 'Hi {record.name}!', // text with holes + list: ['{a}', 2], + obj: { nested: '{x}' }, + }, + }); + expect(result.success).toBe(false); + expect(result.error!.issues.map((i) => [i.code, i.path.join('.')])).toEqual([ + ['custom', 'assignments.owner'], + ['custom', 'assignments.greeting'], + ['custom', 'assignments.list.0'], + ['custom', 'assignments.obj.nested'], + ]); + for (const issue of result.error!.issues) expect(issue.message.startsWith(VALUE_SLOT_TEMPLATE_REFUSAL)).toBe(true); + // The bare legacy config is no way around it — an envelope-shaped literal there included. + const bare = AssignmentConfigSchema.safeParse({ total: '{amount}', lit: { dialect: 'cel', source: '{a}' } }); + expect(bare.success).toBe(false); + expect(bare.error!.issues.map((i) => i.path.join('.'))).toEqual(['total', 'lit.source']); + }); + it.each([ // Flipped by #15430: `{ dialect: 'cel' }` used to be refused by the // persistence contract's source-or-ast rule at the envelope's own path; the @@ -513,7 +538,7 @@ describe('assignment value contract — a CEL envelope beside `{token}` interpol it('a malformed envelope is refused wherever it sits in the map — the path names the variable', () => { const result = AssignmentConfigSchema.safeParse({ - assignments: { fine: DIGEST_ENVELOPE, broken: { dialect: 'cel' }, alsoFine: '{x}' }, + assignments: { fine: DIGEST_ENVELOPE, broken: { dialect: 'cel' }, alsoFine: 'x' }, }); expect(result.success).toBe(false); // `{ dialect: 'cel' }` is refused by the evaluated-slot rule at its `source` @@ -654,10 +679,10 @@ describe('AssignmentConfigSchema.assignments — __proto__ pre-parse guard, cons it.each(['constructor', 'prototype'])( 'PRESERVATION: `%s` remains a legal flow-variable name — no ruling narrowed this slot\'s accept set', (name) => { - const result = AssignmentConfigSchema.safeParse({ assignments: { [name]: '{x}' } }); + const result = AssignmentConfigSchema.safeParse({ assignments: { [name]: 'x' } }); expect(result.success).toBe(true); if (!result.success) return; - expect((result.data.assignments as Record | undefined)?.[name]).toBe('{x}'); + expect((result.data.assignments as Record | undefined)?.[name]).toBe('x'); }, ); @@ -666,7 +691,7 @@ describe('AssignmentConfigSchema.assignments — __proto__ pre-parse guard, cons }); it('an ordinary `assignments` map with no reserved names still parses', () => { - expect(AssignmentConfigSchema.safeParse({ assignments: { total: '{amount}' } }).success).toBe(true); + expect(AssignmentConfigSchema.safeParse({ assignments: { total: { dialect: 'cel', source: 'amount' } } }).success).toBe(true); }); }); @@ -752,14 +777,14 @@ describe('AssignmentConfigSchema — top-level __proto__ refused at the catchall // These reach the catchall unskipped and round-trip intact, so no // ruling narrows them. The guard refusing them would be a narrowing // nobody ordered. - expect(classify(JSON.parse(JSON.stringify({ [name]: '{x}', keep: 1 })), name)).toBe('silently-kept'); + expect(classify(JSON.parse(JSON.stringify({ [name]: 'x', keep: 1 })), name)).toBe('silently-kept'); }, ); it('PRESERVATION: every previously accepted shape still parses', () => { expect(AssignmentConfigSchema.safeParse({}).success).toBe(true); expect(AssignmentConfigSchema.safeParse({ assignments: {} }).success).toBe(true); - expect(AssignmentConfigSchema.safeParse({ assignments: { total: '{amount}' } }).success).toBe(true); + expect(AssignmentConfigSchema.safeParse({ assignments: { total: { dialect: 'cel', source: 'amount' } } }).success).toBe(true); // The bare legacy config (no `assignments` wrapper) — the shape that makes // this top level an authoring surface in the first place. expect(AssignmentConfigSchema.safeParse({ decision: 'approved', digest: { dialect: 'cel' } }).success).toBe(true); @@ -801,13 +826,13 @@ describe('AssignmentConfigSchema — top-level __proto__ refused at the catchall /** * #19938 (the contract half of #11182 ruling D) — the `create_record` / * `update_record` `fields` map carries the value contract `assignments` has: - * a field value may be a CEL value envelope beside a `{token}` template or a - * literal. The widening is the valid envelope; the one newly refused shape is a - * malformed envelope (an object naming a string `dialect` that is not a valid - * CEL value envelope), the edge #14149 accepted on `assignments.*`. Everything - * else parses exactly as before. + * a field value may be a CEL value envelope beside a literal. The widening is + * the valid envelope; the one shape it newly refused is a malformed envelope + * (an object naming a string `dialect` that is not a valid CEL value + * envelope), the edge #14149 accepted on `assignments.*`. #19939 (the C half) + * then retired the `{token}` template dialect from the slot. */ -describe('CRUD `fields` value contract — the CEL value envelope beside `{token}` templates', () => { +describe('CRUD `fields` value contract — the CEL value envelope beside literals', () => { const PRICE_ENVELOPE = { dialect: 'cel', source: 'round(price * 100) / 100.0' }; const configs = [ ['create_record', CreateRecordConfigSchema, (fields: unknown) => ({ objectName: 'quote', fields })], @@ -821,15 +846,14 @@ describe('CRUD `fields` value contract — the CEL value envelope beside `{token expect((result.data as { fields: Record }).fields.total).toEqual(PRICE_ENVELOPE); }); - it.each(configs)('%s PRESERVATION: every field value that parsed before still parses, unchanged', (_type, schema, wrap) => { + it.each(configs)('%s PRESERVATION: every literal still parses, unchanged', (_type, schema, wrap) => { const fields = { - subject: 'Follow up on {record.name}', // text with holes - owner: '{record.owner}', // sole token - due_date: '{TODAY() + 7}', // date macro - amount: '{round(total * 100) / 100}', // template expression + subject: 'Follow up', // text + due_date: '{TODAY() + 7}', // a date macro — kept until CEL can write it + owner: '{$User.Id}', // the run user — kept until the flow CEL scope binds it cel_looking_text: 'a + b', // a STRING is never CEL here n: 3, ok: true, nothing: null, empty: '', - tags: ['{a}', 2, { dialect: 'cel' }], // arrays are data, envelope-shaped members included + tags: ['a', 2, { dialect: 'cel' }], // arrays are data, envelope-shaped members included payload: { nested: { dialect: 'cel' }, source: 'not an envelope without a dialect' }, weird: { dialect: 1 }, // a non-string `dialect` is a literal }; @@ -845,7 +869,7 @@ describe('CRUD `fields` value contract — the CEL value envelope beside `{token [type, schema, wrap, 'a `template` dialect', { dialect: 'template', source: 'Hi {name}' }, 'fields.total.dialect'], [type, schema, wrap, 'an unknown dialect', { dialect: 'javascript', source: '1 + 1' }, 'fields.total.dialect'], ] as const))('%s REFUSES a malformed envelope with %s — code `custom`, at the field\'s path, led by the slot-neutral sentence', (_type, schema, wrap, _what, envelope, path) => { - const result = schema.safeParse(wrap({ subject: '{x}', total: envelope })); + const result = schema.safeParse(wrap({ subject: 'x', total: envelope })); expect(result.success).toBe(false); const issues = result.error!.issues.filter((i) => i.path[0] === 'fields'); expect(issues.map((i) => i.path.join('.'))).toEqual([path]); diff --git a/packages/spec/src/automation/builtin-node-config.zod.ts b/packages/spec/src/automation/builtin-node-config.zod.ts index 13ee4880960..75508b6f5c6 100644 --- a/packages/spec/src/automation/builtin-node-config.zod.ts +++ b/packages/spec/src/automation/builtin-node-config.zod.ts @@ -990,6 +990,17 @@ export const AssignmentValueSchema = celValueSlotSchema( export type AssignmentValue = z.input; export type AssignmentValueParsed = z.infer; +/** + * A value of the bare legacy `assignment` config (#19939) — a literal, whose + * strings are judged by {@link valueSlotTemplateRefusals} with an + * envelope-shaped object read as the literal it is in that shape. + */ +const LEGACY_ASSIGNMENT_VALUE = z.unknown().superRefine((value, ctx) => { + for (const refusal of valueSlotTemplateRefusals(value, { envelopeIsLiteral: true })) { + ctx.addIssue({ code: 'custom', path: [...refusal.path], message: refusal.message }); + } +}); + /** What the refusal of the legacy `assignments: [{ variable, value }]` array says. */ export const ASSIGNMENT_ARRAY_FORM_PRESCRIPTION = '`assignments` is a map of variable name → value (`{ assignments: { total: { dialect: \'cel\', source: \'amount\' } } }`). The array form ' @@ -1049,8 +1060,11 @@ export const AssignmentConfigSchema = lazySchema(() => refuseCatchallProtoKey(z. .describe('Variables to set: each key is a variable name, each value a CEL value envelope or a literal'), }) // Open by design: the bare legacy `{ : }` config and any - // top-level key an author names live here. - .catchall(z.unknown()), + // top-level key an author names live here. [#19939] Each such value is a + // literal (an envelope-shaped object included — nothing evaluates it here), + // and one still spelling the retired `{…}` template dialect is refused like + // in the map, so the bare shape is no way around the retirement. + .catchall(LEGACY_ASSIGNMENT_VALUE), // [#19151] `__proto__` ONLY, and for the same structural reason // the `assignments` slot above refuses it: `handleCatchall`'s // `if (key === "__proto__") continue;` runs above `_catchall.run`, so no diff --git a/packages/spec/src/automation/flow-value-slot-template.ts b/packages/spec/src/automation/flow-value-slot-template.ts index 0279aa1a8bf..44e98ebb0d3 100644 --- a/packages/spec/src/automation/flow-value-slot-template.ts +++ b/packages/spec/src/automation/flow-value-slot-template.ts @@ -219,18 +219,34 @@ export interface ValueSlotTemplateRefusal { readonly source: string; } +/** How {@link valueSlotTemplateRefusals} reads its value. */ +export interface ValueSlotTemplateOptions { + /** + * The position is one of the two legacy `assignment` shapes, where an + * envelope-shaped object is a LITERAL (the ledger does not declare them, so + * nothing evaluates it) and its strings were interpolated like any other. + * Default `false`: in a declared value slot a top-level envelope is an + * expression, judged by the envelope rule and not here. + */ + readonly envelopeIsLiteral?: boolean; +} + /** * Every string inside a value-slot value that carries the retired `{…}` * template dialect — the value itself when it is a string, and every string at * any depth of an array or a plain object (a literal's strings were - * interpolated too). An envelope-shaped value is not judged here: it is an - * expression, and `FlowValueSlotSchema`'s envelope rule owns it. A string whose - * tokens include a kept spelling (a date macro, a `$User` path) is not refused - * — see the module docblock. Cycle-safe: a flow built in code may hold a + * interpolated too). A top-level envelope-shaped value is not judged here: it + * is an expression, and `FlowValueSlotSchema`'s envelope rule owns it — unless + * {@link ValueSlotTemplateOptions.envelopeIsLiteral}. A string whose tokens + * include a kept spelling (a date macro, a `$User` path) is not refused — see + * the module docblock. Cycle-safe: a flow built in code may hold a * self-reference. */ -export function valueSlotTemplateRefusals(value: unknown): ValueSlotTemplateRefusal[] { - if (isExpressionEnvelopeShaped(value)) return []; +export function valueSlotTemplateRefusals( + value: unknown, + options: ValueSlotTemplateOptions = {}, +): ValueSlotTemplateRefusal[] { + if (!options.envelopeIsLiteral && isExpressionEnvelopeShaped(value)) return []; const out: ValueSlotTemplateRefusal[] = []; const seen = new Set(); const visit = (node: unknown, path: (string | number)[]): void => { @@ -278,13 +294,14 @@ function joinPath(prefix: string, inner: readonly (string | number)[]): string { * ARRAY (`[{ variable, value }]` — each element's `value`), and, when * `assignments` is neither an array nor an object, the bare config whose * top-level keys are the variables. An envelope there is a literal, as it - * always was; a `{…}` token there is refused like anywhere else, so the legacy - * shapes are no way around the retirement. + * always was, so its strings are judged like any literal's; a `{…}` token + * there is refused like anywhere else, so the legacy shapes are no way around + * the retirement. */ export function flowNodeValueTemplateRefusals(nodeType: string, config: unknown): FlowNodeValueTemplateRefusal[] { const out: FlowNodeValueTemplateRefusal[] = []; - const judge = (path: string, label: string, value: unknown): void => { - for (const refusal of valueSlotTemplateRefusals(value)) { + const judge = (path: string, label: string, value: unknown, envelopeIsLiteral = false): void => { + for (const refusal of valueSlotTemplateRefusals(value, { envelopeIsLiteral })) { out.push({ path: joinPath(path, refusal.path), label, message: refusal.message, source: refusal.source }); } }; @@ -294,11 +311,11 @@ export function flowNodeValueTemplateRefusals(nodeType: string, config: unknown) if (Array.isArray(raw)) { raw.forEach((item, index) => { if (item !== null && typeof item === 'object' && !Array.isArray(item)) { - judge(`assignments[${index}].value`, 'assignment value', (item as Record).value); + judge(`assignments[${index}].value`, 'assignment value', (item as Record).value, true); } }); } else if (!(raw !== null && typeof raw === 'object')) { - for (const [key, value] of Object.entries(config as Record)) judge(key, 'assignment value', value); + for (const [key, value] of Object.entries(config as Record)) judge(key, 'assignment value', value, true); } } return out; diff --git a/packages/spec/src/automation/flow.test.ts b/packages/spec/src/automation/flow.test.ts index fffcc87a9c4..47c1ee60723 100644 --- a/packages/spec/src/automation/flow.test.ts +++ b/packages/spec/src/automation/flow.test.ts @@ -186,8 +186,10 @@ describe('FlowNodeSchema', () => { // canonical key the executor actually reads. config: { objectName: 'account', + // #19939 — a computed field value is a CEL value envelope; the `{…}` + // template dialect is retired from value slots. fields: { - name: '{input.companyName}', + name: { dialect: 'cel', source: 'input.companyName' }, status: 'active', }, }, @@ -570,14 +572,15 @@ describe('FlowSchema', () => { type: 'create_record', label: 'Create Contact', // #5500 — `object:` → `objectName:` (see `should accept node with - // config`). The `{firstName}` &c. tokens are declared INPUT - // variables, which the engine binds by name, so they resolve. + // config`). `firstName` &c. are declared INPUT variables, which + // the engine binds by name, so the CEL value envelopes (#19939 — + // the `{…}` template dialect is retired from value slots) resolve. config: { objectName: 'contact', fields: { - first_name: '{firstName}', - last_name: '{lastName}', - email: '{email}', + first_name: { dialect: 'cel', source: 'firstName' }, + last_name: { dialect: 'cel', source: 'lastName' }, + email: { dialect: 'cel', source: 'email' }, }, }, }, @@ -594,12 +597,13 @@ describe('FlowSchema', () => { // never written. Measured: with the old shape the run ends with // `variable='contactId'`, `value=''`, `contactId=undefined`. // - // The VALUE token was fine and is kept verbatim: the engine binds - // every node's `result.output` under `.` and the - // template resolver reads that flat key, so `{create_contact.id}` - // resolves to the created row's id (`create_record` outputs `id`). + // The VALUE was fine: the engine binds every node's + // `result.output` under `.`, and the CEL scope reads + // that flat key as a path, so `create_contact.id` resolves to the + // created row's id (`create_record` outputs `id`). Written as a CEL + // value envelope since #19939 retired `{create_contact.id}` here. config: { - assignments: { contactId: '{create_contact.id}' }, + assignments: { contactId: { dialect: 'cel', source: 'create_contact.id' } }, }, }, { id: 'end', type: 'end', label: 'End' }, From 5a8c5d7f49111d79fd4e76fd5a368f187054cb74 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 05:39:45 +0000 Subject: [PATCH 05/14] wip(spec): ledger the bare legacy assignment catchall refinement (#19939) Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude --- packages/spec/dropped-refinements.baseline.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index bc955699660..a2da918e93c 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": 678, + "droppedRefinementSites": 679, "refinementSitesThatDidProject": 369, "refinementSitesWithNoJsonFormToCompare": 0 }, @@ -449,7 +449,8 @@ }, "automation/AssignmentConfig": { "sites": [ - "out.assignments.out.valueType" + "out.assignments.out.valueType", + "out.catchall" ] }, "automation/AssignmentValue": { From 637ec013adc051f38f3393761945e298a31ca6b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 05:43:04 +0000 Subject: [PATCH 06/14] wip(lint): the value-slot template refusal pins; any call is an expression (#19939) Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude --- ...date-expressions.fields-value-slot.test.ts | 79 +++++++++++-------- .../lint/src/validate-expressions.test.ts | 4 +- .../flow-value-slot-template.test.ts | 8 +- .../automation/flow-value-slot-template.ts | 14 +++- 4 files changed, 63 insertions(+), 42 deletions(-) diff --git a/packages/lint/src/validate-expressions.fields-value-slot.test.ts b/packages/lint/src/validate-expressions.fields-value-slot.test.ts index cfe760059a5..b22d41aaee9 100644 --- a/packages/lint/src/validate-expressions.fields-value-slot.test.ts +++ b/packages/lint/src/validate-expressions.fields-value-slot.test.ts @@ -1,8 +1,10 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * `create_record` / `update_record` `fields.*` — the `objectstack validate` half - * of the value slot #19938 declares (the contract half of #11182 ruling D). + * `create_record` / `update_record` `fields.*` and `assignment` values — the + * `objectstack validate` half of the value slots (#19938 declared `fields.*`, + * the contract half of #11182 ruling D; #19939 retired the `{…}` template + * dialect from every value slot, its C half). * * Driven through the REGISTRY, the way `os validate` runs it: the stack is * normalized, parsed by `ObjectStackDefinitionSchema`, and handed to @@ -12,20 +14,26 @@ * * Two halves: * - * 1. **Refusal** — a malformed envelope in `fields.*` is a located `error` - * under the rule id `expression-invalid`, led by the slot-neutral - * `VALUE_ENVELOPE_REFUSAL` (never "an assignment value"), the same verdict - * `registerFlow` throws on. A valid envelope, a `{token}` template and a - * literal are clean. - * 2. **The hint** (ruling D point 1) — a `{…}` template EXPRESSION in any - * `value` slot is pointed at the envelope, at `warning` only, with the one - * conversion trap (`/ 100` → `/ 100.0`) stated. Plain references, date - * macros, `$User` paths and non-value slots get nothing. + * 1. **The malformed envelope** — a located `error` under the rule id + * `expression-invalid`, led by the slot-neutral `VALUE_ENVELOPE_REFUSAL` + * (never "an assignment value"), the same verdict `registerFlow` throws + * on. A valid envelope and a literal are clean. + * 2. **The retired template dialect** (#19939) — a `{…}` token in a value + * slot is a located `error` under the same rule id, led by + * `VALUE_SLOT_TEMPLATE_REFUSAL` and naming the token's CEL spelling — in + * every value slot and both legacy `assignment` shapes. It replaced the + * `warning` hint ruling D point 1 put there for 17.x. The two spellings + * CEL cannot write yet (date macros, `$User` paths) and non-value slots + * get nothing. */ import { describe, expect, it } from 'vitest'; import { ObjectStackDefinitionSchema, normalizeStackInput } from '@objectstack/spec'; -import { ASSIGNMENT_VALUE_ENVELOPE_REFUSAL, VALUE_ENVELOPE_REFUSAL } from '@objectstack/spec/automation'; +import { + ASSIGNMENT_VALUE_ENVELOPE_REFUSAL, + VALUE_ENVELOPE_REFUSAL, + VALUE_SLOT_TEMPLATE_REFUSAL, +} from '@objectstack/spec/automation'; import { EVALUATED_EXPRESSION_SOURCE_REQUIRED } from '@objectstack/spec'; import { runAuthoringRules, splitBySeverity, EXPRESSION_INVALID } from './authoring-rules.js'; @@ -84,7 +92,7 @@ describe('`fields.*` value slot — the malformed envelope is a located error at [t, 'CEL that does not parse', { dialect: 'cel', source: 'price *' }, ''], [t, 'an unknown function', { dialect: 'cel', source: 'nosuchfn(price)' }, ''], ] as const))('%s: %s — rule `expression-invalid`, severity `error`, at `config.fields.total`', (nodeType, _what, envelope, detail) => { - const findings = validate(nodeType, crud(nodeType, { subject: 'Quote {price}', total: envelope })); + const findings = validate(nodeType, crud(nodeType, { subject: 'Quote', total: envelope })); expect(findings).toHaveLength(1); const [f] = findings; // The envelope a gate reads: the rule id and the severity. @@ -100,10 +108,11 @@ describe('`fields.*` value slot — the malformed envelope is a located error at expect(splitBySeverity(findings).errors).toHaveLength(1); }); - it.each(NODE_TYPES)('%s: a valid envelope, `{token}` templates and literals are clean', (nodeType) => { + it.each(NODE_TYPES)('%s: a valid envelope, the two kept spellings and literals are clean', (nodeType) => { expect(validate(nodeType, crud(nodeType, { total: { dialect: 'cel', source: 'round(price * 100) / 100.0' }, - subject: 'Quote for {price}', + subject: { dialect: 'cel', source: "'Quote for ' + string(price)" }, + label: 'Quote', owner: '{$User.Id}', due: '{TODAY() + 7}', n: 3, ok: true, nothing: null, @@ -119,36 +128,36 @@ describe('`fields.*` value slot — the malformed envelope is a located error at }); }); -describe('the author-time hint — a `{…}` template expression in a value slot points at the envelope (#11182 ruling D)', () => { - const HINTED: ReadonlyArray<[NodeType, Record, string]> = [ - ['create_record', crud('create_record', { total: '{round(price * 100) / 100}' }), 'config.fields.total'], - ['update_record', crud('update_record', { total: '{price * 2}' }), 'config.fields.total'], - ['create_record', crud('create_record', { subject: 'Total: {max(price, 10)}' }), 'config.fields.subject'], - ['assignment', { assignments: { total: '{floor(price)}' } }, 'config.assignments.total'], +describe('the retired template dialect — a `{…}` token in a value slot is a located error (#19939)', () => { + const REFUSED: ReadonlyArray<[string, NodeType, Record, string, string]> = [ + ['a template expression (the 17.x hint\'s own case)', 'create_record', crud('create_record', { total: '{round(price * 100) / 100}' }), 'config.fields.total', "source: 'round(price * 100) / 100.0'"], + ['arithmetic', 'update_record', crud('update_record', { total: '{price * 2}' }), 'config.fields.total', "source: 'price * 2'"], + ['a function inside text', 'create_record', crud('create_record', { subject: 'Total: {max(price, 10)}' }), 'config.fields.subject', "\"'Total: ' + (max(price, 10))\""], + ['a plain reference', 'update_record', crud('update_record', { subject: '{record.name}' }), 'config.fields.subject', "source: 'record.name'"], + ['text with a reference hole', 'create_record', crud('create_record', { subject: 'Follow up on {record.name}' }), 'config.fields.subject', "\"'Follow up on ' + record.name\""], + ['a nested literal string', 'create_record', crud('create_record', { payload: { note: '{price}' } }), 'config.fields.payload.note', "source: 'price'"], + ['the canonical assignment map', 'assignment', { assignments: { total: '{floor(price)}' } }, 'config.assignments.total', "source: 'floor(price)'"], + ['the legacy assignment array', 'assignment', { assignments: [{ variable: 'total', value: '{price}' }] }, 'config.assignments[0].value', "source: 'price'"], + ['the legacy bare assignment config', 'assignment', { total: '{price}' }, 'config.total', "source: 'price'"], ]; - it.each(HINTED)('%s: warns at the slot, never errors — the template form keeps its meaning', (nodeType, config, at) => { + it.each(REFUSED)('%s — rule `expression-invalid`, severity `error`, located, with the CEL spelling', (_what, nodeType, config, at, spelling) => { const findings = validate(nodeType, config); expect(findings).toHaveLength(1); const [f] = findings; - expect(f!.severity).toBe('warning'); expect(f!.rule).toBe(EXPRESSION_INVALID); - expect(f!.where).toContain(at); - expect(f!.message).toContain("{ dialect: 'cel', source: '…' }"); - // The one conversion trap, stated where the author reads the hint. - expect(f!.message).toContain('`round(x * 100) / 100.0`'); - // Advisory: `os validate` still passes. - expect(splitBySeverity(findings).errors).toEqual([]); + expect(f!.severity).toBe('error'); + expect(f!.where).toContain(` at ${at}`); + expect(f!.message.startsWith(VALUE_SLOT_TEMPLATE_REFUSAL)).toBe(true); + expect(f!.message).toContain(spelling); + // It gates: `os validate` exits non-zero on it. + expect(splitBySeverity(findings).errors).toHaveLength(1); }); it.each([ - ['a plain reference — CEL adds nothing, and an absent key would start faulting', '{record.name}'], - ['text with a reference hole', 'Follow up on {record.name}'], - ['a date macro — CEL has no string form of a Timestamp', '{NOW()}'], + ['a date macro — CEL has no string form of a Timestamp yet', '{NOW()}'], ['a date macro with an offset', '{TODAY() + 7}'], - ['a `$User` path — the flow CEL scope binds no user', '{$User.Id}'], - ['an unknown function — a run-time refusal, not a candidate to move', '{ROUND(price)}'], - ['a string literal token', '{"fixed"}'], + ['a `$User` path — the flow CEL scope binds no user yet', '{$User.Id}'], ['plain text', 'approved'], ])('says nothing for %s', (_why, value) => { for (const nodeType of NODE_TYPES) { diff --git a/packages/lint/src/validate-expressions.test.ts b/packages/lint/src/validate-expressions.test.ts index d590cce6eac..9ff8c076253 100644 --- a/packages/lint/src/validate-expressions.test.ts +++ b/packages/lint/src/validate-expressions.test.ts @@ -3857,7 +3857,9 @@ describe('assignment value envelope — located findings (#15137)', () => { it('passes a well-formed envelope, and every shape that is not an envelope', () => { expect(valueIssues({ digest: { dialect: 'cel', source: 'joinNonEmpty(rows.map(r, r.subject), "\\n")' }, - greeting: 'Hello {name}', + // A literal — the `{…}` template dialect is retired from value slots + // (#19939), so text with holes is pinned by its own refusal suite. + greeting: 'Hello', count: 3, flags: { enabled: true }, // Envelope-SHAPED only by a non-string dialect — data, not an expression. diff --git a/packages/spec/src/automation/flow-value-slot-template.test.ts b/packages/spec/src/automation/flow-value-slot-template.test.ts index 2c7b211efdf..4689c06a384 100644 --- a/packages/spec/src/automation/flow-value-slot-template.test.ts +++ b/packages/spec/src/automation/flow-value-slot-template.test.ts @@ -74,6 +74,10 @@ describe('a value-slot string in the retired `{…}` dialect is refused, with th expect(message).toContain('CEL divides two integers as integers'); }); + it('a name the dialect does not know is still an expression — CEL\'s own check refuses it, with a did-you-mean', () => { + expect(refusalOf('{ROUND(price)}')).toContain("{ dialect: 'cel', source: 'ROUND(price)' }"); + }); + it('a divisor already written as a double is left as authored', () => { expect(refusalOf('{price / 100.0}')).toContain("source: 'price / 100.0'"); }); @@ -85,9 +89,9 @@ describe('a value-slot string in the retired `{…}` dialect is refused, with th expect(message).toContain('`string(…)`'); }); - it('a token that resolves to nothing: the literal-text escape, as a CEL string literal', () => { + it('a token that is neither a path nor an expression: the literal-text escape, as a CEL string literal', () => { const message = refusalOf('{"a": 1}'); - expect(message).toContain('resolves to nothing in the template dialect'); + expect(message).toContain('is neither a variable path nor an expression'); expect(message).toContain(`{ dialect: 'cel', source: "'{\\"a\\": 1}'" }`); }); diff --git a/packages/spec/src/automation/flow-value-slot-template.ts b/packages/spec/src/automation/flow-value-slot-template.ts index 44e98ebb0d3..9c164afcc48 100644 --- a/packages/spec/src/automation/flow-value-slot-template.ts +++ b/packages/spec/src/automation/flow-value-slot-template.ts @@ -96,8 +96,14 @@ 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 to one of the six functions the dialect mirrors from the CEL stdlib. */ -const EXPRESSION_SHAPE = /[+\-*/%<>=!&|?]|\b(?:round|floor|ceil|abs|min|max)\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'; @@ -172,8 +178,8 @@ function remedyFor(value: string, tokens: readonly Token[]): string { if (tokens.some((t) => t.kind === 'unresolvable')) { const junk = tokens.find((t) => t.kind === 'unresolvable')!; return ( - `\`${junk.text}\` resolves to nothing in the template dialect, which wrote nothing for it. If the braces are ` - + `literal text, write the value as a CEL string literal: ${envelopeOf(celString(value))}.` + `\`${junk.text}\` is neither a variable path nor an expression. If the braces are literal text, write the ` + + `value as a CEL string literal, ${envelopeOf(celString(value))}; otherwise compute it in a CEL envelope.` ); } if (whole?.kind === 'path') { From a96cc41547e53fba3f7b78d5c35f4b44d2d3a33d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 05:53:09 +0000 Subject: [PATCH 07/14] wip: changeset for the value-slot template retirement (#19939) Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude --- ...low-value-slot-template-dialect-refused.md | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 .changeset/19939-flow-value-slot-template-dialect-refused.md diff --git a/.changeset/19939-flow-value-slot-template-dialect-refused.md b/.changeset/19939-flow-value-slot-template-dialect-refused.md new file mode 100644 index 00000000000..4fd97048414 --- /dev/null +++ b/.changeset/19939-flow-value-slot-template-dialect-refused.md @@ -0,0 +1,47 @@ +--- +'@objectstack/spec': minor +'@objectstack/service-automation': minor +'@objectstack/lint': minor +--- + +A flow VALUE slot no longer reads the single-brace `{…}` template dialect: a `create_record` / `update_record` `fields` value or an `assignment` value that carries a `{…}` token is refused at `objectstack validate`, at `registerFlow` and by the executor, with the CEL spelling of each token. A computed value is a CEL value envelope, `{ dialect: 'cel', source: '…' }`; a string is the literal text it spells. + +Clause-②: yes (narrowing) + + + +**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings (the v18 line's `.changeset/pre.json` is not open on `main`). + +**Why.** Since #19938 the value slots also evaluate a CEL value envelope, so a flow had two dialects for one job — two function sets, two meanings of `/`, and a template that wrote nothing where CEL refuses. The maintainer's ruling D on the flow expression dialects put the template's retirement from these slots on the v18 train, with a remedy for every spelling and an automatic conversion only where one is lossless (ADR-0087 D2). + +**What is refused.** In every value slot — `create_record.fields.*`, `update_record.fields.*`, the `assignment` node's `assignments` map, and the two legacy `assignment` shapes the executor still reads (the `assignments: [{ variable, value }]` array and the bare `{ : }` config) — a string, or a string at any depth of an array or object value, that carries a `{…}` token the interpolator would resolve. One judge says it everywhere (`flowNodeValueTemplateRefusals` / `valueSlotTemplateRefusals` in `@objectstack/spec/automation`): `FlowValueSlotSchema`, `AssignmentValueSchema`, `CreateRecordConfigSchema`, `UpdateRecordConfigSchema` and `AssignmentConfigSchema` refuse it at the value's path; `objectstack validate` reports it as `expression-invalid` at `error`, located at the node and `config.`, which also refuses it at the metadata save door; `registerFlow` refuses the flow (a stored flow carrying one is skipped at boot with a warn naming it); and the `create_record` / `update_record` / `assignment` executors refuse the node before writing anything. Each refusal leads with `VALUE_SLOT_TEMPLATE_REFUSAL` and then names the CEL spelling of the string's tokens. + +**Not converted.** No D2 conversion rewrites any spelling: every token spelling authored in flows answers differently under CEL for some input (measured through both engines over the same variables, 13 of 25 probes the same, 12 different), so a rewrite would change what some flow writes. Where a value may be absent, whether the field should take nothing, `null` or a default was decided silently by the template; it is now the author's decision. + +**What is still accepted, unchanged.** A CEL value envelope, every literal (a token-free string, numbers, booleans, `null`, arrays, objects), and two spellings CEL cannot write yet, which keep their meaning until it can: + +- the date macros — `{NOW()}`, `{TODAY()}`, with an optional `± N` day offset. CEL's `now()` / `today()` / `daysFromNow()` / `addDays()` yield a Timestamp, which reaches the data engine as a `Date` object rather than the ISO text the macro wrote, and CEL has no string form for one; +- the run user — `{$User.}`. The flow CEL scope binds no user. + +A string whose tokens include one of these is not refused. Text slots (`notify` `title` / `message`, a screen `description`, …) and `filter` values keep the template dialect. + +## FROM → TO + +| you wrote | write instead | what changes | +|:--|:--|:--| +| `'{record.owner}'`, `'{x}'` | `{ dialect: 'cel', source: 'record.owner' }` | CEL refuses an absent variable or key where the template wrote nothing — guard one that may be absent: `has(record.owner) ? record.owner : null`, `has(vars.x) ? vars.x : null` (writes `null`) | +| `'{list.0}'` | `{ dialect: 'cel', source: 'list[0]' }` | an empty list fails the run | +| `'{$error.message}'` | `{ dialect: 'cel', source: 'vars["$error"].message' }` | a `$`-named variable is read through `vars` | +| `'{round(x * 100) / 100}'` | `{ dialect: 'cel', source: 'round(x * 100) / 100.0' }` | CEL divides two integers as integers: keep a decimal operand on every division, or `123.46` becomes `123` | +| `'Renewal — {contract.number}'` | `{ dialect: 'cel', source: "'Renewal — ' + contract.number" }` | wrap a non-string hole in `string(…)`, one that may be null in `coalesce(…, '')` | +| braces meant literally, `'{"a": 1}'` | `{ dialect: 'cel', source: "'{\"a\": 1}'" }` | a CEL string literal | + +**The one-line fix: write the CEL spelling the refusal names, and guard a value that may be absent.** + +**Who is affected, measured.** A TypeScript-AST census of every `create_record` / `update_record` `fields` value and `assignment` value (all three shapes, same-file spreads included): this repository at `959c209d5` carried 21 authored sites (`examples/**` and `packages/verify/src`) — 20 refused (9 bare references, 10 dotted paths, 1 `$error` path, all migrated in this change) and 1 kept (`{$User.Id}`, `examples/app-todo`); hotcrm at `c529de2` carries 91 (2 of them through a same-file spread) — 71 refused (31 bare references, 36 dotted paths, 4 text with holes) and 20 kept (15 date macros, 5 `{$User.Id}`). Deployed metadata and other repositories were not measured. + +### The kit + +- **The refusal.** `automation/flow-value-slot-template.ts` (`VALUE_SLOT_TEMPLATE_REFUSAL`, `valueSlotTemplateRefusals`, `flowNodeValueTemplateRefusals`), composed into the value-slot contracts in `automation/builtin-node-config.zod.ts`; one new dropped-refinement site (`automation/AssignmentConfig` `out.catchall`, the bare legacy shape's values). +- **The doors.** `AutomationEngine.registerFlow` and the `create_record` / `update_record` / `assignment` executors (`@objectstack/service-automation`); `validateStackExpressions` (`@objectstack/lint`), whose `warning` hint pointing a template expression at the envelope this refusal replaces. +- **The ledger.** The D3 semantic entry `flow-value-slot-template-dialect-refused` (protocol 18). No key is removed, so there is no tombstone, and there is no D2 conversion: no authored spelling maps losslessly. From 03d403abde8a67c2300002956240dfcc64ac8f2c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 06:03:54 +0000 Subject: [PATCH 08/14] wip(service-automation): tests for the value-slot template refusal (#19939) Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude --- .../builtin/assignment-value-envelope.test.ts | 22 +++- .../src/builtin/config-unknown-keys.test.ts | 4 +- .../create-record-duplicate-code.test.ts | 2 +- .../crud-fields-value-envelope.test.ts | 101 +++++++++++++++-- .../src/builtin/crud-output-var.test.ts | 8 +- ...stored-metadata-family.integration.test.ts | 10 +- .../src/builtin/logic-nodes.test.ts | 47 +++++++- .../value-slot-template-grammar.test.ts | 102 ++++++++++++++++++ .../services/service-automation/src/engine.ts | 4 +- ...field-expression-scale.integration.test.ts | 69 ++++++------ .../record-lookup-expand.integration.test.ts | 21 ++-- 11 files changed, 320 insertions(+), 70 deletions(-) create mode 100644 packages/services/service-automation/src/builtin/value-slot-template-grammar.test.ts diff --git a/packages/services/service-automation/src/builtin/assignment-value-envelope.test.ts b/packages/services/service-automation/src/builtin/assignment-value-envelope.test.ts index 7f0d5345cc7..27ffcada4b4 100644 --- a/packages/services/service-automation/src/builtin/assignment-value-envelope.test.ts +++ b/packages/services/service-automation/src/builtin/assignment-value-envelope.test.ts @@ -37,6 +37,7 @@ import { resolveFlowNodeExpressions, isExpressionEnvelopeShaped, ASSIGNMENT_VALUE_ENVELOPE_REFUSAL, + VALUE_SLOT_TEMPLATE_REFUSAL, } from '@objectstack/spec/automation'; import { EVALUATED_EXPRESSION_SOURCE_REQUIRED } from '@objectstack/spec'; import { ExpressionEngine } from '@objectstack/formula'; @@ -195,7 +196,7 @@ describe('assignment value envelope — registration refusal (#15137 ask 1)', () ...assignmentFlow({ assignments: { digest: RULING_EXAMPLE, - greeting: 'Hello {name}', // `{token}` interpolation — untouched + greeting: 'Hello', // a literal (#19939 retired `{token}` here) count: 3, // literal flags: { enabled: true }, // plain object literal nothing: null, @@ -405,9 +406,22 @@ describe('assignment value envelope — the legacy shapes are untouched (#15137 expect(result.output).toEqual({ digest: RULING_EXAMPLE }); }); - it.each(MALFORMED)('$label registers unchanged in the legacy array form — no flow stops registering', ({ envelope }) => { + // An envelope there is a literal, so its MALFORMED shape stops nothing + // registering — except where the literal's own strings spell the retired + // `{…}` template dialect, which the legacy shapes read like any value + // (#19939: no shape is a way around that retirement). + it.each(MALFORMED.filter(({ envelope }) => !String(envelope.source ?? '').includes('{')))( + '$label registers unchanged in the legacy array form — no flow stops registering', + ({ envelope }) => { + expect(() => engine.registerFlow('assign_flow', assignmentFlow({ + assignments: [{ variable: 'digest', value: envelope }], + }))).not.toThrow(); + }, + ); + + it('[#19939] a legacy-shape literal whose string spells the `{…}` dialect is refused like any value', () => { expect(() => engine.registerFlow('assign_flow', assignmentFlow({ - assignments: [{ variable: 'digest', value: envelope }], - }))).not.toThrow(); + assignments: [{ variable: 'digest', value: { dialect: 'template', source: 'Hello {name}' } }], + }))).toThrow(VALUE_SLOT_TEMPLATE_REFUSAL); }); }); diff --git a/packages/services/service-automation/src/builtin/config-unknown-keys.test.ts b/packages/services/service-automation/src/builtin/config-unknown-keys.test.ts index 78d53633465..daf797380f8 100644 --- a/packages/services/service-automation/src/builtin/config-unknown-keys.test.ts +++ b/packages/services/service-automation/src/builtin/config-unknown-keys.test.ts @@ -166,7 +166,9 @@ describe('unknown node config keys are rejected (#4277)', () => { expect(() => engine.registerFlow('f', flowWith('assignment', { approvalStatus: 'pending', - anyVariableNameAtAll: '{record.owner}', + // A literal — the `{…}` template dialect is refused in an assignment + // value since #19939, a different rule from the key check pinned here. + anyVariableNameAtAll: 'owner', }))).not.toThrow(); await expect(engine.getFlow('f')).resolves.toBeDefined(); }); diff --git a/packages/services/service-automation/src/builtin/create-record-duplicate-code.test.ts b/packages/services/service-automation/src/builtin/create-record-duplicate-code.test.ts index 2181fd5328d..1a9c0913b5a 100644 --- a/packages/services/service-automation/src/builtin/create-record-duplicate-code.test.ts +++ b/packages/services/service-automation/src/builtin/create-record-duplicate-code.test.ts @@ -319,7 +319,7 @@ describe('#14419 (PR #14948 patch round 1) — a stale $error.code must not leak { id: 'tc', type: 'try_catch', label: 'Guarded create', config: { - try: { nodes: [{ id: 'mk', type: 'create_record', label: 'Create', timeoutMs: 20, config: { objectName: 'lead', fields: { email: '{row.email}' } } }], edges: [] }, + try: { nodes: [{ id: 'mk', type: 'create_record', label: 'Create', timeoutMs: 20, config: { objectName: 'lead', fields: { email: { dialect: 'cel', source: 'row.email' } } } }], edges: [] }, catch: { nodes: [{ id: 'handle', type: 'swallow_duplicate_else_reraise', label: 'Handle' }], edges: [] }, }, }, diff --git a/packages/services/service-automation/src/builtin/crud-fields-value-envelope.test.ts b/packages/services/service-automation/src/builtin/crud-fields-value-envelope.test.ts index bfb24fe052b..b3030ef100e 100644 --- a/packages/services/service-automation/src/builtin/crud-fields-value-envelope.test.ts +++ b/packages/services/service-automation/src/builtin/crud-fields-value-envelope.test.ts @@ -17,8 +17,10 @@ * 1. **Evaluate** — a valid envelope in `fields.*` is evaluated by the same * engine call the `assignment` executor makes, and its value is written, * type kept. - * 2. **Preserve** — every non-envelope value writes exactly what the old - * whole-map `interpolate()` wrote: ruling D, no spelling changes meaning. + * 2. **Preserve** — every literal writes exactly what the old whole-map + * `interpolate()` wrote. (A `{…}` template no longer reaches a write: + * #19939, ruling D's v18 half, refuses it — pinned below, with the CEL + * spelling that writes the same value.) * 3. **Refuse at registration** — a malformed envelope stops the flow * registering, located at `config.fields.` and led by the * slot-neutral sentence; the evaluator refuses the same set. @@ -28,7 +30,7 @@ import { describe, it, expect } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; -import { VALUE_ENVELOPE_REFUSAL } from '@objectstack/spec/automation'; +import { VALUE_ENVELOPE_REFUSAL, VALUE_SLOT_TEMPLATE_REFUSAL } from '@objectstack/spec/automation'; import { AutomationEngine } from '../engine.js'; import { registerCrudNodes } from './crud-nodes.js'; import { interpolate } from './template.js'; @@ -79,6 +81,25 @@ async function makeStack() { return { automation, writes }; } +/** The CRUD executors themselves, over the same recording store — for the run-time twin. */ +async function captureExecutors() { + const logger = makeLogger(); + const ql = new ObjectQL({ logger }); + const { driver, writes } = makeRecordingDriver(); + ql.registerDriver(driver, true); + await ql.init(); + const executors = new Map Promise }>(); + const engine = new AutomationEngine(logger); + const recorder = new Proxy(engine, { + get(target, key, receiver) { + if (key === 'registerNodeExecutor') return (e: any) => { executors.set(e.type, e); }; + return Reflect.get(target, key, receiver); + }, + }); + registerCrudNodes(recorder as AutomationEngine, { logger, getService: (n: string) => (n === 'data' ? ql : undefined) } as any); + return { writes, executors }; +} + type WriteNode = 'create_record' | 'update_record'; function writeFlow(nodeType: WriteNode, fields: Record) { @@ -155,15 +176,16 @@ describe.each(NODE_TYPES)('%s `fields.*` — a CEL value envelope is EVALUATED ( }); }); -describe.each(NODE_TYPES)('%s `fields.*` — every non-envelope value writes exactly what it wrote before', (nodeType) => { - it('templates, literals, arrays and nested envelope-shaped JSON: byte-identical to the whole-map `interpolate()`', async () => { +describe.each(NODE_TYPES)('%s `fields.*` — every literal writes exactly what it wrote before', (nodeType) => { + it('literals, arrays, nested envelope-shaped JSON and the two kept `{…}` spellings: byte-identical to the whole-map `interpolate()`', async () => { const fields = { - total: '{price}', // sole token keeps its type - subject: 'Quote for {name} at {price}', // text with holes + subject: 'Quote', // a literal string + total: 42, + due: '{TODAY() + 7}', // a date macro — kept until CEL can write it payload: { - note: 'for {name}', // strings inside a literal still interpolate + note: 'for the record', inner: { dialect: 'cel', source: 'price * 2' }, // NESTED envelope shape — data, not evaluated - list: [{ dialect: 'cel', source: 'x' }, '{price}'], + list: [{ dialect: 'cel', source: 'x' }, 'plain'], weird: { dialect: 1 }, }, }; @@ -180,13 +202,70 @@ describe.each(NODE_TYPES)('%s `fields.*` — every non-envelope value writes exa }); }); +/** + * [#19939] The `{…}` template dialect is retired from `fields.*` (the C half + * of #11182 ruling D) — refused at registration, and by the executor's own + * `parseNodeConfig` for a flow that never went through registration. Nothing + * is written either way: the template used to write what the CEL envelope + * named in the refusal now writes. + */ +describe.each(NODE_TYPES)('%s `fields.*` — the retired `{…}` template dialect is refused, and nothing is written', (nodeType) => { + const TEMPLATED: ReadonlyArray<[string, Record, string, string]> = [ + ['a sole token', { total: '{price}' }, 'config.fields.total', "source: 'price'"], + ['text with holes', { subject: 'Quote for {name} at {price}' }, 'config.fields.subject', `"'Quote for ' + name + ' at ' + price"`], + ['a string inside a literal', { payload: { note: 'for {name}' } }, 'config.fields.payload.note', "source: 'name'"], + ['a template expression', { total: '{round(price * 100) / 100}' }, 'config.fields.total', "source: 'round(price * 100) / 100.0'"], + ]; + + it.each(TEMPLATED)('%s: registerFlow refuses it, located, with the CEL spelling', (_what, fields, at, spelling) => { + const automation = new AutomationEngine(makeLogger()); + registerCrudNodes(automation, { logger: makeLogger(), getService: () => undefined } as any); + let thrown: Error | undefined; + try { + automation.registerFlow('price_quote', writeFlow(nodeType, fields)); + } catch (err) { + thrown = err as Error; + } + expect(thrown, 'a template in a value slot must not register').toBeDefined(); + expect(thrown!.message).toContain(`node 'w' (${nodeType}) ${nodeType} field value at ${at}`); + expect(thrown!.message).toContain(VALUE_SLOT_TEMPLATE_REFUSAL); + expect(thrown!.message).toContain(spelling); + }); + + it('the run-time twin: the executor refuses the same value at its own contract parse, and writes nothing', async () => { + const { writes, executors } = await captureExecutors(); + const variables = new Map(Object.entries(PARAMS)); + const config: Record = { objectName: 'quote', fields: { subject: 'ok', total: '{price}' } }; + if (nodeType === 'update_record') config.filter = { id: 'q1' }; + const result = await executors.get(nodeType)!.execute({ id: 'w', type: nodeType, config } as any, variables, { userId: 'u1' } as any); + expect(result.success).toBe(false); + expect((result as { errorClass?: string }).errorClass).toBe('guard'); + expect(String((result as { error?: string }).error)).toContain('config.fields.total'); + expect(String((result as { error?: string }).error)).toContain(VALUE_SLOT_TEMPLATE_REFUSAL); + expect(writes).toEqual([]); + }); + + it('the CEL spelling the refusal names writes the value the template wrote', async () => { + const before = interpolate({ total: '{price}', subject: 'Quote for {name}' }, new Map(Object.entries(PARAMS)), {} as any); + const { automation, writes } = await makeStack(); + automation.registerFlow('price_quote', writeFlow(nodeType, { + total: { dialect: 'cel', source: 'price' }, + subject: { dialect: 'cel', source: "'Quote for ' + name" }, + })); + const res = await run(automation); + expect(res.success, res.error).toBe(true); + expect(writes[0]!.data.total).toBe(before.total); + expect(writes[0]!.data.subject).toBe(before.subject); + }); +}); + describe.each(NODE_TYPES)('%s `fields.*` — a malformed envelope is refused at registration, and by the evaluator', (nodeType) => { it.each(MALFORMED)('$label: registerFlow refuses it, located at the field and led by the slot-neutral sentence', ({ envelope }) => { const { automation } = { automation: new AutomationEngine(makeLogger()) }; registerCrudNodes(automation, { logger: makeLogger(), getService: () => undefined } as any); let thrown: Error | undefined; try { - automation.registerFlow('price_quote', writeFlow(nodeType, { subject: '{name}', total: envelope })); + automation.registerFlow('price_quote', writeFlow(nodeType, { subject: 'name', total: envelope })); } catch (err) { thrown = err as Error; } @@ -207,7 +286,7 @@ describe.each(NODE_TYPES)('%s `fields.*` — a malformed envelope is refused at registerCrudNodes(automation, { logger: makeLogger(), getService: () => undefined } as any); expect(() => automation.registerFlow('price_quote', writeFlow(nodeType, { total: { dialect: 'cel', source: 'price * 2' }, - subject: '{name}', n: 3, ok: true, nothing: null, payload: { dialect: 1 }, list: [{ dialect: 'cel' }], + subject: 'name', due: '{TODAY()}', n: 3, ok: true, nothing: null, payload: { dialect: 1 }, list: [{ dialect: 'cel' }], }))).not.toThrow(); }); }); diff --git a/packages/services/service-automation/src/builtin/crud-output-var.test.ts b/packages/services/service-automation/src/builtin/crud-output-var.test.ts index dd541202d82..cd6f8e87607 100644 --- a/packages/services/service-automation/src/builtin/crud-output-var.test.ts +++ b/packages/services/service-automation/src/builtin/crud-output-var.test.ts @@ -33,7 +33,7 @@ function fakeData() { const ctxWith = (data: any): any => ({ logger: makeLogger(), getService: (n: string) => (n === 'data' ? data : undefined) }); describe('create_record outputVariable (#1873)', () => { - it('exposes the created record so {var.id} resolves in a later node', async () => { + it('exposes the created record so `var.id` resolves in a later node (a CEL value envelope since #19939)', async () => { const engine = new AutomationEngine(makeLogger()); const { data, updates } = fakeData(); registerCrudNodes(engine, ctxWith(data)); @@ -43,7 +43,7 @@ describe('create_record outputVariable (#1873)', () => { nodes: [ { id: 'start', type: 'start', label: 'Start' }, { id: 'mk', type: 'create_record', label: 'Create', config: { objectName: 'topic', outputVariable: 'topic', fields: { title: 'X' } } }, - { id: 'upd', type: 'update_record', label: 'Update', config: { objectName: 'signal', filter: { id: 'sig1' }, fields: { promoted_topic: '{topic.id}' } } }, + { id: 'upd', type: 'update_record', label: 'Update', config: { objectName: 'signal', filter: { id: 'sig1' }, fields: { promoted_topic: { dialect: 'cel', source: 'topic.id' } } } }, { id: 'end', type: 'end', label: 'End' }, ], edges: [ @@ -62,7 +62,7 @@ describe('create_record outputVariable (#1873)', () => { expect(updates[0].fields.promoted_topic).toBe('topic_1'); }); - it('exposes non-id fields of the created record too ({var.title})', async () => { + it('exposes non-id fields of the created record too (`var.title`)', async () => { const engine = new AutomationEngine(makeLogger()); const { data, updates } = fakeData(); registerCrudNodes(engine, ctxWith(data)); @@ -71,7 +71,7 @@ describe('create_record outputVariable (#1873)', () => { nodes: [ { id: 'start', type: 'start', label: 'Start' }, { id: 'mk', type: 'create_record', label: 'Create', config: { objectName: 'topic', outputVariable: 'topic', fields: { title: 'X' } } }, - { id: 'upd', type: 'update_record', label: 'Update', config: { objectName: 'signal', filter: { id: 'sig1' }, fields: { ref: '{topic.title}' } } }, + { id: 'upd', type: 'update_record', label: 'Update', config: { objectName: 'signal', filter: { id: 'sig1' }, fields: { ref: { dialect: 'cel', source: 'topic.title' } } } }, { id: 'end', type: 'end', label: 'End' }, ], edges: [ diff --git a/packages/services/service-automation/src/builtin/get-record-stored-metadata-family.integration.test.ts b/packages/services/service-automation/src/builtin/get-record-stored-metadata-family.integration.test.ts index a970307b657..10019baddc2 100644 --- a/packages/services/service-automation/src/builtin/get-record-stored-metadata-family.integration.test.ts +++ b/packages/services/service-automation/src/builtin/get-record-stored-metadata-family.integration.test.ts @@ -80,7 +80,9 @@ type Branch = 'one' | 'list'; * and copies the first row. */ function familyReadFlow(name: string, object: string, runAs: RunAs, branch: Branch, fields?: string[]) { - const ref = branch === 'one' ? 'rec' : 'rec.0'; + // A CEL path (#19939 — the `{…}` template dialect is retired from value + // slots): the list branch reads its first row by index. + const ref = branch === 'one' ? 'rec' : 'rec[0]'; return { name, label: name, @@ -107,7 +109,11 @@ function familyReadFlow(name: string, object: string, runAs: RunAs, branch: Bran label: 'Copy', config: { objectName: COPY_OBJECT.name, - fields: { title: name, body: `{${ref}.metadata}`, hash: `{${ref}.checksum}` }, + fields: { + title: name, + body: { dialect: 'cel', source: `${ref}.metadata` }, + hash: { dialect: 'cel', source: `${ref}.checksum` }, + }, }, }, { id: 'end', type: 'end', label: 'End' }, diff --git a/packages/services/service-automation/src/builtin/logic-nodes.test.ts b/packages/services/service-automation/src/builtin/logic-nodes.test.ts index b903b524775..a90c0f1097c 100644 --- a/packages/services/service-automation/src/builtin/logic-nodes.test.ts +++ b/packages/services/service-automation/src/builtin/logic-nodes.test.ts @@ -3,6 +3,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { AutomationEngine } from '../engine.js'; import { registerLogicNodes } from './logic-nodes.js'; +import { VALUE_SLOT_TEMPLATE_REFUSAL } from '@objectstack/spec/automation'; function createTestLogger() { return { @@ -74,12 +75,52 @@ describe('assignment node — config-shape parity (Studio + examples)', () => { expect(result.output).toEqual({ approval_path: 'Flat works' }); }); - // Values interpolate {var} against live flow variables, like CRUD/screen nodes. - it('interpolates {var} references in assignment values', async () => { - const flow = assignmentFlow({ assignments: { greeting: 'Hello {name}' } }, ['greeting']); + // [#19939] A computed value is a CEL value envelope — the `{var}` template + // dialect is retired from assignment values (the C half of #11182 ruling D). + it('computes a value from the live flow variables with a CEL value envelope', async () => { + const flow = assignmentFlow({ assignments: { greeting: { dialect: 'cel', source: "'Hello ' + name" } } }, ['greeting']); flow.variables.push({ name: 'name', type: 'text', isInput: true } as any); engine.registerFlow('assign_flow', flow); const result = await engine.execute('assign_flow', { params: { name: 'Ada' } } as any); expect(result.output).toEqual({ greeting: 'Hello Ada' }); }); + + it.each([ + ['the Studio map', { assignments: { greeting: 'Hello {name}' } }, 'config.assignments.greeting'], + ['the legacy array', { assignments: [{ variable: 'greeting', value: 'Hello {name}' }] }, 'config.assignments[0].value'], + ['the legacy flat shape', { greeting: 'Hello {name}' }, 'config.greeting'], + ])('[#19939] refuses the retired `{var}` dialect at registration in %s, located, with the CEL spelling', (_shape, config, at) => { + expect(() => engine.registerFlow('assign_flow', assignmentFlow(config, ['greeting']))) + .toThrow(VALUE_SLOT_TEMPLATE_REFUSAL); + let message = ''; + try { engine.registerFlow('assign_flow', assignmentFlow(config, ['greeting'])); } catch (err) { message = (err as Error).message; } + expect(message).toContain(`node 'assign' (assignment) assignment value at ${at}`); + expect(message).toContain(`source: "'Hello ' + name"`); + }); + + it('[#19939] the run-time twin: the executor refuses the same value before assigning anything', async () => { + // Reached only by a flow that bypassed registration — captured straight + // off the registry the executors are handed. + const executors = new Map(); + registerLogicNodes({ registerNodeExecutor: (e: any) => executors.set(e.type, e) } as any, createCtx()); + const variables = new Map([['name', 'Ada']]); + const result = await executors.get('assignment').execute( + { id: 'assign', type: 'assignment', config: { assignments: { kept: 'x', greeting: 'Hello {name}' } } }, + variables, + {} as any, + ); + expect(result.success).toBe(false); + expect(result.errorClass).toBe('guard'); + expect(result.error).toContain(VALUE_SLOT_TEMPLATE_REFUSAL); + expect(result.error).toContain('config.assignments.greeting'); + expect(variables.has('kept'), 'nothing is assigned once the node is refused').toBe(false); + }); + + it('[#19939] the two spellings CEL cannot write yet keep resolving — a date macro and `$User`', async () => { + engine.registerFlow('assign_flow', assignmentFlow({ assignments: { day: '{TODAY()}', who: '{$User.Id}' } }, ['day', 'who'])); + const result = await engine.execute('assign_flow', { userId: 'usr_1' } as any); + expect(result.success).toBe(true); + expect(result.output!.who).toBe('usr_1'); + expect(String(result.output!.day)).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); }); diff --git a/packages/services/service-automation/src/builtin/value-slot-template-grammar.test.ts b/packages/services/service-automation/src/builtin/value-slot-template-grammar.test.ts new file mode 100644 index 00000000000..20c2f179c75 --- /dev/null +++ b/packages/services/service-automation/src/builtin/value-slot-template-grammar.test.ts @@ -0,0 +1,102 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#19939] The spec's value-slot template judge reads the `{…}` dialect the + * way THIS package's interpolator does — the drift pin its module docblock + * names (`@objectstack/spec/automation`, `flow-value-slot-template.ts`). + * + * The spec cannot import the interpolator, so it carries a copy of + * `resolveToken`'s dispatch: date macro, then `$User.`, then a variable path, + * then an expression. If the two readings drifted, the judge would refuse a + * spelling the retirement keeps (a run-time capability lost with no remedy), + * or keep one it should refuse (the dialect surviving in a value slot). So the + * interpolator itself is driven over the same tokens the judge classifies: + * + * - every KEPT spelling resolves through `interpolateString` to a value — the + * date macros to ISO text, `$User` to the run user — and the judge + * refuses none of them; + * - every REFUSED spelling resolves through the variables (a path), computes + * (an expression), or resolves to nothing — and the judge refuses each, + * including the dispatch-order edges (`{$User}` with no path, `{NOW}` with + * no call, a padded `{ amount }`). + */ + +import { describe, expect, it } from 'vitest'; +import { valueSlotTemplateRefusals } from '@objectstack/spec/automation'; +import { interpolateString } from './template.js'; + +const VARIABLES = new Map([ + ['amount', 1234.5], + ['record', { owner: 'usr_7', name: 'Acme' }], + ['list', ['a', 'b']], + ['$error', { message: 'boom' }], + ['days', 3], +]); +const CONTEXT = { userId: 'usr_1', user: { email: 'ada@example.com' } } as never; + +describe('a spelling the retirement KEEPS resolves through the interpolator, and is not refused', () => { + it.each([ + ['{NOW()}', /^\d{4}-\d{2}-\d{2}T/], + ['{TODAY()}', /^\d{4}-\d{2}-\d{2}$/], + ['{TODAY() + 7}', /^\d{4}-\d{2}-\d{2}$/], + ['{TODAY() - days}', /^\d{4}-\d{2}-\d{2}$/], + ['{NOW() + 2}', /^\d{4}-\d{2}-\d{2}T/], + ])('%s — a date macro', (token, shape) => { + expect(String(interpolateString(token, VARIABLES, CONTEXT))).toMatch(shape); + expect(valueSlotTemplateRefusals(token)).toEqual([]); + }); + + it.each([ + ['{$User.Id}', 'usr_1'], + ['{$User.Email}', 'ada@example.com'], + ])('%s — the run user', (token, value) => { + expect(interpolateString(token, VARIABLES, CONTEXT)).toBe(value); + expect(valueSlotTemplateRefusals(token)).toEqual([]); + }); +}); + +describe('a spelling the retirement REFUSES is one the interpolator reads as template dialect', () => { + it.each([ + ['{amount}', 1234.5], + ['{ amount }', 1234.5], + ['{record.owner}', 'usr_7'], + ['{list.0}', 'a'], + ['{$error.message}', 'boom'], + ])('%s — a variable path, resolved through the variables', (token, value) => { + expect(interpolateString(token, VARIABLES, CONTEXT)).toBe(value); + expect(valueSlotTemplateRefusals(token)).toHaveLength(1); + }); + + it.each([ + ['{amount * 2}', 2469], + ['{round(amount)}', 1235], + ['{max(amount, 10)}', 1234.5], + ])('%s — an expression, computed', (token, value) => { + expect(interpolateString(token, VARIABLES, CONTEXT)).toBe(value); + expect(valueSlotTemplateRefusals(token)).toHaveLength(1); + }); + + it.each([ + // No `.` after `$User`: not the user shortcut — a variable path to a + // variable nobody binds. + '{$User}', + // No call: not the date macro — a variable path. + '{NOW}', + '{missing.key}', + ])('%s — a dispatch-order edge: a path that resolves to nothing', (token) => { + expect(interpolateString(token, VARIABLES, CONTEXT)).toBeUndefined(); + expect(valueSlotTemplateRefusals(token)).toHaveLength(1); + }); + + it('text with holes renders through the interpolator, and is refused as one string', () => { + expect(interpolateString('Owner {record.owner} of {record.name}', VARIABLES, CONTEXT)).toBe('Owner usr_7 of Acme'); + expect(valueSlotTemplateRefusals('Owner {record.owner} of {record.name}')).toHaveLength(1); + }); + + it('a token-free string is the same text to both: no substitution, no refusal', () => { + expect(interpolateString('approved', VARIABLES, CONTEXT)).toBe('approved'); + expect(interpolateString('a {} b', VARIABLES, CONTEXT)).toBe('a {} b'); + expect(valueSlotTemplateRefusals('approved')).toEqual([]); + expect(valueSlotTemplateRefusals('a {} b')).toEqual([]); + }); +}); diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index df61aaad4e4..ab35572c826 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -10758,8 +10758,8 @@ export class AutomationEngine implements IAutomationService { throw new Error( `Flow '${flowName}' has ${failures.length} invalid expression${failures.length > 1 ? 's' : ''} (ADR-0032 §1a). ` + `Predicates — conditions and declared bare-CEL slots such as a screen field's \`visibleWhen\` — ` + - `must not wrap references in \`{…}\` template braces, nor may a value slot (a \`fields\` or ` + - `\`assignments\` value); template slots (e.g. \`loop.collection\`) require them:\n` + + `must not wrap references in \`{…}\` template braces, and neither may a value slot (a computed ` + + `value is a CEL envelope); template slots (e.g. \`loop.collection\`) require them:\n` + `${failures.join('\n')}`, ); } diff --git a/packages/services/service-automation/src/flow-field-expression-scale.integration.test.ts b/packages/services/service-automation/src/flow-field-expression-scale.integration.test.ts index b14cd830a73..266ddab51f3 100644 --- a/packages/services/service-automation/src/flow-field-expression-scale.integration.test.ts +++ b/packages/services/service-automation/src/flow-field-expression-scale.integration.test.ts @@ -18,16 +18,23 @@ * pass, and every assertion below reads the PERSISTED row, never the * expression result. * + * Since #19939 retired the `{…}` template dialect from the value slots (the C + * half of #11182 ruling D), every value here is a CEL value envelope — the one + * value dialect left: + * * - negative control: the raw product is refused (`max_scale`) — proves the * oracle's gate is live in this harness, not assumed; - * - oracle: `round(x * 100) / 100` (the CEL-identical authoring pattern) - * lands `126000` in the field; + * - oracle: `round(x * 100.0) / 100.0` lands `126000` in the field. The + * decimal points are load-bearing: CEL's `round()` returns an int and + * `int / int` is INTEGER division, so `round(x * 100) / 100` would drop + * the cents of any value that has them; * - the same pattern through the `assignment` surface * (`config.assignments`) — the third value-producing surface the issue * names — persists identically; - * - the LOUD half: an unknown function (`ROUND`) fails the run with a named - * error INSTEAD of writing `undefined`, and a `fault` edge cannot swallow - * it (#3863 guard refusal). + * - the LOUD half: an unknown function (`ROUND`) never reaches a run — the + * envelope is refused at registration with a did-you-mean, and so is the + * retired template spelling of the same value — so nothing is written as + * `undefined`. */ import { describe, it, expect, afterEach } from 'vitest'; @@ -36,6 +43,7 @@ import { ObjectQLPlugin, type ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { AutomationServicePlugin } from './plugin.js'; import type { AutomationEngine } from './engine.js'; +import { VALUE_SLOT_TEMPLATE_REFUSAL } from '@objectstack/spec/automation'; function makeSqliteDriver() { return new SqlDriver({ @@ -56,7 +64,7 @@ const quote = { }; /** start → create_record(quote) → end, computing `total` from flow inputs. */ -const quoteFlow = (name: string, totalExpr: string, extraNodes: any[] = [], extraEdges: any[] = []) => ({ +const quoteFlow = (name: string, total: unknown, extraNodes: any[] = [], extraEdges: any[] = []) => ({ name, label: name, type: 'autolaunched', @@ -67,7 +75,7 @@ const quoteFlow = (name: string, totalExpr: string, extraNodes: any[] = [], extr ], nodes: [ { id: 'start', type: 'start', label: 'Start' }, - { id: 'mk', type: 'create_record', label: 'Create', config: { objectName: 'quote', fields: { title: name, total: totalExpr } } }, + { id: 'mk', type: 'create_record', label: 'Create', config: { objectName: 'quote', fields: { title: name, total } } }, { id: 'end', type: 'end', label: 'End' }, ...extraNodes, ], @@ -80,6 +88,9 @@ const quoteFlow = (name: string, totalExpr: string, extraNodes: any[] = [], extr const INPUTS = { amount: 180000, discount: 30 }; +/** A CEL value envelope — the value dialect of `fields.*` / `assignments.*` since #19939. */ +const cel = (source: string) => ({ dialect: 'cel', source }); + describe('flow-computed money lands within its declared scale (#11060, oracle for hotcrm#1206)', () => { let kernel: ObjectKernel; let ql: ObjectQL; @@ -110,7 +121,7 @@ describe('flow-computed money lands within its declared scale (#11060, oracle fo it('NEGATIVE CONTROL: the raw product is refused by scale enforcement — the gate is live in this harness', async () => { await boot(); - automation.registerFlow('raw', quoteFlow('raw', '{amount * (1 - discount / 100)}') as any); + automation.registerFlow('raw', quoteFlow('raw', cel('amount * (1.0 - discount / 100.0)')) as any); const res = await automation.execute('raw', { userId: 'u1', params: { ...INPUTS } }); expect(res.success, `the unrounded 125999.99999999999 must be refused: ${JSON.stringify(res)}`).toBe(false); @@ -120,11 +131,11 @@ describe('flow-computed money lands within its declared scale (#11060, oracle fo expect(await quoteByTitle('raw'), 'no row may persist from the refused write').toBeFalsy(); }); - it('ORACLE: round(x * 100) / 100 writes 126000 into the scale-2 total field, end to end', async () => { + it('ORACLE: round(x * 100.0) / 100.0 writes 126000 into the scale-2 total field, end to end', async () => { await boot(); automation.registerFlow( 'rounded', - quoteFlow('rounded', '{round(amount * (1 - discount / 100) * 100) / 100}') as any, + quoteFlow('rounded', cel('round(amount * (1.0 - discount / 100.0) * 100.0) / 100.0')) as any, ); const res = await automation.execute('rounded', { userId: 'u1', params: { ...INPUTS } }); @@ -136,7 +147,7 @@ describe('flow-computed money lands within its declared scale (#11060, oracle fo expect(row!.total).toBe(126000); }); - it('the assignment surface computes the same rounded value (config.assignments → interpolate)', async () => { + it('the assignment surface computes the same rounded value (config.assignments → the CEL envelope)', async () => { await boot(); const flow = { name: 'via_assignment', @@ -149,8 +160,8 @@ describe('flow-computed money lands within its declared scale (#11060, oracle fo ], nodes: [ { id: 'start', type: 'start', label: 'Start' }, - { id: 'calc', type: 'assignment', label: 'Calc', config: { assignments: { discounted: '{round(amount * (1 - discount / 100) * 100) / 100}' } } }, - { id: 'mk', type: 'create_record', label: 'Create', config: { objectName: 'quote', fields: { title: 'via_assignment', total: '{discounted}' } } }, + { id: 'calc', type: 'assignment', label: 'Calc', config: { assignments: { discounted: cel('round(amount * (1.0 - discount / 100.0) * 100.0) / 100.0') } } }, + { id: 'mk', type: 'create_record', label: 'Create', config: { objectName: 'quote', fields: { title: 'via_assignment', total: cel('discounted') } } }, { id: 'end', type: 'end', label: 'End' }, ], edges: [ @@ -166,27 +177,21 @@ describe('flow-computed money lands within its declared scale (#11060, oracle fo expect((await quoteByTitle('via_assignment'))?.total).toBe(126000); }); - it('LOUD half: an unknown function fails the run with a NAMED error — and a fault edge cannot swallow it', async () => { + it('LOUD half: an unknown function never reaches a run — refused at registration, with the name it meant', async () => { await boot(); - // The fault edge routes ordinary runtime failures; #3863 guard - // refusals — metadata defects like this one — must NOT route, or one - // edge would turn the diagnostic back into the silence it replaces. - automation.registerFlow( + // The CEL spelling: the envelope's own CEL check names the function. + // (Before commit 815585513 the template spelling wrote the field as + // `undefined`; then it failed the run by name; now nothing registers.) + expect(() => automation.registerFlow( 'shouty', - quoteFlow( - 'shouty', - '{ROUND(amount * (1 - discount / 100), 2)}', - [{ id: 'recover', type: 'assignment', label: 'Recover', config: { assignments: { swallowed: 'yes' } } }], - [{ id: 'f1', source: 'mk', target: 'recover', type: 'fault' }], - ) as any, - ); - - const res = await automation.execute('shouty', { userId: 'u1', params: { ...INPUTS } }); - expect(res.success, `the run must FAIL loudly, not route or succeed: ${JSON.stringify(res)}`).toBe(false); - const dump = JSON.stringify(res); - expect(dump).toContain("unknown function 'ROUND'"); - expect(dump).toContain('round'); // the did-you-mean prescription travels with the failure - // Nothing persisted — before commit 815585513 this wrote the field as undefined. + quoteFlow('shouty', cel('ROUND(amount * (1.0 - discount / 100.0), 2)')) as any, + )).toThrow(/ROUND/); + // The retired template spelling of the same value is refused too, with + // the CEL spelling to write instead. + expect(() => automation.registerFlow( + 'shouty_template', + quoteFlow('shouty_template', '{ROUND(amount * (1 - discount / 100), 2)}') as any, + )).toThrow(VALUE_SLOT_TEMPLATE_REFUSAL); expect(await quoteByTitle('shouty')).toBeFalsy(); }); }); diff --git a/packages/services/service-automation/src/record-lookup-expand.integration.test.ts b/packages/services/service-automation/src/record-lookup-expand.integration.test.ts index 511b37c5332..9f5690cb02f 100644 --- a/packages/services/service-automation/src/record-lookup-expand.integration.test.ts +++ b/packages/services/service-automation/src/record-lookup-expand.integration.test.ts @@ -10,8 +10,9 @@ // Boots AutomationServicePlugin on a LiteKernel with a fake objectql that // records the `context` + `expand` each findOne receives and returns a // pre-expanded lead row (standing in for ObjectQL's own expand). A downstream -// update_record interpolates `{record.account.name}`, so the fake's captured -// update fields prove the record was enriched and the template resolved. +// update_record reads `record.account.name` (a CEL value envelope since +// #19939 retired the `{…}` template dialect from value slots), so the fake's +// captured update fields prove the record was enriched and the path resolved. import { describe, it, expect } from 'vitest'; import { LiteKernel } from '@objectstack/core'; @@ -80,7 +81,7 @@ describe('record-change lookup expansion (#3475)', () => { const { engine: ql, crud } = fakeObjectQl(); const kernel = await boot(ql); const automation = kernel.getService('automation'); - automation.registerFlow('u', expandFlow('u', 'user', ['account'], { note: '{record.account.name}' }) as never); + automation.registerFlow('u', expandFlow('u', 'user', ['account'], { note: { dialect: 'cel', source: 'record.account.name' } }) as never); const res = await automation.execute('u', { object: 'lead', record: { ...SEED }, userId: 'u1', params: { noteId: 'n1' }, @@ -105,7 +106,7 @@ describe('record-change lookup expansion (#3475)', () => { const { engine: ql, crud } = fakeObjectQl(); const kernel = await boot(ql); const automation = kernel.getService('automation'); - automation.registerFlow('s', expandFlow('s', 'system', ['account'], { note: '{record.account.name}' }) as never); + automation.registerFlow('s', expandFlow('s', 'system', ['account'], { note: { dialect: 'cel', source: 'record.account.name' } }) as never); await automation.execute('s', { object: 'lead', record: { ...SEED }, userId: 'u1', params: { noteId: 'n1' } }); @@ -125,7 +126,7 @@ describe('record-change lookup expansion (#3475)', () => { const automation = kernel.getService('automation'); // Declare only `account`; `owner` is left un-expanded. automation.registerFlow('sel', expandFlow('sel', 'user', ['account'], { - acc: '{record.account.name}', own: '{record.owner}', + acc: { dialect: 'cel', source: 'record.account.name' }, own: { dialect: 'cel', source: 'record.owner' }, }) as never); await automation.execute('sel', { object: 'lead', record: { ...SEED }, userId: 'u1', params: { noteId: 'n1' } }); @@ -141,17 +142,17 @@ describe('record-change lookup expansion (#3475)', () => { const { engine: ql, crud } = fakeObjectQl({ throwOnFindOne: true }); const kernel = await boot(ql); const automation = kernel.getService('automation'); - automation.registerFlow('fs', expandFlow('fs', 'user', ['account'], { note: '{record.account.name}' }) as never); + // The probe reads the lookup AS STORED: unexpanded, it is the scalar id. + automation.registerFlow('fs', expandFlow('fs', 'user', ['account'], { note: { dialect: 'cel', source: 'string(record.account)' } }) as never); const res = await automation.execute('fs', { object: 'lead', record: { ...SEED }, userId: 'u1', params: { noteId: 'n1' } }); expect(res.success, `run failed: ${JSON.stringify(res)}`).toBe(true); - // account stayed the scalar id 'acc1', so the single-token `{record.account.name}` - // could not resolve (undefined — the interpolator's unresolved single-token value) - // — but the run completed rather than throwing. + // account stayed the scalar id 'acc1' — the record was not enriched — and + // the run completed rather than throwing. const upd = crud.find((c) => c.op === 'update' && c.obj === 'audit'); expect(upd).toBeTruthy(); - expect(upd!.fields.note).toBeUndefined(); + expect(upd!.fields.note).toBe('acc1'); await kernel.shutdown(); }); From 2983595e781ad7aa88c456dcc47aa021a6a8e92f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 06:05:30 +0000 Subject: [PATCH 09/14] wip(service-automation): envelope test expectations (#19939) Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude --- .../src/builtin/crud-fields-value-envelope.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/services/service-automation/src/builtin/crud-fields-value-envelope.test.ts b/packages/services/service-automation/src/builtin/crud-fields-value-envelope.test.ts index b3030ef100e..11baa104b1e 100644 --- a/packages/services/service-automation/src/builtin/crud-fields-value-envelope.test.ts +++ b/packages/services/service-automation/src/builtin/crud-fields-value-envelope.test.ts @@ -179,9 +179,8 @@ describe.each(NODE_TYPES)('%s `fields.*` — a CEL value envelope is EVALUATED ( describe.each(NODE_TYPES)('%s `fields.*` — every literal writes exactly what it wrote before', (nodeType) => { it('literals, arrays, nested envelope-shaped JSON and the two kept `{…}` spellings: byte-identical to the whole-map `interpolate()`', async () => { const fields = { - subject: 'Quote', // a literal string + subject: '{TODAY() + 7}', // a date macro — kept until CEL can write it total: 42, - due: '{TODAY() + 7}', // a date macro — kept until CEL can write it payload: { note: 'for the record', inner: { dialect: 'cel', source: 'price * 2' }, // NESTED envelope shape — data, not evaluated @@ -213,7 +212,7 @@ describe.each(NODE_TYPES)('%s `fields.*` — the retired `{…}` template dialec const TEMPLATED: ReadonlyArray<[string, Record, string, string]> = [ ['a sole token', { total: '{price}' }, 'config.fields.total', "source: 'price'"], ['text with holes', { subject: 'Quote for {name} at {price}' }, 'config.fields.subject', `"'Quote for ' + name + ' at ' + price"`], - ['a string inside a literal', { payload: { note: 'for {name}' } }, 'config.fields.payload.note', "source: 'name'"], + ['a string inside a literal', { payload: { note: 'for {name}' } }, 'config.fields.payload.note', `"'for ' + name"`], ['a template expression', { total: '{round(price * 100) / 100}' }, 'config.fields.total', "source: 'round(price * 100) / 100.0'"], ]; From ceb5821dbd23baedd944df730a27dcfef9da994d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 06:07:10 +0000 Subject: [PATCH 10/14] wip: consumer tests use CEL value envelopes in value slots (#19939) Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude --- ...ge-install-local-boot-steps.integration.test.ts | 4 +++- ...-trigger-record-credential-mask.dogfood.test.ts | 12 +++++++----- .../src/before-update-flow-payload-reach.test.ts | 14 ++++++++------ .../src/bulk-write-per-row-context.test.ts | 12 +++++++----- .../src/formula-context.test.ts | 2 +- .../src/multilookup-context.test.ts | 5 +++-- .../src/record-change-integration.test.ts | 6 ++++-- 7 files changed, 33 insertions(+), 22 deletions(-) diff --git a/packages/cli/test/package-install-local-boot-steps.integration.test.ts b/packages/cli/test/package-install-local-boot-steps.integration.test.ts index 20894a0cf57..57fa4461226 100644 --- a/packages/cli/test/package-install-local-boot-steps.integration.test.ts +++ b/packages/cli/test/package-install-local-boot-steps.integration.test.ts @@ -106,7 +106,9 @@ const ARTIFACT = { id: 'note', type: 'create_record', label: 'Write Note', - config: { objectName: NOTE, fields: { name: 'Completed: {record.name}' } }, + // A CEL value envelope — the `{…}` template dialect is retired from + // value slots (#19939). + config: { objectName: NOTE, fields: { name: { dialect: 'cel', source: "'Completed: ' + record.name" } } }, }, { id: 'end', type: 'end', label: 'End' }, ], diff --git a/packages/qa/dogfood/test/flow-trigger-record-credential-mask.dogfood.test.ts b/packages/qa/dogfood/test/flow-trigger-record-credential-mask.dogfood.test.ts index ded43da92a7..18c29512411 100644 --- a/packages/qa/dogfood/test/flow-trigger-record-credential-mask.dogfood.test.ts +++ b/packages/qa/dogfood/test/flow-trigger-record-credential-mask.dogfood.test.ts @@ -107,12 +107,14 @@ const flow: Flow = { label: 'Echo what the record reads', config: { objectName: ECHO, + // CEL value envelopes — the `{…}` template dialect is retired from + // value slots (#19939). fields: { - name: '{note}', - seen_name: '{record.name}', - seen_password: `{record.${PW}}`, - seen_token: `{record.${TOKEN}}`, - seen_previous_password: `{previous.${PW}}`, + name: { dialect: 'cel', source: 'note' }, + seen_name: { dialect: 'cel', source: 'record.name' }, + seen_password: { dialect: 'cel', source: `record.${PW}` }, + seen_token: { dialect: 'cel', source: `record.${TOKEN}` }, + seen_previous_password: { dialect: 'cel', source: `previous.${PW}` }, }, }, }, diff --git a/packages/triggers/trigger-record-change/src/before-update-flow-payload-reach.test.ts b/packages/triggers/trigger-record-change/src/before-update-flow-payload-reach.test.ts index 6fa8fe7d522..e8d46a045ad 100644 --- a/packages/triggers/trigger-record-change/src/before-update-flow-payload-reach.test.ts +++ b/packages/triggers/trigger-record-change/src/before-update-flow-payload-reach.test.ts @@ -431,7 +431,9 @@ function probeFlow( id: 'log', type: 'create_record', label: 'Log', config: { objectName: `${object}_audit`, - fields: { seen: '{previous.title}', note: '{previous.status}' }, + // CEL value envelopes — the `{…}` template dialect is retired from + // value slots (#19939). + fields: { seen: { dialect: 'cel', source: 'previous.title' }, note: { dialect: 'cel', source: 'previous.status' } }, }, }, { id: 'end', type: 'end', label: 'End' }, @@ -523,7 +525,7 @@ describe('[#15356] can a record-before-update flow reach a multi:true batch payl await seedTwoRows(stack.data, object); stack.automation.registerFlow('s1_flow', probeFlow('s1_flow', object, { id: 'probe', type: 'assignment', label: 'Assign', - config: { assignments: { residue: 'REACHED-{previous.title}' } }, + config: { assignments: { residue: { dialect: 'cel', source: "'REACHED-' + previous.title" } } }, }) as any); const wrote = await batchUpdate(stack.data, object, { status: 'done' }); @@ -754,7 +756,7 @@ describe('[#15356] can a record-before-update flow reach a multi:true batch payl config: { objectName: object, filter: { id: '{record.id}' }, - fields: { residue: 'REACHED-{previous.title}' }, + fields: { residue: { dialect: 'cel', source: "'REACHED-' + previous.title" } }, }, }) as any); @@ -782,9 +784,9 @@ describe('[#15356] can a record-before-update flow reach a multi:true batch payl config: { objectName: object, filter: { id: '{record.id}' }, - // The object token resolves to the array itself; the question is - // whether the second write's own passes mutate the shared array. - fields: { tags: '{record.tags}', residue: 'copied' }, + // The path resolves to the array itself; the question is whether the + // second write's own passes mutate the shared array. + fields: { tags: { dialect: 'cel', source: 'record.tags' }, residue: 'copied' }, }, }) as any); diff --git a/packages/triggers/trigger-record-change/src/bulk-write-per-row-context.test.ts b/packages/triggers/trigger-record-change/src/bulk-write-per-row-context.test.ts index a4fe3b207f9..f3fb2c4b2d3 100644 --- a/packages/triggers/trigger-record-change/src/bulk-write-per-row-context.test.ts +++ b/packages/triggers/trigger-record-change/src/bulk-write-per-row-context.test.ts @@ -171,11 +171,13 @@ function registerTransitionFlow(automation: AutomationEngine, event = 'record-af id: 'log', type: 'create_record', label: 'Log', config: { objectName: 'task_audit', + // CEL value envelopes — the `{…}` template dialect is retired from + // value slots (#19939). fields: { - task_title: '{record.title}', - from_status: '{previous.status}', - to_status: '{record.status}', - task_owner: '{record.owner}', + task_title: { dialect: 'cel', source: 'record.title' }, + from_status: { dialect: 'cel', source: 'previous.status' }, + to_status: { dialect: 'cel', source: 'record.status' }, + task_owner: { dialect: 'cel', source: 'record.owner' }, }, }, }, @@ -268,7 +270,7 @@ describe('[#4862/#5038] a record-change trigger on a predicate bulk write', () = }, { id: 'log', type: 'create_record', label: 'Log', - config: { objectName: 'task_audit', fields: { task_title: '{record.title}', to_status: 'deleted' } }, + config: { objectName: 'task_audit', fields: { task_title: { dialect: 'cel', source: 'record.title' }, to_status: 'deleted' } }, }, { id: 'end', type: 'end', label: 'End' }, ], diff --git a/packages/triggers/trigger-record-change/src/formula-context.test.ts b/packages/triggers/trigger-record-change/src/formula-context.test.ts index 5da9272bc07..474234c690b 100644 --- a/packages/triggers/trigger-record-change/src/formula-context.test.ts +++ b/packages/triggers/trigger-record-change/src/formula-context.test.ts @@ -103,7 +103,7 @@ describe('record-change context hydrates read-time formula fields (#3426)', () = name: 'lead_greeting', label: 'Greeting', type: 'autolaunched', nodes: [ { id: 'start', type: 'start', label: 'Start', config: { objectName: 'crm_lead', triggerType: 'record-after-create' } }, - { id: 'stamp', type: 'update_record', label: 'Stamp', config: { objectName: 'crm_lead', filter: { id: '{record.id}' }, fields: { greeting: 'Hello, {record.full_name}!' } } }, + { id: 'stamp', type: 'update_record', label: 'Stamp', config: { objectName: 'crm_lead', filter: { id: '{record.id}' }, fields: { greeting: { dialect: 'cel', source: "'Hello, ' + record.full_name + '!'" } } } }, { id: 'end', type: 'end', label: 'End' }, ], edges: [ { id: 'e1', source: 'start', target: 'stamp' }, { id: 'e2', source: 'stamp', target: 'end' } ], diff --git a/packages/triggers/trigger-record-change/src/multilookup-context.test.ts b/packages/triggers/trigger-record-change/src/multilookup-context.test.ts index fe6521884a0..0bba88859ef 100644 --- a/packages/triggers/trigger-record-change/src/multilookup-context.test.ts +++ b/packages/triggers/trigger-record-change/src/multilookup-context.test.ts @@ -85,7 +85,7 @@ describe('record-change context hydrates multi-lookup from input (#1872)', () => name: 'cta_default', label: 'CTA', type: 'autolaunched', nodes: [ { id: 'start', type: 'start', label: 'Start', config: { objectName: 'piece', triggerType: 'record-after-create', condition: 'record.target_channels != null' } }, - { id: 'stamp', type: 'update_record', label: 'Stamp', config: { objectName: 'piece', filter: { id: '{record.id}' }, fields: { stamp: '{record.target_channels.0}' } } }, + { id: 'stamp', type: 'update_record', label: 'Stamp', config: { objectName: 'piece', filter: { id: '{record.id}' }, fields: { stamp: { dialect: 'cel', source: 'record.target_channels[0]' } } } }, { id: 'end', type: 'end', label: 'End' }, ], edges: [ { id: 'e1', source: 'start', target: 'stamp' }, { id: 'e2', source: 'stamp', target: 'end' } ], @@ -96,7 +96,8 @@ describe('record-change context hydrates multi-lookup from input (#1872)', () => await sleep(200); const row = await data.findOne('piece', { where: { id } }); console.log('[dbg] row=', JSON.stringify(row)); - // Flow fired (condition saw target_channels) AND `{record.target_channels.0}` resolved. + // Flow fired (condition saw target_channels) AND `record.target_channels[0]` resolved + // (a CEL value envelope — the `{…}` template dialect is retired from value slots, #19939). expect(row?.stamp).toBe('ch_1'); }, 15000); }); diff --git a/packages/triggers/trigger-record-change/src/record-change-integration.test.ts b/packages/triggers/trigger-record-change/src/record-change-integration.test.ts index 3a6c88aa779..98ce0a054c3 100644 --- a/packages/triggers/trigger-record-change/src/record-change-integration.test.ts +++ b/packages/triggers/trigger-record-change/src/record-change-integration.test.ts @@ -303,7 +303,7 @@ function mirrorWriteFlow(name: string, object: string) { type: 'record_change', nodes: [ { id: 'start', type: 'start', label: 'Start', config: { objectName: object, triggerType: 'record-after-write' } }, - { id: 'mirror', type: 'update_record', label: 'Mirror', config: { objectName: object, filter: { id: '{record.id}' }, fields: { mirror: '{record.status}' } } }, + { id: 'mirror', type: 'update_record', label: 'Mirror', config: { objectName: object, filter: { id: '{record.id}' }, fields: { mirror: { dialect: 'cel', source: 'record.status' } } } }, { id: 'end', type: 'end', label: 'End' }, ], edges: [ @@ -682,7 +682,9 @@ describe('record-change trigger — end-to-end (#1491)', () => { }, { id: 'log', type: 'create_record', label: 'Log', - config: { objectName: 'bfw_audit', fields: { seen_tag: '{record.tag}' } }, + // A CEL value envelope — the `{…}` template dialect is retired from + // value slots (#19939). + config: { objectName: 'bfw_audit', fields: { seen_tag: { dialect: 'cel', source: 'record.tag' } } }, }, { id: 'end', type: 'end', label: 'End' }, ], From c03d771894e00727a8fd219a8c334f09baaf63d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 07:22:44 +0000 Subject: [PATCH 11/14] chore(spec): regenerate api-surface, export-origins and reference docs after merging main (#19939) Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude --- .../automation/builtin-node-config.mdx | 19 +++++++++++-------- packages/spec/api-surface/automation.json | 8 +++++++- packages/spec/export-origins/automation.json | 8 +++++++- 3 files changed, 25 insertions(+), 10 deletions(-) diff --git a/content/docs/references/automation/builtin-node-config.mdx b/content/docs/references/automation/builtin-node-config.mdx index 705cfd80431..bb7662cd8a2 100644 --- a/content/docs/references/automation/builtin-node-config.mdx +++ b/content/docs/references/automation/builtin-node-config.mdx @@ -38,7 +38,9 @@ its schema before running (`service-automation`'s `parse-config.ts`), so type and `required` violations refuse the node as a guard. All of these 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. +executor's existing interpolation points. The value slots are the exception +since #19939: the `{token}` dialect is retired there (the "value slots" +section below). ## Unknown keys — closed here too, as of #4001 批 9 @@ -72,8 +74,9 @@ this contract describe the same surface from the two sides. The `create_record` / `update_record` `fields` map carries the same value contract since #19938 (`FlowValueSlotSchema`, the "value slots" section): -a field value may be a CEL value envelope beside a `{token}` template or a -literal, and the three maps are the expression ledger's `value`-role slots. +a field value is a CEL value envelope or a literal — a `{token}` template is +refused there since #19939 — and the three maps are the expression ledger's +`value`-role slots. Deliberately absent: - `decision` / `script` / `subflow` / `wait` / `connector_action` — the @@ -107,7 +110,7 @@ const result = AssignmentConfigSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **assignments** | `Record` | optional | Variables to set: each key is a variable name, each value a `{token}` template, a CEL value envelope, or a literal | +| **assignments** | `Record` | optional | Variables to set: each key is a variable name, each value a CEL value envelope or a literal | --- @@ -130,7 +133,7 @@ CEL value envelope `{ dialect: 'cel', source }` — evaluated by the expression ## AssignmentValue -Value the variable takes: a string (`{token}` flow interpolation — a sole token keeps its type), a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expression engine (the CEL stdlib such as `joinNonEmpty` is reachable), or any other literal +Value the variable takes: a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expression engine (the CEL stdlib such as `joinNonEmpty` is reachable), or a literal written as it is — a `{…}` template token in a string is refused (the template dialect is retired from value slots; the date macros and `$User` paths are kept for now) --- @@ -142,7 +145,7 @@ Value the variable takes: a string (`{token}` flow interpolation — a sole toke | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **objectName** | `string` | ✅ | Object to insert into | -| **fields** | `Record` | optional | Field values to write on the new record: each key is a field name, each value a `{token}` template, a CEL value envelope, or a literal | +| **fields** | `Record` | optional | Field values to write on the new record: each key is a field name, each value a CEL value envelope or a literal | | **outputVariable** | `string` | optional | Flow variable bound to the created record | @@ -175,7 +178,7 @@ Value the variable takes: a string (`{token}` flow interpolation — a sole toke ## FlowValueSlot -A value: a string (`{token}` flow interpolation — a sole token keeps its type), a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expression engine (the CEL stdlib such as `joinNonEmpty` is reachable), or any other literal +A value: a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expression engine (the CEL stdlib such as `joinNonEmpty` is reachable), or a literal written as it is — a `{…}` template token in a string is refused (the template dialect is retired from value slots; the date macros and `$User` paths are kept for now) --- @@ -285,7 +288,7 @@ A value: a string (`{token}` flow interpolation — a sole token keeps its type) | :--- | :--- | :--- | :--- | | **objectName** | `string` | ✅ | Object to update | | **filter** | `Record` | optional | Field/value pairs identifying the record(s) to update | -| **fields** | `Record` | optional | Field values to write: each key is a field name, each value a `{token}` template, a CEL value envelope, or a literal | +| **fields** | `Record` | optional | Field values to write: each key is a field name, each value a CEL value envelope or a literal | | **multi** | `boolean` | optional | Declare bulk intent: update every row the filter matches (default false — a predicate update without it is refused by the engine) | diff --git a/packages/spec/api-surface/automation.json b/packages/spec/api-surface/automation.json index 167e4963355..9c3731702f2 100644 --- a/packages/spec/api-surface/automation.json +++ b/packages/spec/api-surface/automation.json @@ -143,6 +143,7 @@ "FlowNodeExpressionRole (type)", "FlowNodeParsed (type)", "FlowNodeSchema (const)", + "FlowNodeValueTemplateRefusal (interface)", "FlowParsed (type)", "FlowRegion (type)", "FlowRegionArity (type)", @@ -248,6 +249,9 @@ "UpdateRecordConfigParsed (type)", "UpdateRecordConfigSchema (const)", "VALUE_ENVELOPE_REFUSAL (const)", + "VALUE_SLOT_TEMPLATE_REFUSAL (const)", + "ValueSlotTemplateOptions (interface)", + "ValueSlotTemplateRefusal (interface)", "WAIT_EXECUTOR_DESCRIPTOR (const)", "WaitEventType (type)", "WaitEventTypeSchema (const)", @@ -275,6 +279,7 @@ "findRegionEntry (function)", "flowForm (const)", "flowNodeConfigRefusals (function)", + "flowNodeValueTemplateRefusals (function)", "getApprovalNodeConfigJsonSchema (function)", "getBuiltinNodeConfigContracts (function)", "getSchemalessNodeConfigJsonSchemas (function)", @@ -290,6 +295,7 @@ "resolveFlowTriggerKind (function)", "resolveScheduleOrganization (function)", "structuralConditionRefusal (function)", - "validateControlFlow (function)" + "validateControlFlow (function)", + "valueSlotTemplateRefusals (function)" ] } diff --git a/packages/spec/export-origins/automation.json b/packages/spec/export-origins/automation.json index 1e4a793040a..f02aea90b14 100644 --- a/packages/spec/export-origins/automation.json +++ b/packages/spec/export-origins/automation.json @@ -138,6 +138,7 @@ "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)", + "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)", "FlowRegionArity": "src/automation/region-slots.ts#FlowRegionArity (type)", @@ -243,6 +244,9 @@ "UpdateRecordConfigParsed": "src/automation/builtin-node-config.zod.ts#UpdateRecordConfigParsed (type)", "UpdateRecordConfigSchema": "src/automation/builtin-node-config.zod.ts#UpdateRecordConfigSchema (const)", "VALUE_ENVELOPE_REFUSAL": "src/automation/builtin-node-config.zod.ts#VALUE_ENVELOPE_REFUSAL (const)", + "VALUE_SLOT_TEMPLATE_REFUSAL": "src/automation/flow-value-slot-template.ts#VALUE_SLOT_TEMPLATE_REFUSAL (const)", + "ValueSlotTemplateOptions": "src/automation/flow-value-slot-template.ts#ValueSlotTemplateOptions (interface)", + "ValueSlotTemplateRefusal": "src/automation/flow-value-slot-template.ts#ValueSlotTemplateRefusal (interface)", "WAIT_EXECUTOR_DESCRIPTOR": "src/automation/node-executor.zod.ts#WAIT_EXECUTOR_DESCRIPTOR (const)", "WaitEventType": "src/automation/node-executor.zod.ts#WaitEventType (type)", "WaitEventTypeSchema": "src/automation/node-executor.zod.ts#WaitEventTypeSchema (const)", @@ -269,6 +273,7 @@ "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)", + "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)", "getSchemalessNodeConfigJsonSchemas": "src/automation/schemaless-node-config.zod.ts#getSchemalessNodeConfigJsonSchemas (function)", @@ -284,6 +289,7 @@ "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)", - "validateControlFlow": "src/automation/control-flow.zod.ts#validateControlFlow (function)" + "validateControlFlow": "src/automation/control-flow.zod.ts#validateControlFlow (function)", + "valueSlotTemplateRefusals": "src/automation/flow-value-slot-template.ts#valueSlotTemplateRefusals (function)" } } From 0d7eb274c135344230d147ff62ccae2aa0b851c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 07:49:17 +0000 Subject: [PATCH 12/14] wip(example-todo): the pre-fix recurrence shape is refused at registration now (#19939) Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude --- .../app-todo/test/task-recurrence.test.ts | 97 +++++++++---------- 1 file changed, 46 insertions(+), 51 deletions(-) diff --git a/examples/app-todo/test/task-recurrence.test.ts b/examples/app-todo/test/task-recurrence.test.ts index e15e0dfe545..677a9547ff9 100644 --- a/examples/app-todo/test/task-recurrence.test.ts +++ b/examples/app-todo/test/task-recurrence.test.ts @@ -29,10 +29,16 @@ * to be computed BEFORE the create node, which is what a registered function * invoked by a `script` node is for (#1870, pure per #4396). * + * (Both readings above are history, kept for the reasoning: since #19938 a + * `fields` value may be a CEL value envelope, which IS evaluated, and since + * #19939 a value slot no longer reads the `{…}` template dialect at all — the + * pre-fix `DATEADD({…})` shape is now refused at registration, before any + * driver sees it. The script stays: a recurrence rule is a function's job.) + * * The suite drives the app's REAL metadata (`allFlows`, the real `Task` object, * the real `todoFunctions` registry) through a real kernel over sqlite, so it * fails if any link is re-broken: the function name, the node wiring, the - * arithmetic, or the interpolation shape of the `due_date` slot. + * arithmetic, or the shape of the `due_date` slot. */ import { describe, it, expect, afterEach } from 'vitest'; @@ -40,6 +46,7 @@ import { ObjectKernel } from '@objectstack/core'; import { ObjectQLPlugin } from '@objectstack/objectql'; import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { AutomationServicePlugin, type AutomationEngine } from '@objectstack/service-automation'; +import { VALUE_SLOT_TEMPLATE_REFUSAL } from '@objectstack/spec/automation'; import { RecordChangeTriggerPlugin } from '@objectstack/trigger-record-change'; import { allFlows, TaskCompletionFlow } from '../src/flows/index.js'; @@ -262,12 +269,12 @@ describe('#7037 — the recurrence branch computes a real next due date', () => expect(script.config.outputVariable).toBe('nextDueDate'); }); - it('create_next_task reads the computed variable as a WHOLE-STRING token', () => { - // A whole-string `{token}` is the one form `interpolateString` returns the - // raw value for. Any surrounding text turns the slot back into - // text-with-holes — which is precisely the shape that shipped `DATEADD`. + it('create_next_task reads the computed variable as a CEL path — the raw value, type kept', () => { + // [#19939] A value slot computes with a CEL value envelope; a bare path + // hands the create the RAW value the script returned. Text around it + // would be a concatenation — the shape that shipped `DATEADD`. const fields = node('create_next_task').config.fields; - expect(fields.due_date).toBe('{nextDueDate}'); + expect(fields.due_date).toEqual({ dialect: 'cel', source: 'nextDueDate' }); }); it('the recurring branch runs the computation BEFORE the create', () => { @@ -291,10 +298,12 @@ describe('#7037 — the recurrence branch computes a real next due date', () => }); it('GUARD: no write node in any app flow puts function-call text in a field value', () => { - // The class, not the instance. `fields` values are interpolated, never + // The class, not the instance. A STRING `fields` value is a literal, never // evaluated, so ANY `NAME(...)` text in one reaches the driver verbatim — - // whatever the invented function is called. This is the check that would - // have caught the original defect at authoring time. + // whatever the invented function is called. (A computed value is a CEL + // envelope, an object, which this guard leaves to the envelope's own CEL + // check.) This is the check that would have caught the original defect + // at authoring time. const callShaped = /^[A-Za-z_][A-Za-z0-9_]*\s*\(/; for (const flow of allFlows) { for (const n of (flow.nodes ?? []) as any[]) { @@ -304,7 +313,7 @@ describe('#7037 — the recurrence branch computes a real next due date', () => expect( callShaped.test(value.trim()), `flow '${flow.name}' node '${n.id}' field '${field}' = ${value} — ` + - `a create/update field value is template-interpolated, not evaluated`, + `a string create/update field value is a literal, not evaluated`, ).toBe(false); } } @@ -369,19 +378,23 @@ describe('#7037 — the recurrence branch computes a real next due date', () => expect((Array.isArray(all) ? all : []).length).toBe(1); }, 30000); - it('REVERSE: the pre-fix shape — a function call left in the field value — is refused by the driver', async () => { + it('REVERSE: the pre-fix shape — a function call left in the field value — is refused at registration', async () => { // The pre-#7037 defect, rebuilt from the REAL flow so it cannot drift: the // computation node removed, the recurring edge pointed straight at the // create, and `due_date` written as text-with-holes wrapping a call to a // function that does not exist. // // The invented name is deliberately NOT the historical one. What failed - // was never specific to `DATEADD`: a `create_record` field value is - // interpolated and passed through verbatim, so ANY function-call text - // reaches the driver as a literal string. Pinning the class keeps the - // dead name out of the repo entirely, which is the other half of this - // card — an invented function name in a shipped example is exactly the - // shape an AI author copies. + // was never specific to `DATEADD`: ANY function-call text in a field + // value was a literal. Pinning the class keeps the dead name out of the + // repo entirely, which is the other half of this card — an invented + // function name in a shipped example is exactly the shape an AI author + // copies. + // + // [#19939] Before, this shape registered and the DRIVER refused the write + // at run time (`Due Date must be a valid date`). The `{…}` tokens are the + // retired template dialect in a value slot now, so it never registers — + // and the refusal names the CEL spelling instead. const { automation, data } = await bootTodoKernel(); const ctx = { context: { userId: 'u_todo' } }; @@ -393,52 +406,34 @@ describe('#7037 — the recurrence branch computes a real next due date', () => 'create_next_task'; broken.nodes.find((n: any) => n.id === 'create_next_task').config.fields.due_date = 'SHIFT_DATE({completedTask.due_date}, {completedTask.recurrence_interval}, "{completedTask.recurrence_type}")'; - // The fixture is a manual flow: it must not race the real one on the same - // record-change hook. delete broken.nodes.find((n: any) => n.type === 'start').config.triggerType; broken.type = 'autolaunched'; - automation.registerFlow(broken.name, broken); - - // [#14147] Unlike the cases above, this fixture must START from an - // already-completed row — there is no transition to stamp on, and the - // object's `completed_date_required` rule refuses a completed task with a - // blank `completed_date`. Seeding a server-owned column at create time is - // a SYSTEM act (maintainer ruling, 2026-09-03: `context.isSystem`, - // `runAs: 'system'`, a system hook or a seed), so this one write declares - // itself trusted. Nothing else about the case changes: the subject is - // still what the BROKEN flow fixture does with an uncomputed date, and - // the assertions below are untouched. + let refusal = ''; + try { + automation.registerFlow(broken.name, broken); + } catch (err) { + refusal = (err as Error).message; + } + expect(refusal, 'the pre-fix shape must not register').toContain(VALUE_SLOT_TEMPLATE_REFUSAL); + expect(refusal).toContain("node 'create_next_task' (create_record) create_record field value at config.fields.due_date"); + expect(refusal).not.toMatch(/no function named/); + + // [#14147] The real flow needs an already-completed row to start from — + // seeding a server-owned column at create time is a SYSTEM act + // (maintainer ruling, 2026-09-03), so this one write declares itself + // trusted. const created = await data.insert('todo_task', { subject: 'Reverse fixture task', status: 'completed', priority: 'normal', owner: 'u_todo', due_date: '2026-08-10', is_recurring: true, recurrence_type: 'daily', recurrence_interval: 1, completed_date: '2026-08-09T10:00:00.000Z', }, { context: { userId: 'u_todo', isSystem: true } }); const id = Array.isArray(created) ? created[0].id : created.id; - - // Driven directly rather than through the record-change hook, so the - // context has to supply what the hook would: the triggering record AND the - // `previous` row the start predicate transitions from. Without `previous` - // the CEL gate aborts with `No such key: status` — the same totality trap - // #6882 documented on the insert leg — and the run would fail for a reason - // that has nothing to do with this card. const record = { ...(Array.isArray(created) ? created[0] : created), id }; const trigger = { record, previous: { ...record, status: 'in_progress' }, userId: 'u_todo' }; - const result: any = await automation.execute(broken.name, trigger); - - // The direction that matters: the run FAILS, and it fails inside the - // create with the field's own date refusal — NOT with "no function named - // 'SHIFT_DATE' is registered", because nothing ever tried to CALL one. - // The value was never a call; it was always just text. - expect(result?.success, `unexpected result: ${JSON.stringify(result)}`).toBe(false); - const text = String(result?.error ?? '') + JSON.stringify(result?.output ?? null); - expect(text).toContain('create_next_task'); - expect(text).toMatch(/valid date|ISO-8601|invalid_date/); - expect(text).not.toMatch(/no function named/); - // Non-vacuous: the REAL flow, on the very same kernel and record, computes - // the date and completes. So the failure above is the missing computation, - // not a broken fixture or an unusable harness. + // the date and completes. So the refusal above is the retired dialect, not + // a broken fixture or an unusable harness. const good: any = await automation.execute('task_completion', trigger); expect(good?.success, `unexpected result: ${JSON.stringify(good)}`).toBe(true); const spawned = (await data.find('todo_task', { where: { subject: 'Reverse fixture task' }, ...ctx })) From 31c52e59c17b446f76b1dfdce222eb06b9cf811d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 08:00:13 +0000 Subject: [PATCH 13/14] wip(service-automation): type the kept-spellings test output (#19939) Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude --- .../service-automation/src/builtin/logic-nodes.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/services/service-automation/src/builtin/logic-nodes.test.ts b/packages/services/service-automation/src/builtin/logic-nodes.test.ts index a90c0f1097c..9402bb325af 100644 --- a/packages/services/service-automation/src/builtin/logic-nodes.test.ts +++ b/packages/services/service-automation/src/builtin/logic-nodes.test.ts @@ -120,7 +120,8 @@ describe('assignment node — config-shape parity (Studio + examples)', () => { engine.registerFlow('assign_flow', assignmentFlow({ assignments: { day: '{TODAY()}', who: '{$User.Id}' } }, ['day', 'who'])); const result = await engine.execute('assign_flow', { userId: 'usr_1' } as any); expect(result.success).toBe(true); - expect(result.output!.who).toBe('usr_1'); - expect(String(result.output!.day)).toMatch(/^\d{4}-\d{2}-\d{2}$/); + const output = result.output as Record; + expect(output.who).toBe('usr_1'); + expect(String(output.day)).toMatch(/^\d{4}-\d{2}-\d{2}$/); }); }); From b073d92ded5132d710a1c952b858e9308854dfda Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 08:57:24 +0000 Subject: [PATCH 14/14] =?UTF-8?q?chore(changeset):=20grade=20the=20value-s?= =?UTF-8?q?lot=20template=20retirement=20major=20=E2=80=94=20the=20v18=20p?= =?UTF-8?q?re=20line=20is=20open=20on=20main=20(#19939)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude --- .../19939-flow-value-slot-template-dialect-refused.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.changeset/19939-flow-value-slot-template-dialect-refused.md b/.changeset/19939-flow-value-slot-template-dialect-refused.md index 4fd97048414..55120dc3ac8 100644 --- a/.changeset/19939-flow-value-slot-template-dialect-refused.md +++ b/.changeset/19939-flow-value-slot-template-dialect-refused.md @@ -1,7 +1,7 @@ --- -'@objectstack/spec': minor -'@objectstack/service-automation': minor -'@objectstack/lint': minor +'@objectstack/spec': major +'@objectstack/service-automation': major +'@objectstack/lint': major --- A flow VALUE slot no longer reads the single-brace `{…}` template dialect: a `create_record` / `update_record` `fields` value or an `assignment` value that carries a `{…}` token is refused at `objectstack validate`, at `registerFlow` and by the executor, with the CEL spelling of each token. A computed value is a CEL value envelope, `{ dialect: 'cel', source: '…' }`; a string is the literal text it spells. @@ -10,7 +10,7 @@ Clause-②: yes (narrowing) -**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings (the v18 line's `.changeset/pre.json` is not open on `main`). +**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.** Since #19938 the value slots also evaluate a CEL value envelope, so a flow had two dialects for one job — two function sets, two meanings of `/`, and a template that wrote nothing where CEL refuses. The maintainer's ruling D on the flow expression dialects put the template's retirement from these slots on the v18 train, with a remedy for every spelling and an automatic conversion only where one is lossless (ADR-0087 D2).