Revertible Effects: Making Runtime Changes Undoable

Agents
Systems
Distributed Systems
How effect scopes, inverse operations, and nested lifetimes make runtime changes recoverable.
Author

Chirag Sehra

Published

August 17, 2026

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.search

while 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_PROMPT

and 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] = tool

imagine:

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 undo

The 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:

  1. a new state; and
  2. 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_tool

Component B:

runtime.tools["calculator"] = calculator_tool

Final 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_v1

B does:

runtime.tools["search"] = search_v2

What 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

References

Cordiverse. 2026. A Programming Paradigm for Spatiotemporal Composability. https://github.com/cordiverse/paper.
The Linux kernel developers. n.d. Driver Basics. https://docs.kernel.org/driver-api/basics.html.