Skip to content

feat(core): LiteKernel.use() enforces the declared plugin contract, converged with ObjectKernel (#16721 step 2) - #16827

Merged
os-zhuang merged 3 commits into
mainfrom
claude/issue-16721-litekernel-validates-plugin-contract
Sep 8, 2026
Merged

feat(core): LiteKernel.use() enforces the declared plugin contract, converged with ObjectKernel (#16721 step 2)#16827
os-zhuang merged 3 commits into
mainfrom
claude/issue-16721-litekernel-validates-plugin-contract

Conversation

@claude

@claude claude Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Part of #16721 — question ① step 2 only. Question ② (isDefault) is fenced out of this PR and #16721 remains open.

Clause-②: yes

What this does

LiteKernel.use() now runs the same declared-plugin-contract check ObjectKernel.use() has run since the kernel.use() enforcement release — and it is now literally the same check. The measured divergence on #16721 was that PluginSchema was run by PluginLoader.validatePluginContract only, and PluginLoader is reached from ObjectKernel.use() alone; LiteKernel.use() wrote the object straight into its registry. So one plugin object got two answers from two published kernels, and the lenient one was the kernel AGENTS.md names for tests.

Dev session (durable attribution, in prose by design): https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x

Ruling executed: comment 5578286478 (option A — converge, two steps; maintainer 「同意」), step 1 measured and ACCEPTED in 5581457564. This is step 2, landing it.

Route taken: one shared helper, both kernels import it (PM's suggested route, confirmed by measurement)

  • New packages/core/src/plugin-contract.tsassertPluginContract(plugin): PluginSchema.safeParse for validation only, drop version issues, refuse on the first remaining issue with PLUGIN_CONTRACT_VIOLATION (code on the property AND at the head of the message). This is the exact body PluginLoader.validatePluginContract had; it moved, it did not change. ⛔ Not barrel-exported — the same mechanism and posture as plugin-registration.ts (the module both kernels import by relative path, because ObjectKernel does not extend ObjectKernelBase).
  • PluginLoader.validatePluginContract is now a one-line delegate to it; validatePluginStructure (name / init / semver) stays the loader's own. The convergence is on the schema, per the ruling; the loader-structure boundary step ①-1 kept is kept here too, and I found no reason to move it: LiteKernel has never judged name/init/version, and the ruling text is about PluginSchema.
  • LiteKernel.use(): validateIdle()assertPluginContract(plugin)registerPluginByName(...). Exactly the wiring that produced step ①-1's number.
  • dispatcher-error-vocabulary.ts: the PLUGIN_CONTRACT_VIOLATION row's file moves with the stamp site (assignconst resolves the constant in the file it is stamped from), and its why names both kernels. check:dispatcher-error-vocabulary green.

One refusal, same code, same message shape, from either kernel — pinned, not asserted: for one input, the ObjectKernel message is byte-for-byte Failed to load plugin: NAME - + the LiteKernel message. That prefix is the loader's existing wrapper for every load failure and is untouched. On LiteKernel (synchronous, no re-wrap) the code property survives too.

The three refused fixtures from step ①-1, and the split

  1. packages/cli/test/fixtures/option-b-reader-probe.tstype: 'service' as const deleted (one line, plus a comment saying why). Re-confirmed on this tree: it is the only type: 'service' on a plugin object repo-wide (the other hit, graft-runtime-hooks.test.ts, is an isAppPluginLike argument, not a registered plugin); nothing in the fixture reads .type; PluginSchema.type defaults to standard when absent; ObjectKernel already refused this object.
      1. ui-plugin-auto-discovery.pin.test.ts section F — rewritten, not deleted, and not "fixed into passing". F0 (the fully declared control) is unchanged and still mounts on LiteKernel — that is what proves the convergence is on the schema and the harness still mounts under this kernel. F1/F2 now pin the opposite of what they pinned: the same two inputs (no slug, no staticPath) are refused by LiteKernel.use() with the same envelope ObjectKernel.use() gives them — code, key, PLUGIN_UI_REQUIRED_KEY_MISSING, code on the property — and the byte parity above. Why rewrite rather than retire: the file's whole point is stating this block's reachability PER KERNEL; deleting F would leave the file saying nothing about the kernel the card was about, and re-pinning the old leniency by injecting into kernel.plugins directly would pin dead code against an input no kernel can produce. The header, bootLite's docblock, the B/D comments that said "⛔ NOT dead code … LIVE AND LOAD-BEARING on LiteKernel" and the C (#15638) narration were all rewritten because each asserted a fact this PR makes false; each now carries both readings (before / after) rather than the new one alone.

Tests added

packages/core/src/plugin-contract-enforcement.test.ts group G — the SAME contract on LiteKernel.use() (11 it/it.each blocks, 16 cases): the ui-plugin refusal with code on the property, its calibration twin, the two ui required-key refusals (the inputs #16721 was filed on), null on a declared key, untyped plugin loads with no type written back, class-based plugin keeps identity/prototype/methods, version exclusion holds (1.0.0-alpha.1, 1.0.0+20230101, 0.0.0-fixture, version-less), the cross-kernel byte-parity pin, and two ORDER pins — a refused plugin never reaches the registry (cannot supersede an earlier registration by name), and state is checked before the contract (post-bootstrap refusal is the idle one, no PLUGIN_CONTRACT_VIOLATION, no code).

Verification

Code readings at cb787324f (every source file of this PR is at that commit); the one later commit, 8443166a6, touches only content/docs/plugins/anatomy.mdx (below), and the gate union was re-derived and re-reconciled at 8443166a6: 88 derived, 88 run, 0 NOT-MEASURED, 0 UNRUN. The tree equals HEAD, git status --porcelain empty. Exit codes captured before any pipe; lock verdict lines quoted from scripts/pm/os-verify-lock.sh.

Builds (through the lock). pnpm --filter '@objectstack/core...' buildVERDICT command-exit 0 · held the lock 201s. Then the hono-server + cli closures: 58 packages built; the run's VERDICT command-exit 2 is packages/cli's own script refusing a forwarded --concurrency=2 (sh: Syntax error: word unexpected — my flag placement, the documented pnpm trap), not a compile error; cli's own dist/ is not an input to its vitest unit tier, so nothing owed was blocked. ⭐ packages/core was rebuilt before every dist-mediated run (cli resolves @objectstack/core through exports to dist/).

Tests (through the lock).

  • pnpm --filter @objectstack/core testcore-test-exit=0Test Files 49 passed (49) · Tests 1211 passed (1211); pnpm --filter @objectstack/core typecheckcore-typecheck-exit=0 (check:test-typecheck: OK).
  • plugin-contract-enforcement.test.ts alone, verbose reporter → Tests 34 passed (34), group G's 14 cases listed by name (file went from 18 to 29 it/it.each blocks).
  • ui-plugin-auto-discovery.pin.test.tsTests 17 passed | 1 todo (18); pnpm --filter @objectstack/plugin-hono-server typecheck → exit 0 (hono aliases @objectstack/core to src, so it read the source directly).
  • pnpm --filter @objectstack/cli exec vitest run --project unit test/option-b-reader-acceptance.pin.test.tsTests 7 passed (7) — the 7 tests step ①-1 saw skipped by the type: 'service' boot helper now run, on the rebuilt core dist; pnpm --filter @objectstack/cli typecheck → exit 0. The cli integration project is declared to CI (the diff touches no spawn entry).
  • packages/rest rest-api-plugin-objectql-provider-three-state.test.tsTests 8 passed (8) — the one LiteKernel-using test file that changed between the measurement commit 78bc4ad58 and this base (its drift is in a door handler, not a plugin object).
  • packages/runtime — the four tests that read the vocabulary rows → Test Files 4 passed (4) · Tests 70 passed (70).

Blast-radius drift since the measurement. 10 commits between 78bc4ad58 and base 30b099078; of the 156 tracked files mentioning LiteKernel, 5 changed (one rest test above, two rest product files, one skill README — prose — and one more prose file); none adds a plugin object. Step ①-1's 813 / 807 / 6 / 0 stands; the rest of the LiteKernel-consuming suites (30 packages) are declared to CI's test job.

Gates. Derived with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at cb787324f (60 families); reconciled with --ran: 60 derived, 60 run, 0 UNRUN. 57 exit 0. The remaining three: check:doc-authoring went red on my own edit (a tracker id inside the vocabulary row's runtime why string) and is green after removing it (exit 0, re-run); check:dual-build-cjs-loads and check:type-check-debt both exit 3PREREQUISITE NOT MET, they read every package's dist/ and a fresh worktree lacks 82 / the full closure — NOT MEASURED locally, declared to CI, not a pass and not a finding. Also green: check:dispatcher-error-vocabulary (the moved assignconst row resolves), check:nul-bytes, check:error-code-casing (outside the derivation, ran anyway), check:cross-package-test-inputs, check:test-source-alias, check:adr-0087-registration --base origin/main, check:changeset-no-major --base origin/main, check:empty-changeset --base origin/main.

Lint, narrowed and proven. pnpm exec eslint --no-inline-config --format json over the 8 touched files → exit 0, files linted: 8 errors: 0 warnings: 0 (count read from the JSON). Population per eslint.config.mjs (packages/**/*.{ts,tsx,mts,cts} + the root **/* block, NEVER_LINTED excluded). Invariance: the config states it runs no parserOptions.project and no typed @typescript-eslint rules (line 328), so this diff cannot move any untouched file's verdict — the repo-wide pnpm lint is CI's.

Ablation

Committed first (HEAD = the fix), mutation and restore both under the lock, trap … EXIT INT TERM restore, absolute paths, blob hashes — VERDICT command-exit 0 · held the lock 50s.

A first attempt was a no-op and is reported as such: the mutation deleted the call and the DTS build refused the now-unused import (TS6133), so its pins never ran against a mutated artifact; that failed tsup run also left core's dist/ truncated with the marker in 2 files, which the second run's rebuild replaced. The second attempt keeps the import referenced.

  • Mutation: replace assertPluginContract(plugin); in LiteKernel.use() with void assertPluginContract; const ablation16721 = 'ABLATION_16721_NO_CONTRACT'; …. On disk: anchor count 0, marker count 1, blob 6a368df0… (HEAD 38912c87…). pnpm --filter @objectstack/core build exit 0; node scripts/ablation-dist-preflight.mjs core 'ABLATION_16721_NO_CONTRACT' exit 0 (marker PRESENT in dist).
  • Readings under the mutation — direction: red, as predicted:
    • core plugin-contract-enforcement.test.ts: Tests 6 failed | 28 passed (34). The six: refuses the legacy ui-plugin type …, refuses a ui plugin with no staticPath …, … no slug …, refuses null on a declared key …, PARITY — …, ORDER — a refused plugin never reaches the registry …. Every calibration twin, the version-exclusion cases and ORDER — state is checked before the contract stayed green — so the six are attributable to the wiring and not to the harness.
    • hono ui-plugin-auto-discovery.pin.test.ts: Tests 2 failed | 15 passed | 1 todo (18) — F1 and F2 red with expected kernel.use() to refuse the plugin, but it loaded; F0 green.
    • cli option-b-reader-acceptance.pin.test.ts: Tests 7 passed (7) under the mutation too. ⚠️ Reported honestly: this file is not a pin on the wiring — deleting type: 'service' makes the fixture legal on both kernels, so it passes with and without the check. Its evidence is the reverse: before the fixture fix, ①-1 measured it red (7 skipped) under the same wiring.
  • Restore: git checkout HEAD -- packages/core/src/lite-kernel.ts (never bare); blob back to 38912c87… = HEAD; git diff HEAD --stat empty; git status --porcelain empty; anchor count 1, marker 0; rebuild exit 0; preflight --absentmarker absent from all 12 built files; core pins Tests 34 passed (34). Final marker-in-dist count 0.

Changeset

.changeset/litekernel-enforces-plugin-contract.md@objectstack/core: minor, BREAKING banner, adr-0087: not-required (no-migration-prescription). Level derived from scripts/check-changeset-no-major.mjs's header (an accept-set narrowing is breaking; during the launch window breaking ships as minor, breaking-ness carried by the banner + the ADR-0087 disposition) and from the precedent changeset of the ObjectKernel half (enforce-plugin-schema-at-kernel-use.md, same shape). The body states the narrowing plainly, enumerates all EIGHT keys (the loader comment's ⛔ rule), states what is still accepted, the ordering, the measured blast radius (813 / 807 / 6 / 0), and the migration.

Docs-drift rows (coordinator reading, answered per page)

The question per page: does it show or describe a plugin object LiteKernel now refuses, or state that LiteKernel does not validate? Read on this tree, page by page.

page what it shows verdict
content/docs/plugins/anatomy.mdx Its example plugin is class-based with name, version = '1.0.0', dependencies and no type (reads as standard) — accepted. Its UI-plugin bullets said staticPath "must" be provided but named slug only as the mount segment, and nothing on the page said kernel.use() validates. FIXED (commit 8443166a6): slug is now "must provide" beside staticPath, one bullet says a ui plugin missing either is refused at kernel.use() with PLUGIN_CONTRACT_VIOLATION on both kernels, and the lifecycle list gains a kernel.use() step stating the contract check (closed-set type, URL homepage, no null, the two ui keys) and that the object is validated, never replaced. Nothing else on the page moved.
content/docs/plugins/packages.mdx Lists LiteKernel among @objectstack/core's exports; every kernel.use() example registers an in-repo plugin class (ObjectQLPlugin, DriverPlugin, AuthPlugin, …) on ObjectKernel. still true — those classes are inside the measured 0-violation product set; no prose about validation. Untouched.
content/docs/kernel/events.mdx Boot-path hook propagation and kernel:shutdown fail-soft "on ObjectKernel and LiteKernel alike". still true — about hooks, not registration. Untouched.
content/docs/ai/actions-as-tools.mdx new LiteKernel(); kernel.use(new MCPServerPlugin({ … })). still trueMCPServerPlugin declares type = 'standard' (packages/mcp/src/plugin.ts), accepted by the contract. Untouched.
content/docs/ai/natural-language-queries.mdx The same LiteKernel + MCPServerPlugin example. still true, same reason. Untouched.
content/docs/ai/skills-reference.mdx "test harnesses via LiteKernel" inside a skill's one-line description. still true — a passing mention; LiteKernel is still the test kernel, now also strict. Untouched.
content/docs/releases/v15.mdx (READ-ONLY) Line 505: a hook "fired by both ObjectKernel and LiteKernel". not falsified — about a hook, not validation. Nothing for a docs-only card.

Also checked, per the coordinator's note: no page under content/docs states that LiteKernel skips validation (git grep -i LiteKernel -- content/docs filtered on valid/schema/contract/strict/lenient/skip → only the events.mdx hook sentence above). AGENTS.md untouched: its Kernel-table sentence stays true under option A. packages/core/src/types.ts yielded no docs anchor, so its doc comment (updated in this PR to name both kernels) is the only prose about it.

Gates re-derived with the docs path in the delta: 88 families (28 added for content/docs), all run; the one that reddened, check:skill-examples, was a prerequisite refusal (@objectstack/client-react unbuilt) and is green after building that package (258 prose examples type-check across 3 surface(s)).

验收备注

维护者速读(草稿)

改了什么LiteKernel.use() 现在跑与 ObjectKernel.use() 完全相同的插件契约检查(PluginSchema),两个 kernel 共用一个函数(packages/core/src/plugin-contract.ts),拒收的错误码与文案完全一致。之前只有 ObjectKernel 检查,LiteKernel 直接入库。

为什么改 — 同一个插件对象,一个 kernel 接受、另一个拒收;而文档指定测试用的正是宽松的那个,所以「测试绿、生产启动崩」。裁决(决策批次 #82,「同意」)选 A:收敛。第一步已测出代价:仓内 813 次 LiteKernel.use() 调用中 807 次原样通过,6 次拒收全部来自 2 个文件里的 3 个测试夹具对象,产品代码 0 处受影响。

风险与代价(含回滚) — 对外是一次受理面收窄:此前 LiteKernel 接受、ObjectKernel 拒收的插件对象,现在两边都拒收。收窄范围与 ObjectKernel 已有的完全一致,不多一个键。changeset 为 minor + BREAKING 横幅(启动期约定)。回滚:撤销本 PR 即恢复 LiteKernel 的旧行为;不涉及元数据、schema 或存储形态,无需迁移。

席位意见 — (留空,席位定稿)

你要做的 — 合并前由合约评审席位(needs:contract-review)确认收窄范围;确认后按常规流程合并。问题 ②(isDefault 退役)是独立的下一张 PR,本 PR 不涉及。


Generated by Claude Code

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/core, @objectstack/runtime, touching 8 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/core/src/types.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/ai/actions-as-tools.mdx (via LiteKernel (symbol, a top-level class))
  • content/docs/ai/natural-language-queries.mdx (via LiteKernel (symbol, a top-level class))
  • content/docs/ai/skills-reference.mdx (via LiteKernel (symbol, a top-level class))
  • content/docs/kernel/events.mdx (via LiteKernel (symbol, a top-level class))
  • content/docs/plugins/anatomy.mdx (via LiteKernel (symbol, a top-level class), PLUGIN_CONTRACT_VIOLATION (literal, a string literal in PLUGIN_CONTRACT_VIOLATION_CODE), staticPath (literal, a string literal in PluginLoader; a string literal on a changed line))
  • content/docs/plugins/packages.mdx (via LiteKernel (symbol, a top-level class))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v15.mdx (via LiteKernel (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/core/src/types.ts) — pages documenting those are invisible to this run
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 39 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 7862fb711be2819b767fa28b05a036fc1172a50apackageMentionDocs.

Which tree this was computed on

This run read content/docs from b04750b8f51f46099590d810c3e0adccd5f4d71b — the merge of head 8443166a602fd34c6be296eab9446f624734bdbf into base 7862fb711be2819b767fa28b05a036fc1172a50a, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin b04750b8f51f46099590d810c3e0adccd5f4d71b && git checkout b04750b8f51f46099590d810c3e0adccd5f4d71b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7862fb711be2819b767fa28b05a036fc1172a50a 8443166a602fd34c6be296eab9446f624734bdbf && git checkout -B drift-repro 7862fb711be2819b767fa28b05a036fc1172a50a && git merge --no-ff 8443166a602fd34c6be296eab9446f624734bdbf

node scripts/docs-audit/affected-docs.mjs --json 7862fb711be2819b767fa28b05a036fc1172a50a

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 7862fb711be2819b767fa28b05a036fc1172a50a → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 8, 2026
… kernel.use() enforces the contract on both kernels

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x

Copy link
Copy Markdown
Contributor

Contract review (CONTRACT_REVIEW_TIER, isolated seat) — PR #16827 @ 8443166a6

Verdict: PASS WITH FINDINGS — CI-conditional: 3 check runs were still in_progress at review time (Lint & Repo Gates, Test Core (5/6), Dogfood Verify CLI); the verdict holds if they finish green. Nothing below blocks the narrowing itself; F1 is a changeset-disposition repair the author should make before enqueue.

Ruling implemented: yes — question ① step 2 exactly, question ② fenced out.

Ruling is comment 5578286478 on #16721, posted by os-zhuang (director seat, decision batch #82, 2026-09-08), recording the maintainer's verbatim reply 「同意」 to the batch-#82 presentation. The operative text, verbatim:

Question ① — LiteKernel enforces the plugin contract (A), in two steps:

  1. A measurement-only card first: wire PluginSchema into LiteKernel.use() locally, run the whole repo, count which tests go red and on which keys. ⛔ Stop at the measurement; do not land.
  2. With that number in hand, land A (converge, per 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). If the failure set turns out to be large, the honest fallback is B (declare the divergence in LiteKernel's docblock and the AGENTS.md Kernel table) — that is a re-presentation, not a seat's call.

Question ② — isDefault is retired (B): drop the || plugin.isDefault read, add a tombstone so an author writing isDefault gets a named refusal with the prescription (default), ship the breaking changeset with its FROM/TO. Contract and runtime keep one spelling.

⚠️ Two PRs, never one: ① is behaviour-preserving (or a doc change under the fallback), ② is breaking.

Step 1 was accepted at 5581457564 (zhuangjianguo, domain:spec execution seat): 813 LiteKernel.use() calls, 807 accepted, 6 refusals from 3 fixture objects in 2 files, 0 product code — "not large", so A proceeds without re-presentation. All readings below are from refs/review/16827 (= 8443166a602fd34c6be296eab9446f624734bdbf) against merge-base 30b099078.

1. Scope vs the ruling

  • LiteKernel.use() now runs PluginSchema via assertPluginContract — the same statement ObjectKernel.use() reaches through PluginLoader.validatePluginContract. One shared helper, packages/core/src/plugin-contract.ts. ✅
  • isDefault / question ②: zero mentions in the diff; packages/spec untouched (git diff --stat … -- packages/spec empty). ✅ fenced.
  • Nothing wider: kernel.ts, kernel-base.ts, plugin-registration.ts, index.ts are untouched (empty diff). validatePluginStructure (name/init/semver) and checkVersionCompatibility stay in the loader; the loader's call site and order in loadPlugin are unchanged — only the body of validatePluginContract became assertPluginContract(plugin); (plugin-loader.ts:435). ObjectKernel behaviour is therefore unchanged by construction.

2. Files vs merge-base (10, matches the claim)

.changeset/litekernel-enforces-plugin-contract.md (+39) · content/docs/plugins/anatomy.mdx (+3/−1) · packages/cli/test/fixtures/option-b-reader-probe.ts (+5/−1) · packages/core/src/lite-kernel.ts (+24) · packages/core/src/plugin-contract.ts (new, +175) · packages/core/src/plugin-contract-enforcement.test.ts (+202) · packages/core/src/plugin-loader.ts (+8/−128) · packages/core/src/types.ts (+3/−2) · packages/plugins/plugin-hono-server/src/ui-plugin-auto-discovery.pin.test.ts (+142/−120) · packages/runtime/src/dispatcher-error-vocabulary.ts (+9/−5).

Governed paths: NONE. git diff --name-only … | grep -E '^(docs/adr/|\.claude/|skills/|AGENTS\.md|CLAUDE\.md|content/docs/releases/)' → no match. ⇒ No maintainer-only merge is required on governed-surface grounds. Governed Surface Queue Guard is green on the head.

content/docs/plugins/anatomy.mdx (non-governed): the three added sentences say only what the code does — slug "must provide" for ui (true: PluginSchema.superRefine, plugin.zod.ts:181-199); refusal "on ObjectKernel and LiteKernel alike" (true after this PR); the lifecycle step 0 lists closed-set type, URL homepage (z.string().url(), :180), null on a declared key, the two ui keys, and "validated, never replaced" (true: safeParse result discarded on both paths; the loader's toPluginMetadata is a cast). ✅

3. Clause-② from code

  • Accept set narrows on LiteKernel.use() to exactly PluginSchema minus version: type outside standard|ui|driver|server|app|theme|agent|objectql (so 'ui-plugin' and 'service' refused), ui missing slug/staticPath (PLUGIN_UI_REQUIRED_KEY_MISSING), null on any of the eight declared keys, non-URL homepage, bad slug regex, non-string id/author/description/staticPath, non-boolean default. Unknown keys still pass (no .strict()). This is identical to what ObjectKernel already refused — the narrowing does not exceed the ruling.
  • Helper body vs old PluginLoader.validatePluginContract (merge-base plugin-loader.ts:521-546): statement-for-statement identical — safeParseissues.filter(path[0] !== 'version') → first issue → at / named (with id) → Error with PLUGIN_CONTRACT_VIOLATION: … head and error.code = PLUGIN_CONTRACT_VIOLATION_CODE. Only the inline "FIRST issue only" comment moved into the docblock. The parameter type went PluginMetadataPlugin; the loader still passes its cast (not copied) metadata, so the object judged is the same object. ✅
  • Order in LiteKernel.use() (lite-kernel.ts:71-79): validateIdle()assertPluginContract(plugin)registerPluginByName(...). ✅ Both orderings are pinned (G "ORDER —" ×2).
  • Not barrel-exported: index.ts on head has no plugin-contract line (nor plugin-registration); the only importers are lite-kernel.ts:8 and plugin-loader.ts:7, both relative. Judgement: right. The ruling converges existing enforcement onto the second kernel; exporting the helper would mint a new public validation API whose level/changeset would be its own decision, and the precedent (plugin-registration.ts, core(finding): duplicate plugin registration throws on LiteKernel and silently overwrites on ObjectKernel — a fourth instance of the two-kernel semantic split #9864) is package-internal for the same reason.

4. Changeset

@objectstack/core: minor + **BREAKING** banner + <!-- adr-0087: not-required (no-migration-prescription) … -->. Ran locally against the review ref: check-changeset-no-major --base origin/main --head refs/review/16827 exit 0; check-adr-0087-registration exit 0 (1 declared-breaking changeset, disposition present); check-empty-changeset exit 0. CI Check Changeset green. Level vs batch #35 "WHICH LEVEL" (pr-automation.yml:667-682): an accept-set narrowing on packages/core/src (LEVEL-axis population per #16713) cannot be patch, major is refused in the window, banner + ADR-0087 disposition carry breaking-ness ⇒ minor is the only admissible level. ✅ Enumerates all eight keys (id, type, staticPath, slug, default, description, author, homepage), null, the ui conditional, what is still accepted, and ordering. ✅ Honesty of the disposition: see F1.

5. Tests

  • Group G (plugin-contract-enforcement.test.ts:379-544): 11 it/it.each blocks, 14 cases. Cases that redden if assertPluginContract(plugin) is removed from LiteKernel.use() (each routes through refusalSync, which throws "but it loaded"): refuses the legacy ui-plugin type…, refuses a ui plugin with no staticPath…, …with no slug…, refuses null on a declared key…, PARITY — …, ORDER — a refused plugin never reaches the registry… — six, matching the PR's ablation. The calibration/untyped/class-based/version/version-less cases and ORDER — state is checked before the contract are invariant under the mutation (correct: they pin what must NOT change).
  • Hono section F: 12 it blocks at base, 12 at head — nothing deleted. F0 unchanged (still observe(…, bootLite) expecting the four /console-fixture routes) ✅. F1/F2 now assert PLUGIN_CONTRACT_VIOLATION + at 'slug' / at 'staticPath' + PLUGIN_UI_REQUIRED_KEY_MISSING + .code on the property, and byte parity Failed to load plugin: <name> - ${lite.message} against boot() ✅. bootLite calls kernel.use(fixture) inside an async function, so the synchronous throw becomes the rejection refusal() reads ✅. The old F1/F2 pinned the || slug fallback and the && plugin.staticPath conjunct in hono-plugin.ts — behaviour now unreachable through either kernel's use(), so no live pin was lost (see F2 note).
  • Fixture option-b-reader-probe.ts:189: type: 'service' as const removed. .type appears in the file only inside the new comment (:193); the sole remaining type: 'service' repo-wide is the isAppPluginLike argument at graft-runtime-hooks.test.ts:61, not a registered plugin ✅.
  • No .skip/.only/.todo added: the only hit in the two test files is it.todo('C — …#15638…') at head :397, present at base :384 — pre-existing ✅.
  • Vocabulary row (dispatcher-error-vocabulary.ts:806-811): filepackages/core/src/plugin-contract.ts, shape: 'assignconst'. On head the constant and both stamp sites (:139, :173) live only in that file — the loader no longer defines or stamps it (grep: 0 hits). The gate's assignconst regex (\.code\s*=\s*([A-Z][A-Z0-9_]*)) resolves in-file. I ran check-dispatcher-error-vocabulary.mjs --self-test && … on a git archive export of the review ref: exit 0, "65 unregistered code-stamping site(s), all classified".

6. CI on 8443166a6 (read ~09:10Z)

36 check runs: 31 success (incl. Build Core, Test Core 1/2/3/4/6 of 6, all Type Check jobs, Check Changeset, Governed Surface Queue Guard, single-writer / same-issue / Part-of guards, Temporal Conformance, Dogfood Regression Gate ×4, Build Docs, Spec property liveness) · 2 skipped (Console Pin Gate, Packed-tarball smoke — opt-in) · 3 in_progress (Lint & Repo Gates, Test Core (5/6), Dogfood Verify CLI) · 0 failures. mergeable_state: blocked — the PR is a draft, required checks are still running, and it carries needs:contract-review; not a conflict. Head is 4 commits behind origin/main (d4401f75b7f96e1417); merge-base unchanged at 30b099078.

Findings

F1 — Medium — changeset disposition contradicts its own body. .changeset/litekernel-enforces-plugin-contract.md:35 opens a paragraph **Migration.** that prescribes author-side rewrites ("give type a value from the closed set…, declare staticPath and slug…, make homepage a URL, and never null a declared key"), while :37 claims not-required (no-migration-prescription). The gate's ratified meaning of that category (check-adr-0087-registration.mjs:141-147): "REFUSED when the changeset's own body carries a migration prescription. A changeset that ships instructions for rewriting a consumer's code cannot also claim no consumer has to rewrite anything: that is a self-contradiction." The gate passed because the paragraph is a bold lead-in with no heading, arrow or table — a detector miss of exactly the #8277 shape the script documents (:197-208), not a positive finding. The precedent changeset the PR cites as "same shape" (enforce-plugin-schema-at-kernel-use.md, #16363) carries the same disposition but no Migration paragraph. Expectation: keep the disposition (its argument — nothing in the ledger's domain moved, and every newly refused object was already refused by ObjectKernel — is sound) and retitle/reframe the paragraph as refusal handling ("What a refusal asks you to do", or fold it into the "refusal is the channel" sentence), so the body no longer states a migration it disclaims. Not a blocker for the contract narrowing; a one-paragraph edit.

F2 — Low — dead code left with no carrier. With both kernels refusing a ui plugin missing slug/staticPath, hono-plugin.ts's plugin.slug || plugin.name.split('/').pop() fallback and && plugin.staticPath conjunct are reachable through neither published use() and are now unpinned (old F1/F2 were their only pins). The PR records this as "noted, not filed … 承接者: none queued; the natural carrier is #15638". Expectation: the domain:spec seat either attaches it to #15638 explicitly (a comment there) or files a small card, so the observation has an owner rather than living in a PR body.

F3 — Low — PR-body count drift. "Tests added" says group G is "11 it/it.each blocks, 16 cases"; the Verification section and the code say 14 (9 singles + 2 + 3). Cosmetic; the tests themselves are as described.

No other findings. Scope, ordering, envelope parity, non-export, fixture edit, the F rewrite, the vocabulary row and the docs sentence all verify against the code.


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review September 8, 2026 09:13
@os-zhuang
os-zhuang enabled auto-merge September 8, 2026 09:14
@claude

claude Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

Contract review — VERDICT: PASS (at CONTRACT_REVIEW_TIER)

Read at 2026-09-08T09:13Z. Reviewer: dedicated contract-review-tier subagent, read-only mandate.

Tier fuse (PM): the reviewer's transcript carries 89 harness-stamped "model" values, all claude-fable-5-1, and zero stamps of any other model. Self-description was not accepted as evidence. CONTRACT_REVIEW_TIER is met, so this verdict is admissible against the Clause-②: yes declaration on this PR.

Scope of the verdict: head 8443166a602fd34c6be296eab9446f624734bdbf, merge-base 30b0990787ef633cfff724951ceeee08c4fc05b9 with origin/main (7862fb711). A PASS binds to a head, not to a PR — any further push needs its own at-tier reading.

Row 10 (ablation) is NOT MEASURED by design. A reviewer is read-only, so re-running the ablation was withdrawn from its mandate mid-review; the row is judged by reading which assertions depend on lite-kernel.ts:73, and the dev's reported 6-red/28-green signature is not restated as the reviewer's own measurement. The reviewer disclosed that before that instruction reached it, it had already run an ablation in a detached scratch worktree under the session scratchpad — never the shared checkout — and restored packages/core/src/lite-kernel.ts to its original blob 38912c8785843fd9223b4dafadc5e503405c5ff5, then pruned the worktree. PM verified the primary checkout independently: HEAD 5e53d73d8, git status --porcelain 0 lines, 0 ablation markers in tree. Those out-of-process readings are excluded from the record above.


Reviewer report (verbatim)

Verdict: PASS. Clause-②: yes — direction confirmed: every runtime change narrows LiteKernel.use() to exactly ObjectKernel.use()'s existing set; no widening found (row 5).

# Item Verdict Measurement
1 One refusal, one shape PASS (boundary named below) (a) Faithful: mechanical diff of loader@base L521–547 (dedented) vs plugin-contract.ts@Head L151–175 — only two differences: the signature line and a two-line comment moved into the docblock. safeParse L152, version issues dropped L157, first remaining issue L162, code on the property L173 and at the head of the message L170. (b) One statement: product callers of assertPluginContract are lite-kernel.ts:73 and plugin-loader.ts:436 (reached from loadPlugin L178 ← kernel.ts:203). Non-test PluginSchema.safeParse|parse callers: plugin-contract.ts:152 only. Non-test literal stamp of 'PLUGIN_CONTRACT_VIOLATION': plugin-contract.ts:139 only. registerPluginByName callers: kernel.ts:210, lite-kernel.ts:75 only; extends ObjectKernelBase: LiteKernel only; plugins.set( in core non-test src: 0 (control: the comment at plugin-registration.ts:19 matched). (c) Byte parity holds structurally and is pinned: kernel.ts:206 builds Failed to load plugin: ${plugin.name} - ${result.error?.message}; loadPlugin's catch stores the thrown error unwrapped (L202–206); toPluginMetadata L391–401 casts the same object, so name/id are identical on both kernels. Pinned with toBe in core G "PARITY" and hono F1/F2.
2 validatePluginStructure stayed loader's own PASS Base L420–432 and head L403–415 textually identical; call order L175 → L178 → L181 unchanged; the lite-kernel.ts diff adds only the one call. Keeping it is right: the ruling is about PluginSchema; moving name/init/semver onto LiteKernel would refuse more than the ruled set, and isValidSemanticVersion's wider grammar is pinned by plugin-loader.test.ts.
3 Section F rewritten, not retired PASS F0 unchanged: F0's it body with comments stripped is identical base vs head (13 lines each, diff exit 0). F1/F2 bind on the wiring: bootLite L224–230 calls kernel.use(fixture); refusal() L138–145 throws expected kernel.use() to refuse the plugin, but it loaded when nothing throws. F1/F2 assert PLUGIN_CONTRACT_VIOLATION, at 'slug'/at 'staticPath', PLUGIN_UI_REQUIRED_KEY_MISSING, .code on the property, and toBe byte parity against boot() on ObjectKernel. By reading: remove lite-kernel.ts:73 and F1/F2 must fall while F0 stays green. Not "fixed into passing".
4 type: 'service' fixture line PASS Repo-wide type: *['"]service['"]: base 2 hits, head 1 (an isAppPluginLike argument — not a registered plugin). .type reads in the fixture at head: 0 code reads (file 576 lines). 'service''standard' + CORE_PLUGIN_TYPES (plugin.zod.ts L89–97, enum L168–171) ⇒ already refused by ObjectKernel at 'type'.
5 Accept-set movement PASS — narrows only (i) lite-kernel.ts:73: LiteKernel.use() now refuses what PluginSchema refuses minus version — exactly ObjectKernel's set. (ii) Loader delegation: body identical ⇒ ObjectKernel set unchanged. (iii) PluginMetadataPlugin parameter: type-level only. (iv) Fixture, docs, types.ts comment, vocabulary row, tests: no runtime effect. Still accepted on both: unknown keys (no .strict(), L166), version any form, absent type. No widening found.
6 Changeset PASS @objectstack/core: minor + **BREAKING** + the adr-0087 not-required (no-migration-prescription) marker. Convention per scripts/check-changeset-no-major.mjs: during the launch window breaking changes ship as minor; precedent .changeset/enforce-plugin-schema-at-kernel-use.md (the ObjectKernel half) has the same shape. Gates at the PR SHA, exit captured before any pipe: check-changeset-no-major 0, check-adr-0087-registration 0, check-empty-changeset 0. Diff touches no packages/spec path; no key/schema/stored representation moves.
7 Docs PASS anatomy.mdx +L72 (slug "must provide") true per the superRefine L181–200; +L74 and +L263 (both kernels refuse at kernel.use(), "validated, never replaced") true. Its example plugin (L15–21, no type) is accepted. Five other pages read and left alone: packages.mdx:44, events.mdx:34,38, actions-as-tools.mdx:129–134, natural-language-queries.mdx:40–45 (MCPServerPlugin, type = 'standard' as const at packages/mcp/src/plugin.ts:250 ⇒ accepted), skills-reference.mdx:46,64. None shows a now-refused plugin or claims LiteKernel skips validation.
8 content/docs/releases/** absent PASS git diff --name-status merge-base..head: 10 files, none under content/docs/releases/ (control: content/docs/plugins/anatomy.mdx present). v15.mdx's only LiteKernel line is L505, inside the kernel:bootstrapped bullet — a hook, not validation; PluginSchema|kernel.use()|plugin contract|PLUGIN_CONTRACT in v15.mdx: 0. No docs-only card owed.
9 AGENTS.md / governed paths untouched PASS GOVERNED_SURFACES (scripts/pm/check-governed-merges.mjs): docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md. None of the 10 diff paths matches; AGENTS.md absent. Fences: body opens Part of #16721, no Fixes/Closes; isDefault in diff 0 (control: default 7); Clause-②: yes present.
10 Ablation / self-assessment PASS by reading Ablation NOT MEASURED (reviewer is read-only). By reading, the reported reds are exactly the ones that must fall: in core group G six cases route through refusalSync(() => kernel.use(...)) and throw "but it loaded" without lite-kernel.ts:73; the other eight G cases and the 20 A–F cases do not depend on it ⇒ 6 red / 28 green is the required signature. Hono: F1/F2 depend, F0 does not. The cli option-b self-assessment is correct and is upheld: the recorder now has no typePluginSchema accepts it (.default('standard')), so kernel.use() returns with or without the check; option-b-reader-acceptance.pin.test.ts has 0 refusal assertions (control: 7 it( blocks at L207–341) ⇒ 7 pass under both wirings. It pins nothing about the wiring — its evidence is step 1's reverse reading.
11 Vocabulary row + doc-authoring PASS Row file: 'packages/core/src/plugin-contract.ts', shape: 'assignconst'; plugin-contract.ts:139 is const PLUGIN_CONTRACT_VIOLATION_CODE = 'PLUGIN_CONTRACT_VIOLATION';. Gates at the PR SHA: check-dispatcher-error-vocabulary.mjs 0, check-doc-authoring.mjs 0. Caveat: main legs only, not --self-test. Commit cb787324f removed #16721) from the runtime why string; at head the only tracker ids near the row are comments (L802 [#16049], L805 #16721), none inside the string.
CI NOT MEASURED — PM-held gate Not polled, no timer, no watcher.

Boundary named for row 1 (a probe, not agreement). "One refusal, one shape" holds for the schema refusal and stops exactly at the loader's structural fence: ObjectKernel runs validatePluginStructure (name/init/semver) before the contract (L175 → L178); LiteKernel never runs it. For an input failing both (no init + bad type) the kernels still differ (Plugin init function is required vs PLUGIN_CONTRACT_VIOLATION … at 'type'), and an input failing structure only is refused on ObjectKernel and accepted on LiteKernel. That is the divergence the ruling deliberately kept (row 2), the PR states it, and it is pre-existing — but it is the precise edge of the claim. Two smaller pre-existing carriers: the code property survives on LiteKernel only (message head identical), and toPluginMetadata writes version='0.0.0' onto a version-less object on the ObjectKernel path (L396–397; no effect on the contract message).

Non-blocking notes. packages/runtime/src/dispatcher-error-vocabulary.ts changes a registry row (file path + why prose) with no changeset of its own; the fixed group versions in lockstep and check-empty-changeset passed, so nothing is owed. The last commit 8443166a6 touches only content/docs/plugins/anatomy.mdx (1 file, +3/−1), as the PR body states.


PM disposition. needs:contract-review cleared from this PR on the strength of this verdict. This PR is not governed (row 9 measured it against the register), so it is eligible to be flipped ready and enqueued once every check on 8443166a6 reads completed with success/skipped — three were still in_progress at 09:13Z, so the flip waits on them, not on question ②. Question ② of #16721 (retire isDefault under ADR-0049) is untouched by this diff (isDefault count 0) and remains a separate PR.


Generated by Claude Code

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

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants