Skip to content

Commit 382076b

Browse files
committed
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
1 parent c24e2d3 commit 382076b

4 files changed

Lines changed: 388 additions & 1 deletion

File tree

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
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+
function fixture(overrides: Partial<Fixture> & { name: string }): Fixture {
64+
return {
65+
version: '1.0.0',
66+
type: 'standard',
67+
init: () => { /* a contract fixture registers nothing */ },
68+
...overrides,
69+
};
70+
}
71+
72+
describe('A — the legacy `ui-plugin` value is refused at kernel.use() (#15638, #16049)', () => {
73+
it('rejects, and the rejection names the stable code, the plugin and the violated key', async () => {
74+
const kernel = makeKernel();
75+
const legacy = fixture({
76+
name: '@os-fixture/legacy-ui',
77+
// The value #15638 MEASURED as accepted, stored verbatim and mounting
78+
// routes. It is not a member of `CORE_PLUGIN_TYPES`.
79+
type: 'ui-plugin' as unknown as Plugin['type'],
80+
});
81+
82+
await expect(kernel.use(legacy)).rejects.toThrow(/PLUGIN_CONTRACT_VIOLATION/);
83+
84+
// The envelope, not merely "it threw": a bare `toThrow()` would stay
85+
// green if the kernel started refusing this input for an unrelated
86+
// reason, which is the failure mode this card was filed about.
87+
const err = await kernel.use(legacy).catch((e: unknown) => e as Error);
88+
expect(err.message).toContain('PLUGIN_CONTRACT_VIOLATION');
89+
expect(err.message).toContain('@os-fixture/legacy-ui');
90+
expect(err.message).toContain("at 'type'");
91+
92+
// …and nothing was stored, so no later seam can read it off the kernel.
93+
expect(stored(kernel, '@os-fixture/legacy-ui')).toBeUndefined();
94+
});
95+
96+
it('CALIBRATION — the same fixture with the modern `ui` value loads', async () => {
97+
const kernel = makeKernel();
98+
const modern = fixture({ name: '@os-fixture/modern-ui', type: 'ui' });
99+
100+
await expect(kernel.use(modern)).resolves.toBe(kernel);
101+
expect(stored(kernel, '@os-fixture/modern-ui')?.type).toBe('ui');
102+
});
103+
104+
it('stamps `code` on the error the loader itself raises', async () => {
105+
// `ObjectKernel.use()` re-wraps a failed load into a fresh `Error`
106+
// carrying only the message, so the PROPERTY is observable one layer
107+
// in. Both surfaces are pinned: the property here, the message above.
108+
const loader = new PluginLoader(new ObjectLogger({ level: 'silent' }));
109+
const result = await loader.loadPlugin(
110+
fixture({ name: 'x', type: 'ui-plugin' as unknown as Plugin['type'] }),
111+
);
112+
113+
expect(result.success).toBe(false);
114+
expect((result.error as Error & { code?: string })?.code).toBe('PLUGIN_CONTRACT_VIOLATION');
115+
});
116+
});
117+
118+
describe('B — a plain `standard` plugin still loads', () => {
119+
it('registers and is stored verbatim', async () => {
120+
const kernel = makeKernel();
121+
const plain = fixture({ name: 'com.example.plain' });
122+
123+
await expect(kernel.use(plain)).resolves.toBe(kernel);
124+
125+
const entry = stored(kernel, 'com.example.plain');
126+
expect(entry).toBeDefined();
127+
// Identity, not equality: the loader casts rather than copies, and the
128+
// stored entry must be the caller's own object.
129+
expect(entry).toBe(plain);
130+
});
131+
132+
it('a plugin declaring NO type at all still loads — `type` is optional', async () => {
133+
const kernel = makeKernel();
134+
const untyped: Plugin = { name: 'com.example.untyped', version: '1.0.0', init: () => {} };
135+
136+
await expect(kernel.use(untyped)).resolves.toBe(kernel);
137+
// ⛔ The parse output is discarded, so `PluginSchema`'s `.default('standard')`
138+
// must NOT have been written back onto the stored object.
139+
expect(stored(kernel, 'com.example.untyped')?.type).toBeUndefined();
140+
});
141+
});
142+
143+
describe('C — ⭐ a CLASS-BASED plugin still loads, prototype chain intact', () => {
144+
class ClassPlugin implements Plugin {
145+
name = 'com.example.class-based';
146+
version = '2.3.4';
147+
type = 'standard' as const;
148+
149+
/** Lives on the PROTOTYPE, not on the instance — the whole point. */
150+
async init(_ctx: PluginContext): Promise<void> { /* no services */ }
151+
152+
/** Ditto: unreachable through any copy of the instance. */
153+
describeSelf(): string { return `class:${this.name}`; }
154+
}
155+
156+
it('stores the SAME object, with its prototype and prototype methods intact', async () => {
157+
const kernel = makeKernel();
158+
const instance = new ClassPlugin();
159+
160+
await expect(kernel.use(instance)).resolves.toBe(kernel);
161+
162+
const entry = stored(kernel, 'com.example.class-based');
163+
164+
// The three independent statements a spread would break. Each fails on
165+
// its own if `safeParse`'s OUTPUT is ever substituted for the plugin:
166+
expect(entry).toBe(instance); // identity
167+
expect(Object.getPrototypeOf(entry)).toBe(ClassPlugin.prototype); // chain
168+
expect(entry).toBeInstanceOf(ClassPlugin);
169+
expect((entry as unknown as ClassPlugin).describeSelf())
170+
.toBe('class:com.example.class-based'); // callable
171+
172+
// A parse copy carries own enumerable data properties only, so the
173+
// control that a spread WOULD have preserved is asserted too — this is
174+
// what makes the three above attributable to the prototype and not to a
175+
// fixture that happens to have no data.
176+
expect(entry?.version).toBe('2.3.4');
177+
});
178+
179+
it('a class-based plugin with a REFUSED type is still refused', async () => {
180+
class BadClassPlugin implements Plugin {
181+
name = 'com.example.class-bad';
182+
version = '1.0.0';
183+
type = 'ui-plugin' as unknown as Plugin['type'];
184+
async init(): Promise<void> {}
185+
}
186+
187+
const kernel = makeKernel();
188+
await expect(kernel.use(new BadClassPlugin())).rejects.toThrow(/PLUGIN_CONTRACT_VIOLATION/);
189+
});
190+
});
191+
192+
describe('D — the other two refusals the changeset states', () => {
193+
it('refuses an invalid `slug`', async () => {
194+
const kernel = makeKernel();
195+
const bad = fixture({ name: '@os-fixture/bad-slug', type: 'ui', slug: 'Not A Slug' });
196+
197+
const err = await kernel.use(bad).catch((e: unknown) => e as Error);
198+
expect(err.message).toContain('PLUGIN_CONTRACT_VIOLATION');
199+
expect(err.message).toContain("at 'slug'");
200+
});
201+
202+
it('CALIBRATION — the same fixture with a legal slug loads', async () => {
203+
const kernel = makeKernel();
204+
const good = fixture({ name: '@os-fixture/good-slug', type: 'ui', slug: 'not-a-slug' });
205+
206+
await expect(kernel.use(good)).resolves.toBe(kernel);
207+
});
208+
209+
it('refuses an invalid `homepage`', async () => {
210+
const kernel = makeKernel();
211+
const bad = fixture({ name: '@os-fixture/bad-homepage', homepage: 'not-a-url' });
212+
213+
const err = await kernel.use(bad).catch((e: unknown) => e as Error);
214+
expect(err.message).toContain('PLUGIN_CONTRACT_VIOLATION');
215+
expect(err.message).toContain("at 'homepage'");
216+
});
217+
218+
it('CALIBRATION — the same fixture with a real URL loads', async () => {
219+
const kernel = makeKernel();
220+
const good = fixture({ name: '@os-fixture/good-homepage', homepage: 'https://example.com' });
221+
222+
await expect(kernel.use(good)).resolves.toBe(kernel);
223+
});
224+
});
225+
226+
describe('E — `version` is DELIBERATELY not enforced from the schema', () => {
227+
/**
228+
* `PluginSchema.version` is `/^\d+\.\d+\.\d+$/` and refuses the prerelease
229+
* and build-metadata forms SemVer 2.0.0 defines, while the loader's own
230+
* `isValidSemanticVersion` — the check that has always run — accepts them,
231+
* and `plugin-loader.test.ts` pins that acceptance deliberately. Enforcing
232+
* the schema's narrower spelling would retire a pinned capability under a
233+
* card that ruled on `type`, so the loader's check stays authoritative for
234+
* this one key. These cases pin the exclusion so a later change to it is a
235+
* decision rather than an accident.
236+
*/
237+
it.each(['1.0.0-alpha.1', '1.0.0+20230101', '0.0.0-fixture'])(
238+
'still loads a plugin versioned %s',
239+
async (version) => {
240+
const kernel = makeKernel();
241+
const pre = fixture({ name: `com.example.v-${version}`, version });
242+
243+
await expect(kernel.use(pre)).resolves.toBe(kernel);
244+
},
245+
);
246+
247+
it('and a version the LOADER refuses is still refused, by the loader', async () => {
248+
const kernel = makeKernel();
249+
const bad = fixture({ name: 'com.example.bad-version', version: 'v1.0.0' });
250+
251+
// Unchanged message and unchanged owner: this refusal is
252+
// `validatePluginStructure`'s, not the contract check's.
253+
const err = await kernel.use(bad).catch((e: unknown) => e as Error);
254+
expect(err.message).toContain('Invalid semantic version');
255+
expect(err.message).not.toContain('PLUGIN_CONTRACT_VIOLATION');
256+
});
257+
});

packages/core/src/plugin-loader.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,27 @@
22

33
import { Plugin, PluginContext } from './types.js';
44
import type { Logger } from '@objectstack/spec/contracts';
5+
import { PluginSchema } from '@objectstack/spec/kernel';
56
import { parseSignature } from './security/plugin-artifact-signature.js';
67
import { serviceNotRegisteredError } from './service-not-registered.js';
78

9+
/**
10+
* The code carried by a refusal raised because the plugin object does not
11+
* satisfy `PluginSchema` — the protocol's own declaration of what a plugin
12+
* object may be (`@objectstack/spec`, `kernel/plugin.zod.ts`).
13+
*
14+
* ⚠️ Spelled the ADR-0112 way and deliberately NOT wire vocabulary, exactly
15+
* like {@link SERVICE_NOT_REGISTERED_CODE} one module over: this refusal is
16+
* raised while the kernel is still assembling itself, before any HTTP boundary
17+
* exists, and `dispatcher-error-vocabulary.ts` classifies it `door: 'none'` /
18+
* `boot-refusal` for that reason. It is stamped on `err.code` for an in-process
19+
* catcher AND repeated at the head of the message, because the message is what
20+
* survives: `ObjectKernel.use()` re-wraps a failed load into a fresh `Error`
21+
* carrying only `result.error?.message`, so a code that lived only on the
22+
* property would not reach the caller that actually sees the boot fail.
23+
*/
24+
const PLUGIN_CONTRACT_VIOLATION_CODE = 'PLUGIN_CONTRACT_VIOLATION';
25+
826
/**
927
* Service Lifecycle Types
1028
* Defines how services are instantiated and managed
@@ -152,6 +170,9 @@ export class PluginLoader {
152170
// Validate plugin structure
153171
this.validatePluginStructure(metadata);
154172

173+
// Validate against the DECLARED contract (#16049)
174+
this.validatePluginContract(metadata);
175+
155176
// Check version compatibility
156177
const versionCheck = this.checkVersionCompatibility(metadata);
157178
if (!versionCheck.compatible) {
@@ -389,6 +410,82 @@ export class PluginLoader {
389410
}
390411
}
391412

413+
/**
414+
* Refuse a plugin object the DECLARED plugin contract refuses (#16049,
415+
* maintainer ruling 2026-09-06: "the protocol is the baseline; the runtime
416+
* aligns to it").
417+
*
418+
* ## What this closes
419+
*
420+
* `PluginSchema` had **zero runtime callers**. The boot path ran
421+
* {@link validatePluginStructure} — `name`, `init`, semver — and nothing
422+
* else, so every constraint the protocol declared beyond those three was a
423+
* declaration with nothing behind it: `defineStack` accepted a value that
424+
* `PluginSchema.safeParse` refused, and the plugin was stored verbatim and
425+
* mounted routes. A wrong `type` surfaced (if at all) at route mount; it
426+
* now surfaces here, named, at `kernel.use()`.
427+
*
428+
* ## ⛔ safeParse for VALIDATION ONLY — the parse output is discarded
429+
*
430+
* The returned object is a COPY, and {@link toPluginMetadata} exists
431+
* precisely because a copy "destroys the prototype chain for Class-based
432+
* plugins". Substituting the parse output for the plugin would break every
433+
* class-based plugin in the ecosystem while leaving this file's own tests
434+
* green, so the result is read for `success` and for nothing else.
435+
* `plugin-contract-enforcement.test.ts` pins a class-based plugin's
436+
* prototype surviving `use()`, which is what makes that a measurement
437+
* rather than a promise.
438+
*
439+
* ## Why `version` is excluded, and why that is not a weakening
440+
*
441+
* MEASURED on this tree, not assumed. `PluginSchema.version` is
442+
* `/^\d+\.\d+\.\d+$/`, which refuses the prerelease and build-metadata
443+
* forms SemVer 2.0.0 defines — while {@link isValidSemanticVersion}, the
444+
* check this loader has always run, implements the full grammar and accepts
445+
* them. Two declarations in this repository disagree about what a version
446+
* is, and `plugin-loader.test.ts` pins the wider one deliberately: "should
447+
* accept versions with pre-release tags" (`1.0.0-alpha.1`) and "should
448+
* accept versions with build metadata" (`1.0.0+20230101`). Two in-repo
449+
* class-based plugin fixtures ship `version = '0.0.0-fixture'` and boot
450+
* through the real kernel.
451+
*
452+
* So enforcing the schema's `version` here would not enforce the protocol —
453+
* it would RETIRE a pinned capability, silently, under a card that ruled on
454+
* `type`. The ruling's own changeset note enumerates what this refuses:
455+
* an unknown `type`, an invalid `slug`, an invalid `homepage`. Version is
456+
* not in it, and the version check that already runs is the wider, correct
457+
* one. Reconciling the two spellings belongs in `packages/spec` beside
458+
* #16334; until then this exclusion is declared here rather than performed
459+
* by leaving the disagreement unmeasured.
460+
*/
461+
private validatePluginContract(plugin: PluginMetadata): void {
462+
const result = PluginSchema.safeParse(plugin);
463+
if (result.success) {
464+
return;
465+
}
466+
467+
const issues = result.error.issues.filter((issue) => issue.path[0] !== 'version');
468+
if (issues.length === 0) {
469+
return;
470+
}
471+
472+
// The FIRST issue only: a boot refusal is read by a human reading one
473+
// log line, and the first violated key is the one to fix.
474+
const first = issues[0];
475+
const at = first.path.length > 0 ? first.path.join('.') : '(root)';
476+
const id = (plugin as { id?: unknown }).id;
477+
const named = typeof id === 'string' && id.length > 0
478+
? `'${plugin.name}' (id: ${id})`
479+
: `'${plugin.name}'`;
480+
481+
const error = new Error(
482+
`${PLUGIN_CONTRACT_VIOLATION_CODE}: plugin ${named} is refused by the declared plugin `
483+
+ `contract at '${at}': ${first.message}`,
484+
) as Error & { code?: string };
485+
error.code = PLUGIN_CONTRACT_VIOLATION_CODE;
486+
throw error;
487+
}
488+
392489
private checkVersionCompatibility(plugin: PluginMetadata): VersionCompatibility {
393490
// Basic semantic version compatibility check
394491
// In a real implementation, this would check against kernel version

packages/core/src/types.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,19 @@ export interface Plugin {
124124
* Plugin type categorisation for runtime behaviour — a {@link PluginType},
125125
* the closed set the spec declares. The enumeration lives on that type
126126
* (derived from `CORE_PLUGIN_TYPES`), not in this comment: a value outside
127-
* it no longer type-checks, and `PluginSchema.type` refuses it at parse.
127+
* it no longer type-checks, and since #16049 `kernel.use()` REFUSES it at
128+
* boot — `PluginLoader.validatePluginContract` runs `PluginSchema` over
129+
* every plugin object and raises `PLUGIN_CONTRACT_VIOLATION` naming the
130+
* plugin and the first violated key.
131+
*
132+
* ⚠️ This sentence used to say the value was refused "at parse". It was
133+
* measured false (#16049, from #15638): `PluginSchema` had no runtime
134+
* caller, kernel plugin objects were never parsed, and a `type` outside the
135+
* set was accepted and stored verbatim. The refusal this comment describes
136+
* is the one that now exists, on the boot path, and the compiler's arm is
137+
* the second half rather than the only one — `kernel.use(plugin as any)` is
138+
* a shipped in-repo pattern, and externally authored plugins never meet
139+
* this compiler at all.
128140
* @default 'standard'
129141
*/
130142
type?: PluginType;

0 commit comments

Comments
 (0)