Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
ba371be
feat(spec): a bulk action's confirmText is a template slot over count…
claude Oct 20, 2026
377bc3f
test(spec): pin the bulk confirmText template slot, its roots and the…
claude Oct 20, 2026
ff111da
feat(lint): validate, compile and the publish gate judge a bulk confi…
claude Oct 20, 2026
3d09523
test(lint): pin the bulk confirmText warnings at the CLI pass and the…
claude Oct 20, 2026
572bc66
chore(spec): regenerate api-surface, export-origins and reference doc…
claude Oct 20, 2026
ad3856c
chore(changeset): bulk confirmText template slot
claude Oct 20, 2026
394fc2f
test(spec): the bulk confirmText envelope in the confirm-params pin a…
claude Oct 20, 2026
aedea52
test(lint): table the bulk confirmText pass's receivers and the view …
claude Oct 20, 2026
46876bc
test(dogfood): classify the bulk confirmText template slot in the exp…
claude Oct 20, 2026
2a0f011
Merge origin/main into claude/issue-24798-bulk-confirm-template-slot
claude Oct 20, 2026
df1fd6a
Merge origin/main into claude/issue-24798-bulk-confirm-template-slot
claude Oct 20, 2026
247e44a
fix(cli): os i18n extract seeds a bulk confirmText written as its tem…
claude Oct 20, 2026
9fb508c
chore(changeset): os i18n extract reads the bulk confirmText envelope
claude Oct 20, 2026
55237df
Merge origin/main into claude/issue-24798-bulk-confirm-template-slot
claude Oct 22, 2026
f10bc73
fix(spec,lint): the bulk confirmText warning no longer promises a pro…
claude Oct 22, 2026
84b1e35
chore(spec): regenerate reference docs after the main merge and the c…
claude Oct 22, 2026
ae45d41
Merge remote-tracking branch 'origin/main' into claude/issue-24798-bu…
claude Oct 22, 2026
823f523
Merge remote-tracking branch 'origin/main' into claude/issue-24798-bu…
claude Oct 22, 2026
00012f7
fix(spec): compare a bulk confirmText with its packaged base by the s…
claude Oct 23, 2026
a23235a
Merge remote-tracking branch 'origin/main' into claude/issue-24798-bu…
claude Oct 23, 2026
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
19 changes: 19 additions & 0 deletions .changeset/24798-bulk-confirm-template-slot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@objectstack/spec': minor
'@objectstack/lint': minor
'@objectstack/cli': minor
---

feat(spec,lint): a bulk action's `confirmText` is a `template` slot over `count` and the action's params

Clause-②: yes (widening)
Metadata-protocol: semantic

- **Before.** `bulkActionDefs[].confirmText` was a plain `z.string()` that declared no hole. A sentence such as `'Update tier on {{count}} account(s) to {{tier}}?'` parsed, nothing judged its holes, and the console showed them as literal text.
- **Now.** `confirmText` is an ADR-0032 `template` slot (Decisions 2–3). A bare string is shorthand for `{ dialect: 'template', source }`, and the envelope form 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, and `bulkConfirmTemplateRoots(def)` (`@objectstack/spec/ui`) declares its roots: `count` (`BULK_CONFIRM_COUNT_ROOT`, the number of records the run covers) and each of the def's params by `name`. The roots are written bare: `{{ tier }}`, never `{{ params.tier }}`. That matches how the flow text slots spell their roots and how shipped sentences already spell them, and `count` wins over a param of that name. A param hole renders the param's display value: the chosen option's `label` (labels joined with `', '` for a multi-value param), the picked record's display name for a lookup or user param, and the entered value otherwise, so number, date and currency values take a formatter.
- **Parsed shape.** A parsed `confirmText` is now the `{ dialect: 'template', source }` envelope rather than a string, as `titleFormat` and a notify node's `title` already are. A reader of the parsed or served def takes the text from `source` (`templateSlotSource` in `@objectstack/spec/automation` reads both forms). A stored sentence with no hole parses as before, its text unchanged.
- **Judged by the one template judge.** `objectstack validate`, `objectstack compile` and the runtime publish gate (now also on `view` writes, and on an object's own `listViews` at an object write) judge each bulk `confirmText` through `closedScopeTemplateRefusal`. That is the closed-scope entry of the judge the flow text slots use (`automation/flow-text-slot-template.ts`). A hole over an unbound root and a single-brace `{x}` token are named, the latter with its `{{ }}` spelling. A text carrying neither is then compiled as a template, which names a hole holding logic or an unknown formatter.
- **A warning, not a refusal.** Each of those texts parsed while `confirmText` was a plain string, so refusing it is a narrowing that no ADR-0087 conversion carries. This release ships every finding above as a **warning**: an advisory at the publish gate, never a refusal, so no command's exit code changes. A blank `confirmText` (`''`) still parses, and the doors warn on it.
- **`os i18n extract`.** A bulk `confirmText` written as the envelope is seeded exactly as the bare string: the extractor reads the slot's text through `templateSlotSource`, so the envelope form no longer records as an absent key with nothing to translate.
- **Served translations.** The catalog overlay on a served view or an object's `listViews` compares a bulk `confirmText` with its packaged base by the slot's text, whichever form either side holds: an unchanged envelope still takes the catalog's sentence, and an edited one keeps the authored sentence.
- **Unchanged.** The `confirm` / `confirmmessage` near-misses still name `confirmText`. Every other bulk-def key, the default confirm sentence and the flow text slots' verdicts are unchanged.
2 changes: 1 addition & 1 deletion content/docs/references/ui/bulk-action.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ const result = BulkActionDefSchema.parse(data);
| **execution** | `Enum<'perRecord' \| 'aggregate'>` | optional | For `operation: 'custom'` — 'aggregate' dispatches the named action ONCE for the whole selection, carrying every id in `params._selectedIds`. Required on a custom def: the per-record form is declared as `bulkActions: ['<name>']` instead. |
| **patch** | `Record<string, any>` | optional | For `operation: 'update'` — static field values applied to every selected record, merged UNDER the user-supplied params so a fixed value can be declared without exposing it in the dialog. |
| **params** | `{ name: string; label?: string; help?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; … }[]` | optional | Inputs collected once before the run. Omit to skip the params step and go straight to confirm. |
| **confirmText** | `string` | optional | Confirmation text shown above the affected-record summary. |
| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Confirmation text shown above the affected-record summary — 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 two kinds of root, written bare: `count`, the number of records the run covers, and each of the def's params by `name` (`{{ tier }}`, never `{{ params.tier }}`; `count` wins over a param of that name). A param hole renders that param's DISPLAY value: the chosen option's `label` for an option-bearing param (a multi-value param's labels joined with ', '), the picked record's display name for a `lookup` / user param, and the entered value otherwise, so a number, date or currency value takes a formatter (`{{ amount \| currency:EUR }}`, `{{ due \| date:long }}`). Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Update tier on `{{ count }}` account(s) to `{{ tier }}`?'. A hole over any other root, and a single-brace `{x}` token, are named at `objectstack validate`, `compile` and the publish gate, as a warning. |
| **confirmLabel** | `string` | optional | Custom Confirm button label (default: "Run"). |
| **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Eligibility predicate (CEL) — a string or a `{dialect, source}` envelope, i.e. `action.visible` without its boolean-literal arm: a per-record predicate has nothing to say as a constant. Evaluated once PER SELECTED RECORD with that record bound: the button is offered when at least one passes, the run covers only those, and the rest are reported as skipped. A record-free predicate (`features.x`, `current_user.y`) therefore behaves as a plain button-level gate. Fail-closed — a predicate that faults excludes the record. |
| **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capability gate on the button, `action.requiredPermissions` semantics verbatim: absent or empty always passes, several are AND-ed, and a client that cannot resolve the caller's capabilities fails OPEN (the server stays the authority). This key exists for INLINE defs — notably the `update`/`delete` data-plane forms, which dispatch no action and so have nothing to inherit a gate from; a def promoted from `bulkActions: ['<name>']` (or an aggregate def naming a declared action) inherits the action's own declaration instead. On a data-plane def the gate governs visibility only — the write itself is still authorized by the data API's object permissions and server hooks. |
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/ui/component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -928,7 +928,7 @@ Sort field and direction pair
| **execution** | `Enum<'perRecord' \| 'aggregate'>` | optional | For `operation: 'custom'` — 'aggregate' dispatches the named action ONCE for the whole selection, carrying every id in `params._selectedIds`. Required on a custom def: the per-record form is declared as `bulkActions: ['<name>']` instead. |
| **patch** | `Record<string, any>` | optional | For `operation: 'update'` — static field values applied to every selected record, merged UNDER the user-supplied params so a fixed value can be declared without exposing it in the dialog. |
| **params** | `{ name: string; label?: string; help?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; … }[]` | optional | Inputs collected once before the run. Omit to skip the params step and go straight to confirm. |
| **confirmText** | `string` | optional | Confirmation text shown above the affected-record summary. |
| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Confirmation text shown above the affected-record summary — 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 two kinds of root, written bare: `count`, the number of records the run covers, and each of the def's params by `name` (`{{ tier }}`, never `{{ params.tier }}`; `count` wins over a param of that name). A param hole renders that param's DISPLAY value: the chosen option's `label` for an option-bearing param (a multi-value param's labels joined with ', '), the picked record's display name for a `lookup` / user param, and the entered value otherwise, so a number, date or currency value takes a formatter (`{{ amount \| currency:EUR }}`, `{{ due \| date:long }}`). Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Update tier on `{{ count }}` account(s) to `{{ tier }}`?'. A hole over any other root, and a single-brace `{x}` token, are named at `objectstack validate`, `compile` and the publish gate, as a warning. |
| **confirmLabel** | `string` | optional | Custom Confirm button label (default: "Run"). |
| **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Eligibility predicate (CEL) — a string or a `{dialect, source}` envelope, i.e. `action.visible` without its boolean-literal arm: a per-record predicate has nothing to say as a constant. Evaluated once PER SELECTED RECORD with that record bound: the button is offered when at least one passes, the run covers only those, and the rest are reported as skipped. A record-free predicate (`features.x`, `current_user.y`) therefore behaves as a plain button-level gate. Fail-closed — a predicate that faults excludes the record. |
| **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capability gate on the button, `action.requiredPermissions` semantics verbatim: absent or empty always passes, several are AND-ed, and a client that cannot resolve the caller's capabilities fails OPEN (the server stays the authority). This key exists for INLINE defs — notably the `update`/`delete` data-plane forms, which dispatch no action and so have nothing to inherit a gate from; a def promoted from `bulkActions: ['<name>']` (or an aggregate def naming a declared action) inherits the action's own declaration instead. On a data-plane def the gate governs visibility only — the write itself is still authorized by the data API's object permissions and server hooks. |
Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/ui/view.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1039,7 +1039,7 @@ View filter rule
| **execution** | `Enum<'perRecord' \| 'aggregate'>` | optional | For `operation: 'custom'` — 'aggregate' dispatches the named action ONCE for the whole selection, carrying every id in `params._selectedIds`. Required on a custom def: the per-record form is declared as `bulkActions: ['<name>']` instead. |
| **patch** | `Record<string, any>` | optional | For `operation: 'update'` — static field values applied to every selected record, merged UNDER the user-supplied params so a fixed value can be declared without exposing it in the dialog. |
| **params** | `{ name: string; label?: string; help?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; … }[]` | optional | Inputs collected once before the run. Omit to skip the params step and go straight to confirm. |
| **confirmText** | `string` | optional | Confirmation text shown above the affected-record summary. |
| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Confirmation text shown above the affected-record summary — 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 two kinds of root, written bare: `count`, the number of records the run covers, and each of the def's params by `name` (`{{ tier }}`, never `{{ params.tier }}`; `count` wins over a param of that name). A param hole renders that param's DISPLAY value: the chosen option's `label` for an option-bearing param (a multi-value param's labels joined with ', '), the picked record's display name for a `lookup` / user param, and the entered value otherwise, so a number, date or currency value takes a formatter (`{{ amount \| currency:EUR }}`, `{{ due \| date:long }}`). Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Update tier on `{{ count }}` account(s) to `{{ tier }}`?'. A hole over any other root, and a single-brace `{x}` token, are named at `objectstack validate`, `compile` and the publish gate, as a warning. |
| **confirmLabel** | `string` | optional | Custom Confirm button label (default: "Run"). |
| **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Eligibility predicate (CEL) — a string or a `{dialect, source}` envelope, i.e. `action.visible` without its boolean-literal arm: a per-record predicate has nothing to say as a constant. Evaluated once PER SELECTED RECORD with that record bound: the button is offered when at least one passes, the run covers only those, and the rest are reported as skipped. A record-free predicate (`features.x`, `current_user.y`) therefore behaves as a plain button-level gate. Fail-closed — a predicate that faults excludes the record. |
| **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capability gate on the button, `action.requiredPermissions` semantics verbatim: absent or empty always passes, several are AND-ed, and a client that cannot resolve the caller's capabilities fails OPEN (the server stays the authority). This key exists for INLINE defs — notably the `update`/`delete` data-plane forms, which dispatch no action and so have nothing to inherit a gate from; a def promoted from `bulkActions: ['<name>']` (or an aggregate def naming a declared action) inherits the action's own declaration instead. On a data-plane def the gate governs visibility only — the write itself is still authorized by the data API's object permissions and server hooks. |
Expand Down Expand Up @@ -1416,7 +1416,7 @@ View filter rule
| **execution** | `Enum<'perRecord' \| 'aggregate'>` | optional | For `operation: 'custom'` — 'aggregate' dispatches the named action ONCE for the whole selection, carrying every id in `params._selectedIds`. Required on a custom def: the per-record form is declared as `bulkActions: ['<name>']` instead. |
| **patch** | `Record<string, any>` | optional | For `operation: 'update'` — static field values applied to every selected record, merged UNDER the user-supplied params so a fixed value can be declared without exposing it in the dialog. |
| **params** | `{ name: string; label?: string; help?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; … }[]` | optional | Inputs collected once before the run. Omit to skip the params step and go straight to confirm. |
| **confirmText** | `string` | optional | Confirmation text shown above the affected-record summary. |
| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Confirmation text shown above the affected-record summary — 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 two kinds of root, written bare: `count`, the number of records the run covers, and each of the def's params by `name` (`{{ tier }}`, never `{{ params.tier }}`; `count` wins over a param of that name). A param hole renders that param's DISPLAY value: the chosen option's `label` for an option-bearing param (a multi-value param's labels joined with ', '), the picked record's display name for a `lookup` / user param, and the entered value otherwise, so a number, date or currency value takes a formatter (`{{ amount \| currency:EUR }}`, `{{ due \| date:long }}`). Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Update tier on `{{ count }}` account(s) to `{{ tier }}`?'. A hole over any other root, and a single-brace `{x}` token, are named at `objectstack validate`, `compile` and the publish gate, as a warning. |
| **confirmLabel** | `string` | optional | Custom Confirm button label (default: "Run"). |
| **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Eligibility predicate (CEL) — a string or a `{dialect, source}` envelope, i.e. `action.visible` without its boolean-literal arm: a per-record predicate has nothing to say as a constant. Evaluated once PER SELECTED RECORD with that record bound: the button is offered when at least one passes, the run covers only those, and the rest are reported as skipped. A record-free predicate (`features.x`, `current_user.y`) therefore behaves as a plain button-level gate. Fail-closed — a predicate that faults excludes the record. |
| **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capability gate on the button, `action.requiredPermissions` semantics verbatim: absent or empty always passes, several are AND-ed, and a client that cannot resolve the caller's capabilities fails OPEN (the server stays the authority). This key exists for INLINE defs — notably the `update`/`delete` data-plane forms, which dispatch no action and so have nothing to inherit a gate from; a def promoted from `bulkActions: ['<name>']` (or an aggregate def naming a declared action) inherits the action's own declaration instead. On a data-plane def the gate governs visibility only — the write itself is still authorized by the data API's object permissions and server hooks. |
Expand Down
9 changes: 7 additions & 2 deletions packages/cli/src/utils/i18n-extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ import {
pageTabItemKey,
walkAddressedPageComponents,
} from '@objectstack/spec/system';
import { FLOW_REGION_SLOTS_BY_TYPE, flowScreenFieldOptionKey } from '@objectstack/spec/automation';
import { FLOW_REGION_SLOTS_BY_TYPE, flowScreenFieldOptionKey, templateSlotSource } from '@objectstack/spec/automation';
import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel';
import { deriveFieldGroupLayout } from '@objectstack/spec/data';
import { APPROVAL_NODE_TYPE } from '@objectstack/spec/automation';
Expand Down Expand Up @@ -518,7 +518,12 @@ function pushBulkActionDefs(out: ExpectedEntry[], viewRoot: string[], view: any,
// nobody wrote.
const authoredLabel = inlineText(def.label);
pushDerived(out, [...base, 'label'], authoredLabel ?? humanizeFieldPath(defName), def.label, 'view', { objectName });
pushOptional(out, [...base, 'confirmText'], def.confirmText, 'view', { objectName });
// [#24798] `confirmText` is a `template` slot: a bare string or the
// `{ dialect: 'template', source }` envelope, and either one is the same
// authored sentence. Read it through the template judge's one reader, so
// the envelope form is seeded like the string instead of being recorded
// as an absent key.
pushOptional(out, [...base, 'confirmText'], templateSlotSource(def.confirmText), 'view', { objectName });
pushOptional(out, [...base, 'confirmLabel'], def.confirmLabel, 'view', { objectName });
if (!Array.isArray(def.params)) continue;
for (const param of def.params) {
Expand Down
Loading
Loading