ADR-0030 — STM enters as a transactional handler in v1; privilege is concurrency-only
-
Status: Accepted
-
Summary: STM enters v1 as a transactional handler — NO new kernel primitive; privilege (shared heap) is concurrency-only and deferred.
-
Depends-on: 0025, 0023, 0001, 0018, 0026, 0016
-
Status: Accepted
-
Layer: K (kernel semantics — how STM enters the model)
-
Depends on: 0025 (resumptive state handler — STM reuses it), 0023 (CK machine + throws handler — rollback-on-abort), 0001/0018 (effect rows — the
stmlabel), 0026 (laws on the tested rung), 0016 (architecture in force) -
Date: 2026-06-23
Context
Rung 3 (the "ledger" — STM + recovery/rollback) is the other half of the v1 MVP (PRD §3.1:
imperative/State + STM). STM does not exist in the kernel — the Core.lean "STM" hits are all
tvar (rung 2's μ var); the only mention is a stale comment in the legacy Eval.lean citing the
deleted ADR-0003. So STM must be designed in, and the project's standing position (invariants #3, #5)
is that STM is a privileged kernel primitive — "the only one" — NOT a handler. That raises the
question this ADR answers: what does privilege earn us, and must STM be privileged in v1?
A literature sweep (Harris et al. PPoPP'05; Guerraoui–Kapalka PPoPP'08; Tomášek TU-Delft; Lesani–Chlipala C4 OOPSLA'22; Levy CBPV) was run to ground the answer in the source, not instinct.
Decision
In v1, STM enters as an ordinary transactional handler over the existing kernel — state ⊗ exception
composed — with NO new kernel primitive. Privilege (a runtime-owned shared heap) is deferred to the
concurrent runtime, where it is the only thing a handler cannot provide.
v1 STM = a handler, reusing existing machinery
──────────────────────────────────────────────────────────────────────────────
stm operations newTVar / readTVar / writeTVar = up-operations on an `stm` label
atomically M = handle M with (transactional handler) — carries (heap Θ, alloc-set ∆)
as handler state, exactly as rung 1's state handler carries a cell
commit normal exit ⟹ expose Θ′
abort/exception ⟹ restore Θ (discard the write delta), KEEP allocations ∆ ← all-or-nothing
retry ≈ abort (single-threaded retry = deadlock in Harris; fold into all-or-nothing)
orElse a b a aborts/retries ⟹ discard a's writes, run b (OR2/OR3 ≈ exception-discard)
Sub-calls (research-sharpened): retry ≈ abort (cleanest single-threaded; no blocking machinery);
orElse is in the minimal core (exception-style discard, makes the demo composable).
Correction (2026-06-23, post-implementation): "
orElsecosts nothing" was optimistic. The kernel'sthrowshandler discards the continuation and yields the payload — it cannot run an alternative. SoorElse a b("runbifaaborts") needs a genuine recovery/catch handler (a newHandlervariant, or a sum-tagging of the abort path). Real, small, but not free — scoped as a rung-3 follow-on; the verified all-or-nothing law + the running ledger ship without it.
Correctness theorem (the rung-3 moat law): all-or-nothing atomicity —
abort / retry / exception ⟹ store unchanged (modulo fresh allocations ∆). Property-tested via
plausible now (ADR-0026 tested rung), proved later. This is Harris's (ATHROW)/(OR3) discard rule, and
it is opacity's single-threaded degenerate case (Guerraoui–Kapalka) — the upgrade path to the
concurrency checkpoint is explicit.
Why this model (what privilege earns us)
Privilege is load-bearing only for concurrency, and the canonical source says so directly:
- A handler is a fold over ONE computation's tree. It can carry a heap + journal as handler state
and discard-on-abort (rung 1 + the throws handler), which is single-threaded STM. What it
structurally cannot do is observe another transaction's commit — optimistic read-set
validation, conflict detection,
retry-wakeup. Those need a heap that survives across independent computations, owned by the runtime. That shared heap is what "privileged" names. - Harris et al. PPoPP'05 — the reference — agrees verbatim. Their abstract semantics threads the
store directly and models abort as "discard the heap delta, keep allocations." They state the
high-level semantics has "no notion of transaction logs or rollback — these are implementation,"
relegating journal/validation to §6.1, where it exists to make atomicity hold under concurrent
committers. So journal/validation is a concurrency device, not part of the meaning of
atomically. - The reduction is a recognized result, not novel. Single-threaded transaction =
ExceptT-over-State(exception outside state ⟹ state mutations discarded = all-or-nothing); Tomášek models STM ops as algebraic effects interpreted by a handler. We cite, we don't defend. - CBPV favours it. Levy's home example for CBPV is a global store; the value/computation split
threads the store through the computation judgement only, and TVars-as-values fall out free. Grading
(McDermott FSCD'25) makes
stma row entry like any effect. Nothing forces a primitive in v1. - One construct per problem (invariant #1). Shipping STM as a handler unifies it with rung 1 and the throws handler instead of adding privileged Config-threading machinery we cannot yet exercise. v1's kernel stays at its minimal core with zero privileged primitives in use.
Rejected alternatives
- STM as a privileged primitive with a store in the CK Config (the original strawman). Why not: single-threaded, it buys nothing the handler doesn't (Harris puts the store in the semantics either way; the journal is the only addition, and it is a concurrency device). Adds Config-threading + un-exercised machinery now, violating invariant #1. Correct when threads exist — that is exactly where it returns.
- Serializability / opacity as the rung-3 theorem. Why not: vacuous single-threaded — a lone transaction always sees a consistent heap; every history is trivially serial. Spends proof budget for no information. All-or-nothing is the minimal-meaningful theorem and is opacity's degenerate case.
- Full concurrent STM in v1 (retry-blocking, contention). Why not: needs concurrency = multi-shot handlers / threads, which is post-v1 (ROADMAP ◊5+). v1 ships the rollback half of "STM + recovery."
- Linear (use-once) TVars via QTT now. Why not: references are ω-graded (freely shareable) in v1; linear references are the rung-5 "QTT surfaced" hinge. Defer.
Consequences
- Likely no kernel-primitive change (invariant #5 intact — STM-the-privileged-primitive remains a
named member of the five, simply unused in v1). The build adds: an
stmeffect label + its operation signatures (EffSig), a transactional handler (Handler.transaction, a multi-cell state handler with abort-discards-journal),atomically/orElseashandle-shaped forms, and the metatheory cases — paralleling rung 1's state handler, not rung 2's type-former extension. - The moat demo is the ledger: a transfer that aborts leaves balances untouched; the all-or-nothing
law is
plausible-tested (the second ADR-0026 tested-rung use, after rung 2). - The privileged-STM invariant (#3) is reframed, not dropped: it governs the concurrent runtime. The CLAUDE.md invariant text should note "privilege is concurrency-only; v1 STM is a transactional handler (ADR-0030)."
- References: a
transactionsgroup is added toreferences/(the library had zero STM material).
TVar representation (amendment 2026-06-23 — surfaced by the K2 preservation gap)
Building K1 exposed that two representation choices were left implicit, and both break preservation if unpinned. Resolved:
-
TVarRef = int(a heap index is an int). The handler's eliminators (newTVarreturns the new index,read/writetake one) type cleanly against the singlevint : intrule. LeavingTVarRefexistential madevint i : intun-typeable at the result type. -
The store is TOTAL / default-initialized, with monomorphic
intcells in v1.readTVaron an out-of-range index returns a default of the cell type rather than producingComp.oom. (K1 used that legacy untypable sentinel for a bad read — a category error: a well-typedreadTVar (vint 999)stepping toComp.oomfalsifies preservation.) A total store is the standard finite-representation of a totalLoc → Valmap; it makesreadTVartotal, so preservation closes with no change to the frozentype_safetystatement (◊2).writeTVarout-of-range is a type-safe no-op (source programs never hold an invalid ref — refs come only fromnewTVar— so the default/no-op paths are kernel-expressible but source-unreachable).v1 fixes the cell type
S = int(default= vint 0;readTVarmiss returnsΘ.getD i (vint 0)). This closes preservation with zero change to theHandler.transactionarity and zero edits to the committed K2 resume-typing helpers — which are written against the 2-argtransaction ℓ Θ. The general-Salternative (a caller-supplied default-cell witness carried by the handler,atomically default M, config-explicit at the boundary) is more general — TVars of any type, incl. rung-2 ADTs — but it bumps the constructor arity and would churn ~38 committed helper sites for a capability v1 (int-balance ledgers, counters) does not need. Deferred as a refinement, consistent with monomorphic-v1 (ADR-0027) and the project's stage-it discipline.
Rejected for the OOB-read case:
- Bounds invariant
i < |Θ|inHasConfig. Needs a non-trivial reachability invariant (the index is a raw int flowing through the program) — heavy, the wrong tool monomorphically. - Typed trap / generalize
Comp.oomto be typable-at-any-F-type (WASM-style). Cleanest long-term and aligns with the WasmFX backend (which traps on OOB), but it touches the frozentype_safetystatement ("well-typed ⇒ value or trap"). Deferred — a deliberate later change, not a rung-3 detour. - Abstract capability
TVarRef(a distinct value type, onlynewTVarintroduces it). "More correct" (invalid refs unrepresentable) but still needs heap-size tracking to discharge OOB at the kernel level. v1 takes the raw-int + total-store pragma; abstract refs are a refinement.
These are v1 simplifications, recorded so they are not mistaken for the final story.
Revisit if
- Concurrency arrives (threads / multi-shot handlers, ROADMAP ◊5+) → STM-the-privileged-primitive
returns: a runtime-owned shared heap, read-set validation,
retry-wakeup, and the correctness theorem climbs all-or-nothing → opacity (Guerraoui–Kapalka). This ADR's deferral ends here. - STM interleaves with effects that observe partial state mid-transaction. The handler-scoped journal keeps the deferral sound only while no effect can see a half-committed heap. This is the exact invariant the concurrency checkpoint must re-examine — if an effect needs to observe mid-transaction state, the handler model breaks and privilege returns early.
retry ≈ abortproves too weak (a program genuinely needs blocking-retry single-threaded — i.e. wants to wait on its own future write, which is a deadlock) → revisit, but that is a concurrency need wearing a single-threaded mask.- OOB-read should TRAP, not default (aligning with the WasmFX backend, which traps) → generalize
Comp.oom/add a typedtrapterminal and amendtype_safetyto "value or trap". Deferred from v1 (touches the frozen safety statement); the total/default store (above) is the v1 stand-in.