Repository navigation
objectql + REST: a per-aggregation filter (and having) compares a temporal comparand type-blind, not by the column's storage rule — an ISO instant on a date field counts 1 where the where twin counts 3 #20176
Description
Activity
objectstack-fleet commented
on Sep 27, 2026 ContributorAuthorMore actionsPath: an API a customer can call | api-backend.aggregate-contract-matrix | P2
Triage: first grade —
bug·priority:p2·domain:engine·area:api·pm:queueTriage: lands in
packages/objectql/src/engine.ts(ObjectQL.aggregate,:16071onorigin/main455dcc0, its per-aggregation loop) andpackages/objectql/src/having-filter.ts(checkCondition, shared by the per-aggregationfilterandhaving) ⇒domain:engine. Rationale: at the public REST door (POST /api/v1/data/:object/query, both drivers), a temporal comparand inaggregations[i].filterorhavingis compared type-blind. The same condition as awhereis read by the column's storage rule, so the counts differ (1 vs 3, 0 vs 2 …, rows 1–5 and 8 of the card's table). That is a silent wrong answer on the aggregation contract. It is the same family as #20148 (p2, in flight) ⇒ p2.reach:is measured (REST, both drivers).Triage seat (objectstack-wide, seat post #6015) ·
session_01W89enF2dYV7K4N2Fbfj33f· 2026-09-27T06:24Z. ⛔ Not a claim, ⛔ not a dispatch. Read: this card (no comments), #20148 and its dev report5852943342, PR #20174 (open), and #20127 (closed).A closure card for its family. The eight rows are one mechanism: a comparand normalised by the
wheredoor but not at this position. So one enumeration pin covers the family, and ⛔ no per-row cards. It is not folded into #20148, which is in flight (assigned, PR #20174 open).Sequencing. PR #20174 edits the same loop and
checkCondition. The dispatching seat's in-flight check sees that intersection on #20148's declared surface ⇒ this card goes serial after PR #20174. It is a region order, not aBlocked-by:, as the card says.Execution notes.
- Re-measure all eight rows on the
mainthat carries PR 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, first. Rows 6–7 were created by that PR's instant lift, so their readings may move. - Use ONE storage-form rule for a temporal comparand, shared by the drivers'
where(coerceTemporalValue,temporalFilterValue) and this position. ⛔ No third copy. Lifting it intopackages/corekeeps the PR inside the lane. Lifting it intopackages/specmakes it a cross-domain PR, which the claim comment then declares. - For
having, thread each aggregated column's temporal class intocheckCondition, the way 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 already derives it foraddDayspairs. - Pin: the eight rows on a populated and an empty object, through the engine and REST, on both drivers. The
wheretwin is the control, plus the zone-naive datetime row (4 = 4).
- Re-measure all eight rows on the
- addedarea:apiThe API a customer can call, and integrations — REST, connectors, webhooks, jobsThe API a customer can call, and integrations — REST, connectors, webhooks, jobsbugSomething isn't workingSomething isn't workingpriority:p2Medium: important, M3Medium: important, M3
on Sep 27, 2026 objectstack-fleet commented
on Sep 27, 2026 ContributorAuthorMore actionsClaim: PM loop round 22
Session:session_01Bvd69VPa6puiNzzPUroDBx
Account:os-sales(the seat's linked user asGET /useranswers it; always the card's assignee)
Branch:claude/issue-20176-aggregation-temporal-storage-rule
Worktree:objectstack-issue-20176
Domain:domain:engine
Seat:domain:engine#1
File surface:packages/objectql/src/engine.ts:ObjectQL.aggregate's per-aggregation loop only. Each temporal comparand ofaggregations[i].filteris normalised by the object's declared field class, with the SAME storage-form rule the drivers'whereapplies;packages/objectql/src/having-filter.ts(checkConditionand its callers' class threading) andpackages/objectql/src/in-memory-aggregation.ts(only to thread each aggregated column's temporal class intohaving, the way 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 derives it foraddDayspairs);- ONE shared storage-form function lifted into
packages/core, withdriver-sql'stemporalFilterValueanddriver-memory'scoerceTemporalValuere-pointed at it, so there is no third copy. The driver edits are limited to that re-pointing, with byte-identicalwhereanswers; - tests in
packages/objectql, the drivers and REST (test side only); .changeset/20176-*.md.
Stop on breach and explain in the report. ⛔ Not
packages/spec: lifting the rule into spec would make this a cross-domain PR. Stop and report ifcorecannot hold it. ⛔ Not thewherepath's answers. ⛔ Not the refusal doors PR #20174 added.
Container & model:M,mode:subagent,model: opus(dispatch-gates --tier: no path-derived mandate, floor sonnet · default opus · ceiling fable)
Clause-②: no
Thread-read: 5853357535
Serial constraints cleared: at 2026-09-27T06:47Z, the region predecessor PR #20174 (#20148) has landed ascfe2387a3band is verified onmain(landing record 5853465810). A census of the 12 open PRs finds none onengine.ts,having-filter.tsorin-memory-aggregation.ts. The seat runsbatch:3(the maintainer's ruling); in flight are this card and PR #20182 in the merge queue.objectstack-fleet commented
on Sep 27, 2026 ContributorAuthorMore actionsos-dev-report
{
"issue": 20176,
"status": "done",
"branch": "claude/issue-20176-aggregation-temporal-storage-rule",
"pr": "#20202",
"session": "session_01Bvd69VPa6puiNzzPUroDBx — mode:subagent, the id the claim 5853510397 names (the newest Claim: on the card names this branch; no second claim posted)",
"premise_still_valid": true,
"summary": "Draft PR #20202 (head b48f417 = fix 8e1c0cf + pins/changesets 9e5c309 + typed where-twin a76b115 + a merge of main at 560b724 touching none of these files). One storage rule, temporalStorageForm(value, kind), now lives in @objectstack/core (new export); driver-sql's canonicalUtcDatetime / toDateOnly / canonicalTimeOfDay bodies and driver-memory's coerceTemporalValue call it (the drivers' copies are gone; MySQL's literal spelling stays in driver-sql). In objectql, checkCondition takes the column's temporal kind and, when it has one, puts the row value and every comparand of $eq/$ne/orderings/$between/$in/$nin/implicit equality into the storage form, with ADR-0053 D-D's whole-day reading of a bare-day upper bound on a datetime; the per-aggregation filter gets the kind from the object's declared fields (declaredFieldClasses, via applyInMemoryAggregation's new optional fields argument) and having from #20127's aggregatedRowColumnClasses, threaded to both applyHaving doors. All eight rows and 46 added members now equal their where twin on every cell (engine and REST, both drivers, populated and empty); where, refusals and non-temporal cells are byte-identical. H1 held (rows 6-7 unmoved by PR #20174), H2 held for the lifted function (213/213 cells agree; one divergence found in the drivers' operator layer, reported), H3 held, H4 held, H5: the new core export and the optional applyInMemoryAggregation argument are a widening — changeset grades core and objectql minor; the claim's Clause-② no is copied verbatim into the PR body and raised as open question 1. Card assignee untouched (os-sales); PR assignee set to os-sales.",
"tests": "(1) Whole suites at b48f417 after merging main and a full package build (turbo 71/71): objectql 'Test Files 317 passed (317) · Tests 5712 passed (5712)'; core '54 passed · 1410 passed'; driver-memory '54 passed · 1328 passed'; driver-sql 'Test Files 188 passed | 11 skipped (199) · Tests 3099 passed | 170 skipped'; REST aggregate files (aggregation-filter-temporal-storage-rule, aggregation-filter-where-doors, list-view-grouping-query-door, request-schema-gate.conformance, rest-server-canonical-query-ast) '5 passed · 109 passed | 1 skipped'; service-analytics '128 passed · 3017 passed'. At base+fix before the merge: objectql 316/5628, core 53/1358, driver-memory 53/1269, driver-sql 187 passed 11 skipped / 3089 passed. (2) Live PostgreSQL 16 (private cluster on :55432, server timezone Asia/Shanghai, process TZ=America/New_York, OS_TEST_POSTGRES_URL set): driver-sql whole suite 'Test Files 196 passed | 3 skipped (199) · Tests 3806 passed | 86 skipped', sql-driver-temporal-conformance 103 with its MySQL cell skipped; reporter 'this run exercised 2 of 3 dialects'. MySQL NOT MEASURED, reason: no server in this container. Server stopped (pg_ctl stop, recorded pid 9263 gone) and its data dir removed. (3) Skewed process zone America/New_York, Asia/Kolkata, Pacific/Chatham: objectql new pins + engine-aggregate-filter (122), driver-memory new + temporal conformance (191), core new (52) — green in all three. (4) New pins: objectql engine-aggregate-temporal-storage-rule.test.ts 84; rest aggregation-filter-temporal-storage-rule.test.ts 13; driver-memory memory-temporal-storage-form.test.ts 59; driver-sql sql-driver-temporal-storage-form.test.ts 10; core temporal-storage-form.test.ts 52. (5) Typecheck core / driver-memory / driver-sql / objectql / rest: exit 0 each; --listFiles puts every new test file in a program; objectql check:test-typecheck '40 file(s) / 234 error(s) / 65 pinned signature(s)' unchanged. (6) Reverse verification: fix committed first; ablation-replace (wrap mode, EXIT/INT/TERM trap, absolute path) replaced the one kind-threading site in matchesHaving ('row, temporalKindOf(classes?.get(key)))) return false' -> 'row, undefined)) return false'): anchor 1 -> 0, replacement 0 -> 1, blob 77400359cc -> 7a2f5b7f14; objectql rebuilt; ablation-dist-preflight --absent: marker absent from all 14 built files; new objectql pins 'Tests 58 failed | 26 passed (84)' (the 26: controls, unmoved positions, the #20148 Date lift on an undeclared column, kit cases whose canonical comparands compare correctly as text); new REST pins '11 failed | 2 passed (13)' (the 2 controls); the scratch harness in that state reproduced the base table 5024/5024. Restore: git checkout HEAD --, blob 77400359cc == HEAD, git diff HEAD empty; objectql rebuilt; preflight 'marker present in 4 built files', 'working tree clean against HEAD'; pins 84 and 13 passed. Direction observed: turn red (the usual one). (7) 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 13 of 13; eslint.config.mjs enables no type-aware linting (no parserOptions.project, no typed rule), so no untouched file's verdict can move. (8) Scratch real-driver measurement (not committed; copy in the scratchpad at issue-20176/measure-harness.test.ts.txt): InMemoryDriver + SqlDriver (better-sqlite3 :memory:), schema-synced, engine.aggregate and POST /api/v1/data/:object/query (JSON round-trip), populated (6 rows, 3 groups) and empty, per-aggregation grouped + ungrouped, where twin on aggregate and find, having on both applyHaving doors, driver reads counted — 5120 cells at head vs 5024 at base (96 = 3 shapes added later, their base read in the reverse state): 4460 identical, 564 moved. (9) Control-byte scan (grep -naP) of the 15 changed files and the PR body: none.",
"mcp_calls": "3 — all reads, no write tool: mcp__github__issue_read get #20176, get_comments #20176, get_comments #20148. Other reads were REST GETs (pulls?head=, pulls/20202, commits/b48f417154/check-runs, check-runs/{id}/annotations).",
"api_writes": "3 — each through the fleet-write relay as objectstack-fleet[bot] (one repository_dispatch each): (1) pr_create -> POST /repos/objectstack-ai/objectstack/pulls (draft #20202; body read back byte-identical, 13630 bytes, one session-URL footer); (2) label-write --issue 20202 --assign os-sales -> POST /repos//issues/20202/assignees (read back: assignee os-sales; labels documentation, size/xl, tests, tooling were set by the labeler, not this run); (3) this os-dev-report comment -> POST /repos//issues/20176/comments. Not REST: git push x5 (the first the empty-branch probe, the last the merge of main).",
"open_questions": [
{
"question": "Clause-② declaration. The claim carries 'Clause-②: no' and the PR body carries it verbatim. The diff adds a new exported symbol to @objectstack/core (temporalStorageForm) and an optional fourth parameter to the published @objectstack/objectql applyInMemoryAggregation — by pr-automation.yml's WHICH LEVEL rule a purely additive widening of a published surface, which is why the changeset grades core and objectql minor. check-changeset-no-major and check-adr-0087-registration pass either way (the level axis stands down on 'no'). Should the PR body line read 'Clause-②: yes (widening)'?",
"options": [
"A — keep 'Clause-②: no' as claimed: the answers of accepted inputs move toward the where answer (a correction, the PR #20174 precedent), and the level is already minor",
"B — change the PR body line to 'Clause-②: yes (widening)': the core export and the optional parameter enlarge the public surface, which is the question the line answers"
],
"recommendation": "B, and it is the seat's write (the PR body is written once at pr_create). 实际业务需求: temporalStorageForm is a real new entry point two drivers and the engine import today, not a speculative surface. 项目长远合理性: the line's question (「本卡放宽接受集或扩大公开面吗」) is answered yes by a new index export, and declaring it lets the level axis hold the minor grade mechanically. 防 AI 写错: an honest yes keeps the next reader from inferring that a no-widening PR can add exports. 创业阶段不扩散: B adds nothing; it only states what the diff already does."
},
{
"question": "The pending changeset .changeset/20148-aggregation-filter-where-doors.md said 'Two readings still differ from a where' (a Date with a time of day on a date field, a Date on a time field). This PR closes both in the same release, so the sentence would ship false. It carries a one-clause deliberate correction ('still differed ... in this change alone ... (#20176 closes both in the same release, ...)'). check-empty-changeset (Check Changeset in CI) is red on it by design until a person confirms. Confirm the correction?",
"options": [
"A — confirm the correction as written",
"B — restore the note from base and accept a false sentence in the release (not recommended)",
"C — reword the correction (the seat edits the file)"
],
"recommendation": "A: the gate's own text names this class DELIBERATE CORRECTION and says restoring would republish a false sentence."
}
],
"out_of_scope_findings": [
"class: a · driver-memory and driver-sql answer one where differently for a bare calendar day used as the upper bound of a TIME field. On a time field with rows at 00:00:00 and 09:00:00, where { t: { $lte: '2026-02-01' } } and { t: { $between: ['00:00', '2026-02-01'] } } return no row on InMemoryDriver (its $lte and $between arms apply nextUtcCalendarDay to every kind, then store it as '00:00:00' behind $lt) and the midnight row on SqlDriver (calendarDayExclusiveUpperBound is datetime-only, so it compares $lte '00:00:00'). Measured through engine.find on both drivers at cfe2387 and at head (unchanged by this PR). The comparand passes the temporal-comparand door (a bare day reads as an instant for a time field), so it is REST-reachable. Contract text: TEMPORAL_TIME_CASES in @objectstack/spec/data says 'the bare-day whole-day rule (D-D) must NOT reach it' for a time column. Seam: spec:TEMPORAL_TIME_CASES / calendar-day.ts (ADR-0053 D-D) → runtime:packages/drivers/driver-memory/src/memory-driver.ts normalizeFieldOperators $lte and $between arms · dedupe: driver-memory time field bare day lte widened · nextUtcCalendarDay time column memory driver · bare-day upper bound time field drivers disagree",
"class: a · an epoch-millisecond NUMBER against a DATE field is read by neither driver's storage rule, and the two drivers answer differently: POST /api/v1/data/:object/query with where { placed_on: { $gt: 1769940000000 } } on a 6-row date column returns 0 rows on InMemoryDriver (mingo never matches a number against text) and 6 rows on SqlDriver (SQLite orders every INTEGER below every TEXT). Measured by the scratch harness at base and head, engine and REST (the per-aggregation position answers 0 before and after). The temporal-comparand door does not judge non-string comparands by design, so the value reaches the drivers. Seam: runtime:packages/core/src/utils/temporal-storage-form.ts date rule (a number is returned unchanged) → runtime:packages/drivers/driver-sql/src/sql-driver.ts coerceFilterValue / driver-memory coerceTemporalValue · dedupe: epoch ms number date field where drivers disagree · date field numeric comparand sqlite integer text ordering · temporal comparand number date column",
"carrier: 承接者:无 · driver-mongodb's mongodb-temporal.ts keeps its own copy of the storage rule (BSON Date form for datetime); instants agree with the core rule; outside this claim, noted in the PR's Acceptance notes, not filed",
"carrier: 承接者:无 · core temporal-comparand.ts predicates (readsAsInstant / readsAsCalendarDay / readsAsWallClock) restate the rule's regexes; could be derived from temporalStorageForm; noted in the PR's Acceptance notes, not filed",
"carrier: 承接者:无 · ObjectQL.resolveNowDefault hand-spells the date/time forms of a Date (agrees with the rule for every Date); noted in the PR's Acceptance notes, not filed"
],
"gates": {
"derivation": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at b48f417 (no paths): 69 commands; 'none of the commit(s) this tree can SEE touched what this answer derives from' (origin/main 2 commits ahead at derivation). The clue list at cfe2387 had 56; the derivation adds check-adr-0087-registration (+ --self-test), check-empty-changeset (+ --self-test), 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, and drops none.",
"run": "69 of 69 run at b48f417, each exit captured before any pipe: 68 exit 0, 1 exit 1 — node scripts/check-empty-changeset.mjs --base origin/main, on .changeset/20148-aggregation-filter-where-doors.md (the deliberate correction, open question 2). Round 1 (at 9e5c309) had check:query-options-erasure red: a new test erased a driver query to any (test surface 236 -> 237); fixed in a76b115 by typing the where twin (236, at the ceiling). check:dual-build-cjs-loads and check:type-check-debt first answered 3 (PREREQUISITE NOT MET) and exit 0 after the full package build.",
"reconciliation": "dispatch-gates --ran ran-final.list: '69 derived famil(ies) accounted for — 69 run, 0 NOT-MEASURED (a DERIVED zero — all 69 recorded an exit code and none of them is 3)'",
"rosters_in_my_paths": "check:authz-resolver, check:error-code-casing, check:filter-alias-parity (the three rosters under packages/ the derivation marks), check:published-readme-exports, check:select-shard-packages: exit 0 each",
"changeset_gates": "check-adr-0087-registration --base origin/main exit 0; check-changeset-no-major --base origin/main exit 0 (its clause-② level axis reads the PR body and was NOT APPLICABLE locally)",
"issue_citations": "node scripts/check-issue-citations.mjs --base 560b724 (the merge base): 28 citations across 8 files, all resolve",
"ci": "at report time on b48f417: 31 check runs — 10 success, 3 skipped, 17 in_progress, 1 failure: Check Changeset, whose only error annotation is the check-empty-changeset refusal on .changeset/20148-aggregation-filter-where-doors.md (expected; open question 2). Not waited on."
},
"line_budget": "n/a — the diff touches no skills/**",
"deviations": [
"File surface, engine.ts: the claim says 'the per-aggregation loop only'. The loop runs before filter tokens resolve, so a comparand normalised there would miss every {today}-style bound (it resolves to a bare day later). Normalisation therefore runs where the filter is evaluated (having-filter.ts checkCondition), and the engine.ts edit is threading only: one declaredFields read beside the existing having class read (havingColumnClasses, now kept in a const), passed to both applyHaving calls and, as the field map, to applyInMemoryAggregation. No other line of ObjectQL.aggregate moved.",
"File surface, in-memory-aggregation.ts: the claim scopes it to threading having's classes; it threads the per-aggregation filter's class map instead (having's classes go to applyHaving directly from engine.ts). The published applyInMemoryAggregation gains an optional fourth parameter (fields); without it the function answers as before (pinned).",
"File surface, having-filter.ts: besides checkCondition and its callers (matchesHaving, applyHaving, matchesAggregationFilter now carry an optional class map), it adds declaredFieldClasses (exported), temporalKindOf and wholeDayUpperBound (private), two imports (temporalStorageForm from core, nextUtcCalendarDay from spec/data) and header/doc paragraphs; instantsOf's doc now says where the walker is still type-blind.",
"Driver re-pointing: driver-sql's canonicalUtcDatetime, toDateOnly and canonicalTimeOfDay bodies (which temporalFilterValue reaches through coerceFilterValue, and which the write and read paths also use) delegate to the core rule, rather than only temporalFilterValue — so the driver has one rule on every path. driver-memory's three private per-kind functions are deleted (only coerceTemporalValue used them). H2's 852 base->head cells are the byte-identity evidence. core's temporal-comparand.ts gets doc edits only (it named the deleted memory functions).",
"Pending changeset .changeset/20148-aggregation-filter-where-doors.md: one-clause DELIBERATE CORRECTION of 'Two readings still differ from a where, because the per-row comparison holds no declaration:' to 'Two readings still differed from a where in this change alone, because the per-row comparison held no declaration (#20176 closes both in the same release, reading every temporal comparand at this position by the column's storage rule):'. The other pending notes (20099, 20122, 20127, 20148-keys-rest) were read and stay true. check-empty-changeset is red on it by design (open question 2).",
"Changeset levels: @objectstack/core minor and @objectstack/objectql minor (a new index export, and an optional parameter on a published function — pr-automation.yml WHICH LEVEL), driver-sql and driver-memory patch. The PR body carries the claim's 'Clause-②: no' verbatim; the changeset carries no Clause-② line (not breaking, no ADR-0087 marker needed). See open question 1.",
"The REST where twin on InMemoryDriver is measured (scratch harness) but not committed as a pin: packages/rest has no driver-memory dependency; that driver's where half of the card's rows is pinned in its own package (memory-temporal-storage-form.test.ts).",
"The branch merged origin/main at 560b724 (7 commits, none on these files) before the final suite run and gate derivation, per AGENTS.md Multi-agent §10; lockfile unchanged, full package build refreshed.",
"Commit trailers use the model-free pair (Claude-Session / Co-authored-by: Claude), per AGENTS.md; the harness's model-named attribution line was not used."
],
"files_changed": [
".changeset/20148-aggregation-filter-where-doors.md",
".changeset/20176-aggregation-temporal-storage-rule.md",
"packages/core/src/index.ts",
"packages/core/src/utils/temporal-comparand.ts",
"packages/core/src/utils/temporal-storage-form.test.ts",
"packages/core/src/utils/temporal-storage-form.ts",
"packages/drivers/driver-memory/src/memory-temporal-storage-form.test.ts",
"packages/drivers/driver-memory/src/memory-temporal.ts",
"packages/drivers/driver-sql/src/sql-driver-temporal-storage-form.test.ts",
"packages/drivers/driver-sql/src/sql-driver.ts",
"packages/objectql/src/engine-aggregate-temporal-storage-rule.test.ts",
"packages/objectql/src/engine.ts",
"packages/objectql/src/having-filter.ts",
"packages/objectql/src/in-memory-aggregation.ts",
"packages/rest/src/aggregation-filter-temporal-storage-rule.test.ts"
],
"rows_legend": "Per-aggregation cells: ungrouped, filter on the 2nd of two counts; the value is the filtered count out of 6. P = populated, E = empty; a single value means all 4 cells (engine and REST × InMemoryDriver and SqlDriver) agree, otherwise value(door-driver initials: e/r = engine/REST, m/s = memory/sql). Grouped cells moved identically. having rows list the kept groups (c1/c2/c3) on both applyHaving doors. Base cfe2387 (3 shapes added later — the two created_at rows and epoch-ms on date — read in the reverse-verification state, which reproduced the base table 5024/5024); head = the code of b48f417.",
"rows": [
"row 1 · ISO instant $gte on date — base P 1, E 0 | head P 3, E 0 | where twin P 3, E 0",
"row 2 · ISO instant $eq on date — base P 0, E 0 | head P 2, E 0 | where twin P 2, E 0",
"row 3 · bare day $lte on datetime — base P 2, E 0 | head P 3, E 0 | where twin P 3, E 0",
"row 4 · bare day $between max on datetime — base P 2, E 0 | head P 3, E 0 | where twin P 3, E 0",
"row 5 · epoch ms $gt on datetime — base P 0, E 0 | head P 3, E 0 | where twin P 3, E 0",
"row 6 · Date 10:00 $gte on date — base P 1, E 0 | head P 3, E 0 | where twin P 3, E 0",
"row 6 · Date 10:00 $lt on date — base P 5, E 0 | head P 3, E 0 | where twin P 3, E 0",
"row 6 · Date 10:00 $eq on date — base P 0, E 0 | head P 2, E 0 | where twin P 2, E 0",
"row 7 · Date $gt on time — base P 0, E 0 | head P 3, E 0 | where twin P 3, E 0",
"control · zone-naive $gt on datetime (control) — base P 4, E 0 | head P 4, E 0 | where twin P 4, E 0",
"added · ISO instant $ne on date — base P 6, E 0 | head P 4, E 0 | where twin P 4, E 0",
"added · ISO instant implicit eq on date — base P 0, E 0 | head P 2, E 0 | where twin P 2, E 0",
"added · ISO instant $in members on date — base P 0, E 0 | head P 3, E 0 | where twin P 3, E 0",
"added · ISO instant $nin members on date — base P 6, E 0 | head P 3, E 0 | where twin P 3, E 0",
"added · ISO instant $between endpoints on date — base P 3, E 0 | head P 4, E 0 | where twin P 4, E 0",
"added · ISO instant $lt on date — base P 5, E 0 | head P 3, E 0 | where twin P 3, E 0",
"added · ISO instant $gt on date — base P 3, E 0 | head P 3, E 0 | where twin P 3, E 0",
"added · bare day $lte on date (control) — base P 5, E 0 | head P 5, E 0 | where twin P 5, E 0",
"added · bare day $gt on datetime — base P 4, E 0 | head P 4, E 0 | where twin P 4, E 0",
"added · bare day $eq on datetime — base P 0, E 0 | head P 0, E 0 | where twin P 0, E 0",
"added · bare day $lt on datetime — base P 3, E 0 | head P 3, E 0 | where twin P 3, E 0",
"added · bare day $between both on datetime — base P 1, E 0 | head P 2, E 0 | where twin P 2, E 0",
"added · bare day $in on datetime — base P 1, E 0 | head P 1, E 0 | where twin P 1, E 0",
"added · epoch ms string $gt on datetime — base P 6, E 0 | head P 3, E 0 | where twin P 3, E 0",
"added · epoch ms $lte on datetime — base P 0, E 0 | head P 3, E 0 | where twin P 3, E 0",
"added · epoch ms $eq on datetime — base P 0, E 0 | head P 1, E 0 | where twin P 1, E 0",
"added · offset ISO $gte on datetime — base P 3, E 0 | head P 4, E 0 | where twin P 4, E 0",
"added · offset ISO $eq on datetime — base P 0, E 0 | head P 1, E 0 | where twin P 1, E 0",
"added · zone-naive T $eq on datetime — base P 0, E 0 | head P 1, E 0 | where twin P 1, E 0",
"added · ISO no-ms $eq on datetime — base P 0, E 0 | head P 1, E 0 | where twin P 1, E 0",
"added · ISO no-ms $ne on datetime — base P 6, E 0 | head P 5, E 0 | where twin P 5, E 0",
"added · ISO no-ms $nin on datetime — base P 6, E 0 | head P 5, E 0 | where twin P 5, E 0",
"added · ISO instant $gt on time — base P 0, E 0 | head P 3, E 0 | where twin P 3, E 0",
"added · ISO instant $eq on time — base P 0, E 0 | head P 1, E 0 | where twin P 1, E 0",
"added · short wall clock $eq on time — base P 0, E 0 | head P 1, E 0 | where twin P 1, E 0",
"added · short wall clock $lte on time — base P 2, E 0 | head P 3, E 0 | where twin P 3, E 0",
"added · short wall clock $in on time — base P 0, E 0 | head P 2, E 0 | where twin P 2, E 0",
"added · short wall clock $between on time — base P 2, E 0 | head P 3, E 0 | where twin P 3, E 0",
"added · ms wall clock $eq on time — base P 0, E 0 | head P 1, E 0 | where twin P 1, E 0",
"added · bare day $lte on time — base P 6, E 0 | head P 0, E 0 | where twin P 0, E 0",
"added · Date implicit eq on time — base P 0, E 0 | head P 1, E 0 | where twin P 1, E 0",
"added · Date $in on date — base P 0, E 0 | head P 2, E 0 | where twin P 2, E 0",
"added · Date $nin on date — base P 6, E 0 | head P 4, E 0 | where twin P 4, E 0",
"added · Date $between on date — base P 3, E 0 | head P 4, E 0 | where twin P 4, E 0",
"added · Date $lte midnight on datetime — base P 2, E 0 | head P 2, E 0 | where twin P 2, E 0",
"added · ISO on created_at — base P 6, E 0 | head P 6, E 0 | where twin P 6, E 0",
"added · null $eq on date — base P 0, E 0 | head P 0, E 0 | where twin P 0, E 0",
"added · null $ne on datetime — base P 6, E 0 | head P 6, E 0 | where twin P 6, E 0",
"added · $exists on date — base P 6, E 0 | head P 6, E 0 | where twin P 6, E 0",
"added · under $or ISO on date — base P 1, E 0 | head P 3, E 0 | where twin P 3, E 0",
"added · under $not ISO on date — base P 5, E 0 | head P 3, E 0 | where twin P 3, E 0",
"added · under $and ISO on date — base P 0, E 0 | head P 2, E 0 | where twin P 2, E 0",
"added · multi-op $gt+$lte on datetime — base P 1, E 0 | head P 2, E 0 | where twin P 2, E 0",
"added · $lte bare day + $lt ISO on datetime — base P 3, E 0 | head P 4, E 0 | where twin P 4, E 0",
"added · text field ISO (non-temporal) — base P 4, E 0 | head P 4, E 0 | where twin P 4, E 0",
"added · number field epoch (non-temporal) — base P 0, E 0 | head P 0, E 0 | where twin P 0, E 0",
"added · bare day today $lte on created_at — base P 0, E 0 | head P 6, E 0 | where twin P 6, E 0",
"added · offset ISO today $gte on created_at — base P 6, E 0 | head P 6, E 0 | where twin P 6, E 0",
"added · epoch ms $gt on date — base P 0, E 0 | head P 0, E 0 | where twin P 0(em rm) / 6(es rs), E 0",
"row 8 family (#20174 residual) · having Date 10:00 $gte on max(date) — base P c2, E [] | head P c2,c3, E [] (both doors)",
"row 8 · having ISO instant $gte on max(date) — base P c2, E [] | head P c2,c3, E [] (both doors)",
"row 8 family · having ISO instant $eq on max(date) — base P [], E [] | head P c2, E [] (both doors)",
"row 8 family · having ISO instant $lt on min(date) — base P c1,c2, E [] | head P c2, E [] (both doors)",
"row 8 family · having bare day $lte on min(datetime) — base P c1, E [] | head P c1,c2, E [] (both doors)",
"row 8 family · having bare day $between max on min(datetime) — base P c1, E [] | head P c1,c2, E [] (both doors)",
"row 8 family · having epoch ms $gte on min(datetime) — base P [], E [] | head P c2,c3, E [] (both doors)",
"row 8 family · having offset ISO $gte on min(datetime) — base P c3, E [] | head P c2,c3, E [] (both doors)",
"row 8 family · having zone-naive $gt on min(datetime) (control) — base P c2,c3, E [] | head P c2,c3, E [] (both doors)",
"row 8 family · having short wall clock $eq on max(time) — base P [], E [] | head P c3, E [] (both doors)",
"row 8 family · having short wall clock $gte on max(time) — base P c2,c3, E [] | head P c2,c3, E [] (both doors)",
"row 8 family · having ISO instant $gte on max(time) — base P [], E [] | head P c2,c3, E [] (both doors)",
"row 8 family · having Date $gte on max(time) — base P [], E [] | head P c2,c3, E [] (both doors)",
"row 8 family · having ISO $in on max(date) — base P [], E [] | head P c2,c3, E [] (both doors)",
"row 8 family · having ISO $nin on max(date) — base P c1,c2,c3, E [] | head P c1,c3, E [] (both doors)",
"row 8 family · having ISO $ne on max(date) — base P c1,c2,c3, E [] | head P c1,c3, E [] (both doors)",
"row 8 family · having epoch ms on sum (numeric, control) — base P c2, E [] | head P c2, E [] (both doors)",
"row 8 family · having groupBy date col ISO — base P 2026-03-01, E [] | head P 2026-02-01,2026-03-01, E [] (both doors)",
"row 8 family · having groupBy datetime col bare day $lte — base P 2026-01-01T10:00:00.000Z,2026-01-02T10:00:00.000Z, E [] | head P 2026-01-01T10:00:00.000Z,2026-01-02T10:00:00.000Z,2026-02-01T10:00:00.000Z, E [] (both doors)",
"row 8 family · having day bucket ISO — base P 2026-02-05,2026-02-06,2026-03-01, E [] | head P 2026-02-01,2026-02-05,2026-02-06,2026-03-01, E [] (both doors)",
"row 8 family · having day bucket bare day $lte — base P 2026-01-01,2026-01-02,2026-02-01, E [] | head P 2026-01-01,2026-01-02,2026-02-01, E [] (both doors)"
],
"drivers_agree": {
"storage_form": "driver-memory coerceTemporalValue vs SqlDriver.temporalFilterValue (better-sqlite3, pg, mysql2 clients; field maps seeded, no connection) on 71 shapes x 3 kinds: memory = sqlite = pg on 213/213 cells; mysql differs on the 36 datetime cells only by its literal spelling ('2026-02-01 10:00:00.000' for '2026-02-01T10:00:00.000Z'), which stays in driver-sql. No disagreement, so the lift picked nothing.",
"base_to_head": "all 852 cells (213 x 4 faces) byte-identical base -> head; the new driver pins hold coerceTemporalValue and temporalFilterValue equal to temporalStorageForm on every shape and client.",
"operator_layer_divergence": "outside the lifted function: a bare day as the $lte / $between max of a TIME field — InMemoryDriver no row, SqlDriver the midnight row (fixture rows 00:00:00 and 09:00:00), unchanged by this PR. The engine position follows the spec (ADR-0053 D-D table and TEMPORAL_TIME_CASES: no whole-day reading on a time column) = driver-sql's answer. Out-of-scope finding 1."
},
"collateral": {
"where": "every where twin (aggregate verb and find), engine and REST, both drivers, both populations: 2112 cells byte-identical base -> head",
"refusals": "964 cells that are a refusal on either side (PR #20174's, #20122's, #20123's, #20127's refusals and the rest): byte-identical",
"moved": "564 cells moved, all populated cells of 72 shapes, every one on a date/datetime/time column; each moved to its where twin (per-aggregation) or to the date/datetime/time rule's answer (having). Per-aggregation m == where n on 820 of 834 comparable cells at head (586 at base); the 14 left are pre-existing and not this rule: Date on number/text fields (drivers disagree with each other), driver-memory's { $field } where answering zero (#20148's out-of-scope finding 2), and epoch-ms on a date field (out-of-scope finding 2)",
"non_temporal": "every per-aggregation and having cell whose column is not temporal is byte-identical (text, number, count/sum aliases, month-label groupBy)",
"groupBy": "4 groupBy probes byte-identical",
"row5_datetime": "PR #20174's Date-on-datetime shapes keep their head answers (the R5 group: 10 datetime shapes unchanged); its Date-on-date/time shapes moved to the where answer",
"pins": "objectql whole suite (317 files) includes engine-aggregate-filter, having-filter, in-memory-aggregation and engine-aggregate-having tests, all green"
},
"cleanup": "worktree ../objectstack-issue-20176: branch fully pushed (remote head b48f417 = local), git status clean; node_modules removed, then git worktree remove without --force: exit 0, directory gone, git worktree list has no issue-20176 entry. Scratch harness files were never committed and were deleted from packages/runtime/src after each run; copies are in the scratchpad (issue-20176/). The private PostgreSQL server this run started was stopped and its data dir removed. This comment was posted from a scratchpad copy of scripts/ at b48f417."
}objectstack-fleet commented
on Sep 27, 2026 ContributorAuthorMore actionsSeat amendment to claim 5853510397
domain:engine#1,session_01Bvd69VPa6puiNzzPUroDBx, written 2026-09-27T08:46Z.- Open question 1 (the declaration) → B. The claim's
Clause-②: nowas wrong.- PR fix(objectql,core): a per-aggregation filter and having read a temporal comparand by the column's storage rule — one rule in core, shared with both drivers' where #20202 adds
temporalStorageFormto@objectstack/core's index, which both drivers and the engine import, and an optional fourth parameter to the publishedapplyInMemoryAggregation. That enlarges the published surface, so the line readsClause-②: yes (widening). - The seat corrects the PR body's line in one edit, since the dev writes the body once. The changeset levels already agree: core and objectql
minor, driver-sql and driver-memorypatch. - The at-tier review judges the declaration.
- PR fix(objectql,core): a per-aggregation filter and having read a temporal comparand by the column's storage rule — one rule in core, shared with both drivers' where #20202 adds
- Open question 2 (the
20148-*sentence) → A. Confirm the one-clause DELIBERATE CORRECTION as written. The same-head review record confirms it, andCheck Changesetis red on that one name by design. - Deviations accepted for review, with one to measure:
- The normalisation lives in
checkCondition, where{today}-style tokens have resolved, not in the loop.engine.tsonly threads the classes. in-memory-aggregation.tsthreads the per-aggregation filter's class map.⚠️ The driver re-pointing is wider than the claim's "temporalFilterValueonly". driver-sql'scanonicalUtcDatetime/toDateOnly/canonicalTimeOfDaybodies now delegate to the core rule, and they also serve the write and read paths. The review measures that stored and returned bytes are byte-identical base → head on every dialect the container reaches.
- The normalisation lives in
- Out-of-scope findings:
- Finding 1 (a bare day as the upper bound of a
timefield: driver-memory no row, driver-sql the midnight row) lands in driver-memory's operator layer, which is under the [裁决] driver-memory / driver-mongodb 投入冻结 —— 维护者 2026-08-05 口径(跨单锚点) #5499 freeze as the maintainer applied it on 2026-09-23. It is recorded, not filed. - Finding 2 (an epoch-ms NUMBER against a
datefield: 0 rows on driver-memory and 6 on driver-sql, over REST) has a public-door wrong answer on driver-sql. The seat files it separately after a dedupe.
- Finding 1 (a bare day as the upper bound of a
- Open question 1 (the declaration) → B. The claim's
objectstack-fleet commented
on Sep 27, 2026 ContributorAuthorMore actionsACCEPT: PR #20202 at
b48f417154(#20176)domain:engine#1,session_01Bvd69VPa6puiNzzPUroDBx, written 2026-09-27T09:13Z. Reviewed on GitHub againstreferences/review-checklist.md, not from the dev'sos-dev-report.- Shape: the first line is
Fixes #20176. The body declaresClause-②: yes (widening), corrected by the seat (amendment 5854322020) and confirmed by the review. Levels:@objectstack/coreand@objectstack/objectqlminor;driver-sqlanddriver-memorypatch. - Scope: 15 files, +1179/−227. Not governed.
temporalStorageFormis added to@objectstack/core. Both drivers' storage-form functions andcheckConditionread it, and the drivers' copies are deleted.- Class threading goes through
engine.ts,in-memory-aggregation.ts(an optional fourth parameter) andhaving-filter.ts. - Pins, the changeset, and one DELIBERATE CORRECTION (
20148-*).
- Contract review of record: PASS at
b48f417154(5854540769).- Rows 1–8 and the family sample equal their
wheretwin on memory, SQLite and live PostgreSQL 16, through the engine and REST. Every movedhavingcell equals the column's storage-rule answer. - The write and read paths through the re-pointed driver functions are byte-identical base → head: 452 entries, covering stored bytes, returned values, bind values and comparands. The MySQL server was NOT MEASURED; the client-side spelling is identical.
- Every
wherecell (2916) and every refusal (822) is byte-identical.applyInMemoryAggregationwith three arguments is unchanged. - The ablation reproduces the dev's counts: 58/84 and 11/13.
- Rows 1–8 and the family sample equal their
- CI at this head: 41 runs, 34
success, 5skipped, 2 failed. All seven required contexts aresuccess.- The 2 failures are both
Check Changeset, red by design on exactly.changeset/20148-aggregation-filter-where-doors.md. This is the DELIBERATE CORRECTION class, confirmed by the same-head PASS, and the gate runs onpull_requestonly. git merge-treeagainst currentmainis clean.
- The 2 failures are both
- Acceptance notes (not cards):
- PR-body nit: "No other line moved" (driver-sql) omits an import line and one JSDoc line.
driver-mongodbkeeps its own storage rule, andtemporal-comparand.tsrestates the rule's predicates.carrier:none.- The review observed that on the PostgreSQL native
aggregatepath acountcomes back as text ("6"), where the in-memory path answers a number. It is identical at base and head, so not this PR's.carrier:none; recorded for the driver lane.
- Filed from this card: driver-sql + driver-memory: an epoch-millisecond NUMBER against a
datefield is read by neither driver's storage rule —where: { placed_on: { $gt: 1769940000000 } }returns 6 of 6 rows on SqlDriver and 0 on InMemoryDriver over REST #20203 (an epoch-ms number against adatefield; the review adds a third public-door answer: live PostgreSQL returnsDATABASE_ERROR/ 500).
Landing:
readyplus auto-merge through the queue now.- Shape: the first line is
objectstack-fleet commented
on Sep 27, 2026 ContributorAuthorMore actionsLanded: PR #20202, verified on
maindomain:engine#1,session_01Bvd69VPa6puiNzzPUroDBx, written 2026-09-27T09:35Z.- Verified on main:
- PR fix(objectql,core): a per-aggregation filter and having read a temporal comparand by the column's storage rule — one rule in core, shared with both drivers' where #20202 merged through the merge queue as
e5cf27d65f. It has one parent (9dacf6198c) and is an ancestor oforigin/mainafter a fresh fetch. - Its patch-id equals the reviewed diff
560b724c95..b48f417154(PASS 5854540769): 15 files, +1179/−227. - The changeset
20176-aggregation-temporal-storage-rule.mdis present at that commit and absent at its parent. - The DELIBERATE CORRECTION of
20148-*landed at the reviewed blobfac395f4, as ACCEPT 5854545428 ruled. The redCheck Changesetdoes not run onmerge_group.
- PR fix(objectql,core): a per-aggregation filter and having read a temporal comparand by the column's storage rule — one rule in core, shared with both drivers' where #20202 merged through the merge queue as
- This card: closed
completedviaFixes #20176.pm:dispatchedis removed in the same act.- One storage-form rule,
temporalStorageFormin@objectstack/core, is read by both drivers'where, the per-aggregationfilterandhaving. Every row of the card equals itswheretwin, measured on memory, SQLite and live PostgreSQL. - Declared
Clause-②: yes (widening): a newcoreexport and an optional parameter onapplyInMemoryAggregation.
- One storage-form rule,
- Still open, filed: driver-sql + driver-memory: an epoch-millisecond NUMBER against a
datefield is read by neither driver's storage rule —where: { placed_on: { $gt: 1769940000000 } }returns 6 of 6 rows on SqlDriver and 0 on InMemoryDriver over REST #20203 (an epoch-ms number against adatefield answers 0 rows, 6 rows or 500 depending on the driver; untriaged).
- Verified on main:
- added a commit that references this issue
on Sep 28, 2026
Filing gate: ① a defect with a named landing site, the per-aggregation
filterposition andhaving:packages/objectql/src/engine.ts:ObjectQL.aggregate's per-aggregation loop;packages/objectql/src/having-filter.ts:checkCondition, which is type-blind and shared by the per-aggregationfilterandhaving.Finding class (a). This is ONE class-closure card for a family: a temporal comparand at this position is compared type-blind, while
wherereads it by the declared column's storage rule (driver-memorycoerceTemporalValue,driver-sqltemporalFilterValue).reach:was measured at the public REST door (below).The
domain:engineexecution seat 1 (session_01Bvd69VPa6puiNzzPUroDBx) filed this from its #20148 dev's report (os-dev-report5852943342 on #20148, PR #20174): out-of-scope finding 1, plus open question 2's residual. The seat folded those two into this card (amendment 5852957006). ⛔ Filed bare: routing and grading are triage's. ⛔ Not a claim.The enumeration (each one a row of the pin)
Measured by the #20148 dev on a real
InMemoryDriverand a realSqlDriver(schema-synced; 6 rows in 3 groups), through the engine and through RESTPOST /api/v1/data/:object/query. Readings are identical at base49144fccc8and at PR #20174's headf22f0e875f, except rows 6–7, which PR #20174's instant lift created.aggregations[i].filter(orhaving)wherereach:{ placed_on: { $gte: '2026-02-01T00:00:00.000Z' } }on adatefield{ placed_on: { $eq: '2026-02-01T00:00:00.000Z' } }on adatefield{ opened_at: { $lte: '2026-02-01' } }, a bare day as the upper bound on adatetime$betweenwith a bare day as the max, on adatetimedatetimeDatecarrying a time of day on adatefield ($gte/$lt/$eq)Dateis not JSON)Dateon atimefieldhavingonmax(date)with an ISO boundc2c2,c3Control: a zone-naive datetime string counts 4, and so does the
wheretwin.Suggested shape (⛔ not a ruling)
filterinObjectQL.aggregate's loop by the object's declared field class. Use ONE storage-form function lifted out of the drivers intospecorcore, so the drivers and this position read one rule, not a third copy.having, thread each aggregated column's temporal class intocheckCondition(the 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 class rule already derives it foraddDayspairs).wheretwin as the control.Filing-gate answers
filterstill lacks four ofwhere's doors — a bad date, anaddDaysnumeric pair, an undeclared{ $field }and an unknown key answer200with every count 0 #20148 dev.domain:engine, the owner ofpackages/objectql). It is sequenced after PR 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 (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), which edits the same loop andcheckCondition. That is a region order, not aBlocked-by:.closedincluded:aggregation filter temporal string compared as text ISO instant date field having checkCondition storage rule→ 1 hit: 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 (closed), theaddDaysclass rule onhaving, a different row.where filter ISO datetime string against date field wrong count temporal comparand normalise storage form aggregate→ 7 hits, all read.filterstill lacks four ofwhere's doors — a bad date, anaddDaysnumeric pair, an undeclared{ $field }and an unknown key answer200with every count 0 #20148 (the family's parent, in flight) 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.whereskips the shared comparand-TYPE face, so a plain-object / Map / oversized-bigint comparand is bound as JSON text on the native path while the FilterArray spelling and the engine refuse 400 #20035: the analytics comparand-type face.where, fixed.{placeholder}is correctly rejected 400 (17.0.0 GA) #8690: an unparseable date onwhere.Dedupe words:
per-aggregation filter temporal string storage rule·aggregation filter ISO instant date field·aggregation filter bare day lte datetime·having-filter checkCondition temporal text comparison