Skip to content

fix(driver-turso,cli): the v18 organization-ownership ceremony runs on a Turso remote database - #23843

Merged
hotlong merged 11 commits into
mainfrom
claude/issue-23816-remote-ceremony
Oct 16, 2026
Merged

hotlong merged 11 commits into
mainfrom
claude/issue-23816-remote-ceremony

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #23816

Clause-②: yes (the remote face's refused drift and migration doors become accepted ones; triage: not the protocol-first class unless a spec key moves)

Summary

The v18 organization-ownership ceremony (os migrate organization-ownership, the plan and --apply --backup-taken) now completes on a Turso database reached over the REMOTE transport, which is the transport every hosted tenant uses. It was measured end to end on a real sqld (libsql-server 0.24.32), reached as libsql://127.0.0.1:PORT?tls=0.

Two things stopped it, and both are in this repository:

  1. Deferred DDL. Both ceremony boots armed deferSchemaDdl unconditionally, and the remote face refuses that. bootSchemaStack gains deferredDdlUnsupported: 'skip-schema-sync'. When the PRIMARY datasource resolves to the Turso remote transport (the boot asks TursoDriver.detectMode about the URL it resolved), the boot passes skipSchemaSync to the standalone stack instead of arming the deferral on that driver.
    • No DDL is attempted, so the plan stays read-only.
    • Every object reaches the driver through the DDL-free registerObjectMetadata; measureComposedCoverage binds a composed host's objects the same way on such a boot.
    • Every OTHER datasource the boot connects is still armed.
    • Only the ceremony opts in. Every other os migrate command still refuses on the remote face: their dry runs list held-back schema work, which a boot that holds nothing back cannot list.
  2. Drift detection and migration entries. These are the driver-turso remote face changes.
    • detectManagedDrift() answers. The verdict is the shared differ's. Its physical-schema reads go to the remote catalog: pragma_table_info, sqlite_master, pragma_index_list / pragma_index_info, and the ADR-0120 D4 duplicate probe.
    • The declarations it diffs against are recorded by every remote schema door and by registerObjectMetadata.
    • applyMigrationEntries() applies drop_index, create_index, recreate_index, replace_unique_index and drop_column. Each entry runs as ONE libSQL write batch:
      • a drop_column drops the indexes keyed on its column in the same transaction;
      • a re-create that fails leaves the old index in place;
      • an entry is reported applied only after a read-back.
    • Every other column entry needs the SQLite table rebuild, which this transport does not run, so it is skipped and logged.
    • An applied index entry (create_index, recreate_index, replace_unique_index) is read back by its live unique flag and key-column order, not by its name alone.
    • The remote catalog is read in batched round trips per call: one sqlite_master read, then pragma_table_info / pragma_index_list in one read batch, then pragma_index_info for the indexes whose DDL does not parse. Batches are chunked at 200 statements, so it takes 3 round trips up to 100 tables and one more per further 100.
    • The remote face declares driftReportCaveat: its drift report cannot be trusted for a boot verdict yet, because the remote sync creates no declared NOT NULL (driver-turso remote face: create table and add column never emit NOT NULL, so storage.notNull is unenforced and every such field reads as destructive drift #23847) and os migrate apply refuses there (os migrate apply cannot run on a Turso remote database (it arms deferred DDL), so destructive drift on a remote primary has no CLI remedy #23848). The local and embedded-replica faces declare null.

There is one more change. The remote driver's config now carries its url, so the CLI labels a remote database libsql://host:port (credential-free) instead of better-sqlite3. The ceremony writes that label into its plan and refuses an apply whose plan names another database. While every remote database was labelled better-sqlite3, that refusal could not tell two tenants apart.

The remote face does NOT implement deferred DDL. A remote preview would also have to preview the remote canonical-temporal and codec-residue backfills, which have no read-only probe today. The embedded-replica route was not taken (the card measured it as a dead end).

Evidence — real sqld, libsql://127.0.0.1:PORT?tls=0

The v17 fixture, built once, snapshotted, and copied fresh for each run:

  • a served os start boot against the server: the additive sync on the remote face, 70 objects;
  • then, through a SECOND libSQL connection: organization_id plus its tenant index on sys_job and on sys_metadata_activation, and two sys_job rows.

Positive control, unchanged main (1ecb08a1). The plan exits 1:

{"error":"boot_failed","reason":"boot-failed","detail":"Deferred schema DDL is not supported by the Turso REMOTE transport (this datasource's transport mode is `remote`), so a command that promises a dry run or a confirmation before any schema change cannot keep that promise against it. ..."}

With the fix: first measured at 39b9abdd, then re-run at cc73b809 after the contract-review round. cc73b809 is code-identical to the head 26b6acef. Both runs read the same:

step reading
os migrate organization-ownership --json exit 0
the plan is read-only logical dump (every catalog row and every table's rows, hashed) c0790dff… before = c0790dff… after, 341 objects / 12 rows
the plan's database libsql://127.0.0.1:38187 (it was better-sqlite3 before the label change)
--apply --plan-file … --backup-taken --json exit 0 · complete: true · blocking: 0 · advisory: 0; every step done; column-drops {"dropped": 2, "due": 2}
second connection: the columns sys_job.organization_id, sys_metadata_activation.organization_id: absent
second connection: their indexes idx_sys_job_organization_id, idx_sys_metadata_activation_organization_id: absent
second connection: the marker sys_migration row adr-0131-organization-ownership: applied_at and verified_at set
second connection: the rows sys_job: 2 rows, kept

An earlier run over a fixture that also put the column on sys_audit_log ended with blocking 1. The cause is not the transport: the composition booted from an empty project declares no object for sys_audit_log, so the step reports not-declared there. The same happens on file:. The fixture was corrected to tables that composition declares.

Ablation. Both runs used scripts/ablation-replace.mjs (WRAP mode); each mutation was proven to land on disk, restored to HEAD, and the tree checked clean.

  1. The driver arm removed (if (!this.isRemote || this.isRemote) return super.detectManagedDrift(objects);). driver-turso was rebuilt, and ablation-dist-preflight found the marker in dist/index.js and dist/index.mjs.
    • The two remote driver suites: 18 of 18 red.
    • The sqld e2e: red, {"error":"apply_failed","detail":"Unable to acquire a connection"}.
    • Restore leg: rebuild, then --absent (marker gone from all 6 built files, tree clean); 19/19 and 1/1 green.
  2. The ceremony's posture removed from the plan boot. The sqld e2e is red at the plan with the positive-control refusal (boot_failed, Deferred schema DDL is not supported by the Turso REMOTE transport …). Restored blob = HEAD.

The contract-review round added three ablations, made the same way, each restored to HEAD with git diff HEAD empty:

  1. The apply boot's deferredDdlUnsupported: 'skip-schema-sync' removed from organization-ownership.ts: organization-ownership.remote-posture.test.ts red, 1 failed (the apply-boot case).
  2. The exemption line in DeferSchemaDdlPlugin.arm removed: schema-migrate.remote-posture.test.ts red, 2 failed. The no-exemption control stayed green.
  3. The gate's caveat branch disabled in artifact-boot-migration.ts: artifact-boot-migration.test.ts red, 1 failed (the caveat case). The null-caveat control stayed green.

Tests (head 26b6acef)

  • @objectstack/driver-turso: typecheck exit 0; vitest run 90 files, 2415 passed / 33 skipped.
    • turso-remote-drift-detection.test.ts also carries:
      • the embedded-replica control: a replica-mode TursoDriver over a drifted file reports the destructive drop_column, for the no-argument and the explicit-objects call. This is the same-layer replacement for the replica case of the deleted turso-remote-drift-detection-refusal.test.ts;
      • the driftReportCaveat case: a sentence naming NOT NULL on the remote face, null on local and replica;
      • the round-trip case: 3 client calls for 1 table and for 5 tables.
    • turso-remote-drift-detection.test.ts (was turso-remote-drift-detection-refusal.test.ts): the remote face over a real @libsql/client file: client, asserted EQUAL to the local Knex face over the same file. It covers:
      • a clean database synced by each remote door;
      • four drifted shapes;
      • the registerObjectMetadata registry;
      • an absent table.
    • turso-remote-migration-entries.test.ts: each outcome is read back through a second connection. It covers:
      • drop_column with its indexes, and the rolled-back refusal;
      • drop_index, create_index and recreate_index, with the D4 re-probe over duplicates;
      • the skipped rebuild-only entry;
      • convergence;
      • an overlay-index rebuild through the two members;
      • the config label.
    • turso-remote-inherited-members.test.ts: applyMigrationEntries is no longer in the refused set.
  • @objectstack/cli: typecheck (and the test-layer typecheck) exit 0; --project unit 299 files, 4453 passed. A targeted --project integration run (20 files: every organization-ownership* and schema-migrate* integration suite plus unmanaged-tables, the sqld e2e included with OS_TEST_SQLD_BIN set) had 182 passed, 0 skipped. It ran locally because this diff touches the boot path.
    • organization-ownership.remote-posture.test.ts (new, unit tier, runs in CI) pins two things: both ceremony boots pass deferredDdlUnsupported: 'skip-schema-sync', and the apply reads its ledger from the catalog when the boot skipped schema sync.
    • schema-migrate.remote-posture.test.ts (new, unit tier, runs in CI):
      • transportRefusesDeferredDdl over 8 resolved databases;
      • the primary exemption, which leaves only the engine's default driver un-armed, at init and on a later registerDriver;
      • the no-exemption and other-refusing-driver controls.
    • artifact-boot-migration.test.ts: the caveat case (warn with the reason; no detect, no apply; the boot proceeds) and the null-caveat control (the same report refuses the boot).
    • organization-ownership.remote-face.integration.test.ts (new) runs the real command, plan then apply, against a sqld it starts itself. It is skipped unless OS_TEST_SQLD_BIN names a sqld binary: it ran green here, and it reads 1 skipped without the variable.
    • schema-migration-plugins.test.ts gains a case for the DDL-free binding.
  • @objectstack/runtime: typecheck exit 0; vitest run 402 files, 6787 passed / 19 skipped.
  • Gates: node scripts/pm/dispatch-gates.mjs --ran reports 70 derived families, 69 run with exit 0, and 1 NOT MEASURED: pnpm check:dual-build-cjs-loads exited 3, PREREQUISITE NOT MET, because it needs a whole-repo pnpm build and only the closure was built here; CI runs it. Two families first read exit 3 for environment reasons and were re-run alone with exit 0:
    • check-plugin-teardown-shape --self-test: the shallow clone lacked its pinned fixture commit;
    • check:i18n-coverage: contention.
  • eslint over the changed .ts files: exit 0.

Acceptance notes

  • File surface. The claim named packages/cli/src/utils/organization-ownership-*.ts. The boot posture lives one file over: packages/cli/src/utils/schema-migrate.ts (bootSchemaStack, the option and the primary exemption) and packages/cli/src/utils/schema-migration-plugins.ts (measureComposedCoverage's DDL-free binding), with that file's test.
  • The OS_ARTIFACT_URL boot migration gate is unchanged on a Turso remote primary. It still answers "cannot judge": it warns and boots, with no detectManagedDrift call, no apply and no refusal. It decides that from the driver's declared driftReportCaveat, not from the transport-mode string. The contract review measured why it must not judge yet: the remote sync creates no declared NOT NULL, so every storage.notNull field would read as destructive drift, and os migrate apply refuses on that transport. The gate judges remote databases again only after the follow-ups driver-turso remote face: create table and add column never emit NOT NULL, so storage.notNull is unenforced and every such field reads as destructive drift #23847 and os migrate apply cannot run on a Turso remote database (it arms deferred DDL), so destructive drift on a remote primary has no CLI remedy #23848, which this PR does not close. The local, embedded-replica and other SQL drivers are unchanged.
  • Cloud's overlay-index step (rebuildTenantOverlayIndex: detectManagedDrift([subject]), apply the one recreate_index, read again) is the shape the new overlay-index test drives on the remote face. It has not been run against cloud's code.
  • The sqld e2e does not run in CI, because no sqld is provisioned there. One way to provision it is a devDependency on the npm package that ships the binary (@sqld/linux-x64), which is a dependency decision this PR does not make.

Generated by Claude Code

…ex and column-drop entries

The Turso REMOTE face refused detectManagedDrift and applyMigrationEntries,
which left the v18 organization-ownership ceremony with no remote path.
The detector now runs the shared differ over reads sent to the remote
catalog (pragma_table_info, sqlite_master, pragma_index_list/info, the
ADR-0120 D4 duplicate probe), against the declarations every remote schema
door and the DDL-free registerObjectMetadata record. The apply door runs
drop_index, create_index, recreate_index, replace_unique_index and
drop_column as one libSQL write batch each, verified by read-back; other
column entries need the SQLite rebuild and are skipped and logged.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lr3rg6qTp9r38exA5DHEEH
…ync on a transport that cannot defer DDL

bootSchemaStack gains deferredDdlUnsupported: 'skip-schema-sync'. When the
primary datasource resolves to the Turso REMOTE transport (asked of
TursoDriver.detectMode), the boot passes skipSchemaSync to the standalone
stack instead of arming deferral on that driver: no DDL is attempted, every
object reaches its driver through the DDL-free registerObjectMetadata, and
every other datasource is still armed. measureComposedCoverage binds the
composed host's objects DDL-free on such a boot. The ceremony's plan and
apply declare the posture, and the apply reads the ledger's presence from
the catalog when the boot measured no table absent.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lr3rg6qTp9r38exA5DHEEH
…ies, against the local face over one file

The drift-detection refusal suite becomes the detection suite: a clean
remote-synced database drifts nowhere on either face, each drifted shape
reads alike on both, and registerObjectMetadata fills the registry the
no-argument call walks. A new suite applies the remote report's entries over
a real libSQL client and reads each outcome back through a second connection:
drop_column with its indexes, the rolled-back refusal, drop/create/recreate
index, the D4 re-probe, the skipped rebuild-only entry, and convergence.
The remote face's config now names its url, so the CLI's database label tells
remote databases apart.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lr3rg6qTp9r38exA5DHEEH
…, and the DDL-free coverage binding

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lr3rg6qTp9r38exA5DHEEH
…s two drift members

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

github-actions Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/cli, @objectstack/driver-turso, @objectstack/runtime, touching 63 documentable anchor(s).

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

  • content/docs/api/environment-routing.mdx (via createStandaloneStack (symbol, a top-level function))
  • content/docs/concepts/metadata-lifecycle.mdx (via os migrate organization-ownership (command, read off packages/cli/src/commands/migrate/organization-ownership.ts))
  • content/docs/data-modeling/drivers.mdx (via TursoDriver (symbol, a top-level class), createStandaloneStack (symbol, a top-level function))
  • content/docs/data-modeling/indexing.mdx (via recreate_index (literal, a string literal in applyRemoteIndexDriftOp))
  • content/docs/deployment/cli.mdx (via create_index (literal, a string literal in applyRemoteIndexDriftOp), drop_index (literal, a string literal in applyRemoteIndexDriftOp), recreate_index (literal, a string literal in applyRemoteIndexDriftOp), replace_unique_index (literal, a string literal in applyRemoteIndexDriftOp), sys_migration (literal, a string literal in runApply), os migrate organization-ownership (command, read off packages/cli/src/commands/migrate/organization-ownership.ts))
  • content/docs/deployment/seed-tenancy-repair.mdx (via replace_unique_index (literal, a string literal in applyRemoteIndexDriftOp), sys_migration (literal, a string literal in runApply))
  • content/docs/deployment/single-project-mode.mdx (via createStandaloneStack (symbol, a top-level function))
  • content/docs/permissions/capabilities.mdx (via os migrate organization-ownership (command, read off packages/cli/src/commands/migrate/organization-ownership.ts))
  • content/docs/permissions/permission-sets.mdx (via os migrate organization-ownership (command, read off packages/cli/src/commands/migrate/organization-ownership.ts))
  • content/docs/plugins/index.mdx (via createStandaloneStack (symbol, a top-level function))
  • content/docs/plugins/packages.mdx (via TursoDriver (symbol, a top-level class))
  • content/docs/protocol/objectql/query-syntax.mdx (via TursoDriver (symbol, a top-level class))

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

  • content/docs/releases/v17/17-0.mdx (via sys_migration (literal, a string literal in runApply))
  • content/docs/releases/v17/17-4.mdx (via sys_migration (literal, a string literal in runApply))
  • content/docs/releases/v17/17-5.mdx (via TursoDriver (symbol, a top-level class))
  • content/docs/releases/v17/17-6.mdx (via TursoDriver (symbol, a top-level class), sys_migration (literal, a string literal in runApply))
  • content/docs/releases/v17/17-7.mdx (via createStandaloneStack (symbol, a top-level function), sys_migration (literal, a string literal in runApply))

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

Coarse fallback — 46 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 1eac7ad751e833115544be405993efced6a726d3 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 1eac7ad751e833115544be405993efced6a726d3

⚠️ 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 1eac7ad751e833115544be405993efced6a726d3 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@hotlong

hotlong commented Oct 16, 2026

Copy link
Copy Markdown
Contributor

Contract review: CHANGES REQUESTED (head 39b9abdda3)

The PM for #23816 recorded this review on 2026-10-16 from the maintainer's local session. It is read-only and reviewed against merge-base 43567a8f.

The ceremony fix itself passes.

  • No spec key moved, and the remote face's answers have the file face's shapes.
  • applyMigrationEntries executes only the declared kinds.
  • Each drop_column is one libSQL write batch, which rolls back as a whole.
  • Skipped entries count as failed in organization-ownership-column-drops.ts.
  • Identifiers are quoted and catalog reads are parameterized.
  • skip-schema-sync is reached only from the ceremony's two call sites, and only when the resolved primary is a Turso remote.

Blocking 1: the OS_ARTIFACT_URL boot migration gate now refuses healthy remote deployments

  • Cause: the remote face's buildCreateTableSQL / ADD COLUMN never emit NOT NULL. driver-turso has zero notNull references at this head; driver-sql has thirteen files. A field declared storage: { notNull: true } is created nullable on a remote database.
  • What the differ reports: tighten_not_null, category destructive (schema-drift.ts). This was measured on the shared differ: TEXT nullable column + storage.notNull → [{op:'tighten_not_null',category:'destructive'}]; without storage → [].
  • Result: with this PR, os serve / os start with OS_ARTIFACT_URL and a libsql:// primary (a documented self-hosting combination) refuses to boot on a freshly synced database.
    • Its remedy, os migrate apply --allow-destructive, refuses on the remote transport too.
    • HotCRM declares storage.notNull widely (e.g. src/sales/objects/lead.object.ts).
    • Before this PR the gate warned that it could not check, and booted.

Maintainer's ruling (2026-10-16, verbatim: the option 「恢复警告并补测试」): on a Turso remote primary, restore the gate's earlier answer, "cannot judge, warn and boot". Do not auto-apply and do not refuse. Decide it from the declared capability, not by comparing the transportMode string. The gate judges remote databases again only after the two follow-ups below land. Say this plainly in the changeset (today it reads "widening" only).

Blocking 2: the embedded-replica control was lost with the deleted refusal test

The removed turso-remote-drift-detection-refusal.test.ts also pinned that the embedded-replica face reports drift on a drifted database. No remaining test covers that, and the PR names no replacement (AGENTS.md: a deleted test names its same-layer or closer duplicate). Add one replica case.

Required in this PR (the maintainer accepted these with the ruling)

  1. Pure unit tests so CI guards the CLI half without OS_TEST_SQLD_BIN:
    • the ceremony's two deferredDdlUnsupported: 'skip-schema-sync' opt-ins;
    • transportRefusesDeferredDdl;
    • the primary exemption (exemptPrimary).
      Today any of the three can break with CI still green.
  2. The sqld e2e stays opt-in. No new CI dependency (open question 2 → B).

Follow-ups, not in this PR

  • Open question 1 is answered by the ruling above (B, until the follow-ups land).
  • Two cards are filed separately:
    • the remote face honours storage.notNull on create / add column;
    • a remedy path for destructive drift on a remote database (os migrate apply on the remote face).
  • Non-blocking notes:
    • turso-driver.ts cites a non-existent turso-local-remote-drift-parity.test.ts; the real file is turso-remote-drift-detection.test.ts.
    • remoteCatalogRead folds a non-array result into [], a latent "no drift". Prefer a throw.
    • The applied read-back for create_index / replace_unique_index checks the name only, and the doc overstates it.
    • The remote no-argument probe makes about 4 sequential round trips per table, so roughly 440 on a ~110-table tenant. Consider batch(…,'read'); cloud's first-request upgrade pays this.

After the changes, push and re-run the gates. The PM re-reviews the increment and clears needs:contract-review on a PASS.

@hotlong

hotlong commented Oct 16, 2026

Copy link
Copy Markdown
Contributor

Pointer to the review above: the two follow-ups are filed as #23847 (the remote face honours storage.notNull on create / add column) and #23848 (os migrate apply on a Turso remote database). Until both land, the OS_ARTIFACT_URL gate stays warn-and-boot on a remote primary. In the changeset, refer to them as follow-ups, not as closed by this PR.

…nd-boot answer on a Turso remote primary

The remote face's schema sync creates no declared NOT NULL, so a healthy
remote database reads as destructive tighten_not_null, and the gate's
remedy cannot run on that transport. TursoDriver declares
driftReportCaveat (a sentence on the remote face, null elsewhere) and the
gate, reading that declaration, warns and boots, applying and refusing
nothing.

Also from the contract review: the embedded-replica drift control is back
beside the remote suite; the remote detector reads its catalog in three
round trips per call; catalog reads refuse a non-row answer; a created
index counts as applied only with the uniqueness and key columns asked
for; the parity suite is cited by its real name.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lr3rg6qTp9r38exA5DHEEH
… two opt-ins, transportRefusesDeferredDdl and the primary exemption

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lr3rg6qTp9r38exA5DHEEH
…urso remote primary

The maintainer's ruling on the contract review: say plainly that the boot
migration gate does not judge a remote database. It warns and boots, applies
nothing and refuses nothing, and decides that from the driver's declared
driftReportCaveat. It stays that way until the remote sync honours
storage.notNull and os migrate apply has a remedy on that transport.

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


Both are cited as follow-ups that this change does not close.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lr3rg6qTp9r38exA5DHEEH
@hotlong

hotlong commented Oct 16, 2026

Copy link
Copy Markdown
Contributor

Contract review

Served-tier: TIER_DEFAULT
Head: 26b6acefac48d6afbc4706c33a7835b285b1bed1 (round 1 reviewed 39b9abdda3, CHANGES REQUESTED in comment 6211456559)
Implemented-by: os-dev session_01Lr3rg6qTp9r38exA5DHEEH · Reviewed-by: contract-review subagent of the #23816 PM session (separate read-only session)
Local-runs:

  • round 1: a probe of the shared differ over a prebuilt driver-sql dist (storage.notNull over a nullable column → tighten_not_null, destructive);
  • round 2: none.

① Contract surface. No packages/spec change; only widenings:

  • the Turso remote face's detectManagedDrift / applyMigrationEntries now answer instead of 501, with the file face's return shapes and skip-and-warn semantics;
  • TursoDriver.driftReportCaveat, RemoteTransport.batch / createIndexSql, and StandaloneStackConfig.skipSchemaSync are new.

The new CLI symbols (DeferSchemaDdlPlugin, transportRefusesDeferredDdl, SqlDriverLike.driftReportCaveat) cannot be reached from the cli exports map. Only os migrate organization-ownership (plan and apply) opts into skip-schema-sync.

② Semver. Minor for driver-turso, cli and runtime, matching the changeset. On a Turso remote primary the OS_ARTIFACT_URL gate keeps warn-and-boot through the declared caveat: no detect, no apply, no refusal. Local, replica and the other SQL drivers are unchanged.

③ Round-1 flags resolved:

  • the gate is restored (artifact-boot-migration.ts:134-138, with a null-caveat control);
  • the embedded-replica control is back as the same-layer replacement for the deleted refusal suite's replica case;
  • two unit suites pin the CLI posture in CI, and the ablations are consistent.

The PR body is updated to round 2. Follow-ups #23847 and #23848 stay OPEN.

Non-blocking, recorded for later:

  • create_index read-back does not compare NULL-safe / partial shape;
  • the "3 round trips" holds up to 100 tables;
  • remoteCatalogSnapshot is instance state that two concurrent detects could leave stale (no reader today).

PASS

@hotlong
hotlong marked this pull request as ready for review October 16, 2026 09:26
@hotlong
hotlong enabled auto-merge October 16, 2026 09:26
@hotlong
hotlong added this pull request to the merge queue Oct 16, 2026
Merged via the queue into main with commit ae4dfaf Oct 16, 2026
51 checks passed
@hotlong
hotlong deleted the claude/issue-23816-remote-ceremony branch October 16, 2026 09:47
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/xl tests tooling

Projects

None yet

2 participants