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
6 changes: 3 additions & 3 deletions .changeset/25680-action-copy-refusal.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
'@objectstack/lint': minor
'@objectstack/spec': minor
Expand All @@ -9,7 +9,7 @@
Metadata-protocol: narrowed
Ruling-ref: `6365736632` (on #24814, the refusal half); triage `6386348015`

An action's person-facing copy is an ADR-0032 `template` slot over the roots the console's runner binds (#24814): `confirmText` over `record` (and `params` beside static values), `successMessage` over `result` and `record`, `errorMessage` over none. That covers a page action row (`action:button`, `action:icon`, an `action:group` / `action:menu` member), a page component's inline `properties.action`, and a registered action (`stack.actions[]`, an object's `actions[]`). `validateStackExpressions`, the rule all three doors run, judges each sentence through `closedScopeTemplateRefusal`, then compiles it as a template. Its findings were warnings. They are now errors, all but one. This supersedes the warning-only behaviour #24814 described earlier in this release line; a refused finding no longer ends with "A warning, not a refusal".
An action's person-facing copy is an ADR-0032 `template` slot over the roots the console's runner binds (#24814): `confirmText` over `record` (and `params` beside static values), `successMessage` over `result` and `record`, `errorMessage` over none. That covers a page action row (`action:button`, `action:icon`, an `action:group` / `action:menu` member), a page component's inline `properties.action`, and a registered action (`stack.actions[]`, an object's `actions[]`). `validateStackExpressions`, the rule all three doors run, judges each sentence through `closedScopeTemplateRefusal`, then compiles it as a template. Its findings were warnings. They are now errors. This supersedes the warning-only behaviour #24814 described earlier in this release line; a refused finding no longer ends with "A warning, not a refusal".

**BREAKING — what moves for consumers.**

Expand All @@ -18,15 +18,15 @@

**Fix (one line).** Write the hole the finding prescribes (`Delete ${record.name}?` → `Delete {{ record.name }}?`), name a root the key binds, double a single-brace token, move logic out of the hole, or remove a blank key so the runner's default shows.

**Still a warning.** A `${…}` token the console's runner still fills in its transitional reading: in a sentence with no `{{ }}` hole and no other token, `${record.*}` in a `confirmText` and `${result.*}` in a `successMessage` (`ACTION_COPY_DOLLAR_TRANSITION_ROOTS`, measured at objectui `ActionRunner` at the pinned `.objectui-sha`, which fills them there). It is named with its `{{ }}` spelling and ends "A warning, not a refusal, for now". Refusing it waits for the runner to retire that reading.
**The holeless `${…}` sentence too.** `${record.*}` in a `confirmText` and `${result.*}` in a `successMessage`, in a sentence with no `{{ }}` hole and no other token, is refused as well: the console's runner shows it as written. Its own entry in this release says so (#25709).

**Unchanged.**

- The parse. Each copy key accepts what it accepted, a blank string included (the slot's blank-string arm stays). The boot load and the read path judge a stored row with this parse, so a stored page or action carrying a refused sentence keeps loading and serving until it is next saved, and that save is judged. Saving as a draft is still allowed; publishing the draft is judged.
- `ActionSchema.errorMessage` and `outcomeMessages` are not in this family and draw nothing.
- A sentence with no finding draws nothing at any door, in any form (the bare string, the `{ dialect: 'template', source }` envelope, an inline locale map).
- `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write.
- No schema shape moves. `@objectstack/spec` adds two exports: `ACTION_COPY_DOLLAR_TRANSITION_ROOTS` (`@objectstack/spec/ui`) and `dollarOnlyTokenRoots` (`@objectstack/spec/automation`). The three copy describes now say the texts are refused.
- No schema shape moves, and `@objectstack/spec` adds no export. The three copy describes now say the texts are refused.
- Measured before crossing, through the built `@objectstack/lint` at `6021b5e837`: 0 in-repo producers carry such a sentence. `examples/app-crm`, `app-showcase` (23 registered `successMessage`s, 29 pages), `app-todo` (2 `confirmText`, 16 `successMessage`) and `app-multi-package`, `@objectstack/platform-objects` (12 `confirmText`, 48 `successMessage`) and `@objectstack/plugin-approvals` (9 `successMessage`) draw 0 findings, against 1 for a planted unbound hole. HotCRM at `a4b3560` declares 1 action `confirmText` and 19 action `successMessage`s; judged through the same built pass, all 20 draw 0 findings, against an error for a planted `{count}`.

<!-- adr-0087: not-required (no-migration-prescription) a refusal at `objectstack validate`, `compile` and the publish gate of an action copy sentence (`confirmText`, `successMessage`, a page row's `errorMessage`) that is blank, names an unbound root, carries a single-brace or raw-shown `${…}` token, or holds logic in a hole: no authorable key, spelling or stored shape moves, the parse is unchanged, and no stored row is read, rewritten or converted. A stored row whose sentence is refused keeps loading until it is next saved, and the repair is the author's rewrite of the sentence (which root a hole should name is the author's intent), which no ledger entry can derive. The other categories are closed on facts: the packages publish (not unpublished); no ADR-0087 id covers this verdict (not already-registered); and the change is a build and door verdict, not a declaration (not runtime-interface-only or type-surface-only). -->
24 changes: 24 additions & 0 deletions .changeset/25709-action-copy-holeless-dollar-refusal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@objectstack/lint': minor
'@objectstack/spec': minor
---

feat(lint)!: `objectstack validate`, `compile` and the publish gate refuse a holeless `${record.*}` `confirmText` and a holeless `${result.*}` `successMessage` on an action, the one action-copy finding that was still a warning (#25709)

Clause-②: no (narrowing: an action's `confirmText` / `successMessage` sentence with no `{{ }}` hole whose tokens are `${record.*}` / `${result.*}` is refused at `objectstack validate`, `compile` and the publish gate instead of saving with a warning)
Metadata-protocol: narrowed

The console's action runner no longer fills `${…}` in an action's copy: a sentence with no `{{ }}` hole is shown as written, `${…}` included (objectui#13476, in the `ui/` tree). The doors kept one `${…}` shape a warning while the runner still filled it: `Delete ${record.name}?` in a `confirmText`, `Invitation sent to ${result.email}` in a `successMessage`. The person reading the dialog or toast now sees that token raw, so the doors refuse it like every other `${…}` token.

**BREAKING — what moves for consumers.**

- FROM: a warning at `objectstack validate` and `compile`, and an entry on the publish gate's `advisories` channel while the action, object or page saved. TO: an error. Both commands fail, and an active `action`, `object` or `page` write answers `422 INVALID_METADATA` with an `expression-invalid` issue located at the key. The item is not stored. The finding no longer ends with "A warning, not a refusal, for now".
- `ACTION_COPY_DOLLAR_TRANSITION_ROOTS` (`@objectstack/spec/ui`) and `dollarOnlyTokenRoots` (`@objectstack/spec/automation`), which reached `main` after `@objectstack/spec@18.1.0` and were never published, are withdrawn before this release. The `confirmText` and `successMessage` describes no longer say the shape is a warning.

**Fix (one line).** Write the hole the finding prescribes: `Delete ${record.name}?` → `Delete {{ record.name }}?`, `Invitation sent to ${result.email}` → `Invitation sent to {{ result.email }}`.

**Unchanged.** The parse: each copy key accepts what it accepted, so a stored row with such a sentence keeps loading until its next save, and that save is judged. Saving as a draft is still allowed. `outcomeMessages` and `ActionSchema.errorMessage` are not in this family. `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write.

**Measured before crossing**, by walking every action-copy sentence reachable from each module's exports on this branch's base `38050ced` and through the built `@objectstack/lint`: `examples/app-crm` (2 `successMessage`, 2 `errorMessage`), `app-todo` (1 `confirmText`, 8 `successMessage`), `app-showcase` (6 `confirmText`, 24 `successMessage`), `app-multi-package` (none), `@objectstack/platform-objects` and `@objectstack/plugin-approvals` carry 0 sentences with a `${…}` token and 0 of the holeless shape, and the four example stacks draw 0 copy findings. A planted `Delete ${record.name}?` `confirmText` on each example stack draws the finding.

<!-- adr-0087: not-required (no-migration-prescription) a refusal at `objectstack validate`, `compile` and the publish gate of an action copy sentence with no `{{ }}` hole whose tokens are `${record.*}` (in a `confirmText`) or `${result.*}` (in a `successMessage`), which saved with a warning: no authorable key, spelling or stored shape moves, the parse is unchanged, and no stored row is read, rewritten or converted. A stored row whose sentence is refused keeps loading until it is next saved, and the repair is the author's rewrite of the sentence (which root a hole should name is the author's intent), which no ledger entry can derive. The two dropped exports never shipped in a published version. The other categories are closed on facts: the packages publish (not unpublished); no ADR-0087 id covers this verdict (not already-registered); and the change is a build and door verdict, not a declaration (not runtime-interface-only or type-surface-only). -->
4 changes: 2 additions & 2 deletions content/docs/references/data/object.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -492,8 +492,8 @@ const result = ApiMethod.parse(data);
| **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`). |
| **variant** | `Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'link'>` | optional | Button visual variant for styling (primary = highlighted, danger = destructive, ghost = transparent) |
| **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. |
| **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 and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. A `${record.*}` token in a sentence with no `{{ }}` hole, which the console's runner still fills, is named as a warning instead. 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. |
| **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 and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. A `${result.*}` token in a sentence with no `{{ }}` hole, which the console's runner still fills, is named as a warning instead. 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. |
| **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 and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. 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. |
| **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 and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. 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. |
| **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). |
| **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 |
Expand Down
Loading
Loading