From e678fe54010afc64747f44d08bfb80a288ab87aa Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 12:53:00 +0000 Subject: [PATCH 1/3] feat(service-automation): flow value expressions gain the CEL-mirrored function table (round/floor/ceil/abs/min/max) and a loud unknown-function diagnostic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maintainer ruling on #11060 (2026-08-23): option A lands now — the template evaluator's value expressions support exactly round, floor, ceil, abs, min, max, every name and semantic mirrored 1:1 from @objectstack/formula's CEL stdlib (pinned by a parity test driving both engines over one grid), with no second semantics invented. The silent-null rewrite of an identifier in call position becomes FlowExpressionFunctionError — a named, guard-marked (#3863) authoring diagnostic — instead of a swallowed TypeError writing the field as undefined. Oracle (hotcrm#1206 shape, in-tree): a discounted money write within the field's declared scale succeeds end to end on a real ObjectQL + sqlite stack; the raw product stays refused (#7501 max_scale). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4 --- .../src/builtin/template-functions.test.ts | 214 ++++++++++++++++++ .../src/builtin/template.ts | 185 ++++++++++++++- ...field-expression-scale.integration.test.ts | 186 +++++++++++++++ 3 files changed, 580 insertions(+), 5 deletions(-) create mode 100644 packages/services/service-automation/src/builtin/template-functions.test.ts create mode 100644 packages/services/service-automation/src/flow-field-expression-scale.integration.test.ts 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(); + }); +}); From 4136e77d9df2144db50f862b80b00a801fac3c24 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 12:57:06 +0000 Subject: [PATCH 2/3] changeset: minor for the flow expression function table Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4 --- .changeset/flow-expression-function-table.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/flow-expression-function-table.md 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. From a4a42120393af8824386d0b6b706b3089229357e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 13:26:31 +0000 Subject: [PATCH 3/3] docs(flows): the value-dialect reference now enumerates the six CEL-mirrored functions and the loud unknown-function failure mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PM review round on #11347: the 'Expressions in flows' table row for field values enumerated the dialect's whole vocabulary, so landing the function table without it would ship the capability undiscoverable — and the failure the loud diagnostic replaced belongs in the failure-modes callout, stated as the transition it is. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4 --- content/docs/automation/flows.mdx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) 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.