Skip to content

objectql + REST: the per-aggregation filter still lacks four of where's doors — a bad date, an addDays numeric pair, an undeclared { $field } and an unknown key answer 200 with every count 0 #20148

Description

@objectstack-fleet

Filing gate: ① a defect with a named landing site: the per-aggregation filter position.

  • packages/objectql/src/engine.ts: ObjectQL.aggregate's per-aggregation loop;
  • packages/objectql/src/having-filter.ts: assertAggregationFilterIsEvaluable and checkCondition;
  • packages/metadata-protocol/src/protocol.ts: findData's assertAggregationFieldsExist.

Finding class (a). This is ONE class-closure card for a family, filed per the class-closure rule: the per-aggregation filter does not yet take every door where has. reach: was measured at the public REST door (below).

The domain:engine execution seat 1 (session_01Bvd69VPa6puiNzzPUroDBx) filed this from the out-of-scope findings of its #20122 dev (os-dev-report 5832791672 on #20122, PR #20147). The seat's at-tier contract reviewer reproduced every row independently, at the head and the base of PR #20147, on a real InMemoryDriver and a real SqlDriver (record 5833360417 and its notes). ⛔ Filed bare: routing and grading are triage's. ⛔ Not a claim.

PR #20147 gave this position where's walker refusals, its comparand-type door and its shape gate. These locations remain.

The enumeration (each one a row of the pin)

# Shape, in aggregations[i].filter Answer now (both drivers) The same condition as a where reach:
1 { placed_on: { $gt: 'not-a-date' } } on a date field 200, the aggregation counts 0 INVALID_FILTER / 400 ("compares a declared date field against …"), 0 driver calls public: POST /api/v1/data/:object/query → 200 with zero counts
2 { amount: { $gt: { $field: 'cap', addDays: 1 } } } on a numeric pair counts 0 SqlDriver: INVALID_FILTER / 400; FieldReferenceSchema.addDays is declared for date columns only public: REST → 200 with zero counts
3 { amount: { $gt: { $field: 'nope' } } }, an undeclared field counts 0 SqlDriver: INVALID_FILTER / 400 ("nope is not a declared field") public: REST → 200 with zero counts
4 { nope: 1 }, an unknown key, over REST 200, every count 0 where: { nope: 1 } over REST → INVALID_FIELD / 400 (assertFilterFieldsExist, #7534) public, measured; assertAggregationFieldsExist judges only function / alias / field
5 { opened_at: { $gt: new Date('2026-02-01') } } on ISO-text datetimes counts 0 4 rows on both drivers; the ISO-string form counts 4 on every face in-process only (a Date is not JSON; over REST it arrives as an ISO string and answers correctly)

Row 5 sits in checkCondition, which the per-aggregation filter and having share. It is in the family because the same comparison lives at the same position, but it has no public reach of its own.

Two observations for the same pin, not rows of their own:

Suggested shape (⛔ not a ruling)

Filing-gate answers

Dedupe words: per-aggregation filter temporal comparand · aggregation filter addDays numeric · aggregation filter field reference undeclared · aggregations filter keys INVALID_FIELD REST · having-filter Date vs ISO string

Activity

  1. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    Path: an API a customer can call | api-backend.aggregate-contract-matrix | P2

    Triage: first grade — bug · priority:p2 · domain:engine · area:api · pm:queue

    Triage: lands in packages/objectql/src/engine.ts (ObjectQL.aggregate's per-aggregation loop), packages/objectql/src/having-filter.ts (assertAggregationFilterIsEvaluable, checkCondition) and packages/metadata-protocol/src/protocol.ts (assertAggregationFieldsExist) ⇒ domain:engine; rationale: at the public REST door, a per-aggregation filter answers 200 with every count 0 when it holds a bad date, an addDays numeric pair, an undeclared { $field } or an unknown key, while the same condition written as a where is refused 400. That is a wrong number served as an answer ⇒ p2, the grade of its siblings #20122 and #20123. It is one class-closure card (PR #20110) with five pin rows.

    Triage seat (objectstack-wide, seat post #6015) · session_01CRZSc7dU8oDStbTbSwhuZe · 2026-09-25T14:42Z. ⛔ Not a claim, ⛔ not a dispatch. Read: this card, #20122 / #20123 / #20127 (all closed), PR #20147 (merged), and objectstack origin/main.

    Execution note:

    • Run where's remaining doors on each aggregations[i].filter in the engine loop, before any driver call and against the object's fields (the temporal-comparand door, the declared-field check on a { $field } referent, and the addDays class rule).
    • At REST, extend the field check to the keys inside each aggregation filter, with the INVALID_FIELD / 400 envelope where already gets.
    • Row 5 (a Date bound in checkCondition, shared with having) has no public reach of its own. Decide it in the same PR: compare the way where does.
    • Pin all five rows on a populated and an empty object, through the engine and REST, on InMemoryDriver and SqlDriver, with the where twin as the control.

    The filer's region order behind PR #20147 is already met: it merged, and its family cards are closed. No Blocked-by: applies.

  2. added
    area:apiThe API a customer can call, and integrations — REST, connectors, webhooks, jobs
    bugSomething isn't working
    on Sep 25, 2026
  3. objectstack-fleet commented on Sep 27, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 22
    Session: session_01Bvd69VPa6puiNzzPUroDBx
    Account: os-sales (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-20148-aggregation-filter-doors
    Worktree: objectstack-issue-20148
    Domain: domain:engine
    Seat: domain:engine#1
    File surface:

    • packages/objectql/src/engine.ts: the per-aggregation filter loop of ObjectQL.aggregate only. where's remaining doors run on each aggregations[i].filter, before any driver call, against the object's fields: the temporal-comparand door, the declared-field check on a { $field } referent, and the addDays class rule;
    • packages/objectql/src/having-filter.ts: assertAggregationFilterIsEvaluable and checkCondition (row 5, a Date bound compared the way where compares it);
    • packages/metadata-protocol/src/protocol.ts: findData's assertAggregationFieldsExist only, extended to the keys inside each aggregations[i].filter (row 4);
    • tests in packages/objectql / packages/metadata-protocol and REST pins (test side only);
    • .changeset/20148-*.md.

    Stop on breach and explain in the report. ⛔ Not packages/spec. ⛔ Not driver-sql / driver-memory (their withholding posture, #7929 / #8220, stays). ⛔ Not the where path itself. ⛔ Not saveMetaItem in protocol.ts, which spec seat 2's #20051 (claim 5852017412) edits; the later lander merges main.
    Container & model: M, mode:subagent, model: opus (dispatch-gates --tier from a tree at 49144fccc8: no path-derived mandate, floor sonnet · default opus · ceiling fable)
    Clause-②: no (narrowing)
    Thread-read: 5834285763
    Serial constraints cleared: at 2026-09-27T04:00Z, the region predecessor PR #20147 (#20122 + #20123 + #20127, 16c5a33fdd) has landed. A census of the 8 open PRs finds none on engine.ts, having-filter.ts or protocol.ts. The maintainer raised the seat to batch:3 at 2026-09-27 (「并发加到3」); in flight are #20136's dev and this card.

  4. objectstack-fleet commented on Sep 27, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 20148,
    "status": "done",
    "branch": "claude/issue-20148-aggregation-filter-doors",
    "pr": "#20174",
    "session": "session_01Bvd69VPa6puiNzzPUroDBx — mode:subagent, the id the claim 5852472098 names (the newest Claim: on the card names this branch; no second claim posted)",
    "premise_still_valid": true,
    "summary": "Draft PR #20174 (head f22f0e8 = 3129311 fix + d831680 pins/changesets + a merge of main at 369bcbe touching none of these files). In ObjectQL.aggregate's per-aggregation loop the filter now runs where's temporal-comparand door (the same assertTemporalComparandsInterpretable), and assertAggregationFilterIsEvaluable judges each { $field } referent and addDays offset column against the object's declared fields (plus id / created_at / updated_at) and each addDays pair by #20127's class rule (factored into offsetPairViolation, having byte-identical), refused INVALID_FILTER/400 in words that withhold the fields, operator and reason as driver-sql does for where, with the diagnostic logged at warn; findData's assertAggregationFieldsExist sends each entry's filter through where's own assertFilterFieldsExist (INVALID_FIELD/400, param aggregations[i].filter); checkCondition compares a Date against a stored value as an instant (utcInstantMs, the formula evaluator's lift). Every refusal is the same on empty and populated tables with 0 driver reads; H1 held, H2 half-falsified (rows 2/3 have no engine-side where function — driver-sql's compiler refuses them — so the having walker's own rule functions are reused, nothing copied), H3/H4 held, H5 decided with two residual in-process cells named. Card and PR assignee: card untouched (os-sales on arrival), PR assignee set to os-sales.",
    "tests": "All at code head f22f0e8 unless stated. (1) @objectstack/objectql whole suite (vitest run, at d831680 before the main merge): 'Test Files 317 passed (317) · Tests 5633 passed (5633)'. (2) @objectstack/metadata-protocol whole suite (at d831680): 'Test Files 189 passed | 3 skipped (192) · Tests 2699 passed | 19 skipped (2718)'. (3) typecheck objectql / metadata-protocol / rest: exit 0 each; --listFiles shows each new test file in its package's program; objectql check:test-typecheck '40 file(s) / 234 error(s) / 65 pinned signature(s)' unchanged. (4) REST aggregate tests (aggregation-filter-where-doors, list-view-grouping-query-door, request-schema-gate.conformance, rest-server-canonical-query-ast) '96 passed | 1 skipped (97)'; driver-sql aggregate suites (10 files) '125 passed | 10 skipped' (live PG/MySQL NOT RUN); driver-memory aggregate suites (3 files) '75 passed'; service-analytics whole suite '128 files, 3017 passed'. Post-merge rerun of the objectql aggregate/having files (321 passed), the two protocol files (59 passed) and the four REST files (96 passed | 1 skipped). (5) Scratch real-driver measurement (not committed, copy kept in the scratchpad): InMemoryDriver + SqlDriver (better-sqlite3 :memory:), both schema-synced, engine.aggregate and POST /api/v1/data/:object/query (JSON round-tripped body), 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 — 2896 cells per tree; base 49144fc vs head: 2392 identical, 504 moved (see rows / collateral). (6) Reverse verification: fix committed first; engine.ts / having-filter.ts / protocol.ts restored to base blobs bc4f337220 / 876293a1e9 / ba5fbccde6 under an EXIT/INT/TERM trap with absolute paths; on-disk hash + marker counts 0/0/0; objectql + metadata-protocol force-rebuilt, ablation-dist-preflight --absent passed for both; new pins read engine-aggregate-filter.test.ts 34 failed | 73 passed (107), protocol.aggregation-filter-fields.test.ts 7 failed | 6 passed (13), aggregation-filter-where-doors.test.ts 10 failed | 5 passed (15) — exactly the new refusal and Date rows; the reverse-state harness reproduced the base table 2704/2704; restored with git checkout HEAD --, proven by HEAD-blob equality (4e1ebbf2a4 / ab6ba1df00 / a9a77c8f6d) and a clean whole-tree git status; dist rebuilt from HEAD, preflight markers present. (7) Lint, declared narrowing: eslint --no-inline-config --format json on the 6 changed .ts files → 6 files, 0 errors, 0 warnings, 0 fatal; --print-config returns a config for each; eslint.config.mjs enables no type-aware linting (no parserOptions.project), so no untouched file's verdict can move. (8) Control-byte self-scan (grep -naP) of the 8 changed files and the PR body: none.",
    "mcp_calls": "0 — no MCP GitHub tool called. Reads were single-card REST GETs: issue 20148 + comments, issue 20122 comments, pulls?head= (PR read-back), commits/{sha}/check-runs.",
    "api_writes": "3 — each through the fleet-write relay as objectstack-fleet[bot] (one repository_dispatch each, write-pace gated): (1) pr_create → POST /repos/objectstack-ai/objectstack/pulls (draft #20174; body read back byte-identical, 15776 bytes, one session-URL footer); (2) label-write --issue 20174 --assign os-sales → POST /repos//issues/20174/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/20148/comments. Not REST: git push ×4 (the first the empty-branch probe).",
    "open_questions": [
    {
    "question": "Disclosure at the per-aggregation position is now mixed. As ruled, rows 2/3 withhold the fields, operator and reason (driver-sql's where posture) and log the diagnostic. But #20122's landed refusals at the same position — a bare { $field }, a { $field } as a list member / text pattern / $exists operand, a malformed addDays — name the fields in the message. Measured (by reading, at f22f0e8): no read-scope policy reaches this position. plugin-security's RLS/sharing compose into ast.where only, and service-analytics' withReadScope merges its scope into where; per-measure filters come from dataset metadata. Should the position keep the ruling's mix?",
    "options": [
    "A — keep as ruled and shipped in #20174: rows 2/3 withheld, the #20122 reference refusals named",
    "B — withhold every { $field } refusal at this position (reword #20122's three refusals the same way)",
    "C — name everything at this position: drop the withheld error and its warn line, and put the diagnostic on the wire"
    ],
    "recommendation": "C, as a decision for the maintainer — the PR implements A because the ruling says so. 实际业务需求: the withholding protects nothing here, because nothing policy-authored reaches this filter (measured above); its only effect is that an API caller cannot see which field is wrong. 项目长远合理性: one position, one disclosure posture; #7929's premise (the driver cannot tell author from policy) does not hold in the engine loop, which holds only the caller's own filter. 防 AI 写元数据犯错: a named refusal ('"nope" is not a declared field') lets an AI author fix the typo in one turn, while the withheld one needs server-log access an API caller lacks. 创业阶段不扩散: C removes a message variant and a log line, and B adds three more withheld texts."
    },
    {
    "question": "Row 5 residual. checkCondition is type-blind, so it now reads a Date through utcInstantMs. That matches where on every datetime shape and on a UTC-midnight Date against a date field. Two in-process readings still differ from where, which reads a Date by the column's storage rule: a Date carrying a time of day against a date field ($gte 1 vs 3, $lt 5 vs 3, $eq 0 vs 2 on the fixture), and a Date against a time field (0 vs 3). The out-of-scope finding below (interpretable temporal strings compared as text, REST-reachable) has the same root. Close them?",
    "options": [
    "A — leave the type-blind instant lift (this PR) and accept the residual",
    "B — normalise every temporal comparand of a per-aggregation filter in ObjectQL.aggregate's loop by the object's declared field class, with one storage-form function lifted out of the drivers (driver-memory's coerceTemporalValue / driver-sql's temporalFilterValue) into spec or core; having would need its aggregated classes threaded into checkCondition separately",
    "C — thread the declared classes through in-memory-aggregation.ts and applyHaving into checkCondition"
    ],
    "recommendation": "B, as its own card together with the out-of-scope finding. 实际业务需求: the REST-reachable string half, e.g. an ISO instant on a date field counting 1 instead of 3, is a real wrong number; the Date half is in-process only. 项目长远合理性: one storage-form function beside the declaration, instead of a third copy per face. 防 AI 写错: an AI author writes '2026-02-01T00:00:00Z' against a date field routinely, and today that is a silent wrong count. 创业阶段不扩散: B adds no surface, because it moves an existing rule to where the declaration is."
    }
    ],
    "out_of_scope_findings": [
    "class: a · A per-aggregation filter compares an interpretable temporal STRING as text, not by the column's storage rule as where does. Reachable over REST today, and unchanged by this PR (base = head, 192 cells). On driver-memory and driver-sql, engine and REST: { placed_on: { $gte: '2026-02-01T00:00:00.000Z' } } (a date field) counts 1 where the where twin counts 3; { placed_on: { $eq: … } } 0 vs 2; { opened_at: { $lte: '2026-02-01' } } (datetime, bare-day upper bound) 2 vs 3; a $between max bare day 2 vs 3; an epoch-ms bound 0 vs 3. having shows the same on max(date): an ISO bound keeps c2 where the date rule keeps c2, c3. Seam: runtime:packages/objectql/src/having-filter.ts checkCondition (type-blind) + packages/objectql/src/engine.ts ObjectQL.aggregate per-aggregation loop, against driver-memory coerceTemporalValue / driver-sql temporalFilterValue on where · dedupe: 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",
    "class: a · InMemoryDriver answers a VALID { $field } comparison in where with zero rows, silently. POST /api/v1/data/order/query { where: { amount: { $gt: { $field: 'cap' } } } } → 200 rows 0 on InMemoryDriver, rows 4 on SqlDriver. The same holds for addDays date/date (0 vs 5), datetime/datetime (0 vs 2) and a created_at referent (0 vs 6); engine.find answers the same (measured at base and head, schema-synced). Seam: spec:FieldReferenceSchema → runtime:packages/drivers/driver-memory/src/memory-matcher.ts (where evaluation; no $field handling in src) · dedupe: driver-memory field reference where · InMemoryDriver $field cross-field zero rows · memory matcher FieldReferenceSchema addDays · driver-memory cross-field comparison silent",
    "carrier: 承接者:无 · the temporal-comparand and text-operator doors label a per-aggregation filter's position as where.FIELD.OP (the function takes no path; temporal-comparand-door.ts is outside this claim) — noted in the PR's Acceptance notes, not filed",
    "carrier: 承接者:无 · a cross-class { $field } pair with no addDays still counts by coercion at this position (no class rule is declared for it), and id plus the tenant column are accepted as referents where driver-sql refuses them on where — deliberate, noted in the PR's Acceptance notes, not filed",
    "carrier: this PR (open questions 1 and 2) · the mixed disclosure posture at this position, and the row-5 residual — raised as decisions, not filed"
    ],
    "gates": {
    "derivation": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at f22f0e8 (no paths): 65 commands. The first derivation at d831680 warned STALE TREE (origin/main 4 commits ahead, gate-deriving files changed); main was merged, rebuilt (turbo build of the rest closure; spec check:generated 'All 15 generated artifacts are up to date'), and the list was re-derived. The clue list at 49144fc had 52; the re-derived one adds 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, and drops none.",
    "run": "65 of 65 run, each exit captured before any pipe: 65 exit 0. check:dual-build-cjs-loads and check:type-check-debt first answered 3 (PREREQUISITE NOT MET, no dist for most packages); after 'turbo run build --filter=./packages/* --filter=./packages//' (71 tasks, 2m43s) both exit 0 ('check-type-check-coverage --re-measure: OK — 4 ledger entr(ies) re-measured, none above its recorded number').",
    "reconciliation": "dispatch-gates --ran ran-final.list: '65 derived famil(ies) accounted for — 65 run, 0 NOT-MEASURED (a DERIVED zero — all 65 recorded an exit code and none of them is 3)'",
    "changeset_gates": "check-adr-0087-registration --base 49144fc: '2 declared-breaking changeset(s), each carrying an ADR-0087 disposition' (both not-required (no-migration-prescription)); check-changeset-no-major: 'This diff introduces no major bump' (its clause-② level axis reads the PR body and was NOT APPLICABLE locally)",
    "issue_citations": "node scripts/check-issue-citations.mjs --base 369bcbe (the merge base): 20 citations across 3 files, all resolve",
    "ci": "in_progress at report time on f22f0e8: 31 check runs — 10 success, 3 skipped, 17 in_progress, 1 queued, 0 failed. Not waited on."
    },
    "line_budget": "n/a — the diff touches no skills/**",
    "deviations": [
    "File surface: the claim names assertAggregationFilterIsEvaluable and checkCondition in having-filter.ts. The PR also adds private helpers there — assertAggregationFilterReferencesAreDeclared, withheldAggregationReferenceError, declaredReferenceNames, the AggregationFilterDeclaration type, and instantsOf / comparandEquals / ordered / listHolds. It refactors assertOffsetPairIsTemporal into offsetPairViolation plus a wrapper so having and this position share one rule, adds one import (utcInstantMs) and adds a header-comment paragraph. having's cells are byte-identical base→head (H3); stated in the PR's Deviations section.",
    "engine.ts: the edit stays inside the per-aggregation loop, with no import change (assertTemporalComparandsInterpretable was already imported). protocol.ts: inside assertAggregationFieldsExist only; saveMetaItem is untouched.",
    "Row 1's refusal is where's function run unchanged, so its message reads 'at where.FIELD.OP' for this position, as the #15661 text-operator door already does. Fixing that needs a path parameter on assertTemporalComparandsInterpretable in temporal-comparand-door.ts, outside the claim, so it was not taken (H2 said to stop rather than copy).",
    "The REST 'where twin on InMemoryDriver' cells are measured by the scratch harness but not committed as pins: packages/rest has no driver-memory dependency, and adding one (package.json) or pinning in packages/runtime is outside the claim. The committed REST pin runs SqlDriver; the engine pins run both driver kinds (a native-aggregate and a raw driver) with 0 reads asserted.",
    "The branch merged origin/main at 369bcbe (4 commits, none on these files) before the gate re-derivation, per AGENTS.md Multi-agent §10.",
    "Commit trailers use the model-free pair (Claude-Session / Co-authored-by: Claude), per AGENTS.md; the harness's model-named attribution was not used."
    ],
    "files_changed": [
    ".changeset/20148-aggregation-filter-keys-rest.md",
    ".changeset/20148-aggregation-filter-where-doors.md",
    "packages/metadata-protocol/src/protocol.aggregation-filter-fields.test.ts",
    "packages/metadata-protocol/src/protocol.ts",
    "packages/objectql/src/engine-aggregate-filter.test.ts",
    "packages/objectql/src/engine.ts",
    "packages/objectql/src/having-filter.ts",
    "packages/rest/src/aggregation-filter-where-doors.test.ts"
    ],
    "rows_legend": "Per-aggregation cells are ungrouped, filter on the 2nd aggregation (m = filtered count, n = rows). Tags: e/r = engine / REST, m/s = InMemoryDriver / SqlDriver, E/P = empty / populated. ·0reads = no driver call. The where twin is identical at base and head unless shown as base / head. Grouped cells moved identically. Base 49144fc (reproduced 2704/2704 in the reverse state), head f22f0e8.",
    "rows": [
    "R1 bad date $gt on date — base: m0:n0 [emE rmE esE rsE]; m0:n6 [emP rmP esP rsP] | head: INVALID_FILTER/400·0reads [all 8 cells] | where twin: INVALID_FILTER/400·0reads [all 8 cells]",
    "R1+ bad date $in member on date — base: m0:n0 [emE rmE esE rsE]; m1:n6 [emP rmP esP rsP] | head: INVALID_FILTER/400·0reads [all 8 cells] | where twin: INVALID_FILTER/400·0reads [all 8 cells]",
    "R1+ bad date $between endpoint on date — base: m0:n0 [emE rmE esE rsE]; m6:n6 [emP rmP esP rsP] | head: INVALID_FILTER/400·0reads [all 8 cells] | where twin: INVALID_FILTER/400·0reads [all 8 cells]",
    "R1+ preset name on datetime — base: m0:n0 [emE esE]; VALIDATION_FAILED/400·0reads [rmE rmP rsE rsP]; m0:n6 [emP esP] | head: INVALID_FILTER/400·0reads [emE emP esE esP]; VALIDATION_FAILED/400·0reads [rmE rmP rsE rsP] | where twin: INVALID_FILTER/400·0reads [emE emP esE esP]; VALIDATION_FAILED/400·0reads [rmE rmP rsE rsP]",
    "R1+ bad date implicit eq on date — base: m0:n0 [emE rmE esE rsE]; m0:n6 [emP rmP esP rsP] | head: INVALID_FILTER/400·0reads [all 8 cells] | where twin: INVALID_FILTER/400·0reads [all 8 cells]",
    "R1+ bad date behind a held $or branch — base: m0:n0 [emE rmE esE rsE]; m6:n6 [emP rmP esP rsP] | head: INVALID_FILTER/400·0reads [all 8 cells] | where twin: INVALID_FILTER/400·0reads [all 8 cells]",
    "R1+ bad date under $not — base: m0:n0 [emE rmE esE rsE]; m6:n6 [emP rmP esP rsP] | head: INVALID_FILTER/400·0reads [all 8 cells] | where twin: INVALID_FILTER/400·0reads [all 8 cells]",
    "R1+ bad time on time field — base: m0:n0 [emE rmE esE rsE]; m0:n6 [emP rmP esP rsP] | head: INVALID_FILTER/400·0reads [all 8 cells] | where twin: INVALID_FILTER/400·0reads [all 8 cells]",
    "R2 addDays numeric pair — base: m0:n0 [emE rmE esE rsE]; m0:n6 [emP rmP esP rsP] | head: INVALID_FILTER/400·0reads [all 8 cells] | where twin: n0 [emE rmE emP rmP]; INVALID_FILTER/400 [esE rsE esP rsP]",
    "R2+ addDays numeric pair behind held $or — base: m0:n0 [emE rmE esE rsE]; m6:n6 [emP rmP esP rsP] | head: INVALID_FILTER/400·0reads [all 8 cells] | where twin: n0 [emE rmE]; n6 [emP rmP]; INVALID_FILTER/400 [esE rsE esP rsP]",
    "R2+ addDays date target / numeric ref — base: m0:n0 [emE rmE esE rsE]; m0:n6 [emP rmP esP rsP] | head: INVALID_FILTER/400·0reads [all 8 cells] | where twin: n0 [emE rmE emP rmP]; INVALID_FILTER/400 [esE rsE esP rsP]",
    "R2+ addDays date / datetime cross-class — base: m0:n0 [emE rmE esE rsE]; m4:n6 [emP rmP esP rsP] | head: INVALID_FILTER/400·0reads [all 8 cells] | where twin: n0 [emE rmE emP rmP]; INVALID_FILTER/400 [esE rsE esP rsP]",
    "R2+ addDays text / date — base: m0:n0 [emE rmE esE rsE]; m0:n6 [emP rmP esP rsP] | head: INVALID_FILTER/400·0reads [all 8 cells] | where twin: n0 [emE rmE emP rmP]; INVALID_FILTER/400 [esE rsE esP rsP]",
    "R2+ addDays text offset column — base: m0:n0 [emE rmE esE rsE]; m0:n6 [emP rmP esP rsP] | head: INVALID_FILTER/400·0reads [all 8 cells] | where twin: n0 [emE rmE emP rmP]; INVALID_FILTER/400 [esE rsE esP rsP]",
    "R2+ addDays date offset column — base: m0:n0 [emE rmE esE rsE]; m0:n6 [emP rmP esP rsP] | head: INVALID_FILTER/400·0reads [all 8 cells] | where twin: n0 [emE rmE emP rmP]; INVALID_FILTER/400 [esE rsE esP rsP]",
    "R2+ addDays time pair — base: m0:n0 [emE rmE esE rsE]; m0:n6 [emP rmP esP rsP] | head: INVALID_FILTER/400·0reads [all 8 cells] | where twin: n0 [emE rmE emP rmP]; INVALID_FILTER/400 [esE rsE esP rsP]",
    "R3 ref to undeclared field — base: m0:n0 [emE rmE esE rsE]; m0:n6 [emP rmP esP rsP] | head: INVALID_FILTER/400·0reads [all 8 cells] | where twin: n0 [emE rmE emP rmP]; INVALID_FILTER/400 [esE rsE esP rsP]",
    "R3+ ref to undeclared behind held $or — base: m0:n0 [emE rmE esE rsE]; m6:n6 [emP rmP esP rsP] | head: INVALID_FILTER/400·0reads [all 8 cells] | where twin: n0 [emE rmE]; n6 [emP rmP]; INVALID_FILTER/400 [esE rsE esP rsP]",
    "R3+ ref to undeclared under $not — base: m0:n0 [emE rmE esE rsE]; m6:n6 [emP rmP esP rsP] | head: INVALID_FILTER/400·0reads [all 8 cells] | where twin: n0 [emE rmE]; n6 [emP rmP]; INVALID_FILTER/400 [esE rsE esP rsP]",
    "R3+ addDays offset naming undeclared field — base: m0:n0 [emE rmE esE rsE]; m3:n6 [emP rmP esP rsP] | head: INVALID_FILTER/400·0reads [all 8 cells] | where twin: n0 [emE rmE emP rmP]; INVALID_FILTER/400 [esE rsE esP rsP]",
    "R3+ dotted referent — base: m0:n0 [emE rmE esE rsE]; m0:n6 [emP rmP esP rsP] | head: INVALID_FILTER/400·0reads [all 8 cells] | where twin: n0 [emE rmE emP rmP]; INVALID_FILTER/400 [esE rsE esP rsP]",
    "R3+ ref to undeclared under $ne — base: m0:n0 [emE rmE esE rsE]; m6:n6 [emP rmP esP rsP] | head: INVALID_FILTER/400·0reads [all 8 cells] | where twin: n0 [emE rmE]; n6 [emP rmP]; INVALID_FILTER/400 [esE rsE esP rsP]",
    "R4 unknown key — base: m0:n0 [emE rmE esE rsE]; m0:n6 [emP rmP esP rsP] | head: m0:n0 [emE esE]; INVALID_FIELD/400·0reads [rmE rmP rsE rsP]; m0:n6 [emP esP] | where twin: n0 [emE emP]; INVALID_FIELD/400·0reads [rmE rmP rsE rsP]; INVALID_FILTER/400 [esE esP]",
    "R4+ unknown key under $and — base: m0:n0 [emE rmE esE rsE]; m0:n6 [emP rmP esP rsP] | head: m0:n0 [emE esE]; INVALID_FIELD/400·0reads [rmE rmP rsE rsP]; m0:n6 [emP esP] | where twin: n0 [emE emP]; INVALID_FIELD/400·0reads [rmE rmP rsE rsP]; INVALID_FILTER/400 [esE esP]",
    "R4+ unknown key behind held $or — base: m0:n0 [emE rmE esE rsE]; m6:n6 [emP rmP esP rsP] | head: m0:n0 [emE esE]; INVALID_FIELD/400·0reads [rmE rmP rsE rsP]; m6:n6 [emP esP] | where twin: n0 [emE]; INVALID_FIELD/400·0reads [rmE rmP rsE rsP]; n6 [emP]; INVALID_FILTER/400 [esE esP]",
    "R4+ unknown key under $not — base: m0:n0 [emE rmE esE rsE]; m6:n6 [emP rmP esP rsP] | head: m0:n0 [emE esE]; INVALID_FIELD/400·0reads [rmE rmP rsE rsP]; m6:n6 [emP esP] | where twin: n0 [emE]; INVALID_FIELD/400·0reads [rmE rmP rsE rsP]; n6 [emP]; INVALID_FILTER/400 [esE esP]",
    "R4+ unknown key $ne — base: m0:n0 [emE rmE esE rsE]; m6:n6 [emP rmP esP rsP] | head: m0:n0 [emE esE]; INVALID_FIELD/400·0reads [rmE rmP rsE rsP]; m6:n6 [emP esP] | where twin: n0 [emE]; INVALID_FIELD/400·0reads [rmE rmP rsE rsP]; n6 [emP]; INVALID_FILTER/400 [esE esP]",
    "R4+ dotted key on scalar head — base: INVALID_FIELD/400·0reads [all 8 cells] | head: INVALID_FIELD/400·0reads [all 8 cells] | where twin: INVALID_FIELD/400·0reads [all 8 cells]",
    "R4+ formula field key — base: INVALID_FIELD/400·0reads [all 8 cells] | head: INVALID_FIELD/400·0reads [all 8 cells] | where twin: INVALID_FIELD/400·0reads [all 8 cells]",
    "R4+ unknown key and unknown op — base: INVALID_FILTER/400·0reads [all 8 cells] | head: INVALID_FILTER/400·0reads [emE emP esE esP]; INVALID_FIELD/400·0reads [rmE rmP rsE rsP] | where twin: INVALID_FILTER/400 [emE emP esE esP]; INVALID_FIELD/400·0reads [rmE rmP rsE rsP]",
    "R4+ system field id — base: m0:n0 [emE rmE esE rsE]; m1:n6 [emP rmP esP rsP] | head: m0:n0 [emE rmE esE rsE]; m1:n6 [emP rmP esP rsP] | where twin: n0 [emE rmE esE rsE]; n1 [emP rmP esP rsP]",
    "R4+ system field created_at — base: m0:n0 [emE rmE esE rsE]; m6:n6 [emP rmP esP rsP] | head: m0:n0 [emE rmE esE rsE]; m6:n6 [emP rmP esP rsP] | where twin: n0 [emE rmE esE rsE]; n6 [emP rmP esP rsP]",
    "R5 Date $gt on datetime — base: m0:n0 [emE rmE esE rsE]; m0:n6 [emP esP]; m4:n6 [rmP rsP] | head: m0:n0 [emE rmE esE rsE]; m4:n6 [emP rmP esP rsP] | where twin: n0 [emE rmE esE rsE]; n4 [emP rmP esP rsP]",
    "R5+ Date $gte on datetime (equal instant exists) — base: m0:n0 [emE rmE esE rsE]; m0:n6 [emP esP]; m4:n6 [rmP rsP] | head: m0:n0 [emE rmE esE rsE]; m4:n6 [emP rmP esP rsP] | where twin: n0 [emE rmE esE rsE]; n4 [emP rmP esP rsP]",
    "R5+ Date $lt on datetime — base: m0:n0 [emE rmE esE rsE]; m0:n6 [emP esP]; m2:n6 [rmP rsP] | head: m0:n0 [emE rmE esE rsE]; m2:n6 [emP rmP esP rsP] | where twin: n0 [emE rmE esE rsE]; n2 [emP rmP esP rsP]",
    "R5+ Date $lte on datetime — base: m0:n0 [emE rmE esE rsE]; m0:n6 [emP esP]; m3:n6 [rmP rsP] | head: m0:n0 [emE rmE esE rsE]; m3:n6 [emP rmP esP rsP] | where twin: n0 [emE rmE esE rsE]; n3 [emP rmP esP rsP]",
    "R5+ Date $eq on datetime — base: m0:n0 [emE rmE esE rsE]; m0:n6 [emP esP]; m1:n6 [rmP rsP] | head: m0:n0 [emE rmE esE rsE]; m1:n6 [emP rmP esP rsP] | where twin: n0 [emE rmE esE rsE]; n1 [emP rmP esP rsP]",
    "R5+ Date $ne on datetime — base: m0:n0 [emE rmE esE rsE]; m6:n6 [emP esP]; m5:n6 [rmP rsP] | head: m0:n0 [emE rmE esE rsE]; m5:n6 [emP rmP esP rsP] | where twin: n0 [emE rmE esE rsE]; n5 [emP rmP esP rsP]",
    "R5+ Date implicit eq on datetime — base: m0:n0 [emE rmE esE rsE]; m0:n6 [emP esP]; m1:n6 [rmP rsP] | head: m0:n0 [emE rmE esE rsE]; m1:n6 [emP rmP esP rsP] | where twin: n0 [emE rmE esE rsE]; n1 [emP rmP esP rsP]",
    "R5+ Date $in on datetime — base: m0:n0 [emE rmE esE rsE]; m0:n6 [emP esP]; m1:n6 [rmP rsP] | head: m0:n0 [emE rmE esE rsE]; m1:n6 [emP rmP esP rsP] | where twin: n0 [emE rmE esE rsE]; n1 [emP rmP esP rsP]",
    "R5+ Date $nin on datetime — base: m0:n0 [emE rmE esE rsE]; m6:n6 [emP esP]; m5:n6 [rmP rsP] | head: m0:n0 [emE rmE esE rsE]; m5:n6 [emP rmP esP rsP] | where twin: n0 [emE rmE esE rsE]; n5 [emP rmP esP rsP]",
    "R5+ Date $between on datetime — base: m0:n0 [emE rmE esE rsE]; m6:n6 [emP esP]; m2:n6 [rmP rsP] | head: m0:n0 [emE rmE esE rsE]; m2:n6 [emP rmP esP rsP] | where twin: n0 [emE rmE esE rsE]; n2 [emP rmP esP rsP]",
    "R5+ Date midnight $gt on date — base: m0:n0 [emE rmE esE rsE]; m0:n6 [emP esP]; m1:n6 [rmP rsP] | head: m0:n0 [emE rmE esE rsE]; m1:n6 [emP rmP esP rsP] | where twin: n0 [emE rmE esE rsE]; n1 [emP rmP esP rsP]",
    "R5+ Date midnight $gte on date — base: m0:n0 [emE rmE esE rsE]; m0:n6 [emP esP]; m1:n6 [rmP rsP] | head: m0:n0 [emE rmE esE rsE]; m3:n6 [emP esP]; m1:n6 [rmP rsP] | where twin: n0 [emE rmE esE rsE]; n3 [emP rmP esP rsP]",
    "R5+ Date midnight $eq on date — base: m0:n0 [emE rmE esE rsE]; m0:n6 [emP rmP esP rsP] | head: m0:n0 [emE rmE esE rsE]; m2:n6 [emP esP]; m0:n6 [rmP rsP] | where twin: n0 [emE rmE esE rsE]; n2 [emP rmP esP rsP]",
    "R5+ Date 10:00 $gte on date — base: m0:n0 [emE rmE esE rsE]; m0:n6 [emP esP]; m1:n6 [rmP rsP] | head: m0:n0 [emE rmE esE rsE]; m1:n6 [emP rmP esP rsP] | where twin: n0 [emE rmE esE rsE]; n3 [emP rmP esP rsP]",
    "R5+ Date 10:00 $lt on date — base: m0:n0 [emE rmE esE rsE]; m0:n6 [emP esP]; m5:n6 [rmP rsP] | head: m0:n0 [emE rmE esE rsE]; m5:n6 [emP rmP esP rsP] | where twin: n0 [emE rmE esE rsE]; n3 [emP rmP esP rsP]",
    "R5+ Date 10:00 $eq on date — base: m0:n0 [emE rmE esE rsE]; m0:n6 [emP rmP esP rsP] | head: m0:n0 [emE rmE esE rsE]; m0:n6 [emP rmP esP rsP] | where twin: n0 [emE rmE esE rsE]; n2 [emP rmP esP rsP]",
    "R5+ Date $gt on time — base: m0:n0 [emE rmE esE rsE]; m0:n6 [emP rmP esP rsP] | head: m0:n0 [emE rmE esE rsE]; m0:n6 [emP rmP esP rsP] | where twin: n0 [emE rmE esE rsE]; n3 [emP rmP esP rsP]",
    "R5+ Date $gt on number — base: m0:n0 [emE rmE esE rsE]; m3:n6 [emP esP]; m0:n6 [rmP rsP] | head: m0:n0 [emE rmE esE rsE]; m3:n6 [emP esP]; m0:n6 [rmP rsP] | where twin: n0 [emE rmE emP rmP esE rsE rsP]; n3 [esP]",
    "R5+ Date $gt on text — base: m0:n0 [emE rmE esE rsE]; m0:n6 [emP esP]; m6:n6 [rmP rsP] | head: m0:n0 [emE rmE esE rsE]; m0:n6 [emP esP]; m6:n6 [rmP rsP] | where twin: n0 [emE rmE emP esE rsE]; n6 [rmP esP rsP]",
    "R5c ISO string $gt on datetime (control) — base: m0:n0 [emE rmE esE rsE]; m4:n6 [emP rmP esP rsP] | head: m0:n0 [emE rmE esE rsE]; m4:n6 [emP rmP esP rsP] | where twin: n0 [emE rmE esE rsE]; n4 [emP rmP esP rsP]",
    "R5c epoch ms $gt on datetime (control) — base: m0:n0 [emE rmE esE rsE]; m0:n6 [emP rmP esP rsP] | head: m0:n0 [emE rmE esE rsE]; m0:n6 [emP rmP esP rsP] | where twin: n0 [emE rmE esE rsE]; n4 [emP rmP esP rsP]",
    "X ISO instant $gte on date field — base: m0:n0 [emE rmE esE rsE]; m1:n6 [emP rmP esP rsP] | head: m0:n0 [emE rmE esE rsE]; m1:n6 [emP rmP esP rsP] | where twin: n0 [emE rmE esE rsE]; n3 [emP rmP esP rsP]",
    "X ISO instant $eq on date field — base: m0:n0 [emE rmE esE rsE]; m0:n6 [emP rmP esP rsP] | head: m0:n0 [emE rmE esE rsE]; m0:n6 [emP rmP esP rsP] | where twin: n0 [emE rmE esE rsE]; n2 [emP rmP esP rsP]",
    "X bare day $lte on datetime — base: m0:n0 [emE rmE esE rsE]; m2:n6 [emP rmP esP rsP] | head: m0:n0 [emE rmE esE rsE]; m2:n6 [emP rmP esP rsP] | where twin: n0 [emE rmE esE rsE]; n3 [emP rmP esP rsP]",
    "X bare day $between max on datetime — base: m0:n0 [emE rmE esE rsE]; m2:n6 [emP rmP esP rsP] | head: m0:n0 [emE rmE esE rsE]; m2:n6 [emP rmP esP rsP] | where twin: n0 [emE rmE esE rsE]; n3 [emP rmP esP rsP]",
    "X zone-naive $gt on datetime — base: m0:n0 [emE rmE esE rsE]; m4:n6 [emP rmP esP rsP] | head: m0:n0 [emE rmE esE rsE]; m4:n6 [emP rmP esP rsP] | where twin: n0 [emE rmE esE rsE]; n4 [emP rmP esP rsP]",
    "X epoch ms $gt on datetime — base: m0:n0 [emE rmE esE rsE]; m0:n6 [emP rmP esP rsP] | head: m0:n0 [emE rmE esE rsE]; m0:n6 [emP rmP esP rsP] | where twin: n0 [emE rmE esE rsE]; n3 [emP rmP esP rsP]"
    ],
    "collateral": {
    "where": "every where twin (aggregate verb and find), engine and REST, both drivers, both populations, for all 76 shapes: byte-identical base → head",
    "groupBy": "4 groupBy probes (plain, day bucket, month bucket, with where): byte-identical",
    "having": "every having cell without a Date bound is byte-identical: #20123 key refusals, #20127 pair refusals, unknown-op, ref-naming-no-column, 8 controls and the ISO-string controls. The 36 cells that moved are the in-process Date cells (9 shapes × both applyHaving doors × both drivers, populated). Each now keeps the groups its ISO spelling keeps over REST (e.g. $gt Date on min(datetime): [] → c2,c3 = REST c2,c3; $ne: c1,c2,c3 → c1,c3 = REST), except 'Date midnight $gte on max(date)', which keeps c2,c3 — the where date rule's answer — where the ISO string keeps c2 by text comparison (the out-of-scope finding)",
    "pins": "#20147 / #20117 pins green: the objectql whole suite (317 files) includes engine-aggregate-filter, engine-aggregate-having-comparand-shape, having-filter and in-memory-aggregation tests",
    "valid_filters": "per-aggregation controls keep their counts: implicit eq 2, $gt 3, $in 2, $or 2, a $field numeric pair 4, addDays date/date 5, addDays offset column 3, addDays datetime/datetime 2, created_at referent 6, id referent 6, a good date 3, a {placeholder} 6, {} 6, $ne null 4, $between 3; the text-operator door refusal is unchanged"
    },
    "row5": {
    "where_answer": "{ opened_at: { $gt: new Date('2026-02-01') } } counts 4 of 6 on InMemoryDriver and SqlDriver alike once the schema is synced (each driver coerces a Date by the column's storage rule: canonical UTC ISO for datetime, UTC day for date, UTC time for time). The #20122 dev report's 'driver-memory where answers 0' came from a harness that never synced driver-memory's schema.",
    "decision": "checkCondition (shared by the per-aggregation filter and having) compares instants whenever a Date sits on either side of $eq / $ne / implicit equality / the four orderings / $between / an $in or $nin member, and both sides denote an instant by @objectstack/spec/data's utcInstantMs — the spec's reading for a type-blind evaluator and the lift @objectstack/formula's evaluator applies. Every other pair compares as before.",
    "matches_where": "all ten datetime shapes ($gt 4, $gte 4, $lt 2, $lte 3, $eq 1, $ne 5, implicit 1, $in 1, $nin 5, $between 2) and a UTC-midnight Date on a date field ($gt 1, $gte 3, $eq 2)",
    "still_differs": "a Date with a time of day on a date field ($gte 1 vs where 3, $lt 5 vs 3, $eq 0 vs 2) and a Date on a time field (0 vs 3): the walker holds no declaration (open question 2)",
    "having_moves": "yes — 36 in-process Date cells, each toward the where / ISO answer (see collateral.having)"
    },
    "cleanup": "worktree ../objectstack-issue-20148: branch fully pushed (remote head f22f0e8 = local), git status clean; node_modules removed, then git worktree remove without --force: exit 0, directory gone, git worktree list has no issue-20148 entry. The scratch harness was never committed; its copy is in the scratchpad (issue-20148/measure-harness.test.ts.txt). No background process was started by this run. This comment was posted from a scratchpad copy of scripts/ at f22f0e8."
    }

  5. objectstack-fleet commented on Sep 27, 2026

    @objectstack-fleet
    ContributorAuthor

    Seat amendment to claim 5852472098

    domain:engine#1, session_01Bvd69VPa6puiNzzPUroDBx, written 2026-09-27T05:23Z.

  6. objectstack-fleet commented on Sep 27, 2026

    @objectstack-fleet
    ContributorAuthor

    Correction to seat amendment 5852957006. domain:engine#1, session_01Bvd69VPa6puiNzzPUroDBx, written 2026-09-27T05:24Z.

  7. objectstack-fleet commented on Sep 27, 2026

    @objectstack-fleet
    ContributorAuthor

    Seat amendment 2 to claim 5852472098

    domain:engine#1, session_01Bvd69VPa6puiNzzPUroDBx, written 2026-09-27T05:52Z.

  8. objectstack-fleet commented on Sep 27, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT: PR #20174 at 4da90165cd (#20148)

    domain:engine#1, session_01Bvd69VPa6puiNzzPUroDBx, written 2026-09-27T06:18Z. Reviewed on GitHub against references/review-checklist.md, not from the dev's os-dev-report.

    • Shape: the first line is Fixes #20148. The body and both changesets declare Clause-②: no (narrowing): BREAKING, @objectstack/objectql minor and @objectstack/metadata-protocol minor, each with ADR-0087 disposition not-required (no-migration-prescription).
    • Scope: 10 files, +974/−31. Not governed (check-governed-merges: 1005 lines, under the threshold).
      • engine.ts: inside ObjectQL.aggregate's per-aggregation loop only.
      • having-filter.ts: assertAggregationFilterIsEvaluable, checkCondition, module-level helpers, and offsetPairViolation shared with having.
      • protocol.ts: inside assertAggregationFieldsExist only.
      • Three test files, two changesets, and two one-clause DELIBERATE CORRECTIONS (20127-*, 20099-*; amendment 2, 5853144038).
    • Contract review of record:
    • CI at this head: 41 runs, 34 success, 5 skipped, 2 failed. All seven required contexts are success.
      • The 2 failures are both Check Changeset, one per pull_request event, and both are red by design on exactly the two corrected notes. This is the DELIBERATE CORRECTION class, confirmed by the same-head PASS, and the gate runs on pull_request only.
      • git merge-tree against current main (455dcc060d) is clean.
    • Acceptance notes (not cards):
    • Open for the maintainer, not this PR: whether to name the fields in every refusal at this position (Q1 option C, a relaxation of the withholding posture).

    Landing: ready plus auto-merge through the queue now.

  9. objectstack-fleet commented on Sep 27, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed: PR #20174, verified on main

    domain:engine#1, session_01Bvd69VPa6puiNzzPUroDBx, written 2026-09-27T06:39Z.

  10. added 6 commits that reference this issue on Sep 28, 2026
    cfe2387
    e5cf27d
    e7f69db
    89f87f2
    a78f731
    2f122b6
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:apiThe API a customer can call, and integrations — REST, connectors, webhooks, jobsbugSomething isn't workingdomain:enginepriority:p2Medium: important, M3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions