Flow conditions authored as bare strings silently string-compare instead of evaluating
Title suggestion: Bare-string flow conditions bypass the CEL engine and silently string-compare — wrong branches, no error
Found while debugging a downstream app (objectstack-ai/hotcrm) whose automation selected the right records and then did nothing. Verified against @objectstack/spec@16.1.0, @objectstack/service-automation, @objectstack/formula as shipped.
Summary
A flow condition authored as a plain string never reaches the CEL engine. AutomationEngine.evaluateCondition branches on whether the condition is an Expression envelope; a bare string falls through to a legacy template path that substitutes {var} braces and then compares both sides as strings. Nothing errors, and the run is recorded as success.
The result is not a uniform no-op, which is what makes it dangerous — the failure direction depends on the expression:
| Authored condition |
Actually evaluated |
Result |
existingTask == null |
'existingTask' === 'null' |
always false — gate never opens |
record.rating >= 4 |
'record.rating' >= '4' |
always true ('r' > '4') — branch pinned open |
So one flow silently does nothing forever, and another silently always takes the same branch. Both report success.
Reproduction
Author a flow the ordinary way — a typed object literal, no defineFlow():
import type * as Automation from '@objectstack/spec/automation';
type Flow = Automation.Flow;
export const Demo: Flow = {
name: 'demo', type: 'schedule', /* … */
nodes: [
{ id: 'check', type: 'decision', config: { condition: 'someVar == null' } },
],
edges: [
{ id: 'e1', source: 'check', target: 'act', type: 'conditional',
condition: 'someVar == null', label: 'Act' },
],
};
objectstack build emits the condition verbatim:
decision node config: {"condition":"someVar == null"}
edge e1: {…,"condition":"someVar == null","label":"Act"}
At run time the edge is never taken, regardless of someVar.
Root cause
Three things compound:
1. The normalization exists but is never reached. FlowEdgeSchema.condition is ExpressionInputSchema (spec/src/automation/flow.zod.ts:185), whose string arm transforms into { dialect: 'cel', source } (spec/src/shared/expression.zod.ts:92-95). But that transform only runs on .parse(). defineFlow() does call FlowSchema.parse() (flow.zod.ts:324) — apps that author flows as typed object literals (the shape the docs and Flow type invite, since ExpressionInput accepts a bare string by design) never invoke it, so the transform never fires.
2. Node config cannot be normalized at all. FlowNodeSchema.config is z.record(z.string(), z.unknown()) (flow.zod.ts:102). Trigger gates and decision-node conditions live in there, so even a correct defineFlow() call leaves every node-level expression a bare string. This is the structural half — no amount of author-side discipline fixes it.
3. The fallback path is silent and stringly-typed. In service-automation, evaluateCondition routes only an envelope to ExpressionEngine; the fallback splits the string on the first comparison operator and hands both sides to compareValues, which compares them as strings unless both parse as numbers. An unresolved identifier is therefore compared as its own literal name.
There is also a documentation trap: the CEL branch's own error message reads
Conditions are bare CEL (e.g. record.rating >= 4); do not wrap field references in {…} template braces.
which is correct advice for the CEL branch — but a bare string never reaches that branch, so following it is exactly what produces the silent string comparison. The brace form "works" only because it is handled by the legacy path.
Suggested fixes
In rough priority order:
- Make the fallback loud. If a condition string contains an identifier that did not resolve, throw instead of string-comparing. Today the legacy path cannot distinguish "the author meant a literal" from "the variable did not resolve", and it silently picks the former. A wrong branch that reports success is much worse than an error.
- Normalize in
build. spec/src/shared/expression.zod.ts already documents the intent — "String-only shorthand is accepted at input time for developer ergonomics; build emits the canonical envelope." That is not what happens for flows authored without defineFlow(). If build normalized expression-shaped metadata, the whole class disappears regardless of authoring style.
- Type the node config. A discriminated per-node-type config schema (or at minimum an
ExpressionInput-typed condition on the node-config shapes that have one) would let (2) reach trigger gates and decision nodes.
- Lint/validate rule. Warn on any flow condition that reaches the artifact as a bare string.
Related, found in the same investigation
The decision node executor reads config.conditions — plural, an array of { expression, label } — and returns branchLabel: 'default' when it is absent. A node authored with config.condition (singular) is therefore silently ignored. Spec's own JSDoc example uses the plural form, so this is an app-side mistake, but it fails silently and would be worth a validate warning.
It is currently harmless only because traverseNext falls back to the full edge set when no outgoing edge matches branchLabel. If any edge is ever labelled default, that edge monopolises the branch and bypasses every conditional edge on the node.
Workaround for app authors
Author conditions with the P tagged template so the envelope is present at authoring time, which works for both edges and node configs and needs no parse step:
import { P } from '@objectstack/spec';
{ id: 'e1', /* … */ condition: P`someVar == null`, label: 'Act' }
config: { condition: P`record.status == "closed"` }
Note that the condition sources generally need no rewriting: the engine merges its variable map onto the CEL scope via ctx.extra, and buildScope applies ctx.extra after scope.record, so flow variables resolve by bare name and record is the triggering record. Only the envelope is missing.
Flow conditions authored as bare strings silently string-compare instead of evaluating
Title suggestion:
Bare-string flow conditions bypass the CEL engine and silently string-compare — wrong branches, no errorFound while debugging a downstream app (
objectstack-ai/hotcrm) whose automation selected the right records and then did nothing. Verified against@objectstack/spec@16.1.0,@objectstack/service-automation,@objectstack/formulaas shipped.Summary
A flow condition authored as a plain string never reaches the CEL engine.
AutomationEngine.evaluateConditionbranches on whether the condition is anExpressionenvelope; a bare string falls through to a legacy template path that substitutes{var}braces and then compares both sides as strings. Nothing errors, and the run is recorded assuccess.The result is not a uniform no-op, which is what makes it dangerous — the failure direction depends on the expression:
existingTask == null'existingTask' === 'null'record.rating >= 4'record.rating' >= '4''r' > '4') — branch pinned openSo one flow silently does nothing forever, and another silently always takes the same branch. Both report success.
Reproduction
Author a flow the ordinary way — a typed object literal, no
defineFlow():objectstack buildemits the condition verbatim:At run time the edge is never taken, regardless of
someVar.Root cause
Three things compound:
1. The normalization exists but is never reached.
FlowEdgeSchema.conditionisExpressionInputSchema(spec/src/automation/flow.zod.ts:185), whose string arm transforms into{ dialect: 'cel', source }(spec/src/shared/expression.zod.ts:92-95). But that transform only runs on.parse().defineFlow()does callFlowSchema.parse()(flow.zod.ts:324) — apps that author flows as typed object literals (the shape the docs andFlowtype invite, sinceExpressionInputaccepts a bare string by design) never invoke it, so the transform never fires.2. Node
configcannot be normalized at all.FlowNodeSchema.configisz.record(z.string(), z.unknown())(flow.zod.ts:102). Trigger gates and decision-node conditions live in there, so even a correctdefineFlow()call leaves every node-level expression a bare string. This is the structural half — no amount of author-side discipline fixes it.3. The fallback path is silent and stringly-typed. In
service-automation,evaluateConditionroutes only an envelope toExpressionEngine; the fallback splits the string on the first comparison operator and hands both sides tocompareValues, which compares them as strings unless both parse as numbers. An unresolved identifier is therefore compared as its own literal name.There is also a documentation trap: the CEL branch's own error message reads
which is correct advice for the CEL branch — but a bare string never reaches that branch, so following it is exactly what produces the silent string comparison. The brace form "works" only because it is handled by the legacy path.
Suggested fixes
In rough priority order:
build.spec/src/shared/expression.zod.tsalready documents the intent — "String-only shorthand is accepted at input time for developer ergonomics; build emits the canonical envelope." That is not what happens for flows authored withoutdefineFlow(). Ifbuildnormalized expression-shaped metadata, the whole class disappears regardless of authoring style.ExpressionInput-typedconditionon the node-config shapes that have one) would let (2) reach trigger gates and decision nodes.Related, found in the same investigation
The
decisionnode executor readsconfig.conditions— plural, an array of{ expression, label }— and returnsbranchLabel: 'default'when it is absent. A node authored withconfig.condition(singular) is therefore silently ignored. Spec's own JSDoc example uses the plural form, so this is an app-side mistake, but it fails silently and would be worth a validate warning.It is currently harmless only because
traverseNextfalls back to the full edge set when no outgoing edge matchesbranchLabel. If any edge is ever labelleddefault, that edge monopolises the branch and bypasses every conditional edge on the node.Workaround for app authors
Author conditions with the
Ptagged template so the envelope is present at authoring time, which works for both edges and node configs and needs no parse step:Note that the condition sources generally need no rewriting: the engine merges its variable map onto the CEL scope via
ctx.extra, andbuildScopeappliesctx.extraafterscope.record, so flow variables resolve by bare name andrecordis the triggering record. Only the envelope is missing.