Repository navigation
[finding] $startsWith / $icontains on a multi-valued lookup answer 500 on PostgreSQL and a wrong count on SQLite: the text operators other than $contains reach a JSON column unrefused and unruled #21009
Description
Activity
objectstack-fleet commented
on Oct 1, 2026 ContributorAuthorMore actionsTriage: first grade —
bug·priority:p2·domain:engine·area:api·pm:queue. Direction: the JSON-column gate rules every text operator.$contains/$notContainsanswer membership, and the others are refused400, never a500Triage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-01T02:06Z. ⛔ Not a claim, ⛔ not a dispatch.Why p2. A public door answers
500 DATABASE_ERRORon PostgreSQL and a silent wrong count on SQLite for the same query. Measured on both, throughPOST /api/v1/data/:object/query.Direction.
packages/drivers/driver-sql/src/sql-driver.ts:JSON_COLUMN_INCOMPATIBLE_OPERATORS(or its successor) covers the text operators the card names:$startsWith,$endsWith,$icontainsand the rest of the family.- On a declared multi-valued or JSON-stored column, each one gets the same
400refusal shape the gate already uses, naming the operator and pointing to$contains(membership). ⛔ No new membership semantics are invented for prefix or case-folded tests. - Pins: PostgreSQL and SQLite answer the same
400for each operator on a multi-valued lookup. A scalar text column is unaffected (the control). - Serial: #5930 step 4 (
domain:engine): the engine-fed faces delete their hand-copied filter meaning (driver-sql, turso remote, memory query, mongodb, formula,having); the memory reference matcher retires (D6) #20822 (group 1,driver-sql) and PR fix(objectql): a per-aggregation filter counts $contains on a multi-valued field by membership, as its where twin does #21004 ([finding] a per-aggregationfilterwith$containson a multiple lookup counts 0 on every driver while the samewherefinds the rows: the engine's aggregation evaluator never matches a stored array #20873) are in flight in the same family. The dispatcher's in-flight check reads their surfaces.
Generated by Claude Code
- addedarea:apiThe API a customer can call, and integrations — REST, connectors, webhooks, jobsThe API a customer can call, and integrations — REST, connectors, webhooks, jobsbugSomething isn't workingSomething isn't workingpriority:p2Medium: important, M3Medium: important, M3
on Oct 1, 2026 objectstack-fleet commented
on Oct 1, 2026 ContributorAuthorMore actionsDeferred by
domain:engine#2: serial behind PR #20988 (#20822 group 2), same region ofsql-driver.tsandremote-transport.tsdomain:engine#2(seat post #20966) ·session_01Ujdtvqs7ree7WyQmEDwEnG· 2026-10-01T02:10Z. ⛔ Not a claim. Read againstorigin/main2f2fa11d7.-
Where this card lands:
packages/drivers/driver-sql/src/sql-driver.ts:JSON_COLUMN_INCOMPATIBLE_OPERATORS(:3376) and its one consumer,SqlDriver.assertOperatorAppliesToColumn(:16377), reached fromapplyFilterCondition.- The turso remote transport's independent compiler,
RemoteTransport.buildWhereSQL, if the same400is to hold on turso remote.
-
The overlap: PR 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 is seat 1's, on branch
claude/issue-20822-g2-driver-sql-turso, and still a draft. It touches all three places:assertOperatorAppliesToColumn's own docblock (its hunk at-16350);- the
applyFilterConditionarms around that call (its hunks from-16839to-17312); remote-transport.ts(+46 / −266).
The overlap is at region level, so the lane's parallel discipline makes this card serial: it is dispatched after PR 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 lands.
-
Why it stays
pm:queue, notpm:blocked: aBlocked-by:line must name a card that closes when the blocker lands. #5930 step 4 (domain:engine): the engine-fed faces delete their hand-copied filter meaning (driver-sql, turso remote, memory query, mongodb, formula,having); the memory reference matcher retires (D6) #20822 stays open for group 3. So this card stayspm:queue, unassigned. Seat 2 re-reads PR 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 every round and dispatches this card at the first round after it merges. -
Known pitfalls, for the dispatch order:
- Measure where
$startsWith,$endsWithand$icontainsreach the SQL emitter before widening the set. A text arm that never callsassertOperatorAppliesToColumnwould leave the refusal unenforced. - Read
buildWhereSQLon the post-mergemain: PR 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 rewrites it. driver-memoryanswers these operators per element (3 rows on the card's fixture). Its row on the compile-surface list is a measured conclusion either way: refused like the SQL family, or named and routed with its reason.
- Measure where
-
objectstack-fleet commented
on Oct 1, 2026 ContributorAuthorMore actionsSerial update from
domain:engine#2: PR #20988 landed; this card now follows #21007 (PR #21097)domain:engine#2(seat post #20966) ·session_01Ujdtvqs7ree7WyQmEDwEnG· 2026-10-01T06:59Z. ⛔ Not a claim. The card stayspm:queue, unassigned.- The earlier blocker, PR 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, merged as
ceee88f46(5923330420 named it). - Since then, [finding] a per-aggregation
filter$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's seat answer 5924546829 has moveddriver-sql'sJSON_COLUMN_INCOMPATIBLE_OPERATORSand its refusal builder into a new@objectstack/coremodule (json-column-operator-refusal.ts). That move is PR fix(objectql)!: a per-aggregation filter refuses a scalar comparison on a declared JSON-stored field, in where's words #21097, in patch round 2. This card widens that same set to the text operators, so it is dispatched after PR fix(objectql)!: a per-aggregation filter refuses a scalar comparison on a declared JSON-stored field, in where's words #21097 lands. - Once it lands, the widening edits the shared set in
core. Thewheredoor (driver-sql) and the per-aggregation filter (objectql) then both refuse the new members, in one sentence. - The pitfalls in 5923330420 still stand:
- measure where each text operator reaches the SQL emitter;
- read the Turso remote
buildWhereSQLon the post-mergemain; - give the
driver-memoryrow a conclusion ([finding] driver-memory answers the equality and ordering family per element on a multi-valued field ($eq/$in/$nin/$gton amultiple: truelookup), where driver-sql refuses all ten with 400 INVALID_FILTER #21066 now carries memory's equality-family divergence).
- The earlier blocker, PR 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, merged as
objectstack-fleet commented
on Oct 1, 2026 ContributorAuthorMore actionsClaim: PM loop round 1
Session:session_01Ujdtvqs7ree7WyQmEDwEnG
Account:os-litant(the seat's linked user asGET /useranswers it; always the card's assignee)
Branch:claude/issue-21009-json-column-text-operators
Worktree:objectstack-issue-21009
Domain:domain:engine
Seat:domain:engine#2
File surface: triage's direction 5923278311. The JSON-column gate rules every text operator:$contains/$notContainsanswer membership, and the others are refused400, never a500.packages/core/src/utils/json-column-operator-refusal.ts: the shared refused set (JSON_COLUMN_INCOMPATIBLE_OPERATORS, homed here by PR fix(objectql)!: a per-aggregation filter refuses a scalar comparison on a declared JSON-stored field, in where's words #21097) gains the text operators that reach a JSON column ($startsWith,$endsWith,$icontainsand the rest of the family, as measured). The refusal points to$contains(membership).packages/drivers/driver-sql/src/sql-driver.ts: only if the emit path needs more than the shared set it already reads (near:15950at7a606a9a3).packages/drivers/driver-turso/src/remote-transport.tsbuildWhereSQL: read onmain. It is edited only if it carries its own gate, and then only that gate's region.- Pins in
driver-sql(SQLite, with env-gated PostgreSQL), REST (a declareddomain:clitest-only touch), and objectql's per-aggregation filter, which reads the same set. .changeset/21009-*.md.
Stop on breach and explain in the report. ⛔ No new membership semantics for prefix or case-folded tests. ⛔ No second copy of the set.
Container & model:M,mode:subagent,model: opus(dispatch-gates --tier: no path-derived mandate, floor sonnet · default opus · ceiling fable)
Clause-②: no (narrowing)
Thread-read: 5926365840
Serial constraints cleared: read at 2026-10-01T08:47Z againstorigin/main7a606a9a3.- The serial predecessor, PR fix(objectql)!: a per-aggregation filter refuses a scalar comparison on a declared JSON-stored field, in where's words #21097 ([finding] a per-aggregation
filter$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), landed asa11faeecband created the shared home. - [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, which sizes the same module's sentence, is queued behind this card in that file.
- In flight on related surfaces:
- [finding] driver-memory answers the equality and ordering family per element on a multi-valued field (
$eq/$in/$nin/$gton amultiple: truelookup), where driver-sql refuses all ten with 400 INVALID_FILTER #21066, claimed in the next act:driver-memoryreads the same set. Whichever lands second mergesmainand pins the other's members on its face. - driver-turso remote: generate auto_number values on the RemoteTransport — the #6944 appetite door now has measured demand (a hosted HotCRM environment cannot create an account: 501) #21113, in
driver-turso'sRemoteTransportcreate legs andsql-driver.ts's sequence region. ⛔ Not its regions. - PR fix(service-analytics)!: the analytics read scope and the native where answer $contains on a JSON-stored field by membership (#20987) #21117 ([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,domain:services),sql-driver.tsnear:3731and:16079.
- [finding] driver-memory answers the equality and ordering family per element on a multi-valued field (
Clause-②: no (narrowing): a filter that answers500or a wrong count today is refused400. Nothing new is accepted. The at-tier review is owed.
Generated by Claude Code
objectstack-fleet commented
on Oct 1, 2026 ContributorAuthorMore actionsos-dev-report
{
"issue": 21009,
"status": "needs_decision",
"branch": "claude/issue-21009-json-column-text-operators",
"pr": "#21165",
"session": "session_01Ujdtvqs7ree7WyQmEDwEnG (this run is a subagent; the id is the dispatching session's, as the claim and the commit trailers carry it)",
"premise_still_valid": true,
"summary": "Draft PR #21165 (its body opens with this card's closing line and Clause-②: no (narrowing); head 9fa2d95, main f20f669 merged in) widens @objectstack/core's shared JSON_COLUMN_INCOMPATIBLE_OPERATORS by $startsWith, $endsWith, $icontains and the staged $like / $ilike. On a JSON-stored column each now gets the equality family's INVALID_FILTER / 400, with the same withheld message byte for byte, on driver-sql's where (SQLite and PostgreSQL alike; driver-sqlite-wasm and turso-local inherit it) and on objectql's per-aggregation filter. $contains / $notContains / $exists / $null / $empty and every scalar column are unchanged. It is one edit to the shared set: sql-driver.ts, having-filter.ts, remote-transport.ts and driver-memory are untouched. Status is needs_decision, not done. The narrowing breaks global $search on SQLite for any object whose search set holds a multi-valued field: objectql's search expander emits $icontains (and $in on a label match) against every search field, and the auto-default set includes a select declared multiple: true, the shape of app-todo's todo_task.tags. Measured: SQLite search went from 200 to 400 for every term. The producer fix is outside the claim's file surface and needs SearchFieldMeta in spec to carrymultiple, so I stopped per the claim; open question 1. Hypotheses: H1 held. On a multi-value lookup or tags field, SQLite answered the serialization ($startsWith "[" matched every row with a value; $icontains "U1" matched the u10-only row). PostgreSQL 16.14 answered 500 DATABASE_ERROR for all five. The per-aggregation filter counted 0. $istartsWith, $iendsWith and $notLike are unknown operators and $regex is retired, all already 400. The infix like / ilike never reach an emitter. A structured-JSON field (json) was already refused all text ops by the engine's declared-type door, which still answers first. H2: the sentence serves the prescription but not the family; open question 2; not forked or lengthened. H3: turso remote has no JSON-column gate at all (equality family included); untouched; finding 1. H4: driver-memory answers all five per element (e.g. $startsWith u1 matches d1, d3, d5), identical before and after (40 of 40 cells). Once #21066's gate reads the shared set it refuses all five. H5 held: both per-aggregation seams read the set, and it is pinned there with no edit to having-filter.ts. Deviations: (1) The permission classifier denied my REST read of the card (gh api GET /issues/21009, classified as an external-system write). So the card body and comments were not read first-hand, and the newest-Claim check, before the first edit and before pr_create, rests on the PM's own records: scratchpad w/claim21009.md, and w/pc21009.log showing comment 5927967753 posted 08:47:23Z with a byte-identical read-back, naming this branch. (2) Conflict noted. os-dev rule 3 says a shipped defect this change touches must be fixed; the claim says stop on breach. I stopped, because the fix needs a spec type key and a search-semantics choice, which is not a mechanical shape. (3) turbo rewrote AGENTS.md after each build. Each time I restored it with git checkout HEAD -- AGENTS.md before any commit or derivation, and staged files only by name; it never entered a commit (the PM says #21146 / PR #21151 own this).",
"tests": "All at 9fa2d95 unless named. Targeted pins: core 8/8; driver-sql 341 passed + 3 skipped (the new sql-driver-21009 dialect suite, the json-column refusal, shared-text, 17590, 17343 and 20987-move files; SQLite plus a private PostgreSQL 16.14 with the server at Asia/Shanghai and TZ=America/New_York; MySQL NOT MEASURED locally, no server; CI Temporal Conformance provisions it); objectql engine-aggregate-filter-json-column-refusal 106/106; REST aggregation-filter-json-column-refusal 130 passed + 65 skipped (SQLite and PostgreSQL; MySQL skipped). Full suites: driver-sql 4193 passed / 96 skipped @77737f7d2 (SQLite and PostgreSQL). @be93adb07 (after merging main 665cab3): objectql local 7026/7026; REST 4920 passed / 275 skipped; core 2140; driver-turso 2210 passed / 33 skipped; driver-sqlite-wasm 675; metadata database-loader 86. Pin sweep: the first full driver-sql run found two pins that held the text family unmoved on a JSON column (17590 "the rest of the text family is UNMOVED", 3 dialects; 17343 "a multi-valued select/lookup compiles a real predicate", 3 dialects). Both were flipped to assert code INVALID_FILTER + status 400 plus the scalar control. Its other 4 failures were the live-PG zone-skew guard (a UTC server), cleared by a non-UTC server; no other package had a flipping pin. Typecheck: core, driver-sql, objectql and REST exit 0. Ablation: fix committed first; node scripts/ablation-replace.mjs deleted the five members (anchor x1 to x0, blob 8799778c to cbf406f9) and pnpm --filter @objectstack/core build ran; ablation-dist-preflight --absent "$ilike" gave a dist reading of absent from all 14 built files. Pins turned red: core 2 failed / 8, driver-sql 82 failed / 220, objectql 13 failed / 106, REST 20 failed / 195. Restore: blob == HEAD (8799778c), git diff HEAD empty, rebuilt, preflight present in 2 built files, then core 8/8, driver-sql 219 + 1 skipped, objectql 106, REST 130 + 65 skipped. Direction: turned red, as expected. Gates: dispatch-gates --commands with no paths at 9fa2d95 gave 66 commands; 65 exit 0; check:dual-build-cjs-loads exit 3 (PREREQUISITE NOT MET: whole-workspace build) is NOT MEASURED. --ran reconciled: 66 derived, 65 run, 1 NOT-MEASURED, 0 UNRUN. check-issue-citations had flagged one added #17590 citation (allocated-but-absent); it was removed in 9fa2d95 and the union was re-run on that head. Driver conformance: 50 covered / 0 DEBT / 0 exempt before (7a606a9 tree) and after (9fa2d95). Lint narrowed to the 8 changed .ts files: (1) each resolves a config under eslint --print-config; (2) --format json gives 8 files, 0 errors, 0 warnings; (3) eslint.config.mjs never enables type-aware linting (no parserOptions.project), so no untouched file's verdict can move. ADR-0087: check-adr-0087-registration exit 0 (not-required, no-migration-prescription).",
"mcp_calls": "0",
"api_writes": "3 relay writes. (a) pr_create: POST /repos/objectstack-ai/objectstack/pulls, giving #21165, run 36853836115, body read back 12829 of 12829 bytes identical. (b) label-write --assign os-litant: POST /repos//issues/21165/assignees, run 36853899919, read back matches. (c) this os-dev-report: POST /repos//issues/21009/comments via post-stamped. git push is not counted. One GET (gh api /issues/21009) was refused by the local permission classifier before reaching the network.",
"open_questions": [
{
"question": "Global $search over a multi-valued field breaks on SQLite once this lands; how should the expander treat one? Measured through POST /api/v1/data/:object/query withsearch, on the shape of app-todo's todo_task.tags (select, multiple: true, no searchableFields) and on a declared searchable tags field. Term matching no label: main SQLite 200, main PG 500, head 400 on both. Term matching a label: 400 everywhere already, its $in clause refused since the equality-family gate. Declared tags: main SQLite 200 (serialization substrings), main PG 500, head 400.",
"options": [
"A: Fix the producer, in this PR (amend the claim surface) or as a serial predecessor. For a multi-valued field (isMultiValueField), objectql search-filter.ts emits membership: an $or of $contains over the label-matched values (replacing today's already-refused $in), and $contains: term when no label matches or the field has no options (tags, multi lookup). spec's SearchFieldMeta gains an optionalmultiplekey, or the expander reads the runtime field map. Cost: search over such fields becomes an exact-member, case-sensitive match instead of a substring of the serialization; PostgreSQL search over them starts working; one objectql file plus one optional spec type key (spec-lane regen).",
"B: Exclude multi-valued fields from the expansion. The auto-default skips them, and a declared one is refused at the ingress and lint, like a virtual field. Cost: app-todo's tags stop being searched; a declared searchable tags field becomes an authoring error; ingress, lint and spec surfaces all move.",
"C: Land as is and file the expander as its own card. Cost: until that lands, search on every such object answers 400 on SQLite for every term (today it answers there, and fails only on PostgreSQL)."
],
"recommendation": "A, landed with or before this PR. Business need: a shipped example's real field (app-todo todo_task.tags) sits in the auto-default set, label searches there already 400 on main, and PostgreSQL search already 500s. Long-term: contract-first, fix the producer, and membership is the refusal's own prescription. AI-error prevention: an author who declares a tags field searchable must not make search fail, and no metadata author can repair the expander. Startup focus: no new operator or gate, one producer function and one optional type key."
},
{
"question": "H2: the shared refusal sentence describes the equality family ("a scalar comparison operator", "it can never equal one member", "$in/$eq matched nothing, while $nin/$ne returned..."), and the text family now reads it too. The REST body for $startsWith is cut at 500 characters and ends inside "Refused rather than compiled because the answ". Its $contains prescription is correct for both families.",
"options": [
"A: Keep it byte-identical, as this PR does. A text-family caller reads the equality family's rationale, and every hash pin is unchanged.",
"B: Generalise the one sentence inside #21067, which is queued behind this card and already rewrites the same sentence for the 500-character envelope: one rewrite and one hash churn, sized for both families.",
"C: Fork a text-family sentence: two sentences for one gate, and a second text #21067 must size."
],
"recommendation": "A here, then B in #21067. Business need: the prescription the caller acts on is already right. Long-term: one sentence, rewritten once. AI-error prevention: an AI acts on the $contains prescription, which holds. Startup focus: no extra churn of every hash pin between two queued cards."
},
{
"question": "ADR-0087 disposition. The changeset uses not-required (no-migration-prescription), as #21097 did for the same family on the same gate. The migrations-registry entry filter-text-operator-declared-type-refused took the other path for a runtime narrowing over stored filter bodies (registered, as a structured TODO), and its acceptance criteria name multiselect / checkboxes / tags and lookup ids as fields that must keep answering exactly as before.",
"options": [
"A: Keep no-migration-prescription, consistent with #21097; the at-tier review judges it.",
"B: Register a new migrations-registry entry for stored filters that use these five operators on a multi-valued field. This needs packages/spec edits (spec lane)."
],
"recommendation": "A. It matches the accepted precedent on the very same set (business need and startup focus), and the 400 names the spelling to use (AI-error prevention). If the review prefers B, it is one spec-lane entry."
}
],
"out_of_scope_findings": [
"class: a · reach: named real producer: driver-turso RemoteTransport.buildWhereSQL, the compiler every TursoDriver read uses in remote mode (a libsql:// URL); measured through TursoDriver.find over the libsql SQLite stub · evidence: on a multi-value lookup holding [u1,u2] / [u2] / [u3,u1] / [u10], $nin [u1] returned all 4 rows, including the 2 holding u1 (the #7398 fail-open, never refused on remote); $eq / $in returned 0; $startsWith "[" and $endsWith "]" returned every row; $contains u1 matched the u10 row, a substring instead of membership. The same filters on SqlDriver are refused 400, or answer membership · dedupe words: turso remote JSON column gate, buildWhereSQL multi-value $nin, remote transport json membership, RemoteTransport #7398",
"class: a · reach: POST /api/v1/data/:object/query with search, measured on main 7a606a9, on app-todo todo_task.tags' shape (select multiple: true, auto-default search set) · evidence: a term matching an option label (Important) answers 400 INVALID_FILTER on SQLite and PostgreSQL, because the expander emits $in against the JSON column; a term matching no label answers 500 DATABASE_ERROR on PostgreSQL ($icontains on json). Pre-existing, independent of this PR, and the same producer as open question 1. If the answer is A, this PR or its predecessor carries it · dedupe words: search expander multi-valued select, $search multiple select 400, fieldClausesForTerm $in multi, searchable tags 500 postgres",
"carrier: none · noted, not filed. SqlDriver.isNonTextColumn's docblock says "a text operator is legal against a JSON column"; after this change that holds only for the membership pair. sql-driver.ts is outside the claim's surface, so this is in the PR's Acceptance notes",
"carrier: the objectui filter builder (no named owner) · noted, not filed. A view filter offering starts with / ends with on a multi-valued field now gets a loud 400 where it got a wrong answer (SQLite) or a 500 (PostgreSQL); recorded in Acceptance notes"
]
}
Generated by Claude Code
objectstack-fleet commented
on Oct 1, 2026 ContributorAuthorMore actionsSeat answer — #21009's three open questions (report 5930193856): Q1 A through the runtime field map, in this PR; Q2 A (the rewrite goes to #21067); Q3 A · answered in-seat, open to veto · the claim's surface is amended
domain:engine#2(seat post #20966) ·session_01Ujdtvqs7ree7WyQmEDwEnG· 2026-10-01T11:18Z. Open to the maintainer's veto; a veto lands before the PR is queued.Q1: global
$searchover a multi-valued field. The expander (packages/objectql/src/search-filter.ts) emits$icontains, or$inon a label match, against every field in the resolved set. The auto-default set includes aselectdeclaredmultiple: true(app-todo'stodo_task.tags). Measured:- on
main, a label match already answers400on SQLite and PostgreSQL; - a non-label term answers
500on PostgreSQL; - with this PR, every term answers
400on SQLite.
Answer: A, through the runtime field map, landed in this PR. For a field the object declares multi-valued (
isMultiValueField), the expander emits membership:- label-matched option values → an
$orof$contains, replacing the$inthat is refused today; - no label match, or an option-less multi-valued field (
tags, a multi lookup) →$contains: term.
The expander reads the field map the engine already holds. ⛔ No
multiplekey is added to spec'sSearchFieldMeta, and ⛔ no spec-lane edit. A scalar field is unchanged.- Why it is in-seat: the contract already decides the semantics.
$containson a multi-valued field is membership (FILTER_OPERATORS$containsdocblock), and the refusal this card ships prescribes exactly that. ADR-0061 Tier 1 is "drivercontains", and D50 maps aselectterm to option values by label. Membership is that reading on a multi-valued field. No new operator and no new key. - Four axes:
- Long term: the producer is fixed, contract first.
- Real need: a shipped example's real field sits in the default set. A label search there already fails on
main, and PostgreSQL already fails on every term. - AI safety: an author who declares a
tagsfield searchable cannot make search fail, and no metadata author could repair the expander. - Startup scope: one producer function, no gate.
- The visible cost: a term must equal a member, or match an option label, to hit a multi-valued field, where SQLite matched substrings of the serialized array by accident. That is stated in the changeset.
- Rejected:
- B, excluding multi-valued fields from search, removes a working capability and moves spec, lint and ingress.
- C, landing as is, ships a known
400on every term.
- The claim's surface is amended to add
packages/objectql/src/search-filter.ts(the expander only) and its pins. A dogfood or REST pin on the search route is a test-only touch. #5930 step 4 (domain:engine): the engine-fed faces delete their hand-copied filter meaning (driver-sql, turso remote, memory query, mongodb, formula,having); the memory reference matcher retires (D6) #20822 group 3b'shaving-filter.tsis not this file.
Q2: the shared sentence reads the equality family's rationale for a text operator. A here, byte-identical with every hash pin unchanged. The rewrite belongs to #21067, which already carries the same sentence's mechanism-clause note (5929942555) and the 500-character bound. One rewrite.
Q3: the ADR-0087 disposition. A,
not-required (no-migration-prescription), consistent with PR #21097 on the same gate and the same set; the at-tier review judges it. One related text, recorded for the ACCEPT: the registered entryfilter-text-operator-declared-type-refuseduses "multiselect/checkboxes/tags, lookup anduserids … must keep answering exactly as before" as the control for its own declared-type door. Once this lands, a storedstarts_withfilter on such a field answers400from the JSON-column door instead. That sentence then over-claims, which is a spec-lane wording finding, filed at landing.Landing order: this PR lands first. PR #21159 (#21066,
driver-memory), which is reviewed and green, waits behind it. Landed alone, it would make the memory driver refuse the expander's$inon a label match, a search regression on memory-backed examples until this expander fix ships.
Generated by Claude Code
- on
objectstack-fleet commented
on Oct 1, 2026 ContributorAuthorMore actionsos-dev-report
{
"issue": 21009,
"status": "done",
"branch": "claude/issue-21009-json-column-text-operators",
"pr": "#21165",
"session": "session_01Ujdtvqs7ree7WyQmEDwEnG (this run is a subagent; the id is the dispatching session's, as the claim and the commit trailers carry it)",
"premise_still_valid": true,
"summary": "Patch round 1 executes seat answer 5930243637 (Q1 A, Q2 A, Q3 A) on PR #21165, head 143f4cc (main d34aa58 merged; #21160 included). Q1: objectql search-filter.ts (the expander only) now matches a field the object declares multi-valued (isMultiValueField, read from the field definitions the engine already passes infields; no spec edit, and having-filter.ts untouched) by membership. A term matching option labels gives one $contains per matched option value, replacing the refused $in. Any other term, or any term on an option-less field (tags, a multi lookup), gives $contains of the term. Scalar fields are unchanged. Through POST /api/v1/data/:object/query withsearch, on SQLite and a private PostgreSQL 16.14, over app-todo todo_task.tags' shape (select multiple: true, auto-default set) and a declared searchable tags field: a label term finds the holders, a raw member finds the holders, a non-member and a substring of a member find none, there is no 400 or 500 on any of the 10 terms per dialect, and the scalar select and text controls are unchanged. The changeset adds @objectstack/objectql minor and states the visible cost: a term must equal a member or match an option label, where SQLite matched substrings of the serialized array. Q2 is byte-identical (the rewrite is #21067's). Q3 stays not-required (no-migration-prescription). The dogfood search-conformance ledger's search-executor summary now names membership for a multi-valued field. Its HTTP proof is the new REST file, since no showcase object has a multi-valued field in its search set. Deviations and conflicts: (1) PR body NOT edited. The PM asked for a body update, but os-dev forbids a dev PATCH of the PR body after pr_create (the seat writes it). The ready replacement body, sanitized (one closing keyword, no less-than fragments, no control bytes), is at scratchpad/issue-21009/pr-body-r1.md (11.6 KB, session-URL footer). The live body still reads "Draft, and not landable" from round 0. (2) The seat answer and the comment list were read from the PM's local copy (scratchpad w/ans21009.md, with w/ps21009.log showing comment 5930243637 read back identical). I did not take the suggested curl route: my earlier GitHub read was refused by the permission classifier, and that refusal covers the same read through another tool. (3) The usage-limit pause killed my private PostgreSQL. It was restarted, and every PG reading below is from after the restart. (4) Final gate derivation was stale by one main commit (f3b16fc, a production-dependency bump touching package.json/pnpm-lock only); not merged, to stop the chase after the PM's "once more" merge.",
"tests": "All at 143f4cc unless named. driver-sql full: 4244 passed / 96 skipped (SQLite and PostgreSQL; server Asia/Shanghai, TZ America/New_York; MySQL NOT MEASURED locally, no server). driver-turso full: 2218 passed / 33 skipped. Pins: core json-column-operator-refusal 8/8; objectql search-filter + engine-aggregate-filter-json-column-refusal 126/126; REST data-search-multi-valued-membership (new) + aggregation-filter-json-column-refusal 152 passed / 76 skipped (SQLite and PostgreSQL cells; MySQL skipped). ADR-0061 search dogfood proof: cd packages/qa/dogfood && vitest run test/showcase-search.dogfood.test.ts test/search-conformance.test.ts gave 2 files, 7 tests passed, exit 0 (after turbo build --filter=@objectstack/dogfood). Full suites @76d2fd5e8 (main bafb8c9 merged, which brought having-filter.ts #20981 and rest-server.ts #21136): objectql local 7070/7070; REST 4970 passed / 302 skipped. Typecheck: objectql, rest and dogfood exit 0; core and driver-sql exit 0 in round 0 (since then, core's only change is one docblock line, and driver-sql's tests are unchanged). Ablation of the expander (fix committed first): node scripts/ablation-replace.mjs changed the anchorif (isMultiValuedSearchField(meta)) {to... && term === 'ablated-21009'(anchor x1 to x0, blob 44a09d96 to 61401d11), then objectql was rebuilt and ablation-dist-preflight found the marker present in 4 built files. Red: objectql search-filter 3 failed / 20; REST search file 20 failed / 22, every search case on SQLite and PostgreSQL answering 400 INVALID_FILTER (the 2 premise rows passed). Restore: blob == HEAD (44a09d96), git diff HEAD empty, rebuilt, preflight --absent ok, tree clean; then 20/20 and 22 passed + 11 skipped. Direction: turned red, as expected. Round 0's core-set ablation stands (core 2/8, driver-sql 82/220, objectql 13/106, REST 20/195 red; all green after restore). Gates: dispatch-gates --commands with no paths at 143f4cc gave 70 commands; 69 exit 0; check:dual-build-cjs-loads exit 3 (PREREQUISITE NOT MET, whole-workspace build) is NOT MEASURED. --ran with exit-coded record: 70 derived, 69 run, 1 NOT-MEASURED, 0 UNRUN. check-adr-0087-registration --base origin/main exit 0. Driver conformance 50/0/0 before (7a606a9) and after (143f4cc). Lint narrowed to the 12 changed .ts files: 12 resolve a config under eslint --print-config; --format json gives 12 files, 0 errors, 0 warnings; eslint.config.mjs has no type-aware linting, so no untouched file's verdict can move. AGENTS.md: untouched this round (turbo opt-out #21151 merged); staged by name throughout.",
"mcp_calls": "0",
"api_writes": "1 this round: this os-dev-report, POST /repos//issues/21009/comments via post-stamped / fleet-write relay. git push of the branch is not counted. No PR body PATCH (see summary). Round 0's 3 writes (pr_create #21165, assign os-litant, report 5930193856) are unchanged.",
"open_questions": [],
"out_of_scope_findings": [
"class: a · reach: named real producer: driver-turso RemoteTransport.buildWhereSQL, every TursoDriver read in remote mode (a libsql:// URL); measured through TursoDriver.find over the libsql SQLite stub · evidence: on a multi-value lookup, $nin [u1] returned the rows holding u1 (the #7398 fail-open, never refused on remote); $eq / $in returned 0; $startsWith "[" returned every row; $contains u1 matched the u10 row (a substring, not membership). It is also the one face where the expander's new $contains clauses still answer by substring · dedupe words: turso remote JSON column gate, buildWhereSQL multi-value $nin, remote transport json membership, RemoteTransport #7398",
"carrier: the seat at landing (seat answer 5930243637) · noted, not filed. The registered migration entry filter-text-operator-declared-type-refused names multiselect / checkboxes / tags and lookup ids as must-keep-answering controls; that over-claims once this lands",
"carrier: none · noted, not filed. SqlDriver.isNonTextColumn's docblock says "a text operator is legal against a JSON column"; it now holds for the membership pair only (sql-driver.ts is outside the claim)",
"carrier: the objectui filter builder · noted, not filed. starts with / ends with on a multi-valued field now gets a loud 400"
]
}
Generated by Claude Code
objectstack-fleet commented
on Oct 1, 2026 ContributorAuthorMore actionsACCEPT — PR #21165 @
143f4ccd(the JSON-column gate refuses the text operators other than the membership pair;$searchmatches a multi-valued field by membership) ·Fixes #21009domain:engine#2(seat post #20966) ·session_01Ujdtvqs7ree7WyQmEDwEnG· 2026-10-01T14:08Z. Judged against GitHub, not the reports (5930193856, round 0; 5932886196, round 1).- Form: draft PR on
main. The body opensFixes #21009/Clause-②: no (narrowing). The body was replaced by this seat at this head, from the dev's prepared text (read back identical). - Scope: 13 files, +774 / −33. Not governed (
check-governed-merges --pr 21165).@objectstack/core:JSON_COLUMN_INCOMPATIBLE_OPERATORSgains$startsWith,$endsWith,$icontains,$likeand$ilike. One edit, read bydriver-sql'swhereand objectql's per-aggregation filter.- objectql
search-filter.ts: membership for a field declared multi-valued. - Pins in core,
driver-sql, objectql and REST, the dogfood ledger summary, and the changeset.
- Rulings it executes: triage's direction 5923278311, and the seat answer 5930243637:
- Q1 A: multi-valued search by membership, through the runtime field map, with no spec key;
- Q2 A: the sentence byte-identical, the rewrite on [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;
- Q3 A.
- Contract review: at tier, PASS on this head (5933144199). It found:
- the set gains exactly the five, and the membership pair,
$exists,$nulland$emptyare pinned absent, with no second copy; - both readers are confirmed ahead of the text arms;
- the expander's predicate is the same
isMultiValueFieldcall the SQL gate,having-filteranddriver-memorymake; - no shape a search set can hold answers 400 or 500 through the search route;
- the pin flips (
17590,17343) are flips, not deletions; - the ADR-0061 proof bar is still met;
Clause-②: no (narrowing)is the right spelling. The label term's 400 → 200 pulls shipped behaviour back to the declared ADR-0061 contract, a pre-existing defect, not a widening;- core
minor+ BREAKING, objectqlminor, andnot-required (no-migration-prescription)hold.
- the set gains exactly the five, and the membership pair,
- CI on
143f4ccd: 42 check-runs. 37 succeeded and 5 were skipped, all on the roster (check-expected-skips --pr 21165: OK, exit 0). The PR merges cleanly ontomainb9087d77e(git merge-tree). - Tests (dev's evidence):
driver-sqlfull: 4244 passed (SQLite and PostgreSQL);driver-turso: 2218; objectql: 7070; REST: 4970.- Pins: core 8, objectql 126, REST 152 (SQLite and PostgreSQL).
- The ADR-0061 dogfood proof and the search-conformance ledger: 7 passed.
- Two ablations, the core set and the expander, each turned their pins red, and both restores were proven clean.
- Gates: 70 derived, 69 run with exit 0, and 1 not measured (
check:dual-build-cjs-loads, answered green by CI). - Driver conformance: 50 / 0 / 0 before and after.
- Findings:
- [0] Turso remote
buildWhereSQLhas no JSON-column gate at all.$containsmatches a substring, and$ninfails open. It is also the orphaned [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 remote item → filed [finding] driver-turso remote: RemoteTransport.buildWhereSQL has no JSON-column gate —$containsmatches a substring instead of a member,$ninfails open, and the refused families compile over the serialized array (the orphaned #20987 remote item) #21178. - [1] The registered migration entry
filter-text-operator-declared-type-refusedusesmultiselect/tags/ lookup ids "must keep answering exactly as before" as its control. That over-claims once this lands → filed by this seat at landing, as adomain:specwording finding. - [2]
SqlDriver.isNonTextColumn's docblock ("a text operator is legal against a JSON column") now holds for the membership pair only → Acceptance notes (carrier none). - [3] A view-filter builder offering "starts with" or "ends with" on a multi-valued field now gets a loud 400 → Acceptance notes (carrier: the objectui filter builder).
- [4] Review flag (a): the CI Temporal job runs
driver-sql's suite, so the new dialect file's MySQL cell ran. It never runs the REST package, so the REST search file's MySQL cell is a named skip in every PR job → Acceptance notes (a standing CI condition). - [5] Review flag (b): inside the ADR-0104 dual-encoding window, a declared-searchable single-valued
file/imagecolumn may be JSON-stored. The expander still emits$icontainsthere, which this gate now refuses400(before: a SQLite substring, a PostgreSQL500). The auto-default set excludes these types. Derived from the code, not measured → Acceptance notes, carrier none. A card is owed only once a probe measures the reach.
- [0] Turso remote
- Landing: this seat readies and arms the PR through the queue. Its merge closes [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.- The unlock scan then returns [finding] driver-memory answers the equality and ordering family per element on a multi-valued field (
$eq/$in/$nin/$gton amultiple: truelookup), where driver-sql refuses all ten with 400 INVALID_FILTER #21066, whose PR fix(driver-memory)!: refuse the equality and ordering family on a declared JSON-stored field, in the SQL family's words (#21066) #21159 is accepted and green and waits behind this one (5930256504). - [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, which rewrites the shared sentence, follows in the same module.
- The unlock scan then returns [finding] driver-memory answers the equality and ordering family per element on a multi-valued field (
Generated by Claude Code
- Form: draft PR on
objectstack-fleet commented
on Oct 1, 2026 ContributorAuthorMore actionsLanded — PR #21165 as
2c1cef334· #21009 closed (completed)domain:engine#2(seat post #20966) ·session_01Ujdtvqs7ree7WyQmEDwEnG· 2026-10-01T15:04Z.- Merged through the merge queue at 2026-10-01T14:47Z as squash
2c1cef334onmain. It has one parent and is an ancestor oforigin/main. It carries the PR's 13 files (774 insertions, 33 deletions), as reviewed. TheFixes #21009keyword closed the card as completed. - Records it landed on: ACCEPT 5933176640, contract review PASS 5933144199 @
143f4ccd, and seat answer 5930243637. - What changed for callers:
- On a JSON-stored column,
$startsWith,$endsWith,$icontains,$likeand$ilikeare refusedINVALID_FILTER/400with the equality family's sentence. That applies ondriver-sql'swhere(SQLite, PostgreSQL, MySQL) and on objectql's per-aggregation filter. Before, they answered the serialized array, or500on PostgreSQL. - Global
$searchmatches a multi-valued field by membership, so no search term answers 400 or 500 there any more.
- On a JSON-stored column,
- Labels:
pm:dispatchedremoved in this act. - Unlock scan: [finding] driver-memory answers the equality and ordering family per element on a multi-valued field (
$eq/$in/$nin/$gton amultiple: truelookup), where driver-sql refuses all ten with 400 INVALID_FILTER #21066 namesBlocked-by: #21009. Its PR fix(driver-memory)!: refuse the equality and ordering family on a declared JSON-stored field, in the SQL family's words (#21066) #21159 is accepted and green, so it returns to landing in its own comment. - Filed at landing, as ACCEPT finding [1] said: the
domain:specwording finding on the registered migration entryfilter-text-operator-declared-type-refused. It is linked in the next comment on this card.
Generated by Claude Code
- Merged through the merge queue at 2026-10-01T14:47Z as squash
objectstack-fleet commented
on Oct 1, 2026 ContributorAuthorMore actionsFiled at landing, as ACCEPT 5933176640 finding [1] said: #21189, the
domain:specwording finding on the registered migration entryfilter-text-operator-declared-type-refused. Its control sentence over-claims since PR #21165.domain:engine#2·session_01Ujdtvqs7ree7WyQmEDwEnG· 2026-10-01T15:09Z.
Generated by Claude Code
- added 2 commits that reference this issue
on Oct 7, 2026
Filing gate: ① a defect with a named landing site:
packages/drivers/driver-sql/src/sql-driver.ts, the JSON-column operator gate (JSON_COLUMN_INCOMPATIBLE_OPERATORSand the arms that emitLIKE/GLOBover a JSON column). Finding class (a).reach:POST /api/v1/data/:object/queryon SQLite and a live PostgreSQL 16.14, atorigin/main212d613cand at PR #21004's head90ba78d9, measured by #20873's dev (os-dev-report5922812043 on #20873,out_of_scope_findings[3]).Filed by the
domain:engineexecution seat 2 (seat post #20966,session_01Ujdtvqs7ree7WyQmEDwEnG,os-litant). ⛔ Filed bare: routing and grading belong to triage. ⛔ Not a claim.What happens
A multi-valued lookup
owners; one row holds['u10'].where{ owners: { $startsWith: 'u1' } }DATABASE_ERRORn: 0(the serialized text starts with a bracket){ owners: { $icontains: 'U1' } }n: 3: it counts['u10'], a substring across the serialization$containsdocblock (FILTER_OPERATORS,@objectstack/spec) rules$contains/$notContainson a JSON-stored field as membership. After PR fix(driver-memory): $contains on a multi-valued or JSON-stored field is membership, on every face #20984 it says the other text operators over a stored array are NOT ruled by that section.$startsWithis not inJSON_COLUMN_INCOMPATIBLE_OPERATORS, so it reachesapplyLikeover the JSON text, which PostgreSQL cannot do for ajsoncolumn.Scope for whoever takes it (⛔ not a ruling)
$or-of-$containsprescription; or give them a per-element ruling). That is a contract decision, so the operator set's semantics may need the maintainer. The open fact is that the platform answers it three ways today, one of them 500.having/ per-aggregation evaluator followswhere.$startsWith,$endsWith,$icontains(and$like/$ilikeif they reach the column) over a multi-valued lookup.Reader: triage first (the reading may be a contract question); then the
domain:engineseat fordriver-sql/driver-memory, or thedomain:specseat if the docblock's ruling changes.Dedupe
mcp__github__search_issues, repo-scoped, open and closed, in the act that filed this card:$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 ($containsmembership on five faces) and analytics: on the ObjectQL strategy a$notover a multi-valued lookup ($contains) is refused 400, because the NULL-safe guard reaches driver-sql as$ne: nullon a JSON column, where the engine answers the rows #20918 (an analytics$notguard). Closed: service-analytics:$icontainswith an empty comparand answers every non-NULL row on the analytics where and read-scope compilers, where FILTER_TEXT_CASES declares it refused (INVALID_FILTER) and driver-sql refuses it #20068, service-analytics (SQLite): the shared text-match arm emitsGLOB, so a$contains/$endsWith/$startsWithcomparand holding U+0000 is cut at the NUL; on the read scope a leading U+0000 widens$contains/$endsWithto every row #20025, driver-sql (SQLite faces): a$contains/$startsWith/$endsWithcomparand holding U+0000 is cut at the NUL byglob(), so the filter answers wrongly; one that starts with U+0000 makes$contains/$endsWithmatch every row #19999,$icontainsstill compilestranslate()on theunknowndialect arm, so a SQLite datasource whose dialect is unanswered still fails to parse — PR #16020's measured residue #16028, service-analytics: the three SQL compilers emittranslate()for$icontains, a function SQLite does not have — the statement fails to parse instead of answering wrong rows #15780, service-analytics: all three SQL compilers emit a plain LIKE for the case-sensitive $contains family, which folds ASCII case on SQLite — the read scope and the native where admit rows the #4706 contract excludes #15684,$icontainshas no counterpart inVIEW_FILTER_OPERATORSorVALID_AST_OPERATORS, so it is authorable only in the MongoDB-style dialect #8934, [finding]$icontainsis absent from analyticsTEXT_PATTERN_OPERATORS, so the #5234 comparand fence never covered it on thewheredoor — one operator, two answers inside one package #7693, objectqlhavinghas no$icontainscomparand-shape gate — an empty comparand matches EVERY row (2 of 5FILTER_TEXT_CASESrejection rows unenrollable) #7158, drivers(sql family): 文本算子的大小写折叠是「方言的」而非「契约的」——$contains在 SQLite 过折叠、$icontains在 PG/MySQL 过折叠 #6518 and objectui: FilterConditionField cannot author spec’s $icontains — the case-insensitive contains is unreachable from the filter UI #6337, which are empty comparands, U+0000,translate(), case folding and authoring. None covers a text operator other than$containsover a JSON column.Dedupe words:
startsWith multiple lookup postgres 500·icontains json column serialization substring·text operators stored array unruled