Skip to content

Commit 8f404a5

Browse files
os-justinclaude
andauthored
feat(spec)!: plugins / devPlugins are artifact envelope keys — excluded from the assembled package body, refused inside packages[] (#15219) (#15289)
* feat(spec): `plugins` / `devPlugins` are artifact envelope keys, excluded from the assembled package body Maintainer ruling A for both keys (2026-09-04): the two keys are runtime assembly instructions, not metadata. They stay `concat` for in-memory composition and at the artifact top level; `assembledPackageBodyShape()` and the `AssembledPackageBodyKey` derivation now exclude them through one `ASSEMBLED_PACKAGE_BODY_ENVELOPE_KEYS` constant beside `packages`, so a body carrying either is refused at the manifest's strict close. Pins, an ADR-0087 semantic entry under protocol 18, and a BREAKING minor changeset ride along. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H2oQebDDxYKfWZusyd8GXk * test(cli): restate the nine artifact-envelope keys in the option-B acceptance pin and zoo prose Patch round on the seat's ruling: the literal envelope list at the pin's shape assertion gains `devPlugins` and `plugins` (nine keys, in the file's own sort order), and the zoo header/const prose that said "seven" says "nine". Mechanical restatement of the count where it is printed; nothing else in either file moves. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H2oQebDDxYKfWZusyd8GXk --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent cd1f8ee commit 8f404a5

7 files changed

Lines changed: 308 additions & 14 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec)!: `plugins` / `devPlugins` are artifact envelope keys — excluded from the assembled package body and refused inside `packages[]` (#15219)
6+
7+
<!-- adr-0087: registered assembled-package-body-plugins-envelope -->
8+
9+
**BREAKING** accept-set narrowing on `AssembledPackageBodySchema` — the body
10+
under `packages[i].manifest` of a release artifact (ADR-0130 D4): a body that
11+
carries `plugins` or `devPlugins` is now **refused** at the manifest's strict
12+
close (`unrecognized_keys`, naming the key), where it used to parse. Shipped as
13+
`minor` under the repo's launch-window convention for breaking changes; the
14+
hand-migration prescription is registered under protocol major 18. Maintainer
15+
ruling 2026-09-04 on #15219 (director decision batch #32, verbatim 「同意」):
16+
option A for both keys.
17+
18+
`plugins` and `devPlugins` were members of the assembled-body key set by the
19+
same derivation every other collection uses (`COMPOSE_KEY_DISPOSITIONS` gives
20+
both `concat`). They are the only members whose values are **runtime assembly
21+
instructions** rather than serialisable metadata: `plugins` holds what a host
22+
hands to `kernel.use()` — live plugin instances, manifests or package names —
23+
and `devPlugins` is the `os dev` load list. Inside an artifact a package body
24+
is inert JSON, so a plugin under `packages[i].manifest` could never be
25+
constructed by a loader; every reader reads the top level. The classification
26+
is corrected rather than special-cased: an artifact carries metadata, a host
27+
assembles plugins.
28+
29+
**What changes** (`packages/spec/src/stack.zod.ts`):
30+
31+
- `plugins` / `devPlugins` are **envelope keys** — top level only, never inside
32+
`packages[]`. `ASSEMBLED_PACKAGE_BODY_ENVELOPE_KEYS` (`packages`, `plugins`,
33+
`devPlugins`) is declared once and feeds both the `AssembledPackageBodyKey`
34+
derivation and `assembledPackageBodyShape()`.
35+
- Both keys stay `concat`: a live stack still concatenates its plugins to the
36+
top level under `composeStacks`, and `manifest: 'preserve'` no longer folds
37+
them into any package body.
38+
- The two declarations on the stack schema are unchanged.
39+
40+
**What does NOT change:** `os serve` / `os migrate` / `os dev` keep reading the
41+
top level (now correct by construction); no CLI, core or runtime code moves.
42+
43+
## FROM → TO
44+
45+
```ts
46+
// before — a package body inside an artifact could carry plugins nobody could load
47+
{ packages: [{ manifest: { id: 'com.example.crm', /**/ plugins: [{ name: 'plugin.x' }] } }] }
48+
49+
// after — plugins live on the artifact envelope only; the body above is refused:
50+
// packages.0.manifest: unrecognized_keys ['plugins']
51+
{ plugins: [new CrmPlugin()], packages: [{ manifest: { id: 'com.example.crm', /**/ } }] }
52+
```
53+
54+
**Migration.** Declare `plugins` / `devPlugins` at the stack top level and
55+
delete them from every `packages[i].manifest`. An existing multi-package
56+
artifact that carries `packages[i].manifest.plugins` (if `os build` ever wrote
57+
one — not directly measured) is refused on load after this change and must be
58+
rebuilt from source; a hand-written `packages[]` entry drops the keys. Stacks
59+
that only ever declared the two keys at the top level parse byte-identically.

packages/cli/test/fixtures/option-b-collection-zoo.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
* The key set that separates them is **derived from the two schemas**, never
2424
* transcribed here — `ObjectStackDefinitionSchema` ∩ `AssembledPackageBodySchema`
2525
* is precisely "the collections a package owns", and the complement is the
26-
* seven artifact-envelope keys `packages/spec/src/assembled-package-body.test.ts`
26+
* nine artifact-envelope keys `packages/spec/src/assembled-package-body.test.ts`
2727
* classifies. A hand-written list would be a third transcription of a set the
2828
* implementation already refuses to transcribe, and — the half that matters
2929
* here — a collection family added to the stack schema next month would join
@@ -64,7 +64,7 @@ export const PACKAGE_OWNED_COLLECTION_KEYS: readonly string[] = (() => {
6464
return shapeKeys(ObjectStackDefinitionSchema).filter((k) => bodyKeys.has(k)).sort();
6565
})();
6666

67-
/** The seven keys an option-B artifact still carries at its top level. */
67+
/** The nine keys an option-B artifact still carries at its top level. */
6868
export const ARTIFACT_ENVELOPE_KEYS: readonly string[] = (() => {
6969
const owned = new Set(PACKAGE_OWNED_COLLECTION_KEYS);
7070
return shapeKeys(ObjectStackDefinitionSchema).filter((k) => !owned.has(k)).sort();

packages/cli/test/option-b-reader-acceptance.pin.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ describe('#15004 — option-B acceptance pin: every subsystem must see its colle
196196
// than two empties agreeing.
197197
expect(PACKAGE_OWNED_COLLECTION_KEYS.length).toBeGreaterThan(30);
198198
expect(ARTIFACT_ENVELOPE_KEYS).toEqual(
199-
['api', 'i18n', 'manifest', 'onEnable', 'packages', 'runtimeModule', 'server'],
199+
['api', 'devPlugins', 'i18n', 'manifest', 'onEnable', 'packages', 'plugins', 'runtimeModule', 'server'],
200200
);
201201

202202
// Every key option B drops is a package-owned collection, and every key it

packages/spec/src/assembled-package-body.test.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@
3737
* per-package attribution exists: the composed stack flattens every
3838
* collection to the top level, and a flattened array cannot say which
3939
* package each item came from.
40+
* 4. **`plugins` / `devPlugins` are envelope keys** (#15219, maintainer ruling
41+
* A for both keys, 2026-09-04): runtime assembly instructions, not metadata.
42+
* They stay `concat` for in-memory composition and stay at the top level,
43+
* and a body that carries one is REFUSED at the manifest's strict close —
44+
* a plugin inside `packages[i].manifest` is inert JSON no loader could
45+
* construct, so the alternative to refusal was a reader registering garbage.
4046
*/
4147

4248
import { describe, it, expect } from 'vitest';
@@ -69,6 +75,8 @@ const ARTIFACT_ENVELOPE_KEYS = [
6975
'i18n', // one artifact, one supported-locale declaration
7076
'runtimeModule', // written by the compiler, per ARTIFACT
7177
'onEnable', // one bundle, one lifecycle hook (AppPlugin invokes a single one)
78+
'plugins', // runtime assembly instructions a host hands to `kernel.use()` — not metadata (#15219 ruling A)
79+
'devPlugins', // the `os dev` load list — the same class as `plugins` (#15219 ruling A)
7280
].sort();
7381

7482
const shapeKeys = (schema: unknown): string[] =>
@@ -260,3 +268,95 @@ describe("ADR-0130 D4 — `manifest: 'preserve'` assembles each input stack", ()
260268
expect(objectNames(parsed[0].manifest.objects)).toEqual(['crm_order']);
261269
});
262270
});
271+
272+
// ─── 4. `plugins` / `devPlugins` are envelope keys (#15219) ──────────
273+
274+
describe('#15219 A — `plugins` / `devPlugins` are envelope keys: top level only, never inside `packages[]`', () => {
275+
/** What a host hands to `kernel.use()` — a live instance, not metadata. */
276+
const livePlugin = { name: 'plugin.example', init: () => undefined };
277+
278+
/** The unrecognized-keys issue a strict close raises, or undefined. */
279+
const unrecognizedKeys = (verdict: ReturnType<typeof AssembledPackageBodySchema.safeParse>) => {
280+
if (verdict.success) return undefined;
281+
const issue = verdict.error.issues.find((i) => i.code === 'unrecognized_keys');
282+
return issue
283+
? { path: issue.path.map(String), keys: (issue as unknown as { keys: string[] }).keys }
284+
: undefined;
285+
};
286+
287+
it('both keys are absent from the body key set while the stack schema still declares both', () => {
288+
const stackKeys = shapeKeys(ObjectStackDefinitionSchema);
289+
const bodyKeys = shapeKeys(AssembledPackageBodySchema);
290+
291+
// The stack half first: an exclusion is only an exclusion if the key is
292+
// still there to be excluded — a key that vanished from the stack schema
293+
// would satisfy the body assertions below for the wrong reason.
294+
expect(stackKeys).toContain('plugins');
295+
expect(stackKeys).toContain('devPlugins');
296+
297+
expect(bodyKeys).not.toContain('plugins');
298+
expect(bodyKeys).not.toContain('devPlugins');
299+
});
300+
301+
it('a body carrying `plugins` is refused at the strict close, naming the key', () => {
302+
// Issue code + path + the key, never "it threw": the door is
303+
// `ManifestSchema`'s strict close carried through `.extend()`, and this is
304+
// the same instrument the `somethingUndeclared` pin above reads.
305+
const verdict = AssembledPackageBodySchema.safeParse({ ...coreManifest, plugins: [livePlugin] });
306+
expect(verdict.success).toBe(false);
307+
expect(unrecognizedKeys(verdict)).toEqual({ path: [], keys: ['plugins'] });
308+
});
309+
310+
it('a body carrying `devPlugins` is refused the same way — serialisable or not, it is a load instruction', () => {
311+
const verdict = AssembledPackageBodySchema.safeParse({ ...coreManifest, devPlugins: ['@example/dev-tools'] });
312+
expect(verdict.success).toBe(false);
313+
expect(unrecognizedKeys(verdict)).toEqual({ path: [], keys: ['devPlugins'] });
314+
});
315+
316+
it("through the artifact wrapper the refusal is located at `manifest` — the load gate's seam", () => {
317+
// `artifact-packages.ts` parses every `packages[]` entry with
318+
// `ArtifactPackageSchema`; this is the path its refusal message quotes.
319+
const verdict = ArtifactPackageSchema.safeParse({ manifest: { ...coreManifest, plugins: [livePlugin] } });
320+
expect(verdict.success).toBe(false);
321+
expect(unrecognizedKeys(verdict)).toEqual({ path: ['manifest'], keys: ['plugins'] });
322+
});
323+
324+
it('composition keeps both at the top level (concat, in stack order) and out of every package body', () => {
325+
const core = defineStack({
326+
manifest: coreManifest,
327+
objects: [accountObject],
328+
plugins: [livePlugin],
329+
devPlugins: ['@example/dev-core'],
330+
});
331+
const orders = defineStack({
332+
manifest: ordersManifest,
333+
objects: [orderObject],
334+
plugins: [{ name: 'plugin.orders' }],
335+
devPlugins: ['@example/dev-orders'],
336+
});
337+
338+
const composed = composeStacks([core, orders], { manifest: 'preserve' });
339+
340+
// Live stacks still concatenate their plugins to the top level — the
341+
// `concat` disposition is untouched by the envelope exclusion.
342+
expect((composed.plugins as { name: string }[]).map((p) => p.name)).toEqual(['plugin.example', 'plugin.orders']);
343+
expect(composed.devPlugins).toEqual(['@example/dev-core', '@example/dev-orders']);
344+
345+
// …and no package body carries either: the assembler reads the derived
346+
// body shape, so the exclusion reaches composition without a second list.
347+
const entries = (composed as { packages?: { manifest: Record<string, unknown> }[] }).packages ?? [];
348+
expect(entries).toHaveLength(2);
349+
for (const entry of entries) {
350+
expect(entry.manifest, `${entry.manifest.id} carries plugins`).not.toHaveProperty('plugins');
351+
expect(entry.manifest, `${entry.manifest.id} carries devPlugins`).not.toHaveProperty('devPlugins');
352+
}
353+
354+
// SEAM 2/3 for a plugin-carrying host: the composed artifact parses, and
355+
// the top-level plugins survive the parse.
356+
const parsed = ObjectStackDefinitionSchema.safeParse(composed);
357+
expect(parsed.success).toBe(true);
358+
if (!parsed.success) return;
359+
expect(parsed.data.plugins).toHaveLength(2);
360+
expect(parsed.data.devPlugins).toEqual(['@example/dev-core', '@example/dev-orders']);
361+
});
362+
});
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { SemanticMigration } from '../../types.js';
4+
5+
// #15219 — maintainer ruling A for both keys (2026-09-04, director relay,
6+
// verbatim 「同意」): `plugins` and `devPlugins` are artifact ENVELOPE keys —
7+
// top level only, never inside `packages[]`. Registered as D3 SEMANTIC and
8+
// deliberately NOT as a D2 conversion, on the D2 scope guard (lossless only):
9+
// a plugin declared inside an assembled package body has no lossless target.
10+
// `plugins` is `z.array(z.unknown())` and may hold live instances that never
11+
// survived JSON in the first place, and hoisting a serialisable entry to the
12+
// artifact top level changes WHO loads it (the host, not a package) — a
13+
// judgment the author makes. Nothing is retired: both keys stay declared on
14+
// the stack schema at the top level, so no RETIRED_KEYS entry, and the
15+
// refusal inside a body is the manifest's own strict close naming the key.
16+
export const entry: SemanticMigration = {
17+
id: 'assembled-package-body-plugins-envelope',
18+
surface:
19+
'artifact `packages[].manifest.plugins` and `packages[].manifest.devPlugins` — the two '
20+
+ 'keys inside an ASSEMBLED package body (`AssembledPackageBodySchema`, ADR-0130 D4)',
21+
replacement:
22+
'Declare `plugins` / `devPlugins` at the stack TOP LEVEL only — the artifact envelope, '
23+
+ 'where `os serve` / `os migrate` / `os dev` read them and where `composeStacks` still '
24+
+ 'concatenates them (`concat` is unchanged for in-memory composition). Delete both keys '
25+
+ 'from every `packages[i].manifest` body: a multi-package artifact that carried them is '
26+
+ 'rebuilt from source (`os build` / `composeStacks(…, { manifest: \'preserve\' })` no '
27+
+ 'longer folds them into a body), and a hand-written `packages[]` entry drops them.',
28+
reason:
29+
'A classification error, not a new special case (#15219; epic #14122 / #14512). '
30+
+ '`plugins` and `devPlugins` were the only members of the assembled-body key set whose '
31+
+ 'values are runtime ASSEMBLY instructions rather than serialisable metadata: `plugins` '
32+
+ 'holds what a host hands to `kernel.use()` — live plugin instances, manifests or package '
33+
+ 'names — and `devPlugins` is the `os dev` load list. Inside an artifact a package body is '
34+
+ 'inert JSON, so a plugin written under `packages[i].manifest` could never be constructed '
35+
+ 'by any loader; every reader (`serve.ts`, `schema-migration-plugins.ts`) reads the top '
36+
+ 'level, and the "resolve `packages[]` when the top level is absent" repair every other '
37+
+ 'reader took would have turned a silent skip into a boot that registers garbage. Options '
38+
+ 'B (readers resolve JSON descriptions into live plugins) and C (the emitter special-cases '
39+
+ 'the two keys) were refused. After the ruling, "an artifact carries metadata, a host '
40+
+ 'assembles plugins" is one sentence every reader inherits. Not losslessly convertible: '
41+
+ 'hoisting a body-level plugin to the envelope changes who loads it, and a live instance '
42+
+ 'has no JSON form to move.',
43+
acceptanceCriteria:
44+
'No `packages[i].manifest` in any artifact carries `plugins` or `devPlugins`; the body '
45+
+ 'schema refuses either with `unrecognized_keys` naming the key (pinned in '
46+
+ '`assembled-package-body.test.ts`), and `artifact-packages.ts` refuses the entry at '
47+
+ 'load with that path. A stack declaring `plugins` / `devPlugins` at the top level '
48+
+ 'parses byte-identically to before, `composeStacks` still concatenates both in stack '
49+
+ 'order, and `manifest: \'preserve\'` emits package bodies without them. An existing '
50+
+ 'multi-package artifact that carried a body-level `plugins` is rebuilt from source.',
51+
};

packages/spec/src/migrations/registry.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5522,6 +5522,53 @@ const step18: MigrationStep = {
55225522
+ 'every `/analytics/query` body\'s `timeDimensions[]` items carry only '
55235523
+ '`dimension`/`granularity`/`dateRange`. Declared keys parse byte-identically to before.',
55245524
},
5525+
// #15219 — maintainer ruling A for both keys (2026-09-04, director relay,
5526+
// verbatim 「同意」): `plugins` and `devPlugins` are artifact ENVELOPE keys —
5527+
// top level only, never inside `packages[]`. Registered as D3 SEMANTIC and
5528+
// deliberately NOT as a D2 conversion, on the D2 scope guard (lossless only):
5529+
// a plugin declared inside an assembled package body has no lossless target.
5530+
// `plugins` is `z.array(z.unknown())` and may hold live instances that never
5531+
// survived JSON in the first place, and hoisting a serialisable entry to the
5532+
// artifact top level changes WHO loads it (the host, not a package) — a
5533+
// judgment the author makes. Nothing is retired: both keys stay declared on
5534+
// the stack schema at the top level, so no RETIRED_KEYS entry, and the
5535+
// refusal inside a body is the manifest's own strict close naming the key.
5536+
{
5537+
id: 'assembled-package-body-plugins-envelope',
5538+
surface:
5539+
'artifact `packages[].manifest.plugins` and `packages[].manifest.devPlugins` — the two '
5540+
+ 'keys inside an ASSEMBLED package body (`AssembledPackageBodySchema`, ADR-0130 D4)',
5541+
replacement:
5542+
'Declare `plugins` / `devPlugins` at the stack TOP LEVEL only — the artifact envelope, '
5543+
+ 'where `os serve` / `os migrate` / `os dev` read them and where `composeStacks` still '
5544+
+ 'concatenates them (`concat` is unchanged for in-memory composition). Delete both keys '
5545+
+ 'from every `packages[i].manifest` body: a multi-package artifact that carried them is '
5546+
+ 'rebuilt from source (`os build` / `composeStacks(…, { manifest: \'preserve\' })` no '
5547+
+ 'longer folds them into a body), and a hand-written `packages[]` entry drops them.',
5548+
reason:
5549+
'A classification error, not a new special case (#15219; epic #14122 / #14512). '
5550+
+ '`plugins` and `devPlugins` were the only members of the assembled-body key set whose '
5551+
+ 'values are runtime ASSEMBLY instructions rather than serialisable metadata: `plugins` '
5552+
+ 'holds what a host hands to `kernel.use()` — live plugin instances, manifests or package '
5553+
+ 'names — and `devPlugins` is the `os dev` load list. Inside an artifact a package body is '
5554+
+ 'inert JSON, so a plugin written under `packages[i].manifest` could never be constructed '
5555+
+ 'by any loader; every reader (`serve.ts`, `schema-migration-plugins.ts`) reads the top '
5556+
+ 'level, and the "resolve `packages[]` when the top level is absent" repair every other '
5557+
+ 'reader took would have turned a silent skip into a boot that registers garbage. Options '
5558+
+ 'B (readers resolve JSON descriptions into live plugins) and C (the emitter special-cases '
5559+
+ 'the two keys) were refused. After the ruling, "an artifact carries metadata, a host '
5560+
+ 'assembles plugins" is one sentence every reader inherits. Not losslessly convertible: '
5561+
+ 'hoisting a body-level plugin to the envelope changes who loads it, and a live instance '
5562+
+ 'has no JSON form to move.',
5563+
acceptanceCriteria:
5564+
'No `packages[i].manifest` in any artifact carries `plugins` or `devPlugins`; the body '
5565+
+ 'schema refuses either with `unrecognized_keys` naming the key (pinned in '
5566+
+ '`assembled-package-body.test.ts`), and `artifact-packages.ts` refuses the entry at '
5567+
+ 'load with that path. A stack declaring `plugins` / `devPlugins` at the top level '
5568+
+ 'parses byte-identically to before, `composeStacks` still concatenates both in stack '
5569+
+ 'order, and `manifest: \'preserve\'` emits package bodies without them. An existing '
5570+
+ 'multi-package artifact that carried a body-level `plugins` is rebuilt from source.',
5571+
},
55255572
{
55265573
id: 'audience-posture-default-invite-only',
55275574
surface: 'system.AuthConfig.audience',

0 commit comments

Comments
 (0)