Repository navigation
fix(objectql)!: having and the per-aggregation filter refuse a plain { $field } across two comparison classes, as where does - #21297
Conversation
…ield } by the comparison class A plain cross-field reference across two comparison classes (a datetime against a date, text against a number) is refused INVALID_FILTER / 400 at both positions, by the spec's crossFieldComparisonVerdict and in the words where refuses the same pair in. Only an addDays pair was judged before; a plain one reached formula's whole-day reading of the bare day. Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude <noreply@anthropic.com>
…d the per-aggregation filter Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude <noreply@anthropic.com>
…here twin through POST /data/:object/query Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude <noreply@anthropic.com>
…having and the per-aggregation filter Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
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 e68ba3a18443a75ded9f83e17202fbbd5e36a64e && git checkout e68ba3a18443a75ded9f83e17202fbbd5e36a64e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4e6dc2338acbc00eaf98bd8269317a779c6b8c02 df85ac4baabe5a6974e269419b595a274d306c06 && git checkout -B drift-repro 4e6dc2338acbc00eaf98bd8269317a779c6b8c02 && git merge --no-ff df85ac4baabe5a6974e269419b595a274d306c06
node scripts/docs-audit/affected-docs.mjs --json 4e6dc2338acbc00eaf98bd8269317a779c6b8c02
|
Contract reviewServed-tier: Inputs: card #21255 (body; comments 5942982209, 5943357114, 5944459558, 5944487371); PR #21297 (body, the 6-file list, the GitHub diff against merge base ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
…ld } against a column with no comparison class, as where does; applyInMemoryAggregation takes the reference rules (objectstack-ai#21406) Fixes objectstack-ai#21299 Clause-②: no (narrowing) **Status: the seat answered the open question with C (5952549615 on objectstack-ai#21299).** The no-class sentence stays `objectql`-local (`noClassReason`) beside `crossClassReason`, its precedent. The REST twin pin asserts that each engine position's reason equals the real `where` twin's diagnostic, verbatim, and that is the drift guard. The dispatch's no-third-copy clause is relaxed for this sentence only. Patch round 1 (`b397060136`) replaced the PENDING docblock with the decision record. A spec-side home (option A below) is recorded as a candidate for the spec lane in the seat's hand-over ledger, not filed. Dispatched by `domain:engine#1` (seat post objectstack-ai#6367), claim 5949513813, branch `claude/issue-21299-having-no-class-refusal`. Base `db0cf2231b`, `origin/main` merged at `69a12a0952`, head `b397060136` (patch round 1; round 0 was `466e632c33`). ## What changes - **`packages/objectql/src/having-filter.ts`** - The seam `crossClassReferenceViolation` now acts on the spec verdict's `no-class` answer as well as `cross-class`. Its reason is `where`'s sentence for the same pair: the referenced column first, then the target, as `driver-sql`'s `applyCrossFieldComparison` asks them. No second rule: both answers come from the one `crossFieldComparisonVerdict` call. - Both callers ask it of every reference. With `addDays`, only the `no-class` answer is taken from the seam (`where` asks it before it reads an offset); the cross-class half of an `addDays` pair stays `offsetPairViolation`'s, unchanged. - `assertAggregationFilterReferencesAreDeclared` is exported (module-level only; neither package entry re-exports `having-filter.ts`). `AggregationFilterDeclaration.object` becomes optional, so a caller with no object name gets a diagnostic that names the field alone. - **`packages/objectql/src/in-memory-aggregation.ts`**: `applyInMemoryAggregation`, when handed `fields`, runs `assertAggregationFilterReferencesAreDeclared` on each per-aggregation filter before the JSON-column rule, before any row. That is the same function and the same order `engine.aggregate` uses. The signature is unchanged. Without `fields`, nothing new is judged. - **Pins** - `packages/objectql/src/engine-aggregate-reference-verdict-positions.test.ts` (new): the enumeration pin and the card's shapes at the engine door. - `packages/objectql/src/engine-aggregate-filter.test.ts`: objectstack-ai#21242's direct `applyInMemoryAggregation` pin is split in two. With no field map it still answers 1 of 4. With a field map the cross-class pair is now refused, which is the ruled direction ("one door, whichever caller enters it"). - `packages/rest/src/aggregation-filter-where-doors.test.ts`: the positions × verdicts parity table against the real `where` twin on `SqlDriver`. The reasons are read from the twin's own withheld diagnostic, never retyped. `boot()` now takes an optional object and rows. - **`.changeset/21299-having-no-class-reference.md`**: `@objectstack/objectql` `minor`, BREAKING, `Clause-②: no (narrowing)`, `adr-0087: not-required (no-migration-prescription)`. ## Baseline and after (H1), through `engine.aggregate` on `SqlDriver` / better-sqlite3 Probe fixture: 6 rows, `photo` image, `tags` multiselect, `due_f` formula (`returnType: date`). Base `db0cf2231b`; after = `be8d2c428b` (built dists). | shape | base: populated | base: empty | after (both) | `where` twin (base and after) | |---|---|---|---|---| | per-aggregation `{ customer_id: { $ne: { $field: 'photo' } } }` (text vs image) | counted 6 of 6 | 0 | `INVALID_FILTER` / 400 | 400, `"photo" (type "image") has no scalar stored column a comparison can read.` | | per-aggregation `{ amount: { $ne: { $field: 'tags' } } }` (number vs multiselect) | 400 from the per-row array floor (row-dependent; 6 of 6 when `tags` is null) | 0 | `INVALID_FILTER` / 400 | 400, `"tags" (type "multiselect") ...` | | `having: { photo: { $ne: { $field: 'n' } } }` on an image groupBy | kept all 6 groups | `[]` | `INVALID_FILTER` / 400 | twin `photo` vs `amount`: 400, `the target field "photo" (type "image") ...` | | per-aggregation `{ closed_at: { $lte: { $field: 'due_f' } } }` (datetime vs formula) | counted 0 of 6 | 0 | `INVALID_FILTER` / 400 | 400, `"due_f" (type "formula") ...` | | control: `{ due_on: { $lte: { $field: 'due_on' } } }` | 6 | 0 | 6 / 0, unchanged | 6 / 0 | After the change, each per-aggregation refusal logs `where`'s reason verbatim, and the REST pin compares it against the twin's diagnostic. ## Hypotheses - **H1, confirmed.** On the base, `crossClassReferenceViolation` acted only on `cross-class`, through its two callers. `CROSS_FIELD_NO_CLASS_REASONS` is `list-or-object`, `file`, `formula`. `where`'s words are `driver-sql`'s, under `INVALID_FILTER` / 400. - **H2, none exists.** The sentence "has no scalar stored column a comparison can read" is written twice, inline, in `driver-sql`'s `applyCrossFieldComparison` (referent arm and target arm), and exported nowhere. `objectql` does not depend on `driver-sql`. `@objectstack/spec` and `@objectstack/core` hold no no-class sentence. Two other no-class wordings exist, neither of them `where`'s: `@objectstack/formula`'s `describeColumn` (behind the exported `findCrossFieldClassRefusal`) and `@objectstack/lint`'s RLS / sharing-rule doors. Per the dispatch, adding one to `packages/spec` is a spec edit, so this run stopped before it. The seat answered with C (5952549615); see the decision record below. - **H3, no changed answer for the existing callers.** `packages/verify`'s `checkDateBucketParity` calls `applyInMemoryAggregation(rows, ast)` with no `fields`, so the reference rules have nothing to judge. - Read, not run: no other in-repo caller outside `objectql` passes `fields` either (the dogfood parity tests, `service-analytics`' bucket-key test, the `driver-mongodb` parity test). - Measured against the rebuilt dists: `@objectstack/verify` 16 files / 120 tests passed, and dogfood 168 files passed (1 skipped), 1374 tests passed (9 skipped). - The one answer that moved is objectstack-ai#21242's own with-field-map pin, and it moved by the ruling. - **H4, posture kept, and recorded as rows.** - Registry-less host: not judged at the per-aggregation filter, at `having`, or at `applyInMemoryAggregation` without `fields`. - Audit-opt-out object's row-carried `created_at` / `updated_at`: not judged at the engine positions. Measured on `SqlDriver`: the `where` twin refuses it 400 ("the target field "created_at" is not a declared field"), while the per-aggregation filter counts 6 of 6. That divergence is pinned in the REST table as the recorded posture. - objectstack-ai#21336's pins stay green: the registry-less `picked: 3`, `['c1']` and the audit-opt-out 1 of 4. - **H5, unreachable, and pinned.** A `multiple: true` select, lookup or user is refused `INVALID_FIELD` / 400 by the engine's groupBy door and by its `min` / `max` door before `having` is read. Measured on `SqlDriver` and in the pin, for a groupBy, `min` and `max` of each. The same holds for `multiselect` and `json`. The only no-class columns that reach `having` are a file-family groupBy (every driver) and a formula groupBy on a driver whose door does not refuse it (the engine's in-memory path; `driver-sql` refuses a formula groupBy itself). ## The enumeration pin Spec verdict names: the dispatch's `same-class` is the spec's `comparable`. | verdict | `where` (driver-sql) | `having` | `aggregations[i].filter` | `applyInMemoryAggregation` (with `fields`) | |---|---|---|---|---| | `comparable` | answered | answered | answered | answered | | `cross-class` | 400 | 400 | 400 | 400 | | `no-class` | 400 | 400; list-or-object is refused earlier by the groupBy door | 400; a formula KEY is refused earlier by the materializable-filter door (`INVALID_FIELD`), as in `where` | 400 | | `unjudged` (type outside `FieldType`) | judged by the driver's own aliases | unreachable: the registry refuses the object | unreachable: same | not judged | | no declaration | 400 | not judged | not judged | not judged | - **objectql pin.** It sweeps all 15 × 15 ordered pairs at each of the three engine-side positions. The columns are one per row of `CROSS_FIELD_COMPARISON_TYPE_CLASSES`, plus `select` with `multiple: true`, plus a type outside `FieldType`. Each cell's expected answer is computed from `crossFieldComparisonVerdict` itself. A refused cell must be 400 before any read, and must name both classes or the no-class column with its declared type; the filter positions must keep those names off the wire. - **What makes it fail until placed.** - The verdict posture is a `Record` keyed by the spec's verdict union, so a new verdict is a type error. It is also a runtime failure: the set of verdicts the sweep meets must equal the record's keys. - The no-class reasons the sweep covers must equal `CROSS_FIELD_NO_CLASS_REASONS`. - The filter slots of `EngineAggregateOptionsSchema` must equal `where`, `having` and `aggregations[i].filter`. - The published exports of the two evaluator modules must equal `applyInMemoryAggregation` (a position) and `bucketDateValue` (not a position). - **REST pin.** One row per verdict this object can give: comparable, cross-class, and no-class for file, list-or-object and formula. Each row runs at all four positions, empty and populated. Every position's answer (refusal envelope, or total count) must equal the `where` twin's. For the no-class rows, the twin's reason, read from its withheld diagnostic, must appear verbatim in the per-aggregation log line, in `applyInMemoryAggregation`'s diagnostic and, for the file row, in the `having` message. ## Reverse verification (at `623b7474bd`, before the merge) The mutation was made with `node scripts/ablation-replace.mjs` and its EXIT / INT / TERM trap restore. - **Mutation.** It replaced only the seam's no-class arm, `if (verdict.verdict === 'no-class') return noClassReason(...)`, with an always-false guard carrying the marker `ABLATION_21299`. Anchor 1 → 0; blob `f4c9c878df05` → `eb68be65ef10`. - **Build.** `objectql` was rebuilt (exit 0). `ablation-dist-preflight` found the marker in 4 built files. - **Prediction, then reading.** The no-class refusal pins go red and the controls stay green. That is what happened: - `objectql`: **10 failed / 303 passed** (313). The 10 are the three sweeps, the three filter shapes, the `having` image shape, the two `addDays`-against-a-formula rows (per-aggregation and `having`, both answered with the arm off), and the audit-on `created_at` vs image control. The comparable, cross-class, census, no-declaration, `multiple`-seam and same-class rows stayed green, as did all of `engine-aggregate-filter.test.ts` and `engine-aggregate-having-comparand-shape.test.ts`. - `rest`: **3 failed / 23 passed** (26). The 3 are the no-class rows (file, list-or-object, formula). The comparable and cross-class rows, the audit-opt-out posture row and every earlier test stayed green. - **Restore.** Proven four ways: the blob equals HEAD (`f4c9c878df05`), `git diff HEAD` is empty, `--absent` preflight passes after a rebuild ("marker absent from all 14 built files", whole tree clean), and `git status --porcelain` is empty. The re-run was 313 / 26 passed. ## Verification (all heavy runs through `os-verify-lock.sh`) The suites below ran at `be8d2c428b` (the merge). Two commits followed. `251922e411` gives the pin file's two single-choice columns their `options`, the legal declaration `FieldSchema` now requires; `466e632c33` corrects one changeset sentence. At `251922e411` that file was re-run (26 passed) and `objectql` was re-typechecked (exit 0, test layer held at 40 / 234). The whole gate union was re-run at `466e632c33` (below). - Full `@objectstack/objectql`: `--project local` 365 files, **7381 passed**; `--project repo` 1 file, 5 passed. `VERDICT command-exit 0`. - `@objectstack/rest` `aggregation-filter-where-doors.test.ts`: 26 passed. `@objectstack/verify`: 16 files, 120 passed. Dogfood (`pnpm --filter @objectstack/dogfood exec vitest run`) against the rebuilt dists: 168 passed + 1 skipped files, 1374 passed + 9 skipped tests. Each run's verdict was `command-exit 0`. - Typecheck: - `pnpm --filter @objectstack/objectql run typecheck` exit 0. Its test layer held at 40 files / 234 errors, unchanged, so the new pin file adds none. - `pnpm --filter @objectstack/rest run typecheck` exit 0, test layer 0 errors. - Build after the merge: `turbo run build --filter='@objectstack/dogfood^...' --filter=@objectstack/rest --filter=@objectstack/verify`, 63 of 63 tasks. - Gates, at `466e632c33`: - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 65 commands. All 65 ran with exit 0, each exit code recorded beside the printed command. - `--ran` reports "65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN (a DERIVED zero)". - In the earlier pass at `251922e411`, `check:dual-build-cjs-loads` answered `PREREQUISITE NOT MET` (exit 3: 8 unrelated packages had no `dist/`). Those packages were built (41 of 41 turbo tasks from cache), and the gate exited 0 there and in the final pass. - The four roster gates whose rosters sit under my paths also exited 0: `check-changeset-fixed`, `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`. - NOT MEASURED, because their argv takes workflow values and CI runs them: `check-issue-citations --census`, `check-shard-attestation`, `check-test-completeness`. - Lint, a proven narrowing: - Command: `pnpm exec eslint --no-inline-config --format json` on the 5 changed `.ts` files gives 5 results, 0 errors, 0 warnings. - Why the narrowing holds: `eslint.config.mjs` is not type-aware (no `parserOptions.project`; header near `:328`), so this diff cannot move any untouched file's verdict. - The full `pnpm lint` is CI's. ## Deviations (both accepted by the seat, 5952549615; the `rest` test file is declared on the cli seat post, 5952559844) - `packages/rest/src/aggregation-filter-where-doors.test.ts` is outside the claim's file surface, and it is a test only. The `where` twin can only be refused on a real `driver-sql`, which `objectql` does not depend on. This is the same harness and the same reason as objectstack-ai#21255's deviation 2. - The seam judges the `no-class` answer for an `addDays` pair too. Before, an `addDays` pair against a formula was answered at both positions. One against a file or list column was refused, but in the `addDays` pair rule's words: that rule reads those types as `text`, so it answered with a cross-class or an offset sentence (read from the source, not measured). It now gets the no-class sentence. This stays within "no-class references at these positions", and the changeset says so. ## Decision record (H2): C The seat chose **C** in 5952549615. `objectql` keeps the local copy (`noClassReason`), on the `crossClassReason` precedent, which landed with objectstack-ai#21255 (PR objectstack-ai#21297, contract review PASS 5944780154). The REST twin pin is the drift guard. Two options were not taken: - **A**, a pure sentence function beside the verdict in `packages/spec`: it adds a published spec export (`Clause-②: yes`, spec-lane work) and a `driver-sql` edit outside this claim. It would also move the cross-class sentence. It is recorded as a candidate for the spec lane, not filed, because no reader gets a wrong answer today. - **B** strands `formula`, which depends on `spec` alone. **Patch round 1** (`b397060136`, report 5953205116) changed only `noClassReason`'s docblock, +8/−5, every line inside the JSDoc block. At that head: - the pins pass: `objectql` 313 and `rest` 26; - `objectql` typecheck exits 0; - gates: 65 derived, 65 run, all exit 0; - the full suites stand from `be8d2c428b`, because no source line outside comments changed since then (proven by the filtered diff). ## Acceptance notes (nothing filed) - `having` refusal messages exceed the REST envelope's 500-character bound, so the wire cuts the closing clause about how a column's class is read. The no-class reason sits in the surviving prefix (the REST pin asserts it on the wire). This predates the PR (objectstack-ai#21255's note). Carrier: 承接者:无. - The `where` words for a cross-class pair say "is stored as" where the engine positions say "is" (objectstack-ai#20127's convention for a computed column). The no-class sentence needs no such reading and is byte-identical. --- _Generated by [Claude Code](https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #21255
Clause-②: no (narrowing)
This is the precondition #21242 waits on: after it lands, no
havingor per-aggregation filter query reaches@objectstack/formula'slteBoundwith a cross-class plain reference. #21242 itself is not addressed here.What changed
havingand the per-aggregationfilter(aggregations[i].filter) now apply the comparison-class rule to every plain{ $field }comparison, aswheredoes, and not only to anaddDayspair. A plain reference whose two columns belong to different classes of the spec'sCROSS_FIELD_COMPARISON_CLASSESis refusedINVALID_FILTER/ 400 before any driver is asked for a row.packages/objectql/src/having-filter.tscrossClassReferenceViolationasks the spec's owncrossFieldComparisonVerdict(the classificationdriver-sql'scrossFieldComparisonClassdelegates to) and refuses only itscross-classanswer.comparable,no-classandunjudgedare not this rule's, and a side with no declaration is not judged.crossClassReason) and printed by both theaddDaysrule and the plain rule. No third copy of the class table:AggregatedColumnClassis now the spec'sCrossFieldComparisonClass, the same six names.filterstill lacks four ofwhere's doors — a bad date, anaddDaysnumeric pair, an undeclared{ $field }and an unknown key answer200with every count 0 #20148 reference rules. The wire message now names the same-class rule ("compared as the same type class",where's words) beside theaddDaysone.having: the pair is judged against each aggregated column's TYPE (aggregatedRowColumnTypes). The message names the two projected columns and their classes, as the objectqlhaving: a{ $field }reference withaddDaysagainst a non-temporal aggregated column answers by epoch-ms coercion, where SQL push-down refuses the same pair onwhere— the aggregated row declares no temporal class to judge it by #20127addDaysrefusal does (columnPairError, which says "with addDays" only when the reference carries one).addDayspairs: unchanged, stilloffsetPairViolationover the having vocabulary's classes.packages/objectql/src/engine.ts: one argument at theassertHavingIsEvaluablecall site,aggregatedRowColumnTypes(query.groupBy, query.aggregations, declaredFields), which is the same map the number-comparand door already receives a few lines below. This is outside the claim's file surface. The reason: the having vocabulary's class (classOfDeclaredType) reads afile/imagegroupBy projection astext, where the spec gives it no class. Judging plain references by that class would have refused a file-against-number pair under the cross-class words, a narrowing beyond cross-class references. Asking the spec's verdict of the column's type avoids it.packages/objectql(both doors, empty and populated, grouped and ungrouped) andpackages/rest, where thewheretwin runs on a realSqlDriver..changeset/21255-having-plain-reference-class.md:minor, BREAKING (accept-set narrowing), ADR-0087not-required (no-migration-prescription).Measurements (H1 to H4)
2791138cbf:assertConditionIsEvaluablejudged the class only underif (scope.classes && target.addDays !== undefined)(:1425).assertAggregationFilterReferencesAreDeclaredjudged it only underif (offset !== undefined)(:1329).where's words aredriver-sql's (sql-driver.tsapplyCrossFieldComparison):"T" is stored as C1 but "R" as C2, and a cross-class comparison answers differently in SQL (storage-class ordering) than in memory (JS coercion) — compare same-class columns., underuncompilableFieldReferenceError(INVALID_FILTER, withheld). The having vocabulary and the spec verdict:FieldTypemember the spec classes. The having side reads the same five value-class sets, and everything else falls back totext.list-or-object(structured-JSON types, multi-option types,multiple: true) andfileread astextin the having vocabulary.formulaisundefined(not judged) there, while the spec says no class (refused bywhere). A type outsideFieldTypeistextthere andunjudgedin the spec.classOfDeclaredType. The tail of the sentence is pinned against the twin's real diagnostic (see Pins).declaredFieldMeta:typeplusmultiple).havingreads the type the query gives each aggregated column: acount/sum/avgis anumber, adaybucket adate, a coarser buckettext, andmin/maxthe field's type. When the class cannot be told, nothing is judged; that is the posture anaddDayspair already had. Evidence:havingwith no registered object keeps['c1']forlast_closed $lte { $field: 'first_due' }, and a per-aggregation filter with no registered object counts 3 for the card's query. Both are pinned on both doors, and both stayed green under the reverse verification.2791138cbfthroughengine.aggregateonSqlDriverover better-sqlite3 (probe fixture):aggregations[0].filter{ closed_at: { $lte: { $field: 'due_on' } } }answered[{ n: 4 }], andhavingmax(closed_at) $lte { $field: 'due_day' }(due_onday bucket) kept 4 of 6 groups. Thewheretwin answeredINVALID_FILTER/ 400 with the diagnostic above. On the pinned REST fixture, with the rule reverted: 3 rows (c1 2, c2 1) and 3 of 6 buckets.Pins
wheretwin in the same test (packages/rest/src/aggregation-filter-where-doors.test.ts,[#21255]block,POST /api/v1/data/:object/queryonSqlDriver). Each refusal is 400INVALID_FILTERon an empty and a populated table, with no driver read. The twin is 400INVALID_FILTER. The class names and the sentence's tail are parsed out of the twin's own withheld diagnostic (withheldFilterDiagnosticOf) and asserted inside the per-aggregation log line and thehavingmessage, so the agreement is pinned rather than retyped.packages/objectql): 7 per-aggregation shapes (datetime/date both ways, text/number, time/datetime, under$ne, behind a$orbranch that holds, under$not), the names withheld and logged 8 times. 9havingshapes: the card's query, datetime/date both ways, text/sum, sum/date, count/datetime under$ne,$or,$not, and a month bucket against a date.wheretwins count).having: day bucket/date['2026-01-02', '2026-01-15'], datetime/datetime['c1', 'c2'], count/max['c2', 'c3']. The existingANSWEREDrow "a numeric pair with NO addDays" (['c1']) is relabelled "(one class on both sides)", because its old label "(the rule is the offset's)" is no longer true.addDayspairs unchanged: the existing objectqlhaving: a{ $field }reference withaddDaysagainst a non-temporal aggregated column answers by epoch-ms coercion, where SQL push-down refuses the same pair onwhere— the aggregated row declares no temporal class to judge it by #20127REFUSED/ANSWEREDpins and the objectql + REST: the per-aggregationfilterstill lacks four ofwhere's doors — a bad date, anaddDaysnumeric pair, an undeclared{ $field }and an unknown key answer200with every count 0 #20148REFERENCE_REFUSED/REFERENCE_PASSINGpins, left green and not edited.Reverse verification
Only the gate was reverted, through
node scripts/ablation-replace.mjs. IncrossClassReferenceViolation, the anchorif (target === undefined || referent === undefined) return undefined;becameif ('ABLATION_21255'.length > 0) return undefined;: the plain rule answers "not judged" at both positions, and theaddDaysrule is untouched.c425c56a9b→604a038ce3. Rebuild exit 0.node scripts/ablation-dist-preflight.mjs @objectstack/objectql ABLATION_21255: marker present in 4 built files.16 failed | 265 passed (281). The 16 are exactly the 7 per-aggregation and 9havingrefusal pins. Every same-class control, both registry-less controls and everyaddDayspin stayed green.2 failed | 18 passed (20), the two refusal pins. The three same-class controls stayed green.git diff HEADempty. After the rebuild, the preflight with--absentfound the marker absent from all 14 built files and the tree clean. Re-run: objectql281 passed, rest20 passed.Tests and gates (HEAD
df85ac4baa)Every command was run through
bash scripts/pm/os-verify-lock.sh(VERDICT command-exit 0each time) unless it is listed as a gate.pnpm --filter @objectstack/objectql exec vitest run --project local --maxWorkers=2 src/engine-aggregate-filter.test.ts src/engine-aggregate-having-comparand-shape.test.ts:281 passed (281).pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2 src/aggregation-filter-where-doors.test.ts:20 passed (20).@objectstack/objectqlsuite (vitest run --project local --maxWorkers=2):360 files, 7108 passed. This ran at08678c107e, which differs from HEAD only by a secondmainmerge (no objectql file), a doc-comment reflow and the REST test's typed options.df85ac4baa:pnpm --filter @objectstack/objectql typecheckexit 0, includingcheck:test-typecheck("40 file(s) / 234 error(s) … held");pnpm --filter @objectstack/rest typecheckexit 0, test layer "0 error(s)".{ $field }on the aggregate path, run against the same tree and still green:packages/restdata-field-comparand-permission.test.ts:30 passed;packages/services/service-analyticscross-field-engine-fallback,cross-field-offset-datasetandinclude-relation-cross-field-boundary:175 passed.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderived 65 commands atdf85ac4baa. All 65 ran there with exit 0,check:dual-build-cjs-loadsandcheck:type-check-debtincluded.--ranwith the recorded exit codes reports: "65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN … a DERIVED zero".check-issue-citations --censusandcheck-shard-attestation. Reason: each takes a value from the workflow, so CI runs them.pnpm exec eslint --no-inline-config --format jsonover the 5 changed source and test files reported 5 results, 0 errors and 0 warnings.eslint.config.mjssets noparserOptions.projectand registers no typed rule, so linting is not type-aware and this diff cannot move the verdict of any file it does not touch. The repo-widepnpm lintis CI's.Acceptance notes
No-class pairs are not judged by this rule. This is a boundary, kept on purpose: refusing them would narrow beyond cross-class references. Measured at
engine.aggregateonSqlDriver, where thewheretwin is 400 every time:image: counted 6, at HEAD and with the rule reverted;multiselect: counted 6, at HEAD and with the rule reverted;havingon animagegroupBy against a count: answered, at HEAD and with the rule reverted;datetimevs aformula, measured at HEAD only: counted 0, because the formula has no value in the raw rows.None of these reaches
lteBoundwith a bare day. Reported to the seat as a finding for this family's close-out card.applyInMemoryAggregation(exported) does not run the objectql + REST: the per-aggregationfilterstill lacks four ofwhere's doors — a bad date, anaddDaysnumeric pair, an undeclared{ $field }and an unknown key answer200with every count 0 #20148 reference rules for a host that calls it directly, and so does not run this one either. No in-repo caller passes a per-aggregation{ $field }there. Noted, not filed.A
havingrefusal message runs past the REST envelope's 500-character bound, so the wire truncates its closing clause about how a column's class is read. The class reason sits in the first part and survives. The same is true of the existingaddDaysrefusal.Generated by Claude Code