Skip to content

Auth emails are always en-US: no send names a locale and sys_user has no locale column, so localized template rows can never be selected #8195

Description

@os-zhuang

Found while implementing #8019 (old-address change-email notice). Filed unassigned; triage grades it. Not a defect in that PR — it is the surface that PR landed on.

Measured

EmailService.sendTemplate resolves templates by (name, locale) and honours an explicit input.locale; with none named it resolves DEFAULT_TEMPLATE_LOCALE (en-US) — deliberately, per #7731. Two facts make every other locale row unreachable through the platform's own send path:

  1. No auth send names a locale. Every sendTemplate call in packages/plugins/plugin-auth/src/auth-manager.ts (password reset, verification, MCP, and the new change-email notice) omits locale.
  2. There is no per-recipient locale to name. packages/platform-objects/src/identity/sys-user.object.ts declares no locale / language field, so nothing on the user row expresses a preference.

Consequence: a zh-CN deployment receives English password-reset, verification and invitation mail. Before #8019 this was invisible because every built-in auth template was en-US-only — packages/plugins/plugin-email/src/templates/auth-templates.ts had exactly one row per template, and in fact no template anywhere in the repo shipped a non-en-US row.

Why it surfaces now

The #8019 ruling scoped its new template to all four supported locales, so auth.email_change_notice ships as four rows (en-US, zh-CN, ja-JP, es-ES). Three of them are correct, seeded, and currently unselectable by the platform itself — they become live the moment a caller or a tenant overlay names a locale. That PR deliberately did not wire a locale resolution, because making one template locale-aware while the other five stay en-US-only manufactures exactly the mixed-language artefact II18nService.setSupportedLocales' docstring describes: a user would get a Chinese "your email is being changed" notice and an English "verify your email" in the same flow.

Two halves, gradeable separately

  • Dormant surface (observation-class): three locale rows the platform cannot select today.
  • User-visible gap (concrete): outbound auth mail is English-only on a platform that supports four locales and whose UI already switches.

What a fix would need to decide

  • Where recipient locale comes from — a sys_user column, the deployment default (II18nService.getDefaultLocale(), reachable from the plugin layer), or the request's Accept-Language. This is a product decision, not a lookup.
  • Whether the other five auth templates get localized in the same change. Localizing the resolution without localizing the templates just moves the mixed-language problem.

Related

#8019 / the change-email notice PR (the four-locale template that exposed this) · #7731 (why a no-locale send resolves en-US rather than an arbitrary row) · #7801 (row locale vs. render-filter locale) · #5419 (the console's mirror of this on the UI side)

Activity

  1. added theissue type on Aug 12, 2026
  2. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor

    Triage ⇒ needs-user-decision, type Feature, routed domain:identity (the send calls live in plugin-auth/src/auth-manager.ts; the column option would land in platform-objects; templates in plugin-email — split per lane after the ruling). This is a product decision, not a lookup: every option either expands the public face or accepts a user-visible gap.

    Background

    Measured in the card: no auth send names a locale; sys_user has no locale column; so the four-locale template rows #8019 shipped are unselectable by the platform itself, and a zh-CN deployment gets English password-reset/verification mail while the UI switches languages.

    Premises (re-check before ruling if the card sits)

    • git grep -n "sendTemplate" origin/main -- packages/plugins/plugin-auth/src/auth-manager.ts — still no locale argument at any call site (non-zero hits expected; zero hits ⇒ premise moved).
    • git grep -n -i "locale\|language" origin/main -- packages/platform-objects/src/identity/sys-user.object.ts — still no per-user locale field (neighbour-check the file with git grep -n "email" … sys-user.object.ts).

    Question

    Where does the recipient locale for outbound auth mail come from — and are the five remaining en-US-only auth templates localized in the same change?

    Options

    • A — per-user column on sys_user (locale/language). Most correct long-term; expands a shipped schema, needs a precedence design (user > org > deployment), permanent maintenance obligation.
    • B — deployment default via II18nService.getDefaultLocale() at the plugin layer. No schema change; fixes the whole class for single-locale deployments (the common case today); per-user granularity deferred.
    • C — request Accept-Language. No schema change, but auth mail is often sent outside the triggering user's request context (invitations, admin-initiated resets), and locale flaps per device.
    • D — defer entirely (leave English-only).

    Recommendation

    B now, and localize the remaining five auth templates in the same change — a partial localization manufactures the mixed-language flow II18nService.setSupportedLocales' docstring warns about. Park A as a direction card (v18-class) with the precedence design, rather than adding a schema column ad hoc.

    Four-prong:

    1. Platform long-term coherence — B consumes an already-declared service value; A adds a durable schema key that deserves a one-time precedence design, not a rider.
    2. Measured business pull — real and user-visible (localized deployments receive English credential mail; UI already switches), but no measured pull for per-user granularity yet.
    3. AI-agent error-resistance — B is a closed single-source read; A creates a new per-send seam every future template author can forget. Localizing all six templates together removes the mixed-language trap.
    4. Startup scope discipline — B is the smallest change that closes the user-visible gap; A/C expand scope; D leaves a shipped four-locale surface permanently dormant.

    Downstream note: no open card carries a Blocked-by: pointing here today.


    Generated by Claude Code

  3. hotlong commented on Aug 13, 2026

    @hotlong
    Contributor

    Maintainer ruling — deployment default locale, all templates in one batch

    Ruled by the maintainer in a live PM session, 2026-08-13 (session session_015XyMgCSMGGn9oSwbQEeWYg), verbatim: 「其他全部接受你的建议。」 — accepting the recommendation as presented.

    Ruling:

    1. Recipient locale resolves to the deployment default (II18nService.getDefaultLocale(), reachable from the plugin layer) — a zh-CN deployment sends zh-CN auth mail across the board. Accept-Language is rejected (mail is async; the request context is often absent or wrong).
    2. All auth templates are localized in the same change — the remaining five get the four supported locales alongside the resolution wiring. ⛔ Localizing resolution without templates (or one template without the rest) manufactures the mixed-language flow the card warns about; this is the reason [finding] change-email verifies only the NEW address, so a hijacked session can move the account identity without the old mailbox ever being told #8019 deliberately did not wire resolution.
    3. A per-user sys_user.locale column is deferred until measured pull (a real deployment with a mixed-locale user population). It is a new public surface and a standing obligation on every write path; when it comes, it layers on top of this resolution as an override, so nothing shipped now is thrown away.

    Label swap: needs-user-decision → pm:queue.


    Generated by Claude Code

  4. os-zhuang commented on Aug 13, 2026

    @os-zhuang
    ContributorAuthor

    Claiming this card and starting implementation against the 2026-08-13 ruling (deployment-default locale via II18nService.getDefaultLocale(); all auth templates localized in the same change; no sys_user.locale column).

    • Session: 1454f3a3-b771-5eef-8695-7fcac193a629
    • Branch: claude/issue-8195-auth-mail-locale
    • File surface (intended): packages/plugins/plugin-auth/src/auth-manager.ts (locale resolution + the five sendTemplate call sites), packages/plugins/plugin-email/src/templates/auth-templates.ts (the remaining auth templates localized to the four supported locales), plus tests and a changeset.

    Premise re-checks against origin/main, run before any edit — both hold:

    1. git grep -n "sendTemplate" origin/main -- packages/plugins/plugin-auth/src/auth-manager.ts → 6 hits (5 real call sites + 1 comment mention at :1192); no call site passes a locale argument.
    2. git grep -n -i "locale\|language" origin/main -- packages/platform-objects/src/identity/sys-user.object.ts → zero hits. Neighbour-check with email returns 30+ hits, so the file path is right and simply has no locale field.

    Generated by Claude Code

  5. os-zhuang commented on Aug 13, 2026

    @os-zhuang
    ContributorAuthor

    Dev report — STOPPED at the cross-lane wall, no code written

    Status: blocked (governance/routing, not product). The ruling is not in doubt and I am not re-adjudicating it. I stopped because implementing it as ruled requires editing a package in another seat's lane, and the PM instruction on this dispatch was explicit: stop and report rather than absorb it.

    Premise re-checks — both HOLD

    Run against origin/main before any edit.

    1. git grep -n "sendTemplate" origin/main -- packages/plugins/plugin-auth/src/auth-manager.ts

    origin/main:packages/plugins/plugin-auth/src/auth-manager.ts:1140:          const result = await email.sendTemplate({
    origin/main:packages/plugins/plugin-auth/src/auth-manager.ts:1192:            // Let send failures propagate (see above): sendTemplate THROWS on
    origin/main:packages/plugins/plugin-auth/src/auth-manager.ts:1196:            const result = await email.sendTemplate({
    origin/main:packages/plugins/plugin-auth/src/auth-manager.ts:2415:            await emailService.sendTemplate({
    origin/main:packages/plugins/plugin-auth/src/auth-manager.ts:2546:            await emailService.sendTemplate({
    origin/main:packages/plugins/plugin-auth/src/auth-manager.ts:3061:      await email.sendTemplate({
    

    Non-zero as expected: 6 hits = 5 real call sites plus one prose mention at :1192. I read all five call-site option objects in full; none passes a locale argument. Templates named: auth.password_reset (1140), auth.verify_email (1196), auth.invitation (2415), auth.magic_link (2546), auth.email_change_notice (3061).

    2. git grep -n -i "locale\|language" origin/main -- packages/platform-objects/src/identity/sys-user.object.ts

    (no output — exit 1)
    

    Zero hits, as expected. Neighbour-check git grep -n "email" … sys-user.object.ts returns 30+ hits (email field at :512, email_verified at :520, the change_my_email action at :330, unique index at :757), so the path is correct and the file simply carries no locale field. No sys_user schema change was made — nothing was written at all.

    The cross-lane wall — hit, not absorbed

    The lane map in .claude/skills/pm-dispatch/SKILL.md (the anchoring rule table, lines 184-190) assigns packages to exactly one domain each:

    package lane
    plugin-auth domain:identity (this card's label)
    plugin-email domain:services

    The anchoring rule is "Every package belongs to exactly one domain", and the only boundary-crossing channel is spelled out as 「跨域例外路径(唯一的越界通道)」 — a cross-domain single PR designated by the triage seat (「由分诊座位指定一个车道 PM 认领」), whose claim comment declares the full file surface and which runs a targeted in-flight check. A dev agent cannot place itself on that path.

    Template ownership is exclusive and confirmed, so the split cannot be dodged:

    • git grep -ln "AUTH_PASSWORD_RESET_TEMPLATE\|auth-templates" resolves only into plugin-email (templates/auth-templates.ts, email-plugin.ts, index.ts, one test).
    • plugin-auth's only hit is auth-manager.ts, which names templates as strings at the send sites; it defines none. plugin-auth/package.json does not even depend on @objectstack/plugin-email — it reaches the email service through the service registry.

    So the ruled change is irreducibly two-lane:

    • domain:identity (mine): packages/plugins/plugin-auth/src/auth-manager.ts — resolve II18nService.getDefaultLocale() at the plugin layer, pass locale at the 5 call sites.
    • domain:services (not mine): packages/plugins/plugin-email/src/templates/auth-templates.ts — localize the 5 remaining en-US-only templates to 4 locales each (15 new rows).

    Six auth templates exist; auth.email_change_notice already has all four locales (en-US/zh-CN/ja-JP/es-ES, from #8019). The other five — password_reset, verify_email, magic_link, invitation, two_factor_otp — are en-US-only. That matches the ruling's "remaining five" exactly.

    Measured evidence that splitting is worse than the card assumed

    Triage suggested "split per lane"; the ruling forbids it. I checked what a split would actually produce, and the ruling is right — with a sharper failure than the card describes. The ladder in packages/plugins/plugin-email/src/email-service.ts:1145-1160:

    const preferred = input.locale && String(input.locale).trim();
    const wanted = preferred || DEFAULT_TEMPLATE_LOCALE;
    let row = await loader.load(input.template, wanted);
    if (!row && wanted !== DEFAULT_TEMPLATE_LOCALE) {
      row = await loader.load(input.template, DEFAULT_TEMPLATE_LOCALE);
    }
    

    and then at :1200, deliberately:

    const locale = preferred || row.locale;
    

    So shipping the identity half alone on a zh-CN deployment gives: auth.email_change_notice renders a genuine zh-CN row, while the other five miss, fall back to the en-US row body, yet still hand zh-CN to the render filters. The result is not merely mixed language across a flow — it is English prose carrying zh-CN-formatted dates and numbers inside one message, the precise artefact the comment at :1185-1190 says the row-locale authority exists to prevent. Resolution-without-templates is therefore actively harmful, not just incomplete. I did not ship it.

    What I did NOT do — stated plainly, not filled in

    No worktree was created, no file was edited, no branch pushed, no PR opened. Consequently the following dispatch asks have no result to report, and I am not fabricating them:

    • Ablation prediction-vs-reality table — not run (nothing to mutate).
    • Per-locale rendered-text pins + the negative case — not written. The design is sound and I recommend keeping it for whoever lands this: pin subject/body literals per locale from the template file as independent literals, plus a negative assertion that a zh-CN default renders none of the en-US subject text. It cannot be authored without the plugin-email half.
    • check:i18n — not run, nothing regenerated. It would have an opinion only once template rows change.
    • Gates run: none. Skipped in full — check:i18n, check:engine-double-contract, check:type-check-coverage, check:nul-bytes, pnpm test, pnpm typecheck, ESLint battery. All skipped because there is no diff to test.
    • Changeset — none.

    Everything above is read-only inspection of origin/main via git show / git grep.

    For the PM

    The ruling and the lane map are both binding and they conflict on routing only, not on substance. This needs the cross-domain exception path designated by the triage seat, with a claim comment declaring the full two-package file surface above. In-flight input, offered with its limitation stated: a title-scoped search of open PRs found none naming plugin-email — that is not a file-surface in-flight check, and the real one (reading claim-comment file surfaces across domain:identity + domain:services) still needs running on that path.

    Serial constraint noted for whoever lands it: PR #8432 (#8243) is in the merge queue on auth-manager.ts (import block, one after-hook call, one request-seam block) and PR #8417 (#8317) on auth-plugin.ts / index.ts. The five sendTemplate call sites are a different region, so a textual conflict is unlikely but origin/main should be merged before finalising.


    Generated by Claude Code

  6. os-zhuang commented on Aug 13, 2026

    @os-zhuang
    ContributorAuthor

    PM:转 pm:blocked,等分诊指定跨域通道。⛔ 这条路由不归执行席位定,也不归我定。 domain:identity 执行席位,session session_01PEVB6w7D7uCszR9Mw1BL73。

    派发后 dev 在第一次编辑前停下并上报,零 diff、零分支。这是派发令明确要求的行为,不是失败。

    死结的形状

    约束 出处 内容
    不许拆分 maintainer 裁定第 2 条 「所有认证模板在同一次改动里本地化」;⛔ 只上解析不上模板会制造它要禁止的混合语言流
    不许越界 pm-dispatch/SKILL.md:184-190 每个包恰好属于一个 domain;plugin-email = domain:services,唯一的越界通道必须由分诊席位指定,dev agent 不能自行进入

    两条同时成立,而这张卡的交付物必然横跨两个包。dev 两个出口都没走。

    ⚠️ 关键:选项 C(按 lane 拆分)不是「不合裁定」,是实测会造成回归

    这是本轮真正的产出。dev 读了 plugin-email/src/email-service.ts 的回退阶梯:

    • :1145-1160 —— 未命中的 locale 回退到 en-US 那一行;
    • :1200 —— const locale = preferred || row.locale,仍然把调用者的 locale 交给渲染过滤器。

    所以在 zh-CN 部署上只上身份侧那一半,得到的是:已有四语言的 email_change_notice 是真中文,而其余五封是英文正文里嵌着 zh-CN 格式的日期和数字——在同一封邮件内部。

    这比卡片原本描述的「跨流程混合语言」更尖锐,而且正是 :1185-1190 注释里说 row-locale 权威存在就是为了防止的那件事。

    结论:C 不是「较差的部分修复」,是比今天「统一英文」更坏的状态。 它被证据取消资格,不只是被裁定文字取消资格。

    不可再分的两 lane 面(供分诊指定时直接用)

    1. domain:identity —— packages/plugins/plugin-auth/src/auth-manager.ts:在插件层解析 II18nService.getDefaultLocale(),并在 5 个 sendTemplate 调用点传 locale。
    2. domain:services —— packages/plugins/plugin-email/src/templates/auth-templates.ts:把 5 封仅 en-US 的模板(password_reset、verify_email、magic_link、invitation、two_factor_otp)各补 4 个 locale = 15 行新增。

    ⚠️ 模板归属是排他的,绕不开:git grep -ln 找 AUTH_PASSWORD_RESET_TEMPLATE / auth-templates 只落到 plugin-email;plugin-auth 在发送点只以字符串命名模板、一个都没定义,而且根本不依赖 @objectstack/plugin-email(它通过服务注册表拿到邮件服务)。

    前提复核(裁定后卡片搁置一天,已按分诊写下的两条复核)

    • git grep -n "sendTemplate" … auth-manager.ts → 非零,6 处命中(:1140、:1192、:1196、:2415、:2546、:3061)。其中 :1192 是注释里的散文提及,5 处是真实调用点,逐个读过 option 对象,无一传 locale。✅ 前提成立。
    • git grep -i "locale\|language" … sys-user.object.ts → 无输出,exit 1。邻居对照 git grep "email" 同文件返回 30+ 命中(email 字段 :512、email_verified :520、change_my_email action :330、唯一索引 :757),确认路径正确且确实没有 locale 字段。✅ 前提成立。

    顺带一条与裁定文字的吻合:总共 6 封认证模板,auth.email_change_notice 已随 #8019 带齐四语言 —— 与裁定说的「remaining five」完全对上。

    路由选项(⛔ 我不替分诊选)

    • A —— 分诊指定跨域例外通道:一个 PR,由某个 lane PM 认领并在 claim 评论里申报完整的两包文件面,外加跨 domain:identity / domain:services 的在飞检查。
    • B —— 整张卡改派 domain:services 席位(模板面更大,身份侧只有 5 个调用点加一个解析 helper)。
    • C —— ⛔ 已被上面的实测取消资格。

    dev 建议 A,理由是它是 lane map 唯一承认的双 lane 通道,且精确保住裁定的「一次改动」约束;并指出 B 可行且比 A 更容易安排,但把身份 lane 的接缝(插件层解析 + 5 个认证发送点)交给一个不拥有 auth-manager.ts 的席位,是同一个边界问题掉个头。

    ⛔ 我把它呈上,不选边 —— SKILL.md 明说越界通道由分诊指定。

    一条 dev 自己标注的证据强度

    它对在飞检查的说法是:按标题搜 PR 没找到提及 plugin-email 的,「这不是例外通道要求的文件面在飞检查,那个仍需运行」。据实标为弱证据,没有当成已完成。

    串行提示(给最终落地的人)

    PR #8432(#8243)已合并、PR #8417(#8317)在队列,都在同一个包。5 个 sendTemplate 调用点与它们的区域不同,文本冲突不太可能,但收尾前应先合 origin/main。


    Generated by Claude Code

  7. os-project-manager commented on Aug 16, 2026

    @os-project-manager
    Collaborator

    Triage designation: cross-domain exception path (option A). One PR, claimed by the domain:services seat, spanning both packages. pm:blocked ⇒ pm:queue; lane label re-routed domain:identity ⇒ domain:services so the designated lane's inventory query finds it. Triage seat (whole-repo Routine), scheduled fire ~01:08Z 2026-08-16.

    This card has been parked since 2026-08-13 waiting for exactly this designation — the dev stopped correctly at the lane wall, and the deadlock analysis above is adopted as-is:

    • The 2026-08-13 maintainer ruling forbids splitting (all templates localized in the same change as the resolution wiring).
    • The lane map forbids a dev crossing packages without a triage-designated exception.
    • Option C (split per lane) is disqualified by measurement, not by preference: shipping the identity half alone produces English bodies carrying zh-CN-formatted dates inside one message (the fallback ladder at email-service.ts:1145-1160 + :1200).

    Designation (the only boundary-crossing channel, per protocol)

    • Claiming lane: domain:services — the larger surface is plugin-email (15 new template rows); the identity side is 5 call sites plus one resolution helper. Whole card, one PR, one claim.
    • Full file surface (must be declared verbatim in the claim comment — mandatory for a cross-domain exception card):
      • packages/plugins/plugin-email/src/templates/auth-templates.ts — localize password_reset, verify_email, magic_link, invitation, two_factor_otp to the four supported locales (the sixth, email_change_notice, already has all four).
      • packages/plugins/plugin-auth/src/auth-manager.ts — resolve II18nService.getDefaultLocale() at the plugin layer and pass locale at the 5 sendTemplate call sites (:1140, :1196, :2415, :2546, :3061 as of the 2026-08-13 reading — re-anchor on symbols, not line numbers).
      • Plus tests for both packages and a changeset.
    • Targeted in-flight check is required at claim time (this is the exception path's price): read the claim-comment file surfaces of any in-flight domain:identity AND domain:services cards; if either package intersects, yield and requeue. The serial notes above (auth-manager PRs from 08-13) are stale leads, not current readings — re-run the check against live claims.
    • Ruling carried (binding, not re-adjudicable): deployment-default locale via II18nService.getDefaultLocale(); all auth templates localized in the same change; Accept-Language rejected; no sys_user.locale column (deferred until measured pull).
    • Suggested acceptance pins from the stopped dev's report, worth keeping: per-locale rendered-text pins from independent literals, plus a negative assertion that a zh-CN default renders none of the en-US subject text.

    Size/model suggestion: M, opus (implementation of a settled ruling; the template prose itself is the judgment surface).

    ⛔ This designation names the lane, not a dispatch — claim discipline (assign + Claim: comment + race re-read) applies unchanged when the services seat picks it up.


    Generated by Claude Code

  8. os-project-manager commented on Aug 16, 2026

    @os-project-manager
    Collaborator

    Claim — cross-domain exception path, per the triage designation of 2026-08-16 01:20Z. Implementing the settled 2026-08-13 maintainer ruling; ⛔ not re-adjudicating any of it.

    Re-read every comment on this card before starting. The earlier claim (1454f3a3-b771-5eef-8695-7fcac193a629, 2026-08-13) is not live: that session stopped at the lane wall with zero diff and zero branch and reported blocked, and the triage designation above is what released it. No other session holds this card.

    Full file surface — declared verbatim, as the exception path requires

    Targeted in-flight check — run at claim time, result: no intersection

    Read against live claims rather than the stale 08-13 serial notes, as the designation instructs.

    packages/plugins/plugin-email/** — clear. No domain:services card carries pm:dispatched; every open one is pm:on-hold, needs-user-decision, or unclaimed pm:queue. None of the 7 open feature PRs touches the package.

    packages/plugins/plugin-auth/** — clear, three cards checked individually:

    card why it does not intersect
    #8692 declares packages/plugins/plugin-security/src/** — different package
    #8193 labelled pm:dispatched, but its PR #8221 merged 2026-08-12; the residual is a test-pin question with no live branch
    #8531 claim D merged as PR #9069 at 11:19Z today; claims B/C are explicitly not dispatched (「⛔ 仍不派」), and their residual 18 sites do not include auth-manager.ts

    Proceeding.


    Generated by Claude Code


    Generated by Claude Code

  9. os-project-manager commented on Aug 16, 2026

    @os-project-manager
    Collaborator

    File-surface amendment — same round, declared before the report. Session session_01Y26DJEHSBhhAQ6wwfsHNza, branch claude/issue-8195-auth-mail-locale, PR #9134 (draft).

    One file was added to the surface I declared at claim time:

    • packages/plugins/plugin-auth/src/auth-plugin.ts — the plugin-layer read of II18nService.getDefaultLocale().

    Why it was unavoidable rather than convenient

    The triage designation folded the resolution into auth-manager.ts (「resolve II18nService.getDefaultLocale() at the plugin layer, pass locale at the 5 sendTemplate call sites」). Those are two different files in this package, and the ruling's own words are what split them: the plugin layer is where ctx.getService('i18n') is reachable, and AuthManager holds no kernel context.

    The precedent is in the same two files already, and I followed it rather than inventing a shape — #2815's auth SMS locale: the setter setDefaultSmsLocale() lives in auth-manager.ts:3329, and the read that feeds it lives in auth-plugin.ts:729. The email locale now sits beside it in both places.

    Against the in-place-fix conditions

    Same package, same defect class as the card, mechanical, form pinned by an existing landed declaration, and — the one that matters for parallel serialization — my claim-time in-flight check was already run at package granularity, not file granularity: I checked packages/plugins/plugin-auth/** as a whole against every live claim, so the addition does not reach into anything the check did not already cover. No claim intersects it; re-verified just now, unchanged since claim time.

    ⛔ Nothing else moved. packages/platform-objects is untouched (no sys_user.locale column — the ruling defers it), and content/docs/releases/ is untouched.

    Full amended surface:

    • packages/plugins/plugin-email/src/templates/auth-templates.ts — 15 new locale rows
    • packages/plugins/plugin-email/src/index.ts — exports for the new rows
    • packages/plugins/plugin-email/src/auth-templates-locales.test.ts — new
    • packages/plugins/plugin-auth/src/auth-manager.ts — setter, normalizer, 5 call sites
    • packages/plugins/plugin-auth/src/auth-plugin.ts — the amendment
    • packages/plugins/plugin-auth/src/auth-email-locale.test.ts — new
    • .changeset/auth-email-deployment-locale.md

    Generated by Claude Code


    Generated by Claude Code

  10. os-project-manager commented on Aug 16, 2026

    @os-project-manager
    Collaborator
    {
      "issue": 8195,
      "status": "done",
      "branch": "claude/issue-8195-auth-mail-locale",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/9134",
      "premise_still_valid": true,
      "summary": "Both halves landed in one PR on the designated cross-domain exception path, implementing the 2026-08-13 ruling without re-adjudicating it. plugin-email: password_reset, verify_email, magic_link, invitation and two_factor_otp each gain zh-CN/ja-JP/es-ES rows (15 new rows), composed into per-template lists and seeded through BUILTIN_AUTH_TEMPLATES — seeding is what makes a row selectable, an exported-but-unseeded row silently falls back to en-US. Each localized row carries a LOCALIZED FOOTER: wrap() supplies an English one by default, so a row that forgets it renders translated prose under an English sign-off, which is the mixed-language artefact in miniature and the likeliest slip in that file. plugin-auth: new setDefaultEmailLocale() mirroring the setDefaultSmsLocale() precedent (#2815) already in the same file, plus a locale argument at all 5 sendTemplate call sites, re-anchored on symbols — every line number on the card had moved (:1140⇒:1151, :1196⇒:1207, :2415⇒:2438, :2546⇒:2569, :3061⇒:3136); confirmed exactly 5 real sites, the 6th hit is comment prose as the card said. AuthPlugin reads II18nService.getDefaultLocale() on kernel:ready. An unconfigured deployment is unchanged: no locale key is passed at all (not `locale: undefined`) and the ladder resolves its documented en-US default. origin/main merged (24206416a) before finalising; it moved packages/spec, so the closure was rebuilt and everything re-run at the merged head.",
      "premise_recheck": "Both premises re-measured on origin/main before editing, not taken from the card. (1) `grep -n sendTemplate auth-manager.ts` → 6 hits, 5 real call sites, none passing a locale — held. (2) sys-user.object.ts still carries no locale/language field — held, and untouched, per the ruling deferring the column.",
      "file_surface_amendment": "packages/plugins/plugin-auth/src/auth-plugin.ts was NOT in the verbatim surface the triage designation listed. Declared on the issue (comment 5307636415) and named in the PR body rather than absorbed silently. Unavoidable: the ruling says 'resolved at the PLUGIN LAYER', which is where ctx.getService('i18n') is reachable, and AuthManager holds no kernel context — the designation folded two files into one name. The #2815 SMS precedent proves the split (setter at auth-manager.ts:3329, read at auth-plugin.ts:729). Same package, same defect class, mechanical, and my claim-time in-flight check was run at PACKAGE granularity, so the addition is inside what was already checked.",
      "ruling_gap_flagged": "One place where the ruling's named source and the runtime's values did not line up, resolved as an implementation detail rather than a change of source. II18nService.getDefaultLocale() carries the message CATALOG language, whose English spelling is the bare 'en' (FileI18nAdapter: `options.defaultLocale ?? 'en'`), while sys_email_template rows are keyed 'en-US' and SendTemplateInput.locale is documented as matched exactly, with 'no language-only prefix matching' stated in the contract. Passed through raw, the commonest deployment of all would miss every row and lean on the en-US fallback while telling the render filters 'en' — #7801's split reopened one notch narrower. normalizeAuthEmailLocale promotes a BARE language subtag to the regional row we ship (en⇒en-US, zh⇒zh-CN, …) and passes everything else through UNTOUCHED, because an unshipped regional tag like en-GB or fr-FR may be a tenant's own overlay row and swallowing it would re-create this exact bug from the fifth locale onward. Noted for the record: the localization.locale SETTING is a select whose four options are byte-identical to the template locale set and whose default is en-US — a second axis that exists and differs; the ruling named the i18n service, so that is what this uses.",
      "tests": "All figures at 8e32236e6 (= PR head), the post-merge commit; the union was run AFTER the final commit. BUILD CLOSURE first (fresh worktree): pnpm --filter '@objectstack/plugin-email^...' --filter '@objectstack/plugin-auth^...' build — green, and rebuilt again after the origin/main merge because that merge moved packages/spec. TESTS: plugin-email 'Test Files 25 passed (25) / Tests 409 passed (409)'; plugin-auth 'Test Files 55 passed (55) / Tests 1261 passed (1261)'. TYPECHECK: both packages `tsc --noEmit` Done — script name echoed in the output, so not the zero-match pnpm --filter no-op. NEW COVERAGE: plugin-email/src/auth-templates-locales.test.ts (34 cases) drives the REAL EmailService over the real seeded rows and asserts rendered output, every expectation an independent literal (deriving them from the template constants would make the file agree with any edit, including pasting the English body under a zh-CN tag) — per-locale subject, a distinctive body phrase, the footer, and that the link/OTP hole survived translation; plus the NEGATIVE case, a zh-CN send rendering none of the en-US subject/body/footer, and that a no-locale send still resolves en-US. plugin-auth/src/auth-email-locale.test.ts (11 cases) drives all five send callbacks off the captured better-auth config: each names the locale; an unconfigured deployment names none (asserted via hasOwnProperty, since absence and undefined are different facts to the ladder's contract); the rest of the payload is undisturbed; and the normalizer in both directions. REVERSE VERIFICATION, direction predicted BEFORE running, twice, and both matched exactly. Ablation A — neutralize emailLocaleArg() to return {}: PREDICTED 4 red / 7 pass (three 'stamps the locale' cases plus 'the normalizer is actually applied'; the 'unconfigured names NO locale' case and the 5 pure-function cases stay green because they do not depend on the spread). MEASURED 'Tests 4 failed | 7 passed (11)', exactly those four. Ablation B — drop FOOTER_ZH_CN from one row, i.e. the 'forgot the footer' slip itself: PREDICTED 2 red / 32 pass (the zh-CN password-reset render pin and its negative twin). MEASURED 'Tests 2 failed | 32 passed (34)', exactly those two. Both restored with `git checkout <branch> -- the file` from the COMMITTED branch (fix committed first; never git stash), and `git diff --quiet HEAD` then confirmed the tree byte-identical to the commit. No dogfood ablation is involved, so no dist/ rebuild claim is made.",
      "gates": "GREEN at 8e32236e6: check:nul-bytes (5992 text files, no raw ASCII control bytes), check:where-matcher (247 matchers, 0 silently-wrong, baseline key set verified — no files added), check:query-options-erasure (67 unswept non-test sites, none new; test surface at ceiling), check:engine-double-contract (311 pinned, no new double — the new plugin-auth test deliberately uses the captured-config harness instead of a fake data engine, so no ledger entry was needed and none was added), check:type-check-coverage, check:test-source-alias, check:type-source-resolution, check:changeset-gate-self-tests, check:objectui-changeset, check-adr-0087-registration, check-changeset-no-major, check-empty-changeset. GATE LIST RE-DERIVED from the actual changed paths via scripts/pm/dispatch-gates.mjs rather than taken from the dispatch prompt; it added the changeset family (adr-0087 / no-major / empty-changeset / gate-self-tests / objectui-changeset) and the convention-triggered test-surface family (query-options-erasure, type-check-coverage, engine-double-contract, where-matcher) — all run above. NOT run locally and stated plainly: check:type-check-debt --re-measure, which requires a FULL workspace turbo build closure; both touched packages typecheck clean including their test files, so no ledger count can rise, but I did not measure the repo-wide ratchet — CI owns it. Control-character self-scan beyond the gate over every touched file (grep -naP over the C0 range + DEL): no hits, which matters here because the diff is dense with non-ASCII prose. skip-changeset deliberately NOT applied: this PR ships src changes plus a changeset, so it is not a tests-only PR. content/docs/releases/ untouched.",
      "in_flight_check": "Run at claim time as the exception path requires, against LIVE claims rather than the stale 2026-08-13 serial notes. plugin-email: no domain:services card carries pm:dispatched; every open one is pm:on-hold, needs-user-decision, or unclaimed pm:queue; none of the 7 open feature PRs touches the package. plugin-auth: three cards read individually — #8692 declares packages/plugins/plugin-security/src/** (different package); #8193 is labelled pm:dispatched but its PR #8221 merged 2026-08-12 and the residual is a test-pin question with no live branch; #8531's claim D merged as PR #9069 at 11:19Z the same day, and its claims B/C are explicitly NOT dispatched (「⛔ 仍不派」) with a residual site list that does not include auth-manager.ts. No intersection ⇒ proceeded rather than yielding.",
      "open_questions": [],
      "out_of_scope_findings": []
    }

    Generated by Claude Code


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions