From 0ebaa202e228ea7d9b4c211f1adb329085922062 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 21:43:42 +0000 Subject: [PATCH 1/9] fix(metadata): refuse a /meta type name the platform does not have (#8421) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PUT /api/v1/meta/fieldz/showcase_task.title` answered 200 and persisted a `sys_metadata` row under `type='fieldz'` — a namespace for a metadata type that does not exist. #7894 closed the sibling case (a plural spelling of a DECLARED type) and left this one open, because a static predicate cannot tell `fieldz` from a plugin kind and the live-registry alternative was measured to be worse than the defect. Maintainer ruling 2026-08-14, joint with #8586: retiring `additionalTypes` removed the last channel by which a plugin could DECLARE a metadata kind, so an unrecognised name can no longer be a declaration the boundary has not heard about. `@objectstack/spec` gains `unrecognisedMetaTypeRefusal`, deliberately separate from the #7894 verdict: one says you spelled a declared type wrongly and can name the replacement, the other says there is no such type and never guesses. Its accept set is the static spelling contract — the map's keys AND the canonical singulars they fold to — so the six plugin kinds with no registry entry (`theme`, `webhook`, `connector`, `sharing_rule`, `analytics_cube`, `rag_pipeline`) stay writable, including the first create of a kind that has no items yet. The boundary applies it at `saveMetaItem` only, which is measured rather than timid: an ordinary `registerApp` puts `data`, `kind` and `package` into the live type set that `GET /api/v1/meta/types` advertises, so a read-side refusal would 400 types this same service publishes; and refusing DELETE would strand rows minted under an unrecognised type before this change. The residue pin in `metadata-url-spelling.test.ts` is flipped, not deleted, and #7894's positive control keeps every assertion it was written with. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XeQRiAa7vYRVX5Fog7Zby8 --- .changeset/meta-unrecognised-type-refused.md | 70 +++++ packages/metadata-protocol/src/protocol.ts | 59 +++- .../protocol.unrecognised-meta-type.test.ts | 263 ++++++++++++++++++ packages/spec/api-surface/shared.json | 3 +- packages/spec/export-origins/shared.json | 3 +- .../src/shared/metadata-url-spelling.test.ts | 108 ++++++- .../spec/src/shared/metadata-url-spelling.ts | 86 +++++- 7 files changed, 574 insertions(+), 18 deletions(-) create mode 100644 .changeset/meta-unrecognised-type-refused.md create mode 100644 packages/metadata-protocol/src/protocol.unrecognised-meta-type.test.ts diff --git a/.changeset/meta-unrecognised-type-refused.md b/.changeset/meta-unrecognised-type-refused.md new file mode 100644 index 0000000000..7b273d023c --- /dev/null +++ b/.changeset/meta-unrecognised-type-refused.md @@ -0,0 +1,70 @@ +--- +"@objectstack/spec": minor +"@objectstack/metadata-protocol": minor +--- + +fix(metadata): `PUT /meta/:type` refuses a type name the platform does not have, instead of minting a namespace for it (#8421) + +**BREAKING** accept-set narrowing on a published HTTP surface, landing after the +v17.0.0 cut (the lockstep launch-window convention ships it as `minor`). A write +that answered `200 {"success":true}` now answers `400 INVALID_REQUEST`: + +``` +PUT /api/v1/meta/fieldz/showcase_task.title + before → 200, sys_metadata row persisted with type='fieldz' + after → 400 INVALID_REQUEST, nothing persisted +``` + +`fieldz` — or any typo — was neither a declared metadata type nor a known plural +spelling of one, so the boundary classified it as PLUGIN-registered, which every +authorization gate is permissive toward by construction. The row was persisted +under a type nothing reads and nothing serves, and the caller was told it had +succeeded. That silence is the real cost: a metadata-type typo, from a human or +from generated code, produced `success: true` and no indication the type is not +real. + +**Why this is only now safe to refuse.** #7894 closed the sibling case (a plural +spelling of a type the platform DECLARES) and left this one open on purpose: a +static predicate cannot tell `fieldz` from a plugin kind, and the live-registry +alternative was measured to be worse than the defect — the live type set is +ITEM-POPULATED, so it omits every legitimate kind that has no items yet, which +is the state each kind is in immediately before its first create. What changed +is the platform, not the boundary's information: #8586 retired +`MetadataPluginConfig.additionalTypes` and with it the last channel by which a +plugin could DECLARE a metadata kind, so an unrecognised name can no longer be a +declaration this refusal has not heard about (maintainer ruling 2026-08-14). + +**What still passes, pinned in both directions.** Every declared type in +`DEFAULT_METADATA_TYPE_REGISTRY`, in canonical and REST-plural spelling; every +manifest spelling and the singular each folds to; and the six plugin kinds that +have no static registry entry at all — `theme`, `webhook`, `connector`, +`sharing_rule`, `analytics_cube`, `rag_pipeline`. `PUT /meta/theme/dark` on a +deployment with zero themes is explicitly covered, because that first create is +exactly what a live-registry check would have broken. + +**The refusal is scoped to the door that mints.** Reads are untouched: a running +kernel legitimately holds live type keys the static contract does not — `data`, +`kind` and `package` all enter the registry during an ordinary `registerApp`, +and `GET /api/v1/meta/types` lists that live set — so refusing unrecognised +names on the read path would answer 400 for types the same service advertises. +`DELETE` is untouched for the mirror-image reason: rows minted under an +unrecognised type before this change are real, nothing rewrites them on upgrade, +and refusing their deletion would turn the accumulation this fixes into an +accumulation nobody can clear. + +**What breaks.** A caller creating metadata at runtime under a type name that is +in neither half of the static spelling contract. In this repo that set is empty +— all six plugin kinds are mapped — but an out-of-tree plugin that made its kind +live by registering an item of it, and then accepted runtime writes to that kind +through `/meta`, now needs its spelling in the contract. There is no +declared-kind channel to register one through today; that is the trade #8586's +retirement made, and this change is the half of it that stops silently accepting +what nothing can honour. + +`@objectstack/spec` gains one export, `unrecognisedMetaTypeRefusal`, alongside +the #7894 verdict it deliberately does not merge with: one says *you spelled a +declared type wrongly* and can name the replacement, the other says *there is no +such type* and never guesses. The residue pin #7894 left behind +(`metadata-url-spelling.test.ts`, the case that asserted `fieldz` was refused by +nobody) is **flipped, not deleted**, and #7894's positive control keeps every +assertion it was written with. diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 6e21b596d4..eab75aafa0 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -61,7 +61,7 @@ import { type QueryAliasConflict, type QueryAliasSlot, type DroppedFieldsEvent, type QueryAST, type EngineQueryOptionsParsed, } from '@objectstack/spec/data'; -import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL, canonicalMetaUrlType, metaUrlSpellingRefusal } from '@objectstack/spec/shared'; +import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL, canonicalMetaUrlType, metaUrlSpellingRefusal, unrecognisedMetaTypeRefusal } from '@objectstack/spec/shared'; import { applyConversionsToStoredItem, type ConversionNotice } from '@objectstack/spec'; import { type FormView, isAggregatedViewContainer, expandViewContainer } from '@objectstack/spec/ui'; import { METADATA_FORM_REGISTRY, CORE_SERVICE_PROVIDER, serviceUnavailableMessage, inProcessServiceMessage } from '@objectstack/spec/system'; @@ -207,8 +207,40 @@ function canonicalMetaType(type: string): string { * function for why the rule is static rather than a live-registry lookup — * and (#8424) for why this boundary consumes the composed VERDICT rather than * the predicate parts: the spelling contract stays whole at its producer. + * + * ## [#8421] `opts.minting` — the second verdict, and why it is not on all six + * + * The refusal above is silent for a name that is not a plural of ANYTHING + * (`fieldz`), because such a name reaches for no declared type. That residue + * was left open deliberately in #7894 and closed by the maintainer's ruling of + * 2026-08-14 (「同意」), joint with #8586: retiring `additionalTypes` removed the + * last channel by which a plugin could DECLARE a metadata kind, so an + * unrecognised name can no longer be a declaration this boundary has not heard + * about. {@link unrecognisedMetaTypeRefusal} is that verdict. + * + * It is passed ONLY by {@link saveMetaItem} — the one entry point that MINTS a + * `sys_metadata` namespace — and that scoping is measured, not timid: + * + * - **The live type set legitimately holds keys the static contract does + * not.** An ordinary `registerApp` puts `data` (a manifest's seed + * datasets), `kind` (`contributes.kinds`) and `package` into + * `SchemaRegistry`, and {@link listLiveMetadataTypes} — hence `GET + * /api/v1/meta/types` — enumerates exactly that set. Refusing unrecognised + * names on the READ entries would answer 400 for types this same service + * advertises, trading one declared-≠-served gap for another. + * - **`deleteMetaItem` must stay open for the opposite reason.** Rows minted + * under an unrecognised type BEFORE this refusal are real and nothing + * rewrites them on upgrade; refusing delete would strand them permanently — + * turning the accumulation this card was filed about into an accumulation + * nobody can clear. + * + * So reads stay permissive, deletes stay possible, and the door that creates a + * namespace for a type that does not exist is the one that closes. */ -function canonicalizeMetaRequestType(request: T): T { +function canonicalizeMetaRequestType( + request: T, + opts?: { minting?: boolean }, +): T { const refusal = metaUrlSpellingRefusal(request.type); if (refusal) { const err = new Error( @@ -221,6 +253,21 @@ function canonicalizeMetaRequestType(request: T): T (err as any).status = 400; throw err; } + if (opts?.minting === true) { + const unrecognised = unrecognisedMetaTypeRefusal(request.type); + if (unrecognised) { + const err = new Error( + `[invalid_request] '${unrecognised.type}' is not a metadata type. The platform declares ` + + `no such type, and since #8586 retired 'additionalTypes' a plugin cannot declare one ` + + `either — so this write would mint a sys_metadata namespace under ` + + `type='${unrecognised.type}' that nothing reads and nothing serves. Address a real ` + + `metadata type; GET /api/v1/meta/types lists the ones this deployment carries.`, + ); + (err as any).code = 'INVALID_REQUEST'; + (err as any).status = 400; + throw err; + } + } const type = canonicalMetaType(request.type); return type === request.type ? request : { ...request, type }; } @@ -11247,7 +11294,13 @@ export class ObjectStackProtocolImplementation implements throw new Error('Item data is required'); } // #4432 — CANONICAL TYPE KEY. See {@link canonicalMetaType}. - request = canonicalizeMetaRequestType(request); + // + // [#8421] `minting: true` — this is the entry point that CREATES a + // `sys_metadata` namespace, so it is the one that refuses a type name + // the platform has never heard of instead of forwarding it to the + // permissive plugin path. The read entries deliberately do not pass it; + // `canonicalizeMetaRequestType`'s doc carries the measurement. + request = canonicalizeMetaRequestType(request, { minting: true }); // What the history row, the audit row and the watch event record as the // origin of this write. Defaults to this method — the ordinary Studio / // REST / SDK save. The only caller that overrides it is diff --git a/packages/metadata-protocol/src/protocol.unrecognised-meta-type.test.ts b/packages/metadata-protocol/src/protocol.unrecognised-meta-type.test.ts new file mode 100644 index 0000000000..0bd7b78daa --- /dev/null +++ b/packages/metadata-protocol/src/protocol.unrecognised-meta-type.test.ts @@ -0,0 +1,263 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #8421 — a `/meta` type name that is not a plural of anything no longer mints + * a namespace. + * + * `PUT /api/v1/meta/fieldz/showcase_task.title` answered `200 {"success":true}` + * and persisted a `sys_metadata` row under `type='fieldz'`: a namespace for a + * metadata type that does not exist and never will. #7894 closed the sibling + * case (a plural spelling of a DECLARED type) and left this one open on purpose, + * because a static predicate could not tell `fieldz` from a plugin kind and the + * live-registry alternative was measured to be worse than the defect — the live + * set is ITEM-POPULATED, so it omits every legitimate kind that has no items + * yet, which is the state each one is in immediately before its first create. + * + * Maintainer ruling 2026-08-14 (「同意」), joint with #8586: retiring + * `MetadataPluginConfig.additionalTypes` removed the last channel by which a + * plugin could DECLARE a kind, so an unrecognised name can no longer be a + * declaration the boundary has not heard about. That is what makes the static + * refusal safe by construction rather than by luck. + * + * ## What this suite pins, in both directions + * + * A suite that only asserted "`fieldz` is refused" would be satisfied by a + * boundary that refuses everything, so every refusal case here is paired with + * the traffic that must keep working: + * + * - a DECLARED type still saves (`view`), and so does a type whose only write + * channel is runtime (`hook`); + * - a PLUGIN kind with no static registry entry still saves (`theme`) — the + * operation option C would have broken, and the one this change must not; + * - READS of an unrecognised type still answer, because the live type set + * legitimately holds keys the static contract does not (`data`, `kind` and + * `package` all enter `SchemaRegistry` during an ordinary `registerApp`, and + * `GET /api/v1/meta/types` lists exactly that set); + * - DELETE of a row minted under an unrecognised type before this change still + * works, or the accumulation this card was filed about would become + * unremovable. + * + * Harness: the real `saveMetaItem` write path over a stub engine, the shape + * `protocol.code-only-types.test.ts` uses. A gate INSIDE `saveMetaItem` cannot + * be measured against a harness that mocks `saveMetaItem`. + */ +import { describe, expect, it } from 'vitest'; +// [#5619] The producer's OWN write-verb dispatch decisions, so the fake engine +// below cannot accept a call ObjectQL itself refuses. +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +interface Row { + id: string; + type: string; + name: string; + organization_id: string | null; + state: string; + /** A real `checksum` is required or the OCC parent-version check 409s */ + /** before `deleteMetaItem` reaches anything this suite is about. */ + checksum: string; + metadata: string; +} + +function makeProtocol(seedRows: Array> = []) { + const rows = new Map(); + let nextId = 0; + const keyOf = (w: Record) => + `${w.type}|${w.name}|${w.organization_id ?? '__env__'}|${w.state ?? 'active'}`; + for (const seed of seedRows) { + nextId += 1; + const row = { + id: `seed_${nextId}`, + organization_id: null, + state: 'active', + checksum: 'sha256:stored-head', + metadata: JSON.stringify({ name: seed.name, label: 'Stored' }), + ...seed, + } as Row; + rows.set(keyOf(row), row); + } + const deletes: Array | undefined> = []; + const engine: any = { + async findOne(_t: string, opts: { where: Record }) { + for (const row of rows.values()) { + if (opts.where.type !== undefined && row.type !== opts.where.type) continue; + if (opts.where.name !== undefined && row.name !== opts.where.name) continue; + return row; + } + return null; + }, + async find(_t: string, opts?: { where?: Record }) { + const where = opts?.where ?? {}; + return [...rows.values()].filter((row) => + (where.type === undefined || row.type === where.type) + && (where.name === undefined || row.name === where.name)); + }, + async insert(_t: string, data: Record) { + if (_t !== 'sys_metadata') return { id: 'side_effect_skip' }; + nextId += 1; + const row = { id: `r_${nextId}`, ...(data as any) } as Row; + rows.set(keyOf(data), row); + return { id: row.id }; + }, + async update(_t: string, data: Record, opts?: Record) { + assertEngineUpdateDispatch(data, opts); + return { id: null }; + }, + async delete(_t: string, opts?: Record) { + assertEngineDeleteDispatch(opts); + deletes.push(opts); + const id = (opts as any)?.where?.id; + for (const [key, row] of rows.entries()) if (row.id === id) rows.delete(key); + return { deleted: 1 }; + }, + async count() { return 0; }, + async transaction(fn: (ctx: unknown) => Promise) { return fn(undefined); }, + async execute() { return {}; }, + async getObjectSchema() { return undefined; }, + registry: { + registerItem: () => {}, + registerObject: () => {}, + unregisterItem: () => {}, + listItems: () => [], + getItem: () => undefined, + getArtifactItem: () => undefined, + }, + }; + const protocol = new ObjectStackProtocolImplementation( + engine, + () => new Map(), + undefined, + ) as any; + return { protocol, rows, deletes }; +} + +const metaRows = (rows: Map) => [...rows.values()].filter((r) => r.type !== undefined); + +/** The card's own coordinates, plus three more names of the same class. */ +const UNRECOGNISED = ['fieldz', 'objectt', 'viewz', 'nonsense_type']; + +describe('#8421 — an unrecognised `/meta` type is refused instead of minted', () => { + it.each(UNRECOGNISED)('refuses PUT /meta/%s/:name with the ADR-0112 envelope', async (type) => { + const { protocol, rows } = makeProtocol(); + + // The envelope, not just the throw (ADR-0112): a bare `.toThrow()` + // would stay green against an unrelated failure one layer down — + // notably the 422 an unknown type earns from schema resolution — and + // would prove nothing about the refusal being tested. + await expect( + protocol.saveMetaItem({ type, name: 'showcase_task.title', item: { name: 'x' } }), + ).rejects.toMatchObject({ code: 'INVALID_REQUEST', status: 400 }); + + // And the point of the card: nothing is persisted. A refusal that threw + // after the insert would still leave the namespace behind. + expect(metaRows(rows).length).toBe(0); + }); + + it('names the offending type and why, per the 2026-08-12 refusal ruling', async () => { + const { protocol } = makeProtocol(); + await expect( + protocol.saveMetaItem({ type: 'fieldz', name: 'showcase_task.title', item: { name: 'x' } }), + ).rejects.toThrow(/'fieldz' is not a metadata type/); + }); + + it('does not answer the sibling verdict — this is not a misspelling refusal', async () => { + // `fieldz` reaches for no declared type, so it must NOT be reported as + // a misspelling of one. Keeping the two verdicts apart is what lets + // #7894's message name a replacement spelling and this one stay silent + // rather than guess. + const { protocol } = makeProtocol(); + await expect( + protocol.saveMetaItem({ type: 'fieldz', name: 'showcase_task.title', item: { name: 'x' } }), + ).rejects.not.toThrow(/recognised spelling of metadata type/); + }); +}); + +describe('#8421 — the traffic that must keep working', () => { + const ACCEPTED: Array<{ type: string; item: Record; why: string }> = [ + { + type: 'view', + why: 'declared, allowOrgOverride + allowRuntimeCreate', + item: { + name: 'probe_item', + label: 'Probe', + object: 'task', + viewKind: 'list', + columns: [{ field: 'name', label: 'Name' }], + }, + }, + { + type: 'hook', + why: 'declared, runtime-create only', + item: { name: 'probe_item', object: 'task', events: ['beforeUpdate'] }, + }, + { + type: 'theme', + why: 'PLUGIN kind — no static registry entry at all', + item: { name: 'probe_item', label: 'Probe', tokens: {} }, + }, + ]; + + for (const { type, item, why } of ACCEPTED) { + it(`still saves ${type} (${why})`, async () => { + const { protocol, rows } = makeProtocol(); + const result = await protocol.saveMetaItem({ type, name: 'probe_item', item }); + expect(result.success).toBe(true); + expect(metaRows(rows).length).toBe(1); + expect(metaRows(rows)[0]!.type).toBe(type); + }); + } + + it('POSITIVE CONTROL — the plugin path still serves its first create', async () => { + // The measurement that disqualified option C, kept as a live control: + // `theme` has ZERO items at this moment, which is exactly the state a + // live-registry check would have refused. The refusal that shipped + // consults the static contract instead, so the first create of a + // plugin kind is untouched. + const { protocol, rows } = makeProtocol(); + const result = await protocol.saveMetaItem({ + type: 'theme', + name: 'dark', + item: { name: 'dark', label: 'Dark', tokens: {} }, + }); + expect(result.success).toBe(true); + expect(metaRows(rows)[0]!.type).toBe('theme'); + }); +}); + +describe('#8421 — the refusal is scoped to the door that MINTS', () => { + it('leaves READS of an unrecognised type answering', async () => { + // Measured reason, not caution: an ordinary `registerApp` puts `data`, + // `kind` and `package` into `SchemaRegistry`, and `getMetaTypes()` + // enumerates that live set — so a read-side refusal would answer 400 + // for types this same service advertises. `fieldz` stands in for the + // whole class here because the stub registry holds no items. + const { protocol } = makeProtocol(); + await expect(protocol.getMetaItems({ type: 'fieldz' })).resolves.toBeDefined(); + await expect(protocol.getMetaItem({ type: 'fieldz', name: 'showcase_task.title' })) + .resolves.not.toThrow; + }); + + it('leaves a legacy row under an unrecognised type DELETABLE', async () => { + // Rows minted before this refusal are real and nothing rewrites them on + // upgrade. Refusing delete would strand them permanently — turning the + // accumulation this card was filed about into an accumulation nobody + // can clear. + const { protocol, rows } = makeProtocol([ + { type: 'fieldz', name: 'showcase_task.title' }, + ]); + expect(metaRows(rows).length).toBe(1); + await expect( + protocol.deleteMetaItem({ type: 'fieldz', name: 'showcase_task.title' }), + ).resolves.toBeDefined(); + }); + + it('still refuses the same name on a re-mint after the delete', async () => { + // The pair that proves the scoping is a policy rather than an accident + // of ordering: the row can leave, and it cannot come back. + const { protocol } = makeProtocol([{ type: 'fieldz', name: 'showcase_task.title' }]); + await protocol.deleteMetaItem({ type: 'fieldz', name: 'showcase_task.title' }); + await expect( + protocol.saveMetaItem({ type: 'fieldz', name: 'showcase_task.title', item: { name: 'x' } }), + ).rejects.toMatchObject({ code: 'INVALID_REQUEST', status: 400 }); + }); +}); diff --git a/packages/spec/api-surface/shared.json b/packages/spec/api-surface/shared.json index b474f16a56..5253a74634 100644 --- a/packages/spec/api-surface/shared.json +++ b/packages/spec/api-surface/shared.json @@ -121,6 +121,7 @@ "singularToPlural (function)", "strictUnknownKeyError (function)", "suggestFieldType (function)", - "tmpl (function)" + "tmpl (function)", + "unrecognisedMetaTypeRefusal (function)" ] } diff --git a/packages/spec/export-origins/shared.json b/packages/spec/export-origins/shared.json index d19e16792a..959948f700 100644 --- a/packages/spec/export-origins/shared.json +++ b/packages/spec/export-origins/shared.json @@ -121,6 +121,7 @@ "singularToPlural": "src/shared/metadata-collection.zod.ts#singularToPlural (function)", "strictUnknownKeyError": "src/shared/suggestions.zod.ts#strictUnknownKeyError (function)", "suggestFieldType": "src/shared/suggestions.zod.ts#suggestFieldType (function)", - "tmpl": "src/shared/expression.zod.ts#tmpl (function)" + "tmpl": "src/shared/expression.zod.ts#tmpl (function)", + "unrecognisedMetaTypeRefusal": "src/shared/metadata-url-spelling.ts#unrecognisedMetaTypeRefusal (function)" } } diff --git a/packages/spec/src/shared/metadata-url-spelling.test.ts b/packages/spec/src/shared/metadata-url-spelling.test.ts index 34ffd136b9..c3ab35ccef 100644 --- a/packages/spec/src/shared/metadata-url-spelling.test.ts +++ b/packages/spec/src/shared/metadata-url-spelling.test.ts @@ -25,6 +25,7 @@ import { META_URL_TO_SINGULAR, canonicalMetaUrlType, metaUrlSpellingRefusal, + unrecognisedMetaTypeRefusal, } from './metadata-url-spelling'; /** @@ -148,6 +149,15 @@ describe('#7894 — the refusal limb is narrow by construction', () => { // The `s`-final names are the sharp cases: a naive "looks plural" heuristic // would refuse all four, and they are ordinary English words a plugin might // well use for a kind. + // + // [#8421] Untouched — every assertion below is the one #7894 wrote, and it + // still passes for the reason it always did: THIS verdict is about + // misspellings of DECLARED types and none of these is one. Read it as a + // statement about `metaUrlSpellingRefusal`, not about the whole boundary: + // `saveMetaItem` now also consults `unrecognisedMetaTypeRefusal`, under + // which the six mapped kinds below stay writable and the six unmapped + // names no longer are. That narrowing is pinned, deliberately visible, in + // the `#8421` block at the bottom of this file. for (const kind of [ 'theme', 'sharing_rule', 'webhook', 'rag_pipeline', 'analytics_cube', 'connector', 'my_plugin_kind', 'address', 'status', 'kudos', 'analysis', 'series', @@ -157,12 +167,20 @@ describe('#7894 — the refusal limb is narrow by construction', () => { } }); - it('documents its residue rather than pretending to be total', () => { - // A spelling that is not a plural of anything is indistinguishable from a - // plugin kind by static means, so it still takes the plugin path. Pinned so - // the limitation is a stated fact rather than an unnoticed gap; closing it - // needs the live registered-type set at the boundary. + it('hands its residue to the OTHER verdict rather than widening (#8421 flipped this)', () => { + // FLIPPED, not deleted (#8421 closed what #7894 left open, the way #7894 + // flipped what #7743 left behind). + // + // This case used to assert the gap itself: `fieldz` is not a plural of + // anything, so nothing refused it and `PUT /meta/fieldz/x` answered 200 + // and minted a namespace. The FIRST half still holds and must keep + // holding — `fieldz` reaches for no declared type, so this predicate has + // nothing to say about it, which is exactly what keeps the POSITIVE + // CONTROL above true by construction. What flipped is the second half: + // the residue is now closed next door, so the predicate's silence is no + // longer the whole story at the boundary. expect(metaUrlSpellingRefusal('fieldz')).toBeNull(); + expect(unrecognisedMetaTypeRefusal('fieldz')).toEqual({ type: 'fieldz' }); }); it('refuses a wrong plural of EVERY declared type, naming that type (#8424)', () => { @@ -185,6 +203,86 @@ describe('#7894 — the refusal limb is narrow by construction', () => { }); }); +describe('#8421 — the second verdict: not a metadata type AT ALL', () => { + it('accepts every declared type, canonical and REST-plural alike', () => { + for (const entry of DEFAULT_METADATA_TYPE_REGISTRY) { + expect(unrecognisedMetaTypeRefusal(entry.type), `${entry.type} is declared`).toBeNull(); + expect(unrecognisedMetaTypeRefusal(expectedRestPlural(entry.type))).toBeNull(); + } + }); + + it('accepts every manifest spelling AND the singular each one folds to', () => { + // The direction that matters most, and the one a registry-quantified + // refusal would get wrong: six of these singulars — `theme`, `webhook`, + // `connector`, `sharing_rule`, `analytics_cube`, `rag_pipeline` — are + // PLUGIN kinds with no static registry entry at all. Refusing them would + // break `PUT /meta/theme/dark`, the exact operation the plugin path exists + // to serve, which is the failure #8421's measurement disqualified option C + // for. Accepting the singular is not decoration: `themes` folds to `theme`, + // and a boundary that refused the fold's own output would be incoherent. + for (const [plural, singular] of Object.entries(PLURAL_TO_SINGULAR)) { + expect(unrecognisedMetaTypeRefusal(plural), `${plural} works today`).toBeNull(); + expect( + unrecognisedMetaTypeRefusal(singular), + `${singular} is what ${plural} folds to`, + ).toBeNull(); + } + }); + + it('carries the six plugin kinds by NAME, since quantification hides them', () => { + const declared = new Set(DEFAULT_METADATA_TYPE_REGISTRY.map((e) => e.type)); + for (const kind of [ + 'analytics_cube', 'connector', 'rag_pipeline', 'sharing_rule', 'theme', 'webhook', + ]) { + expect(declared.has(kind), `${kind} must NOT be in the static registry`).toBe(false); + expect(unrecognisedMetaTypeRefusal(kind), `${kind} must stay accepted anyway`).toBeNull(); + } + }); + + it('refuses the card coordinates — a name the contract does not carry', () => { + for (const type of ['fieldz', 'objectt', 'viewz', 'nonsense_type']) { + expect(unrecognisedMetaTypeRefusal(type)).toEqual({ type }); + } + }); + + it('CHANGED BEHAVIOUR — an unmapped plugin-kind NAME is no longer mintable', () => { + // Its own case rather than folded into the one above, because this is the + // accept-set narrowing the ruling bought and a reviewer must see it. + // + // The POSITIVE CONTROL further up is untouched and still green: none of + // these is a misspelling of a declared type, so `metaUrlSpellingRefusal` + // cannot refuse any of them, exactly as #7894 built it. What changed is + // that `saveMetaItem` consults BOTH verdicts, so a kind whose name is in + // neither half of the static contract can no longer be CREATED through + // `PUT /meta/:type/:name`. + // + // Safe by construction only because #8586 retired `additionalTypes`: with + // no declared-kind channel left, an unrecognised name cannot be a + // declaration this predicate never heard about. If a declared-kind channel + // is ever reintroduced, this case is the one that must be revisited FIRST. + for (const kind of ['my_plugin_kind', 'address', 'status', 'kudos', 'analysis', 'series']) { + expect(metaUrlSpellingRefusal(kind), `${kind} is still not a misspelling`).toBeNull(); + expect( + unrecognisedMetaTypeRefusal(kind), + `${kind} is outside the static contract`, + ).toEqual({ type: kind }); + } + }); + + it('never refuses a spelling the fold is prepared to canonicalize', () => { + // Composition guard over the whole map: refusing an input the boundary + // would happily fold, or refusing the fold's own output, is incoherent in + // a way no single fixture would catch. + for (const spelling of Object.keys(META_URL_TO_SINGULAR)) { + expect(unrecognisedMetaTypeRefusal(spelling), `${spelling} is mapped`).toBeNull(); + expect( + unrecognisedMetaTypeRefusal(canonicalMetaUrlType(spelling)), + `${spelling} folds to a type the verdict must also accept`, + ).toBeNull(); + } + }); +}); + describe('#7894 — the manifest map keeps its own job', () => { it('gains no `fields` collection, so the authoring lint advertises none', () => { // `kernel/metadata-authoring-lint.ts` iterates `PLURAL_TO_SINGULAR` to diff --git a/packages/spec/src/shared/metadata-url-spelling.ts b/packages/spec/src/shared/metadata-url-spelling.ts index d7cbf06686..1aed039c2c 100644 --- a/packages/spec/src/shared/metadata-url-spelling.ts +++ b/packages/spec/src/shared/metadata-url-spelling.ts @@ -65,13 +65,17 @@ * layers below keep reading the single canonical singular. Nothing here should * ever be consulted by a predicate one layer down. * - * ## The published surface is three symbols, by ruling (#8424) + * ## The published surface is four symbols (#8424, extended by #8421) * * {@link META_URL_TO_SINGULAR} (the spelling contract) · * {@link canonicalMetaUrlType} (the fold) · {@link metaUrlSpellingRefusal} - * (the boundary refusal verdict). The helpers behind them are module-internal; - * see {@link metaUrlSpellingRefusal}'s doc for why the verdict is exported and - * the parts are not. + * (the misspelling verdict) · {@link unrecognisedMetaTypeRefusal} (the + * not-a-type-at-all verdict). The helpers behind them are module-internal; + * see {@link metaUrlSpellingRefusal}'s doc for why the verdicts are exported + * and the parts are not. The two verdicts answer different questions and are + * deliberately not merged: one says *you spelled a type we declare wrongly*, + * the other says *we have no such type*, and only the first can name a + * replacement spelling. * * @module */ @@ -201,12 +205,17 @@ function singularCandidates(type: string): string[] { * (`address`, `status`): `singularCandidates` produces `addre`/`addres` and * `statu`/`statue`, none of which is declared, so it is permitted. Good. * - * ## Known residue, deliberately not closed here + * ## The residue this used to leave is now closed next door (#8421) * * A spelling that is not a plural of anything — `/meta/fieldz` — is - * indistinguishable from a plugin kind by static means, so it still takes the - * plugin path. Closing that needs the live registered-type set at the boundary, - * which is a different change with a different risk profile. + * indistinguishable from a plugin kind BY THIS PREDICATE, and still is: it has + * no declared singular to reach for, so this function keeps returning `null` + * for it and the POSITIVE CONTROL above keeps holding by construction. What + * changed is that the boundary no longer treats "this predicate is silent" as + * "forward it to the plugin path" on a WRITE: {@link unrecognisedMetaTypeRefusal} + * answers the other question — *is this a metadata type at all?* — which became + * answerable statically only once #8586 retired `additionalTypes` and left the + * platform with no declared-kind channel to be ignorant of. * * Module-internal (#8424): consumers get the composed verdict from * {@link metaUrlSpellingRefusal}, never this predicate on its own. @@ -251,3 +260,64 @@ export function metaUrlSpellingRefusal( if (declared === null) return null; return { declared, hint: restPluralOfMetaType(declared) }; } + +/** + * Every CANONICAL metadata type the static contract knows — the values of + * {@link META_URL_TO_SINGULAR} rather than its keys. + * + * Strictly larger than `DECLARED_META_TYPES`, and that difference is the whole + * reason this set exists: limb 1 carries six kinds that NO registry derivation + * could produce — `theme`, `webhook`, `connector`, `sharing_rule`, + * `analytics_cube`, `rag_pipeline` — which are legal, addressable metadata + * kinds with no static registry entry. A refusal quantified over the registry + * alone would refuse all six, i.e. break `PUT /meta/theme/dark`, which is the + * exact operation the plugin path exists to serve. + * + * Module-internal (#8424), for the same reason `DECLARED_META_TYPES` is: it + * LOOKS like a live registry of registered types and is not one. + */ +const CANONICAL_META_TYPES: ReadonlySet = new Set(Object.values(META_URL_TO_SINGULAR)); + +/** + * The verdict for a `/meta/:type` segment that is not a metadata type AT ALL + * (#8421, maintainer ruling 2026-08-14 「同意」, joint with #8586). + * + * Returns `null` when the segment is part of the platform's static spelling + * contract — a canonical type, or any spelling that folds to one. Returns the + * verdict when it is neither, i.e. when honouring it would mint a namespace + * for a metadata type that does not exist: `PUT /meta/fieldz/x` answering 200 + * and persisting a `sys_metadata` row under `type='fieldz'`. + * + * ## Why this became answerable statically, having not been before + * + * The version of this module that shipped with #7894 called this residue + * explicitly unclosable: `fieldz` is indistinguishable from a plugin kind by + * static means, and a LIVE-registry lookup (the obvious alternative) was + * measured on #8421 to be worse than the defect — `listLiveMetadataTypes()` is + * an ITEM-POPULATION set, so it omits a legitimate kind that has zero items, + * which is precisely the state every kind is in immediately before its first + * runtime create. + * + * What changed is not the boundary's information but the platform's: #8586 + * retired `MetadataPluginConfig.additionalTypes` (ADR-0049), and with it the + * last channel by which a plugin could DECLARE a metadata kind. There is now + * no declaration this predicate could be ignorant of, which is what makes + * refusing an unrecognised name safe by construction rather than by luck. + * + * ## What it is deliberately NOT + * + * ⛔ Not a spelling guesser. It offers no "did you mean" — {@link + * metaUrlSpellingRefusal} is the verdict that can name a replacement, because + * it is the only one holding evidence of what the caller was reaching for. + * ⛔ Not a claim about the LIVE type set. A running kernel legitimately holds + * type keys this set does not — `data`, `kind` and `package` all enter + * `SchemaRegistry` during a perfectly ordinary `registerApp` — which is why + * the boundary applies this verdict where a namespace is MINTED and nowhere + * else. See `canonicalizeMetaRequestType` in `@objectstack/metadata-protocol` + * for that scoping and the measurement behind it. + */ +export function unrecognisedMetaTypeRefusal(urlType: string): { type: string } | null { + if (urlType in META_URL_TO_SINGULAR) return null; + if (CANONICAL_META_TYPES.has(urlType)) return null; + return { type: urlType }; +} From 087b8f15503ef7602eef9e76b3419ffddae86c0f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 21:47:44 +0000 Subject: [PATCH 2/9] test(spec): widen the declared-set probe to Set for tsconfig.test.json Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XeQRiAa7vYRVX5Fog7Zby8 --- packages/spec/src/shared/metadata-url-spelling.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/spec/src/shared/metadata-url-spelling.test.ts b/packages/spec/src/shared/metadata-url-spelling.test.ts index c3ab35ccef..455d67e94f 100644 --- a/packages/spec/src/shared/metadata-url-spelling.test.ts +++ b/packages/spec/src/shared/metadata-url-spelling.test.ts @@ -230,7 +230,7 @@ describe('#8421 — the second verdict: not a metadata type AT ALL', () => { }); it('carries the six plugin kinds by NAME, since quantification hides them', () => { - const declared = new Set(DEFAULT_METADATA_TYPE_REGISTRY.map((e) => e.type)); + const declared = new Set(DEFAULT_METADATA_TYPE_REGISTRY.map((e) => e.type)); for (const kind of [ 'analytics_cube', 'connector', 'rag_pipeline', 'sharing_rule', 'theme', 'webhook', ]) { From fa07a7bd7da8585a389bc6190e7a1aa01939e211 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 21:58:23 +0000 Subject: [PATCH 3/9] chore(changeset): record the ADR-0087 disposition for the /meta accept-set narrowing Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XeQRiAa7vYRVX5Fog7Zby8 --- .changeset/meta-unrecognised-type-refused.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.changeset/meta-unrecognised-type-refused.md b/.changeset/meta-unrecognised-type-refused.md index 7b273d023c..58b4ebe291 100644 --- a/.changeset/meta-unrecognised-type-refused.md +++ b/.changeset/meta-unrecognised-type-refused.md @@ -5,6 +5,9 @@ fix(metadata): `PUT /meta/:type` refuses a type name the platform does not have, instead of minting a namespace for it (#8421) + + + **BREAKING** accept-set narrowing on a published HTTP surface, landing after the v17.0.0 cut (the lockstep launch-window convention ships it as `minor`). A write that answered `200 {"success":true}` now answers `400 INVALID_REQUEST`: From f59a135f05e2033c9e73272cf144795762757821 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 22:09:17 +0000 Subject: [PATCH 4/9] test(metadata-protocol): key the stub rows structurally, keeping the tsc ratchet at 63 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stub's `keyOf` took `Record`, which a `Row` interface is not assignable to — one new TS2345 in a package whose measured error count is a shrink-only ledger entry. Reading the four key fields structurally serves both callers without a cast. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XeQRiAa7vYRVX5Fog7Zby8 --- .../src/protocol.unrecognised-meta-type.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/metadata-protocol/src/protocol.unrecognised-meta-type.test.ts b/packages/metadata-protocol/src/protocol.unrecognised-meta-type.test.ts index 0bd7b78daa..894cabc2f1 100644 --- a/packages/metadata-protocol/src/protocol.unrecognised-meta-type.test.ts +++ b/packages/metadata-protocol/src/protocol.unrecognised-meta-type.test.ts @@ -62,7 +62,11 @@ interface Row { function makeProtocol(seedRows: Array> = []) { const rows = new Map(); let nextId = 0; - const keyOf = (w: Record) => + // Reads the four key fields structurally, so both a stored `Row` and the + // loose record `insert` hands over key identically (an interface is not + // assignable to `Record`, and this package's tsc error + // count is a shrink-only ratchet — a cast here would spend it). + const keyOf = (w: { type?: unknown; name?: unknown; organization_id?: unknown; state?: unknown }) => `${w.type}|${w.name}|${w.organization_id ?? '__env__'}|${w.state ?? 'active'}`; for (const seed of seedRows) { nextId += 1; From ee8b852ecd00e464b9f365bf72bccb6dee1bcb34 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 23:39:50 +0000 Subject: [PATCH 5/9] fix(metadata): exempt the compound arity and already-stored namespaces from the /meta mint refusal (#8421) --- .../protocol.stored-residue-resave.test.ts | 238 +++++++++++++++ packages/metadata-protocol/src/protocol.ts | 179 +++++++---- .../protocol.unrecognised-meta-type.test.ts | 81 ++++- .../src/meta-compound-arity-mint-door.test.ts | 281 ++++++++++++++++++ .../spec/src/shared/metadata-url-spelling.ts | 11 +- 5 files changed, 735 insertions(+), 55 deletions(-) create mode 100644 packages/metadata-protocol/src/protocol.stored-residue-resave.test.ts create mode 100644 packages/runtime/src/meta-compound-arity-mint-door.test.ts diff --git a/packages/metadata-protocol/src/protocol.stored-residue-resave.test.ts b/packages/metadata-protocol/src/protocol.stored-residue-resave.test.ts new file mode 100644 index 0000000000..1e9d8672ed --- /dev/null +++ b/packages/metadata-protocol/src/protocol.stored-residue-resave.test.ts @@ -0,0 +1,238 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #8421 — the two PRODUCTION paths that re-save a row taking its type from an + * existing `sys_metadata` row, measured against a residue row. + * + * The refusal this card ships closes the door that MINTS a namespace, and it + * deliberately leaves `deleteMetaItem` open: rows written under an unrecognised + * type BEFORE the refusal are real, nothing rewrites them on upgrade, and + * refusing their removal would turn the accumulation the card is about into an + * accumulation nobody can clear. Residue is re-SAVED as well as deleted, though, + * and by the platform itself: + * + * - {@link ObjectStackProtocolImplementation.migrateStoredMetadata} — + * `source: 'migrate-stored'`, the `os migrate meta --stored --apply` pass; + * - {@link ObjectStackProtocolImplementation.duplicatePackage} — the copy/clone + * path, which re-saves every row of a package under a new name. + * + * ## The two paths do NOT behave the same, and only measurement says which + * + * Both were read from their call sites as "would be refused". One of them is + * not, and the reason is structural rather than lucky — which is exactly the + * kind of claim that must be pinned rather than argued: + * + * - **migrate never reaches the door.** `applyConversionsToStoredItem` keys the + * ADR-0087 chain on the type's MANIFEST COLLECTION (`SINGULAR_TO_PLURAL`), and + * an unrecognised type has none — so the body comes back untouched, the pass + * emits no notice, and the row is recorded `canonical` without `saveMetaItem` + * ever being called. An unrecognised type can never acquire a conversion + * chain (nothing declares one for a type the platform does not have), so this + * is a property of the design, not of this fixture's body. + * - **the copy DID reach it**, and answered + * `{success: false, copiedCount: 0, failedCount: 1}` — a package holding one + * residue row could not be duplicated at all. That contradicts the + * `deleteMetaItem` reasoning directly, so exempting an already-stored + * namespace is a repair of this change rather than a new decision. + * + * Every case therefore asserts the OUTCOME of the whole pass, not "it did not + * throw": a copy that silently dropped the residue row would also not throw, and + * would be the partial-copy-reported-as-whole defect #7819 closed. + */ +import { describe, expect, it } from 'vitest'; +// [#5619] The producer's OWN write-verb dispatch decisions, so the fake engine +// below cannot accept a call ObjectQL itself refuses. +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +function matches(r: Record, where: Record): boolean { + for (const [k, v] of Object.entries(where)) { + if (v === undefined) continue; + // ⚠️ Conjoined with its siblings, never early-returned: a + // `return branches.some(...)` discards every other key in the clause and + // silently widens the match (#7846 / #7620). + if (k === '$or') { + const clauses = v as Array>; + if (!clauses.some((c) => matches(r, c))) return false; + continue; + } + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); + if ((r[k] ?? null) !== v) return false; + } + return true; +} + +/** + * A multi-table stub engine — `sys_metadata` seeded, every other table + * (`sys_metadata_history`, `sys_metadata_audit`, `sys_packages`) created on + * first write. The shape `protocol.stored-migration.test.ts` uses, because the + * claim under test is what the REAL write path does with these rows. + */ +function makeStubEngine(seedRows: Array>) { + let nextId = 0; + const tables = new Map[]>(); + tables.set('sys_metadata', seedRows.map((r) => ({ + id: `r_${++nextId}`, + organization_id: null, + package_id: null, + state: 'active', + checksum: `sha256:seed_${nextId}`, + ...r, + metadata: typeof r.metadata === 'string' ? r.metadata : JSON.stringify(r.metadata), + }))); + const rowsOf = (t: string): Record[] => { + let rows = tables.get(t); + if (!rows) tables.set(t, (rows = [])); + return rows; + }; + const engine: any = { + async find(t: string, opts?: { where?: Record }) { + return rowsOf(t).filter((r) => matches(r, opts?.where ?? {})); + }, + async findOne(t: string, opts?: { where?: Record }) { + return rowsOf(t).find((r) => matches(r, opts?.where ?? {})) ?? null; + }, + async insert(t: string, row: Record) { + const withId = { id: row.id ?? `r_${++nextId}`, ...row }; + rowsOf(t).push(withId); + return withId; + }, + async update(t: string, patch: Record, opts: { where: Record }) { + assertEngineUpdateDispatch(patch, opts); + const target = rowsOf(t).find((r) => matches(r, opts.where)); + if (target) Object.assign(target, patch); + return target ?? { id: 'x' }; + }, + async delete(_t: string, opts?: Record) { + assertEngineDeleteDispatch(opts); + return { deleted: 0 }; + }, + async count() { return 0; }, + async transaction(fn: (ctx: unknown) => Promise) { return fn(undefined); }, + async execute() { return {}; }, + async getObjectSchema() { return undefined; }, + registry: { + listItems: () => [], + isPackageDisabled: () => false, + registerItem: () => { /* no-op */ }, + registerObject: () => { /* no-op */ }, + unregisterItem: () => { /* no-op */ }, + getItem: () => undefined, + getArtifactItem: () => undefined, + getPackage: () => undefined, + }, + }; + return { engine, tables }; +} + +const metaRows = (tables: Map[]>) => tables.get('sys_metadata') ?? []; + +/** + * A row of exactly the class the card was filed about: minted through the old + * permissive plugin path under a type the platform does not have, sitting in a + * package alongside ordinary metadata. Seeded straight into the store, which is + * the only way it can exist now — `saveMetaItem` refuses to create it. + */ +const RESIDUE_ROW = { + type: 'fieldz', + name: 'showcase_task.title', + package_id: 'app.source', + metadata: { name: 'showcase_task.title', label: 'Residue' }, +}; + +/** An ordinary, recognised row in the same package — the copy's control. */ +const CANONICAL_ROW = { + type: 'view', + name: 'showcase_task.open', + package_id: 'app.source', + metadata: { + name: 'showcase_task.open', + label: 'Open', + object: 'showcase_task', + viewKind: 'list', + columns: [{ field: 'title', label: 'Title' }], + }, +}; + +describe('#8421 — `migrate meta --stored` and a residue row', () => { + it('reports it canonical and writes nothing — the mint door is never reached', async () => { + const { engine, tables } = makeStubEngine([RESIDUE_ROW]); + const before = JSON.stringify(metaRows(tables)); + const protocol = new ObjectStackProtocolImplementation(engine); + + const report = await protocol.migrateStoredMetadata({ apply: true }); + + // `failed: 0` is the assertion that matters. `saveMetaItem`'s refusal + // would land here (the pass wraps that call and records the thrown text + // as report DATA), so a green `scanned: 1` alone would not distinguish + // "not refused" from "not reached". + expect(report).toMatchObject({ scanned: 1, canonical: 1, failed: 0, rewritten: 0 }); + expect(report.rows).toHaveLength(0); + // …and the row is byte-identical, so nothing was rewritten under it. + expect(JSON.stringify(metaRows(tables))).toBe(before); + }); + + it('still migrates a REAL legacy row in the same pass', async () => { + // ANTI-VACUITY for the case above: this pass can and does re-save, so + // "canonical, nothing written" is a verdict about the residue row rather + // than a pass that does nothing at all. + const { engine, tables } = makeStubEngine([RESIDUE_ROW, { + type: 'object', + name: 'crm_invoice', + package_id: 'app.source', + metadata: { + name: 'crm_invoice', + label: 'Invoice', + fields: { + amount: { type: 'currency', label: 'Amount', conditionalRequired: "record.status == 'sent'" }, + }, + }, + }]); + const protocol = new ObjectStackProtocolImplementation(engine); + + const report = await protocol.migrateStoredMetadata({ apply: true }); + + expect(report).toMatchObject({ scanned: 2, rewritten: 1, failed: 0 }); + const invoice = metaRows(tables).find((r) => r.name === 'crm_invoice')!; + expect(JSON.parse(invoice.metadata).fields.amount.requiredWhen).toBe("record.status == 'sent'"); + }); +}); + +describe('#8421 — `duplicatePackage` and a residue row', () => { + it('copies it instead of failing the duplicate', async () => { + const { engine, tables } = makeStubEngine([RESIDUE_ROW, CANONICAL_ROW]); + const protocol = new ObjectStackProtocolImplementation(engine); + + const result = await protocol.duplicatePackage({ + sourcePackageId: 'app.source', + targetPackageId: 'app.copy', + }); + + // Measured BEFORE the already-stored exemption: + // `{success: false, copiedCount: 0, failedCount: 1}` with the residue + // row's refusal text in `failed[0].error` — one pre-existing row made a + // whole package unduplicatable. + expect(result).toMatchObject({ success: true, copiedCount: 2, failedCount: 0 }); + expect(result.failed).toEqual([]); + // The store, not the return value: the copy really landed under the + // target package, with the residue type key intact. + const copied = metaRows(tables).filter((r) => r.package_id === 'app.copy'); + expect(copied.map((r) => r.type).sort()).toEqual(['fieldz', 'view']); + }); + + it('POSITIVE CONTROL — the copy does not re-open the mint door', async () => { + // The pair that makes the exemption a policy rather than a hole: the + // duplicate above succeeded because `fieldz` ALREADY had a row. A type + // nothing has ever stored is still refused, in the same store, on the + // ordinary authoring door. + const { engine, tables } = makeStubEngine([RESIDUE_ROW, CANONICAL_ROW]); + const protocol = new ObjectStackProtocolImplementation(engine); + + await protocol.duplicatePackage({ sourcePackageId: 'app.source', targetPackageId: 'app.copy' }); + + await expect( + protocol.saveMetaItem({ type: 'objectt', name: 'showcase_task', item: { name: 'showcase_task' } }), + ).rejects.toMatchObject({ code: 'INVALID_REQUEST', status: 400 }); + expect(metaRows(tables).some((r) => r.type === 'objectt')).toBe(false); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index eab75aafa0..674298b249 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -208,39 +208,18 @@ function canonicalMetaType(type: string): string { * and (#8424) for why this boundary consumes the composed VERDICT rather than * the predicate parts: the spelling contract stays whole at its producer. * - * ## [#8421] `opts.minting` — the second verdict, and why it is not on all six + * ## [#8421] The SECOND verdict is not here * - * The refusal above is silent for a name that is not a plural of ANYTHING + * This refusal is silent for a name that is not a plural of ANYTHING * (`fieldz`), because such a name reaches for no declared type. That residue - * was left open deliberately in #7894 and closed by the maintainer's ruling of - * 2026-08-14 (「同意」), joint with #8586: retiring `additionalTypes` removed the - * last channel by which a plugin could DECLARE a metadata kind, so an - * unrecognised name can no longer be a declaration this boundary has not heard - * about. {@link unrecognisedMetaTypeRefusal} is that verdict. - * - * It is passed ONLY by {@link saveMetaItem} — the one entry point that MINTS a - * `sys_metadata` namespace — and that scoping is measured, not timid: - * - * - **The live type set legitimately holds keys the static contract does - * not.** An ordinary `registerApp` puts `data` (a manifest's seed - * datasets), `kind` (`contributes.kinds`) and `package` into - * `SchemaRegistry`, and {@link listLiveMetadataTypes} — hence `GET - * /api/v1/meta/types` — enumerates exactly that set. Refusing unrecognised - * names on the READ entries would answer 400 for types this same service - * advertises, trading one declared-≠-served gap for another. - * - **`deleteMetaItem` must stay open for the opposite reason.** Rows minted - * under an unrecognised type BEFORE this refusal are real and nothing - * rewrites them on upgrade; refusing delete would strand them permanently — - * turning the accumulation this card was filed about into an accumulation - * nobody can clear. - * - * So reads stay permissive, deletes stay possible, and the door that creates a - * namespace for a type that does not exist is the one that closes. + * was closed by the maintainer's ruling of 2026-08-14 (「同意」), joint with + * #8586 — but not in this function, because the verdict that closes it cannot + * be answered from the request alone: it has to know whether the namespace it + * would create already exists. It lives on the mint door itself, as + * {@link ObjectStackProtocolImplementation.refuseUnmintableMetaType}, which + * carries the scoping argument and the measurements behind it. */ -function canonicalizeMetaRequestType( - request: T, - opts?: { minting?: boolean }, -): T { +function canonicalizeMetaRequestType(request: T): T { const refusal = metaUrlSpellingRefusal(request.type); if (refusal) { const err = new Error( @@ -253,21 +232,6 @@ function canonicalizeMetaRequestType( (err as any).status = 400; throw err; } - if (opts?.minting === true) { - const unrecognised = unrecognisedMetaTypeRefusal(request.type); - if (unrecognised) { - const err = new Error( - `[invalid_request] '${unrecognised.type}' is not a metadata type. The platform declares ` - + `no such type, and since #8586 retired 'additionalTypes' a plugin cannot declare one ` - + `either — so this write would mint a sys_metadata namespace under ` - + `type='${unrecognised.type}' that nothing reads and nothing serves. Address a real ` - + `metadata type; GET /api/v1/meta/types lists the ones this deployment carries.`, - ); - (err as any).code = 'INVALID_REQUEST'; - (err as any).status = 400; - throw err; - } - } const type = canonicalMetaType(request.type); return type === request.type ? request : { ...request, type }; } @@ -11289,18 +11253,129 @@ export class ObjectStackProtocolImplementation implements return true; } + /** + * [#8421] The SECOND `/meta` verdict: refuse a `:type` segment that is not + * a metadata type AT ALL, on the one entry point that MINTS a + * `sys_metadata` namespace. + * + * {@link metaUrlSpellingRefusal} (#7894) is silent for a name that is not a + * plural of anything — `fieldz` reaches for no declared type — so + * `PUT /api/v1/meta/fieldz/showcase_task.title` answered 200 and persisted + * a row under `type='fieldz'`. Maintainer ruling 2026-08-14 (「同意」), joint + * with #8586: retiring `additionalTypes` removed the last channel by which + * a plugin could DECLARE a metadata kind, so an unrecognised name can no + * longer be a declaration this boundary has not heard about. + * {@link unrecognisedMetaTypeRefusal} is that verdict. + * + * ## Why the verdict is not raised on all six `/meta` entry points + * + * Measured, not timid: + * + * - **The live type set legitimately holds keys the static contract does + * not.** An ordinary `registerApp` puts `data` (a manifest's seed + * datasets), `kind` (`contributes.kinds`) and `package` into + * `SchemaRegistry`, and {@link listLiveMetadataTypes} — hence `GET + * /api/v1/meta/types` — enumerates exactly that set. Refusing + * unrecognised names on the READ entries would answer 400 for types this + * same service advertises, trading one declared-≠-served gap for another. + * - **`deleteMetaItem` must stay open for the opposite reason.** Rows + * minted under an unrecognised type BEFORE this refusal are real and + * nothing rewrites them on upgrade; refusing delete would strand them + * permanently — turning the accumulation this card was filed about into + * an accumulation nobody can clear. + * + * ## …and why two shapes reaching THIS door are exempt (#8421 rework) + * + * Both were regressions in the first cut, both measured on the three + * consumer packages the first cut never ran: + * + * 1. **The COMPOUND arity puts an OBJECT name in the `:type` segment.** + * `/metadata/lead/views/all_leads` is `type='lead'`, + * `name='views/all_leads'` — one operation reaching one + * `saveMetaItem`, documented verbatim in the runtime dispatcher's own + * `/meta` branch and in `rest`'s `PUBLISHED_COMPOUND` route. `lead` is + * an object, i.e. RUNTIME DATA, and no static contract can enumerate + * the objects a deployment carries — so applying a static type verdict + * to that segment refuses every object name that is not coincidentally + * a metadata type. The maintainer's ruling is about metadata TYPE names + * like `fieldz`; this was not a narrowing anyone approved. + * ⚠️ Residue, stated rather than hidden: `PUT /meta/fieldz/a/b` is + * therefore still accepted, because at that arity `fieldz` is a claim + * about an object and the alternative is a live-registry check — option + * C, ruled out on this very card. + * 2. **A namespace that ALREADY EXISTS is not being minted.** Two + * production paths re-save a row taking its type from an existing + * `sys_metadata` row: {@link migrateStoredMetadata} (`source: + * 'migrate-stored'`) and {@link duplicatePackage}'s copy/clone. Measured + * on this branch: migrate never reaches this door for such a row + * (`applyConversionsToStoredItem` returns it untouched — an unrecognised + * type has no manifest collection, hence no conversion chain, hence no + * notice, hence `outcome: 'canonical'`), while **`duplicatePackage` + * DID** — a package holding one residue row answered + * `{success: false, copiedCount: 0, failedCount: 1}`. That contradicts + * the `deleteMetaItem` reasoning directly above, so the exemption is a + * repair of this change rather than a new decision. + * + * The store — not the caller — is what says the namespace exists, so the + * exemption cannot be claimed by a request: the probe runs only once the + * static verdict has already fired (never on the ordinary save path), and + * a store that cannot answer refuses, because a fresh deployment has no + * residue to protect. + */ + private async refuseUnmintableMetaType(request: { type: string, name: string }): Promise { + const unrecognised = unrecognisedMetaTypeRefusal(request.type); + if (!unrecognised) return; + // Exemption 1 — the compound arity. Cheap, and first: it is a statement + // about the REQUEST SHAPE and needs no store at all. + if (request.name.includes('/')) return; + // Exemption 2 — the namespace predates this write. + if (await this.metaTypeNamespaceExists(unrecognised.type)) return; + const err = new Error( + `[invalid_request] '${unrecognised.type}' is not a metadata type. The platform declares ` + + `no such type, and since #8586 retired 'additionalTypes' a plugin cannot declare one ` + + `either — so this write would mint a sys_metadata namespace under ` + + `type='${unrecognised.type}' that nothing reads and nothing serves. Address a real ` + + `metadata type; GET /api/v1/meta/types lists the ones this deployment carries.`, + ); + (err as any).code = 'INVALID_REQUEST'; + (err as any).status = 400; + throw err; + } + + /** + * Does `sys_metadata` already carry a row under this type key? + * + * Reached ONLY from {@link refuseUnmintableMetaType} after the static + * verdict has fired, so it costs nothing on the ordinary save path. Not + * scoped by name, org or state on purpose: the question is whether the + * NAMESPACE exists, and a residue row is exactly as real in a draft or in + * another org's overlay as it is here. + * + * A store that cannot answer counts as "no" — the refusal stands. A fresh + * deployment has no residue to protect, and a table that is not provisioned + * yet is the state in which the card's own defect (minting the first row of + * a namespace nothing serves) is at its most reachable. + */ + private async metaTypeNamespaceExists(type: string): Promise { + try { + const row = await this.engine.findOne('sys_metadata', { where: { type } }); + return row != null; + } catch { + return false; + } + } + async saveMetaItem(request: { type: string, name: string, item?: any, organizationId?: string, parentVersion?: string | null, actor?: string, force?: boolean, mode?: 'draft' | 'publish', packageId?: string | null, source?: string }) { if (!request.item) { throw new Error('Item data is required'); } // #4432 — CANONICAL TYPE KEY. See {@link canonicalMetaType}. - // - // [#8421] `minting: true` — this is the entry point that CREATES a - // `sys_metadata` namespace, so it is the one that refuses a type name - // the platform has never heard of instead of forwarding it to the - // permissive plugin path. The read entries deliberately do not pass it; - // `canonicalizeMetaRequestType`'s doc carries the measurement. - request = canonicalizeMetaRequestType(request, { minting: true }); + request = canonicalizeMetaRequestType(request); + // [#8421] …and the second verdict, on the door that MINTS. Kept here + // rather than inside the fold above because it is not answerable from + // the request alone — see {@link refuseUnmintableMetaType} for the + // scoping, its two exemptions, and the measurements behind both. + await this.refuseUnmintableMetaType(request); // What the history row, the audit row and the watch event record as the // origin of this write. Defaults to this method — the ordinary Studio / // REST / SDK save. The only caller that overrides it is diff --git a/packages/metadata-protocol/src/protocol.unrecognised-meta-type.test.ts b/packages/metadata-protocol/src/protocol.unrecognised-meta-type.test.ts index 894cabc2f1..73024fd048 100644 --- a/packages/metadata-protocol/src/protocol.unrecognised-meta-type.test.ts +++ b/packages/metadata-protocol/src/protocol.unrecognised-meta-type.test.ts @@ -37,6 +37,12 @@ * works, or the accumulation this card was filed about would become * unremovable. * + * The last two describe blocks pin the two exemptions the first cut lacked, each + * paired with the refusal that must survive it: the COMPOUND arity (whose + * `:type` segment carries an OBJECT name) and an already-stored namespace (which + * is not being minted by definition). `protocol.stored-residue-resave.test.ts` + * carries the production paths that made the second one necessary. + * * Harness: the real `saveMetaItem` write path over a stub engine, the shape * `protocol.code-only-types.test.ts` uses. A gate INSIDE `saveMetaItem` cannot * be measured against a harness that mocks `saveMetaItem`. @@ -257,7 +263,10 @@ describe('#8421 — the refusal is scoped to the door that MINTS', () => { it('still refuses the same name on a re-mint after the delete', async () => { // The pair that proves the scoping is a policy rather than an accident - // of ordering: the row can leave, and it cannot come back. + // of ordering: the row can leave, and it cannot come back. It is also + // the negative half of the already-stored exemption below — with the + // last `fieldz` row gone the namespace no longer exists, so the door + // closes again on the very name it had just let out. const { protocol } = makeProtocol([{ type: 'fieldz', name: 'showcase_task.title' }]); await protocol.deleteMetaItem({ type: 'fieldz', name: 'showcase_task.title' }); await expect( @@ -265,3 +274,73 @@ describe('#8421 — the refusal is scoped to the door that MINTS', () => { ).rejects.toMatchObject({ code: 'INVALID_REQUEST', status: 400 }); }); }); + +describe('#8421 — the COMPOUND arity carries an OBJECT name, not a type claim', () => { + // `/metadata/lead/views/all_leads` → `type='lead'`, `name='views/all_leads'`. + // One operation, one `saveMetaItem`; the runtime dispatcher's `/meta` branch + // and `rest`'s `PUBLISHED_COMPOUND` route both document that shape verbatim. + // `lead` is an OBJECT — runtime data no static contract can enumerate — so + // a type verdict applied to that segment refuses every object name that is + // not coincidentally a metadata type. Measured as a regression of the first + // cut in two packages; the ruling this card implements is about metadata + // TYPE names like `fieldz`. + const VIEW_BODY = { name: 'all_leads', label: 'All Leads', columns: ['name'] }; + + it('saves a sub-resource under an object name the static contract cannot know', async () => { + const { protocol, rows } = makeProtocol(); + + const result = await protocol.saveMetaItem({ + type: 'lead', name: 'views/all_leads', item: VIEW_BODY, + }); + + expect(result.success).toBe(true); + // The compound name is ONE key — not split, not truncated to its last + // segment — which is what makes this the same operation the two + // transports document rather than a lookalike. + expect(metaRows(rows)).toHaveLength(1); + expect(metaRows(rows)[0]).toMatchObject({ type: 'lead', name: 'views/all_leads' }); + }); + + it('and the exemption is the ARITY, not the name — `lead` alone is still refused', async () => { + // ANTI-VACUITY, and the line between the two fixes: at the simple arity + // the `:type` segment IS a type claim, so the same string that is a + // legal owner above is an illegal type here. Without this case the + // exemption above would be indistinguishable from "stop refusing". + const { protocol, rows } = makeProtocol(); + + await expect( + protocol.saveMetaItem({ type: 'lead', name: 'all_leads', item: VIEW_BODY }), + ).rejects.toMatchObject({ code: 'INVALID_REQUEST', status: 400 }); + expect(metaRows(rows)).toHaveLength(0); + }); +}); + +describe('#8421 — a namespace that ALREADY EXISTS is not being minted', () => { + it('accepts a NEW item under a residue type that has stored rows', async () => { + // The exemption `duplicatePackage` needs (measured in + // `protocol.stored-residue-resave.test.ts`): the copy re-saves a stored + // row's type under a NEW name, so an exemption keyed on the exact item + // would not reach it. What is exempt is the namespace, and the STORE is + // what says it exists — never the caller. + const { protocol, rows } = makeProtocol([{ type: 'fieldz', name: 'showcase_task.title' }]); + + const result = await protocol.saveMetaItem({ + type: 'fieldz', name: 'showcase_task.due_at', item: { name: 'showcase_task.due_at' }, + }); + + expect(result.success).toBe(true); + expect(metaRows(rows)).toHaveLength(2); + }); + + it('POSITIVE CONTROL — a FRESH unrecognised type is still refused in the same store', async () => { + // The pair is the whole point: residue stays workable, and the door + // that mints the FIRST row of a namespace nothing serves stays shut. + // Same protocol instance, so this cannot pass by the store being empty. + const { protocol, rows } = makeProtocol([{ type: 'fieldz', name: 'showcase_task.title' }]); + + await expect( + protocol.saveMetaItem({ type: 'objectt', name: 'showcase_task', item: { name: 'x' } }), + ).rejects.toMatchObject({ code: 'INVALID_REQUEST', status: 400 }); + expect(metaRows(rows)).toHaveLength(1); + }); +}); diff --git a/packages/runtime/src/meta-compound-arity-mint-door.test.ts b/packages/runtime/src/meta-compound-arity-mint-door.test.ts new file mode 100644 index 0000000000..445dcb4b8a --- /dev/null +++ b/packages/runtime/src/meta-compound-arity-mint-door.test.ts @@ -0,0 +1,281 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #8421 — the COMPOUND `/meta` arity survives the unrecognised-type refusal, + * pinned at the LIVE ROUTE. + * + * `/metadata/lead/views/all_leads` is `type='lead'`, `name='views/all_leads'`: + * ONE operation reaching ONE `saveMetaItem`, the shape this dispatcher's own + * `/meta` branch documents verbatim ("compound names are how the client + * expresses sub-resources of a type"). The segment in the `:type` position is an + * OBJECT name — runtime data, which no static contract can enumerate — so a + * static type verdict applied there refuses every object name that is not + * coincidentally a metadata type. + * + * ## Why this file exists rather than one more case next door + * + * `domains/meta-save-capability-gate.test.ts` already drives this exact path, + * and it stayed GREEN through the regression: its caller holds no capabilities, + * so `PERMISSION_DENIED` answers before the protocol is ever resolved, and its + * `saveMetaItem` is a `vi.fn()` that could not have refused anything anyway. The + * site was masked, not unaffected — a 403 arriving first is not evidence about + * what the door behind it does. So every case here holds `manage_metadata` and + * drives the REAL `ObjectStackProtocolImplementation` over a real store, and + * then reads the stored ROW rather than the response body. + * + * ⚠️ `packages/runtime` resolves `@objectstack/metadata-protocol` through its + * built `dist`, and stack traces are source-mapped back to `src` — so any + * ablation of the refusal must REBUILD that package before it proves anything. + * A source-only revert measures the pre-mutation artifact and stays green. + * + * ## Reverse verification, direction predicted BEFORE running + * + * Deleting the compound exemption from `refuseUnmintableMetaType` (the + * `request.name.includes('/')` line) and rebuilding must turn the two compound + * cases RED — `400 INVALID_REQUEST`, no row — and leave the simple-arity + * refusal and the recognised-type control GREEN. Predicted 2 red / 3 green; + * measured 2 red / 3 green. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +// [#5619] The producer's OWN write-verb dispatch decisions, so this double +// cannot accept a call the real ObjectQL engine would refuse. Imported from +// `@objectstack/metadata-core` and never `@objectstack/objectql`. +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { HttpDispatcher } from './http-dispatcher.js'; +import type { HttpDispatcherResult } from './http-dispatcher.js'; + +interface Row { id: string; [k: string]: unknown } + +function matches(row: Row, where: Record | undefined): boolean { + if (!where) return true; + for (const [key, cond] of Object.entries(where)) { + if (cond === undefined) continue; + // ⚠️ Conjoined with its siblings, never early-returned (#7846 / #7620). + if (key === '$or') { + const branches = cond as Array>; + if (!branches.some((b) => matches(row, b))) return false; + continue; + } + const value = row[key]; + if (cond !== null && typeof cond === 'object') { + const op = cond as Record; + if ('$null' in op) { + const isNull = value === null || value === undefined; + if (isNull !== (op.$null === true)) return false; + continue; + } + if ('$in' in op) { + if (!(op.$in as unknown[]).includes(value)) return false; + continue; + } + continue; + } + if (cond === null) { + if (value !== null && value !== undefined) return false; + continue; + } + if (value !== cond) return false; + } + return true; +} + +/** The object the compound arity addresses — runtime-authored, no `_packageId`. */ +const LEAD_OBJECT = { + name: 'lead', + label: 'Lead', + // [#8310] The runtime object door requires an authored OWD. + sharingModel: 'private', + fields: { name: { type: 'text', label: 'Name' } }, +}; + +function makeEngine() { + const tables = new Map(); + let nextId = 0; + const tableOf = (name: string) => { + let t = tables.get(name); + if (!t) { t = []; tables.set(name, t); } + return t; + }; + const runtimeItems = new Map>([ + ['object', new Map([[LEAD_OBJECT.name, LEAD_OBJECT]])], + ]); + + const engine: any = { + registry: { + listItems: (type: string) => Array.from(runtimeItems.get(type)?.values() ?? []), + getItem: (type: string, name: string) => runtimeItems.get(type)?.get(name), + // Nothing here is package-stamped, so nothing is artifact-backed. + getArtifactItem: () => undefined, + getObject: (name: string) => runtimeItems.get('object')?.get(name), + getPackage: () => undefined, + isPackageDisabled: () => false, + applyNavContributions: (app: unknown) => app, + registerItem: (type: string, item: any, keyStrategy?: string) => { + const key = keyStrategy === 'object' ? (item?.object as string) : (item?.name as string); + if (!key) return; + let byName = runtimeItems.get(type); + if (!byName) { byName = new Map(); runtimeItems.set(type, byName); } + byName.set(key, item); + }, + registerObject: () => {}, + }, + async find(table: string, opts?: { where?: Record }) { + return tableOf(table).filter((r) => matches(r, opts?.where)); + }, + async findOne(table: string, opts?: { where?: Record }) { + return tableOf(table).find((r) => matches(r, opts?.where)) ?? null; + }, + async insert(table: string, data: Record) { + nextId += 1; + const row: Row = { id: (data.id as string) ?? `r_${nextId}`, ...data }; + tableOf(table).push(row); + return row; + }, + async update(table: string, data: Record, opts?: { where?: Record }) { + const dispatch = assertEngineUpdateDispatch(data as any, opts as any); + const rows = tableOf(table); + const target = dispatch.kind === 'by-id' + ? rows.find((r) => r.id === dispatch.id) + : rows.find((r) => matches(r, opts?.where)); + if (target) Object.assign(target, data); + return target ?? null; + }, + async delete(table: string, opts?: { where?: Record }) { + const dispatch = assertEngineDeleteDispatch(opts as any); + const rows = tableOf(table); + const keep = dispatch.kind === 'by-id' + ? rows.filter((r) => r.id !== dispatch.id) + : rows.filter((r) => !matches(r, opts?.where)); + const deleted = rows.length - keep.length; + tables.set(table, keep); + return { deleted }; + }, + async count(table: string, opts?: { where?: Record }) { + return tableOf(table).filter((r) => matches(r, opts?.where)).length; + }, + async aggregate() { return []; }, + async execute() { return undefined; }, + metaRows: () => tableOf('sys_metadata'), + }; + return engine; +} + +function makeDispatcher(protocol: unknown, engine: any) { + const services: Record = { + protocol, + objectql: { registry: engine.registry }, + auth: { api: { getSession: async () => ({ session: {} }) } }, + }; + const kernel = { + getServiceAsync: async (name: string) => services[name] ?? null, + getService: (name: string) => services[name] ?? null, + context: { getService: (name: string) => services[name] ?? null }, + } as any; + return new HttpDispatcher(kernel); +} + +/** + * An authorized context. Without `manage_metadata` (#7019) every PUT 403s + * before the mint door is reached — which is precisely how the compound site + * stayed green while it was broken. + */ +const ctx = (): any => ({ + request: { headers: {} }, + environmentId: 'env_1', + executionContext: { userId: 'usr_1', systemPermissions: ['manage_metadata'] }, +}); + +function makeStack() { + const engine = makeEngine(); + const protocol: any = new ObjectStackProtocolImplementation(engine, () => new Map(), undefined); + return { engine, protocol, dispatcher: makeDispatcher(protocol, engine) }; +} + +const metaRow = (engine: any, type: string, name: string) => + engine.metaRows().find((r: any) => r.type === type && r.name === name && r.state === 'active'); + +function responseOf(result: HttpDispatcherResult): NonNullable { + const response = result.response; + if (!response) throw new Error('the dispatcher handled the route but returned no response'); + return response; +} + +describe('#8421 — the compound `/meta` arity is not a metadata-type claim', () => { + beforeEach(() => { + // The protocol logs degradation lines on these paths; not the subject. + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + it('saves a sub-resource addressed under an OBJECT name', async () => { + const { engine, dispatcher } = makeStack(); + + const res = responseOf(await dispatcher.handleMetadata( + '/lead/views/all_leads', ctx(), 'PUT', + { name: 'all_leads', label: 'All Leads', columns: ['name'] }, + )); + + expect(res.status).toBe(200); + // The stored ROW, not the answer: the compound name is reassembled and + // used as ONE key — not split, not truncated to its last segment. + expect(metaRow(engine, 'lead', 'views/all_leads')).toBeDefined(); + expect(metaRow(engine, 'lead', 'all_leads')).toBeUndefined(); + }); + + it('…on a deeper compound name too', async () => { + const { engine, dispatcher } = makeStack(); + + const res = responseOf(await dispatcher.handleMetadata( + '/lead/views/all_leads/columns', ctx(), 'PUT', { name: 'columns', label: 'Columns' }, + )); + + expect(res.status).toBe(200); + expect(metaRow(engine, 'lead', 'views/all_leads/columns')).toBeDefined(); + }); + + it('ANTI-VACUITY — the same object name at the SIMPLE arity is still refused', async () => { + // The line between the two arities. At `/meta/:type/:name` the first + // segment IS a type claim, so `lead` is refused there — otherwise this + // file would be pinning "the refusal stopped firing" and calling it a + // fix. ADR-0112: code AND status, never "it threw". + const { engine, dispatcher } = makeStack(); + + const res = responseOf(await dispatcher.handleMetadata( + '/lead/all_leads', ctx(), 'PUT', { name: 'all_leads', label: 'All Leads' }, + )); + + expect(res.status).toBe(400); + expect(res.body?.error?.code).toBe('INVALID_REQUEST'); + expect(metaRow(engine, 'lead', 'all_leads')).toBeUndefined(); + }); + + it('CONTROL — a recognised type at the simple arity is unaffected', async () => { + const { engine, dispatcher } = makeStack(); + + const res = responseOf(await dispatcher.handleMetadata( + '/theme/midnight', ctx(), 'PUT', { name: 'midnight', label: 'Midnight' }, + )); + + expect(res.status).toBe(200); + expect(metaRow(engine, 'theme', 'midnight')).toBeDefined(); + }); + + it('CONTROL — the capability gate still fires first on the compound form', async () => { + // #7019's gate is what masked this site, and it must keep masking an + // UNAUTHORIZED caller: the fix moved the door behind it, not the gate. + const { engine, dispatcher } = makeStack(); + + const res = responseOf(await dispatcher.handleMetadata( + '/lead/views/all_leads', + { request: { headers: {} }, environmentId: 'env_1', executionContext: { userId: 'u', systemPermissions: [] } } as any, + 'PUT', + { name: 'all_leads', label: 'All Leads' }, + )); + + expect(res.status).toBe(403); + expect(res.body?.error?.code).toBe('PERMISSION_DENIED'); + expect(metaRow(engine, 'lead', 'views/all_leads')).toBeUndefined(); + }); +}); diff --git a/packages/spec/src/shared/metadata-url-spelling.ts b/packages/spec/src/shared/metadata-url-spelling.ts index 1aed039c2c..e8dc40ec19 100644 --- a/packages/spec/src/shared/metadata-url-spelling.ts +++ b/packages/spec/src/shared/metadata-url-spelling.ts @@ -313,8 +313,15 @@ const CANONICAL_META_TYPES: ReadonlySet = new Set(Object.values(META_URL * type keys this set does not — `data`, `kind` and `package` all enter * `SchemaRegistry` during a perfectly ordinary `registerApp` — which is why * the boundary applies this verdict where a namespace is MINTED and nowhere - * else. See `canonicalizeMetaRequestType` in `@objectstack/metadata-protocol` - * for that scoping and the measurement behind it. + * else. See `refuseUnmintableMetaType` in `@objectstack/metadata-protocol` for + * that scoping and the measurement behind it. + * ⛔ Not the whole answer at the boundary either, and deliberately not: this + * predicate reads ONE path segment, while whether that segment is even making + * a claim about a metadata type depends on the request's arity (the compound + * form `/meta/lead/views/all_leads` carries an OBJECT name there) and whether + * the namespace already exists. Both are the consumer's to know — a predicate + * that guessed at them from a bare string is exactly the spelling GUESSER this + * module refuses to contain. */ export function unrecognisedMetaTypeRefusal(urlType: string): { type: string } | null { if (urlType in META_URL_TO_SINGULAR) return null; From 0de3b2457d2aeca238aabd5af70a6c583dc2a64f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 00:17:11 +0000 Subject: [PATCH 6/9] chore(changeset): record the compound-arity and stored-namespace exemptions (#8421) --- .changeset/meta-unrecognised-type-refused.md | 47 ++++++++++++++++---- 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/.changeset/meta-unrecognised-type-refused.md b/.changeset/meta-unrecognised-type-refused.md index 58b4ebe291..bf8a82fd77 100644 --- a/.changeset/meta-unrecognised-type-refused.md +++ b/.changeset/meta-unrecognised-type-refused.md @@ -55,14 +55,45 @@ unrecognised type before this change are real, nothing rewrites them on upgrade, and refusing their deletion would turn the accumulation this fixes into an accumulation nobody can clear. -**What breaks.** A caller creating metadata at runtime under a type name that is -in neither half of the static spelling contract. In this repo that set is empty -— all six plugin kinds are mapped — but an out-of-tree plugin that made its kind -live by registering an item of it, and then accepted runtime writes to that kind -through `/meta`, now needs its spelling in the contract. There is no -declared-kind channel to register one through today; that is the trade #8586's -retirement made, and this change is the half of it that stops silently accepting -what nothing can honour. +**Two shapes reaching the mint door are exempt, and each is a fact about the +request rather than a claim the caller makes.** + +1. *The COMPOUND arity carries an OBJECT name in the `:type` segment.* + `PUT /api/v1/meta/lead/views/all_leads` is `type='lead'`, + `name='views/all_leads'` — one operation reaching one save, the shape both + the runtime dispatcher and the REST route document verbatim. `lead` is an + object, i.e. runtime data no static contract can enumerate, so a type verdict + applied there would refuse every object name that is not coincidentally a + metadata type. The ruling is about metadata TYPE names like `fieldz`. + ⚠️ Residue, stated rather than hidden: `PUT /meta/fieldz/a/b` is therefore + still accepted, because at that arity `fieldz` is a claim about an object and + the only way to check it is the live-registry lookup this card ruled out. +2. *A namespace that already exists is not being minted.* `duplicatePackage` + re-saves every row of a package under a new name, taking each type from the + stored row — measured: a package holding one pre-existing residue row + answered `{success: false, copiedCount: 0, failedCount: 1}`, i.e. could not + be duplicated at all. That contradicts the `DELETE` reasoning above, so the + store (never the request) exempts a type that already has rows. The probe + runs only once the refusal has already fired, and a store that cannot answer + refuses — a fresh deployment has no residue to protect. + `migrate meta --stored` was read as a third victim and measured NOT to be + one: an unrecognised type has no manifest collection, hence no ADR-0087 + chain, hence no notice, so such a row is reported `canonical` and the mint + door is never reached. + +**What breaks.** A caller creating metadata at runtime, at the simple arity, +under a type name that is in neither half of the static spelling contract and +has no rows already. That set is **not** empty in this repo — measured on +`objectql`, `runtime` and `rest`: in-tree fixtures mint `trigger` (a kind ADR-0088 +retired), `policy`, and a synthetic `my_plugin_kind`, and `getMetaTypes()` +advertises `policy` / `data` / `package` / `kind` as `allowRuntimeCreate: true` +while this door refuses them. Whether the accept set should admit those or they +should stop being advertised is with the maintainer (#8421); until that is ruled, +the divergence is left visible rather than papered over. An out-of-tree plugin +that made its kind live by registering an item of it, and then accepted runtime +writes to that kind through `/meta`, needs its spelling in the contract; there is +no declared-kind channel to register one through today — that is the trade +#8586's retirement made. `@objectstack/spec` gains one export, `unrecognisedMetaTypeRefusal`, alongside the #7894 verdict it deliberately does not merge with: one says *you spelled a From 043d0697f7272bda5acb4ec5dfcc46f2d0a8505b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 00:36:52 +0000 Subject: [PATCH 7/9] fix(metadata): propagate a metadata-store outage from the namespace probe instead of inventing 'no rows' (#8421) --- packages/metadata-protocol/src/protocol.ts | 24 ++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 674298b249..67c43650ac 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -11318,9 +11318,10 @@ export class ObjectStackProtocolImplementation implements * * The store — not the caller — is what says the namespace exists, so the * exemption cannot be claimed by a request: the probe runs only once the - * static verdict has already fired (never on the ordinary save path), and - * a store that cannot answer refuses, because a fresh deployment has no - * residue to protect. + * static verdict has already fired, never on the ordinary save path. An + * unprovisioned store counts as "no rows" and the refusal stands; any other + * read failure propagates as a 503 rather than being invented into an + * existence claim (see {@link metaTypeNamespaceExists}). */ private async refuseUnmintableMetaType(request: { type: string, name: string }): Promise { const unrecognised = unrecognisedMetaTypeRefusal(request.type); @@ -11351,16 +11352,23 @@ export class ObjectStackProtocolImplementation implements * NAMESPACE exists, and a residue row is exactly as real in a draft or in * another org's overlay as it is here. * - * A store that cannot answer counts as "no" — the refusal stands. A fresh - * deployment has no residue to protect, and a table that is not provisioned - * yet is the state in which the card's own defect (minting the first row of - * a namespace nothing serves) is at its most reachable. + * An UNPROVISIONED store counts as "no" and the refusal stands — a + * deployment whose `sys_metadata` table does not exist yet has no residue to + * protect, and is the state in which this card's own defect (minting the + * first row of a namespace nothing serves) is at its most reachable. Any + * OTHER read failure propagates as `503 SERVICE_UNAVAILABLE` + * ({@link rethrowUnlessMetadataStoreUnprovisioned}): inventing "no rows" + * from an outage would answer `400 '' is not a metadata type` — an + * existence claim about the store, stated while the store was unreachable — + * for a write that may be perfectly legal (AGENTS.md "Absence must be loud", + * #5186). */ private async metaTypeNamespaceExists(type: string): Promise { try { const row = await this.engine.findOne('sys_metadata', { where: { type } }); return row != null; - } catch { + } catch (error) { + this.rethrowUnlessMetadataStoreUnprovisioned(error); return false; } } From 3f8e4d121f65ade8c0b9e1b153cc164db7df0ed2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 09:35:59 +0000 Subject: [PATCH 8/9] fix(metadata): derive /meta/types' allowRuntimeCreate from the mint door's own contract (#8421) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /meta/types synthesised allowRuntimeCreate: true for every live type with no static registry entry, while the mint door added by this card refuses the subset of them that is outside the static spelling contract. Two endpoints of one service contradicting each other is worse for an AI author than a narrower surface, so both doors now read one predicate. Maintainer ruling 2026-08-15 (verbatim, untranslated): 暂时不考虑让插件申明新的元数据类型 The premise of 'no registry entry => assume plugin-declared => writable' has expired, so policy/data/package/kind stop being advertised as runtime-creatable. The six URL-map-only plugin kinds (theme, webhook, connector, sharing_rule, analytics_cube, rag_pipeline) are unaffected and pinned as the discriminating control. 暂时 is recorded as a CURRENT posture at both sites. isRuntimeCreateAllowed keeps its permissive arm deliberately: it is now the residue/clearance arm behind the mint door, not the read door's twin, and narrowing it would strand rows minted before the refusal shipped. Fixture corrections: trigger (ADR-0088-retired) replaced by mapping as the runtime-creatable specimen in two objectql cases; policy's acceptance becomes its own CHANGED BEHAVIOUR refusal case; #7894's never-heard-of-kind positive control keeps its metaUrlSpellingRefusal half and updates its boundary half. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XeQRiAa7vYRVX5Fog7Zby8 --- ...col.meta-types-mint-door-agreement.test.ts | 247 ++++++++++++++++++ packages/metadata-protocol/src/protocol.ts | 112 +++++++- .../objectql/src/overlay-precedence.test.ts | 22 +- packages/objectql/src/protocol-meta.test.ts | 105 ++++++-- .../src/meta-field-overlay-lock.test.ts | 40 ++- 5 files changed, 490 insertions(+), 36 deletions(-) create mode 100644 packages/metadata-protocol/src/protocol.meta-types-mint-door-agreement.test.ts diff --git a/packages/metadata-protocol/src/protocol.meta-types-mint-door-agreement.test.ts b/packages/metadata-protocol/src/protocol.meta-types-mint-door-agreement.test.ts new file mode 100644 index 0000000000..36cd91abc8 --- /dev/null +++ b/packages/metadata-protocol/src/protocol.meta-types-mint-door-agreement.test.ts @@ -0,0 +1,247 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #8421 — `GET /meta/types` and `PUT /meta/:type/:name` answer the same + * question the same way. + * + * ## The defect this closes, which is NOT the one the card was filed about + * + * The card is about minting `type='fieldz'`. Closing it added a THIRD gate at + * the write door, and a third gate is exactly how two endpoints of one service + * start contradicting each other: the listing kept synthesising + * `allowRuntimeCreate: true` for every type with no static registry entry while + * the new door refused a subset of them. An AI author — the reader this + * platform is built for — has only the platform's own advertisement to go on, + * so "advertised writable, 400 on write" is a worse failure than a narrower + * surface. Maintainer ruling, 2026-08-15, verbatim and untranslated: + * + * 暂时不考虑让插件申明新的元数据类型 + * + * With plugins not declaring metadata types, "no registry entry ⇒ assume a + * plugin declared it ⇒ writable" is a rule whose premise expired, and the two + * doors are made to agree at the honest value by reading ONE predicate + * (`unrecognisedMetaTypeRefusal`) instead of two rules maintained apart. + * + * ⚠️ `暂时` is load-bearing: a CURRENT posture, not a permanent architectural + * closure. The full record, and what a future author reintroducing + * plugin-declared kinds must revisit first, is in `getMetaTypes()`'s synthesis + * comment in `protocol.ts`. + * + * ## Why the sample spans THREE classes and not just the withdrawn four + * + * A suite that only pinned the four withdrawn types would be satisfied by a + * blanket flip of the synthesis — which would break `PUT /meta/theme/dark`, the + * operation the plugin path exists to serve, and would be a worse outcome than + * the defect being closed. So every case here carries its class, and the + * classes are checked against each other: + * + * 1. **statically declared** (`view`, `hook`, `agent`) — the flag comes off + * the registry entry, as it always did, in both the `true` and the `false` + * direction; + * 2. **URL-map-only plugin kinds** (`theme`, and its five siblings) — no + * registry entry, IN the static spelling contract, still advertised and + * still mintable. This is the discriminating control: without it the change + * cannot show its narrowing is narrow; + * 3. **withdrawn** (`policy`, `data`, `package`, `kind`) — live + * `SchemaRegistry` keys an ordinary `registerApp` produces, in NEITHER half + * of the static contract, advertised `false` and refused. + * + * Harness: the real `getMetaTypes()` and the real `saveMetaItem()` on one + * protocol instance over a stub engine, so agreement is MEASURED across the two + * code paths rather than asserted about a shared helper. The registry's + * `getRegisteredTypes()` returns a set shaped like a real `registerApp` — which + * is where `data`, `kind` and `package` come from in production, and why the + * listing must have an opinion about them at all. + */ +import { describe, expect, it } from 'vitest'; +// [#5619] The producer's OWN write-verb dispatch decisions, so the fake engine +// below cannot accept a call ObjectQL itself refuses. +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +/** + * The live type set an ordinary `registerApp` leaves in `SchemaRegistry`, + * plus the declared types this suite samples. Measured on #8770 against a real + * `ObjectQL`: `["data","kind","object","package","theme"]`, of which `data`, + * `kind` and `package` sit outside the static spelling contract. + */ +const LIVE_TYPES = [ + 'view', 'hook', 'agent', 'object', + 'theme', 'webhook', 'connector', 'sharing_rule', 'analytics_cube', 'rag_pipeline', + 'policy', 'data', 'package', 'kind', +]; + +function makeProtocol() { + const rows = new Map>(); + let nextId = 0; + const engine: any = { + async findOne() { return null; }, + async find() { return []; }, + async insert(table: string, data: Record) { + if (table !== 'sys_metadata') return { id: 'side_effect_skip' }; + nextId += 1; + rows.set(`${data.type}|${data.name}`, data); + return { id: `r_${nextId}` }; + }, + async update(_t: string, data: Record, opts?: Record) { + assertEngineUpdateDispatch(data, opts); + return { id: null }; + }, + async delete(_t: string, opts?: Record) { + assertEngineDeleteDispatch(opts); + return { deleted: 1 }; + }, + async count() { return 0; }, + async transaction(fn: (ctx: unknown) => Promise) { return fn(undefined); }, + async execute() { return {}; }, + async getObjectSchema() { return undefined; }, + registry: { + getRegisteredTypes: () => [...LIVE_TYPES], + registerItem: () => {}, + registerObject: () => {}, + unregisterItem: () => {}, + listItems: () => [], + getItem: () => undefined, + getArtifactItem: () => undefined, + }, + }; + const protocol = new ObjectStackProtocolImplementation( + engine, + () => new Map(), + undefined, + ) as any; + return { protocol, rows }; +} + +/** + * One sample per class. `item` is a SPEC-VALID body for its type wherever the + * type resolves a schema — a 422 from schema resolution would answer a + * different question than the one this suite asks, and would read as agreement + * while proving nothing (the trap #8770's own triage names for `webhook`). + */ +const SAMPLE: Array<{ + type: string; + klass: 'declared' | 'url-map-only' | 'withdrawn'; + creatable: boolean; + item: Record; +}> = [ + { + type: 'view', + klass: 'declared', + creatable: true, + item: { + name: 'probe_view', + label: 'Probe', + object: 'task', + viewKind: 'list', + columns: [{ field: 'name', label: 'Name' }], + }, + }, + { + type: 'hook', + klass: 'declared', + creatable: true, + item: { name: 'probe_hook', object: 'task', events: ['beforeInsert'] }, + }, + { + // The `false` direction of class 1, and it must be present: a listing + // that answered `true` for everything declared would otherwise pass. + type: 'agent', + klass: 'declared', + creatable: false, + item: { name: 'probe_agent', label: 'Probe' }, + }, + { + type: 'theme', + klass: 'url-map-only', + creatable: true, + item: { name: 'probe_theme', label: 'Probe', tokens: {} }, + }, + { type: 'policy', klass: 'withdrawn', creatable: false, item: { name: 'probe_policy', label: 'Probe' } }, + { type: 'data', klass: 'withdrawn', creatable: false, item: { name: 'probe_data', label: 'Probe' } }, + { type: 'package', klass: 'withdrawn', creatable: false, item: { name: 'probe_package', label: 'Probe' } }, + { type: 'kind', klass: 'withdrawn', creatable: false, item: { name: 'probe_kind', label: 'Probe' } }, +]; + +describe('#8421 — the read door and the mint door agree, across all three classes', () => { + it.each(SAMPLE)( + '$klass `$type`: GET /meta/types advertises allowRuntimeCreate=$creatable', + async ({ type, creatable }) => { + const { protocol } = makeProtocol(); + const listing = await protocol.getMetaTypes(); + const entry = listing.entries.find((e: any) => e.type === type); + + // The type must be LISTED either way. Withdrawing the advertisement + // is not withdrawing the type: `GET /meta/data/...` still answers, + // and a listing that dropped these would trade one declared-≠-served + // gap for another. + expect(entry, `${type} must still be listed`).toBeDefined(); + expect(entry.allowRuntimeCreate).toBe(creatable); + }, + ); + + it.each(SAMPLE)( + '$klass `$type`: PUT /meta/:type/:name behaves as advertised', + async ({ type, creatable, item }) => { + const { protocol, rows } = makeProtocol(); + const save = protocol.saveMetaItem({ type, name: item.name, item }); + + if (creatable) { + await expect(save).resolves.toMatchObject({ success: true }); + expect(rows.has(`${type}|${item.name}`), `${type} row must be persisted`).toBe(true); + return; + } + // ADR-0112 — code AND status, never "it threw". A bare `.toThrow()` + // would stay green on the 422 an unknown type earns from schema + // resolution, which is not the verdict under test. + await expect(save).rejects.toMatchObject({ + code: expect.stringMatching(/^(INVALID_REQUEST|NOT_CREATABLE)$/), + status: expect.any(Number), + }); + // …and the namespace this card is named for is never minted. + expect(rows.size, `${type} must persist nothing`).toBe(0); + }, + ); + + it('the two doors are read off ONE fact, not compared by hand', async () => { + // The assertion that would survive a future refactor of either door: + // for every sampled type, "advertised creatable" and "the write is + // honoured" are the same boolean. Written as a cross-product rather + // than two independent tables so a drift in either direction fails + // here even if both tables above were updated together and wrongly. + const { protocol } = makeProtocol(); + const listing = await protocol.getMetaTypes(); + + for (const { type, item } of SAMPLE) { + const advertised = listing.entries.find((e: any) => e.type === type)?.allowRuntimeCreate; + const fresh = makeProtocol(); + let honoured: boolean; + try { + await fresh.protocol.saveMetaItem({ type, name: item.name, item }); + honoured = true; + } catch { + honoured = false; + } + expect(honoured, `${type}: advertised ${advertised}, write honoured ${honoured}`) + .toBe(advertised); + } + }); + + it('the six URL-map-only plugin kinds are ALL still advertised as creatable', async () => { + // The blanket-flip guard, quantified rather than sampled. `theme` above + // is the one driven end-to-end through a write; these five have no + // hand-written spec-valid body here, so they are pinned on the door + // that this change actually moved — the advertisement. Breaking any of + // them is the one outcome that would make this change worse than the + // defect it closes. + const { protocol } = makeProtocol(); + const listing = await protocol.getMetaTypes(); + for (const kind of [ + 'analytics_cube', 'connector', 'rag_pipeline', 'sharing_rule', 'theme', 'webhook', + ]) { + const entry = listing.entries.find((e: any) => e.type === kind); + expect(entry, `${kind} must be listed`).toBeDefined(); + expect(entry.allowRuntimeCreate, `${kind} must stay creatable`).toBe(true); + } + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index d6795a49c0..8e6507f122 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -4597,6 +4597,54 @@ export class ObjectStackProtocolImplementation implements } // Runtime-registered type with no registry entry — synthesise a // minimal descriptor so the UI can still surface it. + // + // [#8421] `allowRuntimeCreate` is DERIVED FROM THE MINT DOOR'S OWN + // CONTRACT rather than synthesised as `true`. The two doors are the + // same sentence read twice — this endpoint says which types a + // caller may create, `refuseUnmintableMetaType` decides whether the + // create is honoured — so they consult ONE predicate + // ({@link unrecognisedMetaTypeRefusal}) instead of two + // independently-maintained rules that drifted apart. + // + // What this keeps, and it is the whole point: the six legitimate + // plugin kinds with no static registry entry — `theme`, `webhook`, + // `connector`, `sharing_rule`, `analytics_cube`, `rag_pipeline` — + // are IN the static spelling contract (limb 1 of the URL map), so + // the predicate accepts them and they stay advertised as creatable. + // `PUT /meta/theme/dark` is the operation the plugin path exists to + // serve and this change must not touch it. + // + // What this withdraws: `policy`, `data`, `package`, `kind` — live + // `SchemaRegistry` keys an ordinary `registerApp` produces, outside + // that contract. They were advertised `allowRuntimeCreate: true` by + // the blanket `true` below and nothing ever honoured a create on + // them; the door has refused them since this card's first cut. The + // read door now agrees at the honest value instead of promising a + // write the platform will not perform (the same remedy the + // 2026-08-07 ruling chose for `api`: withdraw the advertisement + // rather than converge the read path onto it). + // + // ## The premise, and why it is a CURRENT one (maintainer, 2026-08-15) + // + // The blanket `true` was not careless: while a plugin could DECLARE + // a metadata kind, a name with no registry entry might be a real + // kind this synthesis had not heard of, so "permissive by + // construction" was the safe reading. That premise is what expired. + // The ruling, verbatim and untranslated: + // + // 暂时不考虑让插件申明新的元数据类型 + // + // ⚠️ `暂时` is load-bearing and is recorded as such: this is the + // platform's CURRENT posture, not a permanent architectural + // closure. Plugin-declared metadata kinds were considered, are + // understood, and are deferred — not ruled impossible. If they are + // ever wanted again, this derivation and its twin + // {@link isRuntimeCreateAllowed} are the two sites that encode the + // deferral, and `unrecognisedMetaTypeRefusal` in `@objectstack/spec` + // is the contract they both read; a declared-kind channel would + // have to feed that predicate before either door could widen. Start + // here rather than re-deriving the decision from the code's silence. + const mintableByContract = unrecognisedMetaTypeRefusal(singular) === null; return { type: singular, schemaId: singular, // API client expects schemaId field @@ -4605,7 +4653,7 @@ export class ObjectStackProtocolImplementation implements filePatterns: [], supportsOverlay: false, allowOrgOverride: writableOverrides.has(singular), - allowRuntimeCreate: true, + allowRuntimeCreate: mintableByContract, supportsVersioning: false, executionPinned: false, loadOrder: 1000, @@ -9919,10 +9967,17 @@ export class ObjectStackProtocolImplementation implements */ /** * Set of type names that have a static entry in - * `DEFAULT_METADATA_TYPE_REGISTRY`. Anything outside this set is - * runtime-registered (plugin-provided types like `theme`, `api`, - * `connector`) — the listing endpoint at `getMetaTypes()` synthesises - * those with `allowRuntimeCreate: true`, so this gate must agree. + * `DEFAULT_METADATA_TYPE_REGISTRY`. Anything outside this set carries no + * declared two-tier flags of its own, so the predicates below have to + * decide what its absence means. + * + * ⚠️ [#8421] It used to mean "plugin-registered, therefore writable", and + * `getMetaTypes()` synthesised `allowRuntimeCreate: true` to match. It no + * longer does: the listing derives that flag from the static SPELLING + * contract (see {@link getMetaTypes}), which is a strictly larger set than + * this one — it also carries the six URL-map-only plugin kinds. Absence + * from THIS set is therefore not on its own an answer to "may it be + * created"; see {@link isRuntimeCreateAllowed} for what absence still buys. */ private static readonly STATIC_REGISTRY_TYPES: ReadonlySet = (() => { const out = new Set(); @@ -10001,16 +10056,55 @@ export class ObjectStackProtocolImplementation implements return env.has(singular) || env.has(type); } - /** Does this type permit creating brand-new (artifact-free) items? */ + /** + * Does this type permit creating brand-new (artifact-free) items? + * + * ## [#8421] What the second arm means now, and why it did NOT narrow + * + * The fall-through below used to be the write-side twin of a read-side + * rule: "no static registry entry ⇒ plugin-registered ⇒ writable", mirrored + * verbatim by `getMetaTypes()`'s synthesised `allowRuntimeCreate: true`. + * The maintainer ruling of 2026-08-15 retired that rule's premise — + * verbatim, untranslated: + * + * 暂时不考虑让插件申明新的元数据类型 + * + * ⚠️ `暂时` is load-bearing: a CURRENT posture, deliberately not a + * permanent architectural closure (the full record, and what a future + * author reintroducing plugin-declared kinds must revisit, is in + * {@link getMetaTypes}'s synthesis comment — read that one first). + * + * The narrowing that ruling bought is enforced ONE layer up, at + * {@link refuseUnmintableMetaType}, which runs before this predicate on + * every `saveMetaItem` and refuses an out-of-contract type outright. This + * arm is therefore no longer an ADVERTISEMENT and must not be narrowed to + * match one: by the time it is consulted for such a type, the caller is in + * one of the residue paths the refusal deliberately exempts or does not + * guard at all — + * + * - `deleteMetaItem` / {@link historyMetaItem} / `rollbackMetaItem` / + * `promoteDraftForPublish`: rows minted under an unrecognised type + * BEFORE the refusal shipped are real, and nothing rewrites them on + * upgrade. Returning `false` here would route them off the repository + * path and strand them — turning the accumulation this card was filed + * about into an accumulation nobody can clear, which is the exact + * reasoning that kept `deleteMetaItem` open in the first place; + * - `saveMetaItem` behind the mint door's two exemptions (the compound + * arity, and a namespace the store says already exists). + * + * So the doors agree where agreement is a claim about creating a NEW + * namespace — the listing and the mint door read one predicate for that — + * and this arm keeps the clearance path open underneath. Narrowing it would + * not close anything; it would only make residue unclearable. + */ private static isRuntimeCreateAllowed(type: string): boolean { const singular = PLURAL_TO_SINGULAR[type] ?? type; if (this.RUNTIME_CREATE_ALLOWED_TYPES.has(singular) || this.RUNTIME_CREATE_ALLOWED_TYPES.has(type)) { return true; } - // Runtime-registered types (no static registry entry) are - // synthesised by getMetaTypes() with allowRuntimeCreate=true; - // mirror that here so /api/v1/meta and PUT /api/v1/meta agree. + // No static registry entry ⇒ no declared flags to consult. See the doc + // above: this is the residue/clearance arm, NOT the read door's twin. if (!this.STATIC_REGISTRY_TYPES.has(singular) && !this.STATIC_REGISTRY_TYPES.has(type)) { return true; diff --git a/packages/objectql/src/overlay-precedence.test.ts b/packages/objectql/src/overlay-precedence.test.ts index 6372d5bab1..58fa0ff928 100644 --- a/packages/objectql/src/overlay-precedence.test.ts +++ b/packages/objectql/src/overlay-precedence.test.ts @@ -224,7 +224,27 @@ describe('overlay whitelist enforcement (shared-DB invariant)', () => { // aware rejection is exercised in `protocol-meta.test.ts`. describe('runtime-creatable (allowOrgOverride:false, allowRuntimeCreate:true) — brand-new items succeed', () => { const runtimeCreatable: Array<{ type: string; item: any }> = [ - { type: 'trigger', item: { name: 'on_insert', object: 'case', event: 'beforeInsert' } }, + // [#8421] `trigger` left this list on 2026-08-15, and it was DEBT + // rather than a consequence of that card: ADR-0088 retired the kind + // outright — `'trigger'` returns zero hits in + // `packages/spec/src/kernel/`, and this very file asserts + // `allowedFromRegistry.has('trigger')` is false ~150 lines below. + // A retired kind cannot demonstrate "runtime-creatable"; it was + // passing only through the fall-through for names the static + // registry has never heard of, which is the arm #8421 closed. It is + // REPLACED rather than deleted, and replaced by a type that carries + // the flags in this describe's title for real — `mapping` declares + // `allowOrgOverride: false, allowRuntimeCreate: true` (#2611: the + // import wizard saves one at runtime) — so the case still measures + // the tier it is named for instead of the closed hole. + { + type: 'mapping', + item: { + name: 'lead_import', + targetObject: 'case', + fieldMapping: [{ source: 'Title', target: 'title' }], + }, + }, // `validation` left this list with the kind (#4509, ADR-0088): it is // no longer registered, so "runtime-creatable" no longer describes // it. The reintroduction guard below is what holds the line now. diff --git a/packages/objectql/src/protocol-meta.test.ts b/packages/objectql/src/protocol-meta.test.ts index 55e4f804de..ab4ef8a540 100644 --- a/packages/objectql/src/protocol-meta.test.ts +++ b/packages/objectql/src/protocol-meta.test.ts @@ -1425,13 +1425,28 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { // `validation` used to ride along here; #4509 retired the kind // (ADR-0088), so `seed` — still registry-default allowRuntimeCreate — // stands in as the second case. - it('accepts brand-new trigger and seed (allowRuntimeCreate:true)', async () => { + // + // [#8421] …and `trigger` followed it out on 2026-08-15, for the same + // reason and by the same rule. ADR-0088 retired that kind too + // (`'trigger'` returns ZERO hits in `packages/spec/src/kernel/`), so it + // never had the registry entry this case's title claims for it: it was + // green through the "no static registry entry ⇒ assume plugin-declared + // ⇒ writable" fall-through, i.e. through the hole #8421 closed, while + // reading as a pin on the declared `allowRuntimeCreate` tier. Debt + // regardless of that card's ruling. REPLACED, not deleted — `mapping` + // really does declare `allowOrgOverride: false, allowRuntimeCreate: + // true` — so the tier keeps a live specimen. + it('accepts brand-new mapping and seed (allowRuntimeCreate:true)', async () => { mockEngine.findOne.mockResolvedValue(null); - const triggerResult = await scoped.saveMetaItem({ - type: 'trigger', - name: 'my_trigger', - item: { name: 'my_trigger', object: 'case', event: 'beforeInsert' }, + const mappingResult = await scoped.saveMetaItem({ + type: 'mapping', + name: 'my_mapping', + item: { + name: 'my_mapping', + targetObject: 'case', + fieldMapping: [{ source: 'Title', target: 'title' }], + }, }); const seedResult = await scoped.saveMetaItem({ type: 'seed', @@ -1439,7 +1454,7 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { item: { object: 'case', records: [] }, }); - expect(triggerResult.success).toBe(true); + expect(mappingResult.success).toBe(true); expect(seedResult.success).toBe(true); }); @@ -1463,16 +1478,32 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { // ─────────────────────────────────────────────────────────────── // Regression: plugin-registered types (no static registry entry) // - // `theme`, `connector`, `data`, `policy`, `sharing_rule`, `webhook`, - // `analytics_cube`, `package` are registered by plugins at runtime — - // not in DEFAULT_METADATA_TYPE_REGISTRY. `getMetaTypes()` synthesises - // descriptors with `allowRuntimeCreate: true` for them so the - // admin UI advertises them as writable. The write gate must - // agree, otherwise users see "writable" types 403 on save. + // `theme`, `connector`, `sharing_rule`, `webhook`, `analytics_cube`, + // `rag_pipeline` have no `DEFAULT_METADATA_TYPE_REGISTRY` entry at all + // — they reach `/meta` through the static URL-spelling contract's + // manifest limb instead. `getMetaTypes()` advertises them as writable + // and the write gate must agree, otherwise users see "writable" types + // 403 on save. // // Before fix: gate keyed off the static registry only, rejecting // these 10+ types with not_creatable / 403. // + // [#8421, maintainer ruling 2026-08-15] ⚠️ THE SET SHRANK, and the two + // halves must not be conflated again. `data`, `package`, `kind` and + // `policy` used to ride this same "no static entry ⇒ writable" + // fall-through, but they are in NEITHER half of the static contract: + // they are live `SchemaRegistry` keys an ordinary `registerApp` + // produces (seed datasets, package rows, kind descriptors), and nothing + // ever honoured a runtime create on one. The ruling — verbatim, + // untranslated: 暂时不考虑让插件申明新的元数据类型 — retired the premise + // that an unrecognised name might be a kind some plugin declared, so + // both doors now read the same static contract: `GET /meta/types` stops + // advertising those four, and the mint door refuses them. The six above + // are unaffected, which is the discriminating fact this pair of cases + // exists to hold; `暂时` is a CURRENT posture, so see + // `getMetaTypes()`'s synthesis comment in `@objectstack/metadata-protocol` + // before concluding the possibility was never entertained. + // // [#5271] `api` LEFT this list — it now has a static registry entry. // Its specimen was REPLACED rather than re-spelled: leaving it here // would have kept the assertion green through the *other* branch of @@ -1502,15 +1533,51 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { item: { name: 'my_theme', label: 'Test', tokens: {} }, organizationId: 'org_alpha', }); - const policyResult = await scoped.saveMetaItem({ - type: 'policy', - name: 'my_policy', - item: { name: 'my_policy', label: 'Test' }, - organizationId: 'org_alpha', - }); expect(themeResult.success).toBe(true); - expect(policyResult.success).toBe(true); + }); + + it('[#8421] CHANGED BEHAVIOUR — `policy` is no longer one of them', async () => { + // The half of the old specimen pair that the 2026-08-15 ruling + // moved. `policy` is not in the static registry AND not in the URL + // spelling contract, so it was never a kind the platform carried — + // only a name the old fall-through could not tell apart from one. + // Its own case rather than an edit to the assertion above, so the + // narrowing is visible to a reviewer instead of inferred from a + // deleted line, and so the `theme` half above stays a pure control: + // if a future change breaks the six URL-map-only kinds, that case + // goes red on its own rather than being masked by this one. + mockEngine.findOne.mockResolvedValue(null); + + // ADR-0112 — code AND status, never "it threw". + await expect( + scoped.saveMetaItem({ + type: 'policy', + name: 'my_policy', + item: { name: 'my_policy', label: 'Test' }, + organizationId: 'org_alpha', + }), + ).rejects.toMatchObject({ + code: 'INVALID_REQUEST', + status: 400, + }); + + // …and the refusal is the one this card is about — it names the + // type and says the platform has none such — not the #7894 spelling + // verdict, which cannot fire here (`policy` is a misspelling of + // nothing) and offers no replacement spelling. + await expect( + scoped.saveMetaItem({ + type: 'policy', + name: 'my_policy', + item: { name: 'my_policy', label: 'Test' }, + organizationId: 'org_alpha', + }), + ).rejects.toThrow(/'policy' is not a metadata type/); + + // Nothing is persisted — the whole point is that no namespace is + // minted under a type nothing reads. + expect(mockEngine.insert).not.toHaveBeenCalled(); }); // ─────────────────────────────────────────────────────────────── diff --git a/packages/runtime/src/meta-field-overlay-lock.test.ts b/packages/runtime/src/meta-field-overlay-lock.test.ts index eef101a0b2..3a9e9635f4 100644 --- a/packages/runtime/src/meta-field-overlay-lock.test.ts +++ b/packages/runtime/src/meta-field-overlay-lock.test.ts @@ -710,19 +710,45 @@ describe('#7743 — PUT /meta/field/. honours the registry overla expect(metaRow(engine, 'themes', 'twilight')).toBeUndefined(); }); - it('#7894 POSITIVE CONTROL — a kind the platform has never heard of is still permitted', async () => { - // The strongest form: a name in NO map and NO registry — exactly what a - // third-party plugin registering a novel kind looks like. The refusal - // must not fire, and it cannot, by construction: it only triggers when a - // spelling's singular is a type the platform itself DECLARES. + it('#7894 POSITIVE CONTROL — the #7894 refusal still cannot reach a never-heard-of kind (#8421 CHANGED the boundary)', async () => { + // The strongest form: a name in NO map and NO registry. This control's + // OWN claim is unchanged and still holds by construction — #7894's + // refusal only triggers when a spelling's singular is a type the + // platform itself DECLARES, and `my_plugin_kind` is a misspelling of + // nothing, so `metaUrlSpellingRefusal` cannot fire on it whatever else + // the boundary does. + // + // ⚠️ [#8421, maintainer ruling 2026-08-15] What DID change is the + // boundary's verdict, and this case says so rather than leaving a + // reader to infer it. The name used to be MINTED, because "not a + // declared type" was read as "a kind some plugin declared". The ruling + // — verbatim, untranslated: 暂时不考虑让插件申明新的元数据类型 — retired + // that reading, so `saveMetaItem` now consults a SECOND verdict + // (`unrecognisedMetaTypeRefusal`) and refuses a name in neither half of + // the static contract. `暂时` is a current posture, not a permanent + // closure: see `getMetaTypes()`'s synthesis comment in + // `@objectstack/metadata-protocol` for the full record. + // + // The discriminating control for that narrowing is the case directly + // ABOVE, which must stay green: `theme` has no registry entry either + // and is still minted, because it IS in the static contract. If a + // change ever breaks both, the narrowing stopped being narrow. const { engine, dispatcher } = makeStack(); const res = responseOf(await dispatcher.handleMetadata( '/my_plugin_kind/widget_a', ctx(), 'PUT', { name: 'widget_a', label: 'Widget A' }, )); - expect(res.status).toBe(200); - expect(metaRow(engine, 'my_plugin_kind', 'widget_a')).toBeDefined(); + // ADR-0112 — code AND status, never "it threw". + expect(res.status).toBe(400); + expect(res.body?.error?.code).toBe('INVALID_REQUEST'); + // It is the not-a-type-at-all verdict, NOT #7894's misspelling verdict: + // it names the type and offers no replacement spelling, because there + // is no declared type this could have been reaching for. + expect(res.body?.error?.message).toContain("'my_plugin_kind' is not a metadata type"); + expect(res.body?.error?.message).not.toContain('did you mean'); + // …and the namespace this card is named for is never minted. + expect(metaRow(engine, 'my_plugin_kind', 'widget_a')).toBeUndefined(); }); // ── The refusal limb — an unrecognised spelling of a DECLARED type ──── From eac5a2583fd7a1ef54af50fed71d7eb687253f63 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 09:49:55 +0000 Subject: [PATCH 9/9] =?UTF-8?q?chore(changeset):=20record=20the=20/meta/ty?= =?UTF-8?q?pes=20advertisement=20narrowing=20and=20the=20=E6=9A=82?= =?UTF-8?q?=E6=97=B6=20posture=20(#8421)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The changeset described the mint-door refusal only, and its 'what breaks' section still named the accept-set question as open with the maintainer. Both are now settled: GET /api/v1/meta/types stops advertising policy/data/package/ kind as allowRuntimeCreate, the six URL-map-only plugin kinds are explicitly unaffected, and the ruling's 暂时 is recorded as a current posture with the two code sites that carry the trail back to it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XeQRiAa7vYRVX5Fog7Zby8 --- .changeset/meta-unrecognised-type-refused.md | 71 ++++++++++++++++---- 1 file changed, 58 insertions(+), 13 deletions(-) diff --git a/.changeset/meta-unrecognised-type-refused.md b/.changeset/meta-unrecognised-type-refused.md index bf8a82fd77..b2af06345b 100644 --- a/.changeset/meta-unrecognised-type-refused.md +++ b/.changeset/meta-unrecognised-type-refused.md @@ -45,7 +45,7 @@ have no static registry entry at all — `theme`, `webhook`, `connector`, deployment with zero themes is explicitly covered, because that first create is exactly what a live-registry check would have broken. -**The refusal is scoped to the door that mints.** Reads are untouched: a running +**The refusal is scoped to the door that mints.** Reads still ANSWER: a running kernel legitimately holds live type keys the static contract does not — `data`, `kind` and `package` all enter the registry during an ordinary `registerApp`, and `GET /api/v1/meta/types` lists that live set — so refusing unrecognised @@ -55,6 +55,46 @@ unrecognised type before this change are real, nothing rewrites them on upgrade, and refusing their deletion would turn the accumulation this fixes into an accumulation nobody can clear. +**…but one published ADVERTISEMENT narrows with it, and that is a second +behaviour change worth reading on its own.** `GET /api/v1/meta/types` keeps +listing every live type, and every entry keeps every field — what changes is the +VALUE of one boolean: + +``` +GET /api/v1/meta/types → entries[] where type ∈ {policy, data, package, kind} + before → allowRuntimeCreate: true + after → allowRuntimeCreate: false +``` + +The listing synthesised `allowRuntimeCreate: true` for every live type with no +static registry entry, on the same expired premise as the write door: a name the +registry does not carry might be a kind some plugin declared. It now derives that +flag from the SAME predicate the mint door enforces, so the two endpoints agree +by construction instead of via two rules maintained apart. Nothing ever honoured +a runtime create on those four — they are internal bookkeeping (seed datasets, +package rows, kind descriptors) — so the advertisement was a promise the platform +did not keep, which is the same defect this card is about, relocated to the read +door. Direct precedent: `api` declared `allowRuntimeCreate: true`, the runtime +never honoured it, and the 2026-08-07 ruling removed the declaration rather than +converging the read path onto it. + +⛔ The six plugin kinds with no registry entry — `theme`, `webhook`, `connector`, +`sharing_rule`, `analytics_cube`, `rag_pipeline` — are **not** affected: they are +in the static spelling contract, stay advertised `allowRuntimeCreate: true`, and +stay mintable. A UI reading this field (Setup → Metadata, the Studio designers) +therefore loses create affordances on exactly the four types whose creates were +already refused, and keeps them everywhere else. + +**The premise behind both halves is a CURRENT posture, not a closed door.** +Maintainer ruling, 2026-08-15, verbatim and untranslated: +暂时不考虑让插件申明新的元数据类型 — plugins do not declare new metadata types +*for now*. That word is recorded deliberately: plugin-declared kinds were +considered and deferred, not ruled out. If they are ever wanted, the two sites +that encode the deferral name it and its date in place — +`getMetaTypes()`'s synthesis and `isRuntimeCreateAllowed` in +`@objectstack/metadata-protocol` — so the decision is findable rather than +re-derived from the code's silence. + **Two shapes reaching the mint door are exempt, and each is a fact about the request rather than a claim the caller makes.** @@ -84,21 +124,26 @@ request rather than a claim the caller makes.** **What breaks.** A caller creating metadata at runtime, at the simple arity, under a type name that is in neither half of the static spelling contract and has no rows already. That set is **not** empty in this repo — measured on -`objectql`, `runtime` and `rest`: in-tree fixtures mint `trigger` (a kind ADR-0088 -retired), `policy`, and a synthetic `my_plugin_kind`, and `getMetaTypes()` -advertises `policy` / `data` / `package` / `kind` as `allowRuntimeCreate: true` -while this door refuses them. Whether the accept set should admit those or they -should stop being advertised is with the maintainer (#8421); until that is ruled, -the divergence is left visible rather than papered over. An out-of-tree plugin -that made its kind live by registering an item of it, and then accepted runtime -writes to that kind through `/meta`, needs its spelling in the contract; there is -no declared-kind channel to register one through today — that is the trade -#8586's retirement made. +`objectql`, `runtime` and `rest`, three in-tree fixtures minted `trigger` (a kind +ADR-0088 retired outright), `policy`, and a synthetic `my_plugin_kind`. All three +are corrected here rather than exempted, and each for its own reason: the +`trigger` specimens were debt independent of any ruling (a retired kind cannot +demonstrate a live tier, and they were green only through the hole this card +closes), `policy` becomes a refusal case of its own, and #7894's control keeps +its `metaUrlSpellingRefusal` claim while its boundary expectation follows the +narrowing. An out-of-tree plugin that made its kind live by registering an item +of it, and then accepted runtime writes to that kind through `/meta`, needs its +spelling in the contract; there is no declared-kind channel to register one +through today — that is the trade #8586's retirement made, and the `暂时` above +is what makes it revisitable. `@objectstack/spec` gains one export, `unrecognisedMetaTypeRefusal`, alongside the #7894 verdict it deliberately does not merge with: one says *you spelled a declared type wrongly* and can name the replacement, the other says *there is no such type* and never guesses. The residue pin #7894 left behind (`metadata-url-spelling.test.ts`, the case that asserted `fieldz` was refused by -nobody) is **flipped, not deleted**, and #7894's positive control keeps every -assertion it was written with. +nobody) is **flipped, not deleted**. ⚠️ #7894's positive control keeps its own +claim intact — `metaUrlSpellingRefusal` still cannot refuse a kind that is a +misspelling of nothing, which is what makes that control true by construction — +but the BOUNDARY it drives now refuses six of the twelve names it exercises, +and that case says so in place rather than leaving it to inference.