Chapter 4

Revertible effects — undo as a value

The question this chapter answers: when a plugin is removed, how do we undo everything it did — completely, in the right order, and without the plugin author writing an uninstall routine? The paper's answer is that every effect should hand back its own undo, and the runtime should keep those undos. The rest is working out what "keep" has to mean for that to be sound.

Running example

A plugin stats does three things when it loads: it registers an HTTP route /stats, starts a one-second timer, and adds its name to a roster of live plugins. When stats is disabled, the route must disappear, the timer must stop, and the roster entry must go — and nothing else should change.

4.1 An undo is a stored function

Any impure function f : X ⇝ Y can be read as a pure one if you make the world an explicit argument: f : Γ × X → Γ × Y. Fix the input and look only at what happens to the world: you get a map Γ → Γ. That map is the effect. "Register route /stats" is a function from worlds-without-the-route to worlds-with-it.

To make an effect undoable, pair it with a second function g : Γ → Γ that takes the world back. Undoing is one-sided: we require g ∘ f = id ("do, then undo, lands where you started") and say nothing about f ∘ g ("undo, then redo" need not give the identical world — the redone route may get a fresh internal id).

If you do two effects and want to undo both, you undo the second first. Written as a rule on pairs:

Definition 1 — twisted composition

(f₁, g₁) ∘ (f₂, g₂) ≔ (f₁ ∘ f₂, g₂ ∘ g₁)

In words: to sequence two undoable effects, sequence the forward parts normally (f₂ first, then f₁), and sequence the undos in the opposite order (g₁ first, then g₂). This is where last-in-first-out teardown comes from — it is derived from wanting g ∘ f = id to keep holding, not decreed.

// twisted composition, in code
const seq = ([f1, g1], [f2, g2]) => [x => f1(f2(x)), x => g2(g1(x))]

4.2 The undo stack: state + accumulator

A runtime that tracks effects needs to carry, alongside the world, the composite of every undo so far. The paper packages the two together:

Definition 2 — effect context

∂Γ ≔ Γ × (Γ → Γ)

In words: a pair (γ, φ) — the current world, and one function φ (the accumulator) that would take it back to the beginning. Initially (γ₀, id): nothing has happened, so the undo is "do nothing".

Two operations move this pair:

track(f, g)(γ, φ) = (f(γ), φ ∘ g)        recover(γ, φ) = (φ(γ), id)

In words: track applies the effect to the world and pushes its undo onto the front of the accumulator (front, because φ ∘ g runs g first). recover runs the whole accumulator and resets it.

let world = γ0, undoAll = id
function track(f, g) { world = f(world); const prev = undoAll; undoAll = x => prev(g(x)) }
function recover()   { world = undoAll(world); undoAll = id }

Three short theorems say this bookkeeping is sound. Each is one sentence in the running example:

4.3 The undo must be produced when the effect runs

Look at the roster step. To remove stats from the roster you need to know which entry is yours — and the entry's id is only known after you add it. So the undo cannot be written down before the effect runs; it has to be returned by the effect, closing over what the effect learned. Def 3's track(f, g) fixed g in advance and required it to work at every world; the fix reshapes the effect so the undo comes out with the result:

Definition 8 — effect function, and its witness

𝔈Γ ≔ Γ → Γ × (Γ → Γ)

In words: an effect function takes the world and returns (the new world, the undo for this application). The witnessed version 𝔈Γ* adds the promise: whenever e(γ) = (δ, g), then g(δ) = γ. The undo only has to work at the world it was produced at — which is exactly what lets it use the roster id it just got.

// an effect function: world in, (world, undo) out
const addToRoster = (name) => (γ) => {
  const id = γ.roster.add(name)                  // learned only now
  return [γ, (γ2) => { γ2.roster.remove(id); return γ2 }]
}

Since effect functions return pairs, you cannot compose them with plain ; the paper defines the composition that threads the world through and twists the undos:

Definition 9 — effect composition

(f ⋄ g)(γ) = let (δ, s) = g(γ) in let (ε, t) = f(δ) in (ε, s ∘ t)

In words: run g, feed its world to f, return f's world and an undo that runs f's undo t first, then g's undo s. Thm 10: this is associative with unit η(γ) = (γ, id). Thm 11: if both parts keep their promise, so does the composite.

One more idea, which you can skim on first read. The paper "lifts" an effect function so it acts on the effect context (world + accumulator) instead of the bare world — effect : 𝔈Γ → 𝔈∂Γ, Def 12. The interesting part is what the lifted version returns as its undo: track(g, pr₁∘e). Read that as: undoing is itself an effect, and the way to undo an undo is to redo. Thm 15 checks it: the world is restored exactly; the accumulator is restored exactly only if g ∘ f = id globally; but in every case the soundness invariant holds — and that is all recovery needs.

4.4 A plugin loads by a sequence of effects: iterators are generators

stats did three things, each with its own undo. The natural shape is a generator: run a step, yield its undo, run the next. The paper reifies that shape as a type:

Definition 17 — effect iterator

ℑΓ ≔ μℑ. Γ → Γ × (Γ → Γ) × Maybe(ℑ)

In words: an iterator is a function from the world to (new world, undo for this step, and either Nothing — done — or Just(the rest)). The "rest" is again an iterator, hence the μ. The paper calls this "a reified delimited continuation, the structure mainstream languages expose through yield" — it is literally a function*. The gap between two steps is a place the runtime may stop the plugin (chapter 8 uses this).

ctx.effect(function* () {
  const route = ctx.http.route('/stats', handler)
  yield () => route.remove()               // undo 1
  const timer = setInterval(tick, 1000)
  yield () => clearInterval(timer)         // undo 2   (runs before undo 1)
  const id = ctx.roster.add('stats')
  yield () => ctx.roster.remove(id)        // undo 3   (runs first of all)
})

Thm 16 is the guarantee for one plugin: apply the steps in order, undo them in reverse order, and every undo meets exactly the world its own step produced, with the soundness invariant holding throughout. A plain effect function is the degenerate iterator that is done after one step.

4.5 What is guaranteed here, and what is not

Local means "one plugin, by itself"

Thm 16 covers one plugin's own steps, undone in its own LIFO order. Two things arrive the moment a second plugin exists: (1) undoing out of that order — disabling stats while a plugin loaded after it stays; (2) a sequence that interleaves other plugins' effects between stats's steps. Both need independence (chapter 7), which is a condition on the effects, not a property of this construction.

Haskell reading

This is bracket / ResourceT / Acquire with the release handed to a runtime that owns the plugin's lifetime instead of a lexical block; MonadResource's ReleaseKey is the nearest thing to Cordis's dispose closure. In your Effect-TS stack it is Effect.acquireRelease inside a Scope — the difference being who opens and closes the scope, and when (chapter 13).

Olai

Today: no teardown; plugins run for the process lifetime. Under Cordis: every registration goes through ctx.effect() / ctx.on() with a paired disposer, unwound LIFO. The spike confirmed disabled rows dispose live fibers cleanly.