Skip to content

Commit d6d6e87

Browse files
feat(spec,client)!: ActionSchema gains outcomeMessages (success copy per handler outcome, ${result.*}), and environments.delete stops guaranteeing message (#21214)
Fixes #21095 Clause-②: yes (narrowing) The first link of ruling A on objectstack-ai/cloud#2315: spec and client first, the console reader (objectstack-ai/objectui#11344) second, cloud's three actions LAST. Ruling B there is not reopened: 「server returns facts, the console composes the message in the user's locale」. ## What changes **`ActionSchema.outcomeMessages`** (`packages/spec/src/ui/action.zod.ts`). This is a map from a snake_case outcome key (`SnakeCaseIdentifierSchema`) to an `I18nLabel`. The handler's success payload carries a closed `outcome` fact, and the console picks `outcomeMessages[result.outcome]`, then falls back to `successMessage`, then to its default text. - Refused loudly, each at the key's own path: - an array or a string (`invalid_type`); - a key that is not snake_case (`invalid_key` at `outcomeMessages.KEY`); - the map on `url` / `modal` / `flow` / `form` (no success payload can carry an outcome); - the map beside `resultDialog` (which suppresses the toast); - the map beside `operation: 'update'` (no handler, so no outcome). This one is a row in the existing declarative-update table, next to its sibling `onSuccess`. - `InlineActionSchema` deliberately does not pick it, because no inline host reads it. - Interpolation reuses the scope `onSuccess.navigate` already declares: `successMessage` and each outcome message may interpolate `${result.*}`. There is no new dialect. The docblock points at the `onSuccess` scope rather than describing a second one. **Translation face, beside `successMessage`:** - `TranslationData` carries `objects.OBJECT._actions.ACTION.outcomeMessages.OUTCOME` and `globalActions.ACTION.outcomeMessages.OUTCOME`. Outcome keys there are snake_case too. - `translateAction`, which the REST localization seam calls through `translateMetadataDocument`, overlays each outcome separately: the object-scoped copy first, then the global one, falling back to the authored copy. It only overlays outcomes the action declares. - `os i18n extract` emits one key per declared outcome, on both action walks. **`client.environments.delete`** (`packages/client/src/index.ts`). The archive answer now declares `message?: string` and `outcome?: 'archived' | 'already_archived' | 'purge_deferred'`. The teardown answer declares `outcome?: 'destroyed'`. **Liveness:** the `outcomeMessages` row is `planned`, its carrier is objectstack-ai/objectui#11344, and it has `authorWarn` set. The `successMessage` row stays `live` and records the interpolation half that is declared ahead of its reader. ## Mechanism assumptions, measured (dispatch zone 2) 1. **Holds.** Read at base `ebdb6f2a`: `outcomeMessages` had 0 hits in `action.zod.ts`. The control `successMessage` hit, as a static `I18nLabelSchema.optional()`. 2. **Holds.** `onSuccess` declares the `${result.*}` scope in `action.zod.ts`: its docblock says "`${result.X}` — the action's server response payload. For a `type:'api'` action, the response body of the `target` call; for a `type:'script'` action, the handler's return value". Its `navigate` describe() says the same. Both copy surfaces cite it and add no new members. 3. **The row lands `planned`, not `live`.** The ledger defines `planned` as "Declared, intentionally not enforced yet". `check-liveness.mts` scans a planned row's `evidence` the same way it scans a live row's. Its authorWarn guidance says: "If nothing reads the key yet, the STATUS is what is wrong: grade it `planned` (a consumer is being built against it)". The gate also reds a warned `live` row, so the flip PR must drop `authorWarn`, and the row's note says so. - The console reader is absent at both pins. Measured at objectui `e420df31` (the pin at base) and again at `31971ff1` (the pin after the base merge): `ActionRunner.handlePostExecution` composes `result.data.message || action.successMessage || runner default` and reads no `outcomeMessages`. The objectui main checkout (`3c3ce15`) has 0 hits too. - `os validate` on a probe stack shows the author warning firing: `liveness-planned-property`, with the row's hint. 4. **`message` becomes optional, `outcome` is added as optional, and the vocabulary is split by arm.** The wire at the last measured cloud sha (`eeac7b22`, recorded in the client test) carries `message` and no `outcome`. The cloud repo cannot be read from this session (`add_repo` refused), so today's wire is not re-measured. - The four outcomes split cleanly across the two existing arms. `archived`, `already_archived` and `purge_deferred` are all `deleted: false, archived: true`, and today only the English `message` tells `archived` from `already_archived`. `destroyed` is the `deleted: true, purged: true` arm, which also covers the one-call teardown of a `failed` environment. - Declaring `destroyed` on the archive arm would claim a combination the route cannot produce, so each arm carries only its own members. - A compile-time pin ties the four-member list to the type in both directions, and a runtime pin parses all four as `outcomeMessages` keys through the real `ActionSchema`. 5. **No in-repo reader of `message`.** `git grep` over `packages/`, `apps/` and `examples/` finds only the client's own test. There is no `os environments delete` command (`packages/cli/src/commands/environments/` has bind, create, list, show and switch). The objectui tree at HEAD has no `environments.delete` caller. The one reader, the test's `const message: string` pin, now pins the optional shape both ways. 6. **The translation face exists, and the outcome copy rides beside `successMessage`.** The pieces are `translation.zod.ts` `actionTranslationSchema` (the slot), `i18n-resolver.ts` `translateAction` (the overlay) and `cli/src/utils/i18n-extract.ts` (the extractor). No new face was invented. ## Acceptance, item by item - **The schema accepts `outcomeMessages` on api/script and refuses an unknown shape loudly.** Pinned on issue `code` + `path` in `packages/spec/src/ui/action-outcome-messages.test.ts`, not on prose. - **"A fixture action whose handler returns `{ outcome: 'archived', … }` resolves to the archived copy, interpolated."** The SELECTION and the `${result.*}` SUBSTITUTION are the console's, so they move to objectstack-ai/objectui#11344. No renderer is built here. What this repo pins on the cloud-shaped `delete_environment` fixture: - the map survives parse byte-for-byte, tokens included; - the archived copy resolves in `zh-CN` through `translateAction` and `translateMetadataDocument`, with its `${result.environmentId}` / `${result.retentionDays}` tokens intact for the renderer; - fallback runs per outcome (object-scoped, then global, then authored); - a bundle cannot add an outcome the action does not declare. - **The client type no longer requires `message`.** Pinned by `@ts-expect-error` in `environments-delete-two-step.test.ts`, which the package's `typecheck` compiles (`tsc -p tsconfig.test.json --listFiles` names the file). - **The changeset names the new key.** `.changeset/21095-action-outcome-messages.md` carries `Clause-②: yes (narrowing)`. It widens the spec and narrows the client's return type, and it holds the ADR-0087 disposition `not-required (runtime-interface-only packages/client/src/index.ts#ObjectStackClient)`. The gate verified that disposition: `check-adr-0087-registration` reports `[BREAKING+bang+clause-②-narrowing] … verified: packages/client/src/index.ts#ObjectStackClient (class)`. ## Tests (all at HEAD `00855f76`, after the `origin/main` merge) - **Patch round 1 (head `3f94d67b`, one file):** `@objectstack/metadata-protocol` (a consumer) `vitest run` passed: 200 files (3 env-gated live-DB suites skipped), 2973 tests. Its served-`action` key-count pin in `protocol.meta-types-degenerate-derivation.test.ts` moves 48 → 49 (46 accepted keys plus 3 tombstones) and now names `outcomeMessages`, so it asserts the key is served. The readings below are from `00855f76`; the only diff since is that test file. - **`@objectstack/spec`:** `pnpm --filter @objectstack/spec test` passed: 595 files, 17431 tests, 1 todo. - **`@objectstack/client`:** `vitest run` passed: 50 files, 644 tests. - **`@objectstack/cli`:** `vitest run --project unit` passed: 243 files, 3439 tests. The integration layer is declared to CI; the diff touches no spawn entry. - **Typecheck:** `typecheck` passed for spec, client and cli. Each includes `check:test-typecheck`. These runs were before the merge, and the merge touched none of the three packages' TypeScript except spec's `component`/`view`/`dataset`, which `check:generated` and the full spec suite above cover. - **Ablations**, each run with `scripts/ablation-replace.mjs`. Each landing is proven by anchor count and blob hash, and each restore by blob equal to HEAD with an empty `git diff HEAD`: - Deleting `.superRefine(refuseInertOutcomeMessages)`: **6 failed / 14 passed**. These are the url/modal/flow/form/resultDialog refusals and the metadata-door case. - Renaming the declarative-update table's `outcomeMessages` row: **1 failed / 19 passed**, the `operation: 'update'` refusal. - Removing the `translateAction` overlay: **3 failed / 17 passed**, the locale, fallback and REST-seam cases. - Reverse verification of the client type: restoring `message: string` makes `tsc -p tsconfig.test.json` answer `TS2578: Unused '@ts-expect-error' directive` at the optional-`message` pin. ## Gates - **At `3f94d67b` (patch round 1):** re-derived 115 commands (round 0's 114 plus `pnpm check:durability-log-level`, matched by the metadata-protocol path); all 115 run at `3f94d67b`, all exit 0; `--ran`: "115 derived, 115 run, 0 NOT-MEASURED, 0 UNRUN". - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` (no paths, change set from git, merge base `0d421041`) derived 114 commands. (round 0) - All 114 were run at `00855f76`, and all exit 0. - `--ran` with recorded exit codes answers: "114 derived, 114 run, 0 NOT-MEASURED, 0 UNRUN". - Three gates first answered exit 3 (PREREQUISITE NOT MET: missing sibling `dist/`) and were re-run green after the builds: `check:skill-examples`, `check:dual-build-cjs-loads` and `check:i18n-coverage`. - `check:type-check-debt` hit a 400s wrapper timeout once. On re-run it passed: "1 ledger entr(ies) re-measured … none above its recorded number". - `check:generated` reports all 15 artifacts current after the merge. - **Lint, narrowed and measured.** I ran `eslint --no-inline-config --format json` on the 9 touched `.ts` files: 9 results, 0 errors, 0 warnings. The config sets no `parserOptions.project` (0 hits; its own header says it never enables type-aware linting), so the diff cannot move a verdict on an untouched file. The full `pnpm lint` is CI's. ## Acceptance notes - **Translation lint gap, introduced with the key.** `os validate` does not judge `outcomeMessages.OUTCOME` translation keys against the action's declared outcomes. - Probe: a stack whose `zh-CN` bundle carries `_actions.delete_probe.outcomeMessages.restored` for an action declaring only `archived`. - Result: `os validate --json` reports nothing for `restored`, while the same probe's undeclared `params.not_a_param` answers `translation-target-unknown`. - Effect: `translateAction` never reads such a key, so it is silently dropped. - Not fixed here: it widens `packages/lint`, outside this claim's file surface. It is handed to the seat in the report. - **No form row.** `action.form.ts` gains no control for the map. `metadata-form-zod-reconciliation.test.ts` records it as a `planned` omission, the shape its siblings use. - **Stale rationale.** The existing `onSuccess` omission in that ledger still says its children are `planned`, but they are `live` since the 2026-09-27 flip. I left it untouched; it is outside this card. - **Hand-written docs untouched on purpose.** `content/docs/ui/actions.mdx` and `content/docs/protocol/objectui/actions.mdx` are not updated, because they should not advertise copy the console does not show yet. They belong with the flip, in objectstack-ai/objectui#11344's landing. - **Interpolation is declared ahead of its reader.** `successMessage`'s `${result.*}` interpolation is in the contract before the console implements it, which is ruling A's order. Census: zero existing `successMessage` values in this repo or objectui carry `${`, so nothing that renders today changes meaning. _Body edited by the `domain:spec` seat 1 (`session_01UtnxvdiN376GF3sgXwAw4d`) after patch round 1: the `Clause-②` line carries the changeset's arm, and the Tests and Gates sections carry the `3f94d67b` readings._ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9969228 commit d6d6e87

20 files changed

Lines changed: 722 additions & 22 deletions
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/client': minor
4+
'@objectstack/cli': patch
5+
---
6+
7+
feat(spec,client)!: `ActionSchema` gains `outcomeMessages` — success copy per closed handler `outcome`, interpolating `${result.*}` — and `client.environments.delete` no longer guarantees `message` on its archive answer (#21095)
8+
9+
Clause-②: yes (narrowing)
10+
11+
<!-- adr-0087: not-required (runtime-interface-only packages/client/src/index.ts#ObjectStackClient) the one narrowing in this changeset is the declared RETURN type of `ObjectStackClient.environments.delete`, a published SDK method of an exported class: its archive answer's `message` becomes optional, beside a new optional `outcome`. That class is not a Zod schema, not a spec contract, not an object definition and not referenced by one, so `objectstack migrate meta` has nothing to rewrite, and the compiler at the consumer's own read is the channel that reaches every affected consumer. The spec half is a widening only (a new optional key, refused solely where it would be inert), so no stored metadata changes meaning and no ADR-0087 entry is owed for it. The other categories are closed on facts: all three packages publish (not `unpublished`); no ADR-0087 id covers a client return type and this diff adds none (not `registered` / `already-registered`); and the diff touches `packages/spec`, which `type-surface-only` refuses. -->
12+
13+
**BREAKING for TypeScript readers of `client.environments.delete`'s archive answer**: `message` is now `message?: string`. Code that assigns it to a `string` stops compiling at that read. Nothing else in the SDK changes, and the request is unchanged.
14+
15+
**`ActionSchema.outcomeMessages`** (`@objectstack/spec/ui`). A server-executing action can succeed in more than one way: an environment delete archives, finds the environment already archived, defers a purge, or destroys it. One static `successMessage` cannot say which of these happened. The handler now reports a closed `outcome` fact in its success payload, and the action declares the copy for each outcome:
16+
17+
```ts
18+
outcomeMessages: {
19+
archived: 'Environment ${result.environmentId} archived.',
20+
already_archived: 'Environment ${result.environmentId} was already archived.',
21+
}
22+
```
23+
24+
- Keys are snake_case outcome names; values are `I18nLabel`s. A key that is not snake_case is refused at its own path (`invalid_key`).
25+
- The key is valid on `type: 'api'` and `type: 'script'` actions only, the two types with a success payload that can carry an `outcome`. It is refused with a prescription on `url` / `modal` / `flow` / `form`, beside `resultDialog` (which suppresses the success toast), and beside `operation: 'update'` (no handler, so no outcome).
26+
- `successMessage` and each outcome message may interpolate `${result.*}`, the server-response scope `onSuccess.navigate` already declares. This is not a new dialect.
27+
- The console picks `outcomeMessages[result.outcome]`, falls back to `successMessage`, and then to its default text. The console does not read it yet. Until it does, the key is accepted, validated, translated and extracted, and the liveness ledger grades it `planned`, so `os lint` tells an author who writes it that it is not shown yet.
28+
29+
**Translation.** `TranslationData` carries the copy beside `successMessage`: `objects.OBJECT._actions.ACTION.outcomeMessages.OUTCOME` and `globalActions.ACTION.outcomeMessages.OUTCOME`. Outcome keys there are snake_case too. `translateAction` overlays them per outcome (object-scoped first, then global) and only for outcomes the action declares. `os i18n extract` emits one key per declared outcome.
30+
31+
**`client.environments.delete`** (`@objectstack/client`). The control plane is replacing its English `message` with the closed `outcome` fact: the server returns facts, and the console composes the message in the user's locale. The archive answer declares `outcome?: 'archived' | 'already_archived' | 'purge_deferred'`, and the teardown answer declares `outcome?: 'destroyed'`. Both `outcome` and `message` are optional, because a 200 may carry either one or both depending on which control-plane release answers. To learn what happened, read `deleted` and `purgeDeferred`, which every answer still carries, or `outcome` when it is present.

‎content/docs/references/data/object.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -460,7 +460,8 @@ const result = ApiMethod.parse(data);
460460
| **variant** | `Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'link'>` | optional | Button visual variant for styling (primary = highlighted, danger = destructive, ghost = transparent) |
461461
| **order** | `number` | optional | Sort order within a location group (lower = higher). Promotes/demotes an action toward the record_header primary button; stable, so actions without `order` keep their registration order. |
462462
| **confirmText** | `string \| Record<string, string>` | optional | Confirmation message before execution. On a registered action, pairing this with a non-empty `params` is refused — that opens a second dialog for one decision; put the question on `description` instead. Correct on a param-LESS action, where the confirm is the only dialog there is. |
463-
| **successMessage** | `string \| Record<string, string>` | optional | Success message to show after execution |
463+
| **successMessage** | `string \| Record<string, string>` | optional | Success message shown after the action succeeds. On a `type: 'api'` or `type: 'script'` action it may interpolate $`{result.*}` — the server response payload, the same scope `onSuccess.navigate` declares (e.g. $`{result.id}`). On a registered action that declares `outcomeMessages`, the entry named by the response's `outcome` is shown instead, and this message is the fallback. |
464+
| **outcomeMessages** | `Record<string, string \| Record<string, string>>` | optional | Success copy per handler outcome, for type:'api' and type:'script' actions: keys are the snake_case `outcome` values the handler returns in its success payload (e.g. archived, already_archived), values the message shown for that outcome. Each message may interpolate $`{result.*}`, the scope `onSuccess.navigate` declares. An outcome with no entry here falls back to `successMessage`, then to the default text. Not allowed beside `resultDialog` (which suppresses the success toast) or `operation: 'update'` (no handler, so no outcome). |
464465
| **errorMessage** | `string \| Record<string, string>` | optional | Error message to show when the action fails (overrides the raw error). |
465466
| **refreshAfter** | `boolean` | optional (default: `false`) | Refresh view after execution |
466467
| **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. |

‎content/docs/references/kernel/metadata-plugin.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -330,7 +330,8 @@ const result = MetadataBulkResultSchema.parse(data);
330330
| **variant** | `Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'link'>` | optional | Button visual variant for styling (primary = highlighted, danger = destructive, ghost = transparent) |
331331
| **order** | `number` | optional | Sort order within a location group (lower = higher). Promotes/demotes an action toward the record_header primary button; stable, so actions without `order` keep their registration order. |
332332
| **confirmText** | `string \| Record<string, string>` | optional | Confirmation message before execution. On a registered action, pairing this with a non-empty `params` is refused — that opens a second dialog for one decision; put the question on `description` instead. Correct on a param-LESS action, where the confirm is the only dialog there is. |
333-
| **successMessage** | `string \| Record<string, string>` | optional | Success message to show after execution |
333+
| **successMessage** | `string \| Record<string, string>` | optional | Success message shown after the action succeeds. On a `type: 'api'` or `type: 'script'` action it may interpolate $`{result.*}` — the server response payload, the same scope `onSuccess.navigate` declares (e.g. $`{result.id}`). On a registered action that declares `outcomeMessages`, the entry named by the response's `outcome` is shown instead, and this message is the fallback. |
334+
| **outcomeMessages** | `Record<string, string \| Record<string, string>>` | optional | Success copy per handler outcome, for type:'api' and type:'script' actions: keys are the snake_case `outcome` values the handler returns in its success payload (e.g. archived, already_archived), values the message shown for that outcome. Each message may interpolate $`{result.*}`, the scope `onSuccess.navigate` declares. An outcome with no entry here falls back to `successMessage`, then to the default text. Not allowed beside `resultDialog` (which suppresses the success toast) or `operation: 'update'` (no handler, so no outcome). |
334335
| **errorMessage** | `string \| Record<string, string>` | optional | Error message to show when the action fails (overrides the raw error). |
335336
| **refreshAfter** | `boolean` | optional (default: `false`) | Refresh view after execution |
336337
| **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. |

‎content/docs/references/system/translation.mdx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ Translation data for a single field
125125
| **description** | `string` | optional | Translated action description — the explanatory line under the title in the action's param dialog |
126126
| **confirmText** | `string` | optional | Translated confirmation prompt |
127127
| **successMessage** | `string` | optional | Translated success toast/message |
128+
| **outcomeMessages** | `Record<string, string>` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares |
128129
| **params** | `Record<string, { label?: string; helpText?: string; placeholder?: string; options?: Record<string, string> }>` | optional | Action parameter translations keyed by parameter name |
129130
| **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record<string, string> }` | optional | Translations for the action result dialog |
130131

@@ -219,6 +220,7 @@ Translation data for a single object
219220
| **description** | `string` | optional | Translated action description — the explanatory line under the title in the action's param dialog |
220221
| **confirmText** | `string` | optional | Translated confirmation prompt |
221222
| **successMessage** | `string` | optional | Translated success toast/message |
223+
| **outcomeMessages** | `Record<string, string>` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares |
222224
| **params** | `Record<string, { label?: string; helpText?: string; placeholder?: string; options?: Record<string, string> }>` | optional | Action parameter translations keyed by parameter name |
223225
| **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record<string, string> }` | optional | Translations for the action result dialog |
224226

@@ -416,6 +418,7 @@ Translation data for a single object
416418
| **description** | `string` | optional | Translated action description — the explanatory line under the title in the action's param dialog |
417419
| **confirmText** | `string` | optional | Translated confirmation prompt |
418420
| **successMessage** | `string` | optional | Translated success toast/message |
421+
| **outcomeMessages** | `Record<string, string>` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares |
419422
| **params** | `Record<string, { label?: string; helpText?: string; placeholder?: string; options?: Record<string, string> }>` | optional | Action parameter translations keyed by parameter name |
420423
| **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record<string, string> }` | optional | Translations for the action result dialog |
421424

@@ -574,6 +577,7 @@ Translation data for a single object
574577
| **description** | `string` | optional | Translated action description — the explanatory line under the title in the action's param dialog |
575578
| **confirmText** | `string` | optional | Translated confirmation prompt |
576579
| **successMessage** | `string` | optional | Translated success toast/message |
580+
| **outcomeMessages** | `Record<string, string>` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares |
577581
| **params** | `Record<string, { label?: string; helpText?: string; placeholder?: string; options?: Record<string, string> }>` | optional | Action parameter translations keyed by parameter name |
578582
| **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record<string, string> }` | optional | Translations for the action result dialog |
579583

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@ const result = ActionSchema.parse(data);
4747
| **variant** | `Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'link'>` | optional | Button visual variant for styling (primary = highlighted, danger = destructive, ghost = transparent) |
4848
| **order** | `number` | optional | Sort order within a location group (lower = higher). Promotes/demotes an action toward the record_header primary button; stable, so actions without `order` keep their registration order. |
4949
| **confirmText** | `string \| Record<string, string>` | optional | Confirmation message before execution. On a registered action, pairing this with a non-empty `params` is refused — that opens a second dialog for one decision; put the question on `description` instead. Correct on a param-LESS action, where the confirm is the only dialog there is. |
50-
| **successMessage** | `string \| Record<string, string>` | optional | Success message to show after execution |
50+
| **successMessage** | `string \| Record<string, string>` | optional | Success message shown after the action succeeds. On a `type: 'api'` or `type: 'script'` action it may interpolate $`{result.*}` — the server response payload, the same scope `onSuccess.navigate` declares (e.g. $`{result.id}`). On a registered action that declares `outcomeMessages`, the entry named by the response's `outcome` is shown instead, and this message is the fallback. |
51+
| **outcomeMessages** | `Record<string, string \| Record<string, string>>` | optional | Success copy per handler outcome, for type:'api' and type:'script' actions: keys are the snake_case `outcome` values the handler returns in its success payload (e.g. archived, already_archived), values the message shown for that outcome. Each message may interpolate $`{result.*}`, the scope `onSuccess.navigate` declares. An outcome with no entry here falls back to `successMessage`, then to the default text. Not allowed beside `resultDialog` (which suppresses the success toast) or `operation: 'update'` (no handler, so no outcome). |
5152
| **errorMessage** | `string \| Record<string, string>` | optional | Error message to show when the action fails (overrides the raw error). |
5253
| **refreshAfter** | `boolean` | optional (default: `false`) | Refresh view after execution |
5354
| **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. |
@@ -299,7 +300,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities
299300
| **params** | `{ name?: string; field?: string; objectOverride?: string; label?: string \| Record<string, string>; … }[]` | optional | Input parameters required from user — an ActionParam[] DEFINITION array, never a payload map (a static request body goes in `bodyExtra`). |
300301
| **bodyExtra** | `Record<string, any>` | optional | Static request-body fields for a type:"api" action, merged last (overrides user params). `{{page.<var>}}` tokens are resolved by the runtime. This — not `params` — is where a payload goes. |
301302
| **confirmText** | `string \| Record<string, string>` | optional | Confirmation message before execution. On a registered action, pairing this with a non-empty `params` is refused — that opens a second dialog for one decision; put the question on `description` instead. Correct on a param-LESS action, where the confirm is the only dialog there is. |
302-
| **successMessage** | `string \| Record<string, string>` | optional | Success message to show after execution |
303+
| **successMessage** | `string \| Record<string, string>` | optional | Success message shown after the action succeeds. On a `type: 'api'` or `type: 'script'` action it may interpolate $`{result.*}` — the server response payload, the same scope `onSuccess.navigate` declares (e.g. $`{result.id}`). On a registered action that declares `outcomeMessages`, the entry named by the response's `outcome` is shown instead, and this message is the fallback. |
303304
| **errorMessage** | `string \| Record<string, string>` | optional | Error message to show when the action fails (overrides the raw error). |
304305
| **refreshAfter** | `boolean` | optional (default: `false`) | Refresh view after execution |
305306
| **opensInNewTab** | `boolean` | optional | Open the action result in a new tab. The renderer pre-opens the tab synchronously on click (popup-blocker-safe) and navigates it to the handler's redirectUrl. |

0 commit comments

Comments
 (0)