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

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions