Every install returns its inverse
Mounting a capability is the easy half. AgentBack now hands you the function that takes it back out.
AgentBack has a couple of dozen install* helpers.
installExplorer mounts Swagger UI at
/explorer. installMcpHttp mounts the MCP
endpoint at /mcp. installConsole mounts the
dev console. Each one takes an app and wires a capability into it.
Until recently none of them could be undone. The only way to remove an explorer was to stop the process. That sounds like a small gap. It is the reason plugin systems can gate and mount but never dismount, the reason teardown paths go untested, and the reason a dev server restarts instead of reloading.
A mount function that returns nothing is a side effect. A mount function that returns its inverse is a value you can hand back.
Where the idea came from
We took it from Cordis, a plugin and context kernel built on dependency injection and revertible side effects. It came out of the Koishi ecosystem, and in August 2026 it became the substrate under DeepSeek Harness, a local coding agent whose tagline is "Everything is a Plugin." Model access, the tool registry, the session log, the approval policy, and the agent loop itself are all plugins there.
DeepSeek published the theory alongside it: A Programming Paradigm for Spatiotemporal Composability, by Yifan Shi, Wei Zhang, and Tianyi Cui, at Peking University with DeepSeek-AI. Eighty pages, twenty-odd theorems, and almost no discussion of models. It names self-evolving agent harnesses as the motivating workload.
The paper splits dynamic composition into two axes.
Temporal composability means removing a component reverts its effects on the shared environment. Spatial composability means a component declares what it needs and the runtime resolves those dependencies as providers appear, change, or go away.
Cordis lifts both to runtime mechanisms. Every registration returns a disposer, and disposal runs in reverse order. Every dependency is a reactive subscription, so re-providing a service unloads and reloads everything that injects it. We read the v4 core, about 1,850 lines, before writing any of this.
We took some of it and left the rest.
| Axis | Cordis mechanism | What AgentBack does |
|---|---|---|
| Temporal | Every registration returns a disposer |
Taken. Every install* returns
Installed, composed LIFO.
|
| Temporal | A traceability proxy reattaches a service's disposers to the calling plugin | Skipped. It allocates a proxy per property access, and our helpers already own their footprints. |
| Spatial | Components declare what they need |
Taken, statically. provides and
inject in package.json, read off disk.
|
| Spatial | Dependencies re-resolve at runtime; a provider swap reloads its dependents | Skipped. A provider change is a restart. |
| Both | Fibers, epoch strings, hot module replacement with transactional rollback | Skipped. Adopting it wholesale is a second framework inside the framework. |
The paper makes the case empirically rather than by assertion. It
counted the hundred most-installed VS Code extensions: eighty-seven
contain executable code, so removing any of them requires restarting
the extension host and everything else loaded in it. Seven declare
dependencies on non-builtin extensions. The value you get back from
getExtension(...).exports is typed any, so
there is no checked interface between two extensions.
deactivate exists, but it runs at host shutdown, and it
separates destroying an effect from creating it, which is what makes a
forgotten line leak in silence.
React's useEffect is named as the closest structural
relative, since it does pair an effect with a cleanup the runtime
calls. Its limit is composition: a hook has to sit at the top level of
a component or another hook, never inside a condition, loop, or nested
function, and the effect body takes neither an async function nor an
iterator. Effects cannot be assembled out of other effects, so nothing
can derive a composite inverse from them. Cordis pairs every
atomic effect with an inverse and gets the composite inverse
by composition, which makes teardown derived from setup rather than
written beside it.
The contract we shipped
Every install* helper now returns an
Installed, which is one method:
const explorer = await installExplorer(app);
// ...later
await explorer.uninstall(); // /explorer answers 404 again
Helpers compose their own teardown with
composeTeardown(), a LIFO stack of disposers that runs in
reverse registration order, is idempotent, and aggregates failures
instead of stopping at the first one.
One rule does most of the work. A binding is retracted by
ownership, not by key possession. Context.bind()
replaces whatever sits at a key, so an uninstall that unbinds by name
would delete a binding the user shadowed over ours after we mounted.
The inverse checks that the binding at that key is still the exact
object it created, and does nothing otherwise.
The paper models an effect as a function of type
Γ → Γ × (Γ → Γ):
apply it to the context, get back the modified context and an explicit
inverse. The inverse is supplied by the caller at the moment the
effect is applied, and the runtime does not verify it. Writing a
correct one is the component author's obligation.
We check a little more than that, and only because our effects are narrow enough to afford it. Every footprint entry is a binding, so the runtime can ask whether the binding at a key is still the one it created. A general effect system has nothing to compare, which is exactly why the paper puts that burden on the author instead.
What the contract actually cost
Three things went wrong on the way, and all three are the kind that ship green.
The identity guard covers unbinding. It also has to cover restoring. When a plugin re-binds a key it was permitted to override, the inverse puts the displaced binding back, and for a while that restore ran unguarded. If a third party had claimed the key in the meantime, the unbind correctly did nothing and the restore then overwrote them, which is the exact bug the guard exists to prevent. An unguarded write destroys as much as an unguarded delete.
Shared components need reference counting.
Application.component() returns early when the key is
already bound to the same class, so when two plugins both list a
nested component, only the first plugin's binding diff contains it.
The plugin that has to retract it is whichever uninstalls last. We
record those bindings against the component instead of against a
plugin, and count references per application.
Our idempotency test passed while the code corrupted state. Each call
to uninstall() rebuilt its teardown, and
composeTeardown is idempotent only per instance, so a
second call re-ran every disposer and decremented a shared component's
reference count twice. The test used one plugin with nothing shared,
so the second pass had nothing left to corrupt: the identity guard
turned the repeat revert into a silent no-op and the assertion held.
Idempotency is invisible without shared state. The test that finds it
uses two plugins over one component.
Retraction also has to reach the transports. Express cannot unmount a
layer, so routes go dead behind a liveness flag the router checks per
request. MCP had a worse version of the same gap: a built server keeps
its tool map, and resolveMember fell back to
new ctor() when a binding vanished. An unmounted tool
stayed callable and ran without its injected dependencies. Removing
that fallback did not break any of the 129 existing MCP tests, which
is how we knew nothing depended on it.
One thing we get by convention rather than by construction, and the
paper is clear about why that is the weaker version. A provider has to
outlive its consumers through their whole teardown, because a
consumer's own cleanup often needs the dependency it is losing:
closing a pool means handing connections back to the thing that
provided them. Cordis spends a lifecycle state on this. A component
marked for deactivation stops offering its services immediately but
keeps everyone's existing view intact, and the step that actually runs
the inverse is guarded until nothing resolves to it. A progress
theorem shows the guard always releases. We get the ordering itself
from composing teardowns last-in-first-out, which holds inside one
report and not across independent handles, so the handles carry a
check instead: retracting a plugin whose declared
provides another live plugin still declares in
inject is refused, naming both, before any teardown runs.
That is the declarative version of the same property. It costs a
lifecycle state less and buys correspondingly less, since it cannot
keep serving through a transition, which is only worth having next to
the reactive re-resolution we do not do.
Why we stop short of hot swap
We took the temporal axis and the cheapest static slice of the spatial
one. Plugins declare provides and inject in
their package.json, and mount order is a topological sort
over those declarations. There are no fibers, no reactive
re-resolution, and no hot module replacement. When a provider changes,
AgentBack restarts.
package.json before any plugin code runs.
The paper is the strongest argument we have read against that choice. Its headline result is confluence: whatever sequence of loads, unloads, and reloads a running system went through, it settles at the state a from-scratch assembly would have produced. The dynamic history leaves no trace. Without that property, long-running hot-reloaded processes accumulate drift, which is why most teams restart instead.
Restart is the coarse workaround the paper concedes everyone uses, and it costs more as edit frequency rises: discarded process state, disrupted in-flight work, and a faulty self-modification that disables the process you need in order to recover. We think that trade is right for a framework whose job is serving requests. Cordis is betting on lifecycle coherence. We are betting on boundary coherence, one schema projected to every surface. Those are different bets, and the paper made ours more honest by naming what it gives up.
The people who wrote the theory shipped the careful version too. DeepSeek Harness has a creation mode where the agent inspects its own plugin tree and mounts or unmounts temporary plugins while it runs, which is the self-modifying harness the paper is aimed at. It is off by default, its trust level is documented as equivalent to shell access, and the temporary plugins live in process memory only: no files written, no packages installed, no config changed, all of it gone on restart. Even with proofs behind the recovery guarantee, the shipped default is the conservative one.
What it buys today
loadPlugins(app) returns a report that satisfies
Installed, so a mounted plugin set is retractable, and a
load that fails halfway still hands back an inverse for the plugins
that did mount. A plugin that contributes a REST controller has its
routes answer 404 after uninstall() and serve again after
a re-mount, on both the Express and the fetch host. Tests mount and
unmount in isolation instead of standing up a process each time.
A shared conformance suite runs the same install, serve, uninstall, 404, reinstall cycle against every helper that implements the contract, which is what keeps it from decaying one helper at a time.