Skip to content

Commit 384bb7b

Browse files
feat(spec,lint): an action's confirmText, successMessage and errorMessage are template slots over the roots the console runner binds (#25637)
Fixes #24814 Clause-②: yes (widening) Metadata-protocol: semantic Ruling-ref: 6365736632 An action's person-facing copy (`confirmText`, `successMessage`, `errorMessage`) is now an ADR-0032 `template` slot over the roots the console's action runner binds. This is ruling A's second half: the console moved first (objectui #13409, in the pinned `.objectui-sha` `2958f17e9c6a`), and this PR types the action-copy family. It uses the #24947 form: the blank arm is kept, door findings are warnings, and the judge prescribes `{{ }}` for a `${…}` token. The refusal half is its own BREAKING card, to be filed once this lands. Nothing here refuses text that parses today. ## What changes - **The 9 row slots** (`action:button`, `action:icon`, the `action:group` / `action:menu` member, × `confirmText` / `successMessage` / `errorMessage`, `ui/component.zod.ts`) and **`ActionSchema.confirmText` / `successMessage`** (`ui/action.zod.ts`, which `InlineActionSchema` picks) are `template` slots. A bare string is shorthand for `{ dialect: 'template', source }`. The envelope is accepted, and a blank string still parses. `ActionSchema` keeps its inline locale map arm. - **Roots, measured at the runner** (objectui `ActionRunner` at `2958f17e9c6a`, `confirmMessage` / `composeSuccessMessage` / `handlePostExecution`), and declared in `@objectstack/spec/ui`: - `confirmText`: `record`, plus `params` only beside an object of static values (`actionConfirmTemplateRoots`). The runner binds `params` to `action.params` minus `_rowRecord`. An input list binds nothing, because its values are collected after the confirmation. - `successMessage`: `result` (the handler's payload) and `record` (`ACTION_SUCCESS_TEMPLATE_ROOTS`). - `errorMessage`: none (`ACTION_ERROR_TEMPLATE_ROOTS`). The runner shows an authored `errorMessage` as written. Re-measured at the pin: it fills no hole, as the earlier report said. - **The judge** (`automation/flow-text-slot-template.ts` `closedScopeTemplateRefusal`) reads a `${…}` token with its `$`. `Delete ${record.name}?` is prescribed `Delete {{ record.name }}?`, no longer `Delete ${{ record.name }}?`. A zero-root scope names every hole and token in its text as literal text. The flow text slots' judge (`textSlotTemplateRefusal`) is unchanged, and the bulk sentence keeps its single-brace wording (pinned). - **The doors**: a new pass, `packages/lint/src/validate-action-copy-templates.ts` `actionCopyTemplateIssues`, runs inside `validateStackExpressions`. That is `objectstack validate`, `compile` and the publish gate. It covers registered actions (top-level, and an object's own, deduplicated), the page action rows and a page component's inline `properties.action`, and judges each locale of an inline map on its own. `page` joins the rule's `runtimeTypes`, and on a page write this pass runs alone. **Every finding is a `warning`**, so it shows as an advisory at the gate and never as a 422. - **The platform's sentences, converted**: the 4 `invite_user` sentences the ruling names, and the 9 `sys_approval_request` decision toasts (see Deviations). They move from `${result.*}` to `{{ result.* }}` in the source and in every shipped locale bundle. The en bundles were regenerated with `check-i18n-bundles --write`. - **i18n**: `ActionLike.confirmText` / `successMessage` in `system/i18n-resolver.ts` widen to the envelope. `resolveActionConfirm` / `resolveActionSuccess` answer the authored value as written when no translation applies. `os i18n extract` seeds an envelope like the string (`actionCopy`, through `templateSlotSource`). - **Expression-surface ledger**: the census (`packages/qa/dogfood/test/expression-conformance.test.ts`) scans only `*.zod.ts` files. So the template input is built at each declaring `.zod.ts` file, and all 11 positions are discovered and classified: `template-action-confirm-text`, `template-action-success-message`, `template-action-error-message`. A first cut built them in a plain `.ts` module, and the census never saw them. That is why the construction lives where it does. ## Measured - **Renderer, re-verified at the pin** (`.objectui-sha` `2958f17e9c6a`, objectui `8e90779dcb` merged in): `actionCopySource` / `fillTemplateHoles` (`packages/core/src/actions/actionCopyTemplate.ts`) read the envelope for all three keys. `useActionTextLocalizer` hands the envelope on, and `flowSuccessCopy` reads it. - **Producers** (authored `confirmText` / `successMessage` / `errorMessage` in `examples/**` + `packages/**`, tests and generated bundles excluded): at `e649c2d91e`, 13 carried `${…}` (4 platform-objects, 9 plugin-approvals) and 0 carried `{{`. On this branch, 0 carry `${…}` and 13 carry `{{`. In `examples/**`, 0 either way. - **Downstream shape check** (the unlock's instruction): `OBJECTUI_ROOT=../objectui bash scripts/build-console.sh`, with objectui at `2958f17e9c6a` and this branch's spec built (pre-merge head `3c1eb1e0`). The console's `tsc && vite build` and app-shell's `tsc` completed. Turbo: 36/36 then 34/34 tasks successful. The canaries passed, including "✓ Console bundle carries THIS tree's @objectstack/spec, and only it". The merge brought no spec type change this check reads (only #25612's `hideEmpty` describe). ## Tests - `packages/spec`: new `src/ui/action-copy-template.test.ts`, plus new cases in `src/automation/flow-text-slot-template.test.ts` (`${…}`, zero-root, bulk control). Re-pinned: `ui/action.test.ts`, `ui/component-report-items-action-members-typed.pin.test.ts` (the copy keys written as envelopes stay byte-identical, and the bare-string normalisation is pinned), and `ui/component-props-unknown-members.pin.test.ts` (the envelopes' `ast`). Full suite before the restructure: 789 of 793 files passed, 3 failed, 1 skipped (the 3 were the fixtures above plus the `export-origins` artifact), and all 3 are fixed. After the restructure, `src/ui/`, `src/system/` and the judge: 211/211 files, 7614 tests. - `packages/lint`: new `src/validate-action-copy-templates.test.ts` (13). It drives the real `runRuntimeAuthoringRules` on a `page` write and an `action` write, and asserts one advisory and zero errors. Re-pinned: the meta-test's import-specifier artefact (`templates`) and `runtime-gate.page-visibility-predicate-writes.test.ts` (`validateStackExpressions` now dispatches on `page`). Full suite: 159/159 files, 7250 tests. - `packages/platform-objects`: 94/94 files, 1343 tests. `packages/plugins/plugin-approvals`: 95/95 files, 1244 tests (token pins moved to `{{ }}`, and the parsed copy is read through `templateSlotSource`). - `packages/cli` unit tier: 327/327 files, 4812 tests. - `packages/metadata-protocol` `protocol.runtime-authoring-gate.test.ts` (197), `packages/rest` `meta-view-container-i18n.test.ts` (11), `packages/runtime` `action-execution-destructive.test.ts` (63), `services/service-i18n` `authored-translations.test.ts` (16): all pass. - `packages/qa/dogfood` `expression-conformance.test.ts`: 8/8. `member-transfer-ownership-step-down.dogfood.test.ts` now reads the served copy through `templateSlotSource`. NOT MEASURED locally: it boots the showcase. CI's battery runs it. ## Gates All runs are at head `e875a564` (the merged head). The derivation is `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 119 families, against merge base `481ac6211`. Each was run with its exit code captured before any pipe. 115 exited 0 on the first pass. 4 refused with exit 3 (PREREQUISITE NOT MET, nothing measured): `check-plugin-teardown-shape --self-test` (a shallow clone; I fetched the pinned fixture commit), `spec check:skill-examples`, `check:dual-build-cjs-loads` and `check:i18n-coverage` (unbuilt packages). All 4 exited 0 after `turbo run build` (77/77) and the fetch. `--ran` with `cmd :: exit N` reports: "119 derived famil(ies) accounted for — 119 run, 0 NOT-MEASURED (a DERIVED zero)". Also run: `pnpm --filter @objectstack/spec check:generated` reports "All 14 generated artifacts are up to date". `typecheck` exited 0 on spec, lint, cli, platform-objects, plugin-approvals and dogfood. The local-only `check:dev-prereqs` and `check:skill-frame-freshness` both exited 0. The full lint battery and the repo-wide `pnpm lint` are CI's. One ablation: `isDollarToken` was forced to `return false` through `scripts/ablation-replace.mjs`. The anchor went 1 to 0, and the blob went `e12c4385` to `38c36986`. `flow-text-slot-template.test.ts` then had 4 failed and 41 passed. The restore left the blob back at `e12c4385` and `git diff HEAD` empty. ## Deviations - **plugin-approvals, 9 sentences, beyond the ruling's "4".** The ruling's count came from the earlier report, which missed `packages/plugins/plugin-approvals/src/sys-approval-request.object.ts` (9 `Approved "${result.request.record_title}"`-style toasts). Left alone, the platform would ship 9 sentences its own doors warn on. This conversion meets the four conditions for a bounded in-place fix: the same defect class, a mechanical conversion of the same form, no other open PR touches the file (all open PRs' file lists were read, and only #25612 overlaps, on `component.zod.ts`), and the same gate family. The claim's file surface needs that file and its translations added. The seat should write that addendum, not me. - **Translated-locale values edited by hand** (zh-CN / ja-JP / es-ES `*.objects.generated.ts`, `successMessage` leaves only). AGENTS.md allows this. None of those leaves has a source-hash entry. - `packages/cli/src/commands/explain.ts`: `confirmText`'s row reads `template (i18n)`, with the hole spelled out. ## Acceptance notes - `ActionSchema.errorMessage` and `outcomeMessages` are untouched by the ruling. `outcomeMessages` still reads `${result.*}` (the runner fills it that way). They are noted, not filed. - The Studio action inspector and preview (objectui `ActionDefaultInspector` / `ActionPreview`) read these keys as `I18nLabel` maps and would show an envelope as `template`. This was noted on objectui #13409, and it is an authoring surface outside the runner. - The bulk row in the expression-surface ledger still says no evaluator. objectui #13183 owns that. --- _Generated by [Claude Code](https://claude.ai/code/session_01DcqhBfbrsH8ACDsMQ62Hzq)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f1ddace commit 384bb7b

46 files changed

Lines changed: 1209 additions & 179 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/lint': minor
4+
'@objectstack/cli': minor
5+
'@objectstack/platform-objects': minor
6+
'@objectstack/plugin-approvals': minor
7+
---
8+
9+
feat(spec,lint): an action's `confirmText`, `successMessage` and `errorMessage` are `template` slots over the roots the console's runner binds
10+
11+
Clause-②: yes (widening)
12+
Metadata-protocol: semantic
13+
14+
- **Before.** On the page action rows (`action:button`, `action:icon`, an `action:group` / `action:menu` member) the three copy keys were plain `z.string()`s, and on a registered action `confirmText` / `successMessage` were `I18nLabel`s. None declared a hole. The console filled `${…}` in them (`${record.name}` in a confirm, `${result.email}` in a success toast), and the platform's own sentences were written that way.
15+
- **Now.** Each is an ADR-0032 `template` slot (Decisions 2–3), the same mechanism as a bulk action's `confirmText`. A bare string is shorthand for `{ dialect: 'template', source }`, and the envelope is accepted too. Holes are `{{ path }}` and `{{ path | formatter[:arg] }}`, with formatters from the template engine's whitelist and no logic inside a hole. They read a closed scope, the one the console's action runner binds, declared in `@objectstack/spec/ui`:
16+
- `confirmText`: `record`, the record the action runs against, and `params` only when the action's `params` is an object of static values (`actionConfirmTemplateRoots(action)`). An input list binds nothing, because its values are collected after the confirmation.
17+
- `successMessage`: `result`, the payload the handler answered, then `record` (`ACTION_SUCCESS_TEMPLATE_ROOTS`).
18+
- `errorMessage`, on the rows only: no root (`ACTION_ERROR_TEMPLATE_ROOTS`). The runner shows it as written.
19+
- **Parsed shape.** A parsed copy key is now the `{ dialect: 'template', source }` envelope rather than a string. A reader of the parsed or served definition takes the text from `source` (`templateSlotSource` in `@objectstack/spec/automation` reads both forms). The console pinned by this release reads both. A registered action keeps its inline locale map, and a blank string still parses as itself.
20+
- **Judged at the doors, as warnings.** `objectstack validate`, `objectstack compile` and the publish gate (on `action`, `object` and now `page` writes) judge every sentence through `closedScopeTemplateRefusal`, the template judge's closed-scope entry, then compile it as a template. A hole over a root the runner does not bind, a `${…}` or single-brace `{x}` token, a hole holding logic or an unknown formatter, and a blank sentence are each named. Each locale of an inline map is judged on its own. Every finding is a **warning**, an advisory at the publish gate and never a refusal, so no command's exit code changes: each of these texts parsed while the keys were plain strings.
21+
- **`${…}` is prescribed its hole.** The judge reads a `${…}` token with its `$`, so `Delete ${record.name}?` is told to become `Delete {{ record.name }}?`. It no longer says `Delete ${{ record.name }}?`. A slot with no root names every hole and token in it as literal text.
22+
- **The platform's sentences.** The `invite_user` success toast on `sys_invitation`, `sys_member`, `sys_organization` and `sys_user` (`Invitation sent to {{ result.email }}`) and the nine `sys_approval_request` decision toasts (`Approved "{{ result.request.record_title }}"`, …) now use `{{ }}` holes, in every shipped locale. The console fills them the same way.
23+
- **Translation and extraction.** `translateAction` answers the authored envelope when no translation applies, and a translated sentence as a string. `os i18n extract` seeds an envelope-form `confirmText` / `successMessage` exactly like the bare string.
24+
- **Unchanged.** `ActionSchema.errorMessage` and `outcomeMessages` are untouched (`outcomeMessages` still reads `${result.*}`), and so are `emptyText` and every other key. Producers measured at `e649c2d91e`: 13 authored copy sentences carried `${…}` (the 13 above) and none carried `{{`. After this change none carries `${…}`, and none in `examples/**`.

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -492,8 +492,8 @@ const result = ApiMethod.parse(data);
492492
| **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`). |
493493
| **variant** | `Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'link'>` | optional | Button visual variant for styling (primary = highlighted, danger = destructive, ghost = transparent) |
494494
| **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. |
495-
| **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. |
496-
| **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. |
495+
| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string \| Record<string, string>` | optional | Confirmation message before execution — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `record`, the record the action runs against (the clicked row on a list, else the page's record), and — only when the action's `params` is an object of static values — `params` (`{{ params.tier }}`); an input list binds nothing, since its values are collected after the confirmation. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Delete `{{ record.name }}`?'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named at `objectstack validate`, `compile` and the publish gate, as warnings: the text still parses and saves. An inline locale map (`{ en, "zh-CN" }`) is accepted too, each value such a sentence. 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. |
496+
| **successMessage** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string \| Record<string, string>` | optional | Success message shown after the action succeeds — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `result`, the payload the action's handler answered (`{{ result.email }}`; for an `api` or `script` action the server response), and `record`, the record the action ran against. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Invitation sent to `{{ result.email }}`'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named at `objectstack validate`, `compile` and the publish gate, as warnings: the text still parses and saves. An inline locale map (`{ en, "zh-CN" }`) is accepted too, each value such a sentence. On a registered action that declares `outcomeMessages`, the entry named by the response's `outcome` is shown instead, and this message is the fallback. |
497497
| **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). |
498498
| **errorMessage** | `string \| Record<string, string>` | optional | Error message to show when the action fails (overrides the raw error). |
499499
| **refreshAfter** | `boolean` | optional (default: `false`) | Refresh view after execution |

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -332,8 +332,8 @@ const result = MetadataBulkResultSchema.parse(data);
332332
| **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`). |
333333
| **variant** | `Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'link'>` | optional | Button visual variant for styling (primary = highlighted, danger = destructive, ghost = transparent) |
334334
| **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. |
335-
| **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. |
336-
| **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. |
335+
| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string \| Record<string, string>` | optional | Confirmation message before execution — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `record`, the record the action runs against (the clicked row on a list, else the page's record), and — only when the action's `params` is an object of static values — `params` (`{{ params.tier }}`); an input list binds nothing, since its values are collected after the confirmation. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Delete `{{ record.name }}`?'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named at `objectstack validate`, `compile` and the publish gate, as warnings: the text still parses and saves. An inline locale map (`{ en, "zh-CN" }`) is accepted too, each value such a sentence. 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. |
336+
| **successMessage** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string \| Record<string, string>` | optional | Success message shown after the action succeeds — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `result`, the payload the action's handler answered (`{{ result.email }}`; for an `api` or `script` action the server response), and `record`, the record the action ran against. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Invitation sent to `{{ result.email }}`'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named at `objectstack validate`, `compile` and the publish gate, as warnings: the text still parses and saves. An inline locale map (`{ en, "zh-CN" }`) is accepted too, each value such a sentence. On a registered action that declares `outcomeMessages`, the entry named by the response's `outcome` is shown instead, and this message is the fallback. |
337337
| **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). |
338338
| **errorMessage** | `string \| Record<string, string>` | optional | Error message to show when the action fails (overrides the raw error). |
339339
| **refreshAfter** | `boolean` | optional (default: `false`) | Refresh view after execution |

0 commit comments

Comments
 (0)