Multilanguage (i18n) specification
Status: Phase 0 foundation shipped (PLT-81 → PLT-85) Last updated: 2026-04-26 Driving ticket: PLT-80
This page summarises how Constellation handles localised UI and tenant content. The full design lives in the repo at .ai/specs/SPEC-plt-i18n-multilanguage.md; this page is the operator-facing reference.
Why two layers
Localisation has two unrelated workloads:
- UI strings — labels, errors, navigation, dates, numbers. Same source content for every tenant. Translated once per locale.
- Tenant content — product names, project names, custom field labels. Different per tenant. Authored or AI-translated by tenant admins.
Constellation handles them separately because conflating them produces a poor build story (every tenant rebuild) or a poor authoring story (no diff/review for static strings). The platform standard is:
- Layer 1 — UI strings:
next-intlper app, JSON catalogs co-located inapps/<module>/src/messages/<locale>.json. - Layer 2 — Tenant content: per-module choice of JSONB overlay columns (sparse, low-volume fields like a tenant name) or sidecar translation tables (high-volume content with workflow needs — Catalog).
Locale tags
All locale tags are full BCP-47 with region: en-US, en-GB, it-IT, de-DE, fr-FR. Base-language tags (en, it) are deliberately not in the supported set because Intl.DateTimeFormat and NumberFormat produce inconsistent output across runtimes when the formatter has to pick a default region. Cookie values, DB columns, and message-catalog filenames all use these exact tags.
The canonical list and isSupportedLocale() guard live in @constellation-platform/i18n.
Locale source — cookie
A single constellation-locale cookie scoped to the apex domain (.planetb2b.com) carries the user's preference across all three Vercel zones (Directory at the apex, Project Tracker at /projects/*, Catalog at /catalog/*). Path-prefix locales (/en/projects/...) were rejected because they would collide with the multi-zone basePath rewrites and require coordinated changes in every <Link>, every apiUrl() call, and every external link.
Cookie attributes:
| Attribute | Value | Why |
|---|---|---|
Domain | .planetb2b.com (prod) | Shared across the three zones. Omitted in local dev. |
SameSite | Lax | First cross-zone navigation under the apex domain still carries the cookie. |
Secure | yes | HTTPS-only. |
HttpOnly | yes (default) | Defence in depth. The switcher receives its initial locale via server-component props, so client-side cookie access is not needed. Apps with a genuine need can opt out with httpOnly: false. |
Max-Age | 1 year | Long enough to persist across sessions; the switcher overwrites it. |
Resolution cascade
For every request, the platform resolves the locale in this order:
- Cookie — only if the value is in
SUPPORTED_LOCALESand in the active tenant'senabled_locales. A cookie that fails either check is rejected and overwritten on the response. - User preference —
users.preferred_locale, only if it is in the tenant's enabled list. - Tenant default —
tenants.default_locale. Accept-Language— only consulted when there is no tenant context (unauthenticated requests).- Platform default —
en-US.
The resolver lives in resolveLocale() and is shipped as a pure function so apps can re-use it from server actions, edge middleware, or tests.
Where the resolution happens
Edge middleware is the canonical correction point because it runs before any route handler and can both forward request headers and write response cookies. The pattern:
incoming request
→ strip attacker-controllable x-constellation-locale header
→ run auth (existing flow)
→ look up tenant locale policy via app-supplied resolver
→ resolveRequestLocale → { locale, source, cookieRejected, setCookie? }
→ forward x-constellation-locale on the request
→ append Set-Cookie on the response when cookieRejected
→ return
Apps opt in by passing a localeResolver to createConstellationAuthMiddleware. Off → the field stays absent on AuthContext and route handlers fall back to reading the cookie directly via withAuth (validated against SUPPORTED_LOCALES only — no tenant policy check at the handler layer).
Tenant policy
Each tenant configures:
default_locale— the locale users see when no preference applies.enabled_locales— the set users may choose from; must containdefault_locale(DB CHECK).time_zone— IANA tz id used byIntlformatters when the user has no preference.currency— ISO 4217 alpha-3.
Defaults preserve current English-only behaviour: 'en-US', ['en-US'], 'UTC', 'USD'.
Admins edit these on the tenant detail page in Directory; the form lives at apps/directory/src/components/tenant-locale-settings.tsx.
Tenant content overlays
Two patterns ship in Phase 0:
-
JSONB overlay columns for sparse, low-volume fields. Schema looks like:
identity.tenants.display_name_i18n JSONB NULLidentity.organisations.display_name_i18n JSONB NULL- PT and Catalog will add similar overlays in Phase 2 (
programmes.name_i18n, etc.). - Read helper:
localized()— exact match → same-base sibling fallback → source-of-truth English.
-
Sidecar translation tables for high-volume content with workflow needs (Catalog only in Phase 2). Each parent entity gets a
<entity>_translationstable with(entity_id, locale)primary key, status (draft/published/needs_review), source (human/ai/imported), and tenant-id denormalised for RLS evaluation.
AI-backed translation
@constellation-platform/translation wraps @constellation-platform/ai-core so tenant budget, audit, and provider selection (mock for tests, Claude in prod) are inherited from the existing AI plumbing. The translation package adds:
- A glossary-aware prompt template.
- An output parser that strips
"Translation:"prefixes and quote pairs. - A
TranslationProviderinterface apps can mock independently.
Tenant attribution happens through withBudget / withAudit decorators applied at app boot — translation calls are not different from catalog enrichment in the budget ledger.
Caching
i18n routes set Vary: Cookie so a CDN cannot serve one user's locale-rendered HTML to another with a different cookie. The decorator withVaryCookie() is idempotent and preserves the Vary: * escape hatch.
API responses that return localised content set Content-Language and Vary: Cookie.
Pages whose render depends on the locale cookie should set export const dynamic = 'force-dynamic'. For most authenticated routes this is already true because of tenant context.
Adding a new locale
Three coordinated edits:
- Append the BCP-47 tag to
SUPPORTED_LOCALESinpackages/platform/i18n/src/supported-locales.ts. - Ship a
<tag>.jsonmessage catalog in each app undersrc/messages/. - Verify the AI provider supports the language for the translation flows.
Phase 0 deliverables (shipped)
| PR | Ticket | Scope |
|---|---|---|
| #424 | PLT-81 | @constellation-platform/i18n + @constellation-platform/translation packages |
| #426 | PLT-82 | Directory schema migration (tenant locale/tz/currency, organisation overlay, user preferences) |
| #434 | PLT-83 | auth-next/locale primitives — cookie helpers, resolveRequestLocale, withVaryCookie, AuthContext.locale |
| #439 | PLT-84 + PLT-85 | Directory middleware wiring + tenant settings UI + shared LocaleSwitcher |
Phase 1 (Q2 2026)
UI string extraction per app, app by app. Each app:
- Adds
next-intlwithlocalePrefix: 'never'(cookie-based; no path segment). - Loads messages via
src/i18n/request.tsreadingx-constellation-localefrom the forwarded headers. - Replaces inline JSX literals with
getTranslations('namespace')keys. - Ships
en-US.jsonas the source-of-truth catalog.
Tracked as separate per-app tasks under PLT-80.
Phase 2 (Q3 2026)
Tenant content layer. Catalog adds the sidecar translation tables and a Translate to {target} action that calls the translation provider, writes a needs_review row, and surfaces it in a review queue. Directory + PT add JSONB overlays on the small set of user-visible name fields.
Phase 3 — first non-English locale
Italian (it-IT) is the planned first add for Stella Catalog's Netcomm Forum 2026 positioning. Translation happens via the AI provider with human review on the catalog side.