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.
Everything user-facing goes through the catalog, not just the UI:
namespace.area.key (e.g.
documents.editor.save,
sched.error.quota_exceeded). Plugins namespace under their
id (plugin:<id>.…).en catalog.
English is authored inline as the default value and extracted
into the base catalog; every other locale must cover the base keys.source_hash of its English text; a translation records the
hash it was made against. When English changes, the hash diverges and
the translation is flagged stale (needs re-review) —
not silently wrong.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.
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:
ja, a
browser asking for fr gets Japanese. The old code hardcoded
"en" at the end of the resolution chain, so there was no
way to run a Japanese instance for Japanese-speaking visitors.available is a fact, not a field. It
is read off the catalogs on disk, so the instance can never advertise a
language it cannot render — and default is validated
against it, so {"default":"de"} on a build with no German
catalog is a 400, not an instance quietly serving English
while claiming German.| 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 |
Two independent checks, both surfaced by the CI tool:
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.)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.A team manages completion per locale. Per-string status lifecycle:
missing → drafted (human) | machine (AI) → needs_review → approved
└────── stale (source changed) ──────┘
localization:read | translate | review | manage; per-locale
scoping via resource_grants (a reviewer approved only for
ja). A built-in localizer role. Only
review+approve can move a string to
approved; drafters can't approve their own. All actions
audited.features activation and is
team-scoped like every other feature.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:
draft --locale ja enqueues one scheduler job
per batch of missing strings (workload queue, §ai-queue), so a
5,000-string draft doesn't DDOS the local model.ai.tokens)
attributed to the team (§quotas).description as context for consistent terms.status = machine, routed to
needs_review."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.
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 is in-scope for "localized top to bottom":
en) and localized variants, tracked with the same
source-hash staleness so a changed English page flags its
translations.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.
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.
en. Route all surfaces
through t(...); extract builds the base
catalog; CI turns on bare-literal failure. Platform ships 100%
en.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.ja ≥ 90%), or
advisory-only in v1?utility vs a
dedicated translation role)?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.