Repository navigation
fix(driver-memory)!: refuse the equality and ordering family on a declared JSON-stored field, in the SQL family's words (#21066) - #21159
Conversation
…lared JSON-stored field The shape gate in front of the query path and the analytics face now refuses a scalar comparison (the shared JSON_COLUMN_INCOMPATIBLE_OPERATORS set, and implicit equality) aimed at a field declared JSON-stored, with INVALID_FILTER / 400 and the shared refusal text from @objectstack/core. The withheld diagnostic goes to the face's logger at warn. Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG Co-authored-by: Claude <noreply@anthropic.com>
…write doors and the analytics face Flip the one per-element pin the suite carried ($ne beside $empty on a tags field) to a refusal pin, keep its composition through $null: false, and note the declared population on the scalar-column array suite. Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG Co-authored-by: Claude <noreply@anthropic.com>
…family refusal Also tag filterFieldDeclarations @internal: it is reachable from the analytics face, not a consumer contract. Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG Co-authored-by: Claude <noreply@anthropic.com>
…mory-json-column-family
…mory-json-column-family
…mory-json-column-family
📓 Docs Drift CheckThis PR changes 1 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 6 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 9 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 a53e51e3e00813bb5881a9194e1a82df6d53935b && git checkout a53e51e3e00813bb5881a9194e1a82df6d53935b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2488b98b48f51e2a1bc5b5e50fc1c206c9565291 2eab11e409f28a6d3be235dfabbd53e50bc124ef && git checkout -B drift-repro 2488b98b48f51e2a1bc5b5e50fc1c206c9565291 && git merge --no-ff 2eab11e409f28a6d3be235dfabbd53e50bc124ef
node scripts/docs-audit/affected-docs.mjs --json 2488b98b48f51e2a1bc5b5e50fc1c206c9565291
|
…narrowing) The seat's answer on the card: InMemoryDriver.filterFieldDeclarations is in the published .d.ts, so the declaration follows the precedent the analogous filterContainsTest set. Level and ADR-0087 marker unchanged. Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: PR #21159 on card #21066, judged against triage's direction 5925785069, the claim 5927990211, the report 5929893869 and the seat's answer 5929927010. Inputs: the card thread, the PR body and file list, the net diff against the merge-base with ① Derived judgmentsEvery accept-set and public-surface change the diff implies, each named right or wrong:
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
…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>
Fixes #21066
Clause-②: yes (narrowing)
On a field the object declares JSON-stored (a
multiple: truefield,tags/multiselect/checkboxes, or a structured-JSON type such asjson),driver-memorynow refuses the scalar-comparison family thatdriver-sql'swhererefuses:$eq,$ne,$gt,$gte,$lt,$lte,$between,$in,$ninand implicit equality, whatever the comparand, at any depth. The answer isINVALID_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 #21097). There is no third copy.$contains/$notContains(membership),$null,$existsand$emptykeep answering.What was wrong (H1, measured at
origin/main670680e93throughengine.find)A real
ObjectQLoverInMemoryDriver, #21004's six rows (ownersis amultiple: truelookup,tagsis atagsfield). Every row reproduces the card:whereowners$eq 'u1'd1,d3(per element)INVALID_FILTERowners$in ['u1','u9']d1,d3owners$nin ['u1','u9']d2,d4,d5,d6owners$gt 'u1'd1,d2,d3,d5tags$gt 'red'd3{ owners: 'u1' },$ne,$gte,$lt,$lte,$between,tags $eq,{ owners: null },$eq null,$ne null,$in [],$nin []owners $contains 'u1'/$notContains/$null/$exists/$empty,title $inThe engine hands the driver the operators as written, except
$ne/$nin. Those arrive inside the spec's null-safe lowering ($andof$orof$null: trueand 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 renderedowners = 'u1', which matches no row over the JSON text the SQL family stores. It now refuses inquery()andgenerateSql()alike.What changed
filter-refusal.ts: the shape gate (assertFilterConditionShape) takes an optionalFilterFieldDeclarations(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 isdriver-sql's order (comparand gate, then column-type gate). So an array under$eqor a one-element$betweenstill gets its own refusal first.jsonStoredFieldOperatorErrorbuilds the error from the shared text: this package'sunsupportedFilterErrorenvelope, with the withheld diagnostic handed toreportWithheld(prefixedAt PATH:) before the throw.memory-driver.ts:convertToMongoQuerypassesthis.filterFieldDeclarations(object). The population isisJsonStoredField, the predicate$containsalready forks on (STRUCTURED_JSON_TYPESorisMultiValueField). So the fields where$containsasks membership are exactly the fields where the family is refused. The diagnostic goes to the driver's logger atwarn, the leveldriver-sqluses for its withheld filter diagnostics. That keeps the message's "the full diagnostic is in the server log" true here.memory-analytics.ts:normalizeFilterstakes the cube. It judges awherekey (a cube member) by the field it maps to on the cube's table, the same (table, field path) pairfilterContainsTestreads. Its diagnostic goes to the analytics service's own logger..changeset/21066-memory-json-column-family-refusal.md:@objectstack/driver-memoryminor, BREAKING banner,Clause-②: yes (narrowing), one ADR-0087 markernot-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
@objectstack/core'sjson-column-operator-refusal.ts, and both names are read. Before this changedriver-memoryhad NO withheld-diagnostic seam: every refusal it raises (the$null/$existsnon-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.driver-sqldecides a JSON column fromjsonFields, filled fromJSON_COLUMN_TYPES.has(type) or isMultiValueField(field).JSON_COLUMN_TYPESisSTRUCTURED_JSON_TYPESplusMULTI_OPTION_TYPESplus the driver-internalobject/arrayaliases. Memory's population is the same predicate less those aliases and less a single-value media field on an unmoved deployment (both recorded onisJsonStoredField). On a schemaless direct call (an object never passed throughsyncSchema), nothing is judged. Every operator answers per element as before, asSqlDriver.isJsonColumnanswersfalsefor a table it was never told about. Pinned. A field declared SCALAR (text) that holds an array is not judged either.@objectstack/formula'sORDERING_OPERATORSdocblock 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 [finding] driver-memory's own reference matcher has no$fieldarm — a cross-field comparand (bare or withaddDays) reaching it is presumably compared as a literal object rather than resolved or refused (grep reading, to be measured) #15104"). [finding] driver-memory's own reference matcher has no$fieldarm — a cross-field comparand (bare or withaddDays) reaching it is presumably compared as a literal object rather than resolved or refused (grep reading, to be measured) #15104 is the$fieldcross-field reference card, shut asnot_plannedunder 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).$startsWith/$icontainson a multi-valued lookup answer 500 on PostgreSQL and a wrong count on SQLite: the text operators other than$containsreach a JSON column unrefused and unruled #21009 widens the same shared set to the text operators. Both gates here read the set live, and the new suite iteratesJSON_COLUMN_INCOMPATIBLE_OPERATORSintersected with this driver's vocabulary, with a floor of the nine$-spellings. So once both land, memory refuses$startsWith/$endsWith/$icontainson these fields with no edit here, and the suite pins them. Whichever of the two lands second mergesmainand checks the other's members on its face. The suite's$containscontrol is outside [finding]$startsWith/$icontainson a multi-valued lookup answer 500 on PostgreSQL and a wrong count on SQLite: the text operators other than$containsreach a JSON column unrefused and unruled #21009's scope.Pin sweep
memory-20444-empty-operator.test.tshad{ tags: { $empty: true, $ne: null } }givingr2. It is now a refusal pin (code+status+ the shared message). The composition ($emptybeside a has-a-value sibling on one multi-value field) is kept through$null: false, which answersr2.driver-sql/SQLite answers that row too, and refuses the$ne: nullspelling with the same body (measured on the built driver).memory-matcher-scalar-comparand-array-value.test.tspins per-element answers on a column declaredtext. Those cells still hold, and a header note now says the population there is a scalar-declared column.packages/runtime's two ruled consumers). Neither filters a JSON-stored field. No otherINVALID_FILTERpin 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 --listFilesincludes both edited test files.memory-21066-json-column-family-refusal.test.ts(89 tests):owners/tags/meta(json);$eq null,$ne null, the engine's$nelowering,$in [],$nin [], under$not, an$orbranch after a holding one,$eqbeside$contains);count/findOne/updateMany/deleteManyrefusing with the table untouched;At filter.$or[1].owners.$gte:diagnostic;query()andgenerateSql(), including thecube.memberspelling, plus its log line and a$containscontrol.node scripts/ablation-replace.mjs, wrap mode, run atde1fef341; the two later merges touched no file in this package; each restore proven blob == HEAD withgit diff HEADempty). The subjects are this package'ssrc, imported relatively, so nodistis involved: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$containscontrol.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(no paths; 7 changed paths, working tree clean) at13407b76f: 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 head5b75fe461. Atde1fef341,check:dual-build-cjs-loadsexited 3 (PREREQUISITE NOT MET, no dist yet); it measured on both later heads.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
InMemoryDriver.filterFieldDeclarationsis tagged@internal. It is not private only because the analytics face is another class.FilterFieldDeclarationsis not exported from the package root, but the method does appear in the published.d.ts. fix(driver-memory): $contains on a multi-valued or JSON-stored field is membership, on every face #20984 graded the analogous publicfilterContainsTestas a surface widening (Clause-②: yes (widening)). The seat graded it so (5929927010): the line isyes (narrowing), with the semver (minor) and the ADR-0087 marker unchanged.memory-driver.tsandmemory-analytics.tsare 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.{ 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.driver-sql's, and is literally untrue of this driver and of the engine's per-aggregation face. The prescription ($contains, an$orof$contains) is right on all three. Inherited as [finding] a per-aggregationfilter$ninon a multi-valued field counts the rows it was asked to exclude, and$incounts none, where the samewhereis refused 400: the aggregation evaluator has no JSON-column equality gate #21007 shipped it. [finding]$startsWith/$icontainson a multi-valued lookup answer 500 on PostgreSQL and a wrong count on SQLite: the text operators other than$containsreach a JSON column unrefused and unruled #21009 is the PR that next edits the shared home.@objectstack/formula'sORDERING_OPERATORSdocblock ("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.turbo2.10.10 to 2.11.5 bump now onmain, every repo-scoped turbo run in an agent session appends a managed "turborepo-agent-rules" block (an HTML-comment-delimited section) toAGENTS.md. These includepnpm exec turbo run build,pnpm check:type-check-debt,check:query-options-erasureandcheck: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 touchAGENTS.md. Tracked as tooling: turbo 2.11.5 writes a managed block into AGENTS.md in every agent worktree; opt out with "agentGuidance": false in turbo.json #21146 (PR chore(turbo): opt out of the agent-guidance block in the root turbo.json #21151).Generated by Claude Code