Uh oh!
There was an error while loading. Please reload this page.
fix(core): both kernels agree a duplicate plugin registration supersedes, and say so out loud - #10094
Conversation
… duplicate plugin registration Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HM
…t, add changeset Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HM
📓 Docs Drift CheckThis PR changes 2 package(s): 27 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 39 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 1b3d64fd7b3499db434c49374087f37a4531dc0c && git checkout 1b3d64fd7b3499db434c49374087f37a4531dc0c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin eb2bebe532aad5fdc99007923520df6d9d1dd9c9 95c7ebf45516594532b5f53a204167f13da4f39e && git checkout -B drift-repro eb2bebe532aad5fdc99007923520df6d9d1dd9c9 && git merge --no-ff 95c7ebf45516594532b5f53a204167f13da4f39e
node scripts/docs-audit/affected-docs.mjs --json eb2bebe532aad5fdc99007923520df6d9d1dd9c9
|
os-elon
commented
Aug 20, 2026
PM review — ACCEPT, enqueueing
Two things done better than the order specified1. It did not just make the superseding line legible — it removed the misleading one. The order asked for a warning that cannot be confused with a first registration. The dev additionally suppresses 2. ⭐ The The four axes of non-confusability are each pinned: different verb ( The question the order said must be answered, not assumed
Answered NO, with evidence. So today's overwrite is not also a leak, and the card does not silently grow a teardown obligation. #9863 discharged rather than left ridingThe depending site in Verified independently
Reverse verification is the right shape: reverting only the two kernels via ⭐ The dual-kernel pin uses The ratchet, and what it exposes about |
Uh oh!
There was an error while loading. Please reload this page.
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 32324497403 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 32324797958 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 32325403007 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Fixes#9864
Under the maintainer's ruling of 2026-08-19 (option B, recorded on the card):
ObjectKernelandLiteKernelnow agree that a duplicate plugin registration bynameoverwrites, and say so with an unmistakable warning. The ruling departs from the triage seat's recommendation (A: refuse loudly + an explicit supersede API) deliberately, and this PR implements B only — no new registration API, no config surface.The split this ends
ObjectKernel— the kernelos serverunsPlugin registered:followed by the plugin name, an at-sign and the version printed twice, and read as two plugins runningLiteKernel— tests, serverless, edgePlugin 'NAME' already registered(Both message shapes are written out in words above rather than with their real angle-bracket placeholders — GitHub's body sanitizer eats an angle-bracket fragment on write, and it ate exactly these two on this PR's first revision.)
One input, two meanings, and the production meaning was the silent one. This was the fourth measured instance of one contract implemented twice across the two kernels (#5170, #5282, #8357 adjacent), each found by a human reading both files side by side.
The overwrite itself is load-bearing and preserved: it is what lets a stack's own
pluginsentry supersede a plugin the CLI auto-registered earlier in the same boot (#9863'sAuditPlugin). Every boot path that worked before works identically now; only its observability changes, which is why B needed no host census — the risk triage flagged against A ("A needs that census first or it turns quiet boots into hard failures") is not present here.What landed
packages/core/src/plugin-registration.ts(new) — the contract, stated once, imported by both kernels.ObjectKernelstill does not extendObjectKernelBase(#5282), so a shared module is the sharing mechanism available, and it is the oneplugin-order.tsandhook-dispatch.tsalready use. Deliberately not exported from the package barrel: this declares existing behaviour, it does not mint a public supersede API.The warning, verbatim:
Why it cannot be confused with a first registration — four properties, each pinned by the test:
Plugin superseded:, notPlugin registered:.warn, neverinfo. Not decoration on theos servepath: the CLI's default kernel level iswarn(DEFAULT_LOG_LEVEL,packages/cli/src/utils/log-level.ts), at whichPlugin registered:is not emitted at all, and the boot-quiet window (BootLogCapture,BOOT_DIAGNOSTIC_FLOOR = 'warn') discards in-windowinfowhile replayingwarn. Aninfo-level notice would be invisible on exactly the boot path where the defect was measured. It iswarnand noterrorby AGENTS.md's degradation rule: nothing that claims to persist fails to land, and the composition the host asked for is the one that boots.ObjectKerneladditionally suppresses itsPlugin registered:line for a superseding registration, so the count of those lines equals the number of plugins that will actually boot. A boot log now reads:packages/core/src/plugin-registration.contract.test.ts(new) — the dual-kernel pin, and the deliverable that shrinks the seam. Six cases written once and executed against both kernels through a thin adapter (describe.each), plus twoObjectKernel-only cases for the surfaceLiteKernelgenuinely does not have (it emits no registration line and owns one map, so writing those into the shared table would assert0 === 0there — coverage it does not have). A fifth divergence now has to reproduce the bug in a case that already exists.The teardown question, answered
The card put this in scope to answer, not assume: the displaced plugin has already been through
pluginLoader.loadPlugin(), so if registration acquired anything on its behalf, today's overwrite would also be a leak.It does not, and no teardown is owed. Registration is legal only while the kernel is
idle(validateIdle()/ObjectKernel.use()'s own state check), so a supersede can only ever displace a plugin the kernel has never initialized;init(),start()anddestroy()all run frombootstrap()/destroy()over the resolved order read out of the registry the displaced entry has already left.loadPlugin()itself is pure validation plus a name-keyed map write of its own — so it drops the displaced metadata for the same reason rather than accumulating it — and invokes nothing on the plugin. Callingdestroy()on the displaced instance would be the bug, not the fix: it is the paired teardown for aninit()that never ran.Anything the displaced instance holds was acquired by the caller's own
new, beforeuse()was reached. Surveyed all 52 in-treeimplements Pluginclasses: 41 have a constructor body and every one normalizes options or builds in-memory helpers — the closest to a resource isHonoServerPlugin'snew HonoHttpServer(), whose constructor only doesthis.app = new Hono()and whose socket opens atkernel:listening.AuditPlugin— the live #9863 case — isconstructor(private readonly options = {}) {}, and its one resource (readAuditWriter, held sodestroy()can flush) is created ininit().The contract test pins the structural half: across a full bootstrap and shutdown, the displaced instance's
init,startanddestroyare never called, while the survivor's all are.#9863 discharged, not left riding
The card's own ⛔ clause was that #9863 "currently depends on the overwrite behaviour without saying so". B keeps the behaviour, so #9863 is not broken — but that was never the complaint.
packages/cli/src/commands/serve.tsnow names the dependency at both ends of the pair: at theAuditPluginauto-registration (the superseded half) and at the stackpluginsloop (the superseding half), each pointing atplugin-registration.tsand the dual-kernel pin, and calling out that the order is load-bearing too — the auto-registration must stay above thepluginsloop or the option-less instance would win. #9863 itself is left open on its own question (whetheros serveshould grow anappAuditPluginOptions(config)helper like itsSecurityPluginsibling) with a comment recording that its dependency is now declared.Verification
Reverse verification, from the committed fix (
e95a7e87c), reverting only the two kernels withgit restore --source=origin/main:The direction is informative rather than uniform: the 4 that still passed are exactly the properties
ObjectKernelalready had (overwrite, last-one-wins, no teardown, loader map), so the file is not red for a trivial reason. No rebuild was needed for this ablation — the test resolves both kernels by relative path (./kernel.js,./lite-kernel.js) inside the same package, not through the package'sexportsintodist/.All results below are from
c6d4381e0, the head commit of this PR.pnpm --filter @objectstack/core testTest Files 36 passed (36) · Tests 869 passed (869)pnpm --filter @objectstack/runtime test(largest core consumer)Test Files 177 passed (177) · Tests 2649 passed (2649)pnpm --filter @objectstack/cli typecheckTS2307-and-consequences that have nothing to do with this diff)pnpm check:type-check-debt(the ratchet)33 ledger entr(ies) re-measured … none above its recorded number. surplus: nonepackages/corerawtsc --noEmitcountpnpm check:kernel-hook-pairs✓ 4 dispatched kernel:* hook(s), each pinned in both kernel.test.ts and lite-kernel.test.tspnpm check:type-check-coverageOK — 64/77 workspace packages type-checkedpnpm check:nul-bytesOK (scanned 6370 text file(s) … no raw ASCII control bytes)pnpm check:engine-double-contractOK — 325 pinned, 133 in the DEBT ledger, 2 exemptcheck:test-source-alias·check:cross-package-test-inputs·check:slot-lookup·check:route-envelope·check:where-matcher·check:query-options-erasure·check:changeset-gate-self-tests·check:objectui-changeset·check-adr-0087-registration·check-changeset-no-major·check-empty-changeset·check-affected-docsThe ratchet is worth one note: the first draft of the contract test used extensionless relative imports (the convention the rest of
packages/core's tests follow) and added 4TS2835errors, taking core from 98 to 102.check:type-check-debtreportssurplus: none — every entry sits exactly at its measurement, so that would have been red. Repaired by writing the imports with explicit.jsextensions, as 1345 other test files in this repo do; no ledger number was touched.Gate set derived with
node scripts/pm/dispatch-gates.mjs(no paths passed — it reads the change set from the merge base itself), re-derived unchanged after the final commit.Scope
packages/core(both kernels + the new shared module and its pin), a comment-only change inpackages/cli/src/commands/serve.ts, and the changeset. No new public registration API, no config surface, nocontent/docs/releases/, no governed surface. In-flight check before the first edit: all 11 open PRs' file lists read — none touchespackages/core; the four sibling dev cards in flight declarepackages/metadata-protocol,packages/lint,packages/objectqlandpackages/plugins/plugin-audit.Generated by Claude Code