From 96cab659dc290a2263d5b69bceb8cb7c43cdbad1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 07:47:54 +0000 Subject: [PATCH 1/2] fix(automation): a bare-string flow condition is evaluated as CEL, not string-compared (#4336) `evaluateCondition` picked its engine by asking whether an `{ dialect, source }` envelope was present. A condition authored as a plain string therefore never reached the CEL engine: it fell through to the legacy `{var}` template path, which substitutes brace holes and then compares the leftover text AS TEXT. Nothing errored and the run was recorded as `success`, with the failure direction depending on the predicate: 'existingTask == null' -> 'existingTask' === 'null' -> always false 'record.rating >= 4' -> 'record.rating' >= '4' -> always true One gate that never opens, one branch pinned open. Author-side discipline could not fix the second half of this: `FlowNodeSchema.config` is an open `z.record`, so no schema transform reaches a `decision` node's `conditions[].expression`, which is exactly where bare-string conditions still live after `registerFlow` parses edges into envelopes. The dialect is now decided by the SOURCE, not by the envelope: a condition is CEL unless it actually contains a `{var}` hole. The same predicate then evaluates the same way wherever it is authored -- edge, start-node gate, or decision node. The `{var}` dialect keeps working where it always did, and gains the two things it was missing: * a quoted literal compares as its contents. `{status} == 'active'` used to compare `active` against `'active'` -- quotes included -- and was false for every value. It is the spelling the flow docs show. * it no longer answers `false` when it could not resolve something. A hole naming no variable (`{lead_record.status}` -- `get_record` stores the whole row under one name, so that key never exists) and a substituted value that is neither boolean, numeric, nor part of a comparison are refused with the source and the offending reference attached, per ADR-0032 1c. Braces inside an explicit `dialect: 'cel'` envelope remain the #1491 brace-trap and still throw: stating the dialect is the author saying "this is CEL". The sniff reads the source outside string literals, so `record.label == '{pending}'` stays CEL and compares the field. Also updated: app-crm's convert-lead guard (the real instance of the unresolvable brace spelling -- its sibling EDGE already carried the bare-CEL form), the flow docs' three-dialect table and its now-inverted "braces missing in a decision expression" warning, and the `FlowNodeSchema` decision example. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011n4UBkyRZsy6CJqmKg6oA5 --- .../flow-condition-bare-string-is-cel.md | 54 ++++++ content/docs/automation/flows.mdx | 39 ++-- .../app-crm/src/flows/convert-lead.flow.ts | 7 +- .../service-automation/src/engine.test.ts | 113 ++++++++++- .../services/service-automation/src/engine.ts | 182 ++++++++++++++++-- .../src/nested-region-parity.test.ts | 22 ++- packages/spec/src/automation/flow.zod.ts | 3 +- 7 files changed, 380 insertions(+), 40 deletions(-) create mode 100644 .changeset/flow-condition-bare-string-is-cel.md diff --git a/.changeset/flow-condition-bare-string-is-cel.md b/.changeset/flow-condition-bare-string-is-cel.md new file mode 100644 index 0000000000..fd90ff0aae --- /dev/null +++ b/.changeset/flow-condition-bare-string-is-cel.md @@ -0,0 +1,54 @@ +--- +"@objectstack/service-automation": minor +--- + +fix(automation): a flow condition authored as a plain string is evaluated as CEL, not string-compared (#4336) + +`evaluateCondition` decided which engine to use by asking whether an +`{ dialect, source }` **envelope** was present. A condition authored as a plain +string therefore never reached the CEL engine: it fell through to the legacy +`{var}` template path, which substitutes brace holes and then compares whatever +text is left — **as text**. + +Nothing errored, and the run was recorded as `success`. The failure direction +depended on the predicate, which is what made it dangerous: + +| Authored | Actually evaluated | Result | +|:---|:---|:---| +| `existingTask == null` | `'existingTask' === 'null'` | always **false** — gate never opens | +| `record.rating >= 4` | `'record.rating' >= '4'` → `'r' > '4'` | always **true** — branch pinned open | + +The dialect is now decided by the **source**, not by the envelope: a condition +is CEL unless it actually contains a `{var}` hole. The same predicate then +evaluates the same way wherever it is authored — an edge `condition` (which +`FlowEdgeSchema` parses into an envelope), a start-node gate, or a `decision` +node's `config.conditions[].expression`, which no schema normalizes because +`FlowNodeSchema.config` is an open `z.record` no transform can reach. That last +one is why author-side discipline could not fix this. + +**What changes for authors** + +- **Decision-node expressions are CEL.** `conditions: [{ expression: 'amount > + 10000' }]` now compares the amount. Field access on an object variable works: + `get_record` stores the whole row under its `outputVariable`, so + `lead_record.status == 'converted'` resolves — the `{lead_record.status}` + spelling never could, because substitution looks for a variable *named* + `lead_record.status`. +- **The `{var}` dialect still works** where it always did — `{amount} > 100`, + `{status} == active` — and now also with a **quoted** literal: + `{status} == 'active'` used to compare `active` against `'active'`, quotes + included, and was false for every value. +- **It no longer answers `false` when it cannot resolve something.** A `{…}` + hole that names no variable, and a substituted value that is neither a + boolean, a number, nor part of a comparison, are refused with the source and + the offending reference attached (ADR-0032 §1c: a predicate that cannot be + evaluated is a fault, never a quiet branch decision). Both used to be a silent + `false`. +- **A condition that is not valid CEL now raises**, where a bare string + previously string-compared to some answer. This is the intended tightening — + it surfaces as a loud flow failure naming the source, which `registerFlow` and + `objectstack build` already do for edge and start-node conditions. + +Braces inside an explicit `dialect: 'cel'` envelope remain the #1491 brace-trap +and still throw: stating the dialect is the author saying "this is CEL", where +`{…}` is a map literal. The sniff applies only where no dialect was stated. diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index 0db5623b40..f12f50532d 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -169,8 +169,8 @@ or missing-`required` violation (#4277). A node type that publishes no label: 'Check Status', config: { conditions: [ - { label: 'Approved', expression: "{status} == 'approved'" }, - { label: 'Rejected', expression: "{status} == 'rejected'" }, + { label: 'Approved', expression: "status == 'approved'" }, + { label: 'Rejected', expression: "status == 'rejected'" }, ], }, } @@ -918,30 +918,39 @@ failures so one broken flow does not abort startup. ## Expressions in flows -A flow mixes **three expression dialects**, and using the wrong one is the -single most common way a flow silently misbehaves. Which dialect applies is -decided by *where* the expression sits — not by what it looks like: +A flow mixes **two expression dialects**, and the rule is short: **every +condition is CEL; braces are for values.** | 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` | **Template compare** (braces required) | `{order_amount} > 10000` | flow variables by name, in `{…}` | +| Decision-node `conditions[].expression` | **CEL** (bare, no braces) | `order_amount > 10000` | same as above | | 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) | -**The two failure modes to memorize:** +**The failure modes to memorize:** -1. **Braces missing in a decision expression** — `'order_amount > 10000'` isn't - evaluated as a variable at all. It compares the *string* `"order_amount"` - against `"10000"`, which is **always true**, so the flow always takes the - first branch and never tells you. Write `'{order_amount} > 10000'`. -2. **Braces missing in a field value** — `due_date: 'TODAY() + 7'` writes the +1. **Braces missing in a field value** — `due_date: 'TODAY() + 7'` writes the literal text `TODAY() + 7` into the field. Write `'{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. + -The mirror mistake is putting braces *into* a CEL condition -(`'{record.amount} > 500'`) — CEL conditions fail loudly rather than silently, -with an error that tells you to drop the braces. + +**Decision-node expressions changed (#4336).** They used to be compared as +*text* rather than evaluated, so `'order_amount > 10000'` compared the string +`"order_amount"` against `"10000"` and was **always true**, while +`'{lead_record.status} == "converted"'` was **always false** — the brace form +substitutes a whole flow variable by name, and a field access on an object +variable is not one. Both reported `success`. + +A decision expression is now CEL like every other condition, so the bare +spelling is the correct one and `lead_record.status == 'converted'` resolves the +field. The `{var}` form still works where it always did — `{amount} > 100`, +`{status} == 'active'` — but a `{…}` hole that resolves to nothing is now a +**loud error** naming the reference, instead of a quietly wrong branch. CEL conditions that fail to evaluate raise an error and stop the run — they diff --git a/examples/app-crm/src/flows/convert-lead.flow.ts b/examples/app-crm/src/flows/convert-lead.flow.ts index bc90e584cb..5c6b8c67e4 100644 --- a/examples/app-crm/src/flows/convert-lead.flow.ts +++ b/examples/app-crm/src/flows/convert-lead.flow.ts @@ -72,8 +72,13 @@ export const ConvertLeadScreenFlow = defineFlow({ type: 'decision', label: 'Already Converted?', config: { + // Bare CEL, no braces (#4336). `get_record` stores the WHOLE row under + // `outputVariable`, so `lead_record` is an object and the status is a + // field access on it — something CEL resolves and `{var}` substitution + // cannot: it looks for a variable literally named `lead_record.status`, + // finds none, and the guard was silently false for every lead. conditions: [ - { label: 'Yes — already converted', expression: "{lead_record.status} == 'converted'" }, + { label: 'Yes — already converted', expression: "lead_record.status == 'converted'" }, { label: 'No — proceed', expression: 'true' }, ], }, diff --git a/packages/services/service-automation/src/engine.test.ts b/packages/services/service-automation/src/engine.test.ts index f3c5cf9615..d560ca1b8a 100644 --- a/packages/services/service-automation/src/engine.test.ts +++ b/packages/services/service-automation/src/engine.test.ts @@ -1905,10 +1905,15 @@ describe('AutomationEngine - Safe Expression Evaluation', () => { it('should not execute malicious code', () => { const vars = new Map(); - // These should all return false safely - expect(engine.evaluateCondition('process.exit(1)', vars)).toBe(false); - expect(engine.evaluateCondition('require("fs").readFileSync("/etc/passwd")', vars)).toBe(false); - expect(engine.evaluateCondition('(() => { while(true) {} })()', vars)).toBe(false); + // None of these is a host-language program to this engine — there is no + // `new Function`, no `eval`, no `require` on either path. They REFUSE + // rather than return `false` since #4336: a brace-free condition is CEL, + // and CEL has no `process`, no `require`, and no arrow-function syntax, + // so each one is a fault the run reports. The safety property is + // unchanged (nothing executes) and the diagnosis is no longer silent. + expect(() => engine.evaluateCondition('process.exit(1)', vars)).toThrow(/exit/); + expect(() => engine.evaluateCondition('require("fs").readFileSync("/etc/passwd")', vars)).toThrow(/require/); + expect(() => engine.evaluateCondition('(() => { while(true) {} })()', vars)).toThrow(/source:/); }); it('should handle string comparisons', () => { @@ -1917,6 +1922,106 @@ describe('AutomationEngine - Safe Expression Evaluation', () => { expect(engine.evaluateCondition('{status} == active', vars)).toBe(true); expect(engine.evaluateCondition('{status} != inactive', vars)).toBe(true); + // #4336 — a QUOTED literal on the right compares as its contents. This is + // the spelling the flow docs show for a decision node, and it used to + // compare `active` against `'active'` (quotes included) and be false for + // every value of `status`. + expect(engine.evaluateCondition("{status} == 'active'", vars)).toBe(true); + expect(engine.evaluateCondition('{status} == "active"', vars)).toBe(true); + expect(engine.evaluateCondition("{status} == 'closed'", vars)).toBe(false); + expect(engine.evaluateCondition("{status} != 'closed'", vars)).toBe(true); + }); +}); + +// ─── #4336: a bare-string condition is CEL, not a string compare ───── +// +// The reported defect: `evaluateCondition` branched on whether an `Expression` +// envelope was present, so a condition authored as a plain string never reached +// the CEL engine and both sides were compared as TEXT. Every case below is one +// of the two failure directions from the issue (a gate that never opens, a +// branch pinned open) or one of the two silent `false`s found afterwards. +describe('AutomationEngine - bare-string conditions evaluate as CEL (#4336)', () => { + let engine: AutomationEngine; + beforeEach(() => { engine = new AutomationEngine(createTestLogger()); }); + + it('opens a null-check gate that used to be pinned shut', () => { + // `'existingTask' === 'null'` → false, forever. The flow selected its + // records, took no branch, and recorded `success`. + expect(engine.evaluateCondition('existingTask == null', new Map([['existingTask', null]]))).toBe(true); + expect(engine.evaluateCondition('existingTask == null', new Map([['existingTask', { id: 'a' }]]))).toBe(false); + }); + + it('gates a numeric comparison that used to be pinned open', () => { + // `'record.rating' >= '4'` → `'r' > '4'` → true for every record. + const low = new Map([['record', { rating: 2 }]]); + const high = new Map([['record', { rating: 5 }]]); + expect(engine.evaluateCondition('record.rating >= 4', high)).toBe(true); + expect(engine.evaluateCondition('record.rating >= 4', low)).toBe(false); + }); + + it('evaluates a bare truthy gate instead of answering false', () => { + // No comparison operator at all: the template path fell through to + // `Number('record.isActive')` → NaN → `false`. + expect(engine.evaluateCondition('record.isActive', new Map([['record', { isActive: true }]]))).toBe(true); + expect(engine.evaluateCondition('record.isActive', new Map([['record', { isActive: false }]]))).toBe(false); + }); + + it('resolves field access on an object variable — the get_record output shape', () => { + // `get_record`'s `outputVariable` stores the WHOLE record under one name, + // which is why the `{lead_record.status}` spelling can never resolve. + const vars = new Map([['lead_record', { status: 'converted' }]]); + expect(engine.evaluateCondition("lead_record.status == 'converted'", vars)).toBe(true); + expect(engine.evaluateCondition("lead_record.status == 'new'", vars)).toBe(false); + }); + + it('refuses a brace-wrapped reference that resolves to nothing, naming it', () => { + const vars = new Map([['lead_record', { status: 'converted' }]]); + // Silently false today even though the status IS 'converted'. + expect(() => engine.evaluateCondition("{lead_record.status} == 'converted'", vars)) + .toThrow(/`\{lead_record\.status\}` did not resolve/); + expect(() => engine.evaluateCondition("{lead_record.status} == 'converted'", vars)) + .toThrow(/Drop the braces/); + // Same for a brace-wrapped truthy gate. + expect(() => engine.evaluateCondition('{record.isActive}', new Map([['record', { isActive: true }]]))) + .toThrow(/did not resolve/); + }); + + it('refuses a template-dialect condition it cannot turn into a predicate', () => { + // `{status}` substitutes to `open` — not a boolean, not a number, and no + // operator to compare it with. That used to be `false`. + expect(() => engine.evaluateCondition('{status}', new Map([['status', 'open']]))) + .toThrow(/is not a predicate/); + // A boolean or numeric value still reads as a gate. + expect(engine.evaluateCondition('{flag}', new Map([['flag', true]]))).toBe(true); + expect(engine.evaluateCondition('{flag}', new Map([['flag', false]]))).toBe(false); + expect(engine.evaluateCondition('{count}', new Map([['count', 3]]))).toBe(true); + expect(engine.evaluateCondition('{count}', new Map([['count', 0]]))).toBe(false); + }); + + it('does not mistake braces inside a string literal for a template hole', () => { + // `'{pending}'` is text the predicate compares AGAINST, not a reference to + // substitute. The dialect sniff reads the source outside string literals, + // so this stays CEL and compares the field. + expect(engine.evaluateCondition("record.label == '{pending}'", + new Map([['record', { label: '{pending}' }]]))).toBe(true); + expect(engine.evaluateCondition("record.label == '{pending}'", + new Map([['record', { label: 'pending' }]]))).toBe(false); + }); + + it('keeps braces inside an explicit CEL envelope a hard error', () => { + // The dialect sniff applies only where no dialect was stated. `dialect: + // 'cel'` is the author saying "this is CEL", where `{…}` is a map literal + // and the #1491 brace-trap. + expect(() => engine.evaluateCondition({ dialect: 'cel', source: '{record.rating} >= 4' }, + new Map([['record', { rating: 5 }]]))).toThrow(/source:/); + }); + + it('treats an absent or empty condition as no branch, not as a fault', () => { + // A `decision` entry with no `expression` is the one caller that does not + // pre-check; an unauthored branch must not open, and must not throw either. + expect(engine.evaluateCondition('', new Map())).toBe(false); + expect(engine.evaluateCondition(' ', new Map())).toBe(false); + expect(engine.evaluateCondition(undefined as unknown as string, new Map())).toBe(false); }); }); diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index 7f805c97d8..69f6489f41 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -41,6 +41,52 @@ import { ExpressionEngine, validateExpression, nearestName } from '@objectstack/ */ const UNRESOLVED_CEL_REFERENCE = /^[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)+$/; +/** + * A legacy single-brace **template hole** — `{amount}`, `{get_lead.id}`. This is + * the exact shape `{var}` substitution can consume (it splits on the literal + * `{}` text for each variable key), which is what makes it a sound dialect + * discriminator in {@link AutomationEngine.evaluateCondition}: a condition + * containing one was written in the template dialect, and a condition containing + * none was written as CEL (#4336). + * + * No whitespace is tolerated inside the braces, deliberately — `{ amount }` + * is not a token substitution can resolve, so treating it as a hole would only + * move the failure. It is not valid CEL either (a map literal needs `key: value` + * pairs), so it lands on the CEL path and gets the brace-trap diagnostic. + */ +const TEMPLATE_HOLE = /\{[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*\}/g; + +/** A quoted string literal, anywhere in a condition. */ +const QUOTED_SEGMENT = /'[^']*'|"[^"]*"/g; + +/** A single-quoted or double-quoted string literal, whole-operand. */ +const QUOTED_LITERAL = /^'([^']*)'$|^"([^"]*)"$/; + +/** + * Every legacy `{var}` hole in `source`, ignoring any that sits **inside a + * string literal** — `record.name == '{unresolved}'` is a CEL predicate + * comparing against text that happens to contain braces, not a template. + * + * This is both the dialect discriminator and the unresolved-hole report, so the + * two can never disagree about what counts as a hole. It reads the AUTHORED + * source rather than the substituted result, so a variable whose *value* + * contains braces cannot masquerade as an unresolved reference. + */ +function templateHoles(source: string): string[] { + return source.replace(QUOTED_SEGMENT, '').match(TEMPLATE_HOLE) ?? []; +} + +/** + * Strip one layer of matching quotes from a template-dialect operand, so a + * quoted string literal compares as its contents (#4336). Anything that is not + * a whole quoted literal is returned untouched — including a value that merely + * contains a quote. + */ +function unquoteLiteral(operand: string): string { + const m = QUOTED_LITERAL.exec(operand); + return m ? (m[1] ?? m[2] ?? '') : operand; +} + /** * The slice of a descriptor's JSON-Schema `configSchema` that the undeclared-key * walk reads (#4045). Structural only — no validation semantics. @@ -3762,15 +3808,42 @@ export class AutomationEngine implements IAutomationService { } /** - * Safe expression evaluator. - * Uses simple operator-based parsing without `new Function`. - * Supports: comparisons (>, <, >=, <=, ==, !=, ===, !==), - * boolean literals (true, false), and basic arithmetic. + * Evaluate a flow condition to a boolean. + * + * ## Which dialect a condition is in + * + * A condition is **CEL** unless it is written in the legacy single-brace + * `{var}` template dialect — and that is decided by looking at the *source*, + * not at whether an envelope happens to be present (#4336). + * + * It used to be decided by the envelope, and that was the bug: only an + * `{ dialect, source }` envelope reached the CEL engine, so a condition + * authored as a plain string — the shape `ExpressionInput` accepts by design, + * and the shape every node `config` still holds, since `FlowNodeSchema.config` + * is an open `z.record` no transform can reach — fell through to the template + * path and was compared **as text**. The failure direction depended on the + * predicate, which is what made it dangerous: + * + * 'existingTask == null' → 'existingTask' === 'null' → always FALSE + * 'record.rating >= 4' → 'record.rating' >= '4' → always TRUE + * + * — one gate that never opens, one branch pinned open, both reporting + * `success`. Reading the source instead means the same predicate evaluates + * the same way wherever it is authored: an edge (parsed into an envelope by + * `FlowEdgeSchema`), a start-node gate, or a `decision` node's + * `config.conditions[].expression`, which no schema normalizes. + * + * The `{var}` dialect stays supported for the flows that use it — but it no + * longer answers `false` when it could not resolve something. Per ADR-0032 + * §1c a predicate that cannot be evaluated is a **fault**, never a quiet + * branch decision, so an unresolved hole is refused with the source attached. + * + * Braces inside a **CEL envelope** remain the #1491 brace-trap and still + * throw: an explicit `dialect: 'cel'` is the author saying "this is CEL", and + * `{…}` is a map literal there. The sniff only applies where the dialect was + * never stated. */ evaluateCondition(expression: string | { dialect?: string; source?: string; ast?: unknown }, variables: Map): boolean { - // M9.5+ wiring: route Expression envelopes through @objectstack/formula - // ExpressionEngine. CEL is the default; legacy `{var}` template syntax - // is preserved as a fallback for back-compat. const isEnvelope = typeof expression === 'object' && expression != null && 'dialect' in expression; const dialect = isEnvelope ? (expression as { dialect?: string }).dialect : undefined; const exprStr = typeof expression === 'string' ? expression : ((expression as { source?: string })?.source ?? ''); @@ -3780,9 +3853,23 @@ export class AutomationEngine implements IAutomationService { return false; } + // An absent / empty condition is not a predicate to evaluate. Callers that + // mean "unconditional" guard before calling; this is the one that does not + // (a `decision` node whose `conditions[]` entry has no `expression`), and + // an unauthored branch must not open. + if (exprStr.trim() === '') return false; + + // The dialect decision (see the doc comment). An explicit `template`/`flow` + // envelope takes the author at their word; a bare string is sniffed for a + // `{var}` hole; everything else — including an envelope with no dialect — + // is CEL. + const holes = templateHoles(exprStr); + const declaredTemplate = isEnvelope && (dialect === 'template' || dialect === 'flow'); + const useTemplateDialect = declaredTemplate || (!isEnvelope && holes.length > 0); + // CEL path — bind `vars` scope for `{step.result}` style references via // the equivalent `vars.step.result` CEL identifier path. - if (dialect === 'cel' || (isEnvelope && !dialect)) { + if (!useTemplateDialect) { try { const vars: Record = {}; for (const [key, value] of variables) { @@ -3838,11 +3925,15 @@ export class AutomationEngine implements IAutomationService { // No `try { … } catch { return false }` around this block (#4347). Nothing // in it throws — `indexOf` / `slice` / `Number` / `compareValues` are all - // total — so the catch guarded nothing, and the one thing that CAN throw - // here now is the deliberate refusal below, which a swallow-to-`false` - // would turn straight back into the silent wrong answer it exists to + // total — so the catch guarded nothing, and the things that CAN throw + // here now are the deliberate refusals below, which a swallow-to-`false` + // would turn straight back into the silent wrong answer they exist to // prevent (ADR-0032 §1c, same rule as the CEL path above). + // A hole naming nothing in the variable map is unresolvable (#4336). + // Refuse — see the helper for why `false` was the wrong answer. + this.refuseUnresolvedTemplateHole(exprStr, holes.filter(h => !variables.has(h.slice(1, -1)))); + // Boolean literals if (resolved === 'true') return true; if (resolved === 'false') return false; @@ -3863,7 +3954,57 @@ export class AutomationEngine implements IAutomationService { const numVal = Number(resolved); if (!isNaN(numVal)) return numVal !== 0; - return false; + // No operator, not a boolean, not a number — this path has no way to + // decide the branch, and `false` used to be its answer (#4336). That made + // a truthy gate on a non-boolean variable — `'{record.status}'`, where + // the value is `'open'` — read as "condition not met" forever, with the + // run still recorded as `success`. Refuse instead, same rule as above. + throw new Error( + `condition evaluation error: \`${resolved}\` is not a predicate — source: \`${exprStr}\`. ` + + `The legacy \`{var}\` template dialect decides a branch by comparing the substituted ` + + `text, so it needs a comparison (\`{status} == 'open'\`) or a value that reads as a ` + + `boolean or number; a bare non-boolean value gives it nothing to compare and used to ` + + `answer \`false\` regardless of the value. Write the predicate as CEL — a condition ` + + `without \`{…}\` braces is evaluated by the CEL engine, where \`record.isActive\` is a ` + + `truthy gate and \`record.status == 'open'\` resolves the field.`, + ); + } + + /** + * Refuse a legacy-dialect condition whose `{…}` holes name no variable + * (#4336). + * + * Substitution replaces the literal text `{}` for each key in the + * variable map, so an unmatched hole survives into the comparison — and the + * template path would then compare the *brace text itself*: + * + * '{lead_record.status} == \'converted\'' + * → '{lead_record.status}' === "'converted'" → always FALSE + * + * The gate never opens, for any record, and the run still reports `success`. + * + * The common way to land here is a **field access on an object variable**: + * `get_record`'s `outputVariable` stores the whole record under one name + * (`lead_record`), so `{lead_record.status}` asks for a key that was never + * written. Note the asymmetry that let this survive — a node's outputs ARE + * flattened into dotted keys (`${node.id}.${key}`), so `{get_lead.id}` + * resolves and looks like proof the spelling works. + * + * CEL resolves that access properly, which is why the prescription is to drop + * the braces rather than to spell the hole differently. + */ + private refuseUnresolvedTemplateHole(source: string, unresolved: readonly string[]): void { + if (unresolved.length === 0) return; + const names = [...new Set(unresolved)]; + throw new Error( + `condition evaluation error: ${names.map(h => `\`${h}\``).join(', ')} did not resolve — ` + + `source: \`${source}\`. The legacy \`{var}\` template dialect substitutes a WHOLE flow ` + + `variable by name, and no variable is named ${names.map(h => `\`${h.slice(1, -1)}\``).join(', ')}. ` + + `Leaving it in place would compare the brace text as a STRING — a branch that is silently ` + + `wrong rather than merely unevaluated — so this is refused. Drop the braces: a condition ` + + `without them is evaluated as CEL, which resolves field access on an object variable ` + + `(\`lead_record.status == 'converted'\`) instead of looking for a variable spelled that way.`, + ); } /** @@ -3885,6 +4026,12 @@ export class AutomationEngine implements IAutomationService { * predicate that cannot be evaluated is a fault, never a quiet `false` (or, * here, a quiet `true`). * + * Since #4336 a *wholly* brace-free condition no longer arrives here at all — + * it is CEL, and `oppRecord.amount > 500000` simply evaluates. What still + * reaches this guard is a dotted reference **mixed into** a template-dialect + * condition (`'{limit} > record.amount'`), where the author is one operand + * away from the right dialect and the string compare would answer anyway. + * * Only *dotted* references are refused. A bare word compares as a string on * purpose — `'{status} == active'` is the documented legacy spelling, and * after substitution both sides are plain words. @@ -3906,8 +4053,19 @@ export class AutomationEngine implements IAutomationService { /** * Compare two string-represented values with an operator. + * + * Quoted operands are unquoted first (#4336). The template dialect compares + * text, so `'{status} == \'active\''` used to substitute to `active == + * 'active'` and compare `active` against `'active'` **with the quotes** — + * never equal, for any value of `status`. That spelling is not exotic: it is + * what the flow docs show for a decision node, and quoting a string literal + * is what every other predicate surface on the platform requires. So the + * quotes are stripped and both the quoted and the bare form (`{status} == + * active`, the older documented spelling) compare the same way. */ private compareValues(left: string, op: string, right: string): boolean { + left = unquoteLiteral(left); + right = unquoteLiteral(right); const lNum = Number(left); const rNum = Number(right); const bothNumeric = !isNaN(lNum) && !isNaN(rNum) && left !== '' && right !== ''; diff --git a/packages/services/service-automation/src/nested-region-parity.test.ts b/packages/services/service-automation/src/nested-region-parity.test.ts index 0b0976e71b..73d13c7d88 100644 --- a/packages/services/service-automation/src/nested-region-parity.test.ts +++ b/packages/services/service-automation/src/nested-region-parity.test.ts @@ -350,17 +350,25 @@ describe("#4347 — the legacy `{var}` path refuses an unresolved reference", () let engine: AutomationEngine; beforeEach(() => { engine = new AutomationEngine(silentLogger()); }); - it('refuses a dotted reference instead of comparing it lexicographically', () => { + it('evaluates a wholly brace-free dotted predicate as CEL', () => { const vars = new Map([['oppRecord', { amount: 10 }]]); // The reported footgun: 'oppRecord.amount' > '500000' compares 'o' to '5', - // so this used to be TRUE for every record regardless of the amount. - expect(() => engine.evaluateCondition('oppRecord.amount > 500000', vars)).toThrow(/unresolved expression reference/); - expect(() => engine.evaluateCondition('oppRecord.amount > 500000', vars)).toThrow(/dialect: 'cel'/); + // so this used to be TRUE for every record regardless of the amount. #4347 + // stopped the wrong answer by refusing it; #4336 goes on to give the RIGHT + // one — a condition with no `{…}` hole is CEL, so the amount is compared. + expect(engine.evaluateCondition('oppRecord.amount > 500000', vars)).toBe(false); + expect(engine.evaluateCondition('oppRecord.amount > 5', vars)).toBe(true); }); - it('refuses it on either side of the operator', () => { - const vars = new Map(); - expect(() => engine.evaluateCondition('5 == row.shouldRun', vars)).toThrow(/unresolved expression reference/); + it('still refuses a dotted reference mixed INTO a template-dialect condition', () => { + // A `{…}` hole puts the whole condition in the template dialect, where a + // dotted operand is text and the compare would answer anyway. One operand + // away from the right dialect, so the refusal names the fix. + const vars = new Map([['limit', 5]]); + expect(() => engine.evaluateCondition('{limit} > record.amount', vars)).toThrow(/unresolved expression reference/); + expect(() => engine.evaluateCondition('{limit} > record.amount', vars)).toThrow(/dialect: 'cel'/); + // Either side of the operator. + expect(() => engine.evaluateCondition('{limit} == row.shouldRun', vars)).toThrow(/unresolved expression reference/); }); it('evaluates the same predicate correctly once it is a CEL envelope', () => { diff --git a/packages/spec/src/automation/flow.zod.ts b/packages/spec/src/automation/flow.zod.ts index 7dc6a1b1c1..0d1ec28fa1 100644 --- a/packages/spec/src/automation/flow.zod.ts +++ b/packages/spec/src/automation/flow.zod.ts @@ -114,7 +114,8 @@ export const FlowVariableSchema = lazySchema(() => z.object({ * label: "Is High Value?", * config: { * conditions: [ - * { label: "Yes", expression: "{amount} > 10000" }, + * // Bare CEL, like every other condition — no `{…}` braces (#4336). + * { label: "Yes", expression: "amount > 10000" }, * { label: "No", expression: "true" } // default * ] * }, From 0523ea42acb942276da21195471ff8e5f214c981 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 08:04:33 +0000 Subject: [PATCH 2/2] docs(changeset): re-scope the #4336 note now that #4414 landed the decision call site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The decision executor wraps its expression in a CEL envelope as of #4440, so this branch is no longer what makes a decision predicate evaluate. Say what it actually does: fix the evaluator's own dialect decision (public API — a plugin-registered executor still hits the reported table), close the legacy path's two silent-`false` exits, and fix the quoted-literal comparison. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011n4UBkyRZsy6CJqmKg6oA5 --- .../flow-condition-bare-string-is-cel.md | 83 +++++++++---------- 1 file changed, 41 insertions(+), 42 deletions(-) diff --git a/.changeset/flow-condition-bare-string-is-cel.md b/.changeset/flow-condition-bare-string-is-cel.md index fd90ff0aae..392cd46119 100644 --- a/.changeset/flow-condition-bare-string-is-cel.md +++ b/.changeset/flow-condition-bare-string-is-cel.md @@ -2,53 +2,52 @@ "@objectstack/service-automation": minor --- -fix(automation): a flow condition authored as a plain string is evaluated as CEL, not string-compared (#4336) +fix(automation): `evaluateCondition` decides the dialect from the source, not from the caller (#4336) -`evaluateCondition` decided which engine to use by asking whether an -`{ dialect, source }` **envelope** was present. A condition authored as a plain -string therefore never reached the CEL engine: it fell through to the legacy -`{var}` template path, which substitutes brace holes and then compares whatever -text is left — **as text**. +`AutomationEngine.evaluateCondition` picked its engine by asking whether an +`{ dialect, source }` **envelope** was present. A condition handed to it as a +plain string therefore never reached the CEL engine: it fell through to the +legacy `{var}` template path, which substitutes brace holes and then compares +whatever text is left — **as text**. Nothing errored, and the run was recorded +as `success`, with the failure direction depending on the predicate: -Nothing errored, and the run was recorded as `success`. The failure direction -depended on the predicate, which is what made it dangerous: - -| Authored | Actually evaluated | Result | +| Handed in | Actually evaluated | Result | |:---|:---|:---| | `existingTask == null` | `'existingTask' === 'null'` | always **false** — gate never opens | | `record.rating >= 4` | `'record.rating' >= '4'` → `'r' > '4'` | always **true** — branch pinned open | -The dialect is now decided by the **source**, not by the envelope: a condition -is CEL unless it actually contains a `{var}` hole. The same predicate then -evaluates the same way wherever it is authored — an edge `condition` (which -`FlowEdgeSchema` parses into an envelope), a start-node gate, or a `decision` -node's `config.conditions[].expression`, which no schema normalizes because -`FlowNodeSchema.config` is an open `z.record` no transform can reach. That last -one is why author-side discipline could not fix this. - -**What changes for authors** - -- **Decision-node expressions are CEL.** `conditions: [{ expression: 'amount > - 10000' }]` now compares the amount. Field access on an object variable works: - `get_record` stores the whole row under its `outputVariable`, so - `lead_record.status == 'converted'` resolves — the `{lead_record.status}` - spelling never could, because substitution looks for a variable *named* - `lead_record.status`. -- **The `{var}` dialect still works** where it always did — `{amount} > 100`, - `{status} == active` — and now also with a **quoted** literal: - `{status} == 'active'` used to compare `active` against `'active'`, quotes - included, and was false for every value. -- **It no longer answers `false` when it cannot resolve something.** A `{…}` - hole that names no variable, and a substituted value that is neither a - boolean, a number, nor part of a comparison, are refused with the source and - the offending reference attached (ADR-0032 §1c: a predicate that cannot be - evaluated is a fault, never a quiet branch decision). Both used to be a silent - `false`. -- **A condition that is not valid CEL now raises**, where a bare string - previously string-compared to some answer. This is the intended tightening — - it surfaces as a loud flow failure naming the source, which `registerFlow` and - `objectstack build` already do for edge and start-node conditions. +#4414 fixed the one built-in that was reaching this — the `decision` executor +now wraps `conditions[].expression` in a CEL envelope before calling. This +fixes the **evaluator**, so the next caller does not have to remember: the +dialect is now read from the source, and a condition is CEL unless it actually +contains a `{var}` hole. `evaluateCondition` is public API, so a +plugin-registered node executor evaluating its own predicate was getting the +table above with nothing to warn it. + +**The legacy `{var}` dialect keeps working** where it always did — +`{amount} > 100`, `{status} == active`, `{a.b} == 7` — and gains the two things +it was missing: + +- **A quoted literal compares as its contents.** `{status} == 'active'` used to + compare `active` against `'active'` — quotes included — and was false for + every value of `status`. It is the spelling the flow docs showed, and quoting + a string literal is what every other predicate surface requires. +- **It no longer answers `false` when it could not resolve something.** A `{…}` + hole matching no flow variable (`{lead_record.status}` — `get_record` stores + the whole row under one name, so that key never exists) and a substituted + value that is neither a boolean, a number, nor part of a comparison are + refused with the source and the offending reference attached. Both used to be + a silent `false`, which ADR-0032 §1c forbids: a predicate that cannot be + evaluated is a fault, never a quiet branch decision. Braces inside an explicit `dialect: 'cel'` envelope remain the #1491 brace-trap -and still throw: stating the dialect is the author saying "this is CEL", where -`{…}` is a map literal. The sniff applies only where no dialect was stated. +and still throw — stating the dialect is the author saying "this is CEL". The +sniff reads the source outside string literals, so `record.label == '{pending}'` +stays CEL and compares the field. + +**Tightening to know about:** a bare string that is not valid CEL now raises +where it previously string-compared to some answer. That includes the +host-language payloads the safety tests use (`process.exit(1)`, +`require("fs")…`) — nothing executed before and nothing executes now, since CEL +has no `process`, no `require` and no arrow functions, but the failure is a +reported fault instead of a silent `false`.