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
41 changes: 41 additions & 0 deletions .changeset/21855-action-member-params-array-only.md
Original file line number Diff line number Diff line change
@@ -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)

<!-- adr-0087: registered ui-action-group-menu-member-params-array-only -->

**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.
4 changes: 2 additions & 2 deletions content/docs/references/ui/component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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 |
Expand Down
14 changes: 12 additions & 2 deletions packages/spec/dropped-refinements.baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
Expand Down Expand Up @@ -1203,6 +1203,16 @@
"outputSchema"
]
},
"ui/ActionGroupProps": {
"sites": [
"actions.element"
]
},
"ui/ActionMenuProps": {
"sites": [
"actions.element"
]
},
"ui/ActionParam": {
"sites": [
"in"
Expand Down
Original file line number Diff line number Diff line change
@@ -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.',
};
Loading
Loading