From 23d303ae11d3e29823e86b1d8aab44a6a28c7317 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 03:20:53 +0000 Subject: [PATCH 1/5] =?UTF-8?q?feat(spec)!:=20marketplace:installed-list?= =?UTF-8?q?=20is=20retired=20=E2=80=94=20refused=20by=20name=20at=20the=20?= =?UTF-8?q?schema=20door,=20the=20separate=20Installed=20Apps=20view=20it?= =?UTF-8?q?=20drew=20was=20retired?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01SovhHbD8dAQGDyEGHRoSUx Co-authored-by: Claude --- .../test/platform-page-i18n-parity.test.ts | 5 +- .../lint/src/validate-component-props.test.ts | 42 +-- .../lint/src/validate-component-types.test.ts | 5 +- ...connection-widgets-unknown-keys-refused.ts | 30 +- ...8.ui-marketplace-installed-list-retired.ts | 52 ++++ ...-mcp-connect-agent-unknown-keys-refused.ts | 4 +- .../spec/src/migrations/registry.ts.template | 14 + ...ketplace-installed-list-retirement.test.ts | 283 ++++++++++++++++++ .../src/ui/component-type-vocabulary.test.ts | 18 ++ .../spec/src/ui/component-type-vocabulary.ts | 14 +- packages/spec/src/ui/component.test.ts | 28 +- packages/spec/src/ui/component.zod.ts | 28 +- packages/spec/src/ui/page.zod.ts | 27 +- packages/spec/vitest.repo-tests.json | 1 + 14 files changed, 479 insertions(+), 72 deletions(-) create mode 100644 packages/spec/src/migrations/entries/semantic/18.ui-marketplace-installed-list-retired.ts create mode 100644 packages/spec/src/ui/component-marketplace-installed-list-retirement.test.ts diff --git a/packages/cli/test/platform-page-i18n-parity.test.ts b/packages/cli/test/platform-page-i18n-parity.test.ts index 4c7d3d56366..5d83b54532d 100644 --- a/packages/cli/test/platform-page-i18n-parity.test.ts +++ b/packages/cli/test/platform-page-i18n-parity.test.ts @@ -1086,6 +1086,9 @@ describe('`pages.*` default-locale parity (#8764, moved off the nav roster by #1 // verdict moved without them is a verdict nobody has seen fail. The // sample is a synthetic page shaped like the retired `marketplace_installed` // page (#23933); it is a fixture, and no shipped population carries it. + // Its header copy is all the gate reads, so its body is a live block: the + // retired page's own `marketplace:installed-list` widget is refused by + // name since #24230. const SAMPLE_PAGE = { name: 'marketplace_installed', label: 'Installed Apps', @@ -1102,7 +1105,7 @@ describe('`pages.*` default-locale parity (#8764, moved off the nav roster by #1 }, ], }, - { name: 'main', components: [{ type: 'marketplace:installed-list', properties: {} }] }, + { name: 'main', components: [{ type: 'element:divider', properties: {} }] }, ], }; const SAMPLE_SOURCES = new Map([[SAMPLE_PAGE.name, pageSourceCopy(SAMPLE_PAGE)]]); diff --git a/packages/lint/src/validate-component-props.test.ts b/packages/lint/src/validate-component-props.test.ts index 46bd9c5df92..446801e08f6 100644 --- a/packages/lint/src/validate-component-props.test.ts +++ b/packages/lint/src/validate-component-props.test.ts @@ -1183,11 +1183,17 @@ describe('validateComponentProps — record:alert / record:quick_actions / recor * open-string-arm types — never in `PageComponentType` — so nothing else * judged them either), and the walker's unregistered-type skip swallowed the * whole props bag: any authored key produced ZERO findings from - * validate/build. Their rows are strict and EMPTY — measured from the - * renderers' read points at the `.objectui-sha` pin, where both registrations - * discard the schema node (`() => `) — so EVERY authored key is a - * finding. Remove either map row and its loud test here goes back to that - * silence. + * validate/build. `cloud-connection:panel`'s row is strict and EMPTY — + * measured from the renderer's read points at the `.objectui-sha` pin, where + * the registration discards the schema node (`() => `) — so EVERY + * authored key is a finding. Remove the map row and its loud test here goes + * back to that silence. + * + * #24230 — `marketplace:installed-list` is RETIRED by name (its one renderer + * was deleted with the separate Installed Apps view). Its row is kept as a + * whole-bag refusal, so the same dispatch now refuses EVERY bag, `{}` included, + * as `component-props-invalid`; the type rule reports the node too (the + * map-driven `flags %s` case in `validate-component-types.test.ts`). */ describe('validateComponentProps — cloud-connection:panel / marketplace:installed-list are dispatched (#11575)', () => { it('reports any key authored on `cloud-connection:panel`, naming the zero-prop surface', () => { @@ -1204,25 +1210,19 @@ describe('validateComponentProps — cloud-connection:panel / marketplace:instal expect(findings[0].message).toContain('cloud-connection:panel'); }); - it('reports any key authored on `marketplace:installed-list` the same way', () => { - const findings = validateComponentProps( - stackWith([{ - type: 'marketplace:installed-list', - properties: { filter: 'installed' }, - }]), - ); - expect(findings).toHaveLength(1); - expect(findings[0].rule).toBe(COMPONENT_PROPS_UNKNOWN_KEY); - expect(findings[0].where).toBe('page "probe_page" · marketplace:installed-list'); - expect(findings[0].message).toContain('`filter`'); + it('refuses the retired `marketplace:installed-list` bag WHOLE, `{}` included, as an error', () => { + for (const properties of [{}, { filter: 'installed' }]) { + const findings = validateComponentProps( + stackWith([{ type: 'marketplace:installed-list', properties }]), + ); + expect(findings.map((f) => [f.rule, f.severity])).toEqual([[COMPONENT_PROPS_INVALID, 'error']]); + expect(findings[0].where).toBe('page "probe_page" · marketplace:installed-list'); + } }); - it('stays silent on the empty bag both plugin-shipped pages author', () => { + it('stays silent on the empty bag the cloud-connection page authors', () => { const findings = validateComponentProps( - stackWith([ - { type: 'cloud-connection:panel', properties: {} }, - { type: 'marketplace:installed-list', properties: {} }, - ]), + stackWith([{ type: 'cloud-connection:panel', properties: {} }]), ); expect(findings).toEqual([]); }); diff --git a/packages/lint/src/validate-component-types.test.ts b/packages/lint/src/validate-component-types.test.ts index 999b0c83c54..3774672d069 100644 --- a/packages/lint/src/validate-component-types.test.ts +++ b/packages/lint/src/validate-component-types.test.ts @@ -111,10 +111,11 @@ describe('leaves the declared vocabulary and the open arm alone', () => { // ⛔ The RETIRED types are deliberately NOT here — their kept // `ComponentPropsMap` row makes them `isKnownComponentType`, and this rule // used to read that as "accepted". They now have their own describe below. - // Plugin namespaces — the open arm's declared story. + // Plugin namespaces — the open arm's declared story. (`marketplace:installed-list` + // left this list when it was retired by name: an open-arm type, so the + // EXACT-name describe below is what reports it.) 'mcp:connect-agent', 'cloud-connection:panel', - 'marketplace:installed-list', // Colon-free custom/SDUI shapes — ditto. 'flex', 'grid', diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-cloud-connection-widgets-unknown-keys-refused.ts b/packages/spec/src/migrations/entries/semantic/18.ui-cloud-connection-widgets-unknown-keys-refused.ts index ccb3e40b0f5..99aeb193992 100644 --- a/packages/spec/src/migrations/entries/semantic/18.ui-cloud-connection-widgets-unknown-keys-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.ui-cloud-connection-widgets-unknown-keys-refused.ts @@ -2,18 +2,25 @@ import type { SemanticMigration } from '../../types.js'; +// Narrowed by #24230 to `cloud-connection:panel` alone. It was written for two +// widgets; the second, `marketplace:installed-list`, was retired by name in the +// same major (D3 entry `ui-marketplace-installed-list-retired`), so the +// guidance here, keep an empty bag and validate clean, would contradict the +// refusal an upgrader meets for that type. The id stays: it is referenced by +// shipped changelog text and by the guidance census in `packages/cli`. export const entry: SemanticMigration = { id: 'ui-cloud-connection-widgets-unknown-keys-refused', - surface: 'page `cloud-connection:panel` / `marketplace:installed-list` components — ' - + '`properties` (any key at all: both widgets declare no props)', - replacement: 'an empty `properties` bag (`{}`), or omit `properties` entirely. Neither ' - + 'widget reads any prop: the console registrations discard the schema node ' - + '(`() => `) and the components take no arguments, so there is no declared ' - + 'key to move to — a key authored on either widget configures nothing and is removed, ' + surface: 'page `cloud-connection:panel` component — `properties` (any key at all: the ' + + 'widget declares no props)', + replacement: 'an empty `properties` bag (`{}`), or omit `properties` entirely. The widget ' + + 'reads no prop: the console registration discards the schema node ' + + '(`() => `) and the component takes no arguments, so there is no declared ' + + 'key to move to — a key authored on it configures nothing and is removed, ' + 'not renamed. Node-level keys (`visibleWhen`, `id`, `style`, …) stay on the component ' + 'node, where the page runtime reads them.', reason: - 'These were two more instances of the class already closed for `record:reference_rail` ' + 'These were two more instances (the second, `marketplace:installed-list`, since retired by ' + + 'name: see `ui-marketplace-installed-list-retired`) of the class already closed for `record:reference_rail` ' + 'and then for `record:alert` / `record:quick_actions` / `record:history`, each by ' + 'declaring a strict `ComponentPropsMap` row measured from the renderer\'s read points: ' + 'console-registered widgets on ' @@ -29,11 +36,10 @@ export const entry: SemanticMigration = { + 'and an authored key is now a publish-time refusal naming the surface instead of a ' + 'silent no-op.', acceptanceCriteria: - 'Every `cloud-connection:panel` / `marketplace:installed-list` node authors an empty ' - + '(or absent) `properties` bag and validates clean — the two plugin-shipped pages ' - + '(`cloud_connection_settings`, `marketplace_installed`) already do; `objectstack ' - + 'validate` reports no `component-props-unknown-key` finding for these types. Any ' - + 'remaining authored key on either widget is deleted (it never configured anything), ' + 'Every `cloud-connection:panel` node authors an empty (or absent) `properties` bag and ' + + 'validates clean — the plugin-shipped page (`cloud_connection_settings`) already does; ' + + '`objectstack validate` reports no `component-props-unknown-key` finding for the type. Any ' + + 'remaining authored key on the widget is deleted (it never configured anything), ' + 'and behaviour that seems to need one is a renderer capability request against ' + 'objectui, not a metadata key.', }; diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-marketplace-installed-list-retired.ts b/packages/spec/src/migrations/entries/semantic/18.ui-marketplace-installed-list-retired.ts new file mode 100644 index 00000000000..b444c159f2f --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.ui-marketplace-installed-list-retired.ts @@ -0,0 +1,52 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #24230 — the D3 record of the `marketplace:installed-list` retirement +// (ADR-0049 enforce-or-remove), executing ruling B on #23892 (record +// 6216295104, item 2: the separate Installed Apps view retires once the +// filtered view serves its actions). objectui#12733 deleted the type's one +// renderer; its one page, `marketplace_installed`, had already left +// `@objectstack/cloud-connection` at #23933. Refused BY NAME through +// `RETIRED_PAGE_COMPONENT_TYPES`, the `ai:chat_window` shape. There is no D2 +// half: the type never had a key, so there is nothing a walker could strip and +// leave a valid node behind, and deleting an authored page node is a layout +// decision a mechanical conversion must not make (the `element-filter-removed` +// docblock's rule). What the chain owes the upgrader is the delegation itself. +// +// It narrows `ui-cloud-connection-widgets-unknown-keys-refused`, which closed +// this type's props bag to `{}` in the same major: that entry now speaks for +// `cloud-connection:panel` alone, and this one for the retired name. +export const entry: SemanticMigration = { + id: 'ui-marketplace-installed-list-retired', + surface: + 'page.component.marketplace:installed-list — the component node (the widget never took a ' + + 'prop), in regions, named slots and nested containers alike', + replacement: + 'Delete the component node. The Installed Apps list is Setup → Packages filtered to ' + + 'Source = marketplace: the console\'s `developer:packages` component opened with ' + + '`params: { source: \'marketplace\' }`. A page that needs to send users there links to it ' + + 'with a navigation item of type `component`, `componentRef: \'developer:packages\'` and ' + + '`params: { source: \'marketplace\' }`, which is what the cloud-connection plugin\'s own ' + + '"Installed Apps" entry does', + reason: + '`marketplace:installed-list` was the console widget that drew the separate Installed Apps ' + + 'view: the list of locally installed marketplace packages, with their uninstall action. ' + + 'Once Setup → Packages carried those packages and their actions behind a Source = ' + + 'marketplace filter, the separate view was retired: its one renderer was deleted from the ' + + 'console, and its one page left the cloud-connection plugin, whose navigation entry now ' + + 'opens the filtered Packages view. A declared element nothing draws validated clean and then ' + + 'showed "Unknown component type" in front of a user, so the name is now refused at ' + + '`PageComponentSchema.type` and its `ComponentPropsMap` row refuses every props bag with ' + + 'the same prescription. The type was never a `PageComponentType` member, so there is no ' + + 'enum value to remove. No conversion is registered, because the only edit is deleting the ' + + 'node, and which region closes up or links to the Packages view instead is the author\'s ' + + 'judgment about a page they composed — this entry is that delegation', + acceptanceCriteria: + 'No `marketplace:installed-list` component remains in any page — regions, named slots and ' + + 'nested containers alike. `os validate` is clean: a remaining node is reported at its own ' + + '`type` path with `params.retiredComponentType` naming `marketplace:installed-list`, so each ' + + 'one is named individually rather than as one page-level failure. Replaying the 17 → 18 ' + + 'chain over the edited source then reports the migrated stack schema-valid — ' + + '`schemaValid: true` in `--json`', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-mcp-connect-agent-unknown-keys-refused.ts b/packages/spec/src/migrations/entries/semantic/18.ui-mcp-connect-agent-unknown-keys-refused.ts index 1a27ceedbd4..840f080140e 100644 --- a/packages/spec/src/migrations/entries/semantic/18.ui-mcp-connect-agent-unknown-keys-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.ui-mcp-connect-agent-unknown-keys-refused.ts @@ -16,8 +16,8 @@ export const entry: SemanticMigration = { reason: 'This was a third instance of the class closed for the `record:*` blocks by declaring a ' + 'strict `ComponentPropsMap` row measured from the renderer\'s read points (the strict, ' - + 'empty `cloud-connection:panel` / `marketplace:installed-list` rows closed the previous ' - + 'two): a ' + + 'empty `cloud-connection:panel` row, and the `marketplace:installed-list` row since retired ' + + 'by name, closed the previous two): a ' + 'console-registered widget on `@objectstack/mcp`\'s plugin-shipped Setup page, ' + 'reachable through the component type union\'s open string arm, with a registered ' + 'renderer but no `ComponentPropsMap` row — so the props gate\'s dispatch (it parses ' diff --git a/packages/spec/src/migrations/registry.ts.template b/packages/spec/src/migrations/registry.ts.template index bbdccf98155..fc10316eeb9 100644 --- a/packages/spec/src/migrations/registry.ts.template +++ b/packages/spec/src/migrations/registry.ts.template @@ -2903,6 +2903,20 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + '`columns` untouched) on `object-form` page components, on every form payload a view ' + 'carries, and on the assembled-manifest `viewItems` channel.', }, + { + id: 'ui-marketplace-installed-list-retired', + order: 102, + text: + 'It also retires the `marketplace:installed-list` page element (ADR-0049 enforce-or-remove), ' + + 'the plugin console widget that drew the separate Installed Apps view. That view was retired: ' + + 'its one renderer was deleted and its one page had already left the cloud-connection plugin, ' + + 'whose "Installed Apps" navigation entry opens Setup → Packages filtered to Source = ' + + 'marketplace instead. The name was never a `PageComponentType` member, so it is refused by ' + + 'name at the node, and its `ComponentPropsMap` row stays as a whole-bag refusal carrying the ' + + 'same prescription. No conversion is registered: the only edit is deleting the node, a layout ' + + 'decision that is the author\'s. Its D3 record is the semantic entry ' + + '`ui-marketplace-installed-list-retired`.', + }, { id: 'ui-object-form-custom-fields-typed', order: 78, diff --git a/packages/spec/src/ui/component-marketplace-installed-list-retirement.test.ts b/packages/spec/src/ui/component-marketplace-installed-list-retirement.test.ts new file mode 100644 index 00000000000..be148385659 --- /dev/null +++ b/packages/spec/src/ui/component-marketplace-installed-list-retirement.test.ts @@ -0,0 +1,283 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #24230 — `marketplace:installed-list` RETIRED by name (ADR-0049 + * enforce-or-remove), executing ruling B on #23892 (record 6216295104, item 2: + * the separate Installed Apps view retires once the filtered view serves its + * actions). + * + * It was the plugin console widget that drew that view (#11575 gave it an + * empty `ComponentPropsMap` row). Its one page, `marketplace_installed`, left + * `@objectstack/cloud-connection` at #23933 (Setup's "Installed Apps" entry + * opens Packages filtered to Source = marketplace instead), and objectui#12733 + * deleted its one renderer. It shipped in 17.3.0. + * + * The `ai:chat_window` shape, with one difference pinned below — the type was + * never a `PageComponentType` member and its namespace is not reserved: + * 1. the name joins `RETIRED_PAGE_COMPONENT_TYPES`, whose text answers at + * every door; without it the name would fall back to the open string arm + * and parse in silence; + * 2. the `ComponentPropsMap` row stays as + * `retiredComponentProps('marketplace:installed-list')`, refusing every + * bag — the only thing that keeps the name known; + * 3. the print classification row leaves with it (a retired type is refused + * at the parse, before any print rule reads it); + * 4. the D3 entry `ui-marketplace-installed-list-retired`. No retired def: + * the row was `emptyProps`, which exports nothing. No D2 conversion: + * deleting an authored page node is a layout decision a conversion must + * not make. + * + * Each refusal is pinned by `code`, `path` and the prescription's first + * sentence, never a bare `toThrow()`, which greens on any error. The live + * control at every door is `cloud-connection:panel`, the sibling console + * widget the same change declared. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { MIGRATIONS_BY_MAJOR } from '../migrations/registry'; +import { ComponentPropsMap } from './component.zod'; +import { + PageComponentSchema, + PageComponentType, + PageSchema, + PRINT_REFUSED_PAGE_COMPONENT_TYPES, + PRINTABLE_PAGE_COMPONENT_TYPES, + RETIRED_PAGE_COMPONENT_TYPES, +} from './page.zod'; + +const TYPE = 'marketplace:installed-list'; +const CONTROL = 'cloud-connection:panel'; +const D3_ID = 'ui-marketplace-installed-list-retired'; +const NARROWED_ID = 'ui-cloud-connection-widgets-unknown-keys-refused'; + +const FIRST_SENTENCE = + /^`marketplace:installed-list` was removed in @objectstack\/spec 18 \(ADR-0049\) — the separate Installed Apps view it drew was retired, so no renderer draws the type and the console would show "Unknown component type" in its place\./; +const FIX = + "Delete the `marketplace:installed-list` component node; to send users to that list, add a navigation item of type `component` with `componentRef: 'developer:packages'` and `params: { source: 'marketplace' }` instead."; +// `check:doc-authoring`: a prescription printed at the customer carries no +// citation-shaped issue id. +const ISSUE_ID = /#\d{3,}/; + +/** The bag #11575's pin refused key by key — now refused like the empty one. */ +const KEYED_BAG = { filter: 'installed' }; + +const setupPage = (node: Record) => ({ + name: 'marketplace_installed', + label: 'Installed Apps', + regions: [{ name: 'main', components: [{ type: 'page:header', properties: { title: 'Installed Apps' } }, node] }], +}); + +describe('marketplace:installed-list is retired, refused by name', () => { + it('the map carries the prescription: the replacement view, the fix imperative, no tracker number', () => { + const guidance = RETIRED_PAGE_COMPONENT_TYPES.get(TYPE); + expect(guidance).toBeTypeOf('string'); + expect(guidance!).toMatch(FIRST_SENTENCE); + expect(guidance!).toContain('Setup → Packages filtered to Source = marketplace'); + expect(guidance!.endsWith(FIX)).toBe(true); + expect(guidance!).not.toMatch(ISSUE_ID); + // No `os migrate meta` sentence: there is no mechanical edit to list. + expect(guidance!).not.toContain('os migrate meta'); + }); + + it('the ComponentPropsMap row is kept and refuses every bag, the empty one included', () => { + expect(Object.keys(ComponentPropsMap)).toContain(TYPE); + for (const bag of [{}, KEYED_BAG]) { + const r = ComponentPropsMap[TYPE].safeParse(bag); + expect(r.success, JSON.stringify(bag)).toBe(false); + if (r.success) continue; + expect(r.error.issues).toHaveLength(1); + const issue = r.error.issues[0]!; + // The `retiredKey` channel at element grain: `expected: 'never'`. + expect(issue.code).toBe('invalid_type'); + expect(issue.path).toEqual([]); + expect(issue.message).toBe(RETIRED_PAGE_COMPONENT_TYPES.get(TYPE)); + } + // Control: the sibling console widget still accepts its empty bag. + expect(ComponentPropsMap[CONTROL].safeParse({}).success).toBe(true); + }); + + it('PageComponentSchema refuses the node by name at `type`, bare or with a bag', () => { + for (const properties of [undefined, {}, KEYED_BAG]) { + const r = PageComponentSchema.safeParse(properties === undefined ? { type: TYPE } : { type: TYPE, properties }); + expect(r.success, `properties=${JSON.stringify(properties)}`).toBe(false); + if (r.success) continue; + expect(r.error.issues).toHaveLength(1); + const issue = r.error.issues[0]!; + expect(issue.code).toBe('custom'); + expect(issue.path).toEqual(['type']); + expect(issue.message).toMatch(FIRST_SENTENCE); + expect((issue as { params?: Record }).params).toEqual({ retiredComponentType: TYPE }); + } + expect(PageComponentSchema.safeParse({ type: CONTROL }).success).toBe(true); + }); + + it('PageSchema refuses an authored page at the element path — the door `os validate` parses', () => { + const r = PageSchema.safeParse(setupPage({ type: TYPE, properties: {} })); + expect(r.success).toBe(false); + if (r.success) return; + const located = r.error.issues.filter((i) => i.code === 'custom'); + expect(located).toHaveLength(1); + expect(located[0]!.path).toEqual(['regions', 0, 'components', 1, 'type']); + expect(located[0]!.message).toMatch(FIRST_SENTENCE); + // Control: the same page with the live sibling in that slot parses. + expect(PageSchema.safeParse(setupPage({ type: CONTROL, properties: {} })).success).toBe(true); + }); + + it('was never an enum member, so the by-name refusal is its ONLY parse door', () => { + expect(PageComponentType.options).not.toContain(TYPE); + // Without the map entry the open string arm would admit it: a stranger in + // the same unreserved namespace still parses. + expect(PageComponentSchema.safeParse({ type: 'marketplace:installed-list-v2' }).success).toBe(true); + }); + + it('the print classification row left with it — a retired type is refused before any print rule reads it', () => { + expect(PRINT_REFUSED_PAGE_COMPONENT_TYPES.has(TYPE)).toBe(false); + expect(PRINTABLE_PAGE_COMPONENT_TYPES.has(TYPE)).toBe(false); + // Control: the live sibling keeps its print refusal reason. + expect(PRINT_REFUSED_PAGE_COMPONENT_TYPES.get(CONTROL)).toMatch(/\S/); + }); + + it('the open string arm stays open — only the retired NAME is refused', () => { + for (const type of ['mcp:connect-agent', 'custom.installed_list', 'object-grid']) { + expect(PageComponentSchema.safeParse({ type }).success, type).toBe(true); + } + }); + + it('is registered with ADR-0087: the D3 entry at major 18, and the props-bag entry narrowed to its sibling', () => { + const semantic = MIGRATIONS_BY_MAJOR[18]?.semantic ?? []; + const entry = semantic.find((m) => m.id === D3_ID); + expect(entry).toBeDefined(); + expect(entry!.surface).toContain(TYPE); + expect(entry!.replacement).toContain('developer:packages'); + expect(entry!.reason.length).toBeGreaterThan(0); + expect(entry!.acceptanceCriteria).toContain('retiredComponentType'); + // The entry that closed this type's bag to `{}` in the same major no longer + // tells an upgrader to keep the node: it speaks for the sibling alone. + const narrowed = semantic.find((m) => m.id === NARROWED_ID); + expect(narrowed).toBeDefined(); + expect(narrowed!.surface).toContain(CONTROL); + expect(narrowed!.surface).not.toContain(TYPE); + expect(narrowed!.acceptanceCriteria).not.toContain(TYPE); + }); +}); + +// ─── Tree-scoped absence, inside the radius the package already declares ─── +// +// No export was deleted, so `tsc` sweeps nothing here at all: a page +// component's `type` is a string inside an open bag, authored through +// `definePage`/`defineStack` or in JSON / YAML. This +// walk covers every text file under the five repo roots +// `scripts/cross-package-test-inputs.mjs` declares for `@objectstack/spec#test` +// (mirrored in `turbo.json`), plus the example apps' own `src/` trees. +// +// The matcher judges the AUTHORING SHAPE, never a mention: a `type` key whose +// value is `marketplace:installed-list` (TS / JS / JSON, and YAML). Prose mentions are spelled +// in inline code in this repo, and inline code is stripped before judging. The +// bound, stated: a `type` computed at runtime, and `docs/**`, `.claude/**`, +// `.github/**` and the repo-root files are outside what this walk sees. +describe('tree-scoped absence: nothing inside the declared radius still authors a marketplace:installed-list node', () => { + const SPEC_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + const REPO_ROOT = path.resolve(SPEC_ROOT, '../..'); + const THIS_FILE = path.relative(REPO_ROOT, fileURLToPath(import.meta.url)).split(path.sep).join('/'); + + /** The walked roots — declared in `scripts/cross-package-test-inputs.mjs` under `@objectstack/spec`. */ + const WALK_ROOTS = ['packages', 'examples', 'skills', 'content', 'scripts']; + const SCANNED_EXT = new Set(['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs', '.json', '.md', '.mdx', '.yaml', '.yml']); + /** Under `examples/` the non-code extensions, plus `.ts` inside an app's own `src/` tree. */ + const EXAMPLES_EXT = new Set(['.json', '.md', '.mdx', '.yaml', '.yml']); + const EXAMPLE_APP_SRC_TS = /^examples\/[^/]+\/src\/.+\.ts$/; + const SKIPPED_DIRS = new Set(['node_modules', 'dist', '.git', '.turbo', '.cache', '.objectstack', 'coverage', '.next', '.source']); + + const AUTHORING = /(^|[^\w.$])["']?type["']?[ \t]*:[ \t]*["']?marketplace:installed-list["']?(?![\w:-])/m; + + /** Inline code spans are prose; newline-bounded, so a fenced example is still judged. */ + const stripInlineCode = (text: string): string => text.replace(/`[^`\n]*`/g, ''); + const judge = (text: string): RegExpExecArray | null => AUTHORING.exec(stripInlineCode(text)); + + /** + * Structural exclusions — the retirement kit, each with its reason. ⛔ NOT an + * allowlist file (`spec-property-retirement` §4): every entry's JOB is to + * author the retired node. + */ + const EXCLUDED = new Set([ + // This pin authors the node to assert its refusal. + THIS_FILE, + // The lint-door pin authors the node to assert the refusal an author meets + // at `os validate` / `os build` / `os lint`. + 'packages/lint/src/validate-component-props.test.ts', + ]); + const EXCLUDED_PREFIXES = [ + // Release-owned prose records what shipped; never edited by a code PR. + 'content/docs/releases/', + // GITIGNORED build output (`packages/spec/json-schema/`), reached only + // because this is a FILESYSTEM walk. Its source is `page.zod.ts`. + 'packages/spec/json-schema/', + ]; + /** tsup's own bundle of `tsup.config.ts`, written and deleted mid-build. */ + const TSUP_BUNDLED_CONFIG = /\.bundled_[^./]+\.mjs$/; + + /** Tolerates ONLY a path that vanished mid-walk; every other read fault is re-raised. */ + const readIfPresent = (full: string): string | undefined => { + try { + return fs.readFileSync(full, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') throw err; + return undefined; + } + }; + + it('the matcher recognises an authoring and ignores a prose mention (anti-vacuity)', () => { + // Offenders — the retired node, in each syntax the walk reads. + expect(judge("{ type: 'marketplace:installed-list', properties: {} }")).not.toBeNull(); + expect(judge('{ "type": "marketplace:installed-list" }')).not.toBeNull(); + expect(judge(' - type: marketplace:installed-list\n properties: {}\n')).not.toBeNull(); + expect(judge("Prose.\n\n```ts\ndefinePage({ regions: [{ components: [{ type: \"marketplace:installed-list\" }] }] });\n```\n")).not.toBeNull(); + // Neighbours that must NOT match. + expect(judge('a node naming `type: marketplace:installed-list` is refused')).toBeNull(); + expect(judge("['marketplace:installed-list', '`marketplace:installed-list` was removed'],")).toBeNull(); + expect(judge("'marketplace:installed-list': retiredComponentProps('marketplace:installed-list'),")).toBeNull(); + expect(judge("{ type: 'marketplace:installed-list-v2' }")).toBeNull(); + expect(judge("{ type: 'cloud-connection:panel' }")).toBeNull(); + }); + + it('no marketplace:installed-list authoring survives inside the declared radius outside the retirement kit', () => { + const offenders: string[] = []; + let visited = 0; + let exampleSources = 0; + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + const rel = path.relative(REPO_ROOT, full).split(path.sep).join('/'); + if (entry.isDirectory()) { + if (SKIPPED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue; + walk(full); + continue; + } + if (!entry.isFile()) continue; + const ext = path.extname(entry.name); + const scanned = rel.startsWith('examples/') + ? EXAMPLES_EXT.has(ext) || EXAMPLE_APP_SRC_TS.test(rel) + : SCANNED_EXT.has(ext); + if (!scanned) continue; + if (entry.name === 'CHANGELOG.md') continue; // release prose records what shipped + if (EXCLUDED.has(rel) || EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) continue; + if (TSUP_BUNDLED_CONFIG.test(entry.name)) continue; + visited += 1; + if (EXAMPLE_APP_SRC_TS.test(rel)) exampleSources += 1; + const text = readIfPresent(full); + if (text === undefined) continue; + const m = judge(text); + if (m) offenders.push(`${rel} authors \`${m[0].trim().replace(/\s+/g, ' ')}\``); + } + }; + for (const root of WALK_ROOTS) walk(path.join(REPO_ROOT, root)); + // Anti-vacuity: the walk really covered the tree and the example apps' sources. + expect(visited).toBeGreaterThan(1000); + expect(exampleSources).toBeGreaterThan(50); + expect(offenders, 'a marketplace:installed-list authoring means the retirement is being undone').toEqual([]); + }); +}); diff --git a/packages/spec/src/ui/component-type-vocabulary.test.ts b/packages/spec/src/ui/component-type-vocabulary.test.ts index 4cd22336998..00d72db7d38 100644 --- a/packages/spec/src/ui/component-type-vocabulary.test.ts +++ b/packages/spec/src/ui/component-type-vocabulary.test.ts @@ -158,6 +158,24 @@ describe('KNOWN_COMPONENT_TYPES covers every declared face', () => { // Lit control: a kept member of the same namespace is a candidate. expect(KNOWN_COMPONENT_TYPE_CANDIDATES).toContain('record:details'); }); + + /** + * #24230 — `marketplace:installed-list` is retired BY NAME, and it is the + * first retired type that was never an enum member: a plugin console widget + * on the open string arm, in a namespace the enum does not reserve. So its + * kept `ComponentPropsMap` row is the ONLY thing that keeps it known, and it + * leaves the typo candidates like every retired name. `marketplace:` stays + * unreserved — the namespace predicate below still holds for it. + */ + it('marketplace:installed-list is known only through its kept row, and not a candidate', () => { + expect(PageComponentType.options).not.toContain('marketplace:installed-list'); + expect(Object.keys(ComponentPropsMap)).toContain('marketplace:installed-list'); + expect(isKnownComponentType('marketplace:installed-list')).toBe(true); + expect(KNOWN_COMPONENT_TYPE_CANDIDATES).not.toContain('marketplace:installed-list'); + expect(hasReservedComponentNamespace('marketplace:installed-list')).toBe(false); + // Lit control: the live sibling console widget is a candidate. + expect(KNOWN_COMPONENT_TYPE_CANDIDATES).toContain('cloud-connection:panel'); + }); }); describe('STRING_ARM_REGISTERED_TYPES ledger discipline', () => { diff --git a/packages/spec/src/ui/component-type-vocabulary.ts b/packages/spec/src/ui/component-type-vocabulary.ts index 8e0a9e8e43c..a867d1517b3 100644 --- a/packages/spec/src/ui/component-type-vocabulary.ts +++ b/packages/spec/src/ui/component-type-vocabulary.ts @@ -11,10 +11,12 @@ * open namespace for custom components, and that HALF IS DELIBERATE (maintainer * ruling 2026-08-05 on the props-gate direction; re-affirmed by every * registered-through-the-string-arm widget since: `cloud-connection:panel`, - * `marketplace:installed-list`, `mcp:connect-agent`, the `object-*` SDUI block - * family, objectui's kebab legacy `page-header`, and the `custom.*` shapes - * pinned in `page.test.ts`). Collapsing the union to the enum would reject all - * of them — measured, not assumed. + * `mcp:connect-agent`, the `object-*` SDUI block family, objectui's kebab + * legacy `page-header`, and the `custom.*` shapes pinned in `page.test.ts`). + * Collapsing the union to the enum would reject all of them — measured, not + * assumed. (`marketplace:installed-list` was one of those widgets until #24230 + * retired it by name: an open-arm type can be retired too, and its kept + * `ComponentPropsMap` row is then the only thing that keeps the name known.) * * What was NOT deliberate is that the open arm also swallowed the spec's OWN * namespaces: `global:serch` parsed as happily as `global:search`, validated @@ -82,8 +84,8 @@ export const RESERVED_COMPONENT_TYPE_NAMESPACES: ReadonlySet = new Set( * `record:line_items`, the plugin console widgets, the `object-*` blocks — plus * every type the vocabulary RETIRED by name, whose row is kept on purpose so the * readers that dispatch on it keep recognising the name: `user:profile`, - * `ai:chat_window`, and the retired-with-tombstones `element:filter` / - * `element:form`), and the + * `ai:chat_window`, `record:json`, `marketplace:installed-list`, and the + * retired-with-tombstones `element:filter` / `element:form`), and the * string-arm ledger above. * * KNOWN is not the same as WRITABLE. A retired type stays known here — that is diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index 72bb498b81c..a6ec1d1c966 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -1470,24 +1470,24 @@ describe('ComponentPropsMap', () => { }); }); - // #11575 — the two `@objectstack/cloud-connection` console widgets. Rows - // exist so the #5068 gate's dispatch reaches them; the accepted key set is - // EMPTY, measured from the renderers' read points (both registrations - // discard the schema node — `() => `; read points recorded in `liveness/page.json`, row `regions`). + // #11575 — the `@objectstack/cloud-connection` console widgets. The row + // exists so the #5068 gate's dispatch reaches it; the accepted key set is + // EMPTY, measured from the renderer's read points (the registration + // discards the schema node — `() => `; read points recorded in `liveness/page.json`, row `regions`). + // Its sibling `marketplace:installed-list` was retired by name at #24230: + // its pins live in `component-marketplace-installed-list-retirement.test.ts`. describe('plugin console widgets', () => { - it('declares rows for cloud-connection:panel and marketplace:installed-list', () => { + it('declares a row for cloud-connection:panel', () => { expect(ComponentPropsMap['cloud-connection:panel']).toBeDefined(); - expect(ComponentPropsMap['marketplace:installed-list']).toBeDefined(); }); - it('accepts the empty bag both shipped pages author', () => { + it('accepts the empty bag its shipped page authors', () => { expect(() => ComponentPropsMap['cloud-connection:panel'].parse({})).not.toThrow(); - expect(() => ComponentPropsMap['marketplace:installed-list'].parse({})).not.toThrow(); }); it('refuses any authored key, naming the surface — the pre-row silent no-op', () => { - // Before the rows, both keys below rode through every validator in - // silence (the widgets read nothing). The refusal must name WHICH + // Before the row, the key below rode through every validator in + // silence (the widget reads nothing). The refusal must name WHICH // zero-prop component refused, or the author is left guessing. const panel = ComponentPropsMap['cloud-connection:panel'].safeParse({ pollInterval: 5 }); expect(panel.success).toBe(false); @@ -1496,14 +1496,6 @@ describe('ComponentPropsMap', () => { expect(message).toContain('cloud-connection:panel'); expect(message).toContain('pollInterval'); } - - const list = ComponentPropsMap['marketplace:installed-list'].safeParse({ filter: 'installed' }); - expect(list.success).toBe(false); - if (!list.success) { - const message = list.error.issues.map((i) => i.message).join('\n'); - expect(message).toContain('marketplace:installed-list'); - expect(message).toContain('filter'); - } }); }); diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index c19607a6582..0365abe5e2c 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -431,15 +431,17 @@ const COMPONENT_LEVEL_GUIDANCE: readonly KeySetGuidance[] = [ /** * A component that declares no props at all — `app:launcher`, `nav:menu`, * `nav:breadcrumb`, `global:search`, `global:notifications`, - * `element:divider`, the three plugin console widgets - * `cloud-connection:panel` and `marketplace:installed-list` (#11575) and - * `mcp:connect-agent` (#12344), and `record:approval_decision` (#22472) and - * `record:attachments` (#22537), which read the record context alone. + * `element:divider`, the two plugin console widgets + * `cloud-connection:panel` (#11575) and `mcp:connect-agent` (#12344), and + * `record:approval_decision` (#22472) and `record:attachments` (#22537), + * which read the record context alone. * (`record:approvals` declares no props either, but its row is its own * {@link RecordApprovalsProps}, which names the two host-channel keys its * renderer reads.) `user:profile` left this list at #14159 — it * is not author-placeable at all, so its row refuses the whole bag - * ({@link retiredComponentProps}). + * ({@link retiredComponentProps}) — and `marketplace:installed-list`, the third + * console widget (#11575), left it at #24230, when its one renderer was + * deleted with the separate Installed Apps view. * * A factory rather than one shared `EmptyProps` const, because the surface name * is the whole value of the rejection here: an empty shape has no candidate @@ -466,7 +468,8 @@ const emptyProps = (type: string) => /** * A component RETIRED at element grain whose props bag is refused WHOLE — * `user:profile` (#14159), `ai:chat_window` (#21504), whose four keys left - * with its props def, and `record:json` (#24162), whose three did. The + * with its props def, `record:json` (#24162), whose three did, and + * `marketplace:installed-list` (#24230), which never had one. The * `retiredKey` channel one grain wider: where a tombstoned KEY accepts absence * and refuses any value, a retired ELEMENT has * nothing an author may write at all, so the row is `z.never` — `{}` is refused @@ -8401,7 +8404,7 @@ export const ComponentPropsMap = { // Plugin console widgets — #11575, the #8691/#8744 mechanism two instances // over, on `@objectstack/cloud-connection`'s published Setup pages: both - // types are console-registered renderers reachable only through the type + // types were console-registered renderers reachable only through the type // union's open string arm, and with no row here the #5068 gate's dispatch // skipped them as unregistered — any authored key would have ridden through // in silence (nothing authors one today: both shipped pages carry `{}`). @@ -8412,7 +8415,16 @@ export const ComponentPropsMap = { // `inputs: []` happen to agree here, but the row is the measurement, not // the claim (#8691/#8744 record where those diverge). 'cloud-connection:panel': emptyProps('cloud-connection:panel'), - 'marketplace:installed-list': emptyProps('marketplace:installed-list'), + // RETIRED by name (#24230, ADR-0049; ruling B on #23892 retired the separate + // Installed Apps view, objectui#12733 deleted its one renderer, and its one + // page left `@objectstack/cloud-connection` at #23933 — Setup's "Installed + // Apps" entry opens Packages filtered to Source = marketplace instead). The + // row STAYS, for the reason `user:profile`'s does, and here it matters more: + // the type is not a `PageComponentType` member, so this row is the only + // thing that makes the name KNOWN — delete it and every reader that + // dispatches on the row skips the retired name as an unregistered custom + // string. It never had a key, so the WHOLE bag is refused, `{}` included. + 'marketplace:installed-list': retiredComponentProps('marketplace:installed-list'), // #12344 — the same mechanism a third instance over, on `@objectstack/mcp`'s // plugin-shipped Setup page (`CONNECT_AGENT_PAGE`, `connect-ui.ts`): a // console-registered widget reachable only through the type union's open diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index caf5b84481f..21cdf15ac94 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -162,6 +162,30 @@ export const RETIRED_PAGE_COMPONENT_TYPES: ReadonlyMap = new Map + 'need to read a record as raw JSON, so no renderer draws the type and no page places it, and ' + 'its `field`, `label` and `requiredPermissions` props configure nothing. Delete the ' + '`record:json` component node and put nothing in its place.'], + // #24230, ADR-0049 enforce-or-remove, executing ruling B on #23892 (record + // 6216295104, item 2: "the separate Installed Apps view retires once the + // filtered view serves its actions"). objectui#12733 (PR objectui#12926, + // merged 35244b6379) deleted the type's one renderer, and its one producer, + // `@objectstack/cloud-connection`'s `marketplace_installed` page, had already + // left the `MARKETPLACE_INSTALLED_UI_BUNDLE` (#23933): the bundle's "Installed + // Apps" navigation entry now opens the console's `developer:packages` + // component with `params: { source: 'marketplace' }`. The type was never a + // `PageComponentType` member (a plugin console widget on the open string + // arm, in a namespace the enum does not reserve), so without this entry the + // retired name would fall back to an open-arm custom string and parse in + // silence. The `ai:chat_window` shape: the kept `ComponentPropsMap` row + // refuses the WHOLE bag (`retiredComponentProps`) with this text; it never + // had a key. No `os migrate meta` sentence: deleting an authored page node is + // the author's layout decision, which the D3 entry + // `ui-marketplace-installed-list-retired` delegates. + ['marketplace:installed-list', '`marketplace:installed-list` was removed in @objectstack/spec ' + + '18 (ADR-0049) — the separate Installed Apps view it drew was retired, so no renderer ' + + 'draws the type and the console would show "Unknown component type" in its place. The ' + + 'Installed Apps list is Setup → Packages filtered to Source = marketplace: the console\'s ' + + '`developer:packages` component opened with `params: { source: \'marketplace\' }`. Delete ' + + 'the `marketplace:installed-list` component node; to send users to that list, add a ' + + 'navigation item of type `component` with `componentRef: \'developer:packages\'` and ' + + '`params: { source: \'marketplace\' }` instead.'], ['element:form', '`element:form` was removed in @objectstack/spec 17 ' + '(ADR-0049) — the whole `element:form` element is retired: no renderer for it ' + 'ever shipped in objectui, framework or cloud (Studio\'s designer palette lists it as a ' @@ -605,7 +629,7 @@ export const PageComponentSchema = lazySchema(() => strictObject({ if (guidance) { ctx.addIssue({ code: 'custom', message: guidance, params: { retiredComponentType: type } }); } - }).describe('Component Type — a standard vocabulary member, or a custom/registered component type in its own namespace (e.g. `object-grid`, `mcp:connect-agent`). The spec\'s own type namespaces are a closed vocabulary at author time: inside them, a type the vocabulary does not declare is refused by `os validate` / `os build` / `os lint` (rule `component-type-unknown`); a type the vocabulary RETIRED by name (`user:profile` — shell chrome, not author-placeable; `ai:chat_window` — no renderer by design, the floating chat overlay is the AI chat entry point; `element:filter` and `element:form` — retired whole, no renderer for either ever shipped; `record:json` — the raw-JSON panel it was declared for was dropped) is refused at the parse itself, with the retirement prescription.'), + }).describe('Component Type — a standard vocabulary member, or a custom/registered component type in its own namespace (e.g. `object-grid`, `mcp:connect-agent`). The spec\'s own type namespaces are a closed vocabulary at author time: inside them, a type the vocabulary does not declare is refused by `os validate` / `os build` / `os lint` (rule `component-type-unknown`); a type the vocabulary RETIRED by name (`user:profile` — shell chrome, not author-placeable; `ai:chat_window` — no renderer by design, the floating chat overlay is the AI chat entry point; `element:filter` and `element:form` — retired whole, no renderer for either ever shipped; `record:json` — the raw-JSON panel it was declared for was dropped; `marketplace:installed-list` — the separate Installed Apps view it drew was retired, Setup → Packages filtered to Source = marketplace replaces it) is refused at the parse itself, with the retirement prescription.'), id: z.string().optional().describe('Unique instance ID'), /** Configuration */ @@ -1331,7 +1355,6 @@ export const PRINT_REFUSED_PAGE_COMPONENT_TYPES: ReadonlyMap = n ['global:search', 'is shell chrome, not document content'], ['global:notifications', 'is shell chrome, not document content'], ['cloud-connection:panel', 'is a console administration widget, not document content'], - ['marketplace:installed-list', 'is a console administration widget, not document content'], ['mcp:connect-agent', 'is a console administration widget, not document content'], ['ai:suggestion', 'is an AI suggestion generated for each viewer, not document content'], ]); diff --git a/packages/spec/vitest.repo-tests.json b/packages/spec/vitest.repo-tests.json index 82290f55be1..bf99cc40247 100644 --- a/packages/spec/vitest.repo-tests.json +++ b/packages/spec/vitest.repo-tests.json @@ -54,6 +54,7 @@ "src/system/email-template-category-is-system-retirement.test.ts", "src/system/email-template-floor-locale-parity.pin.test.ts", "src/ui/action-requires-confirmation-docblock.pin.test.ts", + "src/ui/component-marketplace-installed-list-retirement.test.ts", "src/ui/component-record-json-retirement.test.ts", "src/ui/element-flat-binding-retirement.test.ts", "src/ui/element-text-variant-heading-retirement.test.ts", From 21a09d73e185d54a38d9f5496948d1f8f42802ca Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 03:31:41 +0000 Subject: [PATCH 2/5] test(spec): regenerate the page reference docs and move the whole-bag census for the retired marketplace:installed-list Claude-Session: https://claude.ai/code/session_01SovhHbD8dAQGDyEGHRoSUx Co-authored-by: Claude --- content/docs/references/ui/page.mdx | 4 ++-- .../src/ui/component-record-related-list-parent-23134.test.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/content/docs/references/ui/page.mdx b/content/docs/references/ui/page.mdx index 11c065d4c97..b446911250e 100644 --- a/content/docs/references/ui/page.mdx +++ b/content/docs/references/ui/page.mdx @@ -293,7 +293,7 @@ View filter rule | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **type** | `Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| 'page:card' \| 'page:section' \| 'record:details' \| 'record:highlights' \| 'record:related_list' \| 'record:activity' \| 'record:chatter' \| 'record:discussion' \| 'record:path' \| 'record:alert' \| 'record:quick_actions' \| 'record:reference_rail' \| 'record:history' \| 'record:approval_decision' \| 'record:approvals' \| 'record:attachments' \| 'app:launcher' \| 'nav:menu' \| 'nav:breadcrumb' \| 'global:search' \| 'global:notifications' \| 'ai:suggestion' \| 'element:text' \| 'element:number' \| 'element:image' \| 'element:divider' \| 'element:button' \| 'element:record_picker' \| 'element:text_input'> \| string` | ✅ | Component Type — a standard vocabulary member, or a custom/registered component type in its own namespace (e.g. `object-grid`, `mcp:connect-agent`). The spec's own type namespaces are a closed vocabulary at author time: inside them, a type the vocabulary does not declare is refused by `os validate` / `os build` / `os lint` (rule `component-type-unknown`); a type the vocabulary RETIRED by name (`user:profile` — shell chrome, not author-placeable; `ai:chat_window` — no renderer by design, the floating chat overlay is the AI chat entry point; `element:filter` and `element:form` — retired whole, no renderer for either ever shipped; `record:json` — the raw-JSON panel it was declared for was dropped) is refused at the parse itself, with the retirement prescription. | +| **type** | `Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| 'page:card' \| 'page:section' \| 'record:details' \| 'record:highlights' \| 'record:related_list' \| 'record:activity' \| 'record:chatter' \| 'record:discussion' \| 'record:path' \| 'record:alert' \| 'record:quick_actions' \| 'record:reference_rail' \| 'record:history' \| 'record:approval_decision' \| 'record:approvals' \| 'record:attachments' \| 'app:launcher' \| 'nav:menu' \| 'nav:breadcrumb' \| 'global:search' \| 'global:notifications' \| 'ai:suggestion' \| 'element:text' \| 'element:number' \| 'element:image' \| 'element:divider' \| 'element:button' \| 'element:record_picker' \| 'element:text_input'> \| string` | ✅ | Component Type — a standard vocabulary member, or a custom/registered component type in its own namespace (e.g. `object-grid`, `mcp:connect-agent`). The spec's own type namespaces are a closed vocabulary at author time: inside them, a type the vocabulary does not declare is refused by `os validate` / `os build` / `os lint` (rule `component-type-unknown`); a type the vocabulary RETIRED by name (`user:profile` — shell chrome, not author-placeable; `ai:chat_window` — no renderer by design, the floating chat overlay is the AI chat entry point; `element:filter` and `element:form` — retired whole, no renderer for either ever shipped; `record:json` — the raw-JSON panel it was declared for was dropped; `marketplace:installed-list` — the separate Installed Apps view it drew was retired, Setup → Packages filtered to Source = marketplace replaces it) is refused at the parse itself, with the retirement prescription. | | **id** | `string` | optional | Unique instance ID | | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **properties** | `Record` | optional (default: `{}`) | Component props passed to the widget. See component.zod.ts for schemas. | @@ -432,7 +432,7 @@ Print declaration: paper size, orientation, margins, the running header and foot | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **type** | `Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| …> \| string` | ✅ | Component Type — a standard vocabulary member, or a custom/registered component type in its own namespace (e.g. `object-grid`, `mcp:connect-agent`). The spec's own type namespaces are a closed vocabulary at author time: inside them, a type the vocabulary does not declare is refused by `os validate` / `os build` / `os lint` (rule `component-type-unknown`); a type the vocabulary RETIRED by name (`user:profile` — shell chrome, not author-placeable; `ai:chat_window` — no renderer by design, the floating chat overlay is the AI chat entry point; `element:filter` and `element:form` — retired whole, no renderer for either ever shipped; `record:json` — the raw-JSON panel it was declared for was dropped) is refused at the parse itself, with the retirement prescription. | +| **type** | `Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| …> \| string` | ✅ | Component Type — a standard vocabulary member, or a custom/registered component type in its own namespace (e.g. `object-grid`, `mcp:connect-agent`). The spec's own type namespaces are a closed vocabulary at author time: inside them, a type the vocabulary does not declare is refused by `os validate` / `os build` / `os lint` (rule `component-type-unknown`); a type the vocabulary RETIRED by name (`user:profile` — shell chrome, not author-placeable; `ai:chat_window` — no renderer by design, the floating chat overlay is the AI chat entry point; `element:filter` and `element:form` — retired whole, no renderer for either ever shipped; `record:json` — the raw-JSON panel it was declared for was dropped; `marketplace:installed-list` — the separate Installed Apps view it drew was retired, Setup → Packages filtered to Source = marketplace replaces it) is refused at the parse itself, with the retirement prescription. | | **id** | `string` | optional | Unique instance ID | | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **properties** | `Record` | optional (default: `{}`) | Component props passed to the widget. See component.zod.ts for schemas. | diff --git a/packages/spec/src/ui/component-record-related-list-parent-23134.test.ts b/packages/spec/src/ui/component-record-related-list-parent-23134.test.ts index 9dafd54cf3f..0d4ee6b4484 100644 --- a/packages/spec/src/ui/component-record-related-list-parent-23134.test.ts +++ b/packages/spec/src/ui/component-record-related-list-parent-23134.test.ts @@ -166,7 +166,7 @@ describe('C — every other component row refuses the key: the per-type door', ( expect(notRefused).toEqual([]); // The whole-bag set is the retired types only — a live row landing here // would be refusing for a reason this test does not mean to accept. - expect(wholeBag.sort()).toEqual(['ai:chat_window', 'record:json', 'user:profile']); + expect(wholeBag.sort()).toEqual(['ai:chat_window', 'marketplace:installed-list', 'record:json', 'user:profile']); }); it('CONTROL: the related list itself does not report the key as unknown', () => { From 9068ab1a68c9460a2617cca47dd28ef8f14e5bc3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 03:37:36 +0000 Subject: [PATCH 3/5] fix(spec): keep the retired marketplace:installed-list remedy to one fix line Claude-Session: https://claude.ai/code/session_01SovhHbD8dAQGDyEGHRoSUx Co-authored-by: Claude --- .../component-marketplace-installed-list-retirement.test.ts | 2 +- packages/spec/src/ui/page.zod.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/spec/src/ui/component-marketplace-installed-list-retirement.test.ts b/packages/spec/src/ui/component-marketplace-installed-list-retirement.test.ts index be148385659..f72d17b88f1 100644 --- a/packages/spec/src/ui/component-marketplace-installed-list-retirement.test.ts +++ b/packages/spec/src/ui/component-marketplace-installed-list-retirement.test.ts @@ -57,7 +57,7 @@ const NARROWED_ID = 'ui-cloud-connection-widgets-unknown-keys-refused'; const FIRST_SENTENCE = /^`marketplace:installed-list` was removed in @objectstack\/spec 18 \(ADR-0049\) — the separate Installed Apps view it drew was retired, so no renderer draws the type and the console would show "Unknown component type" in its place\./; const FIX = - "Delete the `marketplace:installed-list` component node; to send users to that list, add a navigation item of type `component` with `componentRef: 'developer:packages'` and `params: { source: 'marketplace' }` instead."; + "Delete the `marketplace:installed-list` component node; to link to that view, add a `component` navigation item with `componentRef: 'developer:packages'` and `params: { source: 'marketplace' }`."; // `check:doc-authoring`: a prescription printed at the customer carries no // citation-shaped issue id. const ISSUE_ID = /#\d{3,}/; diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index 21cdf15ac94..9146237b28f 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -183,9 +183,9 @@ export const RETIRED_PAGE_COMPONENT_TYPES: ReadonlyMap = new Map + 'draws the type and the console would show "Unknown component type" in its place. The ' + 'Installed Apps list is Setup → Packages filtered to Source = marketplace: the console\'s ' + '`developer:packages` component opened with `params: { source: \'marketplace\' }`. Delete ' - + 'the `marketplace:installed-list` component node; to send users to that list, add a ' - + 'navigation item of type `component` with `componentRef: \'developer:packages\'` and ' - + '`params: { source: \'marketplace\' }` instead.'], + + 'the `marketplace:installed-list` component node; to link to that view, add a `component` ' + + 'navigation item with `componentRef: \'developer:packages\'` and ' + + '`params: { source: \'marketplace\' }`.'], ['element:form', '`element:form` was removed in @objectstack/spec 17 ' + '(ADR-0049) — the whole `element:form` element is retired: no renderer for it ' + 'ever shipped in objectui, framework or cloud (Studio\'s designer palette lists it as a ' From fc6245ba2170cbc51c9ca4cafad41a1dc0c1401c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 03:41:21 +0000 Subject: [PATCH 4/5] chore(changeset): marketplace:installed-list retirement Claude-Session: https://claude.ai/code/session_01SovhHbD8dAQGDyEGHRoSUx Co-authored-by: Claude --- ...4230-marketplace-installed-list-retired.md | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .changeset/24230-marketplace-installed-list-retired.md diff --git a/.changeset/24230-marketplace-installed-list-retired.md b/.changeset/24230-marketplace-installed-list-retired.md new file mode 100644 index 00000000000..7c6749624ec --- /dev/null +++ b/.changeset/24230-marketplace-installed-list-retired.md @@ -0,0 +1,41 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec)!: `marketplace:installed-list` is retired — refused by name at the schema door; the separate Installed Apps view it drew was retired, and Setup → Packages filtered to Source = marketplace replaces it (#24230, ADR-0049) + +Clause-②: yes (narrowing) + + + +**BREAKING** — an accept-set narrowing on a published authoring surface, shipped as `minor` under the repo's launch-window convention for accept-set narrowings (the `user:profile`, `element:filter`, `element:form`, `ai:chat_window` and `record:json` retirements shipped the same way). + +`marketplace:installed-list` was the plugin console widget that drew the separate Installed Apps view. It was published in 17.3.0 with an empty `ComponentPropsMap` row and was never a `PageComponentType` member: it reached the page schema through the type union's open string arm. The separate view was then retired, because Setup → Packages carries the same packages and their actions behind a Source = marketplace filter. The widget's one page, `marketplace_installed`, had already left `@objectstack/cloud-connection`, whose "Installed Apps" navigation entry opens the filtered Packages view instead, and the console deleted the widget's one renderer. After that, an authored node validated clean and then drew "Unknown component type" in front of a user. A declared element nothing draws is a capability claim nothing keeps, so it is retired under ADR-0049 enforce-or-remove and refused by name, the `ai:chat_window` way. + +**What is refused:** an authored `marketplace:installed-list` component node, at `PageComponentSchema.type`. That covers `definePage()`, `PageSchema`, and every door that parses pages: `os validate`, `os build`, `os lint` and the metadata save door. The issue is located at the node's own path, with `code: 'custom'` and `params.retiredComponentType`, and its message is the retirement prescription. `ComponentPropsMap['marketplace:installed-list']` stays as a row, because for a type outside the enum the row is the only thing that keeps the name known to the component-props gate, `check-yaml-examples` and the type vocabulary. The row now refuses every props bag, `{}` included, with the same prescription. One prescription string, `RETIRED_PAGE_COMPONENT_TYPES` in `@objectstack/spec/ui`, answers at every door. The authoring rule `component-type-unknown` reports the node by name, and the typo suggester no longer offers it. + +**What else moves:** its print classification row (`PRINT_REFUSED_PAGE_COMPONENT_TYPES`) leaves with it, because a retired type is refused at the parse before any print rule reads it. The D3 entry `ui-cloud-connection-widgets-unknown-keys-refused`, which closed this type's props bag to `{}` in the same major, now speaks for `cloud-connection:panel` alone, so the upgrade guide no longer tells an upgrader to keep a node that is refused. No export is removed: the row was built by the `emptyProps` factory, which exports no def, so no JSON Schema leaves the published set. + +**What stays accepted:** every other `ComponentPropsMap` row, byte-identically, including the sibling console widgets `cloud-connection:panel` and `mcp:connect-agent`. The open string arm also stays open: custom and plugin-registered types keep parsing, the `marketplace:` namespace included. The only string refused is the retired name itself. + +## FROM → TO + +| you wrote | write instead | +|:--|:--| +| a `{ type: 'marketplace:installed-list' }` component node in a page region, slot or container | nothing: delete the node. The Installed Apps list is Setup → Packages filtered to Source = marketplace | +| a page whose only job was to show that list | a navigation item `{ type: 'component', componentRef: 'developer:packages', params: { source: 'marketplace' } }`, which is what `@objectstack/cloud-connection`'s own "Installed Apps" entry does | + +The one-line fix: delete the `marketplace:installed-list` component node, and link to the filtered Packages view if users need to reach it. No ADR-0087 conversion is registered, because the only edit is deleting an authored page node, and a mechanical conversion does not delete page nodes: which region closes up is a layout decision. The D3 entry `ui-marketplace-installed-list-retired` carries that delegation, so `os migrate meta --from 17` lists it as a manual change for every stack that still names the type. + +## Who is affected, measured + +- **objectstack** at `cbfa5e4335`: zero authored `marketplace:installed-list` nodes in shipped metadata. `@objectstack/cloud-connection` serves no page for it, and its pin test asserts that. The other hits were the spec's own row, print row and docblocks, its tests, two lint pins, one synthetic test fixture in `packages/cli` (now a live block) and the release-owned changelogs. A tree-scoped absence pin now holds that zero. +- **objectui** at the console pin `.objectui-sha` `5e15928b46`: the type is registered by string only, with no typed read of `ComponentPropsMap['marketplace:installed-list']`. The row's key is kept, so the console build at the pin is unaffected. objectui `main` has deleted the renderer. +- **Deployed metadata** was not measured. + +The retirement kit: + +- the retired-type map entry and the print classification row removed (`packages/spec/src/ui/page.zod.ts`), the row turned into a whole-bag refusal (`packages/spec/src/ui/component.zod.ts`), and the vocabulary docblock updated (`component-type-vocabulary.ts`) +- the D3 semantic entry `ui-marketplace-installed-list-retired` and its step-18 rationale fragment; the D3 entries `ui-cloud-connection-widgets-unknown-keys-refused` (narrowed to `cloud-connection:panel`) and `ui-mcp-connect-agent-unknown-keys-refused` (one clause) amended +- pin tests: `component-marketplace-installed-list-retirement.test.ts` pins `code`, `path`, `params` and the first sentence at each door (the map, the row with empty and keyed bags, `PageComponentSchema`, `PageSchema` at the element path), the absence from the enum and the print rows, the D3 registration and the narrowed sibling entry, with `cloud-connection:panel` as the live control, plus a tree-scoped absence walk; `component-type-vocabulary.test.ts` pins the type known only through its row, out of the typo candidates, and `marketplace:` still unreserved; the lint accept pins in `validate-component-types.test.ts` and `validate-component-props.test.ts` are flipped into refusal pins +- generated docs follow the schema: the reference page for `ui/page`, `spec-changes.json` and the upgrade guide From 603c1a10c9fac263d7f9b3a6bca3de3240d24d82 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 04:43:12 +0000 Subject: [PATCH 5/5] =?UTF-8?q?fix(changeset):=20marketplace:installed-lis?= =?UTF-8?q?t=20retirement=20declares=20Clause-=E2=91=A1=20no=20(narrowing)?= =?UTF-8?q?=20=E2=80=94=20the=20diff=20widens=20nothing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01SovhHbD8dAQGDyEGHRoSUx Co-authored-by: Claude --- .changeset/24230-marketplace-installed-list-retired.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/24230-marketplace-installed-list-retired.md b/.changeset/24230-marketplace-installed-list-retired.md index 7c6749624ec..58ce82b8946 100644 --- a/.changeset/24230-marketplace-installed-list-retired.md +++ b/.changeset/24230-marketplace-installed-list-retired.md @@ -4,7 +4,7 @@ feat(spec)!: `marketplace:installed-list` is retired — refused by name at the schema door; the separate Installed Apps view it drew was retired, and Setup → Packages filtered to Source = marketplace replaces it (#24230, ADR-0049) -Clause-②: yes (narrowing) +Clause-②: no (narrowing)