Skip to content

fix(core)!: the JSON-column gate refuses $startsWith, $endsWith, $icontains and $like / $ilike as it refuses the equality family - #21165

Merged
objectstack-fleet[bot] merged 13 commits into
mainfrom
claude/issue-21009-json-column-text-operators
Oct 1, 2026
Merged

objectstack-fleet[bot] merged 13 commits into
mainfrom
claude/issue-21009-json-column-text-operators

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #21009
Clause-②: no (narrowing)

Patch round 1 executes the seat answer on #21009 (5930243637):

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 #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:

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 #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 #21066's shape gate reads this set, it refuses them too. The seat answer orders this PR ahead of PR #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

claude added 8 commits October 1, 2026 09:02
…er than membership

$startsWith, $endsWith, $icontains and the staged $like / $ilike join
JSON_COLUMN_INCOMPATIBLE_OPERATORS: on a JSON-stored column each matched the
serialization as text (SQLite), failed at query time (PostgreSQL), or counted
nothing (the per-aggregation filter). $contains / $notContains stay out: they
are the membership pair.

Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG
Co-authored-by: Claude <noreply@anthropic.com>
…ly on a multi-valued field, as where does

Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG
Co-authored-by: Claude <noreply@anthropic.com>
… unmoved on a JSON column

Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG
Co-authored-by: Claude <noreply@anthropic.com>
…rs the text family other than membership

Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG
Co-authored-by: Claude <noreply@anthropic.com>
…e refusal set's docblock

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

github-actions Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/core, @objectstack/objectql, touching 5 documentable anchor(s).

4 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))
  • content/docs/api/wire-format.mdx (via /api/v1/data/:object/query (route, a path literal in a comment on a changed line))
  • content/docs/data-modeling/queries.mdx (via /api/v1/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))
What this run could not see
  • 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 — 34 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 d34aa58a2affc87ded426dc6a326edb03534cb62 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json d34aa58a2affc87ded426dc6a326edb03534cb62

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

claude added 2 commits October 1, 2026 11:20
…ership

A field the object declares multi-valued is stored as a JSON array, where
every operator but the membership pair is refused. The search expander
emitted $in on a label match and $icontains otherwise, so one such field in
the resolved set failed the whole search. A label term now becomes one
$contains per matched option value, and any other term $contains of the
term; scalar fields are unchanged.

Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l and removed size/m labels Oct 1, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 143f4ccd8f85d2fac1d5a56b3f8df070a766e160
Local-runs: none

PR #21165 (card #21009), head merging main at d34aa58a2. Inputs: the card body and its seven comments (triage 5923278311, the two serial notes, the claim 5927967753, the round-0 report 5930193856, the seat answer 5930243637, the round-1 report 5932886196), the PR body and 13-file list, the net diff against the merge base, and the 42 check-runs on the head. Judged against the seat answer (Q1 A through the runtime field map, Q2 A byte-identical, Q3 A not-required (no-migration-prescription)); the questions are not re-answered here.

① Derived judgments

  1. The shared set, @objectstack/core JSON_COLUMN_INCOMPATIBLE_OPERATORS — gains exactly $startsWith, $endsWith, $icontains, $like, $ilike; nothing else moves. The core pin lists the set member for member (27 spellings), pins the five present, and pins $contains, $notContains, $null, $exists, $empty absent. One edit, no second copy: at the head the only other listings of the text family (sql-driver.ts TEXT_OPERATORS / LIKE_PATTERN_OPERATORS, having-filter.ts's evaluator list, spec's comparand-type list) are pre-existing operator-family sets, not copies of the refused set; remote-transport.ts carries none. Right.
  2. Both readers read it. driver-sql imports it (sql-driver.ts:157) and assertOperatorAppliesToColumn (:15821) is asked of the operator as written at :16473, ahead of the $contains / $icontains / $startsWith / $endsWith / $like / $ilike arms (:16563 to :16614) and in the bare-value branch (:16689); objectql's having-filter.ts imports it (:192) and reads it in the one-time judgment (:1191) and the per-row floor (:1834). sql-driver.ts, having-filter.ts, remote-transport.ts and driver-memory are not in the file list. The per-aggregation face already refused $like / $ilike as operators it does not evaluate, and the REST twin pins that unchanged. Right.
  3. The narrowing itself. On a JSON-stored column, through where on every lowering face and through the per-aggregation filter: a serialization match (SQLite), a 500 (PostgreSQL), a count of 0 (per-aggregation) each become INVALID_FILTER / 400 with the equality family's withheld message byte for byte (sha and length pins; the find and count faces; the author-marked diagnostic naming operator and field). The scalar text column control answers exact rows on every dialect cell. No membership reading is invented for a prefix, suffix or case-folded test; the prescription stays $contains. Right — this is triage's direction executed.
  4. The expander's predicate. isMultiValuedSearchField asks spec's isMultiValueField({ type, multiple: multiple === true }), the same call driver-sql's isMultiValuedColumn (:1955), having-filter's declaredJsonStoredFields (:973) and driver-memory's isJsonStoredField (:2386) make; the engine passes schema.fields (engine.ts:11519), the object's whole field definitions, so multiple is there to read. Right for the multi-valued half, which is the seat's Q1. The gate's predicate (isJsonField) is wider by STRUCTURED_JSON_TYPES (where the text-operator declared-type door already refused all seven text operators, unchanged) and, inside the ADR-0104 dual-encoding window, a single-valued file / image column — see ③.
  5. The expander's output. A label term becomes one $contains per matched option value (never $in); any other term, or any term on an option-less multi-valued field, becomes $contains: term; a scalar select keeps $in / $icontains, a scalar text field keeps $icontains, the companion clause is untouched. No spec edit (no packages/spec path). Right.
  6. 400 or 500 through the search route — judged none for any shape a search set can hold. The auto-default admits select (multi-valued or not) and the textual types; a declared tags, multi-valued lookup or user passes the text-operator declared-type door with $contains (single-option, multi-option, reference and file-reference classes are its passing set; multiple does not change its verdict), the relation lowering leaves an operator key alone, and driver-sql compiles the per-dialect membership construct (17590 and 17343 pins, three dialects). driver-memory's filterContainsTest on a JSON-stored field is $elemMatch membership, so memory and the SQL family agree that a substring of a member is not a member. The REST file pins ten terms on SQLite and PostgreSQL at 200 with the exact ids. Right.
  7. The visible cost — a substring of a member no longer matches (wood no longer finds redwood) — is stated in the changeset, in the objectql paragraph, in bold. It is a narrowing on the SQLite face only (PostgreSQL answered 500 there). Right.
  8. The pin flips. sql-driver-17590-…: "the rest of the text family is UNMOVED on a JSON column" becomes "REFUSED on a JSON column, and unmoved on a scalar one", three dialects, adding the scalar label control; sql-driver-17343-… branches on op !== '$contains' to assert INVALID_FILTER / 400 per operator and keeps the membership row's real-predicate assertion. Neither test is deleted. sql-driver-json-column-operator-refusal.test.ts moves the three operators from KEPT to REFUSED with the comparands that used to match, adds the staged pair, and still asserts the partition whole. Right.
  9. Public surface. No export added, removed or retyped (JSON_COLUMN_INCOMPATIBLE_OPERATORS keeps ReadonlySet of string); no Zod key, closed-set member, api-surface row or registration in the diff — the four widening tells are absent. Right.
  10. The dogfood ledger. The search-executor row's summary gains the membership clause; state, enforcement and proof are unchanged. checkLedger resolves proof against the dogfood test directory, so the REST file cannot be the row's proof and the comment names it instead. ADR-0061's bar — a driver executes $search AND a dogfood proof asserts a multi-field match over the real HTTP API — is still met by the unchanged showcase-search.dogfood.test.ts (Dogfood Regression Gate green); the membership half is proven at the real route (RestServer route handler → protocol → ObjectQL.find → SqlDriver) on SQLite and PostgreSQL in Test Core. Right, with the MySQL note in ③.

② Semver level

  • Clause-②: no (narrowing) on the PR body and the claim. The line answers one question — does the card widen the declared accept set or the public surface (clause2-line.mjs; check-widening-tells.mjs: pulling code back to the declared contract does not trigger it). The label-term move from 400 to 200 is exactly that: ADR-0061's QueryParams.search already accepted a term on an object whose set holds a multi-valued select; the 400 was the expander emitting an operator the gate refuses, a defect the round-0 report itself filed as pre-existing on main, not a declared refusal. No key, value, export row or registration is added, so yes would claim a widening the diff does not make, and yes (narrowing) with it. The narrowing arm is earned twice — the core refusal and the substring loss on SQLite — and both are breaking. no (narrowing) is the right spelling.
  • Levels. @objectstack/core minor with the **BREAKING** token: right under the launch-window guard (check-changeset-no-major), where a narrowing ships minor with the token, and the (narrowing) arm is itself a breaking signal the ADR-0087 gate reads. @objectstack/objectql minor: right — its change is a narrowing under the same token in the same changeset, and minor is the floor either way. Not patch, not skip-changeset. Check Changeset is green on the head twice (the push run and the body-replacement run).
  • ADR-0087 marker. One marker, not-required (no-migration-prescription), in the changeset body beside the Clause-② line. It holds: no authorable key, spelling or stored shape moves (no spec path); a prefix, suffix or case-folded test has no mechanical membership equivalent, so there is no FROM → TO for objectstack migrate meta, which is this category's meaning; the "write $contains" sentence is a query author's prescription, and the gate's detector accepted it (check-adr-0087-registration --base merge-base runs inside Check Changeset, green). Same disposition as PR fix(objectql)!: a per-aggregation filter refuses a scalar comparison on a declared JSON-stored field, in where's words #21097 on the same set, and the seat's Q3 A. filter-text-operator-declared-type-refused covers declared non-text classes and lists multi-option and lookup ids as its passing control, so it neither already-registers nor covers this diff; its control sentence over-claims once this lands — the seat's spec-lane finding, in ③.

③ Boundary flags

  • Round-0 open questions Q1, Q2, Q3 — answered by the seat (5930243637); the diff executes each: Q1 membership through the runtime field map with no spec key (confirmed: only isMultiValueField is imported, no packages/spec path); Q2 the sentence byte-identical (jsonColumnOperatorRefusalText's body is untouched, the existing sha and length pins stand, the new pin hashes each of the five text operators to the same MESSAGE); Q3 the marker as judged in ②. Answered.
  • Round-1 open_questions — empty. Nothing outstanding.
  • Round-1 out-of-scope finding 1 — Turso remote. RemoteTransport.buildWhereSQL compiles its own filters and has no JSON-column gate at all, equality family included ($nin fail-open, $startsWith '[' every row, $contains by substring, and the expander's new $contains answers by substring there). Confirmed at the head: remote-transport.ts does not import the set. Outside this claim's surface. The seat's to file (class a, named producer).
  • Finding 2 — the migrations-registry control sentence (multiselect / checkboxes / tags, lookup and user ids "must keep answering exactly as before") over-claims once a stored starts_with filter on such a field answers 400 from this door. Escalated to the seat, which already records it as a spec-lane wording finding filed at landing.
  • Finding 3 — SqlDriver.isNonTextColumn's docblock ("a text operator is legal against a JSON column") now holds for the membership pair only; sql-driver.ts is outside the claim. Noted, carrier none — the seat may fold it into [finding] driver-sql's JSON-column refusal is about 800 characters and is cut at the REST envelope's 500, so no caller reads the sentence saying the field and operator were withheld #21067 or the Turso card.
  • Finding 4 — the objectui filter builder offering "starts with" / "ends with" on a multi-valued field now gets a loud 400 where it got a wrong answer or a 500. Noted, carrier the objectui filter builder.
  • Dev deviations. Rounds 0 and 1: the card and the seat answer were read from the PM's local copies after a permission-classifier refusal — process, no effect on the diff. Round 0: turbo rewrote AGENTS.md during builds — not in the file list. Round 1: the dev did not PATCH the PR body; the seat replaced it at this head, and the body read here opens with Fixes #21009 and Clause-②: no (narrowing). The private PostgreSQL was restarted after the usage-limit pause; the local gate derivation was stale by one main commit (f3b16fc2f, package.json only) — moot, the head's check-runs are the verdicts.
  • MySQL. NOT MEASURED locally by the dev. In CI, Temporal Conformance (live PG + MySQL) (green) runs the whole driver-sql suite with both live URLs, so the new dialect file's MySQL cell and the 17590 / 17343 MySQL compile cells ran. The REST package is not in that job's filter list, so the REST search file's and the REST aggregation twin's MySQL cells are named skips in every PR job; MySQL coverage of the search route rests on the driver-level membership-construct pins. Flag for the seat; not a gate.
  • The dual-encoding window. The gate's isJsonField is wider than the expander's predicate on one deployment shape: where mediaColumnIsJson() holds (ADR-0104, file columns not yet moved), a single-valued file / image column is a JSON column, the equality family is already refused there, and this PR refuses the text family there too. A declared-searchable single-valued file / image field — the auto-default excludes those types, and the resolver calls such a declaration narrow and rarely useful — still gets the expander's $icontains and would answer 400 on that deployment, where SQLite answered a substring of the stored JSON and PostgreSQL a 500. Outside the card's fixture and the seat's Q1 (multi-valued). Escalated to the seat to measure or file; not a defect of this diff, whose refusal is the ruling's own.
  • Checks on the head. 42 check-runs, all completed, none in_progress. Required contexts green: Lint & Repo Gates, TypeScript Type Check, Test Core (and its six shards), Dogfood Regression Gate (and its three shards), Build Core, Temporal Conformance (live PG + MySQL), Check Changeset; the four PM guards green; Vercel status success. Skipped by design: Build Docs, Console Pin Gate, Packed-tarball smoke (path filter / opt-in), and Check PR Size / Auto Label on the body-edit re-run after succeeding on the push run.
  • Landing order per the seat answer: this PR lands before PR fix(driver-memory)!: refuse the equality and ordering family on a declared JSON-stored field, in the SQL family's words (#21066) #21159 ([finding] driver-memory answers the equality and ordering family per element on a multi-valued field ($eq / $in / $nin / $gt on a multiple: true lookup), where driver-sql refuses all ten with 400 INVALID_FILTER #21066, driver-memory), which is green and waiting behind it.

Implemented-by: claude/issue-21009-json-column-text-operators
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 14:09
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 1, 2026 14:09
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue 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/l tests tooling

Projects

None yet

2 participants