Repository navigation
fix(objectql)!: having takes the rest of where's filter doors — the comparand-type door, row-independent refusals, a resolved { $field }, and a refused non-condition - #20117
Conversation
…or, row-independent refusals, resolved $field, array/scalar refused WIP: implementation; tests and changeset follow. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx
…, the condition-object check, row-independent walker refusals and $field resolution 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
📓 Docs Drift Check16 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. 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): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 3935246c5113efaa9df83b31bf1c6ff31dc43a5f && git checkout 3935246c5113efaa9df83b31bf1c6ff31dc43a5f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin bc80e16260597d52b062b7e1ee810c7b9a99aed2 f08f8953fca34b5ae8f59eebfeab5fea52072862 && git checkout -B drift-repro bc80e16260597d52b062b7e1ee810c7b9a99aed2 && git merge --no-ff f08f8953fca34b5ae8f59eebfeab5fea52072862
node scripts/docs-audit/affected-docs.mjs --json bc80e16260597d52b062b7e1ee810c7b9a99aed2 |
Contract reviewServed-tier: Scope: 4 files (+755/−14) on merge base
Read: card #20099 and its comments, #19974, PR #20097 and its records 5826694842 / 5826950623, ruling 乙 on #19757 (5793368540), the PR body, the diff, all 34 check-runs, AGENTS.md, Measured in detached worktrees of head and base through the public ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
…easured at base Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx
Contract reviewServed-tier: Scope: the delta over PASS 5828166480 (
Measured at base (
① Derived judgments
② Semver levelUnchanged by the delta: ③ Boundary flags
Implemented-by: VERDICT: FAIL Must change (prose only):
|
…rder on a text column, measured on lowercase, uppercase and mixed text Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx
Contract reviewServed-tier: Scope: the delta over FAIL 5828483617 (
Measured at base (
① Derived judgments
② Semver levelUnchanged by the delta: ③ Boundary flags
Implemented-by: VERDICT: PASS |
…user.can() from the security service (objectstack-ai#20082) (objectstack-ai#20138) Fixes objectstack-ai#20082 Clause-②: no (narrowing) A `formula` field and a CEL `defaultValue` that call `current_user.can(object, verb)` now get the acting subject's effective object permissions. That is the map PR objectstack-ai#20079 wired for option visibility. It comes through `ObjectQL.registerEffectiveObjectPermissionsResolver` and formula's `toEvalPermissions`, and it is resolved at most once per engine operation. ## The two sites, base against head Measured one-shot through a real `ObjectQL` with a SQL driver (better-sqlite3 `:memory:`) and a real `SecurityPlugin`. The caller holds `crm_account: allowRead + allowEdit`, so `edit` is the granted verb and `delete` the denied one. Base is `55daf89d74`; head is this branch. The same cells are pinned permanently in `packages/objectql/src/engine-formula-default-permission.test.ts` with a resolver double. | site | granted | denied | no resolver | resolver throws | |:--|:--|:--|:--|:--| | formula field on `find`, `findOne`, insert echo, update echo | base `null`, no log; head `true` | base `null`, no log; head `false` | base `null`, no log; head `null` plus one `warn` per operation, `reason: 'no-permission-source'` | base `null`, no log; head `null` plus one `warn` per operation carrying the error, `reason: 'permission-resolution-failed'` | | CEL `defaultValue` on insert | base unset plus `warn`; head stores `true` | base unset plus `warn`; head stores `false` | unchanged: unset plus the existing `warn`, which carries formula's "carries no permission data" refusal | base unset plus `warn`, row written; head the insert is refused with the resolver's own error, and nothing is written | | a `required` field with that default | base refused `VALIDATION_FAILED` / `required`; head admitted (`true`) | head admitted (`false`) | unchanged: refused `VALIDATION_FAILED` / `required` | head refused with the resolver's own error | At base the resolver was asked 0 times by either site, whether it granted, withheld or threw. That is H1, confirmed. ## The rule each site follows (H3) - **Formula field (a read).** Today a formula that does not evaluate reads `null` and logs nothing: `applyFormulaPlan` assigns `r.ok ? … : null`. A read cannot refuse a row over one computed field, so a `can` formula with no map keeps that `null`. It is no longer silent: the engine logs one `warn` per operation naming the object, the `can` fields and the reason. A throw fails closed. It is never read as "no grants", which would make an empty map answer `false`, and never as a grant. - **CEL default (a write).** Today a default that does not evaluate is left unset with the `warn` "Failed to evaluate default expression". A `required` field so defaulted is then refused by required-validation (measured at base with the real plugin: `VALIDATION_FAILED`, field `d_req`, code `required`). With no resolver that rule is kept byte for byte: the absent member passes NO map. A throw fails closed the way the option gate does. A row whose `can` default needed the map is refused with the resolution's own error, re-raised untouched. Under `insertMany` only that row is refused, and the `validate()` preview rejects the same way. A row that supplies the field is never refused by a resolution it did not need. ## Where the map comes from, and how often (H2) - `permissionResolution(context)` is one lazy, memoised resolver ask per engine operation. It is `undefined`, meaning no map, when no resolver is registered or the operation has no acting user. The same conditions apply to the option gate. - **Read path.** `find` and `findOne` resolve after the driver returns, and only when a planned formula calls `can` and at least one row came back. They use `opCtx.context`, which is the context the security middleware already ran with. So `plugin-security`'s per-context permission-set memo serves the resolution. - **Write path.** One resolution per write is shared by the CEL defaults, the re-default after the static-`readonly` strip, the option gates and the formula fields on the response. `resolveOptionPermissions` now receives the write's resolution instead of calling the resolver itself. When it asks, and what a throw does, are unchanged; its 13-case suite passes as it was. - The "needs the map" test for both sites is `readsPermissionPredicate`, the option gate's own AST reading of a receiver `can` call. It is exported from `rule-validator.ts` so that there is one detector, not two. It is not re-exported from the package entry. Resolver asks per operation (pinned): | operation | base | head | |:--|--:|--:| | `find` over 4 rows with a `can` formula | 0 | 1 | | `findOne`, and a by-id update echo | 0 | 1 each | | insert with a `can` default and a `can` formula echo | 0 | 1 | | insert that picks a `can`-gated option AND defaults a `can` field AND echoes a `can` formula | 1 | 1 | | batch insert of 6 rows | 0 | 1 | | `validate()` over 2 rows | 0 | 1 | | two consecutive `find`s | 0 | 2 (never kept across operations) | | object with no `can` anywhere, even with a throwing resolver | 0 | 0 | | system read (no acting user) | 0 | 0 | ## Declaration (H4) - **Narrowing.** When the resolution fails, an insert whose `can` default needed it is now refused. At base that row was written with the field unset. So the changeset and this body carry `Clause-②: no (narrowing)`, a **BREAKING** banner and `adr-0087: not-required (no-migration-prescription)`. No authored key, stored shape, export or route changes. - **The claim reads `Clause-②: no`.** The arm is added here because the dispatch's H4 says a write whose default now fails closed is a narrowing to declare. The seat owns the claim line. - **Reachability of that path.** In the shipped composition, `SecurityPlugin`'s middleware resolves the same memoised permission sets before the write and already refuses on a resolution failure. The newly refused path is therefore reachable only when `buildEffectiveObjectPermissions` throws, when the map is off-shape, or with a third-party resolver. - **Widening.** No key, export or route is added. A `required` field defaulted by `can` is now admitted where it was always refused. That is a declared default finally evaluating, not a new surface. ## Tests Head is `4fcfbed346`. Each line names the commit it ran at. The only commits after `9e622fbedd` edit the new test file: they type its options objects and make its driver refuse unknown WHERE combinators. - **Base red.** The new pin against `engine.ts` and `rule-validator.ts` restored to `55daf89d74` (blob `a9ec130693` = base blob), then restored to HEAD (blobs `15ca43c2eb` and `90cec7aea7` = HEAD, `git diff HEAD` empty) gave `Tests 27 failed | 3 passed (30)`. For example, "expected null to be true" (granted `find`), "expected [] to have a length of 1 but got +0" (no-resolver warn), and "expected ValidationError: flag is required to be Error: permission store unreachable" (throw on a required default). The 3 that pass are controls that must hold on both sides: no-resolver on a required default, no `can` anywhere, and a system read. This ran at `ca6dea2249`, with 30 cases; the 31st case, the `validate()` rejection, was added after it. - **Head (`4fcfbed346`).** The new pin plus `engine-option-permission-predicate` gave `Test Files 2 passed (2)` and `Tests 44 passed (44)`, which is 31 plus 13. - **Ablation (at `ca6dea2249`; src-resolved, so no build).** I ran `scripts/ablation-replace.mjs` to replace the memo's `pending ??=` with `pending =`. The anchor went 1 to 0, the marker 0 to 1, and the blob went `15ca43c2eb` to `592f152bbd`. The pin then gave `Tests 4 failed | 26 passed (30)`: every "one resolution" cell got 2 or 3 asks. The file was restored with blob == HEAD and `git diff HEAD` empty. - **`@objectstack/objectql`.** `vitest run --project local` gave `Test Files 315 passed (315)` and `Tests 5373 passed (5373)`. It ran at `9e622fbedd`; the only later commit retypes the options objects in the new test file. `--project repo` gave 1 file, 5 tests passed. `typecheck` (src, scripts and the test layer) exited 0 at `4fcfbed346`, and the test layer still holds 40 files and 234 errors in the debt ledger. The new test file is in `tsconfig.test.json`'s program (`--listFiles`) with 0 errors. - **`@objectstack/plugin-security` (`9e622fbedd`).** The full suite, run with `objectql` aliased to source, gave `Test Files 135 passed (135)` and `Tests 2676 passed (2676)`. - **Dogfood (`55ec8feee6`).** On a closure built by turbo (64 tasks), `field-zoo-roundtrip`, `field-zoo-value-shape`, `showcase-static-readonly` (the re-default path), `showcase-fls-read-mask-strip` and `expression-conformance` gave `Test Files 5 passed (5)` and `Tests 109 passed (109)`. - **Targeted neighbours (`9e622fbedd`).** `engine-option-permission-predicate`, `rule-validator.option-visibility`, `engine-write-formula-hydration`, `engine-formula-scale`, `engine-cel-default-temporal-shape`, `engine-default-value-tokens` and `record-title`, run with the new pin, gave `Test Files 8 passed (8)` and `Tests 170 passed (170)`. - **eslint, narrowed (`4fcfbed346`).** `eslint --no-inline-config --format json` on the 3 changed `.ts` files found 3 files, 0 errors and 0 warnings. The population is read from `eslint.config.mjs`, and no file was reported ignored. The config sets no `parserOptions.project`, so no type-aware rule can move a verdict on an untouched file. ## Gates - **Derivation.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `4fcfbed346` derived 65 commands. - **Run.** All 65 were run at `4fcfbed346`, and all 65 exited 0. - **Reconciliation.** `--ran` reported `65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN`. - **Fixed on the way.** Two families were red at `9e622fbedd` and are green at head: - `check:query-options-erasure`: the new test's options objects were erased to `any`, and the test surface grew from 236 to 244 sites. They are now typed, and the surface is back to 236. - `check:where-matcher`: the test driver read an unknown `$` key as a field name. It now refuses it. - **Prerequisite, then measured.** `check:dual-build-cjs-loads` exited 3 (PREREQUISITE NOT MET) on the first pass. Later gates in the list built the missing dists, and it exits 0 at head. A CJS/ESM load probe of `packages/objectql/dist` sees `ObjectQL` and `evaluateFormulaField` on both. - **`check-changeset-no-major`, fed this body as a `pull_request` payload (`--event`).** It reported `LEVEL AXIS: this PR declares clause-② no (narrowing), and no package whose packages/**/src/** it moves is graded patch`. - **`check-adr-0087-registration`.** It reported `1 declared-breaking changeset(s), each carrying an ADR-0087 disposition` and `[BREAKING+clause-②-narrowing] not-required (no-migration-prescription)`. - **`check-issue-citations --base 55daf89`.** Exit 0: `17 resolves`. - **Left to CI.** The derivation names 5 path-scheduled CI jobs and 4 type-check lanes. They are CI's own runs, and they are NOT MEASURED locally. ## Acceptance notes - `carrier:` 承接者:无. The expression-conformance ledger row `cel-formula` declares `failPolicy: 'fail-soft-log'`, but a formula that faults for any other reason still reads `null` with no log line (`applyFormulaPlan`'s `r.ok ? … : null`). This PR logs only the `can` case, which is its own. This is read off the code, not measured at a public door. - `carrier:` 承接者:无. `evaluateFormulaField` and `resolveRecordTitle` are synchronous and hold no resolver, so a title formula calling `can` still yields `null` there. The docblock and the changeset say so. - `carrier:` 承接者:无. Each `expand` of a related object is its own `find`, so it asks the resolver again. `plugin-security`'s per-context memo absorbs the set resolution; the map itself is rebuilt. - `carrier:` 承接者:无. A system read, which has no acting user, of a `can` formula reads `null` with no warn, as any `current_user` formula does with no subject. ## Deviations from the claim's file surface - `packages/objectql/src/validation/rule-validator.ts` gets `export` on `readsPermissionPredicate`, plus a three-line docblock note, so that there is one `can` detector. - In `engine.ts`, beyond the bodies of `applyFormulaPlan` and `applyFieldDefaults`: - their call sites in `find`, `findOne`, `insert`, `update` and `validate`; - `hydrateWriteFormulas`, which is now async and takes a permissions callback; - four private helpers; - `resolveOptionPermissions`, which now takes the shared resolution. H2 requires that for "at most once per write" across both uses. Its behaviour is unchanged. - `packages/core`, `packages/formula` and PR objectstack-ai#20117's `aggregate` region are untouched. --- _Generated by [Claude Code](https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #20099
Clause-②: no (narrowing)
This gives the
havingclause ofengine.aggregatethe rest of the filter doorswheretakes, at the entry PR #20097 added. It follows the dispatch's binding frame: ruling 乙 on #19757 (record 5793368540), the seat default 协议为基准, and the ADR-0087 entryfilter-between-field-reference-endpoint-refused, whose prescription 「#5222 compiles on every face」havingnow honours.Session
session_01Bvd69VPa6puiNzzPUroDBx, branchclaude/issue-20099-having-where-doors. Baseaa04ea2964, head98abdf4ee3. Every reading below was taken on one of those two commits, as stated. Heads01f091f523andf08f8953fccorrect changeset cells and change no code or test:engine.ts,having-filter.tsand the test file are byte-identical to98abdf4ee3.1. Measured first (H1)
Each shape was run through the public
engine.aggregateon a realInMemoryDriverand a realSqliteWasmDriver. Eachhavingran on bothapplyHavingdoors: the nativedriver.aggregate()door, and the fallback door forced by a per-aggregationfilter. Each ran on a populated and on an empty grouped set. That is 8 cells per shape and version. Each shape'swheretwin, 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.having)wheretwinhavingat basehavingat head{ total: { $gt: { $field: 'max_cap' } } }$gte/$lt/$lte/$eqagainst{ $field: 'max_cap' }$neagainst{ $field: 'max_cap' }{ total: { $gte: { $field: 'max_cap' }, $lte: 1000 } }{ total: { $field: 'max_cap' } }$in/$ninmember, a$contains/$notContainspattern, an$exists/$nulloperand{ $field: 'nope' }$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]{ total: { $eq: { v: 1 } } },{ total: undefined },$eq: new Map(), a function, an undefined$inmember, a bigint beyond 2^53,{ $field: 5 }total: no group in the implicit slot (the non-object values) and under$eq/$gt/$gte/$in; every group under$ne/$nin; under$lt/$lteno group, except the bigint, which kept every grouphaving{ total: { $in: [500n, 20n] } }[['total', '>', 100]],['total', '>', 100],['and', …][],'total > 100',100, aMap[]: no filter; scalars: every row (see Acceptance notes){ total: { $median: 1 } },$nand,$regex, an empty or non-string$icontains{ nope: { $median: 1 } }(a column the row lacks){ $or: [{ total: { $gt: 0 } }, { total: { $median: 1 } }] }54 shapes were measured, 432
havingcells per version. At base, 28 refusal cells were the walker's, raised after the driver had been asked for rows (aggregate 1 / find 0or0 / 1), and 8 were PR #20097's face. At head, all 272havingrefusal 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$inlist,$ne: null, a plain-object implicit value,{}, a$nereference on a missing column, aDatebound and the bigint500n. The re-measure of base after the change matched the first base measurement byte for byte on all 46 original shapes.2. Where
wheregets each door (H2)Located by symbol, in
ObjectQL.aggregate→lowerWhereFilterArray(engine.ts). The same function servesfindandcount.isFilterAST→parseFilterAST(where, context)from@objectstack/spec/data.parseFilterASTruns the shape face and the type door internally with the path fixed atwhere: it has no root argument. So it cannot lower ahavingwithout printingwhere.inhaving's refusals, andpackages/specis not changed here. More decisive: the spec does not declare the sugar on this slot (§3).normalizeFilterComparandTypes(where, context). The function takes apathargument, so it runs onquery.havingas it stands, rooted athaving, with no spec change. It needs no column set: it judges comparand types, not names.where:assertListComparandShapes, already onhavingsince PR fix(objectql)!: routehavingthrough the shared comparand-shape face —having: { total: [5] }is refused like the same shape inwhere#20097;assertFilterIsMaterializable,assertTextOperatorTargetsAreStringCapableandassertTemporalComparandsInterpretable. The last three judge the OBJECT's declared fields, a namespacehavingdoes not filter. Unknown-operator refusals forwhereare raised by the drivers.havingnever 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'saliasorfield, and every aggregation alias.3. What changed
packages/objectql/src/engine.ts,ObjectQL.aggregate, thehavingentry only, beside the shape-face call:assertHavingIsFilterCondition(query.having).havingis null, absent or a plain object; anything else is refused.QuerySchema.havingandEngineAggregateOptions.havingboth declareFilterConditionSchema. Measured: both schemas refuse every array,[]included. The FilterArray sugar is declared on thewhereslot alone (TransportFilterValueSchema, 「wherewidens to theFilterArraysugar here and ONLY here」). The wire door already refuses ahavingarray (protocol.query-param-arity.test.ts).assertListComparandShapes(…, 'having').normalizeFilterComparandTypes(query.having, "aggregate('order')", 'having'). A narrowed bigint replaces the clause copy-on-write, and the caller's object is not edited.assertHavingIsEvaluable(having, aggregatedRowColumns(query.groupBy, query.aggregations)).packages/objectql/src/having-filter.ts:assertHavingIsEvaluablewalks the whole clause once, the$and/$or/$notwalkmatchesHavingtakes. 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 ownFieldReferenceSchemarefuses (a malformedaddDays), and a reference naming no column. The per-row throws stay as the floor for a caller that evaluates rows directly.checkConditionresolves a{ $field }reference that is the whole comparand of$eq/$ne/$gt/$gte/$lt/$lteagainst the row. The comparison is@objectstack/formula'smatchesFilterCondition, 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-dayaddDaysarithmetic, which are not copied here.engine-aggregate-having-comparand-shape.test.tsextends PR fix(objectql)!: routehavingthrough the shared comparand-shape face —having: { total: [5] }is refused like the same shape inwhere#20097's where/having parity table (37 → 112 tests), both doors, with an empty-grouped-set leg on every refusal..changeset/20099-having-where-doors.md.4.
$fieldagainst the aggregated row (H3)Resolved, not refused. Resolution honours the declared form,
FieldReferenceSchema, and the two-bound spelling the$betweenrefusal prescribes onhaving. 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, andapplyHavingis 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, soClause-②: no (narrowing)stands.check-changeset-no-major --base aa04ea2964:✓ This diff introduces no major bump.Its clause-② level axis reads NOT APPLICABLE locally (nopull_requestpayload); CI reads it from this body.check-adr-0087-registration --base aa04ea2964:✓ 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).filter-between-field-reference-endpoint-refused(the prescriptionhavingnow honours, and the list-member position it refuses),filter-icontains-comparand-refused-at-parseandfilter-regex-options-retired. The marker's prose names the transitions no entry covers, and why none is needed:havingis a request-only key, and no stored document exists forobjectstack migrate metato rewrite.6. Tests, reverse verification, ablation, gates
Head
98abdf4ee3unless stated. It differs fromce22319645, where the typecheck ran, by the changeset only.@objectstack/objectqlvitest run --project local: 314 files, 5417 passed.--project repo: 1 file, 5 passed.@objectstack/objectqltypecheck, atce22319645: exit 0.check:test-typecheckreads OK, 40 files / 234 errors held, unchanged.The extended parity file: 112 passed.
Consumer census.
engine.aggregatetakeshavingfrom ONE non-test source caller,metadata-protocolprotocol.ts(the REST aggregate branch). Its suites were run with objectql's dist rebuilt (turbo, 24 tasks, 18 cached; dist carriesassertHavingIsEvaluable, 2 hits):metadata-protocolprotocol.query-param-arity.test.ts: 46 passed;restlist-view-grouping-query-door.test.ts: 33 passed;plugin-securitypredicate-guard.test.ts: 10 passed.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 fix(objectql)!: routehavingthrough the shared comparand-shape face —having: { total: [5] }is refused like the same shape inwhere#20097's census, which reported the same.Reverse verification.
engine.tsandhaving-filter.tswere put back to their BASE blobs (a9ec130693,2514be70bb) withgit restore --source, under an EXIT/INT/TERM trap.havingthrough the shared comparand-shape face —having: { total: [5] }is refused like the same shape inwhere#20097's rows, the no-clause controls and the where-sugar control.git diff HEADwas 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 withgit diff HEADempty. The tests import./engine.jsfrom source, so nodist/is on the resolution path.assertHavingIsFilterCondition(query.having)FILTER_COMPARAND_TYPE_CASEStype rows, and the bigint narrowingassertHavingIsEvaluable(…)checkConditionGates.
dispatch-gates --commands --repo objectstack-ai/objectstackwas 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).check:dual-build-cjs-loadsandcheck:type-check-debt, both exit 3 PREREQUISITE NOT MET. They need the whole workspace built, and CI runs them.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 aa04ea2964:✅ every citation this change adds resolves(19 judged).Lint, a declared narrowing (
pnpm lintis CI's).eslint --no-inline-config --format jsonon the 3 changed.tsfiles:--print-configreturns a config for each file;eslint.config.mjssets noparserOptions.projectand no typed rule, so no untouched file's verdict can move.7. Compile surfaces, face by face
driver-sql(withdriver-sqlite-wasm, localdriver-turso)having: no driver reads the clause. UnchangedRemoteTransportread-scope-sqlfilter-normalizerhavingto the engine, and the analyticshavingis outside this claim. UnchangedformulamatchesFilterConditionnow also evaluates ahavingreference comparison. Its code is untouchedhaving-filterapplyHavingdoors.{ $field }is resolved in the six scalar comparisons. The where/having parity table now covers the type door and the new armsdriver-memory/driver-mongodbhaving. UnchangedThe 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/srcfinds one reader,predicate-guard.ts:70,collectConditionFields(ast.having, out), which walks field names and writes nothing.Every refusal added here runs before
executeWithMiddlewarein any case. Every refusal the tests pin assertscodeandstatus.8. Deviations from the claim, declared
havingis REFUSED, not lowered. The claim's surface says "FilterArray lowering", and H4 expected "lowered sugar". The declared contract answered otherwise.havingisFilterConditionSchemaon bothQuerySchemaandEngineAggregateOptions, and both refuse an array. The sugar is declared onwherealone, and the protocol door already refuses ahavingarray. Lowering it would widenhaving's contract, a protocol change the frame rules out. It would also printwhere.inhaving's refusals, becauseparseFilterASThas no root argument. The report carries this as an open question.filterresolves{ $field }too. It sharescheckConditionwithhaving(its module note: 「a predicate moved between a driverwhere, ahaving, and a per-aggregationfiltermust 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,countwithfilter: { 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 claimedhavingentry. See Acceptance notes.Acceptance notes
Observed and not fixed here. The report carries each one with its class and evidence.
filter's walker refusals still depend on the data.aggregations: [{ …, filter: { amount: { $median: 1 } } }]isINVALID_FILTER/ 400 on a populated table and200 []on an empty one, on both drivers. This is row 4's class, at the sibling position; the fix is this PR'sassertHavingIsEvaluablewalk run per aggregation filter, in the engine loop outside this claim.wherethat is a string, a number or aMapis dropped.engine.find('order', { where: 'amount > 100' })returns every row on both drivers.wherearray carries no envelope.where: [1, 2, 3]throws withcodeandstatusundefined, on both drivers.driver-memorydoes not resolve awhere{ $field }reference.{ amount: { $gt: { $field: 'cap' } } }answers[], whiledriver-sqlite-wasmanswers the resolved rows.havingkey 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.addDaysagainst 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 onwhere, anddriver-memorydoes 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/$ilikeare still refused onhaving. That is the documented staging inFILTER_OPERATORS, carried by the follow-up on Filter AST:likeis folded to$containsat the wire — wildcards bind as literals and driver-sql'slike/ilikearm is unreachable #7536, and not a new gap.having-filter.tsheader 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