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 artifact — objectstack 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:
- substitutes only
{var} brace placeholders from the variable map,
- splits the remainder on the first comparison operator,
- 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:
- 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.
- 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.
Split out of #489 (found while verifying #560, which fixes the
days_in_stagehalf). This is pre-existing onmainand untouched by that PR.Symptom
opportunity_stagnationselects the right deals and then does nothing. Every loop iteration stops at thecheck_not_nudgeddecision 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 artifact —
objectstack builddoes not normalize them into anExpressionenvelope: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:{var}brace placeholders from the variable map,compareValues, which compares them as strings when they are not both numeric.So
existingStallTask == nullnever 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 —
— 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):
existingStallTask == null(current)check_not_nudged, 0 tasks{existingStallTask} == nullThe 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
decisionnode andconditionaledge insrc/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 andrecord.rating < 4always 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:
{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.recordandextra).Worth deciding before touching the flows, since option 2 may also belong upstream —
buildarguably should be normalizing these strings into envelopes, which is what the "plain string accepted for back-compat; build emits canonical envelope" note in@objectstack/specdescribes for other expression sites.Whichever way it goes, a
test/guard should pin it so the bare form cannot come back silently.