Skip to content

Commit 4f5c4d7

Browse files
feat(spec): declare record:approval_decision, the approval decision panel, with an empty strict ComponentPropsMap row (#22532)
Fixes #22472 Clause-②: yes ## What this declares `record:approval_decision`, the approval decision panel of a `sys_approval_request` record page, is now a declared page component type in `@objectstack/spec`. One node shows the request's decision progress and its declared decision actions. This is the spec-lane half of the maintainer's ruling on objectstack-ai/objectui#12045 (`6079807016`, letter 乙, answered 「同意」): a type with no `ComponentPropsMap` row passes any props bag, so the ruling asks for a row that `os validate` checks. It is an ordinary v18 widening (「17.x 不会发新版」). | File | Change | | :--- | :--- | | `packages/spec/src/ui/page.zod.ts` | `record:approval_decision` joins `PageComponentType` (Record Context group), and `PRINT_REFUSED_PAGE_COMPONENT_TYPES` gets its print reason | | `packages/spec/src/ui/component.zod.ts` | the `ComponentPropsMap` row `emptyProps('record:approval_decision')`; the `emptyProps` docblock lists it | | `content/docs/references/ui/page.mdx` | regenerated (`gen:docs`): the `PageComponentType` list and the `type` column | | `packages/spec/src/ui/component-record-approval-decision-22472.test.ts` | pins at the row, the node, the page, the vocabulary and the print classification | | `packages/lint/src/validate-record-approval-decision-22472.test.ts` | pins at the two authoring rules and the shared pipeline `os validate` runs | | `.changeset/22472-spec-record-approval-decision.md` | `@objectstack/spec` `minor`, `Clause-②: yes (widening)` | ## Contract decisions (the spec seat's; relayed to objectui#12045) 1. **Name: `record:approval_decision`, as proposed.** `record` is a reserved namespace (derived from the enum), so a misspelling there is a `component-type-unknown` error that offers the declared spelling. objectui already uses this name in its module-internal renderer and its tests, so no rename is needed on their side. 2. **Props: an empty strict object, as proposed.** I measured this from the renderer's read points on objectui `main` at `d03b022` (`packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel.tsx`, landed in objectui `a368ccb`). `ApprovalDecisionRenderer` discards the schema node (`schema: _schema`). It draws from the record context (the bound request row, 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 keys, never `properties`. The file is not at this repo's `.objectui-sha` pin `f0268ad78`: `git cat-file -e` at the pin answers 128, with `record-approvals-renderer.tsx` at the same pin as the control answering 0. That is why the citation names objectui `main`, not the pin. The row reuses the existing `emptyProps(type)` factory, the spelling of the nine other zero-prop rows (`nav:menu`, `element:divider`, `mcp:connect-agent`, …), so the refusal names the component. 3. **An enum member, not a row-only type.** `record:line_items`, `element:definition-list` and the other row-only types entered the vocabulary through the open string arm. The vocabulary admits a type that way only on measured registration, publication and authorship (`component-type-vocabulary.ts`; the `#20371` rows write all three down). This type has none of the three yet. objectui keeps its renderer unregistered until this declaration exists (its docblock says so), and plugin-approvals authors the page after it (#22473). A spec-first declaration is therefore an enum member. One consequence for objectui: its palette-coverage test (`block-config.test.ts`, derived from `PageComponentType.options`) asks for an offer-or-exclude decision for the new member once objectui moves to `next` (objectui#12030). ## Measured hypotheses - **H1 holds.** `record:approval_decison` → one `component-type-unknown` finding, severity `error`, message offering `'record:approval_decision'`. The declared spelling in the same position → no finding. Both are pinned in the lint test, through `validateComponentTypes` and through `runAuthoringRules('validate', …)`. - **H2: every table the member must appear in.** Grep for `record:history` / `record:line_items` / `record:reference_rail` across `packages/spec`, `packages/lint`, `content`, `scripts`: - `PageComponentType` (page.zod.ts): added. - `ComponentPropsMap` (component.zod.ts): added. - `PRINTABLE_PAGE_COMPONENT_TYPES` / `PRINT_REFUSED_PAGE_COMPONENT_TYPES` (page.zod.ts): added to the refused map. `page-print.test.ts` requires every live known type to be classified exactly once, and it passes. - `content/docs/references/ui/page.mdx`: regenerated. - Derived, not edited: `KNOWN_COMPONENT_TYPES`, `KNOWN_COMPONENT_TYPE_CANDIDATES` and `RESERVED_COMPONENT_TYPE_NAMESPACES` (unchanged: `record` was already reserved). The lint print gate and its `it.each` iterate the map. The `record:*`-on-react-page refusal in `validate-react-page-props.ts` is prefix-based. - Not a table of members: `scripts/format-type.test.ts` (a static fixture that still lists the retired types), `validate-page-field-bindings.ts` (rows for field-binding props only; this type has none), `sdui.manifest.json` (objectui's build output). - **H3 holds.** A no-props row is spelled `emptyProps(type)`, a `strictObject` with no keys. Any key → `unrecognized_keys` naming `` `record:approval_decision` `` and the key, and through lint one `component-props-unknown-key` warning at `properties.KEY`. A node-level key written inside `properties` (`className`) gets the "belongs on the component NODE" prescription. - **H4, measured and not fixed.** I ran `validateComponentTypes` / `validateComponentProps` from this branch on a record page: - `record:approvals` → `component-type-unknown/error`, no props finding. - `record:attachments` → `component-type-unknown/error`, no props finding. - Controls: `record:approval_decision` and `record:details` → no type finding, one props finding each. Both are refused outright. objectui synthesizes both nodes at render time (`buildDefaultPageSchema`); no authored producer exists in this repo. ## Verification Tests at `b101db36c7` (the final commit), all under `scripts/pm/os-verify-lock.sh`: - `pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/ui/component-record-approval-decision-22472.test.ts src/ui/page-print.test.ts src/ui/component-type-vocabulary.test.ts`: 3 files, 54 tests passed. - `pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2 src/validate-record-approval-decision-22472.test.ts src/validate-print-page-blocks.test.ts src/validate-component-types.test.ts`: 3 files, 94 tests passed, against a spec `dist` rebuilt from this branch. On a stale `dist` the declared type would read as `component-type-unknown`, so the green lint pins also show that the rebuilt `dist` was read. - Full suites, one locked batch joined with `&&`, `VERDICT command-exit 0`: `pnpm --filter @objectstack/spec typecheck` (incl. `check:test-typecheck` OK) · `pnpm --filter @objectstack/lint typecheck` (OK) · spec `vitest run --project local`: 632 files, 18837 passed, 1 todo · spec `vitest run --project repo`: 54 files, 915 passed · lint `vitest run`: 132 files, 6003 passed. - Ablation, one time and not kept: `node scripts/ablation-replace.mjs` deleted the row line (anchor x1 → x0, blob `583b6e1e` → `908d18cc`). The spec pin file went to 5 failed / 3 passed. The 3 that stayed green are the vocabulary and print pins, which the enum member alone still satisfies. Restored blob = HEAD `583b6e1e`, and `git diff HEAD` is empty. - Gates: `node scripts/pm/dispatch-gates.mjs --commands` on this diff derived 109 commands (a superset of the dispatch list, +29 from the docs and lint-test paths). Every one ran with its exit code recorded. `--ran` reconciliation: 109 derived, 107 run, 2 NOT-MEASURED, 0 UNRUN. Five gates first exited 3 (unbuilt `@objectstack/lint` / client / objectql). They passed (exit 0) after their packages were built. NOT MEASURED: `check:dual-build-cjs-loads` and `check:type-check-debt`. Reason: both refuse unless every workspace package is built (a whole-repo build), which is left to CI. `pnpm --filter @objectstack/spec check:generated` is green after `gen:docs`. `authorable-surface/` and `api-surface/` did not move (the enum adds a value, not a key or an export). ## Acceptance notes - **Placement is not checked.** The panel draws nothing outside a `sys_approval_request` record page (objectui returns `null`), and no rule refuses the type on another object's page. The row comment and the changeset say so. Not filed: there is no producer yet, and the services child is the only planned author. - `content/docs/ui/pages.mdx` (hand-written) introduces its list as "include", and that list already omits `record:line_items`. It is outside this card's file surface, so it is left unchanged. - File-surface note: the dispatch placed "the component type enum" in `component.zod.ts`, but `PageComponentType` lives in `page.zod.ts`. That file was already on the surface for the print tables. The lint test file is the H3 pin for the `component-props-unknown-key` rule id, which lives in `@objectstack/lint`. No lint source changed: the row needed no lint hook. - Consumers: objectstack-ai/objectui#12045 (renderer registration and zod arm at its page-mount step) and #22473 (plugin-approvals' request page, which carries `Blocked-by:` on this card). --- _Generated by [Claude Code](https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent faf6348 commit 4f5c4d7

6 files changed

Lines changed: 251 additions & 4 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
`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
6+
7+
Clause-②: yes (widening)
8+
9+
- **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.
10+
- **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.
11+
- **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`.
12+
- **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.
13+
- **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.

‎content/docs/references/ui/page.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,7 @@ View filter rule
260260

261261
| Property | Type | Required | Description |
262262
| :--- | :--- | :--- | :--- |
263-
| **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. |
263+
| **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. |
264264
| **id** | `string` | optional | Unique instance ID |
265265
| **label** | `string \| Record<string, string>` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time |
266266
| **properties** | `Record<string, any>` | optional (default: `{}`) | Component props passed to the widget. See component.zod.ts for schemas. |
@@ -326,6 +326,7 @@ View filter rule
326326
* `record:quick_actions`
327327
* `record:reference_rail`
328328
* `record:history`
329+
* `record:approval_decision`
329330
* `app:launcher`
330331
* `nav:menu`
331332
* `nav:breadcrumb`
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #22472 — `record:approval_decision`, the approval decision panel, through the
5+
* authoring rules `os validate` / `os build` / `os lint` run. The ruling that
6+
* asked for the declaration (objectui#12045, 6079807016, letter 乙) gave its
7+
* reason as exactly these two doors: a type with no `ComponentPropsMap` row
8+
* passes any props bag, so a misspelled property drops in silence, and a type
9+
* the vocabulary does not declare inside the reserved `record` namespace is
10+
* refused outright. The spec-side pins (the row, the enum member, the print
11+
* classification) live beside the row in `@objectstack/spec`.
12+
*/
13+
import { describe, expect, it } from 'vitest';
14+
15+
import { runAuthoringRules } from './authoring-rules.js';
16+
import { COMPONENT_PROPS_UNKNOWN_KEY, validateComponentProps } from './validate-component-props.js';
17+
import { COMPONENT_TYPE_UNKNOWN, validateComponentTypes } from './validate-component-types.js';
18+
19+
type AnyRec = Record<string, unknown>;
20+
21+
/** The request page plugin-approvals authors: one record page, one region. */
22+
const requestPage = (components: unknown[]): AnyRec => ({
23+
pages: [
24+
{
25+
name: 'approval_request_detail',
26+
label: 'Approval request',
27+
type: 'record',
28+
object: 'sys_approval_request',
29+
regions: [{ name: 'main', components }],
30+
},
31+
],
32+
});
33+
34+
const ourRules = (stack: AnyRec) =>
35+
runAuthoringRules('validate', { normalized: stack as never }).filter(
36+
(f) => f.rule.startsWith('component-props') || f.rule === COMPONENT_TYPE_UNKNOWN,
37+
);
38+
39+
describe('a node with no props passes both doors', () => {
40+
it.each([
41+
['no `properties` at all', { type: 'record:approval_decision' }],
42+
['an empty `properties`', { type: 'record:approval_decision', properties: {} }],
43+
['node-level keys only', { type: 'record:approval_decision', id: 'decision', className: 'mt-4' }],
44+
])('%s', (_label, node) => {
45+
expect(validateComponentProps(requestPage([node]))).toEqual([]);
46+
expect(validateComponentTypes(requestPage([node]))).toEqual([]);
47+
expect(ourRules(requestPage([node]))).toEqual([]);
48+
});
49+
});
50+
51+
describe('any authored key is a `component-props-unknown-key` finding, naming the key', () => {
52+
it.each(['showProgress', 'actions', 'requestId'])('`%s`', (key) => {
53+
const stack = requestPage([{ type: 'record:approval_decision', properties: { [key]: true } }]);
54+
const findings = validateComponentProps(stack);
55+
expect(findings).toHaveLength(1);
56+
const [f] = findings;
57+
expect(f.rule).toBe(COMPONENT_PROPS_UNKNOWN_KEY);
58+
expect(f.severity).toBe('warning');
59+
expect(f.path).toBe(`pages[0].regions[0].components[0].properties.${key}`);
60+
expect(f.where).toBe('page "approval_request_detail" · record:approval_decision');
61+
expect(f.message).toContain(`\`${key}\``);
62+
expect(f.message).toContain('`record:approval_decision`');
63+
64+
// The same finding reaches the shared pipeline the authoring commands run.
65+
const viaPipeline = ourRules(stack);
66+
expect(viaPipeline.map((x) => x.rule)).toEqual([COMPONENT_PROPS_UNKNOWN_KEY]);
67+
});
68+
});
69+
70+
describe('a misspelled type in the reserved `record` namespace is refused', () => {
71+
it('`record:approval_decison` is `component-type-unknown`, offering the declared spelling', () => {
72+
const stack = requestPage([{ type: 'record:approval_decison' }]);
73+
const findings = validateComponentTypes(stack);
74+
expect(findings).toHaveLength(1);
75+
const [f] = findings;
76+
expect(f.rule).toBe(COMPONENT_TYPE_UNKNOWN);
77+
expect(f.severity).toBe('error');
78+
expect(f.path).toBe('pages[0].regions[0].components[0].type');
79+
expect(f.message).toContain('`record:approval_decison`');
80+
expect(f.message).toContain("'record:approval_decision'");
81+
82+
expect(ourRules(stack).map((x) => x.rule)).toEqual([COMPONENT_TYPE_UNKNOWN]);
83+
});
84+
85+
it('control: the declared spelling in the same position is not refused', () => {
86+
expect(validateComponentTypes(requestPage([{ type: 'record:approval_decision' }]))).toEqual([]);
87+
});
88+
});
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #22472 — `record:approval_decision`, the approval decision panel of a
5+
* `sys_approval_request` record page (the spec half of the objectui#12045
6+
* ruling 6079807016, letter 乙). Declared spec first: a `PageComponentType`
7+
* member with a `ComponentPropsMap` row that is an EMPTY strict object, because
8+
* the renderer reads the record context and the object's declared actions and
9+
* nothing authored.
10+
*
11+
* What is pinned here is the contract every reader dispatches on: the type is
12+
* known, its empty bag parses at every door, any key is refused naming the
13+
* component and the key, a misspelling inside the reserved `record` namespace
14+
* stays unknown, and the print classification answers for it. The authoring
15+
* rule ids (`component-props-unknown-key`, `component-type-unknown`) are pinned
16+
* where they live, in `@objectstack/lint`.
17+
*/
18+
import { describe, expect, it } from 'vitest';
19+
20+
import { ComponentPropsMap } from './component.zod';
21+
import {
22+
PageComponentSchema,
23+
PageComponentType,
24+
PageSchema,
25+
PRINTABLE_PAGE_COMPONENT_TYPES,
26+
PRINT_REFUSED_PAGE_COMPONENT_TYPES,
27+
RETIRED_PAGE_COMPONENT_TYPES,
28+
} from './page.zod';
29+
import {
30+
hasReservedComponentNamespace,
31+
isKnownComponentType,
32+
KNOWN_COMPONENT_TYPE_CANDIDATES,
33+
STRING_ARM_REGISTERED_TYPES,
34+
} from './component-type-vocabulary';
35+
36+
const TYPE = 'record:approval_decision' as const;
37+
const ROW = () => ComponentPropsMap[TYPE];
38+
39+
const requestPage = (component: Record<string, unknown>) => ({
40+
name: 'approval_request_detail',
41+
label: 'Approval request',
42+
type: 'record',
43+
object: 'sys_approval_request',
44+
regions: [{ name: 'main', components: [component] }],
45+
});
46+
47+
describe('the type is declared, spec first', () => {
48+
it('is a `PageComponentType` member with a `ComponentPropsMap` row', () => {
49+
expect(PageComponentType.options).toContain(TYPE);
50+
expect(PageComponentType.safeParse(TYPE).success).toBe(true);
51+
expect(Object.keys(ComponentPropsMap)).toContain(TYPE);
52+
expect(ROW()).toBeDefined();
53+
});
54+
55+
it('is known through the declaration, not through the string-arm ledger', () => {
56+
expect(STRING_ARM_REGISTERED_TYPES).not.toContain(TYPE);
57+
expect(RETIRED_PAGE_COMPONENT_TYPES.has(TYPE)).toBe(false);
58+
expect(hasReservedComponentNamespace(TYPE)).toBe(true);
59+
expect(isKnownComponentType(TYPE)).toBe(true);
60+
expect(KNOWN_COMPONENT_TYPE_CANDIDATES).toContain(TYPE);
61+
});
62+
63+
it('a misspelling inside the reserved `record` namespace stays unknown', () => {
64+
// The namespace claim is what makes a typo here a refusal rather than an
65+
// unregistered custom string: `component-type-unknown` judges exactly this
66+
// pair of predicates.
67+
for (const typo of ['record:approval_decison', 'record:approvals_decision', 'record:approval-decision']) {
68+
expect(hasReservedComponentNamespace(typo), typo).toBe(true);
69+
expect(isKnownComponentType(typo), typo).toBe(false);
70+
}
71+
});
72+
});
73+
74+
describe('the row is an empty strict object', () => {
75+
it('declares no key', () => {
76+
expect(Object.keys(ROW().shape)).toEqual([]);
77+
});
78+
79+
it('accepts the empty bag at every door: the row, the node, the page', () => {
80+
expect(ROW().safeParse({}).success).toBe(true);
81+
expect(PageComponentSchema.safeParse({ type: TYPE }).success).toBe(true);
82+
expect(PageComponentSchema.safeParse({ type: TYPE, properties: {} }).success).toBe(true);
83+
// Node-level keys stay node-level: the renderer reads `className` off the
84+
// node, never out of `properties`.
85+
expect(PageComponentSchema.safeParse({ type: TYPE, id: 'decision', className: 'mt-4' }).success).toBe(true);
86+
expect(PageSchema.safeParse(requestPage({ type: TYPE })).success).toBe(true);
87+
});
88+
89+
it('refuses any authored key, naming the component and the key', () => {
90+
for (const key of ['showProgress', 'actions', 'requestId', 'location']) {
91+
const result = ROW().safeParse({ [key]: true });
92+
expect(result.success, key).toBe(false);
93+
if (result.success) continue;
94+
expect(result.error.issues).toHaveLength(1);
95+
const [issue] = result.error.issues;
96+
expect(issue.code, key).toBe('unrecognized_keys');
97+
expect((issue as { keys?: string[] }).keys, key).toEqual([key]);
98+
expect(issue.message, key).toContain('`record:approval_decision`');
99+
expect(issue.message, key).toContain(key);
100+
}
101+
});
102+
103+
it('answers a node-level key written inside `properties` with the node prescription', () => {
104+
const result = ROW().safeParse({ className: 'mt-4' });
105+
expect(result.success).toBe(false);
106+
if (result.success) return;
107+
expect(result.error.issues[0].code).toBe('unrecognized_keys');
108+
expect(result.error.issues[0].message).toContain('component NODE');
109+
});
110+
});
111+
112+
describe('the print classification answers for it', () => {
113+
it('is refused inside a print page with its own reason, and is not printable', () => {
114+
expect(PRINTABLE_PAGE_COMPONENT_TYPES.has(TYPE)).toBe(false);
115+
expect(PRINT_REFUSED_PAGE_COMPONENT_TYPES.get(TYPE)).toMatch(/nothing to print$/);
116+
});
117+
});

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

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -406,16 +406,17 @@ const COMPONENT_LEVEL_GUIDANCE: readonly KeySetGuidance[] = [
406406
/**
407407
* A component that declares no props at all — `app:launcher`, `nav:menu`,
408408
* `nav:breadcrumb`, `global:search`, `global:notifications`,
409-
* `element:divider`, and the three plugin console widgets
409+
* `element:divider`, the three plugin console widgets
410410
* `cloud-connection:panel` and `marketplace:installed-list` (#11575) and
411-
* `mcp:connect-agent` (#12344). `user:profile` left this list at #14159 — it
411+
* `mcp:connect-agent` (#12344), and `record:approval_decision` (#22472), which
412+
* reads the record context alone. `user:profile` left this list at #14159 — it
412413
* is not author-placeable at all, so its row refuses the whole bag
413414
* ({@link retiredComponentProps}).
414415
*
415416
* A factory rather than one shared `EmptyProps` const, because the surface name
416417
* is the whole value of the rejection here: an empty shape has no candidate
417418
* keys, so the edit-distance fallback can say nothing, and "unrecognized key on
418-
* this component" would leave the author guessing which of the nine it meant.
419+
* this component" would leave the author guessing which of the ten it meant.
419420
* One `strictObject(` call site either way — the ledger counts sites from the
420421
* AST, and this is one.
421422
*
@@ -9376,6 +9377,22 @@ export const ComponentPropsMap = {
93769377
// makes it known. Key set measured at the `.objectui-sha` pin; see the
93779378
// schema's own header.
93789379
'record:line_items': RecordLineItemsProps,
9380+
// #22472 — the approval decision panel (objectui#12045, ruling 6079807016,
9381+
// letter 乙: the props of the request page's decision node are checked by
9382+
// `os validate`). A `PageComponentType` member, declared spec first; see the
9383+
// enum's own note for why it is not a row-only string-arm type. The accepted
9384+
// key set is EMPTY, measured from the renderer's read points on objectui
9385+
// `main` at `d03b022` (`app-shell/src/views/approval-decision/
9386+
// ApprovalDecisionPanel.tsx`, landed in objectui `a368ccb`, still
9387+
// module-internal there): `ApprovalDecisionRenderer` discards the schema node
9388+
// (`schema: _schema`) and draws from the record context alone (the request
9389+
// row the page binds, with its `viewer` and `decision_progress`) and from
9390+
// `sys_approval_request`'s own declared actions. `className` and the designer
9391+
// attributes it reads are node-level, never `properties`. So the row is
9392+
// strict and refuses every key: a misspelled prop is a
9393+
// `component-props-unknown-key` finding instead of a silently dropped one.
9394+
// The panel draws nothing outside a `sys_approval_request` record page.
9395+
'record:approval_decision': emptyProps('record:approval_decision'),
93799396

93809397
// Navigation
93819398
'app:launcher': emptyProps('app:launcher'),

‎packages/spec/src/ui/page.zod.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,16 @@ export const PageComponentType = z.enum([
184184
// open string arm below, which is what let its props bag dodge the #5068
185185
// gate's dispatch.
186186
'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',
187+
// #22472 (the spec half of the objectui#12045 ruling 6079807016, letter 乙):
188+
// the approval decision panel of a `sys_approval_request` record page — the
189+
// request's decision progress and its declared decision actions in one node.
190+
// DECLARED here, spec first, rather than admitted through a row alone: a type
191+
// that enters the vocabulary with no enum member is admitted on measured
192+
// registration, publication and authorship (`component-type-vocabulary.ts`),
193+
// and this one has none of the three yet — objectui keeps its renderer
194+
// unregistered until this declaration exists, and plugin-approvals authors
195+
// the page after it. Its `ComponentPropsMap` row is an empty strict object.
196+
'record:approval_decision',
187197
// Navigation
188198
'app:launcher', 'nav:menu', 'nav:breadcrumb',
189199
// Utility — `user:profile` REMOVED (#14159): shell chrome, refused by name
@@ -898,6 +908,7 @@ export const PRINT_REFUSED_PAGE_COMPONENT_TYPES: ReadonlyMap<string, string> = n
898908
['record:path', 'is an interactive stage control; the current stage is a field value, which `record:details` prints'],
899909
['record:alert', 'is a banner each viewer can dismiss, so what prints would depend on who prints it'],
900910
['record:quick_actions', 'is a row of action controls, with nothing to print'],
911+
['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'],
901912
['element:button', 'is an action control, with nothing to print'],
902913
['element:record_picker', 'is an input control, with nothing to print'],
903914
['element:text_input', 'is an input control, with nothing to print'],

0 commit comments

Comments
 (0)