Documentation / Exploitation / Explications / Instance language vs UI language

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 responsesGET /api/catalog?locale=<l> and GET /api/plugins?locale=<l> localize node metadata and plugin manifests on read, by copying and overlaying a bundle onto the source strings (localizeCatalog, localizePluginManifest in apps/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-locale cookie server-side (apps/config-plane/i18n/request.ts, apps/ehr-lab/src/i18n/request.ts) and serves messages/{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

75 documents7 sectionssource : /docs · généré au build