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
28 changes: 28 additions & 0 deletions .changeset/25205-bulk-confirm-text-refusal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
'@objectstack/lint': minor
'@objectstack/spec': minor
---

feat(lint)!: `objectstack validate`, `compile` and the publish gate refuse a bulk action's `confirmText` hole over an unbound root, a single-brace token, a hole holding logic and a blank sentence (#25205)

Clause-②: no (narrowing: a bulk action's `confirmText` that names an unbound root, carries a single-brace token, holds logic in a hole or is blank is refused at `objectstack validate`, `compile` and the publish gate; it ships now under the amended 「甲」, #6015 `6303865025`)
Metadata-protocol: narrowed

A list-view bulk action's `confirmText` is an ADR-0032 `template` slot over a closed scope, `count` and the def's params by name (#24798). `validateStackExpressions`, the rule all three doors run, judges each one through `closedScopeTemplateRefusal`, then compiles it as a template. Its findings were warnings. They are now errors, as the flow text slots' findings are. This supersedes the warning-only behaviour described for the same pass earlier in this release line; the finding no longer ends with "A warning, not a refusal".

**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 view or object saved. TO: an error. Both commands fail, and an active `view` or `object` write answers `422 INVALID_METADATA` with an `expression-invalid` issue located at `bulkActionDefs[N] confirmText`. The item is not stored. This covers `saveMetaItem` in publish mode (Studio, REST `/meta`, MCP) and the promotion of a draft (`publishMetaItem`).
- The four refused texts: a hole over a root the def does not bind (`{{ owner }}` when the def binds `count` and `tier`), a single-brace `{x}` token, a hole holding logic or naming an unknown formatter (`{{ count * 2 }}`, `{{ count | shout }}`), and a blank sentence (`''`).

**Fix (one line).** Write the hole over `count` or one of the def's params (`{{ count }}`, `{{ tier }}`), double a single-brace token as the finding prescribes, move logic out of the hole, or remove a blank `confirmText` so the default sentence shows.

**Unchanged.**

- The parse. `bulkActionDefs[].confirmText` accepts what it accepted, a blank string included (its second union arm stays). The boot load and the read path judge a stored view with this parse, so a stored view carrying such a 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.
- A sentence with no finding draws nothing at any door, in either form (the bare string or the `{ dialect: 'template', source }` envelope).
- `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write.
- No public export, signature or schema shape moves. The `confirmText` describe now says the texts are refused.
- Measured before crossing, through the built `@objectstack/lint` at `925fd0772c`: 0 in-repo producers carry such a sentence. The 6 bulk defs with a `confirmText` in `examples/app-showcase` (`project.view.ts`) draw 0 findings, against 1 for a planted unbound hole. HotCRM at `1e71d5e` declares 3 bulk `confirmText` sentences on `account.view.ts` (`{{count}}`, and `{{tier}}` on the def whose param is `tier`), all over bound roots: 0 findings. Its other 9 of the 12 are translations of those 3 in its locale bundles, which this pass does not read.

<!-- adr-0087: not-required (no-migration-prescription) a refusal at `objectstack validate`, `compile` and the publish gate of a bulk action `confirmText` naming an unbound root, carrying a single-brace token, holding logic in a hole, or blank: no authorable key, spelling, export or stored shape moves, the parse is unchanged, and no stored row is read, rewritten or converted. A stored view 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). -->
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 \| { 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. |
| **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 refused by name at `objectstack validate`, `compile` and the publish gate, as are a hole holding logic or an unknown formatter and a blank sentence. |
| **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 \| { 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. |
| **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 refused by name at `objectstack validate`, `compile` and the publish gate, as are a hole holding logic or an unknown formatter and a blank sentence. |
| **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
Loading
Loading