Repository navigation
feat(formula, plugin-email, messaging, automation, ui)!: a template currency hole with no argument formats in the organization's default currency, and with none the render refuses it - #27249
Conversation
…le with no argument formats in the tenant default currency
A {{ amount | currency }} hole with no argument now formats in the
organization's localization.currency, handed to every renderer on a
reserved currency channel. With no tenant default the render refuses the
hole by name (TEMPLATE_INVALID in mail, a guard refusal in a flow, the
sentence shown as written in action copy). Nothing formats in USD.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzizxYz7JyC5JLW3Gmv
…hangeset and semantic entry for the tenant default currency Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzizxYz7JyC5JLW3Gmv
…e and delete through the dispatch predicates Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzizxYz7JyC5JLW3Gmv
…@path form (#26937) beside the tenant default Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzizxYz7JyC5JLW3Gmv
…o-argument-USD sentences of the currency:@path notes Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzizxYz7JyC5JLW3Gmv
📓 Docs Drift CheckThis PR changes 6 package(s): 18 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 11 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 172 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 51ad112a64e9110810719abb5151436d641c9f80 && git checkout 51ad112a64e9110810719abb5151436d641c9f80
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2302b92b1b4abc09bbf95995909d8656c8edf4cf 9727b5111f709fcd92d512e40cc3cbf145662564 && git checkout -B drift-repro 2302b92b1b4abc09bbf95995909d8656c8edf4cf && git merge --no-ff 9727b5111f709fcd92d512e40cc3cbf145662564
node scripts/docs-audit/affected-docs.mjs --json 2302b92b1b4abc09bbf95995909d8656c8edf4cf
|
…ency hole that meets no tenant default Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzizxYz7JyC5JLW3Gmv
Contract reviewServed-tier: ① Derived judgmentsEach accept-set and public-surface change the diff implies, read against the cited code on
② Semver level
③ Boundary flags
④ Design judgmentThe narrowing, read against the published spec on Surfaces where the meaning moves from USD to the tenant default with no word to the author. Every one — Can "refused at send" strand stored templates or flows with no prescription? For the declared paths, no: the mail row dead-letters with a message naming the hole,
Two further should-fixes from the cited code:
Nothing found that formats in a guessed currency, nothing that refuses a hole which names its code, and the Implemented-by: VERDICT: PASS |
…-formatter refusal (#27217) beside the tenant default Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzizxYz7JyC5JLW3Gmv
… shared refusal no longer blames the translation, action copy logs its refusal Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzizxYz7JyC5JLW3Gmv
…rough the wired email service Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzizxYz7JyC5JLW3Gmv
…he date ADR citation the currency-mode closure pin keeps apart from currency text Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzizxYz7JyC5JLW3Gmv
席位核对 · ACCEPT ·
|
Requested by Yibo Wang · project thread
Fixes #26939
Ruling-ref: 6464202770
Clause-②: no (narrowing)
Metadata-protocol: narrowed
Skills-docs: updated here — content/docs/automation/email-templates.mdx, content/docs/automation/flows.mdx and content/docs/data-modeling/formulas.mdx state the tenant default and the refusal; no skill under skills/** taught the dollar default (the formula skill names the formatter grammar only), so no skills/** edit
维护者速读
{{ amount | currency }}不再按美元格式化,改为按组织默认币种(localization.currency)格式化;邮件(含认证邮件)、流程文本槽、动作/批量动作文案三种模板方言全部生效。没有默认币种时,邮件发送/渲染以 TEMPLATE_INVALID 拒绝(不发送),流程节点失败,动作文案按原文显示。fixed's CNY, the example's USD) #27119 Q2 = X)。原先默认美元是没人选过的默认值,非美元组织的金额被印成$。template-currency-no-argument-tenant-default)。没有设置默认币种的组织,带无参 currency 的邮件会进死信(permanent)。编写期不变,保存/发布照常接受。回滚即 revert。packages/formula/src/template-engine.ts的currencyDefaultRefusal与 currency 格式化器;packages/plugins/plugin-email/src/template-engine.ts的 render;packages/services/service-automation/src/run-resume.ts的币种种入;packages/services/service-messaging/src/messaging-service-plugin.ts的getTenantCurrency。pnpm --filter @objectstack/plugin-email exec vitest run src/template-currency-tenant-default.render.test.ts,预期全部通过(CNY 渲染为CN¥1,234.50,无默认币种时 TEMPLATE_INVALID 且不发送)。resolveLocalizationContextcannot tell "no tenant default" from "the settings read failed", so a transientsys_settingfault dead-letters a mail with a{{ x | currency }}hole as a permanentTEMPLATE_INVALID#27257。What changes
One rule, every dialect. A
currencyhole with no argument formats in the organization's default currency, thelocalization.currencysetting that a currency field and a dashboard measure already display in. With no default, the render refuses the hole by name and names the remedy. Nothing formats in USD by default.One sentence.
currencyDefaultRefusal(hole, currency, dialect)(@objectstack/formula, exported) returnsnullfor a three-capital code and otherwise the refusal. The mail dialect's remedy also namescurrency:@currency_code(#26937, merged in here); the in-app dialect's names the literal form only, because it refuses@path.How each host hands the default over. Every server-side read goes through
resolveLocalizationContext(@objectstack/core), the read a request resolvesExecutionContext.currencywith; there is no second resolution.@objectstack/formula:EvalContext.currency, a reserved channel likelocale, never bound as a root.evaluaterefuses the sentence (kind: 'runtime') when a no-argument hole meets no default;formatValue(…, { currency })throws aRangeErrorwith the same sentence. Compile andvalidateExpressionare unchanged.@objectstack/plugin-email:RenderOptions.currency;render()judges a no-argument hole before reading the amount and throwsTEMPLATE_INVALID.EmailServicepassesinput.currencythrough.@objectstack/spec(contracts):SendTemplateInput.currency,RenderTemplateInput.currency,AutomationContext.currency, with docs; theTEMPLATE_INVALIDdocs onsendTemplate/renderTemplatename the case.@objectstack/service-messaging: the email and inbox channels takegetTenantCurrency(organizationId)and passcurrencyon the template arm only when the delivery's organization has one. The plugin wires it toresolveLocalizationContext. A refusal is graded permanent, like everyTEMPLATE_INVALID.@objectstack/service-automation:AutomationEngine.setTenantCurrencyResolver, wired by the plugin.resolveRunContextseedsAutomationContext.currencyonce at run setup, when the context carries none; a failed or empty read leaves it unset. A resumed segment whose persisted context carries none (a run parked before this change) is seeded the same way inresolveDecisionSegmentContext, on a copy, so the persistedrun.contextstays as it parked.renderFlowTextSlotrenders the authored text before it blames a translation: when both throw (a no-argument hole with no default refuses both alike), the authored refusal is the answer and no translation warning is logged. A subflow and a map child inherit it through the context spread, and a suspended run persists it with the context.renderTextSlottakes the currency, and every text slot passescontext.currency: notifytitle/message/actionUrl, httpurland headers, screen texts, end message.@objectstack/plugin-auth: the six auth mail call sites (verification, password reset, account and organization invitations, magic link, notices) reachsendTemplatethroughwithMailTenantCurrency(mail-tenant-currency.ts), which the plugin wraps once around the email service it wires into the AuthManager. A call that names nocurrencygets the default of the organization it names (organizationId) or, for identity mail, the deployment-wide setting, read throughresolveLocalizationContext; a call that names one keeps it, and a failed or empty read adds nothing.@object-ui/core/@object-ui/react/@object-ui/plugin-grid:ActionRunner.setTenantCurrency, installed byActionProvideranduseActionRunnerfromuseLocalization().currency(latest-ref, like the display locale);fillTemplateHolesandbulkConfirmTextpass it to the engine.BulkActionDialogreads it fromuseLocalization. A sentence the engine refuses is shown as written, andfillTemplateHolesnowconsole.warns the refusal once per distinct sentence, naming the hole and the remedy.18.template-currency-no-argument-tenant-default(ADR-0087 D3: intent, not text, so no conversion).Census, left as they are. These show the spelling
{{ record.amount | currency }}and claim nothing about USD, so they stay: the spec describes inflow-text-slot-template.ts,io-node-config.zod.ts(notify, http, screen), the notify and screen node config descriptions, the semantic entry18.flow-text-slot-single-brace-refused, and the formula invalid-hole message. The lint fixtures and the uiflowTextSlotsfixture stay valid because authoring is unchanged. No seeded template in the repo uses a no-argumentcurrencyhole.Tests
packages/formula/src/template-currency-tenant-default.test.ts: CNY inen-US(CN¥1,234.50) andzh-CN(¥1,234.50), EUR, JPY; a literal code ignores the default; a root namedcurrencyis a value, never the default; null renders empty; refusal when the default is unset, empty,usdordollars, whatever the value, and for the whole sentence when one hole of several has no argument; compile andvalidateExpressionstay ok;formatValue;currencyDefaultRefusalper dialect.packages/formula/src/template-formatters.test.ts,template-hole-grammar.test.ts: re-pinned from USD to a supplied default.packages/plugins/plugin-email/src/template-currency-tenant-default.render.test.ts:renderTemplate/sendTemplateover all three faces, double- and triple-braced; with no default orusd,^TEMPLATE_INVALID: email template contract.overdue (locale=en-US): the hole {{ planned_amount | currency }}, naminglocalization.currency,currency:EURandcurrency:@currency_code, and nothing sent; the face renderers throw; acurrency:@pathhole and a template with no no-argument hole need no default.packages/services/service-messaging/src/template-tenant-currency.test.ts: the email and inbox channels pass the delivery organization's default (exact input pinned), pass nocurrencykey without one, and grade the refusal permanent.packages/services/service-automation/src/builtin/notify-currency-tenant-default.test.ts: the resolver is read once per run with the tenant id and the title rendersWon CN¥1,234.50; a context that already carries a currency keeps it; with no resolver, no default or a throwing read, the run fails naming the hole and nothing is emitted;renderTextSlotpins.packages/services/service-automation/src/builtin/notify-currency-tenant-default.test.tsalso: a run parked with no currency resumes on a second engine with a resolver, rendersWon CN¥1,234.50, and the persisted context stays withoutcurrency(red with the resume seeding removed).end-node-refusal-translation.test.ts: a refusal both texts share fails the run naming the hole with no translation warning (red on the oldrenderFlowTextSlot), and withcurrency: 'CNY'the zh-CN translation renders in it.packages/plugins/plugin-auth/src/mail-tenant-currency.test.ts: the named organization's default is added; identity mail reads the deployment-wide one; a named currency is kept and nothing is read; no default or a failed read adds no key; other members delegate.auth-plugin.test.ts: the wired service is the wrapper and still reaches the transport.ActionRunner.copyTemplateTenantCurrency-26939,ActionProvider.copyTemplateTenantCurrency-26939,bulkConfirmTenantCurrency-26939: confirm and success copy in the tenant default; with none, the sentence is shown as written and the refusal is logged once.Acceptance notes
screentext refuses the node like every other text slot. The contract review kept this split (comment 6469598248, open question answered A).TEMPLATE_INVALIDas permanent, so it is not retried. Setting the default and re-sending is the remedy.resolveLocalizationContextcannot tell "no tenant default" from "the settings read failed", so a transientsys_settingfault dead-letters a mail with a{{ x | currency }}hole as a permanentTEMPLATE_INVALID#27257).resolveLocalizationContextanswerscurrency: undefinedboth when an organization has no default and when itssys_settingread fails (the failure is memoized for a short TTL), so within that window a templated delivery with a no-argument hole dead-letters asTEMPLATE_INVALID. Telling the two apart needs a core seam, filed as [finding]resolveLocalizationContextcannot tell "no tenant default" from "the settings read failed", so a transientsys_settingfault dead-letters a mail with a{{ x | currency }}hole as a permanentTEMPLATE_INVALID#27257..changeset/26660-*.md(from feat(spec, formula, plugin-email, lint): a mail template formats an amount in the record's own currency with currency:@path #26937, unreleased) still say "no argument formats in US dollars".check-empty-changesetrefuses an edit to a changeset the merge base carries, so this PR's changeset says it supersedes them in the same release instead.curency) is refused nowhere and renders the raw value; the in-app dialect refuses it (found reviewing #26660) #26948) landed first and is merged in; its unknown-formatter refusal and this PR's no-default refusal sit side by side inrender().Verification
Head
9727b5111, which carries main at9ff8154cf(#27217 included) and the review's should-fixes S1, S3, S4 and S5.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackon2ba0895c2: 124 derived families, each run with its exit recorded;--ran: 124 run, 0 NOT-MEASURED, 0 UNRUN, all exit 0. Five spec-dist families first answered exit 3 because a spec source edit (the9727b5111fix below) had madepackages/spec/diststale; after@objectstack/specbuild they reran exit 0.os-verify-lockon2ba0895c2: the build of every changed package and dogfood's dependencies;@objectstack/speccheck:generatedexit 0; test and typecheck exit 0 for@objectstack/plugin-auth,@objectstack/service-automation,@objectstack/plugin-email,@objectstack/formula,@object-ui/core,@object-ui/reactand@object-ui/plugin-grid;@objectstack/dogfoodtypecheck exit 0 and the full dogfood suite: 348 files passed, 3 skipped (2804 tests passed).2ba0895c2failed one spec repo pin,currency-mode-family-closure.pin.test.tsrule B: the newRenderTemplateInput.currencydoc sat within five lines of thetimezonedoc's ADR-0053 citation.9727b5111moves that member besidedata. Underos-verify-lockon9727b5111:@objectstack/specbuild,check:generated,test:repo,testand typecheck, all exit 0.renderFlowTextSlot; both are green restored.Human Minimum
Each item is one rule from
CONTRIBUTING.md › Human Minimum,
which links the AGENTS.md text that states it.
packages/specholds no runtime logic.pnpm lintpasses. (No formatter ran. ESLint on the changed files: 0 errors;pnpm lintis CI's run.)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_01WGufzizxYz7JyC5JLW3Gmv