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
44 changes: 44 additions & 0 deletions .changeset/19297-undoable-precise-refusal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
---
'@objectstack/spec': minor
---

**BREAKING for authored metadata** — `undoable: true` on a registered `action` is now legal only on a shape some runtime actually fulfils, and refused at parse time everywhere else.

Clause-②: yes

The accept set narrows. `undoable` was a plain optional boolean that no refinement read, so it parsed clean on every action shape while only two of them ever produced an Undo — the declared-but-inert case the spec refuses at author time (ADR-0078).

**The two fulfilled shapes, and which runtime fulfils each**

| shape | who takes the snapshot |
| --- | --- |
| `operation: 'update'` (with a `patch`) | the framework runtime — the prior value of every field in the merged write bag, `patch` UNDER the collected `params` |
| `type: 'api'` | the pinned console — it builds the undo envelope from `undoable` alone |

Both stay accepted, byte-identically. Naming the console in the contract is deliberate: the spec is the contract for every runtime including the console, and a closed table of fulfillable combinations is what the declared-is-delivered rule asks for.

**What is refused**

`undoable: true` on `type: 'script'` (the default route) or `type: 'url'`, and on the dormant `type: 'flow'` / `'modal'` / `'form'`, in each case without `operation: 'update'`. Nothing reads the flag on those shapes, so it promised an Undo that never appeared.

```
✗ undoable: `undoable: true` has no runtime that can fulfil it on this action. An Undo is
captured on exactly two shapes: `operation: 'update'`, where the framework runtime snapshots
the prior value of every field the write bag touches, and `type: 'api'`, which the console
snapshots. …
```

**⛔ What is deliberately NOT refused, because it was measured wrong.** Requiring `operation: 'update'` — the obvious repair — would refuse the published `ReassignLeadAction` skill example (`type: 'api'` + `undoable: true`, no `operation`) at import time, since `defineAction` IS `ActionSchema.parse`, and every console api action with undo along with it. The console's two readers gate the undo envelope on `action.undoable` alone with zero reads of `action.operation`, and those same two files are the entire recorded evidence for this package's own liveness verdict `action/undoable: live`. `undoable` absent or `false` is untouched on every type, and the rule lives on `ActionSchema`'s refine chain alone — an inline action is not a registered action.

### Migration — FROM → TO

| You wrote | Write instead |
| --- | --- |
| `{ type: 'script', body, undoable: true }` | `{ operation: 'update', patch: { … }, undoable: true }` if the action is a single-record field write, or drop `undoable` and keep the handler |
| `{ type: 'url' \| 'flow' \| 'modal' \| 'form', undoable: true }` | the same action without `undoable` — those routes never had a capture, so behaviour is unchanged |

⛔ Not mechanically convertible, so this ships as an ADR-0087 D3 structured TODO rather than a D2 conversion: which of the two fulfilled shapes an author meant is an intent no artifact records — a `script` action with an inline handler and an api action calling an endpoint are different dispatches, not two spellings of one — and dropping the flag automatically would remove an Undo the author asked for.

<!-- adr-0087: registered ui-action-undoable-unfulfillable-refused -->

**Published surface.** No export is added, removed or renamed; `ActionType` still carries all six types. The `undoable` `.describe()` and the comment above it are corrected in the same change: both claimed that an action with no `operation` has nothing anchoring the capture, which is false against the pinned console.
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 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. |
| **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 here, but `type: 'api'` is fulfilled by the console, which builds the undo envelope from `undoable` alone. Those two shapes — `operation: 'update'` and `type: 'api'` — are the whole fulfillable set; on a registered action `undoable` beside any other shape is refused, because nothing would anchor the capture. |
| **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 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. |
| **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 here, but `type: 'api'` is fulfilled by the console, which builds the undo envelope from `undoable` alone. Those two shapes — `operation: 'update'` and `type: 'api'` — are the whole fulfillable set; on a registered action `undoable` beside any other shape is refused, because nothing would anchor the capture. |
| **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 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. |
| **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 here, but `type: 'api'` is fulfilled by the console, which builds the undo envelope from `undoable` alone. Those two shapes — `operation: 'update'` and `type: 'api'` — are the whole fulfillable set; on a registered action `undoable` beside any other shape is refused, because nothing would anchor the capture. |
| **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
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

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

export const entry: SemanticMigration = {
id: 'ui-action-undoable-unfulfillable-refused',
surface: '`action` documents declaring `undoable: true` on a shape no runtime fulfils — '
+ "`type: 'script'` (the default route) and `type: 'url'`, plus the dormant "
+ "`type: 'flow'` / `'modal'` / `'form'`, in each case WITHOUT `operation: 'update'`",
replacement: "either of the two fulfilled shapes — `operation: 'update'` with a `patch`, "
+ 'where the framework runtime snapshots the prior value of every field in the merged '
+ "write bag, or `type: 'api'`, where the pinned console builds the undo envelope — or "
+ 'no `undoable` at all. ⛔ NOT mechanically convertible: which of the two the author '
+ 'meant is an intent no artifact records (a `script` action with an inline handler and '
+ 'an api action calling an endpoint are different dispatches, not two spellings of '
+ 'one), and dropping the flag silently would remove an Undo the author asked for. The '
+ 'refusal names both shapes and the drop, and the author chooses',
reason:
'The key was a plain optional boolean read by no refinement, so every combination '
+ 'parsed clean while only two of them ever produced an Undo — the declared-but-inert '
+ 'shape ADR-0078 refuses at author time. ⚠️ The obvious repair, requiring '
+ "`operation: 'update'`, was MEASURED WRONG and is deliberately not what this entry "
+ "records: the pinned console's two readers gate the undo envelope on "
+ '`action.undoable` alone with zero reads of `action.operation`, and those same two '
+ "files are the entire recorded evidence for this package's own liveness verdict "
+ '`action/undoable: live`. A blanket requirement would therefore have refused the '
+ 'published `ReassignLeadAction` skill example (`type: \'api\'` + `undoable: true`, no '
+ '`operation`) at import time, since `defineAction` IS `ActionSchema.parse`, and every '
+ 'console api action with undo along with it. So the accepted set is closed to the '
+ 'two shapes some runtime fulfils rather than to the one the framework runtime '
+ 'fulfils. Stating "`type: \'api\'` is fulfilled by the console" in the contract is '
+ 'the point, not a leak: the spec is the contract for every runtime including the '
+ 'console, and a closed table of fulfillable combinations is what the '
+ 'declared-is-delivered rule asks for.',
acceptanceCriteria:
"Every `action` document declaring `undoable: true` carries `operation: 'update'` or "
+ "`type: 'api'`. Both fulfilled shapes parse byte-identically to before — the "
+ 'published `ReassignLeadAction` example included — and an action with `undoable` '
+ 'absent or `false` is untouched on every type. An action declaring `undoable: true` '
+ 'on any other shape is refused with a per-key issue at `undoable` whose message names '
+ 'both fulfilling shapes and the runtime that fulfils each; the author adds the shape '
+ 'they meant or drops the flag.',
};
39 changes: 39 additions & 0 deletions packages/spec/src/migrations/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12319,6 +12319,45 @@ const step18: MigrationStep = {
+ 'and must be verified as such: nothing ever parsed or read these shapes, so removing '
+ 'them removes no behaviour.',
},
{
id: 'ui-action-undoable-unfulfillable-refused',
surface: '`action` documents declaring `undoable: true` on a shape no runtime fulfils — '
+ "`type: 'script'` (the default route) and `type: 'url'`, plus the dormant "
+ "`type: 'flow'` / `'modal'` / `'form'`, in each case WITHOUT `operation: 'update'`",
replacement: "either of the two fulfilled shapes — `operation: 'update'` with a `patch`, "
+ 'where the framework runtime snapshots the prior value of every field in the merged '
+ "write bag, or `type: 'api'`, where the pinned console builds the undo envelope — or "
+ 'no `undoable` at all. ⛔ NOT mechanically convertible: which of the two the author '
+ 'meant is an intent no artifact records (a `script` action with an inline handler and '
+ 'an api action calling an endpoint are different dispatches, not two spellings of '
+ 'one), and dropping the flag silently would remove an Undo the author asked for. The '
+ 'refusal names both shapes and the drop, and the author chooses',
reason:
'The key was a plain optional boolean read by no refinement, so every combination '
+ 'parsed clean while only two of them ever produced an Undo — the declared-but-inert '
+ 'shape ADR-0078 refuses at author time. ⚠️ The obvious repair, requiring '
+ "`operation: 'update'`, was MEASURED WRONG and is deliberately not what this entry "
+ "records: the pinned console's two readers gate the undo envelope on "
+ '`action.undoable` alone with zero reads of `action.operation`, and those same two '
+ "files are the entire recorded evidence for this package's own liveness verdict "
+ '`action/undoable: live`. A blanket requirement would therefore have refused the '
+ 'published `ReassignLeadAction` skill example (`type: \'api\'` + `undoable: true`, no '
+ '`operation`) at import time, since `defineAction` IS `ActionSchema.parse`, and every '
+ 'console api action with undo along with it. So the accepted set is closed to the '
+ 'two shapes some runtime fulfils rather than to the one the framework runtime '
+ 'fulfils. Stating "`type: \'api\'` is fulfilled by the console" in the contract is '
+ 'the point, not a leak: the spec is the contract for every runtime including the '
+ 'console, and a closed table of fulfillable combinations is what the '
+ 'declared-is-delivered rule asks for.',
acceptanceCriteria:
"Every `action` document declaring `undoable: true` carries `operation: 'update'` or "
+ "`type: 'api'`. Both fulfilled shapes parse byte-identically to before — the "
+ 'published `ReassignLeadAction` example included — and an action with `undoable` '
+ 'absent or `false` is untouched on every type. An action declaring `undoable: true` '
+ 'on any other shape is refused with a per-key issue at `undoable` whose message names '
+ 'both fulfilling shapes and the runtime that fulfils each; the author adds the shape '
+ 'they meant or drops the flag.',
},
// The one key this close DECLARES rather than refuses is `dependsOn`, so an author
// who wrote it keeps working and now has a contract saying so. Everything else
// undeclared becomes a parse error. Registered as a structured TODO (ADR-0087 D3)
Expand Down
Loading
Loading