Instance language vs UI language
Meridian has two things that look like "language settings" and are easy to conflate: the language an instance writes into the patient record, and the language a given user reads the console or the EHR view in. They are independent axes, resolved by different code paths, and changing one never touches the other. This page explains why they're split, and what that split costs and buys.
Two audiences, two decisions
The instance locale is a single value in the instance manifest
(locale:, default fr — see
libs/shared/src/plugin/instance.ts).
It is a deployment-time decision made once, by whoever stands up the
instance: the language in which behaviors write content that ends up in the
patient record — human-task titles, notification bodies, anything a workflow
persists. A French hospital deploys with locale: fr; every validation and
notification that workflow ever produces is phrased in French, regardless of
who is looking at it later.
The UI locale is a per-browser preference — the meridian-locale cookie
(fr | en, default fr, see
apps/config-plane/lib/locale.ts),
flipped by the FR/EN toggle in the control-plane rail and in the EHR
(apps/ehr-lab/src/components/LocaleToggle.tsx). It governs how the
application chrome reads: the node catalogue, the plugin reference pages,
this documentation viewer, labels and menus. It says nothing about what
language a workflow writes.
Nothing stops an English-speaking clinician from browsing a French instance's
catalogue in English while the validations that instance produces stay in
French — that's the point of keeping the two separate. For the mechanics of
setting either one, see
Set the instance locale and the
locale field in the instance manifest reference.
Why persisted content doesn't get retranslated
The instance locale exists because clinical content is generated text, not a small catalogue of reviewed strings. A notification body assembled by a behavior for a specific patient and a specific result isn't a lookup into a translation table — it's produced once, at the moment the behavior runs, and then it's just a string in the record.
That rules out the two obvious alternatives:
- Store every persisted string in every supported locale. This would mean either running the behavior's content-generation logic twice per language (double the work, and a second source of truth that can drift), or storing one canonical version and machine-translating the rest — which is not something this platform is willing to do silently to clinical wording that a human may act on.
- Machine-translate persisted content at render time, the way the UI catalogue is retranslated on every request. Catalogue strings are static, small, and reviewed once per plugin release, so re-serving them in another locale on demand is safe. A validation title written for an actual patient is neither static nor reviewed per rendering — regenerating an approximation of it for whoever happens to be looking is a correctness risk the platform doesn't take.
So the instance commits to one language and never revisits already-written
content. This is an operational convention, not a hard technical lock: the
manifest schema is just locale: z.string().default("fr"), nothing enforces
immutability. Nothing in the code stops you from redeploying an instance with
a different locale value. What changes is only new content going forward
— everything already persisted stays in whatever language it was written in,
and the platform makes no attempt to reconcile the two. That's why the
convention is to treat it as fixed once real content exists: for a
mono-lingual clinical staff this is invisible, but a hospital with genuinely
multi-lingual clinicians will still see every validation and notification in
whichever single language was chosen at deploy — there's no per-user view of
persisted clinical text.
How the instance locale actually reaches behaviors
The instance locale isn't passed to behaviors as a raw string to interpolate
by hand. makeCatalog accepts an i18n option of { locale, tFor }
(libs/engine-core/src/engine/catalog.ts), and the endpoint wires it at boot
with tFor: makeRuntimeTFor(plugins, instance.locale)
(apps/api/src/restate/services.ts). makeRuntimeTFor
(apps/api/src/i18n.ts) closes over the instance locale and, per node id,
looks up which plugin contributed that node and what that plugin's source
locale is; a behavior receives ctx.locale and a ctx.t(key, params) bound
to its own contributing plugin.
Resolution for a runtime key follows the same three-step fallback used
everywhere else in the platform's i18n: requested locale → the contributing
plugin's source locale → the key itself, visible rather than blocking
(resolveRuntimeKey in
libs/shared/src/plugin/i18n.ts).
The bundles it reads from (i18n/<locale>.yaml) travel with the plugin next
to its plugin.yaml — there's nothing to configure on the instance side, and
a plugin with no bundle for the instance's locale still works, just falls
back to its own source strings. Writing those bundles is covered in
Translate a plugin.
How the UI locale actually reaches the apps
The UI locale never reaches a behavior. It governs two separate things:
- The catalogue and plugin-reference API responses —
GET /api/catalog?locale=<l>andGET /api/plugins?locale=<l>localize node metadata and plugin manifests on read, by copying and overlaying a bundle onto the source strings (localizeCatalog,localizePluginManifestinapps/api/src/i18n.ts); omit the parameter and you get the plugin's source-language strings back. The registries themselves (OBJECT_TYPES,VALUE_SETS, node metadata) are never mutated — they stay in whatever language the plugin declared as source, and localization is always a copy applied at the edge. - The application chrome — next-intl without locale-prefixed routing;
each app reads the
meridian-localecookie server-side (apps/config-plane/i18n/request.ts,apps/ehr-lab/src/i18n/request.ts) and servesmessages/{fr,en}.json. Flipping the toggle triggers a full reload so SSR picks up the new cookie value; there's no client-side locale switch.
Both fallback chains have the same shape — requested → source → key — but
they answer to different registries and different "who is asking": the API
locale is per-request (whatever ?locale= the caller passes), the runtime
t() is fixed for the lifetime of the process (the instance's own locale).
The client-side mechanics of the toggle and the exact routes are not this
page's job — see Set the instance locale.
Traces don't pick a language either
The engine's own execution log takes the "resolve at the edge" idea one step
further: it doesn't emit human text in any fixed language. Each trace event
carries a stable, kebab-case { code, params } pair, and it's the UI that
turns a code into a sentence, in the viewer's locale, at display time
(apps/config-plane/lib/trace-messages.ts). The French text that older runs
persisted before this convention existed is kept only as a fallback for runs
recorded before codes were introduced — it's not the source of truth for new
traces. So a trace, unlike a notification or a validation title, is never
locked to the instance's language; only content a behavior actually writes
into the record commits to locale:.
What this buys, and what it costs
This split keeps three things decoupled that would otherwise fight each other: the registries a plugin fills in stay neutral (no locale baked into a node's metadata), the instance's own operational history is written once and never needs re-translating, and the two Next.js apps can be maintained in two UI languages centrally without touching a single plugin's content. Plugin authors write one i18n bundle that ships with their artifact — there's no central catalogue to keep in sync across plugins or across instances (the broader rationale for this shape is in Architecture — core & plugins §i18n).
The cost is the one described above: no per-user view of persisted clinical content, and an instance locale that is only a convention away from being permanent. Both are deliberate — the alternative was either double-writing content in every supported language or quietly machine-translating what a clinician reads, and neither was judged acceptable for this kind of text.
See also
- Set the instance locale — how to
configure
locale:for a deployment. - Instance manifest reference — full
schema, including
locale:. - Translate a plugin — how
to write the
i18n/<locale>.yamlbundles this page assumes. - Plugins concept — why registries start empty and stay neutral.
- Architecture — core & plugins — the full four-layer i18n resolution stack this page draws from.