Repository navigation
fix(objectql)!: a per-aggregation filter refuses a scalar comparison on a declared JSON-stored field, in where's words - #21097
Conversation
… 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>
…rals Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG Co-authored-by: Claude <noreply@anthropic.com>
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>
…gregation-filter-json-equality
…erasing them Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG Co-authored-by: Claude <noreply@anthropic.com>
…gregation-filter-json-equality
📓 Docs Drift CheckThis PR changes 3 package(s): 6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 38 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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
|
Contract reviewServed-tier: 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 ① Derived judgments
② Semver level
③ Boundary flagsDev flags (report 5925997383 and the PR body):
open_questions:
Check-runs on the head, read once:
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: VERDICT: PASS |
…gregation-filter-json-equality
…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>
…gregation-filter-json-equality
Contract reviewServed-tier: Round-2 delta review on the at-tier PASS 5926186339 at ① Derived judgments
② Semver level
③ Boundary flagsRound-2 report 5927016404: Round-1 flags, carried forward:
New this round:
No governed-surface path is in the file list ( Implemented-by: VERDICT: PASS Generated by Claude Code |
…rness text state each case in words instead of a tracker number (stage 5b) (objectstack-ai#21139) Fixes objectstack-ai#20513 Clause-②: no **Stage 5b, the last stage of this lane under the maintainer's A / A ruling on the card (5902360492): the `src/`-shipped test strings.** These are the 28 literals (32 occurrences in 6 files) that PR objectstack-ai#20924's body inventoried as stage 5b and release 5922539397 restates: shared case labels, a suite title, two contract `why` texts and the mongod test-harness text. After this PR the `domain:engine` packages hold zero rows in `scripts/doc-authoring-prose-id.baseline.json`. Text only: no error `code`, export, HTTP status, case value or control flow moves. ## What this does Each label or harness line sent the reader to a tracker number for the reason behind it. In form D, as stages 1 to 5a applied it, the number goes. Where the sentence already stated the case, only the citation goes (17 literals). Where the sentence leaned on the number, it now says the case in words (11 literals): | Where | Cited | The text now says | |---|---|---| | `driver-mongodb` `test-mongod.ts` skip reason | 5517 | "retired from default test runs because concurrent downloads made green runs exit 1" | | `driver-mongodb` `test-mongod.ts` abandoned-download warning | 5517 | "Ignoring the MongoDB binary download abandoned by a concurrent-download race" | | `ENGINE_DELETE_DISPATCH_CASES`: where.id plus a CAS operator, `multi: false` | 11009 | "refused, the by-id path would drop the CAS guard" | | `ENGINE_FINDONE_PREDICATE_CASES`: an empty `where` object | 3896 | "read as match-every-row, so NOT a predicate" | | `ENGINE_UPDATE_DISPATCH_CASES`: payload id beside a DIFFERENT scalar where.id | 11142, 5748 | "refused; the payload id no longer silently wins over a where.id naming another row" | | same table: payload id beside an `$in` where.id, no multi | 11230 | "refused; the by-id write would silently drop the declared row SET" | | same table: payload id beside a NULL where.id | 11230 | "refused; a declared where.id that is not one primary key is never silently dropped" | | same table: payload id beside a FALSY scalar where.id | 11142, 11230 | "(it is a scalar, so neither the different-row nor the non-scalar refusal applies)" | | same table: where.id plus a CAS operator, `multi: false` | 11009 | "the redeliver shape, refused rather than dropping its CAS guard" | | same table: payload id plus an extra where predicate, no multi | 11009 | "refused; the by-id write would silently drop the predicate, through the payload door" | | `OBJECT_SCHEMA_MASK_CASES` `write-capable-caller/exempt` `why` | 7020, 6603 | "D4 is DERIVED from the schema write gate (`manage_metadata`)" | Citation only: the contract suite's `serialized-form identity` group title; 3 delete, 2 findOne and 9 update labels; the empty-readable-set `why`; the mongod acquisition-timeout message. Each new clause was read against today's code: `resolveEngineUpdateDispatch` (the different-row refusal fires only on a truthy scalar where.id, the non-scalar refusal only on a declared non-scalar), `ObjectQL.requireFindOnePredicate` (absent, null and an empty object are read as match-every-row), and `OBJECT_SCHEMA_WRITE_CAPABILITIES` (`manage_metadata`, the key the D4 exemption is derived from). The `test-mongod.ts` docblock "Names the issue and the switch" now says "Names the reason and the switch", so it stays true. No other comment or docblock moves; code comments still cite ids, as in every earlier stage. **Re-pins (H4).** The two `objectstack-ai#5517` number pins in `mongodb-memory-server-gate.test.ts` now assert the text that replaced the number: the skip line contains "retired from default test runs because concurrent downloads made green runs exit 1", and the guard's warning contains "download abandoned by a concurrent-download race". Neither pin was deleted. Both were proven able to fail (below). ## Ledger (`scripts/doc-authoring-prose-id.baseline.json`) Recomputed with `node scripts/check-doc-authoring.mjs --census-ledger` (exit 0: no growth refusal) and copied into place. The diff is 25 deleted lines, nothing added. | File | Before | After | |---|---|---| | `drivers/driver-mongodb/src/test-mongod.ts` | 3 | 0 | | `metadata-core/src/contract-suite.ts` | 1 | 0 | | `metadata-core/src/engine-delete-dispatch.ts` | 4 | 0 | | `metadata-core/src/engine-findone-predicate.ts` | 3 | 0 | | `metadata-core/src/engine-update-dispatch.ts` | 18 | 0 | | `metadata-core/src/object-schema-fls-contract.ts` | 3 | 0 | | **This stage** | **32 in 6 files** | **0** | Whole ledger: files 175 → 169, pairs 441 → 428, occurrences 653 → 621. Gate pinned sites 549 → 521. No other file's row moves. After each of the four merges of `origin/main` (below) the recomputed ledger was byte-identical to the committed one. ## Hypotheses, measured - **H1, held.** At `origin/main` `9c8b65aa2` the six files held exactly the inventory: `objectstack-ai#5517` ×3; `objectstack-ai#7856` ×1; `objectstack-ai#11009` ×4 (delete); `objectstack-ai#11767`, `objectstack-ai#3896`, `objectstack-ai#4346` (findOne); `objectstack-ai#11009` ×6, `objectstack-ai#11142` ×4, `objectstack-ai#11230` ×4, `objectstack-ai#5748` ×4 (update); `objectstack-ai#3545`, `objectstack-ai#6603`, `objectstack-ai#7020` (FLS). The gate's census listed 28 literals carrying 32 occurrences. - **H2, held.** The recomputed ledger has 0 rows under `packages/objectql`, `core`, `formula`, `metadata*`, `platform-objects`, `drivers/driver-*` and `plugins/plugin-pinyin-search` (the `domain:engine` path set). The lane's ledger is empty. The whole ledger is not, so the gate's stale-baseline arm keeps its blindness floor. - **H3, nothing keys on the old labels.** Each of the 28 old literals was searched byte for byte across the repo (`dist` excluded). Each was found only in this stage's own source files (two delete labels have update twins), with two exceptions. The tail `download — objectstack-ai#5517)` also sits in a comment in `mongodb-pipeline-evaluator.testkit.ts`. `serialized-form identity (objectstack-ai#7856)` also titles an independent `describe` in `metadata-core/test/canonicalize.test.ts`, a test file outside the ledger. Both are left alone. No CI workflow, `package.json` script, vitest config, snapshot or skip list carries the labels or a `-t` filter on them. The cited numbers appear elsewhere only in workflow comments, an ADR anchor's invariant prose and a merge-parent sha. The labels reach other code only as test names: objectql's `engine-*-dispatch` / `engine-findone-predicate` parity loops, `service-queue`'s `it.each`, and `assertObjectSchemaMaskCase`'s failure text. Each label stays unique within its table (35 / 19 / 18). - **H4, held.** See Re-pins. ## Changeset `.changeset/20513-metadata-core-case-labels-state-the-case.md`: `patch` for `@objectstack/metadata-core`. Its case tables ship in `dist/index.*`, and the contract suite and FLS table ship in `dist/testing.js` (measured after build: the new labels found in those files). There is **no `@objectstack/driver-mongodb` entry**, because `test-mongod.ts` is not published. The package builds `src/index.ts` only, and only test files import `test-mongod.ts`. Measured after build: `mongodSkipReason`, "retired from default test runs", "concurrent-download race" and "waiting for the MongoDB binary" are each in 0 files under `packages/drivers/driver-mongodb/dist`. The positive control `MongoDBDriver` is in 4. ## Text-only proof A TypeScript-AST skeleton of each changed `.ts` file compares merge base `670680e93` with head `24b72b809`. In the skeleton, every string literal and template text is one placeholder, pure-literal `+` operands collapse, and comments are never read. Result: 7 of 7 SAME, with token and literal-slot counts identical per file. Control: the same tool reports DIFF on `rest` `import-template.ts` across `6f1f1c103`, a real code change. ## Tests (head `24b72b809` unless noted) - Build: `turbo run build` over `./packages/*` and `./packages/*/*` under the verify lock, 71/71, after the last merge. - `@objectstack/metadata-core`: 16 files / 298 tests passed; `typecheck` exit 0. - `@objectstack/driver-mongodb`: 30 files passed and 5 skipped, 675 tests passed and 172 skipped (the skips need a MongoDB server); `typecheck` exit 0, including `check:test-typecheck`. - Consumers of the case tables, against the rebuilt `metadata-core` `dist`: - `objectql`, 7 files / 181 tests: `engine-delete-dispatch`, `engine-update-dispatch`, `engine-findone-predicate`, `engine-update-by-id-payload-id`, `engine-update-multi-payload-id`, `engine-write-not-found-gate` and `validation/operator-object-write-value`; - `service-queue` `db-queue-adapter`, 38; - `rest` `meta-object-fls`, 95; - `runtime` `domains/meta-object-fls`, 85; - `metadata-fs` `contract`, 31; - `metadata-protocol` `sys-metadata-repository.contract` and `protocol.update-path-id-wins`, 64. - A verbose run before the merges showed the parity loops naming the new labels. objectql's tests resolve `metadata-core` through `dist`, so this shows they read the rebuilt build. - Re-pin mutations, one-off, on the committed head (`2729901db`), through `scripts/ablation-replace.mjs` in WRAP mode (restore armed on EXIT/INT/TERM): - Skip-reason anchor ×1 → ×0: `mongodb-memory-server-gate.test.ts` 1 failed / 7 passed. - Warning anchor ×1 → ×0: 1 failed / 7 passed. - Each restore: blob equals HEAD's (`22a95643c8`), and `git diff HEAD` is empty. After both, `git diff HEAD` was 0 bytes and `git status --porcelain` 0 lines. ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths) at `24b72b809` derived 69 commands. All 69 ran one at a time from the worktree, each exit 0. `--ran` reports "69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN". Among them: - `check:doc-authoring`: 521 pinned sites across 169 files, no growth, no burn-down unrecorded; - `check:driver-conformance`: 50 covered cells, 0 DEBT, 0 exempt, the same reading as before the change; - `check:engine-double-contract`, `check:dual-build-cjs-loads` (105 require entry points across 66 packages load, read from the post-merge build), `check:nul-bytes`, `check:type-check-coverage` and `check:type-check-debt`. After the first merge (`packages/spec` moved on main): `pnpm --filter @objectstack/spec check:generated` reported all 15 artifacts up to date. Narrowed lint: `eslint --no-inline-config --format json` over the 7 changed `.ts` files reported 7 files, 0 errors and 0 warnings, none ignored (counts from eslint's JSON). The resolved `parserOptions` are `ecmaVersion: latest, sourceType: module`, with no `project` or `projectService`. So no type-aware rule runs, and this diff cannot move an untouched file's verdict. Repo-wide `pnpm lint` is CI's. NOT MEASURED locally (CI's): the live MongoDB cells of `driver-mongodb`, the Test Core shards, Dogfood, and the workspace type-check lanes. ## Acceptance notes - `origin/main` was merged four times while this was open (`1d55ee66e`, `4f1c99822`, `4516a0643`, `24b72b809`; the third brings in objectstack-ai#21097). None of the incoming commits touches a file this PR changes. They touch `objectql`, `rest`, `runtime` and `metadata-protocol`, so those consumer suites were re-run at the final head. `main` moved again after the gate union; the queue rebuilds on it. - PR objectstack-ai#21105, in `driver-mongodb`'s filter and temporal suites, landed before the first merge and shares no file with this PR. - `metadata-core/test/canonicalize.test.ts` titles its own `describe` `serialized-form identity (objectstack-ai#7856)`, and `mongodb-memory-server-gate.test.ts` titles two `describe` blocks `[objectstack-ai#5517] …`. Both are `*.test.ts` files, which the ruling's third category (`src/`-shipped test strings) and the ledger do not cover. They are left as they are: noted, not filed. - The lane children objectstack-ai#20749, objectstack-ai#20751 and objectstack-ai#20753 carry `Blocked-by:` lines naming this card. They unlock when it leaves the open state on merge. --- _Generated by [Claude Code](https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…ntains and $like / $ilike as it refuses the equality family (objectstack-ai#21165) Fixes objectstack-ai#21009 Clause-②: no (narrowing) Patch round 1 executes the seat answer on objectstack-ai#21009 (5930243637): - **Q1 → A.** The `$search` expander matches a multi-valued field by membership, in this PR. - **Q2 → A.** The shared sentence stays byte-identical; its rewrite belongs to objectstack-ai#21067. - **Q3 → A.** The ADR-0087 disposition is `not-required (no-migration-prescription)`. Head `143f4ccd8f` merges `main` at `d34aa58a2`. ## What changes **`@objectstack/core`.** `JSON_COLUMN_INCOMPATIBLE_OPERATORS` is the one set `driver-sql`'s `where` and objectql's per-aggregation `filter` both read since objectstack-ai#21097. It gains the text operators other than the membership pair: - `$startsWith`, `$endsWith`, `$icontains`; - the staged pattern pair `$like` / `$ilike`, which `driver-sql` answers ahead of `FILTER_OPERATORS`. On a JSON-stored column each now gets the `400` the equality family already gets there: - `INVALID_FILTER`, with the same withheld message, byte for byte; - the operator and the field named in the server-log diagnostic, and in the message for a filter marked as the caller's own. Nothing else on that gate moves: - `$contains` / `$notContains` (membership), `$exists`, `$null` and `$empty` answer as before. - No membership reading is invented for a prefix, suffix or case-folded test. - It is one edit to the shared set, with no second copy. - `sql-driver.ts`, `having-filter.ts`, `remote-transport.ts` and `driver-memory` are untouched. **`@objectstack/objectql`.** The search expander (`search-filter.ts`, `fieldClausesForTerm`) matches a field the object declares multi-valued (`isMultiValueField`) by membership: - a term matching option labels becomes one `$contains` per matched option value, replacing the `$in` that is refused there; - any other term, or any term on a field with no options (`tags`, a multi-valued lookup), becomes `$contains` of the term. The declaration is read from the field map the engine already passes in: each entry is the object's whole field definition, `multiple` included. No spec type moves. Scalar fields keep their clauses. The visible cost: to hit a multi-valued field, a term must now equal one of its members or match one of its option labels. SQLite used to match substrings of the serialized array as well, so `wood` found a row tagged `redwood`; it no longer does. ## Measured, before (`origin/main` `7a606a9a3`) and after ### The `where` / per-aggregation filter Measured through `POST /api/v1/data/:object/query` on SQLite and a private PostgreSQL 16.14. The fixture has six rows: `owners` is a multi-value lookup (`d1` holds `u1, u2`; `d3` holds `u3, u1`; `d5` holds only `u10`). | filter on `owners` | SQLite `where`, before | PostgreSQL `where`, before | per-aggregation `filter`, before | after | |:--|:--|:--|:--|:--| | `$startsWith: '['` | 5 rows, every row with a value | 500 `DATABASE_ERROR` | m = 0 | 400 on both faces and both dialects | | `$startsWith: 'u1'` | 0 rows, though two rows hold `u1` | 500 | m = 0 | 400 | | `$endsWith: ']'` | 5 rows | 500 | m = 0 | 400 | | `$icontains: 'U1'` | `d1`, `d3`, `d5` (`d5` holds only `u10`) | 500 | m = 0 | 400 | | `$like` / `$ilike` | `d1`, `d3`, `d5` | 500 | 400, as an operator that face does not evaluate | `where` 400 (this refusal); per-aggregation unchanged | - A `tags` field gave the same results. - A `json` field was already refused all seven text operators at the engine's declared-type door, which still answers first. - The scalar `title` control answered the same rows before and after. ### `$search` The search was measured through the same route, with `search`, on two objects: - the shape of `examples/app-todo`'s `todo_task.tags` (`select`, `multiple: true`, in the auto-default set); - a declared searchable `tags` field. | search | `main`, SQLite / PostgreSQL (measured) | this branch before the expander fix | now, SQLite and PostgreSQL (pinned) | |:--|:--|:--|:--| | task, a label term (`Important`, `quick`) | 400 (the `$in`) / 400 | 400 | 200, the rows holding the matched value | | task, a term only the scalar subject holds (`meeting`) | 200 / 500 | 400 | 200, by the subject | | note, a member (`red`) | 200, `n1` and `n2` / 500 | 400 | 200, `n1` (member) and `n2` (scalar title) | | task, a raw member (`quick_win`); note, a member (`redwood`) | not measured on `main` | — | 200, the rows holding it | | task, a non-member (`zebra`); note, a substring of a member (`wood`) | not measured on `main` | — | 200, no rows | No term answers 400 or 500 any more. The scalar controls (a `select` label, a text fold) are unchanged. ### H2, H3, H4 **What a caller reads (H2).** For `$startsWith` on `owners`, the REST body is cut at the envelope's 500 characters: ```text A constraint in this filter WAS NOT APPLIED: it aims a scalar comparison operator at a field this driver stores as a JSON TEXT column (e.g. ["a","b"]), and such an operator compares that whole serialized text against a single value — it can never equal one member. Use "$contains" for membership ({ "FIELD": { "$contains": "a" } }), or an $or of "$contains" for any-of ({ "$or": [{ "FIELD": { "$contains": "a" } }, { "FIELD": { "$contains": "b" } }] }). Refused rather than compiled because the answ… ``` Per the seat answer, it stays byte-identical here, and objectstack-ai#21067 owns the rewrite. **Turso remote (H3).** `RemoteTransport.buildWhereSQL` compiles its own filters and has no JSON-column gate at all, for the equality family included. This PR leaves it alone, and it is reported for filing. **driver-memory (H4).** It answers each text operator per element. It is unchanged here; once objectstack-ai#21066's shape gate reads this set, it refuses them too. The seat answer orders this PR ahead of PR objectstack-ai#21159. ## Pins - `core` `json-column-operator-refusal.test.ts`: - the set, member for member; - every text operator except the membership pair is in it; - each of the five reads the equality family's message (its SHA-256 and length). - `driver-sql` `sql-driver-21009-json-column-text-operator-refusal.test.ts` (new) is a dialect-cell suite: SQLite always, PostgreSQL and MySQL where provisioned, and the Temporal Conformance job provisions both. On a multi-value lookup and a `tags` column, each of the five gets: - `INVALID_FILTER` / `400` through `find` and `count`; - the operator and field named to an author; - for anyone else, the equality family's message, byte for byte. The same file pins the scalar control (exact rows) and membership still answering. - `driver-sql` `sql-driver-json-column-operator-refusal.test.ts`: the text family moves from the keep-working list to the refused list, on every face. - `driver-sql` `sql-driver-17590-…` and `sql-driver-17343-…` held the text family "unmoved" or "compiling" on a JSON column. They now pin the refusal on all three compilers, with the scalar column unmoved. - `objectql` `engine-aggregate-filter-json-column-refusal.test.ts`: the text family on the per-aggregation filter and its per-row floor; a structured-JSON field still meets the declared-type door first. - `objectql` `search-filter.test.ts`: membership clauses for a multi-valued `select` (label, partial label, no label), for `tags` and for a multi-valued lookup, with the scalar `select` and text controls. - `rest` `data-search-multi-valued-membership.test.ts` (new) runs the table above through `POST /api/v1/data/:object/query` with `search`, on SQLite and PostgreSQL, with MySQL where provisioned. - `rest` `aggregation-filter-json-column-refusal.test.ts`: the text family on both faces, with the per-aggregation body equal to the `where` twin's. - The dogfood `search-conformance.ledger.ts` summary now names membership for a multi-valued field. That half's HTTP proof is the REST file above, because no showcase object carries one in its search set. ## Reverse verification Both fixes were committed before each ablation. Each restore leg proved the file's blob equal to HEAD and an empty `git diff HEAD`. **The core set.** The ablation deleted the five new members (blob `8799778c` to `cbf406f9`), rebuilt, and the preflight found the members `--absent`. | suite | with the members deleted | after the restore | |:--|:--|:--| | core | 2 failed of 8 | 8 passed | | driver-sql | 82 failed of 220 | 219 passed, 1 skipped | | objectql | 13 failed of 106 | 106 passed | | REST | 20 failed of 195 | 130 passed, 65 skipped (MySQL) | **The expander.** The membership branch was disabled (`&& term === 'ablated-21009'`, blob `44a09d96` to `61401d11`), objectql rebuilt, and the preflight found the marker present. | suite | with the branch disabled | after the restore (rebuilt, marker `--absent`) | |:--|:--|:--| | objectql `search-filter.test.ts` | 3 failed of 20 | 20 passed | | REST search file | 20 failed of 22: every search case on SQLite and PostgreSQL answered 400 `INVALID_FILTER` | 22 passed, 11 skipped (MySQL) | Both moved in the expected direction: the pins turned red. ## Tests and gates (head `143f4ccd8f`) | suite | result | |:--|:--| | driver-sql, full | 4244 passed, 96 skipped (SQLite and PostgreSQL; server at Asia/Shanghai, process at America/New_York) | | driver-turso, full | 2218 passed, 33 skipped | | core pins | 8 passed | | objectql pins | 126 passed | | REST pins | 152 passed, 76 skipped (SQLite and PostgreSQL) | | ADR-0061 dogfood proof (`showcase-search.dogfood.test.ts`) and the search-conformance ledger | 7 passed, exit 0 | The full suites at `76d2fd5e8` (`main` `bafb8c949` merged; the last merge brought only `sql-driver.ts`'s sequence region and `driver-turso` into these packages) were: | suite | result | |:--|:--| | objectql `local` | 7070 passed | | REST (SQLite) | 4970 passed, 302 skipped | - Typecheck passed for core, driver-sql, objectql, REST and dogfood. **MySQL: NOT MEASURED locally** (no server); CI's temporal job runs it. - Gates: `dispatch-gates --commands` was derived at `143f4ccd8f` with no paths. It named 70 commands, and 69 exited 0. `check:dual-build-cjs-loads` exited 3 (PREREQUISITE NOT MET, a whole-workspace build): NOT MEASURED. `--ran` reconciled 70 derived, 69 run, 1 NOT MEASURED, 0 UNRUN. The derivation was stale by one `main` commit, a production-dependency bump (`f3b16fc2f`) that changes `package.json` only. - Driver conformance: 50 / 0 / 0 before (`7a606a9a3`) and after (`143f4ccd8f`). - Lint was narrowed to the 12 changed `.ts` files. The proof has three parts: 1. each file resolves a config under `eslint --print-config`; 2. `--format json` reports 12 files, 0 errors and 0 warnings; 3. `eslint.config.mjs` never enables type-aware linting, so no untouched file's verdict can move. - The changeset is `@objectstack/core` and `@objectstack/objectql`, both `minor`, BREAKING. It states the search cost. Its ADR-0087 disposition is `not-required (no-migration-prescription)`. ## Acceptance notes - `SqlDriver.isNonTextColumn`'s docblock says "a text operator is legal against a JSON column". That now holds for the membership pair only. Carrier: none; it is outside this claim's surface. - The registered migration entry `filter-text-operator-declared-type-refused` names `multiselect` / `checkboxes` / `tags` and lookup ids as fields that must keep answering exactly as before. That over-claims once this lands. The seat records it as a spec-lane wording finding, filed at landing. - A view-filter builder offering "starts with" or "ends with" on a multi-valued field now gets a loud 400. Carrier: the objectui filter builder. --- _Generated by [Claude Code](https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…lared JSON-stored field, in the SQL family's words (objectstack-ai#21066) (objectstack-ai#21159) Fixes objectstack-ai#21066 Clause-②: yes (narrowing) On a field the object declares JSON-stored (a `multiple: true` field, `tags` / `multiselect` / `checkboxes`, or a structured-JSON type such as `json`), `driver-memory` now refuses the scalar-comparison family that `driver-sql`'s `where` refuses: `$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$between`, `$in`, `$nin` and implicit equality, whatever the comparand, at any depth. The answer is `INVALID_FILTER` / 400 with the same message. The operator set and the sentence are read from `@objectstack/core` (`JSON_COLUMN_INCOMPATIBLE_OPERATORS`, `jsonColumnOperatorRefusalText`, homed by PR objectstack-ai#21097). There is no third copy. `$contains` / `$notContains` (membership), `$null`, `$exists` and `$empty` keep answering. ## What was wrong (H1, measured at `origin/main` `670680e93` through `engine.find`) A real `ObjectQL` over `InMemoryDriver`, objectstack-ai#21004's six rows (`owners` is a `multiple: true` lookup, `tags` is a `tags` field). Every row reproduces the card: | `where` | before | now | |:--|:--|:--| | `owners` `$eq 'u1'` | `d1`, `d3` (per element) | 400 `INVALID_FILTER` | | `owners` `$in ['u1','u9']` | `d1`, `d3` | 400 | | `owners` `$nin ['u1','u9']` | `d2`, `d4`, `d5`, `d6` | 400 | | `owners` `$gt 'u1'` | `d1`, `d2`, `d3`, `d5` | 400 | | `tags` `$gt 'red'` | `d3` | 400 | | also: bare `{ owners: 'u1' }`, `$ne`, `$gte`, `$lt`, `$lte`, `$between`, `tags $eq`, `{ owners: null }`, `$eq null`, `$ne null`, `$in []`, `$nin []` | rows, per element | 400 | | controls: `owners $contains 'u1'` / `$notContains` / `$null` / `$exists` / `$empty`, `title $in` | rows | unchanged rows | The engine hands the driver the operators as written, except `$ne` / `$nin`. Those arrive inside the spec's null-safe lowering (`$and` of `$or` of `$null: true` and the operator). The gate walks `$and` / `$or` / `$not`, so that shape is refused too. The analytics face (`MemoryAnalyticsService`) answered the same per-element rows. Its SQL echo rendered `owners = 'u1'`, which matches no row over the JSON text the SQL family stores. It now refuses in `query()` and `generateSql()` alike. ## What changed - `filter-refusal.ts`: the shape gate (`assertFilterConditionShape`) takes an optional `FilterFieldDeclarations` (`isJsonStoredField`, `reportWithheld`). It has two arms. Implicit equality on a declared JSON-stored field is refused as `=`, bare. Any operator in the shared set is refused AFTER the existing comparand-shape rules, which is `driver-sql`'s order (comparand gate, then column-type gate). So an array under `$eq` or a one-element `$between` still gets its own refusal first. `jsonStoredFieldOperatorError` builds the error from the shared text: this package's `unsupportedFilterError` envelope, with the withheld diagnostic handed to `reportWithheld` (prefixed `At PATH:`) before the throw. - `memory-driver.ts`: `convertToMongoQuery` passes `this.filterFieldDeclarations(object)`. The population is `isJsonStoredField`, the predicate `$contains` already forks on (`STRUCTURED_JSON_TYPES` or `isMultiValueField`). So the fields where `$contains` asks membership are exactly the fields where the family is refused. The diagnostic goes to the driver's logger at `warn`, the level `driver-sql` uses for its withheld filter diagnostics. That keeps the message's "the full diagnostic is in the server log" true here. - `memory-analytics.ts`: `normalizeFilters` takes the cube. It judges a `where` key (a cube member) by the field it maps to on the cube's table, the same (table, field path) pair `filterContainsTest` reads. Its diagnostic goes to the analytics service's own logger. - `.changeset/21066-memory-json-column-family-refusal.md`: `@objectstack/driver-memory` `minor`, BREAKING banner, `Clause-②: yes (narrowing)`, one ADR-0087 marker `not-required (no-migration-prescription)`. No registered id covers a filter operator on a JSON-stored column. The one migration-registry entry that mentions json columns (`cel-predicate-one-value-comparand-refused`) is the CEL list-comparand surface, not this one. ## Hypotheses, measured - **H1** holds: the table above. - **H2.** The shared home is `@objectstack/core`'s `json-column-operator-refusal.ts`, and both names are read. Before this change `driver-memory` had NO withheld-diagnostic seam: every refusal it raises (the `$null` / `$exists` non-boolean refusals included) names the field in the message, and nothing in the package logged a diagnostic. This change keeps the shared posture: the message names neither field nor operator, and the diagnostic goes to the server log. - **H3.** `driver-sql` decides a JSON column from `jsonFields`, filled from `JSON_COLUMN_TYPES.has(type) or isMultiValueField(field)`. `JSON_COLUMN_TYPES` is `STRUCTURED_JSON_TYPES` plus `MULTI_OPTION_TYPES` plus the driver-internal `object` / `array` aliases. Memory's population is the same predicate less those aliases and less a single-value media field on an unmoved deployment (both recorded on `isJsonStoredField`). On a schemaless direct call (an object never passed through `syncSchema`), nothing is judged. Every operator answers per element as before, as `SqlDriver.isJsonColumn` answers `false` for a table it was never told about. Pinned. A field declared SCALAR (`text`) that holds an array is not judged either. - **H4.** `@objectstack/formula`'s `ORDERING_OPERATORS` docblock does NOT declare a per-element reading for the query plane. It records a non-alignment ("driver-memory's read, a frozen test driver, compares a stored list element by element and keeps returning those rows ... declared on objectstack-ai#15104"). objectstack-ai#15104 is the `$field` cross-field reference card, shut as `not_planned` under the driver-memory investment freeze. It rules nothing about the equality or ordering family on a stored list. So this is a formula-plane record of observed behaviour, not a query-plane contract, and no contract conflict stops the card. That docblock sentence goes stale on declared fields once this lands (see Acceptance notes). - **H5.** objectstack-ai#21009 widens the same shared set to the text operators. Both gates here read the set live, and the new suite iterates `JSON_COLUMN_INCOMPATIBLE_OPERATORS` intersected with this driver's vocabulary, with a floor of the nine `$`-spellings. So once both land, memory refuses `$startsWith` / `$endsWith` / `$icontains` on these fields with no edit here, and the suite pins them. Whichever of the two lands second merges `main` and checks the other's members on its face. The suite's `$contains` control is outside objectstack-ai#21009's scope. ## Pin sweep - The ONE per-element pin the package carried on a declared field flipped: `memory-20444-empty-operator.test.ts` had `{ tags: { $empty: true, $ne: null } }` giving `r2`. It is now a refusal pin (`code` + `status` + the shared message). The composition (`$empty` beside a has-a-value sibling on one multi-value field) is kept through `$null: false`, which answers `r2`. `driver-sql`/SQLite answers that row too, and refuses the `$ne: null` spelling with the same body (measured on the built driver). - `memory-matcher-scalar-comparand-array-value.test.ts` pins per-element answers on a column declared `text`. Those cells still hold, and a header note now says the population there is a scalar-declared column. - Repo-wide: only two tests outside this package bind the real driver (`packages/runtime`'s two ruled consumers). Neither filters a JSON-stored field. No other `INVALID_FILTER` pin moves. ## Tests (final head `13407b76f`) - `pnpm --filter @objectstack/driver-memory exec vitest run --maxWorkers=2`: **70 files, 1703 passed**. The first run after the implementation, before any test edit: 69 files, 1 red of 1613, the per-element pin flipped above. - `pnpm --filter @objectstack/driver-memory run typecheck`: exit 0. `tsc --listFiles` includes both edited test files. - New `memory-21066-json-column-family-refusal.test.ts` (89 tests): - the card's five rows; - every family member on `owners` / `tags` / `meta` (`json`); - ten shapes per field (bare, bare null, `$eq null`, `$ne null`, the engine's `$ne` lowering, `$in []`, `$nin []`, under `$not`, an `$or` branch after a holding one, `$eq` beside `$contains`); - `count` / `findOne` / `updateMany` / `deleteMany` refusing with the table untouched; - the withheld message plus the logged `At filter.$or[1].owners.$gte:` diagnostic; - comparand-first ordering; - eleven answered controls; - the declaration boundary (undeclared object, scalar-declared column, the gate with and without declarations); - the analytics face, `query()` and `generateSql()`, including the `cube.member` spelling, plus its log line and a `$contains` control. - Ablations (`node scripts/ablation-replace.mjs`, wrap mode, run at `de1fef341`; the two later merges touched no file in this package; each restore proven blob == HEAD with `git diff HEAD` empty). The subjects are this package's `src`, imported relatively, so no `dist` is involved: - **A** `filterFieldDeclarations`' predicate forced to `() => false`: **73 red / 37 green** of 110 across the new file and 20444. The 17 green in the new file are exactly the answered controls, the declaration-boundary trio, the premise, the comparand-first case and the analytics `$contains` control. - **B** the implicit-equality arm disabled: **7 red**, exactly the six bare cases and the analytics bare case. - **C** the operator arm disabled: **67 red**, every operator-based refusal pin, the direct-gate test and the 20444 flip. - Gate union, derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths; 7 changed paths, working tree clean) at `13407b76f`: **60 derived, 60 run, every one exit 0**. Reconciled with `--ran`: "60 derived famil(ies) accounted for — 60 run, 0 NOT-MEASURED (a DERIVED zero — all 60 recorded an exit code and none of them is 3)". The same 60 also ran all-zero at the previous merge head `5b75fe461`. At `de1fef341`, `check:dual-build-cjs-loads` exited 3 (PREREQUISITE NOT MET, no dist yet); it measured on both later heads. - Driver conformance ledger (`node scripts/check-driver-conformance.mjs`), before and after: byte-identical. 50 covered cells, 0 DEBT, 0 exempt. The shared matrix has no JSON-column or multi-value case-set, so this invariant is held by the per-package pins, not the matrix. ## Acceptance notes - **The class gains one method.** `InMemoryDriver.filterFieldDeclarations` is tagged `@internal`. It is not private only because the analytics face is another class. `FilterFieldDeclarations` is not exported from the package root, but the method does appear in the published `.d.ts`. objectstack-ai#20984 graded the analogous public `filterContainsTest` as a surface widening (`Clause-②: yes (widening)`). The seat graded it so (5929927010): the line is `yes (narrowing)`, with the semver (`minor`) and the ADR-0087 marker unchanged. - **Surface beyond the claim's list.** `memory-driver.ts` and `memory-analytics.ts` are edited. The gate cannot see a declaration on its own, so the plumbing is the minimum the direction needs, and the analytics face calls the same gate. No open PR touched either file when read before the first edit. - **The AST comparison-node door** (`{ type: 'comparison', field: 'owners', operator: '=', value: 'u1' }`) still answers per element on a declared field: `d1`, `d3`, measured on the built driver. No seam emits that form (the engine and the protocol hand a driver a FilterCondition), so it is reachable only by a direct driver call. Left alone. - **The shared sentence's mechanism clause** ("a field this driver stores as a JSON TEXT column", "$in/$eq matched nothing") is `driver-sql`'s, and is literally untrue of this driver and of the engine's per-aggregation face. The prescription (`$contains`, an `$or` of `$contains`) is right on all three. Inherited as objectstack-ai#21007 shipped it. objectstack-ai#21009 is the PR that next edits the shared home. - `@objectstack/formula`'s `ORDERING_OPERATORS` docblock ("driver-memory's read ... keeps returning those rows") is now true only of undeclared objects. It is a comment, and no claim holds that file. - **Tooling.** With the `turbo` 2.10.10 to 2.11.5 bump now on `main`, every repo-scoped turbo run in an agent session appends a managed "turborepo-agent-rules" block (an HTML-comment-delimited section) to `AGENTS.md`. These include `pnpm exec turbo run build`, `pnpm check:type-check-debt`, `check:query-options-erasure` and `check:slot-lookup`. It happened repeatedly in this worktree and was restored each time, and every gate derivation above was taken on a clean tree; this PR does not touch `AGENTS.md`. Tracked as objectstack-ai#21146 (PR objectstack-ai#21151). --- _Generated by [Claude Code](https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #21007
Clause-②: yes (widening)
A per-aggregation
filternow refuses a scalar comparison on a declared JSON-stored field ($eq,$ne,$gt,$gte,$lt,$lte,$between,$in,$nin, implicit equality) withINVALID_FILTER/ 400, in the wordswhererefuses the same filter in. It no longer counts rows the stored arrays cannot support. The operator set and the refusal text move fromdriver-sqlto@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,jsonColumnOperatorRefusalTextand its return typeJsonColumnOperatorRefusalText), andapplyInMemoryAggregationgains an optional trailingreportWithheldparameter. It also narrows:@objectstack/objectqlrefuses queries it used to answer 200, atengine.aggregateand at the publishedapplyInMemoryAggregationgiven a field map. The changeset therefore carriesminorfor objectql with a BREAKING banner and one ADR-0087 marker,minorfor core, andpatchfor driver-sql, whose output is unchanged. The seat answer on the card (## Seat answer — #21007, comment 5924546829) amended the claim to this surface and thisClause-②.What was wrong (measured before this change,
d1f8ce865)POST /api/v1/data/:object/queryon SQLite and a live PostgreSQL 16.14, over the card's six rows (ownersis amultiple: truelookup, andd1andd3holdu1). Both dialects answered identically:wheretwinmbeforeowners $in ['u1','u9'](the card)INVALID_FILTERowners $nin ['u1','u9'](the card)d1andd3countedowners $eq 'u1'/{ owners: 'u1' }owners $ne/$gt/$lte/$betweentags $eq 'red'['red']loosely=='red')meta(json)$eq/$inowners $contains 'u1'(the prescribed spelling)title $in/$nin/$eq(controls)What changed
@objectstack/core: a newsrc/utils/json-column-operator-refusal.ts, exported from the root besidetemporal-storage-form.js. Theexport *publishes three names:JSON_COLUMN_INCOMPATIBLE_OPERATORS(driver-sql's 22 spellings, member for member),jsonColumnOperatorRefusalText(field, op, bare), and its return typeJsonColumnOperatorRefusalText({ message, diagnostic }). These are the two stringsjsonColumnOperatorErrorbuilt, and nothing else. Each face keeps its own error constructor: driver-sql keeps its [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, and objectql keeps its ADR-0112 envelope.driver-sql(sql-driver.ts): the module-private set and the two template strings are gone.jsonColumnOperatorErrorkeeps its name and signature, and now calls the core builder (hunk at:3298). One import line (with its comment) sits at:153–:156, after the top import block. It is outside the declared:3376–:3460region on purpose, so as not to touch the@objectstack/coreimport block that fix(plugin-security,driver-sql,driver-turso): lower type-blind at the RLS seam without a guard, then delete the F1/F2 whole-day and NOT-rewrite copies (#5930 step 4, group 2) #20988 and [finding]$contains/$notContainson a declared multi-valued or JSON-stored field still answer SUBSTRING on five faces, the analytics RLS read scope among them (u1admits a row storingu10) #20987 edit.assertOperatorAppliesToColumn(in fix(plugin-security,driver-sql,driver-turso): lower type-blind at the RLS seam without a guard, then delete the F1/F2 whole-day and NOT-rewrite copies (#5930 step 4, group 2) #20988's former region) is untouched: it reads the imported set under the same name.objectql(having-filter.ts):assertAggregationFilterIsEvaluablegainsassertAggregationFilterSparesJsonStoredFields. It runs once on the filter after the reference rule, againstdeclaredJsonStoredFields(declared.fields), and before any driver is asked for a row, so an empty table refuses too (objectql: a per-aggregationfilterrefuses an unknown operator only when rows exist —aggregations: [{ filter: { amount: { $median: 1 } } }]answers 400 on a populated table and 200 on an empty one #20122's rule). It walks$and/$or/$notand refuses implicit equality (reported as=, bare, as driver-sql does) and every operator in the shared set, whatever the comparand (nulland[]included). The withheld message is thrown, and the diagnostic, with the aggregation position, goes toreportWithheld, as objectql + REST: the per-aggregationfilterstill lacks four ofwhere's doors — a bad date, anaddDaysnumeric pair, an undeclared{ $field }and an unknown key answer200with every count 0 #20148 does.$contains/$notContains/$exists/$null/$emptykeep answering.checkConditioncarries the same refusal above its no-value exit, but only as a backstop for a row that reaches the arm. Its reach is the row's: an empty row set never gets there, and spec lowering rule 3 puts a$nullarm ahead of every negation that the walker's$orshort-circuit takes first. The gate is the complete door, and the docblocks now say so (round 2, from the review's ①.5).objectql(in-memory-aggregation.ts, round 2, the review's F10 (b)): the publishedapplyInMemoryAggregation(rows, ast, timezone, fields, reportWithheld?)now calls the sameassertAggregationFilterSparesJsonStoredFields(exported fromhaving-filter.ts, not from the package root) once peraggregations[i].filterwhen it is handedfields, before any row is judged. Before this, a direct caller reached only the backstop and got a row-dependent answer. This entry point holds no logger, so the closest seam is a new optional trailingreportWithheld(diagnostic), which receives the field, operator and position; without it the diagnostic is dropped and the 400 is unchanged.engine.aggregatepasses none, because it has already judged and logged the same filter. The gate's declaration parameter is narrowed to just thefieldsandreportWithheldmembers ofAggregationFilterDeclaration, sinceobjectis the reference rule's.objectql(engine.ts): one comment block and thereportWithheldlog line atassertAggregationFilterIsEvaluable's call site. The log line now reads "as it is for the same refusal in a where" instead of "…cross-field comparison…", since it carries two refusals now.Measured findings behind the shape (H1–H5)
SET_MEMBER_DESCRIPTION, the$in/$ninentries ofFILTER_OPERATORSand the$containsdocblock give no per-element reading. driver-sql'swhererefuses (it does not answer membership), so triage's "membership, as driver-sql does" misread it.in,=,nin) are refused earlier at both positions by the nested-relation door, so the evaluator only meets the$forms.@objectstack/core.aggregations[i].filter: every driver's aggregate face refuses a per-aggregation filter 501, and the analytics ObjectQL strategy hands measure filters toengine.aggregate.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/maxover a multi-valued field is refusedINVALID_FIELDat the aggregate door. Measured on InMemoryDriver at the merged head:max(owners)with or withouthavinggives 400INVALID_FIELD. Sohavingcannot 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 headf66bed950(main merged).@objectstack/corejson-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 at8f784959c. Hashes avoid a third literal copy of the sentence.driver-sqlsql-driver-json-column-refusal-shared-text.test.ts, run beside the existing JSON-column, compile-refusal-seam and provenance suites: 248 passed. It checks everyFILTER_OPERATORSmember against the shared set: the driver's thrown message and withheld diagnostic equal the core builder's output.SqlDriverover SQLite) covered all 22 spellings plus bare equality, unmarked and author-marked, message and diagnostic, 46 entries. Before (8f784959c) and after:cmpidentical, sha256dbcf32f5…534b2on both. driver-sql'sdistno longer contains the sentence.objectqlengine-aggregate-filter-json-column-refusal.test.ts(engine-level cell over thefind()read shape): 74 passed. It covers 18 family cases on each ofowners,tagsandmeta(code,status, the$contains/$orprescription, 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,titlecontrols) and the per-row floor.restaggregation-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'serroris the same string as itswheretwin's. fix(objectql): a per-aggregation filter counts $contains on a multi-valued field by membership, as its where twin does #21004'saggregation-filter-array-membership.test.tsstill passes beside it.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.typecheckpassed for core, driver-sql, objectql and rest. At head6e541b101: core, driver-sql (refusal suites), objectqlengine-aggregate*(571 passed) and restaggregation-filter*(150 passed with PostgreSQL) re-ran green.ablation-replace, plus a rebuilt objectqldist, plusablation-dist-preflight --absent):owners/tagscases still got a 400 from the per-row floor, without the logged diagnostic.metanegations ($ne,$nin,$nin [],$not $in, wheremetais null on every row) answered 200{ n: 6, m: 6 }; the mechanism was not traced. The empty table answered 200.git diff HEADempty, objectql rebuilt, preflight shows the marker present in 4 dist files with a clean tree, and both suites green again (74 and 104).engine-aggregate-filter-json-column-refusal.test.ts, 92 passed). Formeta(json)$ne,$ninand$not $in, each on four cells: an empty row set, an empty grouped row set,metanull in every row with the filter as speclowerFilterConditionlowers it (the shape that carries the$nullarm), and the same rows with the filter as written. Each must refuse 400INVALID_FILTERwith exactlyengine.aggregate's message, and hand the diagnostic (field, operator,At aggregations[1].filter.…) toreportWithheld. Also pinned: no reporter means the same refusal; no field map means nothing judged (m: 0, as before); and$containsstill answers.applyInMemoryAggregationcall deleted (ablation-replace, anchor 1 to 0, blobc65412761a90tof530761c0559): 13 of 92 red.c65412761a90,git diff HEADempty, 92 passed again.f66bed950: objectql local full suite 7008 passed (356 files), restaggregation-filter*150 passed / 61 skipped with a live PostgreSQL 16.14, core refusal pin 6 passed, driver-sql refusal pins 141 passed.Gates
node scripts/pm/dispatch-gates.mjs --commands(no paths) derived 70 families at6e541b101.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-erasureandcheck:test-source-alias.check:dual-build-cjs-loadsandcheck: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.--ranreconciliation: 70 derived, 68 run, 2 NOT MEASURED, 0 unrun.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.typecheckpassed for core and objectql. Narrowed lint: 10 changed.tsfiles, 0 errors, 0 warnings.e7bd7f667("type the shared-text pin's find options"):check:query-options-erasure's test surface grew 236 to 237 because of anas anyon afindoptions bag in the new driver-sql test.eslint --no-inline-config --format jsonover the 9 changed.tsfiles reports 9 files, 0 errors, 0 warnings.eslint.config.mjssets noparserOptions.projectand registers no typed rule, so linting is not type-aware and this diff cannot move a verdict on an untouched file. The fullpnpm lintis CI's.Acceptance notes
Round 2, from the at-tier review (5926186339).
applyInMemoryAggregationis gated (above).applyInMemoryAggregationdirect callers and the new optionalreportWithheld.The "flip the
m: 0/m: 6pins" step had nothing to flip. No suite onmainpinned a per-aggregation$in/$nincount on a JSON-stored field (fix(objectql): a per-aggregation filter counts $contains on a multi-valued field by membership, as its where twin does #21004's two suites pin only$contains/$notContains). The full objectql, rest and driver-sql runs found no other pin that this change turns. The refusal pins are new files beside fix(objectql): a per-aggregation filter counts $contains on a multi-valued field by membership, as its where twin does #21004's.A
{ $field }comparand on a JSON-stored field ({ owners: { $eq: { $field: 'title' } } }) is refused by this gate in the JSON-column words. driver-sql'swhererefuses it through its cross-field class rule, in that rule's words. Both answers areINVALID_FILTER/ 400 with the field withheld, so the two faces disagree only on which sentence they print.The REST envelope truncates the shared message at 500 characters on both faces, so it ends "…because the answ…". That is unchanged here by direction, and filed separately by the seat.
Findings for the seat, not filed here:
whereanswers the family per element on a multi-valued field, while the SQL family refuses it (engine-level measurement). The seat files it.$contains/$notContainson a declared multi-valued or JSON-stored field still answer SUBSTRING on five faces, the analytics RLS read scope among them (u1admits a row storingu10) #20987.Generated by Claude Code