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
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-DDstring (ADR-0053 Phase 1, #1968). cel-js's equality (overloads.jsisEqual) 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.
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.
Memoizesource → 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.
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.
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.
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.
Split out from #1980 (ADR-0053 Phase 2 · Slice 3). Independent of timezone.
Problem
A
Field.datereads back as aYYYY-MM-DDstring (ADR-0053 Phase 1, #1968). cel-js's equality (overloads.jsisEqual) returnsfalsefor a string left operand without consulting any overload, and refuses cross-type object equality. So: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'shydrateOverloadStringsretry fires); only==/!=fail silently.Impact surface (corrected)
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).cel-to-filter.tsrejects function calls as a compile error (fail closed), so== today()in an RLS condition is already a loud authoring error, not a silent miss.visible/disabledeval uses@objectstack/formula; if so it inherits the fix.Approach — AST temporal-comparison rewrite in
celEngine.evaluateFor 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 indate(...), then serialize and evaluate.@marcbachmann/cel-jsexposesserialize(ast)(verified faithful round-trip);date()= the stdlibtoDatecoercion.Why this beats scope-hydration (the interim plan):
date()/toDatedegrades gracefully for every operand: ISO date string →Date; an already-Datedatetime field → passes through unchanged; a non-date string ("active") / null →Invalid Date→ comparison staysfalse(same as today). Verified: wrapping never worsens a currently-correct result.date(record.d) == today()— the operand is already acall, not a bare ref, so it is not re-wrapped.Design details:
source → rewrittenSource(bounded map) + a cheap fast path (skip unless the source contains==/!=AND a temporal fn name, via plainincludes), soapplyFormulaPlan's per-row × per-formula loop pays the parse once per distinct source.datefield == temporal-function silent-miss (#3183) #3192 (isTemporalCall,fieldRefName,isCelNode); replace the now-obsoletetemporalEqualityFieldscollector with the rewrite.Rejected alternatives
dateField == "literal"or can't resolve the conflict expression; needs field-type plumbing. The AST rewrite is per-occurrence and needs none.isEqualconsulting 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
datefield == temporal-function silent-miss (#3183) #3192 advisory lint (checkTemporalDateEqualityinvalidate.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.)temporalEqualityFieldsis replaced by the rewrite; drop it and its unit tests.KNOWN GAPcharacterization test inpackages/formula/src/cel-engine.test.ts(added by test(formula): lock ADR-0053 Phase 2 Slice 3 acceptance criteria (#1980) #3181) to asserttrue.@objectstack/formulaminor, calling out the behavior change loudly (equality comparisons that were always-falsenow match — the point of the fix; upgraders must see it) and that it supersedes the feat(formula,lint): warn ondatefield == temporal-function silent-miss (#3183) #3192 advisory lint.Acceptance criteria
record.due_date == today()istrueon the same calendar day for a realField.datestring — 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.Field.datetime(realDate)== now()-style comparison unchanged; non-date string / null operands unchanged.is_due_todayformula field on a record read viaengine.find()(real driverYYYY-MM-DDround-trip) evaluatestrue.@objectstack/formula/@objectstack/lint/@objectstack/objectqlsuites green; no new CodeQL findings (AST-only, no regex on author input); serialize+re-eval only on rewritten sources.Workarounds until then
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.