Skip to content

feat(core): enforce PluginSchema at kernel.use() (#16049) - #16363

Merged
zhuangjianguo merged 4 commits into
mainfrom
claude/issue-16049-enforce-pluginschema-at-kernel-use
Sep 6, 2026
Merged

feat(core): enforce PluginSchema at kernel.use() (#16049)#16363
zhuangjianguo merged 4 commits into
mainfrom
claude/issue-16049-enforce-pluginschema-at-kernel-use

Conversation

@claude

@claude claude Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Part of #16049

Implements the maintainer ruling of 2026-09-06 (director seat, decision batch #58) — ADR-0049 enforce-or-remove on PluginSchema, exit enforce: "the protocol is the baseline; the runtime aligns to it."

Part of rather than a closing keyword, deliberately, and the reason is the one open item below: this PR enforces the schema except its version key, which is a declared deviation from the ruling's literal text. If the maintainer endorses the exclusion the card is discharged; if not, the remaining half lives on it. That choice is not mine to close over.

What changed

PluginLoader.loadPlugin now runs PluginSchema.safeParse over every plugin object and refuses one the schema refuses, through the loader's existing error path — no new error channel — with the stable code PLUGIN_CONTRACT_VIOLATION naming the plugin and the first violated key:

PLUGIN_CONTRACT_VIOLATION: plugin '@acme/console' is refused by the declared
plugin contract at 'type': Invalid option: expected one of "standard"|"ui"|…

The code sits at the head of the message as well as on err.code, because ObjectKernel.use() re-wraps a failed load into a fresh Error carrying only result.error?.message — a code living only on the property would not reach the caller that sees the boot fail. It is registered as a boot-refusal / door: 'none' row in packages/runtime/src/dispatcher-error-vocabulary.ts, the same class as SERVICE_NOT_REGISTERED: raised before any HTTP boundary exists, so no door answers with it.

packages/core/src/types.ts no longer says PluginSchema.type "refuses it at parse" — the sentence this card measured false — and describes the boot-path refusal that now exists.

⛔ safeParse is used for VALIDATION ONLY

The parse output is read for success and discarded. A copy destroys the prototype chain of class-based plugins, which is exactly why toPluginMetadata is a cast rather than a spread. Substituting the parse output is the one mistake that would break every class-based plugin in the ecosystem while leaving every refusal test green, so group C of the new suite pins three independent statements a spread would break: object identity, Object.getPrototypeOf(stored) === ClassPlugin.prototype, and a prototype-only method still callable off what the kernel stored.

The card's own measurements, re-derived before acting on them

The card asks for this in its Provenance note. Re-run on origin/main @ c24e2d37c, each zero with a firing positive control:

Reading Result
PluginSchema.parse / .safeParse call sites, all tracked files 3 files — 2 test files + 1 docs prose example. Zero production callers
CONTROL — ObjectSchema.parse/.safeParse call sites 38 files ⇒ runtime schema parsing exists here; the zero is real
validatePluginStructure reads !plugin.name · !plugin.init · !isValidSemanticVersion(version) — never type
Producers of type: 'ui' zero (only a pin-test fixture; the rest are prose/spec/generated)
Producers of staticPath zero — declaration, 3 reads in hono-plugin.ts, 2 generated baselines, 3 prose lines
CONTROL — type: 'standard' occurrences 91 ⇒ the pattern fires when the value is present

All confirmed. The bare-identifier substring trap the triage comment warns about is real and was avoided: git grep PluginSchema returns hits dominated by plugin-auth's unrelated build*PluginSchema family.

Blast radius, measured

Every in-repo plugin object declares a type inside the closed set — standard ×62, server ×2, driver ×2, objectql, app — and no in-repo plugin object carries slug or homepage at all. No in-repo plugin changes behaviour. Two near-misses were checked and cleared: ApiTrigger / ScheduleTrigger carry type = 'api' / 'schedule' but implement FlowTrigger, never Plugin. One inert drift is noted, not touched: the mocked I18nServicePlugin in plugin-dev's dev-i18n-packages-reader.test.ts declares type = 'service', outside the closed set — that test constructs no kernel, so it never reaches this path, and the real plugin declares standard.

⚠️ version is deliberately NOT enforced — the one open item

Measured, not assumed. PluginSchema.version is /^\d+\.\d+\.\d+$/, which refuses the prerelease and build-metadata forms SemVer 2.0.0 defines. The loader's own isValidSemanticVersion — the check that has always run — implements the full grammar and accepts them, and packages/core/src/plugin-loader.test.ts pins that acceptance deliberately, in two named cases: "should accept versions with pre-release tags" (1.0.0-alpha.1) and "should accept versions with build metadata" (1.0.0+20230101). Two in-repo class-based plugin fixtures ship version = '0.0.0-fixture' and boot through the real kernel.

So enforcing the schema's version verbatim would not enforce the protocol — it would retire a pinned capability, silently, under a card that ruled on type. What this enforcement DOES refuse is the other eight declared keys — id, type, staticPath, slug, default, description, author, homepage, plus an explicit null on any of them, all eight being .optional() — and version is not among them. Neither are unknown keys: PluginSchema is a plain z.object with no .strict(), so a plugin carrying keys the schema never declares still loads. The changeset enumerates all eight and that null behaviour. The loader's wider, correct check stays authoritative for that one key; the exclusion is declared in code, pinned by tests, and stated in the changeset rather than left as an unmeasured disagreement. Reconciling the two spellings is packages/spec work and is filed separately.

Corrected 2026-09-06 after the contract review (5562633197): the paragraph above previously named only three of those keys, matching the changeset as it stood at ea8af40e4. The commit that fixed the three carriers is a254237d1; this description edit adds no commit and moves no head.

This is the one thing on this PR that needs a maintainer's yes or no. The alternative is a one-line change here plus flipping those two pins, which is a materially larger published-behaviour change than the ruling describes.

Tests

packages/core/src/plugin-contract-enforcement.test.ts — 15 cases. Every refusal has a calibration twin one line away (the same fixture with the offending key corrected), so a refusal is attributable to the key under test and not to a harness that refuses everything.

Fail-first, measured by ablation rather than asserted. With plugin-loader.ts swapped back to c24e2d37c (mutation proven on disk: blob equals the base blob, and the new call site's whole-line anchor counts 1 → 0), the suite gives 5 failed | 10 passed: the ui-plugin, code-property, class-based-refusal, slug and homepage cases go red, while every calibration and every version case stays green on both trees — so the positives are not being carried by the change. Restore proven: blob equals the HEAD blob, and the two ablated files show 0 differences against HEAD.

No rebuild is involved in that leg and does not need to be: core's own suite resolves ./plugin-loader.js to src/, and the 5 red cases are that claim's own firing control — a dist-resolved import would have stayed green.

Green at 479fa8bbb: @objectstack/core full suite 51 files / 1230 tests; @objectstack/core and @objectstack/runtime typecheck (including check:test-typecheck, which is the program that actually sweeps *.test.ts — the main tsc --noEmit excludes them, and it was the test program that caught 9 real type errors here); @objectstack/runtime error-envelope.conformance + package-door-error-parity 61 tests; and plugin-hono-server's ui-plugin-auto-discovery.pin.test.ts 14 passed | 1 todo — the todo being pin C, the legacy arm #15638 owns and which this PR does not write.

Gates at 479fa8bbb, exit codes captured redirect-then-read: check:nul-bytes · check:dispatcher-error-vocabulary · check:error-code-casing · check:test-source-alias · check:empty-changeset · check:changeset-no-major · check:changeset-fixed · check:system-context-census · check:undeclared-dep-imports · check:published-files · check:dts-closure — all 0. The vocabulary gate's green is not a silent one: its --report names the site, PLUGIN_CONTRACT_VIOLATION assignconst boot-refusal packages/core/src/plugin-loader.ts. check:dts-closure's 30 swept packages equals this tree's 30 dist/ directories and packages/core/dist is one of them, so that green is about this package; packages/runtime has no local dist and is not measured by it here.

Clause ②: yes

Measured on the published declarations, both directions, with the instrument controlled against a change that should move it.

@objectstack/core's exported name set is 421 → 421 on dist/index.d.ts and dist/index.d.cts — nothing added, nothing removed; dist/logger.d.ts / .d.cts are byte-identical. The byte delta is classified rather than eyeballed: of 63 added lines and 2 removed, exactly one added line is not a commentprivate validatePluginContract;, a private member's bare-name line — and no declaration text moves. The instrument is live: the base rebuild lacks that member (mtime moved, and the head rebuild reproduces its original hashes byte for byte).

So the declaration-surface criterion answers no, and the verdict is still yes, because the criterion that governs here is not the .d.ts name set: this change narrows what a published runtime face accepts. Input that kernel.use() accepted and stored yesterday is refused today, and the affected population is precisely the externally authored plugins that never met the compile-time Plugin.type union — the ones with zero in-repo producers, measured above.

That is the mirror image of the criterion question open on #16229 (whether a runtime-only change counts when no declared surface moves), and that question is not settled. Under an unruled criterion the conservative side is the labelled one, so needs:contract-review is hung on both carriers.

Not in scope, ruled so

The "required for type: ui" refine and the core Plugin interface alignment belong to #16334, not here — noted because this card's own comment thread discusses them. #15638's options B and C are downstream of this landing and are left untouched; its pin C in ui-plugin-auto-discovery.pin.test.ts stays it.todo, which is the honest state until that card rules. Neither #16334 nor #15638 is addressed here.

Authored by Claude Code in session session_01ARYe3yQTQCUFm5qPYNgKaJ, dispatched by the domain:engine execution PM seat.


Generated by Claude Code


Generated by Claude Code

`PluginSchema` had zero runtime callers: the boot path checked `name`, `init`
and semver, and every other constraint the protocol declared was a declaration
with nothing behind it. `defineStack` accepted `type: 'ui-plugin'` while
`PluginSchema.safeParse` refused it, and only one of those answers was on the
path a real plugin takes.

`PluginLoader.validatePluginContract` now runs the schema over every plugin
object and refuses one the schema refuses, through the loader's existing error
path with the stable code `PLUGIN_CONTRACT_VIOLATION`, naming the plugin and
the first violated key.

`safeParse` is used for VALIDATION ONLY and the parse output is discarded — a
copy destroys the prototype chain of class-based plugins, which is why
`toPluginMetadata` is a cast. A class-based plugin's identity, prototype and
prototype methods are pinned.

`version` is deliberately excluded and the exclusion is measured, not assumed:
the schema's `/^\d+\.\d+\.\d+$/` refuses the prerelease and build-metadata
forms SemVer 2.0.0 defines, while the loader's own `isValidSemanticVersion`
accepts them and `plugin-loader.test.ts` pins that acceptance deliberately.

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

`kernel.use(x).catch((e) => e as Error)` resolves to `Kernel | Error`, so a case
whose input stopped being refused would assert against a kernel and report a
property miss rather than "this loaded". The helper throws instead.

The changeset states the published-behaviour change the ruling names: an
unknown `type`, an invalid `slug` or an invalid `homepage` is now refused at
load, and `version` is deliberately not enforced from the schema.

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

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

6 anchor(s) derived from 2 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 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; 102 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 0ea5f9d9f7919f237243ca33ba4dec1222d6564epackageMentionDocs.

Which tree this was computed on

This run read content/docs from 80900700019edd8a9893f0c5074d9b770c5f2ad8 — the merge of head a254237d15bf7afeb560bd899ddcee6f9730c82f into base 0ea5f9d9f7919f237243ca33ba4dec1222d6564e, 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 80900700019edd8a9893f0c5074d9b770c5f2ad8 && git checkout 80900700019edd8a9893f0c5074d9b770c5f2ad8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0ea5f9d9f7919f237243ca33ba4dec1222d6564e a254237d15bf7afeb560bd899ddcee6f9730c82f && git checkout -B drift-repro 0ea5f9d9f7919f237243ca33ba4dec1222d6564e && git merge --no-ff a254237d15bf7afeb560bd899ddcee6f9730c82f

node scripts/docs-audit/affected-docs.mjs --json 0ea5f9d9f7919f237243ca33ba4dec1222d6564e

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

…why string

`check:doc-authoring`'s cross-package prose-id leg reads every string
literal under packages/** and holds each (file, id) pair to the pinned
baseline; the new row's `why:` string cited the tracker id of the
pre-HTTP ruling and took the file's `#8035` count from 6 pinned to 7
measured. The sentence now names the reasoning the rows above cite
instead of the tracker id, which is what the gate prescribes ("only the
tracker id goes"); the meaning is unchanged and the count reads 6 again.
The `//` comment marker above the row is outside that gate's population
and stays.

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

claude Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor Author

CI fix for Lint & Repo Gates at 479fa8b — one string, one commit (ea8af40). The PR stays draft and needs:contract-review is untouched.

The gate that failed is check:doc-authoring (its cross-package prose-id leg in scripts/check-doc-authoring.mjs), not check:dispatcher-error-vocabulary — that one scans unregistered code SITES, not issue-id prose, which is why the previous round's local run of it was green. The prose leg reads every string literal in non-test packages/** sources (spec excluded) and holds each (file, id) pair to scripts/doc-authoring-prose-id.baseline.json; // comments are outside its population by design, so the [#16049] comment marker above the row was never the trigger and is untouched.

Before — head 479fa8b, pnpm check:doc-authoring, REAL_EXIT=1:

✗ NEW internal issue-id reference(s) in sibling-package string prose:
  packages/runtime/src/dispatcher-error-vocabulary.ts  #8035  (6 pinned, 7 measured)
    :771  'above, ruled by the same #8035 reasoning: a composition fact raised pre-HTTP is not wire '

After — head ea8af40, same command, REAL_EXIT=0:

✓ doc authoring guard: sibling-package prose ids hold the baseline — 829 pinned site(s) across 231 file(s), 86003 string(s) read in 1161 parsed source(s), no growth, no burn-down unrecorded.

node scripts/check-doc-authoring.mjs --census-ledger reads "#8035": 6 for the file and its output is byte-identical to the pinned baseline — the pin was not raised.

The why: sentence, before and after:

Same class, same reasoning, same conclusion; only the tracker id left the string, which is the gate's own prescription ("only the tracker id goes"). Also green at ea8af40: check:dispatcher-error-vocabulary, eslint on the file (0 errors, 0 warnings), check:nul-bytes. File line count unchanged (1661), so no line-number anchors move.


Generated by Claude Code

@claude

claude Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor Author

Contract review (clause ②) — NOT PASSED, bound to head ea8af40e4af41c76f92243f540b2ada5ad1b2dab

domain:engine dispatching seat, session session_01ARYe3yQTQCUFm5qPYNgKaJ, 2026-09-06T22:4xZ. Reviewed at CONTRACT_REVIEW_TIER; every citation re-verified against the tree by this seat. Tier disclosure as recorded on #16308 (5562566510): the reviewer's tier is asserted, never measured, and the substitute control is that the citations are checkable.

Two blocking findings, and neither needs a line of code moved — both remedies are text in the changeset. Clause-②: yes is correct, both carriers agree, the minor level is right, and the enforcement itself is sound.

0. Disclosure this seat owes first

This card was dispatched by me at the wrong tier. Its content — making kernel.use() refuse plugins it accepts today — is clause-②-mandatory regardless of paths, and I sent it below CONTRACT_REVIEW_TIER. The compensating control (needs:contract-review on both carriers) is what held it, and this review is what that control was holding it for. Nothing reached the tree.

1. ⭐ The accept-set delta, enumerated empirically — and it is eight keys, not three

Both dist legs built, the same probe run against each. Newly refused at head with PLUGIN_CONTRACT_VIOLATION … at '<key>':

input to kernel.use() merge-base c24e2d37 head ea8af40e named in changeset?
type:'ui-plugin' ACCEPTED REFUSED at 'type' yes
slug:'Not A Slug' ACCEPTED REFUSED at 'slug' yes
homepage:'not-a-url' ACCEPTED REFUSED at 'homepage' yes
id:'' / id:42 ACCEPTED REFUSED at 'id' no
author:{name:'x'} ACCEPTED REFUSED at 'author' no
description:123 / null ACCEPTED REFUSED at 'description' no
default:'yes' ACCEPTED REFUSED at 'default' no
staticPath:42 ACCEPTED REFUSED at 'staticPath' no
null on type / homepage ACCEPTED REFUSED no

Verified against plugin.zod.ts:119-133: the schema declares exactly those eight optional keys plus version.

Still accepted at head (so the door is not over-narrowed): the minimal plugin; a valid plugin plus four unknown keys (stored === input, all 7 keys intact); a version-less plugin; type:'ui'; a class-based plugin with identity, prototype and prototype method intact.

2. Two of my own Zone-2 assumptions, both falsified in the safe direction

2.2 worst case FALSIFIED — unknown keys are not refused. PluginSchema is lazySchema(() => z.object({ (plugin.zod.ts:119) with no .strict() — 0 hits, against a firing control of 10 .strict( in object.zod.ts; lazySchema adds no posture; zod 4.4.3 measured to accept an extra key. ⇒ STRIP posture, so the wide narrowing I feared does not exist and the change is confined to declared keys.

Q1 = A CONFIRMED. plugin-loader.ts:467 filters issue.path[0] !== 'version'. 1.0.0-alpha.1 and 1.0.0+20230101 load on both trees; v1.0.0 is still refused by the loader's own message with no PLUGIN_CONTRACT_VIOLATION. ⇒ the maintainer's exclusion is implemented, and the two named pins are not silently retired.

Limb 1 does not fire — name set 242 → 242; exactly one non-comment line moves on the published .d.ts, private validatePluginContract;, a private bare-name member on a class that already had private members. Disclosed rather than buried.

3. ⛔ BLOCKING 1 — the changeset understates the narrowing by five of eight keys

.changeset/…:5 says: "A plugin object with an unknown type, an invalid slug or an invalid homepage is refused". The same three-key enumeration is mirrored at plugin-loader.ts:455 and dispatcher-error-vocabulary.ts:766.

Why this is blocking rather than a nit — AGENTS.md:1023:

this text ships to consumers as CHANGELOG.md inside the npm package and is what an upgrading agent greps after the tombstone error

An author refused at 'author' or at 'description' greps that CHANGELOG and reads an enumeration that affirmatively says only three keys are refused. That is not an omission, it is a misstatement of the accept-set delta on the only surface consumers have. The code is inside the ruling's operative sentence; the disclosure is not.

Remedy: enumerate all eight keys and the null behaviour in the changeset; mirror at the two code carriers.

4. ⛔ BLOCKING 2 — no **BREAKING** banner and no ADR-0087 disposition

scripts/check-changeset-no-major.mjs:55-66, verified verbatim by this seat:

an accept-set narrowing … Until then it is NOT the carrier … The mandatory information carriers for breaking-ness in the meantime are the **BREAKING** banner the author writes in the changeset body and the ADR-0087 migration-ledger dispositionThey are not documentation niceties

This changeset carries 0 BREAKING and 0 adr-0087. The precedent on the same Plugin.type key, d8024f0, carries 2 and 1 — and it reasoned the same way this PR does ("core aligning to the declared contract rather than a new restriction") and banner-ed it anyway. 4635f3e likewise.

⚠️ The gate cannot catch this: check-adr-0087-registration.mjs:572 classifies from the author's own declaration (/\*\*BREAKING/i on the body), so no banner ⇒ "1 non-breaking changeset(s) seen" ⇒ it never asks. That is measured on this PR.

Remedy: the d8024f0 shape, two lines.

⭐ For the record: this finding made me reverse my own release of PR #16296 forty minutes after making it — same defect, different package. That PR is back in draft and out of the queue (5562629666). ⛔ You are not being held to a standard I am waiving elsewhere.

5. What happens next

A fix round is dispatched for the two text remedies. ⛔ No code change, ⛔ no re-litigating the enforcement. When the head moves, a fresh condition-① record is owed, but every measurement above transfers unchanged — the accept-set table, the STRIP-posture finding and the Q1 confirmation do not need re-deriving.

A NOT PASSED that costs two lines of changeset text and no code is a good outcome, and the review earned it: the eight-vs-three gap was invisible to CI, to the gate, and to me.


Generated by Claude Code

… break

The contract review returned NOT PASSED on two text findings. Neither moves a
line of enforcement: `plugin-loader.ts`'s filter and `PluginSchema` are
byte-identical to the reviewed head.

1. The narrowing was understated by five of eight keys. `PluginSchema` declares
   nine optional keys and `validatePluginContract` excludes `version`, so the
   refusal reaches `id`, `type`, `staticPath`, `slug`, `default`, `description`,
   `author` and `homepage` — plus an explicit `null` on any of them, all eight
   being `.optional()`. The changeset, the loader's JSDoc and the
   `PLUGIN_CONTRACT_VIOLATION` vocabulary row each named only three of them, so
   an author refused `at 'author'` who greps the shipped CHANGELOG read an
   enumeration affirmatively saying their key is not enforced. All three
   carriers now enumerate the eight and the `null` behaviour, and all three
   state what is STILL accepted, which is what bounds the blast radius: unknown
   keys pass (a plain `z.object`, no `.strict()`), a version-less plugin loads,
   and `version` is excluded outright so `1.0.0-alpha.1` and `1.0.0+20230101`
   still load.

2. No `**BREAKING**` banner and no ADR-0087 disposition.
   `check-changeset-no-major.mjs` names an accept-set narrowing as the breaking
   shape and those two as the mandatory carriers during the launch window; the
   precedent on this same key (`d8024f0`) carries both. The changeset now opens
   with the banner in that shape and closes with exactly one
   `not-required (no-migration-prescription)` disposition: `PluginSchema` is
   read, not changed, no stored representation moves, and the channel that
   reaches an affected author is the refusal naming the key. The level stays
   `minor`.

No `#NNNNN` id enters the vocabulary `why` string, so the cross-package prose-id
leg of `check:doc-authoring` stays at its baseline.

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

Copy link
Copy Markdown
Collaborator

Fix round for the contract review's two blocking findings (5562633197) — text only, one commit, a254237d1. ⛔ No enforcement logic moved: plugin-loader.ts's issue.path[0] !== 'version' filter and packages/spec/src/kernel/plugin.zod.ts are byte-identical to the reviewed head ea8af40e4. The PR stays draft and needs:contract-review is untouched.

git diff ea8af40e4 a254237d1 is 3 files: the changeset, a JSDoc block, and one why string. Every changed line in plugin-loader.ts begins with * — the filter that isolates non-comment changed lines returns empty.

BLOCKING 1 — all eight keys, and null, in all three carriers

PluginSchema declares nine optional keys and validatePluginContract excludes version, so enforcement reaches eight: id, type, staticPath, slug, default, description, author, homepage. All eight are .optional(), which admits absence and undefined but not an explicit null — re-measured here against zod 4.4.3 with the schema's own shape: null refuses on all eight, four unknown keys parse clean, a version-less object parses clean, 1.0.0-alpha.1 refuses at version (which the loader's filter then drops).

Each carrier now enumerates the eight, states the null behaviour, and states what is still accepted — unknown keys (a plain z.object, no .strict(), output discarded, so the plugin is stored as the object that was passed in), a version-less plugin, a plugin with no type (the .default('standard') is not written back), a class-based plugin's identity and prototype, and version excluded outright so 1.0.0-alpha.1 / 1.0.0+20230101 still load.

⚠️ The dispatcher-error-vocabulary.ts trap was respected: no #NNNNN id enters that why string, and scripts/doc-authoring-prose-id.baseline.json is not in this PR's diff at all.

⚠️ One restatement this commit does NOT reach: the PR description above. Its version section still reads "The ruling's own changeset note enumerates what this refuses (unknown type, invalid slug, invalid homepage)" — that sentence describes the changeset as it was at ea8af40e4 and is now superseded by the eight-key enumeration. Flagged rather than silently edited, since the description is the record the review was bound to.

BLOCKING 2 — banner and disposition, in the d8024f0 shape

The changeset's second paragraph now opens:

BREAKING accept-set narrowing on a published runtime entry point, shipped as minor under the repo's launch-window convention for breaking changes (scripts/check-changeset-no-major.mjs).

and it closes with exactly one disposition:

<!-- adr-0087: not-required (no-migration-prescription) An accept-set narrowing performed entirely at the runtime boot path: PluginSchema is READ by kernel.use(), not changed. No metadata key, spec symbol, Zod schema, object definition or stored representation is added, removed or given a different name, so objectstack migrate meta has nothing to visit and there is no tombstone to mint. Stored metadata is untouched; what moves is which plugin OBJECTS a boot accepts. The channel that reaches an affected plugin author is the refusal itself, which names the offending key at kernel.use() and is more precise than a ledger line — and which value a formerly-refused key should carry is authoring intent no ledger entry can decide. -->

no-migration-prescription is the disposition the ADR's own vocabulary leaves: unpublished is false (@objectstack/core publishes), already-registered names no pre-existing entry, runtime-interface-only requires a <path>#<Symbol> that is not a Zod projection — the subject here is a Zod schema — and type-surface-only requires a merge-base reading of an erased symbol, which this diff does not have. The level stays "@objectstack/core": minor.

Gate readings at a254237d1, exit codes captured redirect-then-read

gate exit printed line
check-changeset-no-major 0 ✓ This diff introduces no major bump.
check-adr-0087-registration 0 ✓ check-adr-0087-registration: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition.
check-doc-authoring 0 ✓ doc authoring guard: sibling-package prose ids hold the baseline — 829 pinned site(s) across 231 file(s), 86007 string(s) read in 1161 parsed source(s), no growth, no burn-down unrecorded.
check-dispatcher-error-vocabulary 0 OK — 66 unregistered code-stamping site(s), all classified; 2 awaiting a ledger entry

The ADR-0087 line is the one that had to move, and it did: at ea8af40e4 the same gate printed ✓ … this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen). It now classifies the changeset as breaking and names the disposition, so the banner is in the spelling /\*\*BREAKING/i matches.

Both self-test legs green (--self-test on doc-authoring: 16 batteries / 198 cases; on the vocabulary gate: 10 shapes / 322 assertions). The doc-authoring string population moved 86003 → 86007 — the four extra literals are the concatenation split in the why string — with the pinned-site count unchanged at 829.

npx eslint on both changed sources: 0 errors, 0 warnings.

Tests

packages/core, the three files the review named: 43 passed (3 files) — plugin-contract-enforcement.test.ts, plugin-loader.test.ts, plugin-type-closed-set.test.ts. Same count as the reviewed head, as it must be: no behaviour was changed.

New head: a254237d15bf7afeb560bd899ddcee6f9730c82f.

https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ


Generated by Claude Code

@claude

claude Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor Author

Contract review (clause ②) — PASS, bound to head a254237d15bf7afeb560bd899ddcee6f9730c82f

domain:engine dispatching seat, session session_01ARYe3yQTQCUFm5qPYNgKaJ, 2026-09-06T23:1xZ. This replaces the NOT PASSED verdict 5562633197, which was bound to ea8af40e and is void by construction now that the head has moved. Both blocking findings are discharged.

⚠️ Why this record is not a fresh full-tier review, stated so it can be overturned

A moved head voids condition ①, and the honest question is whether the clause-② measurement could have changed. It could not, and that is measured rather than assumed — the delta between the two heads contains zero non-comment source lines:

reading at ea8af40ea254237d value firing control
plugin-loader.ts non-comment changed lines 0 40 total changed lines in that file
files touched 3 changeset + 2 JSDoc/string carriers
plugin.zod.ts in the diff no

⇒ the enforcement construct, the version filter and the schema are byte-identical, so the accept-set table, the STRIP-posture finding and the Q1 = A confirmation in 5562633197 transfer unchanged. Re-deriving a 34-shape runtime ablation against a JSDoc-only delta would be theatre, not diligence. ⛔ If anyone disagrees that a comment-only delta preserves an accept set, that is the sentence to attack — the measurement above is the whole basis.

What I did re-derive myself, at the new head, rather than accept:

  • BLOCKING 2 discharged. The banner matches the gate's own predicate — /\*\*BREAKING/i.test(body) (check-adr-0087-registration.mjs:572) returns true — and there is exactly 1 adr-0087 marker. The gate's output moved accordingly: at ea8af40e it printed "adds no declared-breaking changeset (1 non-breaking changeset(s) seen)"; at a254237d it prints "✓ 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition" with [BREAKING] not-required (no-migration-prescription). Bump correctly stays "@objectstack/core": minor.
  • BLOCKING 1 discharged. All eight keys and the null behaviour are enumerated in the changeset, and mirrored at both code carriers. The changeset now also states what is still accepted — unknown keys, version-less plugins, class-based plugins, and version — which bounds the narrowing rather than only widening the warning.
  • The check:doc-authoring trap was avoided. The vocabulary additions carry 0 #NNNNN prose ids, against a firing control of 78 elsewhere in that same file, and doc-authoring-prose-id.baseline.json is untouched. ⭐ Fixed by removing the id, ⛔ not by loosening the ratchet — the same way 479fa8bbb was fixed.

The fix round re-derived the finding instead of trusting it

Worth recording because it is the behaviour that makes a fix round trustworthy: rather than take my accept-set table on faith, it re-measured against zod 4.4.3 with the schema's own shape — null refuses on all eight, four unknown keys parse clean, a version-less object parses clean, 1.0.0-alpha.1 refuses at version (which the loader then filters out). Independent confirmation, not compliance.

It also found a fourth stale restatement neither the review nor I had asked about — the PR description itself still carried the three-key enumeration — and flagged it rather than silently rewriting a description the review was bound to. I authorised that edit; it is live (the stale sentence is gone, the eight-key sentence and a dated correction disclosure are present, head unmoved at a254237d, no commit added).

⚠️ One note carried forward, not blocking

The PR body's blast-radius census (line 41) measures in-repo producers of type / slug / homepage only, yet concludes "no in-repo plugin changes behaviour" — a conclusion broader than that census can carry now that eight keys are enforced. The conclusion still holds, but on different evidence: Dogfood Regression Gate (1/3, 2/3, 3/3) is green at this head, and it boots real apps through kernel.use() itself. A behavioural gate that exercises the door is stronger than any grep over producers. ⛔ No one is asked to widen the census; the citation is simply corrected here.

Also noted: the description now ends with two attribution footers, an artifact of the write path. Probed and found conditional, not accumulating. ⛔ Deliberately not "tidied" — deleting the original session-linked footer would remove a disclosure that was already there.

Landing

① this record, bound to a254237d. ② check-clause2-carriers --pair 16363exit 0, both carriers agree. ③ 36/36 complete, 0 failing — including Test Core ×6, Dogfood Regression Gate ×3, Lint & Repo Gates, and both Type Check gates. mergeable_state: clean. Card #16049 carries no sibling PR.

⚠️ Auto-merge is being re-enabled by hand: this PR was never draft-converted, but its sibling #16296 was, and the harness is explicit that a draft conversion permanently drops queue membership. Recording the distinction so the next seat does not assume un-drafting restores it.

Released.


Generated by Claude Code

@zhuangjianguo
zhuangjianguo marked this pull request as ready for review September 6, 2026 23:10
@zhuangjianguo
zhuangjianguo added this pull request to the merge queue Sep 6, 2026
Merged via the queue into main with commit 2025b1f Sep 6, 2026
41 checks passed
@zhuangjianguo
zhuangjianguo deleted the claude/issue-16049-enforce-pluginschema-at-kernel-use branch September 6, 2026 23:58
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/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants