Skip to content

fix(spec): $like / $ilike _ is one Unicode code point on every JS face - #20222

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-20143-like-underscore-code-point
Sep 27, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-20143-like-underscore-code-point

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20143

Clause-②: yes

What was wrong

$like / $ilike answered two different row sets for one filter, depending on the driver behind the object. The SQLite faces (driver-sql on better-sqlite3, driver-sqlite-wasm, driver-turso local and remote) compile _ to GLOB's ? through likePatternToGlobPattern, and ? counts code points. The JavaScript faces compiled the spec's likePatternToRegexSource with no regex flags, so _ → [\s\S] read one UTF-16 code unit, which is half of an emoji. The spec's text said "_ matches exactly one character" without saying which unit.

Triage set the direction: _ is one code point. The stop condition (a real semantic reason for the UTF-16 reading) was measured and does not hold; see below.

The fix

  • @objectstack/spec (packages/spec/src/data/filter.zod.ts)
    • New export likePatternToRegExp(pattern, foldAscii = false): RegExp. It compiles likePatternToRegexSource with the u flag and nothing else, so the flag lives in the one shared definition instead of in each consumer.
    • matchesLikePattern now evaluates likePatternToRegExp.
    • likePatternToRegexSource is unchanged and still exported. Its docblock gains a section saying the source means one code point per _ only under u.
    • LIKE_DESCRIPTION and the GLOB table now say which "character" is meant.
  • @objectstack/driver-memory (src/memory-driver.ts): both mingo doors (the $like filter at normalizeFilterCondition, and the AST like / ilike node at convertConditionToMongo) built their own new RegExp(likePatternToRegexSource(...)) with no flags. They now take likePatternToRegExp. This is a source change outside the claim's expected surface. Measurement put the flag's choice in these two call sites, not in the spec, so a spec-only change would have left both doors on UTF-16. This is the producer of the RegExp moving into the spec, not consumer-side tolerance.
  • @objectstack/formula: no source change. matchesFilterCondition answers through matchesLikePattern.
  • No driver-sql / driver-turso / driver-sqlite-wasm source change. Those faces are the oracle; they gain test files only, per the card's "pin the family cells on every face".

Changed describe() text, verbatim (LIKE_DESCRIPTION, both copies)

Before:

"_" matches exactly one character, and a backslash escapes the character after it

After:

"" matches exactly one character, and a character is one Unicode code point — an emoji or any other character outside the Basic Multilingual Plane is ONE "", never two, as SQL LIKE counts it. A backslash escapes the character after it

No refusal text changed. likePatternToRegExp throws the same plain Error as the source translation on a dangling escape (the backstop behind hasDanglingLikeEscape), and that message is pinned.

Measured: every face, before and after

A probe (18 patterns over 9 values holding U+1F600 and U+1D49C) ran each face from its built dist/ against a code-point oracle (the spec regex compiled with u):

tree spec matchesLikePattern formula memory $like door memory AST door better-sqlite3 sql.js Turso local Turso remote (real libSQL) differing cells
base e7f69dbb 12 differ 12 differ 12 differ 12 differ 0 0 0 0 48
head 713d1504 0 0 0 0 0 0 0 0 0

The driver-memory reference matcher is not exported from dist/, so it is pinned in the unit suite instead.

The family cells, before → after on the JS faces (after = what every SQLite face already answered)

Rows: x, 😀, 😀😀, a😀b, a😀😀b, A𝒜B, ab, axb, axyb.

op · pattern before (UTF-16) after (code point)
$like _ x 😀, x
$like __ ab, 😀 ab, 😀😀
$like a_b axb a😀b, axb
$like a__b a😀b, axyb a😀😀b, axyb
$like ___ axb a😀b, axb, A𝒜B
$like 😀_ / _😀 none 😀😀
$ilike _ x 😀, x
$ilike A_B axb a😀b, axb, A𝒜B
$ilike A__B a😀b, axyb, A𝒜B a😀😀b, axyb

The answer set moves both ways, so the changeset's declaration names the measured direction arm, narrowing (BREAKING, graded minor in the launch window), with an ADR-0087 not-required (no-migration-prescription) disposition. No pattern is newly refused, no refusal is lifted, and values made only of BMP characters answer exactly as before.

Every consumer of the translation (Zone 2 item 2), before → after

consumer before after
spec matchesLikePattern flagless new RegExp: UTF-16 likePatternToRegExp: code point
formula matchesFilterCondition via matchesLikePattern: UTF-16 code point
driver-memory $like / $ilike door (mingo) local flagless new RegExp: UTF-16 likePatternToRegExp: code point
driver-memory AST like / ilike door (mingo) local flagless new RegExp: UTF-16 likePatternToRegExp: code point
driver-memory reference matcher via matchesLikePattern: UTF-16 code point
driver-turso turso-20024-like-stored-nul grid oracle matchesLikePattern; its rows are BMP-only unchanged answers
service-analytics, @objectstack/lint, objectql having, driver-mongodb no consumer (git grep of both symbols) none
objectui git grep of both symbols: 0 hits at HEAD 5c61e5241 and at the pinned .objectui-sha f8a9d0fb (exit 1 on both; a control grep in the same tree hits) none

No other LIKE-to-regex translation exists in packages/**/src.

u-flag side effects (Zone 2 items 3 and 4)

  • Escapes. The translation escapes exactly \ ^ $ . * + ? ( ) [ ] { } | and /. That is the regex syntax characters plus /, exactly the identity escapes u accepts. Every other ASCII punctuation mark is emitted bare, where u reads it as itself. This is pinned over all 33 printable ASCII non-alphanumerics, bare and backslash-escaped, with foldAscii off and on: each compiles under u and matches only itself. The escaped set is pinned to equal that set. A control proves u really rejects \- and \:.
  • [\s\S], never .. Both wildcards use [\s\S], so under u a _ is one code point including a newline (pinned: source ^[\s\S]$, and a_b matches a newline b).
  • Surrogate pairs. A character outside the BMP, literal or escaped, is emitted as its whole pair (pinned: the source for backslash + 😀 is ^😀$), and u reads the pair as one code point. Under u, % never splits a pair: % followed by a lone low surrogate does not match 😀 (pinned).
  • $ilike / foldAscii. The fold stays in the source as [Aa] classes, unaffected by u. likePatternToRegExp(...).flags is pinned to exactly u, with no i. The $ilike family cells are pinned on every face.

The stop condition: nothing needs the UTF-16 reading (Zone 2 item 5)

  • Nothing in @objectstack/formula lowers CEL to $like: cel-to-filter emits no $like, and rls-predicate leaves SQL LIKE unparseable. The only $like evaluation is matches-filter.ts, via the spec.
  • CEL's own size() in formula already counts code points: size('😀') is 1 and size('a😀b') is 3 (pinned in the formula suite). The old $like answer disagreed with the same package's string length.

Pins (every face, literal code-point rows, not parity alone)

face suite cases
spec packages/spec/src/data/filter-like-code-point.test.ts 23
formula packages/formula/src/matches-filter-like-code-point.test.ts 12
driver-memory, all three doors packages/drivers/driver-memory/src/memory-20143-like-code-point.test.ts 11
driver-sql (better-sqlite3) packages/drivers/driver-sql/src/sql-driver-20143-like-code-point.test.ts 12
driver-sqlite-wasm (sql.js) packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-20143-like-code-point.test.ts 12
driver-turso (local, remote stub, remote real libSQL) packages/drivers/driver-turso/src/turso-20143-like-code-point.test.ts 12

Each SQLite face also holds itself to the JS answer (formula, or the spec's matchesLikePattern where formula is not a dependency), which is the cross-face half.

Ablation (committed fix first; one-shot, no permanent test file)

The mutation replaced the 'u' flag argument with an expression that evaluates to the empty flag string and carries the marker ABLATION20143. It went through scripts/ablation-replace.mjs under its restore trap, inside a script with its own trap restoring from HEAD.

  • Mutate leg: on disk, marker count 1 and anchor count 0. pnpm --filter @objectstack/spec build exited 0, and ablation-dist-preflight.mjs @objectstack/spec ABLATION20143 exited 0, so the marker is in dist/. The expected direction was written down before running: JS faces red on the family cells; SQLite faces green on their literal cells and red only on the cross-face leg. Observed: spec 13 failed / 23; formula 11 / 12 (CEL size() stays green); driver-memory 10 / 11; driver-sql 1 / 12, driver-sqlite-wasm 1 / 12, driver-turso 1 / 12 (each the cross-face leg only). That is the predicted direction.
  • Restore leg: git hash-object equals the HEAD blob 838a80720b3d, git diff HEAD is empty, and git status --porcelain is clean. Rebuild exited 0, ablation-dist-preflight.mjs ... --absent exited 0, and all six suites are green again.

Verification (at 713d1504, after merging origin/main at 585c9af5)

  • Build of the six-package closure: turbo 10/10. pnpm --filter @objectstack/spec check:generated passed after the merge. Regenerated here: api-surface/data.json (0 breaking, 1 added: likePatternToRegExp), export-origins/data.json, content/docs/references/data/filter.mdx.

  • Full suites: spec 542 files / 15,943 passed (2 todo); formula 38 / 1,039; driver-memory 55 / 1,339; driver-sqlite-wasm 35 / 656; driver-turso 73 / 1,921 (16 skipped); driver-sql 189 files (+11 skipped) / 3,111 passed (170 skipped).

  • Typecheck, exit 0 on all six: spec, formula, driver-memory, driver-sql, driver-sqlite-wasm, driver-turso. Each new test file is counted inside a compiled program with --listFiles: tsconfig.json for the four drivers, and tsconfig.test.json (via check:test-typecheck) for spec and formula.

  • Gates: 108 derived at 713d1504 (dispatch-gates.mjs --commands --repo objectstack-ai/objectstack), reconciled with --ran: 101 exit 0, 0 unrun, 7 NOT MEASURED. All seven are exit 3, PREREQUISITE NOT MET:

    • check:doc-formula-expressions, check:doc-security-posture and check:docs-transcript-drift need @objectstack/lint built.
    • check:lean-entry-closure needs objectql built, and check:skill-examples needs client / client-react built.
    • check:dual-build-cjs-loads and check:type-check-debt need the whole workspace built.

    The targeted prerequisite build never acquired the shared verify-lock (two 9-minute queue-timeouts behind other seats' batches), so those seven are declared to CI's Lint & Repo Gates. The seven roster families whose roster sits in a touched directory were run as well, all exit 0: check:meta-url-spelling, check:spec-changes, check:authz-resolver, check:error-code-casing, check:filter-alias-parity, check:object-def-param-keys, check:tenant-chokepoint. check-adr-0087-registration reads the changeset as [clause-②-narrowing] not-required (no-migration-prescription).

Acceptance notes

  • Three hand-written surfaces restate "_ is exactly one character": content/docs/api/client-sdk.mdx:553, content/docs/deployment/troubleshooting.mdx:148, and the packages/client/src/query-builder.ts:88 JSDoc. They stay true under the reading the spec now defines, and are not edited here. Carrier: none.
  • likePatternToRegexSource stays exported, so a future consumer could still compile it without u. Its docblock carries the prohibition, and after this PR nothing outside the spec calls it. Retiring the export would be a removal and is not this card.
  • A value or pattern holding an unpaired surrogate is not measured. UTF-8 SQLite cannot store one, and the JS faces under u treat it as its own code point.
  • The Postgres and MySQL _ arms are NOT MEASURED, as on the card. SQL _ is one character there.

Generated by Claude Code

…int on the JS faces

likePatternToRegexSource returned a source that every JS face compiled
without flags, so `[\s\S]` read one UTF-16 code unit: `_` missed a stored
emoji that every SQLite face (GLOB `?`) matched. likePatternToRegExp now
owns the compilation with `u`; matchesLikePattern and both driver-memory
mingo doors take it. LIKE_DESCRIPTION and the docblocks say which
"character" is meant: one Unicode code point.

Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN
Co-authored-by: Claude <noreply@anthropic.com>
The family cells (`_`, `__`, `a_b`, `a__b`, `___`, and the $ilike twin)
over values outside the BMP, asserted as literal code-point rows on the
spec, formula, all three driver-memory doors, driver-sql (better-sqlite3),
driver-sqlite-wasm (sql.js) and TursoDriver local plus remote (stub and a
real libSQL engine); each SQL face is also held to the JS answer. The spec
suite adds the u-flag legality pin over every ASCII punctuation mark.

Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN
Co-authored-by: Claude <noreply@anthropic.com>
Clause-②: yes (narrowing), with the ADR-0087 not-required disposition.

Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN
Co-authored-by: Claude <noreply@anthropic.com>
+ likePatternToRegExp (function) on ./data (0 breaking, 1 added), and the
$like description's code-point sentence in the generated filter reference.

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

5 anchor(s) derived from 2 changed package(s); no hand-written page names any of them. ⚠️ 2 changed file(s) yielded no anchor (packages/spec/api-surface/data.json, packages/spec/export-origins/data.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/spec/api-surface/data.json, packages/spec/export-origins/data.json) — pages documenting those are invisible to this run
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 137 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 805af4f290565955d6e6b56ee46fed45f721d955 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from ef8deacbdac297ee94797aea951836bfc11c69c5 — the merge of head 713d1504d6e9c710c94dae8efaeda953e478b932 into base 805af4f290565955d6e6b56ee46fed45f721d955, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin ef8deacbdac297ee94797aea951836bfc11c69c5 && git checkout ef8deacbdac297ee94797aea951836bfc11c69c5
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 805af4f290565955d6e6b56ee46fed45f721d955 713d1504d6e9c710c94dae8efaeda953e478b932 && git checkout -B drift-repro 805af4f290565955d6e6b56ee46fed45f721d955 && git merge --no-ff 713d1504d6e9c710c94dae8efaeda953e478b932

node scripts/docs-audit/affected-docs.mjs --json 805af4f290565955d6e6b56ee46fed45f721d955

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actions github-actions Bot added documentation Improvements or additions to documentation protocol:data tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Sep 27, 2026
@objectstack-fleet objectstack-fleet Bot added the needs:contract-review Marker only — PR awaits its at-tier Contract review PASS; no check reads it, the record decides label Sep 27, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 713d1504d6e9c710c94dae8efaeda953e478b932

① Derived judgments

  • u is safe for every pattern the translation can emit — RIGHT. Read at head: likePatternToRegexSource (packages/spec/src/data/filter.zod.ts:1365-1402) emits only ^, $, [\s\S]*, [\s\S], [Xx] (the foldAscii fold, ASCII letters only), \ + one of \ ^ $ . * + ? ( ) [ ] { } | / (the regex at :1399), or the input unit bare. That escape set is exactly the u-mode identity-escape set (SyntaxCharacter plus /), and the three characters u refuses bare (], {, }) are in it. asciiCaseInsensitiveRegexSource is not on the $like path (it serves $icontains); the $ilike fold is the inline [Aa] class. Executed (review/probe-u-flag.out, tsx over the head's TS source): 1320 patterns × 2 folds — every code unit 0x00–0xFF bare, escaped, mid-pattern and doubled; lone high and low surrogates; escaped 😀; regex-meta shapes ({2}, (?, [a-z], \d, \p{L}, \1, \k …) — 0 newly refused under u, 0 newly accepted, 2636 compile under both, 0 throw under both (4 dangling-escape shapes skipped, refused at every door before compile). The changeset's "No pattern is newly refused" holds; no new 500 or undeclared refusal at a request door. Formula would have masked a new throw anyway (packages/formula/src/matches-filter.ts:374-379 catches and answers false); driver-memory's doors would not — and there is nothing to mask.
  • Every JS face compiles through u — RIGHT, census done on the head. Non-test git grep under packages/**: likePatternToRegexSource has no caller outside filter.zod.ts (only CHANGELOG/generated mentions); likePatternToRegExp is called at memory-driver.ts:1399 ($like/$ilike door) and :1647 (AST like/ilike door) and by matchesLikePattern (filter.zod.ts:1430); matchesLikePattern is called by memory-matcher.ts:640 (reference matcher) and formula/src/matches-filter.ts:377. No new RegExp( in driver/formula/spec sources touches a like source (the remaining sites are escapeRegex and $icontains). memory-analytics.ts:373 hands a $contains substring (with %/_ escaped) to GLOB, so no _ wildcard reaches it. The built dist/ carries "u" in all four builds (data/index.{js,mjs}, browser/data/index.{js,mjs} at :1069-1072) and no flagless compile of the like source. objectui (/home/user/objectui, HEAD 5c61e524 and the pinned .objectui-sha f8a9d0fb): 0 hits for the three symbols (control grep hits); its only $like mention, packages/core/src/adapters/ValueDataSource.ts:642, documents that the matcher refuses $like/$ilike ("no pattern engine"). Not a missed site; out of this PR's scope.
  • Each changed published sentence — RIGHT on every measured face; see ③ for the MySQL arm. LIKE_DESCRIPTION is bound at filter.zod.ts:1145 and :1612 (both copies) and the generated content/docs/references/data/filter.mdx carries the new sentence in all four $like rows; the dist shared/index.js carries the string. The built .d.ts (packages/spec/dist/filter.zod-D7MYdk7G.d.ts:230-252) declares likePatternToRegExp and holds every new docblock phrase ("The source means this ONLY under the u flag", "never hand this source", "exactly the identity escapes u", "No i flag, ever", the GLOB-table row). The changeset table was re-derived through matchesLikePattern against a flagless compile of the same source: _ on 😀 false→true; __ on 😀 true→false; a_b on a😀b false→true; a__b on a😀b true→false and on a😀😀b false→true; the $ilike twin identical — exactly the table, nothing else in it moved. The SQLite faces already counted code points: likePatternToGlobPattern maps _ to GLOB ? (filter.zod.ts:1502); driver-sql reaches it via likePatternPredicate → sqliteLikePatternMatch → sqliteLikePatternPlan (sql-driver.ts:3800, :3824, :3941-3946); SqliteWasmDriver extends SqlDriver (sqlite-wasm-driver.ts:67) and inherits it; driver-turso remote calls it at remote-transport.ts:3449 and :3491, local inherits SqlDriver. The new driver tests pin real engine answers, not parity alone: better-sqlite3 :memory: (driver-sql), sql.js :memory: (sqlite-wasm), and three turso transports (libsql local :memory:, the better-sqlite3 stub, and a real @libsql/client file::memory:), each asserting literal code-point row sets per family cell plus a hex(v) storage premise and one cross-face leg.
  • % under u — RIGHT, no undeclared movement class. Movement census (probe-u-flag.out §4, 28 patterns × 2 folds × 32 values): BMP-only values (x, ab, a\nb, é, 中文, �, NUL, %, _, \) — 0 cells moved; well-formed-pair values — 62 cells moved, all of them either the family cells or a pattern holding a lone surrogate against a value holding a pair (e.g. % + U+DE00 on 😀 true→false), which the PR pins; values holding ONLY lone surrogates (\uD83D, \uDE00, a\uD83Db, U+DE00 U+D83D …) — 0 cells moved; the 24 lone-surrogate cells that moved all hold a well-formed pair in the value. So "values made only of characters inside the BMP answer exactly as before" holds even counting a lone surrogate as a BMP code point, and every moved cell is inside the changeset's "only for values holding a character outside the BMP" clause. [\s\S]* never splits a pair under u (% + lone low surrogate on 😀 is false; flagless it was true).
  • memory-driver.ts — RIGHT. Both doors pass the same fold as before: canonical === 'ilike' at :1399 and op === '$ilike' at :1647, unchanged from the removed lines. mingo 7.2.4 (node_modules/.pnpm/mingo@7.2.4): normalize() (esm/util/_internal.js:474-484) rebuilds $regex as new RegExp(expr.$regex, expr.$options); with no $options the second argument is undefined, which the RegExp constructor reads as "keep the pattern's own flags" — proved in node: flags stay "u" and test('😀') is true, whereas a string $options (even '') would clobber the flag to "" and answer false. The predicate itself calls b.exec(x) on that object (esm/operators/_predicates.js:101-105). The driver passes no $options on either door. End to end: memory-20143-like-code-point.test.ts asserts literal code-point rows on find() through the $ door, find() through the AST door and match() on the reference matcher — 11/11 green in my worktree. The reference matcher (memory-matcher.ts:640) goes through matchesLikePattern, so all three doors share the one compilation.
  • Tests — RIGHT, substantive. Every new suite asserts the NEW row sets per face against literal rows (FAMILY tables of 10 cells over 9 values, per face), not merely the absence of old assertions; the spec suite also keeps the flagless reading as a control and asserts each control cell differs from its code-point cell. Re-run here in a detached worktree at the head (/home/user/objectstack-review-20222, removed afterwards): spec 23/23, formula 12/12, driver-memory 11/11 from TS source; driver-sql 12/12 on better-sqlite3, driver-sqlite-wasm 12/12 on sql.js, driver-turso 12/12 on libsql local, the better-sqlite3 stub and a real @libsql/client file::memory: engine (after building the spec, formula, types, core, metadata-core, observability, refd-timer-testkit and driver-sql dists those suites resolve through). No existing test file is modified or deleted (diff vs merge base 585c9af5: 12 files, +779/−21, all deletions inside filter.zod.ts and memory-driver.ts). git grep on origin/main for astral characters (😀, fromCodePoint(0x1f…), \uD83D, surrogate) in spec/formula/driver test files near like: none; the only __ pins (*-20024-like-stored-nul.test.ts) hold BMP-only rows (ab, eu, NUL shapes), so no UTF-16 pin was flipped silently and the 20024 grids' matchesLikePattern oracle answers as before. CI at head: 34 success / 5 skipped / 0 failing, complete; the Lint & Repo Gates job that hosts the seven locally NOT-MEASURED gates is green.

② Semver level

  • minor on spec, formula, driver-memory — RIGHT for this repo. All six packages are 17.4.0, published, in the one Changesets fixed group of 69; no .changeset/pre.json. scripts/check-changeset-no-major.mjs (header, "LAUNCH-WINDOW GUARD") refuses major until GA: "During the launch window we ship breaking changes as minor", breaking-ness carried by the body's declaration plus the ADR-0087 disposition, "not by the level". AGENTS.md :1074-1075: yes takes at least minor, (narrowing) is BREAKING. A BREAKING minor is therefore the coherent (and only permitted) grade; major would have been refused. Run offline against origin/main: check-changeset-no-major exit 0 ("no major bump"; the level axis is PR-scoped and CI's Check Changeset is green at head).
  • (narrowing) when the set moves both ways — RIGHT. check-adr-0087-registration.mjs breakingDeclaration() (:629-642) takes the narrowing arm as signal (4) ([finding] An accept-set narrowing owes a **BREAKING** banner in core but not in platform-objects — and the ADR-0087 classifier reads the banner #16421), states that widening adds no signal, and its self-test fixture pins exactly this spelling: "the narrowing arm beside a yes value — a diff that widens AND narrows" → Clause-②: yes (narrowing). The arm carries the breaking half; the PR body's bare Clause-②: yes is the claim line the level axis reads.
  • ADR-0087 not-required (no-migration-prescription) — RIGHT. Run offline against origin/main: exit 0, reading [clause-②-narrowing] not-required (no-migration-prescription); the category is re-validated statement-against-statement (refused only when the body carries a FROM→TO prescription, which it does not). Nothing authorable is removed or renamed (api-surface: 0 breaking, 1 added); objectstack migrate meta has nothing to rewrite.
  • formula bumped with untouched source — RIGHT. AGENTS.md :1071: a changeset for "anything that publishes"; @objectstack/formula's published behaviour (matchesFilterCondition $like/$ilike) moves through its spec dependency, and the CHANGELOG entry is what an upgrading formula consumer greps. In a fixed group the level propagates regardless; naming the package places the notice where it is read.

③ Boundary flags

  • Out-of-lane driver-memory source (domain:engine). memory-driver.ts +12/−8 on the two mingo doors, recorded by the seat as a claim-surface amendment (ACCEPT 5855574230) under the producer-side clause; measurement placed the flag choice in those two call sites, so a spec-only change would have left both doors on UTF-16. The seat states no other open PR touches memory-driver.ts; not re-measured here.
  • Three hand-written docs still say "one character" — content/docs/api/client-sdk.mdx:553, content/docs/deployment/troubleshooting.mdx:148, packages/client/src/query-builder.ts:88; also the conformance-ledger note packages/spec/src/data/filter-text-conformance.ts:315 ("any one character"). All remain true under the reading the spec now defines; none defines the unit. Carrier: none, as the PR records.
  • likePatternToRegexSource stays exported. A consumer can still new RegExp(source) flagless and get the UTF-16 reading; the only guard is the docblock prohibition (filter.zod.ts:1339-1350). No consumer outside the spec in this repo or in objectui at HEAD or at the pin. A follow-up (deprecation, or a grep gate over new RegExp(likePatternToRegexSource) would make the prohibition enforced rather than declared; not this card.
  • mingo $options hazard. The u flag survives mingo only because neither door passes $options; a future door passing $options: '' would silently drop it (proved above). The new driver-memory test covers the two existing doors, not a future one.
  • Postgres unmeasured — likePatternPredicate (sql-driver.ts:3950-3954) binds the pattern verbatim to LIKE … ESCAPE, where _ is one character in a UTF-8 database: consistent with the sentence. Not executed (no PG here; CI's live PG job exercises temporal conformance, not this cell).
  • MySQL arm: derived contradiction with the new sentence, pre-existing, unmeasured — needs a card. likePatternPredicate on mysql (sql-driver.ts:3956-3960) emits CAST(?? AS BINARY) LIKE CAST(? AS BINARY) ESCAPE ?, and the driver's own docblock (:3510) calls that comparison "byte-wise". MySQL's LIKE over binary strings matches byte by byte, so _ consumes one UTF-8 byte there: 😀 would need four _ and é two, on the one face the same LIKE_DESCRIPTION names as answering $like ("the SQL family (driver-sql …)"). The new sentence ("ONE _, never two, as SQL LIKE counts it") is true of SQL LIKE over character strings and of every measured face, and the MySQL divergence already broke the OLD sentence for every non-ASCII character before this PR — but the PR sharpens the promise without carving out the arm it declares unmeasured. Not measured here (no docker daemon, no MySQL). Not blocking this PR's declared scope (the card and triage set PG/MySQL aside; the spec text is the contract and the arm is the face defect), but it is a finding class (a) candidate for the engine lane: the MySQL $like lowering should count characters (e.g. compare in utf8mb4_bin rather than via BINARY), or the description must name the exception.
  • Unpaired surrogates — declared unmeasured by the PR; my census shows values made only of lone surrogates do not move, and moved cells all hold a pair. UTF-8 SQLite cannot store a lone surrogate, so no cross-face cell exists.
  • REST not re-measured here. The card's HTTP measurement was on a sqlite-wasm stack (already the code-point answer). The JS-face doors the REST data API reaches (find with where on driver-memory; formula for write-side check) are the doors the new suites drive; no HTTP run was made in this review.

Implemented-by: claude/issue-20143-like-underscore-code-point
Reviewed-by: session_01Rjy9MeetSfq34PKn81CRiN

VERDICT: PASS

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:data size/l tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

2 participants