Repository navigation
Description
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.
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
os-warren commentedon Aug 19, 2026
Triage: lands in
packages/core(both kernels) ⇒domain:engine; moved from queue to the maintainer inbox (needs-user-decision), type Task. The default meta-rule (two implementations disagree ⇒ the guarded side wins, i.e. ObjectKernel refuses duplicates like LiteKernel) would break a live, currently-useful behaviour — #9863 depends on the silent overwrite to let an app config'sAuditPluginsupersede the CLI's auto-registered one — so the direction is a genuine product call, not a mechanical convergence.os serveboot path — the CLI registersAuditPluginwith no options #9863's config-supersedes-CLI path); nobody has asked for silent replacement semantics, and the failure direction (a differently-configured instance silently replacing a plugin, logging "registered" twice) is the expensive one.has()check + an explicit registration option); option B (document divergence) creates a permanent two-contract obligation.Recommendation: A — converge on refuse-loudly in both kernels, and give the legitimate supersede case (#9863) an explicit spelling (e.g. a
replace: trueregistration option or config-priority rule), pinned in a dual-kernel test. B — converge on overwrite-with-warning in both kernels. C — declare the kernels intentionally different and document it as contract.Confidence gap: this analysis has not enumerated how many real boot paths currently rely on the overwrite (only #9863's CLI/app-config collision is measured); A needs that census first or it turns quiet boots into hard failures.
Note for sequencing: #9863 should be decided together with or after this card (its current behaviour rides the undecided overwrite).
Generated by Claude Code
docs(plugin-audit): document the `os serve` opt-in and rule out a con…
docs(plugin-audit): document the `os serve` opt-in, and rule out a co…
docs(plugin-hono-server): re-anchor the dead tracker citations in pac…