Skip to content

Commit 4d47afe

Browse files
os-zhuangclaude
andauthored
feat(spec): retire the inert additionalTypes key from MetadataPluginConfig (#8586, ADR-0049) (#8702)
* feat(spec): retire inert MetadataPluginConfig.additionalTypes (#8586, ADR-0049) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E5tUwGM3LQoqErTfkvRW7W * merge origin/main (os-regen artifacts taken from main; regeneration follows) * chore(spec): regenerate registry and reference artifacts after merging main (Batch D analytics entry absorbed; 86 semantic) --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6158146 commit 4d47afe

13 files changed

Lines changed: 409 additions & 49 deletions

File tree

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): retire the inert `additionalTypes` key from `MetadataPluginConfig` (#8586, ADR-0049)
6+
7+
**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep
8+
launch-window convention ships it as `minor`; the migration prescription is
9+
registered under protocol major 18, where `os migrate meta` users will look).
10+
11+
`MetadataPluginConfig.additionalTypes` was declared, authorable, and documented
12+
on four docs pages as THE way a plugin registers a custom metadata type — and
13+
read by **nothing**. The only production writer of the manager's type registry
14+
is `setTypeRegistry(DEFAULT_METADATA_TYPE_REGISTRY)`, called exactly once, and
15+
it replaces the array outright: measured on the real `MetadataManager`,
16+
declared count == live count (27 == 27). An author who followed the published
17+
instructions wrote the key, got no error, and nothing happened — the #4212
18+
`onInstall` silence trap one level down (maintainer-ruled REMOVE, 2026-08-14).
19+
20+
**What is refused:** an authored `additionalTypes` on `MetadataPluginConfig`
21+
(inline or via the manifest's `config` embed). The key is a `retiredKey()`
22+
tombstone — the schema is not `.strict()`, so a plain deletion would have
23+
silently stripped it — refused at `tsc` (typed `never`) and at the parse
24+
(`invalid_type` at path `additionalTypes`, message carrying the prescription).
25+
26+
**What stays accepted:** every `MetadataPluginConfig` without the key,
27+
byte-identically. Runtime behaviour is unchanged: nothing ever read the key,
28+
so removing it removes no behaviour.
29+
30+
The retirement kit:
31+
32+
- tombstone at the schema (`packages/spec/src/kernel/metadata-plugin.zod.ts`)
33+
- ADR-0087 registration: retired-key entry
34+
`kernel/MetadataPluginConfig:additionalTypes` + D3 semantic entry
35+
`metadata-plugin-additional-types-retired`, both under protocol 18 (no D2
36+
conversion — a plugin config is not a stack collection member, the
37+
`kernel/Manifest:loading` precedent)
38+
- pin tests (`additional-types-retirement.test.ts`)
39+
- docs corrected: `content/docs/plugins/adding-a-metadata-type.mdx` (four
40+
sites) now describes how a kind actually enters the live set — as a side
41+
effect of registering an item of that kind; the generated reference page
42+
follows the schema
43+
- the two source comments that asserted the phantom growth path
44+
(`metadata-manager.ts`, `metadata-protocol/src/protocol.ts`) and the
45+
`registerMetadataTypeSchema` doc note corrected
46+
47+
## FROM → TO
48+
49+
```ts
50+
// before — parsed green; the entries were merged into nothing
51+
const config: MetadataPluginConfig = {
52+
storage: {},
53+
additionalTypes: [{ type: 'chart', label: 'Chart', filePatterns: ['**/*.chart.ts'], domain: 'ui' }],
54+
};
55+
56+
// after — delete the key; register items of the kind instead, and bind its schema
57+
const config: MetadataPluginConfig = { storage: {} };
58+
// in the plugin: registerMetadataTypeSchema('chart', ChartSchema) from init(ctx);
59+
// the kind enters the live set when an item of it is registered.
60+
```
61+
62+
<!-- adr-0087: registered metadata-plugin-additional-types-retired -->

content/docs/plugins/adding-a-metadata-type.mdx

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@ files; a third file is required only if you want a bespoke editor.
1414
## TL;DR
1515

1616
```
17-
1. Register the type entry: built-in -> DEFAULT_METADATA_TYPE_REGISTRY;
18-
plugin -> additionalTypes on MetadataPluginConfig (packages/spec)
17+
1. Register the type: built-in -> add an entry to DEFAULT_METADATA_TYPE_REGISTRY
18+
(packages/spec); plugin -> register items of the type (the kind enters the
19+
live set as a side effect -- there is no declared-kind config key)
1920
2. Define a Zod schema for the type (packages/spec/src/<domain>/)
2021
3. (Optional) Register a custom editor (objectui/.../builtinComponents.tsx)
2122
```
@@ -67,11 +68,20 @@ use, not pinned at process start.
6768

6869
`DEFAULT_METADATA_TYPE_REGISTRY` is the core built-in array — edit it (and
6970
`BUILTIN_METADATA_TYPE_SCHEMAS`, step 2) only for types that ship with the
70-
platform. A third-party package contributes its own types instead through
71-
the **`additionalTypes`** array on `MetadataPluginConfig`, and registers the
72-
matching Zod schema with `registerMetadataTypeSchema(type, schema)` from its
73-
plugin's `init(ctx)` so `GET /api/v1/meta` emits a real JSON Schema. The
74-
registry entry shape is the same in both cases.
71+
platform. It is also the **total universe of declared types**: there is no
72+
config key through which a package declares a new type. (Through v17 the
73+
schema carried an `additionalTypes` array on `MetadataPluginConfig` that was
74+
documented for exactly that — it had no reader anywhere and was retired by
75+
#8586, ADR-0049; authoring it is now a loud parse error.)
76+
77+
A third-party package's type instead enters the live set **as a side effect
78+
of registering items of that type**: items your package's manifest carries
79+
are registered through `SchemaRegistry.registerItem` during app/manifest
80+
registration, and runtime code can register items with
81+
`MetadataManager.register`. The first registered item admits the type; a
82+
type with no items is not in the live set. Alongside the items, register the
83+
matching Zod schema with `registerMetadataTypeSchema(type, schema)` from
84+
your plugin's `init(ctx)` so `GET /api/v1/meta` emits a real JSON Schema.
7585

7686
## 2. Define the Zod schema
7787

@@ -126,11 +136,12 @@ editor consumes from the registered Zod schema.
126136
}
127137
```
128138

129-
`getMetaTypes()` reads this registry on every request, so a type registered
139+
`getMetaTypes()` reads this registry on every request, so a schema registered
130140
during `init` is served from the first call onward. Registering the schema
131141
does not by itself put the type in the listing — `getMetaTypes()` enumerates
132142
types from the engine registry and the metadata service and then decorates
133-
each with its schema, so declare the type (via `additionalTypes`) as well.
143+
each with its schema, so the type must also have at least one registered
144+
item (that side effect is what admits it; see step 1).
134145

135146
The Metadata Admin **SchemaForm** consumes the JSON Schema derived from this
136147
Zod schema and produces:
@@ -232,7 +243,7 @@ If you omit them, the directory falls back to the registry `label`.
232243

233244
## Checklist
234245

235-
- [ ] Registry entry added (`type`, `label`, `domain`, required `filePatterns`, flags) — for plugins, via `additionalTypes` on `MetadataPluginConfig`
246+
- [ ] Type registered: built-in → registry entry added to `DEFAULT_METADATA_TYPE_REGISTRY` (`type`, `label`, `domain`, required `filePatterns`, flags); plugin → at least one item of the type registered (the side effect that admits the type — there is no declared-kind config key)
236247
- [ ] Zod schema authored under `packages/spec/src/<domain>/<name>.zod.ts`
237248
- [ ] Zod schema wired up: built-in → `BUILTIN_METADATA_TYPE_SCHEMAS`; plugin → `registerMetadataTypeSchema()` in the plugin's `init()`
238249
- [ ] (Optional) Custom editor registered in `builtinComponents.tsx`

content/docs/references/kernel/metadata-plugin.mdx

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -92,15 +92,15 @@ const result = MetadataBulkResultSchema.parse(data);
9292

9393
| Property | Type | Required | Description |
9494
| :--- | :--- | :--- | :--- |
95-
| **storage** | `{ datasource?: string; tableName?: string; fallback?: Enum<'filesystem' \| 'memory' \| 'none'>; rootDir?: string; … }` || Storage backend configuration |
96-
| **customizationPolicies** | `{ metadataType: string; allowCustomization?: boolean; lockedFields?: string[]; customizableFields?: string[]; … }[]` | optional | Default customization policies per type |
97-
| **mergeStrategy** | `{ defaultStrategy?: Enum<'keep-custom' \| 'accept-incoming' \| 'three-way-merge'>; alwaysAcceptIncoming?: string[]; alwaysKeepCustom?: string[]; autoResolveNonConflicting?: boolean }` | optional | Merge strategy for package upgrades |
98-
| **additionalTypes** | `{ label: string; description?: string; filePatterns: string[]; supportsOverlay?: boolean; … }[]` | optional | Additional custom metadata types |
99-
| **enableEvents** | `boolean` | optional | Emit metadata change events |
100-
| **validateOnWrite** | `boolean` | optional | Validate metadata on write |
101-
| **enableVersioning** | `boolean` | optional | Track metadata version history |
102-
| **cacheMaxItems** | `integer` | optional | Max items in memory cache |
103-
| **bootstrap** | `Enum<'eager' \| 'lazy' \| 'artifact-only'>` | optional | How metadata is primed at plugin start (eager / lazy / artifact-only) |
95+
| **storage** | `{ datasource?: string; tableName: string; fallback: Enum<'filesystem' \| 'memory' \| 'none'>; rootDir?: string; … }` || Storage backend configuration |
96+
| **customizationPolicies** | `{ metadataType: string; allowCustomization: boolean; lockedFields?: string[]; customizableFields?: string[]; … }[]` | optional | Default customization policies per type |
97+
| **mergeStrategy** | `{ defaultStrategy: Enum<'keep-custom' \| 'accept-incoming' \| 'three-way-merge'>; alwaysAcceptIncoming?: string[]; alwaysKeepCustom?: string[]; autoResolveNonConflicting: boolean }` | optional | Merge strategy for package upgrades |
98+
| **additionalTypes** | `never` | optional | [REMOVED] `config.additionalTypes` was removed from `MetadataPluginConfig` in @objectstack/spec 17 (#8586, ADR-0049 enforce-or-remove) — it never had an effect: the only production writer of the metadata type registry is `setTypeRegistry(DEFAULT_METADATA_TYPE_REGISTRY)`, which replaces the array outright, so nothing ever merged these entries and the live type set was exactly the built-in registry whatever you declared here. Delete the key. There is no declared-kind channel: a kind enters the live metadata-type set as a side effect of registering an ITEM of that kind (`SchemaRegistry.registerItem` during app/manifest registration, or `MetadataManager.register` at runtime); bind its schema with `registerMetadataTypeSchema(type, schema)` from your plugin's `init(ctx)` so `GET /api/v1/meta` serves a real JSON Schema for it. |
99+
| **enableEvents** | `boolean` | | Emit metadata change events |
100+
| **validateOnWrite** | `boolean` | | Validate metadata on write |
101+
| **enableVersioning** | `boolean` | | Track metadata version history |
102+
| **cacheMaxItems** | `integer` | | Max items in memory cache |
103+
| **bootstrap** | `Enum<'eager' \| 'lazy' \| 'artifact-only'>` | | How metadata is primed at plugin start (eager / lazy / artifact-only) |
104104

105105

106106
---
@@ -115,9 +115,9 @@ const result = MetadataBulkResultSchema.parse(data);
115115
| **name** | `'ObjectStack Metadata Service'` || Plugin name |
116116
| **version** | `string` || Plugin version |
117117
| **type** | `'standard'` || Plugin type |
118-
| **description** | `string` | optional | Plugin description |
119-
| **capabilities** | `{ crud?: boolean; query?: boolean; overlay?: boolean; watch?: boolean; … }` || Plugin capabilities |
120-
| **config** | `{ storage: object; customizationPolicies?: object[]; mergeStrategy?: object; additionalTypes?: object[]; … }` | optional | Plugin configuration |
118+
| **description** | `string` | | Plugin description |
119+
| **capabilities** | `{ crud: boolean; query: boolean; overlay: boolean; watch: boolean; … }` || Plugin capabilities |
120+
| **config** | `{ storage: object; customizationPolicies?: object[]; mergeStrategy?: object; enableEvents: boolean; … }` | optional | Plugin configuration |
121121

122122

123123
---

packages/metadata-protocol/src/protocol.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4373,8 +4373,10 @@ export class ObjectStackProtocolImplementation implements
43734373
* MetadataManager knows. Its `typeRegistry` is seeded with
43744374
* `DEFAULT_METADATA_TYPE_REGISTRY` in the manager's constructor, so
43754375
* early in boot this source contributes only declared types; it grows
4376-
* later (artifact load, `additionalTypes`) and is read for the types
4377-
* the SchemaRegistry has not been told about.
4376+
* later as items are registered (artifact load, runtime `register()`)
4377+
* and is read for the types the SchemaRegistry has not been told
4378+
* about. (This comment used to also name `additionalTypes` as a growth
4379+
* path — that key never had a reader and was retired by #8586.)
43784380
*
43794381
* Extracted from {@link getMetaTypes} rather than copied: the listing and
43804382
* {@link reportUnhydratableOrgScopedRows} must answer "which types exist

packages/metadata/src/metadata-manager.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2466,9 +2466,11 @@ export class MetadataManager implements IMetadataService {
24662466
const entry = this.typeRegistry.find(e => e.type === type);
24672467
if (!entry) return undefined;
24682468

2469-
// Merge declarative (live registry entry — covers built-ins AND
2470-
// plugin-contributed `additionalTypes`) + plugin-registered type-level
2471-
// actions. Deduped by name; imperatively-registered actions win on
2469+
// Merge declarative (live registry entry — the built-in
2470+
// `DEFAULT_METADATA_TYPE_REGISTRY`, the registry's only production writer;
2471+
// the plugin-contributed `additionalTypes` channel this comment used to
2472+
// claim never existed and was retired by #8586) + plugin-registered
2473+
// type-level actions. Deduped by name; imperatively-registered actions win on
24722474
// collision. Emitted so the metadata-admin engine can render per-type
24732475
// buttons (e.g. datasource "Test connection"). Omit the key entirely
24742476
// when the type has none, to keep the response lean.

packages/spec/authorable-surface/kernel.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -394,7 +394,7 @@
394394
"kernel/MetadataOverlay:tenantId",
395395
"kernel/MetadataOverlay:updatedAt",
396396
"kernel/MetadataOverlay:updatedBy",
397-
"kernel/MetadataPluginConfig:additionalTypes",
397+
"kernel/MetadataPluginConfig:additionalTypes [RETIRED]",
398398
"kernel/MetadataPluginConfig:bootstrap",
399399
"kernel/MetadataPluginConfig:cacheMaxItems",
400400
"kernel/MetadataPluginConfig:customizationPolicies",
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { MetadataPluginConfigSchema, MetadataPluginManifestSchema } from './metadata-plugin.zod';
5+
6+
// ─── [#8586] `MetadataPluginConfig.additionalTypes` is REMOVED ────────────────
7+
//
8+
// ADR-0049 enforce-or-remove, maintainer ruling 2026-08-14, ruled REMOVE. The
9+
// key was declared, authorable, and documented on four docs pages as THE way a
10+
// plugin registers a custom metadata type — and read by NOTHING: the only
11+
// production writer of the manager's type registry is
12+
// `setTypeRegistry(DEFAULT_METADATA_TYPE_REGISTRY)` (`packages/metadata/src/
13+
// plugin.ts`), called exactly once, and it replaces the array outright.
14+
// Measured on the real `MetadataManager`: declared count == live count
15+
// (27 == 27). The same silence trap as #4212's `onInstall`, one level down:
16+
// write it per the docs, get no error, nothing happens.
17+
//
18+
// Route: `retiredKey()` tombstone, NOT plain deletion.
19+
// `MetadataPluginConfigSchema` is not `.strict()`, so deleting the key would
20+
// make zod strip it in silence — replacing an inert declaration with an
21+
// invisible one (the #3726 / #3733 shape, ADR-0104). The tombstone is audible
22+
// in two channels: `tsc` (the key's input type is `never`) and the parse below.
23+
//
24+
// ⚠️ On the assertion set (the #4914 precedent, same reasoning): the dispatch
25+
// asked for the unknown-key refusal shape, but that shape belongs to `.strict()`
26+
// schemas — a `retiredKey()` tombstone raises `invalid_type` from its
27+
// `z.never()`, with the prescription as the message (`shared/retired-key.ts`;
28+
// `alias-integrity.test.ts` records the same fact). And the ADR-0112 `code` +
29+
// `status` envelope belongs to the API error surface — a schema refusal raises
30+
// a `ZodError` whose issues carry `code` and `path` but no `status`. So these
31+
// pins assert the strongest set this surface really has: refusal, the issue
32+
// `code`, the `path` naming WHICH key was refused, and the prescription text
33+
// (#5240: where the wording is the contract, pin the wording).
34+
describe('[#8586] MetadataPluginConfig.additionalTypes retirement', () => {
35+
/** A config that is valid except for whatever the individual test adds. */
36+
const baseConfig = { storage: {} } as const;
37+
38+
it('REJECTS an authored `additionalTypes`, naming the key and carrying the fix', () => {
39+
const result = MetadataPluginConfigSchema.safeParse({
40+
...baseConfig,
41+
additionalTypes: [{
42+
type: 'chart',
43+
label: 'Chart',
44+
filePatterns: ['**/*.chart.ts'],
45+
domain: 'ui',
46+
}],
47+
});
48+
49+
expect(result.success).toBe(false);
50+
if (result.success) return; // narrowing; the assertion above already failed
51+
52+
const issue = result.error.issues.find((i) => i.path[0] === 'additionalTypes');
53+
expect(issue, 'the refusal must name `additionalTypes`').toBeDefined();
54+
// The machine-readable half of the envelope this surface actually has.
55+
expect(issue!.code).toBe('invalid_type');
56+
expect(issue!.path).toEqual(['additionalTypes']);
57+
// The prescription itself — this string IS the migration doc for whoever
58+
// hits it, so it is contract, not commentary.
59+
expect(issue!.message).toMatch(/`config\.additionalTypes`.*removed.*17.*#8586/s);
60+
expect(issue!.message).toMatch(/Delete the key/s);
61+
// The live mechanism must be named: how a kind ACTUALLY enters the set.
62+
expect(issue!.message).toMatch(/registering an ITEM/s);
63+
expect(issue!.message).toMatch(/registerMetadataTypeSchema/s);
64+
});
65+
66+
it('REJECTS it through the manifest embed too (`config.additionalTypes`)', () => {
67+
const result = MetadataPluginManifestSchema.safeParse({
68+
id: 'com.objectstack.metadata',
69+
name: 'ObjectStack Metadata Service',
70+
version: '1.0.0',
71+
type: 'standard',
72+
capabilities: {},
73+
config: { ...baseConfig, additionalTypes: [] },
74+
});
75+
76+
expect(result.success).toBe(false);
77+
if (result.success) return;
78+
const issue = result.error.issues.find(
79+
(i) => i.path[0] === 'config' && i.path[1] === 'additionalTypes',
80+
);
81+
expect(issue, 'the refusal must surface at config.additionalTypes').toBeDefined();
82+
expect(issue!.code).toBe('invalid_type');
83+
});
84+
85+
it('parses cleanly once the key is deleted, and grows no `additionalTypes` property', () => {
86+
const parsed = MetadataPluginConfigSchema.parse({ ...baseConfig });
87+
expect(parsed.enableEvents).toBe(true); // control: defaults still apply
88+
// The non-strict strip path: absence must stay absence. If the tombstone
89+
// were ever replaced by a plain deletion, an authored `additionalTypes`
90+
// would be stripped here in silence — this pin plus the rejections above
91+
// are what make that regression loud.
92+
expect(parsed).not.toHaveProperty('additionalTypes');
93+
});
94+
});

packages/spec/src/kernel/metadata-plugin.test.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -322,12 +322,8 @@ describe('MetadataPluginProtocol', () => {
322322
defaultStrategy: 'three-way-merge' as const,
323323
alwaysKeepCustom: ['fields.*.label'],
324324
},
325-
additionalTypes: [{
326-
type: 'chart',
327-
label: 'Chart',
328-
filePatterns: ['**/*.chart.ts'],
329-
domain: 'ui',
330-
}],
325+
// `additionalTypes` was retired by #8586 (ADR-0049) — authoring it is
326+
// now a parse error; see additional-types-retirement.test.ts for the pins.
331327
enableEvents: true,
332328
validateOnWrite: true,
333329
enableVersioning: true,
@@ -337,7 +333,6 @@ describe('MetadataPluginProtocol', () => {
337333
const result = MetadataPluginConfigSchema.parse(config);
338334
expect(result.storage.datasource).toBe('default');
339335
expect(result.customizationPolicies).toHaveLength(1);
340-
expect(result.additionalTypes).toHaveLength(1);
341336
expect(result.cacheMaxItems).toBe(5000);
342337
});
343338

0 commit comments

Comments
 (0)