Repository navigation
fix(objectql,metadata-protocol)!: the per-aggregation filter takes where's remaining doors — a bad date, an addDays numeric pair, an undeclared { $field } and an unknown key are refused; a Date bound compares as an instant - #20174
Conversation
…s remaining doors
The per-aggregation filter now runs the temporal-comparand door, judges a
{ $field } referent and an addDays pair against the object's declared
fields (withheld words, as where gets for the same comparison), compares a
Date bound as an instant, and the REST door judges its keys with where's
unknown-field gate.
Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx
Co-authored-by: Claude <noreply@anthropic.com>
…'s where doors; changesets Engine pins (both driver kinds, empty and populated, grouped and ungrouped, zero driver calls) for the temporal door, the declared-referent and addDays class rules with their withheld words and logged diagnostic, and the Date instant comparison; a findData pin for the filter-key gate; and a REST pin over a real SqlDriver with the where twin beside every row. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
…gregation-filter-doors
📓 Docs Drift CheckThis PR changes 2 package(s): 17 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 24 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 084b27869487861f3f0eafb801a7292c6bd6fca0 && git checkout 084b27869487861f3f0eafb801a7292c6bd6fca0
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3bd28e2b2ea81206dc9f5507de205b564bd97097 4da90165cd7deebc159117dfb9c2b5b26e26a9a3 && git checkout -B drift-repro 3bd28e2b2ea81206dc9f5507de205b564bd97097 && git merge --no-ff 4da90165cd7deebc159117dfb9c2b5b26e26a9a3
node scripts/docs-audit/affected-docs.mjs --json 3bd28e2b2ea81206dc9f5507de205b564bd97097
|
Contract reviewServed-tier: Scope: PR #20174 (card #20148, ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: FAIL Must-change (one file, prose only; code, tests and gates need no change):
Optional in the same push (PR body, not shipped prose): H4 "reports 2" → the at-head reading (4, two of them |
…withholding posture, and correct two pending changesets' clauses The #20148 changeset's having sentence holds on a datetime column only; on a date-class column a UTC-midnight Date follows the calendar-day reading. Its refusal borrows driver-sql's withholding posture, not its words. Two pending changesets carry one clause each that is no longer true: #20127's says the per-aggregation filter is not judged by the addDays class rule (it is, since this card), and #20099's says an unknown having key keeps no group (it is refused, since #20123). Every other line is byte-identical. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Scope: delta review of PR #20174 (card #20148) after FAIL 5853139988 at ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
…al comparand by the column's storage rule — one rule in core, shared with both drivers' where (objectstack-ai#20202) Fixes objectstack-ai#20176 Clause-②: yes (widening) A per-aggregation `filter` (`aggregations[i].filter`) and `having` on `engine.aggregate` now read a temporal comparand by the column's storage rule — the rule both drivers already apply to the same comparand in a `where`. The rule lives in one place, `temporalStorageForm(value, kind)` in `@objectstack/core`, and `driver-sql` and `driver-memory` call it instead of each carrying a copy. Every `where` answer is byte-identical before and after, and so is every refusal PR objectstack-ai#20174 added. ## What changed - **`@objectstack/core`** — new `utils/temporal-storage-form.ts`, exported from the index: `temporalStorageForm(value, kind)` for `kind` `'datetime' | 'date' | 'time'`. It is the body that `driver-sql`'s `canonicalUtcDatetime` / `toDateOnly` / `canonicalTimeOfDay` and `driver-memory`'s `storageDatetimeValue` / `storageDateValue` / `storageTimeValue` each carried word for word. It is total, operator-blind and does not map arrays. `temporal-comparand.ts` docs now name it. - **`driver-sql`** — the three functions' bodies call the core rule; `storageDatetimeValue` still adds MySQL's literal spelling on top (the dialect half stays in the driver). No other line moved. - **`driver-memory`** — `coerceTemporalValue` calls the core rule; the three private copies are deleted (nothing else imported them). - **`@objectstack/objectql`** - `having-filter.ts` `checkCondition` takes the column's temporal kind. When it has one, the row's value and every comparand of `$eq` / `$ne` / the four orderings / `$between` / `$in` / `$nin` / implicit equality are put in the storage form first. A bare `YYYY-MM-DD` as the upper bound of a `datetime` (`$lte`, a `$between` max) means the whole day — the exclusive bound at the next day's midnight — from the spec's `nextUtcCalendarDay` (ADR-0053 D-D), as `SqlDriver.calendarDayUpperBoundRewrite` and `driver-memory`'s `$lte` arm do. Presence tests, text operators and `{ $field }` references are read as before. `matchesHaving`, `applyHaving` and `matchesAggregationFilter` thread a class map down; `declaredFieldClasses(fields)` is new (the object's declared fields, classified by the same spec sets objectstack-ai#20127's `aggregatedRowColumnClasses` uses). - `in-memory-aggregation.ts` — `applyInMemoryAggregation(rows, ast, timezone?, fields?)`: the optional declared field map reaches each per-aggregation filter. Without it the function answers as before. - `engine.ts` `ObjectQL.aggregate` — reads the declaration once, keeps `having`'s aggregated column classes (already computed for objectstack-ai#20127's `addDays` rule) and hands them to both `applyHaving` doors, and hands the field map to `applyInMemoryAggregation`. ## The rows, measured Real `InMemoryDriver` and real `SqlDriver` (better-sqlite3, schema-synced), through `engine.aggregate` and through `POST /api/v1/data/:object/query` (JSON round-tripped body), on a populated (6 rows, 3 groups) and an empty object; the per-aggregation filter on the second of two counts, the `where` twin beside it. Base `cfe2387a3b` vs head (code identical to `b48f417154`). Every empty-object cell is 0 / `[]` at base and head. | # | shape | base, populated (all 4 driver × door cells) | head | `where` twin | |:--|:--|:--|:--|:--| | 1 | ISO instant `$gte` on a `date` | 1 | 3 | 3 | | 2 | ISO instant `$eq` on a `date` | 0 | 2 | 2 | | 3 | bare day `$lte` on a `datetime` | 2 | 3 | 3 | | 4 | bare day as `$between` max on a `datetime` | 2 | 3 | 3 | | 5 | epoch-ms `$gt` on a `datetime` | 0 | 3 | 3 | | 6 | `Date` 10:00 on a `date`, `$gte` / `$lt` / `$eq` | 1 / 5 / 0 | 3 / 3 / 2 | 3 / 3 / 2 | | 7 | `Date` `$gt` on a `time` | 0 | 3 | 3 | | 8 | `having` ISO instant `$gte` on `max(date)`, both doors | c2 | c2, c3 | the date rule: c2, c3 | | control | zone-naive `'2026-02-01 09:00'` `$gt` on a `datetime` | 4 | 4 | 4 | Rows 6 and 7 are in-process only on the engine door; over REST the `Date` arrives as its ISO text, and that spelling moved the same way. Rows 6/7 on the base were identical to the card's readings, so PR objectstack-ai#20174's instant lift did not move them. The family H1 asked for, all now equal to their `where` twin on every cell (base in brackets): `$ne` 4 (6), implicit equality 2 (0), `$in` members 3 (0), `$nin` members 3 (6), `$between` endpoints 4 (3), ISO `$lt` 3 (5) on a `date`; bare days as both `$between` endpoints 2 (1), epoch-ms string 3 (6), epoch-ms `$lte` 3 (0) and `$eq` 1 (0), offset instant `$gte` 4 (3) and `$eq` 1 (0), zone-naive `T` `$eq` 1 (0), ISO without ms `$eq` 1 (0), `$ne` 5 (6), `$nin` 5 (6) on a `datetime`; ISO instant `$gt` 3 (0) and `$eq` 1 (0), short wall clock `$eq` 1 (0), `$lte` 3 (2), `$in` 2 (0), `$between` 3 (2), `.000` spelling `$eq` 1 (0), bare day `$lte` 0 (6), `Date` implicit 1 (0) on a `time`; `Date` `$in` 2 (0), `$nin` 4 (6), `$between` 4 (3) on a `date`; the ISO row under `$or` 3 (1), `$not` 3 (5), `$and` 2 (0); two operators on one `datetime` 2 (1) and a bare-day `$lte` beside an author `$lt` 4 (3); a bare-day `$lte` of today on `created_at` 6 (0). `having`, both doors: 15 more shapes on `max(date)`, `min(date)`, `min(datetime)`, `max(time)`, a `groupBy` of a `date` / `datetime` field and a `day` bucket, each moved to the date/datetime/time rule's answer. ## H2 — do the two drivers' rules agree? Measured before lifting: `driver-memory`'s `coerceTemporalValue` against `SqlDriver.temporalFilterValue` (better-sqlite3, pg and mysql2 clients, field maps seeded, no connection) on 71 shapes × 3 kinds (`Date`s valid, invalid and out of range, numbers incl. NaN / Infinity / past the Date range, epoch strings, bare days incl. impossible ones, zone-naive and zone-explicit timestamps, RFC 2822, wall clocks in and out of range, junk, placeholders, `null`, booleans, objects, nested and mixed arrays, bigint): memory = sqlite = pg on all 213 cells; mysql differs on the 36 `datetime` cells only by its literal spelling. Base to head, all 852 cells are byte-identical, so the re-pointing moved nothing. One divergence sits in the operator-sensitive layer, not in the lifted function: a bare day as a `$lte` / `$between` max on a **`time`** field. `driver-memory` widens it (`$lt '00:00:00'`, no row), `driver-sql` does not (`$lte '00:00:00'`, keeps a midnight row). Neither driver's emitter is touched here. This position follows the spec: ADR-0053 D-D's table defines the whole-day reading for `datetime`, and `TEMPORAL_TIME_CASES` says it must not reach a time column. Reported, not filed here. ## Tests - New: `packages/objectql/src/engine-aggregate-temporal-storage-rule.test.ts` (84 — the card's rows, the family, unmoved positions, `having` on both doors, the shared `TEMPORAL_CASES` + token spellings + `TEMPORAL_TIME_CASES` through a per-aggregation filter and through `having`, and the no-declaration default); `packages/rest/src/aggregation-filter-temporal-storage-rule.test.ts` (13, REST + SqlDriver, per-aggregation count equals the `where` twin, empty and populated, and `having` row 8); `packages/drivers/driver-memory/src/memory-temporal-storage-form.test.ts` (59 — `coerceTemporalValue` is the core rule, and the card's `where` twins on this driver); `packages/drivers/driver-sql/src/sql-driver-temporal-storage-form.test.ts` (10 — `temporalFilterValue` is the core rule on three clients); `packages/core/src/utils/temporal-storage-form.test.ts` (52). - Whole suites at `b48f417154` (after merging `main`, full package build): objectql `317 files / 5712 passed`; core `54 / 1410`; driver-memory `54 / 1328`; driver-sql `188 passed, 11 skipped / 3099 passed, 170 skipped`; REST aggregate files (5 incl. the new one) `109 passed, 1 skipped`; service-analytics `128 / 3017`. - Live PostgreSQL 16 (private server, server zone Asia/Shanghai, process `TZ=America/New_York`): driver-sql whole suite `196 files passed, 3 skipped / 3806 tests passed, 86 skipped`; `sql-driver-temporal-conformance` 103 with only its MySQL cell skipped. MySQL: NOT MEASURED, no server in this container. - Skewed process zone (America/New_York, Asia/Kolkata, Pacific/Chatham): the new objectql + `engine-aggregate-filter`, driver-memory new + temporal conformance, core new — all green in each zone. - Typecheck `core`, `driver-memory`, `driver-sql`, `objectql`, `rest`: exit 0 each; every new test file is in a typecheck program (`--listFiles`); objectql test-typecheck debt unchanged at 40 files / 234 errors. - Lint (declared narrowing): `eslint --no-inline-config --format json` on the 13 changed `.ts` files → 13 files, 0 errors / 0 warnings / 0 fatal; `--print-config` resolves a config for each; `eslint.config.mjs` enables no type-aware linting, so no untouched file's verdict can move. ## Reverse verification Fix committed first. `ablation-replace` (wrap mode, `EXIT/INT/TERM` trap on the absolute path) replaced the one threading site in `matchesHaving` so no column ever gets its kind: anchor 1 to 0, blob `77400359cc` to `7a2f5b7f14`. objectql rebuilt; `ablation-dist-preflight --absent` passed on all 14 built files. New objectql pins `58 failed / 26 passed (84)` — the 26 are the controls, the unmoved positions, the objectstack-ai#20148 `Date` lift on an undeclared column and the kit cases whose canonical comparands compare correctly as text. REST pins `11 failed / 2 passed (13)` — the two controls. The harness in that state reproduced the base table 5024 / 5024. Restored by `git checkout HEAD --`: blob back to `77400359cc` = HEAD, `git diff HEAD` empty; rebuilt; preflight marker present in 4 built files and the tree clean; pins `84` and `13` passed. ## Gates `dispatch-gates --commands --repo objectstack-ai/objectstack` at `b48f417154`: 69 commands (the clue list's 56 plus `check-adr-0087-registration`, `check-empty-changeset`, `release-rehearsal-clone --self-test`, `check:engine-double-contract`, `check:objectql-double-limit`, `check:objectui-changeset`, `check:pm-changeset-deadline-census`, `check:query-options-erasure`, `check:type-check-coverage`, `check:type-check-debt`, `check:where-matcher`). 68 exit 0; `--ran` reconciles 69 of 69, 0 NOT MEASURED. One is red on purpose: - `check-empty-changeset --base origin/main` exits 1 on `.changeset/20148-aggregation-filter-where-doors.md` — a **deliberate correction**, not a collision, and it needs a person's confirmation here. That pending note said "Two readings still differ from a `where`" (a `Date` with a time of day on a `date` field, and a `Date` on a `time` field). This PR closes both, so the sentence would ship false in the same release. The one-clause correction reads "Two readings still differed from a `where` in this change alone … (objectstack-ai#20176 closes both in the same release, reading every temporal comparand at this position by the column's storage rule)". Nothing else in the note changed. Also run: the three roster gates whose roster sits under `packages/` (`check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`) and `check:published-readme-exports`, exit 0 each; `check-issue-citations --base 560b724` (the merge base): 28 citations, all resolve; a control-byte scan of the changed files found none. ## Deviations - The claim scopes `engine.ts` to "the per-aggregation loop only". The loop runs before filter tokens resolve, so a comparand normalised there would miss every `{today}`-style bound, which resolves to a bare day later. The normalisation therefore happens where the filter is evaluated (`checkCondition`), and the `engine.ts` edit is the threading: one declaration read beside the existing `having` class read, and the class maps passed to both `applyHaving` calls and to `applyInMemoryAggregation`. `in-memory-aggregation.ts` carries the per-aggregation filter's class map, not `having`'s (which the engine hands to `applyHaving` directly). - The re-pointing in `driver-sql` replaces the bodies of `canonicalUtcDatetime`, `toDateOnly` and `canonicalTimeOfDay` (which `temporalFilterValue` reaches through `coerceFilterValue`) rather than only `temporalFilterValue`, so the driver's write and read paths use the same single rule. The H2 measurement is the byte-identity evidence. - The REST `where` twin on `InMemoryDriver` is measured (scratch harness, 5120 cells) but not committed as a pin: `packages/rest` has no `driver-memory` dependency. That driver's `where` half is pinned in its own package. - `.changeset/20148-aggregation-filter-where-doors.md`: one-clause deliberate correction, above. ## Acceptance notes - `driver-mongodb`'s `mongodb-temporal.ts` keeps its own copy of the rule, in BSON `Date` form for `datetime`. Its instants agree with the core rule, but it is outside this claim and was not re-pointed. - `core`'s `temporal-comparand.ts` predicates (`readsAsInstant`, `readsAsCalendarDay`, `readsAsWallClock`) restate the rule's regexes as yes/no questions for the temporal-comparand door. Deriving them from `temporalStorageForm` would remove the last mirror in `core`; not done here. - `ObjectQL.resolveNowDefault` spells the `date` / `time` forms of a `Date` by hand; it agrees with the rule for every `Date` it is handed. - A per-aggregation filter and `having` on a column whose class the declaration cannot tell (no field map, a formula) keep the previous comparison, including PR objectstack-ai#20174's instant reading of a `Date` bound. - An epoch-ms number against a `date` field is not read by the `date` rule on either driver, and the two drivers' `where` answer differently (driver-memory 0 of 6, driver-sql 6 of 6). This position answers 0, before and after; reported separately. --- _Generated by [Claude Code](https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #20148
Clause-②: no (narrowing)
A per-aggregation
filter(aggregations[i].filter) now meets the doorswheremeets. A bad date, anaddDaysnumeric pair, an undeclared{ $field }and an unknown key each answered200with the filtered count0(every row under a negation). Now each is refused400, identically on an empty and on a populated table, before any driver call. ADatebound is compared as an instant, as the same bound in awhereis.Head
4da90165cd: a prose-only patch round (contract review 5853139988, seat amendment 5853144038 on #20148) over the reviewed headf22f0e875f. Every code and test blob is unchanged:engine.ts4e1ebbf2a4,having-filter.tsab6ba1df00,protocol.tsa9a77c8f6d, and the three test files. Base49144fccc8. The branch mergedmainat369bcbeda7; the merge touched none of these files. Written by sessionsession_01Bvd69VPa6puiNzzPUroDBx, branchclaude/issue-20148-aggregation-filter-doors.3129311d33having-filter.ts: reference walk,Dateinstant comparison;protocol.ts: filter keys throughwhere's gated831680d7cobjectql,metadata-protocolandrest; two changesetsf22f0e875fmainat369bcbeda74da90165cd20148-*wording (see Patch round), and a DELIBERATE CORRECTION of one clause in each of two pending changesets (see that section)1. Measured first (H1)
A scratch harness (not committed) ran every shape through
ObjectQL.aggregateand throughPOST /api/v1/data/:object/query(RestServer, thenfindData, thenObjectQL.aggregate). It used a realInMemoryDriverand a realSqlDriver(better-sqlite3:memory:), both schema-synced, on a populated table (6 rows, groups c1 / c2 / c3) and an empty one, with driver reads counted. The REST body is JSON round-tripped, as the wire carries it. Every per-aggregation shape ran grouped and ungrouped. Beside each ran the same condition as the call'swhere(the twin), on the aggregate verb and onfind. 2896 cells per tree.Legend:
mis the filtered count, overnrows. "Base" and "head" are the per-aggregation filter's answer; the twin is identical at base and head.aggregations[i].filterwheretwin{ placed_on: { $gt: 'not-a-date' } }(date)m0, populatedm0of 6INVALID_FILTER/ 400, 0 reads, all 8 cellsINVALID_FILTER/ 400, 0 reads, all 8 cells$inmember / a$betweenendpoint / implicit / behind a held$or/ under$not;'noon'on atimem1/m6/m0/m6/m6/m0INVALID_FILTER/ 400, 0 reads, all 8 cellsINVALID_FILTER/ 400'last_30_days'on adatetimem0; RESTVALIDATION_FAILED(ingress)INVALID_FILTER; REST unchanged{ amount: { $gt: { $field: 'cap', addDays: 1 } } }m0INVALID_FILTER/ 400, 0 reads, all 8 cellsINVALID_FILTER/ 400; memoryn0addDaysdate-to-numeric, date-to-datetime, text-to-date, time pair, text or date offset column, a numeric pair behind a held$orm0(date/datetimem4, held$orm6)INVALID_FILTER/ 400, 0 readsINVALID_FILTER; memory a count{ amount: { $gt: { $field: 'nope' } } }m0INVALID_FILTER/ 400, 0 reads, all 8 cellsINVALID_FILTER/ 400; memoryn0$ne/$not/ a held$or; a dotted referent; an undeclaredaddDaysoffset columnm6/m6/m6/m0/m3INVALID_FILTER/ 400, 0 readsINVALID_FILTER; memory a count{ nope: 1 }over REST200,m0INVALID_FIELD/ 400, 0 reads; the engine door is unchanged (a count)INVALID_FIELD/ 400; engine: sqlINVALID_FILTER, memoryn0$and/$ne/$not/ a held$orm0/m6/m6/m6INVALID_FIELD/ 400INVALID_FIELD/ 400{ nope: { $median: 1 } }INVALID_FILTEReverywhereINVALID_FIELD(the field gate runs first, as forwhere); engineINVALID_FILTER{ opened_at: { $gt: new Date('2026-02-01') } }, in-processm0m4n4on both driversFound beyond the card: the
$betweenand held-$orrows counted every row, not zero. The card's row 5 reads "4 rows on both drivers" for the twin, and the #20122 dev report reads memory0. Both are right about their harness:driver-memorycoerces aDatecomparand by the column's storage rule only aftersyncSchemahas indexed the object's temporal fields. Schema-synced, as a booted runtime is, the twin answers 4 on both drivers.2. What changed
packages/objectql/src/engine.ts, the per-aggregation loop ofObjectQL.aggregate.assertTemporalComparandsInterpretable, the functionwhereruns fourth on its seam, fourth here too: after the text-operator door, before the type door.assertAggregationFilterIsEvaluablethe object's declared field map, and a sink that logs the withheld diagnostic atwarn.packages/objectql/src/having-filter.ts.assertAggregationFilterIsEvaluable(filter, index, declared?): after the walker's own refusals,assertAggregationFilterReferencesAreDeclaredwalks each scalar-comparison{ $field }. The referent, and anaddDaysoffset's nested$field, must name a declared field (the field map plusid,created_at,updated_at: the set the REST field gate reads). AnaddDayspair must pass the class rule.having: a{ $field }reference withaddDaysagainst a non-temporal aggregated column answers by epoch-ms coercion, where SQL push-down refuses the same pair onwhere— the aggregated row declares no temporal class to judge it by #20127's, factored out ofassertOffsetPairIsTemporalintooffsetPairViolation, sohavingand this position judge one rule.having's words are byte-identical.driver-sqlwithholds them for the same comparison inwhere. They name the aggregation (aggregations[1].filter) and the rule, and say the diagnostic is in the server log. The message is sized under the REST envelope's 500-character bound.checkCondition: when aDatesits on either side of an equality, ordering,$betweenor list arm, and both sides denote an instant (utcInstantMs,@objectstack/spec/data), it compares instants. That is the lift@objectstack/formula's evaluator applies. Every other pair compares exactly as before.packages/metadata-protocol/src/protocol.ts,findData'sassertAggregationFieldsExistonly. After its entry and field checks, each entry'sfiltergoes throughassertFilterFieldsExist, the gate the explicitwheremeets. It uses the same field set and the same unknown, dotted and virtual ladder, answersINVALID_FIELD/ 400, and passesaggregations[i].filteras the parameter.3. The hypotheses
wherecalls.where's own gate from insideassertAggregationFieldsExist.where's side.where's refusal isdriver-sql's cross-field compiler (applyCrossFieldComparison).objectqlcannot import it, and the claim excludesdriver-sql. Ondriver-memorythe twin is not refused at all: a count. The per-aggregation filter never reaches a driver, because it forces the in-memory fallback. So these rules reuse thehavingwalker's own rule functions (objectqlhavingdoes not take the rest ofwhere's filter doors: a$fieldreference is never resolved, the comparand-TYPE door does not run, FilterArray sugar answers no group, and its own refusals fire only on a non-empty grouped set #20099 / objectqlhaving: a{ $field }reference withaddDaysagainst a non-temporal aggregated column answers by epoch-ms coercion, where SQL push-down refuses the same pair onwhere— the aggregated row declares no temporal class to judge it by #20127) against the object's declared fields, not the aggregated row's. Nothing is copied, and nothing new needed exporting.havingcell with an in-processDatebound.wheretwin (aggregate andfind), everygroupByprobe, every per-aggregation control, and everyhavingcell without aDateis byte-identical, including objectql: ahavingkey that names no column of the aggregated row keeps no group silently —having: { totl: { $gt: 100 } }answers 200 [], where an unknownwherefield is refused 400 #20123's key refusals and objectqlhaving: a{ $field }reference withaddDaysagainst a non-temporal aggregated column answers by epoch-ms coercion, where SQL push-down refuses the same pair onwhere— the aggregated row declares no temporal class to judge it by #20127's pair refusals.havingcells that moved are the engine-doorDatecells (9 shapes, bothhavingdoors, both drivers, populated table). Each now keeps the groups the same bound's ISO spelling keeps (the REST cell), except the two UTC-midnight-Dateshapes onmax(date), adate-class column.$gtekeepsc2, c3where the ISO-instant spelling keepsc2, and$eqkeepsc3where the ISO-instant spelling keeps none. Both follow the calendar-day reading awheregives, and the ISO-instant text, compared as text, does not. See the out-of-scope note in Acceptance notes.minor, BREAKING,Clause-②: no (narrowing), dispositionnot-required (no-migration-prescription)(the objectql: ahavingkey that names no column of the aggregated row keeps no group silently —having: { totl: { $gt: 100 } }answers 200 [], where an unknownwherefield is refused 400 #20123 / objectqlhaving: a{ $field }reference withaddDaysagainst a non-temporal aggregated column answers by epoch-ms coercion, where SQL push-down refuses the same pair onwhere— the aggregated row declares no temporal class to judge it by #20127 precedent). One is@objectstack/objectql, one is@objectstack/metadata-protocol. At head4da90165cd,check-adr-0087-registration --base 49144fccc8reports 4 declared-breaking changesets, each with a disposition: this PR's two20148-*, andmain's19856-*(registered) and20055-*(not-required), which arrived with the merge. Againstorigin/mainit reports this PR's two.check-changeset-no-majorreports nomajor, and its level axis reads this body'sClause-②: no (narrowing).wheregives 4 of 6 for the row-5 bound on both drivers (schema-synced). Each driver reads aDateby the column's storage rule: canonical UTC ISO fordatetime, the UTC calendar day fordate, the UTC time of day fortime.checkConditionholds a flat row and no declaration, so it reads the pair the way the spec defines for a type-blind evaluator (utcInstantMs). Everydatetimeshape ($gt,$gte,$lt,$lte,$eq,$ne, implicit,$in,$nin,$between) and a UTC-midnightDateon adatefield now count what the twin counts.having's answers move, toward the twin (H3).Datecarrying a time of day against adatefield ($gte1 vs 3,$lt5 vs 3,$eq0 vs 2), and aDateagainst atimefield (0 vs 3). Matching them needs the column's class in the per-row walker. See the open question in the report.4. Tests
All at code head
f22f0e875funless stated.pnpm --filter @objectstack/objectql exec vitest run(whole suite, before the merge, atd831680d7c):Test Files 317 passed (317) · Tests 5633 passed (5633).pnpm --filter @objectstack/metadata-protocol exec vitest run(whole suite, atd831680d7c):Test Files 189 passed | 3 skipped (192) · Tests 2699 passed | 19 skipped (2718).typecheckfor@objectstack/objectql,@objectstack/metadata-protocoland@objectstack/rest: each exit 0.--listFilesshows each new test file in its package's program.check:test-typecheckforobjectql:40 file(s) / 234 error(s) / 65 pinned, unchanged.aggregation-filter-where-doors,list-view-grouping-query-door,request-schema-gate.conformance,rest-server-canonical-query-ast):96 passed | 1 skipped.driver-sqlaggregate suites (10 files):125 passed | 10 skipped(live PG / MySQL not run).driver-memoryaggregate suites (3 files):75 passed.@objectstack/service-analytics, the consumer that lowers measure filters here (whole suite):128 files, 3017 passed.bc4f337220,876293a1e9,ba5fbccde6) under an EXIT / INT / TERM trap. On-disk hashes and marker counts (0 / 0 / 0) were checked.objectqlandmetadata-protocolwere force-rebuilt, andablation-dist-preflight --absentpassed for both.engine-aggregate-filter.test.ts34 failed of 107,protocol.aggregation-filter-fields.test.ts7 of 13,aggregation-filter-where-doors.test.ts10 of 15.Daterows. The controls, the walker-first row and the "not refused by the engine" control stayed green.git checkout HEAD --. Proven by HEAD-blob equality for all three and an empty whole-treegit status.dist/was rebuilt from HEAD and the preflight found the markers present.5. Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackatf22f0e875f: 65 commands, 65 run.--ranwith the exit codes:65 derived famil(ies) accounted for — 65 run, 0 NOT-MEASURED.check:dual-build-cjs-loadsandcheck:type-check-debtfirst answeredPREREQUISITE NOT MET(3). Afterturbo run build --filter='./packages/*' --filter='./packages/*/*', both exit 0.node scripts/check-issue-citations.mjs --base 369bcbeda7: 20 citations, all resolve.pnpm check:nul-bytes: exit 0. A control-byte self-scan of the 8 changed files found none.eslint --no-inline-config --format jsonon the 6 changed.tsfiles: 6 files, 0 errors, 0 warnings, 0 fatal.--print-configreturns a config for each.eslint.config.mjsenables no type-aware linting (noparserOptions.project), so this diff cannot move a verdict on an untouched file.Patch round (contract review 5853139988)
Prose only; every code and test blob is identical to
f22f0e875f(git diff --name-only f22f0e875f 4da90165cdlists three.changeset/files and nothing else)..changeset/20148-aggregation-filter-where-doors.md(git diff --numstat:2 2):havingDatesentence is qualified. It now reads that aDatebound keeps the groups its ISO spelling keeps on adatetimecolumn. On adate-class column (min/maxof adatefield), a UTC-midnightDatefollows the calendar-day reading awheregives ({ $gte: new Date('2026-02-01') }keeps the group whose max is2026-02-01, and so does$eq), which the ISO-instant text, compared as text, did not.driver-sqluses" now reads "refused in the withholding posturedriver-sqlapplies". The sentence on the wire is new; only the posture is borrowed.4da90165cd:check-empty-changeset --base origin/main: exit 1. It names exactly.changeset/20099-having-where-doors.mdand.changeset/20127-having-adddays-temporal-pair.md, each "present on the merge base and CHANGED by this PR". This is by design; see the next section.check-adr-0087-registration: "4 declared-breaking changeset(s), each carrying an ADR-0087 disposition" with--base 49144fccc8, and 2 with--base origin/main. Exit 0.check-changeset-no-major --base 49144fccc8 --event(this body, as apull_requestpayload): exit 0.check-issue-citations --base 369bcbeda7: 20 citations, all resolve. Exit 0.DELIBERATE CORRECTION
Two PENDING changesets carried one clause each that is false at this head. Each file's
git diff --numstatis1 1, and every other line is byte-identical.check-empty-changesetrefuses both by name, which is the gate's own path for this class: the correction is said here, for confirmation..changeset/20127-having-adddays-temporal-pair.md, its last sentence. This PR made it false: the per-aggregation filter is now judged by the class rule, throughoffsetPairViolation, against the object's declared fields.filter(aggregations[i].filter) is not judged by this rule: it reads the object's raw columns, and this change classifies only the aggregated row's."filter(aggregations[i].filter) is not judged by this change: it reads the object's raw columns, and this change classifies only the aggregated row's. Since objectql + REST: the per-aggregationfilterstill lacks four ofwhere's doors — a bad date, anaddDaysnumeric pair, an undeclared{ $field }and an unknown key answer200with every count 0 #20148 it is judged by the same class rule, against the object's declared fields.".changeset/20099-having-where-doors.md, one clause of its "Not changed" paragraph. This has been false since PR fix(objectql)!: per-aggregation filter and having refusals belong to the query, not the data — row-independent walk, shape and type doors, unknown having keys, temporal addDays pairs #20147: objectql: ahavingkey that names no column of the aggregated row keeps no group silently —having: { totl: { $gt: 100 } }answers 200 [], where an unknownwherefield is refused 400 #20123 refuses ahavingkey that names no column of the aggregated row.havingkey that names no column still keeps no group rather than being refused."havingkey that names no column is not judged by this change; since objectql: ahavingkey that names no column of the aggregated row keeps no group silently —having: { totl: { $gt: 100 } }answers 200 [], where an unknownwherefield is refused 400 #20123 it is refused (INVALID_FILTER/ 400) rather than keeping no group."Acceptance notes
formulamin/maxaddDayspair is still unclassified inhaving(objectqlhaving: a{ $field }reference withaddDaysagainst a non-temporal aggregated column answers by epoch-ms coercion, where SQL push-down refuses the same pair onwhere— the aggregated row declares no temporal class to judge it by #20127's rule answersundefinedfor aformula), and aformulareferent here is judged by name only.truncateClientMessage) still applies to objectql: ahavingkey that names no column of the aggregated row keeps no group silently —having: { totl: { $gt: 100 } }answers 200 [], where an unknownwherefield is refused 400 #20123's column list. It bit this PR's first draft of the withheld message, which is now 440 characters at a two-digit index. Neither is changed.where's function, run unchanged, so its message readsat where.placed_on.$gtfor a per-aggregation filter. The [Decision] refuse a text operator ($containsfamily) over a field whose DECLARED type is not textual —INVALID_FILTER400 at the engine's field-aware door (option C of #14079); the textual-type vocabulary is the question #15661 text-operator door already readswhere.…at this position. The pins assert the field and the value, not the path. Apathparameter onassertTemporalComparandsInterpretablewould fix both;temporal-comparand-door.tsis outside the claim.filterrefuses an unknown operator only when rows exist —aggregations: [{ filter: { amount: { $median: 1 } } }]answers 400 on a populated table and 200 on an empty one #20122 reference refusals at the same position (bare reference, list-member reference, malformedaddDays) name the fields. Nothing reaches a per-aggregation filter from a read-scope merge: RLS and sharing compose intoast.whereonly, and analytics merges its scope intowhere. See the report's open question.driver-sql'swhererules, both deliberate.idis accepted as a referent.driver-sqlrefuses it (not in its declared map), but every fallback row carries it, and the base counted it correctly.driver-sql's guard for policy compilation, and no policy reaches this position.{ $field }pair withoutaddDays({ amount: { $gt: { $field: 'placed_on' } } }) still counts by coercion, as objectql: a per-aggregationfilterrefuses an unknown operator only when rows exist —aggregations: [{ filter: { amount: { $median: 1 } } }]answers 400 on a populated table and 200 on an empty one #20122's notes record forhaving.FieldReferenceSchemadeclares a class rule foraddDaysonly.wherereads it. This is reachable over REST today and unchanged by this PR (base = head, 192 cells).{ placed_on: { $gte: '2026-02-01T00:00:00.000Z' } }counts 1 where the twin counts 3.{ opened_at: { $lte: '2026-02-01' } }counts 2 where the twin counts 3, becausewherereads a bare day as the whole day.datetimecounts 0 where the twin counts 3.Deviations
assertAggregationFilterIsEvaluableandcheckConditioninhaving-filter.ts. This PR also adds module-level helpers there: the reference walk, the withheld error, the instant helpers, and theAggregationFilterDeclarationinterface. That interface isexported at module level but package-private, becausehaving-filter.tsis not re-exported bypackages/objectql/src/index.ts. The PR also refactorsassertOffsetPairIsTemporalintooffsetPairViolationplus a wrapper so both positions share one rule, adds one import, and adds a paragraph to the header comment.having's cells are byte-identical (H3).