Repository navigation
fix(spec, drivers)!: a $like / $ilike pattern holding U+0000 is refused by every driver that answers $like, instead of being cut at the NUL on SQLite (#20041) - #20124
Conversation
… every face that refuses a dangling escape One spec predicate, hasNulInLikePattern, beside hasDanglingLikeEscape, asked at the driver-sql walk (through the withheld seam), the Turso RemoteTransport $like arm (through its seam), and driver-memory's shape gate and both of its translator floors. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
…dialect, and enumerate its seam door Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
… $like refusal on every face and door Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
…; changeset and generated artifacts The regenerated api-surface / export-origins shards carry hasNulInLikePattern, and content/docs/references/data/filter.mdx the one-sentence describe change. The pending #20024 changeset's last bullet said a pattern holding U+0000 is still cut on SQLite; it is refused now, so that clause is corrected. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
…river-sql's emitter shares Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check11 anchor(s) derived from 4 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 139 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin aa0e12f57e7910818b253a5b988d1e6e29edfa33 && git checkout aa0e12f57e7910818b253a5b988d1e6e29edfa33
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin bc80e16260597d52b062b7e1ee810c7b9a99aed2 b88ca46d901e0b7d509b89e716b6a89a885d359b && git checkout -B drift-repro bc80e16260597d52b062b7e1ee810c7b9a99aed2 && git merge --no-ff b88ca46d901e0b7d509b89e716b6a89a885d359b
node scripts/docs-audit/affected-docs.mjs --json bc80e16260597d52b062b7e1ee810c7b9a99aed2 |
Contract reviewServed-tier: Scope: 17 files (+1017/−16) on merge base
Read: card #20041 and its comments, #20024, PR #20038 and its record, #20039, PR #20093 and its record 5826632400, PR #20104 and its record 5827309177 (the Clause-② precedent), the PR body, the diff, all 35 check-runs, AGENTS.md, Measured in detached worktrees at head and at base, plus a third worktree of ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: FAIL Must-change (text only; every measured behaviour is RIGHT):
|
…refused Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
…export, hasNulInLikePattern Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Scope: the delta over FAIL 5828387721 (
Measured in a detached worktree at ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
…le stored value past a U+0000 (objectstack-ai#20024) (objectstack-ai#20140) Fixes objectstack-ai#20024 Clause-②: no Item 2 (ii) of objectstack-ai#20024, direction C (landing record 5823785768). On the SQLite faces, a `$like` / `$ilike` pattern without U+0000 over a stored value holding U+0000 now gets the answer `likePatternToRegexSource` gives, which is also what `@objectstack/formula` and `driver-memory` return. Item 1 landed as PR objectstack-ai#20038 and item 2 (i) as PR objectstack-ai#20124 (objectstack-ai#20041). This is the card's last item, so the PR closes it. `patch` for `@objectstack/driver-sql`, `@objectstack/driver-sqlite-wasm` (it inherits the fix) and `@objectstack/driver-turso`. Nothing that was accepted is refused now: the refusal doors, `hasNulInLikePattern` and `hasDanglingLikeEscape` are untouched. The grid, plan and cost numbers below were taken at `b424b90f7c` or earlier. Head `89e02a2521` differs from `b424b90f7c` by one changeset line only, and its emitter code is byte-identical to `7434e6646e`; every commit since then changed changeset prose only. ## The defect, measured at base `fe677aeeed` `glob()` reads the stored value as a C string, only up to its first U+0000. The grid has 59 stored values (27 holding U+0000, leading, interior, trailing, doubled, next to multi-byte and U+0001 characters, plus NULL). It runs 130 queries against them: 81 `$like` and 27 `$ilike` bare patterns, and 22 `$not` / `$or` / `$and` compositions. That is 7670 cells per face, each compared with `formula`. The four faces answered identically: | stored value | cells per face | base: differ from `formula` | head: differ from `formula` | |---|---|---|---| | holds U+0000 (26 values in the Basic Multilingual Plane) | 3380 | 359 | **0** | | no U+0000, in the Basic Multilingual Plane (28 values) | 3640 | 0 | 0 | | NULL | 130 | 0 | 0 | | outside the Basic Multilingual Plane, no U+0000 (3 values) | 390 | 20 | 20 (older than this PR, see H3) | | outside the Basic Multilingual Plane, with U+0000 (1 value) | 130 | 6 (6 against a code-point oracle) | 2 (0 against a code-point oracle) | The four faces are `SqlDriver` on better-sqlite3 (SQLite 3.53.4), `SqliteWasmDriver` on sql.js (3.49.1), `TursoDriver` local, and `TursoDriver` remote over a real `@libsql/client` `file::memory:` engine (3.45.1). At base, 74 of the 108 bare patterns got at least one row holding U+0000 wrong. Some examples: - `$like: 'a'` returned `'a'` + U+0000 + `'b'`; - `$like: ''` returned U+0000 + `'z'`; - `$like: '_'` missed a lone U+0000; - `$ilike: 'A_B'` missed `'a'` + U+0000 + `'b'`. **Base vs head, every stored value without U+0000 and NULL: 0 of 16640 cells moved, on any face.** ## What changes `likePatternPredicate`'s SQLite arm in `sql-driver.ts` now calls `sqliteLikePatternMatch`. `RemoteTransport.pushLikePattern` in `remote-transport.ts` restates the same rule for the remote transport. The PR's own parity test holds the two to the same predicate text and bindings, the precedent `pushLike` already follows. Sharing one compiler would need a new export from `driver-sql`'s `index.ts`, which is outside this claim's file surface. How a pattern compiles: - **A literal prefix followed only by `%`** (`'ab%'`, `'%'`, `'%%'`) keeps the bare `col GLOB ?` with the same binding as before. A prefix free of U+0000 cannot be changed by the cut, the same argument `$startsWith` stands on. - **Every other pattern** compiles to `CASE WHEN instr(CAST(col AS BLOB), X'00') > 0 THEN (a recursive CTE that replaces each U+0000 with one stand-in character, then GLOB) ELSE col GLOB ? END`. - A value without U+0000 takes the `ELSE` arm: the same `GLOB` as before. - NULL also takes the `ELSE` arm and stays NULL, so the NULL-safe `$not` wrapper composes unchanged. - **A case-exact pattern with a literal prefix** leads with `col GLOB 'PREFIX*'`, where PREFIX is the text before the first unescaped wildcard. Every value the pattern matches satisfies that conjunct, cut or not, and it keeps the index range. `$ilike` has no index range to keep, since `lower(col)` is not an indexed column, so it gets no conjunct. **Why the rewrite is exact.** The stand-in is the smallest code point from U+0001 up that is not a literal character of the pattern, not an ASCII letter and not a surrogate. The pattern holds no U+0000, because `hasNulInLikePattern` refuses it at every door. So a U+0000 in the value can only be matched by `%` or `_`. The stand-in is one character, as U+0000 is, and no literal position of the pattern matches it, just as none matched U+0000. Because it is not a letter, the `$ilike` fold cannot turn it into one. So the rewritten value matches exactly when the original does. **H2 is measured, not assumed.** On all three engines, `replace(v, char(0), char(1))` returns its input unchanged, `GLOB` and `LIKE` cut at U+0000, `lower()` keeps the bytes past U+0000, and `instr()` over BLOB finds U+0000. A correlated recursive CTE inside a scalar subquery works on all three and splices every U+0000, with NULL staying NULL. The earlier sentinel measurement (223 of 2392 cells wrong) is consistent with this: `replace()` never reached a U+0000 there. ## H3: characters outside the Basic Multilingual Plane under `_` (older than this PR, not widened) The spec's regex has no `u` flag, so `_` in `formula` and `driver-memory` matches one UTF-16 unit, while `GLOB`'s `?` matches one code point. At base and at head alike, 20 of 390 cells over such values without U+0000 differ from `formula` on every SQLite face (17 queries). For example, `$like: '_'` returns a stored '😀' on SQLite and not in `formula`. This PR's U+0000 arm uses `GLOB` too, so it follows the code-point reading, consistent with the arm for values without U+0000. On the astral value holding U+0000, 0 of 130 head cells differ from a code-point oracle (the spec's regex compiled with the `u` flag). The divergence is reported to the seat as an out-of-scope finding and not fixed here. ## H4: plans and cost `EXPLAIN QUERY PLAN` of the statements the real compilers emit (the `SqlDriver` compile, and the remote statement captured from a recording libSQL client) over `plan_probe(id integer primary key, v text)` with an index on `v`. Identical on better-sqlite3, sql.js and libSQL: | filter | before | after | |---|---|---| | `$like 'ab%'` | SEARCH USING COVERING INDEX (v greater-than ? AND v less-than ?) | same, same statement | | `$like 'ab_'`, `'ab%cd'`, `'abc'`, `'a%b%'` | SEARCH USING COVERING INDEX (range) | the same SEARCH, plus a correlated scalar subquery | | `$like '%ab'`, `'_b'`, `''` | SCAN | SCAN, plus a correlated scalar subquery | | `$like '%'`, `$ilike 'ab%'` | SCAN | SCAN, same statement | | `$ilike 'ab_'` | SCAN | SCAN, plus a correlated scalar subquery | | `$eq`, `$startsWith` (controls) | SEARCH | unchanged | Cost is the median of 7 runs of `count(*)` over 10,000 rows, base `GLOB` against head. This is a shared box, so read the ratios: | rows holding U+0000 | `$like '%ab%c'` | `$like '_b%'` | `$like 'ab_%'` | `$like 'ab%'` | `$ilike '%AB%C'` | |---|---|---|---|---|---| | 0% | 1.4–1.8x | 1.7–2.4x | 1.1–2.2x | 1.0x | 1.3–1.4x | | 10% | 4.2–4.7x | 5.4–7.8x | 3.3–3.7x | 0.9–1.0x | 2.4–2.8x | | 100% (1–3 each) | 25–37x (23–34 ms) | 26–44x (25–31 ms) | 23–33x (5–7 ms) | 0.7–1.1x | 12–14x (25–35 ms) | The cost for values without U+0000 is the per-row `instr()` guard. A text-`instr` spelling of it is also correct on all three engines, but it measured slower than the BLOB one. Every head count equals `formula`'s count, and base counts differ wherever U+0000 rows exist. ## H5: the remote face, executed `TursoDriver` remote over a real `@libsql/client` engine runs the whole grid in the probe (0 of 3380 cells differ at head) and the whole pin suite in CI. Its predicate text and bindings equal the local compiler's, modulo identifier quoting. A real Turso server, and its wire encoding of a string holding U+0000, are NOT MEASURED. ## H6: two pending changesets, two DELIBERATE CORRECTIONS (please confirm) This PR makes one sentence false in each of two pending changesets, and it corrects each sentence in place. One line changes in each file, and every other line of both files is byte-identical. - `Check Changeset` goes RED on both by design. `scripts/check-empty-changeset.mjs`'s foreign-changeset rule is content-blind: any `M` row on a changeset present at the merge base is refused, whichever card owns it. - Both are the **DELIBERATE CORRECTION** class. Its remedy is to confirm the correction here. ⛔ Restoring either file would put a false sentence back. 1. `.changeset/20024-sqlite-glob-stored-nul.md` is this card's own item 1 entry. The last bullet changed (line 29 of 29): - Before: "- `$like` and `$ilike` still compile to `GLOB` on SQLite, so they still read a stored value only up to its first U+0000. A pattern holding U+0000 is no longer cut there: every driver that answers `$like` now refuses it (`INVALID_FILTER` / 400), by its own entry in this release." - After: "- `$like` and `$ilike` are outside this entry. Two other entries in this release cover them: on SQLite they now read the whole stored value as well, and every driver that answers `$like` refuses a pattern holding U+0000 (`INVALID_FILTER` / 400)." 2. `.changeset/20041-like-nul-pattern-refused.md` is objectstack-ai#20041's entry. The first bullet under **Not changed here** changed (line 36 of 44). The seat's claim amendment 5830697185 on objectstack-ai#20024 (ruling A) added this bullet to this PR's surface. - Before: "- A pattern without U+0000 matched against a STORED value that holds U+0000 still differs from `formula` on the SQLite faces, because `GLOB` also reads the stored value only up to its first U+0000. No refusal of the pattern can reach that half." - After: "- A pattern without U+0000 matched against a STORED value that holds U+0000 is not refused: it is well formed, and on the SQLite faces it reads the whole stored value, by its own entry in this release." - The new sentence is true at head `89e02a2521`: - The refusal doors read only the pattern (a non-string pattern, a dangling escape, or a pattern holding U+0000). - The three pin suites pass there: 65 of 65, 22 of 22 and 23 of 23. They include patterns without U+0000 over stored values holding U+0000 on every SQLite face, and every such case returns `formula`'s rows and none is refused. - "Its own entry" is `.changeset/20024-like-stored-nul.md`. The new entry is `.changeset/20024-like-stored-nul.md`. ## Tests (at `7434e6646e`; the code is identical at head) - New suites: - `sql-driver-20024-like-stored-nul.test.ts`: 65 of 65 pass. It has 29 literal cases, each pinned and checked against `formula`, a 67-pattern grid held to `formula` cell by cell, the exact compiled statements, and an `EXPLAIN QUERY PLAN` pin. - `sqlite-wasm-20024-like-stored-nul.test.ts`: 22 of 22 pass (19 literal cases, the grid, a plan pin, and the stored-bytes premise). - `turso-20024-like-stored-nul.test.ts`: 23 of 23 pass. It runs local, remote over the stub, and remote over a real libSQL engine, plus the grid and the emitter-parity case. `driver-turso` does not depend on `formula`, so its grid is held to the spec's `matchesLikePattern`. - Full suites: - `pnpm --filter @objectstack/driver-sql exec vitest run --maxWorkers=2`: 187 files passed and 11 skipped; 3089 tests passed and 170 skipped. - `@objectstack/driver-sqlite-wasm`: 34 of 34 files, 644 of 644 tests. - `@objectstack/driver-turso`: 70 of 70 files, 1686 of 1686 tests. - No existing pin moved: before the new suites were added, all three full suites were green against the new emitters. - `pnpm --filter … run typecheck` exits 0 for all three packages, and `tsc --listFiles` counts each new suite once. - Ablations, each with a prediction made from the base statement or a simulation, a mutation proved on disk through `scripts/ablation-replace.mjs`, a `driver-sql` rebuild, and `ablation-dist-preflight` (marker present, then `--absent` after restore). Each restore was proved as the blob hash equal to HEAD, with `git status --porcelain` empty. - (1) Dispatch reverted in both emitters, which gives the base statement. Predicted and observed: driver-sql 25 failed / 40 passed, sqlite-wasm 15 / 7, turso 16 / 7. - (2) Stand-in fixed at U+0001 in both emitters. Predicted and observed: 4 / 61, 2 / 20, 2 / 21. The failures are the U+0001 cases, the grid, and the stand-in statement pin. - (3) Remote emitter only. Predicted and observed: turso 17 failed / 6 passed, including the parity test. ## Gates (the full union at `b424b90f7c`; the changeset gates re-run at `89e02a2521`) At `89e02a2521`, the head that adds the second correction: - `node scripts/check-empty-changeset.mjs --base origin/main` exits 1. This is expected and names both files: "This PR changes a changeset it did not add: .changeset/20024-sqlite-glob-stored-nul.md present on the merge base and CHANGED by this PR ... .changeset/20041-like-nul-pattern-refused.md present on the merge base and CHANGED by this PR". Both are the H6 DELIBERATE CORRECTIONS. - `node scripts/check-changeset-no-major.mjs --base origin/main --event`, over this body, exits 0: "This diff introduces no `major` bump.", and "LEVEL AXIS: this PR declares clause-② `no`, so no package here is declared to have grown a published surface" (declaration line `Clause-②: no`, no direction arm). - `node scripts/check-adr-0087-registration.mjs --base origin/main` exits 0: "check-adr-0087-registration: this PR adds no declared-breaking changeset (3 non-breaking changeset(s) seen)." - `node scripts/check-issue-citations.mjs --base fe677ae` exits 0: 7 citations across 2 files resolve. At `b424b90f7c`: - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives 62 commands. All 62 were run and reconciled with `--ran`: 62 derived, 62 run, 0 NOT MEASURED, 0 UNRUN. - Every command exits 0 except `node scripts/check-empty-changeset.mjs --base origin/main`, which exits 1 for the H6 correction above. - `check:dual-build-cjs-loads`, `check:lean-entry-closure` and `check:type-check-debt` first exited 3 (PREREQUISITE NOT MET). They exit 0 after `turbo run build` over `./packages/*` and `./packages/*/*` (72 tasks). The dist-reading gates were re-run over that build and are green too. - `check:driver-conformance`: 50 covered cells, 0 DEBT, 0 exempt. - `node scripts/check-issue-citations.mjs --base fe677ae`: 7 citations resolve. - The rosters beside these paths are green: `check-changeset-fixed`, `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`. - ESLint, narrowed: `eslint --no-inline-config --format json` over the 5 changed `.ts` files gives 5 files, 0 errors, 0 warnings, 0 ignored. - The population is every changed `.ts` file, each resolving a config under `--print-config`. - `eslint.config.mjs` never enables type-aware linting, so no untouched file's verdict can move. The full `pnpm lint` run is CI's. ## Compile surfaces 1. `driver-sql` `likePatternPredicate`, SQLite arm: TOUCHED. Measured on better-sqlite3 3.53.4. `SqliteWasmDriver` (sql.js 3.49.1) and `TursoDriver` local inherit it, and each is measured by the grid and by its own suite. The Postgres and MySQL arms are not touched and NOT MEASURED (no live server here). 2. turso `RemoteTransport.pushLikePattern`: TOUCHED. Measured on `makeLibsqlSqliteStub` and on a real `@libsql/client` engine (3.45.1). A real Turso server is NOT MEASURED. 3. and 4. service-analytics `compileScopedFilterToSql` / `lowerAnalyticsWhere`: not touched. `$like` / `$ilike` are in their unevaluated set (`preview-unevaluable-operator.test.ts`), so these faces do not compile them. Rows are NOT MEASURED. 5. `formula` `matchesFilterCondition`: not touched; it is the oracle. - Half face: objectql `having-filter`: not touched, and `$like` does not occur in `having-filter.ts`. - Thawed faces: - `driver-memory` `checkCondition`: not touched. Measured on the same grid: 0 of 7670 cells differ from `formula`. - `driver-mongodb`: not touched, NOT MEASURED (no mongod). ## Acceptance notes - H3's divergence outside the Basic Multilingual Plane is reported to the seat with its measured reach, and is not fixed here. - The objectstack-ai#20041 changeset: its line 32 ("every `$like` / `$ilike` pattern without U+0000 answers exactly as before") is scoped to objectstack-ai#20041's own change and is left as it is. The corrected line 36, in the same section, states what this PR changes for stored values. - Draft PR objectstack-ai#20104 edits `RemoteTransport` elsewhere in `remote-transport.ts`. This PR's hunk is inside `pushLikePattern` only. Written by `session_01Bvd69VPa6puiNzzPUroDBx` (os-dev, `mode:subagent`) for the `domain:engine` seat. --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #20041
Clause-②: yes (narrowing)
What changed
On the SQLite faces
$like/$ilikecompile toGLOB, and SQLite reads a pattern only up to its first U+0000. A pattern holding U+0000 was cut there and answered a different question, with nothing raised. There is no NUL-safe SQLite pattern primitive to compile to instead (LIKEcuts the same way,replace()cannot target U+0000,instr()has no wildcards; measured on #20024). So the accept set becomes one contract: such a pattern is refused, the way a pattern ending in a lone unpaired backslash already is.packages/spec: one exported predicate,hasNulInLikePattern(pattern), besidehasDanglingLikeEscapeinsrc/data/filter.zod.ts, with its own docblock.hasDanglingLikeEscapeis byte-identical. The converters (likePatternToRegexSource,likePatternToGlobPattern,matchesLikePattern) are unchanged (see H3).INVALID_FILTER/ 400:SqlDriver's filter walk (classifyFilterKey), before a dialect is chosendriver-sql/src/sql-driver.tsnulLikePatternError, born in the withheld seam (withheldFilterError, the operator map as the node, as for the dangling escape)RemoteTransport.buildWhereSQL's$like/$ilikearm, before anything is sentdriver-turso/src/remote-transport.tsnulLikePattern, throughwithheldRefusal; its withheld sentence isdriver-sql's behind the[RemoteTransport]prefixdriver-memory's shape gate (assertFieldConstraintShape: the query path and the reference matchermatch())driver-memory/src/filter-refusal.tsnulLikePatternError,driver-sql's author text word for worddriver-memory's QueryASTcomparisonlike/ilikearm, and the$liketranslator floordriver-memory/src/memory-driver.tsOperator "$like" on field "v" at filter.v.$like has a pattern holding the NUL character U+0000 ("%\u0000"). ...— the pattern goes throughJSON.stringify, so U+0000 reaches no message as a raw byte.$likeoperator's.describe()(LIKE_DESCRIPTION, same file) said "A pattern ending in a lone unpaired backslash is refused (INVALID_FILTER)". It now reads "A pattern ending in a lone unpaired backslash, or holding the NUL character U+0000, is refused (INVALID_FILTER)", so the declared contract names the refusal this PR enforces.content/docs/references/data/filter.mdxis its regeneration (gen:schema && gen:docs): 5 rows, that phrase only..changeset/20024-sqlite-glob-stored-nul.md(not yet released) said "a pattern holding U+0000 is still cut there". At this head that sentence is false, so it now says the pattern is refused by every driver that answers$like.node scripts/check-empty-changeset.mjsis RED on this by design: its DELIBERATE CORRECTION class says "do NOT restore it -- say so on the PR and get it confirmed". This is that statement. The alternative is to drop the edit and leave that false sentence in the release's changelog beside this PR's own entry.H1: every face, base vs head (measured, the PM's hypothesis holds on the SQLite faces)
A probe (scratch, not committed) drove the public
findof each face over one row set: 13 stored values, 12 non-NULL, U+0000 at the start, middle and end, alone, and none ('a'+ U+0000 +'b','ab'+ U+0000, U+0000 +'z', U+0000 alone,'A'+ U+0000 +'B',plain,'',ab,a,b,axb,AB, NULL). Cases: 10 patterns holding U+0000 ('%'+NUL, NUL+'%','%'+NUL+'%','a'+NUL+'b','a'+NUL+'%','_'+NUL+'_', backslash+NUL, NUL alone,$ilike'%'+NUL+'B'and'AB'+NUL) and 9 NUL-free controls, each bare and under$not. Base is8d76c2d38c, head is this branch (dists rebuilt at each).SqlDriveron better-sqlite3formula, 0 refusedINVALID_FILTER/ 400SqliteWasmDriverTursoDriverlocalTursoDriverremote,makeLibsqlSqliteStubTursoDriverremote, real@libsql/clientfile::memory:InMemoryDriver.find($-spelling)InMemoryDriver.find, QueryASTcomparisonlike/ilikelike '%'+NUL gives the one value ending in U+0000)like 'ab%'answers the same rowsdriver-memorymatch()truefor'ab'+NUL)@objectstack/formuladriver-mongodbtranslateFilter$likeis not translated at all$like: '%'+ U+0000 returned all 12 non-NULL rows, whereformulareturns the two ending in U+0000;$like: 'a'+ U+0000 +'b'also returned'a';$like: '_'+ U+0000 +'_'also returned'a'and'b';$ilike: 'AB'+ U+0000 also returned'AB'and'ab';$not $like '%'+ U+0000 returned only the NULL row.applyHavingrefuses every$like(INVALID_FILTER/ 400, "Unsupported operator '$like' inhaving"); service-analyticscompileScopedFilterToSqlrefuses it (READ_SCOPE_COMPILE_FAILED/ 500, fail-closed) andnormalizeAnalyticsFilterTreerefuses it (INVALID_FILTER/ 400).lowerAnalyticsWherealone passes the node through; the tree build after it refuses.driver-sqlwere not measured live (no server here). At head the refusal fires on the walk before the dialect is chosen, pinned by compiling with thepgandmysql2clients and no server.H2: the doors (census)
git grep -n -E '\bNAME\(' HEAD -- 'packages/**/*.ts' ':!**/*.test.ts', comments and the definition excluded:hasDanglingLikeEscape: 7 call sites at base and at head. Five are face doors:filter-refusal.ts(1),memory-driver.ts(2),sql-driver.ts(1),remote-transport.ts(1). Two are the converters' own backstops infilter.zod.ts. Positive control: the PM's list (sql-driver.ts,remote-transport.ts,memory-driver.tsx2,filter-refusal.ts) is exactly the five doors.hasNulInLikePattern: 0 at base, 5 at head, one beside each of the five doors.formuladoes NOT refuse a dangling escape:matchesLikePatternthrows, and the arm answersfalse(measured:matchesFilterCondition({ v: 'abc\\' }, { v: { $like: 'abc\\' } })isfalse). So by the claim's condition its file is not touched, and it still evaluates a U+0000 pattern.H3: where the predicate is called, option (a)
Converter consumer census, same grep over non-test sources:
likePatternToGlobPattern: 3 calls:sql-driver.ts(likePatternPredicate),remote-transport.ts(pushLikePattern), anddriver-memory/src/memory-analytics.ts(globSubstringPattern, the analytics echo of a$containscomparand).likePatternToRegexSource: 3 calls:memory-driver.ts(2) and the spec's ownmatchesLikePattern.matchesLikePattern: 2 calls:driver-memory/src/memory-matcher.tsandformula/src/matches-filter.ts.packages/drivers/**6,packages/formula1,packages/spec1.packages/services/**,packages/objectqlandpackages/plugins/**: 0 (the same grep, whose drivers count is the positive control).Option (b), a throw inside the converters, would have changed three consumers beyond this surface:
memory-analytics'$containsecho would throw a plainErroron a comparand holding U+0000;formula's$likewould answerfalse(its caught throw) instead of the right rows; the reference matcher the same (though its shape gate runs first). So the predicate is called at each door (a), andfilter-like-nul-pattern.test.tspins that the JS translation keeps its meaning.H4: the withheld seam
nulLikePatternErrorhas a row insql-driver-compile-refusal-seam.test.tsandnulLikePatterninremote-transport-compile-refusal-seam.test.ts; without the row, "every builder that goes through the seam is driven by a row below" is red. The remote pin's local-vs-remote table gained the class too (the withheld sentence is one sentence on both compilers).INVALID_FILTER/ 400, the error's own keys exactlycode,status, no field or pattern on the wire, both in the log / sink.'author': the full text. Unmarked: byte-identical to policy. Merged$and: the refusing arm's mark decides, in both arm orders.TursoDriverin remote mode no mark survivestoRemoteFilter, so an author gets the class statement there (the fix(driver-sql, driver-turso): every filter-compile refusal goes through the withheld provenance seam, so a read scope refused there names no field or literal (#20039) #20093 fail-closed cost, pinned inturso-20041-like-nul-pattern.test.ts).H5: declaration
Clause-②: yes (narrowing): the PR adds one public export,hasNulInLikePatternon@objectstack/spec/data(api-surface/data.json+1), so the value isyes(AGENTS.md's Clause-② rule, the PR fix(driver-turso)!: a remote TursoDriver answers or refuses every inherited SqlDriver member (#20055) #20104 precedent), and the arm is(narrowing)for the refused patterns. The claim carriedno (narrowing); corrected in the patch round after contract review 5828387721. The changeset grades@objectstack/spec,driver-sql,driver-sqlite-wasm,driver-tursoanddriver-memoryminor, with BREAKING and a!title, following PR fix(driver-turso)!: refuse a remote url beside syncUrl, and any replica not on a local file, instead of running on :memory: #19971's changeset.node scripts/check-changeset-no-major.mjs --base 8d76c2d38c: "✓ This diff introduces nomajorbump." (exit 0)node scripts/check-adr-0087-registration.mjs --base 8d76c2d38c: "✓ check-adr-0087-registration: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition." —[BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription)(exit 0). The gate chosenot-required: no key, schema, object definition or stored representation moves, and no rewrite of a stored pattern keeps its meaning.Compile surfaces (
references/compile-surfaces.md, re-verified with its grep: 92 hits in non-test sources)driver-sqlapplyFilterCondition(sql-driver.ts:15953);driver-sqlite-wasmand Turso local by inheritanceRemoteTransport.buildWhereSQL(remote-transport.ts:2668)$like/$ilikearm refuses itcompileScopedFilterToSql(read-scope-sql.ts:602)$likealready, measuredlowerAnalyticsWhere(filter-normalizer.ts:2015)normalizeAnalyticsFilterTreerefuses every$like, measuredformulamatchesFilterCondition(matches-filter.ts:212)applyHaving/matchesHaving(having-filter.ts:279/:292)$like, measureddriver-memorycheckCondition(memory-matcher.ts:361) and the query pathdriver-mongodbtranslateFieldOperators(mongodb-filter.ts:832)$likethrough itsdefault:arm, measured throughtranslateFilterTests
New pins, each asserting
code,statusand the path (never a baretoThrow()), plus NUL-free controls and the dangling escape beside U+0000:packages/spec/src/data/filter-like-nul-pattern.test.ts(5): the predicate, the escaped U+0000, its independence from the dangling escape, and the converters unchanged.driver-sql/src/sql-driver-20041-like-nul-pattern.test.ts(84): 9 patterns x bare /$not/$or/$and, unmarked (class only, path in the log, throughfindandcount) and author-marked (operator, field, path); the dangling escape keeps its refusal also beside U+0000; 8 controls equalformula;sqlite,pgandmysql2compiles refuse on the walk.driver-sqlite-wasm/src/sqlite-wasm-20041-like-nul-pattern.test.ts(16), the same through sql.js, controls checked againstformula.driver-turso/src/turso-20041-like-nul-pattern.test.ts(17): local, remote over the stub and remote over a real libSQL engine; noFROMstatement reaches the engine for a refused filter; 8 controls, equal on all three transports.driver-memory/src/memory-20041-like-nul-pattern.test.ts(21): the query path, the QueryAST spelling andmatch(), with controls.$like:git grepover every*.test.tsthat names$like/$ilikeand U+0000 finds none outside this PR.Suites (
vitest run --maxWorkers=2), at5f2e6f246d(code equal to this head but for a comment inmemory-driver.ts), dists rebuilt:driver-sql: 186 files passed, 11 skipped; 3024 tests passed, 170 skipped.driver-turso: 69 files, 1629 tests passed.driver-sqlite-wasm: 33 files, 622 tests passed.driver-memory: 53 files, 1269 tests passed (re-run at049b3a6c95, same).formula: 35 files, 978 tests passed.spec: 570 files, 16369 tests passed, 2 todo.plugin-auth(consumer): 114 files, 2440 tests passed.typecheckexits 0 inspec,driver-sql,driver-sqlite-wasm,driver-turso,driver-memory.tsc --listFilescounts each new driver test once in its package program; the spec test once intsconfig.test.json, whichcheck:test-typecheckcompiles.Before-red and ablations (one-off, no file left behind)
All from committed state, through
scripts/ablation-replace.mjs(anchor 1 -> 0 on disk, blob changed), restored with the blob equal toHEAD,git diff HEADempty andgit status --porcelainempty.driver-sqlrebuilt andablation-dist-preflightfound the marker indist/before the run. Predicted and observed exactly:sql-driver-20041: 75 failed, 9 passed (the controls, the dangling escape, and nothing else green);sql-driver-compile-refusal-seam: 4 failed, 91 passed (the new row);sqlite-wasm-20041: 10 failed, 6 passed;turso-20041: 8 failed, 9 passed;remote-transport-compile-refusal-seam: 6 failed, 96 passed;memory-20041: 15 failed, 6 passed;sql-driver-like-pattern(22) andmemory-like-pattern(15) stayed green.Restored, rebuilt, and
ablation-dist-preflight --absentpassed with the tree clean.nulLikePatternErrorcallingunsupportedFilterError,nulLikePatterncallinginvalidFilterError). Predicted and observed:driver-sql78 failed of 179 (both enumeration assertions, the row's 4, and the 72 path / log cases of the new suite); remote seam pin 6 failed of 102.Gates (at
049b3a6c95)node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackfrom the real diff (17 paths, merge base8d76c2d38c): 110 commands, the PM's 79 plus 31 the docs,driver-memoryand changeset paths add. All 110 run after a fullturbo run build --filter='./packages/*' --filter='./packages/*/*'(72 tasks);--ran: "110 derived, 110 run, 0 NOT-MEASURED, 0 UNRUN".node scripts/check-empty-changeset.mjs --base origin/main, the DELIBERATE CORRECTION of.changeset/20024-sqlite-glob-stored-nul.mdabove.check-changeset-fixed,check:meta-url-spelling,check:spec-changes,check:authz-resolver,check:error-code-casing,check:filter-alias-parity,check:engine-double-contract,check:error-status-conformance: all exit 0.pnpm --filter @objectstack/spec check:generated: all 15 artifacts up to date aftergen:api-surface,gen:export-origins,gen:schemaandgen:docs(api-surface/data.jsonandexport-origins/data.jsongainhasNulInLikePattern;authorable-surface.base.jsonuntouched).pnpm check:driver-conformance: "50 covered cell(s), 0 in the DEBT ledger, 0 exempt" at base and at head.node scripts/check-issue-citations.mjs --base 8d76c2d38c: 17 citations across 5 files, all resolve.pnpm check:nul-bytespasses, and a control-byte scan of the changed files finds none. U+0000 is spelledString.fromCharCode(0x00)in every file.b88ca46d90(merge base6780e34af5, 17 paths, after a fullturbo run build): the derivation gives the same 110 commands; 109 exit 0 andcheck-empty-changesetexits 1 on the driver-sql / driver-turso (SQLite faces):GLOBstill cuts a STORED value at its first U+0000, so a NUL-free$contains/$notContains/$icontains/$endsWithanswers wrongly on it; a$like/$ilikepattern holding U+0000 is cut the same way #20024 correction;--ran: "110 derived, 110 run, 0 NOT-MEASURED, 0 UNRUN".check-changeset-no-majorandcheck-adr-0087-registrationexit 0;check:generatedall 15 up to date.--no-inline-config --format jsonover the 12 changed.tsfiles reports 12 files, 0 errors, 0 warnings.eslint.config.mjsenables no type-aware linting (the printedparserOptionsforsql-driver.tsare{"ecmaVersion":"latest","sourceType":"module"}), so this diff cannot move the verdict of a file it does not touch. The fullpnpm lintis CI's.Acceptance notes
GLOBstill cuts a STORED value at its first U+0000, so a NUL-free$contains/$notContains/$icontains/$endsWithanswers wrongly on it; a$like/$ilikepattern holding U+0000 is cut the same way #20024 item 2 (ii), and driver-sql / driver-turso (SQLite faces):GLOBstill cuts a STORED value at its first U+0000, so a NUL-free$contains/$notContains/$icontains/$endsWithanswers wrongly on it; a$like/$ilikepattern holding U+0000 is cut the same way #20024 remains open for it.content/docs/protocol/objectql/query-syntax.mdxstill names only the dangling escape among refused patterns. It is not false, only incomplete, and it is outside this claim's file surface, so it is left for the next PR that touches the page.formulakeeps evaluating a U+0000 pattern (correctly). A write-sidecheckwith such a pattern therefore answers where the read side refuses; the read side refusing loudly means the two can no longer silently disagree.origin/mainat6780e34af5is merged into this branch (merge commit2b8dd90200, no conflicts): the 10 commits past the old merge base8d76c2d38careaa04ea2964,fa00ebf447,7b27bd00c7,7a13e0562a,7c1039b388,55daf89d74,226e00c038,7b068877ce,0d73ff6245,6780e34af5. One of them touches this PR's paths:55daf89d74(PR fix(driver-turso): a remote $between that is not two bounds is refused INVALID_FILTER / 400 through the transport's withheld seam, naming no field or value (#20094) #20114) changespackages/drivers/driver-turso/src/remote-transport.tsandremote-transport-compile-refusal-seam.test.ts; both auto-merged, and the branch's delta against the new merge base is still exactly its 17 files, +1017/−16. After the merge:turso-20041-like-nul-pattern17/17 andremote-transport-compile-refusal-seam136/136 (102 plus fix(driver-turso): a remote $between that is not two bounds is refused INVALID_FILTER / 400 through the transport's withheld seam, naming no field or value (#20094) #20114's rows),driver-turso69 files / 1663 tests;sql-driver-compile-refusal-seamandsql-driver-20041-like-nul-pattern179/179;sqlite-wasm-2004116/16, memory 36/36, spec 31/31.