Skip to content

Commit ecb4e69

Browse files
feat(lint)!: refuse a bulk action's confirmText hole over an unbound root, a single-brace token, a hole holding logic and a blank sentence at validate, compile and the publish gate (#25451)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6Jao2bTBgLstWH97J6JDCaAAFh)_ Fixes #25205 Clause-②: no (narrowing) Metadata-protocol: narrowed The refusal half of ruling `6285948976` item 1 on #24798, verbatim: "an unbound root and a single-brace token are refused by name at `objectstack validate`, `compile` and the publish gate through the one judge the flow text slots use (`automation/flow-text-slot-template.ts`)". #24947 (merged as `925fd0772c`) typed the slot and shipped every finding as a warning. Under the amended 「甲」 (#6015 `6303865025`) this narrowing ships now, in 18.x, as its own BREAKING minor, shaped like #25002's page-predicate bounds. ## What changes - **The bulk `confirmText` pass refuses** (`packages/lint/src/validate-expressions.ts`, `bulkConfirmTextIssues`). Every finding is now `severity: 'error'`, pushed the way the flow text slots push theirs, and the `BULK_CONFIRM_TEXT_WARNING_ONLY` suffix is gone. The judge, the walk and the sentences are unchanged. The four refused texts are a hole over a root the def does not bind, a single-brace `{x}` token, a hole holding logic or an unknown formatter, and a blank sentence. - **How it reaches each door.** `objectstack validate` and `compile` fail on an `error` from `validateStackExpressions`. The publish gate splits `errors` from `advisories` by severity (`runtime-gate.ts`), so an active `view` or `object` write, and a draft promotion, now answer `422 INVALID_METADATA` with an `expression-invalid` issue at `bulkActionDefs[N] confirmText`. No door code moves. - **The blank `''` arm is kept** (`packages/spec/src/ui/bulk-action.zod.ts`, comment and describe only). The doors refuse a blank sentence in this same change, as they refuse the other three. The parse is what the boot load (`computeMetadataDiagnostics` in `loadMetaFromDb`) and the read path judge a stored view with. Dropping the arm would make a stored blank sentence an invalid row on load, a narrowing wider than the three doors the ruling names, and one #25002's precedent avoids ("Stored pages are not migrated and are not refused on read"). With the arm kept, every bulk `confirmText` finding is one kind of verdict: a door refusal over text that parses. A stored view keeps loading until its next save, and that save is judged. The new door test (d) pins this for a blank and for an unbound hole. - **Prose made false by the flip, corrected:** the slot's `.describe()` (it said "as a warning"), the regenerated references (`content/docs/references/ui/{bulk-action,component,view}.mdx`), the `authoring-rules.ts` registration comment, a `runtime-gate.test.ts` comment, and the dogfood `expression-conformance.ledger.ts` row's `enforcement` text. - **Changeset** `.changeset/25205-bulk-confirm-text-refusal.md`: `@objectstack/lint` and `@objectstack/spec` `minor`, both declaration lines, a FROM/TO block, and the ADR-0087 disposition `not-required (no-migration-prescription)`, worded like #25002's. ## Producers, measured first (through the built `@objectstack/lint`) - Shipped corpus at `925fd0772c`: the only non-test bulk defs with a `confirmText` are the 6 in `examples/app-showcase/src/ui/views/project.view.ts`, all holeless: 0 findings, against 1 for a planted `{{ nope }}` control. No `platform-objects`, `skills/` or other example module declares one. - HotCRM `origin/main` `1e71d5e` (read-only): `src/sales/views/account.view.ts` declares the 3 bulk sentences (`{{count}}`, and `{{tier}}` on the def whose param is `tier`): 0 findings, against an `error` for a planted `{count}` control. The other 9 of the card's 12 are their es-ES / ja-JP / zh-CN translations in locale bundles, which this pass does not read; they use the same two roots. No producer is refused. ## Tests (head `6a4c0663`) - `validate-expressions.bulk-confirm-text.test.ts`: the pins flip from `warning` to `error`, the "no error at all" case inverts to "no warning at all", and the gate block now asserts `errors` with empty `advisories` on all three view shapes, plus a single-brace and a blank case per shape. - New door block in `packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`, through the real `saveMetaItem`: (a) each of the four texts is refused with `INVALID_METADATA` / 422 and nothing is stored, and the draft promotion is refused too; (b) the door's finding equals `runAuthoringRules('build', …)`'s on `rule`, `where` and `message`; (c) control: the HotCRM sentence saves; (d) a stored view with a blank or an unbound sentence loads (`invalid: 0`) and its next save is refused, the row untouched. - **Ablation** (`scripts/ablation-replace.mjs`, `severity: 'error'` back to `'warning'` in the push helper; blob `b86ef1862f5b` → `7ab1d8f7b6ad`; `ablation-dist-preflight.mjs` found the marker in 4 built files of `packages/lint/dist`): lint `Tests 11 failed | 6 passed`, door `Tests 8 failed | 1 passed` (the passing one is control (c)). Restore leg: blob back to HEAD, `git diff HEAD` empty, lint rebuilt, preflight `--absent` clean, door 9/9 green. A first attempt's preflight spelled the dist marker with single quotes and read ABSENT although the build carried it; that run is not counted, and the run above is the re-take with the emitted spelling. - `pnpm --filter @objectstack/lint test`: 156/156 files, 7196 tests; typecheck exit 0. - `pnpm --filter @objectstack/metadata-protocol test`: 261 passed / 3 skipped files, 21456 passed / 19 skipped; typecheck exit 0. - `pnpm --filter @objectstack/spec test`: 712/712 files, 21657 passed, 1 todo; typecheck exit 0. `check:generated` green after `--fix` regenerated the three references. - Full dogfood (`pnpm --filter @objectstack/dogfood test`, owed by the `(narrowing)` arm) at `e644a80a`: 290 passed / 3 skipped files, 2494 passed / 18 skipped. `6a4c0663` changes one string in the ledger row (it dropped a tracker id that `check:doc-authoring` named); `expression-conformance.test.ts` re-ran there: 8/8. - Derived gates: `dispatch-gates --commands` gives 112 families, the same list at both heads. Results are in the os-dev-report comment. ## Acceptance notes - Translated bulk `confirmText` strings in translation bundles are still not judged for holes, as before this change: the ruling binds the def's slot. #24947's report carries this to #24814. - Contract review at `CONTRACT_REVIEW_TIER` is the seat's. No governed surface is touched (`.claude/**`, `docs/adr/**`, `AGENTS.md`, `skills/**`, `.github/workflows/**`). - #24814, the parallel text-slot sweep, also edits `validate-expressions.ts`; this diff stays inside the bulk block and its two comments. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01MrzJbTRTRRTVangBSZz6JR --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0b8dbea commit ecb4e69

11 files changed

Lines changed: 242 additions & 66 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
'@objectstack/lint': minor
3+
'@objectstack/spec': minor
4+
---
5+
6+
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)
7+
8+
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`)
9+
Metadata-protocol: narrowed
10+
11+
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".
12+
13+
**BREAKING — what moves for consumers.**
14+
15+
- 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`).
16+
- 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 (`''`).
17+
18+
**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.
19+
20+
**Unchanged.**
21+
22+
- 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.
23+
- A sentence with no finding draws nothing at any door, in either form (the bare string or the `{ dialect: 'template', source }` envelope).
24+
- `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write.
25+
- No public export, signature or schema shape moves. The `confirmText` describe now says the texts are refused.
26+
- 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.
27+
28+
<!-- 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). -->

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ const result = BulkActionDefSchema.parse(data);
4848
| **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. |
4949
| **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. |
5050
| **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. |
51-
| **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. |
51+
| **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. |
5252
| **confirmLabel** | `string` | optional | Custom Confirm button label (default: "Run"). |
5353
| **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. |
5454
| **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. |

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -928,7 +928,7 @@ Sort field and direction pair
928928
| **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. |
929929
| **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. |
930930
| **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. |
931-
| **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. |
931+
| **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. |
932932
| **confirmLabel** | `string` | optional | Custom Confirm button label (default: "Run"). |
933933
| **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. |
934934
| **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. |

0 commit comments

Comments
 (0)