You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
docs(core): enumerate all eight enforced plugin keys, and declare the 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 file name to clipboardExpand all lines: .changeset/enforce-plugin-schema-at-kernel-use.md
+23-6Lines changed: 23 additions & 6 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -2,9 +2,22 @@
2
2
"@objectstack/core": minor
3
3
---
4
4
5
-
`kernel.use()` now enforces the declared plugin contract. A plugin object with an **unknown `type`**, an **invalid `slug`** or an **invalid `homepage`** is refused at load instead of being stored and mounted.
5
+
`kernel.use()` now enforces the declared plugin contract. A plugin object that `PluginSchema` (`@objectstack/spec`, `kernel/plugin.zod.ts`) refuses is refused at load instead of being stored and mounted.
6
6
7
-
**This refuses input the runtime accepted before**, which is why it is not a `patch`: `PluginSchema` (`@objectstack/spec`, `kernel/plugin.zod.ts`) had zero runtime callers, so every constraint it declared beyond `name`, `init` and semver was a declaration with nothing behind it. The sharpest reading of that gap, one input and two answers: `defineStack` accepted `type: 'ui-plugin'` while `PluginSchema.safeParse` refused it — and only one of those answers was on the path a real plugin takes. Maintainer ruling of 2026-09-06 (ADR-0049 enforce-or-remove): the protocol is the baseline, the runtime aligns to it.
7
+
**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`). **This refuses input the runtime accepted before**, which is also why it is not a `patch`: `PluginSchema` had zero runtime callers, so every constraint it declared beyond `name`, `init` and semver was a declaration with nothing behind it. The sharpest reading of that gap, one input and two answers: `defineStack` accepted `type: 'ui-plugin'` while `PluginSchema.safeParse` refused it — and only one of those answers was on the path a real plugin takes. Maintainer ruling of 2026-09-06 (ADR-0049 enforce-or-remove): the protocol is the baseline, the runtime aligns to it.
8
+
9
+
**Exactly what is newly refused: all EIGHT declared keys, not three.** The schema declares nine optional keys; the loader excludes `version` (below), so enforcement reaches these eight, each refused with the offending key named in the message:
10
+
11
+
-**`id`** — a non-string, or the empty string (`z.string().min(1)`).
12
+
-**`type`** — any value outside the closed set `standard`, `ui`, `driver`, `server`, `app`, `theme`, `agent`, `objectql`.
13
+
-**`staticPath`** — a non-string.
14
+
-**`slug`** — a non-string, or a string that does not match `/^[a-z0-9-_]+$/`.
15
+
-**`default`** — a non-boolean.
16
+
-**`description`** — a non-string.
17
+
-**`author`** — a non-string. An object such as `{ name: 'x' }` is refused; the declared type is a plain string.
18
+
-**`homepage`** — a non-string, or a string that is not a URL.
19
+
20
+
**`null` is refused on every one of the eight.** These keys are `.optional()`, which admits absence and `undefined` — never an explicit `null`. A plugin object that spells "no value" as `null` on any of the eight loaded before and is refused now.
8
21
9
22
**What a refusal looks like.** It travels the loader's existing plugin-load error path — no new error channel — carrying the stable code `PLUGIN_CONTRACT_VIOLATION` at the head of the message and on the error's `code` property, and naming the plugin plus the first violated key:
10
23
@@ -15,10 +28,14 @@ plugin contract at 'type': Invalid option: expected one of "standard"|"ui"|…
15
28
16
29
A wrong `type` is therefore diagnosable at boot rather than at route mount. The code is a **boot refusal**, not wire vocabulary: it is raised before any HTTP boundary exists, and no door answers with it.
17
30
18
-
**What does NOT change.**
31
+
**What is STILL ACCEPTED — the door is not narrowed past those eight keys.** Measured on this tree, not assumed:
19
32
20
-
- The plugin object is validated, never replaced. `safeParse` is read for `success` and its output discarded, because a copy destroys the prototype chain of class-based plugins — the reason `PluginLoader.toPluginMetadata` is a cast. A class-based plugin's identity, prototype and prototype methods surviving `use()` is pinned by test, not asserted in prose.
21
-
-`PluginSchema`'s `.default('standard')` is **not** written back: a plugin declaring no `type` still loads and still stores no `type`.
22
-
-**`version` is deliberately excluded from this enforcement.** The schema spells it `/^\d+\.\d+\.\d+$/`, which refuses the prerelease and build-metadata forms SemVer 2.0.0 defines, while the loader's own `isValidSemanticVersion` implements the full grammar and accepts them — and does so deliberately, pinned by `plugin-loader.test.ts`. Enforcing the narrower spelling would retire that capability silently, so the loader's check remains authoritative for `version` and `1.0.0-alpha.1` / `1.0.0+20230101` still load. Reconciling the two spellings is spec work, tracked separately.
33
+
-**Unknown keys still pass.**`PluginSchema` is a plain `z.object` with **no `.strict()`** — the strip posture — and the parse output is discarded, so a valid plugin carrying four keys the schema never declares loads, and is stored as the very object that was passed in with all of its keys intact. A plugin is refused for what it says about a **declared** key, never for saying something extra.
34
+
-**A version-less plugin still loads**, exactly as before.
35
+
-**A plugin declaring no `type` still loads and still stores no `type`**: `PluginSchema`'s `.default('standard')` is **not** written back.
36
+
-**A class-based plugin keeps its identity, its prototype and its prototype methods.** The plugin object is validated, never replaced: `safeParse` is read for `success` and its output discarded, because a copy destroys the prototype chain of class-based plugins — the reason `PluginLoader.toPluginMetadata` is a cast. That survival is pinned by test, not asserted in prose.
37
+
-**`version` is excluded from this enforcement entirely**, so `1.0.0-alpha.1` and `1.0.0+20230101` still load. The schema spells `version` as `/^\d+\.\d+\.\d+$/`, which refuses the prerelease and build-metadata forms SemVer 2.0.0 defines, while the loader's own `isValidSemanticVersion` implements the full grammar and accepts them — deliberately, pinned by `plugin-loader.test.ts`. Enforcing the narrower spelling would retire that capability silently, so the loader's check remains authoritative for `version`. Reconciling the two spellings is spec work, tracked separately.
23
38
24
39
**Blast radius, measured rather than assumed.** Every in-repo plugin object declares a `type` inside the closed set (`standard` ×62, `server` ×2, `driver` ×2, `objectql`, `app`), and the repo contains no producer of `slug` or `homepage` on a plugin object at all — so no in-repo plugin changes behaviour. Externally authored plugins are the population this reaches, and they are exactly the population that never met the compile-time `Plugin.type` union either.
40
+
41
+
<!-- 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. -->
0 commit comments