Skip to content

Commit 3da9a4c

Browse files
feat(spec,formula,lint): a mail template's option formatter prints a select value as its option label in the recipient's language (#30727)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4Uu8VsRMt9NAU2MFrY4eYtfoM)_ domain:spec#2 · os-sales · session_01L6bhgUtH2CXA8Qb8bDBRom Fixes #30172 Clause-②: yes (widening) Metadata-protocol: added Skills-docs: follow-up #30731 — `skills/objectstack-automation/SKILL.md` (the `notify` section) gains one line: an option label in a notice is written with the mail formatter `{{ stage | option:opportunity.stage }}`, not with `label()` in `templateData`. The skills lane owns that file. ## 维护者速读 - 改了什么:邮件模板新增 `option` 格式化器,写法 `{{ stage | option:opportunity.stage }}`,参数是不带引号的「对象.字段」;spec 的模板文法句、formula 的邮件文法与格式化器、lint 的邮件模板规则同步认它;对象按引用阶梯解析(应用自有对象、制品 `packages[]` 里其他包提供的对象、平台对象;带平台前缀却无人注册的名字在 CLI 只给 warning,与 `object-reference-unregistered-platform` 同级;发布门只认 live universe,不在其中的名字一律报错;写入某个包时只认该包的依赖闭包,闭包外的对象报「在闭包外」并提示声明依赖);站内模板文字(流程通知标题/正文、操作确认语)照旧当作未知格式化器拒绝。 - 为什么改:流程通知只填一次就发给不同语言的收件人,选项字段(如商机阶段)只能印出存储值 `needs_analysis`;裁决 F 定下用邮件模板格式化器按收件人语言印标签,本 PR 是其中 spec 一半。 - 风险与回滚:只放宽,以前通过的模板全部照旧;保存时对象不存在、字段不存在、字段没有选项、参数写法不对都会报错。渲染时没有拿到该「对象.字段」的标签表就抛出 `optionTableRefusal` 的句子,不发送,绝不拿存储值冒充标签;所以 server 一半(plugin-email 按收件人语言建标签表)落地前,这种洞能通过校验但发送会失败,席位因此让本 PR 与 server 一半背靠背落地。回滚 = revert 本 PR。 - 先看:`packages/formula/src/template-engine.ts` 的 `option` 格式化器、`optionTableRefusal` 与 `judgeHoleFormatter` 新增的两段;`packages/formula/src/mail-hole-grammar.ts` 的 `optionFieldRefusal`;`packages/lint/src/validate-email-template-hole-grammar.ts` 的 `optionFieldIndex`。 - 两分钟验证:运行 `pnpm --filter @objectstack/formula exec vitest run src/template-option-formatter.test.ts`,预期 51 例全绿,其中「the formatter, given the label table」一组按 en / zh-CN 两张表分别印出 `Needs Analysis` 与 `需求分析`,没有表时抛出拒绝句。 - 需要你做的:无,席位与 server 一半背靠背落地。 ## What changes | Package | Change | | --- | --- | | `@objectstack/spec` | `MAIL_TEMPLATE_HOLES` (the `subject` / `bodyHtml` / `bodyText` descriptions of `email_template`) gains one sentence, `OPTION_ARGUMENT_RULE` word for word, including the multi-select reading. The reference page is regenerated. | | `@objectstack/formula` | `FORMATTERS` gains `option`; `MAIL_TEMPLATE_FORMATTERS` (new) is the mail list, `TEMPLATE_FORMATTERS` stays the in-app list. `judgeHoleFormatter` judges the known set per dialect: in-app, `option` is `known-formatter` with a sentence saying it formats only in a mail template; in mail, an argument that is not a bare `object.field` is `option-field-shape`. `parseMailHole` returns `optionField: { object, field }`. `judgeMailHoleGrammar(face, { objects })` judges the reference against the objects the caller hands in (`option-object-unknown`, `option-field-unknown`, `option-field-no-options`; an object handed in as `null` resolves with its field not judged; a name it does not carry that has a platform prefix, `sys_` / `cloud_` / `ai_`, is rung ④ of the object-reference ladder, neither refused nor judged; with `liveUniverse: true`, objects that are the deployment's whole served universe, there is no rung ④ and such a name is refused; with `packageScope: { packageId }`, objects narrowed to a written package's closure, a name they do not carry is `option-object-outside-closure`, naming the declare-the-dependency remedy). `formatValue` takes `optionLabels` (`OptionLabelTable`, new). New exports: `OPTION_ARGUMENT_RULE`, `optionFieldReference`, `optionTableRefusal`, `optionObjectUnregisteredPlatform`, `MAIL_TEMPLATE_FORMATTERS`, types `OptionLabelTable`, `OptionFieldReference`. | | `@objectstack/lint` | `email-template-hole-grammar` gains an arm (no new rule id, since this rule already owns the hole). It resolves the object on the object-reference ladder: the stack's own objects as the registry serves them (`objectsAsServed`, so `objectExtensions` fields count; the injected columns beside the declared fields; options read by `declaresOptionsOnceServed`, so a `picklist` name counts), then the objects another entry of the artifact's `packages[]` provides, then `PLATFORM_PROVIDED_OBJECT_NAMES`, whose field is left to the publish door and the send. A platform-prefixed name none of these carries is rung ④: advised at `warning` under the same rule id, field unjudged, as `object-reference-unregistered-platform` advises (`EmailTemplateHoleGrammarFinding.severity` widens to `'error' \| 'warning'`). At the publish door (a set `runtimeWriteType`, passed as `validateEmailTemplateHoleGrammar(stack, { liveUniverse })`) the index is the live universe alone: no `packages[]` rung, no platform-name rung, no rung ④, so a name it does not carry is refused, the reading `list-view-object-unknown` gives. A write into a package (`ctx.packageScope`, passed beside `liveUniverse`) is judged against that universe narrowed to the package's closure: an object outside it gets the closure verdict and `closureDependencyRemedy`'s hint, the reading `list-view-object-outside-closure` gives. `EmailTemplateHoleGrammarOptions` is exported beside `EmailTemplateHoleGrammarFinding`. The finding's hint quotes `OPTION_ARGUMENT_RULE` for an `option-*` refusal. | | `content/docs/automation/email-templates.mdx` | One paragraph for `option` (the ladder, multi-select, the refusal with no table); the known-formatter paragraph names `MAIL_TEMPLATE_FORMATTERS`. | The formatter: with a table for the hole's `object.field`, a value prints as its label and a value outside the options prints as stored (ruling parameter 3); a multi-select value labels each element, joined with `, `; a missing value prints empty. With no table for that `object.field` (no table at all, or a table for another field), the call throws a `RangeError` carrying `optionTableRefusal`'s sentence, the `currencyDefaultRefusal` shape, so a renderer can ask it before it formats: the stored value is never printed where a label was promised. This package reads no metadata and no translations. Building the table per recipient locale, and refusing at send an object, field or option list that does not exist (`TEMPLATE_INVALID`), is the server half (`plugin-email`). ### FROM → TO at each door Measured on `origin/main` `4bf9a2d7` (a base worktree, built) and on this branch, the same probe on both trees (`runAuthoringRules('validate', …)` on the parsed stack, `runRuntimeAuthoringRules` for an `email_template` write with the live objects, `judgeMailHoleGrammar`, `templateEngine.compile`, and plugin-email's `EmailService.renderTemplate` / `sendTemplate`). The stack declares `opportunity` with `stage` (select, options) and `name` (text). The ladder rows are pinned in `validate-email-template-hole-grammar.option.test.ts` at `d73ae610`. | Door | Hole | `main` | this head | | --- | --- | --- | --- | | `os validate` | `{{ stage \| option:opportunity.stage }}` | `error` `email-template-hole-grammar`: names the formatter 'option', which is not a template formatter … Known formatters: upper, lower, trim, number, currency, percent, date, datetime, truncate, default, json | nothing | | publish door (`email_template` write) | same | the same refusal | nothing | | renderer grammar (`judgeMailHoleGrammar`) | same | `known-formatter` | nothing | | `os validate` and publish door | `{{ name \| option:opportunity.name }}` | `known-formatter` refusal | `error`: names the field `opportunity.name`, which declares no options … | | `os validate` and publish door | `{{ stage \| option:deal.stage }}` | `known-formatter` refusal | `error`: names the object `deal`, which resolves to no object this app defines, a package of its artifact provides or the platform provides … | | `os validate`, the object only in a sibling `packages[]` entry | `{{ stage \| option:opportunity.stage }}` / `{{ name \| option:opportunity.name }}` | `known-formatter` refusal | nothing / `error`: names the field `opportunity.name`, which declares no options (the sibling's fields are judged) | | `os validate` / `os build`, a platform object the stack does not carry | `{{ status \| option:sys_user.status }}` | `known-formatter` refusal | nothing (field left to the publish door and the send) | | publish door, live universe without `sys_user` | same | `known-formatter` refusal | `error`: names the object `sys_user`, which resolves to no object …; accepted once the live universe serves `sys_user` with a select `status` | | `os validate` / `os build`, a platform-prefixed name no known package registers | `{{ status \| option:sys_userz.status }}` | `known-formatter` refusal | `warning`: names the object `sys_userz`, which has a platform prefix, but no known package registers it … (rung ④; field not judged); control `userz.status` refused at `error` | | publish door, same | same | `known-formatter` refusal | `error`: names the object `sys_userz`, which resolves to no object … | | publish door, a write into package `com.acme.crm` (dependency `com.acme.core`), `opportunity` served by an undeclared package | `{{ stage \| option:opportunity.stage }}` | `known-formatter` refusal | `error`: names the object `opportunity`, which is outside package `com.acme.crm`'s closure …; hint: Declare a dependency on the package that defines "opportunity" in the `dependencies` of "com.acme.crm"'s manifest … ; an object of `com.acme.core` accepted; the same write naming no package accepted | | `os validate` and publish door | `{{ stage \| option:'opportunity.stage' }}` | `known-formatter` refusal | `error`: gives the option formatter the argument `'opportunity.stage'` … one object name and one field name joined by a dot, written bare | | in-app `templateEngine.compile` (notice title / message, confirmText) | `{{ record.stage \| option:opportunity.stage }}` | refused: invalid template hole … holes are a field path with an optional formatter … Known formatters: (the eleven) | refused: names the formatter 'option', which is not a template formatter … `option` formats only in a mail template … Known formatters: (the same eleven) | | `os validate`, any mail hole with a misspelled formatter | `{{ amount \| curency:EUR }}` | Known formatters: … json | Known formatters: … json, option | ### The window between the halves Until the server half lands, `plugin-email` hands `formatValue` no label table. Measured on `d73ae610` (formula rebuilt) through `EmailService.renderTemplate` and `sendTemplate`, a template in `en-US` and one in `zh-CN`, every face holding `{{ stage | option:opportunity.stage }}`, with a transport that records each message it is handed: | Recipient locale | `stage` | `main` | this head (no table) | | --- | --- | --- | --- | | en-US, zh-CN | `needs_analysis` | `TEMPLATE_INVALID` … names the formatter 'option' …; nothing sent | render and send throw: the hole option:opportunity.stage prints the option labels of `opportunity.stage`, and no label table for that field reached this render … The stored value is not printed in place of the label. (then `OPTION_ARGUMENT_RULE`); nothing sent | | en-US, zh-CN | `lost_to_rival` (not an option) | `TEMPLATE_INVALID`; nothing sent | the same refusal; nothing sent | With the table the server half will pass (`formatValue('option', 'needs_analysis', 'opportunity.stage', { optionLabels })`), the en table prints `Needs Analysis` and the zh-CN table `需求分析`; `lost_to_rival` prints as stored (pinned in `template-option-formatter.test.ts`). So in the window a template that validates fails at send, loudly, and never delivers the stored value in place of a label. The seat holds this PR's landing so it lands back to back with the server half, and no release ships the window. ## Tests - `packages/formula/src/template-option-formatter.test.ts` (new, 51): the two lists; the argument parse (2 accepted, 12 refused shapes); the mail dialect accepts, refuses six malformed arguments naming the hole, the argument and the rule; the object / field / no-options judgement with its no-objects control and an object handed in as `null` (field not judged); a `sys_` / `cloud_` / `ai_` name the objects do not carry neither refused nor judged, with the unprefixed `userz` control refused; with `liveUniverse: true` the same three names refused; objects narrowed to a package closure refusing `deal` and `sys_userz` as outside it, naming the remedy, and accepting a carried field; the in-app judge, and compile + `validateExpression` + evaluate refusing it in a notice title, a notice message and a confirm sentence; the formatter with an en and a zh-CN table, outside the options, multi-select and a missing value; with no table and with a table for another field it throws the refusal; `optionTableRefusal` answers null for a table that holds the field and the sentence otherwise; the spec faces quote `OPTION_ARGUMENT_RULE`, multi-select clause included. - `packages/lint/src/validate-email-template-hole-grammar.option.test.ts` (new, 29): a select field accepted in every face; a `picklist`-bound field and an `objectExtensions` field accepted; an object a sibling `packages[]` entry provides, its fields judged; a platform object the stack does not carry (`sys_user.status`) accepted; `sys_userz.status` advised at `warning` with the unprefixed `userz` control refused at `error`; side by side, `os validate` and `os build` answering from the ladder (`sys_userz` a warning, `sys_user` nothing) while the publish door refuses both at `error` against a live universe without them, and accepts `sys_user` once the live universe serves it; a write into a package: an object of an undeclared package gets the closure verdict and the declare-the-dependency hint, an object of a declared dependency is accepted, and the same write naming no package is accepted; the control (the same hole refused once the object is not declared); six refusals naming the hole and what to fix; each face on its own path; `os validate` / `build` / `lint` on the parsed stack; the publish door with and without the object in its live universe; a flow notice `title` and `message` at `os validate`, and an action `confirmText`, refused. - Moved pin: `validate-email-template-hole-grammar.unknown-formatter.test.ts` asserts the mail refusal lists `MAIL_TEMPLATE_FORMATTERS` (it listed `TEMPLATE_FORMATTERS`, which no longer ends the mail list). - Flipped pin (patch round): the no-table case and the table-for-another-field case printed the stored value; they now throw the refusal. ## Verification <details><summary>Suites and typecheck</summary> Head 98f61ec (package-closure verdict, options type export; touches formula and lint only), formula rebuilt: - `pnpm --filter @objectstack/formula test` exit 0: Test Files 65 passed, Tests 1703 passed - `pnpm --filter @objectstack/formula typecheck` exit 0 - `pnpm --filter @objectstack/lint test` exit 0: Test Files 196 passed, Tests 8203 passed - `pnpm --filter @objectstack/lint typecheck` exit 0 - eslint `--no-inline-config` on the nine changed `.ts` files: exit 0. - The four changeset gates: each exit 0. Head 7cea418 (the publish door reads the live universe alone; touches formula and lint only), formula rebuilt: - `pnpm --filter @objectstack/formula test` exit 0: Test Files 65 passed, Tests 1702 passed - `pnpm --filter @objectstack/formula typecheck` exit 0 - `pnpm --filter @objectstack/lint test` exit 0: Test Files 196 passed, Tests 8200 passed - `pnpm --filter @objectstack/lint typecheck` exit 0 - eslint `--no-inline-config` on the nine changed `.ts` files: exit 0. - `check-changeset-no-major --base origin/main`, `check-empty-changeset --base origin/main`, `pnpm check:changeset-gate-self-tests`, `check-changeset-fixed`: each exit 0. Head 030a493 (rung ④): formula 1701, lint 8198, both typechecks, eslint and the four changeset gates exit 0. Head d73ae61, after a full turbo build: - `pnpm --filter @objectstack/formula test` exit 0: Test Files 65 passed, Tests 1700 passed - `pnpm --filter @objectstack/lint test` exit 0: Test Files 196 passed, Tests 8196 passed - formula, lint and spec typecheck exit 0 - `pnpm --filter @objectstack/spec test` exit 0: Test Files 817 passed, Tests 25792 passed, 1 todo - eslint `--no-inline-config` on the nine changed `.ts` files: exit 0. - Round 1 (head `29f86544`): `spec check:generated` reported `content/docs/references/**` stale; `--fix` regenerated it and re-checked green. The patch round's regenerated page is covered by the derived `check:generated` family below. </details> <details><summary>Ablation (through scripts/ablation-replace.mjs; each restored blob equals HEAD and git diff HEAD is empty)</summary> Head 98f61ec: - The closure branch: `...(ctx.packageScope ? { packageScope: ctx.packageScope } : {})` in the rule's registry entry gated on a `__ablated_closure` marker (anchor 1 → 0, blob eaef25c → 9662b429). `validate-email-template-hole-grammar.option.test.ts`: 1 failed, 28 passed (29), the outside-closure pin. Restored blob eaef25c == HEAD, `git diff HEAD` empty, tree clean, 29/29. Head 7cea418: - The publish door's switch: `liveUniverse: ctx.runtimeWriteType !== undefined` in the rule's registry entry gated on a `__ablated_live` marker, so the door walks the CLI ladder (anchor 1 → 0, blob 3cb1d3c → baa96acd). `validate-email-template-hole-grammar.option.test.ts`: 2 failed, 24 passed (26), the two side-by-side door pins (`sys_userz`, `sys_user`). Restored blob 3cb1d3c == HEAD, `git diff HEAD` empty, tree clean, 26/26. Head 030a493: - Rung ④: `optionObjectUnregisteredPlatform` made to answer false (anchor 1 → 0, blob 9d18678 → 25b7acc6), formula rebuilt, `ablation-dist-preflight` marker present in 2 built files. `template-option-formatter.test.ts`: 1 failed, 48 passed (49), the rung ④ pin; `validate-email-template-hole-grammar.option.test.ts`: 2 failed, 22 passed (24), the `sys_userz` warning pin and the validate / publish agreement pin. Restored blob 9d18678 == HEAD, formula rebuilt, preflight `--absent` exit 0, tree clean, 49/49 and 24/24. Head d73ae61: - Must-fix: the `optionTableRefusal(...)` call in the formatter replaced by a constant `null` (anchor 1 → 0, blob 8b10538 → 465302d3). `template-option-formatter.test.ts`: 2 failed, 46 passed (48); the two red are the no-table and other-field pins. - Ladder, platform rung: the `PLATFORM_PROVIDED_OBJECT_NAMES` loop given an empty list (blob f52aa4a → 052030ea). `validate-email-template-hole-grammar.option.test.ts`: 1 failed, 21 passed (22); the red is the `sys_user.status` pin. - Ladder, packages rung: the `packagesOf(stack)` loop given an empty list (blob f52aa4a → d7c112de). Same file: 1 failed, 21 passed (22); the red is the sibling-package pin. - Restored: both files green again (48/48, 22/22 at that head), tree clean. - Round 1 (head `29f86544`): the `option:` key of `FORMATTERS` renamed `__ablated_option`; formula 21 failed of 40, lint 17 failed of 20; restored green. </details> <details><summary>Derived gates (head d73ae61, after a full turbo build; 56928a5, 030a493, 7cea418 and 98f61ec re-ran the changeset gates above)</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 102 families on `d73ae610`; each run with its exit code captured before any pipe: **102 exit 0**. - `dispatch-gates --ran`: 102 derived, 102 run, 0 NOT-MEASURED, 0 UNRUN. - `check-changeset-no-major`: exit 0, no `major` bump (three `minor` changesets: spec, formula, lint). - `check-dev-prereqs --self-test`: exit 0. - Not run locally, CI-owned: the repo-wide `pnpm lint` scan, `test:repo`, and the families outside the derived total. - `origin/main` has moved 21 commits past this branch's merge base (head `05a30e6e`); `git merge-tree` against it is clean. Not merged in this round, since the landing waits for the server half. </details> ## Acceptance notes - **This PR is the spec half of ruling F (6670405874) and closes the card.** The server half (`plugin-email`: the label table per `object.field` built in the recipient's locale through the `readerOptionLabels` path, the per-recipient render, `TEMPLATE_INVALID` at send for a missing object / field / option list, and its en / zh-CN, scheduled-flow and non-option pins) is its own card, cut by the seat. - **The window is closed by the landing order, and fails loudly if it opens.** The seat lands this PR back to back with the server half. Should a build carry this half alone, an `option` hole validates and its send throws `optionTableRefusal`'s sentence: no recipient gets the stored value where a label was promised. - **Lint arm, not a new rule id.** The existing `email-template-hole-grammar` owns the hole, so the object / field / options check is an arm of it; `rule-explanations.ts` is unchanged. - **Spelling** follows the seat's decision of record: `option` with a bare `object.field`, no quotes, no `@path`. - **Where the app's objects come from.** The CLI doors walk the object-reference ladder: the stack's own objects as served, then the objects a sibling `packages[]` entry provides, then the platform-provided names, whose fields the CLI does not hold, so a field on one is judged at the publish door and the send. A platform-prefixed name none of these carries is rung ④, a warning, as the object-reference rule answers it; an unprefixed one is refused. The publish door has no ladder: it judges against the live universe alone and refuses a name it does not carry, whatever its prefix, so a template naming an object the deployment does not serve is refused at save, not stored to fail at send. A write into a package is judged against that package's closure, and an object outside it is refused with the closure verdict and the declare-the-dependency remedy. The publish door reads the live universe the gate carries. - **The in-app refusal sentence moved.** An in-app hole naming `option` used to get the generic "holes are a field path with an optional formatter" refusal (the engine did not know the name at all); it now gets the `known-formatter` sentence, which says `option` is a mail-only formatter. Both are compile refusals; no in-app hole that compiled before stops compiling. - `label()`'s contract does not move (parameter 7). - Branch: the card thread's harness-pinned `claude/project-thread-d8v0qv`, not `claude/issue-30172-*`. --- _Generated by [Claude Code](https://claude.ai/code/session_01L6bhgUtH2CXA8Qb8bDBRom)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L6bhgUtH2CXA8Qb8bDBRom <!-- signed: domain:spec#2 · os-sales · session_01L6bhgVugtCCwBs98FeR27P --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d8ed2e1 commit 3da9a4c

15 files changed

Lines changed: 1169 additions & 38 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/formula': minor
3+
---
4+
5+
feat(formula): a mail template hole may name the `option` formatter, `{{ stage | option:opportunity.stage }}`, which prints a select value as its option's label from the table the mail renderer hands in
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: added
9+
Skills-docs: follow-up #30731 — `skills/objectstack-automation/SKILL.md` (the `notify` section) gains one line: an option label in a notice is written with the mail formatter `{{ stage | option:opportunity.stage }}`, not with `label()` in `templateData`. The skills lane owns that file.
10+
11+
- **`option` is a mail template's formatter.** `judgeHoleFormatter(…, 'mail')`, `parseMailHole` and `judgeMailHoleGrammar` accept `option:object.field`, and `parseMailHole` returns the reference as `optionField: { object, field }`. `MAIL_TEMPLATE_FORMATTERS` (new) is the mail list, `TEMPLATE_FORMATTERS` then `option`, and the mail refusals list it as the known formatters. `OPTION_ARGUMENT_RULE` (new) is the one sentence the spec, the renderer and the authoring rule quote; `optionFieldReference` (new) parses the argument.
12+
- **The argument is a bare `object.field`.** No argument, an empty one, a field with no object (`option:stage`), a quoted reference (`option:'opportunity.stage'`), a marked path (`option:@stage_ref`) and a second dot are refused (`option-field-shape`), naming the hole, the argument and the rule.
13+
- **The object and field are judged when the caller hands in the app's objects.** `judgeMailHoleGrammar(face, { objects })` takes each object's name to its fields, each `true` when the field declares options, each `null` for an object that resolves with its fields not in hand (whose field is then not judged), and refuses an object it does not name (`option-object-unknown`), a field that object does not have (`option-field-unknown`) and a field with no options (`option-field-no-options`). A name it does not carry that has a platform prefix (`sys_`, `cloud_`, `ai_`) is rung ④ of the object-reference ladder, neither refused nor judged, since a package outside the app may register it; `optionObjectUnregisteredPlatform` (new) answers which references those are, for a caller that advises on them. `judgeMailHoleGrammar(face, { objects, liveUniverse: true })` says the objects are the deployment's whole served universe (the publish door, the renderer at send): there rung ④ does not apply and such a name is refused (`option-object-unknown`). `packageScope: { packageId }` says those objects are narrowed to a written package's closure (implying `liveUniverse`): a name they do not carry is refused as outside that closure (`option-object-outside-closure`), naming the remedy, a dependency declared in the package's manifest.
14+
- **The formatter prints from the table it is handed.** `formatValue('option', value, 'opportunity.stage', { optionLabels })` takes `optionLabels` (`OptionLabelTable`, new): for each `object.field`, each option's label keyed by its stored value, in the locale the face renders in. A value the table does not hold prints as stored, and a multi-select value labels each element joined with `, `. With no table for that `object.field` the call throws a `RangeError` carrying `optionTableRefusal`'s sentence (new export, the `currencyDefaultRefusal` shape, so a renderer can ask it before it formats): the stored value is never printed where a label was promised. This package reads no metadata and no translations: the mail renderer builds the table per recipient.
15+
- **In-app template text refuses it.** `TEMPLATE_FORMATTERS` is unchanged, and the in-app dialect (flow notice `title` / `message`, action and bulk-action copy) refuses `option` as a formatter it does not know (`known-formatter`), saying it formats only in a mail template.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/lint': minor
3+
---
4+
5+
feat(lint): `email-template-hole-grammar` accepts an `option` hole whose `object.field` names a field with options, and refuses one that names an object, field or option list the app does not have
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: added
9+
Skills-docs: follow-up #30731 — `skills/objectstack-automation/SKILL.md` (the `notify` section) gains one line: an option label in a notice is written with the mail formatter `{{ stage | option:opportunity.stage }}`, not with `label()` in `templateData`. The skills lane owns that file.
10+
11+
`os validate`, `os build`, `os lint` and the metadata publish door judge each `{{ value | option:object.field }}` hole of a template's three faces against the object-reference ladder: the stack's own objects as the registry serves them (`objectExtensions` fields folded in, the injected system columns beside the declared fields; at the publish door, the live object universe the gate carries), then the objects another entry of the artifact's `packages[]` provides. A platform-provided object the stack does not carry (`option:sys_user.status`) resolves, and its field is left to the publish door and the send. A name none of these carries that has a platform prefix (`sys_`, `cloud_`, `ai_`) is the ladder's rung ④: a package outside the app may register it, so at `os validate`, `os build` and `os lint` the hole is advised at `warning` under the same rule id (`EmailTemplateHoleGrammarFinding.severity` widens to `'error' | 'warning'`), its field unjudged. The publish door judges against the live object universe alone, the whole of what resolves, so there a name it does not carry is refused whatever its prefix (the reading `list-view-object-unknown` gives a list view's binding); A write into a package is judged against the live universe narrowed to that package's closure, and an object outside it is refused with the closure verdict and the declare-the-dependency remedy (the reading `list-view-object-outside-closure` gives), not as an unknown object. `validateEmailTemplateHoleGrammar(stack, { liveUniverse, packageScope })` takes the switches; `EmailTemplateHoleGrammarOptions` is exported beside `EmailTemplateHoleGrammarFinding`. A select field with inline `options` or a `picklist` name is accepted. An object that resolves to none of these, a field that object does not have and a field that declares no options are each refused at `error` under the existing rule id, naming the hole and what to fix, and so is an argument that is not a bare `object.field`. The finding's hint quotes `OPTION_ARGUMENT_RULE`. A misspelled formatter's refusal now lists `option` among the known mail formatters.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec): the email template hole grammar names the `option` formatter, which prints a select value as its option's label in the recipient's language
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: added
9+
Skills-docs: follow-up #30731 — `skills/objectstack-automation/SKILL.md` (the `notify` section) gains one line: an option label in a notice is written with the mail formatter `{{ stage | option:opportunity.stage }}`, not with `label()` in `templateData`. The skills lane owns that file.
10+
11+
The `subject`, `bodyHtml` and `bodyText` descriptions of `email_template` state the hole grammar. They now carry one more sentence, `OPTION_ARGUMENT_RULE` from `@objectstack/formula` word for word: an `option` hole, in a mail template only, prints the label of the select option whose value it holds, in the recipient's language. Its argument is the object and field the value belongs to, written bare as `object.field` (`{{ stage | option:opportunity.stage }}`); the object is one the app has, the field one of its fields that declares options; a multi-select value prints the label of each of its values, joined with `, `, and a value outside the field's options prints as stored.
12+
13+
A flow notice is filled once per run and sent to each recipient in their own locale, so a select field's value reached the template as stored (`needs_analysis`), and a template could not print its label: `{{ stage | label }}` was refused as an unknown formatter, and CEL `label()` in `templateData` has no option labels to read. Additive only: every template that was accepted before is accepted unchanged.

‎content/docs/automation/email-templates.mdx‎

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ in that shape.
9696
**The formatter is one the whitelist names.** `{{ amount | curency:EUR }}` is
9797
refused by `os validate`, `os build`, `os lint` and the metadata publish door
9898
(`email-template-hole-grammar`, a `422 INVALID_METADATA` at the publish door),
99-
naming the hole and listing the known formatters (`TEMPLATE_FORMATTERS`,
99+
naming the hole and listing the known formatters (`MAIL_TEMPLATE_FORMATTERS`,
100100
exported from `@objectstack/formula`). A template stored before this rule fails its render with
101101
`TEMPLATE_INVALID` and sends nothing, rather than print the raw value (`1234.5`)
102102
where a formatted one was meant; republish it with the name corrected.
@@ -150,6 +150,41 @@ sent: the amount is never formatted in US dollars or any other guess. Set the
150150
organization's default currency, or name the currency in the hole: a literal
151151
code (`currency:EUR`) or a marked path to it (`currency:@currency_code`).
152152

153+
**`option` prints a select value as its option's label.** A flow notice is
154+
filled once per run and then sent to each recipient in their own locale, so a
155+
select field's value reaches the template as it is stored (`needs_analysis`).
156+
`option` names the object and field the value belongs to, written bare as
157+
`object.field`, and the hole prints that option's label in the recipient's
158+
language:
159+
160+
```ts
161+
subject: 'Deal stalled at {{ stage | option:opportunity.stage }}',
162+
```
163+
164+
The renderer builds the label table for each `object.field` the template names,
165+
in the locale it renders that recipient's copy in. A multi-select value prints
166+
the label of each of its values, joined with `, `, and a value that is not one
167+
of the field's options prints as stored. A render that reaches an `option` hole
168+
with no label table for its field is refused and nothing is sent: the stored
169+
value is never printed where the label was promised. `os validate`, `os build`,
170+
`os lint` and the metadata publish door refuse an `option` hole whose object
171+
resolves to nothing (the app's own objects, those another package of the same
172+
artifact provides, and the platform's objects all resolve), whose field that
173+
object does not have, or whose field declares no options (`email-template-hole-grammar`, a `422 INVALID_METADATA` at the publish
174+
door), and an argument that is not a bare `object.field`: a quoted one
175+
(`option:'opportunity.stage'`), a field with no object (`option:stage`) or a
176+
marked path (`option:@stage_ref`). An object none of those carries whose name
177+
has a platform prefix (`sys_`, `cloud_`, `ai_`) gets a warning at `os validate`,
178+
`os build` and `os lint`, its field not judged, because a package outside the
179+
app may register it; the publish door judges against every object the
180+
deployment serves and refuses it there if none of them is that object. A
181+
template saved into a package is judged against that package's closure (its
182+
own objects, the objects of the packages its manifest declares in
183+
`dependencies`, and platform objects): an object outside it is refused, and the
184+
fix is to declare the dependency. `option` belongs to mail templates: in-app
185+
template text (flow notifications, action and bulk-action copy) refuses it as a
186+
formatter it does not know.
187+
153188
**Where a hole may sit in `bodyHtml`.** The renderer's escaping is right for
154189
element text and for a quoted attribute value that holds plain text, so those
155190
are the supported placements for `{{path}}`. A hole anywhere else — inside a

‎content/docs/references/system/email-template.mdx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,9 @@ const result = EmailTemplateDefinitionSchema.parse(data);
4949
| **label** | `string` | ✅ | Display label |
5050
| **category** | `never` | optional | [REMOVED] `email_template.category` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — nothing read it: the Studio listing never offered the filter facet it promised, and delivery never depended on it. Delete the key. A template is found by its `name`, so group templates with a dotted namespace prefix instead (`auth.password_reset`, `crm.welcome`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
5151
| **locale** | `string` | optional (default: `"en-US"`) | BCP-47 locale (e.g. en-US, zh-CN) — the bundle key. Each rung of the resolver reads one row at one tag exactly, so `en` and `en-US` are different ROWS of one bundle. A call that NAMES a locale, over the built-in template loader, folds it against the bundle's tags (exact, case-insensitive, platform locale table language, base language, then variant expansion: `en-GB` reaches `en`, `zh-TW` reaches `zh-Hant`, else `zh-CN`), then folds the stack's declared fallback locale (`i18n.fallbackLocale`, else `defaultLocale`) the same way, then tries the literal `en-US`; a replacement template loader matches the tag exactly and retries only `en-US`. `en-US` is the floor every loader honours: when no rung lands on a row the call raises TEMPLATE_NOT_FOUND, which is permanent — the delivery dead-letters with no retry. A call naming NO locale folds nothing and does not dead-letter: it starts at `en-US` and otherwise drops to the bundle's lowest locale tag and renders that silently. Tag the English row `en-US` so every loader has the floor. |
52-
| **subject** | `string` | ✅ | Subject template. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over the send's `data`, with at most one formatter per hole (a second, chained formatter is refused). A formatter is one word, optionally followed by `:` and its argument (text that is not, such as `date-long`, is refused), and it is one the template engine knows (an unknown name, such as a misspelled `curency`, is refused, naming the known formatters). A `currency` argument is either a literal ISO 4217 code in three capital letters (`currency:EUR`) or, in a mail template only, `@` followed by a path into the send's data whose value is such a code (`currency:@currency_code`). |
53-
| **bodyHtml** | `string` | ✅ | HTML body template. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over the send's `data`, with at most one formatter per hole (a second, chained formatter is refused). A formatter is one word, optionally followed by `:` and its argument (text that is not, such as `date-long`, is refused), and it is one the template engine knows (an unknown name, such as a misspelled `curency`, is refused, naming the known formatters). A `currency` argument is either a literal ISO 4217 code in three capital letters (`currency:EUR`) or, in a mail template only, `@` followed by a path into the send's data whose value is such a code (`currency:@currency_code`). |
54-
| **bodyText** | `string` | optional | Plain-text body template (auto-derived from HTML when omitted). Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over the send's `data`, with at most one formatter per hole (a second, chained formatter is refused). A formatter is one word, optionally followed by `:` and its argument (text that is not, such as `date-long`, is refused), and it is one the template engine knows (an unknown name, such as a misspelled `curency`, is refused, naming the known formatters). A `currency` argument is either a literal ISO 4217 code in three capital letters (`currency:EUR`) or, in a mail template only, `@` followed by a path into the send's data whose value is such a code (`currency:@currency_code`). |
52+
| **subject** | `string` | ✅ | Subject template. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over the send's `data`, with at most one formatter per hole (a second, chained formatter is refused). A formatter is one word, optionally followed by `:` and its argument (text that is not, such as `date-long`, is refused), and it is one the template engine knows (an unknown name, such as a misspelled `curency`, is refused, naming the known formatters). A `currency` argument is either a literal ISO 4217 code in three capital letters (`currency:EUR`) or, in a mail template only, `@` followed by a path into the send's data whose value is such a code (`currency:@currency_code`). An `option` hole, in a mail template only, prints the label of the select option whose value it holds, in the recipient's language, and its argument names the object and the field the value belongs to, written bare as `object.field` (`{{ stage \| option:opportunity.stage }}`): the object is one the app has and the field one of its fields that declares options. A multi-select value prints the label of each of its values, joined with `, `, and a value outside the field's options prints as stored. |
53+
| **bodyHtml** | `string` | ✅ | HTML body template. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over the send's `data`, with at most one formatter per hole (a second, chained formatter is refused). A formatter is one word, optionally followed by `:` and its argument (text that is not, such as `date-long`, is refused), and it is one the template engine knows (an unknown name, such as a misspelled `curency`, is refused, naming the known formatters). A `currency` argument is either a literal ISO 4217 code in three capital letters (`currency:EUR`) or, in a mail template only, `@` followed by a path into the send's data whose value is such a code (`currency:@currency_code`). An `option` hole, in a mail template only, prints the label of the select option whose value it holds, in the recipient's language, and its argument names the object and the field the value belongs to, written bare as `object.field` (`{{ stage \| option:opportunity.stage }}`): the object is one the app has and the field one of its fields that declares options. A multi-select value prints the label of each of its values, joined with `, `, and a value outside the field's options prints as stored. |
54+
| **bodyText** | `string` | optional | Plain-text body template (auto-derived from HTML when omitted). Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over the send's `data`, with at most one formatter per hole (a second, chained formatter is refused). A formatter is one word, optionally followed by `:` and its argument (text that is not, such as `date-long`, is refused), and it is one the template engine knows (an unknown name, such as a misspelled `curency`, is refused, naming the known formatters). A `currency` argument is either a literal ISO 4217 code in three capital letters (`currency:EUR`) or, in a mail template only, `@` followed by a path into the send's data whose value is such a code (`currency:@currency_code`). An `option` hole, in a mail template only, prints the label of the select option whose value it holds, in the recipient's language, and its argument names the object and the field the value belongs to, written bare as `object.field` (`{{ stage \| option:opportunity.stage }}`): the object is one the app has and the field one of its fields that declares options. A multi-select value prints the label of each of its values, joined with `, `, and a value outside the field's options prints as stored. |
5555
| **variables** | `{ name: string; type?: Enum<'string' \| 'number' \| 'boolean' \| 'date' \| 'url' \| 'user' \| 'record'>; required?: boolean; description?: string }[]` | optional (default: `[]`) | |
5656
| **fromOverride** | `{ name?: string; address: string }` | optional | |
5757
| **replyTo** | `string` | optional | |

0 commit comments

Comments
 (0)