Revertible Effects: Making Runtime Changes Undoable
Cleanup Functions Solve Only Part of the Problem
Our first instinct might be to modify the plugin interface:
class SearchPlugin:
def load(self, runtime):
runtime.tools["web_search"] = self.search
runtime.hooks.append(self.on_event)
def unload(self, runtime):
del runtime.tools["web_search"]
runtime.hooks.remove(self.on_event)This is better.
But the forward operation exists here:
runtime.tools["web_search"] = self.searchwhile the inverse exists somewhere else:
del runtime.tools["web_search"]Correctness now depends on two pieces of code remaining synchronised.
Add one more effect:
runtime.prompts["search"] = SEARCH_PROMPTand forget to update unload().
You now have a leak.
The paper’s direction is different:
Make reversibility part of the effect itself.
From Mutation to Mutation + Inverse
Instead of:
def register_tool(runtime, name, tool):
runtime.tools[name] = toolimagine:
def register_tool(runtime, name, tool):
previous = runtime.tools.get(name)
runtime.tools[name] = tool
def undo():
if previous is None:
del runtime.tools[name]
else:
runtime.tools[name] = previous
return undoThe operation and recovery action are produced together.
The paper formalises a revertible effect over context \Gamma with the shape:
\mathcal{E}_{\Gamma} \coloneqq \Gamma \rightarrow \Gamma \times (\Gamma \rightarrow \Gamma)
An effect consumes a context state and returns:
- a new state; and
- a recovery transformation.
A strict effect requires that if:
e(\gamma) = (\delta, g)
then:
g(\delta)=\gamma
The inverse must actually recover the pre-effect state under the definition’s assumptions.(Cordiverse 2026)
Example: Our First Effect Runtime
class EffectScope:
def __init__(self):
self._undo = []
def effect(self, operation):
undo = operation()
self._undo.append(undo)
return undo
def dispose(self):
while self._undo:
undo = self._undo.pop()
undo()Tool registration:
def install_search(scope, runtime):
def add_tool():
runtime.tools["web_search"] = web_search
def undo():
del runtime.tools["web_search"]
return undo
scope.effect(add_tool)Hook registration:
def add_hook():
runtime.hooks.append(log_search_result)
def undo():
runtime.hooks.remove(log_search_result)
return undo
scope.effect(add_hook)Prompt registration:
def add_prompt():
runtime.prompts["search"] = SEARCH_PROMPT
def undo():
del runtime.prompts["search"]
return undo
scope.effect(add_prompt)The plugin’s lifetime now owns a ledger:
EffectScope
│
├── undo(add_tool)
├── undo(add_hook)
└── undo(add_prompt)
DeepSeek Harness’s own implementation has the same practical concern: teardown ordering must be explicit when mutable services change.
Why Undo Happens in Reverse Order
Suppose installation performs:
1. register service
2. register listener
3. create child component
The child may depend on the listener.
The listener may depend on the service.
So teardown should normally be:
3. remove child
2. unregister listener
1. unregister service
This is last-in, first-out (LIFO) cleanup.
The intuition is the familiar inverse-composition law:
(f \circ g)^{-1} = g^{-1} \circ f^{-1}
The paper’s effect composition carries recovery functions through composition so that inverses of atomic operations compose into a recovery path for the larger computation.(Cordiverse 2026)
Nested Agent Effects
Suppose installing our MCP plugin performs four managed operations:
def install_mcp(scope, runtime):
register_connection(scope, runtime)
register_tools(scope, runtime)
register_prompt(scope, runtime)
register_event_handler(scope, runtime)Its effect ledger becomes:
MCP Scope
undo connection
undo tools
undo prompt
undo handler
Disposal executes in reverse:
undo handler
undo prompt
undo tools
undo connection
Now add another plugin:
Search Scope
undo search_tool
undo search_prompt
Each component has a separate lifetime:
MCP Component
└── effect ledger
Search Component
└── effect ledger
This is much better than one application-wide cleanup stack.
But a harder problem appears as soon as effects interleave.
The Harder Case: Effects Interleave
Suppose components A and B modify shared state in this order:
A1
B1
A2
B2
A3
Now remove A while B remains.
A global LIFO stack would produce:
undo A3
undo B2
undo A2
undo B1
undo A1
That removes B too.
We could undo everything and replay B, but dynamic composition quickly starts resembling recovery machinery.
The paper therefore studies the conditions under which effects from different components can be independently recovered. This is where effect independence matters.(Cordiverse 2026)
Example: Interleaved Effects
Start with:
runtime.tools = {}Component A:
runtime.tools["search"] = search_toolComponent B:
runtime.tools["calculator"] = calculator_toolFinal state:
{
"search": search_tool,
"calculator": calculator_tool,
}Removing A should leave:
{
"calculator": calculator_tool,
}That is relatively safe because the effects occupy independent keys.
Now change the example.
A does:
runtime.tools["search"] = search_v1B does:
runtime.tools["search"] = search_v2What should unloading A do?
If A’s inverse says:
del runtime.tools["search"]it destroys B’s contribution.
If it restores a previous value, correctness depends on exactly which value was captured and on the permitted interleavings.
This is why:
Every effect has an undo function
is not enough for arbitrary dynamic composition.
The relationships among effects matter.
Relatable Example: Driver Cleanup
Kernel modules and drivers commonly have paired setup and cleanup operations.(The Linux kernel developers n.d.)
The conventional structure resembles:
def init():
register_a()
register_b()
register_c()
def exit():
unregister_c()
unregister_b()
unregister_a()The effect-oriented structure instead aims for:
effect(register_a)
effect(register_b)
effect(register_c)where each registration yields or carries the corresponding recovery action.
Limits of the Comparison
Kernel cleanup APIs do not make arbitrary shared-state mutations independent in the way this model requires.
A process may write a file.
A driver may alter external hardware.
A component may send a network request.
Those operations are not automatically reversible.
A revertible programming model can guarantee recovery only over effects that fit the model and its assumptions.
You cannot unsend an email with:
undo()At best you can perform a compensating action:
send_correction()Compensation and exact inversion are different properties.
A Slightly Better Effect Scope
class EffectScope:
def __init__(self):
self._undo = []
self._disposed = False
def add(self, operation):
if self._disposed:
raise RuntimeError("scope already disposed")
undo = operation()
self._undo.append(self._once(undo))
def _once(self, fn):
used = False
def wrapped():
nonlocal used
if used:
return
used = True
fn()
return wrapped
def dispose(self):
if self._disposed:
return
self._disposed = True
while self._undo:
self._undo.pop()()Now repeated disposal is harmless:
scope.dispose()
scope.dispose()
scope.dispose()This does not reproduce Cordis exactly, but it teaches the lifecycle property we need before moving on.
Previous: Why Dynamic AI Systems Break in Two Different Directions · Next: Reactive Coeffects: Dependencies That Control Component Lifetime