diff --git a/.changeset/ui-cloud-connection-widgets-unknown-keys-refused.md b/.changeset/ui-cloud-connection-widgets-unknown-keys-refused.md
new file mode 100644
index 0000000000..1a2516187a
--- /dev/null
+++ b/.changeset/ui-cloud-connection-widgets-unknown-keys-refused.md
@@ -0,0 +1,57 @@
+---
+"@objectstack/spec": minor
+---
+
+feat(spec): declare `cloud-connection:panel` / `marketplace:installed-list` in `ComponentPropsMap` — undeclared keys on the two are refused (#11575)
+
+**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep
+launch-window convention ships it as `minor`; the migration prescription is
+registered under protocol major 18, where `os migrate meta` users will look).
+
+These were two more instances of the #8691/#8744 silent no-op class:
+console-registered widgets on `@objectstack/cloud-connection`'s published
+Setup pages, reachable through the component type union's open string arm,
+with registered renderers but no `ComponentPropsMap` row — so the #5068
+component-props gate's dispatch skipped them as unregistered and any authored
+key rode through every validator in silence.
+
+The new rows are strict and **empty**, measured from the renderers' actual
+read points at the objectui pin, not from the registrations' declared-input
+lists (#8691/#8744 record where those diverge — here the two happen to
+agree): both registrations discard the schema node entirely
+(`() => `, `() => `) and neither
+component function takes a prop, so the widgets accept **no configuration at
+all**, and an authored key is now a publish-time refusal naming the surface
+instead of a silent no-op.
+
+**What stays accepted:** the empty bag (`{}`, or `properties` omitted) — the
+shape both plugin-shipped pages (`cloud_connection_settings`,
+`marketplace_installed`) author today, byte-identically. Node-level keys
+(`visibleWhen`, `id`, `style`, …) are unaffected: they live on the component
+node, and the refusal's guidance says so.
+
+## FROM → TO
+
+```ts
+// before — parsed green everywhere; the panel polls on its own schedule anyway
+{
+ type: 'cloud-connection:panel',
+ properties: { pollInterval: 5 }, // silent no-op: the widget reads nothing
+}
+
+// after — any key is a publish-time refusal naming the zero-prop surface;
+// write the measured shape
+{
+ type: 'cloud-connection:panel',
+ properties: {},
+}
+```
+
+There is deliberately no automatic rewrite: a key authored on either widget
+configures nothing and is removed, not renamed — behaviour that seems to need
+one is a renderer capability request against objectui, not a metadata key.
+`os migrate meta` surfaces the change as a structured TODO (semantic entry
+`ui-cloud-connection-widgets-unknown-keys-refused`, protocol major 18 — this
+refusal is not part of the v17.0.0 cut).
+
+
diff --git a/packages/cloud-connection/src/canonical-expression-envelopes.test.ts b/packages/cloud-connection/src/canonical-expression-envelopes.test.ts
index 7ba94613bf..0809657703 100644
--- a/packages/cloud-connection/src/canonical-expression-envelopes.test.ts
+++ b/packages/cloud-connection/src/canonical-expression-envelopes.test.ts
@@ -20,16 +20,19 @@
* preconditions, the verdict, and a downgrade control proving the detector
* reaches these real exports.
*
- * ## The two exempted component types
+ * ## No standing exemptions (#11575)
*
- * `cloud-connection:panel` and `marketplace:installed-list` are
- * console-registered widgets with no `ComponentPropsMap` row, so door 3 has
- * no schema to read their `properties` with. The exemption is asserted
- * EXACTLY (a new unmapped type reds), and it is valid only while those
- * components author an EMPTY props bag — nothing authored is nothing to
- * serve bare. The moment either widget grows a real authored prop, the
- * emptiness assert reds and forces the decision: give the type a
- * `ComponentPropsMap` row, or widen the exemption knowingly.
+ * `cloud-connection:panel` and `marketplace:installed-list` were exempted
+ * here between #11480 and #11575: console-registered widgets with no
+ * `ComponentPropsMap` row, so door 3 had no schema to read their
+ * `properties` with. #11575 gave both types their rows (strict, empty —
+ * measured from the renderers' read points at the `.objectui-sha` pin), so
+ * door 3 now reads both bags and the exemption lists are empty. The
+ * machinery stays: the exemption set is still asserted EXACTLY, so any NEW
+ * unmapped type reds and forces the same decision — declare the props
+ * schema in `ComponentPropsMap`, or record the exemption here with the
+ * reason (and then also pin the exempted bags empty, as the pre-#11575
+ * revision of this file did).
*/
import { readFileSync, readdirSync } from 'node:fs';
@@ -40,7 +43,6 @@ import type { Page } from '@objectstack/spec/ui';
import {
auditPageExpressionEnvelopes,
renderBareExpressionFindings,
- walkPageComponents,
} from '@objectstack/lint';
import { CloudConnectionSettingsPage } from './cloud-connection-ui.js';
import { MarketplaceInstalledPage } from './marketplace-ui.js';
@@ -56,18 +58,19 @@ const HERE = dirname(fileURLToPath(import.meta.url));
/**
* Every page this package ships, audited by export name — with the unmapped
- * component types each page is EXPECTED to report (the exemptions above).
+ * component types each page is EXPECTED to report (none since #11575; see
+ * the module header).
*/
const AUDITED_PAGES: { exportName: string; page: Page; exemptUnmappedTypes: string[] }[] = [
{
exportName: 'CloudConnectionSettingsPage',
page: CloudConnectionSettingsPage,
- exemptUnmappedTypes: ['cloud-connection:panel'],
+ exemptUnmappedTypes: [],
},
{
exportName: 'MarketplaceInstalledPage',
page: MarketplaceInstalledPage,
- exemptUnmappedTypes: ['marketplace:installed-list'],
+ exemptUnmappedTypes: [],
},
];
@@ -170,27 +173,14 @@ describe('cloud-connection Page exports serve canonical expression envelopes', (
});
it.each(AUDITS)('$exportName: unmapped component types are EXACTLY the recorded exemptions (door 3 precondition)', ({ audit, exemptUnmappedTypes }) => {
- // See the module header for why these two types are exempt. Anything else
+ // No exemptions stand since #11575 (see the module header). Anything
// unmapped is a new door-3 blind spot: declare the props schema in
- // `ComponentPropsMap`, or record the exemption here with the reason.
+ // `ComponentPropsMap`, or record the exemption here with the reason —
+ // and then also pin the exempted bags empty, as the pre-#11575 revision
+ // of this file did.
expect(audit.unmappedTypes.map(e => e.type).sort()).toEqual([...exemptUnmappedTypes].sort());
});
- it.each(AUDITS)('$exportName: every exempted component authors an EMPTY props bag', ({ page, exemptUnmappedTypes }) => {
- // The exemption above is only sound while there is nothing authored for
- // door 3 to miss. A real key landing in one of these bags must force a
- // decision (props schema row, or a conscious wider exemption) — not ride
- // through a standing exemption silently.
- const offenders = walkPageComponents(page as AnyRec, '')
- .filter(w => typeof w.component.type === 'string' && exemptUnmappedTypes.includes(w.component.type))
- .filter(w => {
- const props = w.component.properties;
- return !!props && typeof props === 'object' && Object.keys(props).length > 0;
- })
- .map(w => `${w.path} [${String(w.component.type)}]`);
- expect(offenders.join('\n')).toBe('');
- });
-
it.each(AUDITS)('$exportName: every authored `properties` bag parses against its props schema (door 3 precondition)', ({ audit }) => {
expect(
audit.unreadableProps.map(e => `${e.path} [${e.type}]: ${e.issues}`).join('\n'),
diff --git a/packages/lint/src/validate-component-props.test.ts b/packages/lint/src/validate-component-props.test.ts
index 959ba9d532..038bfce505 100644
--- a/packages/lint/src/validate-component-props.test.ts
+++ b/packages/lint/src/validate-component-props.test.ts
@@ -775,3 +775,57 @@ describe('validateComponentProps — record:alert / record:quick_actions / recor
expect(findings[0].message).toContain('`dock`');
});
});
+
+/**
+ * #11575 — the two `@objectstack/cloud-connection` console widgets, so the
+ * gate's dispatch reaches them.
+ *
+ * The pre-fix state these pin against: `cloud-connection:panel` and
+ * `marketplace:installed-list` had no `ComponentPropsMap` row (they are
+ * 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.
+ */
+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', () => {
+ const findings = validateComponentProps(
+ stackWith([{
+ type: 'cloud-connection:panel',
+ properties: { pollInterval: 5 },
+ }]),
+ );
+ expect(findings).toHaveLength(1);
+ expect(findings[0].rule).toBe(COMPONENT_PROPS_UNKNOWN_KEY);
+ expect(findings[0].where).toBe('page "probe_page" · cloud-connection:panel');
+ expect(findings[0].message).toContain('`pollInterval`');
+ 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('stays silent on the empty bag both plugin-shipped pages author', () => {
+ const findings = validateComponentProps(
+ stackWith([
+ { type: 'cloud-connection:panel', properties: {} },
+ { type: 'marketplace:installed-list', properties: {} },
+ ]),
+ );
+ expect(findings).toEqual([]);
+ });
+});
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
new file mode 100644
index 0000000000..0f700b526b
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/18.ui-cloud-connection-widgets-unknown-keys-refused.ts
@@ -0,0 +1,34 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+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, '
+ + '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 #8691/#8744 class: console-registered widgets on '
+ + '`@objectstack/cloud-connection`\'s published Setup pages, reachable through the '
+ + 'component type union\'s open string arm, with registered renderers but no '
+ + '`ComponentPropsMap` row — so the #5068 props gate\'s dispatch skipped them as '
+ + 'unregistered and any authored key rode through every validator in silence. The new '
+ + 'rows are strict and EMPTY, measured from the renderers\' actual read points at the '
+ + 'objectui pin (not from the registrations\' declared-input lists): both registrations '
+ + 'ignore the component node entirely, so the widgets accept no configuration at all, '
+ + '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), '
+ + '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/registry.ts b/packages/spec/src/migrations/registry.ts
index 3daee72644..f73986fa94 100644
--- a/packages/spec/src/migrations/registry.ts
+++ b/packages/spec/src/migrations/registry.ts
@@ -6139,6 +6139,36 @@ const step18: MigrationStep = {
+ 'of an envelope-level code; constructing an ApiError with a retired spelling '
+ 'fails `StandardErrorCode`/`ApiErrorSchema` parse rather than passing silently.',
},
+ {
+ 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, '
+ + '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 #8691/#8744 class: console-registered widgets on '
+ + '`@objectstack/cloud-connection`\'s published Setup pages, reachable through the '
+ + 'component type union\'s open string arm, with registered renderers but no '
+ + '`ComponentPropsMap` row — so the #5068 props gate\'s dispatch skipped them as '
+ + 'unregistered and any authored key rode through every validator in silence. The new '
+ + 'rows are strict and EMPTY, measured from the renderers\' actual read points at the '
+ + 'objectui pin (not from the registrations\' declared-input lists): both registrations '
+ + 'ignore the component node entirely, so the widgets accept no configuration at all, '
+ + '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), '
+ + 'and behaviour that seems to need one is a renderer capability request against '
+ + 'objectui, not a metadata key.',
+ },
{
id: 'ui-record-blocks-unknown-keys-refused',
surface: 'page `record:alert` / `record:quick_actions` / `record:history` / '
diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts
index 7a71c46489..aa6b5fd173 100644
--- a/packages/spec/src/ui/component.test.ts
+++ b/packages/spec/src/ui/component.test.ts
@@ -885,6 +885,43 @@ describe('ComponentPropsMap', () => {
expect(() => ComponentPropsMap['global:search'].parse({})).not.toThrow();
expect(() => ComponentPropsMap['user:profile'].parse({})).not.toThrow();
});
+
+ // #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 at the `.objectui-sha`
+ // pin (both registrations discard the schema node — `() => `).
+ describe('plugin console widgets (#11575)', () => {
+ it('declares rows for cloud-connection:panel and marketplace:installed-list', () => {
+ expect(ComponentPropsMap['cloud-connection:panel']).toBeDefined();
+ expect(ComponentPropsMap['marketplace:installed-list']).toBeDefined();
+ });
+
+ it('accepts the empty bag both shipped pages author', () => {
+ 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
+ // zero-prop component refused, or the author is left guessing.
+ const panel = ComponentPropsMap['cloud-connection:panel'].safeParse({ pollInterval: 5 });
+ expect(panel.success).toBe(false);
+ if (!panel.success) {
+ const message = panel.error.issues.map((i) => i.message).join('\n');
+ 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 5df09fe0b3..f0795909c6 100644
--- a/packages/spec/src/ui/component.zod.ts
+++ b/packages/spec/src/ui/component.zod.ts
@@ -316,12 +316,13 @@ const COMPONENT_LEVEL_GUIDANCE: readonly KeySetGuidance[] = [
/**
* A component that declares no props at all — `app:launcher`, `nav:menu`,
* `nav:breadcrumb`, `global:search`, `global:notifications`, `user:profile`,
- * `element:divider`.
+ * `element:divider`, and the two plugin console widgets `cloud-connection:panel`
+ * and `marketplace:installed-list` (#11575).
*
* 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
* keys, so the edit-distance fallback can say nothing, and "unrecognized key on
- * this component" would leave the author guessing which of the seven it meant.
+ * this component" would leave the author guessing which of the nine it meant.
* One `strictObject(` call site either way — the ledger counts sites from the
* AST, and this is one.
*
@@ -2574,7 +2575,24 @@ export const ComponentPropsMap = {
'global:search': emptyProps('global:search'),
'global:notifications': emptyProps('global:notifications'),
'user:profile': emptyProps('user:profile'),
-
+
+ // 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
+ // 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 `{}`).
+ // Key sets measured from the renderers' ACTUAL read points at the
+ // `.objectui-sha` pin (app-shell `console/cloud-connection/
+ // CloudConnectionPanel.tsx`, `console/marketplace/InstalledListWidget.tsx`):
+ // both registrations discard the schema node entirely (`() => `)
+ // and neither component function takes a prop, so the accepted key set is
+ // EMPTY — strict, refuses every key. The registrations' declared
+ // `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'),
+
// AI
'ai:chat_window': AIChatWindowProps,
'ai:suggestion': strictObject({