Skip to content

docs(spec): SPEC-072 recipient-locale rendering for system emails and notifications (built-in user_preferences + resolver seam) - #54

Draft
bartek5412 wants to merge 3 commits into
developfrom
update-pr-942-spec
Draft

docs(spec): SPEC-072 recipient-locale rendering for system emails and notifications (built-in user_preferences + resolver seam)#54
bartek5412 wants to merge 3 commits into
developfrom
update-pr-942-spec

Conversation

@bartek5412

@bartek5412 bartek5412 commented Jul 30, 2026

Copy link
Copy Markdown

Summary

Draft spec (SPEC-072, .ai/specs/SPEC-072-2026-07-30-recipient-locale-system-email-seam.md) reframing the discussion from fullstackhouse/tournee#942 as a platform-level open-mercato problem, per maintainer direction — Tournee is only an example consumer (Covo independently hit the same gap and worked around it with inline sendEmail).

Problem: Core renders system messages (password reset, new-invitee email, notification email) in the initiator's request locale (or a single global default), never in the recipient's stored language. Any multi-locale OM deployment sends wrong-language mail on background/cross-user sends — most visibly, an invite renders in the inviting admin's language, not the invitee's.

Approach — works out of the box, override optional (shaped by review feedback that an app developer shouldn't have to configure anything):

  1. Stored locale — a new core user_preferences module: one row per (user, tenant) with preferred_locale, managed via GET/PUT /api/user_preferences/me.
  2. Resolver seamregisterRecipientLocaleResolver(fn) in @open-mercato/shared/lib/i18n; user_preferences registers a default reader, apps may re-register to override (last wins). The resolver receives em + tenantId (recipient-locale resolution is a tenant-scoped DB lookup, not a pure function of user id).
  3. PrecedenceOM_FORCE_LOCALE → registered resolver → detectLocale() (request paths only) → defaultLocale. No stored row ⇒ resolver returns undefined ⇒ byte-for-byte today's behavior. Additive across all contract surfaces; the frozen User entity is untouched.

Phasing

  • Phase 1 — auth emails (implemented on feat/recipient-locale-auth-emails, PR to follow): password reset + new-invitee render via resolveTranslationsForRecipient.
  • Phase 2 — notification delivery (now unblocked and narrower than originally specced): the pipeline rewrite this phase waited on landed upstream as open-mercato/open-mercato#5366 (supersedes the closed #4326). The merged pipeline already localizes push per device locale (fan-out re-resolves copy per device.locale) and in-app per viewer (keys resolved client-side), so the remaining work is:
    • the email base copy: the dispatch subscriber resolves copy in defaultLocale; Phase 2 resolves the recipient's stored locale via the seam and passes it to resolveNotificationCopy, exposed as copyLocale on NotificationDeliveryContext;
    • a copyLocale handoff to the push fan-out: its fast path reuses the base copy verbatim assuming it is default-locale — without the handoff, an en device of a pl-preference user would get a Polish push.

Verification

Docs-only change (single spec file); lint and audit-scope CI checks pass. Acceptance tests for both phases are enumerated in the spec and land with the implementation PRs.

Follow-ups

  • Phase 1 implementation PR from feat/recipient-locale-auth-emails (rebase + acceptance tests), then Phase 2 on the merged pipeline — proposed as one combined upstream PR to open-mercato/open-mercato since the reason for phasing (rebase conflict with the rewrite) is gone.
  • Post-release: downstream apps bump @open-mercato/core and drop their app-level locale workarounds.

🤖 Generated with Claude Code

…fication delivery

Reframes the Tournee-side SPEC-063 discussion (fullstackhouse/tournee#942) as a
platform-level open-mercato problem: core renders auth emails and notification
delivery on the initiator's request locale, never the recipient's stored
language. Documents an additive host-registered resolver seam across the three
send paths, with sequencing against the notification-pipeline rewrite in
open-mercato#4326. Tournee is documented only as an example
consumer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@bartek5412
bartek5412 requested a review from jtomaszewski July 31, 2026 06:50
@jtomaszewski

Copy link
Copy Markdown

How would the end-user usage look like (by the OM app)?

One consistent shape — an optional host-registered resolver, consumed before dictionary load, defaulting to existing behavior when unregistered.

Does it mean that I, as an app developer, I'd have to configure it anyhow?

Isn't "user's locale" built in anyhow into the OM already?

Maybe we should make that built in? So that notifications are delivered in the recipient's language out of the box, without each OM app having to configure that manually?

@jtomaszewski

Copy link
Copy Markdown

cc @Frshy , did we meet this problem in Covo?

@Frshy

Frshy commented Jul 31, 2026

Copy link
Copy Markdown

@jtomaszewski kind of, from what I remember this was one of the reasons why we send emails using inline sendEmail rather than this notification channel.

… notifications after open-mercato#4326)

Reframes to built-in stored locale (new core user_preferences module) plus an
optional recipient-locale resolver override, addressing PR #54 review:
- built-in out-of-the-box default (Jacek), not per-app config
- resolver signature (em, userId, tenantId) so it exposes em+tenantId (Copilot)
- Covo added as a second example consumer (Frshy)
- explicit phasing: Phase 1 auth emails implemented now; Phase 2 notification
  delivery deferred until after open-mercato#4326

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@bartek5412
bartek5412 marked this pull request as ready for review July 31, 2026 11:02
@bartek5412
bartek5412 requested review from jtomaszewski and removed request for jtomaszewski August 4, 2026 14:03
@bartek5412
bartek5412 marked this pull request as draft August 4, 2026 14:45
…peline (open-mercato#5366)

The notification-pipeline rewrite this spec was waiting on landed upstream
as open-mercato#5366 (supersedes the closed open-mercato#4326). The merged
pipeline already localizes push per device and in-app per viewer, so Phase 2
narrows to the email base copy in the dispatch subscriber plus a copyLocale
handoff to the push fan-out (its default-locale fast path assumes the base
copy is default-locale). Added Phase 2 implementation notes, per-channel
acceptance tests, and the email/push coupling risk.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@bartek5412 bartek5412 changed the title docs(spec): SPEC-072 recipient-locale seam for system emails and notification delivery docs(spec): SPEC-072 recipient-locale rendering for system emails and notifications (built-in user_preferences + resolver seam) Aug 18, 2026
@bartek5412

Copy link
Copy Markdown
Author

@Frshy – Updated after merge your PR in OM. Could you take a look at those specs?

@Frshy Frshy left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

left one comment


## Data models

`user_preferences` (new table):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this in notifications module? if yes please check if we don't already have the table which stores per user preferences

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No — it’s a new standalone user_preferences module, not part of notifications. I checked notification_preferences: that table stores per-(type, channel) opt-outs for notifications only, while preferred_locale is one row per (user, tenant) and is consumed outside notifications too (password reset and invite emails, where the recipient has no notification context yet).

@Frshy
Frshy changed the base branch from main to develop August 19, 2026 10:58

@jtomaszewski jtomaszewski left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for reframing this as a platform problem — that part is right, and the Phase 2 analysis of the merged pipeline (push already per-device, in-app viewer-side, email the remaining gap, plus the copyLocale handoff) is good work and I'd keep it as-is.

Requesting changes on the design, though. Two structural issues and one hole in the flagship example.

1. Split this into two specs

As written, the stored preference is invisible to the user: detectLocale() is untouched, so a user who PUTs preferredLocale: pl still gets the backend UI in whatever the cookie says. Only mail changes. A preference that can only be set via curl and only affects email isn't a shippable user-facing feature — and "OM knows my language" is the more valuable half of this.

  • Spec A — user locale. Storage, the profile UI to set it (/backend/auth/profile, or /backend/profile/* where notification-preferences and communication-channels already live), the precedence chain, and a request-free resolver (resolveUserLocale(em, {userId, tenantId}) + resolveTranslationsFor(locale)).
  • Spec B — recipient rendering. This spec minus the storage: the three send paths + the push copyLocale handoff, as a pure consumer of Spec A.

They can still ship as one PR later if that's more convenient, but I want the "how does OM know a user's locale" discussion to happen on its own, in detail, rather than as a substrate of the email fix.

2. The invite case doesn't work under this design

The Problem section leads with "an invitation renders in the inviting admin's language, not the invitee's" — but a brand-new invitee has no user_preferences row at send time. The resolver returns undefined, precedence falls to detectLocale(), and the invite renders in the inviting admin's request locale exactly as it does today. The acceptance test "invitee with stored en, admin on PL → EN invite" presupposes a row that cannot exist for a new user.

Needs an explicit answer: either the create-user/invite command accepts a preferredLocale (admin picks the invitee's language in the form, and it seeds the preference row), or the no-preference fallback for cross-user sends becomes a tenant default rather than the initiator's request locale. Right now the headline motivating case is unfixed.

3. Storage: no alternatives were considered

A new core module is the largest surface of the available options and the spec doesn't justify it against the others. Please cover at least:

  • users.locale nullable column on the auth User — cheapest, resolver is one select.
  • user_preferences table owned by authauth already owns a per-user preference table (user_sidebar_preferences), and owns User; no new module id/ACL/API surface.
  • User-scoped rows in configs — mirror module_configs (module_id, name, value_json, tenant_id, org_id) with a user_id; makes tenant default and user override the same story, which is what open-mercato#1964 needs.
  • New user_preferences module — what you have.

Also: the stated rationale "The frozen User entity is untouched" is not correct. BACKWARD_COMPATIBILITY.md §8 (Database Schema, ADDITIVE-ONLY) explicitly says "MAY add new columns with defaults (non-breaking)". A nullable locale column is legal. Fix the rationale, or keep the decision on grounds that actually hold (extensibility to timezone/date-format, for instance).

My prior: the generic per-user store only pays for itself once there's a second and third preference — which is exactly open-mercato#1964 (timezone, date format, first day of week). So the decision hinges on point 4.

4. Missing prior art: open-mercato#1964

open-mercato#1964 "FR: Implement system-wide date and locale settings" and its tracking spec .ai/specs/2026-05-18-date-locale-settings.md cover the same ground — the issue scope names "tenant/system settings, and optional user overrides", and the spec's Q2 is verbatim our question:

Q2. Should user profile locale/date preferences override tenant defaults in the first implementation, or be deferred?

Never answered, never implemented. Spec A must reference it and either claim Q2 or explicitly defer, otherwise we ship two competing precedence chains for "where does a user's locale come from".

5. Use DI for the override seam, not a global registry

registerRecipientLocaleResolver(fn) with last-wins semantics is a second override mechanism next to the one OM already has (DI + modules.ts overrides). BC §9 says new DI registrations are free, so a userLocaleResolver service costs nothing and gets scoping, testability and a documented override path for free. The load-order-dependent "last registration wins" and the em: unknown cast in shared are both symptoms of the seam sitting in the wrong layer.

6. Precedence chain is underspecified

Today the language switcher writes only a cookie (auth/api/locale/route.ts). The spec puts the stored preference above detectLocale() without saying what happens when they disagree. Please state it explicitly. My proposal: the switcher writes both, login rewrites the cookie from the stored value (so a second device inherits the choice), stored beats Accept-Language, anonymous/pre-login stays cookie-only. Plus: is there a tenant-default layer between stored preference and defaultLocale? Point 2 needs one.

7. Which recipients?

customer_accounts users have no locale either, and they can be notification recipients. The signature (em, userId, tenantId) bakes in "an auth user". Declare portal/customer recipients in or out of scope, and if out, how the contract extends later without a breaking signature change.

Non-blocking

  • Cite devices.locale (packages/core/src/modules/devices/data/entities.ts:56) as precedent — the platform already stores and honours a recipient-side locale, just per push device. That's a stronger argument than the Tournee/Covo anecdote, and it's the origin of the Phase 2 coupling you correctly identified.
  • Per AGENTS.md, the spec must list integration coverage for every affected API path and key UI path — add TC ids for /api/user_preferences/me (or whatever storage wins) and the profile UI path.
  • 422 on an unsupported locale is inconsistent with the existing POST /api/auth/locale, which returns 400. Pick one.
  • Don't invest further in feat/recipient-locale-auth-emails until 3 and 5 are settled — the storage and seam decisions rewrite most of it.
  • Keep this PR design-only, as you already planned; implementation lands on its own PR.

Tournee isn't blocked on this: we're doing a temporary app-level patch there in the meantime, so take the time to get the platform contract right rather than the fastest path to unblocking one app.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants