using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification; using ZB.MOM.WW.ScadaBridge.Commons.Types; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using Timestamp = Google.Protobuf.WellKnownTypes.Timestamp; namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc; /// /// Canonical bridge between the seven in-process messages the site sends to central /// and the wire format of CentralControlService (Protos/central_control.proto). /// /// /// /// The seven pairs mirror, one for one, the seven messages /// SiteCommunicationActor forwards to /user/central-communication over /// Akka ClusterClient today. Both transports carry the SAME message types /// end-to-end — central's handlers are untouched by the migration — so this mapper is /// the only place the two representations meet, and a field that does not survive a /// round-trip here is a field the gRPC transport silently drops. /// /// /// Ingest is deliberately absent from the message list. IngestAuditEvents /// and IngestCachedTelemetry reuse the AuditEventBatch / /// CachedTelemetryBatch / IngestAck messages already defined for the /// site-hosted SiteStreamService, so the per-row work is delegated to the /// existing and ; only /// the batch/ack envelopes are assembled here. /// /// /// Conventions, applied uniformly across every method below. /// /// /// Nullable strings ↔ empty strings. A proto3 scalar string cannot be absent, /// so a null .NET string is written as and an empty wire /// string is read back as . This is the convention already in /// force on , and it is why no field on this wire /// may distinguish "null" from "deliberately empty". /// /// /// Nullable ↔ string. Execution ids travel as their "D" /// string form; the empty string means . A malformed non-empty /// value throws out of FromDto rather than degrading to null — a corrupt /// correlation id must not be laundered into "no correlation". /// /// /// Nullable numbers and booleans ↔ protobuf wrapper types. /// Int32Value/Int64Value/DoubleValue/BoolValue preserve /// true null. Several health gauges (LocalDbOplogBacklog, /// LocalDbReplicationConnected) are documented as "null means unknown, and /// that is NOT the same as zero/false"; collapsing them to a bare scalar would /// report a broken replication pair as healthy. /// /// /// Nullable collections ↔ wrapper messages. proto3 cannot express presence on /// a repeated or map field, so the three nullable /// collections travel inside single-field wrapper /// messages (ConnectionEndpointMapDto, TagQualityMapDto, /// NodeStatusListDto). An absent wrapper is null; a present-but-empty wrapper /// is an empty collection. /// /// /// normalizes to a UTC instant. A protobuf /// Timestamp is an instant, not an offset-qualified local time, so the offset /// component is dropped and the value round-trips with Offset == TimeSpan.Zero. /// Every producer in this system stamps UTC (the repo-wide invariant; e.g. /// Notify.Send uses DateTimeOffset.UtcNow), so this is lossless in /// practice and the instant is preserved regardless. /// /// /// public static class CentralControlDtoMapper { // ----------------------------------------------------------------------- // Notification Outbox (#21) // ----------------------------------------------------------------------- /// Projects a onto its wire DTO. /// The notification submission to project. /// The wire-format DTO; null strings and null execution ids collapse to empty strings. public static NotificationSubmitDto ToDto(NotificationSubmit msg) { ArgumentNullException.ThrowIfNull(msg); return new NotificationSubmitDto { NotificationId = msg.NotificationId, ListName = msg.ListName, Subject = msg.Subject, Body = msg.Body, SourceSiteId = msg.SourceSiteId, SourceInstanceId = msg.SourceInstanceId ?? string.Empty, SourceScript = msg.SourceScript ?? string.Empty, SiteEnqueuedAt = Timestamp.FromDateTimeOffset(msg.SiteEnqueuedAt), OriginExecutionId = GuidToWire(msg.OriginExecutionId), OriginParentExecutionId = GuidToWire(msg.OriginParentExecutionId), SourceNode = msg.SourceNode ?? string.Empty, }; } /// Reconstructs a from its wire DTO. /// The wire-format DTO to reconstruct. /// The in-process message; empty strings rehydrate as null. public static NotificationSubmit FromDto(NotificationSubmitDto dto) { ArgumentNullException.ThrowIfNull(dto); return new NotificationSubmit( NotificationId: dto.NotificationId, ListName: dto.ListName, Subject: dto.Subject, Body: dto.Body, SourceSiteId: dto.SourceSiteId, SourceInstanceId: NullIfEmpty(dto.SourceInstanceId), SourceScript: NullIfEmpty(dto.SourceScript), SiteEnqueuedAt: dto.SiteEnqueuedAt.ToDateTimeOffset(), OriginExecutionId: GuidFromWire(dto.OriginExecutionId), OriginParentExecutionId: GuidFromWire(dto.OriginParentExecutionId), SourceNode: NullIfEmpty(dto.SourceNode)); } /// Projects a onto its wire DTO. /// The ack to project. /// The wire-format DTO; a null error collapses to an empty string. public static NotificationSubmitAckDto ToDto(NotificationSubmitAck msg) { ArgumentNullException.ThrowIfNull(msg); return new NotificationSubmitAckDto { NotificationId = msg.NotificationId, Accepted = msg.Accepted, Error = msg.Error ?? string.Empty, }; } /// Reconstructs a from its wire DTO. /// The wire-format DTO to reconstruct. /// The in-process ack; an empty error rehydrates as null. public static NotificationSubmitAck FromDto(NotificationSubmitAckDto dto) { ArgumentNullException.ThrowIfNull(dto); return new NotificationSubmitAck( NotificationId: dto.NotificationId, Accepted: dto.Accepted, Error: NullIfEmpty(dto.Error)); } /// Projects a onto its wire DTO. /// The status query to project. /// The wire-format DTO. public static NotificationStatusQueryDto ToDto(NotificationStatusQuery msg) { ArgumentNullException.ThrowIfNull(msg); return new NotificationStatusQueryDto { CorrelationId = msg.CorrelationId, NotificationId = msg.NotificationId, }; } /// Reconstructs a from its wire DTO. /// The wire-format DTO to reconstruct. /// The in-process query. public static NotificationStatusQuery FromDto(NotificationStatusQueryDto dto) { ArgumentNullException.ThrowIfNull(dto); return new NotificationStatusQuery( CorrelationId: dto.CorrelationId, NotificationId: dto.NotificationId); } /// Projects a onto its wire DTO. /// The status response to project. /// The wire-format DTO; a null delivery timestamp leaves the field unset. public static NotificationStatusResponseDto ToDto(NotificationStatusResponse msg) { ArgumentNullException.ThrowIfNull(msg); var dto = new NotificationStatusResponseDto { CorrelationId = msg.CorrelationId, Found = msg.Found, Status = msg.Status, RetryCount = msg.RetryCount, LastError = msg.LastError ?? string.Empty, }; if (msg.DeliveredAt.HasValue) { dto.DeliveredAt = Timestamp.FromDateTimeOffset(msg.DeliveredAt.Value); } return dto; } /// Reconstructs a from its wire DTO. /// The wire-format DTO to reconstruct. /// The in-process response; an unset delivery timestamp rehydrates as null. public static NotificationStatusResponse FromDto(NotificationStatusResponseDto dto) { ArgumentNullException.ThrowIfNull(dto); return new NotificationStatusResponse( CorrelationId: dto.CorrelationId, Found: dto.Found, Status: dto.Status, RetryCount: dto.RetryCount, LastError: NullIfEmpty(dto.LastError), DeliveredAt: dto.DeliveredAt?.ToDateTimeOffset()); } // ----------------------------------------------------------------------- // Audit Log (#23) ingest — envelopes only; rows go through the existing mappers // ----------------------------------------------------------------------- /// /// Projects an onto the shared /// wire message. /// /// /// The per-row projection is , which is lossy /// by design: ForwardState is site-local storage state and IngestedAtUtc /// is stamped centrally at ingest, so neither travels. /// /// The ingest command to project. /// A batch carrying one DTO per audit event, in order. public static AuditEventBatch ToDto(IngestAuditEventsCommand cmd) { ArgumentNullException.ThrowIfNull(cmd); var batch = new AuditEventBatch(); foreach (var evt in cmd.Events) { batch.Events.Add(AuditEventDtoMapper.ToDto(evt)); } return batch; } /// /// Reconstructs an from the shared /// wire message — the shape central's /// CentralCommunicationActor already handles. /// /// The wire batch to reconstruct. /// The in-process ingest command. public static IngestAuditEventsCommand FromDto(AuditEventBatch batch) { ArgumentNullException.ThrowIfNull(batch); var events = new List(batch.Events.Count); foreach (var dto in batch.Events) { events.Add(AuditEventDtoMapper.FromDto(dto)); } return new IngestAuditEventsCommand(events); } /// /// Projects an onto the shared /// wire message. /// /// The cached-telemetry ingest command to project. /// A batch carrying one packet (audit row + operational row) per entry, in order. public static CachedTelemetryBatch ToDto(IngestCachedTelemetryCommand cmd) { ArgumentNullException.ThrowIfNull(cmd); var batch = new CachedTelemetryBatch(); foreach (var entry in cmd.Entries) { batch.Packets.Add(new CachedTelemetryPacket { AuditEvent = AuditEventDtoMapper.ToDto(entry.Audit), Operational = SiteCallDtoMapper.ToDto(entry.SiteCall), }); } return batch; } /// /// Reconstructs an from the shared /// wire message. /// /// The wire batch to reconstruct. /// The in-process dual-write ingest command. public static IngestCachedTelemetryCommand FromDto(CachedTelemetryBatch batch) { ArgumentNullException.ThrowIfNull(batch); var entries = new List(batch.Packets.Count); foreach (var packet in batch.Packets) { entries.Add(new CachedTelemetryEntry( AuditEventDtoMapper.FromDto(packet.AuditEvent), SiteCallDtoMapper.FromDto(packet.Operational))); } return new IngestCachedTelemetryCommand(entries); } /// /// Projects the accepted-id list of an ingest reply onto the shared /// wire message. Shared by both ingest RPCs — the two /// central reply types differ only in which handler produced them. /// /// Ids central considers durably persisted. /// The wire ack carrying the ids in "D" string form, in order. public static IngestAck ToIngestAck(IReadOnlyList acceptedEventIds) { ArgumentNullException.ThrowIfNull(acceptedEventIds); var ack = new IngestAck(); foreach (var id in acceptedEventIds) { ack.AcceptedEventIds.Add(id.ToString()); } return ack; } /// /// Reads the accepted-id list back out of an . /// /// The wire ack to read. /// The accepted event ids, in wire order. public static IReadOnlyList FromIngestAck(IngestAck ack) { ArgumentNullException.ThrowIfNull(ack); var ids = new List(ack.AcceptedEventIds.Count); foreach (var id in ack.AcceptedEventIds) { ids.Add(Guid.Parse(id)); } return ids; } // ----------------------------------------------------------------------- // Startup reconciliation // ----------------------------------------------------------------------- /// Projects a onto its wire DTO. /// The reconcile request to project. /// The wire-format DTO carrying the node's local name→revision-hash inventory. public static ReconcileSiteRequestDto ToDto(ReconcileSiteRequest msg) { ArgumentNullException.ThrowIfNull(msg); var dto = new ReconcileSiteRequestDto { SiteIdentifier = msg.SiteIdentifier, NodeId = msg.NodeId, }; foreach (var (name, hash) in msg.LocalNameToRevisionHash) { dto.LocalNameToRevisionHash[name] = hash; } return dto; } /// Reconstructs a from its wire DTO. /// The wire-format DTO to reconstruct. /// The in-process reconcile request. public static ReconcileSiteRequest FromDto(ReconcileSiteRequestDto dto) { ArgumentNullException.ThrowIfNull(dto); return new ReconcileSiteRequest( SiteIdentifier: dto.SiteIdentifier, NodeId: dto.NodeId, LocalNameToRevisionHash: new Dictionary(dto.LocalNameToRevisionHash)); } /// Projects a onto its wire DTO. /// The reconcile response to project. /// The wire-format DTO carrying the gap items, orphan names and fetch base URL. public static ReconcileSiteResponseDto ToDto(ReconcileSiteResponse msg) { ArgumentNullException.ThrowIfNull(msg); var dto = new ReconcileSiteResponseDto { CentralFetchBaseUrl = msg.CentralFetchBaseUrl, }; foreach (var item in msg.Gap) { dto.Gap.Add(new ReconcileGapItemDto { InstanceUniqueName = item.InstanceUniqueName, DeploymentId = item.DeploymentId, RevisionHash = item.RevisionHash, IsEnabled = item.IsEnabled, FetchToken = item.FetchToken, }); } dto.OrphanNames.AddRange(msg.OrphanNames); return dto; } /// Reconstructs a from its wire DTO. /// The wire-format DTO to reconstruct. /// The in-process reconcile response. public static ReconcileSiteResponse FromDto(ReconcileSiteResponseDto dto) { ArgumentNullException.ThrowIfNull(dto); var gap = new List(dto.Gap.Count); foreach (var item in dto.Gap) { gap.Add(new ReconcileGapItem( InstanceUniqueName: item.InstanceUniqueName, DeploymentId: item.DeploymentId, RevisionHash: item.RevisionHash, IsEnabled: item.IsEnabled, FetchToken: item.FetchToken)); } return new ReconcileSiteResponse( Gap: gap, OrphanNames: dto.OrphanNames.ToList(), CentralFetchBaseUrl: dto.CentralFetchBaseUrl); } // ----------------------------------------------------------------------- // Health Monitoring (#11) // ----------------------------------------------------------------------- /// Projects a onto its wire DTO. /// /// The three nullable collections travel inside wrapper messages so a null stays /// distinguishable from an empty collection; the nullable gauges travel in protobuf /// wrapper types for the same reason. /// /// The health report to project. /// The wire-format DTO. public static SiteHealthReportDto ToDto(SiteHealthReport msg) { ArgumentNullException.ThrowIfNull(msg); var dto = new SiteHealthReportDto { SiteId = msg.SiteId, SequenceNumber = msg.SequenceNumber, ReportTimestamp = Timestamp.FromDateTimeOffset(msg.ReportTimestamp), ScriptErrorCount = msg.ScriptErrorCount, AlarmEvaluationErrorCount = msg.AlarmEvaluationErrorCount, DeadLetterCount = msg.DeadLetterCount, DeployedInstanceCount = msg.DeployedInstanceCount, EnabledInstanceCount = msg.EnabledInstanceCount, DisabledInstanceCount = msg.DisabledInstanceCount, NodeRole = msg.NodeRole, NodeHostname = msg.NodeHostname, ParkedMessageCount = msg.ParkedMessageCount, SiteAuditWriteFailures = msg.SiteAuditWriteFailures, AuditRedactionFailure = msg.AuditRedactionFailure, SiteEventLogWriteFailures = msg.SiteEventLogWriteFailures, OldestParkedMessageAgeSeconds = msg.OldestParkedMessageAgeSeconds, ScriptQueueDepth = msg.ScriptQueueDepth, ScriptBusyThreads = msg.ScriptBusyThreads, ScriptOldestBusyAgeSeconds = msg.ScriptOldestBusyAgeSeconds, LocalDbReplicationConnected = msg.LocalDbReplicationConnected, LocalDbOplogBacklog = msg.LocalDbOplogBacklog, }; foreach (var (name, health) in msg.DataConnectionStatuses) { dto.DataConnectionStatuses[name] = ToDto(health); } foreach (var (name, resolution) in msg.TagResolutionCounts) { dto.TagResolutionCounts[name] = new TagResolutionStatusDto { TotalSubscribed = resolution.TotalSubscribed, SuccessfullyResolved = resolution.SuccessfullyResolved, }; } foreach (var (name, depth) in msg.StoreAndForwardBufferDepths) { dto.StoreAndForwardBufferDepths[name] = depth; } if (msg.DataConnectionEndpoints is { } endpoints) { var wrapper = new ConnectionEndpointMapDto(); foreach (var (name, endpoint) in endpoints) { wrapper.Entries[name] = endpoint; } dto.DataConnectionEndpoints = wrapper; } if (msg.DataConnectionTagQuality is { } tagQuality) { var wrapper = new TagQualityMapDto(); foreach (var (name, counts) in tagQuality) { wrapper.Entries[name] = new TagQualityCountsDto { Good = counts.Good, Bad = counts.Bad, Uncertain = counts.Uncertain, }; } dto.DataConnectionTagQuality = wrapper; } if (msg.ClusterNodes is { } clusterNodes) { var wrapper = new NodeStatusListDto(); foreach (var node in clusterNodes) { wrapper.Nodes.Add(new NodeStatusDto { Hostname = node.Hostname, IsOnline = node.IsOnline, Role = node.Role, }); } dto.ClusterNodes = wrapper; } if (msg.SiteAuditBacklog is { } backlog) { var snapshot = new SiteAuditBacklogSnapshotDto { PendingCount = backlog.PendingCount, OnDiskBytes = backlog.OnDiskBytes, }; if (backlog.OldestPendingUtc.HasValue) { snapshot.OldestPendingUtc = Timestamp.FromDateTime(EnsureUtc(backlog.OldestPendingUtc.Value)); } dto.SiteAuditBacklog = snapshot; } return dto; } /// Reconstructs a from its wire DTO. /// The wire-format DTO to reconstruct. /// The in-process health report; absent wrappers rehydrate as null, not as empty. public static SiteHealthReport FromDto(SiteHealthReportDto dto) { ArgumentNullException.ThrowIfNull(dto); var statuses = new Dictionary(dto.DataConnectionStatuses.Count); foreach (var (name, health) in dto.DataConnectionStatuses) { statuses[name] = FromDto(health); } var resolution = new Dictionary(dto.TagResolutionCounts.Count); foreach (var (name, counts) in dto.TagResolutionCounts) { resolution[name] = new TagResolutionStatus(counts.TotalSubscribed, counts.SuccessfullyResolved); } var bufferDepths = new Dictionary(dto.StoreAndForwardBufferDepths); Dictionary? endpoints = null; if (dto.DataConnectionEndpoints is { } endpointWrapper) { endpoints = new Dictionary(endpointWrapper.Entries); } Dictionary? tagQuality = null; if (dto.DataConnectionTagQuality is { } tagQualityWrapper) { tagQuality = new Dictionary(tagQualityWrapper.Entries.Count); foreach (var (name, counts) in tagQualityWrapper.Entries) { tagQuality[name] = new TagQualityCounts(counts.Good, counts.Bad, counts.Uncertain); } } List? clusterNodes = null; if (dto.ClusterNodes is { } nodeWrapper) { clusterNodes = new List(nodeWrapper.Nodes.Count); foreach (var node in nodeWrapper.Nodes) { clusterNodes.Add(new NodeStatus(node.Hostname, node.IsOnline, node.Role)); } } SiteAuditBacklogSnapshot? backlog = null; if (dto.SiteAuditBacklog is { } snapshot) { backlog = new SiteAuditBacklogSnapshot( PendingCount: snapshot.PendingCount, OldestPendingUtc: snapshot.OldestPendingUtc is null ? null : DateTime.SpecifyKind(snapshot.OldestPendingUtc.ToDateTime(), DateTimeKind.Utc), OnDiskBytes: snapshot.OnDiskBytes); } return new SiteHealthReport( SiteId: dto.SiteId, SequenceNumber: dto.SequenceNumber, ReportTimestamp: dto.ReportTimestamp.ToDateTimeOffset(), DataConnectionStatuses: statuses, TagResolutionCounts: resolution, ScriptErrorCount: dto.ScriptErrorCount, AlarmEvaluationErrorCount: dto.AlarmEvaluationErrorCount, StoreAndForwardBufferDepths: bufferDepths, DeadLetterCount: dto.DeadLetterCount, DeployedInstanceCount: dto.DeployedInstanceCount, EnabledInstanceCount: dto.EnabledInstanceCount, DisabledInstanceCount: dto.DisabledInstanceCount, NodeRole: dto.NodeRole, NodeHostname: dto.NodeHostname, DataConnectionEndpoints: endpoints, DataConnectionTagQuality: tagQuality, ParkedMessageCount: dto.ParkedMessageCount, ClusterNodes: clusterNodes, SiteAuditWriteFailures: dto.SiteAuditWriteFailures, AuditRedactionFailure: dto.AuditRedactionFailure, SiteAuditBacklog: backlog, SiteEventLogWriteFailures: dto.SiteEventLogWriteFailures, OldestParkedMessageAgeSeconds: dto.OldestParkedMessageAgeSeconds) { // Init-only members: SiteHealthReport surfaces the scheduler and LocalDb // gauges as init properties rather than positional parameters, so they // cannot be passed to the constructor above. ScriptQueueDepth = dto.ScriptQueueDepth, ScriptBusyThreads = dto.ScriptBusyThreads, ScriptOldestBusyAgeSeconds = dto.ScriptOldestBusyAgeSeconds, LocalDbReplicationConnected = dto.LocalDbReplicationConnected, LocalDbOplogBacklog = dto.LocalDbOplogBacklog, }; } /// Projects a onto its wire DTO. /// The ack to project. /// The wire-format DTO; a null error collapses to an empty string. public static SiteHealthReportAckDto ToDto(SiteHealthReportAck msg) { ArgumentNullException.ThrowIfNull(msg); return new SiteHealthReportAckDto { SiteId = msg.SiteId, SequenceNumber = msg.SequenceNumber, Accepted = msg.Accepted, Error = msg.Error ?? string.Empty, }; } /// Reconstructs a from its wire DTO. /// The wire-format DTO to reconstruct. /// The in-process ack; an empty error rehydrates as null. public static SiteHealthReportAck FromDto(SiteHealthReportAckDto dto) { ArgumentNullException.ThrowIfNull(dto); return new SiteHealthReportAck( SiteId: dto.SiteId, SequenceNumber: dto.SequenceNumber, Accepted: dto.Accepted, Error: NullIfEmpty(dto.Error)); } /// Projects a onto its wire DTO. /// The heartbeat to project. /// The wire-format DTO. public static HeartbeatDto ToDto(HeartbeatMessage msg) { ArgumentNullException.ThrowIfNull(msg); return new HeartbeatDto { SiteId = msg.SiteId, NodeHostname = msg.NodeHostname, IsActive = msg.IsActive, Timestamp = Timestamp.FromDateTimeOffset(msg.Timestamp), }; } /// Reconstructs a from its wire DTO. /// The wire-format DTO to reconstruct. /// The in-process heartbeat. public static HeartbeatMessage FromDto(HeartbeatDto dto) { ArgumentNullException.ThrowIfNull(dto); return new HeartbeatMessage( SiteId: dto.SiteId, NodeHostname: dto.NodeHostname, IsActive: dto.IsActive, Timestamp: dto.Timestamp.ToDateTimeOffset()); } /// Projects a onto its wire enum. /// The connection health to project. /// The wire enum value. Never ConnectionHealthUnspecified. /// /// A value was added without extending this mapping. /// Throwing beats a silent default: an unmapped state would otherwise be reported as /// whichever value happened to be first. /// public static ConnectionHealthEnum ToDto(ConnectionHealth health) => health switch { ConnectionHealth.Connected => ConnectionHealthEnum.ConnectionHealthConnected, ConnectionHealth.Disconnected => ConnectionHealthEnum.ConnectionHealthDisconnected, ConnectionHealth.Connecting => ConnectionHealthEnum.ConnectionHealthConnecting, ConnectionHealth.Error => ConnectionHealthEnum.ConnectionHealthError, _ => throw new ArgumentOutOfRangeException(nameof(health), health, "Unmapped ConnectionHealth value"), }; /// Reconstructs a from its wire enum. /// /// An unspecified or unknown wire value decodes to , /// never to . A newer site sending a value this /// build has never heard of must not have it rendered as "healthy" on the central /// health page — the safe direction for an unknown connection state is "not working". /// /// The wire enum value to reconstruct. /// The in-process connection health. public static ConnectionHealth FromDto(ConnectionHealthEnum health) => health switch { ConnectionHealthEnum.ConnectionHealthConnected => ConnectionHealth.Connected, ConnectionHealthEnum.ConnectionHealthDisconnected => ConnectionHealth.Disconnected, ConnectionHealthEnum.ConnectionHealthConnecting => ConnectionHealth.Connecting, _ => ConnectionHealth.Error, }; private static string GuidToWire(Guid? value) => value?.ToString() ?? string.Empty; private static Guid? GuidFromWire(string? value) => string.IsNullOrEmpty(value) ? null : Guid.Parse(value); private static string? NullIfEmpty(string? value) => string.IsNullOrEmpty(value) ? null : value; // All ScadaBridge timestamps are UTC by invariant; Timestamp.FromDateTime requires // UTC kind. Specify (never convert) so a value read back from SQLite with Kind=Utc // passes through and a defensively-unspecified one is treated as the UTC it already // is. Mirrors AuditEventDtoMapper/SiteCallDtoMapper.EnsureUtc. private static DateTime EnsureUtc(DateTime value) => value.Kind == DateTimeKind.Utc ? value : DateTime.SpecifyKind(value, DateTimeKind.Utc); }