Transactions and change
A running application needs to install plugins, remove them and swap configuration. Dougong's guarantee is:
Transactions expose only committed state. A change either takes effect entirely, or the execution graph returns to what it was — never a half-built set of Instances.
A single change
const installation = host.install(plugin, config)
await installation.ready()
await installation.update({ config: nextConfig })
await installation.remove()Before host.start(), install() only records a declaration. After it, the call runs a Host transaction.
Atomic multi-Installation change: ChangeSet
When several operations must succeed or fail together, use change():
const changes = host.change()
changes.install(newProvider)
changes.update(current, { plugin: nextVersion })
changes.remove(deprecated)
await changes.commit()This is the canonical entry point for multi-Installation change. install() / update() / remove() are its single-item degenerate forms — they run the same path internally and own no second state machine.
Properties:
- One-shot — no modification after
commit(); calling it again returns the same promise - One operation per installation — the same installation cannot be both updated and removed in one ChangeSet
- Nothing before commit — an abandoned draft never touches committed execution state
What failure does
Dougong has three levels of failure handling, in increasing severity.
1. Rollback
The new graph cannot start → restore the previous graph; host.status returns to active.
const changes = host.change()
changes.install(brokenPlugin)
await expect(changes.commit()).rejects.toThrow("setup failed")
expect(host.status).toBe("active")2. Fail closed
The old graph also cannot be restored (say an Instance's cleanup threw, so whether its resources were released is unknown) → do not pretend to be healthy. Stop the Host at idle and throw an error aggregating every cause.
Better for application code to see "I stopped, and here is why" than to be handed execution state that may be damaged.
3. Aggregated reporting
Multiple failures during shutdown aggregate into an AggregateError with every cause retained. No error is ever silently swallowed.
Validation precedes shutdown
Every affected Installation's Plugin config is validated before any running Instance is stopped.
const changes = host.change()
changes.update(a, { config: validConfig })
changes.update(b, { config: invalidConfig })
await expect(changes.commit()).rejects.toMatchObject({ code: "CONFIG_INVALID" })A misspelled config field never leaves your application halfway down.
Contribution sets are atomic too
Within one transaction, stopping Instances withdraw their contributions and starting ones add theirs. Both halves are published as a single step:
const observer = definePlugin({
name: "app.observer",
requires: { views: VIEWS },
setup(ctx) {
ctx.views.subscribe(() => snapshots.push([...ctx.views.get().keys()]))
},
})
const changes = host.change()
changes.update(outline, { plugin: outlineV2 })
changes.update(search, { plugin: searchV2 })
await changes.commit()
snapshots.lengthThis change produces one snapshot notification. Observers never see an intermediate empty set.
Without that batch boundary an observer would watch the set briefly empty in the middle of a successful change, then refill one entry at a time. This is the difference between HMR and two sequential updates, and it is exactly the count example 12 asserts.
Incremental restart
A change does not restart the whole application. Dougong computes the affected closure: the changed installations plus their transitive dependents, unioned over both the old and the new graph.
A ← B ← C update B
D ← E E is unrelated to B
Affected: B, C Untouched: A, D, EInstances of unrelated Installations are never stopped; their Service values, Lifetimes and background tasks survive intact.
The startup model
host.start() has four steps:
- Build the graph — derive dependencies from
requires/provides, detect cycles and duplicate providers - Validate — every config through its Standard Schema
- Start layers concurrently — Installations in the same topological layer run setup in parallel
- Commit the layer — only after every Service output in that layer validates are Services registered and staged listeners and contributions published
Step 4 is what "transactions expose only committed state" means at startup: listeners and contributions registered during setup are staged, and if any plugin in the layer fails, not one of them is published.
Observable result of a failed prepare, for that layer:
Published Services 0
Published contributions 0
Published listeners 0
Registered Contract kinds 0
Acquired resources all release attemptedGraph-time errors
These are thrown before any Instance starts:
| Code | Condition |
|---|---|
SERVICE_CYCLE | dependency cycle; the message carries the real path |
SERVICE_CONFLICT | two plugins provide the same Service |
SERVICE_MISSING | a required Service has no provider |
CONTRACT_CONFLICT | one ID used as two kinds |
Cycle detection reports the actual path, not "everything that failed to sort":
Installation dependency cycle: app.a:1 -> app.b:2 -> app.a:1Group: an installation ownership tree
A Group manages a set of plugins as one unit:
const feature = host.group("editor", (group) => {
group.install(syntax)
group.install(formatter)
group.group("lsp", (nested) => {
nested.install(languageServer)
})
})
await feature.ready()
feature.status
await feature.remove()The configure callback must be synchronous (returning a promise throws), because the entire Group's content is committed as one ChangeSet. Nested Groups share that commit, but an Installation created by nested.install() still belongs to the inner Group; calling nested.remove() removes only that inner subtree.
A Group can also run its own transaction:
const changes = feature.change()
changes.install(extra)
await changes.commit()A Group changes no visibility
The most common misreading
A Group expresses installation ownership only. It is not a capability scope, not a provider shadow tree, not a permission boundary and not a security sandbox.
Service resolution and ExtensionPoint/Event visibility are always Host-wide. Putting a Plugin inside a Group does not make it "see only" what is in that Group.
So how do you get those things:
| Need | The right tool |
|---|---|
| Several statically selected variants of one capability (a store per workspace) | An explicit Contract family: service<Store>(`app/ws/${id}/store`) |
| Request-time tenant selection | An ordinary method parameter: store.forTenant(id) |
| Security isolation | A separate Host, Worker, iframe or process — a real boundary |
An established Group is not poisoned by failure
const group = host.group("stable", (p) => p.install(good))
await group.ready()
const changes = group.change()
changes.install(broken)
await expect(changes.commit()).rejects.toThrow()
expect(group.status).toBe("active")
await expect(group.ready()).resolves.toBeUndefined()A Group that has been established at least once keeps presenting its last committed state after a change that rolled back completely. A Group that never established (its first commit failed) stays failed.
Observing state
host.status
host.diagnostics.get()
host.diagnostics.subscribe(() => render())host.status is idle, starting, active, changing or stopping. Diagnostics expose the Host name, status, Installation snapshots and Group snapshots.
The changing status exists for a reason: while a Host transaction is in flight the application-code read window is closed — host.get() throws SERVICE_UNAVAILABLE rather than handing you an intermediate state that is being replaced.
Next
- Reactive and observation — driving Lifetime rebuilds from a signal
- External plugin delivery — manifests, permissions, lazy activation
- Core API specification — exact semantics and edge cases