Skip to content

Commit 772d5de

Browse files
os-warrenclaude
andauthored
feat(spec): declare cloud-connection:panel / marketplace:installed-list in ComponentPropsMap — undeclared keys refused (#11575) (#11743)
- ui/component.zod.ts: two emptyProps rows — key sets measured from the renderers' ACTUAL read points at the .objectui-sha pin (app-shell CloudConnectionPanel.tsx / InstalledListWidget.tsx): both registrations discard the schema node ('() => <Widget />') and neither component takes a prop, so the accepted key set is EMPTY (strict). The registrations' declared 'inputs: []' agree here; the row is the measurement, not the claim. - cloud-connection canonical-expression-envelopes.test.ts: the two exemptUnmappedTypes entries and the emptiness pins deleted — door 3 now reads both bags; the exact-set assert (still in place) demands exactly this. - pins: spec-side (component.test.ts — rows defined, {} accepted, any key refused naming the surface) + lint-side dispatch pins (validate-component-props.test.ts #11575 suite). - semantic migration entry under protocol-18 + regenerated registry (111). - changeset: minor + BREAKING + ADR-0087 disposition (#8744 precedent). Claude-Session: https://claude.ai/code/session_01Rxnd8cyFnoU8V5y21PaTsy Co-authored-by: Claude <noreply@anthropic.com>
1 parent 589758d commit 772d5de

7 files changed

Lines changed: 253 additions & 33 deletions

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): declare `cloud-connection:panel` / `marketplace:installed-list` in `ComponentPropsMap` — undeclared keys on the two are refused (#11575)
6+
7+
**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep
8+
launch-window convention ships it as `minor`; the migration prescription is
9+
registered under protocol major 18, where `os migrate meta` users will look).
10+
11+
These were two more instances of the #8691/#8744 silent no-op class:
12+
console-registered widgets on `@objectstack/cloud-connection`'s published
13+
Setup pages, reachable through the component type union's open string arm,
14+
with registered renderers but no `ComponentPropsMap` row — so the #5068
15+
component-props gate's dispatch skipped them as unregistered and any authored
16+
key rode through every validator in silence.
17+
18+
The new rows are strict and **empty**, measured from the renderers' actual
19+
read points at the objectui pin, not from the registrations' declared-input
20+
lists (#8691/#8744 record where those diverge — here the two happen to
21+
agree): both registrations discard the schema node entirely
22+
(`() => <CloudConnectionPanel />`, `() => <InstalledList />`) and neither
23+
component function takes a prop, so the widgets accept **no configuration at
24+
all**, and an authored key is now a publish-time refusal naming the surface
25+
instead of a silent no-op.
26+
27+
**What stays accepted:** the empty bag (`{}`, or `properties` omitted) — the
28+
shape both plugin-shipped pages (`cloud_connection_settings`,
29+
`marketplace_installed`) author today, byte-identically. Node-level keys
30+
(`visibleWhen`, `id`, `style`, …) are unaffected: they live on the component
31+
node, and the refusal's guidance says so.
32+
33+
## FROM → TO
34+
35+
```ts
36+
// before — parsed green everywhere; the panel polls on its own schedule anyway
37+
{
38+
type: 'cloud-connection:panel',
39+
properties: { pollInterval: 5 }, // silent no-op: the widget reads nothing
40+
}
41+
42+
// after — any key is a publish-time refusal naming the zero-prop surface;
43+
// write the measured shape
44+
{
45+
type: 'cloud-connection:panel',
46+
properties: {},
47+
}
48+
```
49+
50+
There is deliberately no automatic rewrite: a key authored on either widget
51+
configures nothing and is removed, not renamed — behaviour that seems to need
52+
one is a renderer capability request against objectui, not a metadata key.
53+
`os migrate meta` surfaces the change as a structured TODO (semantic entry
54+
`ui-cloud-connection-widgets-unknown-keys-refused`, protocol major 18 — this
55+
refusal is not part of the v17.0.0 cut).
56+
57+
<!-- adr-0087: registered ui-cloud-connection-widgets-unknown-keys-refused -->

packages/cloud-connection/src/canonical-expression-envelopes.test.ts

Lines changed: 20 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,19 @@
2020
* preconditions, the verdict, and a downgrade control proving the detector
2121
* reaches these real exports.
2222
*
23-
* ## The two exempted component types
23+
* ## No standing exemptions (#11575)
2424
*
25-
* `cloud-connection:panel` and `marketplace:installed-list` are
26-
* console-registered widgets with no `ComponentPropsMap` row, so door 3 has
27-
* no schema to read their `properties` with. The exemption is asserted
28-
* EXACTLY (a new unmapped type reds), and it is valid only while those
29-
* components author an EMPTY props bag — nothing authored is nothing to
30-
* serve bare. The moment either widget grows a real authored prop, the
31-
* emptiness assert reds and forces the decision: give the type a
32-
* `ComponentPropsMap` row, or widen the exemption knowingly.
25+
* `cloud-connection:panel` and `marketplace:installed-list` were exempted
26+
* here between #11480 and #11575: console-registered widgets with no
27+
* `ComponentPropsMap` row, so door 3 had no schema to read their
28+
* `properties` with. #11575 gave both types their rows (strict, empty —
29+
* measured from the renderers' read points at the `.objectui-sha` pin), so
30+
* door 3 now reads both bags and the exemption lists are empty. The
31+
* machinery stays: the exemption set is still asserted EXACTLY, so any NEW
32+
* unmapped type reds and forces the same decision — declare the props
33+
* schema in `ComponentPropsMap`, or record the exemption here with the
34+
* reason (and then also pin the exempted bags empty, as the pre-#11575
35+
* revision of this file did).
3336
*/
3437

3538
import { readFileSync, readdirSync } from 'node:fs';
@@ -40,7 +43,6 @@ import type { Page } from '@objectstack/spec/ui';
4043
import {
4144
auditPageExpressionEnvelopes,
4245
renderBareExpressionFindings,
43-
walkPageComponents,
4446
} from '@objectstack/lint';
4547
import { CloudConnectionSettingsPage } from './cloud-connection-ui.js';
4648
import { MarketplaceInstalledPage } from './marketplace-ui.js';
@@ -56,18 +58,19 @@ const HERE = dirname(fileURLToPath(import.meta.url));
5658

5759
/**
5860
* Every page this package ships, audited by export name — with the unmapped
59-
* component types each page is EXPECTED to report (the exemptions above).
61+
* component types each page is EXPECTED to report (none since #11575; see
62+
* the module header).
6063
*/
6164
const AUDITED_PAGES: { exportName: string; page: Page; exemptUnmappedTypes: string[] }[] = [
6265
{
6366
exportName: 'CloudConnectionSettingsPage',
6467
page: CloudConnectionSettingsPage,
65-
exemptUnmappedTypes: ['cloud-connection:panel'],
68+
exemptUnmappedTypes: [],
6669
},
6770
{
6871
exportName: 'MarketplaceInstalledPage',
6972
page: MarketplaceInstalledPage,
70-
exemptUnmappedTypes: ['marketplace:installed-list'],
73+
exemptUnmappedTypes: [],
7174
},
7275
];
7376

@@ -170,27 +173,14 @@ describe('cloud-connection Page exports serve canonical expression envelopes', (
170173
});
171174

172175
it.each(AUDITS)('$exportName: unmapped component types are EXACTLY the recorded exemptions (door 3 precondition)', ({ audit, exemptUnmappedTypes }) => {
173-
// See the module header for why these two types are exempt. Anything else
176+
// No exemptions stand since #11575 (see the module header). Anything
174177
// unmapped is a new door-3 blind spot: declare the props schema in
175-
// `ComponentPropsMap`, or record the exemption here with the reason.
178+
// `ComponentPropsMap`, or record the exemption here with the reason —
179+
// and then also pin the exempted bags empty, as the pre-#11575 revision
180+
// of this file did.
176181
expect(audit.unmappedTypes.map(e => e.type).sort()).toEqual([...exemptUnmappedTypes].sort());
177182
});
178183

179-
it.each(AUDITS)('$exportName: every exempted component authors an EMPTY props bag', ({ page, exemptUnmappedTypes }) => {
180-
// The exemption above is only sound while there is nothing authored for
181-
// door 3 to miss. A real key landing in one of these bags must force a
182-
// decision (props schema row, or a conscious wider exemption) — not ride
183-
// through a standing exemption silently.
184-
const offenders = walkPageComponents(page as AnyRec, '')
185-
.filter(w => typeof w.component.type === 'string' && exemptUnmappedTypes.includes(w.component.type))
186-
.filter(w => {
187-
const props = w.component.properties;
188-
return !!props && typeof props === 'object' && Object.keys(props).length > 0;
189-
})
190-
.map(w => `${w.path} [${String(w.component.type)}]`);
191-
expect(offenders.join('\n')).toBe('');
192-
});
193-
194184
it.each(AUDITS)('$exportName: every authored `properties` bag parses against its props schema (door 3 precondition)', ({ audit }) => {
195185
expect(
196186
audit.unreadableProps.map(e => `${e.path} [${e.type}]: ${e.issues}`).join('\n'),

packages/lint/src/validate-component-props.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -775,3 +775,57 @@ describe('validateComponentProps — record:alert / record:quick_actions / recor
775775
expect(findings[0].message).toContain('`dock`');
776776
});
777777
});
778+
779+
/**
780+
* #11575 — the two `@objectstack/cloud-connection` console widgets, so the
781+
* gate's dispatch reaches them.
782+
*
783+
* The pre-fix state these pin against: `cloud-connection:panel` and
784+
* `marketplace:installed-list` had no `ComponentPropsMap` row (they are
785+
* open-string-arm types — never in `PageComponentType` — so nothing else
786+
* judged them either), and the walker's unregistered-type skip swallowed the
787+
* whole props bag: any authored key produced ZERO findings from
788+
* validate/build. Their rows are strict and EMPTY — measured from the
789+
* renderers' read points at the `.objectui-sha` pin, where both registrations
790+
* discard the schema node (`() => <Widget />`) — so EVERY authored key is a
791+
* finding. Remove either map row and its loud test here goes back to that
792+
* silence.
793+
*/
794+
describe('validateComponentProps — cloud-connection:panel / marketplace:installed-list are dispatched (#11575)', () => {
795+
it('reports any key authored on `cloud-connection:panel`, naming the zero-prop surface', () => {
796+
const findings = validateComponentProps(
797+
stackWith([{
798+
type: 'cloud-connection:panel',
799+
properties: { pollInterval: 5 },
800+
}]),
801+
);
802+
expect(findings).toHaveLength(1);
803+
expect(findings[0].rule).toBe(COMPONENT_PROPS_UNKNOWN_KEY);
804+
expect(findings[0].where).toBe('page "probe_page" · cloud-connection:panel');
805+
expect(findings[0].message).toContain('`pollInterval`');
806+
expect(findings[0].message).toContain('cloud-connection:panel');
807+
});
808+
809+
it('reports any key authored on `marketplace:installed-list` the same way', () => {
810+
const findings = validateComponentProps(
811+
stackWith([{
812+
type: 'marketplace:installed-list',
813+
properties: { filter: 'installed' },
814+
}]),
815+
);
816+
expect(findings).toHaveLength(1);
817+
expect(findings[0].rule).toBe(COMPONENT_PROPS_UNKNOWN_KEY);
818+
expect(findings[0].where).toBe('page "probe_page" · marketplace:installed-list');
819+
expect(findings[0].message).toContain('`filter`');
820+
});
821+
822+
it('stays silent on the empty bag both plugin-shipped pages author', () => {
823+
const findings = validateComponentProps(
824+
stackWith([
825+
{ type: 'cloud-connection:panel', properties: {} },
826+
{ type: 'marketplace:installed-list', properties: {} },
827+
]),
828+
);
829+
expect(findings).toEqual([]);
830+
});
831+
});
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { SemanticMigration } from '../../types.js';
4+
5+
export const entry: SemanticMigration = {
6+
id: 'ui-cloud-connection-widgets-unknown-keys-refused',
7+
surface: 'page `cloud-connection:panel` / `marketplace:installed-list` components — '
8+
+ '`properties` (any key at all: both widgets declare no props)',
9+
replacement: 'an empty `properties` bag (`{}`), or omit `properties` entirely. Neither '
10+
+ 'widget reads any prop: the console registrations discard the schema node '
11+
+ '(`() => <Widget />`) and the components take no arguments, so there is no declared '
12+
+ 'key to move to — a key authored on either widget configures nothing and is removed, '
13+
+ 'not renamed. Node-level keys (`visibleWhen`, `id`, `style`, …) stay on the component '
14+
+ 'node, where the page runtime reads them.',
15+
reason:
16+
'These were two more instances of the #8691/#8744 class: console-registered widgets on '
17+
+ '`@objectstack/cloud-connection`\'s published Setup pages, reachable through the '
18+
+ 'component type union\'s open string arm, with registered renderers but no '
19+
+ '`ComponentPropsMap` row — so the #5068 props gate\'s dispatch skipped them as '
20+
+ 'unregistered and any authored key rode through every validator in silence. The new '
21+
+ 'rows are strict and EMPTY, measured from the renderers\' actual read points at the '
22+
+ 'objectui pin (not from the registrations\' declared-input lists): both registrations '
23+
+ 'ignore the component node entirely, so the widgets accept no configuration at all, '
24+
+ 'and an authored key is now a publish-time refusal naming the surface instead of a '
25+
+ 'silent no-op.',
26+
acceptanceCriteria:
27+
'Every `cloud-connection:panel` / `marketplace:installed-list` node authors an empty '
28+
+ '(or absent) `properties` bag and validates clean — the two plugin-shipped pages '
29+
+ '(`cloud_connection_settings`, `marketplace_installed`) already do; `objectstack '
30+
+ 'validate` reports no `component-props-unknown-key` finding for these types. Any '
31+
+ 'remaining authored key on either widget is deleted (it never configured anything), '
32+
+ 'and behaviour that seems to need one is a renderer capability request against '
33+
+ 'objectui, not a metadata key.',
34+
};

packages/spec/src/migrations/registry.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6190,6 +6190,36 @@ const step18: MigrationStep = {
61906190
+ 'of an envelope-level code; constructing an ApiError with a retired spelling '
61916191
+ 'fails `StandardErrorCode`/`ApiErrorSchema` parse rather than passing silently.',
61926192
},
6193+
{
6194+
id: 'ui-cloud-connection-widgets-unknown-keys-refused',
6195+
surface: 'page `cloud-connection:panel` / `marketplace:installed-list` components — '
6196+
+ '`properties` (any key at all: both widgets declare no props)',
6197+
replacement: 'an empty `properties` bag (`{}`), or omit `properties` entirely. Neither '
6198+
+ 'widget reads any prop: the console registrations discard the schema node '
6199+
+ '(`() => <Widget />`) and the components take no arguments, so there is no declared '
6200+
+ 'key to move to — a key authored on either widget configures nothing and is removed, '
6201+
+ 'not renamed. Node-level keys (`visibleWhen`, `id`, `style`, …) stay on the component '
6202+
+ 'node, where the page runtime reads them.',
6203+
reason:
6204+
'These were two more instances of the #8691/#8744 class: console-registered widgets on '
6205+
+ '`@objectstack/cloud-connection`\'s published Setup pages, reachable through the '
6206+
+ 'component type union\'s open string arm, with registered renderers but no '
6207+
+ '`ComponentPropsMap` row — so the #5068 props gate\'s dispatch skipped them as '
6208+
+ 'unregistered and any authored key rode through every validator in silence. The new '
6209+
+ 'rows are strict and EMPTY, measured from the renderers\' actual read points at the '
6210+
+ 'objectui pin (not from the registrations\' declared-input lists): both registrations '
6211+
+ 'ignore the component node entirely, so the widgets accept no configuration at all, '
6212+
+ 'and an authored key is now a publish-time refusal naming the surface instead of a '
6213+
+ 'silent no-op.',
6214+
acceptanceCriteria:
6215+
'Every `cloud-connection:panel` / `marketplace:installed-list` node authors an empty '
6216+
+ '(or absent) `properties` bag and validates clean — the two plugin-shipped pages '
6217+
+ '(`cloud_connection_settings`, `marketplace_installed`) already do; `objectstack '
6218+
+ 'validate` reports no `component-props-unknown-key` finding for these types. Any '
6219+
+ 'remaining authored key on either widget is deleted (it never configured anything), '
6220+
+ 'and behaviour that seems to need one is a renderer capability request against '
6221+
+ 'objectui, not a metadata key.',
6222+
},
61936223
{
61946224
id: 'ui-record-blocks-unknown-keys-refused',
61956225
surface: 'page `record:alert` / `record:quick_actions` / `record:history` / '

packages/spec/src/ui/component.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -885,6 +885,43 @@ describe('ComponentPropsMap', () => {
885885
expect(() => ComponentPropsMap['global:search'].parse({})).not.toThrow();
886886
expect(() => ComponentPropsMap['user:profile'].parse({})).not.toThrow();
887887
});
888+
889+
// #11575 — the two `@objectstack/cloud-connection` console widgets. Rows
890+
// exist so the #5068 gate's dispatch reaches them; the accepted key set is
891+
// EMPTY, measured from the renderers' read points at the `.objectui-sha`
892+
// pin (both registrations discard the schema node — `() => <Widget />`).
893+
describe('plugin console widgets (#11575)', () => {
894+
it('declares rows for cloud-connection:panel and marketplace:installed-list', () => {
895+
expect(ComponentPropsMap['cloud-connection:panel']).toBeDefined();
896+
expect(ComponentPropsMap['marketplace:installed-list']).toBeDefined();
897+
});
898+
899+
it('accepts the empty bag both shipped pages author', () => {
900+
expect(() => ComponentPropsMap['cloud-connection:panel'].parse({})).not.toThrow();
901+
expect(() => ComponentPropsMap['marketplace:installed-list'].parse({})).not.toThrow();
902+
});
903+
904+
it('refuses any authored key, naming the surface — the pre-row silent no-op', () => {
905+
// Before the rows, both keys below rode through every validator in
906+
// silence (the widgets read nothing). The refusal must name WHICH
907+
// zero-prop component refused, or the author is left guessing.
908+
const panel = ComponentPropsMap['cloud-connection:panel'].safeParse({ pollInterval: 5 });
909+
expect(panel.success).toBe(false);
910+
if (!panel.success) {
911+
const message = panel.error.issues.map((i) => i.message).join('\n');
912+
expect(message).toContain('cloud-connection:panel');
913+
expect(message).toContain('pollInterval');
914+
}
915+
916+
const list = ComponentPropsMap['marketplace:installed-list'].safeParse({ filter: 'installed' });
917+
expect(list.success).toBe(false);
918+
if (!list.success) {
919+
const message = list.error.issues.map((i) => i.message).join('\n');
920+
expect(message).toContain('marketplace:installed-list');
921+
expect(message).toContain('filter');
922+
}
923+
});
924+
});
888925
});
889926

890927
// ---------------------------------------------------------------------------

packages/spec/src/ui/component.zod.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -316,12 +316,13 @@ const COMPONENT_LEVEL_GUIDANCE: readonly KeySetGuidance[] = [
316316
/**
317317
* A component that declares no props at all — `app:launcher`, `nav:menu`,
318318
* `nav:breadcrumb`, `global:search`, `global:notifications`, `user:profile`,
319-
* `element:divider`.
319+
* `element:divider`, and the two plugin console widgets `cloud-connection:panel`
320+
* and `marketplace:installed-list` (#11575).
320321
*
321322
* A factory rather than one shared `EmptyProps` const, because the surface name
322323
* is the whole value of the rejection here: an empty shape has no candidate
323324
* keys, so the edit-distance fallback can say nothing, and "unrecognized key on
324-
* this component" would leave the author guessing which of the seven it meant.
325+
* this component" would leave the author guessing which of the nine it meant.
325326
* One `strictObject(` call site either way — the ledger counts sites from the
326327
* AST, and this is one.
327328
*
@@ -2574,7 +2575,24 @@ export const ComponentPropsMap = {
25742575
'global:search': emptyProps('global:search'),
25752576
'global:notifications': emptyProps('global:notifications'),
25762577
'user:profile': emptyProps('user:profile'),
2577-
2578+
2579+
// Plugin console widgets — #11575, the #8691/#8744 mechanism two instances
2580+
// over, on `@objectstack/cloud-connection`'s published Setup pages: both
2581+
// types are console-registered renderers reachable only through the type
2582+
// union's open string arm, and with no row here the #5068 gate's dispatch
2583+
// skipped them as unregistered — any authored key would have ridden through
2584+
// in silence (nothing authors one today: both shipped pages carry `{}`).
2585+
// Key sets measured from the renderers' ACTUAL read points at the
2586+
// `.objectui-sha` pin (app-shell `console/cloud-connection/
2587+
// CloudConnectionPanel.tsx`, `console/marketplace/InstalledListWidget.tsx`):
2588+
// both registrations discard the schema node entirely (`() => <Widget />`)
2589+
// and neither component function takes a prop, so the accepted key set is
2590+
// EMPTY — strict, refuses every key. The registrations' declared
2591+
// `inputs: []` happen to agree here, but the row is the measurement, not
2592+
// the claim (#8691/#8744 record where those diverge).
2593+
'cloud-connection:panel': emptyProps('cloud-connection:panel'),
2594+
'marketplace:installed-list': emptyProps('marketplace:installed-list'),
2595+
25782596
// AI
25792597
'ai:chat_window': AIChatWindowProps,
25802598
'ai:suggestion': strictObject({

0 commit comments

Comments
 (0)