Skip to content

Flow decision / conditional-edge conditions never resolve variables — every bare-identifier condition is compared as a literal string #562

Description

@yinlianghui

Split out of #489 (found while verifying #560, which fixes the days_in_stage half). This is pre-existing on main and untouched by that PR.

Symptom

opportunity_stagnation selects the right deals and then does nothing. Every loop iteration stops at the check_not_nudged decision node — no notification, no follow-up task. The sweep looks healthy (sys_job.last_status = success, failure_count = 0) and produces zero output.

Root cause

Flow conditions are authored as bare strings and stay bare strings in the build artifactobjectstack build does not normalize them into an Expression envelope:

$ node -e "…dist/objectstack.json…"
decision node config: {"condition":"existingStallTask == null"}
edge b2: {…,"condition":"existingStallTask == null","label":"First nudge"}

AutomationEngine.evaluateCondition (@objectstack/service-automation) branches on whether the condition is an envelope. Only an envelope reaches the CEL engine; a plain string falls through to a legacy template path that:

  1. substitutes only {var} brace placeholders from the variable map,
  2. splits the remainder on the first comparison operator,
  3. hands both sides to compareValues, which compares them as strings when they are not both numeric.

So existingStallTask == null never resolves the variable. It evaluates 'existingStallTask' === 'null'false, and the gate never opens.

Note the irony: the CEL branch's own error message tells authors to write bare CEL and not to use braces —

Conditions are bare CEL (e.g. record.rating >= 4); do not wrap field references in {…} template braces.

— but bare strings never reach that branch, so following the advice is exactly what breaks.

Verified

Against a booted server (fresh DB, temporary 1-minute cron):

Condition form Run 1 Run 2
existingStallTask == null (current) loop reaches check_not_nudged, 0 tasks 0 tasks
{existingStallTask} == null notify + create fire, 30 tasks gate closes correctly, still 30

The brace form works in both directions: String(null) is 'null' so the first pass opens the gate, and a found record stringifies to '[object Object]' so the second pass closes it. That confirms both the diagnosis and that the idempotency logic itself is sound — only the variable reference is broken.

Blast radius

This is not one flow. Every decision node and conditional edge in src/flows/ uses the bare-identifier form, so all of them are currently evaluating string literals. Reading the same code path, the failure mode is not uniformly "false" — which is worse than a uniform no-op:

  • existingMember == null (campaign_enrollment), existingRenewalTask == null / existingRenewalOpp == null (contract_renewal) — same shape as the verified case, so these gates should be stuck closed too.
  • record.rating >= 4 (lead_assignment) — neither side is numeric, so it string-compares 'record.rating' >= '4', and 'r' > '4' is true. The Hot branch would always be taken and record.rating < 4 always false. Not yet verified on a running server; flagging it as the case that argues for fixing the class rather than one flow.

Suggested fix

Two candidate directions, and the choice matters more than the diff:

  1. Author-side — switch every flow condition to the brace form ({existingStallTask} == null). Verified to work, but it keeps the stringly-typed comparison semantics (no types, no null-vs-'null' distinction) and contradicts the engine's own guidance.
  2. Envelope-side — author conditions as CEL envelopes (e.g. via a tagged template) so they reach the CEL branch and get real typed evaluation. Closer to the platform's stated intent; needs a check that flow variables bind the way the CEL branch expects (it passes them as both record and extra).

Worth deciding before touching the flows, since option 2 may also belong upstream — build arguably should be normalizing these strings into envelopes, which is what the "plain string accepted for back-compat; build emits canonical envelope" note in @objectstack/spec describes for other expression sites.

Whichever way it goes, a test/ guard should pin it so the bare form cannot come back silently.

Metadata

Metadata

Assignees

Labels

backendbugSomething isn't working

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions