← Telemachus

Design — localization

Localization (i18n) & the Localization Manager

Purpose. Make Telemachus localizable top to bottom, ship English out of the gate, and build a Localization Manager — a feature of the platform that finds unlocalized strings, lets a team complete translations, and gates commits in CI. Additional languages (Japanese, then Dutch, then Latin American Spanish) are produced by using the tool. That is the point: the system builds the tool, and the tool builds the locales — proof the platform can build real things.

This is both infrastructure (everything is localizable) and a flagship application (the manager) that dogfoods RBAC, the scheduler, quotas, AI, and the SDK.

Two layers

  1. i18n runtime — every user-facing string resolves through a message catalog keyed by a message id, never a hardcoded literal. Locale is resolved per request; missing translations fall back down a chain to English.
  2. Localization Manager — the tool built on the platform: extract strings → detect unlocalized literals + missing/stale translations → a team workflow to draft (optionally AI-assisted), review, and approve per locale → a CI gate that flags violations on commit.

Localization scope (top to bottom)

Everything user-facing goes through the catalog, not just the UI:

Message model

Locale resolution & roadmap

Resolution order: request/user preference → instance default (LOC‑7). The team tier is not built and may never be — an instance-wide default plus a per-request header covered every case we actually had. Fallback chain ends at English: e.g. es-419 → es → en, ja → en. English is always complete, so a missing string degrades gracefully, never blanks.

The instance policy (LOC‑7, built)

domain/i18n/policy.rkt, one document in instance_settings, instance:manage to write, and readable without a token on GET /api/config — the sign-in screen has to know which language to render in before anyone has an account.

Setting Default Meaning
default en the locale a request gets when it names none, names one this build has no catalog for, or when negotiation is off
enabled true false pins every request to default and tells the console to drop its language switcher
available derived not a setting — whichever <locale>.json files the build ships

Three properties worth stating, because each replaced a wrong behaviour:

Order Locale Code Notes
1 (baseline) English en Authored inline, 100% by definition — the release gate
2 Japanese ja CJK; ICU plural = other; first tool-produced locale
3 Dutch nl LTR
4 Latin American Spanish es-419 region code; falls back es-419 → es → en

Detecting unlocalized strings

Two independent checks, both surfaced by the CI tool:

  1. Bare-literal scan (static). Convention: every user-facing string is wrapped by the localizer call (t 'id … in code, a catalog ref in templates). A per-language extractor flags user-facing string literals in surface code (response builders, templates, tool descriptions, notification text) that are not wrapped. Extractors are pluggable by file type (Racket, JS, …) since frontends/backends are swappable — the same idea as xgettext / eslint-plugin-i18next. (Decision 2: which surfaces are in-scope for v1.)
  2. Catalog diff. Compare each target catalog to the en base: missing keys (in en, absent in target), stale keys (source_hash diverged), unused keys (in target, gone from en). Coverage = approved / base-count.

Team workflow (the management interface)

A team manages completion per locale. Per-string status lifecycle:

missing → drafted (human) | machine (AI) → needs_review → approved
                                    └────── stale (source changed) ──────┘

AI-assisted drafting (dogfoods the platform)

The manager can draft missing strings with the platform's own models — but never auto-approves; humans review. This is where the platform builds the locales:

"The tool doesn't have to do all the translation" — AI drafts, humans complete and approve. English → Japanese/Dutch/es-419 is then an exercise for the tool.

CI gate

A telemachus-localize CLI (fits the cli/ pattern) usable in CI and a git pre-commit hook:

telemachus-localize extract         # scan surfaces + docs → update the en base catalog
telemachus-localize check [--staged --required en --warn ja,nl,es-419 --min 0.9]
telemachus-localize report          # coverage per locale × namespace
telemachus-localize draft --locale ja [--namespace documents]   # queue AI drafts

Default policy (configurable): fail on bare user-facing literals; fail on missing en base keys; warn / threshold on target-locale coverage (release builds can require e.g. ja ≥ 90%). Wired as a pre-commit hook (--staged) and a CI step. This is the "flag unlocalized strings on commits" requirement.

Documentation

Documentation is in-scope for "localized top to bottom":

Data shapes (backend-neutral)

locales(code pk, name, native_name, fallback_code, status, rtl bool)   -- en, ja, nl, es-419

messages(id pk, namespace, key, source_text, description,              -- the en base catalog (extracted)
         source_hash, surface, first_seen_at, deprecated bool)         -- surface: ui|api|email|tool|doc|plugin

translations(id pk, message_id fk, locale_code fk, text, status,       -- status: missing|drafted|machine|needs_review|approved|stale
             translated_by, reviewed_by, source_hash_at, updated_at,
             uniq(message_id, locale_code))

glossary(id pk, term, locale_code, translation, notes, uniq(term, locale_code))

Coverage is derived (approved / base-count). Bulk AI drafts reuse the jobs table (§ai-queue, type=tool); usage meters into the ledger (§quotas); permissions live in the RBAC catalog. No new infra — this feature is composed from the platform, which is the proof.

Contract (any backend implements)

Localizer:                       # the i18n runtime
  t(id, args?, locale?) -> string          # resolve + ICU-format, with fallback chain
  has(id, locale) -> bool
  locale_for(principal, request) -> code

LocalizationService:             # the manager
  extract(paths) -> {added, changed, removed}
  coverage(locale) -> {approved, total, pct, by_namespace}
  list(locale, status) -> [message…]
  submit(message_id, locale, text, by) -> translation
  review(translation_id, decision, by)
  draft(locale, ids|namespace) -> job_handle            # queues scheduler jobs
  ci_check(paths, policy) -> {violations, coverage, exit_code}

refimpl/racketmaximus implements the Localizer (catalog load + ICU format + fallback) and the telemachus-localize CLI; the manager UI/API compose RBAC + scheduler + quotas.

Bootstrapping plan (the proof)

  1. Externalize to en. Route all surfaces through t(...); extract builds the base catalog; CI turns on bare-literal failure. Platform ships 100% en.
  2. Build the manager on the platform (RBAC-gated workflow + scheduler drafts + quota metering + coverage dashboard + CLI/CI gate).
  3. Produce ja with the tool (AI draft → team review → approve), then nl, then es-419 — each a repeat of the same workflow, proving the tool scales to new languages without new engineering.

Decisions to confirm

  1. Catalog format — ICU-MessageFormat-in-JSON (default), Mozilla Fluent, or gettext PO? (ICU balances power + ubiquity; Fluent is strongest for complex localization; PO has the deepest tooling.)
  2. v1 surfaces — which surfaces enforce bare-literal failure first (UI + API + tool descriptions), with emails/docs/plugins phased in?
  3. Message ids — named namespaced keys (default) vs source-hash keys.
  4. Release gate — is a target-locale coverage threshold release-blocking (e.g. ja ≥ 90%), or advisory-only in v1?
  5. AI drafting default — on (opt-out) or off (opt-in) per locale? Which model role drives it (utility vs a dedicated translation role)?
  6. Documentation generation — spin its pipeline into a separate design doc now, or fold doc strings into this one for v1?
  7. LOC‑7 — who picks the default locale?Decided + built: the instance operator, via Admin › Localization (instance:manage), with an explicit switch to turn per-request negotiation off entirely. Not a per-team setting: the sign-in screen belongs to no team, and it is the one page whose language a visitor cannot configure their way out of.