Skip to content

fix(objectql): refuse an uninterpretable temporal filter comparand at the engine door (#8690) - #8808

Merged
hotlong merged 5 commits into
mainfrom
claude/issue-8690-temporal-comparand-refusal
Aug 15, 2026
Merged

hotlong merged 5 commits into
mainfrom
claude/issue-8690-temporal-comparand-refusal

Conversation

@hotlong

@hotlong hotlong commented Aug 15, 2026 •

Copy link
Copy Markdown
Contributor

Part of #8690 — the B half only. Merging this does not close the card: the C half (refuse the declared preset vocabulary at publish time, packages/spec + @objectstack/lint, claude-fable-5 tier by the ruling's own last line) is carved out of this dispatch and tracked on #8793. #8690 remains open after this lands.

Implements the maintainer ruling of 2026-08-15 (delegated adjudication), option B, explicitly not A.

The defect

A datetime field filtered with a bare string the API cannot take literally was bound as written all the way to the driver, where the comparison is false for every row — HTTP 200, empty result set, no diagnostic. An unknown {placeholder} in the same position was already refused loudly, so one API answered two shapes of unusable comparand two different ways.

Reachable rather than theoretical: last_7_days / last_30_days / last_90_days are declared preset names in the dashboard schema. The console lowers them to {N_days_ago} macros, so the console path was always safe — a saved report, an integration, an MCP client or an AI-authored query sends the preset name itself.

Where the refusal lands, and why

lowerWhereFilterArray is the engine's single filter collection point and the one seam holding the caller's comparand and the field's declared type at the same moment. It is reached by every verb (find / findOne / count / aggregate / update / delete) through both spellings (the array sugar and the already-lowered condition the protocol face hands over), so all four backends inherit one answer.

The two seams triage originally named were measured and cannot host it: packages/core's token resolver is field-agnostic by construction, and packages/rest binds no comparands at all. The driver layer holds both facts but is four frozen packages whose pass-through is a deliberate, counter-pinned contract shared with the write path — rejected by name.

PM mechanism assumption: confirmed by measurement. lowerWhereFilterArray(object, operation, bag, schema) is already handed this._registry.getObject(object) at all six call sites, and its existing neighbour assertFilterIsMaterializable already reads schema.fields[name].type. Nothing new had to be threaded.

package change
@objectstack/core isUninterpretableTemporalComparand(kind, value) — the VALUE half, shared so the rule cannot exist twice in two packages that do not depend on each other. Interpretability is defined by the drivers' own total functions, so the door refuses exactly what a driver would hand back unchanged.
@objectstack/objectql the door: INVALID_FILTER / 400, naming field, declared kind, value, key path and the spellings that work.
@objectstack/service-analytics NativeSQLStrategy.canHandle declines an uninterpretable temporal comparand so raw-SQL paths fall through to the door instead of binding it into their own statement.

Deviation to flag, deliberately not silent

The ruling says the analytics decline should arrive "via a new StrategyContext hook". Measured, StrategyContext is declared in packages/spec (contracts/analytics-service.ts) — which this dispatch forbids, and which is where the carved-out C half lands. Rather than edit spec or declare an undeclared hook on a shared contract, the decline classifies on metadata StrategyContext already carries: a cube dimension declares type: 'time', resolved through the same lookupMember every other member lookup in the strategy uses. Same shape as the 2026-08-12 Q1=B ruling one seam over, which rejected new StrategyContext hooks for exactly this decision. Cost, recorded in the code: a temporal column filtered without being a declared time dimension is not classified, so it keeps today's behaviour on the raw-SQL path — strictly smaller than "every raw-SQL query bypasses the door", and it fails in the safe direction, since a missed decline degrades to today's behaviour rather than a new wrong answer. Reviewed and accepted; the residual precision is carried onto #8793, which is already opening packages/spec.

Scope boundaries, each by ruling

  • Non-empty strings only. The empty-string cell stays its own card and is pinned unchanged (measured: it binds as '' and returns 51 of 51 — the card table's 38 is a transcription error its own prose corrects).
  • {placeholder} strings are stepped around. The door runs before token resolution because the refusal must precede the driver, so judging one would refuse {30_days_ago}. Unknown tokens keep FILTER_TOKEN_UNKNOWN / 400.
  • Non-string comparands untouched — a number is epoch milliseconds, a Date is an instant.
  • ⛔ No packages/drivers change. ⛔ No packages/rest consumer-side patch. ⛔ No packages/spec edit.

Verification

Union re-run after the final commit, at fd917ec42 (main merged in), clean tree.

  • @objectstack/objectql — 209 files, 3664 tests passed; typecheck (tsc --noEmit) clean.
  • @objectstack/core — 34 files, 831 passed. @objectstack/service-analytics — 77 files, 1722 passed (includes [finding] service-analytics carries its own copies of the comparand-type allow-list the #7872 door now single-sources — reconcile membership and message wording to the door #8186's comparand-door-single-source suite, which arrived on main and touches the same file this PR extends).
  • Refusal pin + positive control in one test, as required: the four preset comparands each assert code === 'INVALID_FILTER' and status === 400 with zero driver reads, and the same it() asserts {30_days_ago} still returns 38 rows on the card's 51-row / 38-in-window dataset shape, with the resolved floor read back off the driver AST.
  • Raw-SQL bypass pinned: the decline routes four uninterpretable comparands away from executeRawSql, and an over-decline control proves 2026-07-15, an ISO instant and {30_days_ago} all keep the P1 fast path.
  • Reverse verification, direction predicted before running: with the two door call sites ablated, 5 of 8 red, 3 green — every refusal cell lost its refusal (expected null not to be null, i.e. the silent zero returned), while the three controls (token resolver, scoped-out cells, registry-less no-verdict) stayed green because none of them is the door's doing. Restored with git checkout HEAD -- packages/objectql/src/engine.ts from the commit that already carried the fix.
  • Gates at this head: check:nul-bytes, check:error-code-casing, check:durability-log-level, check:kernel-hook-pairs, check:stack-collection-maps, check:test-source-alias, check:type-source-resolution, check:query-options-erasure, check:type-check-coverage, check:changeset-gate-self-tests, check:objectui-changeset, plus check-adr-0087-registration, check-changeset-no-major, check-empty-changeset, check-engine-split-ratio — all PASS.

check:type-check-debt — the red this PR was kicked for, and the fix

The first push went red on @objectstack/objectql's TEST_DEBT: recorded 355, measured 356. The +1 was reads.at(-1) in the new pin — TS2550, because this package's lib target predates Array.prototype.at. It was invisible to pnpm --filter @objectstack/objectql typecheck because objectql's tsconfig.json excludes **/*.test.ts, so tsc --noEmit never compiled the file; only the TEST_DEBT re-measure, which drops that exclusion, sees it.

Fixed at the source — indexed access, same assertion, no @ts-expect-error, no skipped case, ledger not raised.

All three implicated entries re-measured at fd917ec42, each with the same project shape the gate constructs (fidelity confirmed: this replication reproduced CI's 356 exactly before the fix):

ledger entry recorded measured from this PR's files
@objectstack/objectql (TEST_DEBT) 355 355 0
@objectstack/core (DEBT) 98 98 0
@objectstack/service-analytics (DEBT) 10 10 0

Both new test files were confirmed inside the programs those numbers are measured from (tsc --listFiles), so the zeros are real coverage rather than a file nothing read. The full --re-measure sweep runs every ledger entry sequentially and exceeds one call window locally; CI runs it whole.


Generated by Claude Code

claude added 3 commits August 15, 2026 02:30
… the engine door (#8690)

A bare string a temporal field cannot interpret — `last_30_days`,
`not-a-date-at-all` — was bound as written, compared false for every row,
and answered 200 with an empty result and no diagnostic, while an unknown
`{placeholder}` was refused loudly one branch over.

Refuse it at the ObjectQL engine's single filter collection point, per the
maintainer ruling of 2026-08-15 (option B): `lowerWhereFilterArray` is the
one seam holding the caller's comparand and the field's declared type at the
same moment, on every verb and through both doors.

- `@objectstack/core`: the value-half predicate, shared so the rule cannot
  exist twice; interpretability is defined by the drivers' own totals.
- `@objectstack/objectql`: the door, `INVALID_FILTER` / 400.
- `@objectstack/service-analytics`: `NativeSQLStrategy.canHandle` declines an
  uninterpretable temporal comparand so raw-SQL paths fall through to the
  door instead of binding it directly.

Scoped to non-empty strings by ruling: the empty-string cell stays its own
card, and `{placeholder}` strings keep their existing loud refusal.

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

vercel Bot commented Aug 15, 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 15, 2026 4:03am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/core, @objectstack/objectql, @objectstack/service-analytics.

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

  • content/docs/ai/actions-as-tools.mdx (via @objectstack/core)
  • content/docs/ai/knowledge-rag.mdx (via @objectstack/core)
  • content/docs/ai/natural-language-queries.mdx (via @objectstack/core)
  • content/docs/api/data-api.mdx (via @objectstack/service-analytics)
  • content/docs/api/index.mdx (via @objectstack/service-analytics)
  • content/docs/automation/webhooks.mdx (via @objectstack/core)
  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/objectql)
  • content/docs/concepts/north-star.mdx (via packages/core)
  • content/docs/data-modeling/formulas.mdx (via packages/objectql)
  • content/docs/deployment/migration-from-objectql.mdx (via @objectstack/core, @objectstack/objectql)
  • content/docs/deployment/vercel.mdx (via @objectstack/objectql)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/objectql)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/core)
  • content/docs/kernel/runtime-services/examples.mdx (via @objectstack/core, packages/objectql)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/core, @objectstack/objectql, @objectstack/service-analytics)
  • content/docs/kernel/services.mdx (via @objectstack/core, @objectstack/objectql)
  • content/docs/permissions/authentication.mdx (via @objectstack/core, @objectstack/objectql)
  • content/docs/permissions/authorization.mdx (via packages/core)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/service-analytics)
  • content/docs/permissions/system-context.mdx (via packages/objectql)
  • content/docs/plugins/anatomy.mdx (via @objectstack/core)
  • content/docs/plugins/development.mdx (via @objectstack/core)
  • content/docs/plugins/index.mdx (via @objectstack/core, @objectstack/objectql)
  • content/docs/plugins/packages.mdx (via @objectstack/core, @objectstack/objectql, @objectstack/service-analytics)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/core, @objectstack/objectql)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/core)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/core)
  • content/docs/protocol/objectql/query-syntax.mdx (via packages/objectql)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/objectql)

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

  • content/docs/releases/implementation-status.mdx (via @objectstack/core, @objectstack/objectql, @objectstack/service-analytics)
  • content/docs/releases/v12.mdx (via @objectstack/core)
  • content/docs/releases/v15.mdx (via @objectstack/core)
  • content/docs/releases/v17.mdx (via @objectstack/core, @objectstack/service-analytics)
  • content/docs/releases/v9.mdx (via @objectstack/service-analytics)

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.

claude added 2 commits August 15, 2026 04:03
…in the temporal-door pin (#8690)

`reads.at(-1)` is TS2550 under this package's lib target, and objectql's
tsconfig hides `**/*.test.ts` from its own `typecheck` script — so the error
was invisible to `tsc --noEmit` and surfaced only in the TEST_DEBT re-measure,
pushing the shrink-only ledger 355 -> 356.

Indexed access instead. Same assertion, same read, ledger back at 355.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XeQRiAa7vYRVX5Fog7Zby8
@hotlong
hotlong marked this pull request as ready for review August 15, 2026 04:20
@hotlong
hotlong added this pull request to the merge queue Aug 15, 2026
Merged via the queue into main with commit 402c125 Aug 15, 2026
29 checks passed
@hotlong
hotlong deleted the claude/issue-8690-temporal-comparand-refusal branch August 15, 2026 04:34
os-project-manager pushed a commit that referenced this pull request Aug 15, 2026
…he +2 TEST_DEBT drift (#8793)

Same class as #8808's TS2550: invisible to 'pnpm --filter @objectstack/lint
typecheck' because tsconfig excludes **/*.test.ts; only the TEST_DEBT
re-measure compiles the file. The two TS7006s were downstream of the one
unresolved import (TS2835). Ledger not raised; re-measured 19 vs recorded 20
on the fully built workspace closure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fgvh1iEJfxetei7aNVdtJt
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Aug 17, 2026
…filter comparand at publish time (objectstack-ai#8793) (objectstack-ai#8935)

* feat(spec,lint): refuse bare date-range preset names in ordering filter comparands (objectstack-ai#8793)

The C half of objectstack-ai#8690 (maintainer ruling 5299879288): the declared dashboard
date-range preset vocabulary (last_7_days / last_30_days / last_90_days and
their ten calendar siblings) is refused at publish time when authored as a
bare ordering comparand, where no layer of the platform can interpret it.

- data/date-range-presets.ts: vocabulary re-homed from ui/dashboard.zod.ts
  (single source objectstack-ai#4614 kept; ui re-exports) + macro-window prescriptions
- data/filter.zod.ts: FilterConditionSchema refuses presets under
  $gt/$gte/$lt/$lte and $between endpoints, ordering positions only
- lint: new filter-preset-comparand rule, all three authored filter shapes,
  CLI + runtime-publish surfaces

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

* feat(spec): ADR-0087 entry, changeset, runtime-gate pin updates (objectstack-ai#8793)

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

* fix(lint): give the new rule test its .js import extension — clears the +2 TEST_DEBT drift (objectstack-ai#8793)

Same class as objectstack-ai#8808's TS2550: invisible to 'pnpm --filter @objectstack/lint
typecheck' because tsconfig excludes **/*.test.ts; only the TEST_DEBT
re-measure compiles the file. The two TS7006s were downstream of the one
unresolved import (TS2835). Ledger not raised; re-measured 19 vs recorded 20
on the fully built workspace closure.

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

* chore(spec): regenerate os-regen artifacts after merging main's registry sibling (objectstack-ai#8793)

os-regen relay for objectstack-ai#8932's retired-def:18 landing: merge committed first,
then gen:migration-registry / api-surface / export-origins / spec-changes /
upgrade-guide / openapi re-run on the merged tree. Both sides asserted
surviving: this branch's filter-preset-ordering-comparand-refused entry AND
objectstack-ai#8932's identity-api-key-schema-retired entry + its identity.zod.ts deletion.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
… four-digit year, and one outside 0..9999 is refused INVALID_FILTER / 400 (objectstack-ai#20240) (objectstack-ai#20261)

Fixes objectstack-ai#20240
Clause-②: no (narrowing)

A number or `Date` compared against a `date` field now spells its year
with four digits, the spelling its ISO string already had. One whose UTC
calendar day falls in a year below 0 or above 9999 has no `YYYY-MM-DD`
form, and it is now refused `INVALID_FILTER` / 400 at the
temporal-comparand door, as its ISO string already was. Both edits are
in the one rule and its one predicate, in `@objectstack/core`. No driver
source changes.

## What changed

- `packages/core/src/utils/temporal-storage-form.ts`,
`canonicalCalendarDay`: a non-negative year of a `Date` or a number is
padded to four digits (`0999-06-15`, not `999-06-15`). A year below 0 or
above 9999 keeps the spelling it had (`10000-01-01`, `-1-01-01`). The
rule is shared with the write and read paths, and no ordered form is
invented.
- `packages/core/src/utils/temporal-comparand.ts`,
`isUninterpretableTemporalComparand`: on `date`, a finite number or a
valid `Date` whose UTC year is below 0 or above 9999 is uninterpretable.
That includes a finite number past the `Date` range (±8.64e15). `NaN`,
±Infinity and an Invalid Date name no year, so they stay unjudged. The
`datetime` and `time` branches are untouched. The module note about
non-string comparands was rewritten so it stays true (the stop valve,
below).
- `packages/objectql/src/temporal-comparand-door.ts`: the door already
calls that predicate on `where` (every verb, both spellings) and on a
per-aggregation `filter`. It now words the new class in its own
sentence, because such a comparand answers the wrong rows or a database
error, not "compare false for every row". The result's `value` is
widened from `string` to `unknown`. It is internal and not exported from
the package.
- Tests and `.changeset/20240-date-year-four-digits.md` (`minor`: core
and objectql; `patch`: driver-sql and driver-memory). There is one
one-clause DELIBERATE CORRECTION to
`.changeset/20203-epoch-ms-date-comparand.md` (see Deviations).

## Stop valve: the ruling scoped objectstack-ai#8690's own change; it is not a
standing rule

`temporal-comparand.ts` said: "Non-string comparands. A number is epoch
milliseconds, a `Date` is an instant, `null` is a null test. The refusal
scopes to strings by ruling." Every scope sentence on objectstack-ai#8690, read in
full:

- Triage 5294745926: "Refusing a non-interpretable bare string on a
temporal target is the queued work."
- Maintainer ruling 5299879288, its only scope clause: "The
**empty-string cell stays its own card** — B and C scope to non-empty
strings and must not decide it in passing".
- PR objectstack-ai#8808's body lists "Non-string comparands untouched — a number is
epoch milliseconds, a `Date` is an instant" under "Scope boundaries,
each by ruling". The ruling itself contains no clause about non-strings.

Reading: the ruling scoped objectstack-ai#8690's own change (strings, the card's
defect, and the empty-string boundary). The door's own reason for
leaving non-strings alone was a fact ("both are read correctly today").
That fact is false for a `date` year outside 0..9999, which this card
measured. The valve does not trip, so triage's refuse direction applies.
The module note now says exactly this.

## The rows, base `0d3ec47137` → head

The fixture has seven rows on a `date` field (six 2026 days plus
`0999-06-15`), live on InMemoryDriver, SqlDriver on better-sqlite3, and
PostgreSQL 16.13. The process zone is `America/New_York` and the server
zone `Asia/Shanghai`. Each cell went through `engine.find` /
`engine.aggregate` and through `POST /api/v1/data/:object/query` with a
JSON round-tripped body. The two doors agree on all 288 comparable cells
at base and at head. Over REST a `Date` is its ISO string (all 144 REST
`Date` cells equal their ISO twins). Cells are `$gt` / `$lt` / `$eq`,
given as memory · SQLite · PostgreSQL.

| comparand | position | base | head |
|:--|:--|:--|:--|
| number for 0999-06-15 (`-30627504000000`), and its `Date` (engine) |
`where` | 0/7/0 · 0/7/0 · 6/0/1 | 6/0/1 on all three |
| the same | per-aggregation `filter` | 0/7/0 on all three | 6/0/1 on
all three |
| the same | `having` on `max(date)` | none / all four groups / none, on
all three | c1,c2,c3 / none / c4, on all three |
| ISO `0999-06-15T00:00:00.000Z` | all three positions | 6/0/1, and
c1,c2,c3 / none / c4 | unchanged |
| number for 10000-01-01 (`253402300800000`), and its `Date` | `where` |
6/1/0 · 6/1/0 · 0/7/0 | `INVALID_FILTER` / 400, no read |
| the same | per-aggregation `filter` | 6/1/0 on all three |
`INVALID_FILTER` / 400, no read |
| number for -1-01-01 (`-62198755200000`), and its `Date` | `where` |
7/0/0 · 7/0/0 · `DATABASE_ERROR` 500 | `INVALID_FILTER` / 400, no read |
| the same | per-aggregation `filter` | 7/0/0 on all three |
`INVALID_FILTER` / 400, no read |
| ISO `+010000-01-01T00:00:00.000Z` / `-000001-01-01T00:00:00.000Z` |
`where`, per-aggregation `filter` | `INVALID_FILTER` / 400 | unchanged |
| control, number / `Date` / ISO for 2026-02-01T10:00Z | all three
positions | 1/4/2, and c2 / c1,c4 / c3 | unchanged |
| the same numbers on a `datetime` field | `where` | memory, SQLite
7/0/0 on every year; PostgreSQL 500 for 10000 and -1 | unchanged (see
Out-of-scope 2) |

"No read" was measured with a counter on the object's `find` / `findOne`
/ `count` / `aggregate`: zero reads through the engine and REST, on all
three drivers.

Moved cells, classified mechanically: 180 of 867. Every one is a number
or `Date` on the `date` field: 0999 at `where`, the per-aggregation
`filter` and `having`, and 10000 / -1 at `where` and the per-aggregation
`filter`. Zero moved among ISO strings, the 2026 control, the `datetime`
control and `having` out-of-range.

## H2, the PostgreSQL raw number: not reproduced at base

At `0d3ec47137` (and at the card's `615c468746`, whose query path is
byte-identical: `git diff --stat` on core, both drivers, objectql, rest
and metadata-protocol sources shows only four plugin-contract /
plugin-loader files), no layer hands PostgreSQL the raw number for year
-1. `SqlDriver.coerceFilterValue` → `toDateOnly` → `temporalStorageForm`
binds the rule's `"-1-01-01"`, and the server refuses it with `22007
invalid input syntax for type date: "-1-01-01"`. That was captured from
knex's query-error bindings on every one of the 9 statements. The card's
message, `22009 time zone displacement out of range: "-62198755200000"`,
is exactly what `select '-62198755200000'::date` answers. That is the
pre-objectstack-ai#20224 path, where `toDateOnly` returned a number unchanged. At head
the refusal precedes the driver, with zero reads.

## H4's `having` leg: falsified, `having` never reaches the door

`having` takes its own entry doors in `engine.aggregate`
(`assertHavingIsFilterCondition`, `assertListComparandShapes`,
`normalizeFilterComparandTypes`, `assertHavingIsEvaluable`). None of
them is the temporal-comparand door, for any comparand. Measured at base
on all three drivers, through both doors, on `max(date)`:
- `having: { last_placed: { $lt: 'not-a-date' } }` keeps every group
(200), and its `where` twin is refused 400;
- the ISO string `+010000-01-01T00:00:00.000Z` under `$gt` keeps every
group, and its `where` twin is refused 400.

So a year-outside-range number or `Date` on `having` still compares as
written: the number for 10000-01-01 under `$gt` keeps c1, c2, c3 at
head, as at base. Wiring the door into `having` would also newly refuse
the strings above. That is a second narrowing on a file (`engine.ts` /
`having-filter.ts`) outside the claimed surface, so it is reported
(Out-of-scope 1) and not done. The padding does reach `having`, and its
0999 cells are now right.

## H5, no collateral

- **The rule and the predicate:** 63 shapes × 3 kinds (189 cells), base
→ head. 23 moved, all `date` × a number or `Date`: the 0..999 years now
pad, and the predicate turns true outside 0..9999, the Date-range edges
and past-range numbers included. Every `datetime` and `time` cell is
byte-identical, and so is every string, `null`, bigint, boxed Number,
boolean, object and array cell.
- **End to end, a second fixture** (`date`, `datetime` and `time`
fields; rows in 1000, 2026, 9999 and 0999): 3099 cells over `where`, the
per-aggregation `filter` and `having` on all three fields, three
drivers, both doors, and the read paths (`find`, `max` / `min` per
group, a `groupBy` key). 72 moved, all a finite number past the `Date`
range (±9e15) on the `date` field at `where` or the per-aggregation
`filter`, now 400. Before, memory answered 0/0/0, SQLite 7/0/0 and
PostgreSQL 500. Every cell for years 1000..9999 (number, `Date`, ISO,
bare day) is byte-identical, as is every `datetime` and `time` cell,
year 0, `'not-a-date'`, a wall clock and every read-path cell.
- **Write paths:** 156 cells, 14 moved, all a year-0..999 number or
`Date` on memory or SQLite, now stored padded (`0999-06-15`,
`0000-06-15`). That covers `driver.create` / `driver.update`, plus
`engine.insert` of a `Date`, which the engine accepts. PostgreSQL
already stored a three-digit year's day (this sweep's short years were
0000 and 0999), but not a shorter one: under its default `DateStyle`
(`ISO, MDY`) it stored the unpadded `9-03-04` as 2004-09-03 and refused
`99-03-04` (`22008`). That was measured under ablation A (below) and
with `select '9-03-04'::date`, and it is pinned by the 0009 / 0099
cells. MySQL 8.0, measured at the driver door in round 2, stored
`99-03-04` as 1999-03-04. All three dialects now store the day. The
engine and REST write doors still refuse a number on `date`
(`VALIDATION_FAILED` / 400), unchanged, and every out-of-range write
cell is unchanged.
- **`service-analytics`' raw-SQL decline**
(`NativeSQLStrategy.canHandle`, not edited): 0 of 7 moved. It reads a
time dimension with the `datetime` rule (`lookupMember(...)?.type ===
'time' ? 'datetime' : null`), so the new `date` branch is unreachable
from it.

## Tests

New pins:
- `packages/core/src/utils/temporal-storage-form.test.ts`: years
0000..0999 pad for the number, its `Date` and its ISO string; the
spellings sort chronologically; out-of-range years keep their spelling.
- `packages/core/src/utils/temporal-comparand.test.ts` (new): the `date`
year class, the datetime / time controls, `NaN` / Infinity / Invalid
Date unjudged, and an invariant that for a finite number the predicate
refuses exactly when the rule cannot spell `YYYY-MM-DD`.
- `packages/objectql/src/engine-date-year-range-door.test.ts`: `code`
AND `status` plus zero driver reads, with its positive control (years
0000, 0999, 2026, 9999 reach the driver) in the same `it()`. Also
covered: every comparand position and both spellings, all six verbs, the
per-aggregation `filter`, and the datetime / time controls.
-
`packages/drivers/driver-memory/src/memory-20240-date-year-spelling.test.ts`
and
`packages/drivers/driver-sql/src/sql-driver-20240-date-year-spelling.test.ts`
(the live-dialect matrix): years 0009, 0099 and 0999 answer one count
for the number, its `Date` and its ISO string, plus the 2026 control,
`$in` / `$between`, and the create / update write path.
- `packages/rest/src/data-query-date-year-range.test.ts`: engine and
REST over SqlDriver, with `where`, the per-aggregation `filter` and
`having` for 0999 and 2026; 10000 and -1 refused with no read of the
object; a datetime control.

Whole suites, after merging `main` (`e46218674b`) and rebuilding the
closure of rest, driver-memory and driver-sql at `2f219baa71`:
- `@objectstack/core`: 58 files / 1522 passed.
- `@objectstack/driver-memory`: 57 / 1374 passed.
- `@objectstack/objectql`: 320 / 5777 passed.
- `@objectstack/driver-sql` with live PostgreSQL (`TZ=America/New_York`,
server `Asia/Shanghai`): 199 files passed, 3 skipped / 3888 passed, 88
skipped; the reporter says sqlite RAN, live postgres RAN, live mysql NOT
RUN. In round 2 (below), at `e83b248a39`, the whole driver-sql suite ran
against both live servers: PostgreSQL 16.13 and a private MySQL 8.0.46
(server zone `+08:00`), with `OS_EXPECT_LIVE_DIALECT_MATRIX=1`. Result:
202 files / 4644 passed, 1 skipped; the reporter says all 3 dialects
were exercised.
- `@objectstack/rest` (every query-route test): 203 / 3592 passed, 1
skipped.

Typecheck at `2f219baa71`: core, objectql, driver-memory, driver-sql and
rest each exit 0. Every new test file is in a compiled program
(`--listFiles`: `tsconfig.test.json` for core, objectql and rest;
`tsconfig.json` for the two drivers). The test-typecheck ledgers held:
core 4, objectql 234 errors / 65 signatures, rest 0.

## Reverse verification

Both ablations ran after the fix was committed, through
`ablation-replace` (wrap mode, restore trap on the absolute path). Core
was rebuilt in every leg and `ablation-dist-preflight` passed (present
on the mutate leg, `--absent` on the restore leg), because objectql and
rest resolve `@objectstack/core` through its `dist`. The direction was
predicted before each run.

- **A, padding off.** The whole line `const yyyy = y >= 0 ?
String(y).padStart(4, '0') : String(y);` became `const yyyy =
String(y).padStart(0, '0');`; anchor 1 → 0, blob `123c85a117` →
`685087daf5`. The contract review's literal `padStart(4, '0')` →
`padStart(0, '0')` substitution gives `d803edfcb6`. Both ids reproduce
from the source, and both unpad a non-negative year the same way, so the
counts agree:
  - core 8 failed / 82 passed;
  - memory 9 failed / 3 passed;
  - driver-sql 12 failed / 12 passed / 1 skipped;
  - REST 3 failed / 5 passed;
  - objectql 6 passed.

Predicted red, observed red, with one more red than first predicted. On
the first run PostgreSQL went red on the write path, which I had
predicted green. Its default `DateStyle` (`ISO, MDY`) reads the unpadded
`9-03-04` as **2004-09-03**, a different day, silently, and refuses
`99-03-04` (`22008`). The padded forms read right. So I added 0009 and
0099 cells to both driver pins and re-ran; the counts above are that
run. The base defect is in this card's class and the padding closes it.
- **B, refusal off** (`return isOutsideCalendarDayYears(value);` →
`return isOutsideCalendarDayYears(value) ? false : false;`; blob
`912a0609ab` → `f150d2d782`):
  - core 3 failed / 87 passed;
- objectql 4 failed / 2 passed (the datetime/time control and the
unjudged `NaN` stay green);
  - REST 2 failed / 6 passed;
- memory 12 passed and driver-sql 24 passed, as predicted, since the
drivers are not the door.
- **Restore legs:** after both, the blob equals HEAD, `git diff HEAD` is
empty and `git status --porcelain` is clean. Core was rebuilt and the
preflight `--absent` exited 0. Pins: core 90, objectql 6, memory 12,
driver-sql 24 + 1 skipped, REST 8, all passed.

## Gates

`dispatch-gates --commands --repo objectstack-ai/objectstack` at
`2f219baa71` derives 65 commands (the dispatch clue's 50, plus the
changeset families and more). Every one ran at `2f219baa71`: 62 exit 0,
1 exit 1, 2 exit 3. `--ran` with the recorded exit codes reads: 65
derived, 63 run, 2 NOT MEASURED (derived from exit 3), 0 unrun.
- `check-empty-changeset --base origin/main`: **exit 1, on purpose.** It
names exactly `.changeset/20203-epoch-ms-date-comparand.md`, which is
the DELIBERATE CORRECTION class. Please confirm it here; it must not be
restored from the base.
- `pnpm check:dual-build-cjs-loads` and `pnpm check:type-check-debt`:
**NOT MEASURED**, `PREREQUISITE NOT MET`. Both read every package's
built output, and CI builds the whole `./packages/*` before them.
- Also run, all exit 0: the six roster gates whose roster sits under a
path of mine (`check-changeset-fixed`, `check:authz-resolver`,
`check:error-code-casing`, `check:filter-alias-parity`,
`check:object-def-param-keys`, `check:tenant-chokepoint`), and
`check-issue-citations --base e462186` (11 citations, all resolve).
`check-adr-0087-registration` reads the `not-required
(no-migration-prescription)` disposition, and `check-changeset-no-major`
finds no `major`.
- Lint is a declared narrowing: `eslint --no-inline-config --format
json` on the 9 changed `.ts` files at `2f219baa71` reports 9 files, 0
errors / 0 warnings / 0 fatal, and `--print-config` resolves a config
for each. The two `.md` files resolve none, so they are outside eslint's
population. `eslint.config.mjs` enables no type-aware linting (no
`parserOptions.project`, no `projectService`, no `TypeChecked` preset),
so no untouched file's verdict can move.

## Round 2: contract review 5857959126

**Must-change 1: the live MySQL cell** (CI job 108661118941, `Temporal
Conformance`). It failed with `expected '1909-03-04' to be '0009-03-04'`
on the write-path assertion only; the SQLite and PostgreSQL cells
passed, and so did MySQL's `where` cells, which read ids and not dates.
The cause is the read, not the write:
- mysql2 3.23.1, `lib/packets/packet.js` `parseDate`, rebuilds a `DATE`
as `new Date(y, m - 1, d)`, or as `new Date(Date.UTC(y, m - 1, d))`
under `timezone: 'Z'`, which driver-sql sets (`sql-driver.ts`, the
`timezone: 'Z'` connection override). Both constructors map a year 0..99
to 1900..1999.
- On a private MySQL 8.0.46 (server zone `+08:00`, the CI setting), the
bound `'0009-03-04'` is stored as `0009-03-04` (`CAST(... AS CHAR)` and
`DATE_FORMAT` agree). mysql2 hands back 1909-03-04 for it, 1999-03-04
for `0099-03-04` and 1900-06-15 for `0000-06-15`, while year 999
survives.
- The failure reproduced locally before the fix, byte for byte (1 failed
/ 23 passed).

The fix is in the test only; there is no driver `src` change and no
skip. The write-path cell now reads the STORED text through a raw
per-dialect cast (`::text` on PostgreSQL, `cast(... as char)` on MySQL,
`cast(... as text)` on SQLite) on every dialect, the pattern
`sql-driver-12380-json-roundtrip.test.ts` uses. It adds the year-99
write, the one MySQL stored wrong at base, and checks `$eq` on both
short-year days. The year-999 row still reads back through the driver
too. Result: 36 passed across SQLite, PostgreSQL and MySQL.

Reverse verification of the new cell, with the fix committed and
ablation A re-run through `ablation-replace` (driver-sql reads core from
source, so no rebuild was owed). Direction predicted before the run: 14
failed / 22 passed, split SQLite 9, PostgreSQL 3 (0009, 0099, the write
path) and MySQL 2 (0099 `$lt`, the write path). MySQL reads `9-03-04`
and `999-06-15` literally, so its other cells stay green. Observed
exactly that. Restored: blob equals HEAD, `git diff HEAD` empty,
porcelain clean.

MySQL at the driver door, base `e46218674b` (a detached worktree)
against head, create and update alike:

| written | base: stored / read back | head: stored / read back |
|:--|:--|:--|
| number or `Date` for 0009-03-04 | `0009-03-04` / `1909-03-04` |
`0009-03-04` / `1909-03-04` |
| number or `Date` for 0099-03-04 | **`1999-03-04`** / `1999-03-04` |
`0099-03-04` / `1999-03-04` |
| bare day `'0099-03-04'` | `0099-03-04` / `1999-03-04` | unchanged |
| number or bare day for 0999-06-15 | `0999-06-15` / `999-06-15` |
`0999-06-15` / `0999-06-15` |
| number or bare day for 0000-06-15 | `0000-06-15` / `1900-06-15` |
unchanged |
| number for 2026-02-01T10:00Z | `2026-02-01` / `2026-02-01` | unchanged
|

`$eq` found every row it wrote, at base and at head.

**Must-change 2, the prose.** The changeset's "PostgreSQL already stored
the day." now says what was measured: PostgreSQL and MySQL stored a
three-digit year's day, and PostgreSQL misread (`9-03-04` as 2004-09-03)
or refused (`99-03-04`, `22008`) a shorter one, while MySQL stored
`99-03-04` as 1999-03-04. Its "Unchanged … every read-path presentation"
is now scoped to the three dialects it was measured on, and it names the
MySQL read cells above. The H5 write-path sentence in this body is
aligned the same way.

Gates for this round, at `e83b248a39`: the 59 commands derived for the
two changed paths ran, plus the six roster gates and
`check-issue-citations`. Result: 63 exit 0; `check-empty-changeset` exit
1 on the same DELIBERATE CORRECTION; `check:dual-build-cjs-loads` and
`check:type-check-debt` exit 3 (PREREQUISITE NOT MET). `--ran` against
the full derivation at `e83b248a39` reads 65 derived, 63 run, 2 NOT
MEASURED, 0 unrun. The six families this round's paths do not reach
carry their `2f219baa71` records. Also clean: eslint on the changed test
(0 / 0) and driver-sql typecheck (exit 0, file in the program). The core
pins passed (90).

## Deviations

- **`.changeset/20203-epoch-ms-date-comparand.md`, a one-clause
DELIBERATE CORRECTION.** Its "Not changed" list named "every `Date` … on
a `date` field" and "a number outside the `Date` range", and this card
moves both in the same release. It now reads "… every `Date` and every
string on a `date` field (objectstack-ai#20240, in the same release, then pads a
`Date`'s or a number's year 0..999 to four digits and refuses one whose
year falls outside 0..9999, a number past the `Date` range included),
and every `datetime` and `time` reading." Nothing else in the note
changed. `.changeset/20176-*` and `.changeset/20212-*` were read and
left alone: 20176's "Not changed" list is scoped before and after its
own change (the reading objectstack-ai#20224's accepted review applied to that
sentence's "every `where` answer"), and 20212 is RLS.
- **H4's `having` leg is not delivered** (above). `having` never reached
the door, and wiring it in is outside the claimed surface and a second
narrowing.
- **Where each driver is pinned** follows PR objectstack-ai#20224 / objectstack-ai#20202.
`packages/rest` has no driver-memory dependency, and a live PostgreSQL
is reachable only through driver-sql's testkit (its isolation test
forbids reading the env var elsewhere). So memory and PostgreSQL are
pinned at their driver doors, and their engine and REST cells are the
measured table above.
- `main` was merged once (`e46218674b`) before opening, per AGENTS.md
§10. The whole suites, typecheck and gate union above were re-run on the
merged head. `main` has moved again since (`443b2f4fdc`), and the queue
rebuilds on it.

## Acceptance notes

- `driver-mongodb`'s `storageDateValue` (`mongodb-temporal.ts`) is its
own copy of the `date` rule. It spells a `Date`'s year unpadded
(`${y}-${m}-${d}`), as core did before this PR, and returns a number
unchanged (objectstack-ai#20203's note). After this PR it disagrees with core on both,
as a code reading only: no `mongod` is reachable here, so it is NOT
MEASURED. Carrier none, beside the same note on PR objectstack-ai#20202 and PR objectstack-ai#20224.
- `IObjectQLEngine.judgeFilter` runs the same `where` admission, so the
analytics read-scope judgement (objectstack-ai#19995) now refuses a scope carrying
such a number or `Date` on a `date` field. This is a code reading; it
was not measured.
- The rule keeps `10000-01-01` / `-1-01-01` for an out-of-range `Date`
on the write path, so `engine.insert` of such a `Date` still stores that
text on memory and SQLite, unchanged.

## Out-of-scope findings (reported for the seat; nothing filed from
here)

1. **class (a) · reach: public door, a measured wrong answer.** `having`
on a temporal aggregated column does not reach the temporal-comparand
door for any comparand. `POST /api/v1/data/:object/query` with `having:
{ last_placed: { $lt: 'not-a-date' } }` over `max(placed_on)` answers
200 and keeps every group on memory, SQLite and PostgreSQL, while the
`where` twin answers `INVALID_FILTER` / 400. The number for 10000-01-01
under `$gt` keeps three groups where a refusal is due. Seam: runtime
`packages/objectql/src/engine.ts` (`aggregate`, the `having` entry
doors) → `having-filter.ts`. Family: the temporal-comparand door's
positions (objectstack-ai#8690 `where`, objectstack-ai#20148 per-aggregation `filter`; `having`
missing). Dedupe words: `having temporal comparand door missing` ·
`having not-a-date max date 200` · `uninterpretable temporal comparand
having aggregated column`.
2. **class (a) · reach: public door, a measured wrong answer.** The
`datetime` rule spells a year outside 0..9999 in extended ISO
(`+010000-01-01T00:00:00.000Z`, `-000001-01-01T00:00:00.000Z`), which
sorts as no instant does. On memory and SQLite, `where: { opened_at: {
$gt / $lt / $eq: X } }` for 10000-01-01 answers 7 / 0 / 0 (the instant's
answer is 0 / 7 / 0), in number, `Date` and ISO form alike, and
PostgreSQL answers 500 (`22009` / `22007`) for 10000 and -1. The ISO
string passes the `datetime` door, because `Date.parse` reads extended
years. Seam: runtime `packages/core/src/utils/temporal-storage-form.ts`
`canonicalUtcDatetime` and `temporal-comparand.ts` `readsAsInstant` →
both drivers. Family: this card's (a year outside the four-digit range),
on the arm the claim keeps byte-identical. Dedupe words: `datetime
comparand year 10000 extended ISO text order` · `+010000 datetime filter
postgres 22009` · `datetime storage form year outside 0 9999`.
3. **class (a) · reach: public door, a measured wrong answer.**
PostgreSQL has no year 0000. A `date` comparand in year 0 answers
`DATABASE_ERROR` 500 (`22008`) on PostgreSQL in every spelling, bare day
`'0000-06-15'` included, while memory and SQLite answer 7 / 0 / 0. That
is at base and at head (at base the number spelled `0-06-15`, also
`22008`). A direct driver write of year 0 is refused `22008` too.
Triage's pad range (0..999) includes year 0, so this PR pads it, and
whether year 0 is in the `date` range is not decided here. Family: this
card's. Dedupe words: `postgres date year 0000 out of range 22008` ·
`date comparand year zero postgres 500` · `ISO year 0000 1 BC postgres
date`.
4. **class (a) · reach: public door, measured.** The REST create door
accepts an extended-year ISO string on a `date` field and stores it
verbatim. `POST /api/v1/data/:object` with `placed_on:
'+010000-01-01T00:00:00.000Z'` answers 201 and stores that text (not a
`YYYY-MM-DD` day) on memory and SQLite, and `DATABASE_ERROR` 500 on
PostgreSQL; this is unchanged by this PR. Family: this card's, on the
write door. Dedupe words: `date field write extended year ISO stored
verbatim` · `create date +010000 stored not YYYY-MM-DD` · `date write
door year 10000 postgres 500`.

Findings 2, 3 and 4 are one family with this card (a temporal value
outside the years a fixed-width text or a backend can hold), at three
more positions, so they are named for one closing card rather than
three.
5. **class (a) · reach: public door, a measured wrong answer.** On
MySQL, a stored date in a year below 100 reads back a century late.
`POST /api/v1/data/:object` with `placed_on: '0009-03-04'` answers 201
and the server stores `0009-03-04`, but `POST
/api/v1/data/:object/query` returns `placed_on: '1909-03-04'`;
`'0099-03-04'` returns `'1999-03-04'`, year 0 returns 1900, and `find` /
`findOne` at the driver door agree. A `datetime` written as
`'0009-03-04T10:00:00.000Z'` is stored as `0009-03-04 10:00:00.000` and
returned as `'2004-09-03T10:00:00.000Z'`. Measured at the head's REST
door, and at the driver door at base `e46218674b` and head, with
identical read-back: the write went in as a bare string, which is
unchanged base → head. Cause, for DATE: mysql2 `parseDate` rebuilds the
column with `Date.UTC(y, m - 1, d)` under driver-sql's `timezone: 'Z'`,
and `Date.UTC` maps years 0..99 to 1900..1999. The `datetime` read was
not traced. Seam: runtime
`packages/drivers/driver-sql/src/sql-driver.ts` (the mysql2 connection
options and the read presentation) → mysql2 `parseDate` /
`parseDateTime`. Family: driver-sql's MySQL read path for a year below
100; separate from findings 2 to 4. Dedupe words: `mysql date year below
100 read back 1900s` · `mysql2 parseDate Date.UTC year 0009 1909` ·
`mysql datetime year 9 presented 2004`.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
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/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants