Skip to content

fix(objectql)!: a per-aggregation filter refuses a scalar comparison on a declared JSON-stored field, in where's words - #21097

Merged
objectstack-fleet[bot] merged 13 commits into
mainfrom
claude/issue-21007-aggregation-filter-json-equality
Oct 1, 2026
Merged

objectstack-fleet[bot] merged 13 commits into
mainfrom
claude/issue-21007-aggregation-filter-json-equality

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #21007
Clause-②: yes (widening)

A per-aggregation filter now refuses a scalar comparison on a declared JSON-stored field ($eq, $ne, $gt, $gte, $lt, $lte, $between, $in, $nin, implicit equality) with INVALID_FILTER / 400, in the words where refuses the same filter in. It no longer counts rows the stored arrays cannot support. The operator set and the refusal text move from driver-sql to @objectstack/core, byte for byte, so both faces read one set and one sentence.

Clause-② has two halves. It is yes (widening) because @objectstack/core's root gains three exports (JSON_COLUMN_INCOMPATIBLE_OPERATORS, jsonColumnOperatorRefusalText and its return type JsonColumnOperatorRefusalText), and applyInMemoryAggregation gains an optional trailing reportWithheld parameter. It also narrows: @objectstack/objectql refuses queries it used to answer 200, at engine.aggregate and at the published applyInMemoryAggregation given a field map. The changeset therefore carries minor for objectql with a BREAKING banner and one ADR-0087 marker, minor for core, and patch for driver-sql, whose output is unchanged. The seat answer on the card (## Seat answer — #21007, comment 5924546829) amended the claim to this surface and this Clause-②.

What was wrong (measured before this change, d1f8ce865)

POST /api/v1/data/:object/query on SQLite and a live PostgreSQL 16.14, over the card's six rows (owners is a multiple: true lookup, and d1 and d3 hold u1). Both dialects answered identically:

filter where twin per-aggregation m before now
owners $in ['u1','u9'] (the card) 400 INVALID_FILTER 0 400, same body
owners $nin ['u1','u9'] (the card) 400 6, with d1 and d3 counted 400, same body
owners $eq 'u1' / { owners: 'u1' } 400 0 400
owners $ne / $gt / $lte / $between 400 6 / 4 / 1 / 5 400
tags $eq 'red' 400 1 (['red'] loosely == 'red') 400
meta (json) $eq / $in 400 0 / 0 400
owners $contains 'u1' (the prescribed spelling) 2 2 2 (unchanged)
title $in / $nin / $eq (controls) 2 / 4 / 1 2 / 4 / 1 unchanged

What changed

Measured findings behind the shape (H1–H5)

  • H1, the premise, holds. SET_MEMBER_DESCRIPTION, the $in / $nin entries of FILTER_OPERATORS and the $contains docblock give no per-element reading. driver-sql's where refuses (it does not answer membership), so triage's "membership, as driver-sql does" misread it.
  • H2, the set. All ten operators, plus null and empty-list comparands, were answered with a wrong count; none was already refused. The bare infix spellings (in, =, nin) are refused earlier at both positions by the nested-relation door, so the evaluator only meets the $ forms.
  • H3, the home. None existed; per the seat answer, the home is @objectstack/core.
  • H4, where it fires. The engine's in-memory lowering is the only evaluator of aggregations[i].filter: every driver's aggregate face refuses a per-aggregation filter 501, and the analytics ObjectQL strategy hands measure filters to engine.aggregate.
  • H5, having. After fix(objectql)!: engine aggregate asks the field-type table for every row — min / max / avg over a refused type answer INVALID_FIELD / 400 on every driver #21037 (landed, merged here), min / max over a multi-valued field is refused INVALID_FIELD at the aggregate door. Measured on InMemoryDriver at the merged head: max(owners) with or without having gives 400 INVALID_FIELD. So having cannot meet a JSON-stored column, and it is left alone.

Tests

Round 1 numbers were read at 6e541b101; round 2 numbers are marked with the head f66bed950 (main merged).

  • @objectstack/core json-column-operator-refusal.test.ts: 6 passed. It pins the set member for member, and the message and three diagnostics by SHA-256 and length against what driver-sql printed at 8f784959c. Hashes avoid a third literal copy of the sentence.
  • driver-sql sql-driver-json-column-refusal-shared-text.test.ts, run beside the existing JSON-column, compile-refusal-seam and provenance suites: 248 passed. It checks every FILTER_OPERATORS member against the shared set: the driver's thrown message and withheld diagnostic equal the core builder's output.
  • Byte identity of the move. A scratch capture through the built driver-sql (SqlDriver over SQLite) covered all 22 spellings plus bare equality, unmarked and author-marked, message and diagnostic, 46 entries. Before (8f784959c) and after: cmp identical, sha256 dbcf32f5…534b2 on both. driver-sql's dist no longer contains the sentence.
  • objectql engine-aggregate-filter-json-column-refusal.test.ts (engine-level cell over the find() read shape): 74 passed. It covers 18 family cases on each of owners, tags and meta (code, status, the $contains / $or prescription, the field absent from the message, field and operator in the logged diagnostic, and the driver never asked for a row), an empty table (pure and grouped), the logged position, 11 answered cases (membership, null predicates, title controls) and the per-row floor.
  • rest aggregation-filter-json-column-refusal.test.ts: 52 per cell. SQLite passes and a live PostgreSQL 16.14 passes locally; MySQL is a named skip. Every family case asserts that the per-aggregation 400 body's error is the same string as its where twin's. fix(objectql): a per-aggregation filter counts $contains on a multi-valued field by membership, as its where twin does #21004's aggregation-filter-array-membership.test.ts still passes beside it.
  • Full suites. Read at merge 1a226419e: objectql local 6948 passed, rest local 5072 passed / 247 skipped, core 1809 passed. Read before the first merge: driver-sql 3285 passed / 188 skipped. typecheck passed for core, driver-sql, objectql and rest. At head 6e541b101: core, driver-sql (refusal suites), objectql engine-aggregate* (571 passed) and rest aggregation-filter* (150 passed with PostgreSQL) re-ran green.
  • Ablation A: the engine gate call deleted (ablation-replace, plus a rebuilt objectql dist, plus ablation-dist-preflight --absent):
    • objectql suite: 56 of 74 red. The 54 family cases, the empty table and the logged position failed; the 11 answered cases and the 7 floor cases stayed green.
    • rest suite: 92 of 104 red (46 per dialect). Populated owners / tags cases still got a 400 from the per-row floor, without the logged diagnostic. meta negations ($ne, $nin, $nin [], $not $in, where meta is null on every row) answered 200 { n: 6, m: 6 }; the mechanism was not traced. The empty table answered 200.
    • Restored: blob equals HEAD, git diff HEAD empty, objectql rebuilt, preflight shows the marker present in 4 dist files with a clean tree, and both suites green again (74 and 104).
  • Ablation B: the per-row operator floor replaced by a no-op (src, engine suite): 5 red, the 4 operator floor cases and the no-value row; restored blob equals HEAD.
  • Round 2: the direct-caller pins (engine-aggregate-filter-json-column-refusal.test.ts, 92 passed). For meta (json) $ne, $nin and $not $in, each on four cells: an empty row set, an empty grouped row set, meta null in every row with the filter as spec lowerFilterCondition lowers it (the shape that carries the $null arm), and the same rows with the filter as written. Each must refuse 400 INVALID_FILTER with exactly engine.aggregate's message, and hand the diagnostic (field, operator, At aggregations[1].filter.…) to reportWithheld. Also pinned: no reporter means the same refusal; no field map means nothing judged (m: 0, as before); and $contains still answers.
  • Ablation C: the new applyInMemoryAggregation call deleted (ablation-replace, anchor 1 to 0, blob c65412761a90 to f530761c0559): 13 of 92 red.
    • The 9 empty, empty-grouped and lowered-null cells, plus the no-reporter case, answered instead of refusing. That is the backstop's 200.
    • The 3 as-written null-row cells were refused by the backstop but with no diagnostic reported.
    • Restored: blob equals HEAD c65412761a90, git diff HEAD empty, 92 passed again.
  • Round 2 at f66bed950: objectql local full suite 7008 passed (356 files), rest aggregation-filter* 150 passed / 61 skipped with a live PostgreSQL 16.14, core refusal pin 6 passed, driver-sql refusal pins 141 passed.
  • Driver conformance ledger: 50 covered cells, 0 in the DEBT ledger, 0 exempt, both before and after, in both rounds.

Gates

  • node scripts/pm/dispatch-gates.mjs --commands (no paths) derived 70 families at 6e541b101.
  • 68 ran, exit 0. Among them: check:adr-0087-registration, check:changeset-no-major, check:engine-double-contract, check:nul-bytes, check:doc-authoring, check:driver-conformance, check:driver-memory-census, check:query-options-erasure and check:test-source-alias.
  • 2 NOT MEASURED (exit 3, prerequisite not met): check:dual-build-cjs-loads and check:type-check-debt. Both need the whole workspace built; two attempts at that build timed out in the shared verify-lock queue. CI's lint job builds first.
  • --ran reconciliation: 70 derived, 68 run, 2 NOT MEASURED, 0 unrun.
  • Round 2 at f66bed950: re-derived with no paths, the same 70 families; 68 ran with exit 0 and the same 2 were NOT MEASURED (exit 3). --ran: 70 derived, 68 run, 2 NOT MEASURED, 0 unrun. typecheck passed for core and objectql. Narrowed lint: 10 changed .ts files, 0 errors, 0 warnings.
  • An earlier run caught one real finding, fixed in e7bd7f667 ("type the shared-text pin's find options"): check:query-options-erasure's test surface grew 236 to 237 because of an as any on a find options bag in the new driver-sql test.
  • Lint, narrowed and declared: eslint --no-inline-config --format json over the 9 changed .ts files reports 9 files, 0 errors, 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 a verdict on an untouched file. The full pnpm lint is CI's.

Acceptance notes


Generated by Claude Code

claude added 9 commits October 1, 2026 04:21
… text move to @objectstack/core, byte for byte

driver-sql's module-private JSON_COLUMN_INCOMPATIBLE_OPERATORS and the two
texts of jsonColumnOperatorError now live in core's
json-column-operator-refusal.ts, exported from the root; the driver imports
both under the same names and keeps its own error constructor (the #8220
provenance seam). Pins: the set member for member and the texts by SHA-256
against what the driver printed at 8f78495, and the driver's thrown text
against the shared one.

Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG
Co-authored-by: Claude <noreply@anthropic.com>
…on a declared JSON-stored field, in where's words

$in / $nin / $eq / $ne / the orderings / $between and implicit equality on
a field the object declares JSON-stored (a structured-JSON type or a
multi-valued field) are refused INVALID_FILTER / 400 at
assertAggregationFilterIsEvaluable, before any driver is asked, with the
withheld text driver-sql's where refuses them in (now core's) and the
diagnostic handed to the host log. Before, the in-memory evaluator compared the
whole stored array against a scalar: $in counted 0 and $nin counted the rows
it was asked to exclude. checkCondition carries the same refusal as the floor
for a direct caller.

Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG
Co-authored-by: Claude <noreply@anthropic.com>
…re twin's body, on SQLite and live PostgreSQL

Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG
Co-authored-by: Claude <noreply@anthropic.com>
…inor, core minor, driver-sql patch)

Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG
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 1, 2026
@github-actions

github-actions Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/core, @objectstack/driver-sql, @objectstack/objectql, touching 13 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/core/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/api/client-sdk.mdx (via data.query (sdk, the route ledger binds it to POST /api/v1/data/:object/query, selected by route anchor /data/:object/query; the route ledger binds it to POST /data/:object/query))
  • content/docs/api/data-api.mdx (via /data/:object/query (route, a path literal in a comment on a changed line))
  • content/docs/api/wire-format.mdx (via /data/:object/query (route, a path literal in a comment on a changed line))
  • content/docs/data-modeling/queries.mdx (via /data/:object/query (route, a path literal in a comment on a changed line))
  • content/docs/kernel/runtime-services/data-service.mdx (via data.query (sdk, the route ledger binds it to POST /api/v1/data/:object/query, selected by route anchor /data/:object/query; the route ledger binds it to POST /data/:object/query), /data/:object/query (route, a path literal in a comment on a changed line))
  • content/docs/protocol/objectql/query-syntax.mdx (via /data/:object/query (route, a path literal in a comment on a changed line))

⛔ 1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-5.mdx (via /data/:object/query (route, a path literal in a comment on a changed line))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/core/src/index.ts) — pages documenting those are invisible to this run
  • 2 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 — 38 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 8368f1c00551b289b536291e31de297823f64e0b → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 92026124e73d6122f4ed4385c6f38dce2741b96a — the merge of head f66bed95067d12b4d5ba627bcb0f7b9740519968 into base 8368f1c00551b289b536291e31de297823f64e0b, 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 92026124e73d6122f4ed4385c6f38dce2741b96a && git checkout 92026124e73d6122f4ed4385c6f38dce2741b96a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8368f1c00551b289b536291e31de297823f64e0b f66bed95067d12b4d5ba627bcb0f7b9740519968 && git checkout -B drift-repro 8368f1c00551b289b536291e31de297823f64e0b && git merge --no-ff f66bed95067d12b4d5ba627bcb0f7b9740519968

node scripts/docs-audit/affected-docs.mjs --json 8368f1c00551b289b536291e31de297823f64e0b

⚠️ 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 8368f1c00551b289b536291e31de297823f64e0b → 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: 6e541b101c87368a66ee93b6c1f9f01be7cc6af5
Local-runs: none

Inputs, and nothing else: card #21007 (body and comments 5923286198, 5924145107, 5924194074, 5924484320, 5924546829, 5925997383); PR #21097 (body, the 10-file list, the net diff against main at merge-base 63d1a7c37, +987 / -93 over 10 files, 9 commits); the 31 check-runs and 1 commit status on the head, read ONCE. Nothing was built, run or re-run. The byte comparisons below are text diffs of the net diff's own removed and added lines; every source reading is git show of the head.

① Derived judgments

  1. @objectstack/core publishes a widening — right; the count is three, not two. packages/core/src/index.ts adds export * from './utils/json-column-operator-refusal.js', which puts three names on the root: JSON_COLUMN_INCOMPATIBLE_OPERATORS (a ReadonlySet of 22 spellings, the bare infix forms included), jsonColumnOperatorRefusalText(field, op, bare) returning { message, diagnostic }, and the type JsonColumnOperatorRefusalText. The PR body and the changeset count two; the type is the third. Additive, no existing core name moves, and core carries no api-surface baseline to regenerate (only packages/spec does).

  2. The driver-sql move is byte-identical — right. From the net diff's own lines: the removed message template (9 lines) and core's added one are identical; the removed diagnostic (8 lines) and core's are identical; the spelling / on prefix helpers are identical; the removed module-private set equals the exported set member for member, 22 of 22. jsonColumnOperatorError(field, op, bare, subtree) keeps its name and signature and still goes through withheldFilterError(message, diagnostic, subtree), so the [A of #7929] a spec-declared provenance mark set at both read-scope merge boundaries, so the driver can restore the author-facing cross-field diagnostic without re-disclosing policy #8220 provenance seam is untouched. assertOperatorAppliesToColumn (sql-driver.ts :15921–:15923 at the head) reads the imported set under the same name; the only other change in the file is the import at :153–:156. driver-sql's refused set, its refusal text and its public surface are unchanged.

  3. @objectstack/objectql narrows its accept set at exactly one position — right. assertAggregationFilterIsEvaluable now calls assertAggregationFilterSparesJsonStoredFields once per aggregations[i].filter, after the reference rule, against declaredJsonStoredFields(declared.fields) (every STRUCTURED_JSON_TYPES member — json, composite, repeater, record, location, address, vector — plus every isMultiValueField), before driver.find is asked for a row. The walk covers $and / $or (array or single) and $not; it refuses implicit equality (reported as =, bare; null, a Date or an array comparand included) and every member of the shared set, whatever the comparand. It throws the shared withheld sentence through invalidFilterError (ADR-0112, INVALID_FILTER / 400) and hands the diagnostic with the position (At aggregations[2].filter.owners.$nin:) to reportWithheld. No field map judges nothing. The gate runs on the pre-lowering filter (typed, engine.ts :17144), ahead of the spec lowering at :17300–:17315, and walks every branch, so an empty table and a short-circuited $or refuse too. The bare infix spellings are in the set; the dev measured them refused earlier by the nested-relation door at both positions, and if that door ever stops they are refused here in the same words.

  4. The population is one definition on both faces — right. Engine: STRUCTURED_JSON_TYPES.has(type) || isMultiValueField(...). driver-sql: JSON_COLUMN_TYPES (STRUCTURED_JSON_TYPES plus MULTI_OPTION_TYPES, sql-driver.ts :314) || isMultiValueField(field) at :15627, keyed on the table's registered columns. Symmetric on the unknown object: driver-sql does not refuse a table it was never told about, and the engine judges nothing without a field map.

  5. The per-row floor is NOT complete, and is not meant to be — right, with one docblock sentence to correct. checkCondition refuses implicit equality and every set member above its no-value exit, so the floor fires on every row THAT REACHES THE ARM. It does not fire on rows the walker never brings there, and the report's untraced 200s are exactly that: packages/spec/src/data/filter-lowering.ts rule 3 (:37–:44), applied to every per-aggregation filter by resolveThenLowerWhere (engine.ts :17310, after the gate), rewrites a negative-polarity leaf ($ne a non-null value, $nin) into $or: [ f $null true, f spec ] and gives a $not operand an f $null false conjunct; matchesHaving short-circuits $or with some and $and with every. On a row with no value the $null arm answers first and the $ne / $nin / $not $in arm is never walked. The REST fixture's meta is null in all six rows, so under ablation A every meta negation answered 200 m: 6, while owners / tags rows holding a value reached the arm and got the floor's 400; an empty table has no row and no arm. So the floor's docblock sentence "it is the filter's verdict, not the row's" is true of the arm's position and false of its reach. The gate's own docblock already says the right thing ("the per-row walk never meets an empty table or a short-circuited $or branch"), and the gate is the complete door. A docblock correction for the seat, not a contract defect at the card's doors. The engine suite's floor cases call matchesAggregationFilter on rows that reach the arm, so they pin what the floor does, not completeness; right as pins.

  6. One public door reaches the floor without the gate — right to name; no caller takes it. applyInMemoryAggregation(rows, ast, timezone, fields) is exported from objectql's root (index.ts:366) and from ./core (core.ts:78); given fields it calls the walker with declaredJsonStoredFields(fields) and never calls the gate. Through it a direct caller now meets a row-dependent refusal on a JSON-stored scalar comparison, which the changeset's "Who is affected" does not name (the LEVEL covers it: objectql's BREAKING minor). Callers at the head: engine.aggregate (gated upstream) and packages/verify/src/date-bucket-parity.ts:239, which passes no fields, so nothing is judged there; the objectui sibling has no caller. matchesAggregationFilter and checkCondition are on neither export map. Escalated in ③.

  7. $contains / $notContains / $exists / $null / $empty keep answering; having, where and every scalar column untouched — right. having cannot meet a JSON-stored value after fix(objectql)!: engine aggregate asks the field-type table for every row — min / max / avg over a refused type answer INVALID_FIELD / 400 on every driver #21037 (INVALID_FIELD at the aggregate door, re-measured at the merged head), so leaving it alone is right.

  8. engine.ts log line — host-log wording, now true of both refusals it carries; not a contract. Right.

  9. The comparand edge the dev named: owners $eq { $field: 'title' } is refused by this gate in the JSON-column sentence, and by driver-sql's where through its cross-field class rule in that rule's sentence; both INVALID_FILTER / 400 with the field withheld. A difference in which sentence prints, not in the verdict. ③.

  10. Tests — right as pins of the contract above. Core pins the set member for member and the message plus three diagnostics by SHA-256 and length against the pre-move driver, so no third literal copy exists. driver-sql's shared-text pin runs every FILTER_OPERATORS member on a real SqlDriver over SQLite and asserts the thrown message and the withheld diagnostic equal the core builder's. objectql's engine cell: 18 family cases on each of owners, tags, meta, driver.find never called, the empty table pure and grouped, the logged position, 11 answered cases, the floor. REST: the per-aggregation 400 body's error is the same string as its where twin's, with the field absent and the diagnostic in the log; SQLite always, PG and MySQL only with OS_TEST_*_URL.

② Semver level

  • The changeset: objectql minor, core minor, driver-sql patch; first line fix(objectql)!: …; Clause-②: yes (widening); one adr-0087: not-required (no-migration-prescription) marker; a **BREAKING** banner naming the position, every driver and every caller that reaches the engine, and the spelling to write instead. All three packages are in the fixed group.
  • core minor — right: three new root names; yes takes at least minor.
  • objectql minor with the banner — right: an accept-set narrowing is BREAKING; under the launch-window convention (check-changeset-no-major.mjs) a break ships as minor, carried by the banner, the ! summary and the marker. check-adr-0087-registration.mjs's breakingDeclaration reads two signals here (the banner and the bang; widening adds none by design) and so demands the marker; the marker is present, its category is in CATEGORIES, and its why-text closes the other categories on facts (no authorable key or stored row moves, nothing unpublished, no ADR-0087 id covers a filter operator on a JSON column, one ADDED export and no narrowed interface). The migration prescription is in the banner ($contains, an $or of $contains, $not around either).
  • driver-sql patch — right: no published change; the dependency on @objectstack/core already exists, and the fixed group moves the floor together.
  • The Clause-②: line — right. The pair is legal, the PR body and the changeset carry the same line, and it names what the diff publishes as a public-surface change: new core names, additive. One line carries one arm by rule; the narrowing half is declared where the ADR-0087 gate reads it (signals 2 and 3), so nothing is under-declared at any gate. The amendment from the claim's no (narrowing) (5924194074) to yes (widening) is the seat's (5924546829) and is what the PR carries.
  • Gate verdicts on the head: Check Changeset success. check:adr-0087-registration and check:changeset-no-major run inside Lint & Repo Gates, still in progress: not a verdict, and the dev's local exit 0 is not one either.

③ Boundary flags

Dev flags (report 5925997383 and the PR body):

open_questions:

  • Round 0 (5924484320) Q1, the home — answered by the seat (5924546829: A, @objectstack/core) and implemented as ruled: the new core module and root export, driver-sql re-pointed byte-identically with its refused set unchanged, objectql importing at the one-time seam, the floor kept.
  • Round 0 Q2, disclosure — answered (A, where's posture) and implemented: the message names neither the field nor the operator (core pin not.toContain('secret_col'); engine and REST pins not.toContain the field), and the diagnostic with field, operator and position goes to reportWithheld.
  • Round 1 (5925997383): open_questions: [].

Check-runs on the head, read once:

  • completed, success (12): Auto Label; No other open PR may claim the same single-writer path; Type Check · source gates; Check Documentation Links; filter; Part-of PR must not also close its card; Governed Surface Queue Guard; Check PR Size; The card this PR closes must claim this branch; Check Changeset; Flag docs affected by code changes; No other open PR may claim the same issue. Commit status Vercel: success.
  • completed, skipped (3): Build Docs; Console Pin Gate; Packed-tarball smoke (opt-in).
  • in progress (16), each one NOT a verdict: Build Core; Test Core 1/6, 2/6, 3/6, 4/6, 5/6, 6/6; Dogfood Regression Gate 1/3, 2/3, 3/3; Dogfood Verify CLI; Temporal Conformance (live PG + MySQL); Type Check · workspace; Type Check · consumer gates; Type Check · debt ledger; Lint & Repo Gates. The landing waits on every check green; that read is the owning seat's, not this record's.

No governed-surface path is in the file list. The PR is a draft. This at-tier record is the one the BREAKING changeset owes (claim 5924194074).

Implemented-by: claude/issue-21007-aggregation-filter-json-equality
Reviewed-by: session_01Ujdtvqs7ree7WyQmEDwEnG

VERDICT: PASS

claude added 3 commits October 1, 2026 07:02
…n each filter before any row, as engine.aggregate does

Given a field map, the published applyInMemoryAggregation reached the per-row
backstop without the one-time gate, so an empty row set and a row the lowered
filter's $null arm decided first answered 200 where engine.aggregate refuses
400. It now calls assertAggregationFilterSparesJsonStoredFields (exported from
having-filter.ts, the same function) once per aggregations[i].filter, and takes
an optional reportWithheld for the withheld diagnostic. The floor's docblock
now says what it is: a backstop for a row that reaches the arm.

Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG
Co-authored-by: Claude <noreply@anthropic.com>
…ggregation's direct callers

Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

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

Round-2 delta review on the at-tier PASS 5926186339 at 6e541b101. Inputs, and nothing else: card #21007 (body and comments 5923286198, 5924145107, 5924194074, 5924484320, 5924546829, 5925997383, 5927016404); PR #21097 (body, the 11-file list, the net diff against main at merge-base 8368f1c00, +1124 / -94, 13 commits); the 41 check-runs and 1 commit status on the head, read once. Nothing was built, run or re-run. The whole net diff was judged; the commits after 6e541b101 are fd30845fc (the fix), 13e0104ca (the changeset) and two merges of main. The merge f66bed950 brings main's own +10 to core/src/index.ts and +29 to sql-driver.ts, which are not this PR's hunks; the net diff against the merge-base carries only the PR's (core index +7, sql-driver +24 / -85), and the file list grew from 10 to 11 by in-memory-aggregation.ts alone. Every source reading below is git show of the head.

① Derived judgments

  1. The published applyInMemoryAggregation, given fields, now refuses the JSON-column family before any row is judged, independent of the rows — right. in-memory-aggregation.ts :144–:150: when fields is present and some aggregation carries a non-empty filter, it calls assertAggregationFilterSparesJsonStoredFields(agg.filter, aggregationFilterClause(index).root, { fields, reportWithheld }) once per aggregations[i].filter, before filterClasses and filterJsonStored are computed, before bucketing, and without reading rows, so an empty row set, an empty grouped row set and a null-valued row set are refused alike. It is the same exported function engine.aggregate reaches through assertAggregationFilterIsEvaluable (:1013), with the same population (declaredJsonStoredFields(fields)), the same set and the same two texts from @objectstack/core, the same invalidFilterError envelope (INVALID_FILTER / 400) and the same diagnostic prefix At aggregations[i].filter…. Lowered or as-written: the walk descends $and / $or (array or single) and $not exactly as assertNodeIsEvaluable, the reference walker and matchesHaving do, every branch, no short-circuit, so a rule-3 $or with a $null arm is walked past the arm to the refused leaf. One gate call per filter, no second walk inside the entry point (the per-row arm in checkCondition is an evaluator arm kept as the backstop, not a walk of the filter), and no second copy: both positions read JSON_COLUMN_INCOMPATIBLE_OPERATORS and jsonColumnOperatorRefusalText from core. Precision, for the record: engine.aggregate's OTHER per-aggregation doors (unknown operator, the no-operator object, the nested-relation door, comparand type and number narrowing, the cross-field reference rule) are not carried by applyInMemoryAggregation, exactly as on main; they are not this card's and the changeset does not claim them.

  2. On the engine's own path the gate now runs twice — right, a redundant judgment, not a contract change. engine.aggregate still passes declaredFields to applyInMemoryAggregation (engine.ts :17451), so after the judgment at :17160 on typed (pre-resolution, pre-lowering, with the logger) the same function runs again inside the entry point on the resolved and lowered filter with a no-op reporter. It cannot refuse what the first passed: a filter token is a fully-wrapped string resolved value-for-value (core filter-tokens.ts walks values, never keys or operators), and the spec lowering's closed output vocabulary ($and, $or, $lt, $gte, $lte, $null) emits a comparison only from $between and $lte, both in the set, plus $null guards outside it; the field map is the same registry object. So the second judgment is idempotent and never logs. The PR body acknowledges it ("engine.aggregate passes none, because it has already judged and logged the same filter"). ③.

  3. A caller that passes no fields behaves exactly as on main — right. The gate sits under if (fields && anyFilter); without fields, filterClasses and filterJsonStored are undefined as before, matchesHaving passes jsonStored?.has(key) === true as false, both backstop arms in checkCondition are guarded by jsonStored, and reportWithheld is never read. The function body outside the new block is byte-identical to the merge-base (the diff touches the import list, the docblock, the parameter and the gate block only). A field map with no JSON-stored member returns at jsonStored.size === 0, also as before. Pinned: "no field map: nothing is judged" (m: 0).

  4. reportWithheld is public surface — right, and counted. applyInMemoryAggregation is on objectql's . entry (index.ts:366) and ./core entry (core.ts:78), the only two in package.json exports. An optional trailing parameter is a widening of a published signature and its .d.ts. The changeset's "Who is affected" names it as the fifth argument and says the diagnostic is dropped without it; the ADR-0087 why-text names it; the PR body lists it under yes (widening). objectql carries no api-surface baseline (only packages/spec does), so nothing to regenerate.

  5. assertAggregationFilterSparesJsonStoredFields is internal — right, and rightly not counted. It is exported from the module having-filter.ts, but having-filter.js is not re-exported from index.ts or core.ts (the only mentions are imports in engine.ts, in-memory-aggregation.ts and two door files), index.ts has no export * line, and package.json exposes no deep path. Its parameter narrowing to the fields and reportWithheld members of AggregationFilterDeclaration is internal too. The PR body says "not from the package root"; the changeset is silent on it. Both right.

  6. The backstop docblocks (round 1's F10 (a)) — corrected, right. The gate's docblock now names itself the complete door and names the spec lowering's rule 3 and the walker's short-circuit as the reasons a row never reaches the arm; checkCondition's arm says BACKSTOP and that its reach is the row's. That is ①.5 of the previous record, written into the source.

  7. Unchanged since 6e541b101, re-read at the head and still right: core's export * publishes three names (set, text builder, return type); driver-sql's move is byte-identical in the net diff's own lines, jsonColumnOperatorError (:3335) keeps its name, signature and withheldFilterError seam, the reader at :15950–:15952 is untouched, and the import at :153–:156 is the file's only reader of the core set after main's merge; the engine gate at assertAggregationFilterIsEvaluable runs after the reference rule and before getDriver; the population is one definition on both faces; $contains / $notContains / $exists / $null / $empty, having, where and every scalar column are untouched; the engine.ts log line is host-log wording.

  8. Round-2 tests — right as pins. The direct-caller block runs three negations ($ne, $nin, $not $in on meta, a json field) on four cells each: an empty row set, an empty grouped row set, null-valued rows with the filter lowered by spec lowerFilterCondition (its $null arm asserted present first), and the same rows as written. Each asserts INVALID_FILTER, 400, message strictly equal to engine.aggregate's for the same filter, the $contains prescription, the field withheld, and the diagnostic to reportWithheld with operator, field and At aggregations[1].filter.. Also pinned: no reporter gives the same refusal; no field map judges nothing; $contains still answers m: 2. The dev's ablation C (13 of 92 red with the call deleted) is the dev's measurement; the check-runs are the verdict.

② Semver level

  • The changeset is unchanged in level: objectql minor with the **BREAKING** banner and the fix(objectql)!: summary, core minor, driver-sql patch; Clause-②: yes (widening) in the changeset and on line 2 of the PR body; one adr-0087: not-required (no-migration-prescription) marker. All three packages are in the fixed group.
  • Round 2's edits match the diff: the why-text now reads "ADDITIONS only (three new core exports and one new optional trailing parameter on applyInMemoryAggregation)"; the banner names the published applyInMemoryAggregation given a field map as narrowing the same way; "Who is affected" names direct callers, the fifth argument and the no-fields case; the core paragraph counts three root exports with the type.
  • core minor — right: three new root names. objectql minor with the banner — right: an accept-set narrowing at engine.aggregate and at the published entry point given fields, declared where check-adr-0087-registration.mjs reads it (the banner and the bang), plus one additive optional parameter, which minor already covers. driver-sql patch — right: no published change.
  • The Clause-②: line — right. yes (widening) names the public-surface additions the diff makes: three core names and one optional parameter. The narrowing arm is carried by the banner and the bang, so nothing is under-declared at any gate.
  • Gate verdicts on the head: Check Changeset (pr-automation.yml, which runs check-adr-0087-registration.mjs and check-changeset-no-major.mjs) is success on the first run and on the re-run; Lint & Repo Gates success; Type Check · workspace, · consumer gates, · debt ledger, · source gates success; Build Core success. The two gate families the dev could not run locally need the built workspace; the CI jobs that build it are green.

③ Boundary flags

Round-2 report 5927016404: open_questions: []. Its one out-of-scope finding, the optional reportWithheld dropping the diagnostic when absent — answered: by design, declared in the changeset and the docblock; the entry point has no logger, a host that wants the diagnostic passes its log, and engine.aggregate logs at its own gate (①.4).

Round-1 flags, carried forward:

New this round:

  • N1 — the engine path's second, idempotent judgment inside applyInMemoryAggregation (①.2). Answered: harmless by construction. The seat may trim it later by having engine.aggregate skip the gate it has already run, or leave it; not a defect.
  • N2 — the engine's other per-aggregation doors are not carried by the published entry point (①.1). Pre-existing, not this card's; named for precision only.
  • N3 — check-runs on the head, read once: 41, all completed; 36 success; 5 skipped (Build Docs; Console Pin Gate; Packed-tarball smoke, opt-in; and the re-run's Check PR Size and Auto Label, whose first runs are success). Commit status Vercel: success. None in_progress: nothing is awaited.
  • The PR is still a draft; readiness and the landing are the owning seat's, not this record's.

No governed-surface path is in the file list (packages/spec is untouched). This at-tier record is the one the BREAKING changeset owes (claim 5924194074), re-rendered on the round-2 head.

Implemented-by: claude/issue-21007-aggregation-filter-json-equality
Reviewed-by: session_01Ujdtvqs7ree7WyQmEDwEnG

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 1, 2026 07:55
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 1, 2026 07:55
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 1, 2026
Merged via the queue into main with commit a11faee Oct 1, 2026
43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21007-aggregation-filter-json-equality branch October 1, 2026 08:18
This was referenced Oct 1, 2026
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