Localization

The portal renders its chrome in the viewer's language. English and German ship today.

🚨 Ownership does not expire when a string is PERSISTED. Platform-owned text the platform wrote into a node months ago β€” an activity transcript β€” still follows the viewer who opens it, which takes a third lookup shape because the writer had no viewer to resolve against. See Key + args on a persisted record below.

This is the direct twin of the per-viewer timestamp seam, because it is the same problem shape: a display preference that must reach BOTH the Blazor circuit AND server-side hub layout areas that have no browser.

User.TimeZoneId β†’ AccessContext.TimeZoneId β†’ AccessService.ToDisplayTime
User.Locale     β†’ AccessContext.Locale     β†’ AccessService.Localize

Whose language? The owner's β€” clause 1 and clause 2

Every user-visible string has exactly one owner, and the owner decides the language. This is the answer to "the portal renders its chrome in the viewer's language, but the page in front of me is in someone else's" β€” #3203, where a German reader of an English lesson met AusfΓΌhren on the Run button in the middle of an English paragraph. Both clauses are in force.

Clause 1 β€” ownership decides the language. Platform- and module-owned text follows the viewer. Authored content is rendered as authored. A bare literal compiled into a view is unowned β€” that is a bug, not a third category.

Clause 2 β€” in-flow chrome minimises words. A platform- or module-owned control rendered inside the author's flow carries no translated visible label where a glyph, a number or a symbol conveys the same thing. The localized text moves to the tooltip and the accessible name β€” which is where a reader who needs it will look, and where it cannot land in the middle of a sentence.

Clause 1 is decided by where the string is stored, which a developer chooses at authoring time and a reviewer can see in the diff β€” never by where it appears on screen, and never by a runtime signal:

Where the string lives Owner Renders in
src/MeshWeaver.Messaging.Hub/Localization/strings.{en,de}.json platform the viewer's language
a module's own text table (EduTexts, CourseInviteTexts) module the viewer's language
a [Translation] beside a [Description] on a declaration platform / module the viewer's language
a LogMessage.MessageKey persisted on an activity node platform the viewer's language β€” resolved at READ time (shape 3)
MeshNode.Name / .Description / .Category, or an override in ILocalizedNodeText.Translations author as authored
the node's body, or any typed content field an author edits author as authored
a bare literal in a .razor / .cs view nobody β€” fix it β€”

Clause 2 does not reach the shell β€” the node menu, navigation, settings, toasts, dialogs, the composer all keep their words; a German application menu around an English document is not an inconsistency. It reaches an enumerated list of surfaces the markdown pipeline hydrates directly into a document body: the code-cell toolbar, the fenced block's copy affordance, the code-cell toolbar on a Code node page, the kernel placeholders inside the cell frame, and the Edu lesson frame, exercise grid and quiz. The list is short on purpose, and extending it deliberately when a sixth in-flow control is built is the process working. For that set, clause 2 makes binding the glyph-plus-translated-tooltip preference User Interface states everywhere else.

🚨 A glyph-only control still needs its accessible name. Removing a visible label is only clause 2 if the tooltip / aria-label remains and is itself localized β€” a tooltip is the control's accessible name, so dropping the label without one trades a language bug for an accessibility bug.

Why not the other rule? #3203's option (a) β€” in-content controls follow the content's declared language β€” is declined. It is the rule the Edu pack shipped, measured and reverted: it served German buttons to every learner on earth, and its worst reader is an English speaker facing a page whose every control is in a language they do not have. The full argument, the measurement, the absence of any per-node content-language signal, and what adding one would cost are in Chrome and content language, which also tracks what is still outstanding under clause 2.

The one rule: resolve explicitly, never from ambient culture

CultureInfo.CurrentUICulture is not used and must not be introduced. A layout-area render hops the hub's scheduler; an AsyncLocal ambient culture does not reliably survive that hop, so an ambient design would silently render one user's UI in another user's language.

Instead, LayoutAreaHost captures the subscriber's AccessContext at construction and restores it for the render scope β€” which is exactly what makes an explicit read correct.

🚨 The rule is now MEASURED, and the shape it measures is the implicit one. Nobody writes CurrentUICulture on purpose; they write timestamp.Humanize() or value.ToString("N2"), which reach for it silently β€” every instance found across the fleet during #3203 was a culture-less Humanize(), not a named symbol. AmbientCultureRatchetGuard (test/MeshWeaver.Documentation.Test) therefore bans the CALL SHAPE as well as the symbol, over all of src/, with comments and string literals masked so that explaining the ban is not mistaken for committing it.

It is a ratchet at zero: src/ had no offender when it landed, so there is no allow file and nothing to grandfather. The fix for a red is never a new entry β€” it is to state the culture, from AccessContext.Locale / host.ViewerLocale(), or CultureInfo.InvariantCulture where the value sits inside deliberately unlocalized text.

Three lookup shapes, one resolution rule

All three resolve the viewer's language through Locales.Resolve, so they can never disagree.

1. [Translation] β€” for text attached to a declaration

Property labels, node-type names, enum members, class descriptions. English stays where it already is; the translation rides next to it, so the two cannot drift apart the way a key-indirected resource table allows.

[Description("Display time zone (IANA)")]
[Translation("de", "Anzeige-Zeitzone (IANA)")]
public string? TimeZoneId { get; init; }

[Description] is read as a UI label in only three places, so wiring those localized every generated form label at once:

Site What it feeds
EditorExtensions.cs β†’ MapToControl the Edit macro's property skins
EditorExtensions.cs β†’ GetToggleableDisplayName click-to-edit property views
MeshNodeContentEditorControl.FromType node-bound content editors

🚨 Do not put [Translation] on the [Description] attributes that describe LLM tool parameters (MeshPlugin, McpMeshPlugin, Plugins/*). Those are model-facing, not user-facing; translating them degrades tool-calling.

2. The string catalog β€” for text with no declaration

Blazor markup, inline Controls.* literals, toasts, dialog copy. Keys are dotted and namespaced by UI area (chat.new, menu.edit, settings.privacy); strings.en.json is the key list of record.

// Blazor component
@inject AccessService Access
<button title="@Access.Localize("common.close")">…</button>

// Server-side layout area
Controls.Button(host.Localize("ui.createRelease"))

// Plural
Access.LocalizePlural("plural.message", count)   // "3 messages" / "3 Nachrichten"

Pure builder helpers that deliberately take no host (documented as unit-testable without a layout host) take the viewer's language as an explicit input instead β€” which keeps them pure and makes their German output testable too:

public static StackControl BuildLog(ActivityLog log, string? locale = null)
    => …Controls.Label(LocalizationCatalog.Get("ui.running", locale))…

// caller
BuildLog(log, locale: host.ViewerLocale())

3. Key + args on a persisted record β€” for text written with no viewer in scope

Shapes 1 and 2 both assume the string is chosen while somebody is looking. An activity transcript breaks that assumption, and it is the reason this third shape exists (#3236).

Clause 1 already answers whose language it is: the platform wrote these lines, so they follow the viewer. What was missing was a way to honour that answer β€” the writer has no viewer to resolve against, and the stored row outlives whoever might have been watching.

An ActivityLog line is written server-side, at the moment the work happens: the static-repo import runs as System at boot, a compile runs on a node hub, a write-conflict record is raised inside a storage adapter. There is no viewer β€” and the row that lands is later read by several viewers whose languages differ. Resolving a locale at write time would be wrong even where it is possible: it freezes one reader's language into a shared record.

So the writer stores the key and its arguments, and the reader resolves:

// write site β€” no viewer, no locale, no Localize call
new LogMessage($"Node not found at path: {path}", LogLevel.Error)
    .WithKey("activity.delete.notFound", ("path", path))

// render site β€” LayoutAreaHost has restored the subscriber's AccessContext
Controls.Body(host.Localize(message))          // or message.Localize(host.ViewerLocale())

LogMessage.Message stays the English fallback. That is what makes the shape safe to adopt one site at a time: an un-migrated writer, every row already in the database, and a row naming a key that has since been renamed away all render exactly as they did before.

Three properties, each load-bearing:

Keys live under the activity. namespace. UnkeyedActivityLogMessageRatchetGuard holds the line in three directions: no NEW unkeyed new LogMessage("…") site, no activity.* key that is in no catalog (LocalizationTest compares the catalogs to each other and structurally cannot see a key missing from both), and no target-typed Messages = [new(…)], which constructs a LogMessage while naming no type and is therefore invisible to any textual census β€” three such sites were missing from the issue's own count for exactly that reason.

What is migrated, and what is deliberately not

The write sites came over in two passes β€” #3236 landed the seam plus 37 of them, #3281 the rest of what was migratable β€” and the allow file is now 18 sites, all of them intended to stay. What changed in the second pass is worth knowing because it is the shape any future migration takes:

🚨 Adding an overload to a public helper for this is a cross-repo break. A dependent's <see cref="ActivityRunner.RunActivity"/> becomes CS0419 the moment a second overload exists, and under -warnaserror that is an error, not a warning β€” break shape 3 in CrossRepoPairGate, which no gate detects. Land the dependent's signature-qualified cref FIRST: it resolves against one overload as well as two, so it is correct before and after (MeshWeaver.Plugins#1338 did this for #3281).

The catalog has a second home, and it goes stale SILENTLY

The web clients carry their own copy at MeshWeaver.Plugins/clients/react/src/i18n/strings.{en,de}.json so they can resolve synchronously. Core is the source of truth and its change merges first.

🚨 Adding a key here does not turn the plugins repo red. Its drift guard (src/i18n/localize.test.ts, in the RN app + web clients (typecheck + test) job) compares the client catalog against a pinned core commit recorded in src/i18n/catalog-source.json, not against core's main. The pin is deliberate β€” core merges faster than a Plugins CI cycle, so an unpinned guard could not converge and reddened every unrelated PR on a subsystem its diff could not reach β€” but the consequence is that a core catalog change makes the mirror stale with nothing anywhere going red. Measured 2026-09-04: the pin sat at 1,104 keys while core carried 1,174, both repos fully green.

So a core PR that adds keys hands the sync over explicitly. The mirror moves by npm run sync:i18n -- --ref <the merged core sha>, which rewrites both catalogs, the ref and the recorded key counts together.

The handover is now a gate β€” i18n catalog (mirror sync handed over)

Asking for the handover in prose was not enough, and the recurrence measured it. On 2026-09-07 the pin sat at 1,372 keys against 1,399 on core's main β€” 27 behind, across at least eight merged pull requests, with value drift over the 1,372 shared keys of exactly ZERO. That zero is the whole mechanism: the mirror's guard compares values, every shared value matched, so both repositories were green while the React and RN clients rendered a raw key β€” in both languages β€” for 27 strings. It had been 18 behind three hours earlier, so the debt was growing faster than anyone was reading it.

scripts/check-i18n-mirror-sync.py now refuses a pull request that changes the catalog without a handover in its body:

Mirror-sync: <statement>

The statement must name a real discharge path β€” the sync itself (npm run sync:i18n -- --ref <merged core sha>), the issue or pull request the handover is tracked on, or an explicit none β€” <reason>. That is what stops Mirror-sync: yes from counting as an answer. It fires on a key added and on a value changed on a shared key, because both leave the mirror stale and both are invisible to the mirror's own guard until the pin moves; a key removed does not fire, since a mirror holding a key core no longer uses renders nothing wrong.

🚨 Adding the line to the body does NOT unblock the run that already failed. The gate reads the body from the event payload, not from the API:

PR_BODY: ${{ github.event.pull_request.body }}

That value is frozen when the run is created, and gh run rerun --failed replays the same payload β€” so the job re-reads the old body and fails identically, while the pull request on screen plainly carries the line. Measured 2026-09-09 on #3815: the re-run's log echoed the pre-edit text. Edit the body, then push a commit (or reopen the pull request) so a fresh pull_request event carries it. The same holds for every body-declared gate β€” Pairs-with:, Implementers:, Satellite-pins: β€” because they read the body the same way, and for the same reason: a body fetched at run time is attacker-controlled text that can change after review.

Why a declaration and not a check of the mirror. The gate a reader expects β€” is the pin an ancestor of core's newest catalog-touching commit? β€” cannot live on core's pull-request path, for two independent reasons. It would have to read MeshWeaver.Plugins, and a gate on core's own pull requests whose verdict depends on a sibling's moving HEAD makes the same diff go red or green with no change of its own β€” the rule Repository dependency direction states and package-pin-removal already follows. And the verdict would be one the pull request cannot act on: the sync runs in the other repository, whose pull-request lane may be closed β€” it was, the day this was filed. A gate that reds a core change for a debt only a sibling can discharge is one people learn to route around.

So, exactly like Implementers:, the ordering is inverted β€” the core half lands first β€” and the gate asks for a statement rather than a merged counterpart. It reads no sibling repository, needs no credential, and therefore runs on fork pull requests too.

What it costs. Measured over the 60 most recent first-parent merges on main: eight change the catalog, all eight by adding keys, none by changing a value. So it meets roughly one merge in eight β€” and all eight of those left the mirror stale, which is precisely the 27-key debt. The price is one line in the pull-request body.

What it still cannot do. It cannot make the sync happen, and it does not claim to: the mirror moves only in MeshWeaver.Plugins. What it removes is the silence β€” the state in which the debt is created by a green pull request, in a repository that cannot discharge it, with no record anywhere that it was created at all.

Which core a new key has to reach β€” three refs, three clocks

🚨 "Is the key live?" and "will the guard go green?" are DIFFERENT questions with different answers, and conflating them produces both mistakes: declaring a portal broken when it is fine, and declaring a pin irrelevant when a required check is waiting on it. Measured 2026-09-08 while landing the /onboarding keys.

what reads the catalog which core it resolves so a key merged to core main …
the portal a visitor signs up on β€” memex-portal-ai, built by MeshWeaver.Plugins' portal-ai-image.yml MW_IMAGE_PLATFORM_REF, declared in that workflow as main (the repo VARIABLE MW_PLATFORM_REF would override it and is unset there) reaches the running portal on that lane's next build, with no pin bump
the CI that guards the key β€” MeshWeaver.Plugins' suites, in the required Build + test the portal hosts context ci.yml's MW_PLATFORM_REF, a sealed-set pin moved on the release cadence is absent until the pin moves, so a test asserting the key exists is RED in the meantime
the React / RN clients neither β€” the generated mirror's own pin in clients/react/src/i18n/catalog-source.json needs sync:i18n, as above

The middle row is deliberate, not an accident to route around: it is what makes the cross-repo half VISIBLE. Without it the adopting page compiles, CI is green, and a mistyped key ships a raw token to a new user. So the ordering is: the core catalog half merges first, the adoption half waits for the pin (MeshWeaver.Plugins#1455 merged the day after core#3562 for exactly this reason), and the mirror waits for the sync. Do not "fix" the middle row by moving the pin for one pull request β€” the pin moves at fourteen sites together with the image digests, and moving it obliges every other open pull request in that repo to merge main.

The first two rows cannot drift arbitrarily apart: check-platform-pins.py --check-image-gap bounds the image's core against ci.yml's pin by the same two constants as the staleness arms (24 h, 120 commits) and fails the image lane RED beyond them.

Language resolution

Locales.Resolve falls back in three steps: exact match β†’ primary subtag β†’ English. So de-CH, de-AT and de_DE.UTF-8 all serve German, and anything unshipped renders English rather than blank.

Lookup never throws and never returns null. A key missing from the requested language falls back to English; a key missing from English falls back to the key itself, so an untranslated string surfaces as a visible chat.new-shaped token β€” loud enough to notice in review, harmless enough to ship.

Where the viewer's language comes from

The policy in one line: take the language of the user's own computer, and put it on the user. Never the server's culture β€” see the warning below.

  1. Chosen at onboarding, defaulted from the user's computer. The onboarding form's first field is a language picker, pre-selected from the visitor's own computer language (the request's Accept-Language, already negotiated onto AccessContext.Locale by UserContextMiddleware). Changing it re-renders the form in the chosen language immediately, and submitting writes User.Locale in UserOnboardingService.CreateUser.

    This step exists because the auto-detector below cannot cover it: BrowserPreferenceDetector lives in the authenticated portal shell, so it does not run until after onboarding. Without a picker here, a German-speaking user filled in the form β€” and read the first screens β€” in English.

  2. Auto-detected once, afterwards. BrowserPreferenceDetector reads navigator.language (alongside the IANA zone, in a single interop call) on first render and writes it write-once β€” a manual choice or an earlier session's value is never clobbered. This now mostly serves users who onboarded before the picker existed.

  3. Editable in two places, one control. The profile editor ({user}/EditProfile) and the User β†’ Settings β†’ Preferences tab both carry a language picker. Both are the SAME MeshNodeContentEditorControl bound to the same User.Locale field on the node stream, so they cannot drift apart. The profile is where a user actually looks for "my language"; Preferences is where it sits next to the display time zone. Both store the BCP-47 tag (de) and show the endonym (Deutsch) β€” a German speaker looks for "Deutsch", not "German".

  4. Stamped onto the context. CircuitAccessHandler resolves User.Locale once when the circuit context is built; it then rides AccessContext.Locale to every render path.

Unsupported languages: silent guesses store nothing, explicit choices are honoured. The auto-detector (2) writes nothing for a language this deployment does not ship, leaving the profile empty so a translation shipped later applies automatically instead of pinning the user to a tag we would only ever render in English. The onboarding picker (1) always stores its selection, including en when left at the default β€” because that value was on screen, labelled, and submitted. The distinction is silent guess vs seen and accepted, and Locales.TryMatch (nullable) vs Locales.Resolve (always a tag) is how it is spelled in code.

🚨 "The computer's language" means the USER's computer, never the server's. CultureInfo.CurrentCulture / CurrentUICulture on Blazor Server is the server process culture β€” the machine the portal happens to run on (an en-US container, in practice), identical for every simultaneous viewer and unrelated to any of them. DateTimeView defaulted its calendar culture to it until 2026-08-17, so month names and date order rendered English for a German user no matter what they had chosen. It now resolves AccessService.ViewerLocale(). If you need a CultureInfo for formatting, derive it from the viewer's locale β€” never from ambient culture, which would not survive a hub-scheduler hop anyway.

…and where an ANONYMOUS visitor's comes from

Steps 1–3 all read a profile, and an anonymous visitor does not have one. Without a fourth source, AccessContext.Locale is null for every logged-out visitor and the whole feature is inert for exactly the audience it matters most to: the first-time viewer of a paywall, an invitation link or a public course page is anonymous by definition.

So the request's Accept-Language header is negotiated against Locales.Supported and seeded onto the identity, on both entry paths:

Path Where Reads the header from
SSR / HTTP request UserContextMiddleware HttpContext.Request.Headers.AcceptLanguage
Blazor circuit CircuitAccessHandler constructor CircuitRequestLanguage, published per hub invocation by the global CircuitRequestLanguageFilter

🚨 The circuit reads it off the SignalR CONNECTION, not off IHttpContextAccessor. The accessor works over WebSockets β€” the upgrade request stays in flight for the connection's life β€” and returns nothing under long polling, where every poll is a separate request that ASP.NET disposes (which nulls the accessor's holder for every flow that captured it). A browser behind a proxy that blocks WebSockets falls back to long polling, i.e. exactly a corporate network, so an accessor-only fix would reach most visitors and silently miss the rest. SignalR keeps an IHttpContextFeature on the connection and refreshes it per request, so HubCallerContext.GetHttpContext() answers for every transport; the filter reads it in the hub invocation that creates the circuit handlers. The accessor remains only as a fallback for a host that runs the Blazor hub without the filter. 🚨 The guard that pinned this is GONE. AnonymousCircuitLocaleSeedTest ran its cases over both transports, and the long-polling rows are what caught the defect above. It lived in core at test/MeshWeaver.Hosting.Blazor.Test/; when MeshWeaver.Hosting.Blazor moved to MeshWeaver.Plugins its sibling tests went with it and this file did not. It exists on neither repo's main today, so the anonymous seed currently has zero coverage β€” Locales.Negotiate is still tested, but nothing tests whether the negotiated value reaches a circuit. Tracked as MeshWeaver.Plugins#1273.

Locales.Negotiate does the parsing: the full RFC 9110 list with q= weights, tried in descending weight, each matched by Locales.TryMatch so region variants fold onto the primary subtag exactly as everywhere else (en-GB β†’ en). q=0 is an explicit refusal and * is an absence of preference β€” both are skipped, so neither can pin a caller to a language it never asked for. Nothing matched returns null, not en, so "unsupported" stays distinguishable from "asked for English".

Two properties this rests on, both load-bearing:

Note this is still an explicit resolution off AccessContext.Locale β€” the header is read once, at identity time, and never becomes a second ambient mechanism. CultureInfo.CurrentUICulture is not consulted anywhere (see "The one rule" above).

Adding a language

  1. Add the tag to Locales.Supported and an endonym to Locales.DisplayNames.
  2. Add Localization/strings.{tag}.json as an EmbeddedResource with WithCulture="false".
  3. Add [Translation("{tag}", …)] next to the UI-facing [Description] attributes.

🚨 WithCulture="false" is load-bearing, not boilerplate. The SDK infers a culture from the .en./.de. segment of an EmbeddedResource filename and, having inferred one, routes the file into a satellite assembly instead of the main one. The result is silent: the build succeeds, the assembly carries zero manifest resources, every lookup falls through to the key-fallback path, and the UI renders raw chat.new tokens. LocalizationTest.EnglishCatalog_IsLoaded is the guard.

What stays English on purpose

🚨 The boundary: a viewer's message vs. an owner's diagnostic

"Errors are localized" is true of the errors a viewer reads, and reviewers reasonably read the rule that way. It is not true of the diagnostic layer underneath, and the distinction is not squeamishness about effort β€” localizing there makes the product worse.

viewer message owner diagnostic
reaches a human via a control: label, toast, dialog, validation, empty state ILogger, an exception message, an error payload on the wire
written for the person who tried to do the thing whoever is debugging the mesh
vocabulary the user's domain partitions, paths, stream ids, providers, node types
localize? yes, always no

🚨 An activity transcript sits on the viewer side of that table, and it is the case that proves the row is about the READER rather than about the writing code. Its lines are written by the same server-side machinery that emits the diagnostics above, in the same vocabulary β€” but a user opens the activity node and reads them. So the sentences the platform owns are keyed (shape 3 above), while the verbatim upstream fragments spliced into them stay English, exactly as reason 1 of this section argues. "It was written where no viewer existed" is a statement about the mechanism, never a reason to leave it English: that is what LogMessage.MessageKey exists to decouple.

The Describe* family is the canonical diagnostic shape β€” MessageSizeGuard.Describe / DescribeGrainDispatch / DescribeRouterDispatch, CancellationClassifier.Describe, QueryIdentity.DescribeUnresolved, StoreReachability.DescribeNotAttempted / DescribeMayHavePartiallyLanded, RequiredModuleStatus.Describe, ModuleActivationStatus.DescribeUnresolvable. None is localized, and MeshWeaver.Mesh.Contract β€” which holds several of them β€” contains six Localize( calls in total, none on a Describe*.

Three reasons this is a decision and not a backlog item:

  1. A translated fragment in an English sentence is worse than English. These strings are composed into carriers that are themselves literals β€” MeshNodeStreamExtensions builds $"Update of '{path}' failed: {errorType}" and falls back to the bare "Update rejected by owner". Localizing the inner clause yields a German phrase spliced into an English frame: harder to read for the German viewer and harder to grep for the engineer.
  2. The content is operator vocabulary in every language. A message naming a namespace, a mesh path, a stream id and a store provider does not become more comprehensible in German. What makes it comprehensible is knowing the platform.
  3. Grep-ability is a property of the diagnostic layer. A log line or exception you cannot search for by its English text β€” because it may have been emitted in any of N languages β€” is materially harder to trace, and these are the strings that get pasted into issues.

If a diagnostic really is surfacing to a viewer, that is a bug at the SURFACE, not here. The fix is for that surface to map an error code to a localized message, never to print an owner-side sentence. MeshNodeErrorCode exists precisely so a UI can do that without parsing prose. Localizing the diagnostic would hide the defect behind a translated version of a string the viewer should never have been shown.

Reviewing this: an automated reviewer flagging a Describe* helper or an Error payload as an unlocalized user-visible string is applying the right rule at the wrong layer. Point it at this section. The rule genuinely bites the moment the string reaches a Controls.* literal, an aria-label, or anything a LayoutArea renders.

Tests

Reconnecting…
The server was updated. Reloading the page to pick up the latest version.