Skip to content

fix(objectql)!: having and the per-aggregation filter refuse a plain { $field } across two comparison classes, as where does - #21297

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

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-21255-having-class-rule

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21255

Clause-②: no (narrowing)

This is the precondition #21242 waits on: after it lands, no having or per-aggregation filter query reaches @objectstack/formula's lteBound with a cross-class plain reference. #21242 itself is not addressed here.

What changed

having and the per-aggregation filter (aggregations[i].filter) now apply the comparison-class rule to every plain { $field } comparison, as where does, and not only to an addDays pair. A plain reference whose two columns belong to different classes of the spec's CROSS_FIELD_COMPARISON_CLASSES is refused INVALID_FILTER / 400 before any driver is asked for a row.

  • packages/objectql/src/having-filter.ts
  • packages/objectql/src/engine.ts: one argument at the assertHavingIsEvaluable call 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 a file / image groupBy projection as text, 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.
  • Pins: packages/objectql (both doors, empty and populated, grouped and ungrouped) and packages/rest, where the where twin runs on a real SqlDriver.
  • .changeset/21255-having-plain-reference-class.md: minor, BREAKING (accept-set narrowing), ADR-0087 not-required (no-migration-prescription).

Measurements (H1 to H4)

  • H1, confirmed at base 2791138cbf: assertConditionIsEvaluable judged the class only under if (scope.classes && target.addDays !== undefined) (:1425). assertAggregationFilterReferencesAreDeclared judged it only under if (offset !== undefined) (:1329).
  • H2, measured. where's words are driver-sql's (sql-driver.ts applyCrossFieldComparison): "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., under uncompilableFieldReferenceError (INVALID_FILTER, withheld). The having vocabulary and the spec verdict:
    • They agree on the six class names, and on every single-valued FieldType member the spec classes. The having side reads the same five value-class sets, and everything else falls back to text.
    • They disagree on the no-class families: list-or-object (structured-JSON types, multi-option types, multiple: true) and file read as text in the having vocabulary. formula is undefined (not judged) there, while the spec says no class (refused by where). A type outside FieldType is text there and unjudged in the spec.
    • Hence the plain rule asks the spec's verdict directly and does not reuse classOfDeclaredType. The tail of the sentence is pinned against the twin's real diagnostic (see Pins).
  • H3, measured. Each position takes its classes from its own source. The per-aggregation filter reads the object's declared fields (declaredFieldMeta: type plus multiple). having reads the type the query gives each aggregated column: a count / sum / avg is a number, a day bucket a date, a coarser bucket text, and min / max the field's type. When the class cannot be told, nothing is judged; that is the posture an addDays pair already had. Evidence: having with no registered object keeps ['c1'] for last_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.
  • H4, reproduced at base 2791138cbf through engine.aggregate on SqlDriver over better-sqlite3 (probe fixture): aggregations[0].filter { closed_at: { $lte: { $field: 'due_on' } } } answered [{ n: 4 }], and having max(closed_at) $lte { $field: 'due_day' } (due_on day bucket) kept 4 of 6 groups. The where twin answered INVALID_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

Reverse verification

Only the gate was reverted, through node scripts/ablation-replace.mjs. In crossClassReferenceViolation, the anchor if (target === undefined || referent === undefined) return undefined; became if ('ABLATION_21255'.length > 0) return undefined;: the plain rule answers "not judged" at both positions, and the addDays rule is untouched.

  • The tool showed the anchor 1 → 0 and the blob c425c56a9b → 604a038ce3. Rebuild exit 0. node scripts/ablation-dist-preflight.mjs @objectstack/objectql ABLATION_21255: marker present in 4 built files.
  • Under the mutation:
    • objectql: 16 failed | 265 passed (281). The 16 are exactly the 7 per-aggregation and 9 having refusal pins. Every same-class control, both registry-less controls and every addDays pin stayed green.
    • rest: 2 failed | 18 passed (20), the two refusal pins. The three same-class controls stayed green.
  • Restore: blob back to the HEAD hash, git diff HEAD empty. After the rebuild, the preflight with --absent found the marker absent from all 14 built files and the tree clean. Re-run: objectql 281 passed, rest 20 passed.
  • The direction was as predicted: the refusal pins went red, the controls stayed green.

Tests and gates (HEAD df85ac4baa)

Every command was run through bash scripts/pm/os-verify-lock.sh (VERDICT command-exit 0 each 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).
  • The whole @objectstack/objectql suite (vitest run --project local --maxWorkers=2): 360 files, 7108 passed. This ran at 08678c107e, which differs from HEAD only by a second main merge (no objectql file), a doc-comment reflow and the REST test's typed options.
  • Typechecks, at df85ac4baa: pnpm --filter @objectstack/objectql typecheck exit 0, including check:test-typecheck ("40 file(s) / 234 error(s) … held"); pnpm --filter @objectstack/rest typecheck exit 0, test layer "0 error(s)".
  • Consumers whose fixtures carry { $field } on the aggregate path, run against the same tree and still green:
    • packages/rest data-field-comparand-permission.test.ts: 30 passed;
    • packages/services/service-analytics cross-field-engine-fallback, cross-field-offset-dataset and include-relation-cross-field-boundary: 175 passed.
  • Gates: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 65 commands at df85ac4baa. All 65 ran there with exit 0, check:dual-build-cjs-loads and check:type-check-debt included. --ran with the recorded exit codes reports: "65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN … a DERIVED zero".
  • NOT MEASURED, family check-issue-citations --census and check-shard-attestation. Reason: each takes a value from the workflow, so CI runs them.
  • Lint, a proven narrowing: pnpm exec eslint --no-inline-config --format json over the 5 changed source and test files reported 5 results, 0 errors and 0 warnings. eslint.config.mjs sets no parserOptions.project and 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-wide pnpm lint is 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.aggregate on SqlDriver, where the where twin is 400 every time:

    • per-aggregation text vs image: counted 6, at HEAD and with the rule reverted;
    • number vs multiselect: counted 6, at HEAD and with the rule reverted;
    • having on an image groupBy against a count: answered, at HEAD and with the rule reverted;
    • datetime vs a formula, measured at HEAD only: counted 0, because the formula has no value in the raw rows.

    None of these reaches lteBound with 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-aggregation filter still lacks four of where's doors — a bad date, an addDays numeric pair, an undeclared { $field } and an unknown key answer 200 with 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 having refusal 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 existing addDays refusal.


Generated by Claude Code

claude added 8 commits October 2, 2026 00:58
…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>
@github-actions github-actions Bot added size/l 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 18 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 due_on (literal, a string literal in a comment on a changed line))
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 4e6dc2338acbc00eaf98bd8269317a779c6b8c02 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from e68ba3a18443a75ded9f83e17202fbbd5e36a64e — the merge of head df85ac4baabe5a6974e269419b595a274d306c06 into base 4e6dc2338acbc00eaf98bd8269317a779c6b8c02, 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 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

⚠️ 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 4e6dc2338acbc00eaf98bd8269317a779c6b8c02 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: df85ac4baabe5a6974e269419b595a274d306c06
Local-runs: none

Inputs: card #21255 (body; comments 5942982209, 5943357114, 5944459558, 5944487371); PR #21297 (body, the 6-file list, the GitHub diff against merge base 748b2407); packages/spec/src/data/filter-cross-field-comparison-class.ts and packages/drivers/driver-sql/src/sql-driver.ts (applyCrossFieldComparison, uncompilableFieldReferenceError) at the merge base; having-filter.ts, engine.ts, registry.ts and aggregate-field-type-door.ts at base and head through git show; card #21299; and the head's check-runs, read last. Nothing was built, run or re-run.

① Derived judgments

  1. Refused set at the per-aggregation filter — right, and exact. assertAggregationFilterReferencesAreDeclared hands every plain reference to crossClassReferenceViolation with declaredFieldMeta on both sides (type plus multiple: true, the two inputs crossFieldComparisonVerdict reads) and refuses only the cross-class answer. comparable answers as before; no-class (file, list-or-object, formula) and unjudged return undefined; a side the object's field map does not carry returns undefined. The addDays arm is the old offsetPairViolation call, now one branch of an offset !== undefined ternary. Nothing wider than cross-class is refused. The three row-carried names declaredReferenceNames admits beyond the map (id, created_at, updated_at) are judged only when the map carries them; by default it does, because the registry injects AUDIT_FIELD_DEFS.created_at / updated_at as type: 'datetime' (registry.ts near :655, plan.audit), so the datetime pair that matters is judged on an ordinary object.

  2. Refused set at having — right at this head, with one latent seam. assertConditionIsEvaluable routes a reference with addDays to the unchanged offset rule and a plain one to assertSameClassReference, which asks the spec's verdict of each aggregated column's TYPE (columnTypeMeta over aggregatedRowColumnTypes): a day bucket is date, a coarser bucket text, count / count_distinct / sum / avg number, min / max the field's type. The spec classes each of those as the having vocabulary does; a file, image, formula or structured-JSON projection answers no-class and is not judged. Latent seam: columnTypeMeta passes no multiple, so a multiple: true select / radio / lookup / user projection would read as a scalar text. Unreachable at this head: the [finding] two more JSON-stored columns as a group or distinct key answer 500 on PostgreSQL: groupBy on a multiple: true select, and count_distinct on a json field #20808 door refuses a groupBy on a multi-value field, and the spec's min / max rows admit only numeric, temporal and boolean types, so no such column exists in an aggregated row. Noted for objectql having and the per-aggregation filter answer a { $field } comparison against a no-class column (file, multi-valued, formula) where the where twin refuses it 400: the family's close-out card, with a per-position enumeration pin #21299's enumeration pin; not a defect of this diff.

  3. TYPE rather than the having class at the having seam — right. classOfDeclaredType lumps every non-numeric, non-boolean, non-temporal type into text, so judging by it would have refused a file-against-number pair under the cross-class sentence, the narrowing the dispatch forbade; the type lets the spec answer no-class for it. Nothing else having judges moves: types is a new optional EvaluableScope member read by exactly one new branch; the addDays branch is logically unchanged (classes && addDays became addDays, then classes); assertHavingIsEvaluable has one caller (engine.ts :17317), which now passes the fourth argument.

  4. Words — right. crossClassReason is byte-identical to the sentence offsetPairViolation built before, which is applyCrossFieldComparison's cross-class sentence with "is stored as" read as "is" (the objectql having: a { $field } reference with addDays against a non-temporal aggregated column answers by epoch-ms coercion, where SQL push-down refuses the same pair on where — the aggregated row declares no temporal class to judge it by #20127 convention for computed columns). The per-aggregation wire message gains "compared as the same type class", the clause uncompilableFieldReferenceError's wire message carries; the fields, the operator and the reason stay withheld, and the logged diagnostic keeps where's shape (Operator ... on field ... compares against another field ..., which cannot be evaluated here: reason). having names its own projection, as objectql having: a { $field } reference with addDays against a non-temporal aggregated column answers by epoch-ms coercion, where SQL push-down refuses the same pair on where — the aggregated row declares no temporal class to judge it by #20127 does, and columnPairError says "with addDays" only when the reference carries one.

  5. AggregatedColumnClass — right, and not a public-surface change. It becomes an alias of CrossFieldComparisonClass: the same six literals, so every existing use type-checks unchanged. It is not published: neither the . nor the ./core entry of @objectstack/objectql re-exports having-filter.ts; its consumers are two sibling door modules. The changeset's "No export or published type changes" is accurate.

  6. addDays pairs — unchanged, confirmed. offsetPairViolation keeps its guards and its prose; the objectql having: a { $field } reference with addDays against a non-temporal aggregated column answers by epoch-ms coercion, where SQL push-down refuses the same pair on where — the aggregated row declares no temporal class to judge it by #20127 and objectql + REST: the per-aggregation filter still lacks four of where's doors — a bad date, an addDays numeric pair, an undeclared { $field } and an unknown key answer 200 with every count 0 #20148 pins are untouched except one ANSWERED label ("the rule is the offset's" became "one class on both sides", the true reason now). Green in Test Core on this head.

  7. Pins — they prove what they claim. The objectql per-aggregation helper drives 8 cells (native-capable and raw driver, empty and populated, grouped and ungrouped) and asserts INVALID_FILTER / 400, zero aggregate / find calls, one message across cells and 8 logged diagnostics naming both fields and both classes; the having helper drives both doors, empty and populated, zero reads. The packages/rest pins drive POST /api/v1/data/:object/query on a real SqlDriver over better-sqlite3 with closed_at (datetime) added to the fixture, assert zero reads and the twin's 400, and parse the two class names and the trailing sentence out of the twin's own withheld diagnostic (withheldFilterDiagnosticOf, a published driver-sql export) before asserting them inside the per-aggregation log line and the having message. Same-class controls (5 per-aggregation, 3 having, 3 REST) and two registry-less controls answer as before. The ablation anchor (the target === undefined || referent === undefined guard replaced by an always-true return) disables crossClassReferenceViolation at both positions and nothing else; 7 + 9 objectql and 2 REST refusal blocks is exactly the count the diff adds, so "16 and 2 red, controls green" is the only outcome that mutation can produce. Not re-run here.

② Semver level

  • minor with the BREAKING banner is right: ADR-0087's amendment of 2026-09-13 ([Decision] 两条裁决援引同一个 launch-window convention,却给出相反的 changeset 等级(minor vs major)—— 退役一个可写键到底发哪一级? #18003) states that pre-GA a metadata-facing break ships minor, carrying the BREAKING banner and its ADR-0087 disposition entry; check-changeset-no-major refuses major until GA; the objectql CHANGELOG carries the same sentence on more than ten 17.6.0 entries.
  • adr-0087: not-required (no-migration-prescription) is a category check-adr-0087-registration lists (its CATEGORIES, :497) and re-verifies on every run; Check Changeset and Lint & Repo Gates are green on this head. The claim holds on the diff: no spec key, export, stored shape or accepted request shape moves; what is refused is a comparison where already refuses, and which same-class column the author meant is not something a ledger entry can decide.
  • Clause-②: no (narrowing) stands at line start in the changeset (line 7) and in the PR body (line 3).
  • Prose: accurate. "What was accepted before" is the card's two measured queries and the whole-day reading that answered them; "What is refused now" names the six scalar operators, the spec's six classes, both doors, 400 before any read on an empty set as on a populated one, and the withheld-versus-named split per position; the remedy is where's (compare same-class columns); "Unchanged" correctly keeps same-class, addDays, no-declaration and no-class pairs out of this rule.

③ Boundary flags

Implemented-by: claude/issue-21255-having-class-rule
Reviewed-by: session_017xfMoEjKUuSh2xYB8sCozp

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 2, 2026 02:59
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 2, 2026 02:59
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 2, 2026
Merged via the queue into main with commit c2cd651 Oct 2, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21255-having-class-rule branch October 2, 2026 03:25
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…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>
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/l tests tooling

Projects

None yet

2 participants