Repository navigation
fix(objectql)!: having and the per-aggregation filter refuse a { $field } against a column with no comparison class, as where does; applyInMemoryAggregation takes the reference rules - #21406
Conversation
…d } with no comparison class; applyInMemoryAggregation takes the reference rules The seam (crossClassReferenceViolation) now acts on the spec verdict's no-class answer as well as cross-class, at having and at the per-aggregation filter, with or without addDays, in driver-sql's where words. A direct applyInMemoryAggregation call with a field map runs the same reference rules through the same function, before any row is judged. Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude <noreply@anthropic.com>
…n that judges a { $field }, every answer the spec's verdict gives
Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp
Co-authored-by: Claude <noreply@anthropic.com>
…ver, the no-class reasons read from the twin Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude <noreply@anthropic.com>
…sed at having and the per-aggregation filter; applyInMemoryAggregation takes the reference rules Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude <noreply@anthropic.com>
…ssue-21299-having-no-class-refusal
…, a legal declaration under FieldSchema Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude <noreply@anthropic.com>
…st column was refused in before 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 a75aa9c8873d531e6fec5596a885d32f5f7ffc2a && git checkout a75aa9c8873d531e6fec5596a885d32f5f7ffc2a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4b20c847488c42fc521a64857d32274a8341c306 b397060136571159ee4460c881752a8ac0d48734 && git checkout -B drift-repro 4b20c847488c42fc521a64857d32274a8341c306 && git merge --no-ff b397060136571159ee4460c881752a8ac0d48734
node scripts/docs-audit/affected-docs.mjs --json 4b20c847488c42fc521a64857d32274a8341c306
|
…a local copy of driver-sql's sentence beside crossClassReason, guarded by the REST twin pin Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude <noreply@anthropic.com>
Fixes #21299
Clause-②: no (narrowing)
Status: the seat answered the open question with C (5952549615 on #21299). The no-class sentence stays
objectql-local (noClassReason) besidecrossClassReason, its precedent. The REST twin pin asserts that each engine position's reason equals the realwheretwin'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 #6367), claim 5949513813, branchclaude/issue-21299-having-no-class-refusal. Basedb0cf2231b,origin/mainmerged at69a12a0952, headb397060136(patch round 1; round 0 was466e632c33).What changes
packages/objectql/src/having-filter.tscrossClassReferenceViolationnow acts on the spec verdict'sno-classanswer as well ascross-class. Its reason iswhere's sentence for the same pair: the referenced column first, then the target, asdriver-sql'sapplyCrossFieldComparisonasks them. No second rule: both answers come from the onecrossFieldComparisonVerdictcall.addDays, only theno-classanswer is taken from the seam (whereasks it before it reads an offset); the cross-class half of anaddDayspair staysoffsetPairViolation's, unchanged.assertAggregationFilterReferencesAreDeclaredis exported (module-level only; neither package entry re-exportshaving-filter.ts).AggregationFilterDeclaration.objectbecomes 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 handedfields, runsassertAggregationFilterReferencesAreDeclaredon each per-aggregation filter before the JSON-column rule, before any row. That is the same function and the same orderengine.aggregateuses. The signature is unchanged. Withoutfields, nothing new is judged.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: formula: retire F7's whole-day copy (lteBound in matches-filter.ts) now that the RLS write check judges the stored form (#21109, PR #21235); its direct-call cases move to the storage-form lowering #21242's directapplyInMemoryAggregationpin 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 realwheretwin onSqlDriver. 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/objectqlminor, BREAKING,Clause-②: no (narrowing),adr-0087: not-required (no-migration-prescription).Baseline and after (H1), through
engine.aggregateonSqlDriver/ better-sqlite3Probe fixture: 6 rows,
photoimage,tagsmultiselect,due_fformula (returnType: date). Basedb0cf2231b; after =be8d2c428b(built dists).wheretwin (base and after){ customer_id: { $ne: { $field: 'photo' } } }(text vs image)INVALID_FILTER/ 400"photo" (type "image") has no scalar stored column a comparison can read.{ amount: { $ne: { $field: 'tags' } } }(number vs multiselect)tagsis null)INVALID_FILTER/ 400"tags" (type "multiselect") ...having: { photo: { $ne: { $field: 'n' } } }on an image groupBy[]INVALID_FILTER/ 400photovsamount: 400,the target field "photo" (type "image") ...{ closed_at: { $lte: { $field: 'due_f' } } }(datetime vs formula)INVALID_FILTER/ 400"due_f" (type "formula") ...{ due_on: { $lte: { $field: 'due_on' } } }After the change, each per-aggregation refusal logs
where's reason verbatim, and the REST pin compares it against the twin's diagnostic.Hypotheses
crossClassReferenceViolationacted only oncross-class, through its two callers.CROSS_FIELD_NO_CLASS_REASONSislist-or-object,file,formula.where's words aredriver-sql's, underINVALID_FILTER/ 400.driver-sql'sapplyCrossFieldComparison(referent arm and target arm), and exported nowhere.objectqldoes not depend ondriver-sql.@objectstack/specand@objectstack/corehold no no-class sentence. Two other no-class wordings exist, neither of themwhere's:@objectstack/formula'sdescribeColumn(behind the exportedfindCrossFieldClassRefusal) and@objectstack/lint's RLS / sharing-rule doors. Per the dispatch, adding one topackages/specis a spec edit, so this run stopped before it. The seat answered with C (5952549615); see the decision record below.packages/verify'scheckDateBucketParitycallsapplyInMemoryAggregation(rows, ast)with nofields, so the reference rules have nothing to judge.objectqlpassesfieldseither (the dogfood parity tests,service-analytics' bucket-key test, thedriver-mongodbparity test).@objectstack/verify16 files / 120 tests passed, and dogfood 168 files passed (1 skipped), 1374 tests passed (9 skipped).having, or atapplyInMemoryAggregationwithoutfields.created_at/updated_at: not judged at the engine positions. Measured onSqlDriver: thewheretwin 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.picked: 3,['c1']and the audit-opt-out 1 of 4.multiple: trueselect, lookup or user is refusedINVALID_FIELD/ 400 by the engine's groupBy door and by itsmin/maxdoor beforehavingis read. Measured onSqlDriverand in the pin, for a groupBy,minandmaxof each. The same holds formultiselectandjson. The only no-class columns that reachhavingare 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-sqlrefuses a formula groupBy itself).The enumeration pin
Spec verdict names: the dispatch's
same-classis the spec'scomparable.where(driver-sql)havingaggregations[i].filterapplyInMemoryAggregation(withfields)comparablecross-classno-classINVALID_FIELD), as inwhereunjudged(type outsideFieldType)CROSS_FIELD_COMPARISON_TYPE_CLASSES, plusselectwithmultiple: true, plus a type outsideFieldType. Each cell's expected answer is computed fromcrossFieldComparisonVerdictitself. 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.Recordkeyed 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.CROSS_FIELD_NO_CLASS_REASONS.EngineAggregateOptionsSchemamust equalwhere,havingandaggregations[i].filter.applyInMemoryAggregation(a position) andbucketDateValue(not a position).wheretwin's. For the no-class rows, the twin's reason, read from its withheld diagnostic, must appear verbatim in the per-aggregation log line, inapplyInMemoryAggregation's diagnostic and, for the file row, in thehavingmessage.Reverse verification (at
623b7474bd, before the merge)The mutation was made with
node scripts/ablation-replace.mjsand its EXIT / INT / TERM trap restore.if (verdict.verdict === 'no-class') return noClassReason(...), with an always-false guard carrying the markerABLATION_21299. Anchor 1 → 0; blobf4c9c878df05→eb68be65ef10.objectqlwas rebuilt (exit 0).ablation-dist-preflightfound the marker in 4 built files.objectql: 10 failed / 303 passed (313). The 10 are the three sweeps, the three filter shapes, thehavingimage shape, the twoaddDays-against-a-formula rows (per-aggregation andhaving, both answered with the arm off), and the audit-oncreated_atvs image control. The comparable, cross-class, census, no-declaration,multiple-seam and same-class rows stayed green, as did all ofengine-aggregate-filter.test.tsandengine-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.f4c9c878df05),git diff HEADis empty,--absentpreflight passes after a rebuild ("marker absent from all 14 built files", whole tree clean), andgit status --porcelainis 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.251922e411gives the pin file's two single-choice columns theiroptions, the legal declarationFieldSchemanow requires;466e632c33corrects one changeset sentence. At251922e411that file was re-run (26 passed) andobjectqlwas re-typechecked (exit 0, test layer held at 40 / 234). The whole gate union was re-run at466e632c33(below).@objectstack/objectql:--project local365 files, 7381 passed;--project repo1 file, 5 passed.VERDICT command-exit 0.@objectstack/restaggregation-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 wascommand-exit 0.pnpm --filter @objectstack/objectql run typecheckexit 0. Its test layer held at 40 files / 234 errors, unchanged, so the new pin file adds none.pnpm --filter @objectstack/rest run typecheckexit 0, test layer 0 errors.turbo run build --filter='@objectstack/dogfood^...' --filter=@objectstack/rest --filter=@objectstack/verify, 63 of 63 tasks.466e632c33:node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderived 65 commands. All 65 ran with exit 0, each exit code recorded beside the printed command.--ranreports "65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN (a DERIVED zero)".251922e411,check:dual-build-cjs-loadsansweredPREREQUISITE NOT MET(exit 3: 8 unrelated packages had nodist/). Those packages were built (41 of 41 turbo tasks from cache), and the gate exited 0 there and in the final pass.check-changeset-fixed,check:authz-resolver,check:error-code-casing,check:filter-alias-parity.check-issue-citations --census,check-shard-attestation,check-test-completeness.pnpm exec eslint --no-inline-config --format jsonon the 5 changed.tsfiles gives 5 results, 0 errors, 0 warnings.eslint.config.mjsis not type-aware (noparserOptions.project; header near:328), so this diff cannot move any untouched file's verdict.pnpm lintis CI's.Deviations (both accepted by the seat, 5952549615; the
resttest file is declared on the cli seat post, 5952559844)packages/rest/src/aggregation-filter-where-doors.test.tsis outside the claim's file surface, and it is a test only. Thewheretwin can only be refused on a realdriver-sql, whichobjectqldoes not depend on. This is the same harness and the same reason as objectql having and the per-aggregation filter judge a cross-field reference's comparison class only on an addDays pair, so a plain datetime-vs-date { $field } answers by formula's whole-day reading where the where twin refuses it 400 #21255's deviation 2.no-classanswer for anaddDayspair too. Before, anaddDayspair against a formula was answered at both positions. One against a file or list column was refused, but in theaddDayspair rule's words: that rule reads those types astext, 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.
objectqlkeeps the local copy (noClassReason), on thecrossClassReasonprecedent, which landed with #21255 (PR #21297, contract review PASS 5944780154). The REST twin pin is the drift guard. Two options were not taken:packages/spec: it adds a published spec export (Clause-②: yes, spec-lane work) and adriver-sqledit 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.formula, which depends onspecalone.Patch round 1 (
b397060136, report 5953205116) changed onlynoClassReason's docblock, +8/−5, every line inside the JSDoc block. At that head:objectql313 andrest26;objectqltypecheck exits 0;be8d2c428b, because no source line outside comments changed since then (proven by the filtered diff).Acceptance notes (nothing filed)
havingrefusal 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 (objectql having and the per-aggregation filter judge a cross-field reference's comparison class only on an addDays pair, so a plain datetime-vs-date { $field } answers by formula's whole-day reading where the where twin refuses it 400 #21255's note). Carrier: 承接者:无.wherewords for a cross-class pair say "is stored as" where the engine positions say "is" (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 #20127's convention for a computed column). The no-class sentence needs no such reading and is byte-identical.Generated by Claude Code