Reported from HotCRM (objectstack-ai/hotcrm#643, PR #649), where it forced a workaround for a defect that reproduces end-to-end.
What we hit
AutomationEngine.evaluateCondition evaluates strict CEL, so reading an unbound flow variable aborts the whole predicate. Measured on 17.0.0-rc.1:
| expression |
X unbound |
X = null |
X = {} |
X = {f:1} |
X.f == 1 |
ABORT Unknown variable: X |
ABORT No such key: f |
ABORT No such key: f |
ok |
vars.X.f == 1 |
ABORT No such key: X |
ABORT No such key: f |
ABORT No such key: f |
ok |
has(X.f) |
ABORT Unknown variable: X |
false |
false |
true |
has(vars.X) |
false |
true |
true |
true |
(Worth noting on its own: the natural guard spelling has(X.f) does not survive an unbound variable. Only the vars.-scoped form does.)
The concrete failure: a screen flow collects a checkbox into createOpportunity. The runner only returns fields the user actually touched, so on the untouched path the variable is never bound, the outgoing edge aborts, and the flow stops — in HotCRM's case a lead conversion that persisted nothing at all.
The gap
The obvious remedy is to declare the variable with a default so it is bound on every path. That is not expressible:
// packages/spec — FlowVariableSchema
{ name, type, isInput, isOutput } // strict object; no defaultValue key
and execute binds a declared input only when context.params[name] !== undefined. So declaring a variable does not bind it — the declaration is documentation, not a guarantee. There is no metadata form that says "this variable always has a value".
What we had to do instead
Insert an assignment node before the screen that binds the variable to false, mirroring the screen field's own defaultValue. It works, and it has the virtue of making bindedness a property of the graph rather than of the client — but it is a node added to satisfy a missing declaration, and every flow that collects optional screen input needs the same ceremony.
The alternative an author reaches for first — wrapping the read in has(...) — is worse: it silently encodes "unanswered means no" into the predicate and leaves the graph defect in place.
Suggested direction
Add defaultValue to FlowVariableSchema and have execute bind declared variables to it when no parameter supplies one. That makes "declared" mean "bound", which is what an author already assumes, and it removes the incentive to reach for a guard that hides a graph defect.
If defaults are deliberately out of scope for flow variables, then the docs should say plainly that a declared variable may be unbound at runtime, and the guidance for reading one should name the vars.-scoped has() form — because the bare form does not work and nothing currently says so.
Reported from HotCRM (objectstack-ai/hotcrm#643, PR #649), where it forced a workaround for a defect that reproduces end-to-end.
What we hit
AutomationEngine.evaluateConditionevaluates strict CEL, so reading an unbound flow variable aborts the whole predicate. Measured on 17.0.0-rc.1:X.f == 1Unknown variable: XNo such key: fNo such key: fvars.X.f == 1No such key: XNo such key: fNo such key: fhas(X.f)Unknown variable: Xfalsefalsetruehas(vars.X)falsetruetruetrue(Worth noting on its own: the natural guard spelling
has(X.f)does not survive an unbound variable. Only thevars.-scoped form does.)The concrete failure: a screen flow collects a checkbox into
createOpportunity. The runner only returns fields the user actually touched, so on the untouched path the variable is never bound, the outgoing edge aborts, and the flow stops — in HotCRM's case a lead conversion that persisted nothing at all.The gap
The obvious remedy is to declare the variable with a default so it is bound on every path. That is not expressible:
and
executebinds a declared input only whencontext.params[name] !== undefined. So declaring a variable does not bind it — the declaration is documentation, not a guarantee. There is no metadata form that says "this variable always has a value".What we had to do instead
Insert an
assignmentnode before the screen that binds the variable tofalse, mirroring the screen field's owndefaultValue. It works, and it has the virtue of making bindedness a property of the graph rather than of the client — but it is a node added to satisfy a missing declaration, and every flow that collects optional screen input needs the same ceremony.The alternative an author reaches for first — wrapping the read in
has(...)— is worse: it silently encodes "unanswered means no" into the predicate and leaves the graph defect in place.Suggested direction
Add
defaultValuetoFlowVariableSchemaand haveexecutebind declared variables to it when no parameter supplies one. That makes "declared" mean "bound", which is what an author already assumes, and it removes the incentive to reach for a guard that hides a graph defect.If defaults are deliberately out of scope for flow variables, then the docs should say plainly that a declared variable may be unbound at runtime, and the guidance for reading one should name the
vars.-scopedhas()form — because the bare form does not work and nothing currently says so.