Repository navigation
objectql having does not take the rest of where's filter doors: a $field reference 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
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:blocked(Blocked-by: #19974, as the body already says)Triage: lands in
packages/objectql(engine.tsObjectQL.aggregate, andhaving-filter.tsapplyHavingat:279/checkConditionat:349onorigin/maina8bcce6) ⇒domain:engine; rationale: fourwheredoorshavingdoes not take, each answering a silent200 []wherewhererefuses or answers rows; row 1 is also a declared form not honoured (ADR-0087 entryfilter-between-field-reference-endpoint-refusedprescribes the$fieldspelling). Runs but answers wrong ⇒ p2.Triage seat #6015 ·
session_01CRZSc7dU8oDStbTbSwhuZe· 2026-09-25T04:47Z. ⛔ Not a claim, ⛔ not a dispatch. Read: this card (no comments yet) andorigin/main. Dedupe over 1,113 cards (open, plus closed since 2026-09-18):applyHaving→ 2 hits (this card; #19974, its in-flight parent, which carved these four rows out on purpose).Blocked on #19974: its PR #20097 (draft) puts
havingbehind the shape face at the same engine entry, and this card extends that entry. Unblocks when #19974 closes.- 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 25, 2026 ContributorAuthorMore actionsUnlocked from #19974
domain:engine#1,session_01Bvd69VPa6puiNzzPUroDBx, written 2026-09-25T05:11Z. Thread-read: 5826911712- Blocker closed: objectql
havinganswers an array in the equality slot by JS coercion (having: { total: [5] }is true) — the one face ruling 乙 closes "for every driver at once" that no gate reaches #19974 closedcompleted. PR fix(objectql)!: routehavingthrough the shared comparand-shape face —having: { total: [5] }is refused like the same shape inwhere#20097 merged asaa04ea2964and was verified onorigin/main(landing record 5827149238). - Re-derived, not waved through:
- This card's work is not done. On
origin/mainaa04ea2964,having-filter.tshas no$fieldresolution, andcheckCondition's implicit arm still compares an array or a plain value with loose==.ObjectQL.aggregateruns only the comparand-SHAPE face onhaving: no FilterArray lowering, no comparand-type normaliser, and no row-independent structural check. - The entry this card extends now exists. It is the
assertListComparandShapescall onquery.havingthat PR fix(objectql)!: routehavingthrough the shared comparand-shape face —having: { total: [5] }is refused like the same shape inwhere#20097 added, ahead of the driver and bothapplyHavingdoors. - No merged PR claims this card. PR fix(objectql)!: route
havingthrough the shared comparand-shape face —having: { total: [5] }is refused like the same shape inwhere#20097 cross-referenced it at 04:24Z, before the 04:47Z transition and as its parent. The only later references are the seat's landing record on objectqlhavinganswers an array in the equality slot by JS coercion (having: { total: [5] }is true) — the one face ruling 乙 closes "for every driver at once" that no gate reaches #19974 and the triage seat post.
- This card's work is not done. On
- Census: 8 open PRs, none on
packages/objectql/src/engine.ts,having-filter.tsor the spec filter files. - The label moves
pm:blocked→pm:queue.
- Blocker closed: objectql
objectstack-fleet commented
on Sep 25, 2026 ContributorAuthorMore actionsClaim: PM loop round 22
Session:session_01Bvd69VPa6puiNzzPUroDBx
Branch:claude/issue-20099-having-where-doors
Worktree:objectstack-issue-20099
Domain:domain:engine
Seat:domain:engine#1
File surface:packages/objectql/src/engine.ts: thehavingentry ofObjectQL.aggregateonly, beside the comparand-shape call PR fix(objectql)!: routehavingthrough the shared comparand-shape face —having: { total: [5] }is refused like the same shape inwhere#20097 added: FilterArray lowering, the comparand-type normaliser and a row-independent structural check onquery.having;packages/objectql/src/having-filter.ts:$fieldresolution against the aggregated row (or a refusal where it cannot resolve), and moving its operator refusals ahead of the per-row walk;- tests in
packages/objectql, extending PR fix(objectql)!: routehavingthrough the shared comparand-shape face —having: { total: [5] }is refused like the same shape inwhere#20097's where/having parity table; .changeset/20099-*.md.
Stop on breach and explain in the report. ⛔ Not
packages/spec(the shared faces are consumed, not changed). ⛔ Not thewherepipeline's own doors. ⛔ Not the analyticshavingofservice-analytics(domain:services).
Container & model:M,mode:subagent,model: opus(dispatch-gates --tier: no path-derived mandate, floor sonnet · default opus · ceiling fable; an accept/reject change on a filter face, so the review runs atCONTRACT_REVIEW_TIER)
Clause-②: no (narrowing)
Thread-read: 5826911712
Serial constraints cleared: at 2026-09-25T05:13Z, #19974 (the serial holder of thehavingregion) has landed asaa04ea2964. The census of 8 open PRs finds none on this surface's files.objectstack-fleet commented
on Sep 25, 2026 ContributorAuthorMore actionsos-dev-report
{
"issue": 20099,
"status": "done",
"branch": "claude/issue-20099-having-where-doors",
"pr": "#20117",
"session": "session_01Bvd69VPa6puiNzzPUroDBx — this run's harness-stamped id (mode:subagent = the PM seat's id, as the claim 5827175921 names)",
"premise_still_valid": true,
"summary": "H1 held at base aa04ea2: all four rows reproduced through the public engine.aggregate on driver-memory and driver-sqlite-wasm, both applyHaving doors, populated and empty grouped sets (54 shapes x 8 cells). At head 98abdf4, ObjectQL.aggregate's having entry (beside PR #20097's shape-face call) runs, in where's order: a condition-object check (arrays and scalars refused), the shape face, the comparand-TYPE door (normalizeFilterComparandTypes rooted athaving, bigint narrowed copy-on-write), and a row-independent walk (assertHavingIsEvaluable) that raises the walker's own refusals in its own words plus four { $field } refusals against the aggregated row's column set. checkCondition now RESOLVES a { $field } reference in the six scalar comparisons via @objectstack/formula's matchesFilterCondition (NULL totality and addDays from the one evaluator SQL is held to). All 272 head refusal cells fire before any driver call. Deviation: FilterArray on having is REFUSED, not lowered — QuerySchema.having and EngineAggregateOptions.having (FilterConditionSchema) refuse arrays and the sugar is declared on where alone, so lowering would widen having's contract (see open_questions). Card assignee not written (os-sales on arrival); PR assignee set to os-sales.",
"tests": "Head 98abdf4 unless stated. (1) @objectstack/objectqlvitest run --project local: 'Test Files 314 passed (314) / Tests 5417 passed (5417)';--project repo: 1 file, 5 passed. (2) objectql typecheck at ce22319 (head differs only by the changeset): exit 0; check:test-typecheck 'OK — 40 file(s) / 234 error(s) / 65 pinned signature(s)', unchanged. (3) engine-aggregate-having-comparand-shape.test.ts (PR #20097's parity table, extended 37 -> 112): 112 passed. (4) Consumer suites (objectql dist rebuilt, turbo 24 tasks / 18 cached, dist carries assertHavingIsEvaluable x2): metadata-protocol protocol.query-param-arity.test.ts 46 passed; rest list-view-grouping-query-door.test.ts 33 passed; plugin-security predicate-guard.test.ts 10 passed. (5) Real-driver scratch measurement, base vs head: see rows; base re-measured after the change is byte-identical on all 46 original shapes. (6) Reverse verification: engine.ts and having-filter.ts restored to their BASE blobs (a9ec130693 / 2514be70bb) bygit restore --sourceunder an EXIT/INT/TERM trap; head's test file read 'Tests 69 failed | 43 passed (112)'; trap restore proven by HEAD-blob match and emptygit diff HEAD. (7) Ablation via scripts/ablation-replace.mjs (anchor x1->x0, blob moved, restored to HEAD blob, diff empty each time): assertHavingIsFilterCondition -> 9 failed (exactly the 9 not-a-condition rows); type-door call -> 21 failed (10 type parity rows + 10 FILTER_COMPARAND_TYPE_CASES type rows + bigint narrowing); assertHavingIsEvaluable -> 25 failed (10 walker rows + 14 reference refusals + column-list row); checkCondition resolution branch -> 14 failed (12 resolution rows + NULL row + per-aggregation-filter row). No dist preflight: the tests import ./engine.js and ./having-filter.js from source. (8) Lint, declared narrowing: eslint --no-inline-config --format json on the 3 changed .ts files -> 3 files, 0 errors, 0 warnings; --print-config returns a config for each; eslint.config.mjs sets no parserOptions.project and no typed rule. (9) Control-byte self-scan of the 4 changed files: grep exit 1 (none).",
"mcp_calls": "0 — no MCP GitHub tool was called; reads were single-card REST GETs (issue 20099 + comments, issue 19974 + comments, pull 20097 + comments, comment 5793368540, pulls?head=, pull 20117)",
"api_writes": "3 — each through the fleet-write relay as objectstack-fleet[bot] (one POST /repos/objectstack-ai/objectstack/dispatches each): (1) pr_create -> POST /repos/objectstack-ai/objectstack/pulls (draft PR #20117; body read back byte-identical, 18788 chars, one footer); (2) label-write --issue 20117 --assign os-sales -> POST /repos//issues/20117/assignees (read back: assignee os-sales; labels documentation, size/l, tests, tooling were set by others, not written here); (3) this os-dev-report comment -> POST /repos//issues/20099/comments. Not REST: git push to claude/issue-20099-having-where-doors x4 (the first the empty-branch probe).",
"open_questions": [
{
"question": "FilterArray sugar onhaving: this PR REFUSES it (INVALID_FILTER/400, row-independent). The claim surface and H4 expected lowering. Keep the refusal, or lower it?",
"options": [
"A — keep the refusal (as shipped): enforces the declared FilterConditionSchema on having; no spec change; the wire door already refuses a having array (protocol.query-param-arity.test.ts); zero producers write having as an array (census: 1 source caller forwarding the wire value, 5 docs/skills hits all object form)",
"B — lower it through parseFilterAST: needs a spec change (parseFilterAST has no root-path argument, so its gate refusals would readwhere.on a having) AND declaring the sugar on the having slot — a protocol widening the frame rules out for this defect card"
],
"recommendation": "A. 实际业务需求: 0 producers of array-form having measured. 长远合理性: contract-first — the engine enforces the one declared shape; B is a protocol change, not a fix. 防AI犯错: a loud 400 naming the object form and where-only sugar beats a second accepted dialect. 创业阶段不扩散: B widens a contract with no pull."
},
{
"question": "addDayson a { $field } reference against a NON-temporal aggregated column (e.g. { total: { $gt: { $field: 'max_cap', addDays: 1 } } }): having now follows @objectstack/formula's reading (a number is epoch-ms, so this keeps no group); SQL push-down refuses that pair onwhere; driver-memory does not resolve it at all. Keep, or refuse statically?",
"options": [
"A — keep the in-memory evaluator's reading (as shipped); documented in the PR",
"B — refuse addDays unless both columns are temporal of one class, deriving each aggregated column's class from its aggregation function and the source field's declared type (SQL's rule, statically)",
"C — refuse addDays on having outright"
],
"recommendation": "A now. 实际业务需求: 0 having-with-addDays producers measured. 长远合理性: B is the principled end state but needs a type derivation this card does not own. 防AI犯错: B/C are louder, but C also refuses the temporal case where and formula answer. 创业阶段不扩散: do not build B without pull; revisit if a producer appears."
}
],
"out_of_scope_findings": [
"class: a · A per-aggregationfilter's walker refusals depend on the data — row 4's class at the sibling position. At head 98abdf4 (the engine's aggregation-filter loop is unchanged from aa04ea2), driver-memory and driver-sqlite-wasm: aggregations: [{ function: 'count', alias: 'n', filter: { amount: { $median: 1 } } }] -> INVALID_FILTER/400 on a populated table, 200 [] on an empty one. matchesAggregationFilter runs per raw row on the fallback door. Fix shape: run this PR's assertHavingIsEvaluable walk per aggregation filter in the engine loop (outside this claim's having-entry surface). Seam: runtime:packages/objectql/src/engine.ts ObjectQL.aggregate aggregations loop → having-filter.ts matchesAggregationFilter · dedupe: aggregation filter unknown operator empty set · matchesAggregationFilter refusal data-dependent · per-aggregation filter $median empty table",
"class: a · An enginewherethat is a string, number or Map is dropped: engine.find('order', { where: 'amount > 100' }) returns every row (2 of 2) on driver-memory and driver-sqlite-wasm, at head (engine where path untouched). lowerWhereFilterArray's non-array branch hands a non-node through every door, each of which steps around it. Seam: runtime:packages/objectql/src/engine.ts lowerWhereFilterArray · dedupe: engine where string unfiltered · where non-object scalar dropped · lowerWhereFilterArray non-node",
"class: a · The engine's refusal of a non-filterwherearray carries no ADR-0112 envelope: engine.find('order', { where: [1, 2, 3] }) throws with code undefined and status undefined on both drivers (the wire door answers 400 INVALID_FILTER for the same input). Seam: runtime:packages/objectql/src/engine.ts lowerWhereFilterArray branch (3) · dedupe: where array not a filter envelope · lowerWhereFilterArray uncoded error · where non-filter array code status",
"class: a · driver-memory does not resolve awhere{ $field } reference: { amount: { $gt: { $field: 'cap' } } } answers [] on driver-memory and ['o1'] on driver-sqlite-wasm (same rows). FieldReferenceSchema declares the reference legal as the whole comparand of $eq/$ne/$gt/$gte/$lt/$lte. Seam: spec:FieldReferenceSchema → runtime:packages/drivers/driver-memory/src/memory-driver.ts find (convertToMongoQuery) · dedupe: driver-memory $field reference · memory driver cross-field comparison · mingo field reference unresolved",
"class: a · Ahavingkey naming no column of the aggregated row keeps no group silently: having: { totl: { $gt: 100 } } -> 200 [] on a populated set, both drivers, at head. The engine now holds the column set (aggregatedRowColumns), so the same check the reference gets could refuse it; not one of this card's rows. Seam: runtime:packages/objectql/src/having-filter.ts checkCondition no-value exit · dedupe: having unknown column silent · having alias typo no group · having key not in aggregated row",
"carrier: the #7536 follow-up · $like / $ilike still refused on having — the documented staging in FILTER_OPERATORS, not a new gap · noted, not filed",
"carrier: 承接者:无 · having-filter.ts header 「the only face no conformance table covers」 remains true of the logic axis only; not edited (claim scope) · noted in PR Acceptance notes, not filed"
],
"gates": {
"derivation": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at head 98abdf4 (stderr: repo objectstack-ai/objectstack, commit 98abdf4; 4 paths vs merge base aa04ea2): 64 families. The PM list (derived at 2c1011b, 51 lines) is a subset plus the changeset / check:where-matcher / check:objectql-double-limit / check:query-options-erasure / check:engine-double-contract / check:type-check-* additions of the real diff.",
"reconciliation": "--ran: '✓ dispatch-gates --ran: 64 derived famil(ies) accounted for — 62 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)' · 'Run reconciliation — 64 derived, 62 run, 2 NOT-MEASURED, 0 UNRUN.'",
"exit_0": 62,
"not_measured": [
"pnpm check:dual-build-cjs-loads — exit 3 PREREQUISITE NOT MET ('Runpnpm buildfirst. ⛔ This is NOT a pass'); needs the whole workspace built; CI runs it",
"pnpm check:type-check-debt — exit 3 PREREQUISITE NOT MET; same reason"
],
"key_verdicts": [
"check-adr-0087-registration --base aa04ea2: '✓ 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition.' · '.changeset/20099-having-where-doors.md [BREAKING+bang+clause-②-narrowing] not-required (already-registered)'",
"check-changeset-no-major --base aa04ea2: '✓ This diff introduces nomajorbump.' (clause-② level axis NOT APPLICABLE locally — no pull_request payload)",
"check-empty-changeset: '✓ No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added).'",
"check:query-options-erasure: '✓ query-options-erasure ratchet holds: 67 unswept non-test site(s) in 17 file(s), none new'",
"check:nul-bytes: 'check-nul-bytes: OK (scanned 9536 text file(s) … no raw ASCII control bytes)'",
"check:engine-double-contract: 'OK — 902 pinned, 133 in the DEBT ledger, 3 exempt.'",
"check:where-matcher: '✓ where-matcher conformance holds: 433 matcher(s) discovered'",
"check:doc-authoring: '✓ doc authoring guard: 403 files clean'",
"check:driver-memory-census: 'OK — every declaration is ledgered'",
"node scripts/check-issue-citations.mjs --base aa04ea2: '✅ check-issue-citations: every citation this change adds resolves' (19 judged)"
],
"ci": "in_progress — not waited on, per os-dev.md"
},
"line_budget": "n/a — no skills/** in the diff",
"deviations": [
"FilterArray on having is REFUSED, not lowered (claim surface said 'FilterArray lowering'; H4 said 'lowered sugar'). Measured: FilterConditionSchema, QuerySchema.having and EngineAggregateOptionsSchema.having all refuse an array (safeParse, spec dist at aa04ea2); TransportFilterValueSchema declares the sugar on where only; parseFilterAST has no root-path argument. Carried as open_questions[0].",
"The per-aggregationfilteralso resolves { $field } — it shares checkCondition with having, and a clause fork would give one walker two rules. Declared as the bounded in-place fix (same defect class, same code path, no other claim on having-filter.ts, same gates) with a test pin; measured count filter { amount: { $gt: { $field: 'cap' } } } c1 0/c2 0/c3 0 at base -> c1 2/c2 1/c3 0 at head on both real drivers. The claim's file surface for having-filter.ts should gain 'and the per-aggregation filter through the shared walker' (seat writes it).",
"The committed tests use PR #20097's stand-in driver (both doors), not real drivers: objectql has no driver dependency and check:driver-memory-census holds driver-memory's consumer set to a ruled ledger. The both-real-drivers legs are the scratch measurement (rows).",
"The stand-in's native aggregate() now computes the requested aggregations (count/sum/min/max) instead of hard-coding two aliases, so a { $field } row can name a third column; PR #20097's 37 tests are unchanged and green.",
"Wording change on one refusal that already existed on a populated set: a bare { total: { $field } } used to read "Unsupported operator '$field' inhaving"; it now reads the pointed bare-reference message naming the $eq spelling (same code/status). No committed test pinned the old text.",
"objectql typecheck ran at ce22319, not at the final head 98abdf4; the later commit adds only the changeset."
],
"files_changed": [
"packages/objectql/src/engine.ts (+35/-1: the having entry — condition-object check, comparand-type door, row-independent walk; the import)",
"packages/objectql/src/having-filter.ts (+352/-1: assertHavingIsFilterCondition, assertHavingIsEvaluable, aggregatedRowColumns, compareWithReference, five refusal constructors, the row threaded into checkCondition)",
"packages/objectql/src/engine-aggregate-having-comparand-shape.test.ts (+335/-12: 37 -> 112 tests)",
".changeset/20099-having-where-doors.md (new, +33; @objectstack/objectql minor, fix(objectql)!:, BREAKING, Clause-②: no (narrowing), adr-0087 not-required (already-registered …))"
],
"compile_surfaces": {
"1 driver-sql (incl. driver-sqlite-wasm, local driver-turso)": "not reached byhaving: no driver reads the clause. Unchanged.",
"2 turso RemoteTransport": "not reached. Unchanged.",
"3 service-analytics read-scope-sql": "not reached. Unchanged.",
"4 service-analytics filter-normalizer": "not reached; analytics sends no having to the engine, and its own having is outside this claim. Unchanged.",
"5 formula": "consumed, not changed: matchesFilterCondition now also evaluates a having { $field } comparison (six scalar operators, addDays). Source untouched.",
"half-face objectql having-filter": "CHANGED — at engine.ts ObjectQL.aggregate's having entry: condition-object check, shape face (existing), comparand-TYPE door, row-independent walker incl. { $field } position/name; { $field } resolved in checkCondition on both applyHaving doors. Parity table covers shape face + type door + the new arms, both doors, empty-set leg on every refusal.",
"driver-memory / driver-mongodb": "not reached byhaving. Unchanged.",
"compile_surfaces_md_line (governed, not edited — the seat routes it)": "| 半面 | objectqlhaving-filter|packages/objectql/src/having-filter.ts(applyHaving/matchesHaving/assertHavingIsEvaluable);入口packages/objectql/src/engine.tsObjectQL.aggregate的 having 段(assertHavingIsFilterCondition→assertListComparandShapes→normalizeFilterComparandTypes(…, 'having')→assertHavingIsEvaluable,均在两个applyHaving门之前) | 聚合后过滤。比较值形状轴与类型轴已有 conformance(engine-aggregate-having-comparand-shape.test.ts的 where/having 对等表,两门都跑,每个拒收带空分组腿);{ $field }在六个标量比较中按聚合行解析(@objectstack/formulamatchesFilterCondition);逻辑轴(FILTER_LOGIC_CASES)仍不驱动 HAVING 路径 |"
},
"rows_legend": "Each row: one card row x shape, measured through engine.aggregate on driver-memory AND driver-sqlite-wasm, native AND fallback door, populated AND empty grouped set (8 cells per version); a single value means all 8 cells agreed, 'populated X; empty Y' means the 4 populated cells agreed on X and the 4 empty on Y; all cells agreed across both drivers and both doors in every row. where = the where twin over raw columns (driver-memory; driver-sqlite-wasm when they differ). narrowed = head refuses what base answered; answer_changed = accepted at both, answer differs. Groups: c1 total 500 / max_cap 50, c2 900 / 5000, c3 20 / 20. Base aa04ea2, head 98abdf4.",
"rows": [
{
"row": "1",
"shape": "$gt $field",
"where": "memory []; sqlite [o1,o2,o4]",
"having_base": "[]",
"having_head": "populated [c1]; empty []",
"narrowed": false,
"answer_changed": true
},
{
"row": "1",
"shape": "$gte $field",
"where": "memory []; sqlite [o1,o2,o4,o5]",
"having_base": "[]",
"having_head": "populated [c1,c3]; empty []",
"narrowed": false,
"answer_changed": true
},
{
"row": "1",
"shape": "$lt $field",
"where": "memory []; sqlite [o3]",
"having_base": "[]",
"having_head": "populated [c2]; empty []",
"narrowed": false,
"answer_changed": true
},
{
"row": "1",
"shape": "$lte $field",
"where": "memory []; sqlite [o3,o5]",
"having_base": "[]",
"having_head": "populated [c2,c3]; empty []",
"narrowed": false,
"answer_changed": true
},
{
"row": "1",
"shape": "$eq $field",
"where": "memory []; sqlite [o5]",
"having_base": "[]",
"having_head": "populated [c3]; empty []",
"narrowed": false,
"answer_changed": true
},
{
"row": "1",
"shape": "$ne $field",
"where": "memory [o1,o2,o3,o4,o5]; sqlite [o1,o2,o3,o4]",
"having_base": "populated [c1,c2,c3]; empty []",
"having_head": "populated [c1,c2]; empty []",
"narrowed": false,
"answer_changed": true
},
{
"row": "1",
"shape": "implicit $field",
"where": "refused INVALID_FILTER/400",
"having_base": "populated refused INVALID_FILTER/400; empty []",
"having_head": "refused INVALID_FILTER/400",
"narrowed": true,
"answer_changed": false
},
{
"row": "1",
"shape": "two-bound $field $gte+$lte",
"where": "memory []; sqlite [o1,o2,o4,o5]",
"having_base": "[]",
"having_head": "populated [c1,c3]; empty []",
"narrowed": false,
"answer_changed": true
},
{
"row": "1",
"shape": "$between $field bound (face)",
"where": "refused INVALID_FILTER/400",
"having_base": "refused INVALID_FILTER/400",
"having_head": "refused INVALID_FILTER/400",
"narrowed": false,
"answer_changed": false
},
{
"row": "1",
"shape": "$gt $field unknown column",
"where": "memory []; sqlite refused INVALID_FILTER/400",
"having_base": "[]",
"having_head": "refused INVALID_FILTER/400",
"narrowed": true,
"answer_changed": false
},
{
"row": "1",
"shape": "$gt $field groupBy col",
"where": "memory []; sqlite [o1,o2,o3,o4,o5]",
"having_base": "[]",
"having_head": "populated [c1,c2,c3]; empty []",
"narrowed": false,
"answer_changed": true
},
{
"row": "1",
"shape": "$gt $field addDays",
"where": "memory []; sqlite refused INVALID_FILTER/400",
"having_base": "[]",
"having_head": "[]",
"narrowed": false,
"answer_changed": false
},
{
"row": "1",
"shape": "$field non-string",
"where": "refused INVALID_FILTER/400",
"having_base": "[]",
"having_head": "refused INVALID_FILTER/400",
"narrowed": true,
"answer_changed": false
},
{
"row": "1",
"shape": "$in $field member (face)",
"where": "memory []; sqlite refused INVALID_FILTER/400",
"having_base": "[]",
"having_head": "refused INVALID_FILTER/400",
"narrowed": true,
"answer_changed": false
},
{
"row": "1",
"shape": "$contains $field",
"where": "memory []; sqlite refused INVALID_FILTER/400",
"having_base": "[]",
"having_head": "refused INVALID_FILTER/400",
"narrowed": true,
"answer_changed": false
},
{
"row": "2",
"shape": "$eq plain object",
"where": "refused INVALID_FILTER/400",
"having_base": "[]",
"having_head": "refused INVALID_FILTER/400",
"narrowed": true,
"answer_changed": false
},
{
"row": "2",
"shape": "implicit undefined",
"where": "refused INVALID_FILTER/400",
"having_base": "[]",
"having_head": "refused INVALID_FILTER/400",
"narrowed": true,
"answer_changed": false
},
{
"row": "2",
"shape": "$eq Map",
"where": "refused INVALID_FILTER/400",
"having_base": "[]",
"having_head": "refused INVALID_FILTER/400",
"narrowed": true,
"answer_changed": false
},
{
"row": "2",
"shape": "$gt function",
"where": "refused INVALID_FILTER/400",
"having_base": "[]",
"having_head": "refused INVALID_FILTER/400",
"narrowed": true,
"answer_changed": false
},
{
"row": "2",
"shape": "$in undefined member",
"where": "refused INVALID_FILTER/400",
"having_base": "[]",
"having_head": "refused INVALID_FILTER/400",
"narrowed": true,
"answer_changed": false
},
{
"row": "2",
"shape": "bigint 500n implicit",
"where": "[o1]",
"having_base": "populated [c1]; empty []",
"having_head": "populated [c1]; empty []",
"narrowed": false,
"answer_changed": false
},
{
"row": "2",
"shape": "bigint > 2^53",
"where": "refused INVALID_FILTER/400",
"having_base": "[]",
"having_head": "refused INVALID_FILTER/400",
"narrowed": true,
"answer_changed": false
},
{
"row": "2",
"shape": "$gt Date (accepted)",
"where": "memory []; sqlite [o1,o2,o3,o4,o5]",
"having_base": "populated [c1,c2,c3]; empty []",
"having_head": "populated [c1,c2,c3]; empty []",
"narrowed": false,
"answer_changed": false
},
{
"row": "3",
"shape": "FilterArray list",
"where": "[o2,o3,o4]",
"having_base": "[]",
"having_head": "refused INVALID_FILTER/400",
"narrowed": true,
"answer_changed": false
},
{
"row": "3",
"shape": "FilterArray comparison",
"where": "[o2,o3,o4]",
"having_base": "[]",
"having_head": "refused INVALID_FILTER/400",
"narrowed": true,
"answer_changed": false
},
{
"row": "3",
"shape": "FilterArray group",
"where": "[o2,o3]",
"having_base": "[]",
"having_head": "refused INVALID_FILTER/400",
"narrowed": true,
"answer_changed": false
},
{
"row": "3",
"shape": "FilterArray $field eq",
"where": "memory []; sqlite [o5]",
"having_base": "[]",
"having_head": "refused INVALID_FILTER/400",
"narrowed": true,
"answer_changed": false
},
{
"row": "3",
"shape": "empty array",
"where": "[o1,o2,o3,o4,o5]",
"having_base": "populated [c1,c2,c3]; empty []",
"having_head": "refused INVALID_FILTER/400",
"narrowed": true,
"answer_changed": false
},
{
"row": "3",
"shape": "non-filter array",
"where": "refused undefined/undefined",
"having_base": "[]",
"having_head": "refused INVALID_FILTER/400",
"narrowed": true,
"answer_changed": false
},
{
"row": "3",
"shape": "FilterArray scalar $in (face)",
"where": "refused INVALID_FILTER/400",
"having_base": "[]",
"having_head": "refused INVALID_FILTER/400",
"narrowed": true,
"answer_changed": false
},
{
"row": "4",
"shape": "$median",
"where": "refused INVALID_FILTER/400",
"having_base": "populated refused INVALID_FILTER/400; empty []",
"having_head": "refused INVALID_FILTER/400",
"narrowed": true,
"answer_changed": false
},
{
"row": "4",
"shape": "$icontains empty",
"where": "refused INVALID_FILTER/400",
"having_base": "populated refused INVALID_FILTER/400; empty []",
"having_head": "refused INVALID_FILTER/400",
"narrowed": true,
"answer_changed": false
},
{
"row": "4",
"shape": "$icontains number",
"where": "refused INVALID_FILTER/400",
"having_base": "populated refused INVALID_FILTER/400; empty []",
"having_head": "refused INVALID_FILTER/400",
"narrowed": true,
"answer_changed": false
},
{
"row": "4",
"shape": "$regex retired",
"where": "refused INVALID_FILTER/400",
"having_base": "populated refused INVALID_FILTER/400; empty []",
"having_head": "refused INVALID_FILTER/400",
"narrowed": true,
"answer_changed": false
},
{
"row": "4",
"shape": "$nand logical",
"where": "refused INVALID_FILTER/400",
"having_base": "populated refused INVALID_FILTER/400; empty []",
"having_head": "refused INVALID_FILTER/400",
"narrowed": true,
"answer_changed": false
},
{
"row": "4",
"shape": "$median on missing column",
"where": "refused INVALID_FILTER/400",
"having_base": "[]",
"having_head": "refused INVALID_FILTER/400",
"narrowed": true,
"answer_changed": false
},
{
"row": "4",
"shape": "$median under $or",
"where": "refused INVALID_FILTER/400",
"having_base": "populated [c1,c2,c3]; empty []",
"having_head": "refused INVALID_FILTER/400",
"narrowed": true,
"answer_changed": false
},
{
"row": "4",
"shape": "$like (declared op)",
"where": "[o1,o2,o3,o4,o5]",
"having_base": "populated refused INVALID_FILTER/400; empty []",
"having_head": "refused INVALID_FILTER/400",
"narrowed": true,
"answer_changed": false
},
{
"row": "1",
"shape": "$nin $field member",
"where": "memory [o1,o2,o3,o4,o5]; sqlite refused INVALID_FILTER/400",
"having_base": "populated [c1,c2,c3]; empty []",
"having_head": "refused INVALID_FILTER/400",
"narrowed": true,
"answer_changed": false
},
{
"row": "1",
"shape": "$notContains $field",
"where": "memory [o1,o2,o3,o4,o5]; sqlite refused INVALID_FILTER/400",
"having_base": "populated [c1,c2,c3]; empty []",
"having_head": "refused INVALID_FILTER/400",
"narrowed": true,
"answer_changed": false
},
{
"row": "1",
"shape": "$exists $field",
"where": "memory []; sqlite refused INVALID_FILTER/400",
"having_base": "populated [c1,c2,c3]; empty []",
"having_head": "refused INVALID_FILTER/400",
"narrowed": true,
"answer_changed": false
},
{
"row": "1",
"shape": "$null $field",
"where": "refused INVALID_FILTER/400",
"having_base": "populated [c1,c2,c3]; empty []",
"having_head": "refused INVALID_FILTER/400",
"narrowed": true,
"answer_changed": false
},
{
"row": "2",
"shape": "bigint $in list",
"where": "[o1,o5]",
"having_base": "[]",
"having_head": "populated [c1,c3]; empty []",
"narrowed": false,
"answer_changed": true
},
{
"row": "3",
"shape": "string having",
"where": "[o1,o2,o3,o4,o5]",
"having_base": "populated [c1,c2,c3]; empty []",
"having_head": "refused INVALID_FILTER/400",
"narrowed": true,
"answer_changed": false
},
{
"row": "3",
"shape": "number having",
"where": "[o1,o2,o3,o4,o5]",
"having_base": "populated [c1,c2,c3]; empty []",
"having_head": "refused INVALID_FILTER/400",
"narrowed": true,
"answer_changed": false
},
{
"row": "3",
"shape": "Map having",
"where": "[o1,o2,o3,o4,o5]",
"having_base": "populated [c1,c2,c3]; empty []",
"having_head": "refused INVALID_FILTER/400",
"narrowed": true,
"answer_changed": false
}
],
"consumers": {
"where_doors_H2": [
"FilterArray lowering: engine.ts lowerWhereFilterArray array branch → @objectstack/spec/data isFilterAST + parseFilterAST(where, context). parseFilterAST runs the shape face and the type door with the path fixed atwhere(no root argument) — not callable on having without a spec change; and the sugar is not declared on having (FilterConditionSchema refuses arrays).",
"Comparand-type normaliser: engine.ts lowerWhereFilterArray object branch → normalizeFilterComparandTypes(where, context). Takes a path argument — called on query.having rooted athaving, no column set needed.",
"Structural validation: assertListComparandShapes (already on having since PR #20097); assertFilterIsMaterializable / assertTextOperatorTargetsAreStringCapable / assertTemporalComparandsInterpretable judge the OBJECT's declared fields (not having's namespace); unknown operators on where are refused by the drivers, which having never reaches — so having's structural door is its own walker run once against aggregatedRowColumns(groupBy, aggregations) (groupBy field or structured alias/field, plus every aggregation alias)."
],
"having_consumer_census": "engine.aggregate with having — non-test source callers: 1 (packages/metadata-protocol/src/protocol.ts, the REST aggregate branch forwarding options.having). Test suites asserting having: objectql (engine-aggregate-having*.test.ts, having-.test.ts, protocol-data.test.ts — all in the 5417 green), metadata-protocol protocol.query-param-arity.test.ts (46 green; the wire already refuses a having array), plugin-security predicate-guard.test.ts (10 green; reads ast.having, writes nothing), rest list-view-grouping-query-door.test.ts (33 green). No source writes.having(git grep -nE '\.having\s=[^=]' over non-test packages: 0 hits).",
"docs_skills_census": "5having:occurrences in 3 files — skills/objectstack-query/rules/aggregation.md x2 ({ order_count: { $gt: 5 } }), content/docs/data-modeling/queries.mdx x2 ({ order_count: { $gt: 10 } }, { total_spent: { $gt: 1000 } }), content/docs/protocol/objectql/query-syntax.mdx x1 ({ total: { $gt: 1_000_000 } }) — all scalar comparisons against an alias, unchanged answers. Control: PR #20097's changeset census reports the same ('Everyhavingin this repository's docs and published skills is a scalar comparison')."
},
"cleanup": "worktree /home/user/objectstack-issue-20099: node_modules removed,git worktree removeexit 0 without --force; no background process of this run alive (PIDs 11390, 25364, 26043 gone)."
}objectstack-fleet commented
on Sep 25, 2026 ContributorAuthorMore actionsSeat amendment to claim 5827175921
domain:engine#1,session_01Bvd69VPa6puiNzzPUroDBx, written 2026-09-25T06:47Z.- File surface,
having-filter.ts: add "and the per-aggregationfilter's{ $field }resolution, through the sharedcheckConditionwalker". The dev declared it as a bounded in-place fix (os-dev-report5827889190). The at-tier review (PR fix(objectql)!:havingtakes the rest ofwhere's filter doors — the comparand-type door, row-independent refusals, a resolved{ $field }, and a refused non-condition #20117) confirms all four conditions: same defect class, mechanical and pinned, no other claim on the file, same gate family. The per-aggregation filter's own entry-level doors stay out, and are 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. - Seat rulings on the dev's open questions:
- Q1, FilterArray sugar on
having→ A, refuse.havingis typedFilterConditionSchema, which refuses arrays. The sugar is declared onwhereonly, and lowering it would widen the spec. - Q2,
addDayson a non-temporal referent → A, keep the in-memory evaluator's reading.FieldReferenceSchemaprescribes no refusal on this face. Deriving the aggregated column's temporal class is the end state, and rides a later card.
- Q1, FilterArray sugar on
- Patch round before landing: two cells of the changeset's FROM table (rows 1 and 7), and the PR body's §1 row 2 cell, state an operator-dependent "before" as "kept no group". The dev corrects them from the code, as on PR fix(objectql)!: route
havingthrough the shared comparand-shape face —having: { total: [5] }is refused like the same shape inwhere#20097.
- File surface,
objectstack-fleet commented
on Sep 25, 2026 ContributorAuthorMore actionsACCEPT: PR #20117 at
f08f8953domain:engine#1,session_01Bvd69VPa6puiNzzPUroDBx, written 2026-09-25T07:55Z. Reviewed on GitHub againstreferences/review-checklist.md, not from the dev'sos-dev-report.- Shape: the first line is
Fixes #20099, and it is the only closing keyword. The body and changeset declareClause-②: no (narrowing). The changeset grades@objectstack/objectqlminor, withfix(objectql)!:, BREAKING and ADR-0087not-required (already-registered …). - Scope: 4 files, +755/−14: the
havingentry ofObjectQL.aggregateinengine.ts,having-filter.ts, the parity test file and the changeset. Not governed (check-governed-merges --pr 20117).- The claim is amended at 5828170300: the per-aggregation filter's
{ $field }resolution rides as a bounded in-place fix. - Seat rulings: Q1 A, FilterArray on
havingis refused, as the spec'sFilterConditionSchematypes it. Q2 A,addDayson a non-temporal referent keeps the in-memory evaluator's reading. The end state is card 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.
- The claim is amended at 5828170300: the per-aggregation filter's
- Contract review of record: PASS 5828166480 at
98abdf4ee3(the code), then two prose rounds on the changeset's FROM table. Delta FAIL 5828483617 was followed by delta PASS 5828948132 atf08f8953.- The four card rows reproduce at base and close at head.
- 344/344 head refusal cells fire with 0 driver calls, on empty and populated sets alike.
{ $field }resolves through@objectstack/formula'smatchesFilterCondition.- Nothing outside
havingmoved, and@objectstack/objectqlpasses 5417 tests. - The changeset's "what it did before" cells are measured TRUE per operator and per column class.
- Out of scope, filed: objectql: an engine
wherethat is a string, number or Map is dropped —engine.find({ where: 'amount > 100' })returns every row, and a non-filter array is refused with no code or status; the same seam servesupdateanddelete#20121 (a scalarwhereis dropped), 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 per-aggregation filter's refusals depend on the data), 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 (an unknownhavingkey answers silently) 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 (addDayson a non-temporal column). - CI at this head: 41 runs, 36
successand 5skipped, and every required context is green.git merge-treeagainst currentmainis clean.
Landing:
readyplus auto-merge through the queue now.- Shape: the first line is
objectstack-fleet commented
on Sep 25, 2026 ContributorAuthorMore actionsLanded: PR #20117, verified on
maindomain:engine#1,session_01Bvd69VPa6puiNzzPUroDBx, written 2026-09-25T08:19Z.-
Verified on main:
- PR fix(objectql)!:
havingtakes the rest ofwhere's filter doors — the comparand-type door, row-independent refusals, a resolved{ $field }, and a refused non-condition #20117 merged through the merge queue asfc646cf09c, which has one parent and is an ancestor oforigin/mainafter a fresh fetch. The diff is 4 files, +755/−14, exactly the reviewed PR atf08f8953(PASS 5828166480 on the code; delta PASS 5828948132 on the changeset prose). - The changeset
20099-having-where-doors.mdis present at that commit and absent at its parent.
- PR fix(objectql)!:
-
This card: closed
completedviaFixes #20099.pm:dispatchedis removed in the same act.havingnow takeswhere's other doors at the engine entry, row-independently:- the condition check (FilterArray and scalars refused, as
FilterConditionSchematypes the slot); - the comparand-type door;
- the walker's refusals, plus
{ $field }checks against the aggregated row's columns.
A
{ $field }in the six scalar comparisons now resolves through@objectstack/formula. - the condition check (FilterArray and scalars refused, as
-
Region released:
having-filter.ts+ the aggregatehavingentry. Sequenced after this landing: 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 per-aggregation filter's refusals depend on the data), 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 (an unknownhavingkey answers silently) 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 (addDayson a non-temporal column).
-
- added a commit that references this issue
on Sep 27, 2026 - added 4 commits that reference this issue
on Sep 28, 2026
Filing gate: ① a defect with a named landing site:
packages/objectql/src/engine.tsObjectQL.aggregate(query.having), andpackages/objectql/src/having-filter.ts(applyHaving/checkCondition). Finding class (a), with (b) for the first row.Blocked-by: #19974
The
domain:engineexecution seat 1 (session_01Bvd69VPa6puiNzzPUroDBx) filed this as one class from the out-of-scope findings of its #19974 dev (os-dev-reporton #19974, PR #20097). PR #20097 putshavingbehind the shared comparand-SHAPE face only, and its dispatch forbade a second face. The four rows below are the other doorswheretakes andhavingdoes not. ⛔ Filed bare: routing and grading are triage's. ⛔ Not a claim.What happens
All four were measured by the #19974 dev at PR #20097's head
5649560428, ondriver-memory, with groupsc1(total 500, max_cap 50) andc2(total 900, max_cap 5000).$fieldreference is never resolved.having: { total: { $gt: { $field: 'max_cap' } } }answers200 [], wherec1should pass. ADR-0087 entryfilter-between-field-reference-endpoint-refusedprescribes{"$gte": {"$field": "a"}}as the spelling 「[spec] SqlDriver 将$field编译为列对列比较(cross-field comparison push-down) #5222 compiles on every face」, and PR fix(objectql)!: routehavingthrough the shared comparand-shape face —having: { total: [5] }is refused like the same shape inwhere#20097's own$betweenrefusal now shows that prescription onhaving. It is class (b) as well: a declared form the face prescribes andhavingdoes not honour.having: { total: { $eq: { v: 1 } } }answers200 [], whilewhere: { amount: { $eq: { v: 1 } } }answersINVALID_FILTER/ 400 ("Filter comparand at where.amount.$eq is a plain object"). In-process,{ total: undefined }and$eq: new Map()also answer[].having: [['total', '>', 100]]answers200 [], whilewhere: [['amount', '>', 100]]is lowered and answersc1,c2.applyHavingwalks the array's index keys as column names.having's own refusals depend on the data.having: { total: { $median: 1 } }answersINVALID_FILTER/ 400 on a populated grouped set and200 []on an empty one;{ customer_id: { $icontains: '' } }behaves the same way.applyHavingevaluates per aggregated row, so an empty set never reaches the refusal.Suggested shape (⛔ not a ruling)
Give
havingthewherepipeline's other doors at the same engine entry PR #20097 uses, before any door and the driver:$fieldresolution against the aggregated row, or a refusal where it cannot resolve. The refusal must never be a silent[].Then extend the where/having parity table from PR #20097 to the new arms.
Filing-gate answers
havinganswers an array in the equality slot by JS coercion (having: { total: [5] }is true) — the one face ruling 乙 closes "for every driver at once" that no gate reaches #19974 dev.domain:engine, the owner ofpackages/objectql), after PR fix(objectql)!: routehavingthrough the shared comparand-shape face —having: { total: [5] }is refused like the same shape inwhere#20097 (objectqlhavinganswers an array in the equality slot by JS coercion (having: { total: [5] }is true) — the one face ruling 乙 closes "for every driver at once" that no gate reaches #19974) lands on the same entry (Blocked-by: #19974).closedincluded:having field reference not resolved comparand type door FilterArray having empty set refusal applyHaving→ 10 hits (all read): 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, driver-sql: eight more filter-compile refusal doors ignore the #8220 provenance mark, so a policy read scope refused there relays its field or comparand in the 400; close the class so every compile refusal goes through the withheld seam #20039, 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, objectqlhavinghas no$icontainscomparand-shape gate — an empty comparand matches EVERY row (2 of 5FILTER_TEXT_CASESrejection rows unenrollable) #7158, The seven converged rule-arrayfilterdoors refuse the record form with a bareinvalid_type— this package's own guidance convention is not applied at any of them #17320, [Decision] refuse a text operator ($containsfamily) over a field whose DECLARED type is not textual —INVALID_FILTER400 at the engine's field-aware door (option C of #14079); the textual-type vocabulary is the question #15661, 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, Data query: an unknown field insidewhere/$filteranswers 200/0 instead of400 INVALID_FIELD— the bare-key door disagrees (#4134's uncovered sibling) #7534, objectqlhaving-filter.ts是「无值字段」语义的第五个求值面,且带着 #5299 同款的早退守卫($nin/$notContains不在豁免名单) #5905 and A filter array that isn't a valid AST reaches the driver as an opaquewhere— reject it at the protocol instead #4121.havinghas no$icontainscomparand-shape gate — an empty comparand matches EVERY row (2 of 5FILTER_TEXT_CASESrejection rows unenrollable) #7158 (closed) is an earlier single-operatorhavinggate, and objectqlhaving-filter.ts是「无值字段」语义的第五个求值面,且带着 #5299 同款的早退守卫($nin/$notContains不在豁免名单) #5905 (closed) ishaving's no-value semantics.whereskips 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 the analytics face's own doors.havingrows.havinghas no$icontainscomparand-shape gate — an empty comparand matches EVERY row (2 of 5FILTER_TEXT_CASESrejection rows unenrollable) #7158, the knownhavingcard.Dedupe words:
having $field reference·having comparand type door·having FilterArray·applyHaving empty set refusalGenerated by Claude Code