You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Flow field expressions can call no function but NOW()/TODAY() — every other identifier is rewritten to null, so a computed money value can never be rounded to its field's declared scale #11060
The value-producing expression surface in flows — create_record.config.fields, update_record.config.fields, assignment.config.assignments — supports no function calls at all except the two hard-coded date tokens NOW() / TODAY(). Every other identifier is rewritten to the literal null before evaluation, so round(...), ROUND(...), Math.round(...), .toFixed(2) and Number(...) all silently evaluate to undefined.
Consequence: a flow cannot write a rounded value into a field that declares a scale. Any flow that multiplies a currency by a percentage produces a raw IEEE-754 double, which the (correctly) enforced field scale then refuses at insert time.
Measured on @objectstack/spec17.1.0.
Where
packages/service-automation → src/builtin/template.ts, resolveToken(). After the NOW|TODAY regex and the $User. / bare-path branches, the fallback path does:
safe=safe.replace(/([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)/g,(match)=>{if(match==="true"||match==="false"||match==="null"||match==="undefined")returnmatch;// ... resolve against flow variables ...if(val===void0||val===null)return"null";// <-- every function name lands here
...
});constfn=newFunction(`"use strict"; return (${safe});`);
An identifier that is not a bound flow variable becomes null, so round(x, 2) compiles to null(x, 2), throws TypeError, and is swallowed by the surrounding catch { return void 0; }. The failure is silent — the field is simply written as undefined.
create_record and update_record both reach this via interpolate(cfg.fields ?? {}, variables, context); assignment reaches the same interpolate for its values.
Measured
Driving the real AutomationEngine + installBuiltinNodes (17.1.0) with amount = 180000, discount = 30:
Neither is an acceptable authoring pattern: the | 0 form is an int32 coercion that silently overflows above ~21.5M (any amount over 2147483647 / 100), and the % form has to repeat the entire product three times inside one expression.
The asymmetry
The platform already has a rounding primitive. @objectstack/formula's CEL stdlib exports 35 functions including round, floor, ceil, abs, min, max:
But service-automation imports ExpressionEngine for exactly one call site — evaluateCondition() — and coerces its result with Boolean(result.value). So CEL is wired in as a predicate surface only (edge conditions, decision conditions, start gates). The value-producing surfaces run the separate bespoke new Function mini-evaluator above, which has no function vocabulary.
The result is that the same flow file can call round() in an edge condition and cannot call it in a field value.
Why this matters
scale enforcement on number/currency fields is correct and landed deliberately (#7501). With it enforced and no rounding reachable from the expression surface, any flow computing money from a percentage is unshippable — the write is refused at insert time and the flow node fails.
Standing example, found by driving the flagship exemplar app in a browser: objectstack-ai/hotcrm#1206 — Generate Quote fails for most non-zero discounts because the flow hands 180000 * (1 - 30/100) = 125999.99999999999 to two scale: 2 currency fields. That card is blocked on this gap; there is no app-side fix that is not a workaround for a missing platform primitive.
Suggested direction (not ruled)
Options, roughly in increasing order of scope:
Extend the template evaluator's known-function table beyond NOW/TODAY with a small numeric set (round, floor, ceil, abs, min, max). Smallest change, but grows a second, divergent function vocabulary next to CEL's.
Route value-producing expressions through the CEL ExpressionEngine that already backs conditions, so one dialect and one stdlib serve both predicate and value positions. Larger, and needs a compatibility story for the {var} template dialect the existing flows use.
Option 2 looks right for "declared = enforced, one dialect", but the migration surface is the real cost and this needs a ruling rather than an agent's pick.
Summary
The value-producing expression surface in flows —
create_record.config.fields,update_record.config.fields,assignment.config.assignments— supports no function calls at all except the two hard-coded date tokensNOW()/TODAY(). Every other identifier is rewritten to the literalnullbefore evaluation, soround(...),ROUND(...),Math.round(...),.toFixed(2)andNumber(...)all silently evaluate toundefined.Consequence: a flow cannot write a rounded value into a field that declares a
scale. Any flow that multiplies a currency by a percentage produces a raw IEEE-754 double, which the (correctly) enforced field scale then refuses at insert time.Measured on
@objectstack/spec17.1.0.Where
packages/service-automation→src/builtin/template.ts,resolveToken(). After theNOW|TODAYregex and the$User./ bare-path branches, the fallback path does:An identifier that is not a bound flow variable becomes
null, soround(x, 2)compiles tonull(x, 2), throwsTypeError, and is swallowed by the surroundingcatch { return void 0; }. The failure is silent — the field is simply written asundefined.create_recordandupdate_recordboth reach this viainterpolate(cfg.fields ?? {}, variables, context);assignmentreaches the sameinterpolatefor its values.Measured
Driving the real
AutomationEngine+installBuiltinNodes(17.1.0) withamount = 180000,discount = 30:Only pure operator arithmetic evaluates, because operators need no identifier:
Neither is an acceptable authoring pattern: the
| 0form is an int32 coercion that silently overflows above ~21.5M (any amount over2147483647 / 100), and the%form has to repeat the entire product three times inside one expression.The asymmetry
The platform already has a rounding primitive.
@objectstack/formula's CEL stdlib exports 35 functions includinground,floor,ceil,abs,min,max:But
service-automationimportsExpressionEnginefor exactly one call site —evaluateCondition()— and coerces its result withBoolean(result.value). So CEL is wired in as a predicate surface only (edge conditions,decisionconditions, start gates). The value-producing surfaces run the separate bespokenew Functionmini-evaluator above, which has no function vocabulary.The result is that the same flow file can call
round()in an edge condition and cannot call it in a field value.Why this matters
scaleenforcement on number/currency fields is correct and landed deliberately (#7501). With it enforced and no rounding reachable from the expression surface, any flow computing money from a percentage is unshippable — the write is refused at insert time and the flow node fails.Standing example, found by driving the flagship exemplar app in a browser: objectstack-ai/hotcrm#1206 —
Generate Quotefails for most non-zero discounts because the flow hands180000 * (1 - 30/100)=125999.99999999999to twoscale: 2currency fields. That card is blocked on this gap; there is no app-side fix that is not a workaround for a missing platform primitive.Suggested direction (not ruled)
Options, roughly in increasing order of scope:
NOW/TODAYwith a small numeric set (round,floor,ceil,abs,min,max). Smallest change, but grows a second, divergent function vocabulary next to CEL's.ExpressionEnginethat already backs conditions, so one dialect and one stdlib serve both predicate and value positions. Larger, and needs a compatibility story for the{var}template dialect the existing flows use.scaleinstead of refusing. Rejected on its face here for surfacing: silent coercion of money is worse than a loud refusal, and it would undo A number field's declaredscaleis never enforced — values with more decimals are accepted and stored verbatim (min/max on the same field are enforced) #7501's intent.Option 2 looks right for "declared = enforced, one dialect", but the migration surface is the real cost and this needs a ruling rather than an agent's pick.