Skip to content

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

Description

@os-sam

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 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/spec 17.1.0.

Where

packages/service-automationsrc/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") return match;
  // ... resolve against flow variables ...
  if (val === void 0 || val === null) return "null";   // <-- every function name lands here
  ...
});
const fn = new Function(`"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:

raw            => 125999.99999999999   (number)   <- the unrounded product
ROUND(...)     => undefined
round(...)     => undefined
Math.round(...)=> undefined
(...).toFixed(2) => undefined
Number((...).toFixed(2)) => undefined

Only pure operator arithmetic evaluates, because operators need no identifier:

(x * 100 + 0.5 | 0) / 100                                  => 126000
(x * 100 + 0.5 - ((x * 100 + 0.5) % 1)) / 100              => 126000

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:

now, today, daysFromNow, daysAgo, daysBetween, addDays, addMonths, date, datetime,
abs, round, floor, ceil, min, max, upper, lower, trim, contains, startsWith, endsWith,
matches, joinNonEmpty, isBlank, isEmpty, coalesce, len, size, has, int, string, bool,
double, timestamp, duration

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#1206Generate 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:

  1. 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.
  2. 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.
  3. Coerce on write — have the data layer round to the declared scale instead 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 declared scale is 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.

Metadata

Metadata

Assignees

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions