Skip to content

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

Merged
objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/project-thread-xl4rgk
Oct 28, 2026
Merged

objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/project-thread-xl4rgk

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

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

维护者速读

What changes

One rule, every dialect. A currency hole with no argument formats in the organization's default currency, the localization.currency setting 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) returns null for a three-capital code and otherwise the refusal. The mail dialect's remedy also names currency:@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 resolves ExecutionContext.currency with; there is no second resolution.

  • @objectstack/formula: EvalContext.currency, a reserved channel like locale, never bound as a root. evaluate refuses the sentence (kind: 'runtime') when a no-argument hole meets no default; formatValue(…, { currency }) throws a RangeError with the same sentence. Compile and validateExpression are unchanged.
  • @objectstack/plugin-email: RenderOptions.currency; render() judges a no-argument hole before reading the amount and throws TEMPLATE_INVALID. EmailService passes input.currency through.
  • @objectstack/spec (contracts): SendTemplateInput.currency, RenderTemplateInput.currency, AutomationContext.currency, with docs; the TEMPLATE_INVALID docs on sendTemplate / renderTemplate name the case.
  • @objectstack/service-messaging: the email and inbox channels take getTenantCurrency(organizationId) and pass currency on the template arm only when the delivery's organization has one. The plugin wires it to resolveLocalizationContext. A refusal is graded permanent, like every TEMPLATE_INVALID.
  • @objectstack/service-automation: AutomationEngine.setTenantCurrencyResolver, wired by the plugin. resolveRunContext seeds AutomationContext.currency once 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 in resolveDecisionSegmentContext, on a copy, so the persisted run.context stays as it parked. renderFlowTextSlot renders 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. renderTextSlot takes the currency, and every text slot passes context.currency: notify title / message / actionUrl, http url and headers, screen texts, end message.
  • @objectstack/plugin-auth: the six auth mail call sites (verification, password reset, account and organization invitations, magic link, notices) reach sendTemplate through withMailTenantCurrency (mail-tenant-currency.ts), which the plugin wraps once around the email service it wires into the AuthManager. A call that names no currency gets the default of the organization it names (organizationId) or, for identity mail, the deployment-wide setting, read through resolveLocalizationContext; 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 by ActionProvider and useActionRunner from useLocalization().currency (latest-ref, like the display locale); fillTemplateHoles and bulkConfirmText pass it to the engine. BulkActionDialog reads it from useLocalization. A sentence the engine refuses is shown as written, and fillTemplateHoles now console.warns the refusal once per distinct sentence, naming the hole and the remedy.
  • Migration: semantic entry 18.template-currency-no-argument-tenant-default (ADR-0087 D3: intent, not text, so no conversion).
  • Docs: email-templates (a paragraph; feat(spec, formula, plugin-email, lint): a mail template formats an amount in the record's own currency with currency:@path #26937's "no argument formats in US dollars" sentence now points at it), flows (a bullet), formulas (a sentence).

Census, left as they are. These show the spelling {{ record.amount | currency }} and claim nothing about USD, so they stay: the spec describes in flow-text-slot-template.ts, io-node-config.zod.ts (notify, http, screen), the notify and screen node config descriptions, the semantic entry 18.flow-text-slot-single-brace-refused, and the formula invalid-hole message. The lint fixtures and the ui flowTextSlots fixture stay valid because authoring is unchanged. No seeded template in the repo uses a no-argument currency hole.

Tests

  • packages/formula/src/template-currency-tenant-default.test.ts: CNY in en-US (CN¥1,234.50) and zh-CN (¥1,234.50), EUR, JPY; a literal code ignores the default; a root named currency is a value, never the default; null renders empty; refusal when the default is unset, empty, usd or dollars, whatever the value, and for the whole sentence when one hole of several has no argument; compile and validateExpression stay ok; formatValue; currencyDefaultRefusal per 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 / sendTemplate over all three faces, double- and triple-braced; with no default or usd, ^TEMPLATE_INVALID: email template contract.overdue (locale=en-US): the hole {{ planned_amount | currency }}, naming localization.currency, currency:EUR and currency:@currency_code, and nothing sent; the face renderers throw; a currency:@path hole 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 no currency key 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 renders Won 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; renderTextSlot pins.
  • packages/services/service-automation/src/builtin/notify-currency-tenant-default.test.ts also: a run parked with no currency resumes on a second engine with a resolver, renders Won CN¥1,234.50, and the persisted context stays without currency (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 old renderFlowTextSlot), and with currency: '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.
  • ui: 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

Verification

Head 9727b5111, which carries main at 9ff8154cf (#27217 included) and the review's should-fixes S1, S3, S4 and S5.

  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack on 2ba0895c2: 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 (the 9727b5111 fix below) had made packages/spec/dist stale; after @objectstack/spec build they reran exit 0.
  • Under os-verify-lock on 2ba0895c2: the build of every changed package and dogfood's dependencies; @objectstack/spec check:generated exit 0; test and typecheck exit 0 for @objectstack/plugin-auth, @objectstack/service-automation, @objectstack/plugin-email, @objectstack/formula, @object-ui/core, @object-ui/react and @object-ui/plugin-grid; @objectstack/dogfood typecheck exit 0 and the full dogfood suite: 348 files passed, 3 skipped (2804 tests passed).
  • CI's Test Core shard 2 on 2ba0895c2 failed one spec repo pin, currency-mode-family-closure.pin.test.ts rule B: the new RenderTemplateInput.currency doc sat within five lines of the timezone doc's ADR-0053 citation. 9727b5111 moves that member beside data. Under os-verify-lock on 9727b5111: @objectstack/spec build, check:generated, test:repo, test and typecheck, all exit 0.
  • Ablations: the S3 cold-resume test is red with the resume seeding removed, and the S5 test is red on the old renderFlowTextSlot; both are green restored.
  • ESLint on the changed files: 0 errors.

Human Minimum

Each item is one rule from
CONTRIBUTING.md › Human Minimum,
which links the AGENTS.md text that states it.

  • 1. Schemas start as Zod; packages/spec holds no runtime logic.
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes. (No formatter ran. ESLint on the changed files: 0 errors; pnpm lint is CI's run.)
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: its generated artifacts are regenerated and committed.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English. (The 维护者速读 block is in Chinese by the repo's dispatch rule.)
  • The Before Submitting list in CONTRIBUTING.md is done.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WGufzizxYz7JyC5JLW3Gmv

…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
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 28, 2026
@github-actions

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 6 package(s): @objectstack/formula, @objectstack/plugin-auth, @objectstack/plugin-email, @objectstack/service-automation, @objectstack/service-messaging, @objectstack/spec, touching 40 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/formula/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

18 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 2302b92b1b4abc09bbf95995909d8656c8edf4cf.

⛔ 11 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/formula/src/index.ts) — pages documenting those are invisible to this run
  • 7 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 56 of 208 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 172 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 2302b92b1b4abc09bbf95995909d8656c8edf4cf → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 51ad112a64e9110810719abb5151436d641c9f80 — the merge of head 9727b5111f709fcd92d512e40cc3cbf145662564 into base 2302b92b1b4abc09bbf95995909d8656c8edf4cf, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 2302b92b1b4abc09bbf95995909d8656c8edf4cf → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…ency hole that meets no tenant default

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzizxYz7JyC5JLW3Gmv
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 645d56ff5ed43d942eecf220868f02e080bc6d77
Local-runs: none
Scope: packages/formula/src/index.ts, packages/formula/src/template-engine.ts, packages/formula/src/types.ts, packages/formula/src/template-currency-tenant-default.test.ts, packages/formula/src/template-formatters.test.ts, packages/formula/src/template-hole-grammar.test.ts, packages/plugins/plugin-email/src/email-service.ts, packages/plugins/plugin-email/src/template-engine.ts, packages/plugins/plugin-email/src/template-engine.test.ts, packages/plugins/plugin-email/src/template-currency-tenant-default.render.test.ts, packages/plugins/plugin-email/src/template-hole-grammar.render.test.ts, packages/services/service-automation/src/builtin/http-nodes.ts, packages/services/service-automation/src/builtin/notify-node.ts, packages/services/service-automation/src/builtin/screen-nodes.ts, packages/services/service-automation/src/builtin/template.ts, packages/services/service-automation/src/builtin/notify-currency-tenant-default.test.ts, packages/services/service-automation/src/engine.ts, packages/services/service-automation/src/node-text-rendering.ts, packages/services/service-automation/src/plugin.ts, packages/services/service-automation/src/run-resume.ts, packages/services/service-messaging/src/email-channel.ts, packages/services/service-messaging/src/inbox-channel.ts, packages/services/service-messaging/src/messaging-service-plugin.ts, packages/services/service-messaging/src/template-tenant-currency.test.ts, packages/spec/src/contracts/automation-service.ts, packages/spec/src/contracts/email-service.ts, packages/spec/src/migrations/entries/semantic/18.template-currency-no-argument-tenant-default.ts, scripts/engine-double-contract.pinned.json, ui/packages/core/src/actions/ActionRunner.ts, ui/packages/core/src/actions/actionCopyTemplate.ts, ui/packages/core/src/actions/tests/ActionRunner.copyTemplateTenantCurrency-26939.test.ts, ui/packages/plugin-grid/src/components/BulkActionDialog.tsx, ui/packages/plugin-grid/src/components/bulkConfirmText.ts, ui/packages/plugin-grid/src/tests/bulkConfirmTenantCurrency-26939.test.tsx, ui/packages/react/src/context/ActionContext.tsx, ui/packages/react/src/context/actionRunnerTranslator.ts, ui/packages/react/src/context/tests/ActionProvider.copyTemplateTenantCurrency-26939.test.tsx, ui/packages/react/src/hooks/useActionRunner.ts + packages/core/src/security/resolve-authz-context.ts (resolveLocalizationContext, the cascade every host reads the default through), packages/formula/src/mail-hole-grammar.ts (parseMailHole, the currency:@path shape the mail hunk is judged against), packages/formula/src/stdlib.ts (buildScope, the reserved-channel claim), packages/services/service-automation/src/suspended-run-store.ts, packages/services/service-automation/src/builtin/subflow-node.ts, packages/services/service-automation/src/builtin/map-node.ts, packages/services/service-automation/src/dag-traversal.ts (the persist / inherit / resume claims), packages/services/service-messaging/src/template-renderer.ts (the other {{ }} interpolator in the same channel), packages/plugins/plugin-auth/src/auth-manager.ts and its sibling sendTemplate call sites (the mail callers outside the messaging channels), ui/packages/i18n/src/LocalizationContext.tsx (useLocalization().currency), .changeset/26660-.md (the superseded sentences). Prose faces (content/docs/**, the root .changeset/26939-.md) and the generated ledger scripts/engine-double-contract.pinned.json are not reviewed; the docs were read for the claims they make. The newer head cf826880 differs from this one by a prose-face line only (content/docs/kernel/runtime-services/email-service.mdx, +1/-1), so nothing in this scope moved.

① Derived judgments

Each accept-set and public-surface change the diff implies, read against the cited code on main:

  1. @objectstack/formula template dialect, render accept set narrowed — right. templateEngine.evaluate now refuses the whole sentence (kind: 'runtime') when any currency hole has no argument and ctx.currency is not three capitals, before anything renders; compile and validateExpression are unchanged, so every authoring door (objectstack validate, registerFlow, the publish gate) accepts the hole as before. firstTenantCurrencyHole reads the same HOLE_RE / parseHole pair compile reads, so the pre-check and the formatter agree on what a no-argument hole is. The formatter's own 'USD' fallback (template-engine.ts:84 on main) is gone; nothing formats in US dollars.
  2. formatValue('currency', v, undefined, opts) throws without opts.currency — right, and it throws whatever the value, as the @path value refusal of feat(spec, formula, plugin-email, lint): a mail template formats an amount in the record's own currency with currency:@path #26937 does: the judgment is of the hole, not the data.
  3. EvalContext.currency — right. buildScope (stdlib.ts:763) binds only record / previous / input / the os namespace / extra, so the channel is never a root; the test that a root named currency stays a value holds against that code.
  4. currencyDefaultRefusal export — right, one sentence for both renderers, the mail dialect's remedy naming currency:@currency_code and the in-app one not, which is what each dialect accepts.
  5. @objectstack/plugin-email RenderOptions.currency; render() refuses a no-argument hole without it — right, and coherent with feat(spec, formula, plugin-email, lint): a mail template formats an amount in the record's own currency with currency:@path #26937. parseMailHole keeps arg as the literal text after the colon (@currency_code) and adds codePath beside it, so the new parsed.arg === undefined guard never fires on a marked-path hole; that hole still resolves its code from the send's data first and hands formatValue a literal argument, which wins over opts.currency. The guard sits before the amount is read, as the @path value refusal does, and EmailService.resolveAndRenderTemplate wraps it with the template name and locale through the existing TEMPLATE_INVALID: catch. One refusal family, three causes (grammar, @path value, no default), one prefix.
  6. @objectstack/spec contracts: SendTemplateInput.currency, RenderTemplateInput.currency, AutomationContext.currency — right, optional inputs, documented, with the TEMPLATE_INVALID docs naming the new cause and that it clears once the default is set, which distinguishes it from the republish-only causes.
  7. @objectstack/service-messaging: EmailChannelOptions.getTenantCurrency / InboxChannelOptions.getTenantCurrency, wired through resolveLocalizationContext — right. That resolver returns currency: coerceCurrency(...) with no built-in fallback (only timezone and locale have one), so "no tenant default" is a real state and undefined means it. The read is keyed by the delivery's organizationId, which the notify node stamps from the run's tenantId (notify-node.ts:1077); an organization-less delivery resolves at global scope the way a request without an organization does. The refusal is graded permanent by the existing TEMPLATE_INVALID regex in classifyError. The sys_notification_template arm of the same channel is untouched, correctly: template-renderer.ts's interpolator has no formatter pipe.
  8. @objectstack/service-automation: setTenantCurrencyResolver, FlowTenantCurrencyResolver, renderTextSlot(…, currency), seeding in resolveRunContext — right. Every renderTextSlot caller on main (http url and headers, notify title / message / actionUrl, screen texts, and the end refusal message and screen translations through renderFlowTextSlot) now passes context.currency; none is left on the old signature. A subflow and a map child spread the parent context (subflow-node.ts:103, map-node.ts:188), so the seeded value is inherited; a suspended run persists context_json whole (suspended-run-store.ts:992), so it survives a pause. The resolver is wired inside the block that already holds engineQl, so the read goes over the same engine as the grants resolver. The refusal is a FlowTextTemplateError, the guard class a fault edge may not swallow — the right class, since a re-run with the same metadata and the same setting cannot succeed.
  9. @object-ui/core / react / plugin-grid: ActionRunner.setTenantCurrency, fillTemplateHoles(…, currency), bulkConfirmText(…, currency), useActionRunnerTenantCurrency — right. useLocalization() returns {} outside a provider and currency undefined when unconfigured (no USD anywhere in LocalizationContext.tsx), so the UI cannot format in a guessed currency either. With none, fillTemplateHoles returns the source — the fallback it already had for every engine refusal on main.
  10. Semantic entry 18.template-currency-no-argument-tenant-default (D3) — right. No text to rewrite; the question is intent; the entry says so and names the three remedies.
  11. Census left as written — right. On main, outside tests, the only lines asserting US dollars for the no-argument form are template-engine.ts:71 and :84, both changed here; the spec describes, the notify / screen config descriptions and flow-text-slot-template.ts show the spelling {{ amount | currency }} and claim no currency. The four USD-pinning tests are re-pinned to a supplied default and to the refusal.
  12. Tests pin the narrowing red/green on every leg: formula (refusal when unset, empty, lowercase, not a code; whole-sentence refusal; authoring unchanged), mail (all three faces, double and triple braces, nothing sent, @path needs no default), messaging (exact sendTemplate / renderTemplate input, no currency key without one, permanent grade), automation (resolver read once per run with the tenant id; a carried value wins; unwired / empty / throwing read all refuse and emit nothing), ui (confirm, success and bulk copy in the default; shown as written without one). Nothing in the diff pins $ anywhere.

② Semver level

Clause-②: no (narrowing) and Metadata-protocol: narrowed on the PR body and in .changeset/26939-currency-tenant-default.md; minor on every package the diff moves (formula, plugin-email, spec, service-messaging, service-automation, @object-ui/core, @object-ui/react, @object-ui/plugin-grid); a BREAKING banner with Before / Now / remedy and exactly one ADR-0087 marker (registered template-currency-no-argument-tenant-default), matched by the semantic entry file. Matches the diff. The accept set at render shrinks (a hole that rendered now refuses when no default reaches it) and nothing an author can write is widened; the additions are optional inputs and exports that carry the default, the same shape #26937's changeset declared no (narrowing) with "the additions are exports only". narrowed over semantic is the rule's own precedence: the meaning moved (semantic) and a value is refused (narrowed), several ⇒ narrowed. The "Supersedes, in the same release" paragraph is right and needed: .changeset/26660-currency-code-path-formula.md, 26660-currency-code-path-plugin-email.md, 26660-mail-hole-grammar-formula.md and 26660-mail-hole-grammar.md on main each still say the no-argument form formats in US dollars, and check-empty-changeset forbids editing them. The Skills-docs: line names the three docs pages and the reason no skills/** file changes; read against main, no skill teaches the dollar default. Check Changeset is green on this head.

③ Boundary flags

  • open_questions[0] — with no tenant default, should a flow screen text render without a symbol instead of failing the node? Answered: A, keep. The ruling's target (6464324887) is "render or send refuses with TEMPLATE_INVALID" in every dialect; a screen text is a render by the flow's one renderer, and every flow text slot already answers a guard refusal the same way. Action copy is not a second currency rule: the engine refuses there too, and fillTemplateHoles answers that refusal the way it answers every refusal on main — the source, because a client dialog has no error channel to the author. The split is between two renderers' pre-existing refusal semantics, not inside one, and a per-slot partial render in the engine would be a new rendering mode the ruling did not ask for. What is owed on the display leg is not a symbol-less render but the prescription (④ S1). No follow-up card.
  • out_of_scope_findings[0] (26660 changesets say USD) — accepted, handled by the supersedes paragraph (②).
  • out_of_scope_findings[1] (feat(formula, plugin-email, lint)!: a mail template hole whose formatter the whitelist does not name is refused by name, never rendered raw #27217 edits the same render()) — noted; this record judges this tree only. The two hunks are adjacent, not overlapping, in plugin-email/src/template-engine.ts.
  • PR body "Mail with no default dead-letters … Setting the default and re-sending is the remedy" — accepted with ④ S2. A dead row is not replayed; a new send after the setting is the remedy, as for every other TEMPLATE_INVALID. The grade is wrong only for a read fault (S2).
  • PR body "Pending 26660 changesets" — accepted (②).
  • Human Minimum item 3 unchecked (no local pnpm lint): Lint · ESLint is green on this head; the repo-gate lint families were cancelled (below). Not a flag against the diff.
  • Premise ("the dev re-measures on origin/main"): verified independently (① 11).
  • Check-runs on this head. Green: Lint · ESLint, Test Core, Dogfood Regression Gate, Check Changeset, Governed Surface Queue Guard, Spec property liveness, Check PR Size, Check Documentation Links, Flag docs affected by code changes, PR-body guards. Not answered on this head: every Type Check · job, Lint · repo & tooling gates, Lint · docs & code guards, Lint · CI, release & engine gates, Lint · PM dispatch-gates, the three Lint · scoped · jobs, Build Core, Build UI Deps, Build Docs, Test UI, Live E2E UI — all cancelled at 12:45–12:47Z when the push of cf826880 superseded this head (the Lint & Repo Gates, TypeScript Type Check and Test UI rows read failure with no output: the roll-up of those cancellations, not a red gate). This record does not stand in for them: the derived families are owed by the head that superseded this one, whose CI and Lint & Type Check runs were in progress at review time. The dev's own dispatch-gates --commands run on the merge tree (121 families, all exit 0) is the dev's account, not a gate verdict.

④ Design judgment

The narrowing, read against the published spec on main: the no-argument currency hole stays declared everywhere it was (the describes keep the spelling, authoring accepts it) and stays delivered wherever a tenant default reaches the render — the ruling's own definition of what it means. Where no default exists, the refusal is the ruling's ("SEND refuses … nothing formats in USD"), it names the hole, the setting and both remedies, and it clears by a settings change rather than a republish. That is the spec's refusal made real, not a capability taken away at the consumer. Tried to break it on the three faces named for this review:

Surfaces where the meaning moves from USD to the tenant default with no word to the author. Every one — sys_email_template faces through sendTemplate / renderTemplate and the two messaging channels, the flow text slots (notify, http, screen, end), action and bulk-action copy — is named in the changeset's Before / Now and in the D3 entry, which is the notice the ruling provides; no authoring door can know a tenant's setting, so a save-time warning is not available and the ruling did not ask for one. An organization whose default differs from the currency its amounts are in now sees its default where it saw dollars: a wrong currency for those amounts, silently, exactly as a currency field already shows them — the ruling chose this trade (fields and dashboards follow the same rule), and the remedy currency:CODE is in the entry. Accepted by ruling, not by this record's preference. No surface outside the declared set moves: the messaging sys_notification_template interpolator has no formatters; ExpressionEngine consumers in objectql, lint, the MCP validate tool and the flow simulator compile or evaluate CEL, never render a template hole; the tmpl helper's envelopes land in the slots above.

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, localization.currency, currency:EUR and currency:@currency_code; the flow run fails with the same sentence through FlowTextTemplateError; the next send or run after the setting succeeds because both hosts read the default per delivery / per run (the automation read is once per run, at setup). Three edges strand, each short of a must-fix:

  • S1 — should-fix — fillTemplateHoles discards the refusal. ui/packages/core/src/actions/actionCopyTemplate.ts returns the source when filled.ok is false and drops filled.error. On this leg the viewer sees Reprice {{ amount | currency }}? and nobody is told why: the only surface where the ruling's prescription does not reach anyone. Pre-existing shape for every refusal, so not a must-fix, but this PR is the first to route a refusal there by design. Log the refusal (console.warn or the runner's logger) with the sentence currencyDefaultRefusal builds, so the author has the remedy on the display leg too. This also closes the open question without a follow-up card.
  • S2 — should-fix — a transient read fault is graded permanent. getTenantCurrency in messaging-service-plugin.ts returns undefined both when the organization has no default and when resolveLocalizationContext's direct sys_setting read hits a backend fault (that resolver never throws; it memoizes the fault for LOCALIZATION_FAILURE_CACHE_TTL_MS and answers currency: undefined). Within that window every templated delivery of the organization with a no-argument hole dead-letters as TEMPLATE_INVALID — permanent, not retried, with a sentence saying the organization "has none set, or the caller did not supply it" while it may well have one set. The channel's own classifyError doc defines permanent as "re-trying the identical delivery can never succeed until someone edits the template", which this case is not. Narrow window (the recipient lookup on sys_user must succeed while sys_setting fails) and the same degradation the locale leg has today (silently en-US), so should-fix: expose backendFailed from resolveLocalizationContext (a core seam) or have the resolver throw on it, and grade that throw retryable in the channel.
  • S3 — should-fix — a run suspended before this change resumes with no default. The resumed leg takes segmentContext from the persisted run.context and never re-enters resolveRunContext, so a run parked before deploy carries no currency, and its next notify / screen with a no-argument hole refuses although the organization has a default — a refusal whose remedy (set the default) is already done. Seed in the resume path when the persisted context carries none, the same "when absent" rule setup uses. Pre-upgrade runs only; runs started after deploy persist the value (verified in suspended-run-store.ts).

Two further should-fixes from the cited code:

  • S4 — should-fix — the mail callers outside the messaging channels pass no default. plugin-auth calls email.sendTemplate at six sites (invitation, verification, existing-account notice, organization invite, and so on) without currency, so a tenant-customized auth template with a no-argument hole refuses whatever the setting, and the refusal's first remedy does not apply to it. The contract puts the duty on the caller and the claim scoped the mail path to the two channels; auth mail carries no amounts and the card's census found no shipped template with the hole, so a gap in reach, not in the rule. Thread the organization's default through those calls, or say in SendTemplateInput.currency's doc which callers do.
  • S5 — should-fix — a misattributed warning on the translated leg. renderFlowTextSlot catches the FlowTextTemplateError the translated template throws and logs "the translation … does not compile … Fix the translation's holes", then renders the authored text, which throws the same refusal. For a missing default both throws are the same sentence and the log line blames the translation. Skip the warning, or reword it, when the authored text throws the identical refusal.

Nothing found that formats in a guessed currency, nothing that refuses a hole which names its code, and the @path form of #26937 is untouched in behaviour and named in the refusal's remedy. No must-fix.

Implemented-by: claude/project-thread-xl4rgk
Reviewed-by: session_01WGufzfhobWEgUUrjz4rrbh

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
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT · domain:spec seat 1 (#6017) · session session_01WGufzfhobWEgUUrjz4rrbh · 2026-10-28T15:01Z

Head 9727b5111f709fcd92d512e40cc3cbf145662564. Card #26939, claim 6465560284, ruling 6464202770.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 28, 2026 15:02
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 28, 2026
Merged via the queue into main with commit 79ed6f7 Oct 28, 2026
70 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-xl4rgk branch October 28, 2026 15:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

spec(formula): a currency hole with no argument still formats in US dollars — refuse it at authoring in every template dialect (follow-up of #26660)

2 participants