diff --git a/.changeset/6854-layout-renderer-retired-onclick.md b/.changeset/6854-layout-renderer-retired-onclick.md
new file mode 100644
index 0000000000..94b2bee660
--- /dev/null
+++ b/.changeset/6854-layout-renderer-retired-onclick.md
@@ -0,0 +1,37 @@
+---
+'@object-ui/types': patch
+'@object-ui/runner': patch
+---
+
+The standalone runner renders `AppAction.items` from its declared type only, which
+makes `AppActionSchema.onClick`'s retirement message true again (objectui#6854,
+maintainer ruling of 2026-09-05, option B2).
+
+`AppAction.items` is `AppMenuItem[]`, and the zod mirror parses it with the legacy
+eight-member `MenuItemSchema` — neither declares `onClick` or `shortcut`.
+`LayoutRenderer` reached both through `as any`, past the type it was handed, and
+that left three mutually exclusive signals about the same key: the TypeScript face
+said `?: never`, the validator's refusal said "no renderer reads this key, so
+nothing could ever run it", and a renderer read it. An agent or a reader could
+believe any one of the three and be contradicted by the other two.
+
+**No published accept set moves and no exported symbol changes.** `AppAction.items`
+is NOT re-typed (the alternative was measured and refused: it would have carried a
+breaking migration for `path` / `href` / `badge` / `type` and the divider spelling,
+for a capability with no measured consumer). The refusal message itself is unchanged
+— it is shared by 22 other retired handler keys, and deleting the cast is what makes
+its sentence true rather than restating it.
+
+- `@object-ui/runner`: `LayoutRenderer` no longer reads `onClick` or `shortcut` on a
+ `type: 'user'` action's `items`. The `onClick` branch was an empty body and could
+ never run a JSON value; the `shortcut` read rendered a `DropdownMenuShortcut` from
+ a key the mirror strips in silence, so no validated document could reach it. A
+ census of every JSON and TypeScript app document in this repository found zero
+ authors of either key (positive controls recorded on the issue).
+- `@object-ui/types`: the rationale comments on `AppAction.onClick` and
+ `AppActionSchema.onClick` said "nothing reads `AppComponentSchema.actions[]`".
+ That was false — the runner renders both the `'button'` and the `'user'` arm.
+ Corrected to what was measured: `actions[]` is read, `onClick` is not.
+
+Whether `shortcut` should become authorable on `AppAction.items` is a separate
+contract question and is filed on its own.
diff --git a/packages/runner/src/LayoutRenderer.tsx b/packages/runner/src/LayoutRenderer.tsx
index d039da3535..7e45da193e 100644
--- a/packages/runner/src/LayoutRenderer.tsx
+++ b/packages/runner/src/LayoutRenderer.tsx
@@ -25,7 +25,6 @@ import {
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
- DropdownMenuShortcut,
Avatar,
AvatarImage,
AvatarFallback,
@@ -298,20 +297,28 @@ export const LayoutRenderer = ({ app, children, currentPath, onNavigate }: Layou
+ {/*
+ * Renders `AppAction.items` from its DECLARED type and nothing else
+ * (objectui#6854, maintainer ruling of 2026-09-05, option B2).
+ *
+ * `items` is `AppMenuItem[]` (`@object-ui/types` `app.ts`), and the zod
+ * mirror parses it with the legacy eight-member `MenuItemSchema`.
+ * Neither declares `onClick` or `shortcut`; this map used to reach both
+ * through `as any`, i.e. past the type it was handed. The `onClick` read
+ * is also what made the retirement refusal's own sentence — "no renderer
+ * reads this key, so nothing could ever run it" — false. `type` and
+ * `label` ARE declared on `AppMenuItem` and stay.
+ *
+ * Whether `shortcut` should become authorable on `AppAction.items` is a
+ * separate contract question; do not re-add either read to answer it.
+ */}
{userAction.items?.map((item, idx) => {
if (item.type === 'separator') {
return ;
}
return (
- {
- if ((item as any).onClick) {
- // Handle click logic
- }
- }}>
+
{item.label}
- {(item as any).shortcut && (
- {(item as any).shortcut}
- )}
);
})}
diff --git a/packages/runner/src/__tests__/LayoutRenderer.appActionItems-6854.test.tsx b/packages/runner/src/__tests__/LayoutRenderer.appActionItems-6854.test.tsx
new file mode 100644
index 0000000000..409cb37853
--- /dev/null
+++ b/packages/runner/src/__tests__/LayoutRenderer.appActionItems-6854.test.tsx
@@ -0,0 +1,122 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * `LayoutRenderer` renders `AppAction.items` from the DECLARED element type and
+ * nothing else (objectui#6854, maintainer ruling of 2026-09-05, option B2).
+ *
+ * `AppAction.items` is `AppMenuItem[]` — `type` / `label` / `icon` / `path` /
+ * `href` / `children` / `badge` / `hidden` — and the zod mirror parses it with
+ * the legacy eight-member `MenuItemSchema`, which drops anything else in
+ * silence. This map used to reach two keys that are on neither list through
+ * `as any`: `onClick` and `shortcut`.
+ *
+ * Deleting them is what makes `AppActionSchema.onClick`'s refusal message true
+ * again. It tells an author "no renderer reads this key, so nothing could ever
+ * run it", and until this ruling one did — the three-layer contradiction the
+ * card was filed for (`?: never` on the type, "nobody reads it" from the
+ * validator, a renderer reading it).
+ *
+ * These assertions author BOTH undeclared keys anyway — exactly what a host
+ * bypassing the validator would hand in, which the card's Zone-2 census found
+ * none of in this repo — and require the renderer to ignore both. Re-adding
+ * either read turns one of them red.
+ *
+ * ⛔ NOT a ruling that `shortcut` must stay unrendered for ever: whether it
+ * should become AUTHORABLE on `AppAction.items` is its own contract card. This
+ * pins the contract as it stands, not the answer to that question.
+ *
+ * The `packages/types` half of the same claim — that the refusal message still
+ * makes it — is pinned in
+ * `packages/types/src/__tests__/app-action-onclick-refusal-6854.test.ts`.
+ */
+
+import { describe, it, expect, afterEach, vi } from 'vitest';
+import { render, screen, cleanup } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import type { AppComponentSchema } from '@object-ui/types';
+
+// Static, module-scope import: `LayoutRenderer` pulls `@object-ui/components`
+// in for the dropdown primitives, and that cost must not land inside a test's
+// timeout budget (AGENTS.md 测试纪律).
+import { LayoutRenderer } from '../LayoutRenderer';
+
+afterEach(() => cleanup());
+
+const SHORTCUT = 'Ctrl+P';
+
+/**
+ * A user action whose items carry the two keys the declared type does not have.
+ * `label` and `type: 'separator'` ARE declared on `AppMenuItem`, so they are the
+ * control: they must keep rendering, or this file would pass by rendering
+ * nothing at all.
+ */
+function appWith(onClick: () => void): AppComponentSchema {
+ return {
+ type: 'app',
+ name: 'pin_app',
+ title: 'Pin App',
+ layout: 'header',
+ actions: [
+ {
+ type: 'user',
+ label: 'Ada Lovelace',
+ description: 'ada@example.com',
+ items: [
+ // `onClick` / `shortcut` are NOT on `AppMenuItem`; authored here on
+ // purpose, through the same cast the renderer used to read them with.
+ { label: 'Profile', onClick, shortcut: SHORTCUT } as never,
+ { type: 'separator' },
+ { label: 'Sign out' },
+ ],
+ },
+ ],
+ } as AppComponentSchema;
+}
+
+async function openUserMenu(app: AppComponentSchema) {
+ const user = userEvent.setup();
+ render(
+
+ page body
+ ,
+ );
+ // The trigger is the avatar button; with no `avatar` URL the Radix fallback
+ // renders the label's initials, which is the stable accessible name here.
+ await user.click(screen.getByRole('button', { name: 'AD' }));
+ return user;
+}
+
+describe('LayoutRenderer renders AppAction.items without reading onClick or shortcut (objectui#6854)', () => {
+ it('renders the declared `label` of each item — the control for the two negatives below', async () => {
+ await openUserMenu(appWith(vi.fn()));
+ expect(await screen.findByText('Profile')).toBeTruthy();
+ expect(screen.getByText('Sign out')).toBeTruthy();
+ });
+
+ it('still renders the declared `type: "separator"` item as a separator', async () => {
+ await openUserMenu(appWith(vi.fn()));
+ await screen.findByText('Profile');
+ // Two separators: the one above the group, plus the authored divider item.
+ expect(screen.getAllByRole('separator').length).toBeGreaterThanOrEqual(2);
+ });
+
+ it('never invokes an authored `onClick` — the key the refusal message says no renderer reads', async () => {
+ const onClick = vi.fn();
+ const user = await openUserMenu(appWith(onClick));
+ await user.click(await screen.findByText('Profile'));
+ expect(onClick).not.toHaveBeenCalled();
+ });
+
+ it('never puts an authored `shortcut` into the DOM', async () => {
+ await openUserMenu(appWith(vi.fn()));
+ await screen.findByText('Profile');
+ expect(screen.queryByText(SHORTCUT)).toBeNull();
+ expect(document.body.textContent).not.toContain(SHORTCUT);
+ });
+});
diff --git a/packages/types/src/__tests__/app-action-onclick-refusal-6854.test.ts b/packages/types/src/__tests__/app-action-onclick-refusal-6854.test.ts
new file mode 100644
index 0000000000..7d5b2fb992
--- /dev/null
+++ b/packages/types/src/__tests__/app-action-onclick-refusal-6854.test.ts
@@ -0,0 +1,113 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * `AppActionSchema.onClick`'s retirement message says something FALSIFIABLE
+ * about this tree, so it is pinned here (objectui#6854, maintainer ruling of
+ * 2026-09-05, option B2).
+ *
+ * The message `handlerKeyRefusal('onClick', 'retired', …)` generates ends:
+ *
+ * > … and no renderer reads this key, so nothing could ever run it.
+ *
+ * That sentence was FALSE when this card was filed. `@object-ui/runner`'s
+ * `LayoutRenderer` mapped `AppAction.items` and reached `(item as any).onClick`
+ * — past the declared element type, which is `AppMenuItem` and has no such key
+ * — leaving three mutually exclusive signals for a reader to pick from: the
+ * TypeScript face said `?: never`, the validator said nobody reads it, and a
+ * renderer read it. The ruling closed that by deleting the cast rather than by
+ * softening the sentence, so the sentence is true again and this file is what
+ * keeps it that way from the `packages/types` side: edit the shared template in
+ * `zod/tombstone.zod.ts` and this test names what the edit costs.
+ *
+ * The renderer side is pinned where the renderer lives —
+ * `packages/runner/src/__tests__/LayoutRenderer.appActionItems-6854.test.tsx`
+ * drives a real menu and requires an authored `onClick` never to be invoked.
+ * Neither pin can see the other's package, which is the point: the claim is
+ * about both, so it takes an assertion on each side.
+ *
+ * ⛔ NOT a pin on the template's exact prose in general — 22 other retired keys
+ * share it and their own message assertions live with them. This one asserts
+ * the clause whose truth this card measured.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { AppActionSchema, MenuItemSchema } from '../zod/app.zod';
+
+/** The clause this card measured. Spelled out, not built from the template. */
+const MEASURED_CLAUSE = 'no renderer reads this key, so nothing could ever run it';
+
+const onClickArm = AppActionSchema.shape.onClick;
+const describeText = (onClickArm as { description?: string }).description;
+
+describe('AppActionSchema.onClick — the retirement message states a measured fact (objectui#6854)', () => {
+ it('claims, verbatim, that no renderer reads the key', () => {
+ expect(describeText).toContain(MEASURED_CLAUSE);
+ });
+
+ it('names the key and marks it RETIRED, not a runtime slot', () => {
+ expect(describeText).toContain('`onClick`');
+ expect(describeText).toContain('RETIRED (objectui#6124');
+ expect(describeText).not.toContain('RUNTIME SLOT');
+ });
+
+ it('an authored value is refused BY NAME, carrying that same sentence to the author', () => {
+ const result = AppActionSchema.safeParse({
+ type: 'button',
+ label: 'Quick actions',
+ onClick: 'openQuickActions',
+ });
+ expect(result.success).toBe(false);
+ if (result.success) return;
+ const issue = result.error.issues.find((i) => String(i.path[0]) === 'onClick');
+ expect(issue, 'no issue addressed to `onClick`').toBeDefined();
+ expect(issue!.code).toBe('custom');
+ // ONE string feeds both author-facing channels (the `handlerKeyRefusal`
+ // invariant): the parse-time message an author reads cannot drift away from
+ // the `.describe()` metadata the docs surface publishes.
+ expect(issue!.message).toBe(describeText);
+ expect(issue!.message).toContain(MEASURED_CLAUSE);
+ });
+
+ it('the action without the key parses green — the refusal is about the key, not the action', () => {
+ expect(AppActionSchema.safeParse({ type: 'button', label: 'Quick actions' }).success).toBe(true);
+ });
+});
+
+describe('why the cast could never have been fed by an author (objectui#6854 Zone 2, the premise)', () => {
+ // `AppAction.items` is parsed by the LEGACY eight-member `MenuItemSchema`, a
+ // plain `z.object` — so `onClick` and `shortcut` are not refused there, they
+ // are STRIPPED in silence. An author therefore has no declared route to send
+ // either key, which is what made deleting the two reads a cleanup rather than
+ // a behaviour removal. Whether `shortcut` SHOULD become authorable here is a
+ // separate contract question and deliberately not answered by this file.
+ const authored = { label: 'Profile', onClick: 'goProfile', shortcut: 'Ctrl+P' };
+
+ it('the items mirror accepts the document and drops both undeclared keys', () => {
+ const result = MenuItemSchema.safeParse(authored);
+ expect(result.success).toBe(true);
+ if (!result.success) return;
+ const parsed = result.data as Record;
+ expect(parsed.label).toBe('Profile');
+ expect('onClick' in parsed).toBe(false);
+ expect('shortcut' in parsed).toBe(false);
+ });
+
+ it('a whole action carrying such an item parses green, with the item scrubbed', () => {
+ const result = AppActionSchema.safeParse({
+ type: 'user',
+ label: 'Ada Lovelace',
+ items: [authored, { type: 'separator' }],
+ });
+ expect(result.success).toBe(true);
+ if (!result.success) return;
+ const [first] = (result.data as { items: Record[] }).items;
+ expect('onClick' in first).toBe(false);
+ expect('shortcut' in first).toBe(false);
+ });
+});
diff --git a/packages/types/src/app.ts b/packages/types/src/app.ts
index 40f5d2a808..14d12b7ce4 100644
--- a/packages/types/src/app.ts
+++ b/packages/types/src/app.ts
@@ -716,10 +716,19 @@ export interface AppAction {
/**
* RETIRED (objectui#7344; the objectui#6182 ruling of 2026-08-25 — the
* handler-expression string dialect is not a supported authoring form — in
- * the objectui#6124 shape). Nothing reads `AppComponentSchema.actions[]`, so
- * no value here could ever run. The zod twin refuses the key by name; author
- * behaviour as a node type (an `action:button` node with a declared action)
- * instead.
+ * the objectui#6124 shape). No renderer reads THIS KEY, so no value here
+ * could ever run. The zod twin refuses the key by name; author behaviour as a
+ * node type (an `action:button` node with a declared action) instead.
+ *
+ * Re-measured at objectui#6854, which corrects the narrower claim this
+ * comment used to make. `AppComponentSchema.actions[]` IS read — the standalone
+ * runner's `LayoutRenderer` (`@object-ui/runner`) renders both the `'button'`
+ * and the `'user'` arm — so "nothing reads `actions[]`" was never the reason
+ * this key is inert. The reason is that no reader touches `onClick`: not on
+ * the action, and no longer on {@link AppAction.items}, where that renderer
+ * reached one through an `as any` cast until the maintainer ruling of
+ * 2026-09-05 (option B2) deleted it. Guarded from the renderer side by
+ * `packages/runner/src/__tests__/LayoutRenderer.appActionItems-6854.test.tsx`.
* @deprecated Not part of this contract — the value was inert.
*/
onClick?: never;
diff --git a/packages/types/src/zod/app.zod.ts b/packages/types/src/zod/app.zod.ts
index 3c6609b932..c38c63bb4a 100644
--- a/packages/types/src/zod/app.zod.ts
+++ b/packages/types/src/zod/app.zod.ts
@@ -204,8 +204,16 @@ export const AppActionSchema = z.object({
label: z.string().optional().describe('Action label'),
icon: z.string().optional().describe('Icon name'),
// RETIRED (objectui#7344 — the objectui#6182 ruling: the handler-expression
- // string dialect is not an authoring form; the objectui#6124 shape). Nothing
- // reads `AppComponentSchema.actions[]`, so the key refuses by name.
+ // string dialect is not an authoring form; the objectui#6124 shape). The
+ // `'retired'` arm's message tells an author "no renderer reads this key, so
+ // nothing could ever run it"; objectui#6854 re-measured that sentence rather
+ // than restating it. `actions[]` itself IS read (`@object-ui/runner`'s
+ // `LayoutRenderer` renders the `'button'` and `'user'` arms) — the earlier
+ // "nothing reads `actions[]`" here was wrong — but no reader touches
+ // `onClick`, on the action or on `items[]`, since the maintainer ruling of
+ // 2026-09-05 (option B2) deleted the two `as any` reads that did. Text pinned
+ // in `__tests__/app-action-onclick-refusal-6854.test.ts`; the renderer side in
+ // `packages/runner/src/__tests__/LayoutRenderer.appActionItems-6854.test.tsx`.
onClick: handlerKeyRefusal('onClick', 'retired', 'Click handler'),
avatar: z.string().optional().describe('User avatar URL (for type="user")'),
description: z.string().optional().describe('Additional description (e.g., email for user)'),