Skip to content

Formula guardrail: cel-js arithmetic silently returns null (double × int + bare identifiers) #1928

Description

@os-zhuang

Summary

Two formula-authoring footguns let an AI write a reasonable-looking Field.formula that passes objectstack build but silently evaluates to null at runtime — the failure class our guardrails are meant to eliminate. Both were found during 9.5.x release-prep testing of example-crm (fixed there in #1927; this issue tracks the systemic prevention).

1. No double × int arithmetic overload (primary)

cel-js (@marcbachmann/cel-js) types a record field number as double and a bare integer literal as int, and has no mixed arithmetic overload:

record.amount / 100              → ERR  no such overload: dyn<double> / int   → null
record.amount * 2                → ERR  no such overload: dyn<double> * int    → null
record.amount * record.probability / 100.0   → OK  (float literal)
record.amount >= 4               → OK   (cel-js DOES have mixed *comparison* overloads)

So amount / 100, price * 2, total - fee (int literal) all silently null. Only float literals (100.0, 2.0) work. Integer literals in arithmetic are ubiquitous → high-frequency footgun.

Constraint: cel-js rejects registering operator overloads — env.registerFunction('_/_(double, int): double', …)Invalid signature. So there is no trivial overload-registration fix.

Options

  • (a) Build-time lint — in validate-expressions.ts (ADR-0032), flag a number-typed field used as an arithmetic operand against an int literal; message: "use a float literal (e.g. / 100.0)". Cheapest, matches the advisory-warning guardrail strategy.
  • (b) Engine AST promotion — in the CEL engine, walk the parsed AST and promote int literals that are arithmetic operands to double. Must NOT touch int-typed function args (e.g. daysFromNow(30)). More robust (fixes silently), more invasive, needs tests.
  • (a) and (b) are complementary.

2. Bare identifiers in formula expressions (secondary)

Formula scope exposes only {record, previous, input, os} (see buildScope, packages/formula/src/stdlib.ts) — no field flattening. So:

cel`status == "converted"`        → null   (bare `status`)
cel`record.status == "converted"` → OK

validate-expressions.ts doesn't flag bare identifiers that fail to resolve. A lint that requires record./input./os.-qualified references in formula expressions would catch it at build.

Acceptance

  • An AI-authored expected_revenue = amount * probability / 100 is either rejected at build with an actionable message, or compiles to a working double formula.
  • Bare-identifier formula references are flagged at build.

Refs: #1927 (example-crm fixes), ADR-0032. Same "build-green / runtime-silent" family as #1877 / #1870 / #1876.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions