Skip to main content

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:

  1. UI strings — labels, errors, navigation, dates, numbers. Same source content for every tenant. Translated once per locale.
  2. 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-intl per app, JSON catalogs co-located in apps/<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.

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:

AttributeValueWhy
Domain.planetb2b.com (prod)Shared across the three zones. Omitted in local dev.
SameSiteLaxFirst cross-zone navigation under the apex domain still carries the cookie.
SecureyesHTTPS-only.
HttpOnlyyes (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-Age1 yearLong enough to persist across sessions; the switcher overwrites it.

Resolution cascade​

For every request, the platform resolves the locale in this order:

  1. Cookie — only if the value is in SUPPORTED_LOCALES and in the active tenant's enabled_locales. A cookie that fails either check is rejected and overwritten on the response.
  2. User preference — users.preferred_locale, only if it is in the tenant's enabled list.
  3. Tenant default — tenants.default_locale.
  4. Accept-Language — only consulted when there is no tenant context (unauthenticated requests).
  5. 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 contain default_locale (DB CHECK).
  • time_zone — IANA tz id used by Intl formatters 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 NULL
    • identity.organisations.display_name_i18n JSONB NULL
    • PT and Catalog will add similar overlays in Phase 2 (initiatives.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>_translations table 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 TranslationProvider interface 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:

  1. Append the BCP-47 tag to SUPPORTED_LOCALES in packages/platform/i18n/src/supported-locales.ts.
  2. Ship a <tag>.json message catalog in each app under src/messages/.
  3. Verify the AI provider supports the language for the translation flows.

Phase 0 deliverables (shipped)​

PRTicketScope
#424PLT-81@constellation-platform/i18n + @constellation-platform/translation packages
#426PLT-82Directory schema migration (tenant locale/tz/currency, organisation overlay, user preferences)
#434PLT-83auth-next/locale primitives — cookie helpers, resolveRequestLocale, withVaryCookie, AuthContext.locale
#439PLT-84 + PLT-85Directory middleware wiring + tenant settings UI + shared LocaleSwitcher

Phase 1 (Q2 2026)​

UI string extraction per app, app by app. Each app:

  • Adds next-intl with localePrefix: 'never' (cookie-based; no path segment).
  • Loads messages via src/i18n/request.ts reading x-constellation-locale from the forwarded headers.
  • Replaces inline JSX literals with getTranslations('namespace') keys.
  • Ships en-US.json as 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.