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
16 changes: 16 additions & 0 deletions .changeset/19148-undoable-capture-set.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@objectstack/spec": patch
---

`ActionSchema.undoable` — the published description now names the WRITTEN set, not `patch` alone (#19148).

**FROM** — "`operation: 'update'` is the declared form of that action — its `patch` names exactly the fields whose prior values are captured."

**TO** — "`operation: 'update'` is the one declared operation and the declared form of that action: what the undo captures is the prior value of EVERY field the action writes — the merged write bag, `patch` UNDER the collected `params`, not `patch` alone. An action with no `operation` declares no write set, so nothing anchors the capture there."

An `operation: 'update'` action writes two sources: the static `patch` AND whatever its `params` collect. On any params-carrying action, "exactly the `patch` fields" is a strict subset of what the action writes, so an Undo built to the old sentence restores part of the change and reports the action as undone.

- **Prose only — no schema change, no accept/reject outcome moves.** The same author input parses the same way before and after; `Clause-②: no`.
- **The executor already captured the union.** `executeDeclarativeUpdateAction` keys `undoData` off `Object.keys(data)`, `data` being `declarativeUpdateWrite`'s merged bag `{ ...patch, ...params }`. The sentence was the outlier, and the EXECUTOR CONTRACT doc block ~200 lines above in the same file already read "exactly the fields written".
- **One operation, one rule.** The `operation` enum carries exactly one member, `'update'` (`'delete'` and `'custom'` are refused with their reason), so the per-operation capture rule is a one-row rule and is written as one.
- The describe text renders into three generated reference tables (`ui/action`, `data/object`, `kernel/metadata-plugin`), regenerated here; the hand-written protocol page `content/docs/protocol/objectui/actions.mdx` carried the identical claim and is corrected in the same edit.
2 changes: 1 addition & 1 deletion content/docs/protocol/objectui/actions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ The `type` field selects how an action is dispatched. The complete enum is **`sc
| `api` | Call an API endpoint (`method` defaults to `POST`). | Yes |
| `form` | Open a FormView by name, routed to `/_console/forms/:name` **inside the console shell**; the submit lands on the created record. | Yes |

The one thing that is not a type: the **declarative single-record field write**. It is spelled `operation: 'update'` + `patch` — the same words a list view's `bulkActionDefs` uses for the bulk form — and it rides the default `script` route: the platform action route performs the write on the data plane **as the caller** (never system-elevated), so the caller's permissions, the object's hooks and its validations fire exactly as for a user edit. Beside `operation: 'update'` an action carries no executor binding of its own — `target`, `body`, `method`, `bodyExtra` and any other explicit `type` are refused — and `patch` is merged under whatever the `params` dialog collects, so a fixed value can be declared without exposing it in the dialog. `undoable` has its anchor here: the patch names exactly the fields whose prior values are captured.
The one thing that is not a type: the **declarative single-record field write**. It is spelled `operation: 'update'` + `patch` — the same words a list view's `bulkActionDefs` uses for the bulk form — and it rides the default `script` route: the platform action route performs the write on the data plane **as the caller** (never system-elevated), so the caller's permissions, the object's hooks and its validations fire exactly as for a user edit. Beside `operation: 'update'` an action carries no executor binding of its own — `target`, `body`, `method`, `bodyExtra` and any other explicit `type` are refused — and `patch` is merged under whatever the `params` dialog collects, so a fixed value can be declared without exposing it in the dialog. `undoable` has its anchor here: the capture is the prior value of every field the action writes — the merged bag, `patch` under `params`, not `patch` alone.

```ts
{ name: 'task_complete', label: 'Complete', operation: 'update', patch: { status: 'done' },
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/data/object.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -461,7 +461,7 @@ const result = ApiMethod.parse(data);
| **successMessage** | `string \| Record<string, string>` | optional | Success message to show after execution |
| **errorMessage** | `string \| Record<string, string>` | optional | Error message to show when the action fails (overrides the raw error). |
| **refreshAfter** | `boolean` | optional (default: `false`) | Refresh view after execution |
| **undoable** | `boolean` | optional | Offer an Undo affordance after this single-record update action succeeds. `operation: 'update'` is the declared form of that action — its `patch` names exactly the fields whose prior values are captured. |
| **undoable** | `boolean` | optional | Offer an Undo affordance after this single-record update action succeeds. `operation: 'update'` is the one declared operation and the declared form of that action: what the undo captures is the prior value of EVERY field the action writes — the merged write bag, `patch` UNDER the collected `params`, not `patch` alone. An action with no `operation` declares no write set, so nothing anchors the capture there. |
| **resultDialog** | `{ title?: string \| Record<string, string>; description?: string \| Record<string, string>; acknowledge?: string \| Record<string, string>; format?: Enum<'qrcode' \| 'code-list' \| 'secret' \| 'text' \| 'json'>; … }` | optional | Render API response in a one-shot reveal dialog (suppresses successMessage when set). |
| **visible** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is offered when it evaluates TRUE. Omit = always visible. |
| **requiresFeature** | `Enum<'twoFactor' \| 'organization' \| 'multiOrgEnabled' \| 'degradedTenancy' \| …>` | optional | Public auth feature flag gating this action; lowered into `visible` at parse time. |
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/kernel/metadata-plugin.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,7 @@ const result = MetadataBulkResultSchema.parse(data);
| **successMessage** | `string \| Record<string, string>` | optional | Success message to show after execution |
| **errorMessage** | `string \| Record<string, string>` | optional | Error message to show when the action fails (overrides the raw error). |
| **refreshAfter** | `boolean` | optional (default: `false`) | Refresh view after execution |
| **undoable** | `boolean` | optional | Offer an Undo affordance after this single-record update action succeeds. `operation: 'update'` is the declared form of that action — its `patch` names exactly the fields whose prior values are captured. |
| **undoable** | `boolean` | optional | Offer an Undo affordance after this single-record update action succeeds. `operation: 'update'` is the one declared operation and the declared form of that action: what the undo captures is the prior value of EVERY field the action writes — the merged write bag, `patch` UNDER the collected `params`, not `patch` alone. An action with no `operation` declares no write set, so nothing anchors the capture there. |
| **resultDialog** | `{ title?: string \| Record<string, string>; description?: string \| Record<string, string>; acknowledge?: string \| Record<string, string>; format?: Enum<'qrcode' \| 'code-list' \| 'secret' \| 'text' \| 'json'>; … }` | optional | Render API response in a one-shot reveal dialog (suppresses successMessage when set). |
| **visible** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is offered when it evaluates TRUE. Omit = always visible. |
| **requiresFeature** | `Enum<'twoFactor' \| 'organization' \| 'multiOrgEnabled' \| 'degradedTenancy' \| …>` | optional | Public auth feature flag gating this action; lowered into `visible` at parse time. |
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/ui/action.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ const result = ActionSchema.parse(data);
| **successMessage** | `string \| Record<string, string>` | optional | Success message to show after execution |
| **errorMessage** | `string \| Record<string, string>` | optional | Error message to show when the action fails (overrides the raw error). |
| **refreshAfter** | `boolean` | optional (default: `false`) | Refresh view after execution |
| **undoable** | `boolean` | optional | Offer an Undo affordance after this single-record update action succeeds. `operation: 'update'` is the declared form of that action — its `patch` names exactly the fields whose prior values are captured. |
| **undoable** | `boolean` | optional | Offer an Undo affordance after this single-record update action succeeds. `operation: 'update'` is the one declared operation and the declared form of that action: what the undo captures is the prior value of EVERY field the action writes — the merged write bag, `patch` UNDER the collected `params`, not `patch` alone. An action with no `operation` declares no write set, so nothing anchors the capture there. |
| **resultDialog** | `{ title?: string \| Record<string, string>; description?: string \| Record<string, string>; acknowledge?: string \| Record<string, string>; format?: Enum<'qrcode' \| 'code-list' \| 'secret' \| 'text' \| 'json'>; … }` | optional | Render API response in a one-shot reveal dialog (suppresses successMessage when set). |
| **visible** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is offered when it evaluates TRUE. Omit = always visible. |
| **requiresFeature** | `Enum<'twoFactor' \| 'organization' \| 'multiOrgEnabled' \| 'degradedTenancy' \| 'oidcProvider' \| 'sso' \| 'ssoEnforced' \| 'deviceAuthorization' \| 'admin' \| 'phoneNumber' \| 'phoneNumberOtp'>` | optional | Public auth feature flag gating this action; lowered into `visible` at parse time. |
Expand Down
15 changes: 11 additions & 4 deletions packages/spec/src/ui/action.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1307,10 +1307,17 @@ const actionObject = () => strictObject({
// Single-record update actions only. When true, the runtime captures the
// record's prior field values and offers an "Undo" affordance on the success
// toast (backed by the client UndoManager) to restore them. `operation:
// 'update'` is the DECLARED form of such an action (#14092): its `patch`
// names exactly the fields written, so the capture is exact rather than
// inferred from a handler's side effects — the anchor this flag lacked.
undoable: z.boolean().optional().describe("Offer an Undo affordance after this single-record update action succeeds. `operation: 'update'` is the declared form of that action — its `patch` names exactly the fields whose prior values are captured."),
// 'update'` is the DECLARED form of such an action (#14092), and it is the
// ONE member the operation enum carries — so there is one capture rule, not
// one per operation. The set is the write bag `{ ...patch, ...params }`
// (contract point 4, `patch` UNDER `params`), which is what the executor
// reads back: `executeDeclarativeUpdateAction` keys `undoData` off
// `Object.keys(data)`, `data` being that merged bag — NOT `patch` alone.
// Naming `patch` alone here is what let a consumer build a half-restore and
// report it as a full undo. Without `operation` nothing declares a write
// set, so the capture is inferred from a handler's side effects — the
// un-anchored case this flag started in.
undoable: z.boolean().optional().describe("Offer an Undo affordance after this single-record update action succeeds. `operation: 'update'` is the one declared operation and the declared form of that action: what the undo captures is the prior value of EVERY field the action writes — the merged write bag, `patch` UNDER the collected `params`, not `patch` alone. An action with no `operation` declares no write set, so nothing anchors the capture there."),

/**
* Result Dialog — describe how to render the API response on success.
Expand Down
Loading