Skip to content

Commit 221fdf9

Browse files
hyperpolymathclaude
andcommitted
feat(stdlib): Transaction.affine — affine-bounded write-set isolation — db-theory #2 (8 externs)
New `Transaction` module exposing SQL transactions as an affine-bounded resource: `tx_begin(d) -> Tx` returns an opaque handle consumed exactly once by `tx_commit` or `tx_rollback`. Adds savepoint surface (`tx_savepoint` / `tx_release` / `tx_rollback_to`), `tx_db` aliasing, and `tx_is_live` introspection. Codegen prelude adds `__as_tx*` helpers; Deno-ESM smoke witnesses the six lifecycle shapes including the rollback-discards-writes safety property (an insert during a `Tx` ending in rollback leaves only pre-tx rows visible). Cross-doc: docs/academic/proofs/db-theory-2-transaction-safety.md states three formal safety obligations (#DB-2.1 rollback-discards-writes, references the echo-types upstream extension shape — per owner directive 2026-06-01 the proof must reuse EchoNoSectionGeneric.no-section-of- collapsing-map via a new TransactionMutations.agda instantiation upstream (separate issue to follow). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d7cdec5 commit 221fdf9

5 files changed

Lines changed: 524 additions & 1 deletion

File tree

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
<!-- SPDX-License-Identifier: MPL-2.0 -->
2+
<!-- Copyright (c) 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> -->
3+
4+
# DB-Theory #2 — Transaction Safety (Rollback-Discards-Writes)
5+
6+
**Status**: Carrier wired (`stdlib/Transaction.affine`, codegen, Deno-ESM smoke); formal proof obligation **pending upstream against `hyperpolymath/echo-types`**.
7+
8+
## 1. The obligation
9+
10+
Let `Tx` be the opaque affine handle returned by `tx_begin(d: Db)` in `stdlib/Transaction.affine`. Let `t : Tx` be such a handle, and let `σ₀` be the database state immediately before `tx_begin(d)`. Let `W` be the multiset of mutating SQL statements (`INSERT`, `UPDATE`, `DELETE`, schema mutations) executed against `d` (or any handle aliased through `tx_db(t)`) between `tx_begin(d)` and the consumption of `t`.
11+
12+
**Safety property #DB-2.1 (rollback-discards-writes)**: if `t` is consumed by `tx_rollback(t)`, then for every query `q` issued against `d` *after* the rollback, the answer to `q` is precisely the answer it would have had over `σ₀` (i.e. as if `W` had never happened).
13+
14+
**Safety property #DB-2.2 (commit-promotes-writes)**: if `t` is consumed by `tx_commit(t)`, then for every query `q` issued after the commit, the answer is the same as serially applying `W` to `σ₀`.
15+
16+
**Safety property #DB-2.3 (savepoint locality)**: if a savepoint `s` is opened with `tx_savepoint(t, s)`, mutations `W_s` are issued, and then `tx_rollback_to(t, s)` is called, the database state mid-transaction reverts to the state at `tx_savepoint(t, s)` while leaving the outer transaction live.
17+
18+
## 2. Echo-types audit (2026-06-01)
19+
20+
Per owner directive, every proof in AffineScript must first audit `hyperpolymath/echo-types`, reuse if applicable, extend upstream **with proofs** if not, then cross-document.
21+
22+
**Audit finding** (full report under `tasklist/sub-agent reports/2026-06-01-transaction-echo-audit`): echo-types has no Transaction-specific instantiation today, but carries three reusable abstractions:
23+
24+
1. `EchoLinear.LEcho` + the `weaken : LEcho linear → LEcho affine` collapse map, with `no-section-weaken` proving no recovery after weakening.
25+
2. `EchoSecurity.Security` record + `exit-collapses-at` / `audit-no-recovery-at` — the boundary-collapse template structurally mirrors rollback-discards-writes.
26+
3. `EchoNoSectionGeneric.no-section-of-collapsing-map` — the generic lemma the Transaction proof reduces to.
27+
28+
## 3. Proposed upstream extension
29+
30+
A new module `TransactionMutations.agda` in echo-types, structured as follows:
31+
32+
```agda
33+
module TransactionMutations where
34+
35+
-- The write-set carrier: an opaque set of mutations parametrised by
36+
-- the table-type T being mutated.
37+
record WriteSet (T : Set) : Set where
38+
field
39+
applied : List Mutation -- audit trail
40+
-- Algebra: `empty`, `append`, `compose` give a monoid action on
41+
-- DbState — the action and its laws are deferred to a separate
42+
-- module to keep this carrier import-light.
43+
44+
-- The rollback log is the witness that a WriteSet was discarded.
45+
record RollbackLog {T : Set} (ws : WriteSet T) : Set where
46+
field
47+
discarded-at : Timestamp
48+
-- The trivial receipt: rollback emits no recoverable signal.
49+
receipt : Trivial
50+
51+
-- The collapse map that powers the safety proof: every WriteSet
52+
-- rolls back to the trivial receipt, and the generic
53+
-- no-section-of-collapsing-map (already in echo-types) gives the
54+
-- no-recovery lemma free.
55+
rollback-collapses-at :
56+
{T : Set} (ws : WriteSet T) → RollbackLog ws → Trivial
57+
rollback-collapses-at _ _ = trivial
58+
59+
rollback-discards-writes :
60+
{T : Set} (ws : WriteSet T) →
61+
EchoNoSectionGeneric.no-section-of (rollback-collapses-at ws)
62+
rollback-discards-writes ws = no-section-of-collapsing-map _
63+
```
64+
65+
The Security-record instance lives in a sibling `TransactionSecurity.agda`, parametrising `EchoSecurity.Security` by `(Resource := WriteSet T)`, `(Receipt := RollbackLog ws)`, `(exit := rollback-collapses-at ws)`.
66+
67+
## 4. Cross-doc seam
68+
69+
- AffineScript stdlib: `stdlib/Transaction.affine` carries the safety obligation statement in its module-level docstring and references this file by name.
70+
- AffineScript codegen + smoke: `lib/codegen_deno.ml` and `tests/codegen-deno/transaction_smoke.{affine,harness.mjs}` *witness* the property at the Node runtime level by snapshotting tables on `txBegin` and restoring on `txRollback`. The witness is not a proof — it's an executable check that the runtime mock observes the same invariant the formal proof will eventually establish.
71+
- Echo-types upstream: tracked at `hyperpolymath/echo-types` (issue to be filed alongside this document landing; link added once the issue number is allocated).
72+
73+
## 5. Why this matters
74+
75+
Transactions are the canonical user-facing application of affine resource discipline to data: the type system already enforces "at most one consumption" of `Tx`, and the safety theorem closes the loop by saying that the *one* consumption that discards (rollback) is observationally equivalent to never having started. This is the proof every database textbook assumes — having it mechanised against an echo-types carrier means AffineScript's transaction surface inherits the same "no recovery from boundary collapse" story echo-types already proves for region-exit auditing.

lib/codegen_deno.ml

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,20 @@ const __as_dbLastError = (h) => {
560560
const v = globalThis.__as_sqlite.lastError(h);
561561
return v == null ? "" : String(v);
562562
};
563+
// ---- Sqlite transactions (db-theory #2) ----
564+
// `Tx` is an opaque handle; the host adapter is required to
565+
// invalidate it on `commit` / `rollback` so that subsequent calls
566+
// throw a host-side `Error` (the affine type system's
567+
// at-most-one-use guarantee is enforced statically on the AS side;
568+
// this host invariant catches FFI-side aliasing bugs in tests).
569+
const __as_txBegin = (h) => globalThis.__as_sqlite.txBegin(h);
570+
const __as_txCommit = (t) => { globalThis.__as_sqlite.txCommit(t); return 0; };
571+
const __as_txRollback = (t) => { globalThis.__as_sqlite.txRollback(t); return 0; };
572+
const __as_txSavepoint = (t, n) => { globalThis.__as_sqlite.txSavepoint(t, n); return 0; };
573+
const __as_txRelease = (t, n) => { globalThis.__as_sqlite.txRelease(t, n); return 0; };
574+
const __as_txRollbackTo = (t, n) => { globalThis.__as_sqlite.txRollbackTo(t, n); return 0; };
575+
const __as_txDb = (t) => globalThis.__as_sqlite.txDb(t);
576+
const __as_txIsLive = (t) => (globalThis.__as_sqlite.txIsLive(t) ? 1 : 0);
563577
const __as_httpFetch = async (url, method, headers, bodyOpt) => {
564578
const init = { method, headers: __as_httpHeadersToObject(headers) };
565579
if (bodyOpt && bodyOpt.tag === "Some") init.body = bodyOpt.value;
@@ -866,7 +880,16 @@ let () =
866880
b "db_table_exists" (fun a -> Printf.sprintf "__as_dbTableExists(%s, %s)" (arg 0 a) (arg 1 a));
867881
b "db_import_csv" (fun a -> Printf.sprintf "__as_dbImportCsv(%s, %s, %s, %s)" (arg 0 a) (arg 1 a) (arg 2 a) (arg 3 a));
868882
b "db_export_csv" (fun a -> Printf.sprintf "__as_dbExportCsv(%s, %s, %s, %s)" (arg 0 a) (arg 1 a) (arg 2 a) (arg 3 a));
869-
b "db_last_error" (fun a -> Printf.sprintf "__as_dbLastError(%s)" (arg 0 a))
883+
b "db_last_error" (fun a -> Printf.sprintf "__as_dbLastError(%s)" (arg 0 a));
884+
(* ---- Sqlite transactions (db-theory #2 / stdlib/Transaction.affine) ---- *)
885+
b "tx_begin" (fun a -> Printf.sprintf "__as_txBegin(%s)" (arg 0 a));
886+
b "tx_commit" (fun a -> Printf.sprintf "__as_txCommit(%s)" (arg 0 a));
887+
b "tx_rollback" (fun a -> Printf.sprintf "__as_txRollback(%s)" (arg 0 a));
888+
b "tx_savepoint" (fun a -> Printf.sprintf "__as_txSavepoint(%s, %s)" (arg 0 a) (arg 1 a));
889+
b "tx_release" (fun a -> Printf.sprintf "__as_txRelease(%s, %s)" (arg 0 a) (arg 1 a));
890+
b "tx_rollback_to" (fun a -> Printf.sprintf "__as_txRollbackTo(%s, %s)" (arg 0 a) (arg 1 a));
891+
b "tx_db" (fun a -> Printf.sprintf "__as_txDb(%s)" (arg 0 a));
892+
b "tx_is_live" (fun a -> Printf.sprintf "__as_txIsLive(%s)" (arg 0 a))
870893

871894
(* ============================================================================
872895
Identifier sanitisation (JS reserved words -> trailing underscore)

stdlib/Transaction.affine

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// Copyright (c) 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
3+
//
4+
// Transaction.affine — affine-bounded transactional scope for the
5+
// Sqlite stdlib (db-theory #2).
6+
//
7+
// A `Tx` is an opaque, affine handle returned by `tx_begin(db)` and
8+
// consumed *exactly once* by either `tx_commit(t)` or `tx_rollback(t)`.
9+
// While a `Tx` is live, all `db_execute` / `db_prepare` calls against
10+
// the same `Db` participate in the open transaction; on `tx_commit`
11+
// they atomically become visible to subsequent reads, on `tx_rollback`
12+
// they leak no writes (the safety property — see proof obligation
13+
// below).
14+
//
15+
// Nested scopes use savepoints (`tx_savepoint(t, name)` / `tx_release`
16+
// / `tx_rollback_to`), which is the standard SQLite-flavoured nesting
17+
// model (savepoints are NOT separate `Tx` handles — they share the
18+
// outer affine lifetime).
19+
//
20+
// **Affine discipline (carrier-level)**
21+
// Aliasing: `Tx` is `extern type`, opaque from the AffineScript side
22+
// — there is no copy constructor and no way to clone it. The host
23+
// adapter is required to invalidate the handle on commit / rollback
24+
// so that a use-after-consume turns into a host-side `Error`.
25+
// See `lib/codegen_deno.ml`'s `__as_txBegin` / `__as_txCommit` /
26+
// `__as_txRollback` contract for the JS side of this invariant.
27+
//
28+
// **Safety property (proof obligation #DB-2.1)**
29+
// `rollback-discards-writes` — for any `Tx t` returned by
30+
// `tx_begin(d)` and any sequence of `db_execute(d, ...)` issued while
31+
// `t` is live, if the lifetime ends with `tx_rollback(t)` then no
32+
// row visible to a `db_query*(d, ...)` issued *after* the rollback
33+
// reflects any of those executes.
34+
//
35+
// Status: pending against `hyperpolymath/echo-types` (see
36+
// `docs/proof-obligations/db-theory-2-transaction-safety.md`).
37+
// The echo-types audit (2026-06-01) found `LEcho.weaken` +
38+
// `EchoSecurity.Security` + `EchoNoSectionGeneric.no-section-of-
39+
// collapsing-map` carry the right shape but require a
40+
// `TransactionMutations.agda` instantiation upstream. Tracked at
41+
// the echo-types issue linked from the cross-doc file.
42+
//
43+
// **Why this is the natural db-theory #2**
44+
// Affine semantics maps directly to write-set isolation: an affine
45+
// resource is consumed exactly once, and the consumption is *the*
46+
// commit/abort decision. The same `(begin, commit, rollback)` shape
47+
// covers RDBMS transactions, Haskell STM atomic blocks, Clojure refs,
48+
// and Mnesia activities — picking SQLite as the first concrete
49+
// backend is purely the most useful one to ship first.
50+
51+
module Transaction;
52+
53+
pub extern type Tx;
54+
55+
// ── Top-level transaction lifecycle ────────────────────────────────
56+
//
57+
// `tx_begin` issues `BEGIN` on `d` and returns a fresh `Tx`. Exactly
58+
// one of `tx_commit` / `tx_rollback` must be called on the result;
59+
// the affine type system enforces "at most one", and host-side
60+
// invalidation enforces "use-after-consume is an error".
61+
62+
pub extern fn tx_begin(d: Db) -> Tx;
63+
64+
/// Issue `COMMIT`. Returns 0 on success. Invalidates `t` host-side.
65+
pub extern fn tx_commit(t: Tx) -> Int;
66+
67+
/// Issue `ROLLBACK`. Returns 0 on success. Invalidates `t` host-side.
68+
/// **All writes issued during `t`'s lifetime are discarded** — this
69+
/// is the safety property formalised in proof obligation #DB-2.1.
70+
pub extern fn tx_rollback(t: Tx) -> Int;
71+
72+
// ── Savepoints (nested scopes within a single Tx) ──────────────────
73+
//
74+
// Savepoints share the outer `Tx`'s affine lifetime. A savepoint is
75+
// named (string) and may be released (`tx_release`, forgets the
76+
// savepoint but keeps its writes within the outer scope) or rolled
77+
// back to (`tx_rollback_to`, discards writes issued since the
78+
// savepoint but keeps the outer `Tx` open). Both leave `t` valid.
79+
//
80+
// Naming convention is caller-controlled — SQLite uses string names,
81+
// and shadowing is per-name LIFO. Reusing a savepoint name is legal
82+
// but typically a programmer bug; the adapter does not guard it.
83+
84+
pub extern fn tx_savepoint(t: Tx, name: String) -> Int;
85+
pub extern fn tx_release(t: Tx, name: String) -> Int;
86+
pub extern fn tx_rollback_to(t: Tx, name: String) -> Int;
87+
88+
// ── Introspection ──────────────────────────────────────────────────
89+
//
90+
// `tx_db` returns the `Db` underlying `t` — useful for handing the
91+
// connection to a query function that takes `Db` (the type system
92+
// has no row-polymorphism over "Tx-or-Db", so this manual escape is
93+
// the pragmatic seam). The returned handle aliases the original
94+
// connection; calls against it participate in the open transaction.
95+
96+
pub extern fn tx_db(t: Tx) -> Db;
97+
98+
/// Returns 1 if `t` is still live (neither committed nor rolled back),
99+
/// 0 otherwise. Primarily for assertions and tests; production code
100+
/// should rely on the affine type system, not this runtime check.
101+
pub extern fn tx_is_live(t: Tx) -> Int;
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// db-theory #2 — Transaction codegen smoke.
3+
//
4+
// Six smoke functions exercise the eight Transaction.affine externs:
5+
// - smoke_commit_persists (tx_begin → execute → tx_commit; row visible after)
6+
// - smoke_rollback_discards (tx_begin → execute → tx_rollback; row NOT visible after) ← key safety property
7+
// - smoke_savepoint_release (savepoint → execute → release; writes kept within outer tx)
8+
// - smoke_savepoint_rollback_to (savepoint → execute → rollback_to; writes discarded, outer tx still live)
9+
// - smoke_tx_db_aliasing (tx_db returns a Db handle that participates in the open tx)
10+
// - smoke_is_live_lifecycle (live=1 between begin/commit; live=0 after)
11+
12+
use Sqlite::{db_open, db_close, db_execute, db_query_int};
13+
use Transaction::{tx_begin, tx_commit, tx_rollback, tx_savepoint, tx_release, tx_rollback_to, tx_db, tx_is_live};
14+
15+
pub fn smoke_commit_persists(path: String) -> Int {
16+
let d = db_open(path);
17+
let _id1 = db_execute(d, "CREATE TABLE t(n INTEGER)");
18+
let t = tx_begin(d);
19+
let _id2 = db_execute(d, "INSERT INTO t VALUES (42)");
20+
let _id3 = tx_commit(t);
21+
let v = db_query_int(d, "SELECT n FROM t LIMIT 1", "[]");
22+
let _id4 = db_close(d);
23+
v
24+
}
25+
26+
pub fn smoke_rollback_discards(path: String) -> Int {
27+
let d = db_open(path);
28+
let _id1 = db_execute(d, "CREATE TABLE t(n INTEGER)");
29+
let _id2 = db_execute(d, "INSERT INTO t VALUES (1)");
30+
let t = tx_begin(d);
31+
let _id3 = db_execute(d, "INSERT INTO t VALUES (999)");
32+
let _id4 = db_execute(d, "INSERT INTO t VALUES (1000)");
33+
let _id5 = tx_rollback(t);
34+
// After rollback only the pre-tx row should remain.
35+
let count = db_query_int(d, "SELECT COUNT(*) FROM t", "[]");
36+
let _id6 = db_close(d);
37+
count
38+
}
39+
40+
pub fn smoke_savepoint_release(path: String) -> Int {
41+
let d = db_open(path);
42+
let _id1 = db_execute(d, "CREATE TABLE t(n INTEGER)");
43+
let t = tx_begin(d);
44+
let _id2 = db_execute(d, "INSERT INTO t VALUES (1)");
45+
let _id3 = tx_savepoint(t, "sp1");
46+
let _id4 = db_execute(d, "INSERT INTO t VALUES (2)");
47+
let _id5 = tx_release(t, "sp1");
48+
let _id6 = db_execute(d, "INSERT INTO t VALUES (3)");
49+
let _id7 = tx_commit(t);
50+
let count = db_query_int(d, "SELECT COUNT(*) FROM t", "[]");
51+
let _id8 = db_close(d);
52+
count
53+
}
54+
55+
pub fn smoke_savepoint_rollback_to(path: String) -> Int {
56+
let d = db_open(path);
57+
let _id1 = db_execute(d, "CREATE TABLE t(n INTEGER)");
58+
let t = tx_begin(d);
59+
let _id2 = db_execute(d, "INSERT INTO t VALUES (1)");
60+
let _id3 = tx_savepoint(t, "sp1");
61+
let _id4 = db_execute(d, "INSERT INTO t VALUES (2)");
62+
let _id5 = db_execute(d, "INSERT INTO t VALUES (3)");
63+
let _id6 = tx_rollback_to(t, "sp1");
64+
// Outer tx still live; only the pre-savepoint insert should persist.
65+
let _id7 = tx_commit(t);
66+
let count = db_query_int(d, "SELECT COUNT(*) FROM t", "[]");
67+
let _id8 = db_close(d);
68+
count
69+
}
70+
71+
pub fn smoke_tx_db_aliasing(path: String) -> Int {
72+
let d = db_open(path);
73+
let _id1 = db_execute(d, "CREATE TABLE t(n INTEGER)");
74+
let t = tx_begin(d);
75+
// Aliased connection participates in the open transaction.
76+
let d2 = tx_db(t);
77+
let _id2 = db_execute(d2, "INSERT INTO t VALUES (7)");
78+
let _id3 = tx_commit(t);
79+
let v = db_query_int(d, "SELECT n FROM t LIMIT 1", "[]");
80+
let _id4 = db_close(d);
81+
v
82+
}
83+
84+
pub fn smoke_is_live_lifecycle(path: String) -> Int {
85+
let d = db_open(path);
86+
let _id1 = db_execute(d, "CREATE TABLE t(n INTEGER)");
87+
let t = tx_begin(d);
88+
let live_during = tx_is_live(t);
89+
let _id2 = tx_commit(t);
90+
// Cannot ask tx_is_live(t) after commit — affine: handle consumed.
91+
// Smoke returns the during-witness; harness asserts == 1.
92+
let _id3 = db_close(d);
93+
live_during
94+
}

0 commit comments

Comments
 (0)