Repository navigation
fix(driver-turso)!: refuse a remote url beside syncUrl, and any replica not on a local file, instead of running on :memory: - #19971
Conversation
…ble behind it A remote url beside `syncUrl` was classified `replica` and handed Knex a private `:memory:` database, so every write read back and then vanished on restart, and nothing reached the remote. `@libsql/client` builds no embedded replica for a remote url. The same last arm of `toKnexConfig` took a forced `mode: 'replica'` or `mode: 'local'` beside a remote url, and a replica on `:memory:`. The constructor now refuses those configurations before `super()`, as VALIDATION_ERROR / 400, naming the way out: drop `syncUrl` (or `mode`) for a remote database, or point `url` at a local `file:` for an embedded replica. The false "embedded replica with an in-memory local cache" comment is replaced. Replica-face fixtures that rode `:memory:` + `syncUrl` now use a fresh local file per driver. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
… engine Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): ⛔ 1 release-owned page(s) name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 6 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 163217a7b28e859b84bac8deb8faa934b80a920a && git checkout 163217a7b28e859b84bac8deb8faa934b80a920a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2c1011b01bc071c545f72f2761647b8d9ab56375 7fc737470b73a61f1fb50d37315d24cf0c5f982c && git checkout -B drift-repro 2c1011b01bc071c545f72f2761647b8d9ab56375 && git merge --no-ff 7fc737470b73a61f1fb50d37315d24cf0c5f982c
node scripts/docs-audit/affected-docs.mjs --json 2c1011b01bc071c545f72f2761647b8d9ab56375
|
Contract reviewServed-tier: ① Derived judgments
② Semver levelConsistent: ③ Boundary flags
Implemented-by: VERDICT: FAIL Edits that make it PASS, all prose (no code change is required; the refused set the code implements is the one the PR body describes):
Isolated reviewer: a separate contract-review-tier subagent, fed the card, the PR and AGENTS.md only; the seat verified ③-1 against Generated by Claude Code |
…d README The replica-without-file refusal fires only when the resolved mode is `replica`. A bare path or an uppercase scheme with `syncUrl` and no `mode` auto-detects `local` and still constructs on `:memory:`, and so does the same url under a forced `mode: 'local'`. The changeset headline, the refusal list, the ways out and the blast-radius sentence now say exactly what the constructor refuses, and the README qualifies which replicas it covers. Prose only; no code or test changes. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: ① Derived judgments
② Semver levelConsistent: ③ Boundary flags
Implemented-by: VERDICT: PASS Isolated reviewer: the same contract-review-tier subagent, re-run on the new head; adopted by the seat. Landing condition, outside this record: every check on Generated by Claude Code |
…ough the allow-listed ccr pair; a landing denial stops and surfaces (objectstack-ai#19997) Fixes objectstack-ai#19990 Clause-②: no Rule text only, in three `pm-dispatch` references. This PR adds no allow row, no tool and no gate. `.claude/settings.json`, `scripts/pm/**`, `SKILL.md` and `AGENTS.md` are untouched. Line counts are unchanged (183 / 101 / 37), and every edited line is at or under 120 bytes. The maintainer's words, in the `domain:engine#1` seat's session, quoted on the card verbatim and in order: > 「你的pr为什么没有挂在当前session上」 > 「写一个 skills 卡片,更新技能」 > 「包括你刚才为什么不能merge,我当前session设置的是auto」 The same words reached the `domain:skills` seat directly (claim comment 5817962037): 「你的pr应该挂在当前 session上,对应的卡片优先派发」. ## What changed | file · line (after) | bytes | rule | |---|---|---| | `execution-duties.md` :149 (new) | 118 | Case 1. When a report names a PR, a session seat subscribes it at once (`subscribe_pr_activity`) and lists it on the seat post. Reason, stated once: a PR the relay opens is never attached to the session automatically. | | `execution-duties.md` :147 | 87 → 116 | The collection line now covers both modes itself ("(两种模式)", "评论与返回消息皆无"), replacing the deleted report-channel line (see *Line budget*). | | `landing-operations.md` :49 | 82 → 117 | (b). The landing executes the verdict of record (ACCEPT, or the contract-review PASS). It is not a self-approval. | | `landing-operations.md` :51 | 65 → 115 | (a). Ready and auto-merge go only through the two ccr commands that `settings.json` allow-lists (`rest-channel.md` :51 / :55). | | `landing-operations.md` :54 (new) | 120 | (c). A classifier denial during landing means: stop, report to the maintainer, and record the command and the denial reason on the card. ⛔ Never respell the command or switch to the relay to get around it. | | `landing-operations.md` :77 | 106 + 89 → 111 | Case 1, landing side. Every PR in a session seat's window must be subscribed; subscribe any that is missing. The optional 「关键 PR」 wording is gone. The old :77 "not before the report" clause is folded in as 「⛔ 不早于报告」. Routine seats keep polling. | | `reading-discipline.md` :23 | 82 → 120 | (d). A timer text carries no verdict or landing write verb. | Wording choices that differ from the dispatch text: - **`判决`, not `裁决`, at :49.** In this corpus `裁决` is a maintainer ruling, and `判决` is the review verdict (`execution-duties.md` :180–:183, 「判决 ACCEPT / REWORK / ESCALATE」). - **`判决与落地类写动词`, not only `落地类写动词`, at :23.** The timer that was denied `[Self-Approval]` told the seat to post the ACCEPT as well as run the two landing ops. ## Why no new allow row is owed: case 2 (a) The allow-listed landing route already exists. The skill already names it, and this PR only makes §B's landing step name it too. - `.claude/settings.json` :61–:66 allow-lists `curl -sS -X POST …/pulls/*/ccr/ready_for_review` and `curl -sS -X PUT …/pulls/*/ccr/auto_merge` for all three repos. The hotcrm pair was added on 2026-09-24 by `e6a5ecb9`. That commit also deliberately gave `fleet-write/dispatch.mjs` no row. `git grep -n 'with-fleet' .claude/settings.json` gives 0 hits; the control `git grep -n 'label-write' .claude/settings.json` gives 2. - `SKILL.md` :201 says 「ready/draft 走 ccr 路」, and `platform-readings.md` :48 says 「undraft 单通道:席位凭据走 `POST .../pulls/{n}/ccr/ready_for_review`」. - Measured on the timeline (`GET /issues/N/timeline`, 2026-09-24T16:3xZ): - PRs objectstack-ai#19873, objectstack-ai#19895, objectstack-ai#19902, objectstack-ai#19941, objectstack-ai#19948, objectstack-ai#19956, objectstack-ai#19970 and objectstack-ai#19993 were landed by the `domain:skills` seat under auto mode. Each has `ready_for_review` and `added_to_merge_queue` with actor `os-zhuang` (the ccr route, which writes as the seat's linked user). - PRs objectstack-ai#19971, objectstack-ai#19972 and objectstack-ai#19979 have the same two events with actor `objectstack-fleet[bot]` (the relay route). - Both routes work. The ccr pair is the one with an allow row. The relay route has none, so under auto mode the classifier judges it call by call. ## Where the standing authorization is recorded: case 2 (b) It is already recorded in the tree, so this PR adds only the one clause at :49: - `AGENTS.md` Prime Directive objectstack-ai#14: Tier S lands "by the owning seat on a contract-tier review of record". - `AGENTS.md` Multi-agent discipline §7 and Post-Task Checklist step 2: arm auto-merge on a PR that is green and accepted. - `landing-operations.md` :59 (Tier S). Whether that is enough for a seat landing a PR written by its own `mode:subagent` dev is put to the maintainer below. This PR does not rule on it. ## Line budget: what left, and where each fact still lives All three files stand at headroom 0. Each new line is paid for by deleting content, not by re-wrapping or raising a ceiling. - **`execution-duties.md` old :147 deleted.** It read 「报告通道统一:GitHub 是两种模式共用的真相源;dev 终报先落 issue 评论、再作返回消息。」 - The dev-side ordering lives in `.claude/agents/os-dev.md` :17–:18 (「报告交付两次,GitHub 优先:同一段 JSON 先作 issue 评论 … 再作为终报消息」). - "GitHub is authoritative in both modes" lives in `os-dev.md` :324 (「两种派发模式(`mode:subagent` 与 `mode:cloud`)下 GitHub 都是报告的权威源」). It also stays on the collection line as 「(两种模式)」. - **`landing-operations.md` old :77, second clause, deleted.** It read 「订阅是感知补充,⛔ 不替代 flip 定点」. The fact lives on: - :50: the flip timer is set at ACCEPT. - :52: 「CI success webhook 不可靠:⛔ 不坐等」. - `platform-readings.md` :40: 「订阅来的 `check_suite.completed` 是唤醒不是放行读数」. - Its first clause is kept on :77 as 「⛔ 不早于报告」. - **`landing-operations.md` old :76 rewritten in place.** It dates from `42af12fe7` (the 2026-08-07 ruling on subscribing *key* PRs). The newer maintainer words quoted above replace its optional scope. ## Measured risk that stays open - An allow row does not stop a denial based on content. `mcp__Claude_Code_Remote__send_later` is allow-listed (`settings.json` :23, present since before 2026-09-20), yet the engine seat's timer was denied `[Self-Approval]`. `platform-readings.md` :435 records another content-based `[Self-Approval]` denial. - Two explanations are possible: that session did not load this settings file, or the classifier judges content over an allow row. Which one holds was not measured. The eight ccr landings are the positive reading. The new :54 line covers the negative case. - **Write identity, a tension this PR did not create.** The ccr pair writes as the seat's linked user, `os-zhuang`, which is in `GOVERNED_APPROVERS` (`scripts/pm/check-governed-queue-guard.mjs` :576). Three texts point the other way: - `AGENTS.md`: "Every GitHub write leaves through `scripts/pm/`, as `objectstack-fleet[bot]` … ⛔ Never a bare `curl` … write". - `SKILL.md` :92: 「批准账号永不跑席位或作其关联用户」. - `SKILL.md` :94: 「写侧恒为 `objectstack-fleet[bot]`」. `SKILL.md` :201 already routes ready/draft through ccr, so this tension predates this PR. The new :51 states the same route more plainly. The choice is the maintainer's; see the question below. ## Verification At `04357257d`, every command from `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` was run, with the exit code captured before any pipe. All exited 0: 17 derived commands, plus `pnpm check:pm-governed-prose`, `node scripts/check-skills-token-ratchet.mjs` and `pnpm check:pm-settings-deny-roster`. The reconciliation `dispatch-gates --ran` reports: "17 derived famil(ies) accounted for — 17 run, 0 NOT-MEASURED (a DERIVED zero …)". Verdict lines: - `check:pm-skill-ratchet`: `execution-duties.md is 183 lines (ceiling 183; headroom 0)` · `landing-operations.md is 101 lines (ceiling 101; headroom 0)` · `reading-discipline.md is 37 lines (ceiling 37; headroom 0)`. - `check:pm-skill-id-lint`: `34 file(s) clean`. - `check:skill-frame-sync`: `the one declared copy of the decision frame is internally coherent`. - `check:nul-bytes`: `OK … no raw ASCII control bytes`. - `check:doc-formula-expressions` first exited 3 (PREREQUISITE NOT MET, unbuilt `@objectstack/formula` / `@objectstack/lint`). It exited 0 after `turbo run build` for those two packages under the verify lock. That first run measured nothing; it was not a failure. No build, test, reverse check or ablation applies to this change: it is rule text only, with no code path. ## Acceptance notes - `dispatch-runbook.md` :128 has cloud cards subscribe as soon as the draft PR exists. The folded 「⛔ 不早于报告」 agrees with it only because a cloud dev reports at draft-PR time (runbook :135). No change is made. - `origin/main` was not merged before opening. It moved by one commit (a `docs(qa)` change touching none of these files), and the queue rebuilds on current `main`. ## 维护者速读(草稿) **改了什么** - dev 报告里点名了 PR,席位当场订阅这个 PR 的动态,并记进座位贴。规则里写明原因:经中继开出的 PR 永远不会自动挂到会话上。落地说明里原来"给关键 PR 挂订阅"是可选说法,现在改成"落地窗口里每个 PR 都要挂上,缺了就补"。 - 落地(转 ready、挂 auto-merge)只走 `.claude/settings.json` 已放行的两条 ccr 命令。席位落地是在执行已记录的复核判决,不是自己批准自己。 - 落地过程中如果被权限分类器拒绝:停手,报给您,在卡上记下命令和拒绝原因;不换写法,也不改走中继绕过去。 - 定时提醒的文本里不再写"判决/落地"这类写操作,只写"到时重读什么、满足什么条件"。 **为什么改** - 您问了两个问题。 - PR 为什么没挂在当前 session 上:中继开的 PR 不会自动挂上,而技能里只有可选的"关键 PR 挂订阅"。 - auto 模式下为什么不能 merge:engine 席被拒了两次,一次是一条回读命令,一次是一条写着"发 ACCEPT 并落地"的定时器。它随后改走中继落地,而中继没有放行规则。放行的路本来就有:本席今天在 auto 模式下用它落地了 8 个 PR,一次都没被拒。 **风险与代价(含回滚)** - 只改三个技能参考文件的规则文本。行数不变,每行不超过 120 字节。回滚就是 revert 本 PR。 - ccr 两条命令记在 os-zhuang 名下。而 AGENTS.md 写的是"所有 GitHub 写都经 scripts/pm,以 objectstack-fleet[bot] 身份,永不裸 curl 写",SKILL.md 也写"批准账号永不作席位的关联用户"。这个矛盾早就存在(SKILL.md 本来就写"ready/draft 走 ccr 路"),本 PR 没有新造,只是把它写得更明确。 - 放行规则不保证分类器一定放行。`send_later` 在放行清单里,engine 席那条定时器还是按内容被拒了。所以新加了"被拒就停手上报"这一条。 **席位意见** **你要做的** - 回一句话,确认下面两件事,或者指出要改哪一件: - ① AGENTS.md 第 14 条(Tier S 由所属席位在达档复核记录在案后落地)和 Multi-agent discipline 第 7 条(PR 全绿且已验收就挂 auto-merge),就是席位落地自己子代理所写 PR 的常设授权,不用另外记。 - ② 用 ccr 两条命令落地,算 AGENTS.md「写只经 scripts/pm」这条规则的例外。是把这个例外写进 AGENTS.md,还是改走中继并加一条新的放行规则,都由您决定。 --- _Generated by [Claude Code](https://claude.ai/code/session_01A22sUB3mUWs6M36VgfijBq)_ Co-authored-by: Claude <noreply@anthropic.com>
…l the local engine cannot open instead of running it on :memory: (objectstack-ai#19996) Fixes objectstack-ai#19976 Clause-②: no (narrowing) `TursoDriver` no longer runs a url it cannot open on a private `:memory:` engine. The classifier now reads the url scheme in any letter case, the way `@libsql/client` routes it, so an uppercase `LIBSQL://` is remote. Whatever is still unrecognised (a bare path, an unsupported scheme) is refused at construction in a local or replica mode, as `VALIDATION_ERROR` / 400, naming the `file:` spelling. Both options in triage's execution note were weighed. This PR case-folds the scheme like the client and refuses what is left. Neither half routes any configuration to a `:memory:` engine it did not name. ## Why fold the case instead of refusing uppercase too - `@libsql/core@0.17.4` `lib-esm/config.js` line 26: `const originalUriScheme = uri.scheme.toLowerCase();`. Executed: `expandConfig({ url: 'LIBSQL://r.turso.io' }, true).scheme === 'https'`, and `createClient` opens it (`protocol=http`). `FILE:./x.db` expands to `file` and opens. - The host url sniffers already read it case-insensitively and hand this driver the url as written: `/^libsql:\/\//i` in `inferDriverTypeFromUrl` (`packages/cli/src/utils/storage-driver.ts`) and in `detectDriverFromUrl` (`packages/runtime/src/standalone-stack.ts`). So an uppercase `OS_DATABASE_URL` selected this driver and then ran on `:memory:`. - The driver's own `ridesWebSocketTransport` already folded case, with an in-code note that folding `detectMode` "must be argued on its own". That argument is above: after this PR, every reader of one url (host sniffer, client, both driver predicates) reads the scheme one way. The note is replaced with a single helper, `startsWithScheme`, that every predicate in the file compares through. - Folding widens nothing. An uppercase remote url constructed before, but it ran on the wrong engine. Refusing the uppercase spelling would have refused a url the client, the CLI and the runtime all accept. ## Why refuse a bare path instead of treating it as `file:` H3, measured against the installed `@libsql/client@0.17.4`: `createClient` refuses `./data/app.db`, `data/app.db` and `/abs/app.db` as `URL_INVALID` ("The URL '…' is not in a valid format"), and `C:\data\app.db`, `sqlite:./x.db` and `memory://x` as `URL_SCHEME_NOT_SUPPORTED`. Also `URL_INVALID`: `:MEMORY:`, `''`, `' file:./y.db'` (leading space) and `libsql:host` (no `//`). Control: `file:./x.db` opens with `protocol=file`. Reading a bare path as `file:` would invent a spelling the client refuses, so the same string would open a local file and fail as a replica. ## The refused set, exactly (for objectstack-ai#19977 to mirror at authoring time) Mode is the forced `mode`, or else auto-detected from `url` (and `syncUrl`). Schemes compare case-insensitively. "A remote url" means one starting with `libsql://`, `https://`, `http://`, `wss://` or `ws://`. 1. **unrecognised-url (new)**: mode is `'local'` or `'replica'` (forced, or auto-detected with or without `syncUrl`), and `url` is none of: exactly `:memory:`; a url starting `file:`; a remote url. 2. **remote-url (unchanged, now case-insensitive)**: mode is `'local'` or `'replica'` and `url` is a remote url. That means a forced local or replica mode, or no `mode` with `syncUrl` set. 3. **in-memory-replica (renamed from `replica-without-file`, now covering only in-memory urls; see "The one widened cell" below)**: mode is `'replica'` and `url` is exactly `:memory:`, or a `file:` url whose remainder is `:memory:` or starts with `:memory:?`. Checked in the order 2, 1, 3. The two remote-mode `timeout` refusals are unchanged: `timeout` over 0 with a `wss://`/`ws://` url, and `timeout` over 0 with a supplied `client`. Case-folding now brings an uppercase WebSocket url with no `mode` into the first. **Not refused:** a forced `mode: 'remote'` with any url. It runs no local engine, and `@libsql/client` refuses a bare path there itself at `connect()` (`URL_INVALID`, pinned). `TursoDriver.detectMode` now also answers `'replica'` for an unrecognised url beside `syncUrl` (it answered `'local'`), like its other two arms. The refusal stays in the constructor. `toKnexConfig`'s last arm, which handed such a url `:memory:`, now calls the same refusal. It cannot be reached from the constructor, and it can no longer produce an in-memory engine nobody named. ## CONTROL 1 of `turso-driver-uppercase-ws-scheme-timeout-refusal.test.ts`: flipped, deliberately It pinned that `WSS://` / `Ws://` / `HTTPS://` / `LIBSQL://` with no `mode` construct as `'local'`, and that `WSS://` + `timeout` with no mode stays local and unrefused. That was the fall-through this card removes: a local engine on a private `:memory:` database. The control now pins the opposite: those urls are `'remote'`, and `WSS://` + `timeout` with no mode meets the WebSocket refusal with the same message as `wss://`, except for the echoed scheme. The file's docblock records the flip and its reason. Its reverse-verification paragraph was re-measured (below) and rewritten to match. ## Measurements Before/after probe: `initObjects`, `create`, `find`, then a fresh driver on the same config. BASE = `a7581b326`'s `turso-driver.ts`, loaded next to HEAD's in one vitest run over the same dependency closure. TMP and DIR stand for temp and relative directories. | configuration | BASE `a7581b326` | HEAD | |:--|:--|:--| | `LIBSQL://…`, no mode | local, 1 row, 0 after restart | remote (constructed) | | `./DIR/app.db`, no mode | local, 1 row, 0 after restart, file never created | refused `VALIDATION_ERROR` / 400 | | `TMP/bare.db` (absolute), no mode | local, 1 row, 0 after restart, file never created | refused `VALIDATION_ERROR` / 400 | | `./DIR/app.db` + `mode: 'local'` | local, 1 row, 0 after restart, file never created | refused `VALIDATION_ERROR` / 400 | | `FILE:TMP/upper.db`, no mode | local, 1 row, 0 after restart, file never created | local, 1 row, 1 after restart, file created | | CONTROL `file:TMP/ctl.db` | local, 1 row, 1 after restart | local, 1 row, 1 after restart | H1 confirmed by reading `a7581b326` (the `// Fallback: treat as local` in `detectMode`, the `:memory:` last arm of `toKnexConfig`) and by the rows above. H2 and H3 are covered above. H4: the guard extended is `localEngineDefect` / `refuseNonDurableLocalEngine`. The "or drop `mode: 'replica'` … for a plain local database" sentence is now emitted only for an in-memory url: a bare path takes the new refusal. It reads "for a plain in-memory local database, which is what the url names: ephemeral by declaration", which is true because dropping the replica on `:memory:` / `file::memory:` gives exactly that. The new refusal's own ways out were checked per arm: a `file:` url for a local file or replica, and "drop" every key (`mode`, `syncUrl`) that would still keep a remote url local. H5 reach, on this tree (`a7581b326` + this diff), `git grep`: - uppercase or mixed-case `libsql://` spellings, excluding this package's tests: 3 hits, all prose (the objectstack-ai#19893 changeset, `CHANGELOG.md`, a driver comment). Positive control, lowercase `libsql://`: 487 hits. - uppercase `HTTP(S)`/`WS(S)`/`FILE` scheme literals, excluding this package's tests and changelogs: 5 hits, none a turso config (a CLI `File:` label, driver comments, a spec redirect-url test). Positive control, the same pattern lowercase: 3,652 hits. - `url` literals in every file that mentions turso or libsql, excluding this package's tests: 269 listed. None is a turso config with a bare path or unknown scheme. The leftovers are other drivers, route paths and a `' '` empty-url refusal fixture. Positive control: 46 `file:` values in the same listing. - `OS_DATABASE_DRIVER=turso` / `driver: 'turso'`: no bare path anywhere. Neither host sniffer selects this driver for a bare path. The runtime maps it to sqlite, and both select turso only for `libsql://` or an `http(s)://` url with a `.turso.` host. Out-of-repo deployments: NOT MEASURED, not claimed zero. ## Tests HEAD `e5e29cb11`: - `pnpm --filter @objectstack/driver-turso test`: `Test Files 62 passed (62)`, `Tests 1404 passed (1404)`. - `pnpm --filter @objectstack/driver-turso typecheck` (`tsc --noEmit`): clean. `--listFiles` includes both touched test files. - New file `turso-driver-unrecognised-url-refusal.test.ts` (40 cases) pins: - refusals as the envelope (`code` + `status` + the identifying first sentence), before and after, for the uppercase url and the bare path, with and without `syncUrl`, under a forced `mode: 'local'` and `mode: 'replica'`, and through `createTursoDriver`; - that the url is never echoed; - that the client stub is untouched; - uppercase `FILE:` durability across a restart, local and replica; - the forced-remote scope, where the client answers `URL_INVALID` at connect; - preservation: `file:` local and replica, `:memory:` local, lowercase remote, `mode: 'remote'`. Reverse verification. The direction was predicted in the new file's header before the run. It ran at `457d65f23`, where `src/` is byte-identical to `e5e29cb11` except comments; `git diff 457d65f e5e29cb -- packages/drivers/driver-turso/src` is comments only. `turso-driver.ts` was restored to the `a7581b326` blob (`07363cdb…`, on-disk hash verified, `startsWithScheme` count 8 then 0) under an absolute-path trap, over the three suites: `Tests 35 failed | 52 passed (87)`. - New file: 30 RED (every refusal and uppercase case) and 10 GREEN (scope and 9 preservation). - ws file: 5 RED (the flipped CONTROL 1) and 15 GREEN. - `turso-driver-remote-url-replica-refusal.test.ts`: all GREEN. - Restore: blob `02da2f8f` equals HEAD's, and `git diff HEAD` is empty. Ablation. Using `node scripts/ablation-replace.mjs`, `ridesWebSocketTransport` was reverted to `url.startsWith('wss://') || url.startsWith('ws://')`. The anchor fell from 1 hit to 0 and the blob went `02da2f8f` to `5d086ff9`. Result: `Tests 7 failed | 13 passed (20)`. RED: the 6 refusal cases and CONTROL 1's timeout case. GREEN: CONTROL 1's classification cases and controls 2 and 3, which is what the rewritten docblock says. Restored, with the blob equal to HEAD's and `git diff HEAD` empty. ## Gates (HEAD `e5e29cb11`) - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` returns the same 61 commands at `bc2e4f5f8` and `e5e29cb11`. All 61 were run at `e5e29cb11`. `--ran`: `✓ dispatch-gates --ran: 61 derived famil(ies) accounted for — 59 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3).` - NOT MEASURED: `check:dual-build-cjs-loads` and `check:type-check-debt`. Reason: `PREREQUISITE NOT MET`, exit 3. Each needs a whole-workspace `dist/` build (`turbo run build --filter='./packages/*' …`, as in `lint.yml`), which was not run locally. Targeted substitute: the built `dist/index.js` (CJS) and `dist/index.mjs` (ESM) of this package load, and each refuses a bare path as `VALIDATION_ERROR 400`. `driver-turso` has no `DEBT` / `TEST_DEBT` entry, and its `tsc --noEmit` is clean. - `check:lean-entry-closure` first answered exit 3 (objectql not built). It is green after `turbo run build --filter=@objectstack/objectql`. - `node scripts/check-changeset-no-major.mjs --base origin/main`: `✓ This diff introduces no \`major\` bump.` The clause-② axis reads this PR's body in CI: `LEVEL AXIS: NOT APPLICABLE` locally, because a local run has no `pull_request` payload. - `node scripts/check-adr-0087-registration.mjs --base origin/main`: `✓ check-adr-0087-registration: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition.` Classified as `[BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription)`, the same shape as the precedent changeset from PR objectstack-ai#19971. - `pnpm check:driver-conformance`: `OK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt.` Same reading on base `a7581b326` before the change and on `e5e29cb11` after. - `node scripts/check-issue-citations.mjs --base origin/main`: `✅ check-issue-citations: every citation this change adds resolves (or is a declared cross-repo reference).` - Lint, a declared narrowing to the three changed `.ts` files: `eslint --no-inline-config --format json` counts 3 files, 0 errors and 0 warnings at `e5e29cb11`. Each file is in eslint's own config (`--print-config` exit 0; none reported as ignored). Invariance: `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`, no typed rules; its own note at line 327), so this diff cannot move the verdict on an untouched file. `pnpm lint` over the repo is CI's. - Stale-tree caveat: dispatch-gates reports this tree 10 commits behind `origin/main` (`67ebc84a7`). The 17 family-definition files changed there are sdui/objectui manifest scripts, `lint.yml`, `cut-rc.yml`, `package.json` and similar. None of them touches `packages/drivers/driver-turso`, and no merge was taken. CI runs the current definitions on the merge ref. ## Acceptance notes - The objectstack-ai#19893 changeset (`.changeset/19893-turso-remote-url-replica-refusal.md`, unreleased) is rewritten by this PR at lines 20, 25 and 31 as a DELIBERATE CORRECTION, because this change made those three sentences false for the shipped code; see "Deliberate correction of a pending changeset" below. No other sentence of that note moves. - `toKnexConfig`'s last arm now refuses too, so an ablation of the constructor's `unrecognised-url` line alone leaves the tests green by design: the same envelope comes from the second site. The reverse verification above removes both. - A `file:` url with a query string (for example `file:./x.db?mode=ro`) is still handed to better-sqlite3 as a literal filename. That behaviour predates this PR, is durable, is NOT MEASURED here, and no finding is claimed. - objectstack-ai#19894 (same file, queued) is not touched: no change to `planMediaColumnMove` or the remote media-column paths. ## The one widened cell (seat correction of this body, after contract review 5818338847) The earlier text of this body called arm 3 "same coverage". That was wrong. Under a forced `mode: 'replica'`, a url that is none of `file:`, `:memory:` or a remote url now meets arm 1 (`unrecognised-url`) instead. An uppercase or mixed-case `FILE:` url naming a file, under a forced `mode: 'replica'`, with or without `syncUrl`, is **no longer refused**: it is a `file:` url, the replica runs on that file, and its rows survive a restart. The objectstack-ai#19893 change refused it, because it read the scheme case-sensitively. It is the only configuration refused at base `a7581b326` and accepted at this head. It is stated in `.changeset/19976-turso-unrecognised-url-refusal.md` ("Newly accepted") and pinned in the PRESERVATION table, 4 cases that go red on the base `turso-driver.ts`. Measured at `4a7cb4de`: with `turso-driver.ts` restored to the base blob `07363cdb`, `-t PRESERVATION` gives `Tests 4 failed | 9 passed | 31 skipped (44)`. The 4 red cases are exactly the widened ones, each refused `VALIDATION_ERROR` / 400 by the base replica-without-file message. At head they are 4 of 4 green. ## Deliberate correction of a pending changeset This PR changes `.changeset/19893-turso-remote-url-replica-refusal.md`. That note is pending: it landed with PR objectstack-ai#19971 and no release has consumed it, and it compiles into the same version's CHANGELOG as this PR's note (lockstep `fixed` group). This change made three of its sentences false for the shipped code, so they are rewritten here, from the code at this head. No other sentence of that note moves (`git diff -U0`: lines 20, 25, 31 only). `Check Changeset` is therefore red **by design** (the DELIBERATE CORRECTION class of `scripts/check-empty-changeset.mjs`). `skip-changeset` is not applied, and the note is not restored. The confirmation is the same-head at-tier contract review record that names this note and judges each rewritten sentence (ruling 1A, objectstack-ai#19940, 5814546887). - **Line 20 (S3).** Old: "A remote url here means one of the lowercase schemes `TursoDriver.detectMode` classifies as remote: …". New: "A remote url here means one of the schemes `TursoDriver.detectMode` classifies as remote: `libsql://`, `https://`, `http://`, `wss://`, `ws://`. The objectstack-ai#19976 entry in this same version matches them in any letter case, so an uppercase `LIBSQL://` is a remote url too." - **Line 25 (S2).** Old: "under a forced `mode: 'replica'` only, any `url` that is not a local `file:` path, such as a bare path or an uppercase scheme." New: "under a forced `mode: 'replica'`, a `url` that is none of `:memory:`, a `file:` url or a remote url, such as a bare path or an unsupported scheme. The objectstack-ai#19976 entry in this same version refuses such a url in every local or replica mode, and matches the `file:` scheme in any letter case: an uppercase `FILE:` url naming a file is a `file:` url and is not refused, and the replica runs on that file (`FILE::memory:` is refused as in-memory, like `file::memory:`)." - **Line 31 (S1).** Old: "**Not refused, unchanged here:** … still auto-detects `'local'` and still runs on `:memory:`, with or without `syncUrl`. So does the same url under a forced `mode: 'local'`. That fall-through is tracked as objectstack-ai#19976." New: "**Not refused by this change:** … auto-detected `'local'` with or without `syncUrl`, and the local engine was handed `:memory:`. Under a forced `mode: 'local'` the same url got the same `:memory:` engine. The objectstack-ai#19976 entry in this same version removes that fall-through … No configuration runs on an in-memory database it did not name." --- _Generated by [Claude Code](https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…s Knex base is built with no connection (objectstack-ai#20054) (objectstack-ai#20073) Fixes objectstack-ai#20054 Clause-②: no (narrowing) ## What this changes A remote `TursoDriver` (`libsql://`, `https://`, `wss://` and the other remote schemes) now constructs, connects and runs CRUD with `better-sqlite3` not installed. That is the install `package.json` (an optional peer) and the README's "Dependencies by Mode" table give remote mode, for Vercel and Edge deployments. The fix is one arm of `TursoDriver.toKnexConfig`. The `SqlDriver` base constructor always builds a Knex instance. Remote mode used to hand it `{ client: 'better-sqlite3', connection: { filename: ':memory:' } }`. knex's `Client` constructor loads the dialect's native driver (`initializeDriver`, which runs `require('better-sqlite3')`) and builds a pool only when the config carries a `connection`. Remote mode now passes `{ client: 'better-sqlite3', useNullAsDefault: true }` with no `connection`: the SQLite dialect's compiler, with no native module, no pool and no private `:memory:` database. The client is still spelled `better-sqlite3`, so `isSqlite` and every dialect-keyed rule the remote arms borrow from the base answer as before. `SqlDriver` is not touched, and no export changes. Files: - `packages/drivers/driver-turso/src/turso-driver.ts`: the remote arm of `toKnexConfig` and its comment. Also the text of two remote refusals this change made false (see Deviations). - `packages/drivers/driver-turso/src/turso-remote-no-native-driver.test.ts`: new pin. - Two refusal test docblocks now use the past tense for the placeholder (comments only). - `.changeset/20054-turso-remote-no-native-driver.md`: `@objectstack/driver-turso` minor, BREAKING (patch round 1). ## Measurements All readings were taken in `objectstack-ai/objectstack`. Base: `3557f85fa5`. Head: this branch. The probes used dist built at each tree. "Absent" means `better-sqlite3` made unresolvable in a separate node process (a `Module._resolveFilename` hook). The pin uses a `Module._load` hook instead. **H1 (the throw), confirmed at base.** knex 3.3.0 `lib/client.js`: `if (this.driverName && config.connection) this.initializeDriver();`, which calls `Client_BetterSQLite3._driver()` (`require('better-sqlite3')`). | construction, module absent | base | head | |:--|:--|:--| | remote `libsql://probe-db.example.turso.io` | throws `Knex: run $ npm install better-sqlite3 --save` | constructs, `transportMode = 'remote'` | | local `:memory:` (control) | throws, same message | throws, same message | | local `file:` (control) | throws, same message | throws, same message | | all three, module present | construct | construct | **H2: what the remote face uses `this.knex` for.** I wrapped `driver.knex` in a Proxy that records every property read and call after construction. The remote driver ran over a real `@libsql/client` `file:` client. - Construction: the `SqlDriver` constructor's `knex(config)` call, and `installQueryTiming` (`this.knex.on` for three events). Read from the source, not recorded. - 22 remote-armed doors: `connect`, `checkHealth`, `initObjects`, `syncSchema`, `syncSchemasBatch`, `create`, `bulkCreate`, `find`, `findOne`, `count`, `aggregate`, `update`, `upsert`, `bulkUpdate`, `updateMany`, `execute`, `paginationTieBreaker`, `bulkDelete`, `deleteMany`, `delete`, `dropTable` and `disconnect`. Plus the 4 refusals: `beginTransaction`, `detectManagedDrift`, `planMediaColumnMove`, `setDeferredDdl(true)`. **Zero reads**, at base and at head. - The inherited helpers those arms call have no `this.knex` reference in their bodies (dist, 14 names): `temporalFilterValue`, `temporalFilterColumnSql`, the three `sqlite*Sql` rules, `isNonTextColumn`, `formatInput`, `formatOutput`, `computeTenantField`, `calendarDayUpperBoundRewrite`, `orderKeysFor`, `registerExternalObject`, `toDateOnly` and `rawStatementFault`. - Only the `SqlDriver` methods remote mode does not override reach it: `introspectSchema` (`.raw`), `distinct` (builder), `findWithWindowFunctions` (builder, `.raw`, `.client`), `analyzeQuery` and `explain` (builder, `.raw`), and `reclaimSpace` (`.raw`). - On this state, `previewDeferredSchemaWork`, `flushDeferredSchemaDdl`, `applyMigrationEntries([])` and `getSchemaSyncStats` made zero reads. `rotateShards` threw before it reached Knex. - Remote `disconnect()` never calls `super.disconnect()`, so knex is never destroyed on the remote face. So nothing on the remote face needs a live Knex, and nothing needs a compiling one either. A Knex with no connection was the smallest seam: it lives in `toKnexConfig`, and `SqlDriver`'s constructor contract stays as it is. **H3: the inherited methods, before and after.** A remote face over a libsql `file:` client holding rows in `probe_t`. | method | base, module present | head, present | head, absent | |:--|:--|:--|:--| | `introspectSchema()` | **resolves `{ tables: {} }`** (silent) | rejects `Unable to acquire a connection` | same | | `distinct('probe_t', 'name')` | rejects `DATABASE_ERROR` / 500 | same | same | | `findWithWindowFunctions(…)` | rejects raw `SqliteError` `no such table: probe_t` | rejects `Unable to acquire a connection` | same | | `analyzeQuery` / `explain` | resolves `{ sql, bindings, client, error: 'EXPLAIN QUERY PLAN … no such table …' }` | resolves, same shape, `error: 'Unable to acquire a connection'` | same | | `reclaimSpace()` | **resolves** (vacuumed the private database) | rejects `Unable to acquire a connection` | same | | `previewDeferredSchemaWork`, `flushDeferredSchemaDdl`, `applyMigrationEntries([])`, `getSchemaSyncStats` | resolve, no Knex read | same | same | | `rotateShards('probe_t')` | throws, no rotation policy | same | same | - Loud to silent: **0**. Silent to loud: **2** (`introspectSchema`, `reclaimSpace`). - The head readings with the module present and absent are byte-identical (diffed). - A side reading: at base, a remote driver that had run `introspectSchema()` kept the process alive after `disconnect()`. `timeout 20` killed it (exit 124), because a pooled better-sqlite3 connection was never destroyed. At head, the process exits (no pool is built). **H4: in-repo constructions.** `git grep 'new TursoDriver('` on non-test `.ts` files: 9 hits, 3 of them real calls: - `createTursoDriver` and the plugin's `onEnable` in `driver-turso/src/index.ts`; - the turso arm of `service-datasource/src/default-datasource-driver-factory.ts`. The runtime loader adds `new TursoDriverCtor(` in `runtime/src/turso-driver-factory.ts`. None of the four reads `.knex`. The datasource factory's `sqlServerVersion` goes through `driver.execute`, which is the remote transport. Repo-wide reads of a driver's Knex outside `driver-sql`, non-test: - `getKnex()`: 0 calls (1 comment). Control: `sql-driver.ts` has 1 hit. - `.knex` reads: 2 files. `metadata-protocol`'s `resolveDriverClientName` reads `driver.config.client` first, and that answer is unchanged (`better-sqlite3`). `runtime/src/raw-foreign-key-fixture.ts` is used by two SqlDriver integration tests and not by any Turso test. ## Tests - New pin `turso-remote-no-native-driver.test.ts`: 10 tests. - Control: local `:memory:` and `file:` still throw without the module. The hook counted at least one load attempt, which proves it is live. - Remote construction without the module: 0 load attempts. - Remote CRUD through a real `@libsql/client` `file:` client without the module: 0 load attempts, and the rows read back from that file. - `introspectSchema`, `distinct` and `findWithWindowFunctions`, each with the module present and absent. Each call must either fail or answer from the remote database, never "no tables". The envelope is left to objectstack-ai#20055. - **Reverse verification.** The fix was committed first. `node scripts/ablation-replace.mjs` restored `connection: { filename: ':memory:' }` in the remote arm: anchor 1 to 0, blob `227df64b3046` to `a5b36d46ed8f`. The pin went **6 failed, 4 passed**, the direction predicted before the run: - construction and CRUD failed on knex's install error; - `introspectSchema`, module present, failed on `expected [] to include 'probe_t'`; - all three inherited cases with the module absent failed at construction; - the two controls stayed green, and so did `distinct` and `findWithWindowFunctions` with the module present, which were already loud at base. After the restore, the blob equals HEAD and `git diff HEAD` is empty. The test imports `./index.js` (source), so no rebuild was part of either leg. - `pnpm --filter @objectstack/driver-turso test`: 67 files, 1510 passed. `typecheck`: exit 0. `tsc --listFiles` includes all 4 changed `.ts` files. - `pnpm --filter @objectstack/driver-sql test`: 184 files passed, 11 skipped. 2845 tests passed, 170 skipped. - Consumers that construct a `TursoDriver` (targeted files): - `service-datasource`: `default-datasource-driver-factory`, `datasource-pool-support`, `turso-bound-secret-authoring` and `turso-driver-config`. 4 files, 147 passed. - `runtime`: `turso-driver-factory.convergence` and `standalone-stack.libsql`. 2 files, 38 passed. - `dogfood`: `date-bucket-parity-turso`, which builds a real remote driver. 1 file, 5 passed. - The CLI's `storage-driver.test.ts` builds a fake `TursoDriver` class, not this one. It is declared to CI, not run here. - `pnpm check:driver-conformance`, before (base, in a detached worktree) and after: `OK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt`, both. **Gates at head `014b58a689`**, derived from the real diff with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (merge base `3557f85fa`): 61 commands. The dispatch's list (derived at `2c1011b`) had 54, and all 54 are included. The 7 new ones are `check-adr-0087-registration` x2, `check-empty-changeset` x2, `release-rehearsal-clone --self-test`, `check:objectui-changeset` and `check:pm-changeset-deadline-census`. - **59 exit 0.** - **2 NOT MEASURED** (exit 3, `PREREQUISITE NOT MET`): `check:dual-build-cjs-loads` and `check:type-check-debt`. Both read the whole workspace's built output, and this worktree built only the closures listed above. lint.yml builds the whole closure first, so CI measures them. - `check:dts-closure` first exited 1. It named 26 packages whose `dist/` I had built locally with `OS_SKIP_DTS=1` to run the consumer suites. I removed those `dist/` directories and re-ran it: exit 0, `46/46 declared declaration file(s) present across 8 package(s)`, `driver-turso` among them. - The `--ran` reconciliation with exit codes recorded: `61 derived, 59 run, 2 NOT-MEASURED, 0 UNRUN`. - `node scripts/check-issue-citations.mjs --base 3557f85`: exit 0, 3 citations resolve. - Out-of-gate control-byte scan of the 5 changed files: no match. - Not run locally, and owned by CI: the repo-wide `pnpm lint`, the CLI unit layer, and the path-scheduled CI jobs `dispatch-gates` names. ## Deviations - **Two runtime strings in `turso-driver.ts` outside the claimed arm.** The `NOT_IMPLEMENTED` / 501 messages of the remote `detectManagedDrift()` and `planMediaColumnMove()` said "in remote mode that connection is a placeholder in-memory database holding none of this datasource's tables". This change made that false. They now say remote mode has no Knex connection. Each still gives a reason that holds at head, measured by calling the inherited methods on a head remote driver: - the no-argument drift call answers `[]` from the empty `managedObjectFields`; - the media planner answers an empty scan; - an explicit-objects drift call now fails on `hasTable`. First sentences, codes and statuses are unchanged, so both existing pins stay green. Their docblocks and the two comments on the overrides were edited to match. ## Acceptance notes - **`introspectSchema()` on a remote driver now fails with knex's `Unable to acquire a connection`.** That message points at connectivity. The real answer belongs to objectstack-ai#20055, which will either route the call to the transport or refuse it with `NOT_IMPLEMENTED`. objectstack-ai#20055 remains open for all three methods. The callers I traced (not run): - `ExternalDatasourceService.testConnection` now reports `ok: false` with that message, where it reported `ok: true, tableCount: 0`; - the boot validation sweep now rows a federated object on a remote-Turso datasource as `unreachable` (logged at warn, boot continues), where it rowed `missing_table` against the empty answer. The datasource admin's `testConnection` prefers `checkHealth`, so it is unaffected. - The pending changesets `19845-turso-remote-drift-detection-refusal.md` and `19894-turso-remote-media-column-move-refusal.md` describe the placeholder in the present tense. They narrate what those refusals replaced. I left them alone, and this PR's changeset says the placeholder is gone. - **Banner.** `os serve`'s banner reads a registered driver's `config`. For a remote Turso driver, `describeDriverConnection` returned `:memory:` at base (a false address) and returns `undefined` at head. `describeRegisteredDriver` then prints `(unknown)`. The renderer was measured and the fallback traced. - The CLI's migrate `describeDb` falls back to the client name. At head it would print `better-sqlite3` for a remote Turso datasource instead of `:memory:`. Traced only. ## Semver `@objectstack/driver-turso: minor`, `fix(driver-turso)!:`, `Clause-②: no (narrowing)`, with an ADR-0087 `not-required (no-migration-prescription)` disposition in the changeset. This was ruled in patch round 1, below. ## Patch round 1 The PM seat answered the open question with **B**, for consistency with this lane's precedents on the same driver face: objectstack-ai#19893 (PR objectstack-ai#19971) and objectstack-ai#19894 (PR objectstack-ai#20014). On a remote driver, `introspectSchema()` and `reclaimSpace()` resolved before and reject now. That is a call the published driver answered and no longer answers, so it is declared, whatever the old answer's quality. What changed, in `.changeset/20054-turso-remote-no-native-driver.md` only (commit `93d49b886e`, no code change): - the bump is now `minor` and the summary reads `fix(driver-turso)!:`; - the declaration line is now `Clause-②: no (narrowing)`; - a **BREAKING** paragraph names `introspectSchema()` (used to resolve `{ tables: {} }`) and `reclaimSpace()` (used to resolve). On a remote driver both now reject with knex's `Unable to acquire a connection`; - `findWithWindowFunctions()`, `analyzeQuery()` / `explain()` and `distinct()` are listed apart, as changing their error wording only or not at all; - an ADR-0087 `not-required (no-migration-prescription)` disposition in the objectstack-ai#19893 shape. No key, schema, object definition or stored representation moves, only which calls a remote driver answers. objectstack-ai#20055 carries the per-method answer or refusal. The version axis is unchanged: `.changeset/19894-turso-remote-media-column-move-refusal.md` already bumps `@objectstack/driver-turso` `minor` in the pending release. Gate verdicts at `93d49b886e`, against merge base `3557f85fa5`: - `check-changeset-no-major`: ``✓ This diff introduces no `major` bump.`` With this body as the `--event` payload: ``✓ LEVEL AXIS: this PR declares clause-② `no (narrowing)`, and no package whose `packages/**/src/**` it moves is graded `patch`.`` - `check-adr-0087-registration`: ``✓ check-adr-0087-registration: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition.`` The changeset is tagged ``[BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription)``. - `check-empty-changeset`: `✓ No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added).` --- _Generated by [Claude Code](https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…efused by every driver that answers `$like`, instead of being cut at the NUL on SQLite (objectstack-ai#20041) (objectstack-ai#20124) Fixes objectstack-ai#20041 Clause-②: yes (narrowing) ## What changed On the SQLite faces `$like` / `$ilike` compile to `GLOB`, 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 (`LIKE` cuts the same way, `replace()` cannot target U+0000, `instr()` has no wildcards; measured on objectstack-ai#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)`, beside `hasDanglingLikeEscape` in `src/data/filter.zod.ts`, with its own docblock. `hasDanglingLikeEscape` is byte-identical. The converters (`likePatternToRegexSource`, `likePatternToGlobPattern`, `matchesLikePattern`) are unchanged (see H3). - **Every door that refused a dangling escape now asks the new predicate right after it**, in that door's existing envelope, `INVALID_FILTER` / 400: | door | file | envelope | |:--|:--|:--| | `SqlDriver`'s filter walk (`classifyFilterKey`), before a dialect is chosen | `driver-sql/src/sql-driver.ts` | new builder `nulLikePatternError`, born in the withheld seam (`withheldFilterError`, the operator map as the node, as for the dangling escape) | | `RemoteTransport.buildWhereSQL`'s `$like` / `$ilike` arm, before anything is sent | `driver-turso/src/remote-transport.ts` | new method `nulLikePattern`, through `withheldRefusal`; its withheld sentence is `driver-sql`'s behind the `[RemoteTransport]` prefix | | `driver-memory`'s shape gate (`assertFieldConstraintShape`: the query path and the reference matcher `match()`) | `driver-memory/src/filter-refusal.ts` | new exported `nulLikePatternError`, `driver-sql`'s author text word for word | | `driver-memory`'s QueryAST `comparison` `like` / `ilike` arm, and the `$like` translator floor | `driver-memory/src/memory-driver.ts` | the same builder | - The new check runs AFTER the dangling-escape check at every door, so a pattern with both keeps the refusal it already had. - **Messages.** The withheld class statement names neither the operator variant, the field, the path nor the pattern. The author text (and the server log) reads, for example: `Operator "$like" on field "v" at filter.v.$like has a pattern holding the NUL character U+0000 ("%\u0000"). ...` — the pattern goes through `JSON.stringify`, so U+0000 reaches no message as a raw byte. - **Bounded in-place fix, declared:** the `$like` operator'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.mdx` is its regeneration (`gen:schema && gen:docs`): 5 rows, that phrase only. - **A pending release note corrected, which needs your confirmation:** the last bullet of `.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.mjs` is 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 `find` of 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 is `8d76c2d38c`, head is this branch (dists rebuilt at each). | face | U+0000 pattern, base | U+0000 pattern, head | NUL-free control, base = head | |:--|:--|:--|:--| | `SqlDriver` on better-sqlite3 | 20 of 20 differ from `formula`, 0 refused | 20 of 20 refused, `INVALID_FILTER` / 400 | 0 of 18 differ on the NUL-free rows; 12 of 18 differ over rows holding a stored U+0000 (objectstack-ai#20024 item 2 (ii), not this card) | | `SqliteWasmDriver` | 20 of 20 differ, 0 refused | 20 of 20 refused | same as above | | `TursoDriver` local | 20 of 20 differ, 0 refused | 20 of 20 refused | same as above | | `TursoDriver` remote, `makeLibsqlSqliteStub` | 20 of 20 differ, 0 refused | 20 of 20 refused | same as above | | `TursoDriver` remote, real `@libsql/client` `file::memory:` | 20 of 20 differ, 0 refused | 20 of 20 refused | same as above | | `InMemoryDriver.find` (`$`-spelling) | 0 of 20 differ (answers correctly), 0 refused | 20 of 20 refused | 0 of 18 differ | | `InMemoryDriver.find`, QueryAST `comparison` `like` / `ilike` | answers (`like '%'`+NUL gives the one value ending in U+0000) | refused | `like 'ab%'` answers the same rows | | `driver-memory` `match()` | answers (`true` for `'ab'`+NUL) | refused | pinned equal to the query path | | `@objectstack/formula` | the oracle | unchanged (see H2) | the oracle | | `driver-mongodb` `translateFilter` | 38 of 38 cases refused: `$like` is not translated at all | unchanged, not touched (held by draft PR objectstack-ai#19947) | refused | - The five SQLite faces gave byte-identical answer lists on every case, at base and at head. The control answers are byte-identical base vs head on all six driver faces. - Examples at base, SQLite faces: `$like: '%'` + U+0000 returned all 12 non-NULL rows, where `formula` returns 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. - Also measured at base, not touched: objectql `applyHaving` refuses every `$like` (`INVALID_FILTER` / 400, "Unsupported operator '$like' in `having`"); service-analytics `compileScopedFilterToSql` refuses it (`READ_SCOPE_COMPILE_FAILED` / 500, fail-closed) and `normalizeAnalyticsFilterTree` refuses it (`INVALID_FILTER` / 400). `lowerAnalyticsWhere` alone passes the node through; the tree build after it refuses. - The Postgres and MySQL arms of `driver-sql` were not measured live (no server here). At head the refusal fires on the walk before the dialect is chosen, pinned by compiling with the `pg` and `mysql2` clients 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 in `filter.zod.ts`. Positive control: the PM's list (`sql-driver.ts`, `remote-transport.ts`, `memory-driver.ts` x2, `filter-refusal.ts`) is exactly the five doors. - `hasNulInLikePattern`: 0 at base, 5 at head, one beside each of the five doors. - Every one of the five doors refused a dangling escape at base, and every one must refuse U+0000. `formula` does NOT refuse a dangling escape: `matchesLikePattern` throws, and the arm answers `false` (measured: `matchesFilterCondition({ v: 'abc\\' }, { v: { $like: 'abc\\' } })` is `false`). 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`), and `driver-memory/src/memory-analytics.ts` (`globSubstringPattern`, the analytics echo of a `$contains` comparand). - `likePatternToRegexSource`: 3 calls: `memory-driver.ts` (2) and the spec's own `matchesLikePattern`. - `matchesLikePattern`: 2 calls: `driver-memory/src/memory-matcher.ts` and `formula/src/matches-filter.ts`. - 8 calls in all: `packages/drivers/**` 6, `packages/formula` 1, `packages/spec` 1. `packages/services/**`, `packages/objectql` and `packages/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`' `$contains` echo would throw a plain `Error` on a comparand holding U+0000; `formula`'s `$like` would answer `false` (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), and `filter-like-nul-pattern.test.ts` pins that the JS translation keeps its meaning. ## H4: the withheld seam - `nulLikePatternError` has a row in `sql-driver-compile-refusal-seam.test.ts` and `nulLikePattern` in `remote-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). - policy-marked: `INVALID_FILTER` / 400, the error's own keys exactly `code,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. - Through `TursoDriver` in remote mode no mark survives `toRemoteFilter`, so an author gets the class statement there (the objectstack-ai#20093 fail-closed cost, pinned in `turso-20041-like-nul-pattern.test.ts`). ## H5: declaration - `Clause-②: yes (narrowing)`: the PR adds one public export, `hasNulInLikePattern` on `@objectstack/spec/data` (`api-surface/data.json` +1), so the value is `yes` (AGENTS.md's Clause-② rule, the PR objectstack-ai#20104 precedent), and the arm is `(narrowing)` for the refused patterns. The claim carried `no (narrowing)`; corrected in the patch round after contract review 5828387721. The changeset grades `@objectstack/spec`, `driver-sql`, `driver-sqlite-wasm`, `driver-turso` and `driver-memory` `minor`, with **BREAKING** and a `!` title, following PR objectstack-ai#19971's changeset. - `node scripts/check-changeset-no-major.mjs --base 8d76c2d`: "✓ This diff introduces no `major` bump." (exit 0) - `node scripts/check-adr-0087-registration.mjs --base 8d76c2d`: "✓ 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 chose `not-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) | # | face | this PR | |:--|:--|:--| | 1 | `driver-sql` `applyFilterCondition` (`sql-driver.ts:15953`); `driver-sqlite-wasm` and Turso local by inheritance | CHANGED: the walk refuses a U+0000 pattern on every dialect | | 2 | Turso `RemoteTransport.buildWhereSQL` (`remote-transport.ts:2668`) | CHANGED: the `$like` / `$ilike` arm refuses it | | 3 | service-analytics `compileScopedFilterToSql` (`read-scope-sql.ts:602`) | not touched (claim excludes it): refuses every `$like` already, measured | | 4 | service-analytics `lowerAnalyticsWhere` (`filter-normalizer.ts:2015`) | not touched: passes the node through; `normalizeAnalyticsFilterTree` refuses every `$like`, measured | | 5 | `formula` `matchesFilterCondition` (`matches-filter.ts:212`) | not touched: it refuses no dangling escape, so it is not one of the doors; it evaluates a U+0000 pattern correctly (the oracle above) | | half | objectql `applyHaving` / `matchesHaving` (`having-filter.ts:279` / `:292`) | not touched: refuses every `$like`, measured | | thawed | `driver-memory` `checkCondition` (`memory-matcher.ts:361`) and the query path | CHANGED: the shape gate both run first refuses it; the QueryAST arm and the translator floor too | | thawed | `driver-mongodb` `translateFieldOperators` (`mongodb-filter.ts:832`) | not touched (draft PR objectstack-ai#19947 holds it): refuses every `$like` through its `default:` arm, measured through `translateFilter` | ## Tests New pins, each asserting `code`, `status` and the path (never a bare `toThrow()`), 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, through `find` and `count`) and author-marked (operator, field, path); the dangling escape keeps its refusal also beside U+0000; 8 controls equal `formula`; `sqlite`, `pg` and `mysql2` compiles refuse on the walk. - `driver-sqlite-wasm/src/sqlite-wasm-20041-like-nul-pattern.test.ts` (16), the same through sql.js, controls checked against `formula`. - `driver-turso/src/turso-20041-like-nul-pattern.test.ts` (17): local, remote over the stub and remote over a real libSQL engine; no `FROM` statement 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 and `match()`, with controls. - Consumer pins changed: the two seam enumerations above (one row each; the remote one also one local-vs-remote row). No other consumer pin reads a U+0000 `$like`: `git grep` over every `*.test.ts` that names `$like` / `$ilike` and U+0000 finds none outside this PR. Suites (`vitest run --maxWorkers=2`), at `5f2e6f246d` (code equal to this head but for a comment in `memory-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 at `049b3a6c95`, same). - `formula`: 35 files, 978 tests passed. - `spec`: 570 files, 16369 tests passed, 2 todo. - `plugin-auth` (consumer): 114 files, 2440 tests passed. - `typecheck` exits 0 in `spec`, `driver-sql`, `driver-sqlite-wasm`, `driver-turso`, `driver-memory`. `tsc --listFiles` counts each new driver test once in its package program; the spec test once in `tsconfig.test.json`, which `check:test-typecheck` compiles. ## 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 to `HEAD`, `git diff HEAD` empty and `git status --porcelain` empty. 1. **Every driver's predicate replaced by a never-true local** (the four source files at once, which is base behaviour at every door). `driver-sql` rebuilt and `ablation-dist-preflight` found the marker in `dist/` 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; - the dangling-escape suites `sql-driver-like-pattern` (22) and `memory-like-pattern` (15) stayed green. Restored, rebuilt, and `ablation-dist-preflight --absent` passed with the tree clean. 2. **The new builder bypassing the seam** (`nulLikePatternError` calling `unsupportedFilterError`, `nulLikePattern` calling `invalidFilterError`). Predicted and observed: `driver-sql` 78 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/objectstack` from the real diff (17 paths, merge base `8d76c2d38c`): 110 commands, the PM's 79 plus 31 the docs, `driver-memory` and changeset paths add. All 110 run after a full `turbo run build --filter='./packages/*' --filter='./packages/*/*'` (72 tasks); `--ran`: "110 derived, 110 run, 0 NOT-MEASURED, 0 UNRUN". - 109 exit 0. **One exits 1, on purpose:** `node scripts/check-empty-changeset.mjs --base origin/main`, the DELIBERATE CORRECTION of `.changeset/20024-sqlite-glob-stored-nul.md` above. - Roster families beside these paths, run by hand: `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 after `gen:api-surface`, `gen:export-origins`, `gen:schema` and `gen:docs` (`api-surface/data.json` and `export-origins/data.json` gain `hasNulInLikePattern`; `authorable-surface.base.json` untouched). - `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 8d76c2d`: 17 citations across 5 files, all resolve. - `pnpm check:nul-bytes` passes, and a control-byte scan of the changed files finds none. U+0000 is spelled `String.fromCharCode(0x00)` in every file. - **Re-run at the merged head `b88ca46d90`** (merge base `6780e34af5`, 17 paths, after a full `turbo run build`): the derivation gives the same 110 commands; 109 exit 0 and `check-empty-changeset` exits 1 on the objectstack-ai#20024 correction; `--ran`: "110 derived, 110 run, 0 NOT-MEASURED, 0 UNRUN". `check-changeset-no-major` and `check-adr-0087-registration` exit 0; `check:generated` all 15 up to date. - ESLint, narrowed: `--no-inline-config --format json` over the 12 changed `.ts` files reports 12 files, 0 errors, 0 warnings. `eslint.config.mjs` enables no type-aware linting (the printed `parserOptions` for `sql-driver.ts` are `{"ecmaVersion":"latest","sourceType":"module"}`), so this diff cannot move the verdict of a file it does not touch. The full `pnpm lint` is CI's. ## Acceptance notes - **Not this card:** a NUL-free pattern matched against a STORED value that holds U+0000 still differs on the SQLite faces (12 of 18 control cases in the probe, unchanged). That is objectstack-ai#20024 item 2 (ii), and objectstack-ai#20024 remains open for it. - `content/docs/protocol/objectql/query-syntax.mdx` still 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. - `formula` keeps evaluating a U+0000 pattern (correctly). A write-side `check` with such a pattern therefore answers where the read side refuses; the read side refusing loudly means the two can no longer silently disagree. - `origin/main` at `6780e34af5` is merged into this branch (merge commit `2b8dd90200`, no conflicts): the 10 commits past the old merge base `8d76c2d38c` are `aa04ea2964`, `fa00ebf447`, `7b27bd00c7`, `7a13e0562a`, `7c1039b388`, `55daf89d74`, `226e00c038`, `7b068877ce`, `0d73ff6245`, `6780e34af5`. One of them touches this PR's paths: `55daf89d74` (PR objectstack-ai#20114) changes `packages/drivers/driver-turso/src/remote-transport.ts` and `remote-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-pattern` 17/17 and `remote-transport-compile-refusal-seam` 136/136 (102 plus objectstack-ai#20114's rows), `driver-turso` 69 files / 1663 tests; `sql-driver-compile-refusal-seam` and `sql-driver-20041-like-nul-pattern` 179/179; `sqlite-wasm-20041` 16/16, memory 36/36, spec 31/31. --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #19893
Clause-②: no (narrowing)
What was wrong
TursoDriver.detectModeclassified a remoteurl(libsql://,https://,http://,wss://,ws://) besidesyncUrlasreplica. The last arm oftoKnexConfigthen handed that mode's local engine (Knex + better-sqlite3):memory:. Every write read back, then vanished on restart, and nothing reached the remote. The code comment saying@libsql/client"operates in embedded replica mode with an in-memory local cache" was false.What this changes
super(), beside the two existingtimeoutrefusals and with the same envelope (VALIDATION_ERROR/ 400):syncUrl(the card), or forced withmode: 'replica'/mode: 'local';urlis not a localfile:path::memory:,file::memory:, or a bare path under a forcedmode: 'replica'.syncUrl(or the forcedmode) for a remote database, or useurl: 'file:./data/replica.db'besidesyncUrlfor an embedded replica. It never echoes the url, which may carry a live?authToken=(pinned).detectModereturns the same answers as before: the pair still classifiesreplica, and the refusal is a construction check, not a re-classification (pinned). It now shares one remote-prefix list with the refusal, and the false comment is replaced with the measured reading.syncUrlconfig comment. The mode table never listed the pair.:memory:+syncUrlnow get a fresh local file per driver (replica-file.testkit.ts), keeping the per-test isolation:memory:gave them. Two tests pinned the defect itself and were replaced (see Acceptance notes).In-place fold, named per the bounded in-place fix rule. A forced
mode: 'local'beside a remote url is not the card's arm, but it reaches the identicaltoKnexConfiglast arm (measured below: 1 row back, 0 after restart). It is the same defect class, caught by the same predicate (the classifier's own remote-prefix list), in the same file, under the same gates. So it is folded into the one guard rather than filed.Deliberately NOT changed. A url with no
modethat is none offile:,:memory:or a lowercase remote scheme, such as an uppercaseLIBSQL://or a bare path, still auto-detectslocaland still runs on:memory:. That fall-through is recorded atridesWebSocketTransportas deliberately left alone, andturso-driver-uppercase-ws-scheme-timeout-refusal.test.ts(CONTROL 1) pins it as constructing. Changing it needs its own decision, so it goes back to the seat as a separate finding.planMediaColumnMoveand the remote media-column paths are untouched; #19894 remains open.Measurements, BASE
2c1011b01bA throwaway probe (not committed) ran
initObjects,createandfind, then built a fresh driver on the same config (a restart):libsql://+syncUrl+ client stub,sync.onConnect: false(the card's repro):memory:libsql:///https:///wss://+syncUrl, real client,onConnect: false:memory:libsql://+syncUrl, real client, default sync:memory:connect()rejectsSYNC_NOT_SUPPORTEDlibsql://+mode: 'replica', nosyncUrl:memory:libsql://+mode: 'local':memory::memory:+syncUrl, real client, default sync:memory:connect()rejectsURL_INVALID:memory:+syncUrl+ client stub,onConnect: false:memory:LIBSQL://, no mode (not changed here):memory::memory:file:+syncUrl+ client stubfile:localWhy
@libsql/client@0.17.4builds no replica here.lib-esm/node.js_createClientrouteswss/wsto its WebSocket client andhttps/httpto its HTTP client; only anything else reachessqlite3.js.syncUrlis read only inlib-esm/sqlite3.js. AsyncUrlgrep overhttp.jsandws.jsreturns 0; the control grep forauthTokenreturns 6 in each.http.js/ws.jssync():throw new LibsqlError("sync not supported in http mode", "SYNC_NOT_SUPPORTED"), and the ws variant is the same.sqlite3.js:if (isInMemory && config.syncUrl) throw new LibsqlError("Embedded replica must use file for local db but URI with in-memory mode were provided instead: …", "URL_INVALID").Where the config is parsed. Neither factory parses a schema:
packages/runtime/src/turso-driver-factory.tsandpackages/services/service-datasource/src/default-datasource-driver-factory.tsboth gobuildTursoDriverConfigthennew TursoDriver. So this constructor is the runtime's only gate. The spec-laneTursoConfigSchemais parsed at authoring and wizard time throughvalidateDriverConfig. The driver-local copy has no in-repo parse caller outside its own test. The matching authoring-time refinement is outside this PR's surface (packages/specuntouched) and is handed to the seat.Verification, HEAD
5fb95579pnpm --filter @objectstack/driver-turso typecheck:tsc --noEmit, exit 0. The program covers all 60 test files, including the new pin and testkit (counted with--listFiles).pnpm --filter @objectstack/driver-turso test:Test Files 60 passed (60),Tests 1358 passed (1358).turso-driver-remote-url-replica-refusal.test.ts, 27 cases:code+status), plus named subjects in the message (the keys, the scheme,url: 'file:) and the absence of an echoed token/host;file:replica that connects, writes and keeps its row across a restart; thefile:/:memory:local and remote faces;detectModeunchanged.scripts/ablation-replace.mjs, anchor hit 1 to 0, blob07363cdbto5ee02e4e):Tests 20 failed | 105 passed: all 19 refusal cases plus the convertedturso-driver.test.tscase went red, each at "expected the constructor to refuse, and it returned a driver";07363cdb) andgit diff HEADis empty. The subject resolves fromsrc(relative imports), so nodistleg applies.pnpm check:driver-conformance: beforeOK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt.; after the same.check-adr-0087-registration:1 declared-breaking changeset(s), each carrying an ADR-0087 disposition., with this changeset classified[BREAKING+bang+clause-②-narrowing]not-required (no-migration-prescription);check-changeset-no-major:This diff introduces no major bump.;check-empty-changeset: green.dispatch-gates --commandson the real diff derived 61 commands;--ranreconciles61 derived, 59 run, 2 NOT-MEASURED, 0 UNRUN, and all 59 run exit 0. The four artifact-roster gates whose roster sits under a touched directory were also run, all exit 0.PREREQUISITE NOT MET, needing a whole-workspace build):check:dual-build-cjs-loads. Narrowed stand-in: the builtdriver-tursoCJS and ESM entries both loadTursoDriver;check:type-check-debt. Narrowed stand-in:driver-tursohas no DEBT, TEST_DEBT or test-typecheck-debt entry, and itstsc --noEmitis clean. CI runs both over the whole tree..tsfiles and ignores the 2.md.--format json: 12 files, 0 errors, 0 warnings.eslint.config.mjsnever enables type-aware linting (noparserOptions.project, no typed rules; its own header says so), so this diff cannot move any untouched file's verdict.pnpm lintis CI's.Acceptance notes
turso-driver.test.ts"should accept remote URL when syncUrl is provided" pinned the defect and now asserts the refusal;:memory:/libsql://+syncUrlnow pinTursoDriver.detectMode(unchanged) instead of constructing;turso-driver-ws-timeout-refusal.test.tsbuilt awss://+syncUrlreplica (the card's pair). That half is removed and its successor lives in the new pin file.url: 'libsql://my-db.turso.io'withsyncUrlandmode: 'replica':packages/cli/src/utils/storage-driver.test.ts("produces a config the real driver accepts"),packages/runtime/src/turso-driver-factory.convergence.test.tsandpackages/services/service-datasource/src/__tests__/turso-driver-config.test.ts. They exercise the key-forwarding builder or a capturing constructor, never the real driver, so they stay green. The first title now overstates what the real driver accepts. They are outside this card's file surface; there is no carrier.file:+syncUrlreplica reach the remote primary. Knex writes the file through better-sqlite3, not through the libsql connection. Measuring it needs a live sqld.syncUrl; the host boot path (OS_DATABASE_URL) passes none;sync.onConnect, the card's pair failed atconnect()rather than silently; silent loss neededsync.onConnect: false, a supplied client, or a forcedmode;