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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/22472-spec-record-approval-decision.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion content/docs/references/ui/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time |
| **properties** | `Record<string, any>` | optional (default: `{}`) | Component props passed to the widget. See component.zod.ts for schemas. |
Expand Down Expand Up @@ -326,6 +326,7 @@ View filter rule
* `record:quick_actions`
* `record:reference_rail`
* `record:history`
* `record:approval_decision`
* `app:launcher`
* `nav:menu`
* `nav:breadcrumb`
Expand Down
88 changes: 88 additions & 0 deletions packages/lint/src/validate-record-approval-decision-22472.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;

/** 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([]);
});
});
117 changes: 117 additions & 0 deletions packages/spec/src/ui/component-record-approval-decision-22472.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// 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' as const;
const ROW = () => ComponentPropsMap[TYPE];

const requestPage = (component: Record<string, unknown>) => ({
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', () => {
expect(Object.keys(ROW().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$/);
});
});
23 changes: 20 additions & 3 deletions packages/spec/src/ui/component.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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'),
Expand Down
11 changes: 11 additions & 0 deletions packages/spec/src/ui/page.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -898,6 +908,7 @@ export const PRINT_REFUSED_PAGE_COMPONENT_TYPES: ReadonlyMap<string, string> = 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'],
Expand Down
Loading