Skip to content

[Decision] The declared plugin contract does not govern what the runtime accepts: LiteKernel never runs PluginSchema, and isDefault survives the kernel that does #16721

Description

@os-trump

Measured out of #16599, whose own premise turned out to be false. Two questions, filed as one card because they share a root: PluginSchema does not govern what the runtime actually accepts. Filed by the domain:services PM seat; the subject is the plugin contract, so it is routed to domain:spec.

⚠️ The measurements below were taken by the #16599 dev and independently re-measured by the seat, each with controls. Line numbers move — ⭐ re-measure before acting.


Question ①: does LiteKernel enforce the plugin contract?

Measured on origin/main

reading value
lite-kernel.ts mentions PluginSchema / validatePluginContract / PluginLoader 0
positive control — same grep on kernel.ts (ObjectKernel) 3 ✅ the probe answers "yes"
negative control — PluginSchemaZZZ repo-wide 0 ✅ the probe answers "no"
LiteKernel barrel-exported yespackages/core/src/index.ts:20
extends ObjectKernelBase repo-wide 1lite-kernel.ts:24
AGENTS.md names LiteKernel yes (Kernel table: "Tests (vitest), serverless, edge (Workers)")

LiteKernel.use() calls registerPluginByName directly. #16334 wired PluginSchema into PluginLoader.validatePluginContract only, and PluginLoader is reached only from ObjectKernel.use().

The same plugin object is accepted by one published kernel and refused by the other. Demonstrated end-to-end by the #16599 dev: a type: 'ui' plugin with no slug is refused by ObjectKernel with PLUGIN_CONTRACT_VIOLATION … PLUGIN_UI_REQUIRED_KEY_MISSING, and is accepted by LiteKernel, where it mounts a route derived from the package name.

⚠️ Why this bites in the direction that hurts

AGENTS.md assigns LiteKernel to tests. So the lenient kernel is the one plugin authors develop against, and the strict one is production. ⇒ A plugin can be green in vitest and refused at boot — the failure lands after the work is done, on the person least able to see why.

Options

  • A — converge: LiteKernel validates too.core(finding): duplicate plugin registration throws on LiteKernel and silently overwrites on ObjectKernel — a fourth instance of the two-kernel semantic split #9864's precedent is that the two kernels converge, not diverge (it already converged them on duplicate-registration semantics). Cost: real and wide — every LiteKernel-based test in this repo starts being schema-checked, and some will fail. That failure set is unmeasured today.
  • B — declare the divergence. LiteKernel stays unvalidated by design; say so in its own docblock and in the AGENTS.md Kernel table, so "tests pass" stops implying "boots". Cost: the trap remains, now written down.
  • C — validate in ObjectKernelBase, so both kernels inherit it and the question cannot recur on a third kernel. Cost: the largest blast radius, and it presumes the base is the right home.
  • ① 项目长远合理性(权重 ≥50%) —— 一个声明了却不被执行的契约,是本仓反复付学费的形状。两个已发布 kernel 对同一个对象给出不同答案,意味着「插件契约」这个词今天没有单一所指。A/C 缩小特例;B 把特例写进文档、永久保留。⇒ 指向 A,而 C 是它的更彻底版本。⛔ 但 C 预设了 base 是正确的归宿,而这一点没测。
  • ② 实际业务拉动 —— ⚠️ 具名但间接:今天仓内 type: 'ui' 的生产者是 0,所以这块自动发现只服务外部作者的插件。⇒ 拉动不是内部的,是「外部插件作者在测试里绿、在生产里崩」。⛔ 无法从本仓测量它已经发生过几次。这是本卡最大的置信缺口
  • ③ 防 AI 犯错 —— 现状最坏:宽松的那个 kernel 正是文档指定用来跑测试的那个,于是「测试通过」这个信号本身是误导的。A/C 让失败提前到作者手上;B 只让失败可解释,不让它提前。⇒ 指向 A/C
  • ④ 创业阶段不扩散 —— B 零改动;A 动一个 kernel 但会惊动仓内每一个 LiteKernel 测试(失败集未测);C 更宽。⇒ ④ 是唯一指向 B 的一棱,而且它的理由是实的:A 的代价现在不知道有多大

推荐:A,但先测代价。 具体次序:先派一张只做测量的卡 —— 在本地给 LiteKernel.use() 临时接上 PluginSchema,跑全仓,数出有多少测试会红、红在哪些键上,⛔ 停在测量、不落地。那个数字出来之后 A 与 B 才是可比的。⛔ 本席不替你在 A/B/C 之间定,因为 ④ 的成本项现在是空的。

回退:B —— 若测出来的失败集很大,B 是诚实的暂缓,且不阻止以后再收敛。


Question ②: isDefault is an undeclared alias that works on the validated path

Measured

PluginSchema is a plain z.object with no .strict(), and PluginLoader.toPluginMetadata is a cast, not a copy. ⇒ isDefault: true passes through ObjectKernel.use() verbatim (stored.isDefault === true, stored.default === undefined) and drives the / redirect — the #16599 dev measured routes coming back as ['/','/dflt','/dflt','/dflt/*','/dflt/*'].

An author-writable key that works today, on the fully validated path, which the declared contract will never validate and never reject. The consumer reads plugin.default || plugin.isDefault.

Options

  • A — declare it: add isDefault to PluginSchema as a deprecated alias with an ADR-0087 conversion entry, so the contract describes what the runtime already honours.
  • B — retire it under ADR-0049 enforce-or-remove: drop the || plugin.isDefault read, add a tombstone so an author writing isDefault gets a named refusal with the prescription instead of a silent non-redirect, and ship the breaking changeset with its FROM/TO.
  • —— 一个未声明但生效的别名,是「声明面窄于实际受理面」的教科书形状;两个选项都关掉它,B 关得更彻底(契约与运行时都只剩一个拼写)。指向 B
  • —— ⛔ 未测是否有人真的在写 isDefault(仓内 type: 'ui' 生产者为 0,外部不可测)。⇒ B 的破坏性代价落在一个规模未知的人群上。这是本问的置信缺口。
  • —— 现状:作者写 isDefault静默生效,写 default 也生效,两者都对 —— 直到某天有人按契约收紧。B 把「写错拼写」变成具名拒绝并给出处方,这正是本仓 ADR-0049 的姿态。指向 B
  • —— A 只加一行 schema + 一条转换条目;B 要 tombstone + breaking changeset + FROM/TO。A 更省,但它把一个别名永久写进契约。

推荐:B(ADR-0049 enforce-or-remove,且修复应在生产者/契约侧而非消费者的 || 里)。⛔ 但这是公共契约的取舍,不是席位能定的。

⚠️ 两问必须分两个 PR:①/② 的落地一个不改行为(若选 B-declare 类)、一个是 breaking,⛔ 不得同 PR。


Refs

#16599(本卡的来源;其前提被证伪,已改判为订正散文 + 补 pin)· #16334(把 PluginSchema 接进 PluginLoader 的那次)· #16363 · #9864(两个 kernel 应当收敛的先例)· ADR-0049(enforce-or-remove)· ADR-0087(转换条目)· #15638(⚠️ 其主题同样只死了一半 —— 在 ObjectKernel 上被拒,在 LiteKernel 上仍活)

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions