Repository navigation
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
Activity
objectstack-fleet commented
on Sep 25, 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's per-aggregation loop),packages/objectql/src/having-filter.ts(assertAggregationFilterIsEvaluable,checkCondition) andpackages/metadata-protocol/src/protocol.ts(assertAggregationFieldsExist) ⇒domain:engine; rationale: at the public REST door, a per-aggregationfilteranswers200with every count 0 when it holds a bad date, anaddDaysnumeric pair, an undeclared{ $field }or an unknown key, while the same condition written as awhereis 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 objectstackorigin/main.Execution note:
- Run
where's remaining doors on eachaggregations[i].filterin 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 theaddDaysclass rule). - At REST, extend the field check to the keys inside each aggregation filter, with the
INVALID_FIELD/ 400 envelopewherealready gets. - Row 5 (a
Datebound incheckCondition, shared withhaving) has no public reach of its own. Decide it in the same PR: compare the waywheredoes. - Pin all five rows on a populated and an empty object, through the engine and REST, on
InMemoryDriverandSqlDriver, with thewheretwin 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.- Run
- 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 25, 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-20148-aggregation-filter-doors
Worktree:objectstack-issue-20148
Domain:domain:engine
Seat:domain:engine#1
File surface:packages/objectql/src/engine.ts: the per-aggregationfilterloop ofObjectQL.aggregateonly.where's remaining doors run on eachaggregations[i].filter, before any driver call, against the object's fields: the temporal-comparand door, the declared-field check on a{ $field }referent, and theaddDaysclass rule;packages/objectql/src/having-filter.ts:assertAggregationFilterIsEvaluableandcheckCondition(row 5, aDatebound compared the waywherecompares it);packages/metadata-protocol/src/protocol.ts:findData'sassertAggregationFieldsExistonly, extended to the keys inside eachaggregations[i].filter(row 4);- tests in
packages/objectql/packages/metadata-protocoland REST pins (test side only); .changeset/20148-*.md.
Stop on breach and explain in the report. ⛔ Not
packages/spec. ⛔ Notdriver-sql/driver-memory(their withholding posture, #7929 / #8220, stays). ⛔ Not thewherepath itself. ⛔ NotsaveMetaIteminprotocol.ts, which spec seat 2's #20051 (claim 5852017412) edits; the later lander mergesmain.
Container & model:M,mode:subagent,model: opus(dispatch-gates --tierfrom a tree at49144fccc8: 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 onengine.ts,having-filter.tsorprotocol.ts. The maintainer raised the seat tobatch:3at 2026-09-27 (「并发加到3」); in flight are #20136's dev and this card.objectstack-fleet commented
on Sep 27, 2026 ContributorAuthorMore actionsos-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."
}objectstack-fleet commented
on Sep 27, 2026 ContributorAuthorMore actionsSeat amendment to claim 5852472098
domain:engine#1,session_01Bvd69VPa6puiNzzPUroDBx, written 2026-09-27T05:23Z.- Open question 1 (disclosure posture at the per-aggregation position) → A for 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. Rows 2 and 3 are withheld, as the dispatch ruled, and objectql: a per-aggregation
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's reference refusals keep naming the field.- Option C (name everything and drop the withholding here) relaxes a disclosure posture (finding: after the #7598 Q1=B ruling, a read scope with a driver-refused field reference answers 400 from the driver — which cuts across #5367's attribution argument on that one path #7929 / [A of #7929] a spec-declared provenance mark set at both read-scope merge boundaries, so the driver can restore the author-facing cross-field diagnostic without re-disclosing policy #8220). The dev measured that no policy-authored filter reaches this position, but loosening a withholding posture is a security-boundary call.
- The seat puts C to the maintainer, with the dev's four-axis reading, and does not rule it here.
- Open question 2 (row 5 residual) → A for 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. The PR keeps the type-blind instant lift. Option B (normalise temporal comparands by the declared field class) is not a rider here.
- The residual and the dev's first out-of-scope finding share one root: an interpretable temporal STRING is compared as text, which is REST-reachable (an ISO instant on a date field counts 1 where the
wheretwin counts 3). - The seat files them together as one class-closure card for a follow-up.
- The residual and the dev's first out-of-scope finding share one root: an interpretable temporal STRING is compared as text, which is REST-reachable (an ISO instant on a date field counts 1 where the
- Out-of-scope finding 2 (
InMemoryDriveranswers a valid{ $field }wherewith zero rows; REST 0 vs SqlDriver 4) is a different family. The seat files it as its own card. The [裁决] driver-memory / driver-mongodb 投入冻结 —— 维护者 2026-08-05 口径(跨单锚点) #5499 freeze on driver-memory was lifted on 2026-08-11 (5545071055). - Deviations accepted for review:
- private helpers added in
having-filter.ts, plusoffsetPairViolationfactored out sohavingshares one rule; - row 1's message reads
where.FIELD.OP, because a path parameter would needtemporal-comparand-door.ts, outside the claim; - the REST pin runs on
SqlDriveronly; - a merge of
maininto the branch.
- private helpers added in
- Open question 1 (disclosure posture at the per-aggregation position) → A for 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. Rows 2 and 3 are withheld, as the dispatch ruled, and objectql: a per-aggregation
objectstack-fleet commented
on Sep 27, 2026 ContributorAuthorMore actionsCorrection to seat amendment 5852957006.
domain:engine#1,session_01Bvd69VPa6puiNzzPUroDBx, written 2026-09-27T05:24Z.- Out-of-scope finding 2 (
InMemoryDriver's{ $field }inwhere→ zero rows) is NOT filed. It is [finding] driver-memory's own reference matcher has no$fieldarm — a cross-field comparand (bare or withaddDays) reaching it is presumably compared as a literal object rather than resolved or refused (grep reading, to be measured) #15104. The maintainer closed that cardnot_plannedon 2026-09-23T11:02Z (「15019 15104 关」), under the [裁决] driver-memory / driver-mongodb 投入冻结 —— 维护者 2026-08-05 口径(跨单锚点) #5499 driver-memory investment freeze, and its behaviour half was already measured at 5827984845. - My amendment said the freeze was lifted on 2026-08-11 and that the seat would file the finding. That read an older anchor comment over the maintainer's newer closing, and it was wrong. This dev's measurement adds REST cells (0 vs SqlDriver 4), but no [finding] driver-memory's own reference matcher has no
$fieldarm — a cross-field comparand (bare or withaddDays) reaching it is presumably compared as a literal object rather than resolved or refused (grep reading, to be measured) #15104 reopen condition is met. - Everything else in 5852957006 stands.
- Out-of-scope finding 2 (
objectstack-fleet commented
on Sep 27, 2026 ContributorAuthorMore actionsSeat amendment 2 to claim 5852472098
domain:engine#1,session_01Bvd69VPa6puiNzzPUroDBx, written 2026-09-27T05:52Z.- The contract review FAIL 5853139988 on 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 at
f22f0e875fis prose only. The rows, the bar, the collateral, the pins, the disclosure, the gates and CI all pass as measured. - The patch round:
.changeset/20148-aggregation-filter-where-doors.md: qualify thehavingDatesentence fordate-class columns, and say the refusal borrowsdriver-sql's withholding posture, not its words. This is the review's must-change.- File surface widened by two pending changesets, each a one-clause DELIBERATE CORRECTION with every other line byte-identical:
.changeset/20127-having-adddays-temporal-pair.md, its last sentence ("A per-aggregationfilter… is not judged by this rule"). THIS PR makes it FALSE..changeset/20099-having-where-doors.md, "Ahavingkey that names no column still keeps no group rather than being refused". It 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), which this seat landed, and correcting it in the next PR in the same region keeps the release notes true.
Check Changesetturns red by design on exactly those two names. The same-head delta PASS is what confirms them.- PR body: H4's gate count (4 at head, two of them
main's), and H3's "except" clause naming bothmax(date)midnight shapes ($gteand$eq).
20122-*'s "Not changed …{ $field }reference" is that PR's own before/after, and stays as is.- Everything else in amendment 5852957006 and correction 5852968007 stands.
- The contract review FAIL 5853139988 on 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 at
- added a commit that references this issue
on Sep 27, 2026 objectstack-fleet commented
on Sep 27, 2026 ContributorAuthorMore actionsACCEPT: PR #20174 at
4da90165cd(#20148)domain:engine#1,session_01Bvd69VPa6puiNzzPUroDBx, written 2026-09-27T06:18Z. Reviewed on GitHub againstreferences/review-checklist.md, not from the dev'sos-dev-report.- Shape: the first line is
Fixes #20148. The body and both changesets declareClause-②: no (narrowing): BREAKING,@objectstack/objectqlminorand@objectstack/metadata-protocolminor, each with ADR-0087 dispositionnot-required (no-migration-prescription). - Scope: 10 files, +974/−31. Not governed (
check-governed-merges: 1005 lines, under the threshold).engine.ts: insideObjectQL.aggregate's per-aggregation loop only.having-filter.ts:assertAggregationFilterIsEvaluable,checkCondition, module-level helpers, andoffsetPairViolationshared withhaving.protocol.ts: insideassertAggregationFieldsExistonly.- Three test files, two changesets, and two one-clause DELIBERATE CORRECTIONS (
20127-*,20099-*; amendment 2, 5853144038).
- Contract review of record:
- FAIL 5853139988 at
f22f0e875f, prose only. - Delta PASS 5853308936 at
4da90165cd. The code and test blobs are identical, and the reviewer's 3136-cell harness re-run shows 0 cells differing. - Rows 1–4 are refused
INVALID_FILTER/INVALID_FIELD400, with 0 driver reads and the same answer on empty and populated objects, on both drivers, through the engine and REST. - Row 5 now matches
whereon everydatetimeshape. The residual is filed as objectql + REST: a per-aggregationfilter(andhaving) compares a temporal comparand type-blind, not by the column's storage rule — an ISO instant on adatefield counts 1 where thewheretwin counts 3 #20176. where,groupBy, and everyhavingcell without aDatebound are byte-identical. The valid-filter controls keep their counts.- The ablation reproduces the dev's red counts: 34/107, 7/13 and 10/15.
- Rows 2 and 3 withhold the fields on the wire, as ruled (Q1 A).
- FAIL 5853139988 at
- CI at this head: 41 runs, 34
success, 5skipped, 2 failed. All seven required contexts aresuccess.- The 2 failures are both
Check Changeset, one perpull_requestevent, 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 onpull_requestonly. git merge-treeagainst currentmain(455dcc060d) is clean.
- The 2 failures are both
- Acceptance notes (not cards):
content/docs/protocol/objectql/query-syntax.mdxlines 92 and 136 still markaggregations[].filter[EXPERIMENTAL — not enforced]. That has been FALSE since engine.aggregate: add per-aggregation filter to the contract — ruled half of #10413 (measure-level filters on the ObjectQL analytics path) #10576.carrier:none today, a docs-only tidy.- The row 1 position label reads
where.FIELD.OP, because a path parameter would needtemporal-comparand-door.ts.carrier:none.
- 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:
readyplus auto-merge through the queue now.- Shape: the first line is
objectstack-fleet commented
on Sep 27, 2026 ContributorAuthorMore actionsLanded: PR #20174, verified on
maindomain:engine#1,session_01Bvd69VPa6puiNzzPUroDBx, written 2026-09-27T06:39Z.- Verified on main:
- 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 merged through the merge queue as
cfe2387a3b. It has one parent (2dfe070c79) and is an ancestor oforigin/mainafter a fresh fetch. - Its patch-id equals the reviewed diff
369bcbeda7..4da90165cd(delta PASS 5853308936): 10 files, +974/−31. - Both changesets,
20148-aggregation-filter-where-doors.mdand20148-aggregation-filter-keys-rest.md, are present at that commit and absent at its parent. - The two DELIBERATE CORRECTIONS landed at the reviewed blobs, as ACCEPT 5853314026 ruled:
20127-*6b191f66and20099-*ed810ccd. The redCheck Changesetdoes not run onmerge_group.
- 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 merged through the merge queue as
- This card: closed
completedviaFixes #20148.pm:dispatchedis removed in the same act.- The per-aggregation
filternow takeswhere's remaining doors. A bad date, anaddDaysnumeric pair and an undeclared{ $field }are refusedINVALID_FILTER/ 400, and an unknown key is refusedINVALID_FIELD/ 400 at REST. - Every refusal happens with 0 driver reads, and the answer is the same on empty and populated objects.
- A
Datebound compares as an instant, matchingwhereon everydatetimeshape.
- The per-aggregation
- Still open, filed: objectql + REST: a per-aggregation
filter(andhaving) compares a temporal comparand type-blind, not by the column's storage rule — an ISO instant on adatefield counts 1 where thewheretwin counts 3 #20176 (a temporal comparand at this position and inhavingis read type-blind; REST-reachable; untriaged). - Open for the maintainer: whether every refusal at this position should name its fields (option C of open question 1, amendment 5852957006).
- Verified on main:
- added 6 commits that reference this issue
on Sep 28, 2026
Filing gate: ① a defect with a named landing site: the per-aggregation
filterposition.packages/objectql/src/engine.ts:ObjectQL.aggregate's per-aggregation loop;packages/objectql/src/having-filter.ts:assertAggregationFilterIsEvaluableandcheckCondition;packages/metadata-protocol/src/protocol.ts:findData'sassertAggregationFieldsExist.Finding class (a). This is ONE class-closure card for a family, filed per the class-closure rule: the per-aggregation
filterdoes not yet take every doorwherehas.reach:was measured at the public REST door (below).The
domain:engineexecution seat 1 (session_01Bvd69VPa6puiNzzPUroDBx) filed this from the out-of-scope findings of its #20122 dev (os-dev-report5832791672 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 realInMemoryDriverand a realSqlDriver(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)
aggregations[i].filterwherereach:{ placed_on: { $gt: 'not-a-date' } }on a date fieldINVALID_FILTER/ 400 ("compares a declared date field against …"), 0 driver callsPOST /api/v1/data/:object/query→ 200 with zero counts{ amount: { $gt: { $field: 'cap', addDays: 1 } } }on a numeric pairSqlDriver:INVALID_FILTER/ 400;FieldReferenceSchema.addDaysis declared for date columns only{ amount: { $gt: { $field: 'nope' } } }, an undeclared fieldSqlDriver:INVALID_FILTER/ 400 ("nopeis not a declared field"){ nope: 1 }, an unknown key, over RESTwhere: { nope: 1 }over REST →INVALID_FIELD/ 400 (assertFilterFieldsExist, #7534)assertAggregationFieldsExistjudges onlyfunction/alias/field{ opened_at: { $gt: new Date('2026-02-01') } }on ISO-text datetimesDateis not JSON; over REST it arrives as an ISO string and answers correctly)Row 5 sits in
checkCondition, which the per-aggregationfilterandhavingshare. 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:
min/maxof aformulafield is the one declared column 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 class rule cannot classify, so anaddDayspair against it is not judged.havingkey 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 column list at 500 characters (truncateClientMessage). The key, position and reason survive.Suggested shape (⛔ not a ruling)
where's remaining doors on eachaggregations[i].filterin the engine loop, before any driver call, against the OBJECT's fields (this filter reads raw rows): the temporal-comparand door (assertTemporalComparandsInterpretable), the declared-field check on a{ $field }referent, and theaddDaysclass rule over declared field types. Keepdriver-sql's withholding posture (finding: after the #7598 Q1=B ruling, a read scope with a driver-refused field reference answers 400 from the driver — which cuts across #5367's attribution argument on that one path #7929 / [A of #7929] a spec-declared provenance mark set at both read-scope merge boundaries, so the driver can restore the author-facing cross-field diagnostic without re-disclosing policy #8220) where its words are withheld.assertAggregationFieldsExist(orassertFilterFieldsExist) to the keys inside eachaggregations[i].filter, with theINVALID_FIELD/ 400 envelopewherealready gets.checkCondition: compare aDatebound the waywheredoes.InMemoryDriverandSqlDriver, with thewheretwin as the control.Filing-gate answers
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 dev and reproduced by the seat's at-tier reviewer.domain:engine, the owner ofpackages/objectql). The REST row sits inmetadata-protocol(domain:engineas well). It is sequenced after 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, which edits the same loop. That is a region order, not aBlocked-by:.closedincluded:aggregation filter not-a-date temporal comparand per-aggregation→ 7 hits, all read. 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, 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 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 (in flight, the family's source; the positive control) and 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 (closed, thehavingtwin). service-analytics: object-form analyticswhereskips the shared comparand-shape face's other arms ($innull member,$gt: null, null/blank$betweenbound, scalar$in) that the FilterArray spelling refuses 400 #20010 and service-analytics: the object-form analyticswhereskips 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 are analyticswhere, and No layer refuses an incoherent aggregate / field-type pair — a dataset measureavgover a datetime works on SQLite and errors on Postgres #16099 is the aggregate / field-type pair. None covers these rows.aggregations filter unknown field INVALID_FIELD REST query→ 5 hits. Data query: an unknown field insidewhere/$filteranswers 200/0 instead of400 INVALID_FIELD— the bare-key door disagrees (#4134's uncovered sibling) #7534 (closed) is thewheretwin, REST 读路径:searchFields/groupBy/aggregations指向不存在的字段时被静默降级(#4226 收口后剩下的三条轴) #4254 (closed) isaggregations[].fieldandgroupBy, not keys inside the filter, and Public lookup route builds object-shaped filter rules the data layer refuses:GET /forms/:slug/lookup/:fieldanswers 400 INVALID_FILTER for every search #16581 and driver-sql: the #7929 withhold covers the cross-field family only — every other INVALID_FILTER refusal still names the target field, which is admin-authored on a read-scope predicate #8197 are unrelated. None covers row 4.per-aggregation filter→ 1 hit (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, the control).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