From 57b5a9805a7908254c5302ba8aa9cbb159fe1f56 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 08:31:46 +0000 Subject: [PATCH 1/4] feat(core): retire PluginMetadata.configSchema and PluginConfigValidator under ADR-0049 (#11982) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The kernel-owned plugin-config-validation surface could never run: the loader's one call site passed no config, plugin factories close over their config so the kernel never receives it, and zero plugins declared a configSchema (measured with positive controls; maintainer ruled Option B, 2026-08-27, decision-inbox batch 5). - remove PluginMetadata.configSchema and the always-early-returning validatePluginConfig path from PluginLoader - delete PluginConfigValidator / createPluginConfigValidator and their unit test; unpublish them from the security barrel - record the retirement in ADR-0025 section 3.7: re-declaring a kernel-owned config-validation surface is a fresh decision for the day the distribution layer lands, with the zero-caller measurement as starting evidence - drop the ADVANCED_FEATURES.md section whose example promised 'Config is validated before init is called' — false on this ref - pin the retirement: barrel no longer exports the validator (runtime), a declared configSchema no longer type-checks (compile-time, via the type-check DEBT ratchet), startupTimeout as the live-sibling positive control Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LZbWd2jNV1FErXTPSS4Dry --- docs/adr/0025-plugin-package-distribution.md | 19 +- packages/core/ADVANCED_FEATURES.md | 25 -- .../plugin-loader.retired-fields.pin.test.ts | 60 ++++ packages/core/src/plugin-loader.ts | 38 +-- packages/core/src/security/index.ts | 10 +- .../security/plugin-config-validator.test.ts | 276 ------------------ .../src/security/plugin-config-validator.ts | 193 ------------ 7 files changed, 88 insertions(+), 533 deletions(-) create mode 100644 packages/core/src/plugin-loader.retired-fields.pin.test.ts delete mode 100644 packages/core/src/security/plugin-config-validator.test.ts delete mode 100644 packages/core/src/security/plugin-config-validator.ts diff --git a/docs/adr/0025-plugin-package-distribution.md b/docs/adr/0025-plugin-package-distribution.md index eafb7320ca..f0c97e82ac 100644 --- a/docs/adr/0025-plugin-package-distribution.md +++ b/docs/adr/0025-plugin-package-distribution.md @@ -61,7 +61,7 @@ code and npm dependencies**, not just metadata. The repository already has the with `init/start/destroy` + `PluginContext`), `packages/core/src/security/plugin-permission-enforcer.ts` (capability-based service/hook/file/network enforcement), - `PluginConfigValidator`, and `packages/runtime/src/sandbox/quickjs-runner.ts` + and `packages/runtime/src/sandbox/quickjs-runner.ts` (a QuickJS-WASM sandbox that wires only capability-gated `ctx.api/crypto/log` into untrusted code). @@ -149,7 +149,7 @@ Extends the existing `ObjectStackManifest` with three new blocks "fs": [] }, "integrity": { "dist/index.mjs": "sha256-..." }, // per-file hashes - "configuration": { /* existing config schema (PluginConfigValidator) */ }, + "configuration": { /* config schema — validator retired, re-decide with this layer (§3.7) */ }, "capabilities": { /* existing implements/provides/requires/contributes */ }, "contributes": { /* OPTIONAL declarative metadata: objects/views/flows/... */ } } @@ -276,8 +276,18 @@ enforces this at publish time (an unverified publisher cannot ship `runtime: granted set → `PluginPermissionEnforcer` (service/hook/file/network already enforced). Principle of least privilege; all denials logged (existing behavior). -- **Config.** `PluginConfigValidator` validates plugin config against the - `configuration` schema. +- **Config.** RETIRED 2026-08-27 (#11982, ADR-0049 enforce-or-remove; + maintainer ruling, decision-inbox batch 5). `PluginConfigValidator` / + `createPluginConfigValidator` and `PluginMetadata.configSchema` were removed: + the mechanism could never run — the loader's one call site passed no config, + no manifest→`loadPlugin` path existed to carry one, `PluginMetadata` had no + config-value field, and zero plugins declared a schema (measured with + positive controls on #11982; the sibling `hotReloadable` fell to the same + measurement in #12587). Re-declaring a kernel-owned config-validation + surface is a **fresh decision** for the day this distribution layer actually + lands, with #11982's zero-caller measurement as its starting evidence — the + manifest `configuration` block below records the design intent, not a live + validator. - **Supply chain.** Lockfile + per-file `integrity`; server-side scan for secrets and known-vuln deps; SBOM stored on the version row; **always** `--ignore-scripts` (no `postinstall`). @@ -496,7 +506,6 @@ the developers and operators who compose Apps and provision runtimes. - `packages/core/src/plugin-loader.ts` — plugin loading, lifecycle, health, signature - `packages/core/src/types.ts` — `Plugin` (`init/start/destroy`) + `PluginContext` - `packages/core/src/security/plugin-permission-enforcer.ts` — capability-based enforcement -- `packages/core/src/security/plugin-config-validator.ts` — config validation - `packages/runtime/src/sandbox/quickjs-runner.ts` — QuickJS-WASM sandbox (T1) - `packages/runtime/src/cloud/marketplace-install-local-plugin.ts` — local inline install (ADR-0016 §9) - `packages/runtime/src/cloud/marketplace-proxy-plugin.ts` — marketplace browse proxy diff --git a/packages/core/ADVANCED_FEATURES.md b/packages/core/ADVANCED_FEATURES.md index 3799a7725d..68d29d7a07 100644 --- a/packages/core/ADVANCED_FEATURES.md +++ b/packages/core/ADVANCED_FEATURES.md @@ -285,30 +285,6 @@ Plugins must use semantic versioning: 'latest' ``` -### 10. Plugin Configuration Validation - -Use Zod schemas to validate plugin configuration: - -```typescript -import { z } from 'zod'; - -const MyPluginConfigSchema = z.object({ - apiKey: z.string(), - timeout: z.number().min(1000).max(30000), - retries: z.number().int().min(0).default(3) -}); - -const plugin: PluginMetadata = { - name: 'my-plugin', - version: '1.0.0', - configSchema: MyPluginConfigSchema, - - async init(ctx) { - // Config is validated before init is called - } -}; -``` - ## Migration from LiteKernel To migrate from `LiteKernel` to `ObjectKernel`: @@ -367,7 +343,6 @@ Both kernels adhere to the same `Plugin` interface, but `ObjectKernel` supports Extended `Plugin` interface with: - `version: string` - Semantic version -- `configSchema?: z.ZodSchema` - Configuration schema - `signature?: string` - Plugin signature for verification - `healthCheck?(): Promise` - Health check function - `startupTimeout?: number` - Startup timeout in milliseconds diff --git a/packages/core/src/plugin-loader.retired-fields.pin.test.ts b/packages/core/src/plugin-loader.retired-fields.pin.test.ts new file mode 100644 index 0000000000..86539e3ed4 --- /dev/null +++ b/packages/core/src/plugin-loader.retired-fields.pin.test.ts @@ -0,0 +1,60 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +// +// Pins for the ADR-0049 retirements on `PluginMetadata` (#11982), recorded in +// ADR-0025 §3.7. +// +// Two instruments, deliberately different: +// +// 1. COMPILE-TIME pins — the `@ts-expect-error` directives below. Their +// failure channel is `tsc --noEmit` on this package, which CI runs through +// the type-check DEBT ratchet (`pnpm check:type-check-coverage`; +// `@objectstack/core` is a DEBT entry, growth is red). Re-adding a retired +// field turns each satisfied directive into an "Unused '@ts-expect-error'" +// error, growing the measured count past the ledger — proven able to fail +// by ablation on the retirement PR. +// 2. RUNTIME pins — vitest assertions that the security barrel no longer +// publishes the retired validator. These fail in `pnpm --filter +// @objectstack/core test` the moment the export returns. +// +// Positive control (compile-time): the `startupTimeout` literal below is a +// LIVE field (read at kernel.ts `startPluginWithTimeout`) and must keep +// compiling with no directive — proving the interface still accepts its real +// members, so the directives above it are readings, not a broken instrument. + +import { describe, it, expect } from 'vitest'; +import type { PluginMetadata } from './plugin-loader.js'; +import * as securityBarrel from './security/index.js'; + +describe('PluginMetadata retired fields (ADR-0049, ADR-0025 §3.7)', () => { + it('no longer publishes PluginConfigValidator from the security barrel (#11982)', () => { + expect((securityBarrel as Record).PluginConfigValidator).toBeUndefined(); + expect((securityBarrel as Record).createPluginConfigValidator).toBeUndefined(); + expect(Object.keys(securityBarrel)).not.toContain('PluginConfigValidator'); + expect(Object.keys(securityBarrel)).not.toContain('createPluginConfigValidator'); + }); + + it('compile-time: a declared configSchema no longer type-checks (#11982)', () => { + const declared: PluginMetadata = { + name: 'retired-configschema-pin', + version: '1.0.0', + // @ts-expect-error — `configSchema` was retired under ADR-0049 + // (#11982): the kernel never received a config to validate it + // against. Parse plugin config at the plugin's own seam instead. + configSchema: { parse: (v: unknown) => v }, + async init() {}, + }; + // The value exists at runtime (TS types are erased); the pin is the + // directive above, enforced by tsc through the DEBT ratchet. + expect(declared.name).toBe('retired-configschema-pin'); + }); + + it('positive control: live sibling fields still type-check with no directive', () => { + const control: PluginMetadata = { + name: 'live-sibling-control', + version: '1.0.0', + startupTimeout: 1000, + async init() {}, + }; + expect(control.startupTimeout).toBe(1000); + }); +}); diff --git a/packages/core/src/plugin-loader.ts b/packages/core/src/plugin-loader.ts index c7764c0d36..6c8049b748 100644 --- a/packages/core/src/plugin-loader.ts +++ b/packages/core/src/plugin-loader.ts @@ -2,8 +2,6 @@ import { Plugin, PluginContext } from './types.js'; import type { Logger } from '@objectstack/spec/contracts'; -import { z } from 'zod'; -import { PluginConfigValidator } from './security/plugin-config-validator.js'; import { parseSignature } from './security/plugin-artifact-signature.js'; /** @@ -41,10 +39,15 @@ export interface ServiceRegistration { export interface PluginMetadata extends Plugin { /** Semantic version (e.g., "1.0.0") */ version: string; - - /** Configuration schema for validation */ - configSchema?: z.ZodSchema; - + + // `configSchema` was retired on 2026-08-27 (ADR-0049 enforce-or-remove; + // recorded in ADR-0025 §3.7): the loader's only call passed no config and + // no caller could — plugin factories close over their config, so the + // kernel never receives it. Plugins parse their own config at their own + // seam instead (the `packages/rest` pattern). Re-declaring a kernel-owned + // config-validation surface is a fresh decision for the day the ADR-0025 + // distribution layer lands. + /** Plugin signature for security verification */ signature?: string; @@ -106,7 +109,6 @@ export interface VersionCompatibility { export class PluginLoader { private logger: Logger; private context?: PluginContext; - private configValidator: PluginConfigValidator; private loadedPlugins: Map = new Map(); private serviceFactories: Map = new Map(); private serviceInstances: Map = new Map(); @@ -115,7 +117,6 @@ export class PluginLoader { constructor(logger: Logger) { this.logger = logger; - this.configValidator = new PluginConfigValidator(logger); } /** @@ -153,11 +154,6 @@ export class PluginLoader { throw new Error(`Version incompatible: ${versionCheck.message}`); } - // Validate configuration if schema is provided - if (metadata.configSchema) { - this.validatePluginConfig(metadata); - } - // Verify signature if provided if (metadata.signature) { await this.verifyPluginSignature(metadata); @@ -403,22 +399,6 @@ export class PluginLoader { return semverRegex.test(version); } - private validatePluginConfig(plugin: PluginMetadata, config?: any): void { - if (!plugin.configSchema) { - return; - } - - if (config === undefined) { - // In loadPlugin, we often don't have the config yet. - // We skip validation here or valid against empty object if schema allows? - // For now, let's keep the logging behavior but note it's delegating - this.logger.debug(`Plugin ${plugin.name} has configuration schema (config validation postponed)`); - return; - } - - this.configValidator.validatePluginConfig(plugin, config); - } - private async verifyPluginSignature(plugin: PluginMetadata): Promise { if (!plugin.signature) { return; diff --git a/packages/core/src/security/index.ts b/packages/core/src/security/index.ts index d674a3f39c..017f00d67d 100644 --- a/packages/core/src/security/index.ts +++ b/packages/core/src/security/index.ts @@ -5,7 +5,6 @@ * * Provides security features for the ObjectStack microkernel: * - Plugin signature verification - * - Plugin configuration validation * - Permission and capability enforcement * * @module @objectstack/core/security @@ -35,10 +34,11 @@ export { verifyPluginArtifact, } from './plugin-artifact-signature.js'; -export { - PluginConfigValidator, - createPluginConfigValidator, -} from './plugin-config-validator.js'; +// `PluginConfigValidator` / `createPluginConfigValidator` were RETIRED here on +// 2026-08-27 (#11982, ADR-0049 enforce-or-remove; recorded in ADR-0025 §3.7). +// The kernel never received a plugin's config to validate — factories close +// over it — so the class had zero live callers; plugins parse their own +// config at their own seam instead. export { PluginPermissionEnforcer, diff --git a/packages/core/src/security/plugin-config-validator.test.ts b/packages/core/src/security/plugin-config-validator.test.ts deleted file mode 100644 index 5e5604df40..0000000000 --- a/packages/core/src/security/plugin-config-validator.test.ts +++ /dev/null @@ -1,276 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { z } from 'zod'; -import { PluginConfigValidator } from './plugin-config-validator.js'; -import { createLogger } from '../logger.js'; -import type { PluginMetadata } from '../plugin-loader.js'; - -describe('PluginConfigValidator', () => { - let validator: PluginConfigValidator; - let logger: ReturnType; - - beforeEach(() => { - logger = createLogger({ level: 'error' }); - validator = new PluginConfigValidator(logger); - }); - - describe('validatePluginConfig', () => { - it('should validate valid configuration', () => { - const configSchema = z.object({ - port: z.number().min(1000).max(65535), - host: z.string(), - debug: z.boolean().default(false), - }); - - const plugin: PluginMetadata = { - name: 'com.test.plugin', - version: '1.0.0', - configSchema, - init: async () => {}, - }; - - const config = { - port: 3000, - host: 'localhost', - debug: true, - }; - - const validatedConfig = validator.validatePluginConfig(plugin, config); - - expect(validatedConfig).toEqual(config); - }); - - it('should apply defaults for missing optional fields', () => { - const configSchema = z.object({ - port: z.number().default(3000), - host: z.string().default('localhost'), - debug: z.boolean().default(false), - }); - - const plugin: PluginMetadata = { - name: 'com.test.plugin', - version: '1.0.0', - configSchema, - init: async () => {}, - }; - - const config = { - port: 8080, - }; - - const validatedConfig = validator.validatePluginConfig(plugin, config); - - expect(validatedConfig).toEqual({ - port: 8080, - host: 'localhost', - debug: false, - }); - }); - - it('should throw error for invalid configuration', () => { - const configSchema = z.object({ - port: z.number().min(1000).max(65535), - host: z.string(), - }); - - const plugin: PluginMetadata = { - name: 'com.test.plugin', - version: '1.0.0', - configSchema, - init: async () => {}, - }; - - const config = { - port: 100, // Invalid: < 1000 - host: 'localhost', - }; - - expect(() => validator.validatePluginConfig(plugin, config)).toThrow(); - }); - - it('should provide detailed error messages', () => { - const configSchema = z.object({ - port: z.number().min(1000), - host: z.string().min(1), - }); - - const plugin: PluginMetadata = { - name: 'com.test.plugin', - version: '1.0.0', - configSchema, - init: async () => {}, - }; - - const config = { - port: 100, - host: '', - }; - - try { - validator.validatePluginConfig(plugin, config); - expect.fail('Should have thrown validation error'); - } catch (error) { - const errorMessage = (error as Error).message; - expect(errorMessage).toContain('com.test.plugin'); - expect(errorMessage).toContain('port'); - expect(errorMessage).toContain('host'); - } - }); - - it('should skip validation when no schema is provided', () => { - const plugin: PluginMetadata = { - name: 'com.test.plugin', - version: '1.0.0', - init: async () => {}, - }; - - const config = { anything: 'goes' }; - - const validatedConfig = validator.validatePluginConfig(plugin, config); - - expect(validatedConfig).toEqual(config); - }); - }); - - describe('validatePartialConfig', () => { - it('should validate partial configuration', () => { - const configSchema = z.object({ - port: z.number().min(1000), - host: z.string(), - debug: z.boolean(), - }); - - const plugin: PluginMetadata = { - name: 'com.test.plugin', - version: '1.0.0', - configSchema, - init: async () => {}, - }; - - const partialConfig = { - port: 8080, - }; - - const validatedConfig = validator.validatePartialConfig(plugin, partialConfig); - - expect(validatedConfig).toEqual({ port: 8080 }); - }); - }); - - describe('getDefaultConfig', () => { - it('should extract default configuration', () => { - const configSchema = z.object({ - port: z.number().default(3000), - host: z.string().default('localhost'), - debug: z.boolean().default(false), - }); - - const plugin: PluginMetadata = { - name: 'com.test.plugin', - version: '1.0.0', - configSchema, - init: async () => {}, - }; - - const defaults = validator.getDefaultConfig(plugin); - - expect(defaults).toEqual({ - port: 3000, - host: 'localhost', - debug: false, - }); - }); - - it('should return undefined when schema requires fields', () => { - const configSchema = z.object({ - port: z.number(), - host: z.string(), - }); - - const plugin: PluginMetadata = { - name: 'com.test.plugin', - version: '1.0.0', - configSchema, - init: async () => {}, - }; - - const defaults = validator.getDefaultConfig(plugin); - - expect(defaults).toBeUndefined(); - }); - }); - - describe('isConfigValid', () => { - it('should return true for valid config', () => { - const configSchema = z.object({ - port: z.number(), - }); - - const plugin: PluginMetadata = { - name: 'com.test.plugin', - version: '1.0.0', - configSchema, - init: async () => {}, - }; - - const isValid = validator.isConfigValid(plugin, { port: 3000 }); - - expect(isValid).toBe(true); - }); - - it('should return false for invalid config', () => { - const configSchema = z.object({ - port: z.number(), - }); - - const plugin: PluginMetadata = { - name: 'com.test.plugin', - version: '1.0.0', - configSchema, - init: async () => {}, - }; - - const isValid = validator.isConfigValid(plugin, { port: 'invalid' }); - - expect(isValid).toBe(false); - }); - }); - - describe('getConfigErrors', () => { - it('should return errors for invalid config', () => { - const configSchema = z.object({ - port: z.number().min(1000), - host: z.string().min(1), - }); - - const plugin: PluginMetadata = { - name: 'com.test.plugin', - version: '1.0.0', - configSchema, - init: async () => {}, - }; - - const errors = validator.getConfigErrors(plugin, { port: 100, host: '' }); - - expect(errors).toHaveLength(2); - expect(errors[0].path).toBe('port'); - expect(errors[1].path).toBe('host'); - }); - - it('should return empty array for valid config', () => { - const configSchema = z.object({ - port: z.number(), - }); - - const plugin: PluginMetadata = { - name: 'com.test.plugin', - version: '1.0.0', - configSchema, - init: async () => {}, - }; - - const errors = validator.getConfigErrors(plugin, { port: 3000 }); - - expect(errors).toEqual([]); - }); - }); -}); diff --git a/packages/core/src/security/plugin-config-validator.ts b/packages/core/src/security/plugin-config-validator.ts deleted file mode 100644 index e8f8e1b0f7..0000000000 --- a/packages/core/src/security/plugin-config-validator.ts +++ /dev/null @@ -1,193 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { z } from 'zod'; -import type { Logger } from '@objectstack/spec/contracts'; -import type { PluginMetadata } from '../plugin-loader.js'; - -/** - * Plugin Configuration Validator - * - * Validates plugin configurations against Zod schemas to ensure: - * 1. Type safety - all config values have correct types - * 2. Business rules - values meet constraints (min/max, regex, etc.) - * 3. Required fields - all mandatory configuration is provided - * 4. Default values - missing optional fields get defaults - * - * Architecture: - * - Uses Zod for runtime validation - * - Provides detailed error messages with field paths - * - Supports nested configuration objects - * - Allows partial validation for incremental updates - * - * Usage: - * ```typescript - * const validator = new PluginConfigValidator(logger); - * const validConfig = validator.validatePluginConfig(plugin, userConfig); - * ``` - */ -export class PluginConfigValidator { - private logger: Logger; - - constructor(logger: Logger) { - this.logger = logger; - } - - /** - * Validate plugin configuration against its Zod schema - * - * @param plugin - Plugin metadata with configSchema - * @param config - User-provided configuration - * @returns Validated and typed configuration - * @throws Error with detailed validation errors - */ - validatePluginConfig(plugin: PluginMetadata, config: any): T { - if (!plugin.configSchema) { - this.logger.debug(`Plugin ${plugin.name} has no config schema - skipping validation`); - return config as T; - } - - try { - // Use Zod to parse and validate - const validatedConfig = plugin.configSchema.parse(config); - - this.logger.debug(`✅ Plugin config validated: ${plugin.name}`, { - plugin: plugin.name, - configKeys: Object.keys(config || {}).length, - }); - - return validatedConfig as T; - } catch (error) { - if (error instanceof z.ZodError) { - const formattedErrors = this.formatZodErrors(error); - const errorMessage = [ - `Plugin ${plugin.name} configuration validation failed:`, - ...formattedErrors.map(e => ` - ${e.path}: ${e.message}`), - ].join('\n'); - - this.logger.error(errorMessage, undefined, { - plugin: plugin.name, - errors: formattedErrors, - }); - - throw new Error(errorMessage); - } - - // Re-throw other errors - throw error; - } - } - - /** - * Validate partial configuration (for incremental updates) - * - * @param plugin - Plugin metadata - * @param partialConfig - Partial configuration to validate - * @returns Validated partial configuration - */ - validatePartialConfig(plugin: PluginMetadata, partialConfig: any): Partial { - if (!plugin.configSchema) { - return partialConfig as Partial; - } - - try { - // Use Zod's partial() method for partial validation - // Cast to ZodObject to access partial() method - const partialSchema = (plugin.configSchema as any).partial(); - const validatedConfig = partialSchema.parse(partialConfig); - - this.logger.debug(`✅ Partial config validated: ${plugin.name}`); - return validatedConfig as Partial; - } catch (error) { - if (error instanceof z.ZodError) { - const formattedErrors = this.formatZodErrors(error); - const errorMessage = [ - `Plugin ${plugin.name} partial configuration validation failed:`, - ...formattedErrors.map(e => ` - ${e.path}: ${e.message}`), - ].join('\n'); - - throw new Error(errorMessage); - } - - throw error; - } - } - - /** - * Get default configuration from schema - * - * @param plugin - Plugin metadata - * @returns Default configuration object - */ - getDefaultConfig(plugin: PluginMetadata): T | undefined { - if (!plugin.configSchema) { - return undefined; - } - - try { - // Parse empty object to get defaults - const defaults = plugin.configSchema.parse({}); - this.logger.debug(`Default config extracted: ${plugin.name}`); - return defaults as T; - } catch (error) { - // Schema may require some fields - return undefined - this.logger.debug(`No default config available: ${plugin.name}`); - return undefined; - } - } - - /** - * Check if configuration is valid without throwing - * - * @param plugin - Plugin metadata - * @param config - Configuration to check - * @returns True if valid, false otherwise - */ - isConfigValid(plugin: PluginMetadata, config: any): boolean { - if (!plugin.configSchema) { - return true; - } - - const result = plugin.configSchema.safeParse(config); - return result.success; - } - - /** - * Get configuration errors without throwing - * - * @param plugin - Plugin metadata - * @param config - Configuration to check - * @returns Array of validation errors, or empty array if valid - */ - getConfigErrors(plugin: PluginMetadata, config: any): Array<{path: string; message: string}> { - if (!plugin.configSchema) { - return []; - } - - const result = plugin.configSchema.safeParse(config); - - if (result.success) { - return []; - } - - return this.formatZodErrors(result.error); - } - - // Private methods - - private formatZodErrors(error: z.ZodError): Array<{path: string; message: string}> { - return error.issues.map((e: z.ZodIssue) => ({ - path: e.path.join('.') || 'root', - message: e.message, - })); - } -} - -/** - * Create a plugin config validator - * - * @param logger - Logger instance - * @returns Plugin config validator - */ -export function createPluginConfigValidator(logger: Logger): PluginConfigValidator { - return new PluginConfigValidator(logger); -} From 8f6fb6fca99cdd25dbb6399e12a7adf87cf1c5e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 08:33:01 +0000 Subject: [PATCH 2/4] feat(core): retire PluginMetadata.hotReloadable under ADR-0049 (#12587) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Declared 'Whether plugin supports hot reload' and documented, with zero reads and zero declarations: HotReloadManager.reloadPlugin gates only on its own registered reload configs, so hotReloadable: false was hot-reloaded identically to true — a reload-safety assurance the runtime never honoured (maintainer ruled Option B, 2026-08-27, decision-inbox batch 5, same batch as the configSchema retirement). - remove PluginMetadata.hotReloadable and its ADVANCED_FEATURES.md line - drop the field from ADR-0025's present-capability inventory (the section 3.7 record already names this sibling retirement; the ADR's distribution-layer design prose keeps its forward-looking mentions) - pin: a declared hotReloadable no longer type-checks (compile-time, via the type-check DEBT ratchet) - add the family changeset covering both retirements (@objectstack/core minor under the lockstep launch-window convention, with the ADR-0087 runtime-interface-only disposition) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LZbWd2jNV1FErXTPSS4Dry --- .../retire-plugin-metadata-inert-fields.md | 47 +++++++++++++++++++ docs/adr/0025-plugin-package-distribution.md | 2 +- packages/core/ADVANCED_FEATURES.md | 1 - .../plugin-loader.retired-fields.pin.test.ts | 13 +++++ packages/core/src/plugin-loader.ts | 9 ++-- 5 files changed, 67 insertions(+), 5 deletions(-) create mode 100644 .changeset/retire-plugin-metadata-inert-fields.md diff --git a/.changeset/retire-plugin-metadata-inert-fields.md b/.changeset/retire-plugin-metadata-inert-fields.md new file mode 100644 index 0000000000..48d10dbd1e --- /dev/null +++ b/.changeset/retire-plugin-metadata-inert-fields.md @@ -0,0 +1,47 @@ +--- +"@objectstack/core": minor +--- + +feat(core): retire the inert `PluginMetadata` surfaces — `configSchema` with `PluginConfigValidator`, and `hotReloadable` (#11982, #12587, ADR-0049) + + + +**BREAKING**: removes a published-but-inert capability from the `.` entry of +`@objectstack/core`. Shipped as `minor` under the lockstep launch-window +convention (a `major` bump is refused repo-wide by `check:changeset-no-major`). + +Removed, each measured at zero live consumers with positive controls (the +sibling `startupTimeout` is read live by the kernel's startup timeout guard); +maintainer ruled retire under ADR-0049 enforce-or-remove, 2026-08-27, +decision-inbox batch 5; recorded in ADR-0025 §3.7: + +- `PluginMetadata.configSchema` — declared "Configuration schema for + validation", but the mechanism could never run: the loader's only call + passed no config, and no caller could — plugin factories close over their + config, so the kernel never receives it. Every one of ~40 production + `kernel.use()` compositions already passes config as constructor arguments + and works. +- `PluginConfigValidator` / `createPluginConfigValidator` — the validator + behind that field: real code with zero reachable invocations, deleted along + with its unit test and its export from the security barrel. +- `PluginMetadata.hotReloadable` — declared "Whether plugin supports hot + reload" with zero reads and zero declarations: `HotReloadManager.reloadPlugin` + gates only on its own registered reload configs, so `hotReloadable: false` + was hot-reloaded identically to `true`. +- The `packages/core/ADVANCED_FEATURES.md` example whose inline comment + promised "Config is validated before init is called" — false on the + retired ref, and the retired surface's only in-repo declaration site. + +One-line fixes, per symbol. If you declared `configSchema` on a plugin: +delete the field and parse your config at the plugin's own seam — +`MyConfigSchema.parse(options)` in the plugin factory or constructor, the +pattern `packages/rest` uses. If you imported `PluginConfigValidator` or +`createPluginConfigValidator`: delete the import and hold your own +`schema.parse` call; the compiler (TS2305) locates every such site. If you +declared `hotReloadable`: delete the field — it never gated anything, and +hot-reload participation remains governed solely by +`HotReloadManager.registerReloadConfig`. + +Re-declaring a kernel-owned config-validation surface is a fresh decision for +the day ADR-0025's plugin distribution layer lands, with #11982's zero-caller +measurement as its starting evidence. diff --git a/docs/adr/0025-plugin-package-distribution.md b/docs/adr/0025-plugin-package-distribution.md index f0c97e82ac..52e1e36240 100644 --- a/docs/adr/0025-plugin-package-distribution.md +++ b/docs/adr/0025-plugin-package-distribution.md @@ -57,7 +57,7 @@ code and npm dependencies**, not just metadata. The repository already has the schema), and a `src/index.ts` with a lifecycle entry point. - The microkernel can already *load* code plugins: `packages/core/src/ plugin-loader.ts` (dependency ordering, health checks, `signature` field, - `startupTimeout`, `hotReloadable`), `packages/core/src/types.ts` (`Plugin` + `startupTimeout`), `packages/core/src/types.ts` (`Plugin` with `init/start/destroy` + `PluginContext`), `packages/core/src/security/plugin-permission-enforcer.ts` (capability-based service/hook/file/network enforcement), diff --git a/packages/core/ADVANCED_FEATURES.md b/packages/core/ADVANCED_FEATURES.md index 68d29d7a07..a949cfb106 100644 --- a/packages/core/ADVANCED_FEATURES.md +++ b/packages/core/ADVANCED_FEATURES.md @@ -346,7 +346,6 @@ Extended `Plugin` interface with: - `signature?: string` - Plugin signature for verification - `healthCheck?(): Promise` - Health check function - `startupTimeout?: number` - Startup timeout in milliseconds -- `hotReloadable?: boolean` - Whether plugin supports hot reload ## Examples diff --git a/packages/core/src/plugin-loader.retired-fields.pin.test.ts b/packages/core/src/plugin-loader.retired-fields.pin.test.ts index 86539e3ed4..1828dfbe73 100644 --- a/packages/core/src/plugin-loader.retired-fields.pin.test.ts +++ b/packages/core/src/plugin-loader.retired-fields.pin.test.ts @@ -48,6 +48,19 @@ describe('PluginMetadata retired fields (ADR-0049, ADR-0025 §3.7)', () => { expect(declared.name).toBe('retired-configschema-pin'); }); + it('compile-time: a declared hotReloadable no longer type-checks (#12587)', () => { + const declared: PluginMetadata = { + name: 'retired-hotreloadable-pin', + version: '1.0.0', + // @ts-expect-error — `hotReloadable` was retired under ADR-0049 + // (#12587): nothing ever read it. Reload participation is governed + // solely by `HotReloadManager.registerReloadConfig`. + hotReloadable: false, + async init() {}, + }; + expect(declared.name).toBe('retired-hotreloadable-pin'); + }); + it('positive control: live sibling fields still type-check with no directive', () => { const control: PluginMetadata = { name: 'live-sibling-control', diff --git a/packages/core/src/plugin-loader.ts b/packages/core/src/plugin-loader.ts index 6c8049b748..5bacc62812 100644 --- a/packages/core/src/plugin-loader.ts +++ b/packages/core/src/plugin-loader.ts @@ -56,9 +56,12 @@ export interface PluginMetadata extends Plugin { /** Startup timeout in milliseconds (default: 30000) */ startupTimeout?: number; - - /** Whether plugin supports hot reload */ - hotReloadable?: boolean; + + // `hotReloadable` was retired on 2026-08-27 (#12587, same ADR-0049 batch): + // declared and documented with zero reads — `HotReloadManager.reloadPlugin` + // gates only on its own registered reload configs, so `hotReloadable: + // false` was hot-reloaded identically to `true`. Reload participation is + // governed solely by `HotReloadManager.registerReloadConfig`. } /** From db0509e972f23ee0dec79bbe752dbad6ef4f933c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 09:06:11 +0000 Subject: [PATCH 3/4] test(core,rest): host the retirement compile pins in a compiled program (#11982, #12587) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check:type-check-coverage correctly refused the first shape: @objectstack/core has no typecheck script (type-check DEBT ledger entry), so a @ts-expect-error in core is a phantom pin no tsc program a typecheck script runs would evaluate, and PHANTOM_PIN_DEBT is closed to new entries. - core keeps the RUNTIME pins (security barrel no longer publishes PluginConfigValidator / createPluginConfigValidator, live-sibling positive control on the namespace) - the COMPILE-TIME pins move to packages/rest, whose tsconfig.test.json program is run by its typecheck script (check:test-typecheck, EXACT per-file ratchet) and resolves @objectstack/core to the BUILT dist .d.ts — so the directives pin the published contract consumers actually see, in the package that carries the retirement's worked replacement (#11637 seam parse) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LZbWd2jNV1FErXTPSS4Dry --- .../plugin-loader.retired-fields.pin.test.ts | 77 +++++-------------- ...plugin-metadata-retired-fields.pin.test.ts | 71 +++++++++++++++++ 2 files changed, 91 insertions(+), 57 deletions(-) create mode 100644 packages/rest/src/plugin-metadata-retired-fields.pin.test.ts diff --git a/packages/core/src/plugin-loader.retired-fields.pin.test.ts b/packages/core/src/plugin-loader.retired-fields.pin.test.ts index 1828dfbe73..102ee0eae4 100644 --- a/packages/core/src/plugin-loader.retired-fields.pin.test.ts +++ b/packages/core/src/plugin-loader.retired-fields.pin.test.ts @@ -1,31 +1,25 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. // -// Pins for the ADR-0049 retirements on `PluginMetadata` (#11982), recorded in -// ADR-0025 §3.7. +// RUNTIME pins for the ADR-0049 retirements on `PluginMetadata` (#11982, +// #12587), recorded in ADR-0025 §3.7: the security barrel must not publish the +// retired validator again. These fail in `pnpm --filter @objectstack/core test` +// the moment the export returns. // -// Two instruments, deliberately different: -// -// 1. COMPILE-TIME pins — the `@ts-expect-error` directives below. Their -// failure channel is `tsc --noEmit` on this package, which CI runs through -// the type-check DEBT ratchet (`pnpm check:type-check-coverage`; -// `@objectstack/core` is a DEBT entry, growth is red). Re-adding a retired -// field turns each satisfied directive into an "Unused '@ts-expect-error'" -// error, growing the measured count past the ledger — proven able to fail -// by ablation on the retirement PR. -// 2. RUNTIME pins — vitest assertions that the security barrel no longer -// publishes the retired validator. These fail in `pnpm --filter -// @objectstack/core test` the moment the export returns. -// -// Positive control (compile-time): the `startupTimeout` literal below is a -// LIVE field (read at kernel.ts `startPluginWithTimeout`) and must keep -// compiling with no directive — proving the interface still accepts its real -// members, so the directives above it are readings, not a broken instrument. +// The COMPILE-TIME half — a declared `configSchema` / `hotReloadable` no +// longer type-checks against the published `PluginMetadata` — lives in +// `packages/rest/src/plugin-metadata-retired-fields.pin.test.ts`, deliberately +// NOT here: `@objectstack/core` has no `typecheck` script (type-check DEBT +// ledger entry), so a `@ts-expect-error` in this package is a phantom pin no +// tsc program a `typecheck` script runs would ever evaluate — +// `check:type-check-coverage` refuses exactly that. The rest package's +// `tsconfig.test.json` program is compiled by its `typecheck` script and reads +// core's BUILT `.d.ts`, so the pin over there guards the published contract +// itself. import { describe, it, expect } from 'vitest'; -import type { PluginMetadata } from './plugin-loader.js'; import * as securityBarrel from './security/index.js'; -describe('PluginMetadata retired fields (ADR-0049, ADR-0025 §3.7)', () => { +describe('PluginConfigValidator retirement (ADR-0049, ADR-0025 §3.7)', () => { it('no longer publishes PluginConfigValidator from the security barrel (#11982)', () => { expect((securityBarrel as Record).PluginConfigValidator).toBeUndefined(); expect((securityBarrel as Record).createPluginConfigValidator).toBeUndefined(); @@ -33,41 +27,10 @@ describe('PluginMetadata retired fields (ADR-0049, ADR-0025 §3.7)', () => { expect(Object.keys(securityBarrel)).not.toContain('createPluginConfigValidator'); }); - it('compile-time: a declared configSchema no longer type-checks (#11982)', () => { - const declared: PluginMetadata = { - name: 'retired-configschema-pin', - version: '1.0.0', - // @ts-expect-error — `configSchema` was retired under ADR-0049 - // (#11982): the kernel never received a config to validate it - // against. Parse plugin config at the plugin's own seam instead. - configSchema: { parse: (v: unknown) => v }, - async init() {}, - }; - // The value exists at runtime (TS types are erased); the pin is the - // directive above, enforced by tsc through the DEBT ratchet. - expect(declared.name).toBe('retired-configschema-pin'); - }); - - it('compile-time: a declared hotReloadable no longer type-checks (#12587)', () => { - const declared: PluginMetadata = { - name: 'retired-hotreloadable-pin', - version: '1.0.0', - // @ts-expect-error — `hotReloadable` was retired under ADR-0049 - // (#12587): nothing ever read it. Reload participation is governed - // solely by `HotReloadManager.registerReloadConfig`. - hotReloadable: false, - async init() {}, - }; - expect(declared.name).toBe('retired-hotreloadable-pin'); - }); - - it('positive control: live sibling fields still type-check with no directive', () => { - const control: PluginMetadata = { - name: 'live-sibling-control', - version: '1.0.0', - startupTimeout: 1000, - async init() {}, - }; - expect(control.startupTimeout).toBe(1000); + it('positive control: the barrel still publishes its live siblings', () => { + // Proves the absence assertions above read a populated namespace, not + // an accidentally-empty import. + expect(Object.keys(securityBarrel)).toContain('PluginSignatureVerifier'); + expect(Object.keys(securityBarrel)).toContain('PluginPermissionEnforcer'); }); }); diff --git a/packages/rest/src/plugin-metadata-retired-fields.pin.test.ts b/packages/rest/src/plugin-metadata-retired-fields.pin.test.ts new file mode 100644 index 0000000000..d32bc86130 --- /dev/null +++ b/packages/rest/src/plugin-metadata-retired-fields.pin.test.ts @@ -0,0 +1,71 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// COMPILE-TIME pins for the ADR-0049 retirements on the PUBLISHED +// `PluginMetadata` surface of `@objectstack/core` (#11982 `configSchema`, +// #12587 `hotReloadable`), recorded in ADR-0025 §3.7. +// +// Why the pins live in THIS package: `@objectstack/core` has no `typecheck` +// script (it is a type-check DEBT ledger entry), so a `@ts-expect-error` there +// is a phantom pin — no tsc program a `typecheck` script runs ever evaluates +// it, and `check:type-check-coverage` refuses it. This package's +// `tsconfig.test.json` program IS run by its `typecheck` script +// (`check:test-typecheck`, EXACT per-file ratchet: an unlisted file must stay +// at zero errors), and it resolves `@objectstack/core` to the BUILT +// `dist/index.d.ts` — so these directives pin the contract consumers actually +// see. This package is also the retirement's worked replacement: the REST +// server parses its own config at its own seam (#11637, +// `rest-config-parse-not-cast.test.ts`) precisely because the kernel-side +// validator could never run. +// +// Failure channel, proven able to fail by ablation on the retirement PR: +// re-adding a retired field to `PluginMetadata` (and rebuilding core's dist) +// turns the matching directive into TS2578 "Unused '@ts-expect-error' +// directive", giving this file 1 error where the ratchet requires 0. +// +// Positive control: the `startupTimeout` literal below is a LIVE field (read +// by the kernel's startup timeout guard) and must keep compiling with no +// directive — proving the interface still accepts its real members, so the +// directives above it are readings, not a broken instrument. + +import { describe, it, expect } from 'vitest'; +import type { PluginMetadata } from '@objectstack/core'; + +describe('PluginMetadata retired fields — published-surface pins (ADR-0049, ADR-0025 §3.7)', () => { + it('compile-time: a declared configSchema no longer type-checks (#11982)', () => { + const declared: PluginMetadata = { + name: 'retired-configschema-pin', + version: '1.0.0', + // @ts-expect-error — `configSchema` was retired under ADR-0049 + // (#11982): the kernel never received a config to validate it + // against. Parse plugin config at the plugin's own seam instead. + configSchema: { parse: (v: unknown) => v }, + async init() {}, + }; + // The value exists at runtime (TS types are erased); the pin is the + // directive above, enforced by this package's test-typecheck program. + expect(declared.name).toBe('retired-configschema-pin'); + }); + + it('compile-time: a declared hotReloadable no longer type-checks (#12587)', () => { + const declared: PluginMetadata = { + name: 'retired-hotreloadable-pin', + version: '1.0.0', + // @ts-expect-error — `hotReloadable` was retired under ADR-0049 + // (#12587): nothing ever read it. Reload participation is governed + // solely by `HotReloadManager.registerReloadConfig`. + hotReloadable: false, + async init() {}, + }; + expect(declared.name).toBe('retired-hotreloadable-pin'); + }); + + it('positive control: live sibling fields still type-check with no directive', () => { + const control: PluginMetadata = { + name: 'live-sibling-control', + version: '1.0.0', + startupTimeout: 1000, + async init() {}, + }; + expect(control.startupTimeout).toBe(1000); + }); +}); From 67da0f096a72747e121c5c670319bde02a211738 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 09:44:08 +0000 Subject: [PATCH 4/4] docs: align the hand-written plugin docs with the ADR-0049 retirements (#11982, #12587) Docs-drift pass over the six pages the drift bot anchored to this diff: - plugins/anatomy.mdx: the Plugin-class example carried the retired configSchema field with a comment claiming the loader validates it; the block now shows constructor-owned config and names the retirement - plugins/index.mdx: the Configuration Validation bullet and the configSchema half of the securePlugin example removed (signature kept - live surface); replacement paragraph states the plugin-owned self-parse pattern - protocol/kernel/index.mdx: the Configuration Management snippet and the fail-fast callout no longer document the retired field as merely 'postponed' - both now state the retirement and the self-parse seam No change, with the reason measured per page: automation/flows.mdx names the ADR-0018 node-executor configSchema (different surface); getting-started/quick-reference.mdx names spec's plugin-validator.zod.ts PluginMetadata (locally-declared homonym, live); protocol/kernel/plugin-spec.mdx teaches the surviving self-parse pattern - its phantom manifest file-map row predates this diff and is filed as #12690. content/docs/releases/v17.mdx is release-owned and untouched; its four configSchema mentions are all the ADR-0018 / driver surfaces, none the kernel field. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LZbWd2jNV1FErXTPSS4Dry --- content/docs/plugins/anatomy.mdx | 13 ++++++------- content/docs/plugins/index.mdx | 15 +++++++-------- content/docs/protocol/kernel/index.mdx | 25 ++++++++++--------------- 3 files changed, 23 insertions(+), 30 deletions(-) diff --git a/content/docs/plugins/anatomy.mdx b/content/docs/plugins/anatomy.mdx index af4f0da0ee..5dd05e46c0 100644 --- a/content/docs/plugins/anatomy.mdx +++ b/content/docs/plugins/anatomy.mdx @@ -9,7 +9,6 @@ Plugins are the building blocks of ObjectStack. A plugin is a plain JavaScript/T ```typescript import type { Plugin, PluginContext } from '@objectstack/core'; -import { z } from 'zod'; export class MyPlugin implements Plugin { // Identity @@ -21,12 +20,12 @@ export class MyPlugin implements Plugin { // This controls init ordering — it is NOT an npm-style version map. dependencies = ['com.objectstack.engine.objectql']; - // Configuration Schema (Optional) - // Read by the plugin loader to validate config; it lives on the plugin - // metadata rather than the base `Plugin` interface. - configSchema = z.object({ - apiKey: z.string() - }); + // Configuration (Optional) + // A plugin owns its config: take it as a constructor/factory argument and + // parse it yourself (with a Zod schema, say) before use. The kernel never + // receives plugin config — the old `configSchema` metadata field was + // retired under ADR-0049 because nothing could ever run it. + constructor(private readonly options: { apiKey?: string } = {}) {} /** * Init Phase (REQUIRED) diff --git a/content/docs/plugins/index.mdx b/content/docs/plugins/index.mdx index ee038ae916..9fd7aaa43e 100644 --- a/content/docs/plugins/index.mdx +++ b/content/docs/plugins/index.mdx @@ -284,22 +284,21 @@ const dbPlugin: PluginMetadata = { ObjectStack supports plugin security features: -- **Configuration Validation**: Plugins can define a Zod `configSchema` for runtime validation - **Signature Verification**: Cryptographic signatures for plugin integrity - **Permission Enforcement**: Fine-grained access control for plugin operations -```typescript -import { z } from 'zod'; +Plugin configuration is the plugin's own concern: take it as a constructor or +factory argument and parse it at your own seam (a Zod `schema.parse` in the +factory, constructor or `init`). The kernel-side `configSchema` metadata field +was retired under ADR-0049 — the kernel never received a plugin's config, so +the field could not validate anything. +```typescript const securePlugin: PluginMetadata = { name: 'com.example.secure', version: '1.0.0', - configSchema: z.object({ - apiKey: z.string().min(1), - region: z.enum(['us', 'eu', 'ap']), - }), signature: 'ed25519:key-1:', - + async init(ctx) { /* ... */ }, }; ``` diff --git a/content/docs/protocol/kernel/index.mdx b/content/docs/protocol/kernel/index.mdx index eb504c540c..79b1e6ccec 100644 --- a/content/docs/protocol/kernel/index.mdx +++ b/content/docs/protocol/kernel/index.mdx @@ -403,20 +403,14 @@ packages/plugins/plugin-slack-integration/ ### Configuration Management ```typescript -// A plugin object may carry a Zod `configSchema` describing its settings. -// NOTE: the kernel RECORDS the schema but does not enforce it yet — `use()` -// takes no config argument, so the loader has nothing to parse and logs -// "config validation postponed" instead of running the schema. Treat -// `configSchema` as a declaration of shape, and validate values you actually -// depend on yourself. +// A plugin owns its config: `use()` takes no config argument and the kernel +// never receives one, so validate the values you depend on at your own seam +// (a Zod `schema.parse` in the plugin's factory, constructor or `init`). +// The old `configSchema` metadata field was retired under ADR-0049 — the +// loader only ever logged "config validation postponed" and returned. export const slackPlugin: Plugin = { name: 'slack-integration', version: '0.1.0', - configSchema: z.object({ - apiKey: z.string().describe('Slack API Key'), - channel: z.string().default('#general'), - enabled: z.boolean().default(true), - }), async init(ctx) { // Runtime-resolved values come from the `settings` service, not from a // `config` object on the context — there is no `ctx.config`. @@ -487,10 +481,11 @@ const message = i18n.t('slack.button.send', context.locale); **Example:** Wiring, not config, is what the kernel currently enforces at boot. A plugin that declares a dependency the kernel never received fails `bootstrap()` outright — `[Kernel] Dependency 'com.objectstack.engine.objectql' not found for plugin 'com.objectstack.audit'` — and a dependency cycle throws `[Kernel] Circular dependency detected: `. Boot stops there instead of a request failing later. - Plugin `configSchema` is **not** part of this fail-fast path yet. The loader - stores the schema and postpones the check, so a missing or malformed value in - a plugin's config will not stop boot today. Validate config you depend on in - your own `init`. + Plugin config is **not** part of this fail-fast path. The old `configSchema` + metadata field was retired under ADR-0049 — the loader never enforced it — + so a missing or malformed value in a plugin's config will not stop boot. + Parse config you depend on yourself, in your plugin's factory, constructor + or `init`. ## Comparison: Kernel vs Alternatives