Skip to content

Commit 2025b1f

Browse files
claude[bot]claude
andauthored
feat(core): enforce PluginSchema at kernel.use() (#16049) (#16363)
* feat(core): enforce PluginSchema at kernel.use() `PluginSchema` had zero runtime callers: the boot path checked `name`, `init` and semver, and every other constraint the protocol declared was a declaration with nothing behind it. `defineStack` accepted `type: 'ui-plugin'` while `PluginSchema.safeParse` refused it, and only one of those answers was on the path a real plugin takes. `PluginLoader.validatePluginContract` now runs the schema over every plugin object and refuses one the schema refuses, through the loader's existing error path with the stable code `PLUGIN_CONTRACT_VIOLATION`, naming the plugin and the first violated key. `safeParse` is used for VALIDATION ONLY and the parse output is discarded — a copy destroys the prototype chain of class-based plugins, which is why `toPluginMetadata` is a cast. A class-based plugin's identity, prototype and prototype methods are pinned. `version` is deliberately excluded and the exclusion is measured, not assumed: the schema's `/^\d+\.\d+\.\d+$/` refuses the prerelease and build-metadata forms SemVer 2.0.0 defines, while the loader's own `isValidSemanticVersion` accepts them and `plugin-loader.test.ts` pins that acceptance deliberately. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * test(core): narrow the refusal helper, and state the change for consumers `kernel.use(x).catch((e) => e as Error)` resolves to `Kernel | Error`, so a case whose input stopped being refused would assert against a kernel and report a property miss rather than "this loaded". The helper throws instead. The changeset states the published-behaviour change the ruling names: an unknown `type`, an invalid `slug` or an invalid `homepage` is now refused at load, and `version` is deliberately not enforced from the schema. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * fix(runtime): drop the tracker id from the PLUGIN_CONTRACT_VIOLATION why string `check:doc-authoring`'s cross-package prose-id leg reads every string literal under packages/** and holds each (file, id) pair to the pinned baseline; the new row's `why:` string cited the tracker id of the pre-HTTP ruling and took the file's `#8035` count from 6 pinned to 7 measured. The sentence now names the reasoning the rows above cite instead of the tracker id, which is what the gate prescribes ("only the tracker id goes"); the meaning is unchanged and the count reads 6 again. The `//` comment marker above the row is outside that gate's population and stays. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * docs(core): enumerate all eight enforced plugin keys, and declare the break The contract review returned NOT PASSED on two text findings. Neither moves a line of enforcement: `plugin-loader.ts`'s filter and `PluginSchema` are byte-identical to the reviewed head. 1. The narrowing was understated by five of eight keys. `PluginSchema` declares nine optional keys and `validatePluginContract` excludes `version`, so the refusal reaches `id`, `type`, `staticPath`, `slug`, `default`, `description`, `author` and `homepage` — plus an explicit `null` on any of them, all eight being `.optional()`. The changeset, the loader's JSDoc and the `PLUGIN_CONTRACT_VIOLATION` vocabulary row each named only three of them, so an author refused `at 'author'` who greps the shipped CHANGELOG read an enumeration affirmatively saying their key is not enforced. All three carriers now enumerate the eight and the `null` behaviour, and all three state what is STILL accepted, which is what bounds the blast radius: unknown keys pass (a plain `z.object`, no `.strict()`), a version-less plugin loads, and `version` is excluded outright so `1.0.0-alpha.1` and `1.0.0+20230101` still load. 2. No `**BREAKING**` banner and no ADR-0087 disposition. `check-changeset-no-major.mjs` names an accept-set narrowing as the breaking shape and those two as the mandatory carriers during the launch window; the precedent on this same key (`d8024f0`) carries both. The changeset now opens with the banner in that shape and closes with exactly one `not-required (no-migration-prescription)` disposition: `PluginSchema` is read, not changed, no stored representation moves, and the channel that reaches an affected author is the refusal naming the key. The level stays `minor`. No `#NNNNN` id enters the vocabulary `why` string, so the cross-package prose-id leg of `check:doc-authoring` stays at its baseline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --------- Co-authored-by: claude <noreply@anthropic.com>
1 parent 0a038cc commit 2025b1f

5 files changed

Lines changed: 479 additions & 1 deletion

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
"@objectstack/core": minor
3+
---
4+
5+
`kernel.use()` now enforces the declared plugin contract. A plugin object that `PluginSchema` (`@objectstack/spec`, `kernel/plugin.zod.ts`) refuses is refused at load instead of being stored and mounted.
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`). **This refuses input the runtime accepted before**, which is also why it is not a `patch`: `PluginSchema` had zero runtime callers, so every constraint it declared beyond `name`, `init` and semver was a declaration with nothing behind it. The sharpest reading of that gap, one input and two answers: `defineStack` accepted `type: 'ui-plugin'` while `PluginSchema.safeParse` refused it — and only one of those answers was on the path a real plugin takes. Maintainer ruling of 2026-09-06 (ADR-0049 enforce-or-remove): the protocol is the baseline, the runtime aligns to it.
8+
9+
**Exactly what is newly refused: all EIGHT declared keys, not three.** The schema declares nine optional keys; the loader excludes `version` (below), so enforcement reaches these eight, each refused with the offending key named in the message:
10+
11+
- **`id`** — a non-string, or the empty string (`z.string().min(1)`).
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. An object such as `{ name: 'x' }` is refused; the declared type is a plain 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.** These keys are `.optional()`, which admits absence and `undefined` — never an explicit `null`. A plugin object that spells "no value" as `null` on any of the eight loaded before and is refused now.
21+
22+
**What a refusal looks like.** It travels the loader's existing plugin-load error path — no new error channel — carrying the stable code `PLUGIN_CONTRACT_VIOLATION` at the head of the message and on the error's `code` property, and naming the plugin plus the first violated key:
23+
24+
```
25+
PLUGIN_CONTRACT_VIOLATION: plugin '@acme/console' is refused by the declared
26+
plugin contract at 'type': Invalid option: expected one of "standard"|"ui"|…
27+
```
28+
29+
A wrong `type` is therefore diagnosable at boot rather than at route mount. The code is a **boot refusal**, not wire vocabulary: it is raised before any HTTP boundary exists, and no door answers with it.
30+
31+
**What is STILL ACCEPTED — the door is not narrowed past those eight keys.** Measured on this tree, not assumed:
32+
33+
- **Unknown keys still pass.** `PluginSchema` is a plain `z.object` with **no `.strict()`** — the strip posture — and the parse output is discarded, so a valid plugin carrying four keys the schema never declares loads, and is stored as the very object that was passed in with all of its keys intact. A plugin is refused for what it says about a **declared** key, never for saying something extra.
34+
- **A version-less plugin still loads**, exactly as before.
35+
- **A plugin declaring no `type` still loads and still stores no `type`**: `PluginSchema`'s `.default('standard')` is **not** written back.
36+
- **A class-based plugin keeps its identity, its prototype and its prototype methods.** The plugin object is validated, never replaced: `safeParse` is read for `success` and its output discarded, because a copy destroys the prototype chain of class-based plugins — the reason `PluginLoader.toPluginMetadata` is a cast. That survival is pinned by test, not asserted in prose.
37+
- **`version` is excluded from this enforcement entirely**, so `1.0.0-alpha.1` and `1.0.0+20230101` still load. The schema spells `version` as `/^\d+\.\d+\.\d+$/`, which refuses the prerelease and build-metadata forms SemVer 2.0.0 defines, while the loader's own `isValidSemanticVersion` implements the full grammar and accepts them — deliberately, pinned by `plugin-loader.test.ts`. Enforcing the narrower spelling would retire that capability silently, so the loader's check remains authoritative for `version`. Reconciling the two spellings is spec work, tracked separately.
38+
39+
**Blast radius, measured rather than assumed.** Every in-repo plugin object declares a `type` inside the closed set (`standard` ×62, `server` ×2, `driver` ×2, `objectql`, `app`), and the repo contains no producer of `slug` or `homepage` on a plugin object at all — so no in-repo plugin changes behaviour. Externally authored plugins are the population this reaches, and they are exactly the population that never met the compile-time `Plugin.type` union either.
40+
41+
<!-- adr-0087: not-required (no-migration-prescription) An accept-set narrowing performed entirely at the runtime boot path: `PluginSchema` is READ by `kernel.use()`, 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 a boot accepts. The channel that reaches an affected plugin author is the refusal itself, which names the offending key at `kernel.use()` and is more precise than a ledger line — and which value a formerly-refused key should carry is authoring intent no ledger entry can decide. -->
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `kernel.use()` enforces the DECLARED plugin contract (#16049).
5+
*
6+
* WHY THIS FILE EXISTS. `PluginSchema` (`@objectstack/spec`,
7+
* `kernel/plugin.zod.ts`) had zero runtime callers. The boot path ran three
8+
* checks — `name`, `init`, semver — and every other constraint the protocol
9+
* declared was a declaration with nothing behind it. The sharpest single
10+
* reading from #15638, one input and two answers: `defineStack` accepted
11+
* `type: 'ui-plugin'` while `PluginSchema.safeParse` refused it, and only one
12+
* of those answers was on the path a real plugin takes. The maintainer ruled
13+
* enforce, not remove (2026-09-06, ADR-0049): the protocol is the baseline and
14+
* the runtime aligns to it.
15+
*
16+
* WHAT MAKES THE POSITIVE CASES LOAD-BEARING. A file that only asserted
17+
* refusals would pass just as well against a `use()` that refused everything.
18+
* Every refusal case here has a calibration twin one line away — the SAME
19+
* fixture with the offending key corrected — so a refusal is attributable to
20+
* the key under test and not to the harness.
21+
*
22+
* ⭐ THE PROTOTYPE CASE IS NOT A NICETY. The ruling requires `safeParse` be
23+
* used for VALIDATION ONLY, because `PluginLoader.toPluginMetadata` is a cast
24+
* and its comment records why: "Do not use object spread {...plugin} as it
25+
* destroys the prototype chain for Class-based plugins." Substituting the parse
26+
* output for the plugin object is the one change that would break every
27+
* class-based plugin in the ecosystem while leaving every refusal test in this
28+
* file green. Group C is the falsifier for exactly that mistake: it asserts
29+
* object IDENTITY, prototype identity, and that a method living only on the
30+
* prototype is still callable off what the kernel stored.
31+
*/
32+
33+
import { describe, expect, it } from 'vitest';
34+
import { ObjectKernel } from './kernel.js';
35+
import { PluginLoader } from './plugin-loader.js';
36+
import { ObjectLogger } from './logger.js';
37+
import type { Plugin, PluginContext } from './types.js';
38+
39+
/** A kernel that registers plugins and installs no process signal handlers. */
40+
function makeKernel(): ObjectKernel {
41+
return new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false });
42+
}
43+
44+
/** What `kernel.use()` left in the kernel's own plugin map. */
45+
function stored(kernel: ObjectKernel, name: string): Record<string, unknown> | undefined {
46+
return (kernel as unknown as { plugins: Map<string, Record<string, unknown>> })
47+
.plugins.get(name);
48+
}
49+
50+
/**
51+
* A plugin object with an arbitrary extra surface. The keys under test
52+
* (`type`, `slug`, `homepage`, `id`) are declared by `PluginSchema` and NOT by
53+
* the `Plugin` interface, which is one reason the repo contained no producer of
54+
* them — so the fixture states the extra surface rather than casting it away.
55+
*/
56+
type Fixture = Plugin & {
57+
id?: string;
58+
slug?: string;
59+
homepage?: string;
60+
staticPath?: string;
61+
};
62+
63+
/**
64+
* The refusal `promise` produced, or a loud failure if it produced none.
65+
*
66+
* ⛔ Not `promise.catch((e) => e as Error)`: that resolves to `Kernel | Error`,
67+
* so a case whose input STOPPED being refused would go on asserting against a
68+
* kernel and report a confusing property miss instead of "this loaded".
69+
*/
70+
async function refusal(promise: Promise<unknown>): Promise<Error> {
71+
try {
72+
await promise;
73+
} catch (e) {
74+
return e as Error;
75+
}
76+
throw new Error('expected the plugin to be refused, but it loaded');
77+
}
78+
79+
function fixture(overrides: Partial<Fixture> & { name: string }): Fixture {
80+
return {
81+
version: '1.0.0',
82+
type: 'standard',
83+
init: () => { /* a contract fixture registers nothing */ },
84+
...overrides,
85+
};
86+
}
87+
88+
describe('A — the legacy `ui-plugin` value is refused at kernel.use() (#15638, #16049)', () => {
89+
it('rejects, and the rejection names the stable code, the plugin and the violated key', async () => {
90+
const kernel = makeKernel();
91+
const legacy = fixture({
92+
name: '@os-fixture/legacy-ui',
93+
// The value #15638 MEASURED as accepted, stored verbatim and mounting
94+
// routes. It is not a member of `CORE_PLUGIN_TYPES`.
95+
type: 'ui-plugin' as unknown as Plugin['type'],
96+
});
97+
98+
await expect(kernel.use(legacy)).rejects.toThrow(/PLUGIN_CONTRACT_VIOLATION/);
99+
100+
// The envelope, not merely "it threw": a bare `toThrow()` would stay
101+
// green if the kernel started refusing this input for an unrelated
102+
// reason, which is the failure mode this card was filed about.
103+
const err = await refusal(kernel.use(legacy));
104+
expect(err.message).toContain('PLUGIN_CONTRACT_VIOLATION');
105+
expect(err.message).toContain('@os-fixture/legacy-ui');
106+
expect(err.message).toContain("at 'type'");
107+
108+
// …and nothing was stored, so no later seam can read it off the kernel.
109+
expect(stored(kernel, '@os-fixture/legacy-ui')).toBeUndefined();
110+
});
111+
112+
it('CALIBRATION — the same fixture with the modern `ui` value loads', async () => {
113+
const kernel = makeKernel();
114+
const modern = fixture({ name: '@os-fixture/modern-ui', type: 'ui' });
115+
116+
await expect(kernel.use(modern)).resolves.toBe(kernel);
117+
expect(stored(kernel, '@os-fixture/modern-ui')?.type).toBe('ui');
118+
});
119+
120+
it('stamps `code` on the error the loader itself raises', async () => {
121+
// `ObjectKernel.use()` re-wraps a failed load into a fresh `Error`
122+
// carrying only the message, so the PROPERTY is observable one layer
123+
// in. Both surfaces are pinned: the property here, the message above.
124+
const loader = new PluginLoader(new ObjectLogger({ level: 'silent' }));
125+
const result = await loader.loadPlugin(
126+
fixture({ name: 'x', type: 'ui-plugin' as unknown as Plugin['type'] }),
127+
);
128+
129+
expect(result.success).toBe(false);
130+
expect((result.error as Error & { code?: string })?.code).toBe('PLUGIN_CONTRACT_VIOLATION');
131+
});
132+
});
133+
134+
describe('B — a plain `standard` plugin still loads', () => {
135+
it('registers and is stored verbatim', async () => {
136+
const kernel = makeKernel();
137+
const plain = fixture({ name: 'com.example.plain' });
138+
139+
await expect(kernel.use(plain)).resolves.toBe(kernel);
140+
141+
const entry = stored(kernel, 'com.example.plain');
142+
expect(entry).toBeDefined();
143+
// Identity, not equality: the loader casts rather than copies, and the
144+
// stored entry must be the caller's own object.
145+
expect(entry).toBe(plain);
146+
});
147+
148+
it('a plugin declaring NO type at all still loads — `type` is optional', async () => {
149+
const kernel = makeKernel();
150+
const untyped: Plugin = { name: 'com.example.untyped', version: '1.0.0', init: () => {} };
151+
152+
await expect(kernel.use(untyped)).resolves.toBe(kernel);
153+
// ⛔ The parse output is discarded, so `PluginSchema`'s `.default('standard')`
154+
// must NOT have been written back onto the stored object.
155+
expect(stored(kernel, 'com.example.untyped')?.type).toBeUndefined();
156+
});
157+
});
158+
159+
describe('C — ⭐ a CLASS-BASED plugin still loads, prototype chain intact', () => {
160+
class ClassPlugin implements Plugin {
161+
name = 'com.example.class-based';
162+
version = '2.3.4';
163+
type = 'standard' as const;
164+
165+
/** Lives on the PROTOTYPE, not on the instance — the whole point. */
166+
async init(_ctx: PluginContext): Promise<void> { /* no services */ }
167+
168+
/** Ditto: unreachable through any copy of the instance. */
169+
describeSelf(): string { return `class:${this.name}`; }
170+
}
171+
172+
it('stores the SAME object, with its prototype and prototype methods intact', async () => {
173+
const kernel = makeKernel();
174+
const instance = new ClassPlugin();
175+
176+
await expect(kernel.use(instance)).resolves.toBe(kernel);
177+
178+
const entry = stored(kernel, 'com.example.class-based');
179+
180+
// The three independent statements a spread would break. Each fails on
181+
// its own if `safeParse`'s OUTPUT is ever substituted for the plugin:
182+
expect(entry).toBe(instance); // identity
183+
expect(Object.getPrototypeOf(entry)).toBe(ClassPlugin.prototype); // chain
184+
expect(entry).toBeInstanceOf(ClassPlugin);
185+
expect((entry as unknown as ClassPlugin).describeSelf())
186+
.toBe('class:com.example.class-based'); // callable
187+
188+
// A parse copy carries own enumerable data properties only, so the
189+
// control that a spread WOULD have preserved is asserted too — this is
190+
// what makes the three above attributable to the prototype and not to a
191+
// fixture that happens to have no data.
192+
expect(entry?.version).toBe('2.3.4');
193+
});
194+
195+
it('a class-based plugin with a REFUSED type is still refused', async () => {
196+
class BadClassPlugin implements Plugin {
197+
name = 'com.example.class-bad';
198+
version = '1.0.0';
199+
type = 'ui-plugin' as unknown as Plugin['type'];
200+
async init(): Promise<void> {}
201+
}
202+
203+
const kernel = makeKernel();
204+
await expect(kernel.use(new BadClassPlugin())).rejects.toThrow(/PLUGIN_CONTRACT_VIOLATION/);
205+
});
206+
});
207+
208+
describe('D — the other two refusals the changeset states', () => {
209+
it('refuses an invalid `slug`', async () => {
210+
const kernel = makeKernel();
211+
const bad = fixture({ name: '@os-fixture/bad-slug', type: 'ui', slug: 'Not A Slug' });
212+
213+
const err = await refusal(kernel.use(bad));
214+
expect(err.message).toContain('PLUGIN_CONTRACT_VIOLATION');
215+
expect(err.message).toContain("at 'slug'");
216+
});
217+
218+
it('CALIBRATION — the same fixture with a legal slug loads', async () => {
219+
const kernel = makeKernel();
220+
const good = fixture({ name: '@os-fixture/good-slug', type: 'ui', slug: 'not-a-slug' });
221+
222+
await expect(kernel.use(good)).resolves.toBe(kernel);
223+
});
224+
225+
it('refuses an invalid `homepage`', async () => {
226+
const kernel = makeKernel();
227+
const bad = fixture({ name: '@os-fixture/bad-homepage', homepage: 'not-a-url' });
228+
229+
const err = await refusal(kernel.use(bad));
230+
expect(err.message).toContain('PLUGIN_CONTRACT_VIOLATION');
231+
expect(err.message).toContain("at 'homepage'");
232+
});
233+
234+
it('CALIBRATION — the same fixture with a real URL loads', async () => {
235+
const kernel = makeKernel();
236+
const good = fixture({ name: '@os-fixture/good-homepage', homepage: 'https://example.com' });
237+
238+
await expect(kernel.use(good)).resolves.toBe(kernel);
239+
});
240+
});
241+
242+
describe('E — `version` is DELIBERATELY not enforced from the schema', () => {
243+
/**
244+
* `PluginSchema.version` is `/^\d+\.\d+\.\d+$/` and refuses the prerelease
245+
* and build-metadata forms SemVer 2.0.0 defines, while the loader's own
246+
* `isValidSemanticVersion` — the check that has always run — accepts them,
247+
* and `plugin-loader.test.ts` pins that acceptance deliberately. Enforcing
248+
* the schema's narrower spelling would retire a pinned capability under a
249+
* card that ruled on `type`, so the loader's check stays authoritative for
250+
* this one key. These cases pin the exclusion so a later change to it is a
251+
* decision rather than an accident.
252+
*/
253+
it.each(['1.0.0-alpha.1', '1.0.0+20230101', '0.0.0-fixture'])(
254+
'still loads a plugin versioned %s',
255+
async (version) => {
256+
const kernel = makeKernel();
257+
const pre = fixture({ name: `com.example.v-${version}`, version });
258+
259+
await expect(kernel.use(pre)).resolves.toBe(kernel);
260+
},
261+
);
262+
263+
it('and a version the LOADER refuses is still refused, by the loader', async () => {
264+
const kernel = makeKernel();
265+
const bad = fixture({ name: 'com.example.bad-version', version: 'v1.0.0' });
266+
267+
// Unchanged message and unchanged owner: this refusal is
268+
// `validatePluginStructure`'s, not the contract check's.
269+
const err = await refusal(kernel.use(bad));
270+
expect(err.message).toContain('Invalid semantic version');
271+
expect(err.message).not.toContain('PLUGIN_CONTRACT_VIOLATION');
272+
});
273+
});

0 commit comments

Comments
 (0)