fix: ADR 0050 callback-error precedence, and the honest limits of transaction control
CI / verify (pull_request) Successful in 1m4s
CI / infrastructure-smoke (pull_request) Successful in 38s
CI / migration-smoke (pull_request) Successful in 1m54s
CI / image-build (pull_request) Successful in 22s
CI / deploy-smoke (same-origin) (pull_request) Successful in 51s
CI / deploy-smoke (split) (pull_request) Successful in 1m37s

Narrow contract and implementation adjustments from review. No SDK, manifest,
packaging, or Signage work.

Callback-error precedence:
- A transaction or savepoint callback that rejects keeps its own error, even
  while sibling work it started is still settling (one failed half of a
  Promise.all). Core drains that work, rolls back, and never commits or
  releases.
- A callback that resolves with work outstanding (a forgotten await) still
  fails with MODULE_DATABASE_SCOPE_WORK_OUTSTANDING and rolls back.

Transaction-control SQL:
- ADR 0050 forbids it through query() as a conformance rule for trusted code,
  not a security guarantee. Core does not parse SQL, so PostgreSQL has run
  the statement before Core sees it. Core detects the violation from the
  command tag or the transaction status, and claims nothing it cannot do: a
  module's own COMMIT has committed.
- After a violation, the transaction's scopes refuse every later statement
  without sending it (it would otherwise autocommit). Core never commits the
  transaction itself, and the connection is destroyed rather than reused,
  for top-level violations too.
- The commit and rollback guarantees apply to conforming use.

Duplicate object-mode columns: documented as found only after execution, so a
mutation can have taken effect; atomicity needs an ABI transaction.

Tests prove each of these against PostgreSQL: Promise.all failures in a
transaction and a savepoint keep 23505; a forgotten await still rolls back; a
module COMMIT's prior work is committed while nothing after it runs; a backend
PID changes after every violation; duplicate RETURNING columns persist at the
top level and roll back in a transaction.

ADR 0050 stays Accepted: the executor conforms to the revised text.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-10-07 16:12:01 -05:00
co-authored by Claude Opus 5.5
parent 6db50516b7
commit 317a4ce410
6 changed files with 300 additions and 62 deletions
@@ -181,8 +181,13 @@ transaction(callback, options) → the callback's result
- `"object"` rows are keyed by column name.
- `"array"` rows are positional, in `fields` order. An ORM that selects the same column name twice,
as joins do, needs this mode.
- Core rejects an object-mode result that has duplicate column names, rather than silently
dropping one.
- Duplicate column names are invalid in object mode. Core rejects such a result rather than
silently dropping a column.
- With the current PostgreSQL and driver execution path, the result's shape is only known once
the statement has run. So a mutating statement can already have had its effects when the
result-shape error is reported.
- This is not a security guarantee. A caller that needs atomicity runs the mutation inside an ABI
transaction, where the error rolls the mutation back with everything else.
- **`command`** is PostgreSQL's command tag word, such as `SELECT`, `INSERT`, `UPDATE`, or `DELETE`.
- **`rowCount`** is the number of rows PostgreSQL reports as processed, or `null` when it reports
none.
@@ -331,15 +336,37 @@ transaction(async (tx) => { … }, { isolation, readOnly })
transaction call rejects with the callback's error. There is no `rollback()` method: to roll back,
the module throws.
- **All work is awaited inside the callback.** Module code must await every database operation it
starts through `tx` before its callback returns.
- If the callback settles while an operation launched through that scope is still outstanding, the
module has used the scope incorrectly.
- The transaction is then never committed: Core rolls it back, and the transaction call rejects
with the scope-usage `ModuleDatabaseError`.
- A forgotten `await` therefore fails. It never quietly changes what the transaction contains.
starts through `tx` before its callback returns. What happens when work is still outstanding
depends on how the callback settled:
- **Resolved with work outstanding.** The module forgot an `await` and has used the scope
incorrectly. The transaction is never committed: Core rolls it back, and the transaction call
rejects with the scope-usage `ModuleDatabaseError`. A forgotten `await` therefore fails. It never
quietly changes what the transaction contains.
- **Rejected with work outstanding.** The callback's own rejection stays the transaction's error.
Core rolls back and does not replace that error, a PostgreSQL failure included, with the
scope-usage error just because sibling work was still settling. This is what happens when one
query in `await Promise.all([tx.query(a), tx.query(b)])` fails while the other is still running.
- Either way, outstanding work never leads to a commit.
- Core may let operations that already started finish, or settle, before it rolls back and releases
the connection, so that a pooled connection is never handed on mid-operation. Waiting for them
never turns into a commit.
- **Transaction control belongs to the callbacks.** Transaction-control SQL is forbidden through
`query`: `BEGIN`, `START TRANSACTION`, `COMMIT`, `ROLLBACK`, `SAVEPOINT`, `RELEASE SAVEPOINT`, and
every other statement that starts, ends, or marks a transaction. Modules control a transaction's
lifecycle only through the `transaction` and `savepoint` callbacks.
- This is a conformance rule for trusted module code, not a sandbox or a security guarantee.
- Core does not parse SQL to enforce it. Such a statement is valid PostgreSQL, so PostgreSQL
executes it before Core can see its result.
- Core detects a violation where the driver and protocol make that possible: from the statement's
command tag, and from the connection's transaction status. It then fails safely:
- the call rejects;
- the transaction's scopes refuse every later statement without sending it, because it would
otherwise run outside any transaction;
- Core never commits the enclosing transaction itself, and that transaction rejects too;
- the connection is destroyed rather than reused.
- Core does not claim to undo a transaction-control statement PostgreSQL has already executed. A
`COMMIT` sent through a transaction's `query` has already committed the work before it.
- The commit and rollback guarantees in this section apply to conforming use of the ABI.
- **Revocation.** When the callback settles, `tx` is revoked, and it stays revoked once the scope has
finished. Any later call on it fails with the
closed-scope `ModuleDatabaseError` and sends nothing to PostgreSQL. A `tx` that escapes its
@@ -362,9 +389,12 @@ tx.savepoint(async (sp) => { … })
- **Naming.** Core generates every savepoint identifier. Modules never provide one.
- **Release and rollback.** A resolved callback releases the savepoint. A rejected one rolls back
to it, releases it, and rejects with the callback's error.
- **Outstanding work.** The awaiting rule for transactions applies unchanged. A savepoint callback
that settles with operations still outstanding is a scope-usage error. The savepoint is rolled
back, never released, and the call rejects with that error.
- **Outstanding work.** The awaiting rule for transactions applies unchanged.
- A savepoint callback that resolves with operations still outstanding is a scope-usage error.
The savepoint is rolled back, never released, and the call rejects with that error.
- A savepoint callback that rejects keeps its own error. The savepoint is rolled back as for any
rejection.
- Either way, outstanding work never leads to a release.
- **One active scope.** Only the innermost open scope may run statements. While a savepoint is
open, its parent executor refuses statements and new savepoints, so savepoint scopes can never
overlap.
+6 -1
View File
@@ -522,7 +522,12 @@ because every claim is a property of PostgreSQL or of the protocol:
- commit, rollback, isolation levels, read-only, no retries, and an aborted transaction never being
committed;
- callback-scoped savepoints;
- a forgotten `await` rolling back instead of committing;
- a forgotten `await` rolling back instead of committing, while a rejected callback (one half of a
failed `Promise.all`) keeps its own error;
- transaction-control SQL sent through `query` detected only after PostgreSQL has run it: Core
cannot undo a module's own `COMMIT`, so the suite pins what it does do: it refuses everything
after, never commits, and destroys the connection;
- duplicate object-mode columns found only after a mutation ran, unless it ran in a transaction;
- revoked executors reaching nothing.
The suite's database is created with deliberately hostile defaults: a non-UTC time zone, a non-ISO