Repository navigation
Commit 2205863
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} -->
_Requested by **Yibo Wang** · [project
thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwB2LLTurKiujTuz772H4neEX)_
Fixes #26660
Item (c) of the card landed in #26799. This PR lands item (b): a mail
template formats an amount in the record's own currency, with the code
read from the data.
Clause-②: yes
Metadata-protocol: added
Skills-docs: follow-up #26940
ADR-0087: not-required — nothing stored changes meaning:
`currency:@path` was refused at save and at render until now, so no
stored template or built artifact carries it, and every form accepted
before renders exactly as before.
## 维护者速读
- **改了什么:** 邮件模板新增 `{{ 金额 | currency:@currency_code }}`
写法:币种代码从发送数据里读,一个模板就能按每条记录自己的币种显示金额;站内模板(流程通知、按钮文案)对这个写法按名拒收。
- **为什么改:** hotclm 这类按合同发提醒的应用,金额币种各不相同;此前只能写死一个币种或只显示裸数字。
- **风险与回滚:** 只放宽了一个以前被拒的写法,旧写法与旧拒收全部不变;数据里没有合法代码(缺失、null、小写)时渲染报
`TEMPLATE_INVALID`,绝不用美元顶替。回滚即 revert 本 PR。
- **先看:** `packages/formula/src/template-engine.ts`(`@`
标记与方言判定)、`packages/plugins/plugin-email/src/template-engine.ts`(取值与拒收)、`packages/formula/src/mail-hole-grammar.ts`(声明变量检查)。
- **两分钟验证:** `pnpm --filter @objectstack/plugin-email exec vitest run
src/template-currency-code-path.render.test.ts`,预期 12 个测试全过:EUR/CNY
两条记录在 en-US、zh-CN 两个语言行各自按本币显示,三种坏代码均报 `TEMPLATE_INVALID`。
- **需要你做的:** 本 PR 改了 `skills/objectstack-formula/SKILL.md`(受管面 Tier
H),需要维护者批准后由席位落地。
## What changes
**The rule, in one sentence** (`CURRENCY_ARGUMENT_RULE` in
`@objectstack/formula`; the spec's `email_template` face descriptions,
the lint rule's hint and every renderer refusal quote it): 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`).
The `@` mark keeps the path form apart from the literal for every input:
a literal code never starts with `@`, and no hole path does. So every
shape refused today stays refused, including the #26799 pins
`currency:currency_code` and `currency:record.currency_code`.
- **`@objectstack/formula`** (minor)
- `currencyCodePath(formatterPart)` reads the marked form: `@` then a
dotted path of word characters with no empty segment, unquoted, nothing
between the mark and the path.
- `judgeHoleFormatter` takes an optional dialect (`'in-app'` by default,
`'mail'`). The mail dialect admits the marked form. The in-app dialect
refuses it by name with the new rule `currency-code-path-mail-only` at
compile and evaluate, so flow text slots, action and bulk-action copy,
`titleFormat` and `promptTemplate` all refuse it (they refused it before
as `currency-code-literal`).
- `parseMailHole` carries the path as `codePath`.
`judgeMailHoleGrammar(face, { variables })` refuses a marked path no
declared variable names (itself, or a root it lies under), when the
template declares any: rule `currency-code-path-undeclared`.
- `currencyCodeValueRefusal(hole, path, value)` names a resolved value
that is not an ISO 4217 code in three capitals exactly as stored.
- `formatValue('currency', …)` is unchanged: it never reads a path,
throws for a non-code argument, and formats in USD with no argument.
- **`@objectstack/plugin-email`** (minor): the renderer resolves the
path against the send's data and formats in that code, in all three
faces, double- or triple-braced. A missing, `null`, lowercase or other
value throws `TEMPLATE_INVALID: email template NAME (locale=L): …`,
naming the hole, the path and the value; nothing is sent.
`email-service.ts` changes a comment only: its existing wrap already
prefixes the template and locale.
- **`@objectstack/lint`** (minor): `email-template-hole-grammar` passes
the template's declared `variables` to the judge, and its hint quotes
the rule. `os validate`, `os build`, `os lint` and the `email_template`
publish door accept a well-formed marked path and refuse an undeclared
one (`422 INVALID_METADATA` at the door).
- **`@objectstack/spec`** (minor, descriptions only):
`EmailTemplateDefinitionSchema` `subject`, `bodyHtml` and `bodyText`
state the mail hole grammar (at most one formatter per hole, a second
chained formatter refused) and the rule; `variables[].name` states the
declared-path check. The descriptions promise no refusal of an unknown
formatter name, because none exists today (see Acceptance notes).
Reference docs regenerated
(`content/docs/references/system/email-template.mdx`).
- **Docs and skill:** `content/docs/automation/email-templates.mdx`
teaches the form, the render refusal, the save-time checks and the
in-app refusal. `skills/objectstack-formula/SKILL.md` gains two lines
(below).
**Which dialects:** the mail dialect accepts `currency:@path`. The
in-app dialect refuses it by name, because the doors that judge in-app
text (`closedScopeTemplateRefusal`, the lint flow-path rules) check a
hole's path against the roots a slot binds and never read an argument;
accepting it there would let an argument's root reach the page unjudged.
The three in-app spec describes (`flow-text-slot-template.ts`,
`action-copy-template.ts`, `bulk-action.zod.ts`) stay true as written
(they spell `currency:EUR` and `formatter[:arg]`), so they are
unchanged.
## Why
Measured at `6090bb73` (post-#26799) through
`EmailService.renderTemplate` and the lint rule: `{{ planned_amount |
currency:currency_code }}` is refused at save (1 finding,
`email-template-hole-grammar`) and at render (`TEMPLATE_INVALID … gives
the currency formatter the argument 'currency_code'`) for both
`currency_code: 'eur'` and `'EUR'`. So no mail template could show an
amount in the record's own currency. Data roots: a mail template's holes
read the send's `data` with bare roots (`planned_amount`,
`contract.name`); the in-app engine's roots are `record`, `previous`,
`input`, `user` / `current_user` / `ctx`, `os` plus each slot's `extra`
(flow variables; action `record` / `params` / `result`; bulk `count` and
params). A root-spelled argument (`currency:record.currency_code`) has
no meaning in mail and is refused by an existing pin, and a bare path is
ambiguous against the literal; a marked form fits both dialects.
## Tests
- `packages/formula/src/template-currency-code-path.test.ts` (new, 38):
path reading (3 accepted, 10 rejected shapes); mail dialect admits and
parses; malformed marks refused as `currency-code-literal` quoting the
rule; declared-variable check (refused, exact, root, none declared,
literal not judged); value refusal for missing / null / `eur` / `' EUR'`
/ number / object; in-app compile, `validateExpression`
(`invalid-template`) and evaluate refuse by name; the formatter never
reads a path; the spec's three face descriptions and the variable name
description quote the rule (parity pin against the built spec).
-
`packages/plugins/plugin-email/src/template-currency-code-path.render.test.ts`
(new, 12): one bundle with `en-US` and `zh-CN` rows, records in `EUR`
and `CNY`: `€1,234.50` / `CN¥1,234.50` (en-US) and `€1,234.50` /
`¥1,234.50` (zh-CN) in every face; `sendTemplate` delivers two records
in their own currencies; an unresolvable path, a lowercase code and a
null code are each refused in both locales with the `TEMPLATE_INVALID:
email template contract.overdue (locale=L)` prefix, the hole and the
value, no `$` in the message, nothing sent; the literal form and the
bare-path refusal unchanged.
-
`packages/lint/src/validate-email-template-hole-grammar.currency-path.test.ts`
(new, 13): accepted with no variables and with declared path or root;
undeclared refused per face; malformed marks refused; hint quotes the
rule; each `os` command; the runtime publish gate accepts and refuses.
-
`packages/metadata-protocol/src/runtime-authoring-gate.email-template-writes.test.ts`
(+1): through `evaluateRuntimeAuthoringGate`, an undeclared marked path
is refused with `code` `INVALID_METADATA`, `status` 422 and the issue;
the declared path and a no-variables template publish.
- The #26799 pins (`template-hole-grammar.test.ts`,
`template-hole-grammar.render.test.ts`,
`validate-email-template-hole-grammar.test.ts`, the door test's existing
cases) are unchanged and green.
## Acceptance notes
- **`currency` with no argument still formats in USD** (not changed
here, per the dispatch). Census at `6090bb73` of a no-argument `|
currency` hole, generated files, CHANGELOGs and release notes excluded:
examples 0, seeds 0, skills 0, apps 0; docs 2
(`content/docs/automation/flows.mdx:472`,
`content/docs/data-modeling/formulas.mdx:79`, both in-app); spec
describes and comments 4 (`flow-text-slot-template.ts:23`,
`io-node-config.zod.ts:161`, `:632`, `:686`); service-automation 2
(`builtin/notify-node.ts:810`, `builtin/template.ts:104`); refusal and
migration prose that spells it as an example 2
(`formula/src/template-engine.ts`, the invalid-hole message;
`spec/src/migrations/entries/semantic/18.flow-text-slot-single-brace-refused.ts:24`);
tests pinning the USD output 4
(`formula/src/template-formatters.test.ts:19`,
`formula/src/template-hole-grammar.test.ts:114`,
`plugin-email/src/template-engine.test.ts:79`,
`plugin-email/src/template-hole-grammar.render.test.ts:110`), plus
fixtures that only carry the hole
(`lint/src/validate-email-template-hole-grammar.test.ts:28`,
`lint/src/validate-expressions.text-slot.test.ts:122`,
`ui/packages/app-shell/.../flowTextSlots.doubleBrace-11824.test.tsx:122`).
No shipped template relies on it. Seat ruling: a separate narrowing card
refuses a no-argument `currency` at authoring, filed as #26939 with this
census.
- **Eval owed, filed as #26940** (not in this PR):
`skills/objectstack-formula/evals/cel-predicates-formulas.json` should
ask for an `email_template` that shows an overdue amount in each
contract's own currency, and expect `{{ amount | currency:@currency_code
}}` (not a bare path, not a hard-coded code), the code stored in
capitals, and the path declared when the template declares `variables`.
- **An unknown mail formatter is refused nowhere today:** `{{ x | nosuch
}}` renders raw (`plugin-email/src/template-engine.ts`). Not fixed here;
filed as #26948. The spec descriptions were reworded in this PR so they
do not promise that refusal.
- **In-app reading of a code from data** is not built: it would need the
closed-scope root judges to read an argument's path. Noted, not filed.
- **Skills, governed:** `skills/objectstack-formula/SKILL.md` makes this
PR Tier H (maintainer approval). File 471 → 473 lines, 5627 → 5668
tokens (ceiling 5997, headroom 329 after); the shipped SKILL.md set 4606
→ 4608 lines; bundle total 213518 → 213559 tokens. The two lines are the
only place a skill teaches a `currency` argument.
## Verification
<details><summary>Suites and typecheck at 511e09b (the head: d1fffa1
plus a merge of origin/main 5ce3404)</summary>
Main moved `packages/spec`, `packages/lint` and
`packages/metadata-protocol` (no file this PR touches), so after the
merge (`scripts/pm/os-regen-merge.sh`, no conflict) the tree was rebuilt
(`pnpm build`), `check:generated` reported every spec artifact current,
and the six suites ran again. Under `os-verify-lock`, one `&&` chain,
`VERDICT command-exit 0`:
- `@objectstack/spec`: Test Files 738 passed (738), Tests 22101 passed,
1 todo
- `@objectstack/formula`: 57 passed (57), 1522 passed
- `@objectstack/plugin-email`: 41 passed (41), 724 passed
- `@objectstack/lint`: 170 passed (170), 7483 passed
- `@objectstack/metadata-protocol`: 273 passed, 4 skipped (277); 21694
passed, 26 skipped
- `@objectstack/service-messaging`: 61 passed (61), 734 passed
- `typecheck` for all six: exit 0.
At d1fffa1 (before the merge) the same six were green too (spec 736
files / 22050 tests, lint 7479, metadata-protocol 271 files / 21669),
and `pnpm exec eslint --no-inline-config` on the 11 changed `.ts` files
exited 0.
</details>
<details><summary>Ablations (each restored to the HEAD blob, git diff
HEAD empty)</summary>
Run with `scripts/ablation-replace.mjs`; every mutation landed (anchor 1
→ 0, blob changed) and was restored by blob hash.
- Renderer value guard dropped (`plugin-email/src/template-engine.ts`,
the suite reads source): 4 failed | 8 passed, exactly the refusal pins.
The unresolvable-path case then formatted in USD, the defect the guard
exists to stop.
- In-app dialect check disabled (`formula/src/template-engine.ts`): 2
failed | 36 passed, the two in-app refusal pins.
- Declared-variable check disabled (`formula/src/mail-hole-grammar.ts`):
1 failed | 37 passed, the undeclared pin. Formula's own suite reads
source; the lint and door legs read formula's `dist` and were not
ablated.
</details>
<details><summary>Reproduction on main</summary>
At `6090bb73`: render with `currency_code: 'eur'` and `'EUR'` →
`TEMPLATE_INVALID: email template contract.overdue (locale=en-US): the
hole {{ planned_amount | currency:currency_code }} gives the currency
formatter the argument …`; `validateEmailTemplateHoleGrammar` → 1
finding, `email-template-hole-grammar`.
</details>
<details><summary>Gate families</summary>
- Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` (no paths) at 511e09b: 114 commands, run
serially, exit codes captured before any pipe: **114 exit 0**. `--ran`
reconciliation: "114 derived famil(ies) accounted for — 114 run, 0
NOT-MEASURED".
- At d1fffa1 the same 114 ran: 112 exit 0, and `pnpm --filter
@objectstack/spec run check:skill-examples` and `pnpm
check:dual-build-cjs-loads` exited 3 (unbuilt packages); after `pnpm
build` both exited 0 (267 prose examples type-check).
- Extras at d1fffa1: `pnpm check:dev-prereqs` exit 0, `pnpm
check:skill-frame-freshness` exit 0, `node
scripts/check-platform-checklist.mjs --changed-from origin/main` exit 0,
`pnpm check:error-code-casing` exit 0. `node
scripts/check-skills-token-ratchet.mjs` exit 0 (the formula skill at
5668 of 5997).
- Not run locally: the full dogfood battery (no `!` in the title, no
`(narrowing)` arm); CI's required contexts run the rest.
</details>
<details><summary>Review round at 49258de (contract review 6455368454:
S1, S2)</summary>
One commit on top of 511e09b, text only: the `email_template` face
descriptions now say "at most one formatter per hole (a second, chained
formatter is refused)" instead of naming a formatter whitelist no door
enforces (S2; the unknown-formatter gap is #26948), and the
`email-template-hole-grammar` comment in
`packages/lint/src/authoring-rules.ts` names the marked `@path` form and
the undeclared-path check instead of a literal-only rule (S1). Reference
docs regenerated (`check:generated --fix`, exit 0).
- `pnpm build` under `os-verify-lock`: `VERDICT command-exit 0`, 116 of
116 tasks.
- The six suites and their typechecks under `os-verify-lock`, one loop
that exits on the first failure (`|| exit 1`), exit 0: spec 738 files /
22101 passed, 1 todo; formula 57 / 1522; plugin-email 41 / 724; lint 170
/ 7483; metadata-protocol 273 passed, 4 skipped / 21694 passed, 26
skipped; service-messaging 61 / 734.
- `dispatch-gates --commands` re-derived at 49258de: 114 commands,
**114 exit 0**; `--ran`: "114 derived famil(ies) accounted for — 114
run, 0 NOT-MEASURED". `node scripts/check-skills-token-ratchet.mjs` exit
0.
</details>
## Human Minimum
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic.
- [x] 2. Every name I added uses the casing for its kind.
- [x] 3. No formatter ran; `pnpm lint` passes.
- [x] 4. One issue, one branch off an up-to-date `main`, one pull
request.
- [x] 5. A change that publishes carries a changeset, and both
declaration lines are filled in here and in the changeset.
- [x] 6. `packages/spec` touched: its generated artifacts are
regenerated and committed.
- [x] 7. No hand edits to `content/docs/references/`,
`packages/*/CHANGELOG.md` or `content/docs/releases/`.
- [x] 8. Title, body and comments are in English.
- [x] The *Before Submitting* list in CONTRIBUTING.md is done.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01WGufziJbRgRHSN9KrbF46h
---
_Generated by [Claude
Code](https://claude.ai/code/session_01WGufziJbRgRHSN9KrbF46h)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 12b5aec commit 2205863
19 files changed
Lines changed: 785 additions & 53 deletions
File tree
- .changeset
- content/docs
- automation
- references/system
- packages
- formula/src
- lint/src
- metadata-protocol/src
- plugins/plugin-email/src
- spec/src/system
- skills/objectstack-formula
| 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
88 | 88 | | |
89 | 89 | | |
90 | 90 | | |
91 | | - | |
92 | | - | |
93 | | - | |
94 | | - | |
95 | | - | |
96 | | - | |
97 | | - | |
98 | | - | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
99 | 122 | | |
100 | 123 | | |
101 | 124 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
49 | 49 | | |
50 | 50 | | |
51 | 51 | | |
52 | | - | |
53 | | - | |
54 | | - | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
55 | 55 | | |
56 | 56 | | |
57 | 57 | | |
| |||
71 | 71 | | |
72 | 72 | | |
73 | 73 | | |
74 | | - | |
| 74 | + | |
75 | 75 | | |
76 | 76 | | |
77 | 77 | | |
| |||
93 | 93 | | |
94 | 94 | | |
95 | 95 | | |
96 | | - | |
| 96 | + | |
97 | 97 | | |
98 | 98 | | |
99 | 99 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
63 | 63 | | |
64 | 64 | | |
65 | 65 | | |
66 | | - | |
67 | | - | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
68 | 76 | | |
69 | 77 | | |
70 | 78 | | |
71 | | - | |
| 79 | + | |
72 | 80 | | |
73 | 81 | | |
74 | 82 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
24 | 24 | | |
25 | 25 | | |
26 | 26 | | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
27 | 37 | | |
28 | 38 | | |
29 | | - | |
| 39 | + | |
30 | 40 | | |
31 | 41 | | |
32 | 42 | | |
| |||
52 | 62 | | |
53 | 63 | | |
54 | 64 | | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
55 | 71 | | |
56 | 72 | | |
57 | 73 | | |
| |||
76 | 92 | | |
77 | 93 | | |
78 | 94 | | |
79 | | - | |
| 95 | + | |
80 | 96 | | |
81 | 97 | | |
82 | 98 | | |
83 | 99 | | |
84 | | - | |
| 100 | + | |
| 101 | + | |
85 | 102 | | |
86 | 103 | | |
87 | 104 | | |
88 | 105 | | |
| 106 | + | |
89 | 107 | | |
90 | 108 | | |
91 | 109 | | |
92 | 110 | | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
93 | 131 | | |
94 | 132 | | |
95 | 133 | | |
| |||
100 | 138 | | |
101 | 139 | | |
102 | 140 | | |
103 | | - | |
| 141 | + | |
104 | 142 | | |
| 143 | + | |
105 | 144 | | |
106 | 145 | | |
107 | 146 | | |
108 | | - | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
109 | 163 | | |
110 | 164 | | |
111 | 165 | | |
0 commit comments