i18n (translations)
i18n — translation and localization
github.com/redelay/go-modules/i18n gives a Redelay app three things: it resolves the
active locale for every request, it serves a public key→value message catalog, and it
exposes a Service that other modules use to translate strings with a locale fallback
chain.
go get github.com/redelay/go-modules/i18n
import (
_ "github.com/redelay/go-modules/i18n" // module + locale middleware + GET /i18n/messages
_ "github.com/redelay/go-modules/i18n/flowdsl" // FlowDSL nodes: i18n/translate, i18n/resolve-locale
)
The admin submodule at i18n/admin mounts translation CRUD under the admin prefix —
import it from cmd/admin-api only.
Default locale is Polish (pl) with an English (en) fallback, inherited from the
AsyncShop / go.shop.pl deployment that first used the module. Override per deployment.
Concepts
| Term | Meaning |
|---|---|
| Message | One translated string, uniquely keyed by (namespace, key, locale). |
| Namespace | Groups related keys — ui, checkout, emails. The empty namespace is the default bucket. |
| Locale chain | The ordered fallback list used for every lookup. |
| Catalog | The merged key→value map for one (namespace, locale), fallback chain applied. |
The locale chain
Every lookup walks a de-duplicated chain, most specific first:
requested → its base → fallback → default
pl-PL → pl → en → pl
The base is derived by splitting on - or _, so pl-PL falls back to pl before
reaching the configured fallback. This means a partially-translated regional variant
degrades to its language, then to English, rather than to raw keys.
TranslateLocale returns the key itself when nothing in the chain matches. A missing
translation renders as checkout.submit, never as an empty string — the failure is
visible in the UI instead of silently blanking it.
Request-locale resolution
The module registers middleware on every request. Resolution order:
?lang=query parameterlangcookieAccept-Languageheader — first tag only, quality markers stripped (pl-PL,pl;q=0.9→pl-PL)I18N_DEFAULT_LOCALE
The result lands in the request context. Other modules read it without importing any HTTP plumbing:
import "github.com/redelay/go-modules/i18n"
locale := i18n.LocaleFromContext(ctx) // "" if the middleware did not run
This is how catalog, content, and onepage modules project localized entity fields for the active request.
i18n itself accepts any locale string. A module that needs to constrain the set — the
AsyncShop channels module restricts to a channel's SupportedLocales — wraps this
middleware rather than modifying it.
Interpolation
Values may carry Go-template placeholders, filled from the args map:
greeting.hello = "Cześć {{.Name}}!"
svc.Translate(ctx, "ui", "greeting.hello", map[string]any{"Name": "Ada"})
// → "Cześć Ada!"
Templates use missingkey=zero. On any parse or execution error the raw string is
returned unchanged — a malformed template degrades to visible source text rather than
erroring the request. Strings without {{ skip the template engine entirely.
Configuration
| Key | Default | Purpose |
|---|---|---|
I18N_DEFAULT_LOCALE | pl | Locale when none resolves from the request |
I18N_FALLBACK_LOCALE | en | Fills gaps before falling back to the default |
I18N_COLLECTION | translations | MongoDB collection for messages |
I18N_AUTOREGISTER | true | Enables the public POST /i18n/report-keys write-once endpoint. Set false to lock the catalog in production. |
I18N_SOURCE_LOCALE | (fallback locale) | Language the frontend's inline default labels are written in — the locale new keys are stored under and the from language for bulk auto-translate. |
No database → module disabled. The factory logs i18n: no database — module disabled
and returns a Module with a nil Service. Handlers then answer 503 i18n not configured
rather than panicking, so an app can boot without Mongo.
HTTP surface
Public
| Method | Path | Purpose |
|---|---|---|
GET | /i18n/messages | Message catalog for a locale + namespace |
POST | /i18n/report-keys | Auto-register UI-label keys a frontend uses (text write-once; optional context note upserts) |
Query params: locale (defaults to the resolved request locale, then the configured
default) and ns (defaults to empty).
curl '/api/v1/i18n/messages?locale=pl&ns=ui'
{
"locale": "pl",
"namespace": "ui",
"messages": { "checkout.submit": "Zamów", "cart.empty": "Koszyk jest pusty" }
}
The response is the merged catalog — the chain is walked least-specific first so the requested locale overwrites fallback entries. A storefront fetches this once per locale and renders UI copy from it.
Admin (i18n/admin)
Mounted under /admin/i18n, gated by auth.RequireActiveUser +
auth.RequirePermission("admin:access").
| Method | Path | Purpose |
|---|---|---|
GET | /admin/i18n/messages | List messages, optional ns / locale filters |
PUT | /admin/i18n/messages | Create or update one message |
DELETE | /admin/i18n/messages | Delete one message (key + locale required) |
GET | /admin/i18n/locales | List locales (a project's supported languages) |
POST/PUT/DELETE | /admin/i18n/locales[/{id}] | Manage the locale registry |
GET | /admin/i18n/translations | List translation keys (each key with every locale) |
POST | /admin/i18n/translations/translate-missing | Bulk auto-translate keys into every active locale via the ai module |
GET/POST | /admin/i18n/translations/pending, …/pending/approve, …/pending/reject, …/review | Review gate queue — see the review gate |
The admin surface backs the Languages module in the @redelay/js-admin base
admin (Locales manager + Translations catalog with a per-key AI-translate editor).
Service API
Obtain it from the module via Translations(), or discover the module in Configure():
func (m *Module) Configure(registry *modules.Registry) error {
if mod := registry.Get("i18n"); mod != nil {
if i, ok := mod.(*i18n.Module); ok { m.i18n = i }
}
return nil
}
| Method | Purpose |
|---|---|
Translate(ctx, ns, key, args) | Translate using the context locale |
TranslateLocale(ctx, ns, locale, key, args) | Translate in an explicit locale |
Messages(ctx, ns, locale) | Merged catalog for a namespace + locale |
Upsert(ctx, ns, key, locale, value) | Write + invalidate the affected cache bucket |
Delete(ctx, ns, key, locale) | Delete + invalidate |
InvalidateAll() | Drop the whole cache |
Caching
The Service caches per (namespace, locale) bucket behind an RWMutex, loading on miss.
Writes through Upsert/Delete invalidate only the affected bucket.
The cache is per-process. A write on one instance does not invalidate peers. In a
multi-instance deployment, broadcast a change event and have each instance call
InvalidateAll() — that is what the method exists for.
Storage
One Mongo collection, one unique index:
i18n_ns_key_locale → (namespace, key, locale) unique
Created idempotently in Startup. Upsert is a Mongo upsert against that triple, setting
updated_at on write and created_at only on insert.
FlowDSL nodes (i18n/flowdsl)
| Node | Kind | Outputs | Purpose |
|---|---|---|---|
i18n/translate | transform | Translated, Error | Resolve a key to a localized value (chain + interpolation) |
i18n/resolve-locale | transform | Resolved | Return the active locale from context or input |
i18n/fill-translations | action | — | Fill missing / stale translations — see below |
Both nodes are configured by edge data, not by node settings — neither declares a
settings_schema. Supply their inputs with an edge transform:
| Node | Input fields | Output payload |
|---|---|---|
i18n/translate | key (required), ns, locale, args (map) | {value, locale, key} |
i18n/resolve-locale | locale (fallback only) | {locale} |
locale is optional on both — when absent, each falls back to
i18n.LocaleFromContext(ctx). i18n/translate emits on the Error port when key is
missing; a key that simply has no translation is not an error and returns the key
itself on Translated.
Localizing a notification subject before sending:
nodes:
- id: subject
nodeType: i18n/translate
- id: send
nodeType: redelay/notifications-send
edges:
- from: on_order_shipped
to: subject
transform:
ns: "emails"
key: "order.shipped.subject"
locale: "{{.payload.customer_locale}}"
args:
Order: "{{.payload.order_number}}"
- from: subject
to: send
when: "output.name == 'Translated'"
transform:
user_id: "{{.payload.customer_id}}"
title: "{{.value}}"
settings_schema, Studio renders them with an empty
config panel — all wiring happens on the edges. Worth knowing before you go looking for
node settings that aren't there.Frontend UI-label i18n (customer apps)
The catalog also drives a customer app's own UI chrome (buttons, headings,
placeholders, toasts) — not just backend-localized content. The pattern is
provider-agnostic and reusable across every Nuxt customer app on the framework;
the starter-kit skeleton ships it under app/plugins/i18n.ts +
app/composables/useT.ts + scripts/i18n-extract.mjs.
1. A $t(key, default) helper. A Nuxt plugin loads the catalog for the
active lang cookie from GET /i18n/messages?locale=<lang> server-side (so
labels render translated on first paint, then hydrate), and provides a global
$t:
<h1>{{ $t('home.hero.title', 'Track Your Fitness Journey') }}</h1>
$t returns the catalog value, else the inline English default, else the
key. The default is the source-of-truth text — no separate en.json file. Keep
interpolated variables in the template, outside $t:
{{ $t('dashboard.greeting', 'Welcome back') }}, {{ user.name }}
Switching language flips the lang cookie and reloads, so the plugin re-runs
with the new catalog. The Nitro /api/v1 proxy forwards the cookie as ?lang +
Accept-Language, so backend-localized content follows the same switch.
2. Auto-detection of new keys. In dev, any key the app renders that is not
in the catalog is batched and POSTed to POST /i18n/report-keys (write-once —
never overwrites a curated key), so new labels appear in the admin Translations
catalog as the app is used. The write path is gated by I18N_AUTOREGISTER and
is off in production; there, a build-time extractor seeds keys deterministically:
npm run i18n:seed # scan $t('key','default') sites → POST /i18n/report-keys
3. Auto-translation. Once keys exist (in English), an admin bulk-fills every active locale from one action:
curl -X POST /api/v1/admin/i18n/translations/translate-missing \
-H 'Authorization: Bearer <admin>' -d '{}'
# → {"keysProcessed":40,"localesFilled":960,...} (re-run until keysProcessed is 0)
It translates each key's source text into the locales it's missing via the ai
module (Ollama/OpenAI/…), and is idempotent — already-filled locales are skipped.
The admin Translations catalog also offers per-key AI translation inline.
The end-to-end loop: write $t('key','English') → key auto-registers → admin
translate-missing → every locale filled → users see translated UI. Adding a new
language is: create the locale in the admin, run translate-missing, done.
Grammar & context — don't translate bare words
Translating an isolated word loses grammatical context. "Days" alone is
nominative (Дни), but after a number Russian needs the genitive (5 дней).
Two complementary fixes:
Whole-phrase keys with placeholders (preferred). Put the variable inside
the string so the model translates the whole phrase and gets agreement right.
$t interpolates {token} params and the translate prompt preserves them:
<!-- ✗ splits the number from the word — "Days" is translated in isolation -->
{{ streak }} {{ $t('dashboard.days', 'Days') }}
<!-- ✓ one phrase — model sees the count, returns "{count} дней" -->
{{ $t('dashboard.streakDays', '{count} Days', { count: streak }) }}
ICU plural for full CLDR pluralization. For languages with several plural
forms (Russian 1 день / 2 дня / 5 дней, Polish, Arabic's six forms), write an
ICU MessageFormat plural in the source; $t selects the branch with the
browser-native Intl.PluralRules (full CLDR categories per locale — no
dependency), replacing # with the count:
{{ $t('dashboard.streakDays', '{count, plural, one {# Day} other {# Days}}', { count: streak }) }}
The AI translate prompt recognises the ICU plural and emits the target
language's own categories, e.g. Russian
{count, plural, one {# день} few {# дня} many {# дней} other {# дней}} — so
Intl.PluralRules('ru').select(21) → 'one' renders 21 день correctly. =N
exact cases are supported (=0 {No days}). A locale whose value is plain text
(no plural block) degrades to plain {token}/# substitution.
Per-key context hint. When a whole phrase isn't possible, each translation
key carries an optional context field (Translation.Context) injected into
the AI prompt. Set it in the admin editor — "appears as '{count} Days', a
count" — and hit the row's Re-translate button (re-runs
translate-missing for that key with overwrite:true). The ai/translate
contract takes the same context field, and the prompt instructs the model to
honour it for agreement, gender, and pluralisation.
How to declare translation context
Two places carry a context hint — one for admin data fields, one for UI-label
keys. Both feed the same AI prompt.
Admin localized data fields. Declare context per field in the module's
ResourceConfig form to disambiguate the AI Translate button on
@redelay/js-admin's LocalizedField:
// In a module's ResourceConfig form field (any module using LocalizedField):
{ key: 'name', type: 'custom', component: 'LocalizedFieldAuto',
props: {
label: 'muscle group name', // used as fallback context if `context` is unset
context: 'A human-body muscle group name (e.g. Back, Chest) — the body part, not a direction/verb.'
} }
The context prop threads LocalizedField → AiFieldActions → useAiContent.translate → POST /admin/ai/translate (as the request body's
context field), falling back to the field label when context is unset.
Without it the muscle group "Back" translates to Zurück / Atrás / Назад
(the direction); with it, Rücken / Espalda / Спина (the body part).
Per-key catalog context (UI-label keys). For $t keys, set the
Translation context (AI hint) field on a key in the admin Translations
editor (or POST /admin/i18n/translations with a context field), then hit the
row's Re-translate button. The hint persists on Translation.Context and is
injected into the prompt by both translate-missing and per-row re-translate.
Frontend-owned context (recommended for $t keys). Rather than curating the
hint per key in the admin, a customer app can own it in source. Add
i18n-contexts.json next to the extractor — a map of a key (or a trailing-*
prefix glob) to a free-form note; the most specific match wins:
// nuxt/i18n-contexts.json
{
"tracking.set*": "Gym term: a SET is one group of reps. Not a kit/collection, not the verb 'to set'. Sample — EN: \"Set {n}\"; RU: \"Сет {n}\".",
"dashboard.metric_volume": "Training VOLUME = total weight lifted across all sets (tonnage). Not sound/geometric volume. Sample — EN: \"Volume\"; RU: \"Общий вес\"."
}
Include sample EN + target-language translations in the note for ambiguous
terms — the model few-shots on them and renders the right domain sense in every
locale (fixes Set→kit, Resume→CV, Volume→loudness, Log→timber/journal).
The extractor resolves each key's note and sends it as context on
POST /i18n/report-keys, which — unlike the write-once text — upserts the
note on new and existing keys (ReportKeysResponse.contextUpdated counts the
syncs). Re-run the extractor after editing the file, then translate-missing
(with overwrite:true to replace already-translated locales). The frontend
registry is then the source of truth; admin edits to a covered key's context are
overwritten on the next extractor run.
Sharing keys between frontends
A second surface — a native app, a watch app, a kiosk — usually renders strings the web app already has. It should reuse those keys rather than introduce parallel ones: the string is then translated once for both, and a wording change moves both at the same time.
Report a shared key with your own module. Modules accumulate:
// the web app, at build time
{"key": "tracking.addSet", "text": "Add set", "module": "frontend"}
// the watch app, later — same key, second module
{"key": "tracking.addSet", "text": "Add set", "module": "watch"}
// stored: modules: ["frontend", "watch"]
This is what makes GET /i18n/messages?locale=X&ns=watch return the keys a
surface reuses as well as the ones it introduced. Modules used to be written
only when a key was created, so shared keys kept the first reporter's module
alone and a namespaced fetch silently returned a fraction of what the second
surface renders — a catalogue that looks healthy and ships an app in English.
Adding a module is idempotent, and it never touches translations.
Do not send context on a key you are reusing. Unlike text, the note is
upserted on every report, so two surfaces writing their own notes would
overwrite each other on alternate deploys. Context belongs to whichever surface
introduced the key.
ns= returns raw translations, no fallback chain. ?locale=ru resolves a
missing key to the source text; ?locale=ru&ns=watch omits it. For a UI that
renders live, the first is what you want. For anything building a snapshot —
a bundled catalogue, a coverage report — use the namespaced form, or every
locale will appear fully translated because the source text was substituted in.
Format integrity. /admin/ai/translate validates every returned
translation against the source's format signature — {placeholder} tokens, ICU
plural structure, and HTML tags must survive. Corrupted locales are
re-requested and, if still broken, omitted (the caller keeps the source) rather
than persisting markup that renders wrong. ICU-plural sources are translated one
locale per call so a single malformed value can't fail the whole batch.
Automatic translation: provenance, flows and the review gate
A catalogue that fills itself needs three things a one-off bulk translate does not: a record of who wrote each translation (so automation never overwrites a person), a trigger that is not a person pressing a button, and a check before machine text reaches users. Each exists because its absence shipped a defect.
Provenance
Every row carries provenance beside translations — locale →
{source, by, at, source_hash, context_hash, review}. translations stays a
plain map, so no reader changes.
source | Written by | Automation may replace it? |
|---|---|---|
machine | a translate run | yes |
| (absent) | anything before provenance existed | only with overwrite |
human | an admin edit, an approved correction | only with overwrite + includeProtected |
code / seed | a frontend default, a migration | only with overwrite + includeProtected |
Every write sets or clears the entry for the locale it touches in the same
update, so a stale human can never protect text no human wrote. An admin
whole-row save marks only the locales whose text changed — a form submits all
25 languages, and marking them all would protect 24 machine strings forever.
source_hash / context_hash fingerprint the source text and translator note a
machine translation was made from. When either changes, the translation is
stale and a missing_and_stale run re-translates it — and only it.
Unknown-origin text has no fingerprints and is never considered stale: fixing
old text is an explicit overwrite, scoped with locales to the languages that
are actually wrong (fixing Polish «cal» must not re-roll 23 correct locales).
i18n.keys_changed — the work signal
Published when a translation input changes: keys added, source-language
text changed, translator note changed (report-keys, RegisterMessages, admin
create/update). Never on a translation write — a flow that fills translations in
response cannot wake itself.
It is deliberately not i18n.updated, which is the per-instance cache
broadcast: consuming that for work would translate the same keys once per
replica.
i18n/fill-translations node
Runs the same loop as the admin endpoint and the CLI (needs go-modules/ai).
| Setting | Default | Meaning |
|---|---|---|
keys | — | Keys to fill; otherwise the event's payload.keys |
all_keys | false | Must be set for an empty key list to mean the whole catalogue |
mode | missing | missing or missing_and_stale |
locales | all active | Restrict to these languages |
limit | 200 | Keys per run |
review | true | Run the review gate before writing (below) |
There is no overwrite setting, by design. The most an unattended flow does is re-translate stale machine text.
primitive.A and small numbers int32. A
case []any matched nothing: the first real run read locales: [pl, de] as
empty and translated into all 25 languages while every literal-based unit test
passed.i18n/flowtemplates — auto-published flows
Blank-import github.com/redelay/go-modules/i18n/flowtemplates. On startup it
publishes, via EnsureTemplatePublished (idempotent by template id,
re-published when the template changes, never clobbering an operator-edited
flow):
| Flow | Trigger | Fill |
|---|---|---|
i18n/translate-on-change | i18n.keys_changed | the event's keys, missing_and_stale, review on |
i18n/translate-sweep | i18n.translate.sweep | all_keys, missing_and_stale, 200 keys, review on |
Two flows rather than one because a run walks one path. Each names its own
groupID: a group derived from the flow id would differ between two replicas
publishing on the same first boot, and every event would be translated twice.
It also seeds a scheduler entry, i18n-translate-sweep (stable id, UTC), that
publishes the sweep event. It is never retimed or re-enabled once present —
an operator who paused it during a provider outage has decided something.
| Env | Default | Purpose |
|---|---|---|
I18N_TRANSLATE_SWEEP_CRON | 30 3 * * * | Sweep schedule (empty = do not seed) |
I18N_AUTO_TRANSLATE_DISABLED | false | Skip publishing the flows and seeding the schedule |
EnsureTemplatePublished re-syncs subscribers itself, so these templates are
unaffected.The review gate
Every defect found in one week of real catalogue data had the same shape: fluent, well-formed, in the right language — and wrong. Polish «cal» on a calorie counter is the word for inch; «Zalogowano» for "you logged 3 sets" is logged in; Turkish «km/s» is per second; Greek «ημερολ» abbreviates calendar. No format or wrong-language check can see them.
ai.Service.ReviewTranslations asks a model, in one call per key, whether each
candidate's meaning matches the source given the translator note — wrong
sense, contradicts note, wrong unit, broken placeholder, untranslated. It is told
explicitly not to judge style: a reviewer that proposes improvements flags
everything, and a gate that holds back everything gets turned off. A rejection
whose suggestion equals the candidate is discarded as self-contradicting.
What happens to each translation:
| Verdict | Key has a translator note | Result |
|---|---|---|
| passed, or no verdict | — | live |
| flagged | yes | held in pending.<locale> — never served; users see the fallback |
| flagged | no | live, flag recorded in provenance.<locale>.review |
A reviewer outage never stops translation. The note/no-note split is measured, not assumed — on 17 known-bad strings (15 real shipped defects) and 87 correct catalogue strings:
| Reviewed with | Bad caught | Correct wrongly flagged |
|---|---|---|
| the translator note | 16 / 17 | 2 / 87 |
| no note | 14 / 17 | 7 / 87 |
Without a note the reviewer confidently imposes its own terms (it claimed German «Satz» cannot mean a workout set), so holding on its word alone would fill the queue with correct text. With a note, the figure is optimistic: many notes were written after the defect was found and spell out the answer. The miss was exactly that case — Polish «cal» with a plain "calories" note. A note that names the trap is what makes the gate catch it. Cost: ~640 prompt + ~80 completion tokens per key.
Held and rejected entries block re-translation while their source and note fingerprints match, so a nightly sweep does not re-bill the same flagged answer. Editing the note or the source releases them. Any live write for the locale clears its pending entry.
Admin review queue
| Method | Path | Purpose |
|---|---|---|
GET | /admin/i18n/translations/pending | Rows with held or rejected translations |
POST | /admin/i18n/translations/pending/approve | {key, locale, text?} — unchanged text goes live as machine (review approved); edited text goes live as human |
POST | /admin/i18n/translations/pending/reject | Keep it out; blocks re-translation until inputs change |
POST | /admin/i18n/translations/review | Read-only review of up to 40 keys; items[].candidates and items[].context override the stored values |
POST | /admin/i18n/translations/translate-missing | Also takes overwrite, includeProtected, locales, review (default off) |
The override on /review exists to measure the reviewer honestly — test a
known-bad string without writing it, or with the note as it was before the
defect was found.
@redelay/js-admin ≥ v0.4.16 renders the queue at Languages → Review
(/translations/review): the source, the note, what the reviewer objected to
and its suggestion, an editable translation, and Approve / Publish correction /
Reject. Correcting beats re-rolling — a re-translation can come back worse.
The CLI mirrors it: i18n-translate --include-protected --review.
Related
- Localized content —
[[ module.key ]]settings tokens, which are expanded in catalogue messages too (after parameter interpolation, so both syntaxes coexist in one string), plus the shared translation lifecycle - go-modules addons — the full addon module list,
including
content(localized pages and fragments, which resolve locale the same way)
A locale's URL prefix is not its language
Locale.code is how a language appears in a URL. Locale.language is the
BCP-47 tag it actually is. They are usually the same and sometimes must not be:
a project can reasonably route Ukrainian at /ua, because uk in a path reads
as the United Kingdom to a human — while the language is uk, and
hreflang="ua" is not a language at all.
Getting this wrong is quiet. Four of one project's prefixes (cz, dk, gr,
ua) were codes Google discards outright, so those languages carried hreflang
markup that did nothing. The other two were worse: ee and se are valid
ISO 639-1 codes — for Ewe and Northern Sami — so they parsed cleanly and
advertised the wrong language, and no validator flags it.
LanguageTag() is never empty: an unset Language falls back to an alias
table (cz→cs, ee→et, se→sv, …) and then to the code itself. The table is
a default, not a rule — it lists only codes that are wrong by default, and
any locale overrides it from admin.
Consumers should read language off the locale record and never carry their
own mapping. Two copies of this mapping in one frontend, pointing opposite
ways, is how the wrong ones shipped.
Making the admin the only source of truth
A frontend that hardcodes its locale list needs a release every time a language is added. The one thing that usually forces it is the ROUTER, because route patterns are built at compile time.
Resist listing the codes there. Match the shape of a prefix and validate membership at runtime against the active-locale list:
// Build time: a shape, not a list.
const LANG = ':lang([a-z]{2})'
// Runtime: the database decides what is actually served.
if (!locales.value.some(l => l.code === lang)) {
throw createError({ statusCode: 404, statusMessage: 'Unknown language' })
}
A constrained list does buy one real thing — :lang(bg|cz|…) makes
/dashboard structurally incapable of being read as a language. Keep that
guarantee with an assertion instead: fail the build if any route's first
segment matches the locale shape, so adding a two-letter page like /ai is a
loud error rather than a page silently routed as a language.
With that in place, adding a locale in admin routes immediately, appears in the switcher, the hreflang set and the sitemap; removing one 404s it and drops it from the sitemap. Both verified without a deploy.
The sitemap should fetch the active list too. Built from a compiled-in array it advertises whatever the last release knew: a language added in admin is missing, and one removed is still being submitted.
Deployments
The dispatch unit between modules and flows. Wraps every published flow with a stable ID, canary/A-B variant routing, an enable/disable toggle, and a contract describing what the flow contributes to /openapi.json + /asyncapi.json.
WebSockets
A WebSocket hub — live connections grouped into rooms, with broadcast fan-out that stays correct across replicas via the framework Coordinator.