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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions .changeset/user-profile-not-placeable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
---
"@objectstack/spec": minor
---

feat(spec): `user:profile` is explicitly not author-placeable — refused by name at the schema door (#14159, ADR-0049)

<!-- adr-0087: not-required (no-migration-prescription) The retired row declared zero keys, so there is no key to strip and no old shape to rewrite into a new one; the only edit an author can make is deleting the `user:profile` node, which the conversion layer deliberately does not do (the `element-filter-removed` docblock rules deleting authored page nodes out of a mechanical conversion — it is a layout decision, not a rewrite); and a whole-repo sweep measured zero authored instances across examples, packages, docs and stored metadata (Studio's palette has excluded the type since objectui 5e8965c). Nothing mechanical to prescribe and nothing to prescribe it to — the #7596 disposition, one surface over. -->

**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep
launch-window convention ships it as `minor`, as the `element:filter` /
`element:form` element-grain retirements and the `targetVariable` key
retirement did; the 17.0.0 cut itself carried the `group` / `guest` recipient
removal and the `$in` / `$nin` / `$between` reference-position removal under
`major` because that release was the major).

`user:profile` was declared in `PageComponentType` and carried an empty
`ComponentPropsMap` row, and no renderer for it ever existed anywhere — not in
objectui, framework or cloud (objectui#7135 measured the zero with a positive
control in the same query shape; only the opt-in `PlaceholderRenderer`
scaffold, which `apps/console` alone registers, ever drew it). objectstack#12183
closed the same gap for its four siblings with renderers because real pages
used them; this member had zero measured pull, and the director ruling of
2026-09-01 (maintainer verbatim 「同意」) chose option B of that ask: a user
profile is shell chrome — the signed-in user's avatar menu — which no mainstream
product makes a page-placeable component, so the honest declaration is that it
cannot be placed, not a renderer nobody asked for. Until now an authored
`user:profile` node validated clean and drew `SchemaRenderer`'s red
unknown-type panel in front of an end user in every host except the console
(the ADR-0078 shape).

**What is refused:** an authored `user:profile` component node — at
`PageComponentSchema.type` (so `definePage()`, `PageSchema`, and every stack
door that parses pages: `os validate`, `os build`, `os lint`, the metadata
door), with a located issue (`code: 'custom'`, `params.retiredComponentType`,
the node's own path) whose message is the retirement prescription. The name is
also refused by `PageComponentType`'s own error map when the enum is parsed
alone, and `ComponentPropsMap['user:profile']` refuses every props bag —
`{}` included — with the same prescription for the readers that dispatch on
the row (the #5068 props gate, `check-yaml-examples`, the type vocabulary's
known set). One prescription string (`RETIRED_PAGE_COMPONENT_TYPES`,
`@objectstack/spec/ui`), three doors.

**What stays accepted:** every other member of `PageComponentType` and
`ComponentPropsMap`, byte-identically — `global:search` and
`global:notifications` (the two shipped shell singletons), `app:launcher`,
`nav:menu`, `nav:breadcrumb`, and the whole open string arm: custom and
plugin-registered types keep parsing, stored documents keep loading. `user:`
is no longer a namespace the enum populates, so the `component-type-unknown`
authoring rule no longer claims it; the only string refused is the retired
name itself.

**Fix:** delete the `user:profile` component node. There is nothing to write in
its place — the app shell renders the profile menu on every page without an
authored element. A future application that needs a page-placeable profile
component files a feature card with its consumer; the refusal then flips
additively (option A of the same ask). objectui's `PALETTE_EXCLUSIONS` entry is
unchanged (correct either way — the ruling's point 3).

The retirement kit:

- the retired-type map + enum error map + node-level check
(`packages/spec/src/ui/page.zod.ts`), the refusing row
(`packages/spec/src/ui/component.zod.ts`)
- pin tests (`component.test.ts` — `code` + `path` + first sentence at each of
the three doors, positive controls, preservation of the other shell
singletons, the open arm; `component-type-vocabulary.test.ts` — `user:`
leaves the reserved-namespace derivation)
- generated baselines/docs follow the schema (`authorable-surface/`,
`json-schema.manifest/`, `api-surface/`, reference docs); the hand-written
`content/docs/ui/pages.mdx` component list says the truth
- no ADR-0087 conversion, by the disposition above: the row had no keys to
strip, and a mechanical conversion does not delete authored page nodes.
5 changes: 2 additions & 3 deletions content/docs/references/ui/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,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' \| 'app:launcher' \| 'nav:menu' \| 'nav:breadcrumb' \| 'global:search' \| 'global:notifications' \| 'user:profile' \| 'ai:chat_window' \| '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`). |
| **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' \| 'app:launcher' \| 'nav:menu' \| 'nav:breadcrumb' \| 'global:search' \| 'global:notifications' \| 'ai:chat_window' \| '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) is refused at the parse itself, with the retirement prescription. |
| **id** | `string` | optional | Unique instance ID |
| **label** | `string \| Record<string, string>` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time |
| **properties** | `Record<string, any>` | optional (default: `{}`) | Component props passed to the widget. See component.zod.ts for schemas. |
Expand Down Expand Up @@ -306,7 +306,6 @@ View filter rule
* `nav:breadcrumb`
* `global:search`
* `global:notifications`
* `user:profile`
* `ai:chat_window`
* `ai:suggestion`
* `element:text`
Expand Down Expand Up @@ -334,7 +333,7 @@ View filter rule

| 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`). |
| **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) is refused at the parse itself, with the retirement prescription. |
| **id** | `string` | optional | Unique instance ID |
| **label** | `string \| Record<string, string>` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time |
| **properties** | `Record<string, any>` | optional (default: `{}`) | Component props passed to the widget. See component.zod.ts for schemas. |
Expand Down
2 changes: 1 addition & 1 deletion content/docs/ui/pages.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ The `type` field is a union of the standard `PageComponentType` enum and any cus
- **Structure:** `page:header`, `page:footer`, `page:sidebar`, `page:tabs`, `page:accordion`, `page:card`, `page:section`
- **Record context:** `record:details`, `record:highlights`, `record:related_list`, `record:activity`, `record:chatter`, `record:path`, `record:alert`, `record:quick_actions`, `record:reference_rail`, `record:history` — each renders from the record context a **record page** mounts, so they belong on a `type:'record'` page. A `kind:'react'` page mounts no such context and `os validate` rejects them there (see Validating metadata §10b)
- **Navigation:** `app:launcher`, `nav:menu`, `nav:breadcrumb`
- **Utility:** `global:search`, `global:notifications`, `user:profile`
- **Utility:** `global:search`, `global:notifications` — `user:profile` is **not** author-placeable: it is shell chrome (the signed-in user's avatar menu, which the app shell renders itself on every page), no renderer exists for it by ruling (objectstack#14159 / objectui#7135), and an authored `user:profile` node is refused at the schema door — `definePage()`, `os validate`, `os build` — with that prescription at the node's path instead of drawing an unknown-type panel in front of a user
- **AI:** `ai:chat_window`, `ai:suggestion`
- **Elements:** `element:text`, `element:number`, `element:image`, `element:divider`, `element:button`, `element:record_picker`, `element:text_input` (`element:filter` and `element:form` were retired in v17.x — no renderer ever shipped for either. List surfaces own their filtering via a view's `userFilters` quick-filter bar or the list toolbar's filter builder; for forms use the object-bound `object-form` block, which is rendered and designer-publishable)

Expand Down
1 change: 1 addition & 0 deletions packages/spec/api-surface/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@
"RECORD_CONTEXT_BLOCK_TAGS (const)",
"RECORD_CONTEXT_TYPE_PREFIX (const)",
"RESERVED_COMPONENT_TYPE_NAMESPACES (const)",
"RETIRED_PAGE_COMPONENT_TYPES (const)",
"ReactBlockDef (interface)",
"ReactInteractionProp (interface)",
"ReactPropKind (type)",
Expand Down
1 change: 1 addition & 0 deletions packages/spec/export-origins/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@
"RECORD_CONTEXT_BLOCK_TAGS": "src/ui/react-blocks.ts#RECORD_CONTEXT_BLOCK_TAGS (const)",
"RECORD_CONTEXT_TYPE_PREFIX": "src/ui/react-blocks.ts#RECORD_CONTEXT_TYPE_PREFIX (const)",
"RESERVED_COMPONENT_TYPE_NAMESPACES": "src/ui/component-type-vocabulary.ts#RESERVED_COMPONENT_TYPE_NAMESPACES (const)",
"RETIRED_PAGE_COMPONENT_TYPES": "src/ui/page.zod.ts#RETIRED_PAGE_COMPONENT_TYPES (const)",
"ReactBlockDef": "src/ui/react-blocks.ts#ReactBlockDef (interface)",
"ReactInteractionProp": "src/ui/react-blocks.ts#ReactInteractionProp (interface)",
"ReactPropKind": "src/ui/react-blocks.ts#ReactPropKind (type)",
Expand Down
20 changes: 19 additions & 1 deletion packages/spec/src/ui/component-type-vocabulary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,12 @@ describe('RESERVED_COMPONENT_TYPE_NAMESPACES is derived from the enum', () => {
// A new namespace appearing here is a NEW VOCABULARY CLAIM — the
// `component-type-unknown` rule starts refusing undeclared strings under
// it the day the enum member lands. Update this list consciously.
// `user` LEFT this list at #14159: `user:profile` was the namespace's only
// member and is retired by name (refused at the parse through
// `RETIRED_PAGE_COMPONENT_TYPES`), so the enum no longer populates `user:`
// and the `component-type-unknown` rule no longer claims it.
expect([...RESERVED_COMPONENT_TYPE_NAMESPACES].sort()).toEqual([
'ai', 'app', 'element', 'global', 'nav', 'page', 'record', 'user',
'ai', 'app', 'element', 'global', 'nav', 'page', 'record',
]);
});

Expand Down Expand Up @@ -66,6 +70,20 @@ describe('KNOWN_COMPONENT_TYPES covers every declared face', () => {
expect(isKnownComponentType('global:search')).toBe(true);
expect(isKnownComponentType('global:notifications')).toBe(true);
});

/**
* #14159 (ruling B): `user:profile` is retired BY NAME — out of the enum, so
* `user:` is no longer a reserved namespace, but still KNOWN through its kept
* `ComponentPropsMap` row (the `element:filter` / `element:form` shape), so
* every reader that dispatches on the row keeps recognising the name. What
* refuses it is the schema door (`PageComponentSchema.type`), not this
* vocabulary — pinned in `component.test.ts`.
*/
it('user:profile is known through its kept row and refused by name at the parse (#14159)', () => {
expect(PageComponentType.options).not.toContain('user:profile');
expect(hasReservedComponentNamespace('user:profile')).toBe(false);
expect(isKnownComponentType('user:profile')).toBe(true);
});
});

describe('STRING_ARM_REGISTERED_TYPES ledger discipline', () => {
Expand Down
Loading
Loading