Skip to content

fix(objectql)!: route having through the shared comparand-shape face — having: { total: [5] } is refused like the same shape in where - #20097

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-19974-having-array-equality
Sep 25, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-19974-having-array-equality

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #19974
Clause-②: no (narrowing)

This routes the having clause of engine.aggregate through the shared comparand-shape face. It executes ruling 乙 (record 5793368540 on #19757, 「217 同意」) at the one position that ruling's face did not reach yet. The triage execution point, verbatim: 「钉子:having: { total: [5] } 和 having: { total: { $eq: [5] } } 被拒绝;标量照常通过。」

Session session_01Bvd69VPa6puiNzzPUroDBx, branch claude/issue-19974-having-array-equality. Base 9b8c74c6c5, head 5649560428. Every reading below was taken on one of those two commits, as stated.

1. Measured first, on the base 9b8c74c6c5

Every filter was run through the public engine.aggregate twice, once as a where and once as a having, with the same bytes each time. Two drivers were used: driver-memory and driver-sqlite-wasm. Both have a native aggregate(). Each having was run on both applyHaving doors. The native door is the driver.aggregate() path. The in-memory fallback door was forced by adding a per-aggregation filter. The data was grouped by customer: c1 has total 500 over 2 rows, c2 has 1250 over 3, and c3 has 20 over 1.

Both drivers and both doors gave the same answer in every row. Every where was refused with INVALID_FILTER / 400 and the aggregate('order'): prefix:

arm of the face the shape, as a having having answered
equality-slot array (the triage shape) { total: [500] } c1, because JS 500 == [500] is true
equality-slot empty array { total: [] } no group
$eq array (the triage shape) { total: { $eq: [500] } } c1
equality-slot array under $or / $and { $or: [{ order_count: 99 }, { total: [500] }] } c1
equality-slot array under $not { $not: { total: [500] } } c2, c3
scalar $in / $nin { customer_id: { $in: 'c1' } } $in: no group. $nin: every group
null list member { customer_id: { $in: ['c1', null] } } $in: c1. $nin: c2, c3
null ordering comparand { total: { $gt: null } } $gt / $gte: every group. $lt / $lte: none
malformed $between { total: { $between: 500 } }, [500] the scalar kept every group; the one-bound list kept c1, c2
null / blank $between bound [null, 1000], ['', 1000], [undefined, 1000] c1, c3
{ $field } $between bound [{ $field: 'order_count' }, 1000] c1, c3

That is 20 rows, counting each operator spelling, and all 20 were refused on where and answered on having. Six controls gave the same rows on the base and at head: a scalar, $eq with a scalar, an $in list, a two-bound $between, $eq: null, and $ne with an array. The $ne row is not an arm of the face, see §4.

  • H1 confirmed. having-filter.ts sends an array into its implicit-equality arm (value == condition), and its $eq arm compares with !=.
  • H3 confirmed, and wider than the equality slot. The face refused 20 shapes on where, and having refused none of them.

2. What changed

  • packages/objectql/src/engine.ts, ObjectQL.aggregate. One call was added: assertListComparandShapes(object, 'aggregate', query.having, 'having').
    • It sits after the per-aggregation filter loop and before getDriver, the middleware chain, and either door.
    • It is the same objectql wrapper that where (inside lowerWhereFilterArray) and aggregations[i].filter already call on this verb, and it delegates to @objectstack/spec/data's assertListComparandShapes.
    • ⛔ No second face was added, and having-filter.ts is untouched.
  • packages/objectql/src/engine-aggregate-having-comparand-shape.test.ts, new, 37 tests.
  • .changeset/19974-having-comparand-shape-face.md.

3. The doors that reach applyHaving (H2)

There are exactly two call sites in the repository. Both are inside ObjectQL.aggregate in engine.ts:

  • the native door: return applyHaving(aggregated, ast.having) after drv.aggregate(...);
  • the in-memory fallback door: return applyHaving(applyInMemoryAggregation(raw, ast, tz), ast.having).

The PM's "earlier fork" near the first door is having: query.having on opCtx.ast. That line puts the clause where plugin-security's predicate guard can walk its field references. It evaluates nothing, and nothing writes ast.having afterwards (git grep finds no .having = assignment in any package source).

applyHaving and matchesHaving are not exported from @objectstack/objectql's . or ./core entry, and no driver reads having. The REST aggregate query forwards having to engine.aggregate, in metadata-protocol's protocol.ts.

So one gate ahead of both doors covers every door. It also judges the FILTER rather than the rows. applyHaving evaluates per aggregated row, so a gate inside the walker would stay silent on an empty grouped set. A pin covers that case.

4. The refused set, where against having (H3)

where having
before (9b8c74c6c5) 20 of 20 refused 0 of 20 refused
after (5649560428) 20 of 20 refused 20 of 20 refused, with the face's own words; the only difference from where is having. in place of where. in the path

The test's parity table asserts both halves on every row, on both doors:

  • havingErr.message === whereErr.message.replaceAll('where.', 'having.');
  • whereErr.message equals the face's own refusal, so the row cannot be refused by some other gate;
  • neither door asked the driver for a row.

$ne with an array is not an arm of the face. The ruling left it out, and #19886, which is ruled and dispatched, carries it. On where it is refused one layer down, by the drivers themselves: driver-memory gives the refusal without the aggregate('order'): prefix, and driver-sqlite-wasm withholds the detail. On having it still answers c2, c3 by == coercion. A test holds having to whatever the face answers for it. When the $ne arm lands on the face, having refuses it through this same call, and that pin stays green in both states.

5. Changeset (H4)

@objectstack/objectql: minor with **BREAKING**, fix(objectql)!:, Clause-②: no (narrowing) and one ADR-0087 marker: not-required (already-registered filter-equality-array-comparand-refused, filter-between-blank-endpoint-refused, filter-between-field-reference-endpoint-refused). The gates' own lines:

  • check-adr-0087-registration: ✓ ... 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition. and .changeset/19974-having-comparand-shape-face.md [BREAKING+bang+clause-②-narrowing] not-required (already-registered)
  • check-changeset-no-major: ✓ This diff introduces no major bump. Its clause-② level axis prints NOT APPLICABLE locally, because there is no pull_request payload; CI reads it from this body.

6. Tests, ablation, gates

All counts below are on head 5649560428, except the objectql local project, which ran on 0052ce29c1. engine.ts has the same blob (7830b0cee3) at both commits, and the only later change is the new test file, which was re-run at head.

  • @objectstack/objectql, vitest run --project local: 313 files, 5320 passed. --project repo: 1 file, 5 passed.
  • @objectstack/objectql typecheck: exit 0. check:test-typecheck is OK with 40 files and 234 errors held, unchanged. tsc -p tsconfig.test.json --listFiles includes the new test file (1 hit, 314 test files in the program).
  • New file alone: 37 passed. It sits beside the existing engine-aggregate-having.test.ts (5 passed).
  • Consumer suites that assert having behaviour, found by grep:
    • metadata-protocol protocol.query-param-arity.test.ts: 46 passed;
    • plugin-security predicate-guard.test.ts: 10 passed;
    • rest list-view-grouping-query-door.test.ts: 33 passed, with the objectql dist rebuilt and rest's dependency closure built.
  • The real-driver measurement of §1, repeated at head: every one of the 20 rows is refused on having on both drivers and both doors, and the six controls give byte-identical output to the base.

Ablation. The one gate line was replaced through scripts/ablation-replace.mjs:

  • the anchor hit once, and the blob moved 7830b0cee3 → 380a8f6fd9;
  • grep -c read the anchor 1 → 0 and the marker 0 → 1;
  • an EXIT/INT/TERM trap restored the file with git checkout HEAD --, proven by the HEAD blob-hash match and an empty git diff HEAD.

The test imports ./engine.js from source, so no dist/ is on the resolution path and there was nothing to preflight. 24 red, 18 green, as expected:

  • red: all 20 parity rows, the 3 shared-table door-refusal rows, and the empty-set pin;
  • green: the 10 pass-through controls, the $ne pin that follows the face, the 2 partition checks, and the 5 existing having tests.

Gates. dispatch-gates --commands --repo objectstack-ai/objectstack, re-derived at head, printed 64 families. --ran reconciled them: 64 derived, 62 run with exit 0, 2 NOT MEASURED, 0 UNRUN.

  • NOT MEASURED: check:dual-build-cjs-loads and check:type-check-debt, both exit 3 PREREQUISITE NOT MET. Both need the whole workspace built; CI runs them.
  • check:query-options-erasure went red once on an earlier head, at 236 → 243 test sites, from as any casts in the new test. The options are typed now, and the off-contract bags are cast through unknown to EngineAggregateOptions. It holds at 236.
  • node scripts/check-issue-citations.mjs --base 9b8c74c6c5: ✅ every citation this change adds resolves.

Lint, a declared narrowing (pnpm lint is CI's). eslint --no-inline-config --format json was run over the two changed .ts files:

  • population: eslint --print-config returns a config for each file, so neither is ignored;
  • count: 2 files, 0 errors, 0 warnings;
  • invariance: eslint.config.mjs sets no parserOptions.project and no typed rule, so no rule is type-aware, and this diff cannot move a verdict on any untouched file.

7. Compile surfaces, face by face

This card is the having half-face.

face conclusion
1 driver-sql (and driver-sqlite-wasm, local driver-turso) not reached by having: no driver reads the clause, and the engine evaluates it after aggregation. Unchanged
2 turso RemoteTransport not reached by having. Unchanged
3 read-scope-sql not reached by having. Unchanged
4 analytics filter-normalizer not reached by having; analytics sends no having to the engine. Unchanged
5 formula not reached by having. Unchanged
half-face objectql having-filter changed: behind the shared comparand-shape face on both applyHaving doors, with a where/having parity table and a leg driven from FILTER_COMPARAND_TYPE_CASES
driver-memory / driver-mongodb not reached by having. Unchanged

compile-surfaces.md is governed (.claude/**), so it is not edited here. The row it should gain is in the report on #19974, and the seat routes it.

Acceptance notes

Observed and not fixed here. The report carries each one.

Four of them are reproducible defects, handed to the seat to file. All four were measured at head on driver-memory:

  1. having resolves no { $field } reference.
    • Example: having: { total: { $gt: { $field: 'max_cap' } } } keeps no group, where c1 (500 against 50) should pass.
    • The face's { $field } $between refusal, which having now shows, suggests the two-bound { $field } spelling. having answers that spelling wrong, silently.
    • The changeset says so instead of prescribing it.
  2. The comparand-TYPE door is not run on having. having: { total: { $eq: { v: 1 } } } answers 200 with no group, where the same shape in where is INVALID_FILTER / 400.
  3. The FilterArray sugar in having answers no group. having: [['total', '>', 100]] gives 200 with no group, where where lowers the same sugar and answers c1, c2.
  4. having's own walker refusals depend on the data. having: { total: { $median: 1 } } and an empty $icontains are refused on a populated grouped set, and answer 200 with no group on an empty one.

Noted only, not filed:


Generated by Claude Code

engine.aggregate() now calls assertListComparandShapes on query.having,
seeded at the `having` path, before either applyHaving door is chosen.
The having walker answered every shape the face refuses for `where`
(an equality-slot array by JS `==` coercion, a scalar $nin, a malformed
$between, a null ordering bound); it now gets the face's own
INVALID_FILTER / 400 refusal, word for word, path aside.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 25, 2026
@github-actions

github-actions Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 1 changed package(s)), so this run has no opinion about the docs.

What this run could not see

Coarse fallback — 17 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json a8bcce69c83141b83b2d10724b428cd82f8aabd0 → packageMentionDocs.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 5649560428dd67bb919b5e0b5775d3f2994ce79f

Scope: 3 files (+400/−0) on merge base 9b8c74c6c5: engine.ts (+24), the new pin engine-aggregate-having-comparand-shape.test.ts (+346), and the changeset (+30). packages/spec is untouched, and having-filter.ts is byte-identical at base and head. No governed surface is touched. Review is owed for the .changeset prose.

Read: card #19974 and its comments, ruling 乙 on #19757 (5793368540), PR #19882 and its record 5808368753, the PR body, the diff, all 34 check-runs, AGENTS.md, contract-review.md, compile-surfaces.md, execution-duties.md, and the code at head.

main is 13 commits past the base, none touching this PR's files or the face files, and merge-tree is clean. Measured through the public engine.aggregate on a real InMemoryDriver and SqliteWasmDriver, on both applyHaving doors (native driver.aggregate(), and the fallback forced by a per-aggregation filter), each shape run as where and as having, 364 cells per tree. Base was measured with engine.ts at the base blob 277a986d82, and head with engine.ts restored by blob to 7830b0cee3, with a clean git status.

① Derived judgments

  • Refused-set parity: RIGHT. 25 face shapes (the PR's 20 rows plus 5 more spellings of the same arms) × 2 drivers × 2 doors = 100 having cells.
    • Base: 0/100 refused. For example, { total: [500] } kept c1, $nin: 'c1' kept every group, and $lt: null kept none.
    • Head: 100/100 refused. The same shapes as where are 100/100 refused at base and at head.
    • At head, every having message equals the where message with where. replaced by having., with the same code and status: 0 mismatches.
    • The 20 rows reach every arm and operator spelling of the face (filter-comparand-shape.ts, nine refusal arms), so the table is the face's full refused set.
  • Nothing outside the face started refusing: RIGHT. 41 non-face controls × 4 = 164 cells, 0 changed from base to head. They include scalars, $eq: null, $ne scalar and array, $in / $nin lists including [], a two-bound $between, { $field } whole comparands, $exists, text operators, combinators and FilterArray sugar. The refused control cells are the walker's own pre-existing refusals and are byte-identical at base.
  • The gate runs once, ahead of the driver, the middleware chain and both doors: RIGHT.
    • assertListComparandShapes( has 2 sites at base and 3 at head, the new one on query.having. It precedes getDriver, the opCtx build and executeWithMiddleware.
    • applyHaving has exactly 2 call sites, the native door and the fallback.
    • The driver call count on every having refusal at head is 0 (aggregate and find), 100/100.
  • The error: RIGHT.
    • On every face refusal the error's own keys are exactly code,status (INVALID_FILTER / 400).
    • No policy disclosure is possible: the gate judges the caller's own having before the middleware chain, no source assigns .having =, and plugin-security only reads ast.having.
  • The pins: RIGHT.
    • At head the new file passes 37/37 and engine-aggregate-having.test.ts 5/5.
    • With engine.ts at the base blob: 24 failed / 18 passed. The red set is exactly the 20 parity rows, the 3 comparand-type shape rows and the empty-grouped-set pin.
    • Every refusal asserts code and status.
  • Compile surfaces, face by face: declared correctly. The objectql having-filter half-face is CHANGED, behind the shared face on both doors. driver-sql (with driver-sqlite-wasm and local driver-turso), the turso RemoteTransport, read-scope-sql, filter-normalizer, formula, driver-memory and driver-mongodb carry no having token in non-test source and are unchanged. The PR body's §7 table matches.
  • CI at this head: 34 runs, 31 success, 3 skipped, 0 failures, and all seven required contexts are success, Check Changeset included.

② Semver level

@objectstack/objectql: minor, fix(objectql)!:, one **BREAKING** banner, Clause-②: no (narrowing): CORRECT.

  • An accept-set narrowing is BREAKING and ships minor in the launch window.
  • The body carries the FROM → TO table a breaking changeset owes.
  • ADR-0087 disposition not-required (already-registered …): all three ids resolve and predate the base. Each registers a data.FilterCondition transition at the shared face with the same FROM shape and prescription, and having is typed FilterConditionSchema, so no FROM → TO the ledger lacks is introduced.

③ Boundary flags

  • Changeset: TRUE, except two table cells.
    • The last row's cell, "a { $field } reference in any having slot is compared as a value and keeps no group", is FALSE. It holds for the operator slots ($eq, ordering), but in the implicit slot { total: { $field: 'order_count' } } is REFUSED by the walker as an unsupported operator (INVALID_FILTER / 400).
    • The first row's "kept the 500 group … at any depth under $and / $or / $not" is inexact: under $not it kept the complement (c2, c3).
    • "Callers of engine.aggregate … in a deployment were NOT measured" is declared, and consistent.
  • PR body: TRUE where measured. The §4 sentence on $ne with an array on where, the full-suite counts and the gate-family counts are UNMEASURED here (CI carries them).
  • The dev's four out-of-scope defects are REAL (three reproduced here). They are already filed as one class, 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.
  • Stale published prose, outside this claim: the ADR-0087 entry 18.filter-equality-array-comparand-refused.ts reason lists the doors that refuse at this release without having. That is dated wording, not false. A spec docs-only carrier is the fix.
  • compile-surfaces.md (governed) is correctly not edited. The dev's proposed row checks out against the head line numbers, and the seat routes it.
  • Pre-existing, not this PR's: face refusals carry code and status only, while objectql's own walker refusals also carry httpStatus.

Implemented-by: claude/issue-19974-having-array-equality
Reviewed-by: session_01Bvd69VPa6puiNzzPUroDBx

VERDICT: PASS

…eld } reference, slot by slot

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 1a47a60ba263b4d629adc13b69f4fadd84568dd8

Scope: the delta over PASS 5826694842 (at 5649560428), after the seat's patch round on two changeset table cells.

Measured through the public engine.aggregate on a real InMemoryDriver and SqliteWasmDriver, on both applyHaving doors (native and fallback, each confirmed by driver call counters), with groups totalling 500, 1250 and 20. That is 20 shapes × 2 drivers × 2 doors = 80 cells per tree. Base was measured with engine.ts at blob 277a986d82, then restored by blob to 7830b0cee3 with a clean git status.

① Derived judgments

  • The delta is one commit and one file: RIGHT. 5649560428..1a47a60ba2 is .changeset/19974-having-comparand-shape-face.md only, +2/−2, the two cells. engine.ts and having-filter.ts are byte-identical to the reviewed head. The <!-- adr-0087: … --> marker is byte-identical.
  • Row 1, every sentence TRUE at base:
    • { total: [500] } and { total: { $eq: [500] } }, also under $and and $or, kept c1 (the 500 group) in 16/16 cells;
    • under $not, at depth 1 and 2, they kept the complement c2, c3 (the 1250 and 20 groups) in 16/16 cells.
    • At head all eight shapes are refused INVALID_FILTER / 400 with 0 driver calls (32/32).
  • Row 8, every sentence TRUE at head:
    • under $eq, $gt, $gte, $lt or $lte, a { $field } reference keeps no group (20/20);
    • under $ne it keeps every group (4/4);
    • in the implicit slot it is refused as an unsupported operator, INVALID_FILTER / 400, on a populated set (4/4);
    • on a missing column, or on an empty grouped set, it comes back [] (4/4 each). The value === undefined exit in having-filter.ts sits before the operator switch whose default: refuses;
    • "That gap is not changed here": all 36 { $field } cells are identical at base and head.
  • The face text the cell quotes is real: fieldReferenceRangeBoundError prescribes the two-bound { $field } spelling, and having answers both bounds [].
  • CI at this head: 34 runs, 31 success, 3 skipped, 0 failures, 0 pending. All seven required contexts are success, and Check Changeset is success.

The judgments of PASS 5826694842 stand for everything outside this delta.

② Semver level

The delta moves no frontmatter, banner, Clause-② line or ADR-0087 disposition. minor, fix(objectql)!:, BREAKING and Clause-②: no (narrowing) stand, CORRECT as recorded.

③ Boundary flags

Implemented-by: claude/issue-19974-having-array-equality
Reviewed-by: session_01Bvd69VPa6puiNzzPUroDBx

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 25, 2026 04:52
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 25, 2026
Merged via the queue into main with commit aa04ea2 Sep 25, 2026
35 of 36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-19974-having-array-equality branch September 25, 2026 05:09
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…he comparand-type door, row-independent refusals, a resolved `{ $field }`, and a refused non-condition (objectstack-ai#20117)

Fixes objectstack-ai#20099
Clause-②: no (narrowing)

This gives the `having` clause of `engine.aggregate` the rest of the
filter doors `where` takes, at the entry PR objectstack-ai#20097 added. It follows the
dispatch's binding frame: ruling 乙 on objectstack-ai#19757 (record 5793368540), the
seat default 协议为基准, and the ADR-0087 entry
`filter-between-field-reference-endpoint-refused`, whose prescription
「objectstack-ai#5222 compiles on every face」 `having` now honours.

Session `session_01Bvd69VPa6puiNzzPUroDBx`, branch
`claude/issue-20099-having-where-doors`. Base `aa04ea2964`, head
`98abdf4ee3`. Every reading below was taken on one of those two commits,
as stated. Heads `01f091f523` and `f08f8953fc` correct changeset cells
and change no code or test: `engine.ts`, `having-filter.ts` and the test
file are byte-identical to `98abdf4ee3`.

## 1. Measured first (H1)

Each shape was run through the public `engine.aggregate` on a real
`InMemoryDriver` and a real `SqliteWasmDriver`. Each `having` ran on
both `applyHaving` doors: the native `driver.aggregate()` door, and the
fallback door forced by a per-aggregation `filter`. Each ran on a
populated and on an empty grouped set. That is 8 cells per shape and
version. Each shape's `where` twin, over the raw columns, was the
control. Groups: c1 (total 500, max_cap 50), c2 (total 900, max_cap
5000), c3 (total 20, max_cap 20).

**H1 holds: all four rows still held at base `aa04ea2964`.** In every
row, all 8 cells answered alike on both drivers and both doors, except
where the table below says otherwise.

| row | shape (as a `having`) | `where` twin | `having` at base |
`having` at head |
|:--|:--|:--|:--|:--|
| 1 | `{ total: { $gt: { $field: 'max_cap' } } }` | sqlite: resolved
rows; memory: none | no group | c1 |
| 1 | `$gte` / `$lt` / `$lte` / `$eq` against `{ $field: 'max_cap' }` |
sqlite: resolved | no group | c1,c3 / c2 / c2,c3 / c3 |
| 1 | `$ne` against `{ $field: 'max_cap' }` | sqlite: resolved | every
group | c1, c2 |
| 1 | the two-bound spelling `{ total: { $gte: { $field: 'max_cap' },
$lte: 1000 } }` | sqlite: resolved | no group | c1, c3 |
| 1 | a bare `{ total: { $field: 'max_cap' } }` | 400, both drivers |
400 populated, **200 [] empty** | 400 on every cell, the operator
written in |
| 1 | a reference as an `$in` / `$nin` member, a `$contains` /
`$notContains` pattern, an `$exists` / `$null` operand | sqlite: 400 |
no group, or every group | 400 on every cell |
| 1 | a reference naming no column, e.g. `{ $field: 'nope' }` | sqlite:
400 | per operator (the reference object itself was compared): no group
under `$eq`, every group under `$ne`; under an ordering operator, none
against a number column, and against a text or date column an answer
that follows each value's string order against the text `[object
Object]` | 400 on every cell, listing the columns |
| 2 | `{ total: { $eq: { v: 1 } } }`, `{ total: undefined }`, `$eq: new
Map()`, a function, an undefined `$in` member, a bigint beyond 2^53, `{
$field: 5 }` | 400, the comparand-type door | per operator, on the
numeric `total`: no group in the implicit slot (the non-object values)
and under `$eq` / `$gt` / `$gte` / `$in`; every group under `$ne` /
`$nin`; under `$lt` / `$lte` no group, except the bigint, which kept
every group | 400, the same door's words rooted at `having` |
| 2 | `{ total: { $in: [500n, 20n] } }` | narrowed, the rows | no group
| c1, c3 |
| 3 | `[['total', '>', 100]]`, `['total', '>', 100]`, `['and', …]` |
lowered, the rows | no group | 400 on every cell |
| 3 | `[]`, `'total > 100'`, `100`, a `Map` | `[]`: no filter; scalars:
every row (see Acceptance notes) | every group | 400 on every cell |
| 4 | `{ total: { $median: 1 } }`, `$nand`, `$regex`, an empty or
non-string `$icontains` | 400 | 400 populated, **200 [] empty** | 400 on
every cell, the walker's own words |
| 4 | `{ nope: { $median: 1 } }` (a column the row lacks) | 400 | **200
[] on both** | 400 on every cell |
| 4 | `{ $or: [{ total: { $gt: 0 } }, { total: { $median: 1 } }] }` |
400 | **every group** on a populated set, [] on an empty one | 400 on
every cell |

54 shapes were measured, 432 `having` cells per version. At base, 28
refusal cells were the walker's, raised after the driver had been asked
for rows (`aggregate 1 / find 0` or `0 / 1`), and 8 were PR objectstack-ai#20097's
face. At head, all 272 `having` refusal cells are raised before any
driver call: `aggregate 0 / find 0`.

The controls gave the same bytes at base and head, on every cell: a
scalar `$gt`, an implicit scalar, a key naming no column, an `$in` list,
`$ne: null`, a plain-object implicit value, `{}`, a `$ne` reference on a
missing column, a `Date` bound and the bigint `500n`. The re-measure of
base after the change matched the first base measurement byte for byte
on all 46 original shapes.

## 2. Where `where` gets each door (H2)

Located by symbol, in `ObjectQL.aggregate` → `lowerWhereFilterArray`
(engine.ts). The same function serves `find` and `count`.

- **FilterArray lowering.** The array branch calls `isFilterAST` →
`parseFilterAST(where, context)` from `@objectstack/spec/data`.
`parseFilterAST` runs the shape face and the type door internally with
the path fixed at `where`: it has no root argument. So it cannot lower a
`having` without printing `where.` in `having`'s refusals, and
`packages/spec` is not changed here. More decisive: the spec does not
declare the sugar on this slot (§3).
- **The comparand-type normaliser.** The object branch calls
`normalizeFilterComparandTypes(where, context)`. The function takes a
`path` argument, so it runs on `query.having` as it stands, rooted at
`having`, with no spec change. It needs no column set: it judges
comparand types, not names.
- **Structural validation.** Four engine doors run on `where`:
`assertListComparandShapes`, already on `having` since PR objectstack-ai#20097;
`assertFilterIsMaterializable`,
`assertTextOperatorTargetsAreStringCapable` and
`assertTemporalComparandsInterpretable`. The last three judge the
OBJECT's declared fields, a namespace `having` does not filter.
Unknown-operator refusals for `where` are raised by the drivers.
`having` never reaches a driver, so its structural check is its own
walker's, run once against the aggregated row's column set. That set is
read off the query: the groupBy projections, a structured item's `alias`
or `field`, and every aggregation alias.

## 3. What changed

- **`packages/objectql/src/engine.ts`, `ObjectQL.aggregate`, the
`having` entry only**, beside the shape-face call:
1. `assertHavingIsFilterCondition(query.having)`. `having` is null,
absent or a plain object; anything else is refused. `QuerySchema.having`
and `EngineAggregateOptions.having` both declare
`FilterConditionSchema`. Measured: both schemas refuse every array, `[]`
included. The FilterArray sugar is declared on the `where` slot alone
(`TransportFilterValueSchema`, 「`where` widens to the `FilterArray`
sugar here and ONLY here」). The wire door already refuses a `having`
array (`protocol.query-param-arity.test.ts`).
  2. The existing `assertListComparandShapes(…, 'having')`.
3. `normalizeFilterComparandTypes(query.having, "aggregate('order')",
'having')`. A narrowed bigint replaces the clause copy-on-write, and the
caller's object is not edited.
4. `assertHavingIsEvaluable(having, aggregatedRowColumns(query.groupBy,
query.aggregations))`.
- **`packages/objectql/src/having-filter.ts`:**
- `assertHavingIsEvaluable` walks the whole clause once, the `$and` /
`$or` / `$not` walk `matchesHaving` takes. It raises the walker's own
refusals through the same constructors (`unknownOperator`,
`icontainsComparandError`), in the order the per-row walk would meet
them. It adds four `{ $field }` refusals: a bare reference, a reference
outside the six scalar comparisons, a reference its own
`FieldReferenceSchema` refuses (a malformed `addDays`), and a reference
naming no column. The per-row throws stay as the floor for a caller that
evaluates rows directly.
- `checkCondition` resolves a `{ $field }` reference that is the whole
comparand of `$eq` / `$ne` / `$gt` / `$gte` / `$lt` / `$lte` against the
row. The comparison is `@objectstack/formula`'s
`matchesFilterCondition`, the in-memory evaluator the SQL cross-field
compiler is held to row for row, handed a three-column probe so a flat
column name is never read as a dotted path. It supplies the NULL
totality and the whole-day `addDays` arithmetic, which are not copied
here.
- **The test file** `engine-aggregate-having-comparand-shape.test.ts`
extends PR objectstack-ai#20097's where/having parity table (37 → 112 tests), both
doors, with an empty-grouped-set leg on every refusal.
- **`.changeset/20099-having-where-doors.md`.**

## 4. `$field` against the aggregated row (H3)

Resolved, not refused. Resolution honours the declared form,
`FieldReferenceSchema`, and the two-bound spelling the `$between`
refusal prescribes on `having`. It needs no data at judgement time:
position and name are checked against the query's own column set before
any row exists. A reference that cannot resolve is refused on an empty
set exactly as on a populated one. A reference that can resolve answers
`[]` on an empty set and rows on a populated one, like any filter. The
resolution runs the same function on both doors, and `applyHaving` is
the only evaluator on either.

## 5. Declaration (H4)

**The accept set only narrows.** Every shape accepted at head was
accepted at base, and no refusal at base is lifted. Row 2, row 3, row 4
and the four `{ $field }` refusals are narrowings. Rows 1 (resolution)
and 2 (bigint narrowing) change answers of inputs that were already
accepted: no group, or every group under `$ne`, becomes the rows the
filter names. That is a correction of answers, not a widening of what is
accepted, so `Clause-②: no (narrowing)` stands.

- `check-changeset-no-major --base aa04ea2`: `✓ This diff introduces
no major bump.` Its clause-② level axis reads NOT APPLICABLE locally (no
`pull_request` payload); CI reads it from this body.
- `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)`.
- The ids are `filter-between-field-reference-endpoint-refused` (the
prescription `having` now honours, and the list-member position it
refuses), `filter-icontains-comparand-refused-at-parse` and
`filter-regex-options-retired`. The marker's prose names the transitions
no entry covers, and why none is needed: `having` is a request-only key,
and no stored document exists for `objectstack migrate meta` to rewrite.

## 6. Tests, reverse verification, ablation, gates

Head `98abdf4ee3` unless stated. It differs from `ce22319645`, where the
typecheck ran, by the changeset only.

- `@objectstack/objectql` `vitest run --project local`: **314 files,
5417 passed**. `--project repo`: 1 file, 5 passed.
- `@objectstack/objectql` `typecheck`, at `ce22319645`: exit 0.
`check:test-typecheck` reads OK, 40 files / 234 errors held, unchanged.
- The extended parity file: **112 passed**.
- **Consumer census.** `engine.aggregate` takes `having` from ONE
non-test source caller, `metadata-protocol` `protocol.ts` (the REST
aggregate branch). Its suites were run with objectql's dist rebuilt
(turbo, 24 tasks, 18 cached; dist carries `assertHavingIsEvaluable`, 2
hits):
  - `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.
- **Docs and skills**: 5 `having:` occurrences in 3 files
(`skills/objectstack-query/rules/aggregation.md` ×2,
`content/docs/data-modeling/queries.mdx` ×2,
`content/docs/protocol/objectql/query-syntax.mdx` ×1). All 5 are scalar
comparisons against an alias, which answer exactly as before. The
control is PR objectstack-ai#20097's census, which reported the same.
- **Reverse verification.** `engine.ts` and `having-filter.ts` were put
back to their BASE blobs (`a9ec130693`, `2514be70bb`) with `git restore
--source`, under an EXIT/INT/TERM trap.
- Head's test file then read **69 failed, 43 passed (112)**. Every new
arm was red. Green were PR objectstack-ai#20097's rows, the no-clause controls and the
where-sugar control.
- The trap restored both files: HEAD blobs matched, and `git diff HEAD`
was empty.
- **Ablation**, one per new door, through
`scripts/ablation-replace.mjs`. In each, the anchor hit once, `x1 → x0`,
the blob moved, and the file was restored to its HEAD blob with `git
diff HEAD` empty. The tests import `./engine.js` from source, so no
`dist/` is on the resolution path.

  | ablated | failed / 112 | red set |
  |:--|:--|:--|
| `assertHavingIsFilterCondition(query.having)` | 9 | exactly the 9
not-a-condition rows |
| the type-door call (clause passed through as is) | 21 | the 10
type-door parity rows, the 10 `FILTER_COMPARAND_TYPE_CASES` type rows,
and the bigint narrowing |
| `assertHavingIsEvaluable(…)` | 25 | the 10 walker rows, the 14
reference refusals, and the column-list row |
| the resolution branch in `checkCondition` | 14 | the 12 resolution
rows, the NULL-semantics row, and the per-aggregation-filter row |

- **Gates.** `dispatch-gates --commands --repo
objectstack-ai/objectstack` was re-derived at head: 64 families. Each
was run and its exit code recorded, then reconciled with `--ran`: `✓ 64
derived famil(ies) accounted for — 62 run, 2 NOT-MEASURED (2 DERIVED
from a recorded exit 3)`.
- NOT MEASURED: `check:dual-build-cjs-loads` and
`check:type-check-debt`, both exit 3 PREREQUISITE NOT MET. They need the
whole workspace built, and CI runs them.
- Selected lines: `query-options-erasure ratchet holds: 67 unswept
non-test site(s)`; `check-nul-bytes: OK (scanned 9536 text file(s)…)`;
`check-engine-double-contract: OK`; `where-matcher conformance holds`;
`ObjectQL double limit conformance holds`; `doc authoring guard: 403
files clean`; `check-driver-memory-census: OK`.
- `node scripts/check-issue-citations.mjs --base aa04ea2`: `✅ every
citation this change adds resolves` (19 judged).
- **Lint, a declared narrowing** (`pnpm lint` is CI's). `eslint
--no-inline-config --format json` on the 3 changed `.ts` files:
  - count: 3 files, 0 errors, 0 warnings;
  - population: `--print-config` returns a config for each file;
- invariance: `eslint.config.mjs` sets no `parserOptions.project` and no
typed rule, so no untouched file's verdict can move.

## 7. Compile surfaces, face by face

| face | conclusion |
|:--|:--|
| 1 `driver-sql` (with `driver-sqlite-wasm`, local `driver-turso`) | not
reached by `having`: no driver reads the clause. Unchanged |
| 2 turso `RemoteTransport` | not reached. Unchanged |
| 3 `read-scope-sql` | not reached. Unchanged |
| 4 analytics `filter-normalizer` | not reached; analytics sends no
`having` to the engine, and the analytics `having` is outside this
claim. Unchanged |
| 5 `formula` | **consumed, not changed**: `matchesFilterCondition` now
also evaluates a `having` reference comparison. Its code is untouched |
| half-face objectql `having-filter` | **changed**: behind the
comparand-type door, the condition-object check and the row-independent
walker, on both `applyHaving` doors. `{ $field }` is resolved in the six
scalar comparisons. The where/having parity table now covers the type
door and the new arms |
| `driver-memory` / `driver-mongodb` | not reached by `having`.
Unchanged |

**The author's text is the wire text.** No source writes the clause. The
two greps, over non-test package sources:
- `git grep -nE "\.having\s*=[^=]"` finds no hit.
- `git grep -n having -- packages/plugins/plugin-security/src` finds one
reader, `predicate-guard.ts:70`, `collectConditionFields(ast.having,
out)`, which walks field names and writes nothing.

Every refusal added here runs before `executeWithMiddleware` in any
case. Every refusal the tests pin asserts `code` and `status`.

## 8. Deviations from the claim, declared

- **FilterArray on `having` is REFUSED, not lowered.** The claim's
surface says "FilterArray lowering", and H4 expected "lowered sugar".
The declared contract answered otherwise. `having` is
`FilterConditionSchema` on both `QuerySchema` and
`EngineAggregateOptions`, and both refuse an array. The sugar is
declared on `where` alone, and the protocol door already refuses a
`having` array. Lowering it would widen `having`'s contract, a protocol
change the frame rules out. It would also print `where.` in `having`'s
refusals, because `parseFilterAST` has no root argument. The report
carries this as an open question.
- **The per-aggregation `filter` resolves `{ $field }` too.** It shares
`checkCondition` with `having` (its module note: 「a predicate moved
between a driver `where`, a `having`, and a per-aggregation `filter`
must select rows by one rule」), and forking the walker by clause would
give one walker two rules. The bounded in-place exemption applies: the
same defect class, the same code path, no other claim on the file, and
the same gates. Measured on both real drivers, `count` with `filter: {
amount: { $gt: { $field: 'cap' } } }` went from c1 0, c2 0, c3 0 at base
to c1 2, c2 1, c3 0 at head. A test pins it. Its entry-level refusals
are NOT added: that loop is outside the claimed `having` entry. See
Acceptance notes.

## Acceptance notes

Observed and not fixed here. The report carries each one with its class
and evidence.

- **A per-aggregation `filter`'s walker refusals still depend on the
data.** `aggregations: [{ …, filter: { amount: { $median: 1 } } }]` is
`INVALID_FILTER` / 400 on a populated table and `200 []` on an empty
one, on both drivers. This is row 4's class, at the sibling position;
the fix is this PR's `assertHavingIsEvaluable` walk run per aggregation
filter, in the engine loop outside this claim.
- **An engine `where` that is a string, a number or a `Map` is
dropped.** `engine.find('order', { where: 'amount > 100' })` returns
every row on both drivers.
- **The engine's refusal of a non-filter `where` array carries no
envelope.** `where: [1, 2, 3]` throws with `code` and `status`
undefined, on both drivers.
- **`driver-memory` does not resolve a `where` `{ $field }` reference.**
`{ amount: { $gt: { $field: 'cap' } } }` answers `[]`, while
`driver-sqlite-wasm` answers the resolved rows.
- **A `having` key naming no column keeps no group, silently** (`{ totl:
{ $gt: 100 } }`). The engine knows the column set, so the same check as
the reference's could refuse it; it is not one of this card's rows.
- **`addDays` against a numeric aggregate follows the in-memory
evaluator**, which reads a number as epoch milliseconds: `{ total: {
$gt: { $field: 'max_cap', addDays: 1 } } }` keeps no group. SQL
push-down refuses that pair on `where`, and `driver-memory` does not
resolve it at all. The aggregated row carries no declared type to judge
the pair statically. The report records this as an open question.
- `$like` / `$ilike` are still refused on `having`. That is the
documented staging in `FILTER_OPERATORS`, carried by the follow-up on
objectstack-ai#7536, and not a new gap.
- The `having-filter.ts` header still calls HAVING 「the only face no
conformance table covers」. That remains true of the logic axis
(`FILTER_LOGIC_CASES`). The shape and type axes are now covered by the
parity table. The header is not edited, to keep the claim.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants