Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions .changeset/notification-locale-per-recipient.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
---
"@objectstack/service-messaging": minor
"@objectstack/spec": minor
"@objectstack/service-automation": patch
---

feat(service-messaging): notification locale is resolved per recipient — `sys_user.locale`, then the deployment default (#13881)

Maintainer ruling 2026-09-01, quoted verbatim and untranslated:

> **解析点移到 fan-out 后按收件人**:`payload.locale` 不再是 fan-out 前单值,插进 `email-channel.ts` L86-99 自留的 seam
> **解析链 = 收件人 `locale` → 部署默认**(`II18nService.getDefaultLocale()`),缺失恒回退,⛔ 任何路径不得死信

Before, the delivery path resolved ONE locale per notification: `payload.locale`
if the producer set one (interpolated once, before fan-out), else the deployment
default. Every recipient of a notify node got the same `sys_email_template` row,
whatever language they read — the hotcrm measurement that lifted the 2026-08-13
deferral.

Now the locale is resolved PER RECIPIENT, at delivery time, through ONE read
point (`recipient-locale.ts`, `resolveRecipientLocale`): the recipient's own
`sys_user.locale` — email and SMS read it off the same row they already fetch
for the address, so it costs no second query there; the inbox channel, which
never read the row before, makes one read for it on the template path — else
the deployment default, probed
lazily so live `localization` changes are honoured. The same chain serves the
email channel's two arms (`sendTemplate` and `sys_notification_template`), the
inbox channel's template path, and the SMS channel, so one notification cannot
arrive in two languages across channels.

**Never a dead letter from this seam.** A recipient value that is absent, empty,
whitespace, non-string, malformed, or the literal string `"undefined"` /
`"null"` (the exact shape hotcrm measured dead-lettering every user without a
preference row) falls back to the deployment default; nothing named anywhere
arrives at the downstream ladders as an absent key, which is their documented
`en-US` floor. A locale read that throws (a `userObject` override without the
column) is retried address-only and falls back — the delivery still goes out.

**Behaviour change for producers:** a `payload.locale` set by a producer is no
longer consulted. It was never a declared key of the notify node (only the
generic `payload` passthrough carried it) and no in-repo producer writes it;
the ruling retired it as the pre-fan-out single value. A node that relied on
it now sends each recipient their own language, else the deployment default —
which is the ruled behaviour, not a regression. Nothing to migrate: remove the
key, or leave it, it is inert either way.

Second behaviour change: on the `sys_notification_template` arm (email topic
path, SMS) the deployment default (`II18nService.getDefaultLocale()`) is now
the second rung; before, that arm fell straight from `payload.locale` to the
static `en` and never consulted it. A deployment whose `localization.locale`
is e.g. `zh-CN` with a topic bundle holding `en` and `zh` rows renders `zh`
there now for recipients without a column. SMS is newly handed the
deployment-default probe.

`@objectstack/spec` ships the contract text: the `notify` node's `template`
description and its refusal messages now state the per-recipient chain and
name `payload.locale` as not consulted (`automation/io-node-config.zod.ts`),
mirrored on the runtime descriptor in `@objectstack/service-automation`.

Interaction with the `TEMPLATE_*` permanent-failure class is unchanged in
kind: `sendTemplate`'s ladder for a NAMED locale still ends at `en-US`, so a
recipient locale can dead-letter a delivery only against a bundle that has
neither the requested row nor an `en-US` row — off the documented contract.
Two asymmetries against the old single value, both on such bundles: (a) the
bundle carries the deployment default's row but no `en-US` row — old delivered,
new fails for a recipient whose own tag is a third language; (b) there is NO
deployment default (i18n absent or `getDefaultLocale` unimplemented) — old
called `sendTemplate` with no locale and the ladder's any-row rung delivered,
new names the recipient's tag, the any-row rung is skipped, and a tag absent
from the bundle is `TEMPLATE_NOT_FOUND` (permanent). The fix in both is the
bundle (`en-US` is the ladder's floor), not a third rung.
37 changes: 37 additions & 0 deletions .changeset/sys-user-locale-column.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
"@objectstack/platform-objects": minor
"@objectstack/plugin-auth": patch
---

feat(platform-objects): `sys_user.locale` — the user's own notification language as a first-class column (#13881)

Maintainer ruling 2026-09-01, quoted verbatim and untranslated:

> **A**:`sys_user` 加 `locale` 一等列(用户语言是主流平台的一等用户属性;B 的 preference 袋会把一等概念藏进键值对并孕育第二种拼法,排除)

`sys_user` gains `locale` — a BCP-47 tag (`zh-CN`, `ja-JP`), optional, in the
Profile group. The 2026-08-13 ruling had deferred it "until measured pull";
hotcrm measured the pull (4 published languages × 16 notify nodes × 0
localizable, two independent lanes agreeing), so the deferral lifted on its own
terms. The preference-bag alternative (`sys_user_preference`) was rejected by
the ruling and nothing reads it as a fallback.

The column is owned by objectql exactly like `ai_access`: better-auth is
oblivious to it, and it is deliberately NOT declared as a better-auth
`additionalFields` entry — better-auth SELECTs explicit columns, so declaring
it there would make `getSession` query a column an environment that has not
yet run schema-sync does not have. Boot schema-sync (additive) provisions it.
plugin-auth registers it in `MANAGED_EXTENSION_FIELDS.sys_user`, whose ADR-0105
D7 collision guard proves better-auth's own user schema owns no `locale` at
the pinned version.

Who may write it is unchanged: the column is `readonly` on the object (ADR-0092
D4, so the standard edit form does not advertise a write the runtime refuses)
and is NOT added to `MANAGED_EXTENSION_EDITABLE_FIELDS` or to the ADR-0092 D2
self-service whitelist (`{name, image}`). Widening that whitelist so a user can
set their own language is a security-boundary decision recorded on #13881, not
made here; until then the column is written only by system-context callers (no
admin surface writes it today).

Who reads it: the messaging channels, per recipient, after fan-out — see the
`@objectstack/service-messaging` changeset in the same release.
18 changes: 11 additions & 7 deletions content/docs/automation/email-templates.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -164,13 +164,17 @@ the localizable path:

- `template` names the bundle. The delivery path resolves `(name, locale)`
against `sys_email_template` at delivery time and renders subject/body from
that row. **The locale is one value for the whole notification, not one per
recipient**: `payload.locale` if the producer set one — interpolated once,
before fan-out — else the deployment default (`II18nService.getDefaultLocale()`).
The platform has no per-user locale (`sys_user` carries no locale column), so
recipients whose personal languages differ all receive the same row. A per-user
locale is deferred until measured pull (maintainer ruling, 2026-08-13); it
layers in as an override at that same seam when it lands.
that row. **The locale is resolved per recipient, after fan-out**: the
recipient's own `sys_user.locale` when set, else the deployment default
(`II18nService.getDefaultLocale()`) — so two recipients of one notification
whose `locale` columns differ receive different rows of the same bundle
(maintainer ruling, 2026-09-01). A recipient whose column is empty or unset
always falls back to the deployment default; no value ever dead-letters a
delivery. A `payload.locale` set by the producer — the pre-ruling single
value for the whole notification — is not consulted. The same chain serves
the inbox channel's template path and the `sys_notification_template` rows
the email and SMS channels render, so one notification cannot arrive in two
languages across channels.
- Inline `title` / `message` are the **non-localizable** path: raw strings sent
to every recipient verbatim. The two paths cannot be combined on one node —
the schema refuses the ambiguous shape rather than letting a runtime
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/automation/io-node-config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ const result = HttpConfigSchema.parse(data);
| **recipients** | `string \| string[]` | ✅ | Recipient user id(s) / audience selector(s); `{token}` templates resolve per run |
| **title** | `string` | optional | Notification title, sent to every recipient verbatim (not localizable — use `template` for per-locale content). Either this or `template` is required; the two are mutually exclusive. |
| **message** | `string` | optional | Notification body, sent verbatim like `title` (not localizable). Only valid with inline `title`, never with `template`. |
| **template** | `string` | optional | Email template name (`sys_email_template.name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, locale)` against sys_email_template at delivery time and renders subject/body from that row. The locale is ONE value for the whole notification, not one per recipient: `payload.locale` if the producer set one, else the deployment default (`II18nService.getDefaultLocale()`) — the platform has no per-user locale, so recipients with different personal languages all receive the same row (deferred by the 2026-08-13 ruling; it layers in as an override when it lands). Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation. |
| **template** | `string` | optional | Email template name (`sys_email_template.name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, locale)` against sys_email_template at delivery time and renders subject/body from that row. The locale is resolved per recipient, after fan-out: the recipient's own `sys_user.locale` when set, else the deployment default (`II18nService.getDefaultLocale()`) — so recipients whose personal languages differ receive different rows of the same bundle (maintainer ruling 2026-09-01). A producer-set `payload.locale` is not consulted. Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation. |
| **templateData** | `Record<string, any>` | optional | Render context for the referenced template's `{{var}}` placeholders; values interpolate `{token}` templates per run. Only valid together with `template`. |
| **channels** | `string \| string[]` | optional | Channels to fan out to (default: inbox) |
| **topic** | `string` | optional | Event topic (default: "notify") |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
image: {
label: "Profile Image"
},
locale: {
label: "Locale",
help: "Preferred language for notifications, as a BCP-47 tag (e.g. zh-CN, ja-JP). Read per recipient at delivery time; when unset the deployment default applies. Owned by objectql (better-auth is oblivious to this column)."
},
manager_id: {
label: "Manager",
help: "This user's direct manager. Forms the reporting chain the `own_and_reports` hierarchy scope walks (ADR-0057 / @objectstack/security-enterprise)."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
image: {
label: "Imagen de perfil"
},
locale: {
label: "Idioma",
help: "Idioma preferido para las notificaciones, como etiqueta BCP-47 (p. ej. zh-CN, ja-JP). Se lee por destinatario en el momento de la entrega; si no está definido, se aplica el idioma predeterminado del despliegue. Propiedad de objectql (better-auth ignora esta columna)."
},
manager_id: {
label: "Gerente",
help: "This user's direct manager. Forms the reporting chain the `own_and_reports` hierarchy scope walks (ADR-0057 / @objectstack/security-enterprise)."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
image: {
label: "プロフィール画像"
},
locale: {
label: "言語",
help: "通知に使用する優先言語(BCP-47 タグ、例: zh-CN、ja-JP)。配信時に受信者ごとに読み取られ、未設定の場合はデプロイのデフォルト言語が適用されます。objectql が所有します(better-auth はこの列を認識しません)。"
},
manager_id: {
label: "マネージャー",
help: "This user's direct manager. Forms the reporting chain the `own_and_reports` hierarchy scope walks (ADR-0057 / @objectstack/security-enterprise)."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
image: {
label: "头像"
},
locale: {
label: "语言",
help: "通知使用的首选语言,BCP-47 标签(如 zh-CN、ja-JP)。投递时按收件人读取;未设置时采用部署默认语言。归 objectql 所有(better-auth 不感知此列)。"
},
manager_id: {
label: "经理",
help: "This user's direct manager. Forms the reporting chain the `own_and_reports` hierarchy scope walks (ADR-0057 / @objectstack/security-enterprise)."
Expand Down
36 changes: 36 additions & 0 deletions packages/platform-objects/src/identity/sys-user.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -735,6 +735,42 @@ export const SysUser = ObjectSchema.create({
group: 'Profile',
}),

// #13881 — the user's own notification language, a first-class column
// (maintainer ruling 2026-09-01: 「`sys_user` 加 `locale` 一等列」; the
// preference-bag shape was rejected, so nothing falls back to
// sys_user_preference). BCP-47 tag (`zh-CN`, `ja-JP`). Read PER RECIPIENT
// by the messaging channels after fan-out (`service-messaging/src/
// recipient-locale.ts` — the one read point), with the deployment default
// (`II18nService.getDefaultLocale()`) underneath: absent / empty /
// malformed always falls back, never dead-letters a delivery.
//
// Owned by objectql, exactly like `ai_access`: better-auth is oblivious
// to this column, and it is DELIBERATELY not a better-auth
// `additionalFields` entry — better-auth SELECTs explicit columns, so
// declaring it there would make getSession query a column an env that has
// not yet run schema-sync does not have (the `ai_access` note in
// auth-manager.ts). Registered as an extension field in plugin-auth's
// MANAGED_EXTENSION_FIELDS, whose ADR-0105 D7 guard proves the name does
// not collide with better-auth's own user schema at the pinned version.
//
// `readonly` for the same reason as every non-whitelisted field above
// (ADR-0092 D4): the identity write guard's self-service whitelist is
// {name, image}, this column is not on it, so a form edit would be
// stripped server-side; rendering it editable would advertise a write the
// runtime refuses. Widening that whitelist is a security-boundary decision
// recorded as an open question on #13881, not made here.
locale: Field.text({
label: 'Locale',
required: false,
readonly: true,
maxLength: 35,
group: 'Profile',
description:
'Preferred language for notifications, as a BCP-47 tag (e.g. zh-CN, ja-JP). ' +
'Read per recipient at delivery time; when unset the deployment default applies. ' +
'Owned by objectql (better-auth is oblivious to this column).',
}),

// ── Organization ─────────────────────────────────────────────
manager_id: Field.lookup('sys_user', {
label: 'Manager',
Expand Down
5 changes: 3 additions & 2 deletions packages/plugins/plugin-auth/src/auth-email-locale.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@
* caller's own `Accept-Language` first (only when it names a locale in
* `AUTH_EMAIL_TEMPLATE_LOCALES`), and the deployment default second. The
* 2026-08-13 ruling had made the deployment default the whole answer and
* rejected `Accept-Language` outright. Still no `sys_user.locale` column —
* that half stayed deferred. The ruling text of record lives on
* rejected `Accept-Language` outright. `sys_user.locale` exists since #13881
* (2026-09-01) but auth mail does not read it yet — that is #14641's rung,
* and this file asserts nothing about it. The ruling text of record lives on
* `AuthManager.setDefaultEmailLocale` / `authEmailLocaleFromRequest`; the
* request rung's own cases are the last describe block in this file.
*
Expand Down
29 changes: 19 additions & 10 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3029,8 +3029,12 @@ export class AuthManager {
// very card one seat over. The 2026-09-02 ruling enumerates
// signup, sign-in and password reset — sends where the requester
// IS the recipient — and the superseded 2026-08-13 ruling named
// invitations as its own counterexample. So an invitee gets the
// deployment default until a per-user language exists to read.
// invitations as its own counterexample. A per-user language
// now EXISTS to read — `sys_user.locale` (#13881, ruling
// 2026-09-01) — but an invitee has no `sys_user` row until they
// accept, so this send keeps the deployment rung; reading the
// invitee's column where one exists is #14641's rung, not this
// card's.
...this.emailLocaleArg(),
data: {
inviter: {
Expand Down Expand Up @@ -4452,9 +4456,10 @@ export class AuthManager {
* entirely and `EmailService`'s ladder resolves its documented `en-US`
* default exactly as before.
*
* Still NOT a per-recipient stored preference: `sys_user` carries no locale
* column and the 2026-09-02 ruling continues to defer one. What is read is
* the language this request expressed, not a profile.
* Still NOT a per-recipient stored preference: `sys_user.locale` exists
* since #13881 (ruling 2026-09-01) but auth mail does not read it yet
* (#14762 for this send; #14641 for invitations). What is read is the
* language this request expressed, not a profile.
*/
private async sendChangeEmailNotice(
from: { email: string; name?: string; id?: string },
Expand Down Expand Up @@ -4661,8 +4666,9 @@ export class AuthManager {
* `kernel:ready` and on every settings change (same pattern as
* {@link setAppName}). Unset ⇒ the built-in English text.
*
* Per-user locale is not resolved yet — `sys_user` carries no locale
* column; when it grows one, resolution should prefer it (#2815).
* Per-user locale is not resolved here yet — `sys_user.locale` exists since
* #13881 (ruling 2026-09-01) and the messaging channels read it per
* recipient; auth SMS adopting it is #14762 (supersedes the #2815 note).
*/
setDefaultSmsLocale(locale: string | undefined): void {
this.smsLocale = locale?.trim() || undefined;
Expand Down Expand Up @@ -4706,9 +4712,12 @@ export class AuthManager {
* the request rung applies only where the requester IS the recipient, and
* why the invitation send below still reads this rung.
*
* Per-user locale is STILL deferred by the 2026-09-02 ruling — `sys_user`
* carries no locale column and none is added here. When one arrives it
* layers on top as a third rung, so nothing here is wasted.
* Per-user locale EXISTS since #13881 (maintainer ruling 2026-09-01):
* `sys_user.locale`, resolved per recipient by service-messaging for
* notification mail (`recipient-locale.ts`). Auth mail does NOT read it
* yet — this ladder stays request rung → deployment rung. Layering the
* user's own column on top as a third rung is #14641 (invitations) and
* its own card for the other sends; nothing here is wasted by that.
*/
setDefaultEmailLocale(locale: string | undefined): void {
this.emailLocale = normalizeAuthEmailLocale(locale);
Expand Down
Loading
Loading