Skip to content

Commit a254237

Browse files
committed
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
1 parent ea8af40 commit a254237

3 files changed

Lines changed: 62 additions & 11 deletions

File tree

.changeset/enforce-plugin-schema-at-kernel-use.md

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,22 @@
22
"@objectstack/core": minor
33
---
44

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.
66

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.
821

922
**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:
1023

@@ -15,10 +28,14 @@ plugin contract at 'type': Invalid option: expected one of "standard"|"ui"|…
1528

1629
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.
1730

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:
1932

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.
2338

2439
**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. -->

packages/core/src/plugin-loader.ts

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,36 @@ export class PluginLoader {
425425
* mounted routes. A wrong `type` surfaced (if at all) at route mount; it
426426
* now surfaces here, named, at `kernel.use()`.
427427
*
428+
* ## What this refuses: the EIGHT declared keys, and `null` on any of them
429+
*
430+
* `PluginSchema` declares nine optional keys; the filter below drops
431+
* `version` (see below), so the accept-set narrowing this method
432+
* performs covers exactly these eight, each reported as `at '<key>'`:
433+
*
434+
* - `id` — a non-string, or the empty string (`z.string().min(1)`).
435+
* - `type` — outside the closed set `'standard'` + `CORE_PLUGIN_TYPES`.
436+
* - `staticPath` — a non-string.
437+
* - `slug` — a non-string, or not matching `/^[a-z0-9-_]+$/`.
438+
* - `default` — a non-boolean.
439+
* - `description` — a non-string.
440+
* - `author` — a non-string; an object such as `{ name }` is refused.
441+
* - `homepage` — a non-string, or a string that is not a URL.
442+
*
443+
* All eight are `.optional()`, which admits absence and `undefined` but
444+
* never an explicit `null` — so `null` on any of the eight is refused too.
445+
*
446+
* ⛔ ENUMERATE ALL EIGHT wherever this is restated. The changeset ships to
447+
* consumers as `CHANGELOG.md` and is what an upgrading author greps after
448+
* the refusal, so a shorter enumeration there does not merely omit keys —
449+
* it tells an author refused `at 'author'` that their key is not enforced.
450+
* This comment, the changeset and the `PLUGIN_CONTRACT_VIOLATION` row in
451+
* `dispatcher-error-vocabulary.ts` are the three places that restate it.
452+
*
453+
* What this does NOT refuse, which is what bounds the narrowing: UNKNOWN
454+
* keys. `PluginSchema` is a plain `z.object` with no `.strict()` — the
455+
* strip posture — and the parse output is discarded here, so a plugin
456+
* carrying keys the schema never declares still loads, stored verbatim.
457+
*
428458
* ## ⛔ safeParse for VALIDATION ONLY — the parse output is discarded
429459
*
430460
* The returned object is a COPY, and {@link toPluginMetadata} exists
@@ -451,10 +481,10 @@ export class PluginLoader {
451481
*
452482
* So enforcing the schema's `version` here would not enforce the protocol —
453483
* it would RETIRE a pinned capability, silently, under a card that ruled on
454-
* `type`. The ruling's own changeset note enumerates what this refuses:
455-
* an unknown `type`, an invalid `slug`, an invalid `homepage`. Version is
456-
* not in it, and the version check that already runs is the wider, correct
457-
* one. Reconciling the two spellings belongs in `packages/spec` beside
484+
* `type`. Version is not among the eight keys enumerated above, and the
485+
* version check that already runs is the wider, correct one: a version-less
486+
* plugin loads, and so do `1.0.0-alpha.1` and `1.0.0+20230101`.
487+
* Reconciling the two spellings belongs in `packages/spec` beside
458488
* #16334; until then this exclusion is declared here rather than performed
459489
* by leaving the disagreement unmeasured.
460490
*/

packages/runtime/src/dispatcher-error-vocabulary.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -763,7 +763,11 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [
763763
verdict: 'boot-refusal',
764764
why:
765765
'Raised by `PluginLoader.validatePluginContract` when a plugin object does not satisfy the '
766-
+ 'declared `PluginSchema` — an unknown `type`, an invalid `slug`, an invalid `homepage`. It is '
766+
+ 'declared `PluginSchema` on any of the EIGHT keys that enforcement covers — `id`, `type`, '
767+
+ '`staticPath`, `slug`, `default`, `description`, `author`, `homepage` — including an explicit '
768+
+ '`null` on any of them, since all eight are `.optional()` and admit absence but not `null`. '
769+
+ '`version` is excluded from the enforcement, and unknown keys are not refused at all (the '
770+
+ 'schema carries no `.strict()`), so the narrowing stops at those eight. It is '
767771
+ 'raised while the kernel is still registering plugins, before bootstrap and therefore before '
768772
+ 'any HTTP boundary exists: `ObjectKernel.use()` re-wraps it into a fresh `Error` that the host '
769773
+ 'rethrows and the process aborts on, so no door can answer with it and no door can demote it. '

0 commit comments

Comments
 (0)