Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .changeset/retire-plugin-metadata-inert-fields.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
---
"@objectstack/core": minor
---

feat(core): retire the inert `PluginMetadata` surfaces — `configSchema` with `PluginConfigValidator`, and `hotReloadable` (#11982, #12587, ADR-0049)

<!-- adr-0087: not-required (runtime-interface-only packages/core/src/plugin-loader.ts#PluginMetadata) PluginMetadata is a runtime TS interface in packages/core with no Zod schema, no spec declaration and no stored representation; no metadata surface references it (the PluginMetadata in packages/spec/src/kernel/plugin-validator.zod.ts is an unrelated locally-declared homonym). The deleted PluginConfigValidator / createPluginConfigValidator were runtime classes in the same non-metadata module family, so `objectstack migrate meta` has nothing to rewrite; the compiler is the notification channel — TS2353 on the removed fields, TS2305 on the removed exports. -->

**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.
13 changes: 6 additions & 7 deletions content/docs/plugins/anatomy.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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)
Expand Down
15 changes: 7 additions & 8 deletions content/docs/plugins/index.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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:<base64url-signature>',

async init(ctx) { /* ... */ },
};
```
Expand Down
25 changes: 10 additions & 15 deletions content/docs/protocol/kernel/index.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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`.
Expand DownExpand Up@@ -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: <plugin>`. Boot stops there instead of a request failing later.

<Callout type="warn">
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`.
</Callout>

## Comparison: Kernel vs Alternatives
Expand Down
21 changes: 15 additions & 6 deletions docs/adr/0025-plugin-package-distribution.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,11 +57,11 @@ 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),
`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).

Expand DownExpand Up@@ -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/... */ }
}
Expand DownExpand Up@@ -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`).
Expand DownExpand Up@@ -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
Expand Down
26 changes: 0 additions & 26 deletions packages/core/ADVANCED_FEATURES.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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`:
Expand DownExpand Up@@ -367,11 +343,9 @@ 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<PluginHealthStatus>` - Health check function
- `startupTimeout?: number` - Startup timeout in milliseconds
- `hotReloadable?: boolean` - Whether plugin supports hot reload

## Examples

Expand Down
36 changes: 36 additions & 0 deletions packages/core/src/plugin-loader.retired-fields.pin.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
//
// 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.
//
// 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 * as securityBarrel from './security/index.js';

describe('PluginConfigValidator retirement (ADR-0049, ADR-0025 §3.7)', () => {
it('no longer publishes PluginConfigValidator from the security barrel (#11982)', () => {
expect((securityBarrel as Record<string, unknown>).PluginConfigValidator).toBeUndefined();
expect((securityBarrel as Record<string, unknown>).createPluginConfigValidator).toBeUndefined();
expect(Object.keys(securityBarrel)).not.toContain('PluginConfigValidator');
expect(Object.keys(securityBarrel)).not.toContain('createPluginConfigValidator');
});

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');
});
});
47 changes: 15 additions & 32 deletions packages/core/src/plugin-loader.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';

/**
Expand DownExpand Up@@ -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;

Expand All@@ -53,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`.
}

/**
Expand DownExpand Up@@ -106,7 +112,6 @@ export interface VersionCompatibility {
export class PluginLoader {
private logger: Logger;
private context?: PluginContext;
private configValidator: PluginConfigValidator;
private loadedPlugins: Map<string, PluginMetadata> = new Map();
private serviceFactories: Map<string, ServiceRegistration> = new Map();
private serviceInstances: Map<string, any> = new Map();
Expand All@@ -115,7 +120,6 @@ export class PluginLoader {

constructor(logger: Logger) {
this.logger = logger;
this.configValidator = new PluginConfigValidator(logger);
}

/**
Expand DownExpand Up@@ -153,11 +157,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);
Expand DownExpand Up@@ -403,22 +402,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<void> {
if (!plugin.signature) {
return;
Expand Down
10 changes: 5 additions & 5 deletions packages/core/src/security/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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,
Expand Down
Loading
Loading