Every timestamp in the mesh is stored, serialized, versioned, sorted and logged in UTC. Only rendering converts. This page is the one rule for that conversion, and the traps that make getting it wrong invisible.
π¨
.ToString(...)on a stored timestamp shows UTC to every viewer. So does.ToLocalTime()and.LocalDateTimeβ under Blazor Server those resolve to the server process zone, and the deployment container runs UTC, so the conversion is a no-op that looks like a conversion.
The seam
User.TimeZoneId (IANA, on the User node)
βββΊ AccessContext.TimeZoneId (resolved when the context is built)
βββΊ AccessService.ToDisplayTime(instant)
Because the zone rides on the identity rather than on the browser, the same call is correct on
the Blazor circuit and on server-side hub render paths that have no browser at all. Conversion is
fully synchronous β safe to call on the render path, no profile lookup, no async. Named IANA zones
mean DST is applied automatically and per-region. An unknown viewer, or a viewer whose zone is unset
or invalid, renders UTC β the display is never wrong, just un-localized.
var access = host.Hub.ServiceProvider.GetService<AccessService>(); // MeshWeaver.Messaging
var when = access.ToDisplayTime(node.LastModified).ToString("yyyy-MM-dd HH:mm");
In a Blazor view, BlazorView already exposes a protected AccessService β just call
AccessService.ToDisplayTime(...).
Capture the zone when the value is formatted LATER
ToDisplayTime(instant) β the AccessService extension β resolves the AsyncLocal access context
at the moment it is called. On a synchronous render turn that is exactly right.
It is silently wrong when the formatting happens on a later emission that has left that scope:
an IIoPool HTTP result, a pooled scheduler hop, a change-feed callback. There the context no longer
flows, resolution returns null, and the value degrades to UTC β with no error, no log line, and no
failing test. Two call sites resolving separately can also disagree with each other on the same page.
So on those paths, capture on the render turn and pass the id down:
// On the render turn β the context is still ambient here.
var zoneId = host.Hub.ServiceProvider.GetService<AccessService>().ViewerZoneId();
// On a later emission β the id is a plain string, so it cannot degrade.
return prService.ListAll(spacePath, null, userId)
.Select(rows => rows.Select(p => new Row(
p.Number,
DisplayTimeExtensions.ToDisplayTime(p.UpdatedAt, zoneId).ToString("yyyy-MM-dd"))));
Rule of thumb: if the value is formatted anywhere other than the synchronous body of the area, capture the zone.
Do NOT convert calendar facts
A date is not an instant. A policy inception, a date of loss, a valuation / as-of date, a delivery
date, a certification expiry, an analytics day-bucket key β these are calendar facts. An inception of
2026-01-01 is 2026-01-01 in Zurich, in New York and in UTC.
Converting one shifts data and corrupts joins β a day-bucket key that moves by an hour silently lands in the wrong bucket, and a report reconciles against nothing.
| Convert | Leave alone |
|---|---|
| created, modified, submitted, decided, published, indexed, last-used, expiry instant | inception, date of loss, as-of / valuation date, delivery date, chart day-bucket keys |
The question to ask is not "is this a date type" but "did this happen at an instant, or on a day?"
Comparisons use UTC β only the rendered value is a wall clock
A surprisingly common variant of the bug has nothing to do with formatting:
// β compares a stored UTC instant against the SERVER's clock β every bucket
// boundary is off by the host offset.
var age = DateTime.Now - node.LastModified;
Measure age, ordering, overdue-ness and "is it in the future" against UtcNow. Convert only the
value you are about to render.
Machine-facing output stays UTC, and says so
An agent/MCP tool that emits a timestamp a caller may hand back β a version list feeding a
"restore to this point" call, for instance β must emit ISO-8601 with the Z. A zone-less string
parses in the server's zone on the way back in, so a round trip silently lands on a different
instant. Machine output is UTC; the UI is what localizes.
Native clients are different
MAUI and other native clients keep .ToLocalTime(): there the process is the device, so the
system zone is the real viewer zone. The seam exists because Blazor Server renders on a shared
container, not because ToLocalTime is wrong everywhere.
Testing
Pin both DST directions β a fixed-offset implementation passes one and fails the other β plus the fallback and the date rollover. Keep the formatter pure (take the zone as an argument) so the test needs no hub and no circuit:
public static string DisplayStamp(DateTimeOffset instant, string? zoneId) =>
DisplayTimeExtensions.ToDisplayTime(instant, zoneId).ToString("yyyy-MM-dd HH:mm");
| Stored instant | Zone | Expected | Why this row exists |
|---|---|---|---|
2026-07-20 14:32Z |
Europe/Zurich |
16:32 |
CEST, UTC+2 |
2026-01-20 14:32Z |
Europe/Zurich |
15:32 |
CET, UTC+1 β catches a hard-coded offset |
2026-07-20 14:32Z |
America/New_York |
10:32 |
a second region β "US time" is a specific zone |
2026-07-29 23:30Z |
Europe/Zurich |
2026-07-30 01:30 |
the DATE moves β this is why a missed conversion reads as an off-by-one rather than a timezone bug |
| any | null / unknown |
unchanged (UTC) | must degrade to UTC, never to the server's zone β a server-local fallback looks right in CI and is wrong in Zurich |
The agent's clock is a viewer of this seam too
An agent prompt is not a render path, and it needs the same two clocks for the same reasons. Until #1651 nothing told an agent the date at all, so every model answered "what day is today" from its training priors β and so did every relative expression a scheduling agent computes off that anchor ("tomorrow", "next Tuesday afternoon", "clear my Friday"). A wrong anchor books meetings on wrong days while nothing looks broken.
CurrentTimeContext.Describe(instant, zoneId) (MeshWeaver.AI) renders the block, and
AgentChatClient.AppendContextAndAttachmentsAsync appends it to the per-round context β beside
the application context and attachments, shared by the streaming and non-streaming paths. Three
things about it are load-bearing:
- Per round, never in the instructions. Agents are built once and cached while a thread lives for days, so a date baked into instruction text is stale after midnight β confidently wrong in exactly the way the fix removes.
- Both clocks, labelled. The user's local date answers "what day is today" for a human; the
ISO-8601
Zinstant is what the agent may hand back to a tool (see Machine-facing output above). The block also tells the agent NOT to convert calendar facts. - The zone comes from the identity, and has to RIDE there. By the time a round's prompt is
composed the agent runs on the agent hub, where the ambient
AccessContextis typically null or an impersonated System principal. SoAccessContext.TimeZoneIdis captured at the submit boundary ontoThreadMessage.SubmitterTimeZoneIdand rebuilt by the round-dispatch watcher β the same carrier, and the same argument, asSubmitterLocale(#948). Without it every viewer silently anchors to UTC.
Why this has its own page
The seam shipped with 7 render sites converted. An audit two weeks later found 15 more still formatting the raw value β the activity header, node Settings βΈ Timestamps, every chat timestamp, API token expiry, the GitHub issue/PR columns, the file browser β plus several plugin surfaces.
Nothing failed. Nothing was red. The only visible tell was two surfaces on the same page
disagreeing: the node header saying 16:04 while the comment beside it said 14:04.
That is why the rule is "route it through the seam", not "remember to convert".
See also
- Access Context Propagation β how the identity, and therefore the zone, reaches a render path.
- Data Binding β the contract every backend area follows.