Skip to content

fix(metadata-protocol): duplicatePackage parses an explicit targetNamespace through the manifest namespace declaration - #19829

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-19577-duplicate-package-explicit-namespace
Sep 23, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-19577-duplicate-package-explicit-namespace

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #19577

Clause-②: no (narrowing)

What changed

ObjectStackProtocolImplementation.duplicatePackage (packages/metadata-protocol/src/protocol.ts, located by symbol) resolved its target namespace as request.targetNamespace ?? deriveNamespaceFromPackageId(request.targetPackageId), and the loud 400 fired only on !targetNs. So an explicit targetNamespace crossed no gate. That value is written as the copy's manifest.namespace and spliced into every copied object name as ${targetNs}_${short}, which means targetNamespace: 'my-ns' minted my-ns_ticket, a name the object declaration refuses.

Both branches now pass one parse, before the source scan and before the target package record is minted:

  • The declaration, by reference. ManifestSchema.shape.namespace (exported from @objectstack/spec/kernel, already imported by protocol.ts for the id gate). It is the manifest.namespace rule itself (/^[a-z][a-z0-9_]{1,19}$/, packages/spec/src/kernel/manifest.zod.ts), not a copied regex. The parse input is targetNs ?? '', because the declaration is .optional() and would otherwise pass an absent value.
  • Refused, not sanitised. An explicit value the declaration refuses is refused. It is never rewritten the way the derivation sanitises an id.
  • The declaration's sentence. The refusal opens with the key and the value, then carries the declaration's own issue message verbatim. For example: Invalid package namespace 'my-ns' on + backtick targetNamespace backtick + . Namespace must be 2-20 chars, lowercase alphanumeric + underscore. It becomes the copy's manifest.namespace and the prefix of every copied object name. The derived branch keeps its Cannot derive a package namespace from 'ID'. Pass targetNamespace explicitly. opener. Its reworded rule clause is replaced by the same declaration sentence. Neither message opens with a bracketed tag.
  • No new error code. Both refusals throw { statusCode: 400 } with no code, exactly as the id refusal above them does. An HTTP boundary (resolveThrownHttpError) therefore answers 400 VALIDATION_ERROR on both branches, which is the status-derived code the derived branch already answered.
  • The comment that blessed the gap ("An explicitly declared targetNamespace still wins untouched …") is rewritten to state the new truth: the explicit value still wins over the default, but it is parsed.

Dispatch assumptions, measured

  • A1 (confirmed). On base c11852406 the construct is exactly as described. The ablation below shows the explicit branch resolving for every value the declaration refuses.
  • A2 (refuted in part). There is no exported namespace validator or regex that deriveNamespaceFromPackageId sanitises toward. It tests a module-private NAMESPACE_RE in packages/spec/src/kernel/namespace-prefix.ts, which is byte-identical to the manifest declaration's regex but not exported. The nearest published declaration is ManifestSchema.shape.namespace, and that is what this PR uses. The same shape was used for the id: ManifestSchema.shape.id.
  • A3 (confirmed). The comment is rewritten.
  • A4 (confirmed). No new code is minted. The explicit refusal reuses the derived branch's status-only shape, and the tests assert declaredCode is absent.
  • Producer location. The fix is at the producer. POST /api/v1/packages/:id/duplicate (packages/runtime/src/domains/packages.ts) forwards a string targetNamespace verbatim to this method and maps the throw with errorFromThrown(e, 500). That route therefore answers 400 VALIDATION_ERROR with no route change.

Tests

New file: packages/metadata-protocol/src/protocol.duplicate-package-target-namespace.test.ts. The existing protocol.install-manifest-id.test.ts is untouched and still green, derived-branch refusal included.

  • Refusal, 8 cases. Hyphen my-ns, uppercase, leading digit, leading underscore, 1 char, 21 chars, padded ' leave2 ', and ''. Each case asserts:
    • the envelope via resolveThrownHttpError: status 400, code VALIDATION_ERROR, declaredCode undefined;
    • that the message starts with its first sentence;
    • that the message contains the declaration's own issue message, read from ManifestSchema.shape.namespace.safeParse and not retyped;
    • that nothing was installed, scanned or saved.
  • Lit control, 4 cases. leave2, leave_copy, 20 chars and 2 chars. Each still duplicates, manifest.namespace equals the value, and the written object names are exactly ${ns}_ticket.
  • Derived branch, 1 case. Same envelope, and the declaration's sentence follows the targetNamespace remedy.

Runs (all at HEAD 5e843ab81, through scripts/pm/os-verify-lock.sh):

  • pnpm --filter '@objectstack/metadata-protocol^...' build: VERDICT command-exit 0.
  • pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2 src/protocol.duplicate-package-target-namespace.test.ts src/protocol.install-manifest-id.test.ts src/protocol.bracketed-refusal-opener-absence.test.ts: Test Files 3 passed (3), Tests 36 passed (36). The bracketed-opener pin is green.
  • pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2 (whole package): Test Files 188 passed | 3 skipped (191), Tests 2671 passed | 19 skipped (2690), VERDICT command-exit 0.
  • pnpm --filter @objectstack/metadata-protocol typecheck: exit 0. tsc --noEmit --listFiles includes the new test file (1 hit).
  • Consumer check: pnpm --filter @objectstack/metadata-protocol build && pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2 src/protocol-package-lifecycle.test.ts gave 10 passed. That test is a downstream caller passing the conforming targetNamespace: 'iojn2' through the built dist. The runtime integration suite package-duplicate-adopt-org-scope.integration.test.ts also uses iojn2 only; it is declared to CI and was not run here.

Ablation (one-shot, after commit 5e843ab81). Tool: node scripts/ablation-replace.mjs, which restores itself on EXIT/INT/TERM, wrapped in the verify lock. It replaced the anchor if (targetNs == null || !declaredTargetNs.success) { with if (!targetNs) {, which is the base guard, so the new parse is removed.

  • On disk: anchor x1 → x0, replacement x0 → x1, blob 74ee23265ed4 → 63700594eaa0. The subject resolves via the relative ./protocol.js, so it runs from src/ and needed no rebuild.
  • Result: Tests 7 failed | 6 passed (13). All 7 explicit refusal cases failed with expected the call to be refused, but it resolved.
  • The 6 that stayed green are what was predicted: '' (the base !targetNs guard already caught it), the 4 lit controls, and the derived case.
  • Restore: blob after restore 74ee23265ed4 equals the HEAD blob, and git diff HEAD is empty.

Gates

node scripts/pm/dispatch-gates.mjs --commands was run on HEAD 5e843ab81: 61 commands, each run with its exit captured before any pipe. --ran verdict: ✓ dispatch-gates --ran: 61 derived famil(ies) accounted for — 59 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3).

  • pnpm check:lean-entry-closure first answered exit 3 (PREREQUISITE NOT MET: objectql dist absent). It was re-run after pnpm exec turbo run build --filter=@objectstack/objectql --concurrency=2 and gave exit 0 (2 published condition(s) measured from a real load). The record carries the rerun.
  • NOT MEASURED: pnpm check:dual-build-cjs-loads. Reason: PREREQUISITE NOT MET, since it reads the built output of every package (68 lack dist here). A full pnpm build does not fit the foreground cap, and this diff changes no package.json, exports or build config. CI runs it on a fresh full build.
  • NOT MEASURED: pnpm check:type-check-debt. Reason: PREREQUISITE NOT MET, since it needs the whole packages build closure. The only package touched is @objectstack/metadata-protocol, whose own typecheck exits 0.
node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0
node scripts/check-adr-0087-registration.mjs --self-test :: exit 0
node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0
node scripts/check-changeset-no-major.mjs --self-test :: exit 0
node scripts/check-ci-filter-parity.mjs :: exit 0
node scripts/check-closing-keyword-parity.mjs :: exit 0
node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0
node scripts/check-comment-mask-adoption.mjs :: exit 0
node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0
node scripts/check-comment-mask-corpus.mjs :: exit 0
node scripts/check-empty-changeset.mjs --base origin/main :: exit 0
node scripts/check-empty-changeset.mjs --self-test :: exit 0
node scripts/check-keyed-text-bounds.mjs :: exit 0
node scripts/check-keyed-text-bounds.mjs --self-test :: exit 0
node scripts/check-platform-object-tenancy-census.mjs :: exit 0
node scripts/check-platform-object-tenancy-census.mjs --self-test :: exit 0
node scripts/check-plugin-teardown-shape.mjs :: exit 0
node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0
node scripts/check-registry-log-declared.mjs :: exit 0
node scripts/check-registry-log-declared.mjs --self-test :: exit 0
node scripts/check-rest-log-spy-declared.mjs :: exit 0
node scripts/check-rest-log-spy-declared.mjs --self-test :: exit 0
node scripts/check-system-context-census.mjs :: exit 0
node scripts/check-system-context-census.mjs --self-test :: exit 0
node scripts/check-undeclared-dep-imports.mjs :: exit 0
node scripts/check-undeclared-dep-imports.mjs --self-test :: exit 0
node scripts/docs-audit/check-affected-docs.mjs :: exit 0
node scripts/docs-audit/check-drift-comment.mjs :: exit 0
node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0
pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0
pnpm check:changeset-gate-self-tests :: exit 0
pnpm check:cross-package-test-inputs :: exit 0
pnpm check:dispatcher-error-vocabulary :: exit 0
pnpm check:doc-authoring :: exit 0
pnpm check:driver-memory-census :: exit 0
pnpm check:dts-closure :: exit 0
pnpm check:dual-build-cjs-loads :: exit 3
pnpm check:durability-log-level :: exit 0
pnpm check:engine-double-contract :: exit 0
pnpm check:filter-alias-parity :: exit 0
pnpm check:gitlink-declared :: exit 0
pnpm check:issue-citations :: exit 0
pnpm check:lean-entry-closure :: exit 0
pnpm check:logger-receiver-detach :: exit 0
pnpm check:nul-bytes :: exit 0
pnpm check:objectql-double-limit :: exit 0
pnpm check:objectui-changeset :: exit 0
pnpm check:org-identifier :: exit 0
pnpm check:page-declaration-shape :: exit 0
pnpm check:pm-changeset-deadline-census :: exit 0
pnpm check:published-files :: exit 0
pnpm check:query-options-erasure :: exit 0
pnpm check:refd-timer-probe :: exit 0
pnpm check:slot-lookup :: exit 0
pnpm check:sourcemap-no-sources-content :: exit 0
pnpm check:test-source-alias :: exit 0
pnpm check:tier-file-adoption :: exit 0
pnpm check:type-check-coverage :: exit 0
pnpm check:type-check-debt :: exit 3
pnpm check:watch-hint-literal :: exit 0
pnpm check:where-matcher :: exit 0

Narrowed lint (measured). pnpm exec eslint --no-inline-config --format json was run over the two changed TS files: exit 0, and the JSON reports 2 files, 0 errors, 0 warnings.

  • ① Population, from eslint.config.mjs: files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'] plus the packages/**/*.{ts,…} blocks. The changeset .md is in no files glob, so these two files are the whole lintable part of the diff.
  • ② File count: 2, read from the JSON output.
  • ③ Invariance: the config never enables type-aware linting (no parserOptions.project, no typed rules; stated at eslint.config.mjs around line 327). The baselines it reads (scripts/slot-lookup-baseline.json, scripts/query-options-erasure-baseline.json) are untouched. So this diff cannot move any verdict on an untouched file.

Changeset

.changeset/19577-duplicate-package-explicit-namespace.md: minor for @objectstack/metadata-protocol, declaring Clause-②: no (narrowing), a BREAKING for callers banner and an adr-0087: not-required (no-migration-prescription) disposition. It was re-graded from patch in a patch round on the seat's call, following the same door's precedent PR #19574. The claim's Clause-② VALUE (no) is unchanged; the (narrowing) arm carries the direction the changeset gates read.

Acceptance notes


Generated by Claude Code

…espace through the manifest namespace declaration

An explicit `targetNamespace` was taken raw and spliced into every copied
object name, while the derived default already had to satisfy the namespace
charset. Both branches now pass one `ManifestSchema.shape.namespace` parse
before anything is scanned or minted, refusing with the declaration's own
sentence and the status-derived 400 VALIDATION_ERROR the derived branch
already answered.

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

github-actions Bot commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

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 — 11 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 1cacfe4a425dc0c2cdd4eb5d0088d5b3f5a16c92 → packageMentionDocs.

Which tree this was computed on

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

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

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

…owing (minor)

An explicit targetNamespace outside the manifest.namespace declaration used
to be accepted verbatim and is now refused, so the changeset declares
Clause-② no (narrowing), ships minor, carries the BREAKING banner naming
the refused values, and states its ADR-0087 disposition.

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: a27d6d9348277ae8e8e276712eed7a391e389962

Rendered by an isolated at-tier reviewer subagent that was fed the card, its rulings and this PR only, and adopted by the domain:engine#1 seat. Two commits over merge-base c118524: 5e843ab81 (the fix) and a27d6d9 (the changeset re-grade). Three files, +235/-12. No governed surface is touched.

① Derived judgments

  • An explicit targetNamespace outside the declaration is now refused (it was accepted verbatim) → RIGHT. The parse is the declaration by reference, ManifestSchema.shape.namespace.safeParse(targetNs ?? ''). On origin/main that declaration is a single regex with no refine, transform or trim. So every value now refused is one the published declaration refuses, by construction, and each of the 8 refusal fixtures is regex-refused. The REST route forwards targetNamespace verbatim and untrimmed, so the whitespace case is reachable and correctly refused.
  • No declaration-accepted value is refused → RIGHT. The only refusal outside the parse is targetNs == null: no explicit value, and a derivation that returned null. Base already refused that case. The reserved names sys/base/system are TSDoc-only, not in the regex, and stay accepted on both sides, which is correct for a door that refuses exactly what the declaration refuses.
  • Derived branch: accept set unchanged; refusal changes only in its message → RIGHT. deriveNamespaceFromPackageId returns only values its private NAMESPACE_RE admits, and that regex is byte-identical to the declaration's, so the new parse can never refuse a derived value. Nothing pinned the old wording.
  • Refusal position → unchanged: after the manifest.id gate, and before any engine.find, installPackage or manifest mint. The tests pin that none of them is called on a refusal.
  • No new error code → RIGHT. Both throws are a statusCode: 400 Error with no code. resolveThrownHttpError maps that to 400 VALIDATION_ERROR, declaredCode absent, so the route answers the same envelope on both branches. The success response shape is untouched.
  • Exports / public surface → no export is added, removed or renamed.
  • Tests assert the envelope → yes: status, code, declaredCode, the opener, the rule sentence read off the declaration, and no side effects. Four lit controls assert the namespace and the written object names.

② Semver level

  • minor + Clause-②: no (narrowing) → matches the diff. The change is a value-domain narrowing of a published method and route: l, _leave and 21-character values used to duplicate to legal object names and are now refused. no is correct, since there is no new key and no new error code. check-changeset-no-major.mjs treats a declared narrowing as BREAKING and ships it minor during the launch window. The same door's precedent (2306a75ecb, PR fix(metadata-protocol)!: the protocol install primitive parses manifest.id, and the duplicate door parses its target id (#19417) #19574) took the same shape.
  • BREAKING banner → present. It names what is refused, states that conforming and omitted values duplicate as before, and carries an "If you are refused" remedy.
  • adr-0087 not-required (no-migration-prescription) → TRUE. The body carries no FROM → TO prescription, and no spec key, export, stored row or request field is renamed or reshaped. A refused value was already forbidden by manifest.namespace, and because the door refuses rather than sanitises, no mechanical rewrite exists to register.
  • The PR body's Clause-② line agrees with the changeset's → yes. The reviewer found one stale ## Changeset paragraph (still reading patch); the seat corrected it in the description before landing, with no code change.

③ Boundary flags

  • The declaration's sentence under-states its regex, and the derived-branch message now inherits the gap → acceptable. The card asked for the declaration's own remedy text, read by reference, so the spec-side fix (manifest.namespace refuses a leading digit or underscore, but its refusal sentence and TSDoc rule never say so — 1leave / _leave satisfy every stated clause and are refused #19831, filed) propagates here with no edit.
  • The private NAMESPACE_RE duplicates the declaration's regex → acceptable; it is byte-identical today, and that identity is what keeps the derived accept set unchanged.
  • The reserved names pass the parse → acceptable. That is unchanged from base, and refusing them would refuse values the declaration accepts.
  • The clause-② arm / changeset level → resolved by a27d6d9.
  • Two gate families NOT MEASURED locally → acceptable: the CI jobs that carry them are success on this head.

Implemented-by: claude/issue-19577-duplicate-package-explicit-namespace
Reviewed-by: session_01TEhopqrWQYBycZzyJHpAZr

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 23, 2026 10:04
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 23, 2026
Merged via the queue into main with commit 4112752 Sep 23, 2026
57 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-19577-duplicate-package-explicit-namespace branch September 23, 2026 10:26
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…ce (objectstack-ai#19842)

Fixes objectstack-ai#19823

Clause-②: no (narrowing)

## Measurement first — committed before the fix (`8d145637a8`)

Triage (comment 5792299650) ordered the three predictions recorded
MEASURED or REFUTED before any fix. The instrument is
`packages/drivers/driver-turso/src/turso-remote-deferred-ddl.test.ts`,
first committed as a characterisation of the unrefused behaviour. It
replays, against a remote-mode `TursoDriver` over `makeLibsqlSqliteStub`
(a real SQLite database wearing the `@libsql/client` interface) wrapped
in a recorder that logs every statement the transport sends, the exact
driver calls a `deferSchemaDdl` boot makes:

1. `setDeferredDdl(true)`: the CLI's `DeferSchemaDdlPlugin.init`.
2. `syncSchemasBatch(...)`: `ObjectQLPlugin.start()`'s boot sync. It
takes the batch door because this driver answers
`supports.batchSchemaSync === true` and has the method, which the test
also asserts.
3. `syncSchema(...)`: the composed-host coverage pass
(`engine.syncObjectSchema`) that `plan` / `apply` run.
4. `previewDeferredSchemaWork()` / `flushDeferredSchemaDdl()`: what
`plan` prints, and what `apply` performs after its confirm prompt.

Seed: an existing remote table `probe` missing one declared column and
holding a naive datetime (`2025-07-28 00:00:00`), plus a declared object
`fresh` with no table. Run on base `1cacfe4a42`: 6 of 6 characterisation
tests green.

| prediction | door | verdict | evidence (recorded statements, disk) |
|:--|:--|:--|:--|
| (a) the dry run performs DDL | `syncSchemasBatch` (engine boot sync) |
**MEASURED** | `CREATE TABLE "fresh" (...)`, `ALTER TABLE "probe" ADD
COLUMN "why" TEXT` |
| (a) | `syncSchema` / `initObjects` | **MEASURED** | the same CREATE
and ALTER |
| (b) the dry run rewrites rows | `syncSchemasBatch` | **REFUTED** | no
row write; the naive value is still on disk |
| (b) | `syncSchema` / `initObjects` | **MEASURED** | `update "probe"
set "at" = (case ... end) where rowid in (select ...)`; the row now
reads `2025-07-28T00:00:00.000Z` |
| (c) the plan reports no pending work | every door | **MEASURED** |
`previewDeferredSchemaWork()` answers `[]`, `flushDeferredSchemaDdl()`
answers `[]`, `deferredSchemaObjectCount` is 0 |

**Triage's exits: neither fires.** Exit one (all three REFUTED) does
not: the plan path reaches DDL on every door. Exit two (a destructive
statement) does not: no door emitted a `DROP` or a type change. The only
row writes are the canonical backfill's `update`, which rewrites a
value's spelling and not the instant it names.

**Not measured end to end:** the `os migrate plan` binary itself against
a live libsql remote. The CLI's build closure is 58 workspace packages.
The driver half is measured above. The CLI half is read from source on
`1cacfe4a42`:

- `packages/cli/src/utils/schema-migrate.ts` `DeferSchemaDdlPlugin.init`
calls `setDeferredDdl(true)` in Phase 1.
- `packages/core/src/kernel.ts` `bootstrap()` rethrows an init error
unwrapped, and `Runtime.start()` is `kernel.bootstrap()`.
- `plan` prints `error.message`. Under `--json` it emits `error` plus
`code` through `errorCodeFields`.

## Dispatch assumptions, measured

- **A1 — confirmed, and widened.** The `isRemote` arms of `syncSchema`
and `initObjects` do run remote DDL plus
`backfillRemoteCanonicalTemporalQuietly()`, and never read the flag
(`deferredDdl`: 0 hits under `packages/drivers/driver-turso/src/` on
base). The engine's boot sync reaches neither of them, though. It takes
a third door, `TursoDriver.syncSchemasBatch`, whose remote arm forwards
straight to `RemoteTransport.syncSchemasBatch`: DDL without the
backfill. So on the ordinary `plan` path the DDL is certain, and the
backfill arrives through the coverage pass.
- **A2 — confirmed.** The CLI refuses only on `typeof
driver.setDeferredDdl !== 'function'`. The measurement shows the
inherited setter accepting `true` on the remote face without a throw.
- **A3 — five commands arm deferral; all five carry a dry-run or
confirm-before-change promise.** So the refusal disables nothing that
promised to write, and there is no `needs_decision` stop. Enumerated
from `deferSchemaDdl: true` under `packages/cli/src/commands/**`.
`setDeferredDdl` has no other caller in this repository.

| command | its promise | on remote Turso before this PR | after |
|:--|:--|:--|:--|
| `os migrate plan` | dry run, "never mutates the schema" | DDL (plus
the backfill on the coverage pass), then "no pending work", exit 0 |
refused, exit 1, nothing sent |
| `os migrate apply` | "nothing is written before you say yes" | DDL
during boot, then the confirm prompt; the flush reports `[]` | refused,
exit 1 |
| `os migrate duplicates` | read-only inventory; "cannot change the
install it is describing" | DDL during boot | refused (`boot_failed`
plus the detail) |
| `os migrate account-issuer` | read-only pre-flight | DDL during boot |
refused |
| `os migrate multi-value-columns` | dry run by default; `--apply`
promises "the only statements this command may run are the remedy's" |
DDL during boot, in both modes | refused |

- **A4 — confirmed.** `previewDeferredSchemaWork` /
`flushDeferredSchemaDdl` read `deferredSchemaObjects`, which only the
Knex `SqlDriver.initObjects` fills. On remote both answer `[]`, per the
table above. `remote-canonical-backfill.ts` already said so in prose.

Through the CLI, a Turso URL always builds a **remote** driver.
`standalone-stack.ts` hands the driver `{ url, authToken }` with no
`syncUrl`, and a `file:` URL is classified `sqlite`. So all five
commands refuse on every Turso URL the CLI accepts.

## The fix

`TursoDriver` overrides `setDeferredDdl`: arming (`true`) in `remote`
transport mode throws before any statement is sent. Disarming is
accepted, and `local` / `replica` delegate to `SqlDriver` unchanged. The
refusal (`refuseRemoteDeferredDdl`, beside the transaction and
auto-number refusals) answers `code: 'NOT_IMPLEMENTED'`, `status: 501`.
That is a `StandardErrorCode` member and the envelope this transport
already uses for its other capability gaps, so there is **no new error
code**.

**Whose message the operator reads — measured, not assumed.** The CLI's
own refusal ("does not support deferred schema DDL ... Upgrade
@objectstack/driver-sql") cannot fire, because the method exists. The
driver's throw propagates out of `DeferSchemaDdlPlugin.init` unwrapped,
and the command prints its `message`. So the driver's own message is the
operator contract, and the CLI is untouched:
`packages/cli/src/utils/schema-migrate.ts` and
`packages/cli/src/commands/migrate/*` were read only. Its first
sentence:

> 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.

The rest says why (remote DDL is immediate, and a remote sync rewrites
temporal values in place), what it replaced, and what to do instead:
preview against a local SQLite copy (a `file:` URL), or let an ordinary
`os serve` / `os start` boot perform the additive sync.

Why at the setter: every deferring caller passes through it, and it runs
before any schema work. A refused arm has sent nothing, and it leaves
the driver un-armed, so an ordinary boot sync on the same instance is
unchanged. Honouring the deferral remotely (recording objects, a remote
preview and flush) is new capability with no measured pull, and it is
not attempted here.

## Tests — `turso-remote-deferred-ddl.test.ts` (8 tests)

- **Refusal envelope:** `toBeInstanceOf(Error)`, `code ===
'NOT_IMPLEMENTED'`, `status === 501`, and the message starts with the
first sentence above, spelled out in the test rather than imported.
- **The measurement's own scenario now performs NOTHING:** the full
deferred-boot replay against remote pending work rejects with the
envelope. The recorder holds **zero statements** (so zero DDL and zero
row writes), and the disk is byte-for-byte what the command found: the
tables, the columns and the naive value.
- **Disarm:** `setDeferredDdl(false)` is accepted and sends nothing.
- **Lit control, remote ordinary boot (deferral NOT armed):** after a
refused arm on the same driver, `syncSchemasBatch` still emits the
CREATE and the ALTER, and `syncSchema` still runs the backfill `update`
(the stored value becomes `2025-07-28T00:00:00.000Z`).
- **Lit controls, `local` and `replica`:** arming is accepted. The same
replay records instead of performing (`fresh` absent, `why` absent,
`deferredSchemaObjectCount` 2). The preview lists `create_table fresh
[label]` and `add_columns probe [why]`. The flush performs exactly the
previewed work, and the libsql client carried no DDL.
- **Pin:** `supports.batchSchemaSync === true` and `syncSchemasBatch` is
a function, the two facts the engine ANDs to pick the batch door.

Package suite at `67be9850fd`: `pnpm --filter @objectstack/driver-turso
exec vitest run --maxWorkers=2` gave **56 files / 1299 tests passed**.
`pnpm --filter @objectstack/driver-turso typecheck` exited 0, and `tsc
--noEmit --listFiles` includes the new file (56 test files in the
program).

## Ablation — committed fix, then removed, then restored

At HEAD `67be9850fd`, through `scripts/ablation-replace.mjs` in WRAP
mode, with a shell `trap` restoring `git checkout HEAD --` on the
absolute path. The anchor was `if (deferred && this.isRemote)
refuseRemoteDeferredDdl();`, replaced by a marker comment.

- **On disk:** anchor 1 to 0, marker 0 to 1, blob `79960fb8a08f` to
`d16059b40f0a`. The in-mutation `grep -c` read anchor 0 and marker 1.
The subject is imported from `src/` (a relative `./turso-driver.js`), so
no `dist/` leg applies.
- **Direction predicted before the run:** the three tests that need the
refusal go red (the envelope, performs-NOTHING, and the refused-arm
control), and the five that do not stay green.
- **Observed:** `3 failed | 5 passed (8)`, exactly those three:
`expected null to be an instance of Error`, then `expected undefined to
be 'NOT_IMPLEMENTED'` twice.
- **Restore:** the blob after restore equals HEAD (`79960fb8a08f`), `git
diff HEAD` is empty, and `git status --porcelain` is empty.

## Gates — derived on the final commit `67be9850fd`

`node scripts/pm/dispatch-gates.mjs --commands` (no paths) derived
**61** commands. Every exit code was captured before any pipe, and each
command was recorded with it:

- **58 exited 0.** Among them: `check:adr-0087-registration --base
origin/main` accepted the changeset as `[BREAKING+clause-②-narrowing]
not-required (no-migration-prescription)`, and
`check:changeset-no-major` reported no `major` (the level axis is not
applicable locally, since there is no PR payload). Also green:
`check:empty-changeset`, `check:doc-authoring`, `check:nul-bytes`,
`check:object-def-param-keys`, `check:published-files`,
`check:dts-closure`, `check:sourcemap-no-sources-content`,
`check:test-source-alias`, `check:type-check-coverage`,
`check:cross-package-test-inputs` and `check:engine-double-contract`.
- **3 exited 3, NOT MEASURED (PREREQUISITE NOT MET):**
`check:dual-build-cjs-loads` and `check:type-check-debt` need the whole
workspace built, and `check:lean-entry-closure` needs `objectql/dist`.
They are declared to CI. A narrow probe of the half this diff touches:
the built `driver-turso` `dist/index.js` loads under `require` and
`dist/index.mjs` under `import`, and both export `TursoDriver` (exit 0).
- **`--ran` reconciliation:** `✓ dispatch-gates --ran: 61 derived
famil(ies) accounted for — 58 run, 3 NOT-MEASURED (3 DERIVED from a
recorded exit 3).` It reported 0 UNRUN.
- **CLI integration tier:** declared to CI. No spawn entry or CLI file
is touched.

**Driver conformance ledger (lane commitment)**, identical before the
first edit and after the final commit:

- `OK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt.`
- Dialect axis: `8 conformance suite(s) ... 7 run the matrix, 1 declare
named cell(s), 0 in the DIALECT ledger.`

No DEBT added.

## Changeset judgement — a declared narrowing

A remote `setDeferredDdl(true)` used to resolve, and a remote `os
migrate plan` used to exit 0. Both now refuse. That narrows what the
published driver accepts, so this PR follows the shape of this seat's
sibling PR objectstack-ai#19829: `Clause-②: no (narrowing)`, a `minor` bump for
`@objectstack/driver-turso`, a **BREAKING** banner, and the ADR-0087
disposition `not-required (no-migration-prescription)`. Nothing
authorable is removed or renamed, and `setDeferredDdl` keeps its name
and signature. The claim carries bare `Clause-②: no`, and its own
rationale calls this change a narrowing, so the arm is added and the
base value is unchanged.

## Acceptance notes

- `content/docs/deployment/cli.mdx`, section "Nothing is written before
you confirm", does not mention that a remote Turso datasource is
refused. It is incomplete rather than wrong: plan and apply still write
nothing. Successor: none.
- External callers of `setDeferredDdl` on a remote `TursoDriver` outside
this repository (for example the cloud repository) were NOT MEASURED,
because that repository is not reachable from this container. Inside
this repository the only caller is the CLI plugin.
- Two reproducible defects surfaced during the measurement. They are
**not fixed here** and are reported to the seat for filing: the remote
batch door's missing read-coercion registration and backfill, and remote
`detectManagedDrift()` reading the dummy Knex connection.

---

_Generated by [Claude
Code](https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr)_

---------

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

Projects

None yet

1 participant