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 preferenceusers.preferred_locale, only if it is in the tenant's enabled list.
  3. Tenant defaulttenants.default_locale.
  4. Accept-Language — only consulted when there is no tenant context (unauthenticated requests).
  5. Platform defaulten-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 (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>_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.