Skip to content

fix(driver-turso): refuse remote schema drift detection instead of answering "no drift" - #19891

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-19845-turso-remote-drift
Sep 23, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-19845-turso-remote-drift

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #19845

Clause-②: no (narrowing)

What changed

A remote-mode TursoDriver now refuses detectManagedDrift() with the transport's NOT_IMPLEMENTED / 501 envelope, with or without explicit objects, where it used to answer []. The local and embedded-replica modes inherit the Knex detector unchanged.

  • packages/drivers/driver-turso/src/turso-driver.ts: refuseRemoteDriftDetection() beside the deferred-DDL refusal, and a detectManagedDrift override in the schema-management section. The override spells the base's parameter shape key for key, as check:object-def-param-keys arm C requires.
  • packages/drivers/driver-turso/src/turso-remote-drift-detection-refusal.test.ts: the pin, with local and replica controls.
  • .changeset/19845-turso-remote-drift-detection-refusal.md: minor, BREAKING banner, adr-0087: not-required (no-migration-prescription). This is the shape PR fix(driver-turso): refuse to arm deferred schema DDL on the remote face #19842 used for deferred DDL on this face.

This is the dispatched landing site. packages/cli is untouched.

Zone 1: the chain is reachable

Read on base 1f89ba0d70:

  • packages/cli/src/commands/serve.ts: with OS_ARTIFACT_URL set, pinnedArtifact boots through createDefaultHostConfig, which calls createStandaloneStack. The com.objectstack.cli.artifact-boot-migration-gate plugin then runs runArtifactBootMigrationGate({ driver: findSqlDriverForKernel(kernel) }) on kernel:ready.
  • packages/runtime/src/standalone-stack.ts, turso arm: a libsql:// URL declares the default datasource with the Turso factory. DefaultDatasourcePlugin.init registers it as driver. plus the engine's default driver name. That name is the driver's name, com.objectstack.driver.turso.
  • packages/cli/src/utils/schema-migrate.ts: SQL_DRIVER_SERVICES lists driver.com.objectstack.driver.turso. Its duck-type check needs detectManagedDrift and applyMigrationEntries, and the driver inherits both.

Measured once and not committed. The instrument was the real findSqlDriverForKernel and runArtifactBootMigrationGate from packages/cli/src/utils/, over a kernel stub that exposes the driver under driver.com.objectstack.driver.turso. Table t is synced, then an extra physical column legacy is added.

driver found gate verdict before the fix gate verdict after the fix
local (file: URL) yes ok: false, 1 destructive entry, so the boot is refused unchanged
remote (synced through the batch door) yes ok: true, 0 entries, no warning ok: true, 0 entries, plus the warning ⚠ Could not check the physical schema against the artifact (Schema drift detection is not supported by the Turso REMOTE transport …). Boot continues; …

Not measured end to end: the os serve binary against a live remote libSQL endpoint. The boot-sync door is the batch door, as PR #19863 measured on an ObjectKernel boot.

A1: reproduced

Setup: the libsql SQLite double; table t declared with name: text, plus an extra physical column legacy.

face call answer
local detectManagedDrift() t.legacy: unmapped_column, op drop_column, destructive
remote detectManagedDrift() []
remote detectManagedDrift([{ name: 't', fields }]) []

The remote table's physical columns were id, created_at, updated_at, name, legacy, so the drift was on disk.

A2: how the gate treats a driver that cannot judge

  • (i) A "cannot judge" channel exists. runArtifactBootMigrationGate wraps driver.detectManagedDrift() in a try. On any throw it warns Could not check the physical schema against the artifact (MESSAGE). Boot continues; run 'os migrate plan' to verify. and returns ok: true with nothing applied. The CLI's own suite pins this: artifact-boot-migration.test.ts, "warns and continues when drift detection itself fails". I found no capability flag and no sentinel. The only other channel is applyMigrationEntries's skipped list, for a driver that declines an op.
  • (ii) A thrown NOT_IMPLEMENTED makes the gate warn and continue. The boot does not stop and nothing crashes. This was measured after the fix; see the Zone 1 table.
  • (iii) Real remote introspection is not cheap. The differ can be reused, but the reads that feed it cannot.
    • The differ half, measured: tables built by the remote DDL (plain, and rich with 16 field types, a field-level unique and a declared index) were judged by a local Knex connection to the same SQLite file. detectManagedDrift reported 0 entries for each, the same as a local-face control. So the shared differ gives no false drift on remote-built tables.
    • The read half: every read that feeds the differ goes through this.knex. That covers schema.hasTable, columnInfo plus PRAGMA table_info ordering, the SQLite arm of introspectIndexes (sqlite_master, index_list, index_info) and probeNullSafeUniqueDuplicates. A remote version needs a second copy of each of those arms.
    • The remote managed registry would have to carry fields and indexes (see A4).
    • Once detection returns entries, the gate calls applyMigrationEntries on the safe ones. That inherited Knex path runs on the same placeholder.
    • A destructive verdict refuses the boot and names os migrate apply --allow-destructive. On this face that command refuses at setDeferredDdl (PR fix(driver-turso): refuse to arm deferred schema DDL on the remote face #19842), so the refusal text would need a CLI change. The CLI is outside this card's surface.

A3: the route taken

(i) exists, and a refusal through it does not stop any remote boot: the gate warns and continues. (iii) is not cheap and would pull in a CLI change. So this PR takes the loud refusal, declared as a narrowing. The refusal message names the working remedy, running os migrate plan against a local SQLite copy (a file: URL). The gate embeds that message in its own warning line.

A4: managedObjectFields

Yes, the no-argument detectManagedDrift() needs it. That is the gate's call, and it iterates managedObjectFields / managedObjectIndexes. On the remote face these stay empty, because registerRemoteFieldMetadataregisterExternalObject fills the read-coercion registries and remoteManagedObjects only. It is deliberately not fed here. The explicit-objects call answered [] too, because the Knex hasTable probe reads the placeholder, so feeding the registry alone changes no answer. managedObjectFields also has other readers: getManagedFields, the base paginationTieBreaker and planMediaColumnMove. A real remote detector would need a remote registry of fields plus indexes. The remote doors already receive indexes on the object definition.

Tests

  • New pin turso-remote-drift-detection-refusal.test.ts, 6 cases:
    • local and embedded-replica controls, no-argument and explicit calls: each still reports t.legacy as unmapped_column / drop_column / destructive;
    • remote, both call shapes: the call rejects with code: 'NOT_IMPLEMENTED', status: 501 and the operator-facing first sentence, and sends zero statements to the database.
  • pnpm --filter @objectstack/driver-turso test: 58 files, 1317 tests passed.
  • pnpm --filter @objectstack/driver-turso typecheck: exit 0. tsc --listFiles compiles all 58 test files, including the new one.

Ablation, on HEAD 7d9a7e38c7, through scripts/ablation-replace.mjs:

  • Mutation: the anchor if (this.isRemote) refuseRemoteDriftDetection(); goes from x1 to x0, the marker from x0 to x1, and the blob from 4f2aabfcd133 to 87fe978db7c9.
  • Result: the 2 remote cases turned red with expected a refusal, got an answer: [], which is the original defect. The 4 controls stayed green.
  • Restore: the blob equals HEAD (4f2aabfcd133), and git diff HEAD is empty.
  • No dist step was involved: the test imports ./turso-driver.js from source.

Gates, on HEAD 7d9a7e38c7

  • node scripts/pm/dispatch-gates.mjs --commands (no paths) derived 61 commands. Every one exited 0.
  • --ran verdict: ✓ dispatch-gates --ran: 61 derived famil(ies) accounted for — 61 run, 0 NOT-MEASURED.
  • The three dist-reading gates (check:dual-build-cjs-loads, check:lean-entry-closure, check:type-check-debt) first exited 3 (PREREQUISITE NOT MET). They exited 0 after a workspace turbo run build.
  • check:object-def-param-keys went red once, on arm C (the override derived the parameter type). It is corrected in 6d1e4e9619 and green on HEAD.
  • node scripts/check-issue-citations.mjs (live): exit 0, every citation this change adds resolves.
  • pnpm check:driver-conformance: exit 0, OK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt.
  • Targeted eslint, measured:
    • Scope: the 2 changed TS files, both inside the **/*.{ts,…} block of eslint.config.mjs, run with --no-inline-config --format json.
    • Result: 2 files, 0 errors, 0 warnings.
    • Why the narrowing is sound: the config enables no type-aware linting (parserOptions.project and projectService are both unset for these files), so this diff cannot move any untouched file's verdict. The changeset .md is outside eslint's population.
  • Stale-tree note: origin/main gained 3 commits after the branch point (metadata, objectql, scripts/pm/close-cards.mjs). They are disjoint from this diff, and the derived list came out identical.

Acceptance notes

  • The gate's warning ends with the CLI's generic run 'os migrate plan' to verify. Pointed at the remote URL, that command refuses, because it arms deferred DDL. Its refusal names the local-copy route, and the driver message embedded earlier in the same warning names that route first. Owner: domain:cli (packages/cli/src/utils/artifact-boot-migration.ts). Noted, not filed.
  • After this PR, remote applyMigrationEntries still runs the inherited Knex path on the placeholder. No caller in this repo reaches it: the gate gets no entries from a remote detector, and os migrate apply is refused at setDeferredDdl. There is no repro, so this is noted only.

Out-of-scope findings, for the seat to file

  1. class (a). Remote planMediaColumnMove() answers { plans: [], refusals: [] }.
    • Measured on the SQLite double: table m with doc: file and pic: image was synced through the batch door, and its physical TEXT columns are present. The remote face returned 0 plans and 0 refusals. The local control returned 2 unquote plans.
    • It is the same class as this card: an inherited schema read on the remote face that answers from the placeholder or the empty managedObjectFields.
    • Reach, read from source and not run: os migrate files-to-references boots without deferral and calls stack.driver.planMediaColumnMove().
    • Dedupe words: turso remote planMediaColumnMove empty · files-to-references remote turso nothing to move · remote placeholder knex inherited schema read.
  2. class (a). Replica mode built from a remote url plus syncUrl runs every Knex CRUD against a process-local :memory: database.
    • detectMode answers replica for this pair, and the last branch of toKnexConfig gives it a :memory: connection.
    • Probe on the SQLite double: a table synced and a row created through the driver read back through the driver, while the libsql client's database held no tables at all.
    • Dedupe words: turso replica remote url syncUrl memory · embedded replica knex memory writes lost · turso replica mode libsql url syncUrl.

Generated by Claude Code

…swering "no drift"

A remote TursoDriver inherited SqlDriver.detectManagedDrift, which reads the
physical schema through the placeholder :memory: Knex connection remote mode
is built with. Every table read as absent, so the answer was [] for every
remote database, and the artifact-pinned boot gate of `os serve` read a
remote Turso database as never drifted.

The remote face now refuses with the transport's NOT_IMPLEMENTED/501
envelope, with or without explicit objects. The boot gate already reports a
failed detection as a warning and continues the boot, so a remote boot is
told the schema was not checked instead of being told nothing. Local and
replica modes are unchanged.

Claude-Session: https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr
Co-authored-by: Claude <noreply@anthropic.com>
…shape key for key

check:object-def-param-keys arm C refuses an override that derives the
base's object-definition parameter instead of declaring it, because the
keys a caller may pass then vanish from the override's own declaration.

Claude-Session: https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr
Co-authored-by: Claude <noreply@anthropic.com>
The operator reads this message long after the change that introduced it,
so it states what answering from the placeholder would do rather than what
the driver did "until this change".

Claude-Session: https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 23, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/driver-turso, touching 4 documentable anchor(s).

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/drivers.mdx (via TursoDriver (symbol, a top-level class))
  • content/docs/plugins/packages.mdx (via TursoDriver (symbol, a top-level class))
What this run could not see
  • 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 dabf8d795ee9279c18b4b00bfabb322544106b2fpackageMentionDocs.

Which tree this was computed on

This run read content/docs from a5982a406ee051d356ff901da140f96353461643 — the merge of head 7d9a7e38c742305b04cb8e6b9ce611c74ff40326 into base dabf8d795ee9279c18b4b00bfabb322544106b2f, 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 a5982a406ee051d356ff901da140f96353461643 && git checkout a5982a406ee051d356ff901da140f96353461643
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin dabf8d795ee9279c18b4b00bfabb322544106b2f 7d9a7e38c742305b04cb8e6b9ce611c74ff40326 && git checkout -B drift-repro dabf8d795ee9279c18b4b00bfabb322544106b2f && git merge --no-ff 7d9a7e38c742305b04cb8e6b9ce611c74ff40326

node scripts/docs-audit/affected-docs.mjs --json dabf8d795ee9279c18b4b00bfabb322544106b2f

⚠️ 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 dabf8d795ee9279c18b4b00bfabb322544106b2f → 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: 7d9a7e38c742305b04cb8e6b9ce611c74ff40326

Head matches the branch; merge-base 1f89ba0d70; exactly 3 files, +281/−0 (turso-driver.ts, a new test, the changeset). packages/cli untouched.

① Derived judgments

  • (a) Every caller, before and after. Outside tests, detectManagedDrift has four call sites, all in packages/cli:
    • artifact-boot-migration.ts (the artifact boot gate, reached only from serve.ts when OS_ARTIFACT_URL is set). Its try … catch warns "Could not check the physical schema against the artifact (…). Boot continues; run 'os migrate plan' to verify." and returns ok: true, pinned by "warns and continues when drift detection itself fails". Before: remote answered [], no warning, the gate failed open. After: ok: true with the warning on stderr; the boot continues.
    • migrate/plan.ts, apply.ts, multi-value-columns.ts: each boots through bootSchemaStack({ deferSchemaDdl: true }), and on the remote face setDeferredDdl(true) is refused (fix(driver-turso): refuse to arm deferred schema DDL on the remote face #19842), so the command exits before any drift call. Unchanged before and after.
      No remote deployment gains a crash or a newly refused command; the only behaviour delta is the gate's warning line.
  • (b) Local and replica are unchanged: the override is if (this.isRemote) refuseRemoteDriftDetection(); return super.detectManagedDrift(objects);, pinned by four controls. The parameter type is key-for-key the base's.
  • (c) Every sentence of the refusal message is true, and its remedy (os migrate plan against a local SQLite copy) works. The gate's CLI-owned suffix is unchanged; against a remote URL it yields fix(driver-turso): refuse to arm deferred schema DDL on the remote face #19842's refusal, whose text names the local-copy route. Nothing claims the suffix works remotely.
  • (d) Every changeset sentence is true, including the BREAKING banner's affected population and "no other caller in this repository reaches it". One harmless imprecision, identical to fix(driver-turso): refuse to arm deferred schema DDL on the remote face #19842's wording: the scheme list omits the explicit mode: 'remote' override.
  • (e) Test names match their assertions, including a non-vacuity check that legacy is physically present and that the refusal sends no statement.
  • Checks at review time: 29 of 32 complete, 0 failures; Lint & Repo Gates, Test Core 6/6 and Dogfood Regression Gate 2/3 were in progress.

② Semver level

Clause-②: no (narrowing) + minor + BREAKING banner + adr-0087: not-required (no-migration-prescription) is the right shape and level. No accept set widens; a method that answered [] now refuses, an accept-set narrowing, which is BREAKING and ships minor during the launch window (check-changeset-no-major's level axis reads the PR body's arm). Nothing authorable is removed or reshaped and the body carries a remedy with no FROM → TO prescription, the same category and structure as #19842's changeset. The claim line reads a plain no; no gate compares claim and PR lines, and the claim's prose pre-declared this arm.

③ Boundary flags

  1. Remote planMediaColumnMove() answers { plans: [], refusals: [] } (it iterates managedObjectFields, never filled on the remote face), so os migrate files-to-references, which boots without deferral, reports a false "nothing to move". REAL; FILE SEPARATELY. Bounded: the deployment flag stamp is gated by the data-level self-check.
  2. A remote url plus syncUrl is classified replica; toKnexConfig gives it better-sqlite3 on :memory:, and every CRUD and schema call on a non-remote driver takes the Knex path. Writes land in process memory and vanish at exit; sync() moves nothing out of Knex's :memory:. It looks supported: TursoDriverConfigSchema accepts syncUrl beside any url, the driver factory forwards it, and the driver's own comment claims the pair. Not reachable through OS_DATABASE_URL. REAL (code reading; not measured here), the most serious of the four; FILE SEPARATELY with priority.
  3. The gate's warning suffix names a command that refuses on a remote URL, but the refusal and the driver message both name the working route. Cosmetic, CLI-owned. ACCEPTABLE as noted.
  4. Remote applyMigrationEntries still runs the inherited Knex path on the placeholder; no in-repo caller reaches it on remote after this PR. ACCEPTABLE as noted; low-priority hardening.

Implemented-by: claude/issue-19845-turso-remote-drift
Reviewed-by: session_01TEhopqrWQYBycZzyJHpAZr

VERDICT: PASS


Generated by Claude Code

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/m tests tooling

Projects

None yet

1 participant