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
{{ message }}
Repository navigation
Formula guardrail: cel-js arithmetic silently returns null (double × int + bare identifiers) #1928
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:
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.
Feasibility verified (cel-js 7.6.1) — both compile-time AND runtime fixes work
Spiked both approaches against the pinned cel-js. Yes, these bugs can be caught at metadata-compile time — and there's an even cleaner runtime fix that removes the author burden entirely.
A. Compile-time type check (answers "can the build catch it?") ✅
The build env currently uses unlistedVariablesAreDyn: true, so every field is dyn and nothing type-checks. If the build-time validator instead declares record/input/previous with the schema's real field types and runs cel-js check():
So a typed check() catches a superset of what we hand-fixed in #1927:
double / int arithmetic overload → flagged
unknown/typo field (amont) → flagged
bare identifiers (status instead of record.status) → flagged as undeclared variable (with unlistedVariablesAreDyn:false)
This is a build-time-only environment (runtime intentionally stays dyn + string-hydration retry), wired into validate-expressions.ts (ADR-0032) which already has the object schema. Needs a field-type → CEL-type map (number/currency/percent→double, text/select→string, checkbox→bool, date/datetime→google.protobuf.Timestamp, lookup→nested type or dyn).
B. Runtime fix via registerOperator (makes the common case Just Work) ✅
My earlier note said cel-js rejects operator overloads — that was the wrong API (registerFunction('_/_…')). The correct API is registerOperator('double / int', impl):
~10 lines in buildEnv/registerStdLib (cel-engine.ts). amount / 100 then works with no float-literal burden, and pure int/int keeps integer-division semantics. Composes with the existing string-hydration retry.
Recommendation
Ship both: B removes the footgun for authors (no .0 ritual), A turns the genuinely wrong cases (typos, type mismatches, bare identifiers) into actionable build errors. With B in place, the / 100.0 workaround in #1927's expected_revenue can revert to plain / 100.
Strategy: validate every expression, tiered by what can be checked soundly
Re-evaluated against the mission (all metadata is AI-authored → catch mistakes at build). The goal is yes, validate every expression site. But there's a hard design law, learned the hard way:
The build-time checker may only reject what the runtime evaluator would also fail. Anything stricter is a false positive — and since the authors are AIs generating the whole ecosystem, a false-positive gate breaks the very builds it's meant to protect.
So coverage is staged by soundness, not by ambition. Three findings pin the tiers:
Per-site scope differs. Flow/automation conditions flatten the record's fields to top-level (status is valid); Field.formula and validation predicates bind only the record namespace (status is a silent-null bug). A single rule is wrong for both.
Real-typed checking false-positives. Declaring fields with true types makes record.lead_score != null fault (no such overload: double != null) — null-guards are everywhere and correct at runtime. So fields must be dyn in the checker, not their real types.
Flow conditions also carry flow variables (expiring_deals, new_opportunity) that aren't schema-knowable, so a bare identifier there can't be told apart from a typo.
Tiers
Tier
Catches
Soundness
Status
1. Bare ref in record-scope (formula + validation)
needs flow-var awareness → can't hard-gate; edit-distance warning only
proposed, advisory
4. Full type-soundness (string vs number, date misuse)
wrong-type compares
high false-positive surface; must mirror runtime exactly + bake as warnings
deferred
Tiers 1+2 are the dominant AI-error classes (silent-null formulas, silent-skip rules) and are now hard build errors with zero false positives. Tiers 3–4 are real but need a warning channel (two-tier guardrail) before they can gate — validateStackExpressions currently hard-fails on every issue, so adding severity: warning is the prerequisite. Recommend tackling that next if we want flow-condition typo coverage.
Field.date stores a full timestamp → exact equality against a re-computed timestamp silently never matches. We now lint this (feat(cli): two flow anti-pattern lints — date-equality filters (#1874) + phantom aggregation (#1870) #1950) and the templates use day-window ranges, but the ROOT semantic gap stands: authors naturally write date_field == X. A real fix = normalize Field.date to date-only (00:00:00Z) on write, or compare date-only in the query layer. Needs an ADR — changes storage/query semantics, migration risk.
Tier 4 — full expression type-soundness. Still deferred under the design law (the build checker may only reject what runtime would also fail).
Closing the loop on the catalog/runtime mismatch and the date-equality footgun; leaving this open for the two items above.
Remaining item #1 (Field.date date-only semantics) is now spec'd as a proposed ADR: #1962 (ADR-0053). It frames the root fix as the industry-standard date(tz-naive)/datetime(instant) split, staged: Phase 1 = date-as-YYYY-MM-DD-string at the driver boundary (low risk, fixes equality), Phase 2 = reference-timezone model for today()/daysFromNow()/rendering (needs review). Awaiting decision before implementation.
Closing — the formula/flow guardrail series is fully landed
Both footguns from the original report, plus the whole systemic-prevention program that grew out of them, are shipped. Summary of everything that closed this out:
Tier 4 was the last deferred item — it needed a warning channel (shipped in tier 3) and a checker that mirrors runtime exactly (number/date fields → dyn, equality excluded), so it flags only what the runtime would also fail. It covers Field.formula, validation/action/hook/sharing predicates, and flow conditions.
Runtime parity
Schema-aware validation at registerFlow — a flow registered dynamically (API/Studio, bypassing objectstack build) now gets the same tier-2/3/4 checks as advisory warnings, via an opt-in object-schema resolver. Strictly additive; the fatal set is unchanged. — feat(automation): schema-aware flow-condition validation at registration (#1928) #3190
Activity
os-zhuang commentedon Jun 15, 2026
Feasibility verified (cel-js 7.6.1) — both compile-time AND runtime fixes work
Spiked both approaches against the pinned cel-js. Yes, these bugs can be caught at metadata-compile time — and there's an even cleaner runtime fix that removes the author burden entirely.
A. Compile-time type check (answers "can the build catch it?") ✅
The build env currently uses
unlistedVariablesAreDyn: true, so every field isdynand nothing type-checks. If the build-time validator instead declaresrecord/input/previouswith the schema's real field types and runs cel-jscheck():So a typed check() catches a superset of what we hand-fixed in #1927:
double / intarithmetic overload → flaggedamont) → flaggedstatusinstead ofrecord.status) → flagged as undeclared variable (withunlistedVariablesAreDyn:false)This is a build-time-only environment (runtime intentionally stays
dyn+ string-hydration retry), wired intovalidate-expressions.ts(ADR-0032) which already has the object schema. Needs a field-type → CEL-type map (number/currency/percent→double, text/select→string, checkbox→bool, date/datetime→google.protobuf.Timestamp, lookup→nested type ordyn).B. Runtime fix via
registerOperator(makes the common case Just Work) ✅My earlier note said cel-js rejects operator overloads — that was the wrong API (
registerFunction('_/_…')). The correct API isregisterOperator('double / int', impl):~10 lines in
buildEnv/registerStdLib(cel-engine.ts).amount / 100then works with no float-literal burden, and pure int/int keeps integer-division semantics. Composes with the existing string-hydration retry.Recommendation
Ship both: B removes the footgun for authors (no
.0ritual), A turns the genuinely wrong cases (typos, type mismatches, bare identifiers) into actionable build errors. With B in place, the/ 100.0workaround in #1927'sexpected_revenuecan revert to plain/ 100.os-zhuang commentedon Jun 15, 2026
Strategy: validate every expression, tiered by what can be checked soundly
Re-evaluated against the mission (all metadata is AI-authored → catch mistakes at build). The goal is yes, validate every expression site. But there's a hard design law, learned the hard way:
So coverage is staged by soundness, not by ambition. Three findings pin the tiers:
statusis valid);Field.formulaand validation predicates bind only therecordnamespace (statusis a silent-null bug). A single rule is wrong for both.record.lead_score != nullfault (no such overload: double != null) — null-guards are everywhere and correct at runtime. So fields must bedynin the checker, not their real types.expiring_deals,new_opportunity) that aren't schema-knowable, so a bare identifier there can't be told apart from a typo.Tiers
amount→ nullrecord.Xtypos at all sitesrecord.amontstaus == "done"Tiers 1+2 are the dominant AI-error classes (silent-null formulas, silent-skip rules) and are now hard build errors with zero false positives. Tiers 3–4 are real but need a warning channel (two-tier guardrail) before they can gate —
validateStackExpressionscurrently hard-fails on every issue, so addingseverity: warningis the prerequisite. Recommend tackling that next if we want flow-condition typo coverage.os-zhuang commentedon Jun 16, 2026
Tier 3 implemented in #1933 (advisory did-you-mean warnings for likely field typos in flow conditions; non-blocking). Tiers now shipped: 1 (record-scope bare refs, hard error — #1931), 2 (record.X existence, regex — pre-existing), 3 (flow-condition typos, warning — #1933). Tier 4 (full type-soundness) remains deferred — it needs the checker to mirror runtime string-hydration/optional-type semantics exactly and a longer warning bake. Runtime arithmetic fix: #1930.
fix(formula): register mixed double/int arithmetic overloads (#1928) (#…
feat(validate): flag bare field refs in record-scoped CEL sites (#1928)…
feat(validate): did-you-mean warnings for field typos in flow conditi…
feat(validate): did-you-mean warnings for field typos in flow conditi…
feat(formula): align CEL stdlib catalog with runtime (#1928) (#1949)
os-zhuang commentedon Jun 16, 2026
Status — platform guardrails shipped
The AI-authoring guardrail series for the formula/flow subsystem is now largely landed:
double × intarithmetic overloads (amount / 100works)$in/$eq) vs time values in query filtersaggregations/groupBy/… → silent no-op)Remaining (deferred)
Field.datestores a full timestamp → exact equality against a re-computed timestamp silently never matches. We now lint this (feat(cli): two flow anti-pattern lints — date-equality filters (#1874) + phantom aggregation (#1870) #1950) and the templates use day-window ranges, but the ROOT semantic gap stands: authors naturally writedate_field == X. A real fix = normalizeField.dateto date-only (00:00:00Z) on write, or compare date-only in the query layer. Needs an ADR — changes storage/query semantics, migration risk.Closing the loop on the catalog/runtime mismatch and the date-equality footgun; leaving this open for the two items above.
os-zhuang commentedon Jun 16, 2026
Remaining item #1 (Field.date date-only semantics) is now spec'd as a proposed ADR: #1962 (ADR-0053). It frames the root fix as the industry-standard date(tz-naive)/datetime(instant) split, staged: Phase 1 = date-as-YYYY-MM-DD-string at the driver boundary (low risk, fixes equality), Phase 2 = reference-timezone model for today()/daysFromNow()/rendering (needs review). Awaiting decision before implementation.
docs(adr): ADR-0053 — date is a tz-naive calendar day, datetime is an…
fix(driver-sql): store/return Field.date as a tz-naive YYYY-MM-DD cal…
feat(formula,lint): extend tier-4 type-soundness to flattened flow co…
feat(formula,lint): advisory type-soundness warnings for expressions (#…
feat(automation): schema-aware flow-condition validation at registrat…
feat(automation): schema-aware flow-condition validation at registrat…
os-zhuang commentedon Jul 18, 2026
Closing — the formula/flow guardrail series is fully landed
Both footguns from the original report, plus the whole systemic-prevention program that grew out of them, are shipped. Summary of everything that closed this out:
The two original footguns
double × intarithmetic silently null → fixed at runtime viaregisterOperatormixed-numeric overloads.record.amount / 100now Just Works, no.0ritual. — fix(formula): register mixed double/int arithmetic overloads (#1928) #1930statusinstead ofrecord.status) → hard build error with a corrective message. — feat(validate): flag bare field refs in record-scoped CEL sites (#1928) #1931Build-time guardrail tiers (ADR-0032)
record.Xfield-existence / typosTier 4 was the last deferred item — it needed a warning channel (shipped in tier 3) and a checker that mirrors runtime exactly (number/date fields →
dyn, equality excluded), so it flags only what the runtime would also fail. It coversField.formula, validation/action/hook/sharing predicates, and flow conditions.Runtime parity
registerFlow— a flow registered dynamically (API/Studio, bypassingobjectstack build) now gets the same tier-2/3/4 checks as advisory warnings, via an opt-in object-schema resolver. Strictly additive; the fatal set is unchanged. — feat(automation): schema-aware flow-condition validation at registration (#1928) #3190Adjacent hardening that came out of this
daysBetween/abs/round/upper/…) registered + drift-guarded. — feat(formula): align CEL stdlib catalog with runtime (#1928) #1949date= tz-naive calendar day,datetime= instant): Phase 1 driver-boundary normalization (fix(driver-sql): Field.date is a tz-naive YYYY-MM-DD calendar day (ADR-0053 Phase 1) #1968) + Phase 2 reference-timezone model — resolver feat(runtime): resolve a reference timezone onto ExecutionContext #1993, read-time formula context fix(objectql): thread execution context into read-time formula evaluation #1988, tz-awaretoday()/daysFromNow()feat(formula): timezone-aware today() / daysFromNow() / daysAgo() #1998, datetime rendering feat(formula,email): render datetime in reference timezone (ADR-0053 Phase 2) (#1981) #2003, localization settings feat(settings): localization settings — platform timezone/language/formats (ADR-0053 Phase 2) (#1928) #2006.All merged to
main. Closing as completed.Generated by Claude Code
feat(formula): warn on `date` field == temporal-function silent-miss (#…
feat(formula): warn on `date` field == temporal-function silent-miss (#…
docs(data-modeling): document build-time expression validation guardr…
feat(mcp): validate_expression tool — validate CEL against a schema b…
test(automation): end-to-end coverage for the #1928 object-schema res…
fix(lint): field-formula check reads the declared `expression`, activ…