From ab2115b7a51eba8cccf19c2ae7db10fe697a732d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 09:35:22 +0000 Subject: [PATCH 1/5] feat(spec)!: an action:group / action:menu member refuses a non-array params on a non-api type The member's params takes the ActionParam[] input list only, unless its type is api (the request-payload window). The refusal carries the member prescription: author an action with static parameter values as its own action:button node. D3 entry ui-action-group-menu-member-params-array-only with its step-18 rationale fragment (order 83); registry region regenerated. Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude --- ...ion-group-menu-member-params-array-only.ts | 47 ++++++++++++++ packages/spec/src/migrations/registry.ts | 56 +++++++++++++++++ packages/spec/src/ui/component.zod.ts | 62 +++++++++++++++++-- 3 files changed, 161 insertions(+), 4 deletions(-) create mode 100644 packages/spec/src/migrations/entries/semantic/18.ui-action-group-menu-member-params-array-only.ts diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-action-group-menu-member-params-array-only.ts b/packages/spec/src/migrations/entries/semantic/18.ui-action-group-menu-member-params-array-only.ts new file mode 100644 index 0000000000..2c16178124 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.ui-action-group-menu-member-params-array-only.ts @@ -0,0 +1,47 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #21855 — the rows' value ratchet for one member: an `action:group` / +// `action:menu` member's `params` takes the array form (`ActionParam[]`) only, +// unless the member's `type` is `api`, whose object `params` keeps the +// inline-action payload window (#5777) until 18. The maintainer's ruling A on +// objectui#10289 keeps `params` to one shape and declares no other value-bag +// key, and #21704's fork 5 A refused the member's `properties.params`, so a +// member has no static-values spelling and the container drops a non-array +// `params` on any other type at run time. D3 only: page-component `properties` +// is not parsed on the metadata save or load path, so a stored page is never +// refused; there is no D2 conversion, because the only home for static values +// is a different node (an `action:button`), which no rewrite can build in the +// author's place; and the census found no writer to respell. +export const entry: SemanticMigration = { + id: 'ui-action-group-menu-member-params-array-only', + // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it + // inside a code span. + surface: 'page action:group and action:menu components — a member of properties.actions whose type is not api, ' + + 'and whose params is not an array', + replacement: 'Write `params` as the list of inputs to collect from the user, an `ActionParam[]` array. To run an ' + + 'action with static parameter values, author it as its own `action:button` node, whose `params` object carries ' + + 'them; for a `type: \'api\'` member\'s request body write `bodyExtra`. A member that needs neither drops the key.', + reason: 'An `action:group` or `action:menu` runs each member itself and forwards an array `params` as the input ' + + 'list. It forwards any other `params` value only for a `type: \'api\'` member, as its request payload; for ' + + 'every other `type`, an absent one included, it drops the value, with a development-build warning only. The ' + + 'member declared `params` as any value, so an object `params` on such a member passed the component-props ' + + 'gate and then had no effect: no error and no static values. `params` carries one shape, the input list, and ' + + 'no second value-bag key is declared; a member\'s `properties.params` is already refused, so static parameter ' + + 'values are not part of the inline action vocabulary at all, and the action that needs them is its own ' + + '`action:button` node. The member now refuses a non-array `params` on a non-`api` type at the gate, at ' + + '`actions.N.params`, with that prescription. The `api` member\'s object `params` is unchanged. It is read ' + + 'where every page component\'s props are: the component-props gate reports the refusal as an advisory ' + + '`component-props-invalid` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a ' + + 'stored page still saves and loads, because a page component\'s `properties` is not parsed on the metadata ' + + 'save or load path. No conversion is registered: the static values belong on a different node, and the ' + + 'census found no writer. Deployed metadata NOT MEASURED.', + acceptanceCriteria: 'Every `action:group` and `action:menu` node validates: `objectstack validate` reports no ' + + '`component-props-invalid` finding at `properties.actions.N.params`. Each member whose action needs static ' + + 'parameter values is now its own `action:button` node, and pressing it hands the handler those values. ' + + 'Census at the time of the change: no `action:group` / `action:menu` member authors a non-array `params` on a ' + + 'non-`api` type in this repository, in objectui (at the pinned commit and on its main branch) or in the hotcrm ' + + 'application, outside objectui\'s own tests asserting that the container drops it; the cloud repository was not ' + + 'reachable.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 5ec853449f..a346ed7732 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -6181,6 +6181,19 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'authors are refused at parse; its D3 record is the semantic entry ' + '`translation-widget-sub-caption-retired`.', }, + { + id: 'ui-action-group-menu-member-params-array-only', + order: 83, + text: + 'It then closes the one static-values spelling those members still accepted and the containers drop: an ' + + '`action:group` or `action:menu` member\'s `params` takes the input list, an `ActionParam[]` array, only, ' + + 'unless the member\'s `type` is `api`, whose object `params` keeps its request-payload window. `params` ' + + 'carries one shape and no second value-bag key is declared, so an object `params` on any other member, which ' + + 'parsed and then reached no action, is refused at `actions.N.params` with the prescription to author an action ' + + 'with static parameter values as its own `action:button` node. Read by the component-props gate (advisory); a ' + + 'stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry ' + + '`ui-action-group-menu-member-params-array-only`.', + }, { id: 'ui-action-group-menu-members-typed', order: 82, @@ -19414,6 +19427,49 @@ const step18: MigrationStep = { + 'local file, and rewrite it to that spelling. Done when every turso datasource parses, the ' + 'driver builds from it, and a replica datasource reports a file: url beside its syncUrl.', }, + // #21855 — the rows' value ratchet for one member: an `action:group` / + // `action:menu` member's `params` takes the array form (`ActionParam[]`) only, + // unless the member's `type` is `api`, whose object `params` keeps the + // inline-action payload window (#5777) until 18. The maintainer's ruling A on + // objectui#10289 keeps `params` to one shape and declares no other value-bag + // key, and #21704's fork 5 A refused the member's `properties.params`, so a + // member has no static-values spelling and the container drops a non-array + // `params` on any other type at run time. D3 only: page-component `properties` + // is not parsed on the metadata save or load path, so a stored page is never + // refused; there is no D2 conversion, because the only home for static values + // is a different node (an `action:button`), which no rewrite can build in the + // author's place; and the census found no writer to respell. + { + id: 'ui-action-group-menu-member-params-array-only', + // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it + // inside a code span. + surface: 'page action:group and action:menu components — a member of properties.actions whose type is not api, ' + + 'and whose params is not an array', + replacement: 'Write `params` as the list of inputs to collect from the user, an `ActionParam[]` array. To run an ' + + 'action with static parameter values, author it as its own `action:button` node, whose `params` object carries ' + + 'them; for a `type: \'api\'` member\'s request body write `bodyExtra`. A member that needs neither drops the key.', + reason: 'An `action:group` or `action:menu` runs each member itself and forwards an array `params` as the input ' + + 'list. It forwards any other `params` value only for a `type: \'api\'` member, as its request payload; for ' + + 'every other `type`, an absent one included, it drops the value, with a development-build warning only. The ' + + 'member declared `params` as any value, so an object `params` on such a member passed the component-props ' + + 'gate and then had no effect: no error and no static values. `params` carries one shape, the input list, and ' + + 'no second value-bag key is declared; a member\'s `properties.params` is already refused, so static parameter ' + + 'values are not part of the inline action vocabulary at all, and the action that needs them is its own ' + + '`action:button` node. The member now refuses a non-array `params` on a non-`api` type at the gate, at ' + + '`actions.N.params`, with that prescription. The `api` member\'s object `params` is unchanged. It is read ' + + 'where every page component\'s props are: the component-props gate reports the refusal as an advisory ' + + '`component-props-invalid` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a ' + + 'stored page still saves and loads, because a page component\'s `properties` is not parsed on the metadata ' + + 'save or load path. No conversion is registered: the static values belong on a different node, and the ' + + 'census found no writer. Deployed metadata NOT MEASURED.', + acceptanceCriteria: 'Every `action:group` and `action:menu` node validates: `objectstack validate` reports no ' + + '`component-props-invalid` finding at `properties.actions.N.params`. Each member whose action needs static ' + + 'parameter values is now its own `action:button` node, and pressing it hands the handler those values. ' + + 'Census at the time of the change: no `action:group` / `action:menu` member authors a non-array `params` on a ' + + 'non-`api` type in this repository, in objectui (at the pinned commit and on its main branch) or in the hotcrm ' + + 'application, outside objectui\'s own tests asserting that the container drops it; the cloud repository was not ' + + 'reachable.', + }, // #21464 — each member of the `action:group` / `action:menu` page blocks' // `actions` was an open record: the container draws and runs the member itself, // and the spec declared none of its keys. The maintainer ruled on #21704 (fork diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index ba4ed3ad94..d8565fd7a8 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -3620,7 +3620,8 @@ export type ActionIconPropsParsed = z.infer; * executor — a member is an action entry, whose executor is `type`, where a * node spells it `actionType`), `name`, `label`, `description`, `target`, * `openIn`, `method`, `params` (an array is the input list; an object is - * the request payload of a `type: 'api'` member only, `static-params.ts:172-183`), + * the request payload of a `type: 'api'` member only, `static-params.ts:172-183`, + * and refused on every other member — below), * `bodyExtra`, `bodyShape`, `operation`, `patch`, `confirmText`, * `successMessage`, `errorMessage`, `refreshAfter`, `locations`, `toast`, * `resultDialog`, `onSuccess` and `objectName`. @@ -3646,6 +3647,32 @@ export type ActionIconPropsParsed = z.infer; * A code-composed `onClick` (group `:314`, menu `:249`) is a function, which * metadata cannot carry. * + * ## `params` takes the array form only, unless the member's `type` is `api` + * + * [#21855] The rows' value ratchet for this one member, with its own + * inventory. A member's `params` is its `ActionParam[]` input list: both + * containers forward an array as `actionParams` (group `:330-335`, menu + * `:265-270`; the renderer directory is byte-identical at the current + * `.objectui-sha` pin `0abd4f9f8` and at objectui `main` `f1a177c41`). Any + * other value goes through `readActionEntryParamValues` + * (`static-params.ts:172-182`), which returns it unchanged for a `type: 'api'` + * member — the inline-action payload window (#5777), as the request payload + * until 18 — and for every other `type`, an absent one included, returns + * nothing (with a development-build warning only). So an object `params` on a + * non-`api` member parsed here and was dropped at run time: no error and no + * effect. The maintainer's ruling A on objectui#10289 (record 5825589480) + * keeps `params` to one shape and declares no other value-bag key, and fork + * 5 A on #21704 refused the member's `properties.params`, so a member has no + * static-values spelling at all: {@link actionContainerMemberParamsFitType} + * refuses a non-array `params` on a non-`api` member at the gate, with the + * member prescription the `properties` guidance gives — static values belong + * on an `action:button` node, whose `params` object carries them. The value + * stays `z.unknown()` (the runner reason): the `api` window and the array's + * elements are untouched. Census before the change: no `action:group` / + * `action:menu` member authors a non-array `params` on a non-`api` type in + * objectstack, objectui (pin and `main`) or hotcrm, outside objectui's own + * probes of the drop. + * * The two members differ only in `size`, so one shape builder serves both * ({@link actionContainerMemberShape}); each container's member is built once. * A factory the rows call, not a {@link lazySchema}, for the reason @@ -3670,7 +3697,7 @@ function actionContainerMemberShape() { tags: z.array(z.enum(['separator-before'])).optional() .describe('Item tags — `separator-before` draws a divider above the item in a dropdown or menu (not above the first item); no other tag is drawn'), params: z.unknown().optional() - .describe('Action parameters, forwarded to the runner: an array is the list of inputs to collect from the user before the action runs; an object is forwarded as the request payload of a `type: \'api\'` member only (use `bodyExtra` for that)'), + .describe('Action parameters, forwarded to the runner: an array is the list of inputs to collect from the user before the action runs. Only a `type: \'api\'` member takes any other value, forwarded as its request payload (use `bodyExtra` for that); on every other member a non-array `params` is refused — author an action with static parameter values as its own `action:button` node'), description: z.string().optional() .describe('Action description, forwarded to the runner — the parameter dialog shows it under its title'), target: z.string().optional() @@ -3731,6 +3758,33 @@ const actionContainerMemberHistory = (container: string) => `Until this shape was declared, each \`${container}\` member was an open record: a misspelled key passed, ` + 'and the container drew and ran the member without it.'; +/** + * [#21855] A container member's `params` fits its `type`: the array form only, + * unless the member's `type` is `api` (see "`params` takes the array form only" + * on {@link actionContainerMemberShape}). A refinement on the member, so the + * issue lands at `actions.N.params`; the unknown-key refusal stays terminal + * (`strictObject`), so a member already refused for a key is not judged twice. + */ +function actionContainerMemberParamsFitType(container: 'action:group' | 'action:menu') { + return (member: { type?: string; params?: unknown }, ctx: z.RefinementCtx): void => { + const { params, type } = member; + if (params === undefined || Array.isArray(params) || type === 'api') return; + const written = params !== null && typeof params === 'object' ? 'the object written here' : 'the value written here'; + const typed = type === undefined ? 'this member names no `type`' : `this member's \`type\` is \`'${type}'\``; + ctx.addIssue({ + code: 'custom', + path: ['params'], + message: + `\`params\` on an \`${container}\` member is the list of inputs the runner collects from the user before the ` + + 'action runs — an `ActionParam[]` array. The container forwards any other `params` value only for a ' + + `\`type: 'api'\` member, as its request payload (write \`bodyExtra\` for that), and ${typed}, so ` + + `${written} is dropped and never reaches the action. A member's static parameter values are not part of the ` + + 'inline action vocabulary: to run an action with static parameter values, author it as its own ' + + '`action:button` node, whose `params` object carries them.', + }); + }; +} + /** The aliases a container member answers: the rows' table, with the executor key turned round. */ const ACTION_CONTAINER_MEMBER_ALIASES = { actionType: 'type', @@ -3750,7 +3804,7 @@ function buildActionGroupMember() { ...actionContainerMemberShape(), size: z.enum([...BUTTON_PRIMITIVE_SIZES, 'md']).optional() .describe('Inline button size — the Button primitive\'s vocabulary, plus `md` (drawn as `default`), falling back to the group\'s `size`. A dropdown item reads no size'), - }); + }).superRefine(actionContainerMemberParamsFitType('action:group')); } let actionGroupMemberOnce: ReturnType | undefined; /** The one {@link buildActionGroupMember} instance. */ @@ -3769,7 +3823,7 @@ function buildActionMenuMember() { + 'button (the menu\'s own `size`). Remove it, or put the action in an `action:group`, whose inline buttons ' + 'read a member\'s `size`.', }, - }, actionContainerMemberShape()); + }, actionContainerMemberShape()).superRefine(actionContainerMemberParamsFitType('action:menu')); } let actionMenuMemberOnce: ReturnType | undefined; /** The one {@link buildActionMenuMember} instance. */ From 7a28c5194a2552d5b21cea2ad6d3b4f821edc1f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 09:37:43 +0000 Subject: [PATCH 2/5] test(spec): pin the container member params refusal, its accept set and its D3 registration Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude --- ...ction-member-params-array-only.pin.test.ts | 171 ++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 packages/spec/src/ui/component-action-member-params-array-only.pin.test.ts diff --git a/packages/spec/src/ui/component-action-member-params-array-only.pin.test.ts b/packages/spec/src/ui/component-action-member-params-array-only.pin.test.ts new file mode 100644 index 0000000000..1be3f9a731 --- /dev/null +++ b/packages/spec/src/ui/component-action-member-params-array-only.pin.test.ts @@ -0,0 +1,171 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21855] An `action:group` / `action:menu` member's `params` takes the array + * form only, unless the member's `type` is `api` — the rows' value ratchet for + * this one member (the docblock on `actionContainerMemberShape` in + * `component.zod.ts` carries the read points and the rulings). + * + * ## The defect this file closes + * + * The member declared `params` as `z.unknown()`. Both containers forward an + * array as the input list, forward any other value only for a `type: 'api'` + * member (its request payload), and drop it for every other `type`, with a + * development-build warning only (objectui `static-params.ts:172-182` at the + * `.objectui-sha` pin `0abd4f9f8`). So an object `params` on a `navigate_edit` + * member passed the component-props gate and then reached no action. + * + * ## What is pinned, and why each half + * + * - §1 THE REFUSAL: a non-array `params` on a non-`api` member is refused with + * the code AND the path, so a refusal for the wrong reason reds, and the + * message carries the member prescription. + * - §2 WHAT STAYS ACCEPTED, byte for byte: the array form on any member, the + * `api` member's object `params` (its request-payload window), a member with + * no `params`, and an `action:button` / `action:icon` node's object + * `params`, which IS its static values. A refusal pin with no lit control + * passes just as well when the door refuses everything. + * - §3 ONE COMPLAINT: the unknown-key refusal stays terminal, so a member + * already refused for a key is not judged a second time. + * - §4 THE REGISTRATION: the ADR-0087 D3 entry step 18 carries, and the step's + * rationale names it. + */ + +import { describe, it, expect } from 'vitest'; +import type { z } from 'zod'; + +import { ComponentPropsMap } from './component.zod'; +import { MIGRATIONS_BY_MAJOR } from '../migrations/registry'; + +type Row = 'action:group' | 'action:menu' | 'action:button' | 'action:icon'; +const parse = (row: Row, props: Record) => ComponentPropsMap[row].safeParse(props); + +/** The issue codes and paths a refusal carries, so a refusal for the WRONG reason reds. */ +function issues(result: z.ZodSafeParseResult): { code: string; path: string }[] { + if (result.success) return []; + return result.error.issues.map((i) => ({ code: i.code, path: i.path.join('.') })); +} +const firstMessage = (result: z.ZodSafeParseResult): string => + (result.success ? '' : result.error.issues[0]!.message); + +const CONTAINERS = ['action:group', 'action:menu'] as const; +/** The static values objectui's own drop probe writes on a member. */ +const VALUES = { objectName: 'account', recordId: '${record.id}' }; +/** An `ActionParam[]` input list. */ +const INPUTS = [{ name: 'reason', label: 'Reason', type: 'text', required: true }]; + +// ─────────────────────────────────────────────────────────────────────────── +// §1 the refusal +// ─────────────────────────────────────────────────────────────────────────── + +describe('§1 a non-array `params` on a member whose `type` is not `api` is refused at its path', () => { + const REFUSED: ReadonlyArray]> = [ + ['an object on a `navigate_edit` member', { name: 'edit', label: 'Edit', type: 'navigate_edit', params: VALUES }], + ['an object on a member with no `type`', { name: 'edit', label: 'Edit', params: VALUES }], + // The url action's interpolation scope, a third meaning the runner gave an object. + ['an object on a `url` member', { name: 'open', type: 'url', target: '/x/${param.id}', params: { id: 'r1', newTab: true } }], + ['an empty object on a `script` member', { name: 'run', type: 'script', params: {} }], + ['a string on a `flow` member', { name: 'run', type: 'flow', target: 'close_case', params: 'reason' }], + ['a number on a `modal` member', { name: 'ask', type: 'modal', params: 1 }], + ['null on a `form` member', { name: 'ask', type: 'form', params: null }], + ]; + for (const container of CONTAINERS) { + for (const [label, member] of REFUSED) { + it(`${container}: refuses ${label}`, () => { + const r = parse(container, { actions: [member] }); + expect(r.success).toBe(false); + expect(issues(r)).toEqual([{ code: 'custom', path: 'actions.0.params' }]); + }); + } + + it(`${container}: the issue lands on the member that wrote it, not on its neighbour`, () => { + const r = parse(container, { + actions: [ + { name: 'ask', type: 'script', params: INPUTS }, + { name: 'edit', type: 'navigate_edit', params: VALUES }, + ], + }); + expect(issues(r)).toEqual([{ code: 'custom', path: 'actions.1.params' }]); + }); + } + + it('the refusal names the container, the member\'s `type`, and the member prescription', () => { + const message = firstMessage(parse('action:menu', { actions: [{ name: 'edit', type: 'navigate_edit', params: VALUES }] })); + expect(message).toMatch(/^`params` on an `action:menu` member is the list of inputs/); + expect(message).toMatch(/this member's `type` is `'navigate_edit'`, so the object written here is dropped/); + expect(message).toMatch(/author it as its own `action:button` node, whose `params` object carries them\.$/); + expect(message).toMatch(/write `bodyExtra`/); + }); + + it('a member with no `type` is told so, and a non-object is not called an object', () => { + expect(firstMessage(parse('action:group', { actions: [{ name: 'edit', params: VALUES }] }))) + .toMatch(/^`params` on an `action:group` member .* this member names no `type`, so the object written here/); + expect(firstMessage(parse('action:group', { actions: [{ name: 'run', type: 'flow', params: 'reason' }] }))) + .toMatch(/`'flow'`, so the value written here is dropped/); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// §2 what stays accepted +// ─────────────────────────────────────────────────────────────────────────── + +describe('§2 the array form, the `api` window and the action nodes still parse, byte-identical', () => { + const ACCEPTED: ReadonlyArray]> = [ + ['an input list on a `navigate_edit` member', { name: 'edit', type: 'navigate_edit', params: INPUTS }], + ['an input list on a member with no `type`', { name: 'ask', params: INPUTS }], + ['an empty input list', { name: 'run', type: 'script', params: [] }], + // The request-payload window: an `api` member's object `params` is forwarded as its payload. + ['an object on an `api` member', { name: 'close', type: 'api', target: '/api/v1/order/close', params: { status: 'closed' } }], + ['an input list on an `api` member', { name: 'close', type: 'api', target: '/api/v1/order/close', params: INPUTS }], + ['a string on an `api` member', { name: 'ping', type: 'api', target: '/api/v1/ping', params: 'raw' }], + ['no `params`', { name: 'run', type: 'script' }], + ['an `api` member\'s request body in `bodyExtra`', { name: 'close', type: 'api', target: '/x', bodyExtra: { status: 'closed' } }], + ]; + for (const container of CONTAINERS) { + for (const [label, member] of ACCEPTED) { + it(`${container}: ${label}`, () => { + const r = parse(container, { actions: [member] }); + expect(issues(r)).toEqual([]); + expect(r.success && (r.data as { actions: unknown[] }).actions).toStrictEqual([member]); + }); + } + } + + // CONTROL: on an action NODE the object `params` IS the static values — the + // prescription's own target — and the row keeps it. + for (const row of ['action:button', 'action:icon'] as const) { + it(`${row}: an object \`params\` on a non-api node still parses — it carries the static values`, () => { + const props = { name: 'edit', label: 'Edit', actionType: 'navigate_edit', params: VALUES }; + const r = parse(row, props); + expect(issues(r)).toEqual([]); + expect(r.success && (r.data as { params?: unknown }).params).toStrictEqual(VALUES); + }); + } +}); + +// ─────────────────────────────────────────────────────────────────────────── +// §3 one complaint +// ─────────────────────────────────────────────────────────────────────────── + +describe('§3 a member already refused for a key is not judged a second time', () => { + it('an unknown key beside an object `params` draws the unknown-key refusal alone', () => { + const r = parse('action:group', { actions: [{ name: 'edit', type: 'navigate_edit', params: VALUES, bogus: 1 }] }); + expect(issues(r)).toEqual([{ code: 'unrecognized_keys', path: 'actions.0' }]); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// §4 the registration +// ─────────────────────────────────────────────────────────────────────────── + +describe('§4 the narrowing is registered as the ADR-0087 D3 entry step 18 carries', () => { + const ID = 'ui-action-group-menu-member-params-array-only'; + it('step 18 carries the semantic entry, with no conversion', () => { + const entry = MIGRATIONS_BY_MAJOR[18]!.semantic.find((s) => s.id === ID); + expect(entry).toBeDefined(); + expect(entry!.conversionIds ?? []).toEqual([]); + }); + it('the step\'s rationale names it', () => { + expect(MIGRATIONS_BY_MAJOR[18]!.rationale).toContain(`\`${ID}\``); + }); +}); From 1cc3567d969d61ca6eba1dd35b94a57a13e363a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 09:39:01 +0000 Subject: [PATCH 3/5] chore(changeset): the container member params narrowing, minor, BREAKING, registered Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude --- .../21855-action-member-params-array-only.md | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .changeset/21855-action-member-params-array-only.md diff --git a/.changeset/21855-action-member-params-array-only.md b/.changeset/21855-action-member-params-array-only.md new file mode 100644 index 0000000000..60341e16eb --- /dev/null +++ b/.changeset/21855-action-member-params-array-only.md @@ -0,0 +1,41 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec)!: an `action:group` / `action:menu` member refuses a non-array `params` unless its `type` is `api`, with the prescription to author an action with static parameter values as its own `action:button` node + +Clause-②: yes (narrowing) + + + +**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. What reads the rows: the component-props gate on `objectstack validate`, `objectstack build` and `objectstack lint`, which reports the refusal as an advisory `component-props-invalid` finding. A stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. + +**Why.** A container member's `params` is its input list: both containers forward an array as the `ActionParam[]` inputs to collect before the action runs. They forward any other `params` value only for a `type: 'api'` member, as its request payload, and drop it for every other `type` (an absent one included), with a development-build warning only. The member declared `params` as any value, so an object `params` on a `navigate_edit` member passed the gate and then had no effect: no error and no static values. `params` carries one shape, and no second value-bag key is declared; a member's `properties.params` is already refused. So static parameter values are not part of the inline action vocabulary at all, and an action that needs them is its own `action:button` node, whose `params` object carries them. + +**What is refused.** On an `action:group` or `action:menu` member whose `type` is not `api`, a `params` that is not an array — an object, a string, a number or `null`. The issue's `code` is `custom`, at `actions.N.params`, and its message names the container, the member's `type` and the prescription: *to run an action with static parameter values, author it as its own `action:button` node, whose `params` object carries them* (and, for a `type: 'api'` member's request body, `bodyExtra`). + +**What stays accepted, byte for byte.** An array `params` on any member; every `params` value on a `type: 'api'` member (its request-payload window, unchanged); a member with no `params`; and an `action:button` / `action:icon` node's object `params`, which is its static values. The member's `params` stays `unknown` in the types — the narrowing is a refinement on the member, and no export, key or type moves. + +## FROM → TO + +| you wrote | write instead | +|:--|:--| +| `actions: [{ name: 'edit', type: 'navigate_edit', params: { objectName: 'account', recordId: '${record.id}' } }]` on `action:group` / `action:menu` | the action as its own node: `{ type: 'action:button', properties: { name: 'edit', label: 'Edit', actionType: 'navigate_edit', params: { objectName: 'account', recordId: '${record.id}' } } }` | +| `actions: [{ name: 'save', type: 'api', target: '/api/save', params: { status: 'closed' } }]` | unchanged — or, preferred, `bodyExtra: { status: 'closed' }` | +| `actions: [{ name: 'ask', type: 'script', params: [{ name: 'reason', type: 'text' }] }]` | unchanged — an array is the input list | + +**The one-line fix: move a member that carries static parameter values out of its container into its own `action:button` node, with the same `params` object; drop a non-array `params` from any other member.** The container never forwarded those values, so the `action:button` node is the first place they reach the handler. + +## Who is affected, measured + +A writer is an `action:group` / `action:menu` member authoring a non-array `params` on a non-`api` type. The census walked every `params` key in every file that names either block (TypeScript AST over `.ts`/`.tsx`/`.js`/`.jsx`/`.mjs`/`.cjs`/`.mts`/`.json` and fenced Markdown code, same-file constants resolved; YAML by text), plus every non-array `params` on an element of any `actions` array corpus-wide, and each hit was read by hand. Lit control: a planted fixture with four non-`api` object members (flat, inside a node's `properties` bag, through a same-file constant, in a Markdown fence), an `api` member and an array member — all six found and classified. + +- **objectstack** at `5b2d189e28`, this branch's base: 24 files name a block, holding 32 `params` keys, 23 not an array; none is a container member's — conversion fixtures of the inline `element:button` action, schema source, the liveness ledger, CHANGELOG quotations, and one `properties.params` refusal probe. No example, doc, skill or fixture writes the refused spelling. +- **objectui** at the `.objectui-sha` pin `0abd4f9f8` and at `main` `f1a177c41` (the same census at both; the action renderers byte-identical): 89 files, 47 `params` keys, 41 not an array. The only container members with a non-array `params` on a non-`api` type are objectui's own tests asserting that the container drops it (`action-entry-object-params-10462.test.tsx`, `action-container-member-params-10290.test.tsx`); the `type: 'api'` controls beside them stay accepted. +- **hotcrm** at `4054ec2680`: no file names either block. +- **cloud** was not reachable from this session. **Deployed metadata** was not measured. + +### The kit + +- **The refusal.** A refinement on each container member (`ui/component.zod.ts`), applied to the `action:group` and the `action:menu` member alike; the member's `params` description says what it now takes, and the generated reference page carries it. +- **The ledger.** The D3 semantic entry `ui-action-group-menu-member-params-array-only` (protocol 18) and its step-18 rationale fragment. No key is removed, so there is no tombstone, and there is no D2 conversion: the static values belong on a different node, which no rewrite can build in the author's place. From 1260bff128cd6f21c6c5a6c1da3b311d033a5a80 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 09:41:41 +0000 Subject: [PATCH 4/5] chore(spec): declare the two member refinements the JSON Schema projection drops Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude --- packages/spec/dropped-refinements.baseline.json | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index c91e76755e..bc95569966 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -2,8 +2,8 @@ "description": "Shrink-only ledger of every PUBLISHED JSON Schema that is STILL WIDER than the Zod type it was generated from, because a rule written as `.refine()` reaches the runtime and not the file (#18670). `z.toJSONSchema()` has no arm for a `custom` check: a plain record, the same record with a `.refine()`, and the same record with an ABORTING `.refine()` all project byte-identically (measured on zod 4.4.3, the version packages/spec resolves). So a document one of these files ACCEPTS can still be refused at parse time, and an author -- or an AI -- validating against packages/spec/json-schema/** finds out a release later. Each `sites` path is a position under that schema at which a refinement is dropped; the same paths are written onto the artifact itself as `x-dropped-refinements`. Item 2 closed the first patterns: a refinement DECLARED through the closed list in src/shared/refinement-projection.ts is emitted into the published file, reads `projected` rather than `dropped`, and its row LEAVES this ledger in the same PR -- which is why the ledger shrinks and never grows on a repair. Every refinement outside that closed list stays here, and adding an arm to the list is a public-contract decision, not a refactor. Hand-edited on purpose and with no `gen:` script: a generator would let a new gap be admitted by running a command instead of by a decision, which is the silence this ledger exists to end. Adding, removing or moving a site fails packages/spec/scripts/build-schemas.ts until the line moves with it, and the failure prints the corrected entry in full. ⛔ Do not delete or weaken a refinement to shorten this file -- the runtime rule is correct; it is the projection that is silent, and the remedy is to teach the closed list a NAMED pattern, never to drop the rule.", "measured": { "zod": "4.4.3", - "publishedSchemasWithDroppedRefinements": 218, - "droppedRefinementSites": 676, + "publishedSchemasWithDroppedRefinements": 220, + "droppedRefinementSites": 678, "refinementSitesThatDidProject": 369, "refinementSitesWithNoJsonFormToCompare": 0 }, @@ -1203,6 +1203,16 @@ "outputSchema" ] }, + "ui/ActionGroupProps": { + "sites": [ + "actions.element" + ] + }, + "ui/ActionMenuProps": { + "sites": [ + "actions.element" + ] + }, "ui/ActionParam": { "sites": [ "in" From a6f230772fa89b13676c9107075c277933633e81 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 09:46:33 +0000 Subject: [PATCH 5/5] docs(spec): regenerate the component reference for the member params description Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude --- content/docs/references/ui/component.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index c62146612d..2b692883e0 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -87,7 +87,7 @@ const result = ActionButtonPropsSchema.parse(data); | **visible** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate — a boolean, a CEL string, or a `{ dialect, source }` envelope, evaluated against the row the host binds; the item is not drawn when it is FALSE, and a predicate that fails to evaluate hides it. Omit for always-visible | | **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — a boolean, a CEL string, or a `{ dialect, source }` envelope; the item is drawn but cannot be pressed while it is TRUE, and a predicate that fails to evaluate disables it. Omit for never-disabled | | **tags** | `Enum<'separator-before'>[]` | optional | Item tags — `separator-before` draws a divider above the item in a dropdown or menu (not above the first item); no other tag is drawn | -| **params** | `any` | optional | Action parameters, forwarded to the runner: an array is the list of inputs to collect from the user before the action runs; an object is forwarded as the request payload of a `type: 'api'` member only (use `bodyExtra` for that) | +| **params** | `any` | optional | Action parameters, forwarded to the runner: an array is the list of inputs to collect from the user before the action runs. Only a `type: 'api'` member takes any other value, forwarded as its request payload (use `bodyExtra` for that); on every other member a non-array `params` is refused — author an action with static parameter values as its own `action:button` node | | **description** | `string` | optional | Action description, forwarded to the runner — the parameter dialog shows it under its title | | **target** | `string` | optional | Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `type` | | **openIn** | `Enum<'self' \| 'new-tab'>` | optional | For a `url` action: `self` navigates in place, `new-tab` opens a new browser tab | @@ -170,7 +170,7 @@ const result = ActionButtonPropsSchema.parse(data); | **visible** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate — a boolean, a CEL string, or a `{ dialect, source }` envelope, evaluated against the row the host binds; the item is not drawn when it is FALSE, and a predicate that fails to evaluate hides it. Omit for always-visible | | **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — a boolean, a CEL string, or a `{ dialect, source }` envelope; the item is drawn but cannot be pressed while it is TRUE, and a predicate that fails to evaluate disables it. Omit for never-disabled | | **tags** | `Enum<'separator-before'>[]` | optional | Item tags — `separator-before` draws a divider above the item in a dropdown or menu (not above the first item); no other tag is drawn | -| **params** | `any` | optional | Action parameters, forwarded to the runner: an array is the list of inputs to collect from the user before the action runs; an object is forwarded as the request payload of a `type: 'api'` member only (use `bodyExtra` for that) | +| **params** | `any` | optional | Action parameters, forwarded to the runner: an array is the list of inputs to collect from the user before the action runs. Only a `type: 'api'` member takes any other value, forwarded as its request payload (use `bodyExtra` for that); on every other member a non-array `params` is refused — author an action with static parameter values as its own `action:button` node | | **description** | `string` | optional | Action description, forwarded to the runner — the parameter dialog shows it under its title | | **target** | `string` | optional | Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `type` | | **openIn** | `Enum<'self' \| 'new-tab'>` | optional | For a `url` action: `self` navigates in place, `new-tab` opens a new browser tab |