Skip to content

fix(plugin-email): a templated email to a named locale folds it against the template's locales and tries the stack's declared fallback before en-US - #28917

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-1minft
Nov 2, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-1minft

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

domain:server#5 · os-project-manager · session_01FEkQ5oc56xChD6Wrc4cGSt

Requested by Leehom · project thread

Fixes #28880

Clause-②: no
Metadata-protocol: none
Skills-docs: none — no file under skills/** teaches the email template locale ladder.

维护者速读

  • 问题:模板邮件按收件人 locale 精确匹配,失败只再试 en-US,所以 en-GB 收件人拿不到 en 行,zh-TW 拿不到 zh-CN 行,栈声明的 i18n.fallbackLocale 也从不读取,这些投递都以 TEMPLATE_NOT_FOUND 死信。
  • 现在:指定了 locale 的调用依次走:按平台 BCP-47 规则(resolveBundleLocale:精确、大小写、基础语言、同语言变体,变体取升序第一个)折叠到模板已有的 locale;再按同样规则试声明的 fallback;再 en-US;最后仍是永久失败的 TEMPLATE_NOT_FOUND。
  • 不变:精确命中照旧;不指定 locale 的调用照旧(en-US,再取最小 tag),不读声明的 fallback;替换的 TemplateLoader 保持原来的精确规则。折叠是内置 loader 的能力,通过模块私有的 symbol 提供,不导出、不加在 TemplateLoader 上,公开 d.ts 没有变化。
  • spec 里 SendTemplateInput 和 email-template.zod.ts 的说明文字因此过时,已由 spec: the email template locale docs say the resolver folds no language subtag and retries only en-US, which #28880 makes wrong #28896 跟进;本 PR 不改 packages/spec/**。

What changes

All in packages/plugins/plugin-email/src/.

  • template-loader.ts: createRegistryEmailTemplateLoader's loader also answers, under a module-private symbol (TEMPLATE_BUNDLE_LOCALES, declared in email-service.ts, not re-exported from the package index and not a member of TemplateLoader), the locale tags the name's bundle carries, de-duplicated and sorted ascending. Its load is unchanged and stays exact.
  • email-service.ts (resolveAndRenderTemplate): for a call that names a locale over a loader carrying that symbol, it folds the named tag with resolveBundleLocale (imported from @objectstack/spec/system, no third copy of the ladder), then the declared fallback the same way, loading each resolved tag exactly and skipping a tag already tried; then the existing en-US rung. A loader without the symbol, and every no-locale call, take exactly the base path. EmailService.setTemplateFallbackLocale(read) stores the fallback reader (the class is not exported, so this is not a published member). The TEMPLATE_NOT_FOUND / TEMPLATE_INACTIVE messages, the variables check and the envelope columns are untouched.
  • email-plugin.ts: the composition point. EmailServicePlugin.init hands the service a reader that resolves the i18n service at each send and answers its getFallbackLocale() (i18n.fallbackLocale, else defaultLocale); a host with no i18n service declares none.

The fold is a built-in-loader capability: a replacement TemplateLoader keeps today's exact ladder, declared fallback included (pinned).

Tests

  • template-locale-fold.test.ts (13, the real service over the real registry loader): en-GB → en; zh-TW → zh-CN; zh → zh-CN; zh-cn → zh-CN; variant expansion takes the first base-language match in ascending tag order in both list orders (zh-MO → zh-CN); an exact hit wins over every fold; fallbackLocale: 'zh-CN' with no match → zh-CN before en-US; the fallback is folded (zh → zh-CN); a fallback with no row → en-US; no fallback → en-US; nothing at all → TEMPLATE_NOT_FOUND: welcome …; a no-locale call answers as today and never reads the fallback; a loader without the symbol answers exactly as on base, with a declared fallback (['en-GB', 'en-US'], then TEMPLATE_NOT_FOUND).
  • template-locale-fold.integration.test.ts (2, booted ObjectKernel with ObjectQLPlugin, an app registering the duly shape en + zh-CN bundle through the manifest service, and the real EmailServicePlugin with its built-in loader): en-GB sends the en row and zh-TW the zh-CN row; with an i18n service declaring zh-CN, a ja recipient sends the zh-CN row.
  • Base vs head (origin/main b15b634c): booted send, base throws TEMPLATE_NOT_FOUND: lf_lead_time (locale=en-GB) and (locale=ja), head sends [en], [zh-CN], [zh-CN]. Unit file on base (with only a no-op setTemplateFallbackLocale shimmed in so the file loads): 7 red, the 6 guards green.
  • Ablations, each landed on disk then restored:
rung removed red
fold (exact tag only) 7 (every fold case, the folded fallback, the booted send)
declared fallback 3 (both fallback cases, the booted fallback send)
en-US 3 (fallback-without-row, no-fallback, replacement loader)
ascending sort of the loader's tags 1 (variant order)
the loader's symbol 9
the plugin's i18n wiring 1 (booted fallback send)

Acceptance notes

  • No published type changes. TemplateLoader, EmailServiceOptions and createRegistryEmailTemplateLoader's signature are as before; the built dist/index.d.ts is identical to base.
  • No-locale ladder unchanged (ruling 2). The published SendTemplateInput.locale doc says a call that omits the locale starts at en-US, so the declared fallback stays out of that ladder.
  • Spec docs now stale: SendTemplateInput.template / .locale (packages/spec/src/contracts/email-service.ts) say "no language-subtag folding", and email-template.zod.ts says en-US is the only rung retried. Follow-up spec: the email template locale docs say the resolver folds no language subtag and retries only en-US, which #28880 makes wrong #28896 (domain:spec); not touched here.
  • NotificationTemplateStore's own ladder (named, primary subtag, en) in service-messaging is unchanged (out of scope).

Verification

Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 60 commands on the diff; all 60 exit 0, and --ran reconciles 60 derived, 60 run, 0 NOT-MEASURED. pnpm --filter @objectstack/spec check:error-code-provenance: exit 0. pnpm --filter @objectstack/plugin-email typecheck (runs check:test-typecheck): first exit 2 (TS2353, the symbol key on the loader's object literal typed TemplateLoader), fixed by typing the local as TemplateLoader & { [key]: … }, then exit 0. Scoped eslint over the five changed .ts files: exit 0. pnpm build of the package: the emitted dist/index.d.ts matches base. After the final merge of main (9a9823d9, nothing under packages/plugins/plugin-email or packages/spec): the three template-locale* files green (38 tests).

The booted send in template-locale-fold.integration.test.ts is the order's real sendTemplate through the email service with the built-in loader; the duly app itself was not run. Full dogfood not owed (no !, no narrowing arm).


Generated by Claude Code

…d try the declared fallback before en-US

The built-in registry loader exposes its bundle's sorted locale tags under a
module-private key; the service folds the named locale and then the stack's
declared fallback with resolveBundleLocale before the en-US rung, loading each
resolved tag exactly. EmailServicePlugin reads the fallback from the i18n
service at each send. A no-locale call and a replacement loader keep their
ladders.

Co-Authored-By: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 2, 2026
@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-email, touching 9 documentable anchor(s).

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/kernel/services.mdx (via EmailServicePlugin (symbol, a top-level class))
  • content/docs/protocol/kernel/plugin-spec.mdx (via EmailService (symbol, a top-level class))
What this run could not see
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 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; 101 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 — 7 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 9a9823d921a85a523529c25978daeb8229fa4ff9 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 233f9292a17816263d5f4b71f4cae662e815181e — the merge of head 2c76b75611703a5c0c1aba88b98fbe40ef644596 into base 9a9823d921a85a523529c25978daeb8229fa4ff9, 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 233f9292a17816263d5f4b71f4cae662e815181e && git checkout 233f9292a17816263d5f4b71f4cae662e815181e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9a9823d921a85a523529c25978daeb8229fa4ff9 2c76b75611703a5c0c1aba88b98fbe40ef644596 && git checkout -B drift-repro 9a9823d921a85a523529c25978daeb8229fa4ff9 && git merge --no-ff 2c76b75611703a5c0c1aba88b98fbe40ef644596

node scripts/docs-audit/affected-docs.mjs --json 9a9823d921a85a523529c25978daeb8229fa4ff9

⚠️ 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 9a9823d921a85a523529c25978daeb8229fa4ff9 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-project-manager · session_01FEkQ5oc56xChD6Wrc4cGSt

席位核对

ACCEPT · domain:server seat 5 · os-project-manager · session_01FEkQ5oc56xChD6Wrc4cGSt · 2026-11-02T04:15Z · head 2c76b756 · #28880

The maintainer's standing authorisation for landing reviewed PRs, verbatim: 「按照 pm-dispatch 规则复核过的都授权」.

席位意见:与 diff 相符。指定语言的模板邮件现在先按平台统一的 BCP-47 规则匹配模板已有的语言(en-GB→en、zh-TW→zh-CN),再试站点声明的回退语言,然后才是 en-US;未指定语言的调用和自定义加载器照旧。

  1. Shape: draft against main, Fixes #28880, patch changeset for @objectstack/plugin-email; Clause-②: no, Metadata-protocol: none. Declaration face only, no contract review owed. check-governed-merges --pr 28917: NOT governed, +347 / -5. git merge-tree against main: clean.
  2. Against the order:
    • Rulings 1 and 3. The service folds through the imported resolveBundleLocale, with no third copy of the ladder, over the tags the built-in loader answers under a module-private symbol. The symbol is not on TemplateLoader, and it is not re-exported: index.ts names its exports, and the built d.ts is unchanged. Each resolved tag loads exactly, and en-US stays an exact rung. This is the seat's placement (A).
    • Ruling 2. The no-locale ladder is untouched and reads no fallback.
    • Ruling 4. An exact hit, TEMPLATE_INACTIVE, the variables check and TEMPLATE_NOT_FOUND answer as before. The fallback is read from the i18n service's getFallbackLocale at each send (email-plugin.ts), so an i18n service registered later is seen, and a host with none declares none.
  3. Accepted deviations: none.
  4. Gates: red first, then 6 rung ablations, each red. A booted send in the duly shape: base TEMPLATE_NOT_FOUND for en-GB / ja, head [en], [zh-CN], [zh-CN]. plugin-email 764/764. 60/60 derived gates exit 0, --ran 0 NOT-MEASURED. Typecheck and provenance exit 0. Check-runs on this head were still running at ACCEPT, and auto-merge waits on every required one.
  5. Out of scope:
  6. Landing: pr_ready + automerge_enable through the relay.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 2, 2026 04:16
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 2, 2026 04:16
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 2, 2026
Merged via the queue into main with commit e0dc077 Nov 2, 2026
53 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-1minft branch November 2, 2026 04:50
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/m tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Email template resolver ignores i18n.fallbackLocale and does no language-subtag folding: en-GB / zh-TW recipients never reach en / zh-CN rows

1 participant