diff --git a/src/ScadaLink.Commons/Entities/Audit/AuditEvent.cs b/src/ScadaLink.Commons/Entities/Audit/AuditEvent.cs
new file mode 100644
index 0000000..f5148a3
--- /dev/null
+++ b/src/ScadaLink.Commons/Entities/Audit/AuditEvent.cs
@@ -0,0 +1,73 @@
+using ScadaLink.Commons.Types.Enums;
+
+namespace ScadaLink.Commons.Entities.Audit;
+
+///
+/// Single source of truth for AuditLog (#23) rows. Central rows leave ForwardState null;
+/// site rows leave IngestedAtUtc null until ingest. Append-only.
+///
+public sealed record AuditEvent
+{
+ /// Idempotency key; uniquely identifies one audit lifecycle event.
+ public Guid EventId { get; init; }
+
+ /// UTC timestamp when the audited action occurred at its source.
+ public DateTime OccurredAtUtc { get; init; }
+
+ /// UTC timestamp when the row was ingested at central; null on the site hot-path.
+ public DateTime? IngestedAtUtc { get; init; }
+
+ /// Trust-boundary channel the audited action crossed.
+ public AuditChannel Channel { get; init; }
+
+ /// Specific event kind within the channel (see alog.md ยง4).
+ public AuditKind Kind { get; init; }
+
+ /// Correlation id linking related audit rows (e.g. the cached-op lifecycle).
+ public Guid? CorrelationId { get; init; }
+
+ /// Site id where the action originated; null for central-direct events.
+ public string? SourceSiteId { get; init; }
+
+ /// Instance id where the action originated, when applicable.
+ public string? SourceInstanceId { get; init; }
+
+ /// Script that initiated the action (script trust boundary), when applicable.
+ public string? SourceScript { get; init; }
+
+ /// Authenticated actor for inbound paths (API key name, user, etc.).
+ public string? Actor { get; init; }
+
+ /// Target of the action: external system name, db connection name, list name, or inbound method.
+ public string? Target { get; init; }
+
+ /// Lifecycle status of this row.
+ public AuditStatus Status { get; init; }
+
+ /// HTTP status code where applicable (outbound API + inbound API).
+ public int? HttpStatus { get; init; }
+
+ /// Duration of the audited action in milliseconds, when measurable.
+ public int? DurationMs { get; init; }
+
+ /// Human-readable error summary on failure rows.
+ public string? ErrorMessage { get; init; }
+
+ /// Verbose error detail (stack/exception) on failure rows.
+ public string? ErrorDetail { get; init; }
+
+ /// Truncated/redacted request summary; capped per AuditLogOptions.
+ public string? RequestSummary { get; init; }
+
+ /// Truncated/redacted response summary; capped per AuditLogOptions.
+ public string? ResponseSummary { get; init; }
+
+ /// True when Request/Response summaries were truncated to the payload cap.
+ public bool PayloadTruncated { get; init; }
+
+ /// Free-form JSON extension column for channel-specific extras.
+ public string? Extra { get; init; }
+
+ /// Site-local forwarding state; null on central rows.
+ public AuditForwardState? ForwardState { get; init; }
+}
diff --git a/tests/ScadaLink.Commons.Tests/Entities/Audit/AuditEventTests.cs b/tests/ScadaLink.Commons.Tests/Entities/Audit/AuditEventTests.cs
new file mode 100644
index 0000000..37ccb9f
--- /dev/null
+++ b/tests/ScadaLink.Commons.Tests/Entities/Audit/AuditEventTests.cs
@@ -0,0 +1,135 @@
+using ScadaLink.Commons.Entities.Audit;
+using ScadaLink.Commons.Types.Enums;
+
+namespace ScadaLink.Commons.Tests.Entities.Audit;
+
+///
+/// Verifies behaves as an init-only record:
+/// every property reads back as constructed, and with expressions
+/// produce a new instance with a single property changed.
+///
+public class AuditEventTests
+{
+ [Fact]
+ public void Construction_AllPropertiesReadBack()
+ {
+ var eventId = Guid.NewGuid();
+ var occurredAt = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc);
+ var ingestedAt = new DateTime(2026, 5, 20, 12, 0, 1, DateTimeKind.Utc);
+ var corrId = Guid.NewGuid();
+
+ var evt = new AuditEvent
+ {
+ EventId = eventId,
+ OccurredAtUtc = occurredAt,
+ IngestedAtUtc = ingestedAt,
+ Channel = AuditChannel.ApiOutbound,
+ Kind = AuditKind.ApiCall,
+ CorrelationId = corrId,
+ SourceSiteId = "site-01",
+ SourceInstanceId = "inst-7",
+ SourceScript = "OnAlarm",
+ Actor = "system",
+ Target = "WeatherAPI",
+ Status = AuditStatus.Delivered,
+ HttpStatus = 200,
+ DurationMs = 42,
+ ErrorMessage = null,
+ ErrorDetail = null,
+ RequestSummary = "GET /forecast",
+ ResponseSummary = "{\"temp\":21}",
+ PayloadTruncated = false,
+ Extra = "{}",
+ ForwardState = AuditForwardState.Forwarded
+ };
+
+ Assert.Equal(eventId, evt.EventId);
+ Assert.Equal(occurredAt, evt.OccurredAtUtc);
+ Assert.Equal(ingestedAt, evt.IngestedAtUtc);
+ Assert.Equal(AuditChannel.ApiOutbound, evt.Channel);
+ Assert.Equal(AuditKind.ApiCall, evt.Kind);
+ Assert.Equal(corrId, evt.CorrelationId);
+ Assert.Equal("site-01", evt.SourceSiteId);
+ Assert.Equal("inst-7", evt.SourceInstanceId);
+ Assert.Equal("OnAlarm", evt.SourceScript);
+ Assert.Equal("system", evt.Actor);
+ Assert.Equal("WeatherAPI", evt.Target);
+ Assert.Equal(AuditStatus.Delivered, evt.Status);
+ Assert.Equal(200, evt.HttpStatus);
+ Assert.Equal(42, evt.DurationMs);
+ Assert.Null(evt.ErrorMessage);
+ Assert.Null(evt.ErrorDetail);
+ Assert.Equal("GET /forecast", evt.RequestSummary);
+ Assert.Equal("{\"temp\":21}", evt.ResponseSummary);
+ Assert.False(evt.PayloadTruncated);
+ Assert.Equal("{}", evt.Extra);
+ Assert.Equal(AuditForwardState.Forwarded, evt.ForwardState);
+ }
+
+ [Fact]
+ public void NullableProperties_AcceptNull()
+ {
+ var evt = new AuditEvent
+ {
+ EventId = Guid.NewGuid(),
+ OccurredAtUtc = DateTime.UtcNow,
+ IngestedAtUtc = null,
+ Channel = AuditChannel.Notification,
+ Kind = AuditKind.NotifySend,
+ CorrelationId = null,
+ SourceSiteId = null,
+ SourceInstanceId = null,
+ SourceScript = null,
+ Actor = null,
+ Target = null,
+ Status = AuditStatus.Submitted,
+ HttpStatus = null,
+ DurationMs = null,
+ ErrorMessage = null,
+ ErrorDetail = null,
+ RequestSummary = null,
+ ResponseSummary = null,
+ PayloadTruncated = false,
+ Extra = null,
+ ForwardState = null
+ };
+
+ Assert.Null(evt.IngestedAtUtc);
+ 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.HttpStatus);
+ Assert.Null(evt.DurationMs);
+ Assert.Null(evt.ErrorMessage);
+ Assert.Null(evt.ErrorDetail);
+ Assert.Null(evt.RequestSummary);
+ Assert.Null(evt.ResponseSummary);
+ Assert.Null(evt.Extra);
+ Assert.Null(evt.ForwardState);
+ }
+
+ [Fact]
+ public void With_ProducesNewInstance_WithSingleFieldChanged()
+ {
+ var original = new AuditEvent
+ {
+ EventId = Guid.NewGuid(),
+ OccurredAtUtc = DateTime.UtcNow,
+ Channel = AuditChannel.ApiOutbound,
+ Kind = AuditKind.ApiCall,
+ Status = AuditStatus.Submitted,
+ PayloadTruncated = false
+ };
+
+ var updated = original with { Status = AuditStatus.Delivered };
+
+ Assert.NotSame(original, updated);
+ Assert.Equal(AuditStatus.Submitted, original.Status);
+ Assert.Equal(AuditStatus.Delivered, updated.Status);
+ Assert.Equal(original.EventId, updated.EventId);
+ Assert.NotEqual(original, updated);
+ }
+}