From 439d185179b6cc3a92a069495c453470af684b22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Sat, 8 Aug 2026 21:16:00 +0200 Subject: [PATCH 1/2] Every round spoke English because System shadowed the submitter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A round's user-facing text is resolved explicitly off the round's AccessContext.Locale (never ambient culture — a round hops schedulers). But the round-dispatch watcher REBUILDS that context from the submitter rider on the pending message, and the rider carried only ObjectId and Name. The language was dropped on the floor, so every user-facing string a round emits rendered English for every viewer regardless of their language — the exact failure mode the i18n rule exists to prevent. Tracing it turned up the reason the language was missing in the first place, one level up from where #948 predicted. CaptureSubmitter took the first NON-NULL access context, and at the submit boundary the request-scoped context is frequently a non-user principal — a hub address, or system-security from an enclosing ImpersonateAsSystem scope. It stamped THAT as the submitter (`ctx=system-security/-`) while the real signed-in user sat right there on the circuit context (`circuit=Roland/de`). So the rider lost not just the language but the identity: the watcher's own IsRealUser predicate rejects system-security, yet the rider smuggled it straight past that gate. Fixed at both ends of the rider, which now agree: - CaptureSubmitter picks the first REAL USER context, not the first non-null one — applying the same predicate the watcher applies (no hub-shaped principals, no System). - The rider carries the submitter's Locale next to their identity (ThreadMessage.SubmitterLocale), stamped by StartThread / SubmitMessage / SubmitComposer. - DispatchRound uses it in BOTH branches. The ambient branch needs it too: the thread hub's OWNER-INJECTION stamps the owner as {ObjectId, Name} only, so an identity taken from there is locale-less by construction. The CreatedBy fallback deliberately stays default — a bare user id on the node carries no presentation preference, and inventing one would be worse. Commented as such. - The dispatch identity trace now prints the language, so "renders English for a German user" is diagnosable from the log alone. The test that should have caught this was vacuous: it asserted the round's summary against the ENGLISH catalog entry (locale: null), which passes equally against a hard-coded English literal. It now submits as a German user and asserts the German text, with a sanity assertion that the EN and DE entries differ so it cannot go vacuous again, plus a direct assertion on the rider so a regression names its own cause. Verified failing before this change (found English, expected German) and passing after. Fixes #948. Co-Authored-By: Claude Fable 5 --- src/MeshWeaver.AI/HubThreadExtensions.cs | 60 +++++++++++++------ src/MeshWeaver.AI/Thread.cs | 16 +++++ src/MeshWeaver.AI/ThreadInput.cs | 8 ++- src/MeshWeaver.AI/ThreadSubmission.cs | 45 ++++++++++---- ...6-08-08-round-messages-in-your-language.md | 18 ++++++ .../ModelSubstitutionTest.cs | 49 +++++++++++++-- 6 files changed, 162 insertions(+), 34 deletions(-) create mode 100644 src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-08-round-messages-in-your-language.md diff --git a/src/MeshWeaver.AI/HubThreadExtensions.cs b/src/MeshWeaver.AI/HubThreadExtensions.cs index d68f1d28ab..99b620166b 100644 --- a/src/MeshWeaver.AI/HubThreadExtensions.cs +++ b/src/MeshWeaver.AI/HubThreadExtensions.cs @@ -4,6 +4,7 @@ using MeshWeaver.Graph; using MeshWeaver.Layout; using MeshWeaver.Mesh; +using MeshWeaver.Mesh.Security; using MeshWeaver.Mesh.Services; using MeshWeaver.Messaging; using Microsoft.Extensions.DependencyInjection; @@ -43,25 +44,47 @@ public static class HubThreadExtensions /// one moment the originating user's identity is reliably on the AsyncLocal /// (AccessService.Context) or the per-circuit fallback (CircuitContext). The /// captured (ObjectId, Name) is stamped onto the pending - /// ( / ) + /// ( / / + /// ) /// so the round-dispatch watcher can rebuild the identity AFTER every later async boundary /// (its own .Subscribe continuation + the AI streaming continuations) has wiped the /// AsyncLocal. This is "capture the identity when computing the submit patch" — the data /// carries the truth, not the (by-then-null) AsyncLocal. /// - /// Returns (null, null) when no real user identity is live (e.g. a hub-shaped - /// principal, or no context at all) — the watcher then falls back to the thread owner derived - /// from the node, NEVER hub-self. A hub-shaped principal is explicitly rejected here so it can - /// never be persisted as a fake submitter (the CreatedBy=sync/… class of bug). + /// 🌍 The snapshot includes the submitter's : every + /// user-facing string a round emits is resolved off the round's context, so a rebuilt context + /// without the language renders English for a German user (#948). The language is part of the + /// identity, and this is the one place it is reliably live. + /// + /// The first REAL USER wins, not the first non-null context. The request-scoped + /// Context frequently holds a NON-user principal at the submit boundary — a hub-shaped + /// address, or system-security from an enclosing ImpersonateAsSystem scope + /// (seeding, hydration, any infrastructure step the submit is nested in). Taking it merely + /// because it is non-null stamps THAT as the submitter and discards the real signed-in user + /// sitting right there on the circuit context — which cost both the identity AND the language + /// (#948: a German user's round rendered English because system-security shadowed + /// Roland/de). This is the same IsRealUser predicate the round-dispatch watcher + /// applies at the other end of the rider; the two ends now agree. + /// + /// Returns all-null when no real user identity is live at all — the watcher then falls + /// back to the thread owner derived from the node, NEVER hub-self and never System. /// - private static (string? ObjectId, string? Name) CaptureSubmitter(this IMessageHub hub) + private static (string? ObjectId, string? Name, string? Locale) CaptureSubmitter(this IMessageHub hub) { var accessService = hub.ServiceProvider.GetService(); - var ctx = accessService?.Context ?? accessService?.CircuitContext; - if (ctx is null || string.IsNullOrEmpty(ctx.ObjectId) - || AccessService.LooksLikeHubPrincipal(ctx.ObjectId)) - return (null, null); - return (ctx.ObjectId, string.IsNullOrEmpty(ctx.Name) ? ctx.ObjectId : ctx.Name); + var ctx = IsRealUser(accessService?.Context) ? accessService!.Context + : IsRealUser(accessService?.CircuitContext) ? accessService!.CircuitContext + : null; + if (ctx is null) + return (null, null, null); + return (ctx.ObjectId, string.IsNullOrEmpty(ctx.Name) ? ctx.ObjectId : ctx.Name, ctx.Locale); + + // A submitter is a PERSON. Hub addresses (sync/, mesh/, node/, activity/, portal/) and the + // well-known System identity are infrastructure credentials — never a submitter. + static bool IsRealUser(AccessContext? candidate) => + !string.IsNullOrEmpty(candidate?.ObjectId) + && !AccessService.LooksLikeHubPrincipal(candidate.ObjectId) + && !string.Equals(candidate.ObjectId, WellKnownUsers.System, StringComparison.Ordinal); } // ═════════════════════════════════════════════════════════════════════ @@ -138,7 +161,7 @@ public static void StartThread( // Capture the submitter's identity NOW — synchronous, live AsyncLocal — and persist it on the // pending message (below) so the round-dispatch watcher can rebuild it AFTER the async boundary // wipes the AsyncLocal. See ThreadMessage.SubmitterObjectId / AccessContextPropagation.md. - var (submitterObjectId, submitterName) = hub.CaptureSubmitter(); + var (submitterObjectId, submitterName, submitterLocale) = hub.CaptureSubmitter(); // 🎯 The thread's COMPOSER is the single source of truth for the round's sticky // selection (agent / model / harness / context). Seed it from the supplied composer @@ -180,7 +203,8 @@ public static void StartThread( attachments: attachments, harness: harness, submitterObjectId: submitterObjectId, - submitterName: submitterName)) + submitterName: submitterName, + submitterLocale: submitterLocale)) } : baseThread; // empty thread — no round threadNode = threadNode with { Content = seededThread }; @@ -311,7 +335,7 @@ public static void SubmitMessage( if (string.IsNullOrWhiteSpace(userText)) return; - var (submitterObjectId, submitterName) = hub.CaptureSubmitter(); + var (submitterObjectId, submitterName, submitterLocale) = hub.CaptureSubmitter(); var userMessage = ThreadInput.CreateUserMessage( userText ?? string.Empty, createdBy: createdBy, @@ -322,7 +346,8 @@ public static void SubmitMessage( attachments: attachments, harness: harness, submitterObjectId: submitterObjectId, - submitterName: submitterName); + submitterName: submitterName, + submitterLocale: submitterLocale); try { ThreadInput.AppendUserInput(hub.GetWorkspace(), threadPath, userMessage); @@ -375,7 +400,7 @@ public static void SubmitComposer( var logger = hub.ServiceProvider.GetService() ?.CreateLogger("MeshWeaver.AI.HubThreadExtensions"); var msgId = Guid.NewGuid().ToString("N")[..8]; - var (submitterObjectId, submitterName) = hub.CaptureSubmitter(); + var (submitterObjectId, submitterName, submitterLocale) = hub.CaptureSubmitter(); hub.GetWorkspace().GetMeshNodeStream(threadPath).Update(node => { @@ -399,7 +424,8 @@ public static void SubmitComposer( attachments: attachments ?? c.Attachments, harness: c.Harness, submitterObjectId: submitterObjectId, - submitterName: submitterName); + submitterName: submitterName, + submitterLocale: submitterLocale); return node with { diff --git a/src/MeshWeaver.AI/Thread.cs b/src/MeshWeaver.AI/Thread.cs index 32f2bc98d2..2b202c5d02 100644 --- a/src/MeshWeaver.AI/Thread.cs +++ b/src/MeshWeaver.AI/Thread.cs @@ -555,6 +555,22 @@ public record ThreadMessage /// public string? SubmitterName { get; init; } + /// + /// The submitter's — their preferred UI language as a + /// BCP-47 tag (de, en, …) — captured alongside + /// at submit time. Null when the submitter has no language preference (→ English). + /// + /// 🌍 Why the LANGUAGE has to ride on the message too. The round-dispatch watcher + /// rebuilds the round's from this rider, because its + /// Throttle/Subscribe continuation has no live identity. Every user-facing string a round + /// emits is resolved explicitly off AccessContext.Locale (never ambient + /// CultureInfo — a round hops schedulers), so a rebuilt context that carries only + /// + renders the DEFAULT language + /// for every viewer regardless of theirs. The identity's own presentation preference is part + /// of the identity: carry it on the same rider (#948). + /// + public string? SubmitterLocale { get; init; } + /// /// Completed tool calls from this message's execution. /// Populated when execution finishes, used for post-execution inspection. diff --git a/src/MeshWeaver.AI/ThreadInput.cs b/src/MeshWeaver.AI/ThreadInput.cs index d02d23b401..98691f88af 100644 --- a/src/MeshWeaver.AI/ThreadInput.cs +++ b/src/MeshWeaver.AI/ThreadInput.cs @@ -34,6 +34,10 @@ public static class ThreadInput /// . /// The submitter's , captured /// alongside . + /// The submitter's , captured + /// alongside so the rebuilt round context can render + /// user-facing text in the submitter's language. See + /// . public static ThreadMessage CreateUserMessage( string text, string? createdBy = null, @@ -44,7 +48,8 @@ public static ThreadMessage CreateUserMessage( IReadOnlyList? attachments = null, string? harness = null, string? submitterObjectId = null, - string? submitterName = null) => + string? submitterName = null, + string? submitterLocale = null) => new() { Role = "user", @@ -58,6 +63,7 @@ public static ThreadMessage CreateUserMessage( Attachments = attachments, SubmitterObjectId = submitterObjectId, SubmitterName = submitterName, + SubmitterLocale = submitterLocale, Timestamp = DateTime.UtcNow, Type = ThreadMessageType.ExecutedInput, // User cells don't have a streaming lifecycle — Submitted on creation. diff --git a/src/MeshWeaver.AI/ThreadSubmission.cs b/src/MeshWeaver.AI/ThreadSubmission.cs index dbe3e22316..129b8e7872 100644 --- a/src/MeshWeaver.AI/ThreadSubmission.cs +++ b/src/MeshWeaver.AI/ThreadSubmission.cs @@ -752,14 +752,30 @@ bool IsRealUser(AccessContext? ctx) => // — the authoritative identity that survives every async boundary. Prefer it over the // wrapping-node CreatedBy fallback so the round's cross-hub cell/state writes carry the real // user and never post a null context → fail closed → stuck cell / wedge. See AccessContextPropagation.md. - if (userCtx is null) - { - var submitter = dispatch.UserMessageIds - .Select(mid => thread.PendingUserMessages.TryGetValue(mid, out var m) ? m : null) - .LastOrDefault(m => !string.IsNullOrEmpty(m?.SubmitterObjectId)); - if (submitter?.SubmitterObjectId is { } sid) - userCtx = new AccessContext { ObjectId = sid, Name = submitter.SubmitterName ?? sid }; - } + var submitter = dispatch.UserMessageIds + .Select(mid => thread.PendingUserMessages.TryGetValue(mid, out var m) ? m : null) + .LastOrDefault(m => !string.IsNullOrEmpty(m?.SubmitterObjectId)); + if (userCtx is null && submitter?.SubmitterObjectId is { } sid) + userCtx = new AccessContext + { + ObjectId = sid, + Name = submitter.SubmitterName ?? sid, + // 🌍 The submitter's LANGUAGE rides on the same rider as their identity. Every + // user-facing string this round emits is resolved off AccessContext.Locale, so a + // context rebuilt without it renders English for every viewer (#948). + Locale = submitter.SubmitterLocale + }; + // 🌍 …and when the identity came from an AMBIENT context above, that context is + // locale-less BY CONSTRUCTION: the thread hub's OWNER-INJECTION (ThreadExecution) stamps + // the owner as {ObjectId, Name} only — no presentation preferences — onto the shared + // AccessService, and it is that injected context the watcher usually observes here. The + // per-round rider is the only carrier of the submitter's language, so take it from there + // whichever branch supplied the identity. Without this the round speaks English to a + // German submitter even though the rider knew better (#948). + else if (userCtx is not null + && string.IsNullOrEmpty(userCtx.Locale) + && !string.IsNullOrEmpty(submitter?.SubmitterLocale)) + userCtx = userCtx with { Locale = submitter.SubmitterLocale }; var fellBackToCreatedBy = false; // Resolution: thread content's CreatedBy → wrapping node's CreatedBy → null. @@ -768,6 +784,11 @@ bool IsRealUser(AccessContext? ctx) => : threadNode.CreatedBy; if (userCtx is null && !string.IsNullOrEmpty(resolvedCreatedBy)) { + // 🌍 No Locale on this path, deliberately: CreatedBy is a bare user id stamped on the + // node — it carries no presentation preferences, and this branch runs when there is no + // submitter rider at all (a resumed / orphaned round). The round therefore speaks the + // DEFAULT language, which is the honest answer when nothing on the data says otherwise; + // inventing one (e.g. reading the ambient culture) would be worse (#948). userCtx = new AccessContext { ObjectId = resolvedCreatedBy, Name = resolvedCreatedBy }; fellBackToCreatedBy = true; } @@ -779,7 +800,8 @@ bool IsRealUser(AccessContext? ctx) => logger?.LogInformation( "[ThreadSubmission] DispatchRound identity thread={ThreadPath} responseId={ResponseId} " + "asyncLocal={AsyncLocal} hubAsUserMatch={HubAsUser} circuit={Circuit} threadCreatedBy={ThreadCreatedBy} " + - "nodeCreatedBy={NodeCreatedBy} fallbackToCreatedBy={FallbackToCreatedBy} effective={Effective}", + "nodeCreatedBy={NodeCreatedBy} fallbackToCreatedBy={FallbackToCreatedBy} effective={Effective} " + + "locale={Locale}", threadPath, responseMsgId, asyncLocalCtx?.ObjectId ?? "(null)", hubAsUserMatch, @@ -787,7 +809,10 @@ bool IsRealUser(AccessContext? ctx) => thread.CreatedBy ?? "(null)", threadNode.CreatedBy ?? "(null)", fellBackToCreatedBy, - userCtx?.ObjectId ?? "(null)"); + userCtx?.ObjectId ?? "(null)", + // 🌍 The language the round will speak — the one thing that makes a "renders English + // for a German user" report diagnosable from the log alone (#948). + userCtx?.Locale ?? "(default)"); var meshService = hub.ServiceProvider.GetRequiredService(); diff --git a/src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-08-round-messages-in-your-language.md b/src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-08-round-messages-in-your-language.md new file mode 100644 index 0000000000..05bbb2ec70 --- /dev/null +++ b/src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-08-round-messages-in-your-language.md @@ -0,0 +1,18 @@ +--- +Name: Chat rounds speak your language +Category: What's New +Description: Messages a chat round writes back — like "no language model can serve this round" — now appear in the language you picked, not always English. +Icon: Sparkle +--- + +# Chat rounds speak your language + +When a chat round has something to tell you — for example that no language model can currently +serve it — that message is now shown in the language you selected, not always in English. + +Until now those messages were written by a background step that had lost track of who submitted +the message, and with it your language preference. Your name and account were carried across, but +your language was not, so the text fell back to English for everyone. + +Your language now travels with your message all the way to the round that answers it. This applies +to every message a round writes back, including ones added in future. diff --git a/test/MeshWeaver.AI.Test/ModelSubstitutionTest.cs b/test/MeshWeaver.AI.Test/ModelSubstitutionTest.cs index d114165ae8..e8269d8e7c 100644 --- a/test/MeshWeaver.AI.Test/ModelSubstitutionTest.cs +++ b/test/MeshWeaver.AI.Test/ModelSubstitutionTest.cs @@ -134,6 +134,12 @@ await Observable.Interval(TimeSpan.FromMilliseconds(50)) /// (c): NOTHING resolves — the requested model is unusable and the catalog offers no usable /// replacement, so no agent can be built. The round must FAIL with a message that names the /// situation, not settle as a success carrying a raw provider error. + /// + /// 🌍 The round submits under a GERMAN submitter (#948) and asserts the GERMAN catalog + /// text. Asserting the English entry (locale: null) would pass equally against a + /// hard-coded English literal AND against a round that lost the submitter's locale — which is + /// exactly the defect this pins: the submission watcher rebuilds the round's identity from the + /// submitter rider on the pending message, so the locale has to ride there too. /// [Fact(Timeout = 120_000)] public async Task NoUsableModelAnywhere_FailsRoundWithSpeakingError() @@ -153,9 +159,31 @@ await Observable.Interval(TimeSpan.FromMilliseconds(50)) resolver.ResolveDefaultModelId().Should().BeNull( "the precondition for this test is that NO model in the catalog has a usable credential"); + // 🌍 The assertion below only bites if EN and DE actually differ for this key — otherwise + // "renders German" and "renders English" are the same string and the test goes vacuous. + var germanText = LocalizationCatalog.Get("chat.noUsableModel", "de", StaleModel); + var englishText = LocalizationCatalog.Get("chat.noUsableModel", locale: null, StaleModel); + germanText.Should().NotBe(englishText, + "the German and English catalog entries must differ, or asserting the German text " + + "would pass against an English round and prove nothing (#948)"); + var threadPath = await SeedThread(); var client = GetClient(); - client.SubmitMessage(threadPath, "hello", modelName: StaleModel, createdBy: TestUser); + + // Submit as a GERMAN-speaking user. The submitter's identity — locale included — is + // captured at the submit boundary and rides on the pending message, because the submission + // watcher's Throttle/Subscribe continuation has no live AccessContext (#948). + var access = Mesh.ServiceProvider.GetRequiredService(); + var previousContext = access.CircuitContext; + access.SetCircuitContext((previousContext ?? TestUsers.Admin) with { Locale = "de" }); + try + { + client.SubmitMessage(threadPath, "hello", modelName: StaleModel, createdBy: TestUser); + } + finally + { + access.SetCircuitContext(previousContext); + } var thread = await WaitForThread(threadPath, t => t.Status == ThreadExecutionStatus.Idle && t.Messages.Count >= 2, 60_000); @@ -166,11 +194,20 @@ await Observable.Interval(TimeSpan.FromMilliseconds(50)) cell.Status.Should().Be(ThreadMessageStatus.Error, "a round that no model can serve must FAIL — settling as Completed reads as success to " + "every automation that checks the node (#476)"); - // The failure must SPEAK — and speak the viewer's language: it comes from the localization - // catalog, not a hard-coded English literal, and it names the requested model. - cell.Summary.Should().Be( - LocalizationCatalog.Get("chat.noUsableModel", locale: null, StaleModel), - "the round's failure text must be the localized catalog message for this situation"); + + // The mechanism, pinned: the submitter's language rides on the user message next to their + // identity. That rider is what the round-dispatch watcher rebuilds the round's + // AccessContext from, so if it loses the locale every round speaks English (#948). + var userCell = await WaitForCell(threadPath, thread.Messages[0], _ => true, 30_000); + userCell.SubmitterLocale.Should().Be("de", + "the submit boundary must capture the submitter's language onto the message — it is the " + + "only carrier that survives the watcher's Throttle/Subscribe hop (#948)"); + // The failure must SPEAK — and speak the SUBMITTER's language: it comes from the + // localization catalog resolved off the round's own AccessContext.Locale, not a hard-coded + // English literal, and it names the requested model. + cell.Summary.Should().Be(germanText, + "the round's failure text must be the catalog message in the submitter's language — " + + "the round's AccessContext must carry the submitter's Locale (#948)"); cell.Summary.Should().Contain(StaleModel, "the message must name the model that was requested"); cell.Summary.Should().NotContain("ApiKey", "the raw provider/factory error belongs in the log, never pasted into the thread (#476)"); From 31ce2af13c24b5b8862be3a204d0fca35d7dd366 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Sat, 8 Aug 2026 22:21:55 +0200 Subject: [PATCH 2/2] Treat an empty Locale as "no preference", not as a blank in the trace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AccessContext.Locale's contract is null/empty/unsupported → English, so an empty string is a real "no preference" — but the identity trace printed it with `?? "(default)"`, which only catches null. An empty Locale rendered as a blank gap in the log line, which reads like the trace itself failed rather than like the user has no preference — undermining the one diagnostic that makes a "renders English for a German user" report readable from logs alone. Same correction on SubmitterLocale's doc: it claimed null means no preference, but the value is copied verbatim from AccessContext.Locale, so empty reaches it unchanged and callers must use IsNullOrEmpty. Co-Authored-By: Claude Opus 5 (1M context) --- src/MeshWeaver.AI/Thread.cs | 5 ++++- src/MeshWeaver.AI/ThreadSubmission.cs | 7 +++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/MeshWeaver.AI/Thread.cs b/src/MeshWeaver.AI/Thread.cs index 2b202c5d02..6469bdf953 100644 --- a/src/MeshWeaver.AI/Thread.cs +++ b/src/MeshWeaver.AI/Thread.cs @@ -558,7 +558,10 @@ public record ThreadMessage /// /// The submitter's — their preferred UI language as a /// BCP-47 tag (de, en, …) — captured alongside - /// at submit time. Null when the submitter has no language preference (→ English). + /// at submit time. Null OR EMPTY when the submitter has no language preference — the value is + /// copied verbatim from , whose own contract is + /// null/empty/unsupported → English, so both absences reach here unchanged. Test it with + /// , never a null check alone. /// /// 🌍 Why the LANGUAGE has to ride on the message too. The round-dispatch watcher /// rebuilds the round's from this rider, because its diff --git a/src/MeshWeaver.AI/ThreadSubmission.cs b/src/MeshWeaver.AI/ThreadSubmission.cs index 129b8e7872..5f06ea93b9 100644 --- a/src/MeshWeaver.AI/ThreadSubmission.cs +++ b/src/MeshWeaver.AI/ThreadSubmission.cs @@ -811,8 +811,11 @@ bool IsRealUser(AccessContext? ctx) => fellBackToCreatedBy, userCtx?.ObjectId ?? "(null)", // 🌍 The language the round will speak — the one thing that makes a "renders English - // for a German user" report diagnosable from the log alone (#948). - userCtx?.Locale ?? "(default)"); + // for a German user" report diagnosable from the log alone (#948). IsNullOrEmpty, not + // ??: AccessContext.Locale's contract is null/empty/unsupported → English, so an empty + // string is a real "no preference" and must read as "(default)", not as a blank gap + // that looks like the trace itself failed. + string.IsNullOrEmpty(userCtx?.Locale) ? "(default)" : userCtx!.Locale); var meshService = hub.ServiceProvider.GetRequiredService();