diff --git a/src/ScadaLink.AuditLog/ScadaLink.AuditLog.csproj b/src/ScadaLink.AuditLog/ScadaLink.AuditLog.csproj
index 91c296c..821fb5b 100644
--- a/src/ScadaLink.AuditLog/ScadaLink.AuditLog.csproj
+++ b/src/ScadaLink.AuditLog/ScadaLink.AuditLog.csproj
@@ -21,6 +21,8 @@
IAuditLogRepository is registered by ScadaLink.ConfigurationDatabase; the project
reference is documented here so M2 writers + telemetry actors can depend on it. -->
+
+
diff --git a/src/ScadaLink.AuditLog/Telemetry/AuditEventMapper.cs b/src/ScadaLink.AuditLog/Telemetry/AuditEventMapper.cs
new file mode 100644
index 0000000..d821db0
--- /dev/null
+++ b/src/ScadaLink.AuditLog/Telemetry/AuditEventMapper.cs
@@ -0,0 +1,112 @@
+using ScadaLink.Commons.Entities.Audit;
+using ScadaLink.Commons.Types.Enums;
+using ScadaLink.Communication.Grpc;
+using Timestamp = Google.Protobuf.WellKnownTypes.Timestamp;
+
+namespace ScadaLink.AuditLog.Telemetry;
+
+///
+/// Bridges Audit Log (#23) rows between the in-process record
+/// and the wire-format exchanged over the
+/// IngestAuditEvents RPC.
+///
+///
+/// Lossy by design: the proto contract intentionally omits two fields.
+///
+/// - — site-local SQLite state, never travels.
+/// - — central-set at ingest time, not at the site.
+///
+///
+/// String nullability convention: proto3 scalar strings cannot be absent, so nullable
+/// .NET strings round-trip as empty strings on the wire. Nullable integers use the
+/// Int32Value wrapper so they preserve true null semantics.
+///
+///
+public static class AuditEventMapper
+{
+ ///
+ /// Projects an into its wire-format DTO. Null reference
+ /// fields collapse to empty strings; null integer fields leave the wrapper unset.
+ ///
+ public static AuditEventDto ToDto(AuditEvent evt)
+ {
+ ArgumentNullException.ThrowIfNull(evt);
+
+ var dto = new AuditEventDto
+ {
+ EventId = evt.EventId.ToString(),
+ OccurredAtUtc = Timestamp.FromDateTime(EnsureUtc(evt.OccurredAtUtc)),
+ Channel = evt.Channel.ToString(),
+ Kind = evt.Kind.ToString(),
+ CorrelationId = evt.CorrelationId?.ToString() ?? string.Empty,
+ SourceSiteId = evt.SourceSiteId ?? string.Empty,
+ SourceInstanceId = evt.SourceInstanceId ?? string.Empty,
+ SourceScript = evt.SourceScript ?? string.Empty,
+ Actor = evt.Actor ?? string.Empty,
+ Target = evt.Target ?? string.Empty,
+ Status = evt.Status.ToString(),
+ ErrorMessage = evt.ErrorMessage ?? string.Empty,
+ ErrorDetail = evt.ErrorDetail ?? string.Empty,
+ RequestSummary = evt.RequestSummary ?? string.Empty,
+ ResponseSummary = evt.ResponseSummary ?? string.Empty,
+ PayloadTruncated = evt.PayloadTruncated,
+ Extra = evt.Extra ?? string.Empty
+ };
+
+ if (evt.HttpStatus.HasValue)
+ {
+ dto.HttpStatus = evt.HttpStatus.Value;
+ }
+
+ if (evt.DurationMs.HasValue)
+ {
+ dto.DurationMs = evt.DurationMs.Value;
+ }
+
+ return dto;
+ }
+
+ ///
+ /// Reconstructs an from its wire-format DTO. Empty strings
+ /// rehydrate as null reference values; absent integer wrappers stay null.
+ /// and
+ /// are intentionally left null — the central ingest actor sets the latter.
+ ///
+ public static AuditEvent FromDto(AuditEventDto dto)
+ {
+ ArgumentNullException.ThrowIfNull(dto);
+
+ return new AuditEvent
+ {
+ EventId = Guid.Parse(dto.EventId),
+ OccurredAtUtc = DateTime.SpecifyKind(dto.OccurredAtUtc.ToDateTime(), DateTimeKind.Utc),
+ IngestedAtUtc = null,
+ Channel = Enum.Parse(dto.Channel),
+ Kind = Enum.Parse(dto.Kind),
+ CorrelationId = NullIfEmpty(dto.CorrelationId) is { } cid ? Guid.Parse(cid) : null,
+ SourceSiteId = NullIfEmpty(dto.SourceSiteId),
+ SourceInstanceId = NullIfEmpty(dto.SourceInstanceId),
+ SourceScript = NullIfEmpty(dto.SourceScript),
+ Actor = NullIfEmpty(dto.Actor),
+ Target = NullIfEmpty(dto.Target),
+ Status = Enum.Parse(dto.Status),
+ HttpStatus = dto.HttpStatus,
+ DurationMs = dto.DurationMs,
+ ErrorMessage = NullIfEmpty(dto.ErrorMessage),
+ ErrorDetail = NullIfEmpty(dto.ErrorDetail),
+ RequestSummary = NullIfEmpty(dto.RequestSummary),
+ ResponseSummary = NullIfEmpty(dto.ResponseSummary),
+ PayloadTruncated = dto.PayloadTruncated,
+ Extra = NullIfEmpty(dto.Extra),
+ ForwardState = null
+ };
+ }
+
+ private static string? NullIfEmpty(string? value) =>
+ string.IsNullOrEmpty(value) ? null : value;
+
+ private static DateTime EnsureUtc(DateTime value) =>
+ value.Kind == DateTimeKind.Utc
+ ? value
+ : DateTime.SpecifyKind(value.ToUniversalTime(), DateTimeKind.Utc);
+}
diff --git a/tests/ScadaLink.AuditLog.Tests/Telemetry/AuditEventMapperTests.cs b/tests/ScadaLink.AuditLog.Tests/Telemetry/AuditEventMapperTests.cs
new file mode 100644
index 0000000..6901361
--- /dev/null
+++ b/tests/ScadaLink.AuditLog.Tests/Telemetry/AuditEventMapperTests.cs
@@ -0,0 +1,224 @@
+using Google.Protobuf.WellKnownTypes;
+using ScadaLink.AuditLog.Telemetry;
+using ScadaLink.Commons.Entities.Audit;
+using ScadaLink.Commons.Types.Enums;
+using ScadaLink.Communication.Grpc;
+
+namespace ScadaLink.AuditLog.Tests.Telemetry;
+
+///
+/// Round-trip + edge tests for the that bridges
+/// (Commons) ↔ (proto).
+/// ForwardState is site-local and IngestedAtUtc is central-set, so neither survives
+/// the proto round-trip.
+///
+public class AuditEventMapperTests
+{
+ [Fact]
+ public void ToDto_FromDto_Roundtrip_FullyPopulated_PreservesAllFields()
+ {
+ var occurredAt = new DateTime(2026, 5, 20, 10, 15, 30, 123, DateTimeKind.Utc);
+ var ingestedAt = new DateTime(2026, 5, 20, 10, 15, 31, 0, DateTimeKind.Utc);
+ var correlationId = Guid.NewGuid();
+ var eventId = Guid.NewGuid();
+
+ var original = new AuditEvent
+ {
+ EventId = eventId,
+ OccurredAtUtc = occurredAt,
+ IngestedAtUtc = ingestedAt,
+ Channel = AuditChannel.ApiOutbound,
+ Kind = AuditKind.ApiCallCached,
+ CorrelationId = correlationId,
+ SourceSiteId = "site-1",
+ SourceInstanceId = "Pump01",
+ SourceScript = "OnDemand",
+ Actor = "design-key",
+ Target = "weather-api",
+ Status = AuditStatus.Forwarded,
+ HttpStatus = 200,
+ DurationMs = 42,
+ ErrorMessage = "transient timeout",
+ ErrorDetail = "stack-trace",
+ RequestSummary = "GET /weather",
+ ResponseSummary = "{ \"ok\": true }",
+ PayloadTruncated = true,
+ Extra = "{ \"retryCount\": 1 }",
+ ForwardState = AuditForwardState.Pending
+ };
+
+ var dto = AuditEventMapper.ToDto(original);
+ var roundTripped = AuditEventMapper.FromDto(dto);
+
+ Assert.Equal(original.EventId, roundTripped.EventId);
+ Assert.Equal(original.OccurredAtUtc, roundTripped.OccurredAtUtc);
+ Assert.Equal(original.Channel, roundTripped.Channel);
+ Assert.Equal(original.Kind, roundTripped.Kind);
+ Assert.Equal(original.CorrelationId, roundTripped.CorrelationId);
+ Assert.Equal(original.SourceSiteId, roundTripped.SourceSiteId);
+ Assert.Equal(original.SourceInstanceId, roundTripped.SourceInstanceId);
+ Assert.Equal(original.SourceScript, roundTripped.SourceScript);
+ Assert.Equal(original.Actor, roundTripped.Actor);
+ Assert.Equal(original.Target, roundTripped.Target);
+ Assert.Equal(original.Status, roundTripped.Status);
+ Assert.Equal(original.HttpStatus, roundTripped.HttpStatus);
+ Assert.Equal(original.DurationMs, roundTripped.DurationMs);
+ Assert.Equal(original.ErrorMessage, roundTripped.ErrorMessage);
+ Assert.Equal(original.ErrorDetail, roundTripped.ErrorDetail);
+ Assert.Equal(original.RequestSummary, roundTripped.RequestSummary);
+ Assert.Equal(original.ResponseSummary, roundTripped.ResponseSummary);
+ Assert.Equal(original.PayloadTruncated, roundTripped.PayloadTruncated);
+ Assert.Equal(original.Extra, roundTripped.Extra);
+
+ // ForwardState + IngestedAtUtc are NOT carried in the proto contract.
+ Assert.Null(roundTripped.ForwardState);
+ Assert.Null(roundTripped.IngestedAtUtc);
+ }
+
+ [Fact]
+ public void ToDto_NullableStringFields_BecomeEmptyString()
+ {
+ var evt = new AuditEvent
+ {
+ EventId = Guid.NewGuid(),
+ OccurredAtUtc = DateTime.UtcNow,
+ Channel = AuditChannel.Notification,
+ Kind = AuditKind.NotifySend,
+ Status = AuditStatus.Submitted
+ // all string? fields left null; CorrelationId null
+ };
+
+ var dto = AuditEventMapper.ToDto(evt);
+
+ Assert.Equal(string.Empty, dto.CorrelationId);
+ Assert.Equal(string.Empty, dto.SourceSiteId);
+ Assert.Equal(string.Empty, dto.SourceInstanceId);
+ Assert.Equal(string.Empty, dto.SourceScript);
+ Assert.Equal(string.Empty, dto.Actor);
+ Assert.Equal(string.Empty, dto.Target);
+ Assert.Equal(string.Empty, dto.ErrorMessage);
+ Assert.Equal(string.Empty, dto.ErrorDetail);
+ Assert.Equal(string.Empty, dto.RequestSummary);
+ Assert.Equal(string.Empty, dto.ResponseSummary);
+ Assert.Equal(string.Empty, dto.Extra);
+ }
+
+ [Fact]
+ public void FromDto_EmptyString_BecomesNullProperty()
+ {
+ var dto = new AuditEventDto
+ {
+ EventId = Guid.NewGuid().ToString(),
+ OccurredAtUtc = Timestamp.FromDateTime(DateTime.UtcNow),
+ Channel = nameof(AuditChannel.ApiOutbound),
+ Kind = nameof(AuditKind.ApiCall),
+ Status = nameof(AuditStatus.Submitted),
+ CorrelationId = string.Empty,
+ SourceSiteId = string.Empty,
+ SourceInstanceId = string.Empty,
+ SourceScript = string.Empty,
+ Actor = string.Empty,
+ Target = string.Empty,
+ ErrorMessage = string.Empty,
+ ErrorDetail = string.Empty,
+ RequestSummary = string.Empty,
+ ResponseSummary = string.Empty,
+ Extra = string.Empty
+ };
+
+ var evt = AuditEventMapper.FromDto(dto);
+
+ Assert.Null(evt.CorrelationId);
+ Assert.Null(evt.SourceSiteId);
+ Assert.Null(evt.SourceInstanceId);
+ Assert.Null(evt.SourceScript);
+ Assert.Null(evt.Actor);
+ Assert.Null(evt.Target);
+ Assert.Null(evt.ErrorMessage);
+ Assert.Null(evt.ErrorDetail);
+ Assert.Null(evt.RequestSummary);
+ Assert.Null(evt.ResponseSummary);
+ Assert.Null(evt.Extra);
+ }
+
+ [Fact]
+ public void ToDto_OccurredAtUtc_PreservesUtcKind()
+ {
+ var occurredAt = new DateTime(2026, 5, 20, 8, 0, 0, DateTimeKind.Utc);
+ var evt = new AuditEvent
+ {
+ EventId = Guid.NewGuid(),
+ OccurredAtUtc = occurredAt,
+ Channel = AuditChannel.DbOutbound,
+ Kind = AuditKind.DbWrite,
+ Status = AuditStatus.Delivered
+ };
+
+ var dto = AuditEventMapper.ToDto(evt);
+ var roundTripped = AuditEventMapper.FromDto(dto);
+
+ Assert.Equal(DateTimeKind.Utc, roundTripped.OccurredAtUtc.Kind);
+ Assert.Equal(occurredAt, roundTripped.OccurredAtUtc);
+ }
+
+ [Fact]
+ public void ToDto_NullableInt_BecomesNullInt32Value()
+ {
+ var evt = new AuditEvent
+ {
+ EventId = Guid.NewGuid(),
+ OccurredAtUtc = DateTime.UtcNow,
+ Channel = AuditChannel.Notification,
+ Kind = AuditKind.NotifySend,
+ Status = AuditStatus.Submitted,
+ HttpStatus = null,
+ DurationMs = null
+ };
+
+ var dto = AuditEventMapper.ToDto(evt);
+
+ Assert.Null(dto.HttpStatus);
+ Assert.Null(dto.DurationMs);
+ }
+
+ [Fact]
+ public void FromDto_NullInt32Value_BecomesNullProperty()
+ {
+ var dto = new AuditEventDto
+ {
+ EventId = Guid.NewGuid().ToString(),
+ OccurredAtUtc = Timestamp.FromDateTime(DateTime.UtcNow),
+ Channel = nameof(AuditChannel.ApiInbound),
+ Kind = nameof(AuditKind.InboundRequest),
+ Status = nameof(AuditStatus.Delivered)
+ // HttpStatus + DurationMs intentionally left absent
+ };
+
+ Assert.Null(dto.HttpStatus);
+ Assert.Null(dto.DurationMs);
+
+ var evt = AuditEventMapper.FromDto(dto);
+
+ Assert.Null(evt.HttpStatus);
+ Assert.Null(evt.DurationMs);
+ }
+
+ [Fact]
+ public void ToDto_EnumValues_StoredAsStringNames()
+ {
+ var evt = new AuditEvent
+ {
+ EventId = Guid.NewGuid(),
+ OccurredAtUtc = DateTime.UtcNow,
+ Channel = AuditChannel.ApiOutbound,
+ Kind = AuditKind.ApiCallCached,
+ Status = AuditStatus.Parked
+ };
+
+ var dto = AuditEventMapper.ToDto(evt);
+
+ Assert.Equal("ApiOutbound", dto.Channel);
+ Assert.Equal("ApiCallCached", dto.Kind);
+ Assert.Equal("Parked", dto.Status);
+ }
+}