Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions .changeset/metadata-plugin-additional-types-retired.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
---
"@objectstack/spec": minor
---

feat(spec): retire the inert `additionalTypes` key from `MetadataPluginConfig` (#8586, ADR-0049)

**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep
launch-window convention ships it as `minor`; the migration prescription is
registered under protocol major 18, where `os migrate meta` users will look).

`MetadataPluginConfig.additionalTypes` was declared, authorable, and documented
on four docs pages as THE way a plugin registers a custom metadata type — and
read by **nothing**. The only production writer of the manager's type registry
is `setTypeRegistry(DEFAULT_METADATA_TYPE_REGISTRY)`, called exactly once, and
it replaces the array outright: measured on the real `MetadataManager`,
declared count == live count (27 == 27). An author who followed the published
instructions wrote the key, got no error, and nothing happened — the #4212
`onInstall` silence trap one level down (maintainer-ruled REMOVE, 2026-08-14).

**What is refused:** an authored `additionalTypes` on `MetadataPluginConfig`
(inline or via the manifest's `config` embed). The key is a `retiredKey()`
tombstone — the schema is not `.strict()`, so a plain deletion would have
silently stripped it — refused at `tsc` (typed `never`) and at the parse
(`invalid_type` at path `additionalTypes`, message carrying the prescription).

**What stays accepted:** every `MetadataPluginConfig` without the key,
byte-identically. Runtime behaviour is unchanged: nothing ever read the key,
so removing it removes no behaviour.

The retirement kit:

- tombstone at the schema (`packages/spec/src/kernel/metadata-plugin.zod.ts`)
- ADR-0087 registration: retired-key entry
`kernel/MetadataPluginConfig:additionalTypes` + D3 semantic entry
`metadata-plugin-additional-types-retired`, both under protocol 18 (no D2
conversion — a plugin config is not a stack collection member, the
`kernel/Manifest:loading` precedent)
- pin tests (`additional-types-retirement.test.ts`)
- docs corrected: `content/docs/plugins/adding-a-metadata-type.mdx` (four
sites) now describes how a kind actually enters the live set — as a side
effect of registering an item of that kind; the generated reference page
follows the schema
- the two source comments that asserted the phantom growth path
(`metadata-manager.ts`, `metadata-protocol/src/protocol.ts`) and the
`registerMetadataTypeSchema` doc note corrected

## FROM → TO

```ts
// before — parsed green; the entries were merged into nothing
const config: MetadataPluginConfig = {
storage: {},
additionalTypes: [{ type: 'chart', label: 'Chart', filePatterns: ['**/*.chart.ts'], domain: 'ui' }],
};

// after — delete the key; register items of the kind instead, and bind its schema
const config: MetadataPluginConfig = { storage: {} };
// in the plugin: registerMetadataTypeSchema('chart', ChartSchema) from init(ctx);
// the kind enters the live set when an item of it is registered.
```

<!-- adr-0087: registered metadata-plugin-additional-types-retired -->
31 changes: 21 additions & 10 deletions content/docs/plugins/adding-a-metadata-type.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,8 +14,9 @@ files; a third file is required only if you want a bespoke editor.
## TL;DR

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

`DEFAULT_METADATA_TYPE_REGISTRY` is the core built-in array — edit it (and
`BUILTIN_METADATA_TYPE_SCHEMAS`, step 2) only for types that ship with the
platform. A third-party package contributes its own types instead through
the **`additionalTypes`** array on `MetadataPluginConfig`, and registers the
matching Zod schema with `registerMetadataTypeSchema(type, schema)` from its
plugin's `init(ctx)` so `GET /api/v1/meta` emits a real JSON Schema. The
registry entry shape is the same in both cases.
platform. It is also the **total universe of declared types**: there is no
config key through which a package declares a new type. (Through v17 the
schema carried an `additionalTypes` array on `MetadataPluginConfig` that was
documented for exactly that — it had no reader anywhere and was retired by
#8586, ADR-0049; authoring it is now a loud parse error.)

A third-party package's type instead enters the live set **as a side effect
of registering items of that type**: items your package's manifest carries
are registered through `SchemaRegistry.registerItem` during app/manifest
registration, and runtime code can register items with
`MetadataManager.register`. The first registered item admits the type; a
type with no items is not in the live set. Alongside the items, register the
matching Zod schema with `registerMetadataTypeSchema(type, schema)` from
your plugin's `init(ctx)` so `GET /api/v1/meta` emits a real JSON Schema.

## 2. Define the Zod schema

Expand DownExpand Up@@ -126,11 +136,12 @@ editor consumes from the registered Zod schema.
}
```

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

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

## Checklist

- [ ] Registry entry added (`type`, `label`, `domain`, required `filePatterns`, flags) — for plugins, via `additionalTypes` on `MetadataPluginConfig`
- [ ] 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)
- [ ] Zod schema authored under `packages/spec/src/<domain>/<name>.zod.ts`
- [ ] Zod schema wired up: built-in → `BUILTIN_METADATA_TYPE_SCHEMAS`; plugin → `registerMetadataTypeSchema()` in the plugin's `init()`
- [ ] (Optional) Custom editor registered in `builtinComponents.tsx`
Expand Down
24 changes: 12 additions & 12 deletions content/docs/references/kernel/metadata-plugin.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -92,15 +92,15 @@ const result = MetadataBulkResultSchema.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **storage** | `{ datasource?: string; tableName?: string; fallback?: Enum<'filesystem' \| 'memory' \| 'none'>; rootDir?: string; … }` | ✅ | Storage backend configuration |
| **customizationPolicies** | `{ metadataType: string; allowCustomization?: boolean; lockedFields?: string[]; customizableFields?: string[]; … }[]` | optional | Default customization policies per type |
| **mergeStrategy** | `{ defaultStrategy?: Enum<'keep-custom' \| 'accept-incoming' \| 'three-way-merge'>; alwaysAcceptIncoming?: string[]; alwaysKeepCustom?: string[]; autoResolveNonConflicting?: boolean }` | optional | Merge strategy for package upgrades |
| **additionalTypes** | `{ label: string; description?: string; filePatterns: string[]; supportsOverlay?: boolean; … }[]` | optional | Additional custom metadata types |
| **enableEvents** | `boolean` | optional | Emit metadata change events |
| **validateOnWrite** | `boolean` | optional | Validate metadata on write |
| **enableVersioning** | `boolean` | optional | Track metadata version history |
| **cacheMaxItems** | `integer` | optional | Max items in memory cache |
| **bootstrap** | `Enum<'eager' \| 'lazy' \| 'artifact-only'>` | optional | How metadata is primed at plugin start (eager / lazy / artifact-only) |
| **storage** | `{ datasource?: string; tableName: string; fallback: Enum<'filesystem' \| 'memory' \| 'none'>; rootDir?: string; … }` | ✅ | Storage backend configuration |
| **customizationPolicies** | `{ metadataType: string; allowCustomization: boolean; lockedFields?: string[]; customizableFields?: string[]; … }[]` | optional | Default customization policies per type |
| **mergeStrategy** | `{ defaultStrategy: Enum<'keep-custom' \| 'accept-incoming' \| 'three-way-merge'>; alwaysAcceptIncoming?: string[]; alwaysKeepCustom?: string[]; autoResolveNonConflicting: boolean }` | optional | Merge strategy for package upgrades |
| **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. |
| **enableEvents** | `boolean` | | Emit metadata change events |
| **validateOnWrite** | `boolean` | | Validate metadata on write |
| **enableVersioning** | `boolean` | | Track metadata version history |
| **cacheMaxItems** | `integer` | | Max items in memory cache |
| **bootstrap** | `Enum<'eager' \| 'lazy' \| 'artifact-only'>` | | How metadata is primed at plugin start (eager / lazy / artifact-only) |


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


---
Expand Down
6 changes: 4 additions & 2 deletions packages/metadata-protocol/src/protocol.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4373,8 +4373,10 @@ export class ObjectStackProtocolImplementation implements
* MetadataManager knows. Its `typeRegistry` is seeded with
* `DEFAULT_METADATA_TYPE_REGISTRY` in the manager's constructor, so
* early in boot this source contributes only declared types; it grows
* later (artifact load, `additionalTypes`) and is read for the types
* the SchemaRegistry has not been told about.
* later as items are registered (artifact load, runtime `register()`)
* and is read for the types the SchemaRegistry has not been told
* about. (This comment used to also name `additionalTypes` as a growth
* path — that key never had a reader and was retired by #8586.)
*
* Extracted from {@link getMetaTypes} rather than copied: the listing and
* {@link reportUnhydratableOrgScopedRows} must answer "which types exist
Expand Down
8 changes: 5 additions & 3 deletions packages/metadata/src/metadata-manager.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2466,9 +2466,11 @@ export class MetadataManager implements IMetadataService {
const entry = this.typeRegistry.find(e => e.type === type);
if (!entry) return undefined;

// Merge declarative (live registry entry — covers built-ins AND
// plugin-contributed `additionalTypes`) + plugin-registered type-level
// actions. Deduped by name; imperatively-registered actions win on
// Merge declarative (live registry entry — the built-in
// `DEFAULT_METADATA_TYPE_REGISTRY`, the registry's only production writer;
// the plugin-contributed `additionalTypes` channel this comment used to
// claim never existed and was retired by #8586) + plugin-registered
// type-level actions. Deduped by name; imperatively-registered actions win on
// collision. Emitted so the metadata-admin engine can render per-type
// buttons (e.g. datasource "Test connection"). Omit the key entirely
// when the type has none, to keep the response lean.
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/authorable-surface/kernel.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -394,7 +394,7 @@
"kernel/MetadataOverlay:tenantId",
"kernel/MetadataOverlay:updatedAt",
"kernel/MetadataOverlay:updatedBy",
"kernel/MetadataPluginConfig:additionalTypes",
"kernel/MetadataPluginConfig:additionalTypes [RETIRED]",
"kernel/MetadataPluginConfig:bootstrap",
"kernel/MetadataPluginConfig:cacheMaxItems",
"kernel/MetadataPluginConfig:customizationPolicies",
Expand Down
94 changes: 94 additions & 0 deletions packages/spec/src/kernel/additional-types-retirement.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import { MetadataPluginConfigSchema, MetadataPluginManifestSchema } from './metadata-plugin.zod';

// ─── [#8586] `MetadataPluginConfig.additionalTypes` is REMOVED ────────────────
//
// ADR-0049 enforce-or-remove, maintainer ruling 2026-08-14, ruled REMOVE. The
// key was declared, authorable, and documented on four docs pages as THE way a
// plugin registers a custom metadata type — and read by NOTHING: the only
// production writer of the manager's type registry is
// `setTypeRegistry(DEFAULT_METADATA_TYPE_REGISTRY)` (`packages/metadata/src/
// plugin.ts`), called exactly once, and it replaces the array outright.
// Measured on the real `MetadataManager`: declared count == live count
// (27 == 27). The same silence trap as #4212's `onInstall`, one level down:
// write it per the docs, get no error, nothing happens.
//
// Route: `retiredKey()` tombstone, NOT plain deletion.
// `MetadataPluginConfigSchema` is not `.strict()`, so deleting the key would
// make zod strip it in silence — replacing an inert declaration with an
// invisible one (the #3726 / #3733 shape, ADR-0104). The tombstone is audible
// in two channels: `tsc` (the key's input type is `never`) and the parse below.
//
// ⚠️ On the assertion set (the #4914 precedent, same reasoning): the dispatch
// asked for the unknown-key refusal shape, but that shape belongs to `.strict()`
// schemas — a `retiredKey()` tombstone raises `invalid_type` from its
// `z.never()`, with the prescription as the message (`shared/retired-key.ts`;
// `alias-integrity.test.ts` records the same fact). And the ADR-0112 `code` +
// `status` envelope belongs to the API error surface — a schema refusal raises
// a `ZodError` whose issues carry `code` and `path` but no `status`. So these
// pins assert the strongest set this surface really has: refusal, the issue
// `code`, the `path` naming WHICH key was refused, and the prescription text
// (#5240: where the wording is the contract, pin the wording).
describe('[#8586] MetadataPluginConfig.additionalTypes retirement', () => {
/** A config that is valid except for whatever the individual test adds. */
const baseConfig = { storage: {} } as const;

it('REJECTS an authored `additionalTypes`, naming the key and carrying the fix', () => {
const result = MetadataPluginConfigSchema.safeParse({
...baseConfig,
additionalTypes: [{
type: 'chart',
label: 'Chart',
filePatterns: ['**/*.chart.ts'],
domain: 'ui',
}],
});

expect(result.success).toBe(false);
if (result.success) return; // narrowing; the assertion above already failed

const issue = result.error.issues.find((i) => i.path[0] === 'additionalTypes');
expect(issue, 'the refusal must name `additionalTypes`').toBeDefined();
// The machine-readable half of the envelope this surface actually has.
expect(issue!.code).toBe('invalid_type');
expect(issue!.path).toEqual(['additionalTypes']);
// The prescription itself — this string IS the migration doc for whoever
// hits it, so it is contract, not commentary.
expect(issue!.message).toMatch(/`config\.additionalTypes`.*removed.*17.*#8586/s);
expect(issue!.message).toMatch(/Delete the key/s);
// The live mechanism must be named: how a kind ACTUALLY enters the set.
expect(issue!.message).toMatch(/registering an ITEM/s);
expect(issue!.message).toMatch(/registerMetadataTypeSchema/s);
});

it('REJECTS it through the manifest embed too (`config.additionalTypes`)', () => {
const result = MetadataPluginManifestSchema.safeParse({
id: 'com.objectstack.metadata',
name: 'ObjectStack Metadata Service',
version: '1.0.0',
type: 'standard',
capabilities: {},
config: { ...baseConfig, additionalTypes: [] },
});

expect(result.success).toBe(false);
if (result.success) return;
const issue = result.error.issues.find(
(i) => i.path[0] === 'config' && i.path[1] === 'additionalTypes',
);
expect(issue, 'the refusal must surface at config.additionalTypes').toBeDefined();
expect(issue!.code).toBe('invalid_type');
});

it('parses cleanly once the key is deleted, and grows no `additionalTypes` property', () => {
const parsed = MetadataPluginConfigSchema.parse({ ...baseConfig });
expect(parsed.enableEvents).toBe(true); // control: defaults still apply
// The non-strict strip path: absence must stay absence. If the tombstone
// were ever replaced by a plain deletion, an authored `additionalTypes`
// would be stripped here in silence — this pin plus the rejections above
// are what make that regression loud.
expect(parsed).not.toHaveProperty('additionalTypes');
});
});
9 changes: 2 additions & 7 deletions packages/spec/src/kernel/metadata-plugin.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -322,12 +322,8 @@ describe('MetadataPluginProtocol', () => {
defaultStrategy: 'three-way-merge' as const,
alwaysKeepCustom: ['fields.*.label'],
},
additionalTypes: [{
type: 'chart',
label: 'Chart',
filePatterns: ['**/*.chart.ts'],
domain: 'ui',
}],
// `additionalTypes` was retired by #8586 (ADR-0049) — authoring it is
// now a parse error; see additional-types-retirement.test.ts for the pins.
enableEvents: true,
validateOnWrite: true,
enableVersioning: true,
Expand All@@ -337,7 +333,6 @@ describe('MetadataPluginProtocol', () => {
const result = MetadataPluginConfigSchema.parse(config);
expect(result.storage.datasource).toBe('default');
expect(result.customizationPolicies).toHaveLength(1);
expect(result.additionalTypes).toHaveLength(1);
expect(result.cacheMaxItems).toBe(5000);
});

Expand Down
Loading
Loading