Repository navigation
feat(spec, formula, plugin-email, lint): a mail template formats an amount in the record's own currency with currency:@path - #26937
Conversation
…in the record's own currency with currency:@path 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 two forms apart for every input, so every refusal of a bare path stays as it was. - formula: the mail grammar admits the marked form (`codePath` on the parsed hole) and, given the template's declared variables, refuses a path none of them declares; the in-app dialect refuses the form by name (`currency-code-path-mail-only`); `CURRENCY_ARGUMENT_RULE` is the one sentence every reader quotes. - plugin-email: the renderer resolves the path and refuses a value that is not a code (missing, null, lowercase) with TEMPLATE_INVALID, never a default currency. - lint: email-template-hole-grammar passes the template's declared variables. - spec: the email_template face descriptions state the grammar. Claude-Session: https://claude.ai/code/session_01WGufziJbRgRHSN9KrbF46h Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 4 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 149 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 7b172babc34cab4dd805af9ce09ebc9969f27165 && git checkout 7b172babc34cab4dd805af9ce09ebc9969f27165
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 912076f8854b96093e11480f369456100f2de3d0 49258decf331b8a12c16270a505d99bf68ab5b27 && git checkout -B drift-repro 912076f8854b96093e11480f369456100f2de3d0 && git merge --no-ff 49258decf331b8a12c16270a505d99bf68ab5b27
node scripts/docs-audit/affected-docs.mjs --json 912076f8854b96093e11480f369456100f2de3d0
|
Contract reviewServed-tier: Face: the file list carries a published schema ( ① Derived judgmentsAccept-set changes the diff implies, each named RIGHT or WRONG against the tree at the head.
Should-fix (level unaffected, no sentence this PR publishes is false):
② Semver levelFour changesets, read against what the diff publishes:
③ Boundary flagsEvery dev flag, deviation and
Implemented-by: VERDICT: PASS |
…unknown-formatter refusal The email_template face descriptions said a hole takes "one formatter from the template engine whitelist", but no door refuses an unknown mail formatter today: the renderer prints such a hole raw. They now say "at most one formatter per hole (a second, chained formatter is refused)". The authoring-rules comment for email-template-hole-grammar names the marked `currency:@path` form and the undeclared-path check instead of a literal-only rule. Reference docs regenerated. Claude-Session: https://claude.ai/code/session_01WGufziJbRgRHSN9KrbF46h Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Round 2, adopting round 1. The head ① Derived judgmentsRound 1 adopted. What this commit changes, each named RIGHT or WRONG against the tree at
② Semver levelRound 1 adopted: four ③ Boundary flags
Implemented-by: VERDICT: PASS |
维护者速读 (席位终稿)
席位意见:接受,等维护者审批。契约复核第 1 轮 PASS(6455368454),附带的两处文字问题(S1、S2)已在第 2 轮修正,第 2 轮 PASS(6456767827)。三处缺口已立卡:无参 席位核对 · ACCEPT (Tier H, awaiting a human merge) ·
|
|
Director note · Recorded by the director seat ( |
Claude-Session: https://claude.ai/code/session_01WGufzgBzVZJqh8CQJLrZu8 Co-authored-by: Claude <noreply@anthropic.com>
…@path form (#26937) beside the tenant default Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzizxYz7JyC5JLW3Gmv
Requested by Yibo Wang · project thread
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:@pathwas 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 }}写法:币种代码从发送数据里读,一个模板就能按每条记录自己的币种显示金额;站内模板(流程通知、按钮文案)对这个写法按名拒收。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。skills/objectstack-formula/SKILL.md(受管面 Tier H),需要维护者批准后由席位落地。What changes
The rule, in one sentence (
CURRENCY_ARGUMENT_RULEin@objectstack/formula; the spec'semail_templateface descriptions, the lint rule's hint and every renderer refusal quote it): Acurrencyargument 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 pinscurrency:currency_codeandcurrency: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.judgeHoleFormattertakes 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 rulecurrency-code-path-mail-onlyat compile and evaluate, so flow text slots, action and bulk-action copy,titleFormatandpromptTemplateall refuse it (they refused it before ascurrency-code-literal).parseMailHolecarries the path ascodePath.judgeMailHoleGrammar(face, { variables })refuses a marked path no declared variable names (itself, or a root it lies under), when the template declares any: rulecurrency-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 throwsTEMPLATE_INVALID: email template NAME (locale=L): …, naming the hole, the path and the value; nothing is sent.email-service.tschanges a comment only: its existing wrap already prefixes the template and locale.@objectstack/lint(minor):email-template-hole-grammarpasses the template's declaredvariablesto the judge, and its hint quotes the rule.os validate,os build,os lintand theemail_templatepublish door accept a well-formed marked path and refuse an undeclared one (422 INVALID_METADATAat the door).@objectstack/spec(minor, descriptions only):EmailTemplateDefinitionSchemasubject,bodyHtmlandbodyTextstate the mail hole grammar (at most one formatter per hole, a second chained formatter refused) and the rule;variables[].namestates 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).content/docs/automation/email-templates.mdxteaches the form, the render refusal, the save-time checks and the in-app refusal.skills/objectstack-formula/SKILL.mdgains 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 spellcurrency:EURandformatter[:arg]), so they are unchanged.Why
Measured at
6090bb73(post-#26799) throughEmailService.renderTemplateand 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 bothcurrency_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'sdatawith bare roots (planned_amount,contract.name); the in-app engine's roots arerecord,previous,input,user/current_user/ctx,osplus each slot'sextra(flow variables; actionrecord/params/result; bulkcountand 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 ascurrency-code-literalquoting 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 withen-USandzh-CNrows, records inEURandCNY:€1,234.50/CN¥1,234.50(en-US) and€1,234.50/¥1,234.50(zh-CN) in every face;sendTemplatedelivers two records in their own currencies; an unresolvable path, a lowercase code and a null code are each refused in both locales with theTEMPLATE_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; eachoscommand; the runtime publish gate accepts and refuses.packages/metadata-protocol/src/runtime-authoring-gate.email-template-writes.test.ts(+1): throughevaluateRuntimeAuthoringGate, an undeclared marked path is refused withcodeINVALID_METADATA,status422 and the issue; the declared path and a no-variables template publish.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
currencywith no argument still formats in USD (not changed here, per the dispatch). Census at6090bb73of a no-argument| currencyhole, 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-argumentcurrencyat authoring, filed as spec(formula): acurrencyhole with no argument still formats in US dollars — refuse it at authoring in every template dialect (follow-up of #26660) #26939 with this census.currency:@pathfor a mail amount in the record's own currency (follow-up owed by #26660) #26940 (not in this PR):skills/objectstack-formula/evals/cel-predicates-formulas.jsonshould ask for anemail_templatethat 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 declaresvariables.{{ x | nosuch }}renders raw (plugin-email/src/template-engine.ts). Not fixed here; filed as mail templates: an unknown hole formatter (e.g. a misspelledcurency) is refused nowhere and renders the raw value; the in-app dialect refuses it (found reviewing #26660) #26948. The spec descriptions were reworded in this PR so they do not promise that refusal.skills/objectstack-formula/SKILL.mdmakes 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 acurrencyargument.Verification
Suites and typecheck at 511e09b (the head: d1fffa1 plus a merge of origin/main 5ce3404)
Main moved
packages/spec,packages/lintandpackages/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:generatedreported every spec artifact current, and the six suites ran again. Underos-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 passedtypecheckfor 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-configon the 11 changed.tsfiles exited 0.Ablations (each restored to the HEAD blob, git diff HEAD empty)
Run with
scripts/ablation-replace.mjs; every mutation landed (anchor 1 → 0, blob changed) and was restored by blob hash.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.formula/src/template-engine.ts): 2 failed | 36 passed, the two in-app refusal pins.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'sdistand were not ablated.Reproduction on main
At
6090bb73: render withcurrency_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.Gate families
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.--ranreconciliation: "114 derived famil(ies) accounted for — 114 run, 0 NOT-MEASURED".pnpm --filter @objectstack/spec run check:skill-examplesandpnpm check:dual-build-cjs-loadsexited 3 (unbuilt packages); afterpnpm buildboth exited 0 (267 prose examples type-check).pnpm check:dev-prereqsexit 0,pnpm check:skill-frame-freshnessexit 0,node scripts/check-platform-checklist.mjs --changed-from origin/mainexit 0,pnpm check:error-code-casingexit 0.node scripts/check-skills-token-ratchet.mjsexit 0 (the formula skill at 5668 of 5997).!in the title, no(narrowing)arm); CI's required contexts run the rest.Review round at 49258de (contract review 6455368454: S1, S2)
One commit on top of 511e09b, text only: the
email_templateface 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 theemail-template-hole-grammarcomment inpackages/lint/src/authoring-rules.tsnames the marked@pathform and the undeclared-path check instead of a literal-only rule (S1). Reference docs regenerated (check:generated --fix, exit 0).pnpm buildunderos-verify-lock:VERDICT command-exit 0, 116 of 116 tasks.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 --commandsre-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.mjsexit 0.Human Minimum
packages/specholds no runtime logic.pnpm lintpasses.main, one pull request.packages/spectouched: its generated artifacts are regenerated and committed.content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/.🤖 Generated with Claude Code
https://claude.ai/code/session_01WGufziJbRgRHSN9KrbF46h
Generated by Claude Code