docs: ADR 0050, tighten transaction scope, single-statement, and error rules
CI / verify (pull_request) Successful in 56s
CI / infrastructure-smoke (pull_request) Successful in 36s
CI / migration-smoke (pull_request) Successful in 1m52s
CI / image-build (pull_request) Successful in 22s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / deploy-smoke (split) (pull_request) Successful in 1m29s

Three narrow clarifications to the proposed contract. No other decision
changes.

- Outstanding work: module code awaits every operation before its
  transaction or savepoint callback returns. A callback that settles
  with work outstanding is a scope-usage error and never commits. Core
  may let started operations settle before rolling back and releasing
  the connection, but waiting never turns into a commit. Executors stay
  revoked once their scope has finished.
- One statement per query() call: a correctness and API rule, not a
  security boundary. It is upheld by a reliable driver or protocol
  mechanism, never a home-grown SQL parser. An implementation that
  cannot uphold it stops and reports instead of weakening the ADR.
- Errors: `code` is a stable Ekklesia-owned category and always present.
  `sqlState` is optional, present only when PostgreSQL supplied one.
  ABI-detected failures (closed scope, invalid parameter, invalid
  option) carry a code and no SQLSTATE.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-10-07 15:27:29 -05:00
co-authored by Claude Opus 5.5
parent 759872834d
commit 11e2495484
@@ -168,8 +168,13 @@ query({ text, values, rowMode }) → { command, rowCount, rows, fields }
transaction(callback, options) → the callback's result
```
- **`text`** is exactly one PostgreSQL statement, using `$1 … $n` placeholders. Statement text that
holds several statements is not part of the ABI, and Core may reject it.
- **`text`** is exactly one PostgreSQL statement, using `$1 … $n` placeholders. **One `query` call
runs one statement.** This is a correctness and API rule, not a security boundary.
- The implementation upholds it with a reliable driver or protocol mechanism where one exists,
such as PostgreSQL's extended query protocol, which accepts a single statement per request.
- It does not require, and must not grow, an Ekklesia SQL parser.
- If an implementation cannot reliably uphold this rule, the implementing change stops and reports
the conflict. It does not weaken this ADR silently.
- **`values`** is the ordered list of bound parameters (see
[Parameters](#parameters)). Values are always bound and never interpolated into the text.
- **`rowMode`** is `"object"` or `"array"`.
@@ -280,16 +285,24 @@ Conceptually:
ModuleDatabaseError { code, sqlState? }
```
- **`code`** is a small, stable, Ekklesia-defined set. It covers at least:
- the statement failed in PostgreSQL;
- a parameter or option was refused;
- the scope is closed;
The two fields mean different things:
- **`code`** is always present. It is a stable, Ekklesia-owned error **category**, meant for
programmatic handling. Its categories cover at least:
- PostgreSQL rejected the statement;
- a parameter value was refused;
- a transaction option was refused;
- the scope was closed or used incorrectly;
- the transaction could not be completed.
The exact names are an implementation detail.
- **`sqlState`** is PostgreSQL's five-character SQLSTATE, present when PostgreSQL reported one.
Modules legitimately branch on stable conditions such as `23505` (unique violation), `23503`
(foreign-key violation), and `40001` (serialization failure).
This ADR does not freeze the complete set or the exact names. Once published, a code keeps its
meaning.
- **`sqlState`** is optional. It is the five-character SQLSTATE that PostgreSQL itself reported, such
as `23505` (unique violation), `23503` (foreign-key violation), or `40001` (serialization failure).
It is present only when the database supplied one, and Core never invents one.
- Failures that the ABI detects itself have a `code` and no `sqlState`: a closed scope, an
invalid parameter type, or an invalid transaction option.
- Modules branch on `code` for ABI failures and on `sqlState` for PostgreSQL conditions.
- **It never carries:**
- SQL text or parameter values;
- the driver's message, detail, hint, constraint, table, or column names;
@@ -313,13 +326,22 @@ transaction(async (tx) => { … }, { isolation, readOnly })
- **Starting.** Core takes a connection from its pool and begins a transaction with the requested
options. It then calls the callback with `tx`, a Module Database ABI v1 executor scoped to that
transaction alone. No `pg` client and no Core ORM transaction is ever exposed.
- **Commit and rollback.** Core commits when the callback's promise resolves, and rolls back when it
rejects. The transaction call then rejects with the callback's error. There is no `rollback()`
method: to roll back, the module throws.
- **Unfinished queries.** Before committing or rolling back, Core waits for every query the scope
has already started. If one of them fails after the callback resolved, Core rolls back and reports
the failure.
- **Revocation.** When the callback settles, `tx` is revoked. Any later call on it fails with the
- **Commit and rollback.** Core commits only when the callback's promise resolves and no operation
launched through `tx` is still outstanding. When the callback rejects, Core rolls back, and the
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.
- 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.
- **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
callback can never run a statement on a connection that has gone back to the pool.
- **Options.** `isolation` is `read committed`, `repeatable read`, or `serializable`. `readOnly` is a
@@ -340,6 +362,9 @@ 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.
- **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.