Skip to content

fix(driver-turso): refuse transactions on the remote face instead of silently dropping them - #18717

Merged
huangyiirene merged 4 commits into
mainfrom
claude/issue-18616-turso-remote-transaction-refusal
Sep 17, 2026
Merged

huangyiirene merged 4 commits into
mainfrom
claude/issue-18616-turso-remote-transaction-refusal

Conversation

@huangyiirene

Copy link
Copy Markdown
Collaborator

Fixes #18616

Clause-②: yes

The Turso remote face now refuses transactions with NOT_IMPLEMENTED / 501 instead of accepting them and doing nothing with them. Local and embedded-replica modes are byte-for-byte unchanged in behaviour.

What was wrong

@objectstack/spec's driver.zod.ts states the delivery mechanism verbatim:

A transaction handle to be passed to subsequent operations via options.transaction.

On the remote transport nothing could receive it. ⭐ Every reading below was re-derived by content on this branch's base f8eaf670454a, which contains ef67b47afb (PR #18668) — that PR edited remote-transport.ts after the card was written, so the card's line numbers no longer locate anything:

reading instrument result
RemoteTransport members naming a transaction signature scan of remote-transport.ts 3 — beginTransaction(), commit(t), rollback(t)
RemoteTransport data methods accepting options or a transaction same file, same instrument 0
⭐ firing control — data methods present at all in that file same file 13 (find, findOne, aggregate, create, update, upsert, delete, count, bulkCreate, bulkUpdate, bulkDelete, updateMany, deleteMany) ⇒ the zero is a reading, not a dead grep
this.isRemote guards in turso-driver.ts git grep -c 26 — matches the card

⚠️ The card's reading said 9 data methods; re-derived by content it is 13. The difference is in the instrument, not in the finding — the zero above and its firing control both stand, and the wider control makes the zero stronger, not weaker.

So a write issued between beginTransaction() and rollback() executed on the plain connection, was already durable, and the rollback resolved having undone nothing. Every step reported success. The local-mode pin in turso-driver.test.ts ("should support transactions with rollback") already asserted the correct shape; the remote face was the unpinned one.

The line triage drew, and this PR's radius

⛔ The deliverable is stop the silence, not implement remote transactions. Triage's ruling is quoted rather than paraphrased, because the boundary is the deliverable:

止损(本卡):remote 模式遇到 options.transaction 或 beginTransaction() 时大声拒绝 / 声明不支持,让调用者立刻知道自己没有事务语义。
实现远程事务:那是 #18116 已完成的那次「measure, do not implement」量出来的半径,是另一件事、另一个量级。⛔ 不要把它折进本卡。

Measured radius of the half NOT taken, reported rather than spent: implementing remote transactions means giving RemoteTransport's 13 data methods an options parameter, threading a libsql Transaction through each statement builder, and deciding what connect()'s knex-free remote arm does about SqlDriver's openTransactions accounting. That is a different order of change and it is not in this PR.

The judgement the dispatch left to me: which door, and why not only one

The dispatch named three candidates and asked which callers can reach a remote data method with a handle in hand. Measured, there are two independent producers, so the answer is both doors:

  1. driver.beginTransaction() — every in-repo producer of a handle is one of these: engine.transaction(), ScopedContext.beginTransaction(), the sandbox trio. Closing this door closes all of them, and it closes them before the callback writes anything.
  2. ExecutionContext.transaction, threaded by the caller. buildDriverOptions in packages/objectql/src/engine.ts reads it first — its own comment: "Explicit wins; ambient is the safety net" — and ExecutionContext.transaction is a declared member of the envelope in packages/spec/src/kernel/execution-context.zod.ts. Such a handle never passes through (1). The same-origin gate does not stop it either: transactionCoversDriverFor attributes a handle only when it is the ambient store's handle, and for anything else it declines to judge and returns true — its own recorded limit.

⇒ Door (1) alone is the false floor the dispatch warned about, and this is measured, not argued — see the second ablation below, where keeping only the beginTransaction() refusal leaves all 17 options.transaction cases green while silently dropping the handle.

commit() / rollback() refuse on the remote arm too. With beginTransaction() refusing, this driver issues no remote handle at all, so the only way to reach them is with a handle from somewhere else — and rollback() accepting one is the precise silence the card is named for: it resolves, reports a successful undo, and undoes nothing. Both engine faces call commit/rollback only with a handle their own beginTransaction() returned, which now throws first, so no in-repo caller can be stranded with an unrolled-back transaction by this refusal.

Where the refusal lives, and why not one layer down

On TursoDriver, not inside RemoteTransport — the same layering argument the sibling auto_number refusal already records in this file. The transport's data methods have no options parameter at all, so the handle is already gone by the time a statement is built. This class is the last layer that still holds one.

Why NOT_IMPLEMENTED / 501, and why no packages/spec edit

The request is spelled correctly and the spec declares these members, so the gap is the backend's — the same two-class taxonomy this package already applies to remote auto_number, aggregate functions and date buckets. ⭐ No new error code is needed and packages/spec is untouched: NOT_IMPLEMENTED is a StandardErrorCode member, and the error-code ledger registers extension codes only, so nothing has to be added to it. The precedent is eleven hundred lines up in the same file.

What this deliberately does not do

  • It does not implement transactions on the remote transport.
  • It does not remove RemoteTransport's own beginTransaction / commit / rollback, which stay reachable through getRemoteTransport() and are pinned by an existing declared-types suite.
  • It does not touch driver-sql, the engine, or the spec.

Verification

All commands run from this branch; exit codes captured before any pipe.

  • pnpm --filter @objectstack/driver-turso test — Test Files 54 passed (54), Tests 1278 passed (1278). The pre-existing local-mode transaction pins are inside that green.
  • pnpm --filter @objectstack/driver-turso typecheck — exit 0.
  • pnpm --filter '@objectstack/driver-turso^...' build — VERDICT command-exit 0 (dependency closure).
  • Full package build — pnpm exec turbo run build --filter=./packages/* --filter=./packages/*/* — Tasks: 72 successful, 72 total.
  • Gate families — derived with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (⛔ not from a hand-written diff): 60 families, 60 run, 0 NOT MEASURED, 0 UNRUN, reconciled back through --ran with an exit code recorded per family. One was a real red and is fixed in this PR: check:doc-authoring refused the tracker id inside the refusal's runtime string (maintainer ruling 「处理 issue 时犯的错应该总结成经验,保留 issue id没有意义」) — the id moved to the docblock. Three more were PREREQUISITE NOT MET (exit 3) until the full build above, then green: check:dual-build-cjs-loads, check:lean-entry-closure, check:type-check-debt.
  • pnpm lint — the repo-wide eslint . --no-inline-config union, exit 0, run at final commit 97009bc42e. ⭐ This is the union, not a narrowed run, so no narrowing argument is owed.

Reverse verification — two ablations, directions predicted before either was run

Both ran from the committed fix, mutated on disk with the mutation proved by grep counts and a changed blob hash, and restored under a trap that verifies the restored blob equals the HEAD blob and that git diff HEAD is empty. Subject blob at HEAD: 4b2b21a0da4e.

① Both doors removed (the options guard emptied, the trio restored to its pre-fix delegation). Predicted: the two REMOTE blocks go red, the silence controls stay green. Measured 21 failed | 5 passed (26) — 4 trio cases plus 17 options.transaction cases red, the 5 controls green. The failures reprint the defect rather than merely reporting an absence: expected the remote face to refuse, but it resolved with ..., carrying the row that landed.

② Only the options.transaction door removed, the beginTransaction / commit / rollback refusals kept — i.e. exactly the shape of a fix that closed candidate (1) alone. Predicted: the 17 options cases red, the 4 trio cases and the 5 controls green. Measured 17 failed | 9 passed (26). ⭐ That is the false floor, measured: a PR that had stopped at (1) would have shown a green trio block and shipped the silent drop.

Restore verified on both legs: RESTORE OK — on-disk blob 4b2b21a0da4e... == HEAD blob 4b2b21a0da4e..., and git diff HEAD is empty.

Changeset, measured rather than assumed

skip-changeset is wrong here and it was measured, not taken from the dispatch. @objectstack/driver-turso publishes and its files[] is dist, README.md, CHANGELOG.md. After building the package, grepping those paths for a symbol unique to this change gives dist/index.js:1, dist/index.mjs:1; the positive control (a pre-existing shipped refusal sentence) hits dist/index.js:2, dist/index.mjs:2, CHANGELOG.md:2; the negative control (a test-only helper name) hits 0 anywhere in files[]. ⇒ it publishes ⇒ a changeset is owed, and it is graded minor to satisfy the clause-② level axis while staying inside the launch-window ban on major.

Acceptance notes

Observations found while measuring, ⛔ not filed and ⛔ not fixed here.

Neither is a class (a) reproducible defect, a class (b) declared-contract violation, or a class (c) metadata-authoring trap, so neither is filed.


Generated by Claude Code

…dropping them

The Turso REMOTE transport has no transaction semantics: `RemoteTransport`
names a transaction in exactly three members and zero of its data methods take
an `options` argument, so `options.transaction` had nowhere to land. A write
issued between `beginTransaction()` and `rollback()` executed on the plain
connection, was already durable, and the rollback resolved without undoing it.

Both doors now refuse with NOT_IMPLEMENTED/501 on the remote arm:

- `beginTransaction()` / `commit()` / `rollback()`, so the capability is never
  handed out in the first place;
- every remote-arm override that receives `options.transaction`, because the
  engine's `buildDriverOptions` reads `execCtx.transaction` first and a handle
  threaded that way never passes through `beginTransaction()`.

Local and embedded-replica modes are untouched: the guard returns immediately
unless `isRemote`.

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

check:doc-authoring — a runtime string reaches authors and operators, who have
no tracker to resolve an id against. The card id stays in the docblock.

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

23 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json f6189a43f9ba09516f142fd7aca8ae15583d6c9d.

⛔ 7 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 8 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

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 f6189a43f9ba09516f142fd7aca8ae15583d6c9d → packageMentionDocs.

Which tree this was computed on

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

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

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

Copy link
Copy Markdown
Collaborator Author

needs:contract-review cleared from both carriers — provenance for the stroke, written 2026-09-17T17:13Z.

contract-review record card #18616, comment 5717918594 — ## Contract review, Served-tier: present, VERDICT: PASS
head judged by that record 97009bc42ec8e532a22fa878b748e91dcc00e2ad — the head this PR still carries, ⛔ not a predecessor
carriers card #18616 cleared · PR #18717 cleared, one stroke, label-write.mjs with read-back on each

Pre-landing checks, all three re-taken at this stroke — ⛔ none carried over.

  • ① the in-seat clause-② review is in case, in the template's shape, at the tier the fuse reads.
  • ② check-clause2-carriers.mjs --pair 18717 → exit 0, and it reports that a review of record names this head.
  • ③ CI on 97009bc42e: 34 distinct names, 0 pending, 0 non-green; all seven required contexts green. The five skips — Auto Label · Check PR Size · Packed-tarball smoke (opt-in) · Console Pin Gate · Build Docs — are each on check-expected-skips' roster with a declared reason this diff satisfies. ⭐ Note what is not skipped: Check Changeset ran, because this PR carries no skip-changeset label — the opt-out that would have switched that gate off is absent, so the changeset was judged by the gate rather than by the seat alone.

Governed-surface predicate, derived by the script from this PR's own file list rather than one typed by hand: check-governed-merges.mjs --pr 18717 → 0 of 3 paths hit the register ⇒ ordinary queue landing.

⚠️ ⛔ One thing this provenance records against the seat, not the dev. The gate was never hung at the claim stroke; the dev found and reported it as row C3 (--pair exit 4, 「the event stream shows the gate was NEVER HUNG on either carrier」) and correctly refused to hang it itself. The seat hung both carriers before writing the review. ⇒ The clean exit 0 above is the repaired state, ⛔ not evidence the stroke was right the first time.

⇒ Going ready and into the merge queue (SQUASH). This seat follows it to MERGED.


Generated by Claude Code

@huangyiirene
huangyiirene marked this pull request as ready for review September 17, 2026 17:13
@huangyiirene
huangyiirene added this pull request to the merge queue Sep 17, 2026
Merged via the queue into main with commit 62bce5c Sep 17, 2026
43 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-18616-turso-remote-transaction-refusal branch September 17, 2026 17:32
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…objectstack-ai#18736)

Fixes objectstack-ai#18318

Clause-②: yes

Execution of the maintainer ruling on batch objectstack-ai#148 item 1, **letter B**
(comment 5716041368, 「同意」): `EvalContext` declared a kernel query API —
`api?: { exists, count, lookup }`, docblocked as `os.exists / os.count /
os.lookup`, "implemented opportunistically by call sites that have a
query engine" — and `buildScope()` never read it. The declaration is
removed, with the reason written at the deletion site. No shim, no
alias, no reserved spelling; the three functions are **not** implemented
here, and the real capability need (reading a related record's field
inside a predicate) is tracked on its own card, objectstack-ai#18682, which nothing in
this PR advances or blocks.

## The premise, re-measured on the branch base (df1b275)

| reading | value | control |
|:---|:---|:---|
| `ctx.api` / `.api` in `packages/formula/src` | **0** hits | `ctx.user`
in `stdlib.ts`: **3** hits |
| `os.exists` / `os.count` / `os.lookup` in the tree, CHANGELOGs and
`dist/` excluded | **1** hit — the docblock being deleted | the same
grep finds the three names nowhere else, so no stdlib entry, validator
row, doc page or skill file has to follow |

So the card's reading holds exactly: the member was declared,
documented, and bound nowhere.

## The LEVEL is `minor`, and that is a reading rather than an assumption

The question the grade turns on: **can a consumer set `api` today and
get anything out of it?** No.

- `buildScope()` binds `record`, `previous`, `input`, `os.user` /
`os.org` / `os.env`, and `extra` — it has never read `ctx.api`, so every
implementation ever passed there was discarded before evaluation.
- A predicate calling `os.lookup` fails whether or not the call site
supplies `api` — pinned as a test in this PR.
- The whole of the break is therefore a **compile** error on an
`EvalContext` literal that carries `api`, and the sweep below finds zero
such literals in this repo and zero in the pinned sibling.

This is the shape PR objectstack-ai#18717 graded `minor` in the same round — what
changed never delivered the semantics it reported, so depending on it
meant depending on nothing. The launch-window `major` ban
(`scripts/check-changeset-no-major.mjs`) also applies and is recorded
here as a **constraint, not the justification**. The changeset is
`@objectstack/formula: minor` and carries the migration line it owes:
`api: { … }` → delete the property; there is no replacement key and no
result changes.

## Consumer sweep — derived by TYPE, with a firing control

⛔ Not by text. A text grep is structurally useless here: the
identically-named `ctx.api` on the hook `ScopedContext`
(`packages/spec/src/contracts/scoped-context.ts`) is a live, unrelated
surface this PR does not touch, which is why the dispatching seat's own
`api: *{` grep returned 234 files and was reported as an instrument
failure.

**Population** — the 19 workspace packages that declare
`@objectstack/formula`. Every one has a `typecheck` script, so no filter
matched zero scripts and exited 0 without running anything.

**Measurement leg** (at `d706b60b57`, after `turbo run build` — 73 tasks
successful, so each package resolves formula through its rebuilt
`dist/index.d.ts`): all 19 packages typecheck **green**, `error TS`
count **0**.

**Firing control** — the same 19-package run with a REQUIRED member
injected into `EvalContext` (`__ablationProbeFiringControl: 'fire'`),
formula rebuilt, and the marker proved present in `dist/` by `node
scripts/ablation-dist-preflight.mjs @objectstack/formula
'__ablationProbeFiringControl'` before the colour was read:

- **12 of 19 packages red**; 21 distinct `error TS2345` supply sites
across 13 files, plus two more files caught a layer down by the
test-typecheck ledger gate (`platform-objects`, `plugin-auth`) — e.g.
`metadata-protocol/src/seed-loader.ts:1026`,
`objectql/src/validation/rule-validator.ts` (6 sites),
`service-automation/src/engine.ts`,
`plugin-approvals/src/approval-service.ts`.
- ⇒ the instrument reaches consumer code and goes red when `EvalContext`
changes incompatibly. The green above is a measurement, not a silence.

**Restore leg**: `git checkout HEAD -- packages/formula/src/types.ts`,
`git hash-object` equal to the HEAD blob `27f991bbd7…`, whole-tree `git
status --porcelain` empty, formula rebuilt, and `ablation-dist-preflight
… --absent` green — so no mutated byte survives in `dist/` for a later
run to read.

**The opposite direction** the card asked about — does anything
CONSTRUCT an `EvalContext` carrying `api`? — is answered by the same
green leg: after the removal such a literal is a compile error, and none
exists.

**Pinned sibling** (Post-Task Checklist step 4, Console Pin Gate):
objectui at `.objectui-sha` = `53ded82bf7a494f54e344e19099dbf00854b8694`
names `EvalContext` **zero** times; control in the same tree — four
distinct `from '@objectstack/formula'` import lines (`ExpressionEngine`,
`validateExpression`, `collectCelRootIdentifiers`,
`firstUndeclaredReference`) across `packages/app-shell`. Nothing there
is broken by this removal and no pin bump is owed.

## Tests

`packages/formula`: **31 files / 876 tests pass**, `pnpm typecheck`
green. The new `src/eval-context-no-query-api.test.ts` pins the
retirement in both directions — a type-level pin (`@ts-expect-error` on
`api` in an `EvalContext` literal) and two runtime pins (`buildScope`
binds nothing from a context still carrying one; a predicate calling
`os.lookup` is refused even when an implementation is supplied).

The type-level pin is verified live rather than assumed: formula's
test-typecheck ledger reports the **same** 3 files / 7 errors / 4
signatures as before this PR. That ledger is exact and shrink-only, so
an `@ts-expect-error` that had become unused would arrive as TS2578 and
red it. It stayed silent, which means the directive is used — `api`
really is rejected.

## Gates

- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived **57** gate families from the change
set itself (the 3 paths above). All 57 were run at this branch's head
and every one exited **0**; `--ran` reconciliation with the exit codes
recorded reports **57 derived / 57 run / 0 NOT-MEASURED / 0 UNRUN**. The
artifact-roster, wide-population and path-scheduled families the same
tool prints outside that total remain CI's.
- `pnpm lint` is CI's repo-wide run; the local delivery is a declared
narrowing with its three readings: ① the population eslint's own config
admits is **6819** files (`git ls-files` filtered to lintable
extensions, each asked through `ESLint#isPathIgnored`; 0 ignored) — note
`eslint.config.mjs`'s own header records 4659 from an older tree, a
drift this PR does not touch; ② the narrowed run linted **2** files
(`--format json`, result length), 0 errors / 0 warnings; ③ the config
enables **no type-aware linting** (no `parserOptions.project`, no
`projectService`, no typed `@typescript-eslint` rules — its header says
so and the config carries no such key), so this diff cannot move any
untouched file's verdict.
- `skip-changeset` measured rather than assumed:
`@objectstack/formula`'s `files[]` is `dist`, `README.md`,
`CHANGELOG.md`; after the build, `packages/formula/dist/index.d.ts`
carries the `EvalContext` interface with `grep -c "api?:"` = **0** —
positive control `grep -c "EvalContext"` = **10** in the same file.
Published bytes move, so the label would be wrong and a changeset is
owed and present.

## Acceptance notes

- **Out of scope, noted and not filed**: `eslint.config.mjs`'s header
states "across all 4659 linted files" while the population reads 6819
today. A stale prose count in a config header — an observation, not a
defect, a contract violation or an authoring trap. Carrier: the next PR
that edits that header.
- The identically-named `ctx.api` on the hook `ScopedContext` is
untouched and stays live; nothing in this PR narrows it.
- `needs:contract-review` belongs to the dispatching seat, which hangs
it on both carriers. This PR neither hangs, removes nor waits on it.
- Draft on purpose: the PR body is written once, in the opening stroke,
and never patched — anything it needs later is named in the dev report
for the seat to edit.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…it has no transactions, and the engine gates on the declaration (objectstack-ai#18890)

Fixes objectstack-ai#18063

Clause-②: yes

Governing text: maintainer decision batch **objectstack-ai#148 item 3, letter B**,
「同意」 2026-09-17T14:26Z (issue comment 5716042163). It supersedes batch
objectstack-ai#133's route C. Quoted verbatim and untranslated, because the spelling
delegation is the part this PR had to execute:

> `packages/spec`: the driver contract gains a way for a transport to
**declare 「no transactions」** (the dev picks the smallest spelling the
existing capability/contract surface already has — a capability bit is
preferred over a new key), and the engine's transaction gating reads the
declaration instead of method presence (`driver.zod.ts:266` re-keyed).

**Notation.** This body writes generic types bracket-free —
`Promise[Knex.Transaction]` means the declaration `SqlDriver` publishes.
That is a spelling choice against body sanitization, not a different
type.

---

## What landed

1. **`packages/spec`** — `DriverCapabilities` gains one live bit,
`transactionsUnsupported`, plus the predicate that reads it,
`driverSupportsTransactions()`, exported from `@objectstack/spec/data`.
2. **`packages/objectql` and `packages/core`** — ⚠️ **corrected by the
seat: all FOUR transaction gates**, not three. They read that predicate
instead of `typeof driver.beginTransaction`: `ObjectQL.transaction`,
`ScopedContext.transaction`, `ScopedContext.txDriver()` behind the
discrete begin/commit/rollback trio, and **`engineCanRollBack`** — the
ADR-0119 D4 gate that metadata-protocol's atomic `batchData` /
`updateManyData` / `deleteManyData` and `runMigrationJournal` share. The
degrade warning now says WHICH of the two reasons it fired for.

⭐ **The fourth gate was found by the at-tier review and is why this
count changed.** With only three re-keyed, the gates DISAGREED:
`driverSupportsTransactions` said false while `engineCanRollBack` still
read method presence and said true — and the D1 degrade swallowed the
driver's refusal. Measured on the real chain: an atomic `batchData`
returned a 「rollback」 with **one record persisted** and `begins = 0`,
against a lit control (same double, bit removed) that threw 501 with 0
rows; the migration runner ran to `completed` and wrote the `chunk_done`
marker its own header says 「would not mean committed」. ⇒ this PR briefly
re-opened the 「rollback does not roll back」 defect one layer up. Fixed,
with a pin that fails without it — and ⚠️ the pre-existing pin stayed
GREEN under that ablation, which is why it never caught this.
3. **`driver-sql`** — `SqlDriver.supports` spells
`transactionsUnsupported: false`. `SqlDriver.beginTransaction()` keeps
its narrow `Promise[Knex.Transaction]`; nothing in the base was widened.
4. **`driver-turso`** — the remote face declares the bit;
`TursoDriver.beginTransaction()` publishes the inherited declaration
instead of `Promise[any]`; `RemoteTransport` loses its three decorative
transaction members.

---

## The spelling, priced — because the ruling's preferred spelling points
at a tombstone

`driver.zod.ts:266` is the prescription line of a **retired-key
tombstone**: `transactions` was removed in `@objectstack/spec` 17.0.0
under ADR-0049 enforce-or-remove, and `savepoints` / `isolationLevels`
beside it are the same retired family. Three spellings were priced
before one was chosen.

**(a) Revive the name `transactions`.** Rejected, and the cost is
measurable rather than aesthetic:

- It needs **`packages/spec/src/migrations/registry.ts`** edited — the
D3 entry `driver-capabilities-inert-bits-removed` names
`data.DriverCapabilities.transactions` in its `surface` list and states
the count ("of 34 declared bits, THREE have a decision-making reader …
THIRTY-ONE were written by every driver and read by nothing") in its
`reason` and `acceptanceCriteria`. That file is MIXED and deliberately
not routed to the os-regen merge driver, so it is the one file in this
area that a merge cannot resolve mechanically. **The chosen spelling
touches it zero times** (verified: `git diff origin/main..HEAD` does not
name it).
- It inverts the record's own convention. Every optional bit here means
`false` when absent; a revived `transactions` must mean "yes,
transactions" when absent, or every existing driver silently loses them
on upgrade. That is a tri-state boolean in a record where nothing else
is one.
- It converts a documented refusal into silent acceptance of a value
whose **meaning changed underneath it**. The old bit claimed "I support
transactions" and nothing read it; the new declaration must express "I
have none", and it is load-bearing. An old inert value becoming
load-bearing with the opposite sense is the ADR-0104 silent-strip class
one level up — the class the tombstones exist to prevent.
- It makes the tombstone's own published text false. That text ("no code
in any repository ever read it, so its value never changed which code
path ran") is what an upgrading author actually reads.
- It also costs the pins that hold the retired set: `RETIRED_BITS` in
`driver.test.ts`, the prescription case, the 31-tombstone counts in two
docblocks.

**(b) A new key outside `supports`** — dispreferred by the ruling by
name, and larger: a second place to look for one fact.

**(c) A new inverse-polarity bit on the live `DriverCapabilities`
record.** **Chosen.** It keeps `absence = false`, leaves the tombstone
true and refusing, touches neither MIXED file, and costs one key plus
its reader.

⭐ **The ruling's stated preference and the tombstone do not conflict,
and that is the finding worth stating plainly.** "A capability bit is
preferred over a new key" asks for a bit on the existing
`DriverCapabilities` record — it does not ask for the retired NAME back.
Spelling (c) satisfies the preference in full while the tombstone stays
exactly as published. No seat question is escalated here because there
is no fork to escalate.

### Why adding a bit SATISFIES enforce-or-remove rather than reversing
it

The 17.0.0 audit removed thirty-one bits for one stated reason: **no
code anywhere read them.** It kept the three where method presence
provably cannot carry the signal. Ruling B's entire content is the
creation of the missing reader. The bit arrives **with** the engine
dispatch that consumes it, in the same change — the honest order the ADR
asks for — and the record's own docblock now states that bar for the
next author.

### Why method presence could not carry this

`TursoDriver extends SqlDriver`, whose `beginTransaction()` opens a real
knex transaction, so the inherited method reported the libSQL REMOTE
transport as transactional. It is not: `RemoteTransport`'s data methods
take **no `options` argument at all**, so a handle cannot reach the
statement that would have to join it. **A subclass cannot opt out of a
door it did not open.** This is the exact mirror of `batchSchemaSync`,
which exists because a subclass can inherit `syncSchemasBatch` from a
base whose transport batches while its own cannot — and which the engine
likewise ANDs with method presence.

---

## Premise check: part of this card was consumed while it sat in the box

Reported rather than quietly absorbed, because it changes what clause 3
still owed.

`62bce5c297d` — "refuse transactions on the remote face instead of
silently dropping them (PR objectstack-ai#18717)", 2026-09-17T17:13:06Z, four hours
after the ruling — landed the **driver-level** loud refusal for card
objectstack-ai#18616: `TursoDriver` refuses `beginTransaction()` / `commit()` /
`rollback()` and any `options.transaction` on the remote arm. That card
is already `completed`; this PR does not re-open or re-decide it, and
deliberately does not add a second, engine-level `options.transaction`
refusal beside the driver's — a second mechanism for zero additional
drivers is the shape this whole card is about.

What that leaves for this PR is the half nothing had built, and it is
load-bearing:

⭐ **The refusal's own remedy was unreachable.**
`refuseRemoteTransaction`'s message tells callers to "take the
non-transactional path deliberately: `engine.transaction()` without
`require: true` on a datasource whose driver has no transactions runs
the callback with no rollback and says so (ADR-0119 D1)". With the gate
reading method presence, that path could never be taken for this driver
— the method is there, so the engine opened a transaction and the
callback got a 501 out of `beginTransaction()` instead of the declared
degrade. **The message made a promise only this change can keep.**

Two more premise readings, both against `origin/main`:

- `RemoteTransport`'s three transaction members were **unreachable**
once the driver refused:
`remoteTransport.beginTransaction|commit|rollback` — **0** call sites
repo-wide, against a lit control of **7** lines calling other members of
the same field. They are deleted here.
- `TursoDriver.beginTransaction()`'s `any` dissolves without paying
either price objectstack-ai#17690 priced. `refuseRemoteTransaction` returns `never`,
so the remote branch is assignable to any declared return type and the
only arm that still returns is `super.beginTransaction()`. The override
republishes the base's type. **It is spelled
`ReturnType[SqlDriver['beginTransaction']]` and not the knex type
directly, because `knex` is not a dependency of `driver-turso`**
(`check:undeclared-dep-imports`; the same constraint the doors suite's
`KnexSlice` works around) — and deriving it from the base is the
stronger pin.

⚠️ **objectstack-ai#17690 could not be read** — it, objectstack-ai#17876 and objectstack-ai#17878 all answer 404
(the `os-musk` account is deactivated). Lit control: objectstack-ai#18063 and objectstack-ai#18116
read fine through the same instrument in the same round, so the 404s are
a reading. Clause 4 of the ruling — "`SqlDriver.beginTransaction` keeps
its narrow `Promise[Knex.Transaction]` (the honest narrowing objectstack-ai#17690
protected)" — is therefore treated as **the governing restatement**. No
claim is made here about objectstack-ai#17690's original text.

---

## Behaviour change for a caller

On a datasource whose driver declares the bit, `engine.transaction()`
takes the DECLARED non-transactional path (ADR-0119 D1) instead of
opening a transaction it cannot honour:

- without `require`: the callback runs with no transaction, `owned:
false`, and the degrade warns **once per datasource** — naming the
declaration, not a missing method, because sending an operator to look
for a method this class publishes wastes the report;
- with `require: true`: `TransactionUnsupportedError` **before the
callback writes anything**;
- `ScopedContext.transaction` answers identically, and the discrete
trio's `begin` returns `null`.

Every one of those is the answer a driver with no `beginTransaction`
already received. Nothing that worked stops working — which is why the
changesets are **minor**: the remote transport never honoured a
transaction, so no working behaviour is withdrawn (the ruling's own
stated ground, and the ground on which `RemoteTransport`'s three
published members are removed at minor).

---

## Verification

All readings on the merged tree, `0c6eeea0f6f`, against `origin/main`
`0b31d90fb37`. Every exit code captured by redirect, never through a
pipe.

| run | result |
|:--|:--|
| build closure (`turbo run build`, driver-turso + objectql closures) |
**exit 0** — 15/15 tasks |
| typecheck — spec, objectql, driver-sql, driver-turso | **exit 0** —
17/17 tasks |
| `@objectstack/spec check:generated` | **exit 0** — all 15 artifacts
current |
| `spec` `src/data/driver.test.ts` | **58 passed** |
| `objectql` — 6 transaction suites | **66 passed** |
| `driver-turso` `pnpm test` (whole package) | **1282 passed / 55
files** |
| `driver-sql` `pnpm test` (whole package) | **2627 passed, 168
skipped** |
| `pnpm lint` (`eslint . --no-inline-config`, whole repo) | **exit 0** —
complete population, no narrowing claimed |
| 24 further gate families run locally | **all exit 0** |

`pnpm check:type-check-debt` returned **exit 3, `PREREQUISITE NOT MET`**
— it refuses to measure without the whole workspace built, which is a
farm-scale build. Recorded as **NOT MEASURED**, ⛔ not as a pass and ⛔
not as a red. The rest of the derived gate roster is CI's run.

### Reverse verification — two legs, both dist-aware

**Leg A — the spec predicate** (`objectql` resolves `@objectstack/spec`
through `exports`, i.e. `dist/`, per the `KNOWN_UNALIASED_TEST_IMPORTS`
ledger, so the mutation had to reach `dist/` to mean anything):

| | reading |
|:--|:--|
| `driver.zod.ts` blob at HEAD |
`63ab873394848c025325ac234e8bd62b361c9ce0` |
| blob after mutation (declaration clause deleted) |
`481e373c37ec774e03b6c127b6ceb6d8527c03d1` — differs, so it reached disk
|
| rebuild, then `ablation-dist-preflight @objectstack/spec … --absent` |
**exit 0** — the guard is gone from `dist/` |
| `engine-transaction-declared-unsupported.test.ts` | **8 failed / 8** |
| `spec` `driver.test.ts` | **1 failed / 58** — only the predicate case,
as predicted |
| restore (`git checkout HEAD -- …`), `git diff HEAD` | empty; blob back
to `63ab873…` |
| rebuild, preflight (present) | **exit 0** |
| re-run | **8 passed / 8** |

**Leg B — the driver declaration** (same-package source resolution, no
build in the path):

| | reading |
|:--|:--|
| `turso-driver.ts` blob at HEAD |
`bb55757e6025d55d58babbbe0c090eefa4afa001` |
| blob after mutation (`transactionsUnsupported: false`) |
`2bfb64a95287d3145dea4d36fd75cad23868f3e1` — reached disk; injected
marker observed on disk |
| declaration suite + capability census pin | **2 failed / 102** |
| restore | blob identical to HEAD, `git diff HEAD` empty |
| re-run | **102 passed / 102** |

Predicted direction was RED, and RED is what both legs produced. Both
scripts carried `trap … EXIT INT TERM` with absolute paths; both
restores are proven by blob identity and an empty `git diff HEAD`, not
by an exit code.

⚠️ One instrument error, reported rather than dropped: leg B's two
`*_SRC_COUNT` echo lines were mis-quoted inside a quoted heredoc, so
`grep` read the pattern's tail as extra filenames and printed a prefixed
`0`. Those two lines are **void**, not readings. The on-disk proof does
not rest on them — it rests on the anchor assertion (the pre-mutation
text had to occur exactly once or the script aborted), the injected
marker counted on disk, and the two blob hashes.

### Merge

`origin/main` was merged after PR objectstack-ai#18704 landed, through
`scripts/pm/os-regen-merge.sh` — merge committed first, regeneration
afterwards, never during (a `gen:schema` run in MERGE state rolls the
authorable-surface anchor back to the old fork point). Three os-regen
artifacts were regenerated from the merged tree. Asserted afterwards:
**zero** lines present in `origin/main`'s `api-surface/data.json`,
`authorable-surface/data.json` or `export-origins/data.json` are absent
from the regenerated files, with the lit control firing (the single
addition is `driverSupportsTransactions (function)`). Neither MIXED file
— `dropped-refinements.baseline.json`,
`packages/spec/src/migrations/registry.ts` — is touched by this branch
at all.

---

## Acceptance notes

Out-of-scope observations, noted and deliberately not acted on here:

- `packages/spec/src/data/driver.zod.ts` still carries the retired
`savepoints` and `isolationLevels` beside `transactions`; both remain
correctly retired under this change and neither gained a reader. Noted,
not filed.
- `packages/objectql/src/engine.ts` has a third comment (near the
`batchData` observability path) that cites `warnTransactionUnsupported`
as its model; it is prose, still accurate, and left alone. Noted, not
filed.
- The `turso-driver-doors-declared-types.test.ts` header still quotes a
TS2416 coordinate (`src/turso-driver.ts(1662,18)`) that has drifted by
landings. The receipt's substance reproduces; only the coordinate is
stale, and it is kept verbatim as the historical error text. Noted, not
filed.

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants