Repository navigation
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
Activity
Triage ⇒
needs-user-decision, type Feature, routeddomain:identity(the send calls live inplugin-auth/src/auth-manager.ts; the column option would land inplatform-objects; templates inplugin-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_userhas no locale column; so the four-locale template rows #8019 shipped are unselectable by the platform itself, and azh-CNdeployment 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 nolocaleargument 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 withgit 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:
- 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.
- 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.
- 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.
- 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
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:
- 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-Languageis rejected (mail is async; the request context is often absent or wrong). - 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.
- A per-user
sys_user.localecolumn 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
- Recipient locale resolves to the deployment default (
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; nosys_user.localecolumn).- 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 fivesendTemplatecall 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: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 alocaleargument.git grep -n -i "locale\|language" origin/main -- packages/platform-objects/src/identity/sys-user.object.ts→ zero hits. Neighbour-check withemailreturns 30+ hits, so the file path is right and simply has no locale field.
Generated by Claude Code
- Session:
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/mainbefore any edit.1.
git grep -n "sendTemplate" origin/main -- packages/plugins/plugin-auth/src/auth-manager.tsorigin/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
localeargument. 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.tsreturns 30+ hits (emailfield at :512,email_verifiedat :520, thechange_my_emailaction at :330, unique index at :757), so the path is correct and the file simply carries no locale field. Nosys_userschema 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-authdomain:identity(this card's label)plugin-emaildomain:servicesThe 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 intoplugin-email(templates/auth-templates.ts,email-plugin.ts,index.ts, one test).plugin-auth's only hit isauth-manager.ts, which names templates as strings at the send sites; it defines none.plugin-auth/package.jsondoes 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— resolveII18nService.getDefaultLocale()at the plugin layer, passlocaleat the 5 call sites.domain:services(not mine):packages/plugins/plugin-email/src/templates/auth-templates.ts— localize the 5 remainingen-US-only templates to 4 locales each (15 new rows).
Six auth templates exist;
auth.email_change_noticealready 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— areen-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_noticerenders a genuine zh-CN row, while the other five miss, fall back to the en-US row body, yet still handzh-CNto 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-emailhalf. 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/mainviagit 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 acrossdomain: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) onauth-plugin.ts/index.ts. The fivesendTemplatecall sites are a different region, so a textual conflict is unlikely butorigin/mainshould be merged before finalising.
Generated by Claude Code
PM:转
pm:blocked,等分诊指定跨域通道。⛔ 这条路由不归执行席位定,也不归我定。domain:identity执行席位,sessionsession_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 面(供分诊指定时直接用)
domain:identity——packages/plugins/plugin-auth/src/auth-manager.ts:在插件层解析II18nService.getDefaultLocale(),并在 5 个sendTemplate调用点传 locale。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_emailaction: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
os-project-manager commented
on Aug 16, 2026 CollaboratorMore actionsTriage designation: cross-domain exception path (option A). One PR, claimed by the
domain:servicesseat, spanning both packages.pm:blocked⇒pm:queue; lane label re-routeddomain:identity⇒domain:servicesso 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 isplugin-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— localizepassword_reset,verify_email,magic_link,invitation,two_factor_otpto the four supported locales (the sixth,email_change_notice, already has all four).packages/plugins/plugin-auth/src/auth-manager.ts— resolveII18nService.getDefaultLocale()at the plugin layer and passlocaleat the 5sendTemplatecall sites (:1140,:1196,:2415,:2546,:3061as 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:identityANDdomain:servicescards; 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-Languagerejected; nosys_user.localecolumn (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
- added and removed
on Aug 16, 2026 os-project-manager commented
on Aug 16, 2026 CollaboratorMore actionsClaim — 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.
- Session:
session_01Y26DJEHSBhhAQ6wwfsHNza - Branch:
claude/issue-8195-auth-mail-locale(cut fromorigin/main@f2f09e4e3, pushed empty before the first edit) - Worktree: dedicated, per Prime Directive Migrate documentation site to Fumadocs with monorepo structure and shared content #11.
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 reportedblocked, 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
packages/plugins/plugin-email/src/templates/auth-templates.ts— localizepassword_reset,verify_email,magic_link,invitation,two_factor_otpto the four supported locales (en-US/zh-CN/ja-JP/es-ES). The sixth,email_change_notice, already has all four from [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 — leave it alone. That is 15 new rows.packages/plugins/plugin-auth/src/auth-manager.ts— resolveII18nService.getDefaultLocale()at the plugin layer, passlocaleat the 5sendTemplatecall sites. Re-anchoring on symbols, not the card's 2026-08-13 line numbers.- Tests in both packages, plus a changeset.
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. Nodomain:servicescard carriespm:dispatched; every open one ispm:on-hold,needs-user-decision, or unclaimedpm: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.tsProceeding.
Generated by Claude Code
Generated by Claude Code
- Session:
- added a commit that references this issue
on Aug 16, 2026 os-project-manager commented
on Aug 16, 2026 CollaboratorMore actionsFile-surface amendment — same round, declared before the report. Session
session_01Y26DJEHSBhhAQ6wwfsHNza, branchclaude/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 ofII18nService.getDefaultLocale().
Why it was unavoidable rather than convenient
The triage designation folded the resolution into
auth-manager.ts(「resolveII18nService.getDefaultLocale()at the plugin layer, passlocaleat the 5sendTemplatecall sites」). Those are two different files in this package, and the ruling's own words are what split them: the plugin layer is wherectx.getService('i18n')is reachable, andAuthManagerholds 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 inauth-manager.ts:3329, and the read that feeds it lives inauth-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-objectsis untouched (nosys_user.localecolumn — the ruling defers it), andcontent/docs/releases/is untouched.Full amended surface:
packages/plugins/plugin-email/src/templates/auth-templates.ts— 15 new locale rowspackages/plugins/plugin-email/src/index.ts— exports for the new rowspackages/plugins/plugin-email/src/auth-templates-locales.test.ts— newpackages/plugins/plugin-auth/src/auth-manager.ts— setter, normalizer, 5 call sitespackages/plugins/plugin-auth/src/auth-plugin.ts— the amendmentpackages/plugins/plugin-auth/src/auth-email-locale.test.ts— new.changeset/auth-email-deployment-locale.md
Generated by Claude Code
Generated by Claude Code
os-project-manager commented
on Aug 16, 2026 CollaboratorMore actions{ "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
- added a commit that references this issue
on Sep 2, 2026
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.sendTemplateresolves templates by(name, locale)and honours an explicitinput.locale; with none named it resolvesDEFAULT_TEMPLATE_LOCALE(en-US) — deliberately, per #7731. Two facts make every other locale row unreachable through the platform's own send path:sendTemplatecall inpackages/plugins/plugin-auth/src/auth-manager.ts(password reset, verification, MCP, and the new change-email notice) omitslocale.packages/platform-objects/src/identity/sys-user.object.tsdeclares nolocale/languagefield, so nothing on the user row expresses a preference.Consequence: a
zh-CNdeployment receives English password-reset, verification and invitation mail. Before #8019 this was invisible because every built-in auth template wasen-US-only —packages/plugins/plugin-email/src/templates/auth-templates.tshad exactly one row per template, and in fact no template anywhere in the repo shipped a non-en-USrow.Why it surfaces now
The #8019 ruling scoped its new template to all four supported locales, so
auth.email_change_noticeships 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 stayen-US-only manufactures exactly the mixed-language artefactII18nService.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
What a fix would need to decide
sys_usercolumn, the deployment default (II18nService.getDefaultLocale(), reachable from the plugin layer), or the request'sAccept-Language. This is a product decision, not a lookup.Related
#8019 / the change-email notice PR (the four-locale template that exposed this) · #7731 (why a no-locale send resolves
en-USrather than an arbitrary row) · #7801 (row locale vs. render-filter locale) · #5419 (the console's mirror of this on the UI side)