# Capability Spec Delta: api-plugin (add-api-plugin) **Package**: `@suaveplan/api-plugin` **Status**: PROPOSED ## ADDED Requirements ### Requirement: definePlugin factory produces a typed PluginDescriptor The package SHALL export `definePlugin(descriptor): PluginDescriptor` that returns the descriptor with full `TConfig` type inference flowing through `configSchema?: ZodType`, `defaults?: Partial`, and the 4 lifecycle hooks (`onRegister/onInit/onReady/onClose`), each receiving a `PluginContext` typed for `TConfig`. #### Scenario: descriptor preserves TConfig through the type system - **WHEN** `definePlugin({ name, version, configSchema: MyConfigSchema, onInit: (ctx) => ctx.getOwnConfig() })` is invoked - **THEN** `ctx.getOwnConfig()` returns `z.infer` (no `unknown` widening) **Verified by:** `src/define/define.test.ts::"definePlugin preserves TConfig type"` AND `src/define/define.spec-conformance.test.ts::"expectTypeOf getOwnConfig matches configSchema infer"` --- ### Requirement: PluginRegistry runs the 4-phase lifecycle in dep order `PluginRegistry.initialize(context)` SHALL run topologically-sorted plugins through `onRegister → onInit → onReady` in that order, calling each hook on each plugin exactly once. `shutdown()` SHALL run `onClose` in **reverse** dep order. Each hook receives a `PluginContext` whose `signal: AbortSignal` is bound to the registry's `initSignal` / `shutdownSignal`. #### Scenario: lifecycle hooks fire in dep order - **WHEN** plugins `a` (no deps) and `b` (depends on `a`) register, and `initialize()` is awaited - **THEN** `a.onRegister`, `a.onInit`, `a.onReady` complete before any of `b`'s lifecycle hooks #### Scenario: shutdown reverses the order - **WHEN** the same registry is shut down - **THEN** `b.onClose` completes before `a.onClose` **Verified by:** `src/registry/registry.test.ts::"initialize runs onRegister/onInit/onReady in dep order"` AND `src/registry/registry.test.ts::"shutdown calls onClose in reverse dep order"` --- ### Requirement: Topological sort uses Kahn's algorithm with deterministic tie-break `topologicalSort(nodes)` SHALL implement Kahn's algorithm. Equal-in-degree nodes SHALL be processed in alphabetical order of `id` (stable across runs, independent of registration order). Missing required deps go into the `unresolved` array. Cycles raise `PluginError(PLUGIN_CIRCULAR_DEPENDENCY)` carrying the offending cycle path. #### Scenario: equal-in-degree nodes sort alphabetically - **WHEN** plugins `z`, `a`, `m` are registered in that order with no deps - **THEN** `getSortedPlugins()` returns them in order `[a, m, z]` regardless of registration order #### Scenario: cycle throws with cycle path - **WHEN** plugins form a cycle `a → b → c → a` and `initialize()` is awaited - **THEN** the promise rejects with `PluginError(PLUGIN_CIRCULAR_DEPENDENCY)` whose `context.cycle` equals `["a", "b", "c", "a"]` **Verified by:** `src/topo-sort/topo-sort.test.ts::"sorts equal-in-degree nodes alphabetically"` AND `src/topo-sort/topo-sort.test.ts::"throws PLUGIN_CIRCULAR_DEPENDENCY with cycle path"` --- ### Requirement: PluginRegistry enforces initTimeout per hook The registry SHALL accept `initTimeout` (default 30000ms) and `shutdownTimeout` (default 10000ms) in `PluginRegistryOptions`. If any single lifecycle hook on any plugin exceeds the per-hook budget, the registry SHALL reject with `PluginError(PLUGIN_INIT_TIMEOUT)` carrying `context.plugin` and `context.hook`. Timeout uses the injected `Clock` and is implemented via `AbortSignal` (the plugin's `ctx.signal` is aborted when the timer fires). #### Scenario: hung onInit hits the timeout - **WHEN** a plugin's `onInit` returns a Promise that never resolves and `initialize()` is awaited with `initTimeout: 100` - **THEN** the promise rejects within ~100ms with `PluginError(PLUGIN_INIT_TIMEOUT)` whose `context.plugin` is the offending plugin name **Verified by:** `src/registry/registry.test.ts::"initTimeout aborts a hung onInit"` AND `src/registry/registry.test.ts::"shutdownTimeout aborts a hung onClose"` --- ### Requirement: PluginContext.getService is type-safe via ServiceRegistry module augmentation The package SHALL export `interface ServiceRegistry {}` (empty by default) from `./service-registry`. `registerService(name: K, service: ServiceRegistry[K])` and `getService(name: K): ServiceRegistry[K] | undefined` SHALL be typed against augmentations. Untyped string keys (not in `ServiceRegistry`) SHALL fail TypeScript compilation; runtime behaviour preserved for forward compatibility. #### Scenario: augmented service slot types correctly - **WHEN** a consumer declares `declare module "@suaveplan/api-plugin/service-registry" { interface ServiceRegistry { db: DbAdapter } }` and calls `ctx.getService("db")` - **THEN** the return type is `DbAdapter | undefined` (no cast required) #### Scenario: unknown service name fails compilation - **WHEN** a consumer calls `ctx.getService("unknown-slot")` without augmenting `ServiceRegistry` - **THEN** the compile fails **Verified by:** `src/context/context.spec-conformance.test.ts::"expectTypeOf getService(K) returns ServiceRegistry[K] | undefined"` AND `src/context/context.spec-conformance.test.ts::"unknown service key produces a type error"` --- ### Requirement: HookExecutor handles non-lifecycle hooks only The package SHALL ship `HookExecutor` with `registerHook(name, handler, pluginName, priority)`, `unregisterPlugin(name)`, `execute(name, ...args)` (sequential, abort on first throw → `PluginError(PLUGIN_HOOK_FAILED)`), and `executeAll(name, ...args)` (parallel via `Promise.allSettled`). Lifecycle hooks (`onRegister`/`onInit`/`onReady`/`onClose`) SHALL NOT be dispatched via the executor — registry owns them. The executor's hook namespace is for plugin-contributed custom hooks AND the request/tenant/db hooks that ship in sibling packages. #### Scenario: registry does not invoke lifecycle hooks via executor - **WHEN** a plugin's `onInit` is called by the registry and the executor is queried for `getHookCount("onInit")` - **THEN** the count is 0 — lifecycle hooks are not registered with the executor #### Scenario: custom hooks dispatch with priority - **WHEN** two plugins register handlers for a custom hook `"my-extension"` with priorities 10 and 5, then `executor.execute("my-extension", ...args)` runs - **THEN** the priority-10 handler runs first **Verified by:** `src/hook-executor/hook-executor.test.ts::"lifecycle hooks are not registered with the executor"` AND `src/hook-executor/hook-executor.test.ts::"dispatch order respects priority"` AND `src/hook-executor/hook-executor.test.ts::"execute aborts on first throw with PLUGIN_HOOK_FAILED"` --- ### Requirement: Every PLUGIN_* error code is thrown from a production path `PLUGIN_CODES` SHALL define exactly 10 codes (`PLUGIN_NOT_FOUND`, `PLUGIN_ALREADY_REGISTERED`, `PLUGIN_INIT_FAILED`, `PLUGIN_DEP_MISSING`, `PLUGIN_CIRCULAR_DEPENDENCY`, `PLUGIN_INIT_TIMEOUT`, `PLUGIN_SHUTDOWN_FAILED`, `PLUGIN_INVALID_CONFIG`, `PLUGIN_HOOK_FAILED`, `PLUGIN_ABORTED`). Every code SHALL be raised by at least one production call site. #### Scenario: spec conformance exercises every code - **WHEN** the conformance test exercises each code's production path - **THEN** every code appears in the assertions **Verified by:** `src/api-plugin.spec-conformance.test.ts::"every PLUGIN_* code is thrown from a production path"` --- ### Requirement: Universal-tier purity, subpath exports, sibling docs The package SHALL run unchanged in Bun, Node, and the browser (no `node:fs`, no `process.cwd()` outside lazy CLI paths). `package.json` SHALL declare a subpath export per feature module. Every implementation `.ts` SHALL have a sibling `.md` of ≥200 words. No `export *` in feature barrels. #### Scenario: dep-graph asserts package shape - **WHEN** `tests/dep-graph.test.ts` inspects package.json `dependencies` + `exports` - **THEN** dependencies are restricted to `@suaveplan/error`, `@suaveplan/events`, `@suaveplan/types`, `tslib`, `zod`, `@opentelemetry/api`; every feature module is reachable via a declared subpath **Verified by:** `tests/dep-graph.test.ts::"dependencies match the universal allowlist"` AND `tests/dep-graph.test.ts::"subpath exports cover every feature module"` AND `tests/dep-graph.test.ts::"every implementation .ts has a sibling .md"` --- ### Requirement: 100% coverage with property test for topo-sort stability The package SHALL enforce 100/100/100/100 statements/branches/functions/lines via Istanbul. `tests/property.test.ts` SHALL use fast-check (numRuns: 200) to assert that for arbitrary acyclic dep graphs `[id → deps]`, `topologicalSort` produces an order in which every node appears after every one of its declared dependencies AND the order is reproducible (calling sort twice on the same input yields identical output). #### Scenario: dep-order invariant holds for arbitrary graphs - **WHEN** fast-check generates random acyclic dep graphs and `topologicalSort` is called - **THEN** for every node N in the result, every dep of N appears earlier in the result **Verified by:** `tests/property.test.ts::"sorted order satisfies dep invariant"` AND `tests/property.test.ts::"sort is deterministic across repeated calls"`