Skip to content

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

Merged
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-21299-having-no-class-refusal
Oct 2, 2026
Merged

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-21299-having-no-class-refusal

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

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) 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 #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
  • .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.
  • H4, posture kept, and recorded as rows.
  • 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)

Decision record (H2): C

The seat chose C in 5952549615. objectql keeps the local copy (noClassReason), on the crossClassReason precedent, which landed with #21255 (PR #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)


Generated by Claude Code

claude added 7 commits October 2, 2026 10:19
…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>
…, 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>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Oct 2, 2026
@github-actions

github-actions Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql, touching 8 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/protocol/objectql/query-syntax.mdx (via applyInMemoryAggregation (symbol, a top-level function))
What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

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

Which tree this was computed on

This run read content/docs from a75aa9c8873d531e6fec5596a885d32f5f7ffc2a — the merge of head b397060136571159ee4460c881752a8ac0d48734 into base 4b20c847488c42fc521a64857d32274a8341c306, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 4b20c847488c42fc521a64857d32274a8341c306 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…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>
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 2, 2026 13:21
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 2, 2026 13:22
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 2, 2026
Merged via the queue into main with commit ceb4a93 Oct 2, 2026
43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21299-having-no-class-refusal branch October 2, 2026 13:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants