Skip to content

Commit 51ae731

Browse files
claude[bot]claude
andauthored
feat(core): LiteKernel.use() enforces the declared plugin contract, converged with ObjectKernel (#16721 step 2) (#16827)
* wip(core): LiteKernel.use() runs the shared plugin contract (#16721 step 2) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x * wip(runtime): keep the tracker id out of the vocabulary row's runtime prose Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x * docs(plugins): anatomy names slug as required for ui plugins and says kernel.use() enforces the contract on both kernels Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e08892d commit 51ae731

10 files changed

Lines changed: 612 additions & 255 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
"@objectstack/core": minor
3+
---
4+
5+
`LiteKernel.use()` now enforces the declared plugin contract — the same check, the same refusal, as `ObjectKernel.use()`. A plugin object that `PluginSchema` (`@objectstack/spec`, `kernel/plugin.zod.ts`) refuses is refused at registration on **both** published kernels instead of on one.
6+
7+
**BREAKING** accept-set narrowing on a published runtime entry point, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). **A plugin object `LiteKernel` accepted before can be refused now.** Until this release `LiteKernel.use()` wrote the object straight into its registry: `PluginSchema` was run by `PluginLoader.validatePluginContract` only, and `PluginLoader` is reached from `ObjectKernel.use()` alone. So the same plugin was accepted by one kernel and refused by the other — a `type: 'ui'` plugin with no `slug` was refused by `ObjectKernel` with `PLUGIN_CONTRACT_VIOLATION` and mounted a route on `LiteKernel`. `AGENTS.md` names `LiteKernel` for tests, serverless and edge, so the lenient kernel was the one authors develop against and the strict one was production: a plugin could be green in vitest and refused at boot. Maintainer ruling of 2026-09-08 (option A, under the precedent that the two kernels converge rather than diverge): `LiteKernel` validates too.
8+
9+
**Exactly what `LiteKernel.use()` newly refuses** is exactly what `ObjectKernel.use()` has refused since the `kernel.use()` enforcement release: all EIGHT declared keys, each refused with the offending key named in the message —
10+
11+
- **`id`** — a non-string, or the empty string.
12+
- **`type`** — any value outside the closed set `standard`, `ui`, `driver`, `server`, `app`, `theme`, `agent`, `objectql`.
13+
- **`staticPath`** — a non-string.
14+
- **`slug`** — a non-string, or a string that does not match `/^[a-z0-9-_]+$/`.
15+
- **`default`** — a non-boolean.
16+
- **`description`** — a non-string.
17+
- **`author`** — a non-string.
18+
- **`homepage`** — a non-string, or a string that is not a URL.
19+
20+
**`null` is refused on every one of the eight**, and a `type: 'ui'` plugin missing `staticPath` or `slug` is refused with `PLUGIN_UI_REQUIRED_KEY_MISSING` inside the same envelope.
21+
22+
**What a refusal looks like — one refusal, from either kernel.** The check is now one function (`assertPluginContract`, package-internal) that both kernels call, so the code and the message are produced once:
23+
24+
```
25+
PLUGIN_CONTRACT_VIOLATION: plugin '@acme/console' is refused by the declared
26+
plugin contract at 'slug': PLUGIN_UI_REQUIRED_KEY_MISSING: a `type: 'ui'` plugin must declare `slug` — …
27+
```
28+
29+
`LiteKernel.use()` is synchronous and throws that error as-is, so the stable code is on the error's `code` property as well as at the head of the message. `ObjectKernel.use()` is unchanged: it still re-wraps a failed load as `Failed to load plugin: <name> - <that message>`, its existing wrapper for every load failure. The text after that prefix is byte-for-byte the `LiteKernel` message for the same input, pinned by test.
30+
31+
**What is STILL ACCEPTED on `LiteKernel` — the narrowing stops where `ObjectKernel`'s does.** Unknown keys still pass (`PluginSchema` carries no `.strict()`, and the parse output is discarded, so the stored object is the very object passed in). A version-less plugin still loads, and so do `1.0.0-alpha.1` and `1.0.0+20230101`: `version` is excluded from the schema check on both kernels, and `LiteKernel` — which has never judged `version` — still does not. A plugin declaring no `type` still loads and still stores no `type`. A class-based plugin keeps its identity, its prototype and its prototype methods. And `PluginLoader`'s structural checks (`name`, `init`, semver) stay the loader's own: the convergence is on the schema, not on the loader.
32+
33+
**Ordering, stated because it is observable.** `LiteKernel.use()` checks its state first (a kernel past bootstrap still says `Cannot register plugins after bootstrap has started`, never `PLUGIN_CONTRACT_VIOLATION`), then the contract, then registers — so a refused plugin never reaches the registry and cannot supersede an earlier registration under its name.
34+
35+
**Blast radius, measured before landing rather than assumed.** Across this repository's suites, 813 `LiteKernel.use()` calls were reachable; 807 were accepted by the schema unchanged and the six refusals came from three test-local fixture objects in two files — zero product or library code. Externally authored plugins registered on `LiteKernel` are the population this reaches, and they are exactly the plugins that would already have been refused by `ObjectKernel` at production boot.
36+
37+
**Migration.** There is nothing to rename. A plugin refused on `LiteKernel` now was already refused on `ObjectKernel`; fix the named key: give `type` a value from the closed set (or drop it — an absent `type` reads as `standard`), declare `staticPath` and `slug` on a `type: 'ui'` plugin, spell `slug` in `[a-z0-9-_]`, make `homepage` a URL, and never `null` a declared key. The refusal names the plugin and the first violated key.
38+
39+
<!-- adr-0087: not-required (no-migration-prescription) An accept-set narrowing performed entirely at the runtime registration path: `PluginSchema` is READ by `LiteKernel.use()` now, exactly as `ObjectKernel.use()` has read it since the `kernel.use()` enforcement release — the schema itself is not changed. No metadata key, spec symbol, Zod schema, object definition or stored representation is added, removed or given a different name, so `objectstack migrate meta` has nothing to visit and there is no tombstone to mint. Stored metadata is untouched; what moves is which plugin OBJECTS the second kernel accepts, and every object it newly refuses was already refused by the first. The channel that reaches an affected plugin author is the refusal itself, which names the offending key at `use()` and is more precise than a ledger line — and which value a refused key should carry is authoring intent no ledger entry can decide. -->

content/docs/plugins/anatomy.mdx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,9 @@ ObjectStack uses `type` discrimination to optimize runtime behavior, allowing th
6969
* **Behavior:**
7070
* **Passive:** Driven by `plugin-hono-server` (or other HTTP adapters).
7171
* **Static Assets:** Must provide `staticPath` pointing to a build output (e.g., `dist/`).
72-
* **Routing:** Automatically mounted to `/{slug}` with SPA fallback support.
72+
* **Routing:** Must provide `slug`; automatically mounted to `/{slug}` with SPA fallback support.
7373
* **Assets:** Files are served locally under `/{slug}/assets`.
74+
* **Enforced at registration:** a `type: 'ui'` plugin missing `staticPath` or `slug` is refused by `kernel.use()` with `PLUGIN_CONTRACT_VIOLATION` — on `ObjectKernel` and `LiteKernel` alike — so a UI plugin that boots in a `LiteKernel` test harness is one that also boots in production.
7475

7576
### 3. App Plugin (`app`)
7677
* **Role:** Vertical Business Solution.
@@ -259,6 +260,7 @@ Plugins follow a strict three-phase lifecycle managed by the kernel:
259260
└──────────────────────┘
260261
```
261262

263+
0. **kernel.use()** — Registers the plugin. Before storing it, both kernels check the object against the declared plugin contract (`PluginSchema` in `@objectstack/spec`) and refuse one that violates it with `PLUGIN_CONTRACT_VIOLATION`, naming the first violated key — a `type` outside the closed set, a non-URL `homepage`, a `null` on a declared key, or a `type: 'ui'` plugin missing `staticPath` / `slug`. The object is validated, never replaced: what is stored is the very instance you passed in, prototype and all.
262264
1. **init()** — Called during kernel initialization. Register services that other plugins may depend on.
263265
2. **start()** — Called after *all* plugins have initialized. Start servers, connect to databases, or execute main logic.
264266
3. **destroy()** — Called during shutdown, in reverse order. Clean up connections, timers, and resources.

packages/cli/test/fixtures/option-b-reader-probe.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,11 @@ function makeRecorder(rec: Recording) {
186186
let ctxRef: Bag | undefined;
187187
return {
188188
name: 'com.objectstack.probe.option-b-recorder',
189-
type: 'service' as const,
189+
// No `type`: `PluginSchema` defaults an absent `type` to `standard`, and
190+
// `'service'` is not a member of the declared closed set — `ObjectKernel`
191+
// refused this object already, and since #16721 `LiteKernel.use()` (the
192+
// kernel `bootAndRecord` boots) runs the same contract. Nothing here reads
193+
// `.type`; the recorder IS the subsystems, not a typed plugin.
190194
version: '1.0.0',
191195
init: async (ctx: Bag) => {
192196
ctxRef = ctx;

packages/core/src/lite-kernel.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { createLogger, ObjectLogger } from './logger.js';
55
import type { LoggerConfig } from '@objectstack/spec/system';
66
import { ObjectKernelBase } from './kernel-base.js';
77
import { registerPluginByName } from './plugin-registration.js';
8+
import { assertPluginContract } from './plugin-contract.js';
89

910
/**
1011
* ObjectKernel - MiniKernel Architecture
@@ -34,6 +35,26 @@ export class LiteKernel extends ObjectKernelBase {
3435
* Register a plugin
3536
* @param plugin - Plugin instance
3637
*
38+
* A plugin object the DECLARED plugin contract refuses is refused here,
39+
* with `PLUGIN_CONTRACT_VIOLATION` — the same check, the same envelope,
40+
* that `ObjectKernel.use()` runs through `PluginLoader` (`plugin-contract.ts`
41+
* is the one statement both kernels call; #16721, maintainer ruling
42+
* 2026-09-08, option A under #9864's precedent that the kernels converge).
43+
*
44+
* This method used to write the object straight into the registry, so the
45+
* same plugin was accepted by this kernel and refused by `ObjectKernel` —
46+
* and `AGENTS.md` names THIS kernel for tests, so a plugin could be green
47+
* in vitest and refused at production boot. Measured before converging
48+
* (#16721 step 1): of 813 `LiteKernel.use()` calls reachable in this
49+
* repository's suites, 807 were accepted by the schema unchanged and the
50+
* six refusals came from three test-local fixture objects, none of them
51+
* product code.
52+
*
53+
* Ordering, and why it is pinned: state first (`validateIdle`), then the
54+
* contract, then registration — a refused plugin never reaches the
55+
* registry, so it can neither be booted nor supersede an earlier
56+
* registration under its name.
57+
*
3758
* Duplicate names OVERWRITE, with one `warn` naming both versions — the
3859
* declared contract in `plugin-registration.ts`, applied identically by
3960
* `ObjectKernel.use()` (#9864, maintainer ruling 2026-08-19).
@@ -48,6 +69,9 @@ export class LiteKernel extends ObjectKernelBase {
4869
use(plugin: Plugin): this {
4970
this.validateIdle();
5071

72+
// Same check, same envelope, as `ObjectKernel.use()` (#16721).
73+
assertPluginContract(plugin);
74+
5175
registerPluginByName(this.plugins, plugin, this.logger);
5276

5377
return this;

0 commit comments

Comments
 (0)