Skip to content

feat(formula,lint): warn on date field == temporal-function silent-miss (#3183)#3192

Merged
os-zhuang merged 1 commit into
mainfrom
claude/hydrate-date-fields-cel-equality-3183
Jul 18, 2026
Merged

feat(formula,lint): warn on date field == temporal-function silent-miss (#3183)#3192
os-zhuang merged 1 commit into
mainfrom
claude/hydrate-date-fields-cel-equality-3183

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Part of #3183. A build-time guardrail for the cel-js date-equality silent-miss discovered while adding the ADR-0053 Slice 3 acceptance tests (#3181).

The bug

A Field.date deserializes as a YYYY-MM-DD string (ADR-0053 Phase 1), and cel-js's equality hard-codes string == <timestamp> to false (@marcbachmann/cel-js overloads.js isEqual — returns false for a string left operand without consulting any overload, and refuses cross-type object equality). So the most natural predicate an author (or AI) writes —

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

— compiles clean, throws nothing, and silently never matches. Same silent-miss family as #1928; timezone-independent (fails identically at UTC); cross-cutting (formulas, validation, RLS, flow/action/sharing/hook predicates).

Why a lint, not a runtime fix

cel-js gives no operator-layer hook (equality short-circuits before overload lookup). A naive runtime fix — hydrating date fields to Date in the data layer — would trade this silent-miss for another: dateField == "2026-06-20" (string literal) would start returning false. Verified empirically. So the runtime fix needs its own design and stays tracked in #3183; this PR ships the safe, established guardrail: turn the silent runtime miss into a loud, self-correcting build warning.

Changes

  • @objectstack/formulaExprSchemaHint.dateFields; validateExpression flags a ==/!= between a named date field (record./previous./bare) and today()/daysFromNow()/daysAgo()/now(), with a message pointing at the working idioms: date(record.d) == today(), a range (>= … && <= …), or daysBetween(today(), record.d) == 0. Warning severity — never fails the build (the write/validation path may carry a real Date).
  • @objectstack/lintvalidateStackExpressions threads each object's date field names into the validator so the warning fires across every predicate site. Restricted to type: 'date' (unambiguously a string); datetime excluded to avoid false positives.
  • Ordering operators (>=/<=/</>) already work (cel-js throws → the engine's existing string-hydration retry fires), so they are not flagged.

Tests

  • packages/formula/src/validate.test.ts — warns on both operand orders + all four temporal fns + bare-field (flattened) scope; does NOT warn on the working idioms, ordering ops, non-date fields, or when dateFields is absent; de-dups repeated refs. (formula suite: 220 pass)
  • packages/lint/src/validate-expressions.test.ts — end-to-end on formula/validation/flow sites; advisory (never fails build); no warning for datetime or the working idioms. (lint suite: 230 pass)
  • tsc --noEmit + ESLint clean on all changed files. Changeset included (minor).

🤖 Generated with Claude Code


Generated by Claude Code

@vercel

vercel Bot commented Jul 18, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
spec Ready Ready Preview, Comment Jul 18, 2026 8:23am

Request Review

@os-zhuang
os-zhuang marked this pull request as ready for review July 18, 2026 07:18
@os-zhuang os-zhuang closed this Jul 18, 2026
@os-zhuang os-zhuang reopened this Jul 18, 2026
@os-zhuang
os-zhuang force-pushed the claude/hydrate-date-fields-cel-equality-3183 branch from 22edff2 to 572b914 Compare July 18, 2026 07:39
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling size/m and removed documentation Improvements or additions to documentation tests tooling labels Jul 18, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/formula, @objectstack/lint.

6 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/formulas.mdx (via @objectstack/formula)
  • content/docs/data-modeling/validation.mdx (via @objectstack/formula)
  • content/docs/permissions/authorization.mdx (via @objectstack/lint)
  • content/docs/plugins/packages.mdx (via @objectstack/formula)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/formula)
  • content/docs/releases/v15.mdx (via @objectstack/formula)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

Comment thread packages/formula/src/validate.ts Fixed
Comment thread packages/formula/src/validate.ts Fixed
…3183)

A `Field.date` deserializes as a "YYYY-MM-DD" string (ADR-0053 Phase 1), and
cel-js's equality hard-codes `string == <timestamp>` to false, so
`record.due_date == today()` compiles clean but silently never matches. cel-js
offers no operator-layer hook to fix the comparison, so this adds a build-time
advisory warning (the established ADR-0032 guardrail strategy) that turns the
silent runtime miss into a loud, self-correcting build signal.

`validateExpression` reuses the shared `ExprSchemaHint.fieldTypes` (the same
per-field type map the #1928 tier-4 soundness check already threads through
`@objectstack/lint`) to flag `==`/`!=` between a `date` field and
today()/daysFromNow()/daysAgo()/now(), with a message pointing at the working
idioms (date(...), a range, or daysBetween). Warning severity — the
write/validation path may carry a real Date, so it never fails the build.
Restricted to `type: 'date'` (unambiguously a string); `datetime` excluded to
avoid false positives. Ordering operators already work (they fault→hydrate) and
are not flagged. Complements the #1874 flow-scheduling lint, which targets a
different root cause (record-change triggers firing only on the exact write day).

A runtime fix (normalizing the peer of a temporal operand in the data layer)
stays tracked in #3183 — a naive "hydrate date fields to Date" version would
trade this silent-miss for another (breaking `dateField == "literal"`).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SuiM565BZ3TR1VD3prMguB
@os-zhuang
os-zhuang force-pushed the claude/hydrate-date-fields-cel-equality-3183 branch from 572b914 to 346236d Compare July 18, 2026 07:54
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Jul 18, 2026
@os-zhuang
os-zhuang merged commit 80273c8 into main Jul 18, 2026
15 of 16 checks passed
@os-zhuang
os-zhuang deleted the claude/hydrate-date-fields-cel-equality-3183 branch July 18, 2026 08:06
os-zhuang pushed a commit that referenced this pull request Jul 18, 2026
…arison rewrite (#3183)

A `Field.date` reads back as a "YYYY-MM-DD" string (ADR-0053 Phase 1), and
cel-js's equality treats a string and a timestamp as unequal without consulting
any overload, so `record.due_date == today()` silently returned false (and
`!= today()` silently true) even for a same-day record.

celEngine.evaluate now rewrites the parsed AST: for each `==`/`!=` whose one
operand is today()/daysFromNow()/daysAgo()/now(), the field operand is wrapped in
date(...) (the stdlib coercion), then serialized and evaluated — so
`record.due_date == today()` runs as `date(record.due_date) == today()`.

- Per-occurrence: a mixed `d == "literal" || d == today()` keeps the literal
  comparison intact while fixing the temporal one.
- Type-blind-safe: date() degrades gracefully (already-Date passes through;
  non-date string / null → Invalid Date → stays false), so no field types are
  needed and no currently-correct result is worsened.
- Cheap: reserializes only when such a comparison is present (plain-includes
  gate) and memoizes source → rewritten source. AST-based, no regex on input.

Covers every interpreter site (formulas, defaults, validation, hooks, flow
conditions) via the single evaluate chokepoint. RLS/sharing unaffected
(cel-to-filter rejects function calls loudly).

Supersedes the #3192 advisory lint (checkTemporalDateEquality + the
temporalEqualityFields helper), now removed — with the runtime fixed it would be
a false alarm. Flips the #3181 KNOWN GAP characterization test to assert the fix,
and adds an objectql end-to-end test (a date formula field read via find()).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SuiM565BZ3TR1VD3prMguB
os-zhuang added a commit that referenced this pull request Jul 18, 2026
…arison rewrite (#3183) (#3205)

A `Field.date` reads back as a "YYYY-MM-DD" string (ADR-0053 Phase 1), and
cel-js's equality treats a string and a timestamp as unequal without consulting
any overload, so `record.due_date == today()` silently returned false (and
`!= today()` silently true) even for a same-day record.

celEngine.evaluate now rewrites the parsed AST: for each `==`/`!=` whose one
operand is today()/daysFromNow()/daysAgo()/now(), the field operand is wrapped in
date(...) (the stdlib coercion), then serialized and evaluated — so
`record.due_date == today()` runs as `date(record.due_date) == today()`.

- Per-occurrence: a mixed `d == "literal" || d == today()` keeps the literal
  comparison intact while fixing the temporal one.
- Type-blind-safe: date() degrades gracefully (already-Date passes through;
  non-date string / null → Invalid Date → stays false), so no field types are
  needed and no currently-correct result is worsened.
- Cheap: reserializes only when such a comparison is present (plain-includes
  gate) and memoizes source → rewritten source. AST-based, no regex on input.

Covers every interpreter site (formulas, defaults, validation, hooks, flow
conditions) via the single evaluate chokepoint. RLS/sharing unaffected
(cel-to-filter rejects function calls loudly).

Supersedes the #3192 advisory lint (checkTemporalDateEquality + the
temporalEqualityFields helper), now removed — with the runtime fixed it would be
a false alarm. Flips the #3181 KNOWN GAP characterization test to assert the fix,
and adds an objectql end-to-end test (a date formula field read via find()).


Claude-Session: https://claude.ai/code/session_01SuiM565BZ3TR1VD3prMguB

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Jul 20, 2026
…arison rewrite (objectstack-ai#3183)

A `Field.date` reads back as a "YYYY-MM-DD" string (ADR-0053 Phase 1), and
cel-js's equality treats a string and a timestamp as unequal without consulting
any overload, so `record.due_date == today()` silently returned false (and
`!= today()` silently true) even for a same-day record.

celEngine.evaluate now rewrites the parsed AST: for each `==`/`!=` whose one
operand is today()/daysFromNow()/daysAgo()/now(), the field operand is wrapped in
date(...) (the stdlib coercion), then serialized and evaluated — so
`record.due_date == today()` runs as `date(record.due_date) == today()`.

- Per-occurrence: a mixed `d == "literal" || d == today()` keeps the literal
  comparison intact while fixing the temporal one.
- Type-blind-safe: date() degrades gracefully (already-Date passes through;
  non-date string / null → Invalid Date → stays false), so no field types are
  needed and no currently-correct result is worsened.
- Cheap: reserializes only when such a comparison is present (plain-includes
  gate) and memoizes source → rewritten source. AST-based, no regex on input.

Covers every interpreter site (formulas, defaults, validation, hooks, flow
conditions) via the single evaluate chokepoint. RLS/sharing unaffected
(cel-to-filter rejects function calls loudly).

Supersedes the objectstack-ai#3192 advisory lint (checkTemporalDateEquality + the
temporalEqualityFields helper), now removed — with the runtime fixed it would be
a false alarm. Flips the objectstack-ai#3181 KNOWN GAP characterization test to assert the fix,
and adds an objectql end-to-end test (a date formula field read via find()).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SuiM565BZ3TR1VD3prMguB
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants