Skip to content

Commit d13004a

Browse files
authored
feat(core,runtime): plugin ordering is a declared, kernel-enforced contract (ADR-0116, #4131) (#4163)
The kernel resolves init/start order from the plugin dependency graph, so kernel.use() registration order proves nothing — yet a plugin that needs 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 (#4085). - Plugin contract gains optionalDependencies (order-if-present), requiresServices (validated pre-Phase-1 and immediately before each init, with named structural errors), and providesServices. - One shared topological sort in plugin-order.ts replaces the two byte-identical copies in ObjectKernel and ObjectKernelBase. - A getService miss during Phase 1 names the initializing plugin and the composed provider, so undeclared plugins fail diagnosably too. - AppPlugin declares the engine soft dependency + the manifest init requirement; ObjectQLPlugin and MetadataPlugin declare what they register. - ADR-0116 records the decision; lifecycle.mdx documents the contract. Closes #4131
1 parent 09e4547 commit d13004a

20 files changed

Lines changed: 918 additions & 113 deletions
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
---
2+
"@objectstack/core": minor
3+
"@objectstack/spec": minor
4+
"@objectstack/runtime": minor
5+
"@objectstack/objectql": patch
6+
"@objectstack/metadata": patch
7+
---
8+
9+
feat(core,runtime): plugin ordering is a declared, kernel-enforced contract (ADR-0116, #4131)
10+
11+
`kernel.use()` registration order was never a contract — the kernel resolves
12+
init/start order from the plugin dependency graph — but a plugin that needed a
13+
service at init *when its provider is composed* while also booting *without*
14+
the provider had no way to declare that. `AppPlugin` was the standing example:
15+
it grabs `manifest`/`objectql` synchronously in `init()`, declared nothing
16+
(a hard dependency would break empty-env / metadata-only / mock-engine
17+
kernels), and so its correctness rode on which array slot each caller put it
18+
in. That convention failed the same way twice (`DefaultDatasourcePlugin`'s
19+
first cut; then #4085, disguised for months as "crashes when the artifact is
20+
missing").
21+
22+
The kernel `Plugin` contract gains three additive fields, enforced by both
23+
`ObjectKernel` and `LiteKernel` through one shared implementation
24+
(`plugin-order.ts` — the previously duplicated topological sort is unified
25+
there):
26+
27+
- **`optionalDependencies: string[]`** — order-if-present: hoisted ahead
28+
exactly like `dependencies` when composed (real topology edges, including
29+
cycle detection), silently skipped when absent.
30+
- **`requiresServices: string[]`** — services resolved synchronously during
31+
`init()` with no fallback. Validated **before Phase 1**: a required service
32+
whose only declared provider initializes later fails the boot with an error
33+
naming both plugins, both slots, and the fix — before any init side
34+
effects. Re-checked immediately before the plugin's own init, where a still-
35+
missing service becomes a named composition error exactly where the old
36+
bare `Service not found` crash fired.
37+
- **`providesServices: string[]`** — services a plugin's `init()`
38+
unconditionally registers; powers the validation and the diagnostics.
39+
40+
Plugins that declare nothing get the diagnosis too: a `getService` miss
41+
during Phase 1 now appends which plugin was initializing and — when a
42+
composed plugin declares the service — who provides it and how to declare the
43+
ordering. The `Service '<name>' not found` prefix and the factory-backed
44+
`is async - use await` message are unchanged.
45+
46+
First adopters: `AppPlugin` declares
47+
`optionalDependencies: ['com.objectstack.engine.objectql']` +
48+
`requiresServices: ['manifest']` (cleared on the empty-env no-op path), so
49+
the #4085 composition — AppPlugin registered before the engine — now boots
50+
correctly in every slot; `ObjectQLPlugin` declares
51+
`providesServices: ['objectql', 'data', 'manifest', 'lifecycle']` and
52+
`MetadataPlugin` declares `providesServices: ['metadata']`.
53+
54+
Everything is additive — plugins that declare nothing keep their exact
55+
ordering semantics; no existing declaration changes meaning.

content/docs/protocol/kernel/lifecycle.mdx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,35 @@ kernel.use(new AppPlugin(stack));
111111
await kernel.bootstrap();
112112
```
113113

114+
### Plugin Ordering Contract
115+
116+
`kernel.use()` **registration order is not a contract**. The kernel resolves
117+
both init and start order from the plugin dependency graph (topological sort;
118+
insertion order is kept only between plugins with no edges), and Phase 1
119+
(every `init()`) completes before Phase 2 (any `start()`) begins. A plugin
120+
that needs something another plugin sets up declares it (ADR-0116):
121+
122+
| Declaration | Semantics |
123+
| :--- | :--- |
124+
| `dependencies: string[]` | Hard — hoisted ahead of the declarant; a name not composed on the kernel fails the boot. |
125+
| `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. |
126+
| `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. |
127+
| `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. |
128+
129+
Start-time needs require no declaration — the Phase 1/2 split already
130+
guarantees every init-registered service is visible to every `start()`.
131+
132+
```typescript
133+
// AppPlugin: degrades on engine-less kernels, but when the engine is
134+
// composed it must never init first — and a non-empty bundle cannot
135+
// register at all without the manifest service.
136+
class AppPlugin implements Plugin {
137+
optionalDependencies = ['com.objectstack.engine.objectql'];
138+
requiresServices = ['manifest'];
139+
// ...
140+
}
141+
```
142+
114143
### Boot Logs (Example)
115144

116145
```

content/docs/references/kernel/plugin-validator.mdx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,10 @@ Plugin metadata for validation
4141
| :--- | :--- | :--- | :--- |
4242
| **name** | `string` || Unique plugin identifier |
4343
| **version** | `string` | optional | Semantic version (e.g., 1.0.0) |
44-
| **dependencies** | `string[]` | optional | Array of plugin names this plugin depends on |
44+
| **dependencies** | `string[]` | optional | Array of plugin names this plugin depends on (hard — a missing name fails the boot) |
45+
| **optionalDependencies** | `string[]` | optional | Plugin names hoisted ahead when composed, skipped when absent (order-if-present, ADR-0116) |
46+
| **requiresServices** | `string[]` | optional | Service names the plugin resolves synchronously during init(); validated by the kernel before Phase 1 (ADR-0116) |
47+
| **providesServices** | `string[]` | optional | Service names the plugin's init() unconditionally registers (ADR-0116) |
4548
| **signature** | `string` | optional | Cryptographic signature for plugin verification |
4649

4750

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# ADR-0116: Plugin ordering is a declared contract — soft dependencies plus validated init-service requirements
2+
3+
**Status**: Accepted (2026-07-30)
4+
**Deciders**: ObjectStack Protocol Architects
5+
**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)
6+
**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)
7+
**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)
8+
9+
---
10+
11+
## TL;DR
12+
13+
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.
14+
15+
This ADR makes the ordering contract declarable and enforced, with two small additions to the kernel `Plugin` contract and one validation pass:
16+
17+
- **`optionalDependencies`** — order-if-present: hoisted like `dependencies` when composed, skipped (not an error) when absent.
18+
- **`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.
19+
20+
Auto-reordering from service declarations is deliberately **not** done (yet): ordering is driven only by declared dependencies; service declarations drive *validation and diagnosis*.
21+
22+
## Context
23+
24+
### The mechanism that already existed
25+
26+
`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.
27+
28+
### The gap
29+
30+
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:
31+
32+
- `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`.
33+
- 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.
34+
35+
### Once is an accident, twice is a missing contract
36+
37+
`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.)
38+
39+
## Decision
40+
41+
**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']`.
42+
43+
**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`):
44+
45+
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.
46+
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.
47+
48+
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`.
49+
50+
**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 '<name>' not found` prefix is preserved verbatim (consumers substring-match it), as is the factory-backed `is async - use await` branch.
51+
52+
**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.
53+
54+
**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.
55+
56+
### What deliberately stays
57+
58+
- **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.
59+
- **`DefaultDatasourcePlugin`'s hard dependency** — correct as-is: every composition of that plugin composes ObjectQL.
60+
- **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.
61+
62+
## Consequences
63+
64+
- **+** 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.
65+
- **+** 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).
66+
- **+** 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.
67+
- **** 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).
68+
- **** `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.
69+
- **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.

packages/cli/src/commands/serve.ts

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1051,22 +1051,17 @@ export default class Serve extends Command {
10511051
const configHasMetadata = !!(
10521052
config.objects || config.manifest || config.apps || config.flows || config.apis
10531053
);
1054-
// ORDERING (#4085): the wrap is APPENDED to `plugins` rather than
1055-
// registered here, because plugin registration order IS the kernel's
1056-
// init/start order (`resolveDependencies` preserves insertion order for
1057-
// plugins that declare no `dependencies`, and AppPlugin declares none).
1058-
// AppPlugin.init() registers its manifest through the `manifest` service
1059-
// and AppPlugin.start() seeds through the default datasource — both owned
1060-
// by plugins that live in `plugins[]` (ObjectQLPlugin /
1061-
// DefaultDatasourcePlugin, contributed by `createStandaloneStack`) and
1062-
// registered by the loop far below. Registering the wrap HERE put it
1063-
// ahead of them, so config-boot died in Phase 1 with
1064-
// "Service 'manifest' is async - use await" whenever no compiled
1065-
// `dist/objectstack.json` existed — the artifact path never hit it only
1066-
// because `createStandaloneStack` appends ITS AppPlugin after the engine
1067-
// (which also made the crash look artifact-related rather than
1068-
// order-related). Appending puts the config-derived app in exactly that
1069-
// same slot, so both boot paths share one plugin order.
1054+
// ORDERING (#4085 → #4131/ADR-0116): the wrap is APPENDED to `plugins`
1055+
// rather than registered here — but the append is no longer what makes
1056+
// the boot correct. AppPlugin now DECLARES its ordering contract
1057+
// (`optionalDependencies: ['com.objectstack.engine.objectql']`,
1058+
// `requiresServices: ['manifest']`), so the kernel hoists the engine
1059+
// ahead of it in every slot, and a composition that genuinely lacks a
1060+
// manifest provider fails as a named ordering error instead of the
1061+
// mislabelled "Service 'manifest' is async - use await" crash #4085
1062+
// produced. Appending is kept so both boot paths (config-derived wrap
1063+
// here, `createStandaloneStack`'s own AppPlugin) share one plugin
1064+
// order and one story in the boot logs.
10701065
if (!hasAppPluginAlready && configHasMetadata) {
10711066
try {
10721067
const { AppPlugin } = await import('@objectstack/runtime');

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
export * from './kernel-base.js';
1111
export * from './kernel.js';
12+
export * from './plugin-order.js';
1213
export * from './lite-kernel.js';
1314
export * from './types.js';
1415
export * from './logger.js';

0 commit comments

Comments
 (0)