Skip to content

Commit 40e420f

Browse files
os-zhuangclaude
andauthored
refactor(spec)!: retire the plugin lifecycle-hook family the kernel never implemented (#4212) (#4233)
PluginLifecycleSchema declared onInstall / onEnable / onDisable / onUninstall / onUpgrade, each with confident TSDoc. The kernel calls none of them, and never has. Its plugin contract is init / start / destroy (packages/core/src/types.ts): use() validates and stores, bootstrap() runs init then start, shutdown runs destroy in reverse order. There is no install phase and no first-time-only path. Repo-wide, the schema's only importer was its own test — and the docs built on it sent authors at a dead seam (#4216 fixed the metadata-type registry's advice; this removes what the advice pointed at). Removed, with zero consumers verified across objectstack, objectui and cloud: - PluginLifecycleSchema + PluginLifecycleHooks, and the five hook members on PluginSchema (now a plain descriptor object — and, with the function members gone, JSON-representable: it publishes a kernel/Plugin JSON schema for the first time). - UpgradeContextSchema + UpgradeContext, which existed solely to serve onUpgrade. - The @objectstack/spec/system ./types module: the ObjectStackPlugin interface and its PluginContext / PluginLogger / ObjectQLClient / IKernel / ObjectOSKernel companions — seeded by the aspirational spec in issue #2, referenced by no file in any repo since. Route: plain deletion, not retiredKey() tombstones — nothing parses plugin objects through these schemas (stack.zod carries plugins as z.array(z.unknown())), so a prescription nobody can receive is noise; the plugin-runtime.zod.ts precedent. Per that precedent the key-vanish guard's baseline entries (kernel/UpgradeContext:* in authorable-surface.json) are dropped deliberately, and kernel/UpgradeContext leaves json-schema.manifest.json in the same PR. No ADR-0087 conversion: function members on runtime objects are not authorable stack metadata; there is no source for os migrate meta to rewrite. The kernel docs that taught the fiction now teach the real contract: protocol/kernel/lifecycle.mdx (five-hook example → init/start/destroy class, plus the app-bundle onEnable distinction), protocol/kernel/index.mdx (the "kernel calls onInstall/onEnable" claim and a fictional Kernel.installPlugin API → the real package-install flow), protocol/kernel/plugin-spec.mdx (a manifest `lifecycle:` file-map ManifestSchema never declared + a six-hook section with an invented context API → the runtime contract and real execution order), and AGENTS.md's own plugin example. Each page carries a callout naming what was fiction, so an author who wrote against the old docs finds the correction where the fiction was. Pin tests: PluginSchema declares none of the five names; an authored hook is stripped by the parse; the retired exports are gone from the module. The stack-level lint keeps reporting onDisable and friends as undeclared keys, so authors who still write them hear about it at load. Claude-Session: https://claude.ai/code/session_01HrRNgrWaRtggzmrHpbomyh Co-authored-by: Claude <noreply@anthropic.com>
1 parent f0d6594 commit 40e420f

22 files changed

Lines changed: 339 additions & 919 deletions

File tree

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
---
2+
"@objectstack/spec": major
3+
---
4+
5+
refactor(spec)!: retire the plugin lifecycle-hook family the kernel never implemented (#4212)
6+
7+
`PluginLifecycleSchema` declared `onInstall` / `onEnable` / `onDisable` /
8+
`onUninstall` / `onUpgrade`, each with confident TSDoc ("Called when plugin is
9+
installed", …). **The kernel calls none of them, and never has.** Its plugin
10+
contract is `init` / `start` / `destroy` (`packages/core/src/types.ts`):
11+
`kernel.use()` validates and stores, `bootstrap()` runs `init` then `start`,
12+
shutdown runs `destroy` in reverse order. There is no install phase and no
13+
first-time-only path. Repo-wide, the schema's only importer was its own test —
14+
and the docs built on it sent authors at a dead seam (`registerMetadataTypeSchema`
15+
told plugins to register custom metadata types "from their `onInstall` hook";
16+
a plugin that obeyed registered nothing, with no error saying so).
17+
18+
Removed, with zero consumers verified across `objectstack`, `objectui` and
19+
`cloud`:
20+
21+
- `PluginLifecycleSchema` + `PluginLifecycleHooks`, and the five hook members
22+
on `PluginSchema` (now a plain descriptor object).
23+
- `UpgradeContextSchema` + `UpgradeContext` — existed solely to serve
24+
`onUpgrade`.
25+
- The `@objectstack/spec/system` `./types` module: the `ObjectStackPlugin`
26+
interface (the same three hooks as a TS contract, referenced by no file in
27+
any repo) and its companions `PluginContext` (interface duplicate),
28+
`PluginLogger`, `ObjectQLClient`, `IKernel`, `ObjectOSKernel` — seeded by
29+
the aspirational spec in issue #2 and never consumed since.
30+
31+
FROM → TO:
32+
33+
- `onInstall` → there is no install-time code hook; installation is a
34+
package-registry state transition (`registry.installPackage()`
35+
`sys_packages`). Install-shaped setup (registering services, schemas,
36+
metadata types) belongs in the plugin's **`init(ctx)`**.
37+
- `onEnable` (kernel plugin) → `init(ctx)` / `start(ctx)`. The *app-bundle*
38+
`onEnable` module export is a different, real contract and is unchanged —
39+
`AppPlugin` invokes it at boot (`STACK_RUNTIME_MEMBERS`).
40+
- `onDisable` / `onUninstall``destroy()` for runtime teardown; package
41+
uninstall is a registry transition.
42+
- `onUpgrade` / `UpgradeContext` → package upgrades apply metadata migrations
43+
(ADR-0087); no plugin code runs.
44+
45+
One-line fix: replace the hook object with a class implementing
46+
`Plugin``name` + `async init(ctx)` (+ optional `start`/`destroy`).
47+
48+
Plain deletion rather than `retiredKey()` tombstones because nothing parses
49+
plugin objects through these schemas (`stack.zod` carries `plugins` as
50+
`z.array(z.unknown())`) — a prescription nobody can receive is noise (the
51+
`plugin-runtime.zod.ts` precedent). Per that precedent, the key-vanish guard's
52+
baseline entries (`kernel/UpgradeContext:*` in `authorable-surface.json`) are
53+
dropped deliberately in this PR. A pleasant side effect: with the function
54+
members gone, `PluginSchema` became JSON-representable and now publishes a
55+
`kernel/Plugin` JSON schema for the first time. No ADR-0087 conversion: these are function
56+
members on runtime objects, not authorable stack metadata; there is no source
57+
file for `os migrate meta` to rewrite. The kernel docs
58+
(`protocol/kernel/{index,lifecycle,plugin-spec}.mdx`) that documented the
59+
fictional lifecycle — including a manifest `lifecycle:` file-map ManifestSchema
60+
never declared — now document the real contract.

AGENTS.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -346,13 +346,16 @@ export const FieldSchema = z.object({
346346
export type Field = z.infer<typeof FieldSchema>;
347347
```
348348

349-
**Plugin:**
349+
**Plugin** (the kernel contract is `init`/`start`/`destroy`
350+
`packages/core/src/types.ts`; the old `onInstall`/`onEnable`/`onDisable`
351+
example described hooks nothing ever called, retired in #4212):
350352
```ts
351-
export default {
352-
async onInstall(ctx: PluginContext) { /* migrations */ },
353-
async onEnable(ctx: PluginContext) { /* register routes/services */ },
354-
async onDisable(ctx: PluginContext) { /* cleanup */ },
355-
};
353+
export class MyPlugin implements Plugin {
354+
name = 'plugin.my-feature';
355+
async init(ctx: PluginContext) { /* register services, schemas, routes */ }
356+
async start(ctx: PluginContext) { /* begin work that needs every service up */ }
357+
async destroy() { /* cleanup */ }
358+
}
356359
```
357360

358361
---

content/docs/protocol/kernel/index.mdx

Lines changed: 19 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ const config = context.config.resolve('stripe.apiKey', {
214214
**Kernel Solution:**
215215
- **Package Manifest:** The `manifest` block in `objectstack.config.ts` (compiled to `objectstack.json`) declares what a package provides and needs
216216
- **Dependency Resolution:** Automatic validation that plugin versions are compatible with core platform
217-
- **Lifecycle Hooks:** `onInstall`, `onEnable`, `onDisable` hooks for setup/teardown
217+
- **Lifecycle:** every plugin implements `init` / `start` / `destroy`, driven by `kernel.bootstrap()` and shutdown
218218
- **Sandboxing:** Plugins can't access each other's data or crash each other
219219
220220
**Results:**
@@ -272,8 +272,9 @@ await kernel.use(new AppPlugin(stack));
272272
await kernel.bootstrap();
273273
```
274274
275-
The kernel resolves plugin dependencies, calls each plugin's `onInstall` / `onEnable`
276-
hooks in order, and exposes the resulting services through DI.
275+
The kernel validates each plugin at `use()`, then `bootstrap()` runs every
276+
plugin's `init` in registration order followed by every plugin's `start`, and
277+
exposes the resulting services through DI.
277278
278279
### 2. Request Handling
279280
```typescript
@@ -307,35 +308,23 @@ async function handleRequest(req) {
307308
}
308309
```
309310
310-
### 3. Plugin Installation
311+
### 3. Package Installation
311312
```typescript
312313
// Install a package: os package install @vendor/salesforce-sync
313-
await Kernel.installPlugin({
314-
source: '@vendor/salesforce-sync@1.5.0',
315-
316-
// Step 1: Validate dependencies
317-
validate: (manifest) => {
318-
PluginRegistry.checkDependencies(manifest.dependencies);
319-
// Ensures '@objectstack/core@^2.0.0' is installed
320-
},
321-
322-
// Step 2: Run onInstall hook
323-
onInstall: async (plugin) => {
324-
// Plugin may create custom objects (ObjectQL)
325-
await ObjectQL.createObjects(plugin.objects);
326-
327-
// Plugin may register UI views (ObjectUI)
328-
await ObjectUI.registerViews(plugin.views);
329-
330-
// Plugin may set default config
331-
await ConfigStore.set(plugin.defaultConfig);
332-
},
333-
334-
// Step 3: Enable plugin
335-
enable: () => {
336-
PluginRegistry.enable('@vendor/salesforce-sync');
337-
}
338-
});
314+
//
315+
// A package is METADATA — no package code executes at install time.
316+
// What actually happens:
317+
//
318+
// 1. Validate — manifest shape, engines.protocol compatibility (ADR-0087),
319+
// artifact signature.
320+
// 2. Register — registry.installPackage() gates the namespace and records
321+
// the InstalledPackage; the durable copy lands in `sys_packages` and is
322+
// rehydrated on every boot.
323+
// 3. Materialize — metadata is hot-registered, `syncSchemas` applies the
324+
// schema, and declared side effects (translations, seed data) run.
325+
//
326+
// Executable members of an app bundle (`onEnable`, `functions`) run at BOOT,
327+
// invoked by AppPlugin — installation only stores them.
339328
```
340329
341330
## Philosophy: Infrastructure as Code

content/docs/protocol/kernel/lifecycle.mdx

Lines changed: 42 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -299,51 +299,57 @@ async function resolveDependencies(
299299
}
300300
```
301301

302-
### Plugin Lifecycle Hooks
302+
### Plugin Runtime Contract
303303

304-
Plugins can define hooks that run at specific lifecycle events:
304+
Installation registers **metadata** — no plugin code runs at install time.
305+
Executable behaviour belongs to the runtime contract, which has exactly three
306+
methods (`packages/core/src/types.ts`), invoked by the kernel:
305307

306308
```typescript
307-
// plugin.ts — a plugin is a plain default-exported object (there is no
308-
// `definePlugin` helper). Lifecycle hooks are top-level methods, each
309-
// receiving a single PluginContext ({ ql, os, logger, config, pluginId }).
310-
export default {
311-
name: '@vendor/salesforce',
312-
version: '3.2.1',
313-
314-
// Runs once, when the plugin is installed
315-
onInstall: async (context) => {
316-
// Validate environment. `config` is a plain record, not a store.
317-
if (!context.config['salesforce.apiKey']) {
309+
// plugin.ts — a runtime plugin implements Plugin: `name` plus
310+
// init / start / destroy. `init` is required; the other two are optional.
311+
export class SalesforceSyncPlugin implements Plugin {
312+
name = 'plugin.salesforce-sync';
313+
version = '3.2.1';
314+
315+
// Phase 1 — runs for every plugin, sequentially, in registration order.
316+
// Register services, schemas and routes here; other plugins' services
317+
// may not exist yet.
318+
async init(ctx: PluginContext) {
319+
if (!process.env.SALESFORCE_API_KEY) {
318320
throw new Error('Salesforce API key not configured');
319321
}
320-
context.logger.info('Salesforce plugin installed');
321-
},
322+
}
322323

323-
// Runs when the plugin is enabled (at startup or manually)
324-
onEnable: async (context) => {
325-
context.logger.info('Salesforce sync enabled');
326-
},
324+
// Phase 2 — runs after EVERY plugin's init has completed, so every
325+
// registered service is resolvable. Begin actual work here.
326+
async start(ctx: PluginContext) {
327+
ctx.logger.info('Salesforce sync started');
328+
}
327329

328-
// Runs when the plugin is disabled (at shutdown or manually)
329-
onDisable: async (context) => {
330-
context.logger.info('Salesforce sync disabled');
331-
},
330+
// Shutdown — invoked in reverse registration order, and as rollback for
331+
// plugins already started when a later plugin's start() fails.
332+
async destroy() {
333+
/* stop timers, close connections */
334+
}
335+
}
336+
```
332337

333-
// Runs once, when the plugin is uninstalled
334-
onUninstall: async (context) => {
335-
context.logger.info('Removing Salesforce plugin data');
336-
},
338+
An **authored app bundle** (`objectstack.config.ts`) is not a kernel plugin
339+
and has its own single code seam: a module-level `export const onEnable`,
340+
which `AppPlugin` invokes at its own `start()` with the host context. That is
341+
the place apps register action handlers — see the worked examples in
342+
`examples/app-todo` and `examples/app-showcase`.
337343

338-
// Runs when the plugin version changes. Receives an UpgradeContext with
339-
// previousVersion, newVersion, and isMajorUpgrade.
340-
onUpgrade: async (context) => {
341-
context.logger.info(
342-
`Salesforce ${context.previousVersion} → ${context.newVersion}`,
343-
);
344-
},
345-
};
346-
```
344+
<Callout type="warn">
345+
Earlier revisions of this page documented an `onInstall` / `onEnable` /
346+
`onDisable` / `onUninstall` / `onUpgrade` hook family on plugins. **The kernel
347+
never called any of them** — code written against them silently never ran —
348+
and the family was retired from the protocol (#4212, ADR-0049
349+
enforce-or-remove). Install/uninstall/upgrade are package-registry state
350+
transitions (this page, above), not code hooks; boot-time code goes in
351+
`init`/`start`.
352+
</Callout>
347353

348354
### Installation CLI
349355

0 commit comments

Comments
 (0)