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
Recognize external Error valuesisError()repeating prototype checks at each boundary
Validate declaration recordsnormalizePlainRecord()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.

normalizePlainRecord(value, label, { fields, createError }) is the shared declaration boundary for Core and higher layers. It accepts data records whose prototype is Object.prototype or null, rejecting arrays, symbol keys, non-enumerable own keys, accessors and fields outside fields. It captures each field once from its own data descriptor and returns a frozen record with a null prototype, without ordinary property reads of the input. Callers must consume the returned record; the former assertion API has been removed. Capture is shallow: callbacks and opaque config payloads retain their identity, while nested declarations normalize at their own boundaries. It throws TypeError by default; higher layers may use createError(message) to preserve their error taxonomy without copying the capture and validation algorithm.

host.get() and Plugin requirement normalization share the same identity capture path. The optional wrapper and its Service are captured once; lookup consumes only the frozen result. Ordinary reads or later changes to the wrapper cannot select another Service.

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.
  • Call boundaries accept only own data properties for id/kind, without executing getters or reading inherited fields. Normalization captures and freezes one identity for validation, lookup and storage.
  • 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.
  • Lifetime captures an external token's inert identity before reading its current phase and signal to grant operation authority. Disposal during reflection rejects the operation with LIFETIME_DISPOSED, without registering a kind, creating resources or emitting an Event; InstanceCoordinator does not read the external token again.
  • 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,
}

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.
  • Core awaits only the schema's validation result. Without a schema, it passes the input unchanged; with a schema, it passes the successful result's value unchanged. Core neither unwraps Promise or thenable config payloads nor reads their then. A schema or setup must explicitly await a payload when it needs the resolved value. Installation and updates follow the same contract.
  • A configuration result is discriminated only by own value / issues properties, never through its prototype chain. Failure throws ConfigValidationError carrying a frozen issues list. Each issue message / path is read once; validation and storage use the same captured value without retaining third-party payloads.
  • 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 data record containing only name, config, requires, provides and setup; unknown fields, symbols, hidden properties, accessors 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 data records containing only enumerable string own keys rather than arrays, Maps, accessors 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 InstanceCoordinator/Logger edge and further use throws LIFETIME_DISPOSED. The Logger's four members are strict synchronous function properties. A narrow implementation that drops an unknown message or rest details does not type-check, and an accidental thenable is rejected at the call site while its rejection is observed.

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

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))

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().

Engine owns 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 entry iteration order and every value's identity are unchanged, and a new snapshot is created when a change commits. Publication order belongs only to ContributionStore; snapshots project that order, so an order change publishes a new snapshot even if all keys and values stay equal. 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 the captured listener set; a registration disposed before its callback begins is skipped
  • 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()

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. An unreadable error name is no evidence of cancellation, so the original failure still propagates; identity with signal.reason remains sufficient.

isError(value: unknown): value is Error is the shared Error recognition boundary for Core and higher layers. It checks the current prototype chain and returns false when that chain is unreadable, so reflection cannot replace the original rejection. Business classification of non-Error failures keeps the original value as cause; cancellation still checks identity with signal.reason first. Recognizing an Error does not guarantee readable custom fields; terminal records separately capture permitted diagnostic fields.

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
revoke Services
→ seal Context work across the entire Lifetime subtree
→ withdraw all listeners, subscriptions and views, then contributions
→ abort the subtree signals
→ drain tasks and descendant Lifetimes concurrently
→ each Lifetime runs cleanups LIFO after its tasks and children settle

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 data record containing only name, logger and onError. Only enumerable own data properties are accepted; unknown fields, symbols, hidden properties, accessors, 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. onError may return synchronously or return a thenable. Error reporting never blocks a Host command, but an asynchronous rejection is observed and routed to the fallback logger.

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. Readiness first awaits all Host commands queued before the call, including changes in another Group that affect this Installation; empty Groups use the same boundary.

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)
await commands.settled

settled waits for every operation queued when it is read.

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.diagnostics.get()
installation.diagnostics.subscribe(notify)
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 data record containing only enumerable plugin / config own data properties and at least one of them; unknown fields, symbols, hidden properties, accessors, 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. Repeated removal requests already accepted into the queue complete idempotently for the same member identity. A terminal remove() succeeds idempotently and update() rejects with INSTALLATION_REMOVED; keeping a removed Installation never keeps the Host alive.

Failed commands propagate the original Error. If setup or a config validator throws a non-Error value, the first public command and the stable failure state still attached to the Host share the same INSTALLATION_UNAVAILABLE error, with the original value in cause. A discarded Installation later rejects ready() with RecordedFailure, retaining only a frozen, bounded error record rather than reconstructing the original class. A failed Installation still attached to the Host keeps the original error for command propagation and retry, while diagnostics always use a recorded projection. Platform Registrations follow the same rule.

installation.diagnostics exposes the same immutable InstallationSnapshot used by Host diagnostics. It invalidates when the committed declaration or lifecycle changes. Installation and Host snapshots invalidate in one notification batch, so callbacks cannot read conflicting states. Removal publishes a final snapshot and reports final observer failures through Host onError, then releases the reader, reporter and subscriptions when the command’s readiness settles. All diagnostic errors use immutable RecordedFailure, including snapshots of recoverable failures; original Errors, custom fields and causes cannot retain execution objects through these snapshots.

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()
  • staging takes write authority only from the draft's current phase; if input reflection reenters commit(), the operation still reading its input rejects synchronously and cannot append an Installation or change to the submitted draft
  • 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
  • candidate declarations and membership remain in a transaction draft; public Group and diagnostics reads keep using committed declarations until the Engine commits
  • 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() first awaits Host commands submitted before the call, then the configuration establishment result and subtree installations; the boundary also covers dependencies outside the subtree and empty Groups
  • 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; Installation also has update and diagnostics
  • a Group changes no capability visibility: Services, ExtensionPoints and Events belong to the whole Host

Nested Group configures share one explicit configuration session. A rejected name, duplicate child, invalid Plugin, forbidden change() / remove() call or callback failure marks the entire session failed. Even a caller that catches the exception cannot keep appending declarations or commit a partial configuration; the session preserves its first error. 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 subtree actually detached at completion, including descendants created during asynchronous cleanup, 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.

Commit prepares every affected ExtensionPoint snapshot before delivering any notification. A subscriber to one collection therefore reads the committed values of the other collections too.

13. The unified observation protocol and the reactive layer ​

An invalidation arriving during a snapshot read is preserved. The read commits before delivering notifications it raised, so an older result cannot overwrite a newer projection produced by reentrant reading. A failed read while active retains its invalid state and throws the original error. If the final read during disposal fails, the first dispose still throws the original value and later get calls throw a bounded RecordedFailure. The terminal state releases the previous snapshot, reader, reporter and subscriptions rather than returning an obsolete value as a successful result. Invalidations raised during notification are queued until the current callback returns. Each subscription awaiting delivery is queued once. This order also applies when a diagnostics callback changes another snapshot, preventing reentry across Publishers.

SnapshotPublisher owns its read execution boundary. A reader that directly or indirectly reads the same view while it is being materialized throws TypeError (Circular snapshot read), rather than receiving the previous cache as a result. The first disposal cannot begin inside that Publisher’s reader; release it after the read finishes. Invalidations raised by a reader remain preserved, and subscriber callbacks delivered after the read may still dispose the Publisher.

ContributionView, Installation diagnostics, 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
snapshots.invalidate()
snapshots.dispose()

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. Subscribers and the error reporter must be synchronous; an accidental thenable is observed and enters the same error boundary as a precise TypeError, never as an unrelated unhandled rejection. 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() seals the final read result and severs the reader, reporter and existing subscriptions. A historical view reads the last snapshot after successful closure or throws RecordedFailure after a failed final read, without retaining obsolete snapshot payloads or the original error object. Host, Installation, 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. After asynchronous cleanup, the task’s cancellation signal is checked again and a cancelled observation creates no replacement child. A failed later replacement stops the observation, releases the subscription and current child Lifetime, and detaches its cleanup from the owner
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.

When a third-party source.subscribe() returns a subscription, Observation checks its own current phase before accepting it. If it closed during that call, it immediately disposes the returned subscription and fails construction, rather than attaching a resource after disposal has already passed it. A failure of that subscription’s disposer propagates synchronously too.

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 a RecordedFailure containing only frozen, bounded diagnostic data without retaining the original error's object graph. 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. Phase, per-category counts and child membership read their sole owners directly; there is no second diagnostic tree or increment/decrement counter. 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.

Instance retains the same read-only Lifetime view from creation instead of reacquiring the execution binding while reading diagnostics. When Lifetime has disposed but Installation is still stopping, Installation and Host diagnostics can still read that view's disposed terminal state. This projection cannot advance Lifetime phase or create resources.

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.

RecordedFailure is the shared terminal error type. Its read-only snapshot: ErrorSnapshot getter returns a privately owned frozen record. Only a genuine RecordedFailure can reuse that private record; an external Error inheriting its prototype is captured again. The record contains diagnostic values: original name, message, code, stack text, bounded cause and aggregate error trees, validation issues, and permission fields. It never reconstructs the original subclass or retains arbitrary error payloads. Operation failures and recoverable failures still propagate their original Error; ready() on a discarded Installation or Registration rejects with RecordedFailure. Enumerate or serialize diagnostic data explicitly through error.snapshot; snapshot is no longer an enumerable instance data field.

new RecordedFailure(reason: unknown) accepts any rejection value without requiring prior classification. Each error node's prototype is checked at most once. Unrecognizable values become NonError records: primitives retain bounded text and other objects are omitted. SnapshotPublisher records its final reader rejection directly rather than constructing another error. The normalization path registers non-Error wrapper provenance in a module-private set; prototypes and constructors cannot grant reclassification authority. Only the current activation's internal outcome carries incomplete-cleanup evidence. Error classes, codes, prototypes, constructors and historical failures from another activation cannot trigger its fail-closed behavior.

Non-Error wrappers created by Core are frozen. Later holders cannot rewrite cause or code and change the original reason used by an outer classification. The application still owns any object referenced by cause, and explicitly thrown application Errors propagate unchanged.

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.

Transaction failure boundaries ​

Candidate validation does not revoke running Instances: Event and Contribution operations remain authorized by their Lifetime until disposal starts. Cleanup integrity survives aggregation of concurrent startup failures; any incomplete cleanup forbids automatic rollback.

Fail-closed ends readiness for the entire stopped graph, including indirect consumers and otherwise unaffected Installations. They become failed and pending ready() calls reject after the command finishes. Declarations remain installed and a later successful start() restores readiness. Ordinary stop() leaves Installations pending for the next start. An earlier successful Group commit establishes its baseline even when a later submitted change already owns the current barrier.

Released under the MIT License.