Skip to content

fix(driver-turso)!: refuse a remote url beside syncUrl, and any replica not on a local file, instead of running on :memory: - #19971

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-19893-turso-remote-syncurl-replica
Sep 24, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-19893-turso-remote-syncurl-replica

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #19893

Clause-②: no (narrowing)

What was wrong

TursoDriver.detectMode classified a remote url (libsql://, https://, http://, wss://, ws://) beside syncUrl as replica. The last arm of toKnexConfig then 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

  • The constructor refuses these configurations before super(), beside the two existing timeout refusals and with the same envelope (VALIDATION_ERROR / 400):
    • a remote url in a local or replica mode: auto-detected through syncUrl (the card), or forced with mode: 'replica' / mode: 'local';
    • a replica whose url is not a local file: path: :memory:, file::memory:, or a bare path under a forced mode: 'replica'.
  • The message names the scheme it met and both ways out: drop syncUrl (or the forced mode) for a remote database, or use url: 'file:./data/replica.db' beside syncUrl for an embedded replica. It never echoes the url, which may carry a live ?authToken= (pinned).
  • detectMode returns the same answers as before: the pair still classifies replica, 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.
  • README: a paragraph under the auto-detection table, plus a correction to the syncUrl config comment. The mode table never listed the pair.
  • Test fixtures: replica-face fixtures that rode :memory: + syncUrl now 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 identical toKnexConfig last 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 mode that is none of file:, :memory: or a lowercase remote scheme, such as an uppercase LIBSQL:// or a bare path, still auto-detects local and still runs on :memory:. That fall-through is recorded at ridesWebSocketTransport as deliberately left alone, and turso-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. planMediaColumnMove and the remote media-column paths are untouched; #19894 remains open.

Measurements, BASE 2c1011b01b

A throwaway probe (not committed) ran initObjects, create and find, then built a fresh driver on the same config (a restart):

configuration mode knex engine rows back rows after restart
libsql:// + syncUrl + client stub, sync.onConnect: false (the card's repro) replica :memory: 1 (the stub held no tables) 0
libsql:// / https:// / wss:// + syncUrl, real client, onConnect: false replica :memory: 1 0
libsql:// + syncUrl, real client, default sync replica :memory: connect() rejects SYNC_NOT_SUPPORTED n/a
libsql:// + mode: 'replica', no syncUrl replica :memory: 1 0
libsql:// + mode: 'local' local :memory: 1 0
:memory: + syncUrl, real client, default sync replica :memory: connect() rejects URL_INVALID n/a
:memory: + syncUrl + client stub, onConnect: false replica :memory: 1 (the stub held no tables) 0
uppercase LIBSQL://, no mode (not changed here) local :memory: 1 0
bare path, no mode (not changed here) local :memory: 1; the file was never created 0
CONTROL file: + syncUrl + client stub replica the file 1 1
CONTROL file: local local the file 1 1

Why @libsql/client@0.17.4 builds no replica here.

  • lib-esm/node.js _createClient routes wss/ws to its WebSocket client and https/http to its HTTP client; only anything else reaches sqlite3.js.
  • syncUrl is read only in lib-esm/sqlite3.js. A syncUrl grep over http.js and ws.js returns 0; the control grep for authToken returns 6 in each.
  • http.js / ws.js sync(): 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.ts and packages/services/service-datasource/src/default-datasource-driver-factory.ts both go buildTursoDriverConfig then new TursoDriver. So this constructor is the runtime's only gate. The spec-lane TursoConfigSchema is parsed at authoring and wizard time through validateDriverConfig. 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/spec untouched) and is handed to the seat.

Verification, HEAD 5fb95579

  • pnpm --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).
  • New pin turso-driver-remote-url-replica-refusal.test.ts, 27 cases:
    • 19 refusal cases assert the envelope (code + status), plus named subjects in the message (the keys, the scheme, url: 'file:) and the absence of an echoed token/host;
    • 8 preservation cases: a file: replica that connects, writes and keeps its row across a restart; the file: / :memory: local and remote faces; detectMode unchanged.
  • Reverse verification, direction predicted before running. With the constructor's guard call deleted (scripts/ablation-replace.mjs, anchor hit 1 to 0, blob 07363cdb to 5ee02e4e):
    • Tests 20 failed | 105 passed: all 19 refusal cases plus the converted turso-driver.test.ts case went red, each at "expected the constructor to refuse, and it returned a driver";
    • all 8 preservation cases stayed green;
    • restore proven: blob equals HEAD (07363cdb) and git diff HEAD is empty. The subject resolves from src (relative imports), so no dist leg applies.
  • pnpm check:driver-conformance: before OK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt.; after the same.
  • Changeset gates:
    • 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.
  • Gates: dispatch-gates --commands on the real diff derived 61 commands; --ran reconciles 61 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.
  • NOT MEASURED (both PREREQUISITE NOT MET, needing a whole-workspace build):
    • check:dual-build-cjs-loads. Narrowed stand-in: the built driver-turso CJS and ESM entries both load TursoDriver;
    • check:type-check-debt. Narrowed stand-in: driver-turso has no DEBT, TEST_DEBT or test-typecheck-debt entry, and its tsc --noEmit is clean. CI runs both over the whole tree.
  • Lint, narrowed and proven.
    • Population, read from eslint itself: of the 14 changed paths it lints the 12 .ts files and ignores the 2 .md.
    • Count, from --format json: 12 files, 0 errors, 0 warnings.
    • Invariance: eslint.config.mjs never enables type-aware linting (no parserOptions.project, no typed rules; its own header says so), so this diff cannot move any untouched file's verdict.
    • Full pnpm lint is CI's.

Acceptance notes

  • Replaced pins:
    • turso-driver.test.ts "should accept remote URL when syncUrl is provided" pinned the defect and now asserts the refusal;
    • its two "detect replica mode" cases for :memory: / libsql:// + syncUrl now pin TursoDriver.detectMode (unchanged) instead of constructing;
    • turso-driver-ws-timeout-refusal.test.ts built a wss:// + syncUrl replica (the card's pair). That half is removed and its successor lives in the new pin file.
  • Loader fixtures outside this package still pair url: 'libsql://my-db.turso.io' with syncUrl and mode: 'replica': packages/cli/src/utils/storage-driver.test.ts ("produces a config the real driver accepts"), packages/runtime/src/turso-driver-factory.convergence.test.ts and packages/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.
  • Not measured: whether writes on a file: + syncUrl replica reach the remote primary. Knex writes the file through better-sqlite3, not through the libsql connection. Measuring it needs a live sqld.
  • Reach, for the seat's grading:
    • in-repo, no example, template, published skill, hand-written doc or factory default declares a remote url beside syncUrl; the host boot path (OS_DATABASE_URL) passes none;
    • with the driver's own client and the default sync.onConnect, the card's pair failed at connect() rather than silently; silent loss needed sync.onConnect: false, a supplied client, or a forced mode;
    • out-of-repo deployments are not measured.

…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>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests labels Sep 24, 2026
@github-actions

github-actions Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/driver-turso, touching 13 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/drivers/driver-turso/README.md), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

⛔ 1 release-owned page(s) name something this change touched. These are read-only:

  • content/docs/releases/v17/17-4.mdx (via TursoDriverConfig (symbol, a top-level interface))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/drivers/driver-turso/README.md) — pages documenting those are invisible to this run
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 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; 100 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 — 6 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 2c1011b01bc071c545f72f2761647b8d9ab56375 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 163217a7b28e859b84bac8deb8faa934b80a920a — the merge of head 7fc737470b73a61f1fb50d37315d24cf0c5f982c into base 2c1011b01bc071c545f72f2761647b8d9ab56375, 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 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

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 2c1011b01bc071c545f72f2761647b8d9ab56375 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 5fb95579ee5271ee45ecad3e08809db249e9dc90

① Derived judgments

  1. A remote url (libsql://, https://, http://, wss://, ws://) beside syncUrl, with no mode, is now refused at construction; before, it constructed as replica on Knex :memory:. RIGHT: the constructor calls localEngineDefect, which returns 'remote-url' via hasRemotePrefix, then refuseNonDurableLocalEngine. A probe refuses all five schemes; pinned in turso-driver-remote-url-replica-refusal.test.ts.
  2. A remote url with a forced mode: 'replica' is refused. RIGHT: same predicate; pinned.
  3. A remote url with a forced mode: 'local' is refused (the in-place fold). RIGHT: same predicate and the same toKnexConfig last arm; pinned.
  4. A replica on :memory:, file::memory: or file::memory:?… beside syncUrl is refused ('replica-without-file'). RIGHT: namesInMemoryDatabase mirrors @libsql/core@0.17.4 isInMemoryConfig; pinned.
  5. A non-file: url (bare path) under a forced mode: 'replica' is refused. RIGHT: !url.startsWith('file:'); pinned.
  6. Still accepted, unchanged: file: + syncUrl (replica), a file: or :memory: local database, a remote url alone, a remote url with mode: 'remote'. RIGHT: 8 preservation cases, including a file: replica that keeps its row across a restart.
  7. Still silently accepted onto Knex :memory:: a bare path or an uppercase scheme with no mode, with or without syncUrl. RIGHT as the PR body states it; WRONG as the changeset states it (③-1).
  8. The refusal fires before any Knex or libsql client exists. RIGHT: in the constructor, detectMode runs, then the new guard, then the two timeout refusals, then toKnexConfig and super.
  9. The refusal envelope is code: 'VALIDATION_ERROR', status: 400, identical to the two existing timeout refusals. RIGHT.
  10. The message withholds the url and any token, naming only the scheme. RIGHT: pinned with an ?authToken= url.
  11. detectMode's answers are unchanged. RIGHT: the same five prefixes behind hasRemotePrefix, still case-sensitive; pinned.
  12. Published surface: nothing added or removed. RIGHT: exports, src/index.ts and tsup.config.ts are untouched; the new helpers are module-private.
  13. Both loaders reach the refusal through the same constructor, with no schema parse in between. RIGHT: turso-driver-factory.ts and default-datasource-driver-factory.ts both run buildTursoDriverConfig, then new TursoDriver.
  14. No test pins the defect any more; every refusal test asserts code + status. RIGHT.

② Semver level

Consistent: minor, a ! title, a BREAKING banner and Clause-②: no (narrowing) in the changeset, and the PR body line now matches. The ADR-0087 disposition not-required (no-migration-prescription) holds, because nothing an author writes is renamed or removed. Precedent in this package's CHANGELOG is the timeout and uppercase-scheme refusals. At the reviewer's final poll all seven required contexts were success, and both Check Changeset runs were green.

③ Boundary flags

  1. FALSE, changeset refusal bullet 3: "a replica (syncUrl, or mode: 'replica') whose url is not a local file: path". Measured against the head: { url: './data/replica.db', syncUrl } and { url: 'LIBSQL://r.turso.io', syncUrl } both CONSTRUCT, classified local, on Knex :memory:. The replica arm of localEngineDefect fires only when mode === 'replica'. The PR body states this correctly; the changeset, which ships as CHANGELOG text, does not.
  2. IMPRECISE, changeset headline: "a local or replica TursoDriver whose engine would have nothing durable behind it is refused". A local driver on a bare path or an uppercase scheme has nothing durable behind it and is not refused. Same root as ③-1.
  3. IMPRECISE, README: "and a replica whose url is not a local file: path". This reads true only if "replica" means the detected mode. One clause of precision; not a FAIL on its own.
  4. TRUE: every other changeset and README sentence, checked against the code and the @libsql/client@0.17.4 tarball (routing, syncUrl read only in sqlite3.js, SYNC_NOT_SUPPORTED, URL_INVALID, the envelope, the loaders, the blast-radius sentence).
  5. Dev open question (the Clause-② line): resolved by the body amendment to no (narrowing).
  6. Dev deviations: folding a forced mode: 'local' beside a remote url is legitimate: same predicate, file, arm and envelope, and pinned. The testkit is not published.
  7. Dev out-of-scope findings: all REAL. The spec refinement and the mode: 'remote' inert syncUrl are filed as spec: TursoConfigSchema accepts turso configs the driver refuses or ignores — a remote url beside syncUrl or a forced replica/local mode, a non-file: replica, syncUrl/sync under mode: remote #19977. The bare path / uppercase fall-through is filed as driver-turso: a url whose scheme the classifier does not recognise (an uppercase LIBSQL://, a bare path) and no mode falls through to local on a :memory: Knex engine, so every write is lost on restart #19976. The loader-fixture title is noted only. The replica write path to the primary is NOT MEASURED.

Implemented-by: claude/issue-19893-turso-remote-syncurl-replica
Reviewed-by: session_01Bvd69VPa6puiNzzPUroDBx

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):

  1. In .changeset/19893-turso-remote-url-replica-refusal.md, rewrite refusal bullet 3 to name what is refused. That is a replica on an in-memory url (:memory: or file::memory:, beside syncUrl or under mode: 'replica'), and, under a forced mode: 'replica' only, any url that is not a local file: path.
  2. In the "What stays accepted" paragraph, say that a url with no mode that is none of file:, :memory: or a lowercase remote scheme (an uppercase LIBSQL://, or a bare path) still auto-detects 'local' and still runs on :memory:, with or without syncUrl, unchanged here.
  3. Narrow the headline to what is refused: a local or replica driver on a remote url, or a replica off a local file.
  4. Optional: in packages/drivers/driver-turso/README.md, qualify "a replica" as auto-detected from file: or forced with mode: 'replica'.

Isolated reviewer: a separate contract-review-tier subagent, fed the card, the PR and AGENTS.md only; the seat verified ③-1 against localEngineDefect at the head before adopting.


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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 7fc737470b73a61f1fb50d37315d24cf0c5f982c

① Derived judgments

  1. The delta from the reviewed head 5fb95579 is prose only: one commit, touching .changeset/19893-turso-remote-url-replica-refusal.md and packages/drivers/driver-turso/README.md (+37/−21). No .ts or .json file changed, so localEngineDefect, detectMode, toKnexConfig, the refusal message text, every test, package.json and index.ts are byte-identical to the head already judged in record 5815787919. RIGHT.
  2. Every ① judgment of 5815787919 carries over unchanged. Re-probed on this head:

② Semver level

Consistent: minor, a ! title, a BREAKING banner, Clause-②: no (narrowing) in both the changeset and the PR body, and an ADR-0087 disposition not-required (no-migration-prescription). Check Changeset is success on this head, including its ADR-0087 and no-major steps. The other required contexts were still in_progress at the reviewer's last poll. The code is byte-identical to 5fb95579, where all seven finished success.

③ Boundary flags

  1. Every changeset sentence was re-judged against the unchanged code, and all are TRUE:
  2. The README hunk is TRUE. One non-blocking precision note: "auto-detected from a :memory: or file: url beside syncUrl" reads better as ":memory: or file::memory: url".
  3. The earlier dev flags stand as answered in 5815787919, and the five out-of-scope findings are real. The fall-through is driver-turso: a url whose scheme the classifier does not recognise (an uppercase LIBSQL://, a bare path) and no mode falls through to local on a :memory: Knex engine, so every write is lost on restart #19976; the spec refinement and the inert mode: 'remote' + syncUrl are spec: TursoConfigSchema accepts turso configs the driver refuses or ignores — a remote url beside syncUrl or a forced replica/local mode, a non-file: replica, syncUrl/sync under mode: remote #19977.

Implemented-by: claude/issue-19893-turso-remote-syncurl-replica
Reviewed-by: session_01Bvd69VPa6puiNzzPUroDBx

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 7fc73747 must conclude success or be an expected skip.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 24, 2026 14:53
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 24, 2026
Merged via the queue into main with commit 0142415 Sep 24, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-19893-turso-remote-syncurl-replica branch September 24, 2026 15:22
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
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 size/l tests tooling

Projects

None yet

1 participant