Skip to content

fix(driver-turso): remote syncSchemasBatch registers read coercion and runs the canonical backfill - #19863

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-19844-turso-batch-door-registration
Sep 23, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-19844-turso-batch-door-registration

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #19844

Clause-②: no

What was wrong

ObjectQLPlugin.syncRegisteredSchemas takes its batch branch when a driver declares supports.batchSchemaSync and implements syncSchemasBatch, and TursoDriver does both. On the remote transport, syncSchemasBatch returned straight after its DDL. The two sibling remote doors (syncSchema, initObjects) go on to call registerRemoteFieldMetadata and then the canonical temporal backfill. So the door a remote-Turso boot actually takes was the one door that skipped all three halves: read-coercion registration, the managed-object record, and the backfill.

Boot measurement (taken before any edit)

One boot of an ObjectKernel with ObjectQLPlugin, a remote TursoDriver registered as the driver.turso service over the libsql SQLite double (libsql-sqlite-stub.testkit.ts), and an app object w with flag: boolean, meta: json, at: datetime. It ran as a scratch test and is not committed (see Acceptance notes). Before = origin/main 8cbc3c0; after = this branch at d8940d7.

reading before after
schema doors called during the boot syncSchemasBatch twice (phase 1 and phase 3); no syncSchema, initObjects, registerExternalObject or registerObjectMetadata call syncSchemasBatch twice, each followed by registerExternalObject for all six objects in the batch
driver.findOne flag / meta 1 (number) / the string {"k":1} true (boolean) / {"k":1} (object)
engine findOne flag / meta 1 (number) / a string true (boolean) / an object
paginationTieBreaker('w') null id
booleanFields.w / jsonFields.w / datetimeFields.w empty / empty / empty flag / meta / created_at, updated_at, at
canonicalDatetimeFields.w empty created_at, updated_at, at

So the card stands at p1: nothing else on the boot path populated the registries.

The fix

The landing site is the one the card named, the isRemote arm of TursoDriver.syncSchemasBatch in packages/drivers/driver-turso/src/turso-driver.ts. The producer is this driver, so no consumer changes.

  • A new private helper, completeRemoteSchemaSync(objects), registers each object and then runs backfillRemoteCanonicalTemporalQuietly() once for the call. The registration is registerRemoteFieldMetadata: the remoteManagedObjects record, plus the coercion, tenant and autonumber registries registerExternalObject fills. An empty list is a no-op.
  • All three remote doors call the helper after their DDL resolves. syncSchema passes one object, keyed by its object argument. initObjects passes its objects. syncSchemasBatch now passes each entry as { ...schema, name: object }, the same strict keying syncSchema uses.
  • Order: the DDL is awaited first, so a DDL failure rejects before anything is registered. Registration comes before the backfill because the backfill reads it to learn which columns are temporal.
  • The backfill runs once per batch, not once per object. It probes every unmarked column in one round-trip, so a steady-state boot costs one probe per sync call.
  • One docblock in the deferred-DDL refusal section said the latter two doors "also run" the backfill. It now says the batch door runs it too.

Not touched: packages/objectql/src/plugin.ts, which was read only. detectManagedDrift in remote mode belongs to #19845. It is not in this diff, and #19845 remains open.

Tests

The new file packages/drivers/driver-turso/src/turso-remote-batch-door-registration.test.ts has 12 cases:

  • A describe.each over the three remote doors, with syncSchemasBatch (the boot door) as the case under test and syncSchema and initObjects as controls. Each syncs the reproduction object { fields: { flag: boolean, meta: json } }, creates a row and calls findOne. Expected: flag === true, meta deep-equals { k: 1 }, and the raw row on disk is still { flag: 1, meta: '{"k":1}' }, which proves the test is not vacuous. paginationTieBreaker('w') goes from null to 'id'.
  • Write side, on each of the three doors (patch round): a datetime written as 2025-07-28T08:00:00+08:00 reaches disk as 2025-07-28T00:00:00.000Z.
  • The batch door keys by object, never by a schema.name that differs from it.
  • The batch door over a pre-existing legacy row: backfillRemoteCanonicalTemporal is called exactly once for a two-object batch. The naive 2025-07-28 00:00:00 is rewritten on disk to 2025-07-28T00:00:00.000Z, and both columns are marked canonical.
  • A failing DDL batch rejects with the injected error. No table is created, and there is no tie-breaker, no boolean registry and no backfill call.

Ablation (run once, after the fix was committed)

Mutation: delete only the batch door's completeRemoteSchemaSync(...) call, using node scripts/ablation-replace.mjs. The anchor went from 1 hit to 0, the blob changed from b33d3987b0bc to 5f1d1c909d7d, and an on-disk grep count read 0. Command for both runs: pnpm --filter @objectstack/driver-turso exec vitest run --maxWorkers=2 src/turso-remote-batch-door-registration.test.ts.

  • Mutant run (re-run on de31187a96): exit 1, Tests 5 failed | 7 passed (12). The new write pin reds on the batch door only (expected [ { at: '2025-07-28T08:00:00+08:00' } ] to deeply equal [ { at: '2025-07-28T00:00:00.000Z' } ]). The other four failures are the batch-door cases from the first run: expected 1 to be true, expected null to be 'id', expected undefined to deeply equal [ 'flag' ], and expected "backfillRemoteCanonicalTemporal" to be called 1 times, but got 0 times. The syncSchema and initObjects controls (including their write pins) and the DDL-failure case stayed green.
  • Restore: the blob matches HEAD (b33d3987b0bc) and git diff HEAD is empty; the restored file is green in the full-suite run below.
  • No dist/ is involved: the suite imports ./turso-driver.js from source.

Verification (HEAD d8940d7)

Re-run on de31187a96 after the patch round: pnpm --filter @objectstack/driver-turso test exited 0 (57 files, 1311 tests), typecheck exited 0, node scripts/check-issue-citations.mjs exited 0, node scripts/check-changeset-no-major.mjs --base origin/main exited 0, and pnpm check:driver-conformance exited 0. The --commands derivation was byte-identical (61 commands), and --ran read 59 run and 2 NOT-MEASURED, as below.

  • pnpm --filter @objectstack/driver-turso test: exit 0, 57 files and 1308 tests passed.
  • pnpm --filter @objectstack/driver-turso typecheck: exit 0. The package's tsconfig includes src/**/*, so the tests are type-checked too.
  • node scripts/pm/dispatch-gates.mjs --commands (no paths) derived 61 commands. All 61 ran, each exit code captured before any pipe. The --ran verdict exited 0: 61 derived famil(ies) accounted for — 59 run, 2 NOT-MEASURED.
    • NOT MEASURED: pnpm check:dual-build-cjs-loads and pnpm check:type-check-debt both exited 3 (PREREQUISITE NOT MET), because each needs the whole-workspace build. CI runs both over the full build. Declared narrowing for the first: require of the rebuilt packages/drivers/driver-turso/dist/index.js loads (14 exports, TursoDriver a function), exit 0. For the second: driver-turso has no DEBT or TEST_DEBT entry, and its own tsc --noEmit is clean.
    • Gates the dispatch named, all green in that run: pnpm check:driver-conformance, pnpm check:object-def-param-keys, pnpm check:issue-citations and pnpm check:nul-bytes, each exit 0.
  • node scripts/check-issue-citations.mjs, the live diff-scoped verdict: exit 0, with 3 citations judged and all 3 resolving.
  • Lint, narrowed: eslint --no-inline-config --format json over the two changed .ts files reported 2 files, 0 errors, 0 warnings. The changeset .md is outside eslint's configured population ("no matching configuration"). eslint.config.mjs enables no type-aware linting (no parserOptions.project), so this diff cannot change the verdict on any untouched file. The full pnpm lint is CI's.

Changeset

.changeset/19844-turso-remote-boot-read-coercion.md, @objectstack/driver-turso: patch. It was rewritten in the patch round after the first contract review (FAIL on prose, comment 5794937369). Every claim was re-measured on the SQLite double, including what the pre-fix door wrote and which of those cells the backfill does and does not converge.

Acceptance notes

  • The boot measurement is not kept as a test. @objectstack/objectql is not a dependency of @objectstack/driver-turso, and adding one (with its lockfile change) for a single test is outside this card's file surface. The door-level cases make the same call the boot's batch branch makes.
  • The header table in turso-remote-deferred-ddl.test.ts records the syncSchemasBatch row of prediction (b) as "REFUTED, no row write on this door". It was measured before that refusal existed, so it stays historically true; the patch round adds a one-clause footnote saying the door now runs the backfill too.
  • A behaviour change for review: an ordinary remote boot now runs the canonical temporal backfill, which the batch door never ran before. On a deployment that holds legacy datetime or time text, the first boot after upgrading rewrites those cells into the canonical spelling of the same value, as syncSchema and initObjects already did. The changeset says so, and it names the two kinds of cells the pre-fix door wrote unconverted that no remote backfill converges (a date stored as a full timestamp; a scalar json stored unencoded).

Generated by Claude Code

…nd runs the canonical backfill

The remote arm of TursoDriver.syncSchemasBatch -- the door ObjectQLPlugin's
boot sync takes on this driver -- returned straight after its DDL, while the
syncSchema and initObjects remote arms went on to register the read-coercion
registries (and record the table as driver-created) and run the canonical
temporal backfill. A booted remote app read booleans back as 1, JSON as a
string, got no id tie-breaker on paged reads and never converged its temporal
columns.

All three remote doors now finish through one private helper,
completeRemoteSchemaSync: register each object keyed by the object string,
then run the backfill once for the call. DDL failures still reject before
any registration.

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

github-actions Bot commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

4 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), syncSchema (symbol, a method of class TursoDriver), syncSchemasBatch (symbol, a method of class TursoDriver))
  • content/docs/plugins/packages.mdx (via TursoDriver (symbol, a top-level class))
  • content/docs/protocol/kernel/lifecycle.mdx (via syncSchema (symbol, a method of class TursoDriver))
  • content/docs/protocol/objectql/types.mdx (via syncSchema (symbol, a method of class TursoDriver))

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

  • content/docs/releases/implementation-status.mdx (via syncSchema (symbol, a method of class TursoDriver))

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
  • 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 2bbb462335ad617f51ba7fc1a0c872f932f47513 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 2bbb462335ad617f51ba7fc1a0c872f932f47513

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

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 23, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: d8940d7ee483fa877c6f9ac9c165953fb92f8056

① Derived judgments

Head = PR head; merge-base 8cbc3c0084; the diff is exactly the three named files (+244/−24). The code is correct: all three remote doors await their transport DDL and then call the private completeRemoteSchemaSync (per-object registerRemoteFieldMetadata, then one backfillRemoteCanonicalTemporalQuietly()). The boot's batch branch (ObjectQLPlugin, supports.batchSchemaSync: true) is confirmed as the door. The new test's claims match the code. The changeset carries statements that are not true at the head:

  • (a) "a guard such as field != true was always true" holds only for a CEL comparison or a matchesFilter $ne: true over the RAW row. The engine coerces declared booleans (coerceBooleanFields) before hook and flow contexts are built, and JS 1 != true is false. Unqualified, it is not true on the path a reader would assume.
  • (b) TRUE: the JSON and id tie-breaker bullets; the Paged read of '…' is NOT deterministic warn exists verbatim in sql-driver.ts.
  • (c) FALSE: "their filters stayed on the slower read-side repair expression". needsLegacyDatetimeRepair / needsLegacyTimeRepair are false unless the field is in datetimeFields / timeFields, which the batch door never filled. So remote temporal filters compiled to the plain column, with no repair and no comparand canonicalization, and values read back raw: fast and wrong against legacy-shaped rows, not slow.
  • (d) FALSE as worded: "Values already on disk were stored correctly". formatInput canonicalizes datetime / time on write through the same registries, so with them empty a non-canonical string or a number was written as sent. JSON and Date objects were unaffected (transport serializeValue).
  • (e) TRUE: local and embedded-replica arms are unchanged.
  • (f) Under-inclusive: detectMode classifies libsql://, https://, http://, wss:// and ws:// without syncUrl as remote; the changeset lists three.
  • (g) TRUE for the CREATE/ALTER batch (pinned). Index-retrofit failures are caught and reported, not rejected (pre-existing, correct).
  • (h) Holds: initObjects already returned early on an empty list on main; no caller loses a backfill.

② Semver level

@objectstack/driver-turso: patch with Clause-②: no is correct. The helper is private; the three doors keep their signatures; no accept set widens. The added boot-time write is the #5770 backfill the other two doors already ran, extended to the door that wrongly skipped it: a bug fix in a released package.

③ Boundary flags

  1. Changeset prose (① a, c, d, f): MUST FIX BEFORE LANDING, prose only in .changeset/19844-turso-remote-boot-read-coercion.md.
  2. turso-remote-deferred-ddl.test.ts header table, syncSchemasBatch row "REFUTED — no row write on this door": ACCEPTABLE (captioned as measured before the refusal existed); an optional footnote may ride this PR.
  3. Sibling driver-turso remote detectManagedDrift() diffs against the dummy :memory: Knex connection remote mode is given — drift is always empty, so the artifact boot migration gate reads a remote Turso database as never drifted #19845 (remote detectManagedDrift): verified NOT fixed here. Acceptable.
  4. Write-side coercion skipped by the same missing registration (① d): closed by the fix, but unpinned. A pin may ride the fix round.
  5. Index-retrofit failures do not reject: ACCEPTABLE, pre-existing.
  6. The boot-level measurement is not kept as a test (@objectstack/objectql is not a driver-turso dependency): ACCEPTABLE.
  7. origin/main moved one unrelated spec commit: ACCEPTABLE; CI validates the merge ref.

Implemented-by: claude/issue-19844-turso-batch-door-registration
Reviewed-by: session_01TEhopqrWQYBycZzyJHpAZr

VERDICT: FAIL: ① does not hold. The changeset's temporal bullet and its "stored correctly" sentence are false at head, the remote-scheme list omits http:// and ws://, and the guard sentence is true only over the raw row. A prose-only patch round is in flight; a fresh review runs on its head.


Generated by Claude Code

… reads, writes and filters; pin the write side

The changeset now says what a remote app actually saw before the fix: reads
returned stored forms, writes of datetime/time/date/scalar-json values were
not converted, datetime/time filters compared text as spelled, and paged
reads had no tie-breaker. It says the one write added is the canonical
datetime/time backfill, and names the two kinds of cells this door wrote
that nothing rewrites (full-timestamp date cells, unencoded scalar json).
It lists all five remote URL schemes and scopes the boolean guard sentence
to CEL / in-memory $ne over a raw record.

A write-side pin joins the per-door cases: a datetime written with an offset
reaches disk in the canonical spelling. The deferred-DDL suite's header table
notes that the batch door now runs the backfill.

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

① Derived judgments

Head = PR head = branch tip; merge-base 8cbc3c0084; exactly four files (+270/−25). Check runs on this head, latest per name: 34 names, 29 success, 5 skipped, 0 failed. The code is as before: all three remote doors await their DDL and then call the private completeRemoteSchemaSync; the batch door keys by object; the local and replica arms are untouched. The earlier record's defects (a, c, d, f; comment 5794937369) are gone.

  • "Remote" definition: TRUE (detectMode: explicit mode, else libsql://, https://, http://, wss://, ws:// without syncUrl).
  • Reads came back as stored: TRUE for declared fields; driver and engine find / findOne return the rows as read. CEL: MEASURED with @marcbachmann/cel-js 8.0.0, field != true over {field: 1} is true; in-memory $ne uses ===. Hook-context exception: FALSE as written. It holds for the write-side contexts only; the afterFind context built by find and findOne is hookContext.result = result, the rows exactly as the driver returned them, so on the pre-fix boot door an afterFind hook saw flag: 1. (Nit: the audit columns created_at / updated_at were always presented canonical.)
  • Writes were not converted: TRUE in every sub-claim, including the Date → datetime exception, date full timestamps and unencoded scalar json.
  • Filters compared text as spelled: TRUE, including the $lte: '2025-07-28' example.
  • Paging: TRUE; the warn text exists verbatim.
  • "Now finishes the way the other two doors do": TRUE.
  • First boot after upgrade: TRUE. The backfill probes only datetimeFields / timeFields, leaves unparseable text alone, and has no date or json leg, so both residue bullets are TRUE.
  • "Local and embedded-replica deployments are unaffected": TRUE.
  • New test claims and the deferred-DDL footnote: TRUE.

② Semver level

@objectstack/driver-turso: patch with Clause-②: no is correct: a private helper, unchanged signatures, no export, option or accepted input added; the behaviour change is the fix's repair of a defect in a released package.

③ Boundary flags

  1. The hook-context sentence: MUST FIX BEFORE LANDING, prose only; scope it to write-side contexts and say an afterFind hook saw the rows as read.
  2. The dev's class (a) residue (date full timestamps and unencoded scalar json written by the pre-fix door; no remote counterpart to backfillCanonicalJsonEncoding, and no date leg): REAL, verified. FILE SEPARATELY: it is a new migration mechanism with its own safety gates, and this changeset already discloses both residues.
  3. Sibling driver-turso remote detectManagedDrift() diffs against the dummy :memory: Knex connection remote mode is given — drift is always empty, so the artifact boot migration gate reads a remote Turso database as never drifted #19845: untouched. ACCEPTABLE.
  4. PR body staleness: corrected by the seat. ACCEPTABLE.
  5. Deferred-DDL header row plus footnote: ACCEPTABLE.
  6. Registry keying by the plugin's resolved table name: pre-existing, the same keying syncSchema used. ACCEPTABLE.
  7. The boot-level measurement is not kept as a test: ACCEPTABLE.
  8. Index-retrofit failures reported, not rejected: pre-existing by design. ACCEPTABLE.

Implemented-by: claude/issue-19844-turso-batch-door-registration
Reviewed-by: session_01TEhopqrWQYBycZzyJHpAZr

VERDICT: FAIL: one sentence. "Hook contexts were not affected" is false for afterFind. The minimal fix is a one-sentence prose edit; a patch round is in flight.


Generated by Claude Code

…ontexts and names the afterFind exception

engine.find / findOne hand an afterFind hook the rows as the driver returned
them, with no boolean conversion, so on the pre-fix batch door an afterFind
hook saw 1/0. Only the write-side contexts are converted. The changeset now
says that, and notes that the created_at / updated_at audit columns were
always presented canonical.

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

① Derived judgments

Head = PR head = branch tip; merge-base 8cbc3c0084; four files vs main (+270/−25). Delta PROVEN from de31187a96: git diff --stat is exactly .changeset/19844-turso-remote-boot-read-coercion.md | 2 +-, so the code judgments of record 5795600998 carry. Two of its load-bearing claims were re-checked at this head before carrying it: (a) SqlDriver.formatInput runs its datetime, date, time and json legs only on non-empty datetimeFields / dateFields / timeFields / jsonFields, filled solely by registerExternalObject, and the remote write door reaches formatInput through toRemoteWriteForms; (b) backfillRemoteCanonicalTemporal builds its columns from datetimeFields and timeFields only. Both confirmed. Check runs on this head, latest per name: 34 names, 31 success, 3 skipped, 0 failed.

  • "including the rows an afterFind hook receives": TRUE. engine.find / findOne go driver → applyFormulaPlan → expandRelatedRecords (only with expand) → resolveFileReferences → hookContext.result; no conversion sits between. The prior FAIL is repaired.
  • "Write-side hook contexts did see true/false … the afterInsert / afterUpdate results and the previous record on update and delete hooks": TRUE and complete. Every coerceBooleanFields call site was enumerated (afterInsert result; afterUpdate result and previous; by-id beforeUpdate previous; bindPreImage for beforeDelete / afterDelete; the per-row after and before contexts), and no previous assignment escapes them.
  • "The created_at / updated_at audit columns were the exception: they were always presented canonical": FALSE as an absolute. presentAuditTimestampOutput runs unconditionally, but it folds only a Date and a zone-naive YYYY-MM-DD HH:MM:SS[.fff] string, and returns any other shape unchanged. The claim holds for every audit value the platform writes (the datetime('now') column default, the engine's toISOString() stamps, a bound Date). It does not hold for a caller-supplied audit value in another spelling: an engine write under preserveAudit (the documented historical-import path), an isSystem context, or a direct driver write. On the pre-fix door such a value was stored as sent and read back as stored. After the fix those columns are in datetimeFields, so they are converted and backfilled like any other datetime. "Always" tells an operator holding imported audit stamps that nothing changes for them, and both their reads and their disk change.

② Semver level

@objectstack/driver-turso: patch with Clause-②: no is correct: one private method, three existing doors routed through it, no export, signature, option or accepted input added; a defect repair in a released package.

③ Boundary flags

  1. The audit-column clause: MUST FIX BEFORE LANDING, prose only. Scope it to the values the platform writes, and say that a caller-supplied value in another spelling (as a preserveAudit import sends) read back as stored.
  2. The class (a) residue: REAL, verified again at this head; now filed as driver-turso remote: cells the pre-#19844 boot door wrote unconverted (a date as a full timestamp, a scalar json unencoded) are converged by no remote backfill, so a stored true reads back as 1 #19868; the changeset discloses both residues. ACCEPTABLE.
  3. Sibling driver-turso remote detectManagedDrift() diffs against the dummy :memory: Knex connection remote mode is given — drift is always empty, so the artifact boot migration gate reads a remote Turso database as never drifted #19845: untouched, still open. ACCEPTABLE.
  4. PR body: current for this head. ACCEPTABLE.
  5. Deferred-DDL header row plus footnote: ACCEPTABLE.
  6. Registry keying by the plugin's resolved table name: pre-existing. ACCEPTABLE.
  7. Boot-level measurement not kept as a test: ACCEPTABLE.
  8. Index-retrofit failures reported, not rejected: pre-existing by design. ACCEPTABLE.
  9. Governed surfaces: none. Not tiered.

Implemented-by: claude/issue-19844-turso-batch-door-registration
Reviewed-by: session_01TEhopqrWQYBycZzyJHpAZr

VERDICT: FAIL: one clause. "they were always presented canonical" is false for a caller-supplied non-canonical audit value. The minimal fix is a one-clause prose edit, in flight. (The clause was added on the seat's own optional suggestion in the previous round.)


Generated by Claude Code

…tform-written values

presentAuditTimestampOutput folds only a Date and a zone-naive timestamp
string, so on the pre-fix batch door only the values the platform writes
itself (the column default, the engine's stamps, a Date) read back
canonical. An audit value supplied in another spelling, such as an offset
string a preserveAudit import can send, read back as stored.

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: 153e8776f995d01694378c688b0e6682996afe17

① Derived judgments

Head = PR head = branch tip; merge-base 8cbc3c0084; four files vs main (+270/−25). Delta PROVEN from e220c27153: one commit; git diff --stat is exactly .changeset/19844-turso-remote-boot-read-coercion.md | 2 +-, and the word diff is confined to the audit-column clause, so the code judgments of records 5795600998 and 5795854049 carry. Re-checked at this head before carrying: the coerceBooleanFields call-site enumeration in packages/objectql/src/engine.ts. All nine call sites were read in context, and every .previous = assignment and previous: literal goes through the coercion. Check runs on this head, latest per name: 34 names, 31 success, 3 skipped, 0 failed, 0 pending.

  • "The values the platform writes there itself (the column default, the engine's stamps, a Date) read back canonical": TRUE. presentAuditTimestampOutput runs on every created_at / updated_at cell regardless of registration. The remote DDL default datetime('now') is zone-naive and is folded by repairNaiveUtcAuditTimestamp; the engine's new Date().toISOString() stamp is canonical; a Date is bound as toISOString().
  • "as a preserveAudit import can send": TRUE on the update path (stripReadonlyFields keeps the audit family under preserveAudit).
  • "an audit value supplied in another spelling … read back as stored": FALSE as a universal. It holds for an offset-bearing spelling and any unrecognised shape. It does not hold for a zone-naive YYYY-MM-DD[ T]HH:MM:SS[.fff] spelling: the same repair that folds the column default folds a supplied zone-naive value, reading it as UTC. The presenter discriminates by SHAPE, not by who wrote the value. The writes bullet names "a zone-naive wall clock" among the non-canonical spellings, and that is the typical historical-import shape.
  • Consistency with the writes bullet and the first-boot paragraph: holds.

② Semver level

@objectstack/driver-turso: patch with Clause-②: no is correct and unchanged; the code delta since the prior record is nil.

③ Boundary flags

  1. The audit-column clause: MUST FIX BEFORE LANDING, prose only. State the fold by shape: a zone-naive wall clock folds as UTC like the column default, and an offset-bearing (or otherwise unrecognised) value reads back as stored.
  2. The class (a) residue: filed as driver-turso remote: cells the pre-#19844 boot door wrote unconverted (a date as a full timestamp, a scalar json unencoded) are converged by no remote backfill, so a stored true reads back as 1 #19868; the changeset discloses both residues. ACCEPTABLE.
  3. Sibling driver-turso remote detectManagedDrift() diffs against the dummy :memory: Knex connection remote mode is given — drift is always empty, so the artifact boot migration gate reads a remote Turso database as never drifted #19845: open, untouched. ACCEPTABLE.
  4. PR body: its verification dates predate two one-line changeset edits that alter no verified claim. ACCEPTABLE.
    5–8. Deferred-DDL footnote; registry keying by the resolved table name; the boot measurement not kept as a test; index-retrofit failures reported, not rejected: ACCEPTABLE, as before.
  5. Governed surfaces: none. Not tiered.

Implemented-by: claude/issue-19844-turso-batch-door-registration
Reviewed-by: session_01TEhopqrWQYBycZzyJHpAZr

VERDICT: FAIL: one clause. "an audit value supplied in another spelling … read back as stored" is false for a supplied zone-naive wall clock. The wording that failed was the previous record's prescription, which described the fold by source; the fix states it by shape, derived from repairNaiveUtcAuditTimestamp itself.


Generated by Claude Code

…l shape

presentAuditTimestampOutput decides by the cell's shape, not by who wrote
it: repairNaiveUtcAuditTimestamp reads a zone-naive YYYY-MM-DD[ T]HH:MM:SS
with an optional fraction as UTC and returns it canonical, and returns every
other cell unchanged (Z- or offset-terminated strings, epoch numbers or
text, unparseable values). The changeset's audit exception now says exactly
that, in one sentence.

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

① Derived judgments

Head = PR head = branch tip; merge-base with origin/main is 8cbc3c0084; four files vs main (+270/−25). Delta PROVEN from 153e8776f9: one commit; git diff --stat is exactly .changeset/19844-turso-remote-boot-read-coercion.md | 2 +-, and the word diff is confined to the audit-column clause, so the code judgments of records 5795600998, 5795854049 and 5796135095 carry. Spot-checked at this head before carrying: SqlDriver.formatOutput runs presentAuditTimestampOutput over AUDIT_TIMESTAMP_COLUMNS unconditionally, ahead of every registry-gated leg, and the remote find / findOne reach it through formatRemoteRow(s). Also re-read: all three remote doors await their DDL and then call the private completeRemoteSchemaSync, and backfillRemoteCanonicalTemporal builds its columns from datetimeFields / timeFields only.

The new audit-column sentence, judged against repairNaiveUtcAuditTimestamp and presentAuditTimestampOutput read at this head and exercised through a scratch copy of the two functions (28 cells), is TRUE as a consumer would read it:

  • A zone-naive YYYY-MM-DD HH:MM:SS / YYYY-MM-DDTHH:MM:SS cell, with or without a fraction, matches ^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2}(?:\.\d+)?)$ and is re-emitted as UTC by toISOString(): read as UTC, read back canonical.
  • "the column default writes the first shape": the remote DDL declares both columns TEXT DEFAULT (datetime('now')), which yields the space-separated, fraction-free UTC wall clock.
  • Cells ending in Z or in an offset (+08:00, and the colon-less +0800) are returned unchanged, canonical or not.
  • Epoch numbers and other non-strings pass the typeof guard unchanged; epoch text matches no regex.
  • "anything that does not parse as a date": a shape match that Date rejects returns the stored value; other spellings miss the shape regex.
  • Two unnamed edges mislead no reader: whitespace-padded naive cells also fold (the test runs on the trimmed string), and V8 rolls an out-of-range day inside a matching shape.
  • Consistent with the preceding sentence (the carve-out is by shape, as in the code), the writes bullet (stored naive, read back canonical), and the first-boot paragraph.

② Semver level

@objectstack/driver-turso: patch with Clause-②: no is correct and unchanged: one private async completeRemoteSchemaSync, unchanged door signatures, no export, option or accepted input added; a defect repair in a released package.

③ Boundary flags

  1. The audit-column clause: REPAIRED, stated by shape as record 5796135095 prescribed. ACCEPTABLE.
  2. The class (a) residue: filed as driver-turso remote: cells the pre-#19844 boot door wrote unconverted (a date as a full timestamp, a scalar json unencoded) are converged by no remote backfill, so a stored true reads back as 1 #19868; the changeset discloses both residues. ACCEPTABLE.
  3. Sibling driver-turso remote detectManagedDrift() diffs against the dummy :memory: Knex connection remote mode is given — drift is always empty, so the artifact boot migration gate reads a remote Turso database as never drifted #19845: open, untouched. ACCEPTABLE.
  4. PR body: its verification dates predate three one-line changeset edits that alter no verified claim. ACCEPTABLE.
    5–8. Deferred-DDL footnote; registry keying by the resolved table name; boot measurement not kept as a test; index-retrofit failures reported, not rejected: ACCEPTABLE, as before.
  5. Governed surfaces: none. Not tiered.
  6. Three checks were still in progress at review time (Test Core (1/6), Dogfood Regression Gate (2/3), Lint & Repo Gates), none red; the seat waits for them before arming anything.

Implemented-by: claude/issue-19844-turso-batch-door-registration
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 14:12
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 23, 2026
Merged via the queue into main with commit 1f89ba0 Sep 23, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-19844-turso-batch-door-registration branch September 23, 2026 14:32
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…swering "no drift" (objectstack-ai#19891)

Fixes objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 `registerRemoteFieldMetadata`
→ `registerExternalObject` 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](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