Skip to content
Merged
43 changes: 43 additions & 0 deletions .changeset/23427-page-action-row-declarative-update-rules.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
'@objectstack/spec': minor
---

feat(spec)!: a page action row is judged by `ActionSchema`'s declarative-update rules, from the one judge both read

Clause-②: no (narrowing)

<!-- adr-0087: registered ui-page-action-row-declarative-update-rules -->

**BREAKING**: an accept-set narrowing on four published page component rows, shipped as `minor` under the launch-window convention for accept-set narrowings.

The page action rows are `action:button` and `action:icon` (`properties`), and each member of an `action:group` or `action:menu` (`properties.actions[]`). Each forwards `operation`, `patch`, `params`, `target`, its executor key and `locations` to the console's action runner, which reads `operation` before any executor: `operation: 'update'` posts the write through the platform action route and runs no executor of its own. `ActionSchema` refused the shapes below on an action, and the rows accepted them. The rows now apply the same judge (`ActionSchema`'s texts are unchanged), with the executor spelled as the row spells it (`actionType` on a node, `type` on a member):

- `patch` without `operation: 'update'` is refused at `patch` (it is never written);
- beside `operation: 'update'`:
- an executor other than `'script'` is refused at the executor key;
- `target`, `method`, `bodyExtra`, `bodyShape` and, on `action:button`, `recordIdField` are each refused at their own key;
- a row with nothing to write is refused at `patch`;
- a `list_toolbar` location is refused at `locations.N`;
- a row's `patch` is `ActionSchema`'s own record of field values: a string, array or number `patch` is refused at `patch` (the runner dropped it before the write).

On `action:button` and `action:icon`, a non-empty object `params` (the node's static values, which the write merges over `patch`) counts as something to write. On a container member it does not. Beside the operation, the rows' url-target and `'navigation'` rename refusals stand down, because the update's refusal of `target` and of the executor covers both keys.

### FROM → TO

| before (on a row) | what to write instead |
| --- | --- |
| `{ actionType: 'script', patch: { status: 'done' } }` (no `operation`) | `{ operation: 'update', patch: { status: 'done' } }` for the write, or drop `patch` |
| `{ operation: 'update', patch: { status: 'done' }, target: 'close_task' }` | the same row without `target`: the platform action route performs the write |
| `{ operation: 'update', patch: { status: 'done' }, actionType: 'url' }` (or `type` on a member, or any other executor) | the same row with no executor key, or `'script'` |
| `{ operation: 'update', patch: { … }, method: 'PATCH' }` (likewise `bodyExtra`, `bodyShape`, `recordIdField`) | the same row without that key (static field values go in `patch`) |
| `{ operation: 'update' }` (no `patch`, empty `params`) | add `patch: { … }`, or `params` that supply the values |
| `{ operation: 'update', patch: { … }, locations: ['list_toolbar'] }` | a record location (`list_item`, `record_header`, `record_more`, `record_section`), or a list view `bulkActionDefs` entry for the selection bar |
| `{ operation: 'update', patch: 'done' }` | `patch: { status: 'done' }` |

A row that meant a call of its own keeps its executor and `target`, and drops `operation` and `patch` instead.

**The one-line fix: write a page action row's declarative update as `ActionSchema` accepts it, with `operation: 'update'` and a `patch` record, no executor or `'script'`, no dispatch keys, and record locations only.** `os migrate meta --from 17` lists the D3 entry `ui-page-action-row-declarative-update-rules`; the rewrite is by hand.

**What an author now sees.** `os validate`'s props gate reports each refusal at the row's own key (`properties.…`, or `properties.actions.N.…` for a member), with `ActionSchema`'s text. For example, `{ operation: 'update', patch: { status: 'done' }, actionType: 'url' }` on an `action:button` gets ``… so `actionType` names that route or nothing; leave it off. …`` at `properties.actionType`.

**Measured producers.** None in this repository: its examples, the CLI corpus, the dogfood fixtures and the package tests carry none, apart from these rows' own pins. Two landed pins put `api` or a host handler beside the update, and they now name `'script'`. In objectui, at its pin `b9814b334` and its main `524136e`, no page action row with these shapes is parsed by the spec. One renderer-test control mounts an `action:button` with `patch` and no `operation`, to show nothing is written.
16 changes: 8 additions & 8 deletions content/docs/references/ui/component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ const result = ActionButtonPropsSchema.parse(data);
| **method** | `string` | optional | HTTP method for an `api` action, forwarded to the runner |
| **bodyExtra** | `any` | optional | Static request-body fields for an `api` action, forwarded to the runner |
| **bodyShape** | `any` | optional | How an `api` action shapes its request body, forwarded to the runner |
| **operation** | `Enum<'update'>` | optional | Declarative single-record write, forwarded to the runner together with `patch`: `ActionSchema`'s own `operation`, whose one member is `update`. The runner reads it before any executor, performs the write through the platform action route and runs no executor of its own |
| **patch** | `any` | optional | Field values the declarative `operation` writes, forwarded to the runner |
| **operation** | `Enum<'update'>` | optional | Declarative single-record write, forwarded to the runner together with `patch`: `ActionSchema`'s own `operation`, whose one member is `update`. The runner reads it before any executor, performs the write through the platform action route and runs no executor of its own. Judged as `ActionSchema` judges it: beside it the executor key is absent or `script`, `target`, `method`, `bodyExtra`, `bodyShape`, `recordIdField` and `onSuccess` are refused, a `patch` or `params` gives the values to write, and `list_toolbar` is not a location |
| **patch** | `Record<string, any>` | optional | Static field values the declarative `operation: 'update'` writes, forwarded to the runner: `ActionSchema`'s own record of field values, merged UNDER the values `params` supplies. Refused on a row without `operation: 'update'` (it would be silently dropped) |
| **confirmText** | `string` | optional | Confirmation question asked before the action runs |
| **successMessage** | `string` | optional | Toast shown when the action succeeds |
| **errorMessage** | `string` | optional | Toast shown when the action fails, in place of the raw error |
Expand Down Expand Up @@ -101,8 +101,8 @@ const result = ActionButtonPropsSchema.parse(data);
| **method** | `string` | optional | HTTP method for an `api` action, forwarded to the runner |
| **bodyExtra** | `any` | optional | Static request-body fields for an `api` action, forwarded to the runner |
| **bodyShape** | `any` | optional | How an `api` action shapes its request body, forwarded to the runner |
| **operation** | `Enum<'update'>` | optional | Declarative single-record write, forwarded to the runner together with `patch`: `ActionSchema`'s own `operation`, whose one member is `update`. The runner reads it before any executor, performs the write through the platform action route and runs no executor of its own |
| **patch** | `any` | optional | Field values the declarative `operation` writes, forwarded to the runner |
| **operation** | `Enum<'update'>` | optional | Declarative single-record write, forwarded to the runner together with `patch`: `ActionSchema`'s own `operation`, whose one member is `update`. The runner reads it before any executor, performs the write through the platform action route and runs no executor of its own. Judged as `ActionSchema` judges it: beside it the executor key is absent or `script`, `target`, `method`, `bodyExtra`, `bodyShape` and `onSuccess` are refused, a `patch` or `params` gives the values to write, and `list_toolbar` is not a location |
| **patch** | `Record<string, any>` | optional | Static field values the declarative `operation: 'update'` writes, forwarded to the runner: `ActionSchema`'s own record of field values, merged UNDER the values `params` supplies. Refused on a row without `operation: 'update'` (it would be silently dropped) |
| **confirmText** | `string` | optional | Confirmation question asked before the action runs |
| **successMessage** | `string` | optional | Toast shown when the action succeeds |
| **errorMessage** | `string` | optional | Toast shown when the action fails, in place of the raw error |
Expand Down Expand Up @@ -137,8 +137,8 @@ const result = ActionButtonPropsSchema.parse(data);
| **method** | `string` | optional | HTTP method for an `api` action, forwarded to the runner |
| **bodyExtra** | `any` | optional | Static request-body fields for an `api` action, forwarded to the runner |
| **bodyShape** | `any` | optional | How an `api` action shapes its request body, forwarded to the runner |
| **operation** | `Enum<'update'>` | optional | Declarative single-record write, forwarded to the runner together with `patch`: `ActionSchema`'s own `operation`, whose one member is `update`. The runner reads it before any executor, performs the write through the platform action route and runs no executor of its own |
| **patch** | `any` | optional | Field values the declarative `operation` writes, forwarded to the runner |
| **operation** | `Enum<'update'>` | optional | Declarative single-record write, forwarded to the runner together with `patch`: `ActionSchema`'s own `operation`, whose one member is `update`. The runner reads it before any executor, performs the write through the platform action route and runs no executor of its own. Judged as `ActionSchema` judges it: beside it the executor key is absent or `script`, `target`, `method`, `bodyExtra`, `bodyShape` and `onSuccess` are refused, a `patch` or `params` gives the values to write, and `list_toolbar` is not a location |
| **patch** | `Record<string, any>` | optional | Static field values the declarative `operation: 'update'` writes, forwarded to the runner: `ActionSchema`'s own record of field values, merged UNDER the values `params` supplies. Refused on a row without `operation: 'update'` (it would be silently dropped) |
| **confirmText** | `string` | optional | Confirmation question asked before the action runs |
| **successMessage** | `string` | optional | Toast shown when the action succeeds |
| **errorMessage** | `string` | optional | Toast shown when the action fails, in place of the raw error |
Expand Down Expand Up @@ -191,8 +191,8 @@ const result = ActionButtonPropsSchema.parse(data);
| **method** | `string` | optional | HTTP method for an `api` action, forwarded to the runner |
| **bodyExtra** | `any` | optional | Static request-body fields for an `api` action, forwarded to the runner |
| **bodyShape** | `any` | optional | How an `api` action shapes its request body, forwarded to the runner |
| **operation** | `Enum<'update'>` | optional | Declarative single-record write, forwarded to the runner together with `patch`: `ActionSchema`'s own `operation`, whose one member is `update`. The runner reads it before any executor, performs the write through the platform action route and runs no executor of its own |
| **patch** | `any` | optional | Field values the declarative `operation` writes, forwarded to the runner |
| **operation** | `Enum<'update'>` | optional | Declarative single-record write, forwarded to the runner together with `patch`: `ActionSchema`'s own `operation`, whose one member is `update`. The runner reads it before any executor, performs the write through the platform action route and runs no executor of its own. Judged as `ActionSchema` judges it: beside it the executor key is absent or `script`, `target`, `method`, `bodyExtra`, `bodyShape` and `onSuccess` are refused, a `patch` or `params` gives the values to write, and `list_toolbar` is not a location |
| **patch** | `Record<string, any>` | optional | Static field values the declarative `operation: 'update'` writes, forwarded to the runner: `ActionSchema`'s own record of field values, merged UNDER the values `params` supplies. Refused on a row without `operation: 'update'` (it would be silently dropped) |
| **confirmText** | `string` | optional | Confirmation question asked before the action runs |
| **successMessage** | `string` | optional | Toast shown when the action succeeds |
| **errorMessage** | `string` | optional | Toast shown when the action fails, in place of the raw error |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,21 +111,24 @@ describe("the props gate refuses a page action row's `onSuccess` beside an execu

// `operation` on a page action row: `ActionSchema`'s own enum, and its refusal
// of `onSuccess` beside `operation: 'update'` (the runner performs the write
// before any executor), whatever executor the row names.
// before any executor). Beside the operation a row names no executor or
// `script`, the route the write is posted to; every other executor there is
// refused at the executor key (`@objectstack/spec`'s
// `component-action-row-declarative-update.test.ts`).

describe("the props gate judges a page action row's `operation` as `ActionSchema` does", () => {
const UPDATE = { operation: 'update', patch: { status: 'done' } };

it.each(ROWS)("REFUSAL: %s with `api`, `operation: 'update'` and `onSuccess` is one finding at `onSuccess`", (_row, node, at, key, subject) => {
const findings = validateComponentProps(stackWith(node({ [key]: 'api', ...UPDATE, onSuccess: { navigate: '/tasks' } })));
it.each(ROWS)("REFUSAL: %s with `script`, `operation: 'update'` and `onSuccess` is one finding at `onSuccess`", (_row, node, at, key, subject) => {
const findings = validateComponentProps(stackWith(node({ [key]: 'script', ...UPDATE, onSuccess: { navigate: '/tasks' } })));
expect(findings.map((f) => [f.rule, f.path])).toEqual([[COMPONENT_PROPS_INVALID, `${at}.onSuccess`]]);
expect(findings[0].message).toContain(
`${ownerOf(subject)} \`onSuccess\` (\`operation: 'update'\`) would follow a result the contract does not declare:`,
);
});

it.each(ROWS)("ACCEPTED CONTROL: %s with `api` and `operation: 'update'` and no `onSuccess` reports nothing", (_row, node, _at, key) => {
expect(validateComponentProps(stackWith(node({ [key]: 'api', ...UPDATE })))).toEqual([]);
it.each(ROWS)("ACCEPTED CONTROL: %s with `script` and `operation: 'update'` and no `onSuccess` reports nothing", (_row, node, _at, key) => {
expect(validateComponentProps(stackWith(node({ [key]: 'script', ...UPDATE })))).toEqual([]);
});

it.each(ROWS)("REFUSAL: %s with `operation: 'delete'` is one finding at `operation`", (_row, node, at) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import type { SemanticMigration } from '../../types.js';

export const entry: SemanticMigration = {
id: 'ui-page-action-row-declarative-update-rules',
surface: 'a page action row (`properties` of an `action:button` or `action:icon`, and each `action:group` / '
+ '`action:menu` member of `properties.actions`) that carries `patch` without `operation: \'update\'`, or '
+ 'that carries `operation: \'update\'` beside an executor other than `\'script\'` (`actionType` on a node, '
+ '`type` on a member), beside `target`, `method`, `bodyExtra`, `bodyShape` or (on `action:button`) '
+ '`recordIdField`, with nothing to write (no `patch`, and no non-empty `params`), or with `list_toolbar` in '
+ '`locations`. Also a row `patch` that is not a record of field values',
replacement: 'Write the row as `ActionSchema` accepts the same action: `operation: \'update\'` with a `patch` '
+ 'record (or `params` that supply the values), no executor key or `\'script\'`, none of the dispatch keys, '
+ 'and record locations only. A row that meant a call of its own keeps its executor and its `target` and '
+ 'drops `operation` and `patch` instead. ⛔ NOT mechanically convertible: whether a row meant the '
+ 'declarative write or the executor is the author\'s call.',
reason:
'Every page action row forwards `operation`, `patch`, `params`, `target`, its executor key and `locations` '
+ 'to the console\'s action runner, and the runner reads `operation` before any executor: it posts the write '
+ 'through its `script` dispatch and runs no executor of its own, so beside it the executor and the keys '
+ 'that bind one are never read, a `patch` without it is never written, and a `patch` that is not a record '
+ 'is dropped before the write. `ActionSchema` refused each of these shapes on an action; the rows accepted '
+ 'them. The rows now apply the same judge, read from one place, with the action\'s own texts (the executor '
+ 'spelled as the row spells it), and take `ActionSchema`\'s own `patch` record by reference. A page\'s '
+ '`properties` is not parsed on the metadata save or load path; `os validate`\'s props gate parses it '
+ 'against these same rows and reports each refusal at the row\'s own key. Measured producers: none in this '
+ 'repository\'s examples, CLI corpus or tests outside these rows\' own pins; in objectui, at its pin and '
+ 'main, one renderer-test control mounts an `action:button` with `patch` and no `operation` to show nothing '
+ 'is written, and no spec parse judges it.',
acceptanceCriteria:
'No page action row carries `patch` without `operation: \'update\'`; every row with `operation: \'update\'` '
+ 'names no executor or `\'script\'`, carries none of `target`, `method`, `bodyExtra`, `bodyShape` and '
+ '`recordIdField`, has a `patch` record or non-empty `params`, and lists no `list_toolbar` location; '
+ '`os validate` reports no `component-props-invalid` finding at `properties.KEY` or '
+ '`properties.actions.N.KEY` for those keys, and every such row parses as before.',
};
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,5 @@ export const entry: SemanticMigration = {
'Every page action row\'s `operation` is absent or `\'update\'`, and no row with `operation: \'update\'` carries '
+ '`onSuccess`; `os validate` reports no `component-props-invalid` finding at `properties.operation`, '
+ '`properties.actions.N.operation`, or at the `onSuccess` of an `operation: \'update\'` row, and every such row '
+ 'parses as before.',
+ 'parses as before unless `ui-page-action-row-declarative-update-rules` refuses it.',
};
Loading
Loading