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
60 changes: 60 additions & 0 deletions .changeset/dual-kernel-duplicate-plugin-registration.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
---
"@objectstack/core": minor
---

fix(core): both kernels agree that a duplicate plugin registration OVERWRITES, and say so out loud (#9864)

Registering two plugins under the same `name` used to mean two different things
depending on which kernel was running:

| kernel | behaviour before |
|---|---|
| `ObjectKernel` (what `os serve` runs) | accepted and overwrote, with **no check and no distinguishing log line** — `Plugin registered: <name>@<version>` printed twice, reading as two plugins running |
| `LiteKernel` (tests, serverless, edge) | threw `[Kernel] Plugin '<name>' already registered` |

Under the maintainer's ruling (2026-08-19, option B) both kernels now apply one
declared contract: **duplicate registration by `name` overwrites — last-one-wins
— and emits a `warn` naming the plugin and both versions.**

```
WARN Plugin superseded: 'com.objectstack.audit' — the later registration (v2.0.0)
REPLACED the earlier one (v1.0.0). Only the later instance is initialized and
started; the earlier one is discarded without ever running init(). Duplicate
registration by name is last-one-wins on both kernels by declared contract
(#9864) — register the plugin once if that is not what you meant.
```

**This declares and warns about behaviour that already shipped; it does not fix a
user-visible bug.** The overwrite is load-bearing today — it is exactly what lets
a stack's own `plugins` entry supersede a plugin the CLI auto-registered earlier
in the same boot (`AuditPlugin`, #9863) — and every boot path that worked before
works the same way now. What changes is that the behaviour is declared, audible,
and pinned against **both** kernels
(`packages/core/src/plugin-registration.contract.test.ts`) rather than being an
accident of whichever kernel a reader happened to open. This was the fourth
measured instance of one contract implemented twice across the two kernels
(#5170, #5282, #8357 adjacent).

**What this changes for a caller**

- `LiteKernel.use()` no longer throws on a duplicate name. FROM: catch
`[Kernel] Plugin '<name>' already registered` to detect a double registration.
TO: there is no throw to catch — a duplicate is a `warn` and the later instance
wins. Code that registered a plugin twice and relied on the refusal should
register it once instead.
- `ObjectKernel` emits one `warn` where it previously emitted nothing, and
**suppresses** its `Plugin registered:` line for the superseding registration,
so the count of those lines equals the number of plugins that actually boot.
- The level is part of the contract: `warn`, never `info`. The CLI's default
kernel level is `warn`, and its boot-quiet window replays `warn` while
discarding in-window `info` — an `info` notice would be invisible on exactly
the boot path where this was measured.

**Measured, not assumed:** the displaced instance holds nothing that needs
teardown. Registration is legal only while the kernel is `idle`, so a supersede
can only ever displace a plugin that has never been initialized; `init()`,
`start()` and `destroy()` all run later, over a registry the displaced entry has
already left. `PluginLoader.loadPlugin()` — which `ObjectKernel` runs first — is
pure validation plus a name-keyed map write of its own, and invokes nothing on
the plugin. Calling `destroy()` on the displaced instance would be the bug, not
the fix: it is the paired teardown for an `init()` that never ran.
32 changes: 32 additions & 0 deletions packages/cli/src/commands/serve.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2491,6 +2491,32 @@ export default class Serve extends Command {
}

// Pair: AuditPlugin — optional
//
// [#9863 / #9864] Registered with NO options, so record-view
// auditing (`readAudit`) is off on this path. The one way an app
// turns it on today is to put its own
// `new AuditPlugin({ readAudit: … })` in the stack's `plugins`
// array, which this file registers further down — AFTER this line.
// Both instances carry the name `com.objectstack.audit`, so the
// app's supersedes this one and the opt-in takes effect.
//
// That is a DECLARED contract now, not the accident #9863 found it
// as: duplicate registration by name overwrites — last-one-wins,
// with a `warn` naming both versions — identically on both kernels,
// stated in `packages/core/src/plugin-registration.ts` and pinned
// against `ObjectKernel` AND `LiteKernel` by
// `packages/core/src/plugin-registration.contract.test.ts` (#9864,
// maintainer ruling 2026-08-19, option B). Before that ruling the
// behaviour was undeclared, untested and order-dependent, and
// `LiteKernel.use()` threw on the very same input.
//
// ⚠️ The dependency is on the ORDER as much as on the overwrite:
// this registration must stay ABOVE the stack's `plugins` loop, or
// the CLI's option-less instance would supersede the app's
// configured one instead. #9863 remains open on its own question —
// whether `os serve` should grow an `appAuditPluginOptions(config)`
// helper like its `SecurityPlugin` sibling above, rather than
// reaching the capability only through a supersede.
try {
const auditPkg = '@objectstack/plugin-audit';
const { AuditPlugin } = await import(/* webpackIgnore: true */ auditPkg);
Expand DownExpand Up@@ -2535,6 +2561,12 @@ export default class Serve extends Command {
}
}

// [#9863 / #9864] The superseding half of the pair documented at
// the `AuditPlugin` auto-registration above: a stack plugin whose
// `name` matches one auto-registered earlier REPLACES it, by
// declared contract (`packages/core/src/plugin-registration.ts`),
// with a `warn` naming both versions. That is how an app supplies
// options to a plugin this CLI mounts without them.
await kernel.use(pluginToLoad);
const pluginName = plugin.name || plugin.constructor?.name || 'unnamed';
trackPlugin(pluginName);
Expand Down
32 changes: 25 additions & 7 deletions packages/core/src/kernel.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,7 @@ import {
describeInitOrderFault,
} from './plugin-order.js';
import { dispatchHookIsolating, dispatchHookPropagating } from './hook-dispatch.js';
import { registerPluginByName } from './plugin-registration.js';

/**
* Enhanced Kernel Configuration
Expand DownExpand Up@@ -178,6 +179,14 @@ export class ObjectKernel {

/**
* Register a plugin with enhanced validation
*
* Duplicate names OVERWRITE, with one `warn` naming both versions — the
* declared contract in `plugin-registration.ts`, applied identically by
* `LiteKernel.use()` (#9864, maintainer ruling 2026-08-19). The overwrite
* itself is unchanged: it is what lets an app config's `plugins` entry
* supersede a plugin the CLI auto-registered earlier in the same boot
* (#9863). What changes is that it is no longer silent, and no longer
* disagrees with the other kernel.
*/
async use(plugin: Plugin): Promise<this> {
if (this.state !== 'idle') {
Expand All@@ -186,18 +195,27 @@ export class ObjectKernel {

// Load plugin through enhanced loader
const result = await this.pluginLoader.loadPlugin(plugin);

if (!result.success || !result.plugin) {
throw new Error(`Failed to load plugin: ${plugin.name} - ${result.error?.message}`);
}

const pluginMeta = result.plugin;
this.plugins.set(pluginMeta.name, pluginMeta);

this.logger.info(`Plugin registered: ${pluginMeta.name}@${pluginMeta.version}`, {
plugin: pluginMeta.name,
version: pluginMeta.version,
});
const superseded = registerPluginByName(this.plugins, pluginMeta, this.logger);

// [#9864] Suppressed for a superseding registration, deliberately. The
// defect the ruling names is that this line printed TWICE for one
// surviving plugin and so read as two plugins running; the `warn`
// `registerPluginByName` just emitted says everything this line would
// and says which instance survived. Suppressing it here makes the
// count of `Plugin registered:` lines in a boot log equal the number
// of plugins that will actually boot.
if (superseded === undefined) {
this.logger.info(`Plugin registered: ${pluginMeta.name}@${pluginMeta.version}`, {
plugin: pluginMeta.name,
version: pluginMeta.version,
});
}

return this;
}
Expand Down
18 changes: 13 additions & 5 deletions packages/core/src/lite-kernel.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,7 @@ import { Plugin } from './types.js';
import { createLogger, ObjectLogger } from './logger.js';
import type { LoggerConfig } from '@objectstack/spec/system';
import { ObjectKernelBase } from './kernel-base.js';
import { registerPluginByName } from './plugin-registration.js';

/**
* ObjectKernel - MiniKernel Architecture
Expand DownExpand Up@@ -32,16 +33,23 @@ export class LiteKernel extends ObjectKernelBase {
/**
* Register a plugin
* @param plugin - Plugin instance
*
* Duplicate names OVERWRITE, with one `warn` naming both versions — the
* declared contract in `plugin-registration.ts`, applied identically by
* `ObjectKernel.use()` (#9864, maintainer ruling 2026-08-19).
*
* This method used to `throw` `[Kernel] Plugin '<name>' already
* registered` here while `ObjectKernel` overwrote silently, so one input
* had two meanings depending on which kernel was running — and the kernel
* that runs in production was the silent one. The ruling converged them on
* the behaviour that already works (an app config superseding a plugin the
* CLI auto-registered, #9863) and made it audible rather than removing it.
*/
use(plugin: Plugin): this {
this.validateIdle();

const pluginName = plugin.name;
if (this.plugins.has(pluginName)) {
throw new Error(`[Kernel] Plugin '${pluginName}' already registered`);
}
registerPluginByName(this.plugins, plugin, this.logger);

this.plugins.set(pluginName, plugin);
return this;
}

Expand Down
Loading
Loading