Skip to content

Bare dateField == today() silently returns false — fix via AST temporal-comparison rewrite in the CEL engine #3183

Description

@os-zhuang

Split out from #1980 (ADR-0053 Phase 2 · Slice 3). Independent of timezone.

Status: the build-time guardrail shipped in #3192 (advisory lint warning + the temporalEqualityFields AST helper). This issue tracks the runtime fix. The approach below (AST rewrite) supersedes both the original "data-layer hydration" proposal AND the interim "scope-hydration + fail-safe" plan — a serialize-based probe showed a cleaner path (see Approach).

Problem

A Field.date reads back as a YYYY-MM-DD string (ADR-0053 Phase 1, #1968). cel-js's equality (overloads.js isEqual) returns false for a string left operand without consulting any overload, and refuses cross-type object equality. So:

record.due_date == today()      // silently → false, even when due_date IS today
record.due_date != today()      // silently → true for a same-day record

No parse/type/runtime error — the silent-miss class ADR-0053/ADR-0032 exist to kill. Ordering operators (>=/<=/</>) already work (cel-js throws → the engine's hydrateOverloadStrings retry fires); only ==/!= fail silently.

Impact surface (corrected)

  • One chokepoint fixes everything interpreter-side: all sites route through celEngine.evaluate — read-time formulas + defaults (objectql/engine.ts), validation rules (rule-validator.ts), hook conditions (hook-wrappers.ts), flow conditions (service-automation/engine.ts), seed (seed-eval.ts).
  • RLS / sharing rules NOT affected: cel-to-filter.ts rejects function calls as a compile error (fail closed), so == today() in an RLS condition is already a loud authoring error, not a silent miss.
  • objectui (sibling repo): confirm client-side visible/disabled eval uses @objectstack/formula; if so it inherits the fix.

Approach — AST temporal-comparison rewrite in celEngine.evaluate

For each ==/!= node where one operand is a temporal call (today()/daysFromNow()/daysAgo()/now()) and the other is a field reference (record.<f> / previous.<f> / bare <f>), wrap that field operand in date(...), then serialize and evaluate. @marcbachmann/cel-js exposes serialize(ast) (verified faithful round-trip); date() = the stdlib toDate coercion.

record.due_date == today()                    → date(record.due_date) == today()
record.d == "2026-06-20" || record.d == today()
                                              → record.d == "2026-06-20" || date(record.d) == today()

Why this beats scope-hydration (the interim plan):

  • Per-occurrence, not per-field → the literal+temporal conflict resolves perfectly (the literal comparison is untouched, the temporal one is coerced) — no fail-safe skip, no residual broken case.
  • No field-type info needed at runtime. date()/toDate degrades gracefully for every operand: ISO date string → Date; an already-Date datetime field → passes through unchanged; a non-date string ("active") / null → Invalid Date → comparison stays false (same as today). Verified: wrapping never worsens a currently-correct result.
  • Idempotent: date(record.d) == today() — the operand is already a call, not a bare ref, so it is not re-wrapped.

Design details:

  • Only serialize + re-evaluate when a rewrite actually happened (temporal-vs-field comparison present); otherwise evaluate the original source untouched → zero risk / zero cost for the ~99% case.
  • Memoize source → rewrittenSource (bounded map) + a cheap fast path (skip unless the source contains ==/!= AND a temporal fn name, via plain includes), so applyFormulaPlan's per-row × per-formula loop pays the parse once per distinct source.
  • Reuse the existing AST helpers from feat(formula,lint): warn on date field == temporal-function silent-miss (#3183) #3192 (isTemporalCall, fieldRefName, isCelNode); replace the now-obsolete temporalEqualityFields collector with the rewrite.

Rejected alternatives

  • Data-layer / scope hydration: blanket or per-field conversion breaks dateField == "literal" or can't resolve the conflict expression; needs field-type plumbing. The AST rewrite is per-occurrence and needs none.
  • Upstream cel-js fix (isEqual consulting overloads): cleanest long-term but external/unbounded; worth filing in parallel, not blocking.
  • today() returning a string: violates ADR-0053 D1 (breaks ordering, timestamp arithmetic).

Must land in the same PR

  1. Remove the feat(formula,lint): warn on date field == temporal-function silent-miss (#3183) #3192 advisory lint (checkTemporalDateEquality in validate.ts + its call + tests): with the runtime fixed there is no remaining broken case, so the warning is a pure false alarm. (Lockstep publishing — no version skew.) temporalEqualityFields is replaced by the rewrite; drop it and its unit tests.
  2. Flip the KNOWN GAP characterization test in packages/formula/src/cel-engine.test.ts (added by test(formula): lock ADR-0053 Phase 2 Slice 3 acceptance criteria (#1980) #3181) to assert true.
  3. Changeset: @objectstack/formula minor, calling out the behavior change loudly (equality comparisons that were always-false now match — the point of the fix; upgraders must see it) and that it supersedes the feat(formula,lint): warn on date field == temporal-function silent-miss (#3183) #3192 advisory lint.

Acceptance criteria

  • record.due_date == today() is true on the same calendar day for a real Field.date string — non-UTC reference tz and across DST; != dual correct.
  • previous.<dateField> and bare (flattened flow scope) forms; all four temporal fns; both operand orders.
  • dateField == "2026-06-20" (string literal) byte-for-byte unchanged.
  • Mixed literal+temporal expression: BOTH clauses correct (no fail-safe, no residual miss).
  • A Field.datetime (real Date) == now()-style comparison unchanged; non-date string / null operands unchanged.
  • End-to-end: an is_due_today formula field on a record read via engine.find() (real driver YYYY-MM-DD round-trip) evaluates true.
  • Full @objectstack/formula / @objectstack/lint / @objectstack/objectql suites green; no new CodeQL findings (AST-only, no regex on author input); serialize+re-eval only on rewritten sources.

Workarounds until then

date(record.due_date) == today()
record.due_date >= today() && record.due_date <= today()
daysBetween(today(), record.due_date) == 0

Refs ADR-0053, ADR-0032, #1928, #1975, #3181, #3192. Estimated size: ~90 lines engine (rewrite + memoize) − ~40 lines lint removal + ~180 lines tests, single PR.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Fields

    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