Repository navigation
Commit 7e3822c
feat(spec)!: an action's description, param helpText and outcomeMessages are {{ }} template slots; ${…} refused at publish (#28463)
domain:spec#2 · os-tesla · session_01L9qibx5r5nhDMwpRs4SL6D
<!-- claude-projects-attribution: {"github_login":"os-tesla"} -->
_Requested by **Tesla** · [project
thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoBCpS3vE7HS7cD6zZzsAPjZ)_
Fixes #27948
Clause-②: yes (narrowing)
Metadata-protocol: narrowed
Skills-docs: follow-up #28464 (skills/objectstack-ui/rules/actions.md
line 23 lists the `{{ }}` copy holes for `confirmText` /
`successMessage` / `errorMessage` only; add `description`, param
`helpText` and `outcomeMessages`)
## 维护者速读
- 改了什么:动作的 `description`(参数对话框说明)、每个参数的 `helpText`、每条 `outcomeMessages`
现在和 `confirmText` / `successMessage` 一样是 `{{ }}` 模板槽:裸字符串解析成 `{ dialect:
'template', source }`,`objectstack validate` / `compile` / 发布闸(422)拒绝其中的
`${…}`,并给出 `{{ }}` 写法。平台自带文案(邀请、重发邀请、改角色)和四种语言翻译已改成 `{{ }}`。
- 为什么改:裁决 A(「同意」)定五个文案键统一用 `{{ }}`;第 1 步(#28436)让控制台先能读 `{{ }}`,这是第 2
步:规范收口 + 发布拒绝 + 迁移。
- 风险与回滚:收窄(破坏性):写 `${…}` 的这三个键在保存/发布时被拒;已存的仍能加载,控制台对没有 `{{ }}` 的句子继续按
`${…}` 填,直到下次保存。解析后的值变成信封对象,所以第一个提交先让所有读取方(core、react、runtime、cli、Studio
预览)接受信封。回滚即 revert 本 PR。
-
先看:packages/spec/src/ui/action.zod.ts(三个槽的类型)、packages/lint/src/validate-action-copy-templates.ts(发布闸)、ui/packages/core/src/actions/ActionRunner.ts(`dialogCopyOf`)、迁移条目
packages/spec/src/migrations/entries/semantic/18.action-dialog-outcome-copy-template-holes.ts。
- 两分钟验证:`pnpm --filter @objectstack/lint exec vitest run
src/validate-action-copy-templates.test.ts`,预期 23/23 通过;`cd ui && pnpm
exec vitest run
packages/core/src/actions/__tests__/ActionRunner.copyEnvelope-27948.test.ts`,预期
4/4。
- 需要你做的:无,契约复审后由席位落地;HotCRM 的同类文案另开为 objectstack-ai/hotcrm#2384。
## What changes
Step 2 of #27948, ruling 6545229148, letter A. Four commits.
**1. Readers first** (`fcd2e654`). Typing the three keys makes a parsed
action carry `{ dialect: 'template', source }` where it carried a
string, so every reader that took a string now takes the envelope (claim
6546254270, amendments 6546313079 and 6546830184):
- `ui/packages/core/src/actions/ActionRunner.ts`: `dialogCopyOf` hands
the param-collection handler the action and params with `description` /
each `helpText` reduced to their text (`actionCopyText`, new in
`actionCopyTemplate.ts`); a locale map is handed on for the host to
localize. `composeSuccessMessage` reads an outcome envelope's `source`.
- `ui/packages/react/src/hooks/useActionTextLocalizer.ts`: an
`outcomeMessages` envelope keeps its shape (a bundle entry replaces its
`source`) instead of collapsing to the word "template".
- `packages/runtime/src/action-execution.ts` `summarizeActionParams`:
the MCP param description reads `helpText`'s text, never "[object
Object]".
- `packages/cli/src/utils/i18n-extract.ts`: `description`, param
`helpText` and `outcomeMessages` go through `actionCopy`
(`templateSlotSource`), so the envelope seeds exactly as the bare
string.
-
`ui/packages/app-shell/src/views/metadata-admin/previews/ActionPreview.tsx`:
`localize` reads the envelope's `source` and `helpText` goes through it
(it was drawn raw, an object as a React child).
**2. Spec and the publish refusal** (`45d5e284`).
- `packages/spec/src/ui/action.zod.ts`: `description` and each
`outcomeMessages` value are `actionCopy(...)` slots with the inline
locale map; `ActionParamSchema.helpText` is `actionParamHelpText()`, the
same slot without a map (it was a plain string). Describes teach `{{
}}`.
- `packages/spec/src/ui/action-copy-template.ts`: `ActionCopyKey`
widened to the three keys, each with its own refusal wording and
example; `ActionRowCopyKey` keeps the page rows at their three; three
describe constants; module notes on the roots and the `${…}` transition.
- `packages/lint/src/validate-action-copy-templates.ts`: a registered
action is judged on `description`, each `outcomeMessages.OUTCOME` and
each `params[N].helpText`; an inline `properties.action` on its params'
`helpText`. Roots: `actionConfirmTemplateRoots(action)` for the dialog
copy, `ACTION_SUCCESS_TEMPLATE_ROOTS` for an outcome. Every finding is
an error, so the publish gate answers 422.
- `packages/spec/src/system/i18n-resolver.ts`: `ActionLike` types the
envelope on the three keys.
- Expression-conformance ledger: rows `template-action-dialog-copy` and
`template-action-outcome-message`. `liveness/action.json`
outcomeMessages evidence names `{{ }}`. Reference docs regenerated.
- Eight platform-objects identity tests read the parsed copy through
`templateSlotSource` (`copyAsWritten`), as their `confirmText` reads
already did.
**3. Migration and producers** (`a8f9f1c7`).
- Semantic entry `action-dialog-outcome-copy-template-holes`. No
conversion: there is no copy-hole conversion family in `os migrate
meta`, and a `${…}` token held an expression (`${record.user_id?.name ??
'this member'}`), which has no mechanical `{{ }}` spelling.
- Producers: `invite-outcome-messages.ts` (`sent` / `link_only`, invite
and resend), `sys_invitation.resend_invitation` and
`sys_member.update_member_role` descriptions (`{{ record.user_id.name |
default:'this member' }}`), both `permission_set_granted` outcomes, the
four `objects.generated` bundles (en regenerates identically;
`check-i18n-bundles` in sync), the action form's `outcomeMessages` help
(`action.form.ts` + four `metadata-forms.generated` bundles), and
`content/docs/ui/actions.mdx`.
- Changesets: `27948-action-copy-template-slots.md` (`@objectstack/spec`
and `@objectstack/lint` minor; platform-objects, runtime, cli patch;
ADR-0087 `registered`, FROM → TO list) and
`27948-action-copy-envelope-readers.md` (`@object-ui/core`,
`@object-ui/react`, `@object-ui/fields` patch).
**4. Fields reader** (`408466db`).
`ui/packages/fields/src/resolveActionParams.ts` reads `helpText` through
`actionCopySource`; pin
`ui/packages/app-shell/src/utils/resolveActionParams.helpTextEnvelope-27948.test.ts`
(3 tests; reverse 2 failed, the type pin passed).
## Tests
- New pins, each reverse-checked (source restored from the merge base,
test kept, then restored):
- `ActionRunner.copyEnvelope-27948.test.ts` (core): 4; reverse 2 failed,
2 passed (the passing two are controls).
- `useActionTextLocalizer.outcomeEnvelope-27948.test.tsx` (react): 3;
reverse 2 failed, 1 control passed.
- `action-param-help-envelope-27948.test.ts` (runtime): 2; reverse 2
failed.
- `i18n-extract-action-copy-envelope-27948.test.ts` (cli): 2; reverse 1
failed, 1 control passed.
- `ActionPreview.copyEnvelope-27948.test.tsx` (app-shell): 2; reverse 1
failed, 1 control passed.
- `validate-action-copy-templates.test.ts` (lint): 6 new; reverse all 5
refusal pins failed, the accept pin passed.
- `action-copy-template.test.ts` (spec): 7 new (parse arms, locale map
per slot, refusal names, describes teach `{{ }}`).
- Updated: `action-description.test.ts` and
`action-outcome-messages.test.ts` expect the envelope from a parsed bare
string.
## Acceptance notes
- **Readers, all five groups** (seat ruling A, amendments 6546313079,
6546830184 and 6547728288): `@object-ui/react` `useActionTextLocalizer`;
runtime `summarizeActionParams`; cli `i18n-extract`; app-shell
`ActionPreview.tsx`; `@object-ui/fields` `resolveActionParams` (commit
4, `408466db`, named by CI "Type Check UI": `RawActionParam.helpText` is
`unknown` and read through `actionCopySource`; a field-backed param
whose envelope has no text falls back to the field's help). Plus core's
own `ActionRunner` (`dialogCopyOf`, `composeSuccessMessage`).
App-shell's `useConsoleActionRuntime` / `RecordDetailView` /
`ActionParamDialog` read `description` / `helpText` from what the runner
hands the handler, so they get strings and do not move.
- **Left as is: `action-icon.tsx` tooltip**
(`ui/packages/components/src/renderers/action/action-icon.tsx` lines 319
and 332, `schema.label || schema.description`). It would draw an
envelope only with an empty label, and no parsed slot reaches it that
way: `useActionTextLocalizer` always fills `label` (falling back to the
name), a page row's `description` is not a slot, and `UIActionSchema`
still types `description` as a string. Seat ruling: leave it.
- **Reader sweep** at `408466db` over non-test sources in
`ui/packages/*/src`, `packages/*/src`, plugins, services, mcp and cli:
no other reader of the three keys takes the parsed value as a string.
The renderers (`action:button` / `icon` / `group` / `menu`,
`element:button`) forward `description` and `outcomeMessages` into
`execute()`, and the runner reduces them.
- **HotCRM producers** (carried by objectstack-ai/hotcrm#2384):
`src/service/actions/knowledge_article.actions.ts` lines 121-123 and
144-146 (six `outcomeMessages` entries with `${result.helpful}` /
`${result.notHelpful}`), and
`src/sales/translations/{en,es-ES,ja-JP,zh-CN}/objects.service.ts`.
Roots are `result`, so each converts to `{{ result.helpful }}` / `{{
result.notHelpful }}`.
- **`default` vs `??`.** `{{ record.user_id.name | default:'this member'
}}` also fires on an empty name, where `??` fired only on
null/undefined. For a member name that is the intended reading.
- **Census beyond the stop report.** Three more platform holes than
`out/27948-step2-surface-stop.md` listed: the `link_only` outcomes and
both `permission_set_granted` outcomes
(`${result.grantedPermissionSet.label}`). All on `record` / `result`,
all converted.
- **The family's other refusals now reach these keys**: a blank
sentence, a single-brace `{x}`, a hole over an unbound root, logic or an
unknown formatter. The in-repo census found none; full dogfood is the
check on the platform's own copy.
- **Not changed.**
`packages/plugins/plugin-auth/src/role-change-granted-set.ts` line 29
still names `${result.grantedPermissionSet.label}` in a comment (outside
the surface). The ui console pin
`ActionParamDialog.invitationScreens-27521.test.tsx` carries its own
`${…}` literal, which the console still reads.
- **`ActionLike` / `ActionCopyKey` widening** is a type-level change for
TypeScript readers of a parsed `ActionSchema`; the changeset names
`templateSlotSource` as the reader.
## Verification
Readings at head `a8f9f1c7` (merge base `776c1d87`).
<details><summary>Tests</summary>
- `pnpm --filter @objectstack/spec test`: Test Files 773 passed, 1
skipped; Tests 22871 passed.
- `packages/lint` vitest: Test Files 183/183, Tests 7699/7699.
- `packages/platform-objects` vitest: Test Files 104/104, Tests
1474/1474.
- `packages/cli` vitest `test/i18n`: 44/44 files, 419 tests.
`packages/runtime` vitest `src/action*`: 29/29 files, 521 tests.
- `cd ui && pnpm exec vitest run packages/core/src/actions
packages/react/src/hooks packages/react/src/context`: 108/108 files,
1263 tests. `packages/app-shell/src/views/metadata-admin/previews`:
157/157 files, 1713 tests.
- Type-check: spec `tsc --noEmit`, runtime and cli `typecheck`,
`@object-ui/core` and `@object-ui/react` `type-check`: clean.
- `node scripts/check-i18n-bundles.mjs`: all bundles in sync.
- Full dogfood: see the os-dev-report on the card.
</details>
<details><summary>Gates</summary>
- Build: `env -u OS_SKIP_DTS bash scripts/pm/os-verify-lock.sh -c "pnpm
exec turbo run build --filter='./packages/**'
--filter='@objectstack/dogfood^...'"`, exit 0.
- `dispatch-gates --commands` at `a8f9f1c7`: 116 families, stdin from
`/dev/null`, exit codes recorded before any pipe; all 116 exit 0.
`--ran`: 116 derived, 116 run, 0 NOT-MEASURED. Record:
`/mnt/project-files/spec-seat-2/r2026-10-30/out/27948-step2-gates-ran-a8f9f1c7.list`.
- The full CI farm is CI's.
</details>
---
🤖 Generated with [Claude Code](https://claude.com/claude-code)
_Generated by [Claude
Code](https://claude.ai/code/session_01L9qibwQ8tWwY7TiHQPMCHR)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 52306d4 commit 7e3822c
57 files changed
Lines changed: 1360 additions & 211 deletions
File tree
- .changeset
- content/docs
- references
- api
- data
- kernel
- system
- ui
- ui
- packages
- cli
- src/utils
- test
- lint/src
- platform-objects/src
- apps/translations
- identity
- plugins/plugin-approvals/src
- translations
- qa/dogfood/test
- runtime/src
- spec
- liveness
- src
- migrations/entries/semantic
- system
- ui
- ui/packages
- app-shell/src
- utils
- views/metadata-admin/previews
- __tests__
- core/src/actions
- __tests__
- fields/src
- react/src/hooks
- __tests__
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
794 | 794 | | |
795 | 795 | | |
796 | 796 | | |
797 | | - | |
| 797 | + | |
798 | 798 | | |
799 | 799 | | |
800 | 800 | | |
| |||
971 | 971 | | |
972 | 972 | | |
973 | 973 | | |
974 | | - | |
| 974 | + | |
975 | 975 | | |
976 | 976 | | |
977 | 977 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
122 | 122 | | |
123 | 123 | | |
124 | 124 | | |
125 | | - | |
| 125 | + | |
126 | 126 | | |
127 | 127 | | |
128 | 128 | | |
| |||
372 | 372 | | |
373 | 373 | | |
374 | 374 | | |
375 | | - | |
| 375 | + | |
376 | 376 | | |
377 | 377 | | |
378 | 378 | | |
| |||
0 commit comments