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..b0dc7008f9 --- /dev/null +++ b/.changeset/enforce-plugin-schema-at-kernel-use.md @@ -0,0 +1,41 @@ +--- +"@objectstack/core": minor +--- + +`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. + +**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: + +``` +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 is STILL ACCEPTED — the door is not narrowed past those eight keys.** Measured on this tree, not assumed: + +- **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-contract-enforcement.test.ts b/packages/core/src/plugin-contract-enforcement.test.ts new file mode 100644 index 0000000000..ac93b56cba --- /dev/null +++ b/packages/core/src/plugin-contract-enforcement.test.ts @@ -0,0 +1,273 @@ +// 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; +}; + +/** + * 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', + 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 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'"); + + // …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 refusal(kernel.use(bad)); + 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 refusal(kernel.use(bad)); + 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 refusal(kernel.use(bad)); + 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..ac4884774a 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,112 @@ 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()`. + * + * ## 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 + * 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`. 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. + */ + 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..eee53a495a 100644 --- a/packages/runtime/src/dispatcher-error-vocabulary.ts +++ b/packages/runtime/src/dispatcher-error-vocabulary.ts @@ -752,6 +752,31 @@ 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` 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. ' + + 'Same class as the migration-journal runner refusals and the service-resolution discriminator ' + + '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.', + }, // [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`