Skip to content

fix(objectql): a CEL defaultValue stores the declared type's contract shape, not a raw Date (#7373) - #7469

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-7373-cel-default-date-normalization
Aug 10, 2026
Merged

fix(objectql): a CEL defaultValue stores the declared type's contract shape, not a raw Date (#7373)#7469
os-zhuang merged 1 commit into
mainfrom
claude/issue-7373-cel-default-date-normalization

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Closes #7373.

The defect

applyFieldDefaults produces a default three ways, and only two of them honoured the stored-value contract:

branch normalization
NOW() token routes through resolveNowDefault → the form the declared type stores
literal checked against valueSchemaFor(def, 'stored') at author time (#7127)
expression envelope assigned verbatim — no counterpart

The temporal stdlib returns a JS Date (ADR-0053 D1: today() / daysFromNow(n) / daysAgo(n) are UTC-midnight of the reference-tz calendar day; now() the raw instant), so { dialect: 'cel', source: 'daysFromNow(7)' } on a datetime put a Date object in the column while valueSchemaFor names an ISO-8601 string. validateRecord accepts a Date on date/datetime by explicit decision, so nothing refused the write — and os migrate value-shapes, which walks stored values against that same schema, reports such a row as a violation by the platform's own scan.

The fix

The expression branch routes a Date result through the same per-type table the NOW() token uses — one table, both branches, not a second copy of the contract:

declared type stored
datetime (and anything else) YYYY-MM-DDTHH:MM:SS.sssZ
date YYYY-MM-DD
time HH:MM:SS[.fff]

Measurement behind the choice

In-tree CEL defaultValue census — the dialect: 'cel' corpus is almost entirely expression: (formula fields, virtual — never stored), condition:/when: (rule predicates). Exactly two are defaultValue, one temporal:

site source declared type pre-fix stored shape
engine-write-formula-hydration.test.ts:157 now() datetime Date object
engine-select-option-default.test.ts:210 'approved' text string (unaffected)

Per-driver behaviour when handed a Date — measured by reading each driver's write seam:

driver datetime date net effect of this change
driver-sql canonicalUtcDatetimetoISOString() toDateOnlyYYYY-MM-DD byte-identical
driver-mongodb storageDatetimeValue keeps BSON Date; an ISO string parses to the identical Date storageDateValueYYYY-MM-DD byte-identical
driver-memory imports only coerceTemporalValue (filter comparands); stores writes as handed same Date object → contract shape

So SQL and MongoDB already stored exactly what the engine now produces; the only stored-shape change is on the memory driver, which is where the defect was visible. This is the split #4597 / #4560 closed for the NOW() token, reappearing on the CEL branch and closed the same way — engine-side, so one answer serves every driver.

Why normalize rather than refuse. Refusing a Date out of a CEL default would make the rule depend on who wrote the value: validateRecord accepts a Date on date/datetime from any caller (if (value instanceof Date) return null), and temporal types are not in ADR-0104's strict value-shape block at all — so there is no strict path that already refuses this on a user write for a defaults path to be bypassing. Refusal would also break the documented envelope (#7244) on precisely the SQL backends where it stores correctly today.

No day-shift. ADR-0053 D1's Date is UTC-midnight of the reference-tz calendar day, and it is read back with toISOString() — UTC getters, the same getUTC* the ADR names for the driver filter path. Reading those parts in local time is the move that would shift a day, and nothing here does it. Pinned directly: at an instant whose UTC day and America/Los_Angeles day differ, a date field defaulted by today() stores the LA day.

Non-Date results pass through untouched (a CEL default's result type is otherwise a runtime concern), as does an Invalid Date — the same totality the driver canons keep.

Pins

packages/objectql/src/engine-cel-default-temporal-shape.test.ts — 8 tests. They assert stored values against valueSchemaFor itself rather than a hand-copied shape, and assert the type as well as the text: JSON.stringify renders a Date as its ISO string, which is exactly what made the original defect read as correct.

Subjects: datetime, date, time CEL defaults; the reference-tz day-shift guard.
Controls: NOW() token byte-identical, literal defaults untouched, non-date CEL results passed through, caller-supplied values untouched.

Reverse-verified by restoring the pre-fix out[f.name] = result.value — the 4 subject pins fail, the 4 controls pass in both states.

Verification

  • packages/objectql suite: 177 files / 3135 tests pass
  • Derived gates, all exit 0: check:adr-anchors, check:durability-log-level, check:engine-double-contract, check:stack-collection-maps, scripts/check-engine-split-ratio.mjs, check:nul-bytes
  • check-empty-changeset (1 declaring changeset added), check-changeset-no-major (no major)
  • tsc --noEmit and eslint clean on the changed files

Changeset

patch, on @objectstack/objectql alone. Argued from the gate text: check-changeset-no-major.mjs records that the launch window ships even breaking changes as minor, so major (and the ADR-0087 trio) is out regardless. Between patch and minor, the observable change is confined to correcting values the platform's own value-shape scan already classified as violations, on the one backend that stored them — no API, type, or message-contract change. That matches the recent precedent for contract-correcting behaviour fixes (nested-plugin-view-container-expansion, driver-sql-json-column-operator-refusal, both patch).

No packages/spec touch — deliberately. resolveNowDefault keeps its name so the reference in packages/spec/src/data/default-value-shape.ts stays accurate; only its parameter is renamed nowinstant, since daysFromNow(7) is not "now" and takes the identical per-type treatment.

Refs #7373, #7244, #7127, #4597 / #4560, ADR-0053, ADR-0104.


Generated by Claude Code

…ct shape, not a raw `Date` (#7373)

`applyFieldDefaults` produces a default three ways and only two honoured the
stored-value contract: the `NOW()` token routes through `resolveNowDefault`, a
literal is checked against `valueSchemaFor(def, 'stored')` at author time
(#7127) — and the expression envelope's result was assigned verbatim. The
temporal stdlib returns a JS `Date` (ADR-0053 D1: `today()`/`daysFromNow(n)`/
`daysAgo(n)` are UTC-midnight of the reference-tz calendar day, `now()` the raw
instant), so `{ dialect: 'cel', source: 'daysFromNow(7)' }` on a `datetime` put
a `Date` OBJECT in the column while `valueSchemaFor` names an ISO-8601 STRING.
`validateRecord` accepts a `Date` on `date`/`datetime` by explicit decision, so
nothing refused the write and the divergence was silent — while
`os migrate value-shapes` walks stored values against that same schema and
reports such a row as a violation by the platform's own scan.

The expression branch now routes a `Date` result through the SAME per-type
table the `NOW()` token uses, rather than growing a second copy of the
contract: `datetime` → `YYYY-MM-DDTHH:MM:SS.sssZ`, `date` → `YYYY-MM-DD`,
`time` → `HH:MM:SS[.fff]`.

Storage on SQL and MongoDB is byte-identical to before: `SqlDriver.formatInput`
already coerced a `Date` through `canonicalUtcDatetime`/`toDateOnly`, and
mongodb's `storageDatetimeValue`/`storageDateValue` do the same. What changes is
the memory driver, which applies its temporal canon to filter comparands only
(`coerceTemporalValue`) and stored writes as handed. Same declaration, different
stored shape per datasource — the split #4597 / #4560 closed for the `NOW()`
token, reappearing on the CEL branch and closed the same way, engine-side.

Normalization rather than refusal: refusing a `Date` here would make the rule
depend on WHO wrote the value — `validateRecord` accepts one from any caller,
temporal types are not in ADR-0104's strict value-shape block, and the
documented envelope (#7244) stores correctly on SQL today. Non-`Date` results
pass through untouched, as does an `Invalid Date` (the totality the driver
canons keep). No day can shift: the ADR-0053 `Date` is UTC-midnight OF the
reference-tz day and is read back with UTC getters.

Pins in `engine-cel-default-temporal-shape.test.ts` assert the stored value
against `valueSchemaFor` itself, and assert the TYPE as well as the text —
`JSON.stringify` renders a `Date` as its ISO string, which is what made the
original defect read as correct. Reverse-verified: the four subject pins fail
against the pre-fix assignment; the four controls (`NOW()` token, literals,
non-date CEL results, caller-supplied values) pass in both states.

Refs #7373, #7244, ADR-0053, ADR-0104.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012tjfhdVGuYU9KH7SNbor56
@vercel

vercel Bot commented Aug 10, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 10, 2026 2:39pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql.

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

  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/objectql)
  • content/docs/data-modeling/formulas.mdx (via packages/objectql)
  • content/docs/deployment/migration-from-objectql.mdx (via @objectstack/objectql)
  • content/docs/deployment/vercel.mdx (via @objectstack/objectql)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/objectql)
  • content/docs/kernel/runtime-services/examples.mdx (via packages/objectql)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/objectql)
  • content/docs/kernel/services.mdx (via @objectstack/objectql)
  • content/docs/permissions/authentication.mdx (via @objectstack/objectql)
  • content/docs/permissions/system-context.mdx (via packages/objectql)
  • content/docs/plugins/index.mdx (via @objectstack/objectql)
  • content/docs/plugins/packages.mdx (via @objectstack/objectql)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/objectql)
  • content/docs/protocol/objectql/query-syntax.mdx (via packages/objectql)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/objectql)

1 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx (via @objectstack/objectql)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

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.

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

2 participants