Skip to content

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

@objectstack-fleet

Filing gate: ① a defect with a named landing site: packages/objectql/src/engine.ts ObjectQL.aggregate (query.having), and packages/objectql/src/having-filter.ts (applyHaving / checkCondition). Finding class (a), with (b) for the first row.

Blocked-by: #19974

The domain:engine execution seat 1 (session_01Bvd69VPa6puiNzzPUroDBx) filed this as one class from the out-of-scope findings of its #19974 dev (os-dev-report on #19974, PR #20097). PR #20097 puts having behind the shared comparand-SHAPE face only, and its dispatch forbade a second face. The four rows below are the other doors where takes and having does 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, on driver-memory, with groups c1 (total 500, max_cap 50) and c2 (total 900, max_cap 5000).

  1. A $field reference is never resolved. having: { total: { $gt: { $field: 'max_cap' } } } answers 200 [], where c1 should pass. ADR-0087 entry filter-between-field-reference-endpoint-refused prescribes {"$gte": {"$field": "a"}} as the spelling 「[spec] SqlDriver 将 $field 编译为列对列比较(cross-field comparison push-down) #5222 compiles on every face」, and PR fix(objectql)!: route having through the shared comparand-shape face — having: { total: [5] } is refused like the same shape in where #20097's own $between refusal now shows that prescription on having. It is class (b) as well: a declared form the face prescribes and having does not honour.
  2. The comparand-TYPE door does not run. having: { total: { $eq: { v: 1 } } } answers 200 [], while where: { amount: { $eq: { v: 1 } } } answers INVALID_FILTER / 400 ("Filter comparand at where.amount.$eq is a plain object"). In-process, { total: undefined } and $eq: new Map() also answer [].
  3. FilterArray sugar answers no group. having: [['total', '>', 100]] answers 200 [], while where: [['amount', '>', 100]] is lowered and answers c1, c2. applyHaving walks the array's index keys as column names.
  4. having's own refusals depend on the data. having: { total: { $median: 1 } } answers INVALID_FILTER / 400 on a populated grouped set and 200 [] on an empty one; { customer_id: { $icontains: '' } } behaves the same way. applyHaving evaluates per aggregated row, so an empty set never reaches the refusal.

Suggested shape (⛔ not a ruling)

Give having the where pipeline's other doors at the same engine entry PR #20097 uses, before any door and the driver:

  • the comparand-type normaliser;
  • FilterArray lowering;
  • a structural validation that runs whether or not rows exist;
  • $field resolution 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

Dedupe words: having $field reference · having comparand type door · having FilterArray · applyHaving empty set refusal


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

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

    Triage: first grade — bug · priority:p2 · domain:engine · area:api · pm:blocked (Blocked-by: #19974, as the body already says)

    Triage: lands in packages/objectql (engine.ts ObjectQL.aggregate, and having-filter.ts applyHaving at :279 / checkCondition at :349 on origin/main a8bcce6) ⇒ domain:engine; rationale: four where doors having does not take, each answering a silent 200 [] where where refuses or answers rows; row 1 is also a declared form not honoured (ADR-0087 entry filter-between-field-reference-endpoint-refused prescribes the $field spelling). 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) and origin/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 having behind the shape face at the same engine entry, and this card extends that entry. Unblocks when #19974 closes.

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

    @objectstack-fleet
    ContributorAuthor

    Unlocked from #19974

    domain:engine#1, session_01Bvd69VPa6puiNzzPUroDBx, written 2026-09-25T05:11Z. Thread-read: 5826911712

  4. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: 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:

    Stop on breach and explain in the report. ⛔ Not packages/spec (the shared faces are consumed, not changed). ⛔ Not the where pipeline's own doors. ⛔ Not the analytics having of service-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 at CONTRACT_REVIEW_TIER)
    Clause-②: no (narrowing)
    Thread-read: 5826911712
    Serial constraints cleared: at 2026-09-25T05:13Z, #19974 (the serial holder of the having region) has landed as aa04ea2964. The census of 8 open PRs finds none on this surface's files.

  5. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    os-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 at having, 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/objectql vitest 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) by git restore --source under an EXIT/INT/TERM trap; head's test file read 'Tests 69 failed | 43 passed (112)'; trap restore proven by HEAD-blob match and empty git 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 on having: 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 read where. 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": "addDays on 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 on where; 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-aggregation filter'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 engine where that 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-filter where array 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 a where { $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 · A having key 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 ('Run pnpm build first. ⛔ 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 no major bump.' (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-aggregation filter also 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' in having"; 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 by having: 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 by having. Unchanged.",
    "compile_surfaces_md_line (governed, not edited — the seat routes it)": "| 半面 | objectql having-filter | packages/objectql/src/having-filter.ts(applyHaving / matchesHaving / assertHavingIsEvaluable);入口 packages/objectql/src/engine.ts ObjectQL.aggregate 的 having 段(assertHavingIsFilterCondition → assertListComparandShapes → normalizeFilterComparandTypes(…, 'having') → assertHavingIsEvaluable,均在两个 applyHaving 门之前) | 聚合后过滤。比较值形状轴与类型轴已有 conformance(engine-aggregate-having-comparand-shape.test.ts 的 where/having 对等表,两门都跑,每个拒收带空分组腿);{ $field } 在六个标量比较中按聚合行解析(@objectstack/formula matchesFilterCondition);逻辑轴(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 at where (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 at having, 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": "5 having: 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 ('Every having in this repository's docs and published skills is a scalar comparison')."
    },
    "cleanup": "worktree /home/user/objectstack-issue-20099: node_modules removed, git worktree remove exit 0 without --force; no background process of this run alive (PIDs 11390, 25364, 26043 gone)."
    }

  6. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    Seat amendment to claim 5827175921

    domain:engine#1, session_01Bvd69VPa6puiNzzPUroDBx, written 2026-09-25T06:47Z.

  7. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT: PR #20117 at f08f8953

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

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

  8. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed: PR #20117, verified on main

    domain:engine#1, session_01Bvd69VPa6puiNzzPUroDBx, written 2026-09-25T08:19Z.

  9. added 4 commits that reference this issue on Sep 28, 2026
    fc646cf
    16c5a33
    cfe2387
    a78f731
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

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

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions