Status: Active Derives from: ADR 0008 — Localization Schema. Public-read additions derive from ADR-0052. Frontend UI additions derive from ADR-0027.
i18n is a platform-level concern. It affects translatable content, slugs, URLs, SEO, dates, numbers, currencies, formats, and the Admin Studio UI itself. See docs/architecture/12-localization.md for the strategy.
Localization covers:
- Public content (pages, blocks, course metadata).
- Slugs.
- SEO metadata (title, description, OG tags).
- Notification templates.
- Admin Studio UI.
- Learner portal UI.
- Validation error messages.
- Date, time, number, currency formatting.
- A tenant has a default locale.
- A tenant has a set of enabled locales.
- Translatable fields use Pattern A or Pattern B storage below. There is no
isLocalizedkeyword in the content-type schema profile. Education's inline lesson body is a JSON object per translation locale, including repeated non-translatable values; the exact content-type revision pin belongs to its lesson root (Education data model). - Slug is unique per
(tenant_id, locale), enforced on the translation table and flat across organizations — see § Pattern A. - Display fallback follows the single owner, Localization § Fallback Rules, under ADR-0008 and P02d-3's Accepted G24 answer. It never authorizes URL/body fallback or enabled-locale membership. Resolved values carry their actual locale.
P02d-4 response contract delivered — 2026-10-04. Pattern A content
uses exact enabled query locale; display-only Pattern B values carry authored
{value,locale}. Neither fallback nor headers authorize a content locale. Internal
fallback remains the delivered P02d-3 contract; language attributes are P02d-6.
P02d-6 Step 1 UI foundation implemented — 2026-10-10. ADR-0027 supplies next-intl and one app-local catalogue home, with UI-language fallback separate from content admission. The delivery record owns foundation validation. Step 2 adds catalog/course views with actual resolved label languages and exact-locale metadata; Step 3 adds ordered lesson fields and lesson metadata with eligible translated course/lesson slugs. Phase 04 inherits this foundation for CMS/Studio coverage.
Public URLs:
/{locale}/{slug} # primary
/{locale}/{section}/{slug} # nested
Rules:
- Locale is always in the path on public URLs.
- The default locale does not get a special slot (no
/default/...); requests without a locale prefix redirect to/{tenant-default-locale}/.... - Custom domains resolve tenant first; locale resolves from the path.
Accepted P02d-5 entry policy — 2026-10-08.
The entry matrix owns
Education path admission: bootstrap first, then bounded LocaleTag canonicalization
and enabled membership before any shorthand/scaffold rule. Enabled noncanonical
prefixes get 308; default/locale-root entry gets 307. Unsupported prefixes get
masked 404; no cookie, Accept-Language or query override supplies a locale.
Grammar-valid names such as courses are not reserved; membership has precedence.
Redirect origins use the verified visitor host and configured scheme/port. P5
implements this policy; P6 owns pages and localized section names remain Phase 06.
Per ADR-0008 two storage patterns coexist; the choice is per-entity, not project-global. Both are encapsulated behind the application contract — consumers see resolved values, not the on-disk shape.
Used for Course, Lesson, Page, ContentEntry, and anything with multiple
translatable fields, per-locale slugs, or SEO metadata. The parent table holds
non-translatable columns; a <entity>_translations table holds translatable fields
keyed by PRIMARY KEY (<entity>_id, locale).
Four rules. A migration reviewer checks all four.
- The parent holds no translatable column. No
title, nodescription, noslug. A parent may hold aslug_key— a stable, locale-independent authoring handle — but nothing routes on it. - The translation table carries
tenant_idas a real column and declares its ownENABLE+FORCE ROW LEVEL SECURITYand full policy set from the canonical template in Database Standards. Row Level Security is per table; it is not inherited from a parent through a check constraint, and a satellite carryingtitleandslugcarries the content. It also carries a mirroredorganization_idwhen the parent is[OrganizationScoped], for the isolation predicate only, and its foreign key to the parent is composite ontenant_id. The factory derives this scope; the independent INSERT/UPDATE parent-scope trigger verifies it, alongside the organization immutability guard. Neither RLS nor the tenant-only composite foreign key can prove a correct organization mirror alone. - Slug uniqueness is
UNIQUE (tenant_id, locale, slug)on the translation table.UNIQUE (<entity>_id, locale, slug)is forbidden: its columns are a proper superset of the primary key, so it can reject no row the table would otherwise accept, and two courses in one tenant end up sharing/en/courses/beginner. organization_iddoes not belong in a slug unique key, even when the entity is organization-scoped. Two reasons, and the second survives fixing the first. In a standardUNIQUEconstraint PostgreSQL treats nulls as distinct, soUNIQUE (tenant_id, organization_id, locale, slug)places no constraint at all on tenant-wide rows — the rows a tenant authors first. And repairing that withNULLS NOT DISTINCTstill leaves an organization-scoped row and a tenant-wide row free to claim one slug, while a host resolving to(tenant_id, organization_id)serves both tiers and would have to pick a winner at render time. One flat namespace per(tenant_id, locale)is the rule; an organization that wants its own variant of a shared course gives it its own slug.
CREATE TABLE course_translations (
course_id uuid NOT NULL,
tenant_id uuid NOT NULL,
organization_id uuid NULL, -- mirrors the parent; for RLS, never for uniqueness
locale varchar(35) NOT NULL,
title text NOT NULL,
summary text NULL,
slug varchar(160) NOT NULL,
PRIMARY KEY (course_id, locale),
CONSTRAINT ux_course_translations_tenant_id_locale_slug
UNIQUE (tenant_id, locale, slug),
CONSTRAINT fk_course_translations_course
FOREIGN KEY (tenant_id, course_id) REFERENCES courses (tenant_id, id)
ON DELETE CASCADE
);This sketch shows where the translatable columns and the slug key live. The table's complete DDL — its foreign-key index, policy set and triggers — is Database Standards § Translation satellite tables, the canonical artefact.
The accepted P02d-1 Education satellites have no independent id, audit timestamps,
concurrency token or deleted_at. They belong to their own aggregate root's lifecycle
and audit capture. A draft translation reserves its slug on insertion; the flat key
keeps that reservation when the parent is soft-deleted. Parent eligibility excludes
the content from public reads. Phase 05 decides any future release together with
Phase 04's redirect/slug registry.
Education's routable slugs and non-routable course slug_key use 1–160 characters:
lowercase ASCII letters and digits, separated by single interior hyphens. UUIDs in
32-hex (N) or hyphenated (D) form are refused. Invalid case, whitespace or native
script is rejected with no implicit lowercasing, trimming or transliteration; the
content itself remains Unicode. The same predicate is an application check and a
named database check in the canonical DDL. The Education width is separate from
Tenancy's 63-character hostname slug limit. This accepted storage grammar does not
choose P02d-4's public route template or parameter handling.
Slug lookup is exact on (tenant_id, locale, slug). The fallback chain resolves
display fields after the entity is found; it never resolves a slug. An entity with no
translation in the requested locale has no URL in that locale, and a link to it is
omitted rather than rendered dead.
A slug collision is refused when the translation is inserted; its writing command
returns Result.Fail(business_rule_violation, …). The accepted 2026-10-02
P02d-2 G11 answer
selects AddCourseTranslationCommand and AddLessonTranslationCommand for Education,
mapping their named localized-slug uniqueness constraints at insertion. Publication
does not reserve or newly collide a slug. P02d-2 Step 3 implements the mapping and
filtered collision-identity disclosure;
Phase 04's criterion
requires verification of these insertion-time refusals.
The refusal names the conflicting entity when the caller may read it — tenant-wide rows
and the caller's own organization's rows both qualify under the canonical policy — and
otherwise names only the slug and the locale, because naming a row in another
organization would leak across the boundary Row Level Security exists to hold.
Used for Level.display_name, Tag.label, Category.name, and similar short
atomic strings where joining a translation table would be overkill.
{
"title": {
"tr": "İngilizce Kursları",
"en": "English Courses"
}
}| Field shape | Pattern |
|---|---|
| Long text, multiple fields per entity, SEO metadata | A (side table) |
| Short atomic string, few fields | B (JSONB) |
| Rich content with version history | A (side table; the entity's lifecycle owns publication and versioning) |
| Taxonomy display names | B |
The full table + worked examples live in 12-localization.md § Storage Schema. In both patterns the application contract returns a resolved string for the requested locale, applying the fallback chain.
<html lang="{locale}">set per page.hreflangannotations for every translated public page.- Canonical URL is the requested locale.
- Open Graph locale properties use
language_TERRITORYonly for admitted tags with an explicit two-letter territory and no script, variant or extension to discard. Omit unrepresentable values; never guess a territory. HTML language, URL and hreflang retain BCP-47. The approved G40 addendum records this bounded projection and the optional-property tradeoff.
- Dates / times:
Intl.DateTimeFormaton the frontend,IFormatProvideron the backend. - Numbers:
Intl.NumberFormat/CultureInfo. - Currency: never store amounts as strings; store integer minor units + ISO currency code; format at presentation.
- Pluralization: use ICU MessageFormat (
{count, plural, one {# lesson} other {# lessons}}).
- Frontend: server-first
next-intl4.14.9, with app-localapps/web/src/i18n/messages/{en,tr}/public.jsonandsrc/i18n/request.ts. P02d-6 Step 1 implements the dependency/plugin, complete catalogues and key/ICU/ argument/callsite controls. No shared i18n package is created without ADR-0009's duplication trigger. - Backend: localized strings live in resource files under each module.
- Strings are referenced by key, never duplicated:
const t = await getTranslations("public");
return <p>{t("catalog.course_count", {count})}</p>;var msg = _stringLocalizer["lockey_course_publish_success"];General UI identifiers use lowercase dotted feature namespaces and snake_case
segments (public.catalog.course_count); JSON nesting supplies the dots. Backend
LocalizedMessage.Key and Problem Details messageKey retain lockey_*, including
consumer-owned backend error resources. The SDK normalizes errors and owns no
translated resources. P6 maps closed page outcomes to explicitly owned UI keys;
arbitrary wire keys/titles/errors/parameters never become lookup identifiers or copy.
Request configuration shares the server-only request-cached admission loader used
by document/layout/page consumers. It re-verifies the ingress envelope and live
enabled membership using the signed target's canonical content locale. No i18n
routing middleware, middleware-derived requestLocale, callsite override, cookie
or Accept-Language is authority, and message loading adds no bootstrap call.
Select a whole UI catalogue by exact tag, then remove rightmost subtags, then use
platform en. Missing required keys in supported bundled catalogues fail the
build; no per-key fallback or raw-key output. ICU formatting uses the selected UI
catalogue's locale. Plain text parameters only; no HTML/rich callbacks or authored
URL attributes. Formatter failure yields a bounded translated unavailable state.
Nonempty/equal key sets, ICU argument names/types and callsite coverage require
planted failure controls, per ADR-0027 and the canonical
test catalogue.
The exact admitted content locale still selects API reads and document lang.
UI groups carry their selected catalogue's actual lang/direction when different;
Pattern B labels carry the API-resolved locale. Neither UI fallback nor label
fallback substitutes Pattern A titles, summaries or bodies. Studio/portal scaffolds
retain platform English until Phase 06 supplies their UI.
- IETF BCP 47 identity uses the shipped
LocaleTagvalidator and canonicalizer: lowercase language, Title-cased script and uppercase region (tr,en-GB,tr-TR,zh-Hans). - Education translation keys store the full canonical code in
varchar(35), never a truncated or fully lowercased form. This is G6 (a)'s accepted storage decision. - Tenant locale membership is validated through a Tenancy application contract when the P02d-2 writer lands, not through a cross-chain Education foreign key.
P02d-4 request contract delivered — 2026-10-04. Education requires
one query locale. Validate LocaleTag's 35-character grammar without trimming,
canonicalize case, then use enabled tenant membership, exact lookup and cursor
binding. Missing/repeated/malformed/overlength values return 400 validation_failed
for locale; a well-formed absent/disabled locale returns 400 unsupported_locale.
Enabled but untranslated means empty catalog or masked detail 404, not fallback.
X-Locale and Accept-Language do not select content. No locale rows authorize
none; bootstrap returns 404. Invalid stored locale/default configuration is
503 dependency_unavailable, never a synthesized default. URL-segment redirects
are delivered by P02d-5 Step 3 under G6(c). ADR-0052 and the
accepted locale contract
record the authority; no platform locale registry is introduced.
G13 reader implemented in P02d-2 Step 1 — 2026-10-02. The
Tenancy contract
selects no platform registry: use LocaleTag's existing grammar, canonicalization and
35-character bound, then the tenant's enabled membership. No locale rows authorize
no content locale, rather than an implicit en. P02d-4 accepts request G6(b) and
G24's public fields; P02d-3 implements the internal display fallback.
P02d-2 Step 2 implements locale command admission and the
default-enabled CHECK; request-language negotiation is not part of those writers.
- The platform supports RTL languages from the start.
- Layout uses logical CSS properties (
padding-inline-start, notpadding-left). - Components flip via
dir="rtl"on the document root.
Accepted P02d-6 derives document direction from the admitted content locale using
runtime internationalization data; an undescribed admitted tag falls back to
ltr, never refusal. UI fallback groups retain their own direction. Test-owned
enabled ar content proves lang="ar"/dir="rtl" and labelled English fallback
UI independently; it neither rewrites the seed nor claims an Arabic UI catalogue.
Step 1 implements direction selection and labelled fallback chrome; Step 2
proves an empty admitted Arabic catalog in production. Step 3 adds per-field
label direction. Step 4 adds eligible authored RTL content with concurrent
host/locale HTML/Flight proofs. Passing manual accessibility is recorded in
the delivery record.
Admin Studio separates:
- Platform UI language — what the editor sees (Turkish or English).
- Tenant content language — what the editor edits.
- Learner-facing course language — the locale the learner experiences.
These three are independent. An editor may use Admin Studio in English while editing Turkish public pages and English course content.
- Templates are per-locale.
- A tenant can override the platform default template per locale.
- The dispatch system picks the recipient's locale, with fallback to tenant default.
Localized content requires tests for:
- Requested locale render.
- Fallback locale render.
- Missing-translation render.
- Locale-specific slug uniqueness, including the tenant-wide-versus-organization-scoped collision and the same-slug-different-locale case.
- Notification template selection per recipient locale.
- Hardcoded user-facing strings in code (every visible string goes through the i18n layer).
- Concatenating sentences across translation keys (use ICU placeholders).
- Storing localized text in a non-localized field then "interpreting" it.
- Using locale-derived
ifs (if (locale === "tr")); branch on capabilities, not on locale identity. - Truncating BCP 47 codes (
en-GB≠en). - Putting a translatable field —
title,description,slug— on the parent table when the entity uses Pattern A. - Putting
organization_idinside a slug unique key (§ Pattern A rule 4), or declaring a slug constraint whose columns are a superset of the translation table's primary key. - Falling back to another locale to resolve a slug. Fallback applies to display fields after the entity is found, never to the lookup.