From 382076ba43a59b3bbb25b84a5782f960ea93bb19 Mon Sep 17 00:00:00 2001 From: claude Date: Sun, 6 Sep 2026 15:04:32 +0000 Subject: [PATCH 1/4] feat(core): enforce PluginSchema at kernel.use() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- .../src/plugin-contract-enforcement.test.ts | 257 ++++++++++++++++++ packages/core/src/plugin-loader.ts | 97 +++++++ packages/core/src/types.ts | 14 +- .../src/dispatcher-error-vocabulary.ts | 21 ++ 4 files changed, 388 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/plugin-contract-enforcement.test.ts diff --git a/packages/core/src/plugin-contract-enforcement.test.ts b/packages/core/src/plugin-contract-enforcement.test.ts new file mode 100644 index 0000000000..e73496504c --- /dev/null +++ b/packages/core/src/plugin-contract-enforcement.test.ts @@ -0,0 +1,257 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `kernel.use()` enforces the DECLARED plugin contract (#16049). + * + * WHY THIS FILE EXISTS. `PluginSchema` (`@objectstack/spec`, + * `kernel/plugin.zod.ts`) had zero runtime callers. The boot path ran three + * checks — `name`, `init`, semver — and every other constraint the protocol + * declared was a declaration with nothing behind it. The sharpest single + * reading from #15638, 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. The maintainer ruled + * enforce, not remove (2026-09-06, ADR-0049): the protocol is the baseline and + * the runtime aligns to it. + * + * WHAT MAKES THE POSITIVE CASES LOAD-BEARING. A file that only asserted + * refusals would pass just as well against a `use()` that refused everything. + * Every refusal case here has a calibration twin one line away — the SAME + * fixture with the offending key corrected — so a refusal is attributable to + * the key under test and not to the harness. + * + * ⭐ THE PROTOTYPE CASE IS NOT A NICETY. The ruling requires `safeParse` be + * used for VALIDATION ONLY, because `PluginLoader.toPluginMetadata` is a cast + * and its comment records why: "Do not use object spread {...plugin} as it + * destroys the prototype chain for Class-based plugins." Substituting the parse + * output for the plugin object is the one change that would break every + * class-based plugin in the ecosystem while leaving every refusal test in this + * file green. Group C is the falsifier for exactly that mistake: it asserts + * object IDENTITY, prototype identity, and that a method living only on the + * prototype is still callable off what the kernel stored. + */ + +import { describe, expect, it } from 'vitest'; +import { ObjectKernel } from './kernel.js'; +import { PluginLoader } from './plugin-loader.js'; +import { ObjectLogger } from './logger.js'; +import type { Plugin, PluginContext } from './types.js'; + +/** A kernel that registers plugins and installs no process signal handlers. */ +function makeKernel(): ObjectKernel { + return new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); +} + +/** What `kernel.use()` left in the kernel's own plugin map. */ +function stored(kernel: ObjectKernel, name: string): Record | undefined { + return (kernel as unknown as { plugins: Map> }) + .plugins.get(name); +} + +/** + * A plugin object with an arbitrary extra surface. The keys under test + * (`type`, `slug`, `homepage`, `id`) are declared by `PluginSchema` and NOT by + * the `Plugin` interface, which is one reason the repo contained no producer of + * them — so the fixture states the extra surface rather than casting it away. + */ +type Fixture = Plugin & { + id?: string; + slug?: string; + homepage?: string; + staticPath?: string; +}; + +function fixture(overrides: Partial & { name: string }): Fixture { + return { + version: '1.0.0', + type: 'standard', + init: () => { /* a contract fixture registers nothing */ }, + ...overrides, + }; +} + +describe('A — the legacy `ui-plugin` value is refused at kernel.use() (#15638, #16049)', () => { + it('rejects, and the rejection names the stable code, the plugin and the violated key', async () => { + const kernel = makeKernel(); + const legacy = fixture({ + name: '@os-fixture/legacy-ui', + // The value #15638 MEASURED as accepted, stored verbatim and mounting + // routes. It is not a member of `CORE_PLUGIN_TYPES`. + type: 'ui-plugin' as unknown as Plugin['type'], + }); + + await expect(kernel.use(legacy)).rejects.toThrow(/PLUGIN_CONTRACT_VIOLATION/); + + // The envelope, not merely "it threw": a bare `toThrow()` would stay + // green if the kernel started refusing this input for an unrelated + // reason, which is the failure mode this card was filed about. + const err = await kernel.use(legacy).catch((e: unknown) => e as Error); + expect(err.message).toContain('PLUGIN_CONTRACT_VIOLATION'); + expect(err.message).toContain('@os-fixture/legacy-ui'); + expect(err.message).toContain("at 'type'"); + + // …and nothing was stored, so no later seam can read it off the kernel. + expect(stored(kernel, '@os-fixture/legacy-ui')).toBeUndefined(); + }); + + it('CALIBRATION — the same fixture with the modern `ui` value loads', async () => { + const kernel = makeKernel(); + const modern = fixture({ name: '@os-fixture/modern-ui', type: 'ui' }); + + await expect(kernel.use(modern)).resolves.toBe(kernel); + expect(stored(kernel, '@os-fixture/modern-ui')?.type).toBe('ui'); + }); + + it('stamps `code` on the error the loader itself raises', async () => { + // `ObjectKernel.use()` re-wraps a failed load into a fresh `Error` + // carrying only the message, so the PROPERTY is observable one layer + // in. Both surfaces are pinned: the property here, the message above. + const loader = new PluginLoader(new ObjectLogger({ level: 'silent' })); + const result = await loader.loadPlugin( + fixture({ name: 'x', type: 'ui-plugin' as unknown as Plugin['type'] }), + ); + + expect(result.success).toBe(false); + expect((result.error as Error & { code?: string })?.code).toBe('PLUGIN_CONTRACT_VIOLATION'); + }); +}); + +describe('B — a plain `standard` plugin still loads', () => { + it('registers and is stored verbatim', async () => { + const kernel = makeKernel(); + const plain = fixture({ name: 'com.example.plain' }); + + await expect(kernel.use(plain)).resolves.toBe(kernel); + + const entry = stored(kernel, 'com.example.plain'); + expect(entry).toBeDefined(); + // Identity, not equality: the loader casts rather than copies, and the + // stored entry must be the caller's own object. + expect(entry).toBe(plain); + }); + + it('a plugin declaring NO type at all still loads — `type` is optional', async () => { + const kernel = makeKernel(); + const untyped: Plugin = { name: 'com.example.untyped', version: '1.0.0', init: () => {} }; + + await expect(kernel.use(untyped)).resolves.toBe(kernel); + // ⛔ The parse output is discarded, so `PluginSchema`'s `.default('standard')` + // must NOT have been written back onto the stored object. + expect(stored(kernel, 'com.example.untyped')?.type).toBeUndefined(); + }); +}); + +describe('C — ⭐ a CLASS-BASED plugin still loads, prototype chain intact', () => { + class ClassPlugin implements Plugin { + name = 'com.example.class-based'; + version = '2.3.4'; + type = 'standard' as const; + + /** Lives on the PROTOTYPE, not on the instance — the whole point. */ + async init(_ctx: PluginContext): Promise { /* no services */ } + + /** Ditto: unreachable through any copy of the instance. */ + describeSelf(): string { return `class:${this.name}`; } + } + + it('stores the SAME object, with its prototype and prototype methods intact', async () => { + const kernel = makeKernel(); + const instance = new ClassPlugin(); + + await expect(kernel.use(instance)).resolves.toBe(kernel); + + const entry = stored(kernel, 'com.example.class-based'); + + // The three independent statements a spread would break. Each fails on + // its own if `safeParse`'s OUTPUT is ever substituted for the plugin: + expect(entry).toBe(instance); // identity + expect(Object.getPrototypeOf(entry)).toBe(ClassPlugin.prototype); // chain + expect(entry).toBeInstanceOf(ClassPlugin); + expect((entry as unknown as ClassPlugin).describeSelf()) + .toBe('class:com.example.class-based'); // callable + + // A parse copy carries own enumerable data properties only, so the + // control that a spread WOULD have preserved is asserted too — this is + // what makes the three above attributable to the prototype and not to a + // fixture that happens to have no data. + expect(entry?.version).toBe('2.3.4'); + }); + + it('a class-based plugin with a REFUSED type is still refused', async () => { + class BadClassPlugin implements Plugin { + name = 'com.example.class-bad'; + version = '1.0.0'; + type = 'ui-plugin' as unknown as Plugin['type']; + async init(): Promise {} + } + + const kernel = makeKernel(); + await expect(kernel.use(new BadClassPlugin())).rejects.toThrow(/PLUGIN_CONTRACT_VIOLATION/); + }); +}); + +describe('D — the other two refusals the changeset states', () => { + it('refuses an invalid `slug`', async () => { + const kernel = makeKernel(); + const bad = fixture({ name: '@os-fixture/bad-slug', type: 'ui', slug: 'Not A Slug' }); + + const err = await kernel.use(bad).catch((e: unknown) => e as Error); + expect(err.message).toContain('PLUGIN_CONTRACT_VIOLATION'); + expect(err.message).toContain("at 'slug'"); + }); + + it('CALIBRATION — the same fixture with a legal slug loads', async () => { + const kernel = makeKernel(); + const good = fixture({ name: '@os-fixture/good-slug', type: 'ui', slug: 'not-a-slug' }); + + await expect(kernel.use(good)).resolves.toBe(kernel); + }); + + it('refuses an invalid `homepage`', async () => { + const kernel = makeKernel(); + const bad = fixture({ name: '@os-fixture/bad-homepage', homepage: 'not-a-url' }); + + const err = await kernel.use(bad).catch((e: unknown) => e as Error); + expect(err.message).toContain('PLUGIN_CONTRACT_VIOLATION'); + expect(err.message).toContain("at 'homepage'"); + }); + + it('CALIBRATION — the same fixture with a real URL loads', async () => { + const kernel = makeKernel(); + const good = fixture({ name: '@os-fixture/good-homepage', homepage: 'https://example.com' }); + + await expect(kernel.use(good)).resolves.toBe(kernel); + }); +}); + +describe('E — `version` is DELIBERATELY not enforced from the schema', () => { + /** + * `PluginSchema.version` is `/^\d+\.\d+\.\d+$/` and refuses the prerelease + * and build-metadata forms SemVer 2.0.0 defines, while the loader's own + * `isValidSemanticVersion` — the check that has always run — accepts them, + * and `plugin-loader.test.ts` pins that acceptance deliberately. Enforcing + * the schema's narrower spelling would retire a pinned capability under a + * card that ruled on `type`, so the loader's check stays authoritative for + * this one key. These cases pin the exclusion so a later change to it is a + * decision rather than an accident. + */ + it.each(['1.0.0-alpha.1', '1.0.0+20230101', '0.0.0-fixture'])( + 'still loads a plugin versioned %s', + async (version) => { + const kernel = makeKernel(); + const pre = fixture({ name: `com.example.v-${version}`, version }); + + await expect(kernel.use(pre)).resolves.toBe(kernel); + }, + ); + + it('and a version the LOADER refuses is still refused, by the loader', async () => { + const kernel = makeKernel(); + const bad = fixture({ name: 'com.example.bad-version', version: 'v1.0.0' }); + + // Unchanged message and unchanged owner: this refusal is + // `validatePluginStructure`'s, not the contract check's. + const err = await kernel.use(bad).catch((e: unknown) => e as Error); + expect(err.message).toContain('Invalid semantic version'); + expect(err.message).not.toContain('PLUGIN_CONTRACT_VIOLATION'); + }); +}); diff --git a/packages/core/src/plugin-loader.ts b/packages/core/src/plugin-loader.ts index 24c3c31c51..63e4938095 100644 --- a/packages/core/src/plugin-loader.ts +++ b/packages/core/src/plugin-loader.ts @@ -2,9 +2,27 @@ import { Plugin, PluginContext } from './types.js'; import type { Logger } from '@objectstack/spec/contracts'; +import { PluginSchema } from '@objectstack/spec/kernel'; import { parseSignature } from './security/plugin-artifact-signature.js'; import { serviceNotRegisteredError } from './service-not-registered.js'; +/** + * The code carried by a refusal raised because the plugin object does not + * satisfy `PluginSchema` — the protocol's own declaration of what a plugin + * object may be (`@objectstack/spec`, `kernel/plugin.zod.ts`). + * + * ⚠️ Spelled the ADR-0112 way and deliberately NOT wire vocabulary, exactly + * like {@link SERVICE_NOT_REGISTERED_CODE} one module over: this refusal is + * raised while the kernel is still assembling itself, before any HTTP boundary + * exists, and `dispatcher-error-vocabulary.ts` classifies it `door: 'none'` / + * `boot-refusal` for that reason. It is stamped on `err.code` for an in-process + * catcher AND repeated at the head of the message, because the message is what + * survives: `ObjectKernel.use()` re-wraps a failed load into a fresh `Error` + * carrying only `result.error?.message`, so a code that lived only on the + * property would not reach the caller that actually sees the boot fail. + */ +const PLUGIN_CONTRACT_VIOLATION_CODE = 'PLUGIN_CONTRACT_VIOLATION'; + /** * Service Lifecycle Types * Defines how services are instantiated and managed @@ -152,6 +170,9 @@ export class PluginLoader { // Validate plugin structure this.validatePluginStructure(metadata); + // Validate against the DECLARED contract (#16049) + this.validatePluginContract(metadata); + // Check version compatibility const versionCheck = this.checkVersionCompatibility(metadata); if (!versionCheck.compatible) { @@ -389,6 +410,82 @@ export class PluginLoader { } } + /** + * Refuse a plugin object the DECLARED plugin contract refuses (#16049, + * maintainer ruling 2026-09-06: "the protocol is the baseline; the runtime + * aligns to it"). + * + * ## What this closes + * + * `PluginSchema` had **zero runtime callers**. The boot path ran + * {@link validatePluginStructure} — `name`, `init`, semver — and nothing + * else, so every constraint the protocol declared beyond those three was a + * declaration with nothing behind it: `defineStack` accepted a value that + * `PluginSchema.safeParse` refused, and the plugin was stored verbatim and + * mounted routes. A wrong `type` surfaced (if at all) at route mount; it + * now surfaces here, named, at `kernel.use()`. + * + * ## ⛔ safeParse for VALIDATION ONLY — the parse output is discarded + * + * The returned object is a COPY, and {@link toPluginMetadata} exists + * precisely because a copy "destroys the prototype chain for Class-based + * plugins". Substituting the parse output for the plugin would break every + * class-based plugin in the ecosystem while leaving this file's own tests + * green, so the result is read for `success` and for nothing else. + * `plugin-contract-enforcement.test.ts` pins a class-based plugin's + * prototype surviving `use()`, which is what makes that a measurement + * rather than a promise. + * + * ## Why `version` is excluded, and why that is not a weakening + * + * MEASURED on this tree, not assumed. `PluginSchema.version` is + * `/^\d+\.\d+\.\d+$/`, which refuses the prerelease and build-metadata + * forms SemVer 2.0.0 defines — while {@link isValidSemanticVersion}, the + * check this loader has always run, implements the full grammar and accepts + * them. Two declarations in this repository disagree about what a version + * is, and `plugin-loader.test.ts` pins the wider one deliberately: "should + * accept versions with pre-release tags" (`1.0.0-alpha.1`) and "should + * accept versions with build metadata" (`1.0.0+20230101`). Two in-repo + * class-based plugin fixtures ship `version = '0.0.0-fixture'` and boot + * through the real kernel. + * + * So enforcing the schema's `version` here would not enforce the protocol — + * it would RETIRE a pinned capability, silently, under a card that ruled on + * `type`. The ruling's own changeset note enumerates what this refuses: + * an unknown `type`, an invalid `slug`, an invalid `homepage`. Version is + * not in it, and the version check that already runs is the wider, correct + * one. Reconciling the two spellings belongs in `packages/spec` beside + * #16334; until then this exclusion is declared here rather than performed + * by leaving the disagreement unmeasured. + */ + private validatePluginContract(plugin: PluginMetadata): void { + const result = PluginSchema.safeParse(plugin); + if (result.success) { + return; + } + + const issues = result.error.issues.filter((issue) => issue.path[0] !== 'version'); + if (issues.length === 0) { + return; + } + + // The FIRST issue only: a boot refusal is read by a human reading one + // log line, and the first violated key is the one to fix. + const first = issues[0]; + const at = first.path.length > 0 ? first.path.join('.') : '(root)'; + const id = (plugin as { id?: unknown }).id; + const named = typeof id === 'string' && id.length > 0 + ? `'${plugin.name}' (id: ${id})` + : `'${plugin.name}'`; + + const error = new Error( + `${PLUGIN_CONTRACT_VIOLATION_CODE}: plugin ${named} is refused by the declared plugin ` + + `contract at '${at}': ${first.message}`, + ) as Error & { code?: string }; + error.code = PLUGIN_CONTRACT_VIOLATION_CODE; + throw error; + } + private checkVersionCompatibility(plugin: PluginMetadata): VersionCompatibility { // Basic semantic version compatibility check // In a real implementation, this would check against kernel version diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 7687b550ad..83546bcb74 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -124,7 +124,19 @@ export interface Plugin { * Plugin type categorisation for runtime behaviour — a {@link PluginType}, * the closed set the spec declares. The enumeration lives on that type * (derived from `CORE_PLUGIN_TYPES`), not in this comment: a value outside - * it no longer type-checks, and `PluginSchema.type` refuses it at parse. + * it no longer type-checks, and since #16049 `kernel.use()` REFUSES it at + * boot — `PluginLoader.validatePluginContract` runs `PluginSchema` over + * every plugin object and raises `PLUGIN_CONTRACT_VIOLATION` naming the + * plugin and the first violated key. + * + * ⚠️ This sentence used to say the value was refused "at parse". It was + * measured false (#16049, from #15638): `PluginSchema` had no runtime + * caller, kernel plugin objects were never parsed, and a `type` outside the + * set was accepted and stored verbatim. The refusal this comment describes + * is the one that now exists, on the boot path, and the compiler's arm is + * the second half rather than the only one — `kernel.use(plugin as any)` is + * a shipped in-repo pattern, and externally authored plugins never meet + * this compiler at all. * @default 'standard' */ type?: PluginType; diff --git a/packages/runtime/src/dispatcher-error-vocabulary.ts b/packages/runtime/src/dispatcher-error-vocabulary.ts index 0d3038f5e6..99d2e02857 100644 --- a/packages/runtime/src/dispatcher-error-vocabulary.ts +++ b/packages/runtime/src/dispatcher-error-vocabulary.ts @@ -752,6 +752,27 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [ 'vocabulary. If a transport ever ANSWERS with this fact, the verdict becomes ' + 'pending-registration and the code belongs in the ledger batch.', }, + // [#16049] The plugin-contract refusal `kernel.use()` now raises. Same + // pre-HTTP class as the rows above; the ruling that created it is the + // 2026-09-06 ADR-0049 enforce-or-remove call on `PluginSchema`. + { + code: 'PLUGIN_CONTRACT_VIOLATION', + file: 'packages/core/src/plugin-loader.ts', + shape: 'assignconst', + door: 'none', + verdict: 'boot-refusal', + why: + 'Raised by `PluginLoader.validatePluginContract` when a plugin object does not satisfy the ' + + 'declared `PluginSchema` — an unknown `type`, an invalid `slug`, an invalid `homepage`. It is ' + + 'raised while the kernel is still registering plugins, before bootstrap and therefore before ' + + 'any HTTP boundary exists: `ObjectKernel.use()` re-wraps it into a fresh `Error` that the host ' + + 'rethrows and the process aborts on, so no door can answer with it and no door can demote it. ' + + 'Same class as the migration-journal runner refusals and the service-resolution discriminator ' + + 'above, ruled by the same #8035 reasoning: a composition fact raised pre-HTTP is not wire ' + + 'vocabulary. The code is repeated at the head of the message because that re-wrap keeps only ' + + '`message`. If a transport ever ANSWERS with this fact, the verdict becomes ' + + 'pending-registration and the code belongs in the ledger batch.', + }, // [ADR-0130 D4] The artifact load path's three wrapper refusals, added with the // N-package load path itself. The pre-HTTP reasoning is the one the rows above // cite; what is specific to these three is the second half recorded in each `why` From 479fa8bbbd1aba7e8037d8d21cc37928e4a4dd93 Mon Sep 17 00:00:00 2001 From: claude Date: Sun, 6 Sep 2026 15:25:08 +0000 Subject: [PATCH 2/4] 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 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- .../enforce-plugin-schema-at-kernel-use.md | 24 +++++++++++++++++++ .../src/plugin-contract-enforcement.test.ts | 24 +++++++++++++++---- 2 files changed, 44 insertions(+), 4 deletions(-) create mode 100644 .changeset/enforce-plugin-schema-at-kernel-use.md diff --git a/.changeset/enforce-plugin-schema-at-kernel-use.md b/.changeset/enforce-plugin-schema-at-kernel-use.md new file mode 100644 index 0000000000..bdfbc15be1 --- /dev/null +++ b/.changeset/enforce-plugin-schema-at-kernel-use.md @@ -0,0 +1,24 @@ +--- +"@objectstack/core": minor +--- + +`kernel.use()` now enforces the declared plugin contract. A plugin object with an **unknown `type`**, an **invalid `slug`** or an **invalid `homepage`** is refused at load instead of being stored and mounted. + +**This refuses input the runtime accepted before**, which is why it is not a `patch`: `PluginSchema` (`@objectstack/spec`, `kernel/plugin.zod.ts`) 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. + +**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: + +``` +PLUGIN_CONTRACT_VIOLATION: plugin '@acme/console' is refused by the declared +plugin contract at 'type': Invalid option: expected one of "standard"|"ui"|… +``` + +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. + +**What does NOT change.** + +- 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. A class-based plugin's identity, prototype and prototype methods surviving `use()` is pinned by test, not asserted in prose. +- `PluginSchema`'s `.default('standard')` is **not** written back: a plugin declaring no `type` still loads and still stores no `type`. +- **`version` is deliberately excluded from this enforcement.** The schema spells it `/^\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 — and does so 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` and `1.0.0-alpha.1` / `1.0.0+20230101` still load. Reconciling the two spellings is spec work, tracked separately. + +**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. diff --git a/packages/core/src/plugin-contract-enforcement.test.ts b/packages/core/src/plugin-contract-enforcement.test.ts index e73496504c..ac93b56cba 100644 --- a/packages/core/src/plugin-contract-enforcement.test.ts +++ b/packages/core/src/plugin-contract-enforcement.test.ts @@ -60,6 +60,22 @@ type Fixture = Plugin & { staticPath?: string; }; +/** + * The refusal `promise` produced, or a loud failure if it produced none. + * + * ⛔ Not `promise.catch((e) => e as Error)`: that resolves to `Kernel | Error`, + * so a case whose input STOPPED being refused would go on asserting against a + * kernel and report a confusing property miss instead of "this loaded". + */ +async function refusal(promise: Promise): Promise { + try { + await promise; + } catch (e) { + return e as Error; + } + throw new Error('expected the plugin to be refused, but it loaded'); +} + function fixture(overrides: Partial & { name: string }): Fixture { return { version: '1.0.0', @@ -84,7 +100,7 @@ describe('A — the legacy `ui-plugin` value is refused at kernel.use() (#15638, // The envelope, not merely "it threw": a bare `toThrow()` would stay // green if the kernel started refusing this input for an unrelated // reason, which is the failure mode this card was filed about. - const err = await kernel.use(legacy).catch((e: unknown) => e as Error); + const err = await refusal(kernel.use(legacy)); expect(err.message).toContain('PLUGIN_CONTRACT_VIOLATION'); expect(err.message).toContain('@os-fixture/legacy-ui'); expect(err.message).toContain("at 'type'"); @@ -194,7 +210,7 @@ describe('D — the other two refusals the changeset states', () => { const kernel = makeKernel(); const bad = fixture({ name: '@os-fixture/bad-slug', type: 'ui', slug: 'Not A Slug' }); - const err = await kernel.use(bad).catch((e: unknown) => e as Error); + const err = await refusal(kernel.use(bad)); expect(err.message).toContain('PLUGIN_CONTRACT_VIOLATION'); expect(err.message).toContain("at 'slug'"); }); @@ -210,7 +226,7 @@ describe('D — the other two refusals the changeset states', () => { const kernel = makeKernel(); const bad = fixture({ name: '@os-fixture/bad-homepage', homepage: 'not-a-url' }); - const err = await kernel.use(bad).catch((e: unknown) => e as Error); + const err = await refusal(kernel.use(bad)); expect(err.message).toContain('PLUGIN_CONTRACT_VIOLATION'); expect(err.message).toContain("at 'homepage'"); }); @@ -250,7 +266,7 @@ describe('E — `version` is DELIBERATELY not enforced from the schema', () => { // Unchanged message and unchanged owner: this refusal is // `validatePluginStructure`'s, not the contract check's. - const err = await kernel.use(bad).catch((e: unknown) => e as Error); + const err = await refusal(kernel.use(bad)); expect(err.message).toContain('Invalid semantic version'); expect(err.message).not.toContain('PLUGIN_CONTRACT_VIOLATION'); }); From ea8af40e4af41c76f92243f540b2ada5ad1b2dab Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 15:52:06 +0000 Subject: [PATCH 3/4] 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 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- packages/runtime/src/dispatcher-error-vocabulary.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/runtime/src/dispatcher-error-vocabulary.ts b/packages/runtime/src/dispatcher-error-vocabulary.ts index 99d2e02857..2c5247e208 100644 --- a/packages/runtime/src/dispatcher-error-vocabulary.ts +++ b/packages/runtime/src/dispatcher-error-vocabulary.ts @@ -768,7 +768,7 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [ + 'any HTTP boundary exists: `ObjectKernel.use()` re-wraps it into a fresh `Error` that the host ' + 'rethrows and the process aborts on, so no door can answer with it and no door can demote it. ' + 'Same class as the migration-journal runner refusals and the service-resolution discriminator ' - + 'above, ruled by the same #8035 reasoning: a composition fact raised pre-HTTP is not wire ' + + 'above, ruled by the same reasoning those rows cite: a composition fact raised pre-HTTP is not wire ' + 'vocabulary. The code is repeated at the head of the message because that re-wrap keeps only ' + '`message`. If a transport ever ANSWERS with this fact, the verdict becomes ' + 'pending-registration and the code belongs in the ledger batch.', From a254237d15bf7afeb560bd899ddcee6f9730c82f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 22:37:17 +0000 Subject: [PATCH 4/4] docs(core): enumerate all eight enforced plugin keys, and declare the break MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- .../enforce-plugin-schema-at-kernel-use.md | 29 +++++++++++--- packages/core/src/plugin-loader.ts | 38 +++++++++++++++++-- .../src/dispatcher-error-vocabulary.ts | 6 ++- 3 files changed, 62 insertions(+), 11 deletions(-) diff --git a/.changeset/enforce-plugin-schema-at-kernel-use.md b/.changeset/enforce-plugin-schema-at-kernel-use.md index bdfbc15be1..b0dc7008f9 100644 --- a/.changeset/enforce-plugin-schema-at-kernel-use.md +++ b/.changeset/enforce-plugin-schema-at-kernel-use.md @@ -2,9 +2,22 @@ "@objectstack/core": minor --- -`kernel.use()` now enforces the declared plugin contract. A plugin object with an **unknown `type`**, an **invalid `slug`** or an **invalid `homepage`** is refused at load instead of being stored and mounted. +`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. -**This refuses input the runtime accepted before**, which is why it is not a `patch`: `PluginSchema` (`@objectstack/spec`, `kernel/plugin.zod.ts`) 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. +**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. + +**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: + +- **`id`** — a non-string, or the empty string (`z.string().min(1)`). +- **`type`** — any value outside the closed set `standard`, `ui`, `driver`, `server`, `app`, `theme`, `agent`, `objectql`. +- **`staticPath`** — a non-string. +- **`slug`** — a non-string, or a string that does not match `/^[a-z0-9-_]+$/`. +- **`default`** — a non-boolean. +- **`description`** — a non-string. +- **`author`** — a non-string. An object such as `{ name: 'x' }` is refused; the declared type is a plain string. +- **`homepage`** — a non-string, or a string that is not a URL. + +**`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. **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: @@ -15,10 +28,14 @@ plugin contract at 'type': Invalid option: expected one of "standard"|"ui"|… 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. -**What does NOT change.** +**What is STILL ACCEPTED — the door is not narrowed past those eight keys.** Measured on this tree, not assumed: -- 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. A class-based plugin's identity, prototype and prototype methods surviving `use()` is pinned by test, not asserted in prose. -- `PluginSchema`'s `.default('standard')` is **not** written back: a plugin declaring no `type` still loads and still stores no `type`. -- **`version` is deliberately excluded from this enforcement.** The schema spells it `/^\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 — and does so 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` and `1.0.0-alpha.1` / `1.0.0+20230101` still load. Reconciling the two spellings is spec work, tracked separately. +- **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. +- **A version-less plugin still loads**, exactly as before. +- **A plugin declaring no `type` still loads and still stores no `type`**: `PluginSchema`'s `.default('standard')` is **not** written back. +- **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. +- **`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. **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. + + diff --git a/packages/core/src/plugin-loader.ts b/packages/core/src/plugin-loader.ts index 63e4938095..ac4884774a 100644 --- a/packages/core/src/plugin-loader.ts +++ b/packages/core/src/plugin-loader.ts @@ -425,6 +425,36 @@ export class PluginLoader { * mounted routes. A wrong `type` surfaced (if at all) at route mount; it * now surfaces here, named, at `kernel.use()`. * + * ## What this refuses: the EIGHT declared keys, and `null` on any of them + * + * `PluginSchema` declares nine optional keys; the filter below drops + * `version` (see below), so the accept-set narrowing this method + * performs covers exactly these eight, each reported as `at ''`: + * + * - `id` — a non-string, or the empty string (`z.string().min(1)`). + * - `type` — outside the closed set `'standard'` + `CORE_PLUGIN_TYPES`. + * - `staticPath` — a non-string. + * - `slug` — a non-string, or not matching `/^[a-z0-9-_]+$/`. + * - `default` — a non-boolean. + * - `description` — a non-string. + * - `author` — a non-string; an object such as `{ name }` is refused. + * - `homepage` — a non-string, or a string that is not a URL. + * + * All eight are `.optional()`, which admits absence and `undefined` but + * never an explicit `null` — so `null` on any of the eight is refused too. + * + * ⛔ ENUMERATE ALL EIGHT wherever this is restated. The changeset ships to + * consumers as `CHANGELOG.md` and is what an upgrading author greps after + * the refusal, so a shorter enumeration there does not merely omit keys — + * it tells an author refused `at 'author'` that their key is not enforced. + * This comment, the changeset and the `PLUGIN_CONTRACT_VIOLATION` row in + * `dispatcher-error-vocabulary.ts` are the three places that restate it. + * + * What this does NOT refuse, which is what bounds the narrowing: UNKNOWN + * keys. `PluginSchema` is a plain `z.object` with no `.strict()` — the + * strip posture — and the parse output is discarded here, so a plugin + * carrying keys the schema never declares still loads, stored verbatim. + * * ## ⛔ safeParse for VALIDATION ONLY — the parse output is discarded * * The returned object is a COPY, and {@link toPluginMetadata} exists @@ -451,10 +481,10 @@ export class PluginLoader { * * So enforcing the schema's `version` here would not enforce the protocol — * it would RETIRE a pinned capability, silently, under a card that ruled on - * `type`. The ruling's own changeset note enumerates what this refuses: - * an unknown `type`, an invalid `slug`, an invalid `homepage`. Version is - * not in it, and the version check that already runs is the wider, correct - * one. Reconciling the two spellings belongs in `packages/spec` beside + * `type`. Version is not among the eight keys enumerated above, and the + * version check that already runs is the wider, correct one: a version-less + * plugin loads, and so do `1.0.0-alpha.1` and `1.0.0+20230101`. + * Reconciling the two spellings belongs in `packages/spec` beside * #16334; until then this exclusion is declared here rather than performed * by leaving the disagreement unmeasured. */ diff --git a/packages/runtime/src/dispatcher-error-vocabulary.ts b/packages/runtime/src/dispatcher-error-vocabulary.ts index 2c5247e208..eee53a495a 100644 --- a/packages/runtime/src/dispatcher-error-vocabulary.ts +++ b/packages/runtime/src/dispatcher-error-vocabulary.ts @@ -763,7 +763,11 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [ verdict: 'boot-refusal', why: 'Raised by `PluginLoader.validatePluginContract` when a plugin object does not satisfy the ' - + 'declared `PluginSchema` — an unknown `type`, an invalid `slug`, an invalid `homepage`. It is ' + + 'declared `PluginSchema` on any of the EIGHT keys that enforcement covers — `id`, `type`, ' + + '`staticPath`, `slug`, `default`, `description`, `author`, `homepage` — including an explicit ' + + '`null` on any of them, since all eight are `.optional()` and admit absence but not `null`. ' + + '`version` is excluded from the enforcement, and unknown keys are not refused at all (the ' + + 'schema carries no `.strict()`), so the narrowing stops at those eight. It is ' + 'raised while the kernel is still registering plugins, before bootstrap and therefore before ' + 'any HTTP boundary exists: `ObjectKernel.use()` re-wraps it into a fresh `Error` that the host ' + 'rethrows and the process aborts on, so no door can answer with it and no door can demote it. '