Skip to content

Commit a38408a

Browse files
Elon Muskclaude
andauthored
fix(core): both kernels agree a duplicate plugin registration supersedes, and say so out loud (#10094)
* fix(core): converge both kernels on overwrite-with-a-loud-warning for duplicate plugin registration Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HM * docs(cli),chore: point #9863's depending site at the declared contract, add changeset Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HM --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6f5a449 commit a38408a

6 files changed

Lines changed: 616 additions & 12 deletions

File tree

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
---
2+
"@objectstack/core": minor
3+
---
4+
5+
fix(core): both kernels agree that a duplicate plugin registration OVERWRITES, and say so out loud (#9864)
6+
7+
Registering two plugins under the same `name` used to mean two different things
8+
depending on which kernel was running:
9+
10+
| kernel | behaviour before |
11+
|---|---|
12+
| `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 |
13+
| `LiteKernel` (tests, serverless, edge) | threw `[Kernel] Plugin '<name>' already registered` |
14+
15+
Under the maintainer's ruling (2026-08-19, option B) both kernels now apply one
16+
declared contract: **duplicate registration by `name` overwrites — last-one-wins
17+
— and emits a `warn` naming the plugin and both versions.**
18+
19+
```
20+
WARN Plugin superseded: 'com.objectstack.audit' — the later registration (v2.0.0)
21+
REPLACED the earlier one (v1.0.0). Only the later instance is initialized and
22+
started; the earlier one is discarded without ever running init(). Duplicate
23+
registration by name is last-one-wins on both kernels by declared contract
24+
(#9864) — register the plugin once if that is not what you meant.
25+
```
26+
27+
**This declares and warns about behaviour that already shipped; it does not fix a
28+
user-visible bug.** The overwrite is load-bearing today — it is exactly what lets
29+
a stack's own `plugins` entry supersede a plugin the CLI auto-registered earlier
30+
in the same boot (`AuditPlugin`, #9863) — and every boot path that worked before
31+
works the same way now. What changes is that the behaviour is declared, audible,
32+
and pinned against **both** kernels
33+
(`packages/core/src/plugin-registration.contract.test.ts`) rather than being an
34+
accident of whichever kernel a reader happened to open. This was the fourth
35+
measured instance of one contract implemented twice across the two kernels
36+
(#5170, #5282, #8357 adjacent).
37+
38+
**What this changes for a caller**
39+
40+
- `LiteKernel.use()` no longer throws on a duplicate name. FROM: catch
41+
`[Kernel] Plugin '<name>' already registered` to detect a double registration.
42+
TO: there is no throw to catch — a duplicate is a `warn` and the later instance
43+
wins. Code that registered a plugin twice and relied on the refusal should
44+
register it once instead.
45+
- `ObjectKernel` emits one `warn` where it previously emitted nothing, and
46+
**suppresses** its `Plugin registered:` line for the superseding registration,
47+
so the count of those lines equals the number of plugins that actually boot.
48+
- The level is part of the contract: `warn`, never `info`. The CLI's default
49+
kernel level is `warn`, and its boot-quiet window replays `warn` while
50+
discarding in-window `info` — an `info` notice would be invisible on exactly
51+
the boot path where this was measured.
52+
53+
**Measured, not assumed:** the displaced instance holds nothing that needs
54+
teardown. Registration is legal only while the kernel is `idle`, so a supersede
55+
can only ever displace a plugin that has never been initialized; `init()`,
56+
`start()` and `destroy()` all run later, over a registry the displaced entry has
57+
already left. `PluginLoader.loadPlugin()` — which `ObjectKernel` runs first — is
58+
pure validation plus a name-keyed map write of its own, and invokes nothing on
59+
the plugin. Calling `destroy()` on the displaced instance would be the bug, not
60+
the fix: it is the paired teardown for an `init()` that never ran.

packages/cli/src/commands/serve.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2491,6 +2491,32 @@ export default class Serve extends Command {
24912491
}
24922492

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

2564+
// [#9863 / #9864] The superseding half of the pair documented at
2565+
// the `AuditPlugin` auto-registration above: a stack plugin whose
2566+
// `name` matches one auto-registered earlier REPLACES it, by
2567+
// declared contract (`packages/core/src/plugin-registration.ts`),
2568+
// with a `warn` naming both versions. That is how an app supplies
2569+
// options to a plugin this CLI mounts without them.
25382570
await kernel.use(pluginToLoad);
25392571
const pluginName = plugin.name || plugin.constructor?.name || 'unnamed';
25402572
trackPlugin(pluginName);

packages/core/src/kernel.ts

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
describeInitOrderFault,
1515
} from './plugin-order.js';
1616
import { dispatchHookIsolating, dispatchHookPropagating } from './hook-dispatch.js';
17+
import { registerPluginByName } from './plugin-registration.js';
1718

1819
/**
1920
* Enhanced Kernel Configuration
@@ -178,6 +179,14 @@ export class ObjectKernel {
178179

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

187196
// Load plugin through enhanced loader
188197
const result = await this.pluginLoader.loadPlugin(plugin);
189-
198+
190199
if (!result.success || !result.plugin) {
191200
throw new Error(`Failed to load plugin: ${plugin.name} - ${result.error?.message}`);
192201
}
193202

194203
const pluginMeta = result.plugin;
195-
this.plugins.set(pluginMeta.name, pluginMeta);
196-
197-
this.logger.info(`Plugin registered: ${pluginMeta.name}@${pluginMeta.version}`, {
198-
plugin: pluginMeta.name,
199-
version: pluginMeta.version,
200-
});
204+
const superseded = registerPluginByName(this.plugins, pluginMeta, this.logger);
205+
206+
// [#9864] Suppressed for a superseding registration, deliberately. The
207+
// defect the ruling names is that this line printed TWICE for one
208+
// surviving plugin and so read as two plugins running; the `warn`
209+
// `registerPluginByName` just emitted says everything this line would
210+
// and says which instance survived. Suppressing it here makes the
211+
// count of `Plugin registered:` lines in a boot log equal the number
212+
// of plugins that will actually boot.
213+
if (superseded === undefined) {
214+
this.logger.info(`Plugin registered: ${pluginMeta.name}@${pluginMeta.version}`, {
215+
plugin: pluginMeta.name,
216+
version: pluginMeta.version,
217+
});
218+
}
201219

202220
return this;
203221
}

packages/core/src/lite-kernel.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Plugin } from './types.js';
44
import { createLogger, ObjectLogger } from './logger.js';
55
import type { LoggerConfig } from '@objectstack/spec/system';
66
import { ObjectKernelBase } from './kernel-base.js';
7+
import { registerPluginByName } from './plugin-registration.js';
78

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

39-
const pluginName = plugin.name;
40-
if (this.plugins.has(pluginName)) {
41-
throw new Error(`[Kernel] Plugin '${pluginName}' already registered`);
42-
}
51+
registerPluginByName(this.plugins, plugin, this.logger);
4352

44-
this.plugins.set(pluginName, plugin);
4553
return this;
4654
}
4755

0 commit comments

Comments
 (0)