From fa6b606a84ecf9831ad49d3b9be6d61f2252dd34 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 22:54:38 +0000 Subject: [PATCH 1/3] wip(spec): retire user:profile by name at the schema door (#14159) Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i --- .changeset/user-profile-not-placeable.md | 72 ++++++++++ content/docs/ui/pages.mdx | 2 +- .../src/ui/component-type-vocabulary.test.ts | 20 ++- packages/spec/src/ui/component.test.ts | 124 +++++++++++++++++- packages/spec/src/ui/component.zod.ts | 49 ++++++- packages/spec/src/ui/page.zod.ts | 86 +++++++++++- 6 files changed, 341 insertions(+), 12 deletions(-) create mode 100644 .changeset/user-profile-not-placeable.md diff --git a/.changeset/user-profile-not-placeable.md b/.changeset/user-profile-not-placeable.md new file mode 100644 index 00000000000..5c3b2aef25b --- /dev/null +++ b/.changeset/user-profile-not-placeable.md @@ -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) + + + +**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. diff --git a/content/docs/ui/pages.mdx b/content/docs/ui/pages.mdx index 033cc74744d..047cc263923 100644 --- a/content/docs/ui/pages.mdx +++ b/content/docs/ui/pages.mdx @@ -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) diff --git a/packages/spec/src/ui/component-type-vocabulary.test.ts b/packages/spec/src/ui/component-type-vocabulary.test.ts index d56d3be07cd..fffc3eb96d1 100644 --- a/packages/spec/src/ui/component-type-vocabulary.test.ts +++ b/packages/spec/src/ui/component-type-vocabulary.test.ts @@ -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', ]); }); @@ -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', () => { diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index 25c8b84fe1e..23fd3caad36 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -22,7 +22,7 @@ import { ElementTextInputPropsSchema, ObjectMetricPropsSchema, } from './component.zod'; -import { PageComponentSchema, PageSchema, ElementDataSourceSchema } from './page.zod'; +import { PageComponentSchema, PageSchema, PageComponentType, ElementDataSourceSchema } from './page.zod'; describe('PageHeaderProps', () => { it('should accept minimal header', () => { @@ -1022,7 +1022,123 @@ describe('ComponentPropsMap', () => { it('should parse empty props schemas for utility components', () => { expect(() => ComponentPropsMap['page:footer'].parse({})).not.toThrow(); expect(() => ComponentPropsMap['global:search'].parse({})).not.toThrow(); - expect(() => ComponentPropsMap['user:profile'].parse({})).not.toThrow(); + // `user:profile` LEFT this pin at #14159 — see the describe below: it is + // not author-placeable, so its row refuses even the empty bag. + }); + + // #14159 — ruling B (director seat 2026-09-01, batch #26, maintainer verbatim + // 「同意」): `user:profile` is shell chrome (the avatar menu), not an + // author-placeable element, and no renderer is built for it (objectui#7135 + // measured the zero with a positive control). The member is refused BY NAME + // with one located prescription at every door; `code` + `path` + the first + // sentence are the pin — never a bare `toThrow()`, which greens on any error. + describe('user:profile is not author-placeable (#14159, ruling B)', () => { + const FIRST_SENTENCE = + /^`user:profile` is not a page-placeable element — it is shell chrome \(the signed-in user's avatar menu, which the app shell renders itself on every page\), no renderer for it exists anywhere by ruling \(objectstack#14159, objectui#7135\), and there is nothing to put in its place: delete the component node and let the shell render the profile\./; + + it('the ComponentPropsMap row refuses even the empty bag — the flipped accept pin', () => { + const r = ComponentPropsMap['user:profile'].safeParse({}); + expect(r.success).toBe(false); + if (r.success) return; + 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).toMatch(FIRST_SENTENCE); + // A populated bag gets the same prescription, not an unknown-key verdict. + const populated = ComponentPropsMap['user:profile'].safeParse({ showAvatar: true }); + expect(populated.success).toBe(false); + if (!populated.success) { + expect(populated.error.issues[0]!.code).toBe('invalid_type'); + expect(populated.error.issues[0]!.message).toMatch(FIRST_SENTENCE); + } + }); + + it('PageComponentSchema refuses the node by name at `type` — the door the open string arm used to defeat', () => { + const r = PageComponentSchema.safeParse({ type: 'user:profile' }); + expect(r.success).toBe(false); + if (r.success) return; + 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: 'user:profile' }); + }); + + it('PageSchema refuses an authored page at the element path — the door `os validate` parses', () => { + const r = PageSchema.safeParse({ + name: 'home', + label: 'Home', + regions: [{ + name: 'main', + components: [ + { type: 'page:header', properties: { title: 'Home' } }, + { type: 'user:profile' }, + ], + }], + }); + 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); + + // A slot-mounted node goes through the same node schema. + const slotted = PageSchema.safeParse({ + name: 'home', + label: 'Home', + regions: [], + slots: { header: { type: 'user:profile' } }, + }); + expect(slotted.success).toBe(false); + if (!slotted.success) { + const atSlot = slotted.error.issues.filter((i) => i.code === 'custom'); + expect(atSlot).toHaveLength(1); + expect(atSlot[0]!.path).toEqual(['slots', 'header', 'type']); + expect(atSlot[0]!.message).toMatch(FIRST_SENTENCE); + } + }); + + it('PageComponentType itself refuses the member with the prescription — the enum error map', () => { + expect(PageComponentType.options).not.toContain('user:profile'); + const r = PageComponentType.safeParse('user:profile'); + expect(r.success).toBe(false); + if (r.success) return; + expect(r.error.issues[0]!.code).toBe('invalid_value'); + expect(r.error.issues[0]!.message).toMatch(FIRST_SENTENCE); + // Only a value that USED to be legal gets the prescription — a stranger + // keeps zod's own enum message. + const stranger = PageComponentType.safeParse('user:avatar'); + expect(stranger.success).toBe(false); + if (!stranger.success) expect(stranger.error.issues[0]!.message).not.toContain('shell chrome'); + }); + + it('positive control: the two shipped shell singletons still parse at every door', () => { + for (const type of ['global:search', 'global:notifications'] as const) { + expect(() => ComponentPropsMap[type].parse({})).not.toThrow(); + expect(PageComponentSchema.safeParse({ type }).success, type).toBe(true); + expect(PageComponentType.safeParse(type).success, type).toBe(true); + } + }); + + it('preservation: the other three shell singletons are unchanged', () => { + for (const type of ['app:launcher', 'nav:menu', 'nav:breadcrumb'] as const) { + expect(() => ComponentPropsMap[type].parse({})).not.toThrow(); + expect(PageComponentSchema.safeParse({ type }).success, type).toBe(true); + expect(PageComponentType.safeParse(type).success, type).toBe(true); + const r = ComponentPropsMap[type].safeParse({ zzUndeclared: 1 }); + expect(r.success, type).toBe(false); + if (!r.success) expect(r.error.issues.map((i) => i.message).join('\n')).toContain(type); + } + }); + + it('the open string arm stays open — only the retired NAME is refused', () => { + for (const type of ['custom.widget', 'mcp:connect-agent', 'object-grid', 'user:avatar']) { + expect(PageComponentSchema.safeParse({ type }).success, type).toBe(true); + } + }); }); // #11575 — the two `@objectstack/cloud-connection` console widgets. Rows @@ -2165,6 +2281,10 @@ describe('批 17 / #5068 — the carrier stays an open bag; the gate is on the l // zod records an unknown-key policy on the object def; `.strict()` sets a // `never` catchall. Anything else means the site is still open. if (def.catchall?._zod?.def?.type === 'never') continue; + // #14159 — a row retired at element grain is `z.never` itself: no shape, + // no catchall, refuses every bag including `{}`. Closed by construction; + // the positive control below still exercises it through the parse. + if (def.type === 'never') continue; stillOpen.push(type); } expect(stillOpen).toEqual([]); diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 6023830825e..14d8bb446dc 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -9,6 +9,10 @@ import { FeedItemType, FeedFilterMode } from '../data/feed.zod'; import { lazySchema } from '../shared/lazy-schema'; import { ExpressionInputSchema } from '../shared/expression.zod'; import { retiredKey } from '../shared/retired-key'; +// `user:profile`'s retirement prescription — one string, three doors (#14159): +// the enum's error map and the `PageComponentSchema.type` check in page.zod.ts, +// and the kept `ComponentPropsMap` row below (`retiredComponentProps`). +import { RETIRED_PAGE_COMPONENT_TYPES } from './page.zod'; // `element:record_picker`'s flat `sort` shorthand is the SAME contract as // `ElementDataSourceSchema.sort` (page.zod.ts) — one shape, imported from the // shared source rather than re-spelled here (#6276). @@ -318,15 +322,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`, `user:profile`, + * `nav:breadcrumb`, `global:search`, `global:notifications`, * `element:divider`, and the three plugin console widgets * `cloud-connection:panel` and `marketplace:installed-list` (#11575) and - * `mcp:connect-agent` (#12344). + * `mcp:connect-agent` (#12344). `user:profile` left this list at #14159 — it + * is not author-placeable at all, so its row refuses the whole bag + * ({@link retiredComponentProps}). * * 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 ten 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. * @@ -345,6 +351,33 @@ const emptyProps = (type: string) => {}, ); +/** + * A component RETIRED at element grain whose props bag is refused WHOLE — + * `user:profile` (#14159). 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 + * exactly like a populated bag, `expected: 'never'` / `code: 'invalid_type'` is + * the same issue shape a key tombstone raises, and the message is the element's + * retirement prescription from `RETIRED_PAGE_COMPONENT_TYPES` (page.zod.ts), so + * the row and the node-level refusal on `PageComponentSchema.type` cannot drift + * apart. A type that map does not name has no business here — the throw makes + * a row without its prescription a module-load error, not a silent `undefined` + * message. + * + * Why not delete the row: `component-type-vocabulary.ts` derives the KNOWN set + * from the row keys, the #5068 props gate skips a type with no row as an + * unregistered custom string, and `check-yaml-examples` judges only rowed types + * — deleting the row would demote a loud retirement to a silent skip on every + * reader that dispatches on it (the `element:filter` argument, #9220). + */ +const retiredComponentProps = (type: string) => { + const guidance = RETIRED_PAGE_COMPONENT_TYPES.get(type); + if (!guidance) { + throw new Error(`retiredComponentProps: \`${type}\` has no RETIRED_PAGE_COMPONENT_TYPES entry (page.zod.ts)`); + } + return z.never({ error: () => guidance }).describe(`[REMOVED] ${guidance}`); +}; + /** * The composition slot every thin container renders: `page:section`, * `page:footer`, `page:sidebar`. @@ -2859,7 +2892,15 @@ export const ComponentPropsMap = { // Utility 'global:search': emptyProps('global:search'), 'global:notifications': emptyProps('global:notifications'), - 'user:profile': emptyProps('user:profile'), + // RETIRED at element grain (#14159, ruling B: not author-placeable) — the + // row STAYS, as for `element:filter` / `element:form` below, so every reader + // that dispatches on the row (the #5068 props gate, `check-yaml-examples`, + // the vocabulary's known set) keeps recognising the name and refuses it with + // the prescription instead of skipping it as an unregistered custom string. + // Unlike those two, the WHOLE bag is refused — `{}` included — because the + // node itself is refused by name at `PageComponentSchema.type`; a row that + // accepted the empty bag would contradict the door one level up. + 'user:profile': retiredComponentProps('user:profile'), // Plugin console widgets — #11575, the #8691/#8744 mechanism two instances // over, on `@objectstack/cloud-connection`'s published Setup pages: both diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index bd5bf6c2b7f..0f6e88ee89c 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -49,8 +49,60 @@ export const PageRegionSchema = lazySchema(() => strictObject({ components: z.array(z.lazy(() => PageComponentSchema)).describe('Components in this region') })); +// Page-component TYPES retired by name → the prescription an author who still +// writes one receives (#14159; director ruling 2026-09-01, batch #26, maintainer +// verbatim 「同意」: option B — `user:profile` is explicitly NOT author-placeable, +// and no renderer is built). Declared with `//` on purpose — the +// `CHATTER_POSITION_RETIRED` placement note in component.zod.ts applies: +// build-docs takes a file's first JSDoc per exported symbol, and this needs no +// doc page. +// +// This is an enum-VALUE narrowing (the def survives, one member fewer), so there +// is no `retiredKey()` tombstone to hang the prescription on — the enum's own +// error map below carries it, keyed on `issue.input` so only a value that USED +// to be legal gets the "not author-placeable" message (the `record:chatter` +// `position` precedent, #8762). What the enum's error map cannot do on its own +// is reach the author: `PageComponentSchema.type` is +// `z.union([PageComponentType, z.string()])`, and the open string arm admits +// every string the enum refuses — the gap the `element:filter` / `element:form` +// retirements recorded as "a node-level refusal is not expressible here". It is +// expressible one level up, and this map is what makes it so: the union carries +// a check against it (see `PageComponentSchema.type`), so the name is refused +// with this prescription at the element's own path (`code: 'custom'`), and +// `ComponentPropsMap['user:profile']` (component.zod.ts) refuses the props bag +// with the same string for every reader that dispatches on the row. One +// prescription, three doors, no drift. +// +// Adding a member here is an accept-set narrowing (Clause ②) — a contract +// decision, never a convenience: an entry needs the ruling that retired the +// type, the measured zero-renderer finding, and the row + enum edits beside it. +export const RETIRED_PAGE_COMPONENT_TYPES: ReadonlyMap = new Map([ + ['user:profile', '`user:profile` is not a page-placeable element — it is shell chrome (the ' + + "signed-in user's avatar menu, which the app shell renders itself on every page), no " + + 'renderer for it exists anywhere by ruling (objectstack#14159, objectui#7135), and there ' + + 'is nothing to put in its place: delete the component node and let the shell render the ' + + 'profile. Until this refusal an authored `user:profile` node validated clean and drew the ' + + 'red unknown-type panel in front of an end user — the ADR-0078 shape its four sibling ' + + 'shell singletons closed with renderers (objectstack#12183); this one gets no renderer ' + + '(zero measured pull — a future consumer files a feature card, and the refusal flips ' + + 'additively). Removed from `PageComponentType` in @objectstack/spec 17 (ADR-0049 ' + + 'enforce-or-remove); the name stays refused here so the failure lands in front of the ' + + 'author, not the user.'], +]); + /** * Standard Page Component Types + * + * `user:profile` REMOVED (#14159, ADR-0049 enforce-or-remove — option B of the + * objectstack#12183 ask, ruled 2026-09-01): a user profile is shell chrome (the + * avatar menu), no mainstream product makes it a page-placeable component, and + * no renderer for it ever existed anywhere (objectui#7135 measured the zero + * with a positive control in the same query shape). Unlike the `element:filter` + * / `element:form` removals below, dropping the value is NOT de-advertisement + * only: the name is refused through {@link RETIRED_PAGE_COMPONENT_TYPES} — by + * this enum's error map, by the check on `PageComponentSchema.type` that the + * open string arm would otherwise defeat, and by the kept `ComponentPropsMap` + * row. */ export const PageComponentType = z.enum([ // Structure @@ -64,8 +116,9 @@ export const PageComponentType = z.enum([ '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', // Navigation 'app:launcher', 'nav:menu', 'nav:breadcrumb', - // Utility - 'global:search', 'global:notifications', 'user:profile', + // Utility — `user:profile` REMOVED (#14159): shell chrome, refused by name + // through `RETIRED_PAGE_COMPONENT_TYPES` above, not merely de-advertised. + 'global:search', 'global:notifications', // AI 'ai:chat_window', 'ai:suggestion', // Content Elements (Airtable Interface parity) @@ -80,7 +133,14 @@ export const PageComponentType = z.enum([ // — retired at element grain, same mechanism; the tombstones' prescription // names the live replacement, the object-bound `object-form` block (#7751). 'element:button', 'element:record_picker', 'element:text_input' -]); +], { + // Only a value that USED to be legal gets a retirement prescription; every + // other string keeps zod's own enum message (the `record:chatter` `position` + // precedent, #8762). Reaches an author only where the enum is parsed alone — + // `PageComponentSchema.type` carries the door the open string arm needs. + error: (issue) => + typeof issue.input === 'string' ? RETIRED_PAGE_COMPONENT_TYPES.get(issue.input) : undefined, +}); /** * Element Data Source Schema @@ -156,11 +216,29 @@ export const PageComponentSchema = lazySchema(() => strictObject({ * `component-type-vocabulary.ts`. The parse stays open so stored documents * keep loading; the refusal lands at the authoring doors, where the author * is still present to fix the string. + * + * ONE exception to the open arm, by NAME (#14159): a type the vocabulary + * RETIRED — `RETIRED_PAGE_COMPONENT_TYPES` — is refused at the parse itself, + * here, with its prescription at this node's path. The enum's own error map + * cannot deliver it through this door (the string arm admits whatever the + * enum refuses), which is the gap the `element:filter` / `element:form` + * retirements recorded as "a node-level refusal is not expressible here". */ type: z.union([ PageComponentType, z.string() - ]).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`).'), + ]).superRefine((type, ctx) => { + // #14159 — a type RETIRED BY NAME is refused at the node with the located + // prescription. Nothing else about the arm changes: every string the map + // does not name keeps parsing, stored documents keep loading, and the + // `component-type-unknown` rule keeps its namespace claim. `code: 'custom'` + // with the type in `params`, so a consumer can tell a retirement apart + // from a shape error without parsing the message. + const guidance = RETIRED_PAGE_COMPONENT_TYPES.get(type); + 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`, #14159 — shell chrome, not author-placeable) is refused at the parse itself, with the retirement prescription.'), id: z.string().optional().describe('Unique instance ID'), /** Configuration */ From 4c392c5d9f29c0534c1a1f52f0673f5810a387d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 23:08:05 +0000 Subject: [PATCH 2/3] wip(spec): user:profile pins green, generated artifacts regenerated (#14159) Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i --- content/docs/references/ui/page.mdx | 5 ++--- packages/spec/api-surface/ui.json | 1 + packages/spec/export-origins/ui.json | 1 + packages/spec/src/ui/component.test.ts | 20 +++++++++++++++++--- 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/content/docs/references/ui/page.mdx b/content/docs/references/ui/page.mdx index 8b2ac562917..78acc27784b 100644 --- a/content/docs/references/ui/page.mdx +++ b/content/docs/references/ui/page.mdx @@ -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`, #14159 — shell chrome, not author-placeable) 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. | @@ -306,7 +306,6 @@ View filter rule * `nav:breadcrumb` * `global:search` * `global:notifications` -* `user:profile` * `ai:chat_window` * `ai:suggestion` * `element:text` @@ -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`, #14159 — shell chrome, not author-placeable) 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/api-surface/ui.json b/packages/spec/api-surface/ui.json index bf6158c69cc..17a89889364 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -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)", diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index 46d29b2383b..acb6eaa3f72 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -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)", diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index 23fd3caad36..b261c96c065 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -1085,7 +1085,15 @@ describe('ComponentPropsMap', () => { expect(located[0]!.path).toEqual(['regions', 0, 'components', 1, 'type']); expect(located[0]!.message).toMatch(FIRST_SENTENCE); - // A slot-mounted node goes through the same node schema. + // A slot-mounted node goes through the same node schema. The slot is a + // `z.union([PageComponentSchema, z.array(PageComponentSchema)])`, and zod 4 + // reports a union whose every arm failed as ONE `invalid_union` at the + // slot's path with the arms' issues nested under `errors`, their paths + // RELATIVE to the union — a property of the slot union, not of this + // retirement (an unknown key on a slotted node arrives the same way; + // `formatZodIssue` renders the nested line with the absolute path, #4971 + // / #5341, and `describeIssue` in `@objectstack/lint` unpacks it, #5583). + // The located prescription must still be inside it, at the node's `type`. const slotted = PageSchema.safeParse({ name: 'home', label: 'Home', @@ -1094,9 +1102,15 @@ describe('ComponentPropsMap', () => { }); expect(slotted.success).toBe(false); if (!slotted.success) { - const atSlot = slotted.error.issues.filter((i) => i.code === 'custom'); + type Nested = { code: string; path: PropertyKey[]; message: string; errors?: Nested[][] }; + const outer = slotted.error.issues.filter((i) => i.code === 'invalid_union'); + expect(outer).toHaveLength(1); + expect(outer[0]!.path).toEqual(['slots', 'header']); + const flatten = (issues: Nested[]): Nested[] => + issues.flatMap((i) => [i, ...(i.errors ?? []).flatMap(flatten)]); + const atSlot = flatten(slotted.error.issues as Nested[]).filter((i) => i.code === 'custom'); expect(atSlot).toHaveLength(1); - expect(atSlot[0]!.path).toEqual(['slots', 'header', 'type']); + expect(atSlot[0]!.path).toEqual(['type']); expect(atSlot[0]!.message).toMatch(FIRST_SENTENCE); } }); From bf4716f283b410d95464e0bc20f6742080b9df08 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 23:36:04 +0000 Subject: [PATCH 3/3] =?UTF-8?q?feat(spec):=20user:profile=20is=20explicitl?= =?UTF-8?q?y=20not=20author-placeable=20=E2=80=94=20refused=20by=20name=20?= =?UTF-8?q?at=20the=20schema=20door=20(#14159)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ruling B (director seat 2026-09-01, maintainer verbatim 「同意」): a user profile is shell chrome (the avatar menu), no renderer is built, and an authored user:profile node is refused at the parse with a located prescription instead of drawing the unknown-type panel in front of a user. - page.zod.ts: RETIRED_PAGE_COMPONENT_TYPES (retired type -> prescription), consulted by PageComponentType's own error map and by a check on PageComponentSchema.type so the open string arm cannot re-admit the name - component.zod.ts: the ComponentPropsMap row stays and refuses every bag (z.never, the retiredKey channel at element grain) - pins: code + path + first sentence at each door, controls, preservation - generated artifacts regenerated; content/docs/ui/pages.mdx says the truth - changeset: @objectstack/spec minor (launch-window convention), ADR-0087 not-required (no-migration-prescription), the #7596 disposition Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i --- content/docs/references/ui/page.mdx | 4 ++-- packages/spec/src/ui/component.test.ts | 8 +++++++- packages/spec/src/ui/page.zod.ts | 18 ++++++++++++------ 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/content/docs/references/ui/page.mdx b/content/docs/references/ui/page.mdx index 78acc27784b..738a2717867 100644 --- a/content/docs/references/ui/page.mdx +++ b/content/docs/references/ui/page.mdx @@ -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' \| '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`, #14159 — shell chrome, not author-placeable) 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' \| '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` | 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. | @@ -333,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`); a type the vocabulary RETIRED by name (`user:profile`, #14159 — shell chrome, not author-placeable) 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) 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.test.ts b/packages/spec/src/ui/component.test.ts index b261c96c065..e361ec0ffaa 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -1034,7 +1034,11 @@ describe('ComponentPropsMap', () => { // sentence are the pin — never a bare `toThrow()`, which greens on any error. describe('user:profile is not author-placeable (#14159, ruling B)', () => { const FIRST_SENTENCE = - /^`user:profile` is not a page-placeable element — it is shell chrome \(the signed-in user's avatar menu, which the app shell renders itself on every page\), no renderer for it exists anywhere by ruling \(objectstack#14159, objectui#7135\), and there is nothing to put in its place: delete the component node and let the shell render the profile\./; + /^`user:profile` is not a page-placeable element — it is shell chrome \(the signed-in user's avatar menu, which the app shell renders itself on every page\), no renderer for it exists anywhere by ruling, and there is nothing to put in its place: delete the component node and let the shell render the profile\./; + // `check:doc-authoring` (maintainer ruling 2026-08-12): a prescription printed + // at the customer carries no citation-shaped issue id — the ADR id is the + // durable reference; the issue anchors live in the adjacent source comment. + const ISSUE_ID = /#\d{3,}/; it('the ComponentPropsMap row refuses even the empty bag — the flipped accept pin', () => { const r = ComponentPropsMap['user:profile'].safeParse({}); @@ -1045,6 +1049,8 @@ describe('ComponentPropsMap', () => { expect(issue.code).toBe('invalid_type'); expect(issue.path).toEqual([]); expect(issue.message).toMatch(FIRST_SENTENCE); + expect(issue.message).not.toMatch(ISSUE_ID); + expect(issue.message).toContain('ADR-0049'); // A populated bag gets the same prescription, not an unknown-key verdict. const populated = ComponentPropsMap['user:profile'].safeParse({ showAvatar: true }); expect(populated.success).toBe(false); diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index 0f6e88ee89c..5449b3b914d 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -76,14 +76,20 @@ export const PageRegionSchema = lazySchema(() => strictObject({ // Adding a member here is an accept-set narrowing (Clause ②) — a contract // decision, never a convenience: an entry needs the ruling that retired the // type, the measured zero-renderer finding, and the row + enum edits beside it. +// +// The prescription names no issue id on purpose — `check:doc-authoring` refuses +// citation-shaped tokens in text printed AT the customer (maintainer ruling +// 2026-08-12); the anchors for an internal reader are HERE: the ruling is +// objectstack#14159 (option B of the objectstack#12183 ask), the zero-renderer +// measurement is objectui#7135. export const RETIRED_PAGE_COMPONENT_TYPES: ReadonlyMap = new Map([ ['user:profile', '`user:profile` is not a page-placeable element — it is shell chrome (the ' + "signed-in user's avatar menu, which the app shell renders itself on every page), no " - + 'renderer for it exists anywhere by ruling (objectstack#14159, objectui#7135), and there ' - + 'is nothing to put in its place: delete the component node and let the shell render the ' - + 'profile. Until this refusal an authored `user:profile` node validated clean and drew the ' - + 'red unknown-type panel in front of an end user — the ADR-0078 shape its four sibling ' - + 'shell singletons closed with renderers (objectstack#12183); this one gets no renderer ' + + 'renderer for it exists anywhere by ruling, and there is nothing to put in its place: ' + + 'delete the component node and let the shell render the profile. Until this refusal an ' + + 'authored `user:profile` node validated clean and drew the red unknown-type panel in ' + + 'front of an end user — the ADR-0078 shape its four sibling shell singletons closed with ' + + 'renderers; this one gets no renderer ' + '(zero measured pull — a future consumer files a feature card, and the refusal flips ' + 'additively). Removed from `PageComponentType` in @objectstack/spec 17 (ADR-0049 ' + 'enforce-or-remove); the name stays refused here so the failure lands in front of the ' @@ -238,7 +244,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`, #14159 — shell chrome, not author-placeable) 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) is refused at the parse itself, with the retirement prescription.'), id: z.string().optional().describe('Unique instance ID'), /** Configuration */