Skip to content

Commit 0c81694

Browse files
feat(lint)!: refuse an action's copy-template findings (blank, unbound root, single-brace, logic, raw-shown ${…}) at validate, compile and the publish gate (#25702)
Fixes #25680 Clause-②: no (narrowing) Metadata-protocol: narrowed Ruling-ref: `6365736632` (on #24814, the refusal half: "its own BREAKING minor card, filed when this card lands, as #25205 is for the bulk slot"); triage `6386348015` The refusal half of ruling `6365736632` on #24814, verbatim: "**The refusal half** (blank and door findings as errors) is its own BREAKING minor card, filed when this card lands, as #25205 is for the bulk slot." #24814 (PR #25637, `384bb7ba02`) typed an action's copy keys as ADR-0032 `template` slots and shipped every door finding as a warning. This PR makes them errors, shaped like #25205 / PR #25451 for the bulk slot. ## What changes - **The action copy pass refuses** (`packages/lint/src/validate-action-copy-templates.ts`, `judgeAction`). A blank sentence, a hole over a root the key does not bind, a single-brace `{x}` token, a hole holding logic or an unknown formatter, and a `${…}` token the runner shows raw are now `severity: 'error'`, and the `ADVISORY` suffix is gone from them. The judge, the walk, the roots and the sentences are unchanged. - **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 `action`, `object` or `page` write, and a draft promotion, answer `422 INVALID_METADATA` with an `expression-invalid` issue at the key. No door code moves. - **`${…}`: kept a warning only where objectui's runner still fills it.** Measured at the pinned `.objectui-sha` `2958f17e9c` (objectui `packages/core/src/actions/ActionRunner.ts`): `confirmMessage` still evaluates a `confirmText` with no `{{ }}` hole through its `${…}` expression evaluator (whose `record` is the record the action runs against), and `composeSuccessMessage` still substitutes `${result.*}` (and nothing else) in a `successMessage` with no `{{ }}` hole; an `errorMessage` is shown as written (`actionCopySource`, no substitution). So, per the triage's lead, a `${…}` token stays a **warning** exactly when the runner fills it: a sentence with no `{{ }}` hole whose every token is `${record.*}` (`confirmText`) or `${result.*}` (`successMessage`). Its finding now ends "A warning, not a refusal, for now: the console's runner still fills …". Every other `${…}` is shown raw by the runner, so it is an error: in an `errorMessage`, beside a `{{ }}` hole or a `{x}` token, over `params` in a `confirmText`, or over `record` in a `successMessage`. - The roots are a spec fact beside the slot's other runner-measured roots: `ACTION_COPY_DOLLAR_TRANSITION_ROOTS` (`@objectstack/spec/ui`). The shape is read by the one template reader: `dollarOnlyTokenRoots` (`@objectstack/spec/automation`, beside `closedScopeTemplateRefusal`), so lint holds no second reading of the dialect. - **The blank arm is kept** (`actionCopyTemplateSlot`, comment only), following #25205's answer for the bulk slot. The parse is what the boot load and the read path judge a stored row with; dropping the arm would make a stored blank sentence an invalid row on load, a narrowing wider than the three doors. The doors refuse a blank sentence in this same change, so every finding is one kind of verdict: a door refusal over text that parses. Door test (d) pins this for a blank and for an unbound hole. - **Prose made false by the flip, corrected:** the module notes (`action-copy-template.ts` "Widening only" section, `validate-action-copy-templates.ts`), the three copy `.describe()`s and their regenerated references (`content/docs/references/{ui/action,ui/component,data/object,kernel/metadata-plugin}.mdx`), the `authoring-rules.ts` registration comment, three `validate-expressions.ts` comments, and the dogfood `expression-conformance.ledger.ts` rows' `enforcement` / `note` text. `affected-docs.mjs --json origin/main` named 17 pages (4 release-owned, untouched); none of the 13 hand-written ones states the warning-only rule or a `${…}` copy sentence, so none changes. - **Changeset** `.changeset/25680-action-copy-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 #25205's. ## Producers, measured first (through the built `@objectstack/lint` at `6021b5e837`, before the change) - `examples/app-crm`, `app-showcase` (23 registered `successMessage`s, 29 pages), `app-todo` (2 `confirmText`, 16 `successMessage`), `app-multi-package`: 0 findings. `@objectstack/platform-objects` (12 `confirmText`, 48 `successMessage`, as one stack of its exported objects): 0. `@objectstack/plugin-approvals` (9 `successMessage`): 0. Control: a planted `{{ nope.x }}` drew 1 (a warning before, an error after). - HotCRM `a4b3560` (read-only clone): its 20 action sentences (1 `confirmText`, 19 `successMessage`, `src/{sales,service,marketing}/actions`) judged through the same built pass: 0 findings, against an error for a planted `{count}`. Its bulk `confirmText`s are #25205's pass, already measured there. - No producer is refused, so none is converted. ## Tests (head `c69a7a6d`; final head `f03deb5f` adds only the changeset's `Ruling-ref:` line, and the ten changeset / doc families re-ran on it, all exit 0) - `validate-action-copy-templates.test.ts`: every pin flips from `warning` to `error`, plus new cases: a single-brace token and an empty blank as errors; the four raw-shown `${…}` shapes as errors; `${…}` in a row's `errorMessage` as an error; the transitional `${record.*}` / `${result.*}` as warnings; and the gate block now asserts `errors` with empty `advisories` for a page write (single-brace, unbound, blank), errors for an action and an object write, and one advisory for each transitional sentence. 17/17. - `flow-text-slot-template.test.ts`: `dollarOnlyTokenRoots` answers the roots for an all-`${…}` holeless sentence and `undefined` for no token, a `{{ }}` hole, a bare `{x}`, logic, and an empty string. `action-copy-template.test.ts`: the transition roots per key. - New door block in `packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`, through the real `saveMetaItem`: (a) each of four texts on a page row is refused with `INVALID_METADATA` / 422 and nothing is stored, an action write is refused the same way, and the draft promotion is refused too; (b) the door's finding equals `runAuthoringRules('build', …)`'s on `rule`, `where` and `message`; (c) control: the transitional `${record.name}` sentence saves with one advisory; (d) a stored page with a blank or an unbound sentence loads (`invalid: 0`) and its next save is refused, the row untouched. 10/10. - **Ablation** (`scripts/ablation-replace.mjs`, the refuse helper's `severity: 'error'` back to `'warning'`; blob `dcede89c7bb5` → `60fcaccce37a`; `ablation-dist-preflight.mjs` dist reading PASSED on the mutated build): lint `Tests 9 failed | 8 passed`, door `Tests 9 failed | 1 passed` (the passing one is control (c)). Restore leg: blob back to HEAD, `git diff HEAD` empty, lint rebuilt, preflight `--absent` exit 0, lint 17/17 and door 10/10 green. - `pnpm --filter @objectstack/lint test`: 161/161 files, 7273 tests; typecheck exit 0. - `pnpm --filter @objectstack/spec test`: 719/719 files, 21779 passed, 1 todo; typecheck exit 0; `test:repo` 79/79 files, 1269 tests. `check:generated` green after `--fix` regenerated api-surface, export-origins and the references, and green again after merging `origin/main`. - `pnpm --filter @objectstack/metadata-protocol test` (the door's consumer): 263 passed / 3 skipped files, 21485 passed / 19 skipped; typecheck exit 0. - `pnpm --filter @objectstack/cli exec vitest run --project unit` (`os validate` / `compile`): 328/328 files, 4816 tests. - Full dogfood (`pnpm --filter @objectstack/dogfood test`, owed by the `(narrowing)` arm) at `c69a7a6d`: 295 passed / 3 skipped files, 2520 passed / 18 skipped. - Derived gates: `dispatch-gates --commands` gives 114 families; all 114 exit 0 (three first answered exit 3, prerequisite not met, and were re-run once their prerequisite was built or fetched: `check-plugin-teardown-shape --self-test`, `check:skill-examples`, `check:dual-build-cjs-loads`). `--ran` reconciles 114/114. Results are in the os-dev-report comment. ## Acceptance notes - `${…}` stays a warning where the runner fills it, as the triage ordered. Refusing it is owed once objectui's `ActionRunner` retires the `${…}` reading in `confirmMessage` and `composeSuccessMessage`. Who carries that is a seat call; this PR names it in the report and files nothing. - Translated copy strings in translation bundles are still not judged, as before this change: the pass reads the action's own slot (and an inline locale map), the same boundary #25205 kept. - `ActionSchema.errorMessage` and `outcomeMessages` stay outside the family (contract review `6385469184` on PR #25637); a pin asserts a `${…}` in `ActionSchema.errorMessage` still draws nothing. - Contract review at `CONTRACT_REVIEW_TIER` is the seat's. No governed surface is touched (`.claude/**`, `docs/adr/**`, `AGENTS.md`, `skills/**`, `.github/workflows/**`). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WbVg72GQsLxcNHWW17FJZA --- _Generated by [Claude Code](https://claude.ai/code/session_01WbVg72GQsLxcNHWW17FJZA)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent fa44dd2 commit 0c81694

20 files changed

Lines changed: 408 additions & 78 deletions
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
'@objectstack/lint': minor
3+
'@objectstack/spec': minor
4+
---
5+
6+
feat(lint)!: `objectstack validate`, `compile` and the publish gate refuse an action's `confirmText` / `successMessage` / `errorMessage` that is blank, names an unbound root, carries a single-brace token, holds logic in a hole, or carries a `${…}` token the runner shows raw (#25680)
7+
8+
Clause-②: no (narrowing: an action's copy sentence that is blank, holes an unbound root, carries a single-brace or raw-shown `${…}` token, or holds logic in a hole is refused at `objectstack validate`, `compile` and the publish gate; it ships now under the amended 「甲」, #6015 `6303865025`, as #25205 did for the bulk slot)
9+
Metadata-protocol: narrowed
10+
Ruling-ref: `6365736632` (on #24814, the refusal half); triage `6386348015`
11+
12+
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".
13+
14+
**BREAKING — what moves for consumers.**
15+
16+
- 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 (`page 'P'.regions[0].components[1] (action:button) · confirmText`, `stack · action 'A' successMessage`, `… successMessage.zh-CN` for one value of a locale map). The item is not stored. This covers `saveMetaItem` in publish mode (Studio, REST `/meta`, MCP) and the promotion of a draft (`publishMetaItem`).
17+
- The refused texts: a blank sentence (`''`, `' '`); a hole over a root the key does not bind (`{{ result.name }}` in a `confirmText`, any hole in an `errorMessage`); a single-brace `{x}` token; a hole holding logic or naming an unknown formatter (`{{ record.count * 2 }}`, `{{ result.email | shout }}`); and a `${…}` token the runner shows as written — one in an `errorMessage`, one in a sentence that also has a `{{ }}` hole or a `{x}` token, one over `params` in a `confirmText`, or one over any root but `result` in a `successMessage`.
18+
19+
**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.
20+
21+
**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.
22+
23+
**Unchanged.**
24+
25+
- 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.
26+
- `ActionSchema.errorMessage` and `outcomeMessages` are not in this family and draw nothing.
27+
- 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).
28+
- `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write.
29+
- 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.
30+
- 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}`.
31+
32+
<!-- 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). -->

‎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 \| { 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. |
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 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. |
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 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. |
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 |

0 commit comments

Comments
 (0)