Repository navigation
fix(objectql)!: route having through the shared comparand-shape face — having: { total: [5] } is refused like the same shape in where - #20097
Conversation
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
… face Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx
… to any Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx
📓 Docs Drift CheckNothing 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): |
Contract reviewServed-tier: Scope: 3 files (+400/−0) on merge base 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,
① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
…eld } reference, slot by slot Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx
Contract reviewServed-tier: Scope: the delta over PASS 5826694842 (at Measured through the public ① Derived judgments
The judgments of PASS 5826694842 stand for everything outside this delta. ② Semver levelThe delta moves no frontmatter, banner, ③ Boundary flags
Implemented-by: VERDICT: PASS |
…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>
Fixes #19974
Clause-②: no (narrowing)
This routes the
havingclause ofengine.aggregatethrough 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, branchclaude/issue-19974-having-array-equality. Base9b8c74c6c5, head5649560428. Every reading below was taken on one of those two commits, as stated.1. Measured first, on the base
9b8c74c6c5Every filter was run through the public
engine.aggregatetwice, once as awhereand once as ahaving, with the same bytes each time. Two drivers were used:driver-memoryanddriver-sqlite-wasm. Both have a nativeaggregate(). Eachhavingwas run on bothapplyHavingdoors. The native door is thedriver.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
wherewas refused withINVALID_FILTER/ 400 and theaggregate('order'):prefix:havinghavinganswered{ total: [500] }500 == [500]is true{ total: [] }$eqarray (the triage shape){ total: { $eq: [500] } }$or/$and{ $or: [{ order_count: 99 }, { total: [500] }] }$not{ $not: { total: [500] } }$in/$nin{ customer_id: { $in: 'c1' } }$in: no group.$nin: every group{ customer_id: { $in: ['c1', null] } }$in: c1.$nin: c2, c3{ total: { $gt: null } }$gt/$gte: every group.$lt/$lte: none$between{ total: { $between: 500 } },[500]$betweenbound[null, 1000],['', 1000],[undefined, 1000]{ $field }$betweenbound[{ $field: 'order_count' }, 1000]That is 20 rows, counting each operator spelling, and all 20 were refused on
whereand answered onhaving. Six controls gave the same rows on the base and at head: a scalar,$eqwith a scalar, an$inlist, a two-bound$between,$eq: null, and$newith an array. The$nerow is not an arm of the face, see §4.having-filter.tssends an array into its implicit-equality arm (value == condition), and its$eqarm compares with!=.where, andhavingrefused none of them.2. What changed
packages/objectql/src/engine.ts,ObjectQL.aggregate. One call was added:assertListComparandShapes(object, 'aggregate', query.having, 'having').getDriver, the middleware chain, and either door.where(insidelowerWhereFilterArray) andaggregations[i].filteralready call on this verb, and it delegates to@objectstack/spec/data'sassertListComparandShapes.having-filter.tsis 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.aggregateinengine.ts:return applyHaving(aggregated, ast.having)afterdrv.aggregate(...);return applyHaving(applyInMemoryAggregation(raw, ast, tz), ast.having).The PM's "earlier fork" near the first door is
having: query.havingonopCtx.ast. That line puts the clause whereplugin-security's predicate guard can walk its field references. It evaluates nothing, and nothing writesast.havingafterwards (git grepfinds no.having =assignment in any package source).applyHavingandmatchesHavingare not exported from@objectstack/objectql's.or./coreentry, and no driver readshaving. The REST aggregate query forwardshavingtoengine.aggregate, inmetadata-protocol'sprotocol.ts.So one gate ahead of both doors covers every door. It also judges the FILTER rather than the rows.
applyHavingevaluates 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,
whereagainsthaving(H3)wherehaving9b8c74c6c5)5649560428)whereishaving.in place ofwhere.in the pathThe test's parity table asserts both halves on every row, on both doors:
havingErr.message === whereErr.message.replaceAll('where.', 'having.');whereErr.messageequals the face's own refusal, so the row cannot be refused by some other gate;$newith an array is not an arm of the face. The ruling left it out, and #19886, which is ruled and dispatched, carries it. Onwhereit is refused one layer down, by the drivers themselves:driver-memorygives the refusal without theaggregate('order'):prefix, anddriver-sqlite-wasmwithholds the detail. Onhavingit still answers c2, c3 by==coercion. A test holdshavingto whatever the face answers for it. When the$nearm lands on the face,havingrefuses it through this same call, and that pin stays green in both states.5. Changeset (H4)
@objectstack/objectql: minorwith**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 nopull_requestpayload; CI reads it from this body.6. Tests, ablation, gates
All counts below are on head
5649560428, except the objectql local project, which ran on0052ce29c1.engine.tshas 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-typecheckis OK with 40 files and 234 errors held, unchanged.tsc -p tsconfig.test.json --listFilesincludes the new test file (1 hit, 314 test files in the program).engine-aggregate-having.test.ts(5 passed).havingbehaviour, found by grep:metadata-protocolprotocol.query-param-arity.test.ts: 46 passed;plugin-securitypredicate-guard.test.ts: 10 passed;restlist-view-grouping-query-door.test.ts: 33 passed, with the objectql dist rebuilt andrest's dependency closure built.havingon 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:7830b0cee3→380a8f6fd9;grep -cread the anchor 1 → 0 and the marker 0 → 1;git checkout HEAD --, proven by the HEAD blob-hash match and an emptygit diff HEAD.The test imports
./engine.jsfrom source, so nodist/is on the resolution path and there was nothing to preflight. 24 red, 18 green, as expected:door-refusalrows, and the empty-set pin;$nepin that follows the face, the 2 partition checks, and the 5 existinghavingtests.Gates.
dispatch-gates --commands --repo objectstack-ai/objectstack, re-derived at head, printed 64 families.--ranreconciled them: 64 derived, 62 run with exit 0, 2 NOT MEASURED, 0 UNRUN.check:dual-build-cjs-loadsandcheck:type-check-debt, both exit 3 PREREQUISITE NOT MET. Both need the whole workspace built; CI runs them.check:query-options-erasurewent red once on an earlier head, at 236 → 243 test sites, fromas anycasts in the new test. The options are typed now, and the off-contract bags are cast throughunknowntoEngineAggregateOptions. It holds at 236.node scripts/check-issue-citations.mjs --base 9b8c74c6c5:✅ every citation this change adds resolves.Lint, a declared narrowing (
pnpm lintis CI's).eslint --no-inline-config --format jsonwas run over the two changed.tsfiles:eslint --print-configreturns a config for each file, so neither is ignored;eslint.config.mjssets noparserOptions.projectand 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
havinghalf-face.driver-sql(anddriver-sqlite-wasm, localdriver-turso)having: no driver reads the clause, and the engine evaluates it after aggregation. UnchangedRemoteTransporthaving. Unchangedread-scope-sqlhaving. Unchangedfilter-normalizerhaving; analytics sends nohavingto the engine. Unchangedformulahaving. Unchangedhaving-filterapplyHavingdoors, with a where/having parity table and a leg driven fromFILTER_COMPARAND_TYPE_CASESdriver-memory/driver-mongodbhaving. Unchangedcompile-surfaces.mdis 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:havingresolves no{ $field }reference.having: { total: { $gt: { $field: 'max_cap' } } }keeps no group, where c1 (500 against 50) should pass.{ $field }$betweenrefusal, whichhavingnow shows, suggests the two-bound{ $field }spelling.havinganswers that spelling wrong, silently.having.having: { total: { $eq: { v: 1 } } }answers 200 with no group, where the same shape inwhereisINVALID_FILTER/ 400.havinganswers no group.having: [['total', '>', 100]]gives 200 with no group, wherewherelowers the same sugar and answers c1, c2.having's own walker refusals depend on the data.having: { total: { $median: 1 } }and an empty$icontainsare refused on a populated grouped set, and answer 200 with no group on an empty one.Noted only, not filed:
$newith an array still answers onhaving. [finding]$newith an array comparand splits across backends: driver-sql and driver-memory refuse (400), driver-mongodb answers, formula matches every row — and both shared faces pass it #19886 remains open and carries it; see §4.filter-equality-array-comparand-refusedlists the doors that refuse the shape "at this release", andhavingis not among them. The entry is inpackages/spec, outside this claim. Carrier: none.having-filter.ts's header still calls HAVING "the only face no conformance table covers". That is still true of the logic axis. The comparand-shape axis now has one. The file is not edited, to keep the claim. Carrier: none.Generated by Claude Code