diff --git a/.changeset/plugin-ordering-declared-contract.md b/.changeset/plugin-ordering-declared-contract.md new file mode 100644 index 0000000000..5ddea4fdfb --- /dev/null +++ b/.changeset/plugin-ordering-declared-contract.md @@ -0,0 +1,55 @@ +--- +"@objectstack/core": minor +"@objectstack/spec": minor +"@objectstack/runtime": minor +"@objectstack/objectql": patch +"@objectstack/metadata": patch +--- + +feat(core,runtime): plugin ordering is a declared, kernel-enforced contract (ADR-0116, #4131) + +`kernel.use()` registration order was never a contract — the kernel resolves +init/start order from the plugin dependency graph — but a plugin that needed a +service at init *when its provider is composed* while also booting *without* +the provider had no way to declare that. `AppPlugin` was the standing example: +it grabs `manifest`/`objectql` synchronously in `init()`, declared nothing +(a hard dependency would break empty-env / metadata-only / mock-engine +kernels), and so its correctness rode on which array slot each caller put it +in. That convention failed the same way twice (`DefaultDatasourcePlugin`'s +first cut; then #4085, disguised for months as "crashes when the artifact is +missing"). + +The kernel `Plugin` contract gains three additive fields, enforced by both +`ObjectKernel` and `LiteKernel` through one shared implementation +(`plugin-order.ts` — the previously duplicated topological sort is unified +there): + +- **`optionalDependencies: string[]`** — order-if-present: hoisted ahead + exactly like `dependencies` when composed (real topology edges, including + cycle detection), silently skipped when absent. +- **`requiresServices: string[]`** — services resolved synchronously during + `init()` with no fallback. Validated **before Phase 1**: a required service + whose only declared provider initializes later fails the boot with an error + naming both plugins, both slots, and the fix — before any init side + effects. Re-checked immediately before the plugin's own init, where a still- + missing service becomes a named composition error exactly where the old + bare `Service not found` crash fired. +- **`providesServices: string[]`** — services a plugin's `init()` + unconditionally registers; powers the validation and the diagnostics. + +Plugins that declare nothing get the diagnosis too: a `getService` miss +during Phase 1 now appends which plugin was initializing and — when a +composed plugin declares the service — who provides it and how to declare the +ordering. The `Service '' not found` prefix and the factory-backed +`is async - use await` message are unchanged. + +First adopters: `AppPlugin` declares +`optionalDependencies: ['com.objectstack.engine.objectql']` + +`requiresServices: ['manifest']` (cleared on the empty-env no-op path), so +the #4085 composition — AppPlugin registered before the engine — now boots +correctly in every slot; `ObjectQLPlugin` declares +`providesServices: ['objectql', 'data', 'manifest', 'lifecycle']` and +`MetadataPlugin` declares `providesServices: ['metadata']`. + +Everything is additive — plugins that declare nothing keep their exact +ordering semantics; no existing declaration changes meaning. diff --git a/content/docs/protocol/kernel/lifecycle.mdx b/content/docs/protocol/kernel/lifecycle.mdx index 8e2951389e..5365835707 100644 --- a/content/docs/protocol/kernel/lifecycle.mdx +++ b/content/docs/protocol/kernel/lifecycle.mdx @@ -111,6 +111,35 @@ kernel.use(new AppPlugin(stack)); await kernel.bootstrap(); ``` +### Plugin Ordering Contract + +`kernel.use()` **registration order is not a contract**. The kernel resolves +both init and start order from the plugin dependency graph (topological sort; +insertion order is kept only between plugins with no edges), and Phase 1 +(every `init()`) completes before Phase 2 (any `start()`) begins. A plugin +that needs something another plugin sets up declares it (ADR-0116): + +| Declaration | Semantics | +| :--- | :--- | +| `dependencies: string[]` | Hard — hoisted ahead of the declarant; a name not composed on the kernel fails the boot. | +| `optionalDependencies: string[]` | Order-if-present — hoisted exactly like `dependencies` when composed, silently skipped when absent. For plugins that degrade without the dependency but must never init before it. | +| `requiresServices: string[]` | Services the plugin resolves **synchronously during `init()`** with no fallback. Validated before Phase 1 (a required service whose only declared provider inits later is a named boot error, before any init side effects) and again immediately before the plugin's own init. | +| `providesServices: string[]` | Services the plugin's `init()` **unconditionally** registers. Powers the validation above and lets ordering errors name the provider. Never declare option-gated registrations. | + +Start-time needs require no declaration — the Phase 1/2 split already +guarantees every init-registered service is visible to every `start()`. + +```typescript +// AppPlugin: degrades on engine-less kernels, but when the engine is +// composed it must never init first — and a non-empty bundle cannot +// register at all without the manifest service. +class AppPlugin implements Plugin { + optionalDependencies = ['com.objectstack.engine.objectql']; + requiresServices = ['manifest']; + // ... +} +``` + ### Boot Logs (Example) ``` diff --git a/content/docs/references/kernel/plugin-validator.mdx b/content/docs/references/kernel/plugin-validator.mdx index 269ef2804c..c365146feb 100644 --- a/content/docs/references/kernel/plugin-validator.mdx +++ b/content/docs/references/kernel/plugin-validator.mdx @@ -41,7 +41,10 @@ Plugin metadata for validation | :--- | :--- | :--- | :--- | | **name** | `string` | ✅ | Unique plugin identifier | | **version** | `string` | optional | Semantic version (e.g., 1.0.0) | -| **dependencies** | `string[]` | optional | Array of plugin names this plugin depends on | +| **dependencies** | `string[]` | optional | Array of plugin names this plugin depends on (hard — a missing name fails the boot) | +| **optionalDependencies** | `string[]` | optional | Plugin names hoisted ahead when composed, skipped when absent (order-if-present, ADR-0116) | +| **requiresServices** | `string[]` | optional | Service names the plugin resolves synchronously during init(); validated by the kernel before Phase 1 (ADR-0116) | +| **providesServices** | `string[]` | optional | Service names the plugin's init() unconditionally registers (ADR-0116) | | **signature** | `string` | optional | Cryptographic signature for plugin verification | diff --git a/docs/adr/0116-plugin-ordering-declared-contract.md b/docs/adr/0116-plugin-ordering-declared-contract.md new file mode 100644 index 0000000000..5b284d2fa9 --- /dev/null +++ b/docs/adr/0116-plugin-ordering-declared-contract.md @@ -0,0 +1,69 @@ +# ADR-0116: Plugin ordering is a declared contract — soft dependencies plus validated init-service requirements + +**Status**: Accepted (2026-07-30) +**Deciders**: ObjectStack Protocol Architects +**Builds on**: [ADR-0062](./0062-external-datasource-runtime.md) (D1/D5 — the DefaultDatasourcePlugin whose header note first wrote this contract down as prose), [ADR-0078](./0078-no-silently-inert-metadata.md) (no silently inert declarations — an ordering requirement that lives only in a comment is the lifecycle form of the same lie), [ADR-0049](./0049-no-unenforced-security-properties.md) (enforce-or-remove — the disposition method applied here to a lifecycle convention) +**Consumers**: `@objectstack/core` (`plugin-order.ts`, both kernels), `@objectstack/runtime` (`AppPlugin` — the first requirer), `@objectstack/objectql` + `@objectstack/metadata` (the first declared providers), `@objectstack/spec` (`contracts/plugin-validator.ts`, `kernel/plugin-validator.zod.ts`), `packages/cli` (serve composition comments) +**Surfaced by**: [#4131](https://github.com/objectstack-ai/objectstack/issues/4131) — the structural cause of [#4085](https://github.com/objectstack-ai/objectstack/issues/4085) (fixed positionally by PR #4110, comments trued up by PR #4138) + +--- + +## TL;DR + +The kernel resolves init/start order from the plugin dependency graph, so `kernel.use()` registration order proves nothing — yet until now the only plugins that could *say* what they need were ones whose dependency is safe to make hard. `AppPlugin` needs `manifest`/`objectql` synchronously at init but is deliberately composed on engine-less kernels too, so it could declare nothing, and its correctness rode on which array slot each caller put it in. That convention failed twice in the same subsystem (the first cut of `DefaultDatasourcePlugin`; then #4085), and the first failure's lesson — written as an excellent comment in one plugin's header — did not transfer to the next plugin. + +This ADR makes the ordering contract declarable and enforced, with two small additions to the kernel `Plugin` contract and one validation pass: + +- **`optionalDependencies`** — order-if-present: hoisted like `dependencies` when composed, skipped (not an error) when absent. +- **`requiresServices` / `providesServices`** — what a plugin's init consumes synchronously / unconditionally registers. The kernel validates the resolved order **before Phase 1** and again immediately before each init, so a misplaced plugin fails as a *named* structural error instead of a bare `Service 'manifest' not found` from inside the victim's init. + +Auto-reordering from service declarations is deliberately **not** done (yet): ordering is driven only by declared dependencies; service declarations drive *validation and diagnosis*. + +## Context + +### The mechanism that already existed + +`resolveDependencies()` (both kernels) topologically hoists `plugin.dependencies` ahead of the declarant, and Phase 1 (init) / Phase 2 (start) both follow the resolved order. At least 11 plugins use it — `plugin-audit`, `plugin-approvals`, `metadata-protocol`, `default-datasource`, four connectors, three app plugins, `schema-migrate`. A missing hard dependency throws. + +### The gap + +A plugin that needs a service at init *when its provider is composed* but must also boot *without* the provider had no way to declare anything: + +- `AppPlugin.init()` registers its bundle via `getService('manifest').register(...)` (no fallback) and seeds package state / sandbox runners via `objectql` (try/catch degradation) — but is legitimately assembled on kernels with no ObjectQLPlugin at all: empty environments (`plugin.app.empty-*`), metadata-only one-shot commands (`os migrate plan`/`apply`), embedder compositions, mock-engine tests. A hard `dependencies` entry would turn every one of those boots into `Dependency ... not found`. +- So `AppPlugin` declared nothing, and every composition site carried the ordering by hand. `serve.ts` put the wrap in the wrong slot → #4085, disguised for months as "crashes when the artifact is missing" because the artifact path happened to append its AppPlugin in the right slot. + +### Once is an accident, twice is a missing contract + +`default-datasource-plugin.ts`'s header records the first instance: the plugin originally connected in `start()` and relied on list position; a serve boot hoisted ObjectQLPlugin ahead, boot schema-sync ran first, and the server shipped with no tables. The fix (connect in init, declare the hard dependency) was correct — and the lesson stayed in that file's header, where the next plugin with the same constraint never saw it. #4085 is the same failure with a soft constraint instead of a hard one. Documentation was tried; it does not transfer. (Option 3 of #4131 — "document + lint" — is rejected on this evidence.) + +## Decision + +**D1 — `optionalDependencies`: order-if-present.** Third ordering primitive next to `dependencies` and the Phase 1/2 split. Composed names are hoisted exactly like hard dependencies (they are real topology edges, including for cycle detection); absent names are skipped silently. This is the correct declaration for every "degrade without it, never init before it" plugin — `AppPlugin` declares `['com.objectstack.engine.objectql']`. + +**D2 — `requiresServices` / `providesServices`: the init-service contract, validated.** `requiresServices` lists services a plugin resolves *synchronously during init()* with no fallback (a probe behind try/catch does not belong in it — `objectql` is deliberately absent from AppPlugin's list). `providesServices` lists services a plugin's init *unconditionally* registers (conditionally-registered services must not be declared — the kernel would blame orderings the plugin cannot satisfy; ObjectQLPlugin declares `objectql`/`data`/`manifest`/`lifecycle` and deliberately omits option-gated `protocol`/`objects`/`analytics`). Enforcement is two-stage, one implementation shared by both kernels (`packages/core/src/plugin-order.ts`): + +1. **Pre-Phase-1 (provable misordering)**: after resolving order and before any init runs, a plugin whose required service is provided only by a *later* plugin fails the boot with an error naming both plugins, both slots, and the fix. No init side effects have happened yet. +2. **Immediately before each init (authoritative)**: Phase 1 is sequential, so a required service absent at that instant is absent for that init — the boot fails with a named composition error where it previously died inside the plugin on a bare `Service not found`. This stage never false-positives: it fires precisely where the old crash fired. + +The two stages divide the problem exactly: stage 1 catches misordering that declarations can prove early; stage 2 catches genuinely absent providers, including undeclared ones. A required service with no declared provider is deliberately *not* a pre-Phase-1 error — an earlier plugin may register it without declaring `providesServices`. + +**D3 — Undeclared plugins get diagnosis too.** Both kernels track which plugin is currently initializing; a `getService` miss during Phase 1 appends the structural diagnosis to the error — the initializing plugin's name, and (when a composed plugin declares the service) the provider and the directive to declare the ordering. The next plugin that reaches for a service in init *without* declaring anything still gets a named, actionable boot error instead of the bare symptom. The `Service '' not found` prefix is preserved verbatim (consumers substring-match it), as is the factory-backed `is async - use await` branch. + +**D4 — No auto-reordering from service declarations.** Ordering comes only from `dependencies`/`optionalDependencies`; `requiresServices`/`providesServices` drive validation and messages. Deriving topology from service declarations would make ordering emergent from declarations whose completeness nothing yet guarantees. If the declarations prove reliable in practice, a future ADR may revisit — the pre-Phase-1 check's "declare the dependency" error is the migration pressure that builds that reliability first. + +**D5 — The duplicated topological sort is unified.** `ObjectKernel.resolveDependencies()` and `ObjectKernelBase.resolveDependencies()` were byte-identical copies; both now delegate to `resolvePluginOrder()` in `plugin-order.ts`. One ordering semantic, one place to read it, one place the contract is enforced from. + +### What deliberately stays + +- **The Phase 1/2 split** remains the primary ordering tool: everything registered in any init is visible to every start. `requiresServices` is only for *init-time* consumption; start-time needs are already covered. +- **`DefaultDatasourcePlugin`'s hard dependency** — correct as-is: every composition of that plugin composes ObjectQL. +- **Composition-site slotting** (serve.ts appends the AppPlugin wrap last) — harmless, and keeps both boot paths' logs telling one story; it is just no longer load-bearing. + +## Consequences + +- **+** The #4085 composition (AppPlugin `use()`d before the engine) now boots correctly — the kernel hoists the engine — instead of depending on the caller's slot discipline. Regression-tested in `app-plugin.ordering.test.ts` with a real kernel in the failure shape. +- **+** A composition genuinely missing a manifest provider fails before init side effects with an error that names the plugin, the service, and the remediation — including for plugins that declare nothing (D3). +- **+** The contract is written where the kernel enforces it (plugin class fields + `plugin-order.ts`), not in comments two files away from the code they describe. +- **−** Declarations can be wrong: a `providesServices` entry for a conditionally-registered service, or a `requiresServices` entry for a tolerated probe, produces misleading verdicts. Mitigated by the declaration rules on the fields' TSDoc and by stage 2 being immune (it checks reality, not declarations). +- **−** `providesServices` adoption is incremental (ObjectQLPlugin, MetadataPlugin today); pre-Phase-1 coverage grows as providers declare. Stage 2 and D3 carry the enforcement in the meantime. +- **Risk**: a plugin relying on initializing *before* `com.objectstack.engine.objectql` while also being named in someone's `optionalDependencies` would be re-ordered. No such plugin exists; the hoist direction is the one every documented contract already requires. diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 31b21a0c8a..3626220928 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -1051,22 +1051,17 @@ export default class Serve extends Command { const configHasMetadata = !!( config.objects || config.manifest || config.apps || config.flows || config.apis ); - // ORDERING (#4085): the wrap is APPENDED to `plugins` rather than - // registered here, because plugin registration order IS the kernel's - // init/start order (`resolveDependencies` preserves insertion order for - // plugins that declare no `dependencies`, and AppPlugin declares none). - // AppPlugin.init() registers its manifest through the `manifest` service - // and AppPlugin.start() seeds through the default datasource — both owned - // by plugins that live in `plugins[]` (ObjectQLPlugin / - // DefaultDatasourcePlugin, contributed by `createStandaloneStack`) and - // registered by the loop far below. Registering the wrap HERE put it - // ahead of them, so config-boot died in Phase 1 with - // "Service 'manifest' is async - use await" whenever no compiled - // `dist/objectstack.json` existed — the artifact path never hit it only - // because `createStandaloneStack` appends ITS AppPlugin after the engine - // (which also made the crash look artifact-related rather than - // order-related). Appending puts the config-derived app in exactly that - // same slot, so both boot paths share one plugin order. + // ORDERING (#4085 → #4131/ADR-0116): the wrap is APPENDED to `plugins` + // rather than registered here — but the append is no longer what makes + // the boot correct. AppPlugin now DECLARES its ordering contract + // (`optionalDependencies: ['com.objectstack.engine.objectql']`, + // `requiresServices: ['manifest']`), so the kernel hoists the engine + // ahead of it in every slot, and a composition that genuinely lacks a + // manifest provider fails as a named ordering error instead of the + // mislabelled "Service 'manifest' is async - use await" crash #4085 + // produced. Appending is kept so both boot paths (config-derived wrap + // here, `createStandaloneStack`'s own AppPlugin) share one plugin + // order and one story in the boot logs. if (!hasAppPluginAlready && configHasMetadata) { try { const { AppPlugin } = await import('@objectstack/runtime'); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 372885d11c..e2a92c3bd1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -9,6 +9,7 @@ export * from './kernel-base.js'; export * from './kernel.js'; +export * from './plugin-order.js'; export * from './lite-kernel.js'; export * from './types.js'; export * from './logger.js'; diff --git a/packages/core/src/kernel-base.ts b/packages/core/src/kernel-base.ts index 47892b2f8b..b479216f44 100644 --- a/packages/core/src/kernel-base.ts +++ b/packages/core/src/kernel-base.ts @@ -3,6 +3,12 @@ import type { Plugin, PluginContext } from './types.js'; import type { Logger } from '@objectstack/spec/contracts'; import type { IServiceRegistry } from '@objectstack/spec/contracts'; +import { + resolvePluginOrder, + validateInitServiceContract, + assertInitServiceRequirements, + describeInitOrderFault, +} from './plugin-order.js'; /** * Kernel state machine @@ -28,6 +34,12 @@ export abstract class ObjectKernelBase { protected state: KernelState = 'idle'; protected logger: Logger; protected context!: PluginContext; + /** + * Name of the plugin whose init() is currently executing (Phase 1 runs + * sequentially, so there is at most one). Lets a getService miss during + * init name the structural fault (#4131) instead of only the symptom. + */ + protected currentlyInitializing?: string; constructor(logger: Logger) { this.logger = logger; @@ -77,7 +89,9 @@ export abstract class ObjectKernelBase { if (this.services instanceof Map) { const service = this.services.get(name); if (!service) { - throw new Error(`[Kernel] Service '${name}' not found`); + throw new Error( + `[Kernel] Service '${name}' not found${this.describeInitOrderFault(name)}` + ); } return service as T; } else { @@ -133,50 +147,41 @@ export abstract class ObjectKernelBase { } /** - * Resolve plugin dependencies using topological sort + * Resolve plugin dependencies using topological sort — `dependencies` + * hard, `optionalDependencies` order-if-present (ADR-0116, #4131). One + * implementation shared with ObjectKernel via `plugin-order.ts`. * @returns Ordered list of plugins (dependencies first) */ protected resolveDependencies(): Plugin[] { - const resolved: Plugin[] = []; - const visited = new Set(); - const visiting = new Set(); - - const visit = (pluginName: string) => { - if (visited.has(pluginName)) return; - - if (visiting.has(pluginName)) { - throw new Error(`[Kernel] Circular dependency detected: ${pluginName}`); - } - - const plugin = this.plugins.get(pluginName); - if (!plugin) { - throw new Error(`[Kernel] Plugin '${pluginName}' not found`); - } - - visiting.add(pluginName); - - // Visit dependencies first - const deps = plugin.dependencies || []; - for (const dep of deps) { - if (!this.plugins.has(dep)) { - throw new Error( - `[Kernel] Dependency '${dep}' not found for plugin '${pluginName}'` - ); - } - visit(dep); - } + return resolvePluginOrder(this.plugins); + } - visiting.delete(pluginName); - visited.add(pluginName); - resolved.push(plugin); - }; + /** + * Whether a service is registered on this kernel right now. Backs the + * init-service contract checks (#4131). + */ + protected hasRegisteredService(name: string): boolean { + // Both the plain Map and IServiceRegistry expose `has`. + return this.services.has(name); + } - // Visit all plugins - for (const pluginName of this.plugins.keys()) { - visit(pluginName); - } + /** + * Pre-Phase-1 ordering validation (ADR-0116, #4131): a plugin whose + * `requiresServices` names a service provided only by a LATER plugin is + * a named boot error before any init side effects. + */ + protected validateInitServices(ordered: Plugin[]): void { + validateInitServiceContract(ordered, (name) => this.hasRegisteredService(name)); + } - return resolved; + /** + * When a getService miss happens while a plugin's init() is running, + * append the structural diagnosis (#4131): which plugin was initializing, + * and — when a composed plugin declares the service — who provides it. + * Empty string outside Phase 1, so non-boot messages stay unchanged. + */ + protected describeInitOrderFault(serviceName: string): string { + return describeInitOrderFault(this.currentlyInitializing, this.plugins.values(), serviceName); } /** @@ -186,13 +191,20 @@ export abstract class ObjectKernelBase { protected async runPluginInit(plugin: Plugin): Promise { const pluginName = plugin.name; this.logger.info(`Initializing plugin: ${pluginName}`); - + + // Authoritative init-service check (#4131): Phase 1 is sequential, + // so a required service absent NOW is absent for this init. + assertInitServiceRequirements(plugin, (name) => this.hasRegisteredService(name)); + + this.currentlyInitializing = pluginName; try { await plugin.init(this.context); this.logger.info(`Plugin initialized: ${pluginName}`); } catch (error) { this.logger.error(`Plugin init failed: ${pluginName}`, error as Error); throw error; + } finally { + this.currentlyInitializing = undefined; } } diff --git a/packages/core/src/kernel.ts b/packages/core/src/kernel.ts index cf41c3b9fc..664de065f0 100644 --- a/packages/core/src/kernel.ts +++ b/packages/core/src/kernel.ts @@ -7,6 +7,12 @@ import { ServiceRequirementDef } from '@objectstack/spec/system'; import { PluginLoader, PluginMetadata, ServiceLifecycle, ServiceFactory, PluginStartupResult } from './plugin-loader.js'; import { isNode, safeExit } from './utils/env.js'; import { CORE_FALLBACK_FACTORIES } from './fallbacks/index.js'; +import { + resolvePluginOrder, + validateInitServiceContract, + assertInitServiceRequirements, + describeInitOrderFault, +} from './plugin-order.js'; /** * Enhanced Kernel Configuration @@ -59,6 +65,12 @@ export class ObjectKernel { private startedPlugins: Set = new Set(); private pluginStartTimes: Map = new Map(); private shutdownHandlers: Array<() => Promise> = []; + /** + * Name of the plugin whose init() is currently executing (Phase 1 is + * sequential, so at most one). Lets a getService miss during init name + * the structural fault (#4131) instead of only the symptom. + */ + private currentlyInitializing?: string; constructor(config: ObjectKernelConfig = {}) { this.config = { @@ -112,7 +124,9 @@ export class ObjectKernel { // pointing at the wrong layer. Decide from the registry // instead, which is synchronous and authoritative. if (!this.pluginLoader.hasService(name)) { - throw new Error(`[Kernel] Service '${name}' not found`); + throw new Error( + `[Kernel] Service '${name}' not found${this.describeInitOrderFault(name)}` + ); } // Registered but not instantiated ⇒ factory-backed. Message @@ -311,6 +325,11 @@ export class ObjectKernel { // Resolve plugin dependencies const orderedPlugins = this.resolveDependencies(); + // Pre-Phase-1 ordering contract (ADR-0116, #4131): a plugin that + // requires a service provided only by a later plugin fails HERE, + // named, before any init side effects. + validateInitServiceContract(orderedPlugins, (name) => this.hasAnyService(name)); + // Phase 1: Init - Plugins register services this.logger.info('Phase 1: Init plugins'); for (const plugin of orderedPlugins) { @@ -513,17 +532,45 @@ export class ObjectKernel { private async initPluginWithTimeout(plugin: PluginMetadata): Promise { const timeout = plugin.startupTimeout || this.config.defaultStartupTimeout!; - + this.logger.debug(`Init: ${plugin.name}`, { plugin: plugin.name }); - - const initPromise = plugin.init(this.context); - const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => { - reject(new Error(`Plugin ${plugin.name} init timeout after ${timeout}ms`)); - }, timeout); - }); - await Promise.race([initPromise, timeoutPromise]); + // Authoritative init-service check (#4131): Phase 1 is sequential, + // so a required service absent NOW is absent for this init. + assertInitServiceRequirements(plugin, (name) => this.hasAnyService(name)); + + this.currentlyInitializing = plugin.name; + try { + const initPromise = plugin.init(this.context); + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + reject(new Error(`Plugin ${plugin.name} init timeout after ${timeout}ms`)); + }, timeout); + }); + + await Promise.race([initPromise, timeoutPromise]); + } finally { + this.currentlyInitializing = undefined; + } + } + + /** + * Whether a service is resolvable on this kernel right now — direct + * registration or a loader-registered factory. Backs the init-service + * contract checks (#4131). + */ + private hasAnyService(name: string): boolean { + return this.services.has(name) || this.pluginLoader.hasService(name); + } + + /** + * When a getService miss happens while a plugin's init() is running, + * append the structural diagnosis (#4131): which plugin was initializing, + * and — when a composed plugin declares the service — who provides it. + * Empty string outside Phase 1, so non-boot messages stay unchanged. + */ + private describeInitOrderFault(serviceName: string): string { + return describeInitOrderFault(this.currentlyInitializing, this.plugins.values(), serviceName); } private async startPluginWithTimeout(plugin: PluginMetadata): Promise { @@ -616,45 +663,13 @@ export class ObjectKernel { } } + /** + * Topological order over `dependencies` (hard) + `optionalDependencies` + * (order-if-present) — ADR-0116, #4131. One implementation shared with + * LiteKernel via `plugin-order.ts`. + */ private resolveDependencies(): PluginMetadata[] { - const resolved: PluginMetadata[] = []; - const visited = new Set(); - const visiting = new Set(); - - const visit = (pluginName: string) => { - if (visited.has(pluginName)) return; - - if (visiting.has(pluginName)) { - throw new Error(`[Kernel] Circular dependency detected: ${pluginName}`); - } - - const plugin = this.plugins.get(pluginName); - if (!plugin) { - throw new Error(`[Kernel] Plugin '${pluginName}' not found`); - } - - visiting.add(pluginName); - - // Visit dependencies first - const deps = plugin.dependencies || []; - for (const dep of deps) { - if (!this.plugins.has(dep)) { - throw new Error(`[Kernel] Dependency '${dep}' not found for plugin '${pluginName}'`); - } - visit(dep); - } - - visiting.delete(pluginName); - visited.add(pluginName); - resolved.push(plugin); - }; - - // Visit all plugins - for (const pluginName of this.plugins.keys()) { - visit(pluginName); - } - - return resolved; + return resolvePluginOrder(this.plugins); } private registerShutdownSignals(): void { diff --git a/packages/core/src/lite-kernel.ts b/packages/core/src/lite-kernel.ts index bfdcccdd1c..ddeae3fcb6 100644 --- a/packages/core/src/lite-kernel.ts +++ b/packages/core/src/lite-kernel.ts @@ -61,6 +61,11 @@ export class LiteKernel extends ObjectKernelBase { // Resolve dependencies const orderedPlugins = this.resolveDependencies(); + // Pre-Phase-1 ordering contract (ADR-0116, #4131): a plugin that + // requires a service provided only by a later plugin fails HERE, + // named, before any init side effects. + this.validateInitServices(orderedPlugins); + // Phase 1: Init - Plugins register services this.logger.info('Phase 1: Init plugins'); for (const plugin of orderedPlugins) { diff --git a/packages/core/src/plugin-order.test.ts b/packages/core/src/plugin-order.test.ts new file mode 100644 index 0000000000..a7f573aea2 --- /dev/null +++ b/packages/core/src/plugin-order.test.ts @@ -0,0 +1,260 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Plugin ordering + init-service contract (ADR-0116, #4131). + * + * The unit half exercises `plugin-order.ts` directly; the kernel half boots + * real LiteKernel / ObjectKernel instances in the exact #4085 shape — + * a consumer plugin registered BEFORE its provider — and proves the contract + * either fixes the order (optionalDependencies) or names the fault + * (requiresServices), instead of dying inside the victim's init. + */ + +import { describe, it, expect } from 'vitest'; +import { + resolvePluginOrder, + validateInitServiceContract, + assertInitServiceRequirements, + type OrderablePlugin, +} from './plugin-order'; +import { LiteKernel } from './lite-kernel'; +import { ObjectKernel } from './kernel'; +import type { Plugin, PluginContext } from './types'; + +const toMap = (plugins: OrderablePlugin[]) => + new Map(plugins.map((p) => [p.name, p])); + +describe('resolvePluginOrder', () => { + it('preserves insertion order when no edges exist', () => { + const order = resolvePluginOrder(toMap([{ name: 'a' }, { name: 'b' }, { name: 'c' }])); + expect(order.map((p) => p.name)).toEqual(['a', 'b', 'c']); + }); + + it('hoists hard dependencies ahead of the dependent', () => { + const order = resolvePluginOrder(toMap([ + { name: 'consumer', dependencies: ['provider'] }, + { name: 'provider' }, + ])); + expect(order.map((p) => p.name)).toEqual(['provider', 'consumer']); + }); + + it('throws on a missing hard dependency', () => { + expect(() => + resolvePluginOrder(toMap([{ name: 'consumer', dependencies: ['ghost'] }])), + ).toThrow("Dependency 'ghost' not found for plugin 'consumer'"); + }); + + it('hoists an optional dependency when it is composed', () => { + const order = resolvePluginOrder(toMap([ + { name: 'consumer', optionalDependencies: ['provider'] }, + { name: 'provider' }, + ])); + expect(order.map((p) => p.name)).toEqual(['provider', 'consumer']); + }); + + it('skips an optional dependency that is absent — no error', () => { + const order = resolvePluginOrder(toMap([ + { name: 'consumer', optionalDependencies: ['ghost'] }, + { name: 'other' }, + ])); + expect(order.map((p) => p.name)).toEqual(['consumer', 'other']); + }); + + it('detects cycles through optional edges', () => { + expect(() => + resolvePluginOrder(toMap([ + { name: 'a', optionalDependencies: ['b'] }, + { name: 'b', dependencies: ['a'] }, + ])), + ).toThrow('Circular dependency detected'); + }); +}); + +describe('validateInitServiceContract', () => { + it('passes when the provider initializes earlier', () => { + expect(() => + validateInitServiceContract( + [ + { name: 'provider', providesServices: ['svc'] }, + { name: 'consumer', requiresServices: ['svc'] }, + ], + () => false, + ), + ).not.toThrow(); + }); + + it('names both plugins when the only declared provider initializes later', () => { + expect(() => + validateInitServiceContract( + [ + { name: 'consumer', requiresServices: ['svc'] }, + { name: 'provider', providesServices: ['svc'] }, + ], + () => false, + ), + ).toThrow(/'consumer' requires service 'svc' during init, but 'svc' is provided by 'provider'/); + }); + + it('passes when the service is already registered on the kernel', () => { + expect(() => + validateInitServiceContract( + [ + { name: 'consumer', requiresServices: ['svc'] }, + { name: 'provider', providesServices: ['svc'] }, + ], + (name) => name === 'svc', + ), + ).not.toThrow(); + }); + + it('does not fail on an undeclared provider — that verdict belongs to the init-time check', () => { + expect(() => + validateInitServiceContract( + [{ name: 'consumer', requiresServices: ['svc'] }], + () => false, + ), + ).not.toThrow(); + }); +}); + +describe('assertInitServiceRequirements', () => { + it('passes when every required service is registered', () => { + expect(() => + assertInitServiceRequirements( + { name: 'consumer', requiresServices: ['svc'] }, + (name) => name === 'svc', + ), + ).not.toThrow(); + }); + + it('names the plugin and the missing service', () => { + expect(() => + assertInitServiceRequirements( + { name: 'consumer', requiresServices: ['svc'] }, + () => false, + ), + ).toThrow(/Plugin 'consumer' requires service 'svc' at init/); + }); +}); + +// ─── Kernel-level behaviour ───────────────────────────────────────── + +/** A provider/consumer pair in the #4085 shape: consumer registered FIRST. */ +function misorderedPair(initLog: string[]): { provider: Plugin; consumer: Plugin } { + const provider: Plugin = { + name: 'test.provider', + version: '1.0.0', + providesServices: ['svc'], + init: (ctx: PluginContext) => { + initLog.push('provider'); + ctx.registerService('svc', { ok: true }); + }, + }; + const consumer: Plugin = { + name: 'test.consumer', + version: '1.0.0', + optionalDependencies: ['test.provider'], + requiresServices: ['svc'], + init: (ctx: PluginContext) => { + initLog.push('consumer'); + ctx.getService('svc'); + }, + }; + return { provider, consumer }; +} + +describe('LiteKernel ordering contract', () => { + it('optionalDependencies fixes the #4085 shape: consumer used first still inits after provider', async () => { + const initLog: string[] = []; + const { provider, consumer } = misorderedPair(initLog); + const kernel = new LiteKernel(); + kernel.use(consumer); // deliberately BEFORE the provider + kernel.use(provider); + await kernel.bootstrap(); + expect(initLog).toEqual(['provider', 'consumer']); + await kernel.shutdown(); + }); + + it('optionalDependencies of an absent plugin does not fail the boot', async () => { + const kernel = new LiteKernel(); + kernel.use({ + name: 'test.lonely', + version: '1.0.0', + optionalDependencies: ['test.not-composed'], + init: () => { /* degrades */ }, + }); + await expect(kernel.bootstrap()).resolves.toBeUndefined(); + await kernel.shutdown(); + }); + + it('a misordered requiresServices consumer without the ordering declaration fails NAMED, before any init', async () => { + const initLog: string[] = []; + const { provider, consumer } = misorderedPair(initLog); + delete (consumer as any).optionalDependencies; // forget the ordering declaration + const kernel = new LiteKernel(); + kernel.use(consumer); + kernel.use(provider); + await expect(kernel.bootstrap()).rejects.toThrow( + /'test\.consumer' requires service 'svc' during init, but 'svc' is provided by 'test\.provider'/, + ); + expect(initLog).toEqual([]); // pre-Phase-1: no init side effects + }); + + it('a requiresServices consumer with no provider at all fails named at its init slot', async () => { + const kernel = new LiteKernel(); + kernel.use({ + name: 'test.consumer', + version: '1.0.0', + requiresServices: ['svc'], + init: () => { /* never reached */ }, + }); + await expect(kernel.bootstrap()).rejects.toThrow( + /Plugin 'test\.consumer' requires service 'svc' at init/, + ); + }); + + it('an UNDECLARED getService miss during init is diagnosed with the initializing plugin and provider', async () => { + const kernel = new LiteKernel(); + kernel.use({ + name: 'test.undeclared-consumer', + version: '1.0.0', + init: (ctx: PluginContext) => { + ctx.getService('svc'); // no requiresServices declared + }, + }); + kernel.use({ + name: 'test.provider', + version: '1.0.0', + providesServices: ['svc'], + init: (ctx: PluginContext) => ctx.registerService('svc', {}), + }); + await expect(kernel.bootstrap()).rejects.toThrow( + /Service 'svc' not found \(while plugin 'test\.undeclared-consumer' was initializing[\s\S]*provided by composed plugin 'test\.provider'/, + ); + }); +}); + +describe('ObjectKernel ordering contract', () => { + it('optionalDependencies fixes the #4085 shape on the production kernel too', async () => { + const initLog: string[] = []; + const { provider, consumer } = misorderedPair(initLog); + const kernel = new ObjectKernel({ skipSystemValidation: true, gracefulShutdown: false }); + await kernel.use(consumer); + await kernel.use(provider); + await kernel.bootstrap(); + expect(initLog).toEqual(['provider', 'consumer']); + }); + + it('a misordered requiresServices consumer fails NAMED before any init', async () => { + const initLog: string[] = []; + const { provider, consumer } = misorderedPair(initLog); + delete (consumer as any).optionalDependencies; + const kernel = new ObjectKernel({ skipSystemValidation: true, gracefulShutdown: false }); + await kernel.use(consumer); + await kernel.use(provider); + await expect(kernel.bootstrap()).rejects.toThrow( + /'test\.consumer' requires service 'svc' during init, but 'svc' is provided by 'test\.provider'/, + ); + expect(initLog).toEqual([]); + }); +}); diff --git a/packages/core/src/plugin-order.ts b/packages/core/src/plugin-order.ts new file mode 100644 index 0000000000..aa88593ee9 --- /dev/null +++ b/packages/core/src/plugin-order.ts @@ -0,0 +1,196 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Plugin ordering + init-service contract (ADR-0116, #4131). + * + * The kernel resolves BOTH init and start order from the plugin dependency + * graph, so `kernel.use()` registration order proves nothing. Twice a plugin + * relied on list position anyway and shipped a boot that dies inside init — + * the first cut of DefaultDatasourcePlugin (started after boot schema-sync; + * server with no tables) and AppPlugin (#4085: `manifest` grabbed in init + * before ObjectQLPlugin registered it). Both times the fix existed only as a + * convention: put the plugin in the right slot, write a comment. This module + * is the enforced form of that contract, shared by ObjectKernel and + * LiteKernel so there is exactly one ordering semantic: + * + * - `dependencies` — hard: hoisted ahead, missing ⇒ boot error (unchanged). + * - `optionalDependencies` — order-if-present: hoisted ahead when composed, + * silently skipped when absent. For plugins that DEGRADE without the + * dependency but must never init before it (AppPlugin on an engine-less + * metadata-only kernel). + * - `requiresServices` — services a plugin resolves SYNCHRONOUSLY during + * `init()`. Validated before Phase 1 (provable misordering ⇒ named error + * instead of a crash inside init) and again immediately before each init + * (authoritative: the service is either registered by now or init dies). + * - `providesServices` — services a plugin's `init()` UNCONDITIONALLY + * registers. Powers the pre-Phase-1 check and the named diagnostics. + * Declare only unconditional registrations: a conditional service (e.g. + * one gated behind an option) would indict this plugin for orderings it + * cannot actually satisfy. + */ + +/** + * The ordering-relevant surface of a kernel plugin. Structural on purpose: + * ObjectKernel sorts `PluginMetadata`, LiteKernel sorts `Plugin`, and both + * satisfy this shape. + */ +export interface OrderablePlugin { + name: string; + /** Hard dependencies — hoisted ahead; missing ⇒ boot error. */ + dependencies?: string[]; + /** Soft dependencies — hoisted ahead when composed, skipped when absent. */ + optionalDependencies?: string[]; + /** Services resolved synchronously during init(). */ + requiresServices?: string[]; + /** Services init() unconditionally registers. */ + providesServices?: string[]; +} + +/** + * Topologically order plugins: every plugin's `dependencies` (throw when + * missing) and `optionalDependencies` (skip when missing) init before it. + * Insertion order is preserved for plugins with no edges between them. + * Cycles through either edge kind throw — an optional dependency is a real + * edge whenever both sides are composed. + */ +export function resolvePluginOrder

(plugins: Map): P[] { + const resolved: P[] = []; + const visited = new Set(); + const visiting = new Set(); + + const visit = (pluginName: string) => { + if (visited.has(pluginName)) return; + + if (visiting.has(pluginName)) { + throw new Error(`[Kernel] Circular dependency detected: ${pluginName}`); + } + + const plugin = plugins.get(pluginName); + if (!plugin) { + throw new Error(`[Kernel] Plugin '${pluginName}' not found`); + } + + visiting.add(pluginName); + + for (const dep of plugin.dependencies ?? []) { + if (!plugins.has(dep)) { + throw new Error( + `[Kernel] Dependency '${dep}' not found for plugin '${pluginName}'` + ); + } + visit(dep); + } + for (const dep of plugin.optionalDependencies ?? []) { + if (plugins.has(dep)) visit(dep); + } + + visiting.delete(pluginName); + visited.add(pluginName); + resolved.push(plugin); + }; + + for (const pluginName of plugins.keys()) { + visit(pluginName); + } + + return resolved; +} + +/** + * Pre-Phase-1 check: walk the resolved order and prove no plugin requires a + * service whose only declared provider initializes AFTER it. A violation is + * the exact #4085 class — misplaced composition — reported as a named, + * structural boot error BEFORE any init side effects, instead of a bare + * "Service not found" thrown from inside the victim's init. + * + * Deliberately does NOT fail when a required service has no declared provider + * and is not yet registered: an earlier plugin may register it without + * declaring `providesServices`. That case is settled authoritatively by + * {@link assertInitServiceRequirements} immediately before the requiring + * plugin's init runs. + */ +export function validateInitServiceContract

( + ordered: P[], + isServiceRegistered: (name: string) => boolean, +): void { + const providerSlot = new Map(); + ordered.forEach((plugin, slot) => { + for (const service of plugin.providesServices ?? []) { + if (!providerSlot.has(service)) { + providerSlot.set(service, { plugin: plugin.name, slot }); + } + } + }); + + const violations: string[] = []; + ordered.forEach((plugin, slot) => { + for (const service of plugin.requiresServices ?? []) { + if (isServiceRegistered(service)) continue; + const provider = providerSlot.get(service); + if (provider && provider.slot > slot) { + violations.push( + `'${plugin.name}' requires service '${service}' during init, but '${service}' is ` + + `provided by '${provider.plugin}', which initializes later (slot ${provider.slot} vs ${slot}). ` + + `Registration order is not a contract — declare '${provider.plugin}' in ` + + `'${plugin.name}'.dependencies (hard) or .optionalDependencies (order-if-present) ` + + `so the kernel hoists it.` + ); + } + } + }); + + if (violations.length > 0) { + throw new Error( + `[Kernel] Plugin ordering contract violated (#4131):\n - ${violations.join('\n - ')}` + ); + } +} + +/** + * Diagnosis suffix for a getService miss that happens WHILE a plugin's + * init() is running: names the initializing plugin and — when a composed + * plugin declares the service — the provider and the directive to declare + * the ordering. Returns '' when no plugin is initializing, so non-boot + * error messages stay byte-identical. Shared by both kernels. + */ +export function describeInitOrderFault( + currentlyInitializing: string | undefined, + plugins: Iterable, + serviceName: string, +): string { + if (!currentlyInitializing) return ''; + let providerHint = ''; + for (const plugin of plugins) { + if (plugin.providesServices?.includes(serviceName)) { + providerHint = ` '${serviceName}' is provided by composed plugin '${plugin.name}', which has ` + + `not initialized yet — declare it in the requiring plugin's dependencies/optionalDependencies.`; + break; + } + } + return ` (while plugin '${currentlyInitializing}' was initializing — a composition/` + + `ordering fault, #4131.${providerHint})`; +} + +/** + * Just-before-init check: every service in `requiresServices` must be + * registered at the moment the plugin's init() is about to run. At this + * point the verdict is authoritative — Phase 1 runs sequentially, so a + * service absent now is absent for this init, and the init would die on a + * bare "Service not found" anyway. This turns that crash into a named + * composition error. + */ +export function assertInitServiceRequirements( + plugin: OrderablePlugin, + isServiceRegistered: (name: string) => boolean, +): void { + for (const service of plugin.requiresServices ?? []) { + if (isServiceRegistered(service)) continue; + throw new Error( + `[Kernel] Plugin '${plugin.name}' requires service '${service}' at init, but no such ` + + `service is registered at this point of the boot. No composed plugin that initializes ` + + `earlier provides it — compose a provider (and, if it initializes later without declaring ` + + `'${service}' in providesServices, order it ahead via this plugin's dependencies/` + + `optionalDependencies) (#4131).` + ); + } +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index bbc073ab90..902f913874 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -108,9 +108,39 @@ export interface Plugin { /** * List of other plugin names that this plugin depends on. * The kernel ensures these plugins are initialized before this one. + * A name that is not registered on the kernel is a boot error. */ dependencies?: string[]; + /** + * Soft dependencies — order-if-present (ADR-0116, #4131). + * Registered names are hoisted ahead exactly like `dependencies`; + * absent names are silently skipped instead of failing the boot. + * For plugins that DEGRADE gracefully without the dependency but must + * never initialize before it when both are composed (e.g. AppPlugin on + * an engine-less metadata-only kernel). + */ + optionalDependencies?: string[]; + + /** + * Services this plugin resolves SYNCHRONOUSLY during `init()` + * (ADR-0116, #4131). The kernel validates the resolved order before + * Phase 1 (a required service whose only declared provider initializes + * later is a named boot error) and re-checks immediately before this + * plugin's init runs. Declare only hard init-time needs — a service the + * init merely probes behind a try/catch does not belong here. + */ + requiresServices?: string[]; + + /** + * Services this plugin's `init()` UNCONDITIONALLY registers + * (ADR-0116, #4131). Powers the pre-Phase-1 ordering validation and + * lets misordering errors name the provider. Never declare a service + * that is registered conditionally (option-gated, environment-gated): + * the kernel would blame orderings this plugin cannot satisfy. + */ + providesServices?: string[]; + /** * Init Phase: Register services * Called when kernel is initializing. diff --git a/packages/metadata/src/plugin.ts b/packages/metadata/src/plugin.ts index 0f7ca41e19..c6429dc9b4 100644 --- a/packages/metadata/src/plugin.ts +++ b/packages/metadata/src/plugin.ts @@ -179,6 +179,12 @@ export class MetadataPlugin implements Plugin { name = 'com.objectstack.metadata'; type = 'standard'; version = '1.0.0'; + /** + * Services init() UNCONDITIONALLY registers (ADR-0116, #4131) — lets the + * kernel name this plugin when a consumer requires `metadata` before it + * initializes. + */ + providesServices = ['metadata']; private manager: NodeMetadataManager; private options: MetadataPluginOptions; diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index 4ae6869650..6ad295871d 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -123,6 +123,14 @@ export class ObjectQLPlugin implements Plugin { name = 'com.objectstack.engine.objectql'; type = 'objectql'; version = '1.0.0'; + /** + * Services init() UNCONDITIONALLY registers (ADR-0116, #4131) — lets the + * kernel name this plugin when a consumer requires one of them before it + * initializes. `protocol`/`objects`/`analytics` are deliberately absent: + * they are option-gated (`registerProtocol`) and may be owned by + * MetadataProtocolPlugin instead. + */ + providesServices = ['objectql', 'data', 'manifest', 'lifecycle']; /** * Schema sync to remote SQL DBs is latency-bound (one round-trip per * table × 2 phases). Default to 120s instead of the kernel's 30s so diff --git a/packages/runtime/src/app-plugin.ordering.test.ts b/packages/runtime/src/app-plugin.ordering.test.ts new file mode 100644 index 0000000000..b571b361f3 --- /dev/null +++ b/packages/runtime/src/app-plugin.ordering.test.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * AppPlugin ordering contract (ADR-0116 — the #4131 close of #4085). + * + * #4085's structural cause: AppPlugin resolves `manifest` + `objectql` + * synchronously in init() but declared nothing, so its correctness depended + * entirely on which slot the caller `use()`d it into. These tests boot REAL + * kernels in the failure composition — AppPlugin registered BEFORE the + * engine — and prove the declared contract makes slot position irrelevant: + * the kernel hoists the engine (optionalDependencies), and a composition + * with no manifest provider at all fails as a NAMED error, not a bare + * "Service 'manifest' not found" from inside init. + */ + +import { describe, it, expect } from 'vitest'; +import { LiteKernel } from '@objectstack/core'; +import { ObjectQLPlugin } from '@objectstack/objectql'; +import { AppPlugin } from './app-plugin'; + +describe('AppPlugin ordering contract (#4085 / #4131)', () => { + it('boots when use()d BEFORE the engine — the exact #4085 composition', async () => { + const kernel = new LiteKernel({ logger: { level: 'error' } }); + kernel.use(new AppPlugin({ id: 'com.test.misordered', objects: [] })); // wrong slot, on purpose + kernel.use(new ObjectQLPlugin({})); + + await kernel.bootstrap(); + + // The engine was hoisted ahead, so init() found a live `manifest` + // service and the app registered instead of crashing Phase 1. + const ql = kernel.getService<{ registry: { getPackage(id: string): unknown } }>('objectql'); + expect(ql.registry.getPackage('com.test.misordered')).toBeTruthy(); + + await kernel.shutdown(); + }); + + it('an empty-env AppPlugin still boots on a bare kernel with no engine at all', async () => { + const kernel = new LiteKernel({ logger: { level: 'error' } }); + kernel.use(new AppPlugin({})); // no app payload → empty no-op plugin + await expect(kernel.bootstrap()).resolves.toBeUndefined(); + await kernel.shutdown(); + }); + + it('a non-empty AppPlugin with no manifest provider fails NAMED, before init side effects', async () => { + const kernel = new LiteKernel({ logger: { level: 'error' } }); + kernel.use(new AppPlugin({ id: 'com.test.orphan', objects: [] })); + await expect(kernel.bootstrap()).rejects.toThrow( + /requires service 'manifest' at init/, + ); + }); +}); diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index 1016096d8d..64fc9e6199 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -56,7 +56,29 @@ export class AppPlugin implements Plugin { name: string; type = 'app'; version?: string; - + /** + * Ordering — declared, not positional (ADR-0116, the #4131 close of + * #4085). `init()` synchronously registers the manifest through the + * `manifest` service and seeds package state / sandbox runners through + * `objectql` — both registered by ObjectQLPlugin's init. For months that + * ordering held only because callers happened to `use()` this plugin in + * the right slot; #4085 is what the wrong slot looks like. Soft (order- + * if-present) rather than hard because AppPlugin is deliberately + * composed on engine-less kernels too (metadata-only one-shot commands, + * mock-engine tests) where it degrades: every `objectql` touch in init + * is behind a try/catch. + */ + optionalDependencies = ['com.objectstack.engine.objectql']; + /** + * The one init-time service this plugin CANNOT degrade without: a + * non-empty bundle is registered via `getService('manifest').register()` + * with no fallback. Declared so a composition that breaks it fails as a + * named ordering/composition error before init side effects (#4131). + * Cleared in the constructor for the empty-env no-op path, which never + * touches the service. + */ + requiresServices: string[] = ['manifest']; + private bundle: any; private projectContext?: AppPluginProjectContext; /** When true, init/start become no-ops — env has no app payload. */ @@ -110,8 +132,11 @@ export class AppPlugin implements Plugin { if (!hasAppPayload) { // Empty env — degrade to a no-op plugin so kernel boot // succeeds. Auth / data routes will still work; there's - // simply nothing to register. + // simply nothing to register. No manifest is registered on + // this path, so the init-service requirement is dropped — + // empty envs boot on kernels with no engine at all. this.empty = true; + this.requiresServices = []; const envSlug = projectContext?.environmentId ? projectContext.environmentId.slice(0, 8) : 'empty'; diff --git a/packages/runtime/src/standalone-stack.ts b/packages/runtime/src/standalone-stack.ts index 0534f3c19a..16a2e95c3a 100644 --- a/packages/runtime/src/standalone-stack.ts +++ b/packages/runtime/src/standalone-stack.ts @@ -328,8 +328,9 @@ export async function createStandaloneStack(config?: StandaloneStackConfig): Pro // itself — see the "Ordering — phase, not list position" note in // `default-datasource-plugin.ts`. Order requirements belong THERE, next // to the code the kernel enforces them from; a comment on an array index - // cannot enforce anything. (#4131 tracks making the AppPlugin end of this - // contract enforced rather than conventional.) + // cannot enforce anything. (The AppPlugin end of this contract is now + // declared too — `optionalDependencies` + `requiresServices`, enforced + // by the kernel per ADR-0116 / #4131.) defaultDatasourcePlugin, new MetadataPlugin({ // Source-file scanner OFF — declarative metadata is loaded diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json index 6d2706ec9d..e5d2d73b3c 100644 --- a/packages/spec/authorable-surface.json +++ b/packages/spec/authorable-surface.json @@ -5239,6 +5239,9 @@ "kernel/PluginLoadingState:state", "kernel/PluginMetadata:dependencies", "kernel/PluginMetadata:name", + "kernel/PluginMetadata:optionalDependencies", + "kernel/PluginMetadata:providesServices", + "kernel/PluginMetadata:requiresServices", "kernel/PluginMetadata:signature", "kernel/PluginMetadata:version", "kernel/PluginPerformanceMonitoring:budgets", diff --git a/packages/spec/src/contracts/plugin-validator.ts b/packages/spec/src/contracts/plugin-validator.ts index 857278ed05..21dc91805b 100644 --- a/packages/spec/src/contracts/plugin-validator.ts +++ b/packages/spec/src/contracts/plugin-validator.ts @@ -50,10 +50,27 @@ export interface Plugin { version?: string; /** - * Plugin dependencies + * Plugin dependencies (hard — a missing name fails the boot) */ dependencies?: string[]; - + + /** + * Soft dependencies — order-if-present (ADR-0116). Composed names are + * hoisted ahead like `dependencies`; absent names are skipped. + */ + optionalDependencies?: string[]; + + /** + * Services the plugin resolves synchronously during init(). The kernel + * validates ordering against these before Phase 1 (ADR-0116). + */ + requiresServices?: string[]; + + /** + * Services the plugin's init() unconditionally registers (ADR-0116). + */ + providesServices?: string[]; + /** * Plugin initialization function */ diff --git a/packages/spec/src/kernel/plugin-validator.zod.ts b/packages/spec/src/kernel/plugin-validator.zod.ts index ac0b61f735..21a3ca5151 100644 --- a/packages/spec/src/kernel/plugin-validator.zod.ts +++ b/packages/spec/src/kernel/plugin-validator.zod.ts @@ -146,8 +146,32 @@ export const PluginMetadataSchema = lazySchema(() => z.object({ /** * Plugin dependencies (array of plugin names) */ - dependencies: z.array(z.string()).optional().describe('Array of plugin names this plugin depends on'), - + dependencies: z.array(z.string()).optional().describe('Array of plugin names this plugin depends on (hard — a missing name fails the boot)'), + + /** + * Soft dependencies — order-if-present (ADR-0116, #4131). The kernel + * hoists composed names ahead exactly like `dependencies`, and silently + * skips absent names instead of failing the boot. For plugins that + * degrade gracefully without the dependency but must never initialize + * before it when both are composed. + */ + optionalDependencies: z.array(z.string()).optional().describe('Plugin names hoisted ahead when composed, skipped when absent (order-if-present, ADR-0116)'), + + /** + * Services the plugin resolves synchronously during init() (ADR-0116). + * The kernel validates the resolved order before Phase 1 — a required + * service whose only declared provider initializes later is a named boot + * error — and re-checks immediately before this plugin's init runs. + */ + requiresServices: z.array(z.string()).optional().describe('Service names the plugin resolves synchronously during init(); validated by the kernel before Phase 1 (ADR-0116)'), + + /** + * Services the plugin's init() UNCONDITIONALLY registers (ADR-0116). + * Never declare a conditionally-registered service: the kernel would + * blame orderings this plugin cannot satisfy. + */ + providesServices: z.array(z.string()).optional().describe('Service names the plugin\'s init() unconditionally registers (ADR-0116)'), + /** * Plugin signature for cryptographic verification (optional) */