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

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/formula — ExprSchemaHint.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/lint — validateStackExpressions 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

This branch was successfully deployed

1 active deployment
Preview — 346236d8 Deployed Jul 18, 2026 by vercel[bot]
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