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.
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:
(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:
∂Γ ≔ Γ × (Γ → Γ)
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:
- Thm 4 (pr₁ ∘ track(f,g) = f ∘ pr₁): tracking changes nothing about what the route registration does; it only records the undo on the side.
- Thm 5 (track respects sequencing): tracking the route, then the timer, then the roster one at a time is the same as tracking the three-step composite once. So we may reason about a plugin's whole load as one effect.
- Thm 7 (if g(f(γ)) = γ then recover(track(f,g)(γ,φ)) = recover(γ,φ)): after tracking an effect whose undo works, "recover" still lands at the same place it would have before. The quantity φ(γ) — "where recover would take us" — never moves. The paper names φ(γ) = γ₀ the soundness invariant; it is the thing every later result protects.
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:
𝔈Γ ≔ Γ → Γ × (Γ → Γ)
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:
(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:
ℑΓ ≔ μℑ. Γ → Γ × (Γ → Γ) × 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
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.
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).
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.