Skip to content

Dougong Core API specification

This is the specification layer

This document is the observable behaviour specification for @dougongjs/core, organised for precision rather than readability, and used to settle edge cases and implementation consistency.

If you are new to Dougong, read Core concepts and Writing plugins first — they describe the same model, unfolded in learning order.

When an implementation conflicts with this document, fix the implementation or the specification at the root. Do not add compatibility aliases.

Dougong Core is positioned as:

A capability composition and structured lifetime kernel in pure JavaScript/TypeScript.

It is not an IoC container, a reactive framework, an event framework or a frontend framework. It provides a small set of orthogonal atoms so that advanced capabilities can be composed from ordinary functions and ordinary objects.

1. Inviolable design axioms

1.1 One path

One abstraction layer and one semantic allow exactly one canonical entry point. Higher-level sugar must expand mechanically onto it and may not own a second state machine, transaction, dependency graph, resource stack or error model.

SemanticSole entry pointCore does not provide
Install a plugininstall()use / apply / load
Modify the installation planchange()a second transaction / batch
Publish a Serviceprovides + the setup return valuectx.provide / host.provide
Contribute to an ExtensionPointcontribute()add / append / register
Listen to an Eventon()listen / hook
Emit an Eventemit()dispatch / publish / fire
Register a cleanupcleanup()using / own / defer
Create a child lifetimelifetime(label)child / scope / fiber
Start a background taskspawn()run / fork / task
Classify cancellationisCancellationReason()checking only signal.aborted / matching only an error name
Validate declaration recordsassertPlainRecord()copied prototype / own-key checks in higher layers
Read a live valueget().value / a function call / getSnapshot()
Subscribe to changesubscribe()watch / listen / observeChanges
Update an Installationupdate()replace / reload / restart
Remove an installationremove()uninstall / delete
Release a resourcedispose()close / destroy / off

host.install(), installation.update() and installation.remove() are single-target sugar: internally each creates a one-shot ChangeSet and commits it. They own no second validation, queue or rollback logic.

assertPlainRecord(value, label, { fields, createError }) is the shared declaration boundary for Core and higher layers. It accepts only records whose prototype is Object.prototype or null, never reads inherited properties, and rejects arrays, symbol keys, non-enumerable own keys and fields outside fields. It throws TypeError by default; a higher layer with a structured error taxonomy may use createError(message) to preserve its own error type without copying the validation algorithm.

1.2 Composition closure

Composing objects of the same kind preserves the original semantics:

text
Lifetime + owned resources → Lifetime
Plugin + Group → Installation
ExtensionPoint contributions + an ordinary composer → Catalog / Pipeline
Manifest + reference → Artifact → Registration → Core Installation

A Group expands mechanically onto the canonical ChangeSet; Platform and reactive observe() may compose only through public APIs. None of the three may create a second registry or transaction state machine.

1.3 Semantic orthogonality

  • A Service expresses a stable capability; it does not broadcast events.
  • An ExtensionPoint holds open contributions; it does not decide ordering, override or business conflict policy.
  • An Event expresses a fact that already happened; it queries no result and retains no state.
  • A Lifetime manages temporal ownership only; it resolves no dependencies.
  • A Group manages installation ownership only; it creates no capability namespace.
  • A Plugin does not load other plugins; the loader lives in Platform.
  • A Host understands no HTTP, React, database, window or filesystem.

1.4 Explicit over implicit

Any relationship that changes capability resolution, lifetime ownership or execution order must be readable directly from a Contract, a Plugin or an explicit parameter:

  • Service selection is decided solely by the stable Contract ID in requires; never by a Group, the call stack, a current workspace or an ancestor context.
  • Setup order is decided solely by the Service dependency graph; install order, Events, ExtensionPoints and completion timing are never hidden dependencies.
  • Resource ownership comes from the Lifetime that created it; transferring across a boundary must be expressed explicitly through an ordinary parameter or a Disposable.
  • Domain configuration is composed through plugin config, method parameters or an explicit adapter Service — never a global interception chain, a proxy shadow or prototype-chain override.

"Convention defaults" may reduce boilerplate but may not change the semantics above. If deleting a declaration still leaves Core guessing the relationship from the environment, the abstraction has become too implicit.

1.5 Public protocols and utility types

Public types exist for downstream composition, not as implementation residue:

ExportPrecise responsibility
Service, ExtensionPoint, Event, OptionalService, Requirementthe three Contract identities and optional Service dependencies
ContractKind, ContractValuethe Contract-kind union and value-type extraction
Plugin, AnyPlugin, PluginContext, AwaitablePlugin declaration, heterogeneous collection shape, setup context and sync/async return boundary
Requirements, ResolvedRequirement, ResolvedRequirementsdeclared dependency maps and their resolved value types
Provisions, ProvidedServicesService provision declarations and setup return types
Host, HostOptions, HostStatus, HostSnapshotexecution boundary, construction options, status and diagnostic snapshot
Installer, ChangeSet, Group, InstallationUpdate, LifecycleStatusinstallation capability, transactions, structural ownership, installation updates and the shared Group/Installation lifecycle
GroupSnapshot, SnapshotViewstructural diagnostic entries and the unified read-only observation protocol
LifetimeContext, LifetimeOperations, LifetimePhasethe full Lifetime context, its composable operations and diagnostic phase
Task, BackgroundTask, Cleanupowned tasks, task callbacks and cleanup callbacks
Disposable, AsyncDisposablestrict synchronous and asynchronous release protocols for using and await using
disposeSymbol, asyncDisposeSymbolthe sole cross-runtime keys for those two release protocols
EventListenerthe Event listener signature
Logger, isLoggerthe diagnostic output port and its sole runtime classifier

2. The capability algebra

Core has four capability atoms and two orchestration atoms:

text
capability atoms
├── Service          a stable one-to-one capability
├── ExtensionPoint   a dynamic open contribution set
├── Event            a fact that retains no state
└── Lifetime         resource ownership and cancellation

orchestration atoms
├── Plugin   a capability producer with one setup
└── Host     dependency graph, transactions and Instance orchestration
AtomRetains current valueChanges dynamicallyBehaviour after change
Serviceyesprovider topology may changerebuild consumers
ExtensionPointyescontributions add/remove livenotify subscribers
Eventnolisteners add/removebroadcast this fact
Lifetimen/achildren may be createdparent releases all live children

A signal is a value type inside a capability, not a new Contract kind obtainable through requires.

3. Contract

Core has exactly six authoring entry points:

ts
import {
  createHost,
  definePlugin,
  service,
  extensionPoint,
  event,
  optional,
} from "@dougongjs/core"

Error classes are a catching boundary and do not count against the capability atom budget.

Declaration:

ts
const DATABASE = service<Database>("app/database")
const ROUTES = extensionPoint<Route>("http/routes")
const USER_CREATED = event<User>("users/created")

Uniform rules:

  • The first argument is a stable string ID and is the execution identity; object identity plays no part in matching.
  • At runtime, the return value is a frozen plain object whose shape is exactly { id, kind }. Its TypeScript type also carries a factory-private phantom brand, so a plain { id, kind } cannot accidentally masquerade as a Contract at compile time. The brand takes no part in runtime matching.
  • A Contract holds no execution state and is reusable across applications.
  • The ID must be non-empty with no leading or trailing whitespace. It is case-sensitive, and is neither trimmed nor Unicode-normalised.
  • One ID cannot serve two kinds in the same Host; doing so throws CONTRACT_CONFLICT.
  • Only successfully committed declarations and use by an active Lifetime register a kind. A failed setup, a rollback and an unmatched application-code read never occupy a Contract ID.
  • optional() is the sole typed constructor for its branded OptionalService wrapper and accepts only a Service. An ExtensionPoint's empty map is already a valid value, and an Event has no notion of a provider.

A fixed Contract ID should be declared exactly once in a codebase and exported from a stable module. TypeScript alone cannot prevent two modules from writing different type arguments for the same ID, so Dougong's architecture guard rejects duplicate fixed-string declarations in this repository; downstream codebases should enforce the same static rule. Parameterized Contract families are not duplicate fixed declarations.

When one interface needs several statically selected variants, build an explicit Contract family with an ordinary function instead of introducing an implicit scope:

ts
const workspaceStore = (workspace: string) =>
  service<Store>(`workspace/${encodeURIComponent(workspace)}/store`)

const ALPHA_STORE = workspaceStore("alpha")
const BETA_STORE = workspaceStore("beta")

The family function is the single declaration source: the type and the ID namespace are written once, and repeating the call with the same argument yields an equivalent ID without relying on object identity. Providers and consumers must declare the same concrete token. A Contract ID therefore carries both "what the capability is" and "which one is selected" as a stable identity, so the dependency graph, errors and diagnostics never have to explain a second scope tree. A dynamic tenant chosen per request must not expand the Installation graph without bound; use a Service that explicitly accepts a tenant/workspace parameter instead.

A Contract's value type is strictly invariant: Service<Admin> cannot silently widen to Service<User>, and the same rule applies to Event and ExtensionPoint. Each participates in both reads and writes; covariance would let the same Contract identity later accept a value satisfying only the wider type. Expose a narrower or wider domain capability with a new Contract and an ordinary adapter Plugin that performs the conversion explicitly.

Requirement means only “a factory-created identity allowed in Plugin requires”; it does not invent one value type parameter shared by every Service and ExtensionPoint. Use ContractValue<T> or ResolvedRequirement<T> to extract the value of an exact Contract, and use Requirements for heterogeneous declaration maps. Provisions selects the Service branch of Requirement directly instead of maintaining a second hidden Service identity.

Local configuration layering also uses an explicit Service adapter rather than a general intercept():

ts
const HTTP = service<HttpClient>("http/client")
const ALPHA_HTTP = service<HttpClient>("workspace/alpha/http")

const alphaHttpPlugin = definePlugin({
  name: "workspace.alpha.http",
  requires: { base: HTTP },
  provides: { http: ALPHA_HTTP },
  setup: ctx => ({
    http: withDefaults(ctx.base, { timeout: 5_000 }),
  }),
})

The adapter's input, output and affected closure are all visible in the ordinary dependency graph, and the wrapping policy is decided by domain code that actually understands the HttpClient type. Core neither guesses method calls with a proxy nor needs a separate configuration-merging protocol.

Core deliberately does not provide extensionPoint.keyed(), extensionPoint.many(), ordered() or override(). Those are Catalog, Pipeline or domain-specific composition policies, not contribution-set atoms.

4. Plugin

A plugin has exactly one shape:

ts
const usersPlugin = definePlugin({
  name: "app.users",
  config: usersConfigSchema,
  requires: {
    db: DATABASE,
    cache: optional(CACHE),
    routes: ROUTES,
  },
  provides: {
    users: USERS,
  },
  setup(ctx, config) {
    const users = createUsers({ db: ctx.db, cache: ctx.cache })

    ctx.contribute(ROUTES, "users.show", {
      method: "GET",
      path: "/users/:id",
      handler: request => users.find(request.params.id),
    })

    return { users }
  },
})

Function plugins, plugin base classes, decorators and { apply() } shapes are not supported.

Plugin preserves the exact config, requires and provides types of one declaration. When a composition root stores plugins with different shapes, use AnyPlugin, which erases only authoring-time generics:

ts
const plugins: readonly AnyPlugin[] = [databasePlugin, usersPlugin]
for (const plugin of plugins) host.install(plugin)

AnyPlugin is not another plugin kind and has no second execution path. It is only a storage type for values already defined by definePlugin(), not an authoring type for raw object literals. The installation boundary still revalidates and normalises every declaration through the same rules.

Erasure propagates through the stable identity. A precise Plugin produces Installation<typeof plugin> and retains its install/update config input; a declaration read from readonly AnyPlugin[] produces Installation<AnyPlugin> and may later be replaced by another AnyPlugin without an assertion. A precise Plugin cannot silently fall through to the erased signature to omit required config. To discard authoring-time types, the caller must first store the declaration explicitly as AnyPlugin.

4.1 requires

Each dependency alias becomes an own property of the context:

ts
requires: {
  primary: PRIMARY_DATABASE,
  analytics: ANALYTICS_DATABASE,
}

// setup
ctx.primary
ctx.analytics

There is no ctx.get(string), service locator, proxy, prototype-chain injection or module declaration merging.

A Service alias yields a stable value; an ExtensionPoint alias yields a stable ContributionView object. The context and ctx.meta are shallow-frozen, but a Service value itself is neither proxied nor frozen.

Within one Plugin, each Contract ID may be declared exactly once. Two aliases cannot reference the same capability, and one Contract cannot appear in both requires and provides; definePlugin() rejects these ambiguities immediately with a TypeError instead of leaving the dependency graph or setup to guess the caller's intent.

Reserved aliases:

text
signal meta log cleanup lifetime spawn on emit contribute

4.2 provides

A Service has one publication path: declare it in provides and return a same-named own property from setup.

ts
provides: { database: DATABASE },
setup() {
  return { database }
}

A missing declared output throws SERVICE_NOT_RETURNED. Even a ready-made value from application code should be wrapped in an ordinary Plugin; Core provides no host.provide() branch.

4.3 Configuration

Configuration accepts a Standard Schema and distinguishes input from output:

ts
StandardSchemaV1<ConfigInput, Config>
  • install(plugin, input) receives ConfigInput.
  • setup(ctx, config) receives the validated or transformed Config.
  • Schemas may validate asynchronously.
  • A configuration result is discriminated only by own value / issues properties, never through its prototype chain. Failure throws ConfigValidationError carrying a frozen issues list.
  • A schema result must be either a success object containing value or a failure object containing an issues array; malformed issues, messages or paths reject with a precise TypeError before setup.
  • Core neither clones nor deep-freezes config; defensive transformation belongs to the schema.

definePlugin() validates and normalizes declarations at definition time. A Plugin must be a plain record containing only name, config, requires, provides and setup; unknown fields, symbols, hidden properties and class instances are rejected. A config schema must declare the complete Standard Schema V1 version, vendor and validate protocol, while requires and provides must likewise be plain records containing only enumerable string own keys rather than arrays, Maps or class instances. The ChangeSet re-normalises at install and update boundaries so a JavaScript caller cannot bypass the factory.

5. The context API budget

With no dependencies, the context contains only:

ts
ctx.signal
ctx.meta
ctx.log

ctx.cleanup(disposer)
ctx.lifetime(label)
ctx.spawn(task)
ctx.on(event, listener)
ctx.emit(event, payload)
ctx.contribute(point, localKey, value)

ctx.signal is a standard AbortSignal. ctx.meta is a frozen InstanceMeta:

ts
interface InstanceMeta {
  hostName: string
  pluginName: string
  installationId: string
  groupId: string
}

ctx.log still uses the Host's Logger, but Core passes the current frozen InstanceMeta as the first detail so a sink never has to infer ownership from message text. It is a Lifetime-revocable narrow facade: cleanup may still log, while terminal disposal severs the Runtime/Logger edge and further use throws LIFETIME_DISPOSED. The Logger's four members are strict function properties; a narrow implementation that drops an unknown message or rest details does not type-check.

The operations on LifetimeOperations are strict function properties as well. A reusable utility targeting this protocol must accept the complete label, Task, Event and ExtensionPoint input domains; an implementation restricted to a local literal cannot masquerade as a full Context. Class methods still satisfy the structural protocol directly.

Filesystem, network, window, clipboard, storage, notification and router all belong in Services and must never become a context namespace.

The context provides no effect(), observe() or using():

  • using(ctx, resource) expands mechanically to ctx.cleanup(() => dispose(resource)).
  • reactive observe(ctx, source, listener) is fully implementable from get/subscribe + lifetime/spawn/cleanup.
  • Both live in a higher layer; Core needs no private privilege.

6. Service

A Service is a stable snapshot for the lifetime of an Instance:

ts
const db = ctx.db
db === ctx.db // always true while this Instance lives

Live Service proxies are forbidden. When a provider updates, the Host:

text
pre-validates the candidate graph and configs
→ stops affected consumers in reverse order
→ stops the old provider
→ starts the new provider
→ rebuilds consumers in dependency order

optional(SERVICE) follows snapshot semantics too. When a provider goes from absent to present or the reverse, the consumer Instance is rebuilt; a live context is never mutated.

Outside the Host and at test boundaries you may read:

ts
const users = host.get(USERS)
const cache = host.get(optional(CACHE)) // Cache | undefined

host.get() accepts only a Service or the existing optional(Service) wrapper, and reads only while the Host is active. A required Service that is unavailable throws SERVICE_UNAVAILABLE; an optional Service with no provider returns undefined. Inside a Plugin, only declared dependencies are available. Core adds no semantically duplicate tryGet().

The Host caches the validated dependency graph corresponding to committed execution state; host.get() performs provider and Service map lookups without rebuilding the graph. The idle state allows a stepwise, temporarily incomplete plan, so candidate graphs are built only during start() or a ChangeSet committed while the Host is active.

Executing a ChangeSet while the Host is active explicitly enters changing, and application-code host.get() refuses reads for that window. It never poses as a stable active state while old Instances stop and new Instances start. Only after the transaction succeeds or a rollback completes does it re-enter active and switch to the corresponding graph; if recovery is impossible it fails closed to idle. Application-code reads therefore see only "before commit" and "after commit"; candidate graphs and half-rebuilt Service maps never leak.

6.1 Startup scheduling

The dependency graph uses deterministic topological layers: within a layer there are no Service dependencies, so setups run concurrently, and the next layer waits for the previous layer to commit completely. Each layer has two phases:

text
prepare: concurrently create Lifetimes, resolve dependencies, run setup, validate Service outputs
commit:  after the whole layer succeeds, publish Services / listeners / contributions in stable install order

A failing setup aborts ctx.signal for the rest of the layer, waits for every setup in the layer to settle, and releases every prepared Lifetime. A failed layer publishes zero new capabilities; already-committed earlier layers are cleaned up uniformly by the outer startup or ChangeSet rollback boundary.

Concurrency comes only from the explicit Service dependency graph. Events and ExtensionPoints establish no startup edge, and the relative start and completion order of independent plugins is undefined. If you need ordering, declare a Service dependency; never rely on install order or timers. Stopping remains serial in full reverse dependency order to keep resource revocation deterministic.

Core currently offers no concurrency-limit configuration. It has no second scheduling mode and presets no process-level policy without data. To limit a class of expensive operations, the relevant Service supplies its own queue or semaphore, because only it knows the resource type and real capacity.

7. ExtensionPoint

7.1 The atomic model

Every ExtensionPoint is:

A dynamic contribution map owned by an Instance and identified by stable local keys.

ts
const ROUTES = extensionPoint<Route>("http/routes")

const contribution = ctx.contribute(ROUTES, "users.show", route)
contribution.update(nextRoute)
contribution.dispose()

The real key is composed by Core:

text
<escaped installation id>/<escaped local key>

where % and / become %25 and %2F, so the separator cannot make two different (installation ID, local key) pairs produce the same real key, while common keys stay readable.

This key expresses ownership identity only; it is not a domain ID and changes after reinstall. Put command IDs, ordering weights and other domain data in the value, then validate conflicts or sort explicitly. Never derive domain policy from the map key or insertion order.

Therefore:

  • different Installations using the same local key do not conflict
  • one installation with one local key may have exactly one live contribution
  • an update must go through the original Contribution
  • undefined is a legal contribution value; liveness is determined by the real key and record identity, never by using a value as a terminal sentinel
  • an obsolete Contribution is checked by record identity and cannot delete a later contribution with the same key
  • stopping an Instance removes all of its contributions
  • a failed setup publishes no contribution

Contribution<T> is a writable handle and does not covary into Contribution<Base>; only the read-only ContributionView<T> may widen safely with its value type. Narrow authority by handing out the View, not by hiding update() behind a wider generic annotation.

7.2 The unified observation protocol

ts
interface ContributionView<T> {
  get(): ReadonlyMap<string, T>
  subscribe(listener: () => void): Disposable
}

subscribe() notifies only about future invalidation. It does not fire immediately and carries no value. The caller explicitly performs "read the current value, then subscribe to the future":

ts
const rebuild = () => {
  router.replace([...ctx.routes.get().values()])
}

rebuild()
ctx.routes.subscribe(rebuild)

A ContributionView obtained from the context assigns its subscriptions to the current Lifetime automatically; early release still uses the returned dispose().

Application code can obtain a Host-owned stable view of the same committed Store:

ts
const routes = host.contributions(ROUTES)
const subscription = routes.subscribe(render)
await host.start()
render()

The view may be created before startup and keeps its identity across stop/start. It exposes committed snapshots only, and the caller owns the subscription Disposable. The call also records the Contract ID as an ExtensionPoint identity, so another Contract kind cannot later reuse that ID in the same Host. Plugins still obtain Lifetime-bound views only through requires. That stable identity across restarts also means retaining the view intentionally retains its Host observation chain; let it leave scope with the Host rather than placing it in a longer-lived global cache.

The snapshot is a genuinely read-only map with no set/delete/clear. Object identity is preserved when nothing changes, and a new snapshot is created when a change commits. However many times one ExtensionPoint changes inside a single Core ChangeSet, it notifies exactly once.

A ContributionView is a live capability owned by the Plugin Lifetime, not a store reference that can leak permanently. After an Instance stops, the old view's get/subscribe refuse to work and sever the store reference; a new Instance receives a new view. The view's public get/subscribe come from a narrow facade holding only a revocable binding — an arrow function created inside a store method scope must not implicitly capture the store. This boundary differs from the treatment of an old Service closure: a Service is an ordinary value resolved once, while a ContributionView keeps observing committed contributions.

An exception from a later subscriber goes to the Host onError and must not damage the Host command that produced the notification. The first read and the Plugin's own synchronous rebuild() errors still fail setup normally.

7.3 Higher-level composition

Domain uniqueness, ordering, override and folding must be composed on top of the raw contributions:

text
ExtensionPoint<Command> + keyOf(command.id) + conflict policy → CommandCatalog Service
ExtensionPoint<Middleware> + orderBy(order) + reduceRight    → middleware pipeline
ExtensionPoint<Theme> + keyOf(theme.id) + stack policy       → ThemeCatalog Service

These composers may expose a more domain-appropriate API, but their input must be the public ContributionView, their lifetime must use public cleanup/subscribe, and they may not reach the internal ContributionStore.

8. Event

ts
const TRACK_CHANGED = event<Track>("playback/track-changed")

const subscription = ctx.on(TRACK_CHANGED, listener)
await ctx.emit(TRACK_CHANGED, track)
subscription.dispose()

Every on() call creates an independent Listener registration. Reusing the same function does not let disposal of one registration revoke another.

An Event has exactly one dispatch semantic:

  • a single payload; use an object for complex arguments
  • asynchronous concurrent broadcast to every listener
  • awaits all of them
  • returns no business result
  • if any listener fails it throws an AggregateError, even with a single cause
  • a listener is owned automatically by the Lifetime that created it
  • listeners registered during setup are invisible until setup succeeds

emit() always reports failure through its Promise, including call-boundary failures such as a disposed Lifetime or an invalid Contract. Therefore void ctx.emit(...).catch(report) covers both pre-dispatch and listener phases. Event<void> may be emitted as ctx.emit(READY) without an explicit undefined.

If you need a result, use a Service. If you need an ordered processing chain, use an ExtensionPoint plus ordinary functions. If you need the current state, use a Service exposing a Readable/signal.

Events never replay. Initial state belongs in a Service getter, a Signal/Readable, or an ExtensionPoint's current snapshot; do not emit a “seed” during setup and rely on another plugin's listener already being committed.

An Event is a fact, not state, so an emit() is not itself rollback-able. External side effects produced during setup are the plugin's responsibility to compensate; Core's transactional promise covers the framework-visible Services, contributions and listeners.

9. Lifetime and Disposable

Every Instance inherently owns a root Lifetime. All listeners, contributions, subscriptions, tasks, child Lifetimes and cleanups created through the context belong to it automatically.

Release has one operation, dispose(), while two strict protocols make its timing explicit:

ts
interface Disposable {
  dispose(): void
  [Symbol.dispose](): void
}

interface AsyncDisposable {
  dispose(): Promise<void>
  [Symbol.asyncDispose](): Promise<void>
}

Resources are uniformly released with dispose(); removing an Installation from the plan is uniformly remove(). The two are never interchangeable.

Synchronous resources work with using; Lifetimes, Tasks and cleanups that must be awaited work with await using. The symbol method projects the same dispose() operation onto JavaScript syntax and owns no second state machine or error semantics. Dougong selects stable protocol keys when a runtime lacks the well-known symbols but does not mutate globals; explicit dispose() therefore remains available, while using requires native support or an application-supplied symbol polyfill.

Implementations of Dougong's structural release protocols must use Core's canonical keys rather than deciding against the runtime again:

ts
import { asyncDisposeSymbol, type AsyncDisposable } from "@dougongjs/core"

class Session implements AsyncDisposable {
  async dispose() {}
  [asyncDisposeSymbol]() { return this.dispose() }
}

Each value references the native well-known symbol when available and otherwise uses the corresponding global Symbol-registry key. Platform and Core resources therefore share one protocol identity; the independent, zero-dependency Reactive package keeps its equivalent foundation declaration.

9.1 cleanup

ts
const cleanup = ctx.cleanup(() => server.close())
await cleanup.dispose() // may release early; idempotent

Cleanups run in reverse registration order, and one failure never skips earlier resources. A single failure is rethrown as-is; multiple failures aggregate.

9.2 Child lifetimes

ts
const session = ctx.lifetime("session")

session.on(MESSAGE, listener)
session.spawn(signal => pump(signal))
session.cleanup(() => transport.close())

await session.dispose()
  • a parent releases every still-live child
  • releasing a child does not affect the parent
  • a child released early detaches from the parent's ownership set
  • dispose() is idempotent
  • the parent context and a child Lifetime use the same resource API
  • label is a required, non-empty diagnostic description; leading or trailing whitespace is rejected rather than trimmed. It takes no part in execution lookup or identity, and duplicates among siblings are legal
  • actively releasing a Lifetime or task cancels its signal with a frozen AbortError, while a parent cancellation forwards the parent signal's reason explicitly. Classification first requires an aborted signal, then accepts either the exact signal.reason or a standard AbortError; neither condition is sufficient by itself
  • repeated dispose() during an in-flight release shares one completion promise; repeated calls after the terminal state are completed no-ops. The caller that initiated the release still receives the original failure, but a terminal resource stops retaining a rejected promise or its error stack. Once released, a Lifetime expresses its terminal state with a fresh aborted signal and the shared stateless AbortError, severing both the old signal's listener closures and any historical reason that might carry application objects

9.3 spawn

ts
const task = ctx.spawn(signal => synchronize({ signal }))
await task.result
await task.dispose()

Releasing a task aborts first, then awaits the result settling. A background failure not handled synchronously by the caller is reported through the Host onError. Only a rejection identical to signal.reason or an explicit AbortError is classified as cancellation; another failure merely occurring after abort is still reported, so genuine shutdown failures cannot disappear behind cancellation.

isCancellationReason(signal, error) is the sole public classifier for that rule. Platform loaders and downstream adapters reuse it instead of copying their own heuristic for what merely looks like cancellation.

A task that settles naturally immediately detaches from the parent Lifetime's ownership set and from the AbortSignal listeners. A later dispose() on that task is an idempotent completion and never retroactively aborts the signal of a finished task. Completed tasks do not accumulate in a long-lived owner proportional to history; releasing a parent still aborts and awaits every task that had not settled at that moment.

Waiting is a structured-ownership guarantee, not a timeout policy. If a task is stuck on a non-cancellable operation that never settles, Task.dispose(), parent Lifetime release and host.stop() all remain pending. Core never detaches such work implicitly; application code must explicitly own both an abandon-wait policy and its resource consequences. See the safe pattern in the Lifetime guide.

9.4 Stop order

A plugin's stop order is fixed and never depends on registration coincidence:

text
refuse new context work
→ revoke Services
→ revoke listeners, contributions and ContributionView subscriptions
→ abort the root signal
→ await background tasks
→ release child lifetimes in reverse order
→ run cleanups LIFO

Consequently a cleanup may not continue to emit() or acquire new resources; stopping has already crossed the "accept new work" boundary.

10. Host and ChangeSet

ts
const host = createHost({
  name: "desktop",
  logger,
  onError,
})

Host options are a plain record containing only name, logger and onError. Only enumerable own properties are read; unknown fields, symbols, hidden properties, arrays and class instances are rejected immediately. The logger and onError values remain structural ports and may themselves be implemented by ordinary objects or class instances.

Installer precisely means “can install into an ownership position”: it contains install/group/change and is implemented by Host and Group. A higher-level collaborator that consumes only the transaction entry point declares Pick<Installer, "change"> on its side; a narrower port without installation capability must not be named Installer.

10.1 Install and start

ts
const database = host.install(databasePlugin, config)
host.install(usersPlugin)

await host.start()
await database.ready()

install() synchronously returns a stable Installation and queues a single-item ChangeSet onto the Host command queue. Plugin-shape errors throw synchronously; commit and startup errors surface through ready() / start().

ready()'s barrier sits after the whole command: it settles only once candidate-graph validation, the Instance switch and the ExtensionPoint batch publication have all finished. A caller reading a ContributionView immediately after await installation.ready() sees only the committed snapshot and never needs to wait an extra tick.

The command queue linearises install, update, remove, start and stop. One failure never destroys the ability to queue later commands.

Core expresses that semantic with a single SerialQueue, which Platform's change and activation queues reuse:

ts
const commands = new SerialQueue()
const result = commands.run(operation) // the caller receives its own value or error
await commands.settled                 // await everything queued at read time

run() continues with the next item whether the previous one succeeded or failed; the internal tail records only completion boundaries and never rejects, and each item's raw result goes only to its own caller. It is the command serialization protocol shared by Hosts and higher-level orchestrators, and owns no Host, transaction or error-classification state.

10.2 Installation

ts
installation.status
installation.ready()
installation.update({ plugin })
installation.update({ config })
installation.update({ plugin, config })
installation.remove()

update() covers both config and Plugin declaration replacement. Its argument must be a plain record containing only enumerable plugin / config own properties and at least one of them; unknown fields, symbols, hidden properties, arrays and class instances are rejected immediately. There is no replace/reload/restart. A Plugin update may not change its name; the Installation and its ID stay stable while the active Instance is replaced.

Installation's sole type parameter is its underlying Plugin declaration, not four independently supplied config/requires/provides parameters that can drift apart. The declaration remains the single source of truth: a precise declaration stays precise, while an AnyPlugin erased once remains erased across both install and update.

Because update() accepts the Plugin and config belonging to that declaration, a writable Installation must remain invariant; Installation<typeof adminPlugin> cannot silently widen to Installation<AnyPlugin>. If a heterogeneous collection only waits for or removes Installations, define the minimum read/control protocol on the consuming side instead of exporting another framework type that discards update constraints:

ts
type ManagedInstallation = Pick<Installation, "id" | "status" | "ready" | "remove">
const managed: ManagedInstallation[] = [host.install(adminPlugin), host.install(auditPlugin)]

Once an Installation reaches removed it revokes its control reference to the Host and releases the Plugin declaration and config. A terminal remove() succeeds idempotently and update() rejects with INSTALLATION_REMOVED; keeping a removed Installation never keeps the Host alive.

When an Installation fails before commit, a caller already awaiting ready() still receives the original Error. If setup or a config validator throws a non-Error value, the first public command and the stable failure state share the same INSTALLATION_UNAVAILABLE error, with the original value in cause. After the Installation detaches from the Host, it keeps only a name/message/code data summary and reconstructs an error at the call boundary on a later ready(). A JavaScript Error's stack may retain the whole orchestration object graph and must not become a hidden ownership edge on a terminal Installation. Failed Installations still attached to an active Host keep the original error for diagnostics and retry semantics. Platform's terminal Registration follows the same rule.

10.3 The canonical ChangeSet

ts
const change = host.change()
change.update(provider, { plugin: providerV2 })
change.update(consumer, { plugin: consumerV2 })
change.remove(legacy)
const extra = change.install(extraPlugin)
await change.commit()

Rules:

  • one-shot; sealed after the first commit()
  • commit is idempotent; repeated calls return the same promise
  • an empty ChangeSet manufactures neither a fake changing status nor a diagnostics revision, but still crosses the Host command queue and owner-authority boundary in submission order; an earlier Group removal makes a subsequently submitted stale empty draft reject with GROUP_REMOVED
  • one Installation may appear only once per ChangeSet
  • Installations from another Host are rejected
  • the candidate dependency graph and every affected config are validated before any Instance stops
  • during execution the Host is changing and application-code Service reads are closed
  • an active change rebuilds only the targets and the affected transitive consumers in the old and new graphs
  • multiple changes share one stop, start, rollback and ExtensionPoint notification boundary

The Installation returned by change.install() is an exclusive draft of that ChangeSet. It gains Host control authority only at commit(); before then, calling its update/remove or targeting it from another ChangeSet rejects with INSTALLATION_UNAVAILABLE. An Installation detached after removal or failure likewise cannot re-enter a ChangeSet. host.install() returns an immediately controllable Installation only because that sugar has already synchronously submitted its internal single-item ChangeSet.

ChangeSet reuses the exact install type from Installer rather than maintaining a second config-inference signature. install() returns a draft Installation that the caller may need to keep; update() and remove() only stage commands and return void. ChangeSet is deliberately not half-fluent: stage each item, then call commit() separately.

When a change's setup fails, Core releases the partial activation and restores the old graph. If old resources cannot be stopped, the partial activation cannot be cleaned up, or the old graph cannot be restored, the Host fails closed to idle rather than falsely reporting active.

10.4 stop

host.stop() stops in reverse dependency order. The installation plan survives and Installations return to pending; a later start() creates new Instances from the current Plugin declarations and configs. Only remove() deletes from the plan.

11. Group

A Group is installation composition and subtree ownership. It is neither a seventh kernel atom nor a dependency-injection scope:

ts
const backend = host.group("backend", group => {
  group.install(databasePlugin, config.database)
  group.install(usersPlugin)
  group.group("transport", transport => {
    transport.install(httpPlugin, config.http)
  })
})

await backend.ready()
await backend.remove()

Host and Group share the same install/group/change verbs and both implement Installer. Internal higher-layer collaborators such as Platform that only compile transactions consume Pick<Installer, "change">, so they need not fake unused capabilities. The first configure must be synchronous so every declaration compiles into one ChangeSet; returning a thenable rejects immediately.

Group rules:

  • may nest
  • every installation inside configure shares one commit
  • ready() awaits the installations produced by configure crossing the ready barrier
  • remove() deletes the whole subtree in one Core transaction
  • a Group ChangeSet may only modify Installations in its own subtree
  • Group and Installation share status/ready/remove; only Installation adds update
  • a Group changes no capability visibility: Services, ExtensionPoints and Events belong to the whole Host

Nested Group configures share one explicit configuration session. Any child failure marks the entire session failed, so even a caller that catches the exception in an outer scope cannot keep appending declarations or commit a partial configuration. Non-Error failure values are classified at the configuration and Host transaction boundaries as GROUP_UNAVAILABLE; after a failed ready() the Group status must be failed and may not appear healthy merely because the failure value happened to be undefined.

Each Group keeps exactly one current readiness barrier. A Group that has not yet been established stays failed after a failed commit, and a later successful change replaces the old barrier and establishes it. An already-established Group whose change failed and whose previously committed state Core restored stays healthy. status and ready() always read the same lifecycle state.

Removing a Group revokes authority for the whole subtree at once, including ChangeSets created before removal but not yet committed. Every later install/update/remove/commit on one of those stale drafts consistently rejects with GROUP_REMOVED; it cannot cross the Group boundary into the Host. A terminal Group keeps only its identity and the removed status, remove() stays idempotent, and it no longer holds the Host, the configuration session or a historical failure stack, nor can it create Installations, child Groups or ChangeSets.

When workspace or tenant separation is needed, choose by semantics: a small fixed number of capability variants uses an explicit Contract family; data selected per request uses a Service taking a tenant/workspace parameter; a fully independent capability graph uses multiple Hosts; security isolation uses a Worker, iframe or process. Never pass a Group off as a resolution or security boundary.

12. Transactional publication

Starting one topological layer:

text
1. validate configs
2. resolve stable Service snapshots and ContributionViews for each plugin in the layer
3. create each root Lifetime and run setup concurrently
4. stage Contract kinds, listeners and contributions; cleanups enter their own rollback stacks immediately
5. validate every Service output in the layer
6. after the whole layer succeeds, register Services and publish staged capabilities in stable install order
7. mark the layer active, then move to the next topological layer

When the prepare phase fails:

text
Published Services         0
Published contributions    0
Published listeners        0
Registered Contract kinds  0
Acquired resources         all release attempted

Host start, stop and a ChangeSet committed while active use ExtensionPoint batches: an observer sees only the pre-operation or post-operation snapshot, never per-plugin intermediates.

13. The unified observation protocol and the reactive layer

ContributionView, Host diagnostics, Platform diagnostics and @dougongjs/reactive signals all adopt one structural protocol:

ts
interface Readable<T> {
  get(): T
  subscribe(listener: () => void): Disposable
}

Producing such operational diagnostics uses Core's single write-side primitive, SnapshotPublisher:

ts
const snapshots = new SnapshotPublisher(readSnapshot, reportError)

export const diagnostics = snapshots.view // exposes only get / subscribe
snapshots.invalidate()                     // mark invalid and notify
snapshots.dispose()                        // freeze the terminal state and sever closures

view is an authority narrowing, not a second observation API: a reader may only get/subscribe, and the owner may drive invalidation and termination only through SnapshotPublisher. Every subscription has independent identity; disposal immediately withdraws a notification whose turn has not started. A subscriber failure is handed to the explicit reporter without preventing later subscribers from being notified; if the reporter itself fails, the Publisher finishes the notification pass and then preserves both failures in an AggregateError. dispose() freezes the last snapshot before severing the reader, reporter and existing subscriptions, so a historical view can still read the terminal state without keeping the owner alive. Host, Lifetime and Platform diagnostics take this path directly; ContributionStore composes the same Publisher and adds only Lifetime ownership around each subscription, so registering one function twice still creates two independent subscriptions. No higher layer may rewrite the subscription registry or error boundary.

Where a snapshot needs map semantics it uniformly uses ReadonlyMapSnapshot. It accepts only the Map or entry-iterable inputs admitted by its type, copies the input and exposes only ReadonlyMap methods, avoiding the fake immutability of Object.freeze(new Map()), on which set/delete/clear still work. It guarantees only the container's structural immutability; entry values should be frozen as they enter the snapshot.

@dougongjs/reactive is an independent foundation package:

ts
signal(initial)
computed(calculate)
batch(callback)
observe(lifetimeOwner, source, observer)
  • a signal holds the current value
  • computed auto-tracking applies only to synchronous, pure, lazy, cached computation
  • batch accepts only a synchronous callback and coalesces repeated notifications per subscription identity
  • observe is a higher-level Lifetime combinator: it explicitly reads one source, creates a child Lifetime for the current value, and on change releases the old child before creating the new one. The observer must be synchronous, and a failed later replacement stops the observation and releases the subscription and the current child Lifetime
ts
const endpoint = computed(() => `${base.get()}/${account.get()}`)

observe(ctx, endpoint, (url, lifetime) => {
  const socket = new WebSocket(url)
  lifetime.cleanup(() => socket.close())
})

observe() uses only the public get/subscribe/lifetime/spawn/cleanup, so it is neither a Core privilege nor a second execution engine. Core does not depend on reactive, and third-party Readables are structurally compatible.

Structural compatibility unifies only the observation protocol; it does not flatten the ownership boundary of a resource's origin. A ContributionView injected through the context is a live capability bound to the current Lifetime, and a subscription created directly on it is owned by that Lifetime automatically. A standalone signal or third-party Readable has no implicit owner, so a direct subscriber holds the returned Disposable itself, or hands it to observe(owner, source, observer) to be composed into an explicit Lifetime. Both still have one subscribe() and one dispose(); the only difference is whether a clear structural owner already exists.

Solid-style bare effect(), dependency arrays, deep proxy stores and watchEffect/autorun/reaction are not provided. Effect-TS may be used inside a Service or attached through a one-way adapter, but does not enter Core.

14. Diagnostics and the encapsulation boundary

ts
host.diagnostics.get()
host.diagnostics.subscribe(notify)

A snapshot contains the Host name/status/revision, an InstallationSnapshot map and a GroupSnapshot map. When an entry carries its latest failure, error is precisely typed as Error because the state machine has already classified non-Error values before publishing diagnostics. The snapshot, its entries, arrays and maps are all read-only; diagnostics cannot control the Host.

A running InstallationSnapshot also carries an independent lifetime observation view:

ts
interface LifetimeSnapshot {
  readonly label: string
  readonly phase: "active" | "disposing" | "disposed"
  readonly cleanups: number
  readonly tasks: number
  readonly listeners: number
  readonly contributions: number
  readonly contributionViews: number
  readonly subscriptions: number
  readonly children: readonly LifetimeSnapshot[]
}

const lifetime = host.diagnostics.get().installations.get(id)?.lifetime
const current = lifetime?.get()
const subscription = lifetime?.subscribe(render)

The root node's label is the stable installation ID, and every children entry corresponds strictly to one real lifetime(label) ownership relationship. Node counts describe only the resources that Lifetime owns directly, and children lists only direct child Lifetimes. Subtree totals are derivable recursively from this irreducible set of facts, so no second aggregate state is stored in the snapshot. The whole snapshot is recursively frozen and exposes no Lifetime, resource object, callback or store.

A label answers only "why do these resources live together". It is not a capability ID, a lookup key or a new scope. Duplicate labels create no conflict and change no release semantics. Leaf resources such as cleanups, tasks and listeners add no naming overloads of their own; a child Lifetime is created only where a shared release boundary genuinely exists. Core never guesses nodes from function names, call stacks or ordinals, and never fabricates tree levels merely to implement categorised counts.

A resource change updates only this small view: it does not bump the Host revision or rebuild every InstallationSnapshot. A caller wanting to observe resource churn subscribes to the nested view explicitly. A child Lifetime detaches from the tree as soon as it terminates; after an Instance stops, new InstallationSnapshots no longer carry lifetime, and an already-obtained old view stops at a childless, all-zero disposed terminal state without retaining the Host.

Public facade objects and the top-level Host / Platform are frozen and narrow. Plain JavaScript inspection of own properties or prototypes will not reveal:

text
InstallationRecord
GroupNode
ContributionStore
EventHub
LifetimePort
ChangeSet port
ChangeSet discard / Installation attach / revoke
The Host's Group orchestration port
staged publication methods
Platform Artifact / Core Installation

TypeScript's private is not treated as a security measure; the implementation uses real #private fields or separate facade objects to prevent JavaScript shape leakage.

Context restrictions are not a security sandbox either. A same-realm plugin can still reach globalThis, the DOM or fetch. Untrusted plugins must go into a Worker, iframe, restricted realm or separate process.

15. Error conventions

Programming-shape errors use TypeError. Decidable model errors use DougongError.code:

codeMeaning
CONFIG_INVALIDthe Standard Schema rejected the config
CONTRACT_CONFLICTone ID serving several kinds
SERVICE_CONFLICTa Service has several providers
SERVICE_MISSINGa required Service has no provider
SERVICE_CYCLEa Service dependency cycle or self-dependency
SERVICE_NOT_RETURNEDsetup did not return a declared output
SERVICE_UNAVAILABLEan external read or an Instance binding is currently unavailable
INSTALLATION_REMOVEDan operation on an Installation already removed from the plan
INSTALLATION_UNAVAILABLEthe Installation cannot enter an awaitable state
INSTALLATION_IDENTITYupdate attempted to change the plugin name
GROUP_REMOVEDan operation on a removed Group
GROUP_UNAVAILABLEthe Group has not been successfully established

Because an Event by definition collects every listener failure, it always throws an AggregateError. Lifetime and shutdown attempt every resource first: a single failure is rethrown as-is, and multiple failures aggregate. A rollback or fail-closed spanning several phases uses AggregateError uniformly.

Errors from background tasks, subscribers and later observes cannot return to the original synchronous stack and are reported through onError. A failure inside onError itself must not change the Host command being observed.

16. Forbidden directions

  • plugin base classes and framework inheritance trees
  • decorator dependency injection and string service locators
  • proxy contexts, prototype-chain shadowing, live Service proxies
  • signals/effects, React, HTTP, Node, filesystem or timers built into Core
  • domain policies such as extensionPoint.keyed/many/ordered/override entering Core
  • conflating scope with Group
  • a lifecycle hook matrix
  • adding serial/bail/waterfall query modes to Event
  • a Plugin arbitrarily mutating the global Installation graph
  • loaders, manifests, HMR or permissions entering Core
  • passing context API restrictions off as a security sandbox
  • fields or methods hidden only in type declarations but leaked on the JavaScript object

17. Final criteria

Answer these before any new requirement:

  1. Is it a stable capability, an open contribution, a transient fact or a resource?
  2. Can it be composed from Service, ExtensionPoint, Event, Lifetime and ordinary functions?
  3. Does it genuinely require changing Core?
  4. Does a semantically equivalent entry point already exist at the same layer?
  5. Can the higher-level implementation use only public APIs?
  6. Does composition preserve the original lifetime, transaction and error semantics?
  7. Does it leak an internal registry, port, installation state object or a security illusion?
  8. With React, Node, Wails and the reactive package removed, does Core still hold?

The design formula:

text
Plugin =
  setup(
    immutable service snapshot,
    live ContributionViews,
    config,
    lifetime,
  )

  atomic service outputs
  + owned contributions
  + owned listeners
  + owned resources

A user only needs to remember three sentences:

text
A plugin obtains capabilities through requires, provides Services through return,
and joins open extensions through contribute.

Every listener, contribution, task and cleanup belongs automatically to the
Lifetime that created it.

A Service change rebuilds consumers, an ExtensionPoint change notifies subscribers,
and an Event only broadcasts this one fact.

Released under the MIT License.