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
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_OPERATORS and the arms that emit LIKE / GLOB over a JSON column). Finding class (a). reach: POST /api/v1/data/:object/query on SQLite and a live PostgreSQL 16.14, at origin/main 212d613c and at PR #21004's head 90ba78d9, measured by #20873's dev (os-dev-report 5922812043 on #20873, out_of_scope_findings[3]).
Filed by the domain:engine execution 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 |
PostgreSQL 16 | SQLite | in-memory |
|---|---|---|---|
{ owners: { $startsWith: 'u1' } } |
500 DATABASE_ERROR |
200, n: 0 (the serialized text starts with a bracket) |
3, per element |
{ owners: { $icontains: 'U1' } } |
500 | 200, n: 3: it counts ['u10'], a substring across the serialization |
per element |
- The
$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. - The SQL family does not refuse them either:
$startsWithis not inJSON_COLUMN_INCOMPATIBLE_OPERATORS, so it reachesapplyLikeover the JSON text, which PostgreSQL cannot do for ajsoncolumn. - One filter, three answers, one of them a 500.
Scope for whoever takes it (⛔ not a ruling)
- Decide the text operators' reading over a JSON-stored field (refuse them like the equality family, with the
$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. - Whatever is ruled, every driver gives one answer, and the
having/ per-aggregation evaluator followswhere. - Pins on memory, SQLite and PostgreSQL:
$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:engine seat for driver-sql / driver-memory, or the domain:spec seat if the docblock's ruling changes.
Dedupe
mcp__github__search_issues, repo-scoped, open and closed, in the act that filed this card:
- 「$startsWith $icontains multiple lookup JSON column PostgreSQL 500 DATABASE_ERROR text operator multi-valued field」: 13 hits. Open: [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 ($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
Activity
objectstack-fleet commentedon Oct 1, 2026
Triage: 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.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.400for each operator on a multi-valued lookup. A scalar text column is unaffected (the control).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
objectstack-fleet commentedon Oct 1, 2026
Deferred 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.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);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:
$startsWith,$endsWithand$icontainsreach the SQL emitter before widening the set. A text arm that never callsassertOperatorAppliesToColumnwould leave the refusal unenforced.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.objectstack-fleet commentedon Oct 1, 2026
Serial 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.ceee88f46(5923330420 named it).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.core. Thewheredoor (driver-sql) and the per-aggregation filter (objectql) then both refuse the new members, in one sentence.buildWhereSQLon the post-mergemain;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).objectstack-fleet commentedon Oct 1, 2026
Claim: PM loop round 1
Session:
session_01Ujdtvqs7ree7WyQmEDwEnGAccount:
os-litant(the seat's linked user asGET /useranswers it; always the card's assignee)Branch:
claude/issue-21009-json-column-text-operatorsWorktree:
objectstack-issue-21009Domain:
domain:engineSeat:
domain:engine#2File 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.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 against
origin/main7a606a9a3.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.$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'sRemoteTransportcreate legs andsql-driver.ts's sequence region. ⛔ Not its regions.$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.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 commentedon Oct 1, 2026
os-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 carry
multiple, 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 with
search, 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 optional
multiplekey, 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 commentedon Oct 1, 2026
Seat 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:main, a label match already answers400on SQLite and PostgreSQL;500on PostgreSQL;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:$orof$contains, replacing the$inthat is refused today;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.$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.main, and PostgreSQL already fails on every term.tagsfield searchable cannot make search fail, and no metadata author could repair the expander.400on every term.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
objectstack-fleet commentedon Oct 1, 2026
os-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 in
fields; 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 anchor
if (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 commentedon Oct 1, 2026
ACCEPT — 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).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).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.search-filter.ts: membership for a field declared multi-valued.driver-sql, objectql and REST, the dogfood ledger summary, and the changeset.$exists,$nulland$emptyare pinned absent, with no second copy;isMultiValueFieldcall the SQL gate,having-filteranddriver-memorymake;17590,17343) are flips, not deletions;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;minor+ BREAKING, objectqlminor, andnot-required (no-migration-prescription)hold.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).driver-sqlfull: 4244 passed (SQLite and PostgreSQL);driver-turso: 2218; objectql: 7070; REST: 4970.check:dual-build-cjs-loads, answered green by CI).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.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.SqlDriver.isNonTextColumn's docblock ("a text operator is legal against a JSON column") now holds for the membership pair only → Acceptance notes (carrier none).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).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.$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.$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).Generated by Claude Code
objectstack-fleet commentedon Oct 1, 2026
Landed — PR #21165 as
2c1cef334· #21009 closed (completed)domain:engine#2(seat post #20966) ·session_01Ujdtvqs7ree7WyQmEDwEnG· 2026-10-01T15:04Z.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.143f4ccd, and seat answer 5930243637.$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.$searchmatches a multi-valued field by membership, so no search term answers 400 or 500 there any more.pm:dispatchedremoved in this act.$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.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
objectstack-fleet commentedon Oct 1, 2026
Filed 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
fix(core)!: the JSON-column gate refuses $startsWith, $endsWith, $ico…
fix(driver-memory)!: refuse the equality and ordering family on a dec…