Uh oh!
There was an error while loading. Please reload this page.
fix(formula): cover a Date-valued binding in the temporal-equality rewrite (#7168) - #8160
Conversation
…write (#7168) `record.due == previous.due` — `previous` hydrated by the driver as a `Date`, `record` parsed from a JSON payload as `"2026-06-20"` — compared a string against a Timestamp and answered a silent `false` (`!=` answered a silent `true`). No fault, no log line: the wrong answer is shaped like a legitimate one. `rewriteTemporalEquality` (#3183) already coerced the string operand with `date(...)` when its counterpart was a temporal CALL. It now also fires when the counterpart is a Date-valued BINDING. A binding's runtime type is not in the AST, so this arm reads the evaluation scope — the same discipline `rewriteFaultedOperands` (#7098) uses — and its verdict is never cached against the source, because it is a property of the row and not of the expression. What IS cached per source is the analysis: which occurrences could depend on a scope at all, so sources without one keep the memoized static path. The coercion requires the counterpart to be a real `Date` AND this operand to be an ISO-8601 string that parses, which is what keeps the fix from becoming the mirror-image defect. `"5"` and `"05"` are different strings that both parse to 2001-05-01; wrapping unconditionally would invent an equality between them and turn a correct `false` into a silent `true`. Fact 1 of the card (cross-type `in` membership) is deliberately untouched, per the maintainer ruling of 2026-08-12: record-and-defer pending a measured victim. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014C8pAprWdmtecFsEprZax4
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 4 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also reference the affected code. These are read-only:
|
…ral-equality callout (#7168) The callout describes the rewrite this PR extends, and its only named counterpart was a temporal call — so after the code change it stayed true but became incomplete in the direction that matters: a reader would still believe `record.due == previous.due` silently answers `false`. Extends that paragraph only: the counterpart may be a temporal call OR a binding holding a `Date`, named through the reachable mixed-provenance shape. Also states the fence, so the sentence cannot be read as "any string now matches a date": the coercion needs one side to be a real `Date` and the other an ISO-8601 string. No other prose touched, and cross-type `in` membership is deliberately not mentioned — it is record-and-defer per the maintainer ruling on #7168. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014C8pAprWdmtecFsEprZax4
…poral-eq-date-binding
Uh oh!
There was an error while loading. Please reload this page.
Fixes#7168
Implements Fact 2 only, per the maintainer ruling of 2026-08-12 11:15Z on the card.
The defect
A mixed-provenance comparison —
previoushydrated by the driver as aDate,recordparsed from a JSON payload as a"YYYY-MM-DD"string — answered the wrong boolean:cel-js compares a
stringagainst agoogle.protobuf.Timestampand never matches, so the predicate answeredfalse— and!=on the same pair answeredtrue. No fault, no log line. Measured onorigin/mainbefore the fix ({"ok":true,"value":false}), and both are pinned red-without-the-fix below.The fix
rewriteTemporalEquality(#3183) already coerced the string operand withdate(...)when its counterpart was a temporal call (today()/now()/daysFromNow()/daysAgo()). It now also fires when the counterpart is a Date-valued binding.On the design question the dispatch flagged as unverified — how a Date-valued binding is distinguishable at rewrite time: it is not visible in the AST at all.
record.dueandprevious.dueare the same shape whether either holds aDate, a string, or null, and underunlistedVariablesAreDynthe static type says nothing either. So the analogy to the temporal-call arm does not carry: that arm is a pure function of the source and is memoized under it, whereas this verdict is a property of the row.Rather than force that shape, this arm reads the evaluation scope — the discipline
rewriteFaultedOperands(#7098) already established, reusing itsscopePath/resolveScopePath/coercionForprimitives. Two consequences, both deliberate:evaluate), never incompile;Keeping the negative pins
The coercion requires the counterpart to be a real
Dateand this operand to be an ISO-8601 string that parses. That guard is what keeps the fix from becoming the mirror-image defect:date()istoDate, i.e.new Date(String(v)), and JS date parsing is lenient enough to invent equalities —"5"and"05"are different strings that both parse to 2001-05-01. Wrapping unconditionally would turn a correctfalseinto a silenttrue.Changes answer (string plus Date binding, same instant):
record.due == previous.due— wasfalse, nowtruerecord.due != previous.due— wastrue, nowfalse"...T14:33:00Z"string against the same instantDeliberately unchanged, each pinned:
Dates — already compared as instantsfalse"hello") or a numeric string ("5") against aDate— staysfalseDatecarrying wall-clock time — staysfalse.date()parses, it does not truncate to a calendar day, and those are genuinely different instants; truncating both sides would makerecord.dt == previous.dta day-granularity comparison, which is the same silent-wrong-answer defect pointing the other way>=) is untouched — that is ADR-0032 §1c's retry pathDocs
content/docs/data-modeling/formulas.mdxcarries a callout describing this exact rewrite, and its only named counterpart was a temporal call. After this change the paragraph stays true but becomes incomplete in the direction that matters — a reader would still believerecord.due == previous.duesilently answersfalse. Prose asserting a mechanism that has since moved stays green forever, because no gate reads prose, so the PR that changes the behaviour is the only one that can honestly change the sentence.That paragraph is extended to name the binding counterpart and the mixed-provenance shape, plus the fence, so it cannot be read as "any string now matches a date". Scope held deliberately: no other prose touched, nothing under
content/docs/releases/**, and cross-typeinmembership is not mentioned at all — it is record-and-defer per the ruling. (content/docs/**isdomain:devx's surface; the engine-core PM seat is filing that declaration.)Fact 1 is NOT in this PR, and its deferral survives this closure
The card reports two facts. Fact 1 — cross-type
inmembership (record.n in [1, 7]withn: "7") — is untouched here: the 11:15Z ruling is record-and-defer, no change to clean-path cross-type semantics without a measured victim, with the reopen trigger being the first measured wrong answer attributable to it. It was deliberately not filed as a separate card (zero measured pull), so that ruling comment on #7168 is the durable record. This PR usesFixes #7168because the ruling says so explicitly — closing the card does not retract Fact 1's deferral, and a future reader should not read the closure as burying it.Verification
origin/mainand the new tests kept, exactly 5 tests fail — all 5 the new arm, including the card's literal defect (expected { ok: true, value: false } to deeply equal { ok: true, value: true }) — while the other 80 pass. Every negative pin is green on both sides, which is what makes it a fence rather than a restatement of the fix.pnpm --filter @objectstack/formula test— 25 files, 643 tests pass;typecheckclean; ESLint clean on both changed files.@objectstack/objectql3390 tests,@objectstack/service-automation940 tests — all pass.check:driver-conformance,check:query-options-erasure,check:nul-bytes,check:type-check-debt --re-measure(after a full closure build), plus the docs-path family pulled in by the.mdxedit —check:doc-formula-expressions,check:docs-audit-scope,check:quick-reference-counts,check:role-word— and the changeset family. All OK. No baseline raised;--lowernot run.mainadvanced 7 commits mid-task and one of them bumped.objectui-sha, socheck:objectui-pin-freshread stale against the older base. Resolved by mergingorigin/main(not by hand-editing another lane's pin artifact); green afterwards, and the whole suite plus both named ratchets were re-run against the merged tree.Generated by Claude Code