diff --git a/.changeset/19148-undoable-capture-set.md b/.changeset/19148-undoable-capture-set.md new file mode 100644 index 00000000000..021416ef59a --- /dev/null +++ b/.changeset/19148-undoable-capture-set.md @@ -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. diff --git a/content/docs/protocol/objectui/actions.mdx b/content/docs/protocol/objectui/actions.mdx index 77c2a7e2424..b467d104be2 100644 --- a/content/docs/protocol/objectui/actions.mdx +++ b/content/docs/protocol/objectui/actions.mdx @@ -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' }, diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index cde88732c5c..c52cb2da670 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -461,7 +461,7 @@ const result = ApiMethod.parse(data); | **successMessage** | `string \| Record` | optional | Success message to show after execution | | **errorMessage** | `string \| Record` | 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; description?: string \| Record; acknowledge?: string \| Record; 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. | diff --git a/content/docs/references/kernel/metadata-plugin.mdx b/content/docs/references/kernel/metadata-plugin.mdx index c9f251a0d67..522dcb8d71d 100644 --- a/content/docs/references/kernel/metadata-plugin.mdx +++ b/content/docs/references/kernel/metadata-plugin.mdx @@ -330,7 +330,7 @@ const result = MetadataBulkResultSchema.parse(data); | **successMessage** | `string \| Record` | optional | Success message to show after execution | | **errorMessage** | `string \| Record` | 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; description?: string \| Record; acknowledge?: string \| Record; 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. | diff --git a/content/docs/references/ui/action.mdx b/content/docs/references/ui/action.mdx index e6b89ff5d33..98b029af98c 100644 --- a/content/docs/references/ui/action.mdx +++ b/content/docs/references/ui/action.mdx @@ -49,7 +49,7 @@ const result = ActionSchema.parse(data); | **successMessage** | `string \| Record` | optional | Success message to show after execution | | **errorMessage** | `string \| Record` | 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; description?: string \| Record; acknowledge?: string \| Record; 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. | diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index d8616b2c5ff..40c9ff6136e 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -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.