Skip to content

Bare-string flow conditions bypass the CEL engine and silently string-compare — wrong branches, no error #4336

Description

@yinlianghui

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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions