ADR 0012 — A transaction's membership is an explicit handle, not a bus-global flag

Status: accepted · Amends: ADR 0009 · Amended by: —

Context

commitTransaction(label, fn) grouped every commit() made inside fn into one undo step, so a parcel split — remove one, add two — is a single Ctrl-Z. It knew which commits belonged to the group by a single mutable field on the bus, #transaction: commit() checked "is a transaction open?" and, if so, ran the command inline and filed it under the group.

A single mutable flag is safe only while writes are synchronous. They are not — a commit() awaits the commit pipeline, and a validation middleware may await a server. So the flag was open across every await inside fn, and any commit submitted during one of those gaps joined the group, whether or not it had anything to do with it:

The veto that rolls a transaction back had the same shape of bug. It was recorded on a second bus field, #vetoed, set by any rejected commit — including a standalone one outside every transaction. Nothing cleared it there, so a rejected standalone commit() left #vetoed set, and the next unrelated commitTransaction read the stale reason and silently rolled itself back.

Both are the same root cause: transaction membership and its veto were global bus state, read across await points, when they are properly a property of one call. AsyncLocalStorage would scope them to the call stack, but it does not exist in the browser this library targets.

Decision

The transaction hands fn an explicit handle; membership is what goes through the handle.

await map.commands.commitTransaction('Split parcel', async (tx) => {
  await tx.commit(new RemoveFeaturesCommand([parcel.id]))
  await tx.commit(new AddFeaturesCommand('parcels', [left, right]))
})

Alternatives rejected

AsyncLocalStorage, keeping the bus-global flag and scoping it to the call stack. This is the textbook answer, and it is the right one on a server: membership stays implicit, fn needs no new parameter, and no in-tree caller changes. Rejected because it does not exist in the browser this library targets, and a mechanism that only works in half the environments we ship to is worse than an explicit handle that works in all of them.

Detect the interleaving instead of preventing it — timestamp each commit, or tag it with the transaction that was open when it was submitted, and reconcile afterwards. Rejected because it keeps the global field and adds a second mechanism to get right; the failure it is guarding against left the store looking correct throughout, which is exactly the class of bug detection logic is least likely to catch. Serialising through the write queue removes the interleaving outright.

Roll a vetoed transaction back by undoing its own children, rather than restoring the snapshot. It would close the one documented edge below — a non-transient dispatch() that fires during an async transaction's await and keeps its history entry after the rollback reverts its store write. Rejected because undoing the children makes a rolled-back transaction a sequence of inverse operations rather than a revision-preserving no-op, which is the property the deep-equality rollback test enforces and the one a caller can actually reason about. The guidance is stated in #enqueue instead: an undoable state change should prefer commit().

Consequences

Edit this page on GitHub — this site is generated from docs/adr/0012-transaction-scope-is-an-explicit-handle.md, which is the source of truth.