Skip to content

core(finding): duplicate plugin registration throws on LiteKernel and silently overwrites on ObjectKernel — a fourth instance of the two-kernel semantic split #9864

Description

@os-steve

Observation-class finding, measured while tracing whether an app config can supply AuditPlugin options on the os serve path (#9540 / PR #9860). Filing because it is a new instance of an already-documented structural seam, not a new theory.

Measurement

Registering two plugins with the same name has two different meanings depending on which kernel is running.

packages/core/src/lite-kernel.ts:36-45 — refuses:

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

packages/core/src/kernel.ts:182-195 — accepts and overwrites, with no check and no log line distinguishing it from a first registration:

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

ObjectKernel is the one os serve runs (runtime.ts:56 constructs it; serve.ts:1467 takes it from runtime.getKernel()), so the production path is the silent-overwrite one. Note the earlier instance has already been through pluginLoader.loadPlugin() by then — it is loaded, then dropped from the map, and only the later one bootstraps.

Why it is worth a card

This is the same shape as the closed #5170 (kernel:ready hook errors fail boot on ObjectKernel, get swallowed into one error log on LiteKernel) and #5282 (ObjectKernel does not inherit ObjectKernelBase, so hook dispatch semantics are written twice). Both were about one contract with two implementations that disagree; this is a third contract in the same pair, found the same way — by reading both rather than by anything mechanical.

The user-visible consequence today is benign-to-useful: it is what lets an app config's AuditPlugin supersede the CLI's auto-registered one (#9863). But it is undeclared, untested, and order-dependent, so it is load-bearing behaviour nobody has agreed to. The failure direction is the one that costs: a plugin silently replaced by a differently-configured instance of itself boots clean and logs "Plugin registered" twice, which reads as two plugins.

⚠️ Note #8357 (closed) recorded a related shape one layer up — serve's marketplace arm silently replacing a host config's own plugins — so the "silent replacement" failure has been paid for once already at a different altitude.

Dispositions

  • Make the two agree, in whichever direction the maintainer picks (refuse, or overwrite-with-a-warning), and pin it in a test that runs against both kernels.
  • Decide the two kernels are allowed to differ here and document it — but then the difference belongs in a declared contract, not in whichever file a reader happens to open.

⛔ Whichever way it goes, #9863 should be resolved first or together: it currently depends on the overwrite behaviour without saying so.

Refs: #9540 / PR #9860 (where it was measured) · #9863 (the dependent finding) · #5170 · #5282 · #8357.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions