diff --git a/.changeset/flow-expression-function-table.md b/.changeset/flow-expression-function-table.md
new file mode 100644
index 0000000000..02c980980c
--- /dev/null
+++ b/.changeset/flow-expression-function-table.md
@@ -0,0 +1,7 @@
+---
+"@objectstack/service-automation": minor
+---
+
+Flow value expressions (`create_record`/`update_record` `config.fields`, `assignment` `config.assignments`) now support a small numeric function table — `round`, `floor`, `ceil`, `abs`, `min`, `max` — with every name and semantic mirrored 1:1 from the `@objectstack/formula` CEL stdlib (no second dialect: `round` is integer-only exactly like CEL's; for N-decimal rounding write `round(x * 100) / 100`, the same pattern CEL authors use). A flow can finally write a computed money value that satisfies its field's declared `scale` (`{round(amount * (1 - discount / 100) * 100) / 100}` → a `scale: 2` currency field).
+
+Loud diagnostic in the same stroke: an identifier in call position that is not a supported function — `ROUND(...)`, `Math.round(...)`, `(x).toFixed(2)`, or the next name anyone invents — now fails the node with a named `FlowExpressionFunctionError` (guard-marked, so a `fault` edge cannot swallow it) instead of being silently rewritten to `null` and writing the field as `undefined`. Non-call template resolution is unchanged: unresolved plain tokens still become `null`/empty, and `NOW()`/`TODAY()` whole-token macros behave exactly as before.
diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx
index 27cedf8b1d..9880dba07f 100644
--- a/content/docs/automation/flows.mdx
+++ b/content/docs/automation/flows.mdx
@@ -1266,14 +1266,16 @@ failures so one broken flow does not abort startup.
## Expressions in flows
A flow mixes **two expression dialects**, and the rule is short: **every
-condition is CEL; braces are for values.**
+condition is CEL; braces are for values** — and the numeric functions the value
+dialect accepts are mirrored from CEL's, so a name you learn in conditions
+means the same thing inside braces.
| Where | Dialect | Write it like | Bindings |
|:---|:---|:---|:---|
| Start-node `condition` | **CEL** (bare, no braces) | `record.amount > 500` | `record.*`, `previous.*`, bare field names, `vars.*` |
| Edge `condition` | **CEL** (bare, no braces) | `record.status == 'open'` | same as above |
| Decision-node `conditions[].expression` | **CEL** (bare, no braces) | `order_amount > 10000` | flow variables by name, and `vars.*` |
-| Field values in `create_record` / `update_record` | **Interpolation** (braces required) | `'Follow up on {record.name}'`, `'{TODAY() + 7}'` | `{var}`, `{var.path}`, `{$User.Id}`, `{$User.Email}`, `{NOW()}`, `{TODAY()}`, `{TODAY() + 90}` (whole days) |
+| Field values in `create_record` / `update_record` | **Interpolation** (braces required) | `'Follow up on {record.name}'`, `'{TODAY() + 7}'` | `{var}`, `{var.path}`, `{$User.Id}`, `{$User.Email}`, `{NOW()}`, `{TODAY()}`, `{TODAY() + 90}` (whole days), and the CEL-mirrored numeric functions `round`, `floor`, `ceil`, `abs`, `min`, `max` (#11060) — `round` is **integer-only**, exactly like CEL's (there is no `round(x, 2)`); for N decimals write the CEL idiom `{round(x * 100) / 100}` (scale 2) |
**The failure modes to memorize:**
@@ -1283,6 +1285,13 @@ condition is CEL; braces are for values.**
2. **Braces put *into* a condition** — `'{record.amount} > 500'`. Conditions
fail loudly rather than silently, with an error that tells you to drop the
braces.
+3. **An unsupported function in a field value** — `total: '{ROUND(x, 2)}'`,
+ `'{Math.round(x)}'`, `'{(x).toFixed(2)}'` — fails the node with a **named
+ error** listing the supported set and, where one is close, the spelling you
+ meant. Before #11060 this was a *silent* failure: the unknown name was
+ rewritten to `null` and the field was simply written `undefined`. A `fault`
+ edge does not catch this error — the expression itself is wrong, so
+ re-running can never succeed; fix the spelling.
diff --git a/packages/services/service-automation/src/builtin/template-functions.test.ts b/packages/services/service-automation/src/builtin/template-functions.test.ts
new file mode 100644
index 0000000000..00796f8607
--- /dev/null
+++ b/packages/services/service-automation/src/builtin/template-functions.test.ts
@@ -0,0 +1,214 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * #11060 — the flow VALUE-expression function table, both halves of the ruling:
+ *
+ * 1. `round` / `floor` / `ceil` / `abs` / `min` / `max` work in value
+ * expressions, every name and semantic mirrored **1:1 from the CEL
+ * stdlib** — proven here by PARITY, not by re-stating the numbers: one
+ * input grid drives BOTH engines (`ExpressionEngine` CEL vs the template
+ * evaluator) and the results must agree exactly. A drift in either
+ * implementation reds this file.
+ *
+ * 2. The silent-`null` rewrite is gone for CALL positions: an unknown
+ * function name is a LOUD named error ({@link FlowExpressionFunctionError},
+ * guard-marked so a `fault` edge cannot swallow it) — never an `undefined`
+ * field write. This is the half the ruling weights most: without it the
+ * seventh name anyone types silently falls into the same trap the six
+ * functions climbed out of.
+ *
+ * Over-denial controls pin that the diagnostic did NOT become a blanket
+ * refusal: operator-only arithmetic, `NOW()`/`TODAY()` (whole-token macros),
+ * and unresolved NON-call identifiers all behave exactly as before.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { ExpressionEngine } from '@objectstack/formula';
+import { interpolateString, FlowExpressionFunctionError } from './template.js';
+import { isGuardRefusal } from '../guard-refusal.js';
+
+const ctx = {} as any;
+
+function tpl(expr: string, vars: Record = {}): unknown {
+ return interpolateString(`{${expr}}`, new Map(Object.entries(vars)), ctx);
+}
+
+function cel(source: string): { ok: boolean; value?: unknown; error?: { message: string } } {
+ // The CEL engine requires a context object (it reads `ctx.timezone`);
+ // an empty one gives the stdlib its UTC defaults.
+ return ExpressionEngine.evaluate({ dialect: 'cel', source } as any, {}) as any;
+}
+
+// ── Half 1: the six functions, semantics pinned BY PARITY with CEL ─────────
+
+describe('value-expression functions mirror the CEL stdlib 1:1 (#11060)', () => {
+ // The grid deliberately covers the rounding-mode traps: halves (JS
+ // Math.round rounds half toward +∞: round(-1.5) === -1, round(2.5) === 3),
+ // negative floor/ceil direction (toward −∞ / +∞), zero, and the issue's
+ // own unrounded product ×100.
+ const UNARY_GRID = [1.4, 1.5, 1.6, 2.5, -1.2, -1.5, -2.5, 0, 0.5, -0.5, 3.7, -3.7, 12599999.999999998, 125999.99999999999];
+
+ for (const fn of ['round', 'floor', 'ceil', 'abs'] as const) {
+ it(`${fn}(x) agrees with ExpressionEngine CEL on every grid input`, () => {
+ for (const x of UNARY_GRID) {
+ const c = cel(`${fn}(${x})`);
+ expect(c.ok, `CEL ${fn}(${x}) must evaluate: ${JSON.stringify(c)}`).toBe(true);
+ const t = tpl(`${fn}(x)`, { x });
+ // CEL int results come back as plain numbers (safe range) —
+ // Object.is equality, so a -0/0 or carrier drift also reds.
+ expect(t, `${fn}(${x}): template=${String(t)} cel=${String(c.value)}`).toBe(c.value);
+ }
+ });
+ }
+
+ it('min/max agree with CEL and return the operand verbatim (type preserved)', () => {
+ const PAIRS: Array<[unknown, unknown]> = [[1, 2], [2, 1], [1.5, 1.5], [-1, -2], [0.1, 0.2], [180000, 200000]];
+ for (const fn of ['min', 'max'] as const) {
+ for (const [a, b] of PAIRS) {
+ const c = cel(`${fn}(${String(a)}, ${String(b)})`);
+ expect(c.ok, `CEL ${fn}(${String(a)}, ${String(b)})`).toBe(true);
+ expect(tpl(`${fn}(a, b)`, { a, b })).toBe(c.value);
+ }
+ }
+ // Operand-verbatim on non-numeric operands (numeric comparison is NaN
+ // → both predicates false → second operand), same lambda both sides.
+ const cs = cel(`min("apple", "banana")`);
+ expect(cs.ok).toBe(true);
+ expect(tpl(`min(a, b)`, { a: 'apple', b: 'banana' })).toBe(cs.value);
+ });
+
+ it("abs of a non-numeric value is NaN in BOTH engines (CEL's abs(dyn): double does not fault)", () => {
+ const c = cel(`abs("x")`);
+ expect(c.ok).toBe(true);
+ expect(Number.isNaN(c.value as number), `cel abs("x") = ${String(c.value)}`).toBe(true);
+ const t = tpl(`abs(v)`, { v: 'x' });
+ expect(Number.isNaN(t as number), `template abs("x") = ${String(t)}`).toBe(true);
+ });
+
+ it('round of a non-numeric value REFUSES in both engines (CEL faults on BigInt(NaN); here it is the named error)', () => {
+ // CEL side: stdlib round → BigInt(Math.round(Number("x"))) throws.
+ const c = cel(`round("x")`);
+ expect(c.ok, 'CEL must fault, not return a value').toBe(false);
+ // Template side: same direction, as the named diagnostic.
+ let caught: unknown;
+ try { tpl(`round(v)`, { v: 'x' }); } catch (e) { caught = e; }
+ expect(caught).toBeInstanceOf(FlowExpressionFunctionError);
+ expect((caught as FlowExpressionFunctionError).problem).toBe('argument');
+ expect((caught as FlowExpressionFunctionError).fn).toBe('round');
+ });
+
+ it('wrong arity REFUSES in both engines — round(x, 2) has NO precision form, and the refusal says what to write', () => {
+ const c = cel('round(1.5, 2)');
+ expect(c.ok, 'CEL has no round/2 overload').toBe(false);
+ let caught: unknown;
+ try { tpl('round(x, 2)', { x: 1.5 }); } catch (e) { caught = e; }
+ expect(caught).toBeInstanceOf(FlowExpressionFunctionError);
+ const err = caught as FlowExpressionFunctionError;
+ expect(err.problem).toBe('arity');
+ expect(err.fn).toBe('round');
+ // The prescription IS the contract here: the refusal must hand the
+ // author the CEL-identical authoring pattern for N-decimal rounding.
+ expect(err.message).toContain('round(x * 100) / 100');
+ // min/max are exactly binary, like their CEL registrations.
+ expect(() => tpl('min(1)', {})).toThrow(FlowExpressionFunctionError);
+ expect(() => tpl('min(1, 2, 3)', {})).toThrow(FlowExpressionFunctionError);
+ });
+
+ it('declared divergence pin: beyond MAX_SAFE_INTEGER, CEL switches carrier to string; the template dialect refuses loudly', () => {
+ const c = cel('round(10000000000000000.0)'); // 1e16 > 2^53
+ expect(c.ok).toBe(true);
+ expect(typeof c.value, 'CEL boundary hands back a string carrier here').toBe('string');
+ // A string riding into this dialect's JS arithmetic would corrupt
+ // silently (`"1e16" / 100`), so the mirror's documented edge is a
+ // named error instead.
+ let caught: unknown;
+ try { tpl('round(x)', { x: 1e16 }); } catch (e) { caught = e; }
+ expect(caught).toBeInstanceOf(FlowExpressionFunctionError);
+ expect((caught as FlowExpressionFunctionError).problem).toBe('argument');
+ });
+
+ it('the issue’s oracle shape: round(amount * (1 - discount / 100) * 100) / 100 lands the scale-2 value', () => {
+ // 180000 * (1 - 30/100) = 125999.99999999999 — the raw product #7501
+ // refuses on a scale: 2 field. The CEL-identical authoring pattern
+ // produces the exact representable value.
+ expect(tpl('amount * (1 - discount / 100)', { amount: 180000, discount: 30 })).toBe(125999.99999999999);
+ expect(tpl('round(amount * (1 - discount / 100) * 100) / 100', { amount: 180000, discount: 30 })).toBe(126000);
+ // A case where the cents matter (integer round would be WRONG):
+ expect(tpl('round(amount * (1 - discount / 100) * 100) / 100', { amount: 199.99, discount: 15 })).toBe(169.99);
+ });
+
+ it('functions compose with variables, nesting, and embedded substitution', () => {
+ expect(tpl('min(round(a), ceil(b))', { a: 2.6, b: 1.2 })).toBe(2);
+ expect(tpl('max(abs(a), b)', { a: -5, b: 3 })).toBe(5);
+ expect(interpolateString('Total: {round(amount * 1.5)}', new Map([['amount', 180000]]), ctx)).toBe('Total: 270000');
+ });
+
+ it('in call position the function table wins; bare, a variable of the same name still wins', () => {
+ const vars = { round: 99 };
+ expect(tpl('round', vars)).toBe(99); // bare → variable (unchanged)
+ expect(tpl('round(1.4)', vars)).toBe(1); // call → the function
+ });
+});
+
+// ── Half 2: the LOUD diagnostic — unknown function ⇒ named error, never null ─
+
+describe('unknown function in call position is a named, guard-marked error (#11060)', () => {
+ const CASES: Array<{ expr: string; fn: string; hint?: string }> = [
+ // The issue's measured spellings, verbatim:
+ { expr: 'ROUND(amount * (1 - discount / 100), 2)', fn: 'ROUND', hint: "Did you mean 'round'" },
+ { expr: 'round2(x)', fn: 'round2', hint: "Did you mean 'round'" },
+ { expr: 'Math.round(amount)', fn: 'Math.round', hint: "Did you mean 'round'" },
+ { expr: 'Number(x)', fn: 'Number' },
+ { expr: '(amount * 0.7).toFixed(2)', fn: 'toFixed' },
+ ];
+
+ for (const { expr, fn, hint } of CASES) {
+ it(`'${expr}' names '${fn}' instead of writing undefined`, () => {
+ let caught: unknown;
+ try { tpl(expr, { amount: 180000, discount: 30, x: 1 }); } catch (e) { caught = e; }
+ expect(caught, `'${expr}' must throw — got value instead`).toBeInstanceOf(FlowExpressionFunctionError);
+ const err = caught as FlowExpressionFunctionError;
+ expect(err.problem).toBe('unknown-function');
+ expect(err.fn).toBe(fn);
+ expect(err.message).toContain(`'${fn}'`);
+ if (hint) expect(err.message).toContain(hint);
+ // #3863 — the metadata is wrong; a `fault` edge must not route it.
+ expect(isGuardRefusal(err), 'must be guard-marked').toBe(true);
+ });
+ }
+
+ it('the diagnostic reaches EMBEDDED tokens too — no silent empty-string leg left', () => {
+ expect(() => interpolateString('Total: {ROUND(amount)}', new Map([['amount', 1]]), ctx))
+ .toThrow(FlowExpressionFunctionError);
+ });
+
+ it('NOW/TODAY misused inside arithmetic get the whole-token guidance (they never worked here — but now they say so)', () => {
+ let caught: unknown;
+ try { tpl('TODAY() + amount * 2', { amount: 1 }); } catch (e) { caught = e; }
+ expect(caught).toBeInstanceOf(FlowExpressionFunctionError);
+ expect((caught as FlowExpressionFunctionError).message).toContain('whole token');
+ });
+});
+
+// ── Over-denial controls: everything that worked keeps working ─────────────
+
+describe('over-denial controls — the diagnostic is not a blanket refusal (#11060)', () => {
+ it('operator-only arithmetic (no identifiers in call position) still evaluates', () => {
+ const x = 125999.99999999999;
+ expect(tpl('(x * 100 + 0.5 - ((x * 100 + 0.5) % 1)) / 100', { x })).toBe(126000);
+ expect(tpl('a + b * 2', { a: 1, b: 3 })).toBe(7);
+ });
+
+ it('NOW()/TODAY() whole-token macros are unchanged', () => {
+ expect(String(tpl('NOW()'))).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
+ expect(String(tpl('TODAY()'))).toMatch(/^\d{4}-\d{2}-\d{2}$/);
+ const plus90 = new Date();
+ plus90.setDate(plus90.getDate() + 90);
+ expect(tpl('TODAY() + 90')).toBe(plus90.toISOString().slice(0, 10));
+ });
+
+ it('unresolved NON-call identifiers keep the documented fail-soft contract', () => {
+ expect(tpl('missing')).toBeUndefined();
+ expect(interpolateString('x={missing}', new Map(), ctx)).toBe('x=');
+ });
+});
diff --git a/packages/services/service-automation/src/builtin/template.ts b/packages/services/service-automation/src/builtin/template.ts
index d23cae846b..31ab794e1a 100644
--- a/packages/services/service-automation/src/builtin/template.ts
+++ b/packages/services/service-automation/src/builtin/template.ts
@@ -13,10 +13,22 @@
* {NOW()} → ISO timestamp at evaluation time
* {TODAY()} → YYYY-MM-DD at evaluation time
* {TODAY() + 90} → date + N days (days only, integer)
+ * {round(x)} {floor(x)} {ceil(x)}
+ * {abs(x)} {min(a, b)} {max(a, b)}
+ * → the CEL stdlib's numeric six, names and
+ * semantics mirrored 1:1 (#11060 — see
+ * KNOWN_EXPRESSION_FUNCTIONS below)
*
* Anything that fails to resolve becomes the literal `null` value (for
* single-token templates) or the empty string (for embedded substitution),
- * matching the behavior of common low-code formula engines.
+ * matching the behavior of common low-code formula engines — with ONE loud
+ * exception (#11060): an identifier in CALL position (`name(…)`) that is not a
+ * supported function throws {@link FlowExpressionFunctionError} instead of
+ * being rewritten to `null`. Before that diagnostic, `ROUND(…)` /
+ * `Math.round(…)` / `(x).toFixed(2)` all compiled to `null(…)`, the TypeError
+ * was swallowed, and the field was silently written `undefined` — so a flow
+ * could never round a computed money value to its field's declared `scale`,
+ * and nothing said why.
*
* The interpolator walks objects, arrays, and primitives recursively so it
* can be applied wholesale to a node's `config.fields`/`config.filter` blocks.
@@ -24,9 +36,155 @@
import type { AutomationContext } from '@objectstack/spec/contracts';
import { isKnownFilterToken } from '@objectstack/spec/data';
+import { nearestName } from '@objectstack/formula';
+import { markGuardRefusal } from '../guard-refusal.js';
export type VariableMap = Map;
+/**
+ * A function-shaped defect in a flow VALUE expression (#11060) — an unknown
+ * name in call position, a supported name called at the wrong arity, or an
+ * argument outside the function's domain.
+ *
+ * This is the LOUD half of the #11060 ruling: the silent-`null` rewrite of
+ * unknown identifiers hid every one of these as an `undefined` field write.
+ * The error is a guard refusal (#3863) — the metadata (the authored
+ * expression) is wrong, re-running the flow unchanged can never succeed, and
+ * a `fault` edge must not be able to swallow it back into silence.
+ */
+export class FlowExpressionFunctionError extends Error {
+ /** The function name as authored (`'ROUND'`, `'Math.round'`, `'round'`). */
+ readonly fn: string;
+ /** Which contract the call broke. */
+ readonly problem: 'unknown-function' | 'arity' | 'argument';
+
+ constructor(fn: string, problem: 'unknown-function' | 'arity' | 'argument', message: string) {
+ super(message);
+ this.name = 'FlowExpressionFunctionError';
+ this.fn = fn;
+ this.problem = problem;
+ markGuardRefusal(this);
+ }
+}
+
+/**
+ * The value-expression function table (#11060) — maintainer ruling 2026-08-23:
+ * exactly `round` / `floor` / `ceil` / `abs` / `min` / `max`, every name and
+ * semantic mirrored **1:1 from the CEL stdlib** (`@objectstack/formula`
+ * `src/stdlib.ts`, "Numbers" block), ⛔ no second semantics invented. A parity
+ * test (`template-functions.test.ts`) drives BOTH engines over one input grid
+ * so a divergence cannot land silently.
+ *
+ * Carrier note — why these return plain JS numbers while the stdlib returns
+ * BigInt for `round`/`floor`/`ceil`: cel-js carries CEL `int` as BigInt, and
+ * the CEL engine's public boundary (`cel-engine.ts` `coerce`) hands callers a
+ * plain number whenever the value fits the safe-integer range. THIS dialect's
+ * operators are plain JS, where a BigInt result would throw on the next `/`
+ * (`round(x * 100) / 100` — the canonical scale-2 authoring pattern, identical
+ * in CEL). So the table returns exactly the post-coercion value the public CEL
+ * surface yields. The two edges where that value CANNOT be mirrored into JS
+ * arithmetic are named errors instead of silent corruption: a non-finite
+ * argument (CEL faults there too — `BigInt(NaN)` throws inside the stdlib) and
+ * a result beyond `Number.MAX_SAFE_INTEGER` (CEL's boundary switches carrier
+ * to string there; a string riding into `/ 100` would corrupt silently).
+ *
+ * `NOW()` / `TODAY()` are deliberately NOT in this table: they are whole-token
+ * date macros with their own `± N days` grammar, handled before this path.
+ */
+const EXPRESSION_FUNCTION_ARITY: Record = {
+ round: 1,
+ floor: 1,
+ ceil: 1,
+ abs: 1,
+ min: 2,
+ max: 2,
+};
+
+function requireArity(fn: string, args: unknown[]): void {
+ const want = EXPRESSION_FUNCTION_ARITY[fn];
+ if (args.length !== want) {
+ // cel-js refuses the same call with "no matching overload" — same
+ // outcome, message written for self-correction (ADR-0032 §1d). The
+ // `round(x, 2)` precision form is THE anticipated misuse (#11060), so
+ // its refusal carries the supported spelling.
+ const precisionHint = fn === 'round' && args.length === 2
+ ? ' There is no precision form — the CEL stdlib\'s round() is integer-only; for N-decimal rounding write round(x * 100) / 100 (scale 2), matching the CEL authoring pattern.'
+ : '';
+ throw new FlowExpressionFunctionError(
+ fn,
+ 'arity',
+ `flow value expression: ${fn}() takes exactly ${want} argument${want === 1 ? '' : 's'}, got ${args.length}.${precisionHint}`,
+ );
+ }
+}
+
+/** Mirror of the CEL boundary for an int-typed result — see the table note. */
+function celIntegerResult(fn: string, result: number): number {
+ if (!Number.isFinite(result)) {
+ throw new FlowExpressionFunctionError(
+ fn,
+ 'argument',
+ `flow value expression: ${fn}() needs a numeric argument, got a value that is not a finite number. ` +
+ `(The CEL stdlib faults on the same input.)`,
+ );
+ }
+ if (Math.abs(result) > Number.MAX_SAFE_INTEGER) {
+ throw new FlowExpressionFunctionError(
+ fn,
+ 'argument',
+ `flow value expression: ${fn}() result ${result} exceeds the safe integer range — ` +
+ `it cannot be represented exactly. (The CEL boundary returns a string here, which this ` +
+ `dialect's arithmetic cannot compose; refusing loudly instead.)`,
+ );
+ }
+ // JS Math.round(-0.5) / Math.ceil(-0.2) yield -0; the CEL path's
+ // BigInt(-0) → Number(0n) collapses it to +0. Mirror that collapse —
+ // the parity test compares with Object.is and would red on -0.
+ return Object.is(result, -0) ? 0 : result;
+}
+
+type TemplateExpressionFunction = (...args: unknown[]) => unknown;
+
+const KNOWN_EXPRESSION_FUNCTIONS: Record = {
+ // stdlib: 'abs(dyn): double' → Math.abs(Number(x)) — a non-numeric input
+ // yields NaN (a double), same as CEL; no fault, mirrored exactly.
+ abs: (...args) => { requireArity('abs', args); return Math.abs(Number(args[0])); },
+ // stdlib: 'round(dyn): int' → BigInt(Math.round(Number(x))). JS Math.round
+ // rounds half toward +∞ (round(-1.5) === -1) — that IS the mirrored mode.
+ round: (...args) => { requireArity('round', args); return celIntegerResult('round', Math.round(Number(args[0]))); },
+ // stdlib: floor/ceil round toward −∞ / +∞ (floor(-1.2) === -2, ceil(-1.2) === -1).
+ floor: (...args) => { requireArity('floor', args); return celIntegerResult('floor', Math.floor(Number(args[0]))); },
+ ceil: (...args) => { requireArity('ceil', args); return celIntegerResult('ceil', Math.ceil(Number(args[0]))); },
+ // stdlib: 'min(dyn, dyn): dyn' — returns the smaller/larger OPERAND
+ // verbatim (type preserved), comparison numeric. Exact lambda copy.
+ min: (...args) => { requireArity('min', args); return Number(args[0]) <= Number(args[1]) ? args[0] : args[1]; },
+ max: (...args) => { requireArity('max', args); return Number(args[0]) >= Number(args[1]) ? args[0] : args[1]; },
+};
+
+const KNOWN_EXPRESSION_FUNCTION_NAMES = Object.keys(KNOWN_EXPRESSION_FUNCTIONS);
+
+/** Compose the unknown-function refusal, with a did-you-mean when one is near. */
+function unknownFunctionError(name: string, expr: string): FlowExpressionFunctionError {
+ const last = name.includes('.') ? name.slice(name.lastIndexOf('.') + 1) : name;
+ const lower = last.toLowerCase();
+ const suggestion = KNOWN_EXPRESSION_FUNCTION_NAMES.includes(lower)
+ ? lower
+ : nearestName(lower, KNOWN_EXPRESSION_FUNCTION_NAMES);
+ const hint = name === 'NOW' || name === 'TODAY'
+ ? ` ${name}() is supported only as the whole token, with an optional ± N day offset (e.g. {${name}() + 7}).`
+ : suggestion
+ ? ` Did you mean '${suggestion}'?${name.includes('.') || name !== last ? ' Method/namespace call syntax is not supported — write the bare form.' : ''}`
+ : '';
+ return new FlowExpressionFunctionError(
+ name,
+ 'unknown-function',
+ `flow value expression: unknown function '${name}' in '${expr}'. ` +
+ `Value expressions support round, floor, ceil, abs, min, max (1:1 with the CEL stdlib) ` +
+ `and the whole-token date macros NOW() / TODAY().${hint} ` +
+ `(Before #11060 this name was silently rewritten to null and the field was written undefined.)`,
+ );
+}
+
/**
* Resolve a dotted path against a base value.
* Returns `undefined` for any missing intermediate node.
@@ -98,9 +256,21 @@ function resolveToken(token: string, variables: VariableMap, context: Automation
// dots and identifier characters) — never executed on raw user input.
if (!/^[\w\s+\-*/%().,?:<>=!&|"'$]+$/.test(trimmed)) return undefined;
let safe = trimmed;
- safe = safe.replace(/([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)/g, (match) => {
+ safe = safe.replace(/([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)/g, (match, _p1, offset: number, whole: string) => {
// Don't substitute reserved literals
if (match === 'true' || match === 'false' || match === 'null' || match === 'undefined') return match;
+ // CALL position (`name(…)`) resolves against the function table, never
+ // against flow variables (#11060). A known name stays literal — it is
+ // bound as a Function parameter below. An unknown one is the loud half
+ // of the ruling: refuse with a named error instead of the old `null`
+ // rewrite, whose swallowed TypeError wrote the field as `undefined`.
+ // (The lookahead reads the ORIGINAL string — String.replace never
+ // rescans substituted output, so a variable's value cannot fabricate a
+ // call position.)
+ if (/^\s*\(/.test(whole.slice(offset + match.length))) {
+ if (Object.prototype.hasOwnProperty.call(KNOWN_EXPRESSION_FUNCTIONS, match)) return match;
+ throw unknownFunctionError(match, trimmed);
+ }
const segs = match.split('.');
const head = segs[0];
let val: unknown;
@@ -112,9 +282,14 @@ function resolveToken(token: string, variables: VariableMap, context: Automation
});
try {
// eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
- const fn = new Function(`"use strict"; return (${safe});`);
- return fn();
- } catch {
+ const fn = new Function(...KNOWN_EXPRESSION_FUNCTION_NAMES, `"use strict"; return (${safe});`);
+ return fn(...KNOWN_EXPRESSION_FUNCTION_NAMES.map((n) => KNOWN_EXPRESSION_FUNCTIONS[n]));
+ } catch (err) {
+ // The named diagnostics (arity / domain, thrown inside a table
+ // function) must escape — swallowing them here would re-create the
+ // exact silence #11060 removes. Everything else (junk syntax after
+ // substitution) keeps the documented fail-soft contract.
+ if (err instanceof FlowExpressionFunctionError) throw err;
return undefined;
}
}
diff --git a/packages/services/service-automation/src/flow-field-expression-scale.integration.test.ts b/packages/services/service-automation/src/flow-field-expression-scale.integration.test.ts
new file mode 100644
index 0000000000..9e6b64b3fe
--- /dev/null
+++ b/packages/services/service-automation/src/flow-field-expression-scale.integration.test.ts
@@ -0,0 +1,186 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * #11060 end-to-end oracle — the hotcrm quote-flow shape (hotcrm#1206),
+ * reproduced in-tree because that repo is out of reach from here: a flow
+ * computes a discounted money value (`180000 * (1 - 30/100)` =
+ * `125999.99999999999`) and writes it into a `scale: 2` currency field.
+ *
+ * Real stack end to end: ObjectKernel + ObjectQLPlugin + better-sqlite3
+ * `:memory:` driver + AutomationServicePlugin — so the #7501 `scale`
+ * enforcement in ObjectQL's record validator is the REAL gate the write must
+ * pass, and every assertion below reads the PERSISTED row, never the
+ * expression result.
+ *
+ * - negative control: the raw product is refused (`max_scale`) — proves the
+ * oracle's gate is live in this harness, not assumed;
+ * - oracle: `round(x * 100) / 100` (the CEL-identical authoring pattern)
+ * lands `126000` in the field;
+ * - the same pattern through the `assignment` surface
+ * (`config.assignments`) — the third value-producing surface the issue
+ * names — persists identically;
+ * - the LOUD half: an unknown function (`ROUND`) fails the run with a named
+ * error INSTEAD of writing `undefined`, and a `fault` edge cannot swallow
+ * it (#3863 guard refusal).
+ */
+
+import { describe, it, expect, afterEach } from 'vitest';
+import { ObjectKernel } from '@objectstack/core';
+import { ObjectQLPlugin, type ObjectQL } from '@objectstack/objectql';
+import { SqlDriver } from '@objectstack/driver-sql';
+import { AutomationServicePlugin } from './plugin.js';
+import type { AutomationEngine } from './engine.js';
+
+function makeSqliteDriver() {
+ return new SqlDriver({
+ client: 'better-sqlite3',
+ connection: { filename: ':memory:' },
+ useNullAsDefault: true,
+ });
+}
+
+/** The quote shape: a `scale: 2` currency field, as hotcrm#1206 declares it. */
+const quote = {
+ name: 'quote',
+ label: 'Quote',
+ fields: {
+ title: { name: 'title', label: 'Title', type: 'text' },
+ total: { name: 'total', label: 'Total', type: 'currency', scale: 2 },
+ },
+};
+
+/** start → create_record(quote) → end, computing `total` from flow inputs. */
+const quoteFlow = (name: string, totalExpr: string, extraNodes: any[] = [], extraEdges: any[] = []) => ({
+ name,
+ label: name,
+ type: 'autolaunched',
+ runAs: 'system',
+ variables: [
+ { name: 'amount', type: 'number', isInput: true },
+ { name: 'discount', type: 'number', isInput: true },
+ ],
+ nodes: [
+ { id: 'start', type: 'start', label: 'Start' },
+ { id: 'mk', type: 'create_record', label: 'Create', config: { objectName: 'quote', fields: { title: name, total: totalExpr } } },
+ { id: 'end', type: 'end', label: 'End' },
+ ...extraNodes,
+ ],
+ edges: [
+ { id: 'e1', source: 'start', target: 'mk' },
+ { id: 'e2', source: 'mk', target: 'end' },
+ ...extraEdges,
+ ],
+});
+
+const INPUTS = { amount: 180000, discount: 30 };
+
+describe('flow-computed money lands within its declared scale (#11060, oracle for hotcrm#1206)', () => {
+ let kernel: ObjectKernel;
+ let ql: ObjectQL;
+ let automation: AutomationEngine;
+
+ afterEach(async () => {
+ try { await kernel?.shutdown(); } catch { /* noop */ }
+ });
+
+ async function boot() {
+ kernel = new ObjectKernel({ logger: { level: 'fatal' } });
+ await kernel.use(new ObjectQLPlugin());
+ await kernel.use(new AutomationServicePlugin({ suspendedRunStore: 'memory' }));
+ await kernel.bootstrap();
+
+ ql = kernel.getService('objectql');
+ automation = kernel.getService('automation');
+
+ const driver = makeSqliteDriver();
+ await driver.connect();
+ ql.registerDriver(driver, true);
+ ql.registry.registerObject(quote as any, 'scale-test', 'scale-test');
+ await ql.syncSchemas();
+ }
+
+ const quoteByTitle = (title: string) =>
+ ql.findOne('quote', { where: { title }, context: { isSystem: true } });
+
+ it('NEGATIVE CONTROL: the raw product is refused by scale enforcement — the gate is live in this harness', async () => {
+ await boot();
+ automation.registerFlow('raw', quoteFlow('raw', '{amount * (1 - discount / 100)}') as any);
+
+ const res = await automation.execute('raw', { userId: 'u1', params: { ...INPUTS } });
+ expect(res.success, `the unrounded 125999.99999999999 must be refused: ${JSON.stringify(res)}`).toBe(false);
+ // #7501's max_scale refusal, in its user-facing wording — the raw
+ // product carries 11 decimal places against the declared 2.
+ expect(JSON.stringify(res)).toContain('must have at most 2 decimal places (got 11)');
+ expect(await quoteByTitle('raw'), 'no row may persist from the refused write').toBeFalsy();
+ });
+
+ it('ORACLE: round(x * 100) / 100 writes 126000 into the scale-2 currency field, end to end', async () => {
+ await boot();
+ automation.registerFlow(
+ 'rounded',
+ quoteFlow('rounded', '{round(amount * (1 - discount / 100) * 100) / 100}') as any,
+ );
+
+ const res = await automation.execute('rounded', { userId: 'u1', params: { ...INPUTS } });
+ expect(res.success, `run failed: ${JSON.stringify(res)}`).toBe(true);
+
+ const row = await quoteByTitle('rounded');
+ expect(row, 'the quote row must persist').toBeTruthy();
+ // The PERSISTED value — not the expression result.
+ expect(row.total).toBe(126000);
+ });
+
+ it('the assignment surface computes the same rounded value (config.assignments → interpolate)', async () => {
+ await boot();
+ const flow = {
+ name: 'via_assignment',
+ label: 'via_assignment',
+ type: 'autolaunched',
+ runAs: 'system',
+ variables: [
+ { name: 'amount', type: 'number', isInput: true },
+ { name: 'discount', type: 'number', isInput: true },
+ ],
+ nodes: [
+ { id: 'start', type: 'start', label: 'Start' },
+ { id: 'calc', type: 'assignment', label: 'Calc', config: { assignments: { discounted: '{round(amount * (1 - discount / 100) * 100) / 100}' } } },
+ { id: 'mk', type: 'create_record', label: 'Create', config: { objectName: 'quote', fields: { title: 'via_assignment', total: '{discounted}' } } },
+ { id: 'end', type: 'end', label: 'End' },
+ ],
+ edges: [
+ { id: 'e1', source: 'start', target: 'calc' },
+ { id: 'e2', source: 'calc', target: 'mk' },
+ { id: 'e3', source: 'mk', target: 'end' },
+ ],
+ };
+ automation.registerFlow('via_assignment', flow as any);
+
+ const res = await automation.execute('via_assignment', { userId: 'u1', params: { ...INPUTS } });
+ expect(res.success, `run failed: ${JSON.stringify(res)}`).toBe(true);
+ expect((await quoteByTitle('via_assignment'))?.total).toBe(126000);
+ });
+
+ it('LOUD half: an unknown function fails the run with a NAMED error — and a fault edge cannot swallow it', async () => {
+ await boot();
+ // The fault edge routes ordinary runtime failures; #3863 guard
+ // refusals — metadata defects like this one — must NOT route, or one
+ // edge would turn the diagnostic back into the silence it replaces.
+ automation.registerFlow(
+ 'shouty',
+ quoteFlow(
+ 'shouty',
+ '{ROUND(amount * (1 - discount / 100), 2)}',
+ [{ id: 'recover', type: 'assignment', label: 'Recover', config: { assignments: { swallowed: 'yes' } } }],
+ [{ id: 'f1', source: 'mk', target: 'recover', type: 'fault' }],
+ ) as any,
+ );
+
+ const res = await automation.execute('shouty', { userId: 'u1', params: { ...INPUTS } });
+ expect(res.success, `the run must FAIL loudly, not route or succeed: ${JSON.stringify(res)}`).toBe(false);
+ const dump = JSON.stringify(res);
+ expect(dump).toContain("unknown function 'ROUND'");
+ expect(dump).toContain('round'); // the did-you-mean prescription travels with the failure
+ // Nothing persisted — before #11060 this wrote the field as undefined.
+ expect(await quoteByTitle('shouty')).toBeFalsy();
+ });
+});