Skip to content

Commit 88a39c0

Browse files
feat(spec)!: an action:group / action:menu member refuses a non-array params unless its type is api, with the action:button prescription (#21855) (#21869)
Fixes #21855 Clause-②: yes (narrowing) ## What this does An `action:group` / `action:menu` member's `params` now takes the array form (`ActionParam[]`, the input list) only, unless the member's `type` is `api`. Any other `params` value on a non-`api` member (an object, a string, a number or `null`) is refused at the component-props gate, at `actions.N.params`, with the member prescription in the file's existing voice: static values belong on an `action:button` node, whose `params` object carries them. This is the card's step, under the governing text it cites: - The maintainer's ruling A on objectstack-ai/objectui#10289 (`5825589480`), verbatim: "⛔ `params` never carries two shapes, and ⛔ no new value-bag key is declared." - #21704 fork 5 A (`5979239990`) refused a member's `properties.params` and kept the member's `params` as `z.unknown()`, the button row's value schema. - The member's own `properties` prescription in `component.zod.ts` already pointed at `action:button` for static values. - triage's answer on objectstack-ai/objectui#11638 (`5990495682`, amended by `5991243780`) withdrew the other direction (the container reading an object `params`) and filed this card for the gate half. | | before | after | |:--|:--|:--| | a non-`api` member, `params` an array | accepted | accepted, byte-identical | | a non-`api` member (an absent `type` included), `params` not an array | accepted, then dropped at run time | refused: `custom` at `actions.N.params`, with the prescription | | a `type: 'api'` member, any `params` | accepted (the request-payload window, to 18) | **unchanged** | | `action:button` / `action:icon` node, object `params` | accepted (its static values) | **unchanged** | The refusal message, for a `navigate_edit` menu member: ```text `params` on an `action:menu` 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 this member's `type` is `'navigate_edit'`, so the object written here 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. ``` ### How - **`packages/spec/src/ui/component.zod.ts`.** - A module-private refinement, `actionContainerMemberParamsFitType(container)`, goes on both member builders (`buildActionGroupMember`, `buildActionMenuMember`) through `.superRefine`. - The member's `params` stays `z.unknown()`, so it keeps the enumeration pin's `runner` line. Its `.describe()` now states the accept set and still says "forwarded to the runner". - The members' docblock gains a section with the read points at the `.objectui-sha` pin `0abd4f9f8`. The objectui renderer directory is byte-identical there, at `2e818d0b5` and at objectui `main` `f1a177c41` (`git diff --quiet`). - `strictObject`'s unknown-key refusal stays terminal, so a member already refused for a key is not judged a second time (pinned). - ⛔ **What stays the same:** no key added or removed, no second shape for `params`, no export moved, and the `api` window is untouched. ### The ADR-0087 kit (the #21702 / PR #21712 and #21464 / PR #21764 shape) - The D3 semantic entry `packages/spec/src/migrations/entries/semantic/18.ui-action-group-menu-member-params-array-only.ts`. - Its `STEP18_RATIONALE` fragment at **order 83**, inserted where its id sorts. 83 is the next free order: the highest on `main` is 82, re-read at `5b2d189e28` and again at `2df3d13d16` after the merge. - `registry.ts`'s generated region, written by `gen:migration-registry`. - One BREAKING `@objectstack/spec` `minor` changeset, `.changeset/21855-action-member-params-array-only.md`. It carries the `Clause-②` line, the `adr-0087: registered ui-action-group-menu-member-params-array-only` disposition marker and a FROM → TO table. - No tombstone, because no key is removed. No D2 conversion, because the static values belong on a different node, which no rewrite can build in the author's place. - **No regeneration is owed for `spec-changes.json` and `docs/protocol-upgrade-guide.md`.** Both project the registry from the support floor up to the current protocol major (17), so a step-18 entry is not in either yet. `check:spec-changes` and `check:upgrade-guide` are green with both files untouched, as on the two precedents. - **`packages/spec/dropped-refinements.baseline.json`** gains `ui/ActionGroupProps` and `ui/ActionMenuProps` (site `actions.element` each), exactly as `build-schemas` printed them. Its `measured` counts go 218 → 220 schemas and 676 → 678 sites. The JSON Schema projection has no arm for a `custom` check; the S-final stage declared its timeline refinement the same way. - **`content/docs/references/ui/component.mdx`** was regenerated by `check:generated --fix`. That run proved this the only stale artifact; the change is the two member `params` rows. ## Census, re-run before writing (at base `5b2d189e28`), with a lit control The question: which `action:group` / `action:menu` members author a non-array `params` on a non-`api` type? **Instrument** (scratchpad `census.cjs`): - A TypeScript-AST walk over `.ts`/`.tsx`/`.js`/`.jsx`/`.mjs`/`.cjs`/`.mts`/`.json` and fenced Markdown code. YAML is read as text. - Pass 1 lists every `params` key in every file that names either block, with its value kind and its owner's `type`. Same-file constants are resolved. A member is classified automatically when its `actions` owner (flat, inside a node's `properties` bag, or through a same-file constant array) carries the block `type`. - Pass 2 lists every non-array `params` on an element of any `actions` array corpus-wide, to catch members built in files that never name a block. - Every non-array hit was read by hand. Helper positions (`mount(surface, entry)`, `schema(member({ … }))`) are resolved that way. - **Lit control:** a planted fixture with four non-`api` object members (flat, inside `properties`, through a const array, in a Markdown fence), one `api` member and one array member. The instrument found and classified all six. Separately, the hand-read loose pass reached objectui's own helper-position drop probes, below. | corpus | files | naming a block | `params` keys there | not an array | container members with a non-array `params` on a non-`api` type | |:--|--:|--:|--:|--:|:--| | objectstack `5b2d189e28` | 10069 | 24 | 32 | 23 | **0**. The 23 are inline `element:button` action conversion fixtures, schema source, the liveness ledger's `params` row, CHANGELOG quotations, and one `properties.params` refusal probe. | | objectui pin `0abd4f9f8` | 7451 | 89 | 47 | 41 | **0 writers.** The only such members are objectui's own tests asserting that the container drops the value: `action-entry-object-params-10462.test.tsx:144`, `:193` and `action-container-member-params-10290.test.tsx:196`. The `type: 'api'` controls beside them stay accepted. | | objectui `main` `f1a177c41` | 7467 | 89 | 47 | 41 | the same; zero hits differ between pin and main | | hotcrm `4054ec2680` | 888 | 0 | 0 | 0 | **0** | | cloud | — | — | — | — | **not reachable** from this session: `git ls-remote` asks for credentials, the API answers 403, and `add_repo` (read) answers "you don't have access" | Deployed metadata was not measured. So the change narrows a zero-writer spelling, and `Clause-②: yes (narrowing)` holds. ## Tests - **New pin** `packages/spec/src/ui/component-action-member-params-array-only.pin.test.ts`, 39 tests: - §1: the refusal on both containers, each case asserting `[{ code: 'custom', path: 'actions.N.params' }]` exactly. The values are an object on `navigate_edit`, on an absent `type` and on `url`; an empty object; a string; a number; and `null`. One more case puts the issue on the second member. The message names the container, the member's `type` and the `action:button` prescription. - §2: the accept set, byte-identical. That is an array on non-`api` and `api` members, an empty array, the `api` member's object and string `params`, no `params`, and `bodyExtra`. A CONTROL shows `action:button` / `action:icon` nodes keep their object `params`. - §3: one complaint only, because the unknown-key refusal is terminal. - §4: the D3 entry is registered with no conversion, and the step-18 rationale names it. - **Ablation, predicted first.** Prediction: 18 red (all of §1), 21 green in the pin, and the two neighbour pins untouched. - Mutation, at `7a28c5194a` (the `component.zod.ts` blob is unchanged since, through the merge): `scripts/ablation-replace.mjs` replaced the refinement's guard with a bare `return` (anchor ×1 → ×0, blob `d8565fd7a8` → `930000300e`), inside a driver carrying its own `EXIT INT TERM` restore trap on the absolute path. - Observed over the three pins: `Tests 18 failed | 160 passed (178)`. The 18 were exactly the §1 cases. - Restore: blob `d8565fd7a8` equals HEAD's, and `git diff HEAD` is empty. - The pin imports `./component.zod` relatively, so it reaches `src` and no `dist` is involved. - **The public door.** lint's `validateComponentProps`, from `src`, ran over this branch's built `@objectstack/spec`: - a `navigate_edit` group member and a menu member with no `type`, each with an object `params`, each give one `component-props-invalid` finding (`warning`) at `pages[0].regions[0].components[0].properties.actions.0.params`, carrying the message above; - CONTROLS give 0 findings: an array on a `script` member, an object on an `api` member, and an object on an `action:button` node. - The before-state is the card's own reading: the door reported nothing for such a member. ## Verification All at head `810b1c4b18` (the merge of `main` `2df3d13d16`, below) unless noted. - **`@objectstack/spec`, `vitest run --project local --maxWorkers=2`** (the package's `test` script), under the verify lock: `Test Files 616 passed (616)`, `Tests 18426 passed | 1 todo`. Before the merge, at `a6f230772f`, both projects (`local` and `repo`): `Test Files 669 passed (669)`, `Tests 19325 passed | 1 todo`. - **`pnpm --filter @objectstack/spec typecheck`**, run at `a6f230772f`: exit 0. That covers `tsc --noEmit`, `check:scripts-typecheck` and `check:test-typecheck: OK` (52 files / 246 errors / 135 signatures held, unchanged). `tsc -p tsconfig.test.json --listFilesOnly` names the new pin, and the D3 entry is in the `tsconfig.json` program. - **`@objectstack/lint`, whole suite**, the component-props gate's package: `Test Files 119 passed (119)`, `Tests 5627 passed`. - **`@objectstack/spec` build, then `check:generated`**: `All 15 generated artifacts are up to date`. The `check:generated --fix` run before the merge proved `check:docs` the only stale artifact, and only it was regenerated. - **Derived gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths) derived 114 commands at `810b1c4b18`, against merge base `2df3d13d1` (7 paths). Each was run with its exit code recorded before any pipe. `--ran` reconciled **114 derived, 114 run, 0 NOT-MEASURED, 0 UNRUN**, and all 114 exited 0. - Six first answered `PREREQUISITE NOT MET` (exit 3) because workspace packages were unbuilt: lint's `check:doc-formula-expressions` and `check:doc-security-posture`, spec's `check:skill-examples`, `check:docs-transcript-drift`, `check:dual-build-cjs-loads` and `check:lean-entry-closure`. After `turbo run build --filter=!@objectstack/docs --concurrency=2` they were re-run at the same head, each reaching its own verdict with exit 0. The record holds the re-runs. - That build reported `@objectstack/hono#build` failed in its DTS-emitted check. The `dist/index.d.ts` it named missing was on disk right after, and a rebuild was green (`31 successful, 31 total`). The adapter is outside this diff. - Among the 114: `check-adr-0087-registration --base origin/main` (one declared-breaking changeset, `registered ui-action-group-menu-member-params-array-only`), `check-changeset-no-major`, `check-empty-changeset`, `check:migration-registry`, `check:spec-changes`, `check:upgrade-guide`, `check:authorable-surface`, `check:api-surface`, `check:docs`, `check:strictness-ledger`, `check:doc-authoring`, `check:issue-citations`, `check:cross-package-test-inputs`, `check:nul-bytes`, `check:type-check-debt`. - **eslint, narrowed and proven.** These three together make the narrowing a measurement: 1. Population: `eslint.config.mjs`'s `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` block covers all four changed `.ts` files. 2. `eslint --no-inline-config --format json`: 4 files, 0 errors, 0 warnings. 3. Invariance: the config enables no type-aware linting (no `parserOptions.project`, as its own comment states), so this diff cannot move a verdict on an untouched file. - **Merge.** `origin/main` moved 4 commits after the base (`5b2d189e28` → `2df3d13d16`). It was merged through `scripts/pm/os-regen-merge.sh` (⛔ no rebase), and none of those commits touches a file of this PR. A re-fetch just before this PR showed 4 more commits on `main`, none touching these files, so no second merge. - **Not measured here:** the full `pnpm lint`, the Console Pin Gate, the Dogfood Regression Gate and the `repo` vitest project after the merge. Reason: CI-owned. ## Acceptance notes - **Interpretation, stated:** "array form only" is read literally. A string, a number or `null` `params` on a non-`api` member is refused as well as an object. The container drops each of those the same way (`readActionEntryParamValues` returns nothing for any non-array value on a non-`api` type), and the census found none of them either. If the reviewer reads the card as objects only, the change is one condition in the refinement's guard plus the string, number and `null` cases in §1. - **The `api` window is untouched, and ending it is not this PR's job.** An `api` member's object `params` stays accepted. When protocol 18 ends that window, the member refinement's `type === 'api'` arm is the one line that moves. Carrier: whoever ends that window. Noted, not filed. - **objectui remainder, already carried.** `readMemberStaticParamValues` still reads a member's `properties.params`, which the spec refuses. objectstack-ai/objectui#11638, as re-scoped by triage (`5991243780`), carries it. No objectui file is touched here. - **Inert number in a hand-edited ledger.** `dropped-refinements.baseline.json`'s `measured.refinementSitesThatDidProject` reads 369, while this build prints "450 refinement site(s) DID reach the file". No gate reads that number. It is left as found; changing it is outside this card. Carrier: none. Noted, not filed. --- _Generated by [Claude Code](https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent aead296 commit 88a39c0

7 files changed

Lines changed: 387 additions & 8 deletions

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
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
6+
7+
Clause-②: yes (narrowing)
8+
9+
<!-- adr-0087: registered ui-action-group-menu-member-params-array-only -->
10+
11+
**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.
12+
13+
**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.
14+
15+
**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`).
16+
17+
**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.
18+
19+
## FROM → TO
20+
21+
| you wrote | write instead |
22+
|:--|:--|
23+
| `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}' } } }` |
24+
| `actions: [{ name: 'save', type: 'api', target: '/api/save', params: { status: 'closed' } }]` | unchanged — or, preferred, `bodyExtra: { status: 'closed' }` |
25+
| `actions: [{ name: 'ask', type: 'script', params: [{ name: 'reason', type: 'text' }] }]` | unchanged — an array is the input list |
26+
27+
**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.
28+
29+
## Who is affected, measured
30+
31+
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.
32+
33+
- **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.
34+
- **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.
35+
- **hotcrm** at `4054ec2680`: no file names either block.
36+
- **cloud** was not reachable from this session. **Deployed metadata** was not measured.
37+
38+
### The kit
39+
40+
- **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.
41+
- **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.

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ const result = ActionButtonPropsSchema.parse(data);
8787
| **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 |
8888
| **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 |
8989
| **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 |
90-
| **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) |
90+
| **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 |
9191
| **description** | `string` | optional | Action description, forwarded to the runner — the parameter dialog shows it under its title |
9292
| **target** | `string` | optional | Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `type` |
9393
| **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);
170170
| **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 |
171171
| **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 |
172172
| **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 |
173-
| **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) |
173+
| **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 |
174174
| **description** | `string` | optional | Action description, forwarded to the runner — the parameter dialog shows it under its title |
175175
| **target** | `string` | optional | Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `type` |
176176
| **openIn** | `Enum<'self' \| 'new-tab'>` | optional | For a `url` action: `self` navigates in place, `new-tab` opens a new browser tab |

‎packages/spec/dropped-refinements.baseline.json‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
"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.",
33
"measured": {
44
"zod": "4.4.3",
5-
"publishedSchemasWithDroppedRefinements": 218,
6-
"droppedRefinementSites": 676,
5+
"publishedSchemasWithDroppedRefinements": 220,
6+
"droppedRefinementSites": 678,
77
"refinementSitesThatDidProject": 369,
88
"refinementSitesWithNoJsonFormToCompare": 0
99
},
@@ -1203,6 +1203,16 @@
12031203
"outputSchema"
12041204
]
12051205
},
1206+
"ui/ActionGroupProps": {
1207+
"sites": [
1208+
"actions.element"
1209+
]
1210+
},
1211+
"ui/ActionMenuProps": {
1212+
"sites": [
1213+
"actions.element"
1214+
]
1215+
},
12061216
"ui/ActionParam": {
12071217
"sites": [
12081218
"in"
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { SemanticMigration } from '../../types.js';
4+
5+
// #21855 — the rows' value ratchet for one member: an `action:group` /
6+
// `action:menu` member's `params` takes the array form (`ActionParam[]`) only,
7+
// unless the member's `type` is `api`, whose object `params` keeps the
8+
// inline-action payload window (#5777) until 18. The maintainer's ruling A on
9+
// objectui#10289 keeps `params` to one shape and declares no other value-bag
10+
// key, and #21704's fork 5 A refused the member's `properties.params`, so a
11+
// member has no static-values spelling and the container drops a non-array
12+
// `params` on any other type at run time. D3 only: page-component `properties`
13+
// is not parsed on the metadata save or load path, so a stored page is never
14+
// refused; there is no D2 conversion, because the only home for static values
15+
// is a different node (an `action:button`), which no rewrite can build in the
16+
// author's place; and the census found no writer to respell.
17+
export const entry: SemanticMigration = {
18+
id: 'ui-action-group-menu-member-params-array-only',
19+
// No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it
20+
// inside a code span.
21+
surface: 'page action:group and action:menu components — a member of properties.actions whose type is not api, '
22+
+ 'and whose params is not an array',
23+
replacement: 'Write `params` as the list of inputs to collect from the user, an `ActionParam[]` array. To run an '
24+
+ 'action with static parameter values, author it as its own `action:button` node, whose `params` object carries '
25+
+ 'them; for a `type: \'api\'` member\'s request body write `bodyExtra`. A member that needs neither drops the key.',
26+
reason: 'An `action:group` or `action:menu` runs each member itself and forwards an array `params` as the input '
27+
+ 'list. It forwards any other `params` value only for a `type: \'api\'` member, as its request payload; for '
28+
+ 'every other `type`, an absent one included, it drops the value, with a development-build warning only. The '
29+
+ 'member declared `params` as any value, so an object `params` on such a member passed the component-props '
30+
+ 'gate and then had no effect: no error and no static values. `params` carries one shape, the input list, and '
31+
+ 'no second value-bag key is declared; a member\'s `properties.params` is already refused, so static parameter '
32+
+ 'values are not part of the inline action vocabulary at all, and the action that needs them is its own '
33+
+ '`action:button` node. The member now refuses a non-array `params` on a non-`api` type at the gate, at '
34+
+ '`actions.N.params`, with that prescription. The `api` member\'s object `params` is unchanged. It is read '
35+
+ 'where every page component\'s props are: the component-props gate reports the refusal as an advisory '
36+
+ '`component-props-invalid` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a '
37+
+ 'stored page still saves and loads, because a page component\'s `properties` is not parsed on the metadata '
38+
+ 'save or load path. No conversion is registered: the static values belong on a different node, and the '
39+
+ 'census found no writer. Deployed metadata NOT MEASURED.',
40+
acceptanceCriteria: 'Every `action:group` and `action:menu` node validates: `objectstack validate` reports no '
41+
+ '`component-props-invalid` finding at `properties.actions.N.params`. Each member whose action needs static '
42+
+ 'parameter values is now its own `action:button` node, and pressing it hands the handler those values. '
43+
+ 'Census at the time of the change: no `action:group` / `action:menu` member authors a non-array `params` on a '
44+
+ 'non-`api` type in this repository, in objectui (at the pinned commit and on its main branch) or in the hotcrm '
45+
+ 'application, outside objectui\'s own tests asserting that the container drops it; the cloud repository was not '
46+
+ 'reachable.',
47+
};

0 commit comments

Comments
 (0)