# Your plugin system couldn't replace plugins. So here's the transaction that you're missing.

> Source: <https://dev.to/luke_gree_fc8780a9e7/your-plugin-system-couldnt-replace-plugins-so-heres-the-transaction-that-youre-missing-21lm>
> Published: 2026-09-20 02:32:36+00:00

What if an upgrade fails halfway through activating? The question would be: what is still running?

In most plugin systems, the answer is: none. And in registries, the common bug could be as follows:

```
// the old plugin is already gone before the new one exists
registry.set('storage', await next.setup());
```

If `setup()` throws, the old working value is destroyed, and the new one never arrives. Now, your host is missing a capability it had a millisecond ago. In a daemon, that's an outage, not an error.

This is the failure mode that matters most for systems which can't restart: long-running CLI daemons, editors, dev tools, and the new wave of AI-agent extension hosts, where shipping an upgrade should be routine, not a maintenance window.

Moult treats it like one. A plugin is a versioned capability provider with an owned resource scope, and replacing it runs a protocol, not an assignment:

If anything fails before the commit, the candidate scope is disposed, and the failure stays invisible: the previous generation remains active and usable. Like a crab, the system only sheds its shell once the new one is ready.

``` js
import { capability, createRuntime } from '@moult/runtime';

const storage = capability<{ get(k: string): string | undefined }>('storage', '1.0.0');
const runtime = createRuntime();

runtime.install({
  id: 'memory.storage', version: '1.0.0',
  provides: [{ capability: storage }],
  setup: (ctx) => {
    const map = new Map([['k', 'v1']]);
    ctx.provide(storage, { get: (k) => map.get(k) });
  },
});
await runtime.start('memory.storage');

// A broken replacement is rejected — and v1 keeps serving:
await runtime.replace({
  id: 'memory.storage', version: '2.0.0',
  provides: [{ capability: storage }],
  setup: () => { throw new Error('bug in the new version'); },
}).catch((e) => console.log(e.code)); // REPLACEMENT_FAILED

// Usable, not just active: a consumer installed after the failure
// still binds the old generation's value.
let seen: string | undefined;
runtime.install({
  id: 'reader', version: '1.0.0',
  requires: [{ capability: storage, range: '^1.0.0' }],
  setup: (ctx) => { seen = ctx.require(storage).get('k'); },
});
await runtime.start('reader');
console.log(seen); // 'v1' — the failed v2 never existed to readers
```

That is the entire guarantee, runnable: the rejection is a structured `MoltError`, and a consumer installed after the failure still reads v1's value usable, not just active.

There's a second half to "versioned capability provider" that the snippet hides: capabilities carry a semver version, consumers declare semver ranges, and each token declares whether it accepts exactly one provider or aggregates many (`multiple: true`). Resolution runs during preparation; a candidate whose requirements don't resolve fails before setup, so a version mismatch can never become visible either.

It's what's enforced. Nine replacement transaction tests cover failed setup, failed validation, disposal ordering, and resource cleanup, on top of property-based and stress suites: 145 tests, each run under both Node and a DOM environment — 290 runs, all green. The 15 invariants are written down in `docs/guarantees.md`, and the repo's comparison harness runs the same failed-upgrade scenario against a naive registry, cordis, and Moult, publishing the raw numbers in `demo/comparison/RESULTS.md`:

| Runner | Survives | Leaked | 
|---|---|---|
| naive registry | no | 102 | 
| cordis 4.0.0-rc.9 | no | 0 | 
| @moult/runtime 0.1.1 | yes | 0 | 

Excerpt — the full table adds the blocked-stop diagnostic and the average-ms column.

*(Their caveat, which I'll repeat: environment-specific evidence, not a performance promise.)*

That safety isn't free: the whole benchmark scenario — install, one failed replace, and one hundred successful replaces — averages ~16ms against ~0.14ms for the naive registry (roughly ~0.16ms per replace in that environment, not ~16ms; scenario average, not per-call). You pay it per replace, never per capability read.

A note on the neighbours: HMR reloads modules, and Module Federation shares them. Both are code-delivery mechanisms — neither promises that an upgrade either fully applies or fully rolls back. Moult owns a different layer: the replacement transaction itself. And if you use Vite, `@moult/vite` routes module updates through the same replace path, so import/setup failures preserve the old generation and the bridge doesn't commit anything itself; the runtime's transaction does.

The honest limits, because a runtime that hides its limits can't be trusted with your uptime: Moult is not a sandbox — plugins are trusted code, and it governs lifecycle and capability visibility, not permissions. It's not a loader or bundler; there's no global registry. And v1 refuses to silently rebind dependents — replacing a provider with active dependents is rejected outright with a structured `REPLACEMENT_FAILED` error that names the dependent path, instead of quietly re-wiring them. Moult preserves service, not state: every generation gets a fresh scope, so in-memory handles don't migrate — React component state is explicitly not promised to survive replacement, and durable state belongs in a host-provided capability. The success path is just as strict: `replaced` fires before the old scope is disposed, old resources release in LIFO order, and if the old generation's disposal fails after commit, the failure is recorded and inspectable — the replacement stands; there is no rollback.

Around the runtime there's a small family: `@moult/events` (generation-scoped typed events), `@moult/react` (bindings for committed contributions), `@moult/test` (utilities for proving ownership, replacement, and leak behaviour), and `@moult/vite` (Vite HMR bindings that route module updates through the same transactional lifecycle).

```
npm install @moult/runtime  # Node 22 or newer
```

Repo: [github.com/neryva-lab/moult](https://github.com/neryva-lab/moult) — if you have an upgrade scenario your system can't survive, open an issue with the repro. I'd genuinely like to see it fail.

[docs/guarantees.md](https://github.com/neryva-lab/moult/blob/main/docs/guarantees.md)

[demo/comparison/RESULTS.md](https://github.com/neryva-lab/moult/blob/main/demo/comparison/RESULTS.md)

[@moult/runtime](https://www.npmjs.com/package/@moult/runtime)
