From 9c23135f99e1a1d0616e5c6c0703a15f7ca3ee9d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 18:38:06 +0000 Subject: [PATCH 1/3] feat(spec): declare record:approval_decision with an empty strict ComponentPropsMap row Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN --- ...ent-record-approval-decision-22472.test.ts | 118 ++++++++++++++++++ packages/spec/src/ui/component.zod.ts | 23 +++- packages/spec/src/ui/page.zod.ts | 11 ++ 3 files changed, 149 insertions(+), 3 deletions(-) create mode 100644 packages/spec/src/ui/component-record-approval-decision-22472.test.ts diff --git a/packages/spec/src/ui/component-record-approval-decision-22472.test.ts b/packages/spec/src/ui/component-record-approval-decision-22472.test.ts new file mode 100644 index 0000000000..240c9dd1d7 --- /dev/null +++ b/packages/spec/src/ui/component-record-approval-decision-22472.test.ts @@ -0,0 +1,118 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #22472 — `record:approval_decision`, the approval decision panel of a + * `sys_approval_request` record page (the spec half of the objectui#12045 + * ruling 6079807016, letter 乙). Declared spec first: a `PageComponentType` + * member with a `ComponentPropsMap` row that is an EMPTY strict object, because + * the renderer reads the record context and the object's declared actions and + * nothing authored. + * + * What is pinned here is the contract every reader dispatches on: the type is + * known, its empty bag parses at every door, any key is refused naming the + * component and the key, a misspelling inside the reserved `record` namespace + * stays unknown, and the print classification answers for it. The authoring + * rule ids (`component-props-unknown-key`, `component-type-unknown`) are pinned + * where they live, in `@objectstack/lint`. + */ +import { describe, expect, it } from 'vitest'; + +import { ComponentPropsMap } from './component.zod'; +import { + PageComponentSchema, + PageComponentType, + PageSchema, + PRINTABLE_PAGE_COMPONENT_TYPES, + PRINT_REFUSED_PAGE_COMPONENT_TYPES, + RETIRED_PAGE_COMPONENT_TYPES, +} from './page.zod'; +import { + hasReservedComponentNamespace, + isKnownComponentType, + KNOWN_COMPONENT_TYPE_CANDIDATES, + STRING_ARM_REGISTERED_TYPES, +} from './component-type-vocabulary'; + +const TYPE = 'record:approval_decision'; +const ROW = () => (ComponentPropsMap as Record)[TYPE]; + +const requestPage = (component: Record) => ({ + name: 'approval_request_detail', + label: 'Approval request', + type: 'record', + object: 'sys_approval_request', + regions: [{ name: 'main', components: [component] }], +}); + +describe('the type is declared, spec first', () => { + it('is a `PageComponentType` member with a `ComponentPropsMap` row', () => { + expect(PageComponentType.options).toContain(TYPE); + expect(PageComponentType.safeParse(TYPE).success).toBe(true); + expect(Object.keys(ComponentPropsMap)).toContain(TYPE); + expect(ROW()).toBeDefined(); + }); + + it('is known through the declaration, not through the string-arm ledger', () => { + expect(STRING_ARM_REGISTERED_TYPES).not.toContain(TYPE); + expect(RETIRED_PAGE_COMPONENT_TYPES.has(TYPE)).toBe(false); + expect(hasReservedComponentNamespace(TYPE)).toBe(true); + expect(isKnownComponentType(TYPE)).toBe(true); + expect(KNOWN_COMPONENT_TYPE_CANDIDATES).toContain(TYPE); + }); + + it('a misspelling inside the reserved `record` namespace stays unknown', () => { + // The namespace claim is what makes a typo here a refusal rather than an + // unregistered custom string: `component-type-unknown` judges exactly this + // pair of predicates. + for (const typo of ['record:approval_decison', 'record:approvals_decision', 'record:approval-decision']) { + expect(hasReservedComponentNamespace(typo), typo).toBe(true); + expect(isKnownComponentType(typo), typo).toBe(false); + } + }); +}); + +describe('the row is an empty strict object', () => { + it('declares no key', () => { + const shape = (ROW() as unknown as { shape: Record }).shape; + expect(Object.keys(shape)).toEqual([]); + }); + + it('accepts the empty bag at every door: the row, the node, the page', () => { + expect(ROW().safeParse({}).success).toBe(true); + expect(PageComponentSchema.safeParse({ type: TYPE }).success).toBe(true); + expect(PageComponentSchema.safeParse({ type: TYPE, properties: {} }).success).toBe(true); + // Node-level keys stay node-level: the renderer reads `className` off the + // node, never out of `properties`. + expect(PageComponentSchema.safeParse({ type: TYPE, id: 'decision', className: 'mt-4' }).success).toBe(true); + expect(PageSchema.safeParse(requestPage({ type: TYPE })).success).toBe(true); + }); + + it('refuses any authored key, naming the component and the key', () => { + for (const key of ['showProgress', 'actions', 'requestId', 'location']) { + const result = ROW().safeParse({ [key]: true }); + expect(result.success, key).toBe(false); + if (result.success) continue; + expect(result.error.issues).toHaveLength(1); + const [issue] = result.error.issues; + expect(issue.code, key).toBe('unrecognized_keys'); + expect((issue as { keys?: string[] }).keys, key).toEqual([key]); + expect(issue.message, key).toContain('`record:approval_decision`'); + expect(issue.message, key).toContain(key); + } + }); + + it('answers a node-level key written inside `properties` with the node prescription', () => { + const result = ROW().safeParse({ className: 'mt-4' }); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.issues[0].code).toBe('unrecognized_keys'); + expect(result.error.issues[0].message).toContain('component NODE'); + }); +}); + +describe('the print classification answers for it', () => { + it('is refused inside a print page with its own reason, and is not printable', () => { + expect(PRINTABLE_PAGE_COMPONENT_TYPES.has(TYPE)).toBe(false); + expect(PRINT_REFUSED_PAGE_COMPONENT_TYPES.get(TYPE)).toMatch(/nothing to print$/); + }); +}); diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index da77709a09..583b6e1ea6 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -406,16 +406,17 @@ const COMPONENT_LEVEL_GUIDANCE: readonly KeySetGuidance[] = [ /** * A component that declares no props at all — `app:launcher`, `nav:menu`, * `nav:breadcrumb`, `global:search`, `global:notifications`, - * `element:divider`, and the three plugin console widgets + * `element:divider`, the three plugin console widgets * `cloud-connection:panel` and `marketplace:installed-list` (#11575) and - * `mcp:connect-agent` (#12344). `user:profile` left this list at #14159 — it + * `mcp:connect-agent` (#12344), and `record:approval_decision` (#22472), which + * reads the record context alone. `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 nine it meant. + * this component" would leave the author guessing which of the ten it meant. * One `strictObject(` call site either way — the ledger counts sites from the * AST, and this is one. * @@ -9376,6 +9377,22 @@ export const ComponentPropsMap = { // makes it known. Key set measured at the `.objectui-sha` pin; see the // schema's own header. 'record:line_items': RecordLineItemsProps, + // #22472 — the approval decision panel (objectui#12045, ruling 6079807016, + // letter 乙: the props of the request page's decision node are checked by + // `os validate`). A `PageComponentType` member, declared spec first; see the + // enum's own note for why it is not a row-only string-arm type. The accepted + // key set is EMPTY, measured from the renderer's read points on objectui + // `main` at `d03b022` (`app-shell/src/views/approval-decision/ + // ApprovalDecisionPanel.tsx`, landed in objectui `a368ccb`, still + // module-internal there): `ApprovalDecisionRenderer` discards the schema node + // (`schema: _schema`) and draws from the record context alone (the request + // row the page binds, with its `viewer` and `decision_progress`) and from + // `sys_approval_request`'s own declared actions. `className` and the designer + // attributes it reads are node-level, never `properties`. So the row is + // strict and refuses every key: a misspelled prop is a + // `component-props-unknown-key` finding instead of a silently dropped one. + // The panel draws nothing outside a `sys_approval_request` record page. + 'record:approval_decision': emptyProps('record:approval_decision'), // Navigation 'app:launcher': emptyProps('app:launcher'), diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index aca328dadc..e3221dff63 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -184,6 +184,16 @@ export const PageComponentType = z.enum([ // open string arm below, which is what let its props bag dodge the #5068 // gate's dispatch. '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', + // #22472 (the spec half of the objectui#12045 ruling 6079807016, letter 乙): + // the approval decision panel of a `sys_approval_request` record page — the + // request's decision progress and its declared decision actions in one node. + // DECLARED here, spec first, rather than admitted through a row alone: a type + // that enters the vocabulary with no enum member is admitted on measured + // registration, publication and authorship (`component-type-vocabulary.ts`), + // and this one has none of the three yet — objectui keeps its renderer + // unregistered until this declaration exists, and plugin-approvals authors + // the page after it. Its `ComponentPropsMap` row is an empty strict object. + 'record:approval_decision', // Navigation 'app:launcher', 'nav:menu', 'nav:breadcrumb', // Utility — `user:profile` REMOVED (#14159): shell chrome, refused by name @@ -898,6 +908,7 @@ export const PRINT_REFUSED_PAGE_COMPONENT_TYPES: ReadonlyMap = n ['record:path', 'is an interactive stage control; the current stage is a field value, which `record:details` prints'], ['record:alert', 'is a banner each viewer can dismiss, so what prints would depend on who prints it'], ['record:quick_actions', 'is a row of action controls, with nothing to print'], + ['record:approval_decision', 'is the decision panel of an approval request: action controls that differ by viewer, beside a progress tally each decision changes, with nothing to print'], ['element:button', 'is an action control, with nothing to print'], ['element:record_picker', 'is an input control, with nothing to print'], ['element:text_input', 'is an input control, with nothing to print'], From 5014dea2693b75e66ece98f84b03d017ba052f6b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 18:50:49 +0000 Subject: [PATCH 2/3] test(spec,lint): pin record:approval_decision at the row, the vocabulary and the authoring doors; regenerate page reference Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN --- content/docs/references/ui/page.mdx | 3 +- ...ate-record-approval-decision-22472.test.ts | 88 +++++++++++++++++++ ...ent-record-approval-decision-22472.test.ts | 7 +- 3 files changed, 93 insertions(+), 5 deletions(-) create mode 100644 packages/lint/src/validate-record-approval-decision-22472.test.ts diff --git a/content/docs/references/ui/page.mdx b/content/docs/references/ui/page.mdx index 9238ed8128..a19e40bec8 100644 --- a/content/docs/references/ui/page.mdx +++ b/content/docs/references/ui/page.mdx @@ -260,7 +260,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:suggestion' \| 'element:text' \| 'element:number' \| 'element:image' \| 'element:divider' \| 'element:button' \| 'element:record_picker' \| 'element:text_input'> \| string` | ✅ | Component Type — a standard vocabulary member, or a custom/registered component type in its own namespace (e.g. `object-grid`, `mcp:connect-agent`). The spec's own type namespaces are a closed vocabulary at author time: inside them, a type the vocabulary does not declare is refused by `os validate` / `os build` / `os lint` (rule `component-type-unknown`); a type the vocabulary RETIRED by name (`user:profile` — shell chrome, not author-placeable; `ai:chat_window` — no renderer by design, the floating chat overlay is the AI chat entry point; `element:filter` and `element:form` — retired whole, no renderer for either ever shipped) is refused at the parse itself, with the retirement prescription. | +| **type** | `Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| 'page:card' \| 'page:section' \| 'record:details' \| 'record:highlights' \| 'record:related_list' \| 'record:activity' \| 'record:chatter' \| 'record:discussion' \| 'record:path' \| 'record:alert' \| 'record:quick_actions' \| 'record:reference_rail' \| 'record:history' \| 'record:approval_decision' \| 'app:launcher' \| 'nav:menu' \| 'nav:breadcrumb' \| 'global:search' \| 'global:notifications' \| 'ai:suggestion' \| 'element:text' \| 'element:number' \| 'element:image' \| 'element:divider' \| 'element:button' \| 'element:record_picker' \| 'element:text_input'> \| string` | ✅ | Component Type — a standard vocabulary member, or a custom/registered component type in its own namespace (e.g. `object-grid`, `mcp:connect-agent`). The spec's own type namespaces are a closed vocabulary at author time: inside them, a type the vocabulary does not declare is refused by `os validate` / `os build` / `os lint` (rule `component-type-unknown`); a type the vocabulary RETIRED by name (`user:profile` — shell chrome, not author-placeable; `ai:chat_window` — no renderer by design, the floating chat overlay is the AI chat entry point; `element:filter` and `element:form` — retired whole, no renderer for either ever shipped) 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. | @@ -326,6 +326,7 @@ View filter rule * `record:quick_actions` * `record:reference_rail` * `record:history` +* `record:approval_decision` * `app:launcher` * `nav:menu` * `nav:breadcrumb` diff --git a/packages/lint/src/validate-record-approval-decision-22472.test.ts b/packages/lint/src/validate-record-approval-decision-22472.test.ts new file mode 100644 index 0000000000..95c8be585d --- /dev/null +++ b/packages/lint/src/validate-record-approval-decision-22472.test.ts @@ -0,0 +1,88 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #22472 — `record:approval_decision`, the approval decision panel, through the + * authoring rules `os validate` / `os build` / `os lint` run. The ruling that + * asked for the declaration (objectui#12045, 6079807016, letter 乙) gave its + * reason as exactly these two doors: a type with no `ComponentPropsMap` row + * passes any props bag, so a misspelled property drops in silence, and a type + * the vocabulary does not declare inside the reserved `record` namespace is + * refused outright. The spec-side pins (the row, the enum member, the print + * classification) live beside the row in `@objectstack/spec`. + */ +import { describe, expect, it } from 'vitest'; + +import { runAuthoringRules } from './authoring-rules.js'; +import { COMPONENT_PROPS_UNKNOWN_KEY, validateComponentProps } from './validate-component-props.js'; +import { COMPONENT_TYPE_UNKNOWN, validateComponentTypes } from './validate-component-types.js'; + +type AnyRec = Record; + +/** The request page plugin-approvals authors: one record page, one region. */ +const requestPage = (components: unknown[]): AnyRec => ({ + pages: [ + { + name: 'approval_request_detail', + label: 'Approval request', + type: 'record', + object: 'sys_approval_request', + regions: [{ name: 'main', components }], + }, + ], +}); + +const ourRules = (stack: AnyRec) => + runAuthoringRules('validate', { normalized: stack as never }).filter( + (f) => f.rule.startsWith('component-props') || f.rule === COMPONENT_TYPE_UNKNOWN, + ); + +describe('a node with no props passes both doors', () => { + it.each([ + ['no `properties` at all', { type: 'record:approval_decision' }], + ['an empty `properties`', { type: 'record:approval_decision', properties: {} }], + ['node-level keys only', { type: 'record:approval_decision', id: 'decision', className: 'mt-4' }], + ])('%s', (_label, node) => { + expect(validateComponentProps(requestPage([node]))).toEqual([]); + expect(validateComponentTypes(requestPage([node]))).toEqual([]); + expect(ourRules(requestPage([node]))).toEqual([]); + }); +}); + +describe('any authored key is a `component-props-unknown-key` finding, naming the key', () => { + it.each(['showProgress', 'actions', 'requestId'])('`%s`', (key) => { + const stack = requestPage([{ type: 'record:approval_decision', properties: { [key]: true } }]); + const findings = validateComponentProps(stack); + expect(findings).toHaveLength(1); + const [f] = findings; + expect(f.rule).toBe(COMPONENT_PROPS_UNKNOWN_KEY); + expect(f.severity).toBe('warning'); + expect(f.path).toBe(`pages[0].regions[0].components[0].properties.${key}`); + expect(f.where).toBe('page "approval_request_detail" · record:approval_decision'); + expect(f.message).toContain(`\`${key}\``); + expect(f.message).toContain('`record:approval_decision`'); + + // The same finding reaches the shared pipeline the authoring commands run. + const viaPipeline = ourRules(stack); + expect(viaPipeline.map((x) => x.rule)).toEqual([COMPONENT_PROPS_UNKNOWN_KEY]); + }); +}); + +describe('a misspelled type in the reserved `record` namespace is refused', () => { + it('`record:approval_decison` is `component-type-unknown`, offering the declared spelling', () => { + const stack = requestPage([{ type: 'record:approval_decison' }]); + const findings = validateComponentTypes(stack); + expect(findings).toHaveLength(1); + const [f] = findings; + expect(f.rule).toBe(COMPONENT_TYPE_UNKNOWN); + expect(f.severity).toBe('error'); + expect(f.path).toBe('pages[0].regions[0].components[0].type'); + expect(f.message).toContain('`record:approval_decison`'); + expect(f.message).toContain("'record:approval_decision'"); + + expect(ourRules(stack).map((x) => x.rule)).toEqual([COMPONENT_TYPE_UNKNOWN]); + }); + + it('control: the declared spelling in the same position is not refused', () => { + expect(validateComponentTypes(requestPage([{ type: 'record:approval_decision' }]))).toEqual([]); + }); +}); diff --git a/packages/spec/src/ui/component-record-approval-decision-22472.test.ts b/packages/spec/src/ui/component-record-approval-decision-22472.test.ts index 240c9dd1d7..c1fe3d8b24 100644 --- a/packages/spec/src/ui/component-record-approval-decision-22472.test.ts +++ b/packages/spec/src/ui/component-record-approval-decision-22472.test.ts @@ -33,8 +33,8 @@ import { STRING_ARM_REGISTERED_TYPES, } from './component-type-vocabulary'; -const TYPE = 'record:approval_decision'; -const ROW = () => (ComponentPropsMap as Record)[TYPE]; +const TYPE = 'record:approval_decision' as const; +const ROW = () => ComponentPropsMap[TYPE]; const requestPage = (component: Record) => ({ name: 'approval_request_detail', @@ -73,8 +73,7 @@ describe('the type is declared, spec first', () => { describe('the row is an empty strict object', () => { it('declares no key', () => { - const shape = (ROW() as unknown as { shape: Record }).shape; - expect(Object.keys(shape)).toEqual([]); + expect(Object.keys(ROW().shape)).toEqual([]); }); it('accepts the empty bag at every door: the row, the node, the page', () => { From b101db36c7c2f10dccaa3cad818599fc7ccfd9be Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 18:51:38 +0000 Subject: [PATCH 3/3] chore(changeset): record:approval_decision, a v18 widening of the spec's component vocabulary Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN --- .changeset/22472-spec-record-approval-decision.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .changeset/22472-spec-record-approval-decision.md diff --git a/.changeset/22472-spec-record-approval-decision.md b/.changeset/22472-spec-record-approval-decision.md new file mode 100644 index 0000000000..27077185bc --- /dev/null +++ b/.changeset/22472-spec-record-approval-decision.md @@ -0,0 +1,13 @@ +--- +'@objectstack/spec': minor +--- + +`record:approval_decision` is a declared page component type — the approval decision panel of a `sys_approval_request` record page — with a `ComponentPropsMap` row that accepts no props + +Clause-②: yes (widening) + +- **New `PageComponentType` member:** `record:approval_decision`, in the reserved `record` namespace. One node shows an approval request's decision progress and its declared decision actions. It reads the record context (the request row the page binds, with its `viewer` and `decision_progress`) and `sys_approval_request`'s own declared actions, and nothing authored. Outside a `sys_approval_request` record page it draws nothing. +- **Its `ComponentPropsMap` row is an empty strict object.** A node with no `properties`, or `properties: {}`, passes. Any key inside `properties` is refused by name: `os validate` / `os build` / `os lint` report it as `component-props-unknown-key`, naming the component and the key. Node-level keys (`id`, `className`, `visibleWhen`, …) stay on the node as for every component. +- **A misspelling is refused.** Because `record` is a reserved namespace, `record:approval_decison` and other near spellings are `component-type-unknown` errors that offer `record:approval_decision`. +- **Not printable.** Inside a page that declares `print`, the type is refused with its own reason (decision controls that differ by viewer, with nothing to print), like the other action-control blocks. +- **Consumers.** objectui renders it (objectui#12045, the approvals request page), and `@objectstack/plugin-approvals` places it on the request page it ships (objectstack#22473). A consumer that derives coverage from `PageComponentType.options` — a designer palette, a renderer registry — sees one more member to classify.