Skip to content

feat(spec,metadata-protocol): ADR-0118 — transactions reach plugin space through the contract, and atomic stops lying (#4612) - #4623

Merged
os-zhuang merged 2 commits into
mainfrom
claude/focused-mendel-5akrna
Aug 2, 2026
Merged

feat(spec,metadata-protocol): ADR-0118 — transactions reach plugin space through the contract, and atomic stops lying (#4612)#4623
os-zhuang merged 2 commits into
mainfrom
claude/focused-mendel-5akrna

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Closes #4612. Records ADR-0118 (docs/adr/0118-plugin-reachable-transactions-and-honest-atomic-batch.md) and lands its two implementable decisions.

Why this isn't one of the issue's three options

#4612 asks the platform to pick a debt: (1) surface driver transactions through the engine, (2) bless a migration-journal primitive, or (3) rule both out. Scoping it overturned the premise the pricing rested on.

Option 1's expensive half was paid three majors ago. ADR-0034 (docs/adr/0034-transactional-writes-and-ambient-transaction.md) is Accepted — implemented (v8.0.0): ObjectQL.transaction() (packages/objectql/src/engine.ts:4934-4973) opens a driver transaction inside an AsyncLocalStorage store, and buildDriverOptions (:1257-1275) lifts that ambient handle onto every driver call. That is precisely the "middleware/hook semantics inside a transaction" design project the issue budgets for — a hook body, validation predicate or FK-resolution read issued during a transactional write already binds to the transaction's connection. ADR-0034 exists because not doing so deadlocked SQLite's single-connection pool.

The issue's other premise is also stale here: it cites driver-turso primitives as unsurfaced capability, but there is no Turso driver in this repo (only docs/design/driver-turso.md, Status: Proposal), and every in-repo driver already implements beginTransaction/commit/rollback as required members of IDataDriver.

What was actually missing is declared reach. IObjectQLEngine — the objectql slot contract — declares 25 members and not transaction. So consumers reached around the type system: protocol.ts:2289 (discovery probe), :7455-7458 (inTxn), and a hand-declared member in sys-metadata-repository.ts. Three unchecked structural claims about a class none of them import — exactly what that contract file was created to end, and its own evidence bar ("declared here only where a CROSS-PACKAGE consumer already calls it through the service slot") was met three times over.

But transactions alone still don't close the issue. A million-row backfill can't hold one write-lock; driver-memory.beginTransaction deep-clones the whole database; transaction() binds only the default driver so multi-datasource writes escape silently; and a process crash defeats in-process compensation entirely. The four queued consumers need journal + compensation + resume on top of transactions. So the ADR splits the ask along where the work actually is, rather than choosing between two things that solve different halves.

What lands here

D1 — transaction joins IObjectQLEngine. Signature verbatim from the class, so implements still checks and no engine behaviour changes. Required, not optional, per the contract's own rule. The three cast sites drop their casts; the two narrow host surfaces (MetadataHostEngine, the sys-metadata repository's engine) type their optional member as IObjectQLEngine['transaction'] so they can't drift from the real signature. Both caveats — default-driver-only, and the silent no-rollback fallback when the driver lacks beginTransaction — go into the TSDoc as declared meaning rather than behaviour to be discovered.

D4 — batchData's atomic becomes real or refuses. It advertised "rollback entire batch on any failure (transaction mode)" and opened no transaction; it broke the loop (protocol.ts:5302-5305). Everything already written stayed written — and the response reported those rows success: true under the one flag whose job is guaranteeing they were undone. That reporting, not just the missing transaction, was the damage. Now:

  • the batch runs inside one engine.transaction(); first failure rolls back every prior write;
  • the response reports zero successes, with rows marked ROLLED_BACK: / the causal error / NOT_ATTEMPTED:;
  • a runtime that cannot roll back (no transaction(), or a default driver without beginTransaction) is refused with 501 NOT_IMPLEMENTED rather than degraded — silent degradation is how the flag came to lie;
  • atomic outranks continueOnError (whose own description already scoped it to atomic=false);
  • in atomic mode the upsert path no longer falls back to insert when its update throws — inside an aborted transaction that fallback can only fail with a secondary error burying the real cause.

Declaration aligned to enforcement. BatchOptionsSchema.atomic declared .default(true) while no site delivered atomicity, and REST forwards the original body so the parsed default never reached the loop. It becomes false — aligned down to what every site already does, not up to what none of them did. Honouring the old true would have silently flipped every existing caller's failure semantics and hard-failed ordinary batches on non-transactional drivers. This is the same shape as the validateOnly tombstone already in that file.

Behaviour change

Callers explicitly sending atomic: true now get real rollback, real 501s on runtimes that can't transact, and a response that no longer credits rolled-back rows as successes. If you were passing atomic: true and relying on partial results surviving a failure, that was the bug — use atomic: false (or omit it) for best-effort. Callers sending nothing are unaffected.

A second commit trues up three hand-written API docs the docs-drift check flagged. They had accurately documented the old behaviour — wire-format.mdx stated the non-rollback outright — so leaving them would have shipped the fix alongside docs describing the bug. deleteMany's note stays honest that its atomic still rolls nothing back, since that route did not get D4's treatment (#4620).

Tests

10 unit pins (packages/metadata-protocol/src/protocol.batch-atomic.test.ts): rollback shape and zero-success reporting, commit-path transaction threading, 501 refusal with zero writes attempted in both the no-transaction() and driver-can't-transact cases, atomic-beats-continueOnError, atomic upsert rethrow, and non-atomic regression pins.

6 integration pins (packages/objectql/src/protocol-batch-atomic.test.ts) over a real ObjectQL plus a snapshot-transaction driver: rows genuinely gone after rollback, pre-existing rows untouched, one shared handle across every write, and an internal read during the batch binding to the open transaction — the no-deadlock coverage whose absence ADR-0034 was written about.

Gates: pnpm typecheck 122/122; spec 7292, objectql 1612, metadata-protocol 216, rest 554 tests green; check:generated 8/8 (the .describe() change regenerated content/docs/references/api/batch.mdx); check:adr-anchors OK with two new anchors.

Follow-ups filed

#4617 D2 — implement the migration-journal runner (sys_migration_journal + runner in core): preflight → journal → chunked writes with chunk_done written inside each chunk's transaction → LIFO compensation → re-entrant resume. Specified in ADR-0118 D2 in implementable detail.
#4618 D3 — retire IDataEngine.batch? (declared, implemented by nobody, called by nobody).
#4619 engine.transaction caveat hardening — silent degrade, default-driver-only, no owned-vs-joined signal.
#4620 deleteManyData has the same fake-atomic; updateManyData ignores atomic entirely; per-row result shape diverges from BatchOperationResultSchema.

D13 proceeds on its hand-rolled pattern and collapses onto the runner when #4617 lands, as the issue anticipated.

…ace through the contract, and `atomic` stops lying (#4612)

#4612 asks the platform to choose a debt: transactionalize the engine, bless a
shared migration-journal primitive, or rule both out. Scoping it found the
first option's expensive half was paid three majors ago — ADR-0034's ambient
transaction is implemented, and hooks/validation/internal reads already join it
— while the cheap half was never done. So this records ADR-0118, which splits
the ask along where the work actually is.

D1 (here) — `transaction` joins `IObjectQLEngine`. The mechanism ships; only
the declaration was missing, so the metadata protocol's atomic publish, its
`transactionalBatch` probe, and the sys-metadata repository each reached it
through `as unknown as` casts — unchecked claims about a class none of them
import. The contract's own evidence bar was met three times over. Its caveats
(default-driver only; runs without a transaction when the driver lacks
`beginTransaction`) go into the TSDoc as declared meaning, not behaviour to be
discovered. Narrow host surfaces now type their optional member as
`IObjectQLEngine['transaction']` so they cannot drift from it.

D4 (here) — `batchData`'s `atomic` opened no transaction. It broke a loop:
prior writes stayed committed while the response reported them `success: true`
under the one flag guaranteeing they were undone — #4346's class, silent and
destructive. It now runs the batch inside one `engine.transaction()`, and a
failure rolls back everything and says so (`succeeded: 0`, rows marked
ROLLED_BACK / NOT_ATTEMPTED). A runtime that cannot roll back is REFUSED with
501, never degraded — silent degradation is how the flag came to lie.
`BatchOptionsSchema.atomic`'s declared `.default(true)` is aligned down to the
enforced `false`, so opting in is explicit and no existing caller's failure
semantics change underneath them.

D2 (#4617) specifies a framework-owned migration-journal runner: transactions
cannot span a million-row backfill, a whole-DB-snapshot driver, a multi-
datasource write, or a process crash, so the four queued consumers need journal
+ compensation + resume on top of transactions, not instead of them.
D3 (#4618) retires `IDataEngine.batch?` — declared, implemented by nobody,
called by nobody. Caveat hardening is #4619; the sibling fake-atomics in
`deleteManyData`/`updateManyData` are #4620.

Tests: 10 unit pins on the atomic contract (rollback shape, 501 refusal with
zero writes attempted, precedence, non-atomic regression) and 6 integration
pins over a real engine + transacting driver, including that an internal read
during the batch binds to the open transaction — the no-deadlock coverage whose
absence ADR-0034 was written about.

Closes #4612

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NKcGqCYCCpMkB5UW8jNPXx
@vercel

vercel Bot commented Aug 2, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 2, 2026 11:49am

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling size/xl labels Aug 2, 2026
@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/metadata-protocol, @objectstack/spec.

107 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/spec)
  • content/docs/api/environment-routing.mdx (via @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/spec)
  • content/docs/automation/approvals.mdx (via @objectstack/spec)
  • content/docs/automation/connectors.mdx (via @objectstack/spec)
  • content/docs/automation/flows.mdx (via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/metadata-protocol, packages/spec)
  • content/docs/concepts/north-star.mdx (via packages/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/cli.mdx (via @objectstack/spec)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via packages/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via packages/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/metadata-protocol, @objectstack/spec)
  • content/docs/kernel/services.mdx (via @objectstack/spec)
  • content/docs/permissions/authorization.mdx (via @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/runtime-capabilities.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/releases/implementation-status.mdx (via @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v16.mdx (via @objectstack/spec)
  • content/docs/releases/v17.mdx (via @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/metadata-protocol, @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

The docs-drift check flagged the hand-written API docs, and three of them were
genuinely stale — they documented the bug ADR-0118 D4 fixes:

- `wire-format.mdx` stated it outright: "By default (`options.atomic: true`)
  processing stops at the first failing record — records already written
  earlier in the same batch are **not** rolled back, since there is no wrapping
  database transaction." Accurate before, wrong on both halves now.
- `client-sdk.mdx`'s options table listed `atomic` default `true` with
  "Rollback entire batch on any failure" — the default moved, and the rollback
  claim only became true with this change.
- `data-api.mdx` described `atomic` as the default on `/batch` and `deleteMany`.

Each now describes what the route actually does, including the 501 refusal and
the rolled-back response shape (`succeeded: 0`, ROLLED_BACK / NOT_ATTEMPTED).

Kept honest about scope: `deleteMany` and `updateMany` did NOT get D4's
treatment, so `deleteMany`'s note now says plainly that its `atomic` only stops
the run and rolls nothing back (#4620 tracks the fix). Documenting the fixed
behaviour on routes that still carry the bug would have replaced one inaccuracy
with another.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NKcGqCYCCpMkB5UW8jNPXx
@os-zhuang
os-zhuang marked this pull request as ready for review August 2, 2026 12:11
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 2, 2026
Merged via the queue into main with commit 9a86949 Aug 2, 2026
23 checks passed
@os-zhuang
os-zhuang deleted the claude/focused-mendel-5akrna branch August 2, 2026 12:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

No plugin-reachable transactional write primitive — every migration-class tool rebuilds journal + compensation

2 participants