Repository navigation
Commit 2473e26
fix(core,objectql)!: a temporal comparand is refused exactly when the write door refuses it — a real calendar day, an ISO datetime spelling, and a time instant with a four-digit UTC year (#20668)
Fixes #20549
Fixes #20480
Clause-②: no (narrowing)
A family PR: two cards, one branch, one changeset, one commit per card.
- `70b98719c` is #20549. The temporal comparand door now refuses what
the write door refuses: a calendar day that does not exist, and a
`datetime` string outside the ISO 8601 spellings the platform writes.
- `0adb1bf84` is #20480. A `time` comparand whose instant has no
four-digit UTC year is refused, in every spelling.
Measured head: `0adb1bf84`, rebased onto `origin/main` `19fc8d6f1`. The
two commits since `main` touch no file that `main` moved.
## The change
### `@objectstack/core` —
`packages/core/src/utils/temporal-comparand.ts`
- **Moved, not copied.** `ISO_DATETIME_WRITE_FORM` and
`namesRealCalendarDay` leave `record-validator.ts` and sit beside
`readsAsCalendarDay`. Both stay module-private, so there is no new root
export.
- `readsAsCalendarDay` (the `date` reading) now also requires the
leading day to exist.
- `readsAsInstant` (the `datetime` reading, which a `time` column also
uses for an instant) now requires three things:
- one of the ISO spellings;
- a real leading day;
- an instant that `Date.parse` reads.
The bare-integer-string arm is gone (see H3). Epoch milliseconds as a
NUMBER are untouched.
- (#20480) There is a new private helper, `keepsTimeOfDay(value)`. It
asks `temporalStorageForm(value, 'time')` itself whether the rule keeps
an `HH:MM:SS` time of day. The `time` arm now refuses an instant string
the rule hands back unchanged. So does a finite number or a valid
`Date`, which the `time` arm never judged before.
- Year 0 spells `0000-…`, so it is still read.
- NaN, Infinity and an Invalid Date stay unjudged.
### `@objectstack/objectql`
- **`validation/record-validator.ts`** (the write door). The `date` /
`datetime` arm is now `readable &&
!isUninterpretableTemporalComparand(t, value)`.
- Its private copies are deleted.
- `readable` stays on top. It is the one place the two doors differ, on
purpose: a NUMBER is refused as a written value but read as a comparand.
- The `time` arm is not in the diff.
- **`temporal-comparand-door.ts`**. The verdict is unchanged: core's
predicate, `INVALID_FILTER` / 400, naming the field, before any read.
The refusal text changes only as far as the new classes need, because
"compare false for EVERY row" is untrue for them:
- `whose calendar day does not exist`;
- `not one of the ISO 8601 spellings`;
- (#20480) `an instant whose UTC year falls outside the years 0001 to
9999, so no time of day is read from it`.
Each has a `where` and a `having` sentence. The remedy now says "on a
calendar day that exists" and "epoch milliseconds as a number". The junk
class and the year class keep their exact words.
## Measured, before and after
Through `engine.find` over InMemoryDriver, SqlDriver on SQLite, and
SqlDriver on a live PostgreSQL 16.13. The process ran under
`TZ=America/New_York`, and the server at `Asia/Shanghai`. Base is
`cd901d7a5`; after is `0adb1bf84`. Rows are `r1` 2026-03-02T10:00Z /
09:00, `r2` 2026-07-15T14:00Z / 10:30, and `r3` 2028-02-29T10:00Z /
12:00.
| comparand | base: memory / SQLite / PostgreSQL | after, all three |
|:--|:--|:--|
| datetime `$eq "2026-02-30T10:00:00Z"` | `[r1]` (rolled over) / `[r1]`
/ `[r1]` | 400 `INVALID_FILTER` |
| datetime `$eq "07/15/2026 10:00"`, `"2026/07/15 10:00"` | `[r2]`
(process zone) / `[r2]` / `[r2]` | 400 |
| date `$eq "2026-02-30"` | `[]` / `[]` / 500 `DATABASE_ERROR` | 400 |
| time `$gt "+010000-01-01T10:00:00Z"` (the #20480 card) | 3 of 3 / 3 of
3 / 500 | 400 |
| time `$lt` the same | `[]` / `[]` / 500 | 400 |
| time `$gt "9999-12-31T23:00:00-02:00"` (UTC year 10000) | `[]` / `[]`
/ 500 | 400 |
| time `$gt` the number or `Date` of `+010000-01-01T10:00Z` | `[]` / 3
of 3 / 500 | 400 |
| time `$gt "07/15/2026 10:00"` | `[]` (14:00 UTC, the process zone) |
400 |
| datetime `$gt "2026"` | every row (read as 2026 epoch ms) | 400 |
| datetime `$gt 1769940000000` (a number) | 3 of 3 | unchanged, 3 of 3 |
| controls: leap day `2028-02-29` on date and datetime; ISO `Z`,
`+08:00` and zone-naive `"2026-07-15 14:00"`; time `$gt` / `$lt
"2026-07-15T10:00:00Z"`; time `$gt "10:00"` | `[r3]`, `[r3]`, `[r2]`×3,
`[r2,r3]` / `[r1]`, `[r2,r3]` | identical |
After commit 1 alone, the #20480 card's literal row already read 400 on
all three, because it is not an ISO spelling. The UTC-year-10000 ISO
spelling and the number and `Date` spellings still answered as at base.
The second commit closes those (see H1).
## PM hypotheses — which held
- **H1: partly held.** Tightening `readsAsInstant` to the ISO form
refuses the card's `+010000-…` spelling as a side effect (measured after
commit 1). It does not close the class: `9999-12-31T23:00:00-02:00` is
an ISO spelling whose UTC year is 10000, and the number and `Date` never
reached the string arm. So `time` needs its own arm, which is commit 2.
- **H2: held.** The validator calls core's one predicate for both arms
and deletes its copies. No root export was added: `export *` from
`temporal-comparand.ts` exposes exactly the three symbols it did before.
`Clause-②` stays `no (narrowing)`.
- **H3: measured. One legitimate difference is kept, and one is
removed.**
- An epoch-ms NUMBER is refused by the write door (`readable`) and read
as a comparand. This predates this PR, is scoped by the #8690 ruling
(strings only), and is now asserted in
`engine-temporal-comparand-door.test.ts`.
- A zone-naive ISO string is admitted by both doors, so there is no
divergence.
- An epoch-ms STRING is refused by the write door. The comparand door
read it, and two earlier suites pinned it as a control. I found no
producer: the token resolver and the analytics date range emit ISO text,
and no test in the repo filters with one. The same arm read `"2026"` as
two seconds after 1970, and matched every row on all three backends.
Zone 1's direction ("the comparand door refuses what the write door
refuses") and the claim's "`datetime`: only the ISO 8601 spelling the
write door admits" therefore cover it, and it is refused. The two pins
are flipped, with load-bearing assertions (below). Because earlier cards
pinned it, this is raised in the report as an open question rather than
decided silently.
- A `date` string with a real leading day and text `Date.parse` cannot
read after it (`2026-07-15T25:00`) is refused by the write door. It is
read by the comparand door as its day, the #20481 shape. This predates
this PR and is outside both cards' classes, so it is left as is
(Acceptance notes).
- **H4: held.** `service-analytics/src/comparand-shape.ts` is not
edited. Its `judgeTemporalLiterals` calls core's predicate, so the
raw-SQL decline moves with it. The whole suite is green on `0adb1bf84`:
135 files, 3171 tests, under `TZ=America/New_York`. No pin flipped.
- **H5: nothing to carry.** Core's `namesRealCalendarDay` stays private,
so there is still nothing for `packages/rest/src/import-coerce.ts` to
read in place of its own copy.
## Compile faces — one conclusion each
The door is the engine's single filter collection point, in front of
every driver. The pins show zero driver reads on every refusal: a
recording driver in objectql, and the REST door over SQLite and
PostgreSQL.
1. `driver-sql` `applyFilterCondition`, with `driver-sqlite-wasm` and
turso LOCAL: **already compliant, behind the door.** Measured: the
refusal happens before any read on SQLite and PostgreSQL. The driver's
deliberate pass-through (`temporalFilterValue('t','at','not-a-date')`)
is unchanged.
2. turso `RemoteTransport.buildWhereSQL`: **already compliant, behind
the same door.** Not measured, because no turso server was available.
3. service-analytics `compileScopedFilterToSql` (RLS read side):
**explicitly out of scope.** It compiles the platform's injected RLS
predicate, which the door never judges by design (the door's docblock:
"an injected read filter is the platform's own").
4. service-analytics `lowerAnalyticsWhere`: **changed by inheritance.**
A comparand core now refuses is declined off the raw-SQL strategy to the
ObjectQL strategy, whose `engine.aggregate` passes the door. The suite
is green, as in H4.
5. `formula` `matchesFilterCondition`: **explicitly out of scope.** It
evaluates authored RLS `check` predicates and formula conditions, not a
caller's `where`. Its own `2026-02-30` text-fallback pin
(`matches-filter.test.ts:180`) is unchanged.
- Half face, objectql `applyHaving` / `matchesHaving`: **changed.**
`assertHavingTemporalComparandsInterpretable` runs the same predicate.
It is pinned in `engine-temporal-comparand-door.test.ts` (#20549 and
#20480 `having` rows) and
`engine-aggregate-having-temporal-door.test.ts` (#20480 rows, plus the
parity table computed from the predicate).
- Per-aggregation `filter`: **changed**, and pinned in the same two
files and in `engine-aggregate-temporal-storage-rule.test.ts`.
- `driver-memory` `checkCondition`: **behind the door.** Measured: the
engine over InMemoryDriver refuses every row above. The driver's
admitted-controls half is pinned.
- `driver-mongodb` `translateFieldOperators`: **behind the door, not
measured**, because no MongoDB was available.
## Pins
**#20549** (commit 1):
- `core` `temporal-comparand.test.ts`, a new describe:
- impossible days on date and datetime (plus the datetime day part) are
refused, and leap days and month ends are read;
- 15 non-ISO datetime spellings are refused, each shown
`Date.parse`-readable (or a bare integer) as the control;
- 14 ISO spellings are read;
- `T25:00` and `+99:99` are refused;
- the date reading of an instant on a real day is kept;
- the `time` instant half is covered;
- the exemptions (empty string, blank, `{today}`, an epoch-ms number, a
`Date`) are kept.
- `objectql` `engine-temporal-comparand-door.test.ts`, a new describe:
- 14 comparands × `$eq` / `$gt` / `$in` member → `INVALID_FILTER` / 400
naming the field, with zero reads, and not the junk class's words;
- the leap day and ISO controls in the same `it`;
- the per-aggregation filter and `having` positions;
- a **one-rule corpus pin:** over 32 strings × 2 fields, a string is
refused as a comparand exactly when `engine.insert` refuses it with
`invalid_date`;
- the number difference, asserted.
- `rest` `data-temporal-write-real-day-iso.test.ts`, beside the write
door's twin rows: `POST /api/v1/data/:object/query` answers 400
`INVALID_FILTER` for the card's values with no read, and the leap and
ISO controls find their rows, on SQLite and on live PostgreSQL.
- Driver halves:
- `sql-driver-20264-temporal-year-range.test.ts` gains a leap row and an
`it` over 7 ISO spellings, on the dialect matrix CI's `Temporal
Conformance (live PG + MySQL)` job runs;
- `memory-20525-temporal-write-real-day-iso.test.ts` gains comparand
controls under `America/New_York`.
**#20480** (commit 2):
- `core`: a new describe covers the card's spelling, UTC year 10000 and
year -1, numbers and `Date`s, and the Date-range extremes, all refused.
The 2026 control and year 0 are read. An agreement pin: refused exactly
when `temporalStorageForm(v, 'time') === v`.
- `objectql` `engine-temporal-comparand-door.test.ts`: five spellings ×
`$gt` / `$lt` / `$eq` at `where`, plus the per-aggregation filter and
`having`, with zero reads, beside the 2026 control and year 0.
- `engine-aggregate-having-temporal-door.test.ts`: two refused rows, and
two 2026 controls on `max(time)`.
- `rest`: a `time` field on SQLite and live PostgreSQL. The card's
instant, the UTC-10000 ISO spelling and the number answer 400, with no
read. The 2026 instant, the offset and the number answer 2 / 1.
- Drivers:
- `sql-driver-time-live-dialects.test.ts` gains the 2026 control in five
spellings, on live PostgreSQL and MySQL (CI's temporal job);
- `memory-temporal-storage-form.test.ts` gains four 2026 control rows.
**Flipped pins, one round (sweep ①).** Each keeps a load-bearing
assertion of the new semantics:
- `core` `temporal-comparand.test.ts`: the #20264 control
`'1769940000000'` (string) moves to the number `1769940000000`, read.
The string is refused in the #20549 describe. The #20240 "leaves the
time rule alone" test becomes a per-value verdict: year 0 and in-range
are read; years 10000 / -1 and the Date extremes are refused, with the
rule's own output asserted.
- `objectql` `engine-aggregate-temporal-storage-rule.test.ts`: the
family row "an epoch-ms string `$gt` on a datetime counts 3" is now
`INVALID_FILTER` / 400 at both positions, for `"1769940000000"` and
`"2026"`. The number of the same instant still counts 3.
- `objectql` `engine-temporal-year-range.test.ts`: "a time field judges
no year" becomes year 0 read (three spellings, three reads) and years
10000 / -1 refused (five spellings), with no further read.
- `objectql` `engine-date-year-range-door.test.ts`: the time half
becomes year 0 read (two reads) and 10000 / -1 refused.
- `objectql` `engine-aggregate-having-temporal-door.test.ts`: "the
number for 10000-01-01 on max(time) — not judged on time" moves from the
unchanged table to the refused table (`INVALID_FILTER` / 400, no read,
and the `where` twin agrees).
The repo-wide sweep found no other same-semantic pin, re-run on
`0adb1bf84`. It covered quoted 4–14 digit strings under a filter
operator on a temporal field, slashed or worded datetime comparands, and
extended-year comparands on `time` fields, over every `*.test.ts`. Truly
illegal shapes keep their refusal assertions verbatim.
## Verification, on `0adb1bf84`
Every suite below ran under `TZ=America/New_York`. PostgreSQL cells ran
against a live PostgreSQL 16.13 at `Asia/Shanghai`.
- `pnpm --filter @objectstack/core test`: 57 files / 1536 tests passed.
- `@objectstack/objectql`, whole suite: 336 files / 6674 tests passed.
- `@objectstack/rest`, whole suite with live PostgreSQL: 228 files, 4422
passed / 22 skipped. The changed file ran verbosely, and both the
`sqlite` and `live postgres` cells executed every #20549 and #20480
`it`.
- `@objectstack/driver-memory` test: 62 files / 1436 passed.
- `@objectstack/driver-sql` test with live PostgreSQL: 208 files passed
/ 3 skipped, 4023 tests passed / 95 skipped. The two changed files ran
verbosely: the SQLite and live-postgres cells ran; MySQL is a named
skip.
- `@objectstack/service-analytics`: 135 files / 3171 passed.
- `typecheck` passed for all five: core, objectql, rest, driver-memory
and driver-sql (the test layers included, with their
`test-typecheck-debt.json` ledgers unchanged).
- `pnpm check:driver-conformance`, before (`19fc8d6f1`) and after
(`0adb1bf84`): identical, 50 covered cells, 0 DEBT, 0 exempt, dialect
axis 10 of 10.
- `node scripts/pm/dispatch-gates.mjs --commands` on the final head
derived 67 commands. All 67 ran with exit 0.
- `check:dual-build-cjs-loads` and `check:type-check-debt` first exited
3 (PREREQUISITE NOT MET, no workspace `dist/`). They were re-run with
exit 0 after `turbo run build --filter='./packages/*'
--filter='./packages/*/*'`.
- `--ran` over the recorded exit codes: "67 derived famil(ies) accounted
for — 67 run, 0 NOT-MEASURED (a DERIVED zero)".
- The five artifact-roster gates whose rosters sit under a changed
directory also ran, exit 0: `check-changeset-fixed`,
`check:authz-resolver`, `check:filter-alias-parity`,
`check:object-def-param-keys` and `check:tenant-chokepoint`.
- Lint, narrowed and declared (the repo-wide `pnpm lint` is CI's).
`eslint --no-inline-config --format json` over the 14 changed `.ts`
files: 14 files, 0 errors, 0 warnings.
- Population: `eslint.config.mjs`'s
`**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` / `packages/**` objects cover all
14.
- Invariance: the config never enables type-aware linting (no
`parserOptions.project`, no typed rules), so this diff cannot move a
verdict on an untouched file.
- **Ablation**, two legs each, through `scripts/ablation-replace.mjs`
and `scripts/ablation-dist-preflight.mjs`, with a `trap` restore. Each
restore was proven: blob == HEAD, and a clean `git status --porcelain`.
- **A (#20549).** The `readsAsInstant` ISO / real-day guard was deleted:
anchor 1 → 0, and the marker was absent from `packages/core/dist`.
Core's predicate suite went 3 failed / 23, and the objectql door suite
went 2 failed / 14 (the refusal and the aggregation/`having` tests). The
corpus agreement pin stayed green, correctly, because both doors moved
together. Restored and rebuilt, the marker is present in 2 dist files,
and 23/23 and 14/14 pass.
- **B (#20480).** The `time` arm's non-string verdict was replaced with
`return false`: the marker was present in dist before, and absent after.
Core went 3 failed / 23; four objectql files went 4 failed / 82.
Restored and rebuilt, the marker is present, and 82/82 pass.
**NOT MEASURED:**
- MySQL cells: not provisioned in this container. They run in CI's
`Temporal Conformance (live PG + MySQL)` job, in
`sql-driver-time-live-dialects.test.ts` and
`sql-driver-20264-temporal-year-range.test.ts`.
- turso remote and MongoDB: no server.
- The PostgreSQL cells in `packages/rest` ran locally, but no CI job
provisions PostgreSQL for that package. CI's PostgreSQL coverage of this
card is the driver-sql half above.
## Acceptance notes (not filed)
- A `date` comparand with a real leading day and unreadable trailing
text (`"2026-07-15T25:00"`) is read as its day. The write door refuses
the same string through `Date.parse`. This predates this PR and is
outside both cards' classes.
- The `time` year class's wording is chosen by core's
`isOutsideTemporalYearRange` on the instant. For a year-0 instant in a
non-ISO spelling on a `time` column (refused for its spelling), the
message would name the year class instead. The verdict is right; the
edge is theoretical.
- The in-flight spec lane branch for #20600 (`datetime.ts`,
`having-filter.ts`, `matches-filter.ts`) has not landed, and no file
here overlaps it.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent bae3859 commit 2473e26
15 files changed
Lines changed: 962 additions & 143 deletions
File tree
- .changeset
- packages
- core/src/utils
- drivers
- driver-memory/src
- driver-sql/src
- objectql/src
- validation
- rest/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
106 | 106 | | |
107 | 107 | | |
108 | 108 | | |
109 | | - | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
110 | 118 | | |
111 | | - | |
112 | | - | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
113 | 127 | | |
114 | 128 | | |
115 | 129 | | |
116 | 130 | | |
117 | | - | |
| 131 | + | |
118 | 132 | | |
119 | 133 | | |
120 | 134 | | |
| |||
155 | 169 | | |
156 | 170 | | |
157 | 171 | | |
158 | | - | |
| 172 | + | |
| 173 | + | |
159 | 174 | | |
160 | 175 | | |
161 | 176 | | |
| |||
169 | 184 | | |
170 | 185 | | |
171 | 186 | | |
| 187 | + | |
172 | 188 | | |
173 | 189 | | |
174 | 190 | | |
175 | 191 | | |
176 | 192 | | |
177 | 193 | | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
| 333 | + | |
| 334 | + | |
| 335 | + | |
| 336 | + | |
| 337 | + | |
| 338 | + | |
| 339 | + | |
0 commit comments