feat(core): enforce PluginSchema at kernel.use() (#16049) - #16363
Conversation
`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
📓 Docs Drift Check6 anchor(s) derived from 2 changed package(s); no hand-written page names any of them, so this run has nothing to list — not 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
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 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 |
…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
|
CI fix for The gate that failed is Before — head 479fa8b, After — head ea8af40, same command, REAL_EXIT=0:
The
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: Generated by Claude Code |
Contract review (clause ②) — NOT PASSED, bound to head
|
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.mdinside 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 disposition … They 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.
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
|
Fix round for the contract review's two blocking findings (
BLOCKING 1 — all eight keys, and
|
| 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
Contract review (clause ②) — PASS, bound to head
|
reading at ea8af40e → a254237d |
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 1adr-0087marker. The gate's output moved accordingly: atea8af40eit printed "adds no declared-breaking changeset (1 non-breaking changeset(s) seen)"; ata254237dit 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
nullbehaviour 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, andversion— which bounds the narrowing rather than only widening the warning. - The
check:doc-authoringtrap was avoided. The vocabulary additions carry 0#NNNNNprose ids, against a firing control of 78 elsewhere in that same file, anddoc-authoring-prose-id.baseline.jsonis untouched. ⭐ Fixed by removing the id, ⛔ not by loosening the ratchet — the same way479fa8bbbwas 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 16363 → exit 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.
Released.
Generated by Claude Code
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 ofrather than a closing keyword, deliberately, and the reason is the one open item below: this PR enforces the schema except itsversionkey, 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.loadPluginnow runsPluginSchema.safeParseover every plugin object and refuses one the schema refuses, through the loader's existing error path — no new error channel — with the stable codePLUGIN_CONTRACT_VIOLATIONnaming the plugin and the first violated key:The code sits at the head of the message as well as on
err.code, becauseObjectKernel.use()re-wraps a failed load into a freshErrorcarrying onlyresult.error?.message— a code living only on the property would not reach the caller that sees the boot fail. It is registered as aboot-refusal/door: 'none'row inpackages/runtime/src/dispatcher-error-vocabulary.ts, the same class asSERVICE_NOT_REGISTERED: raised before any HTTP boundary exists, so no door answers with it.packages/core/src/types.tsno longer saysPluginSchema.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
successand discarded. A copy destroys the prototype chain of class-based plugins, which is exactly whytoPluginMetadatais 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:PluginSchema.parse/.safeParsecall sites, all tracked filesObjectSchema.parse/.safeParsecall sitesvalidatePluginStructurereads!plugin.name·!plugin.init·!isValidSemanticVersion(version)— nevertypetype: 'ui'staticPathhono-plugin.ts, 2 generated baselines, 3 prose linestype: 'standard'occurrencesAll confirmed. The bare-identifier substring trap the triage comment warns about is real and was avoided:
git grep PluginSchemareturns hits dominated byplugin-auth's unrelatedbuild*PluginSchemafamily.Blast radius, measured
Every in-repo plugin object declares a
typeinside the closed set —standard×62,server×2,driver×2,objectql,app— and no in-repo plugin object carriesslugorhomepageat all. No in-repo plugin changes behaviour. Two near-misses were checked and cleared:ApiTrigger/ScheduleTriggercarrytype = 'api'/'schedule'but implementFlowTrigger, neverPlugin. One inert drift is noted, not touched: the mockedI18nServicePlugininplugin-dev'sdev-i18n-packages-reader.test.tsdeclarestype = 'service', outside the closed set — that test constructs no kernel, so it never reaches this path, and the real plugin declaresstandard.versionis deliberately NOT enforced — the one open itemMeasured, not assumed.
PluginSchema.versionis/^\d+\.\d+\.\d+$/, which refuses the prerelease and build-metadata forms SemVer 2.0.0 defines. The loader's ownisValidSemanticVersion— the check that has always run — implements the full grammar and accepts them, andpackages/core/src/plugin-loader.test.tspins 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 shipversion = '0.0.0-fixture'and boot through the real kernel.So enforcing the schema's
versionverbatim would not enforce the protocol — it would retire a pinned capability, silently, under a card that ruled ontype. What this enforcement DOES refuse is the other eight declared keys —id,type,staticPath,slug,default,description,author,homepage, plus an explicitnullon any of them, all eight being.optional()— andversionis not among them. Neither are unknown keys:PluginSchemais a plainz.objectwith no.strict(), so a plugin carrying keys the schema never declares still loads. The changeset enumerates all eight and thatnullbehaviour. 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 ispackages/specwork 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 atea8af40e4. The commit that fixed the three carriers isa254237d1; 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.tsswapped back toc24e2d37c(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: theui-plugin,code-property, class-based-refusal,slugandhomepagecases go red, while every calibration and everyversioncase 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.jstosrc/, and the 5 red cases are that claim's own firing control — adist-resolved import would have stayed green.Green at
479fa8bbb:@objectstack/corefull suite 51 files / 1230 tests;@objectstack/coreand@objectstack/runtimetypecheck (includingcheck:test-typecheck, which is the program that actually sweeps*.test.ts— the maintsc --noEmitexcludes them, and it was the test program that caught 9 real type errors here);@objectstack/runtimeerror-envelope.conformance+package-door-error-parity61 tests; andplugin-hono-server'sui-plugin-auto-discovery.pin.test.ts14 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--reportnames 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 30dist/directories andpackages/core/distis one of them, so that green is about this package;packages/runtimehas no localdistand 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 ondist/index.d.tsanddist/index.d.cts— nothing added, nothing removed;dist/logger.d.ts/.d.ctsare byte-identical. The byte delta is classified rather than eyeballed: of 63 added lines and 2 removed, exactly one added line is not a comment —private 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.tsname set: this change narrows what a published runtime face accepts. Input thatkernel.use()accepted and stored yesterday is refused today, and the affected population is precisely the externally authored plugins that never met the compile-timePlugin.typeunion — 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-reviewis hung on both carriers.Not in scope, ruled so
The "required for
type: ui" refine and the corePlugininterface 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 inui-plugin-auto-discovery.pin.test.tsstaysit.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 thedomain:engineexecution PM seat.Generated by Claude Code
Generated by Claude Code