diff --git a/src/ScadaLink.Commons/Messages/Integration/CachedCallTelemetry.cs b/src/ScadaLink.Commons/Messages/Integration/CachedCallTelemetry.cs
new file mode 100644
index 0000000..37532aa
--- /dev/null
+++ b/src/ScadaLink.Commons/Messages/Integration/CachedCallTelemetry.cs
@@ -0,0 +1,34 @@
+using ScadaLink.Commons.Entities.Audit;
+using ScadaLink.Commons.Types;
+
+namespace ScadaLink.Commons.Messages.Integration;
+
+///
+/// Combined audit + operational telemetry packet for cached outbound calls
+/// (Audit Log #23 / M3). The site emits one packet per lifecycle event
+/// — Submit (Audit kind CachedSubmit), per-attempt
+/// ApiCallCached/DbWriteCached, terminal CachedResolve —
+/// and central writes the row plus the
+/// upsert in one MS SQL transaction. Two
+/// payload shapes ride on a single wire message so the same on-the-wire batch
+/// can carry mixed lifecycle events without the central dual-write needing a
+/// second RPC for the operational state.
+///
+///
+///
+/// Both inner records carry the same TrackedOperationId — the
+/// idempotency key end-to-end. The
+/// pattern (used by Audit Log #23 to thread cached-call rows together) is
+/// honoured by the site emitter; the packet itself is shape-only and makes no
+/// independent correlation guarantees.
+///
+///
+/// Additive-only per Commons REQ-COM-5a (M2 reviewer note) — this is a new
+/// message, not a rename of any existing M2 envelope.
+///
+///
+/// The Audit Log #23 row to insert at central.
+/// The SiteCalls upsert mirroring this lifecycle event.
+public sealed record CachedCallTelemetry(
+ AuditEvent Audit,
+ SiteCallOperational Operational);
diff --git a/src/ScadaLink.Commons/Types/SiteCallOperational.cs b/src/ScadaLink.Commons/Types/SiteCallOperational.cs
new file mode 100644
index 0000000..25d9414
--- /dev/null
+++ b/src/ScadaLink.Commons/Types/SiteCallOperational.cs
@@ -0,0 +1,46 @@
+namespace ScadaLink.Commons.Types;
+
+///
+/// Operational state of one cached call as seen by the site, carried on the
+/// combined CachedCallTelemetry packet (Audit Log #23 / M3) and persisted
+/// at central as the SiteCalls row mirroring the call's status.
+///
+///
+///
+/// One row per at central; ingest is
+/// insert-if-not-exists then upsert-on-newer-status (monotonic — never rolls
+/// back). The site remains the source of truth — this record is the
+/// "eventually-consistent mirror" the central UI's Site Calls page reads.
+///
+///
+/// Idempotency key shared with the audit row.
+///
+/// Trust-boundary channel — "ApiOutbound" or "DbOutbound". String form
+/// (not the enum) so the
+/// record serialises identically across SQL / gRPC / JSON boundaries.
+///
+/// Human-readable target (e.g. "ERP.GetOrder").
+/// Site id that submitted the cached call.
+///
+/// Lifecycle status — string form of :
+/// Submitted, Retrying, Attempted, Delivered,
+/// Failed, Parked, Discarded.
+///
+/// Number of dispatch attempts so far; 0 prior to first attempt.
+/// Most recent error message; null when no failures have occurred.
+/// Most recent HTTP status code (API calls only); null otherwise.
+/// UTC timestamp the cached call was first submitted.
+/// UTC timestamp of the latest status mutation.
+/// UTC timestamp the row reached a terminal status; null while still active.
+public sealed record SiteCallOperational(
+ TrackedOperationId TrackedOperationId,
+ string Channel,
+ string Target,
+ string SourceSite,
+ string Status,
+ int RetryCount,
+ string? LastError,
+ int? HttpStatus,
+ DateTime CreatedAtUtc,
+ DateTime UpdatedAtUtc,
+ DateTime? TerminalAtUtc);
diff --git a/tests/ScadaLink.Commons.Tests/Messages/Integration/CachedCallTelemetryTests.cs b/tests/ScadaLink.Commons.Tests/Messages/Integration/CachedCallTelemetryTests.cs
new file mode 100644
index 0000000..03980d8
--- /dev/null
+++ b/tests/ScadaLink.Commons.Tests/Messages/Integration/CachedCallTelemetryTests.cs
@@ -0,0 +1,203 @@
+using ScadaLink.Commons.Entities.Audit;
+using ScadaLink.Commons.Messages.Integration;
+using ScadaLink.Commons.Types;
+using ScadaLink.Commons.Types.Enums;
+
+namespace ScadaLink.Commons.Tests.Messages.Integration;
+
+///
+/// Audit Log #23 (M3 Bundle A — Task A4) — tests for the combined
+/// audit + operational telemetry packet emitted per cached-call lifecycle event
+/// (Submit → per-attempt ApiCallCached / DbWriteCached →
+/// terminal Resolve). The site emits one packet per event; central writes
+/// AuditLog + SiteCalls in one MS SQL transaction.
+///
+public class CachedCallTelemetryTests
+{
+ private static readonly DateTime FixedNowUtc = new(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc);
+
+ private const string SiteId = "site-77";
+ private const string InstanceName = "Plant.Pump42";
+ private const string SourceScript = "ScriptActor:OnTick";
+
+ private static AuditEvent BuildAuditEvent(
+ TrackedOperationId trackedId,
+ AuditKind kind,
+ AuditStatus status,
+ Guid? correlationId = null,
+ string? errorMessage = null,
+ int? httpStatus = null)
+ {
+ return new AuditEvent
+ {
+ EventId = Guid.NewGuid(),
+ OccurredAtUtc = FixedNowUtc,
+ Channel = AuditChannel.ApiOutbound,
+ Kind = kind,
+ CorrelationId = correlationId ?? trackedId.Value,
+ SourceSiteId = SiteId,
+ SourceInstanceId = InstanceName,
+ SourceScript = SourceScript,
+ Target = "ERP.GetOrder",
+ Status = status,
+ HttpStatus = httpStatus,
+ ErrorMessage = errorMessage,
+ PayloadTruncated = false,
+ ForwardState = AuditForwardState.Pending,
+ };
+ }
+
+ private static SiteCallOperational BuildOperational(
+ TrackedOperationId trackedId,
+ AuditStatus status,
+ int retryCount,
+ string? lastError = null,
+ int? httpStatus = null,
+ DateTime? terminalAtUtc = null)
+ {
+ return new SiteCallOperational(
+ TrackedOperationId: trackedId,
+ Channel: nameof(AuditChannel.ApiOutbound),
+ Target: "ERP.GetOrder",
+ SourceSite: SiteId,
+ Status: status.ToString(),
+ RetryCount: retryCount,
+ LastError: lastError,
+ HttpStatus: httpStatus,
+ CreatedAtUtc: FixedNowUtc,
+ UpdatedAtUtc: terminalAtUtc ?? FixedNowUtc,
+ TerminalAtUtc: terminalAtUtc);
+ }
+
+ [Fact]
+ public void SubmitPacket_AuditCarriesCachedSubmit_AndOperationalRetryCountZero()
+ {
+ var trackedId = TrackedOperationId.New();
+ var audit = BuildAuditEvent(trackedId, AuditKind.CachedSubmit, AuditStatus.Submitted);
+ var operational = BuildOperational(trackedId, AuditStatus.Submitted, retryCount: 0);
+
+ var packet = new CachedCallTelemetry(audit, operational);
+
+ Assert.Equal(AuditKind.CachedSubmit, packet.Audit.Kind);
+ Assert.Equal(AuditStatus.Submitted, packet.Audit.Status);
+ Assert.Equal(nameof(AuditStatus.Submitted), packet.Operational.Status);
+ Assert.Equal(0, packet.Operational.RetryCount);
+ Assert.Null(packet.Operational.TerminalAtUtc);
+ Assert.Equal(trackedId, packet.Operational.TrackedOperationId);
+ }
+
+ [Fact]
+ public void AttemptedPacket_AuditCarriesApiCallCached_RetryCountAlignsBetweenAuditAndOperational()
+ {
+ var trackedId = TrackedOperationId.New();
+ var audit = BuildAuditEvent(
+ trackedId,
+ AuditKind.ApiCallCached,
+ AuditStatus.Attempted,
+ errorMessage: "HTTP 503 from ERP",
+ httpStatus: 503);
+ var operational = BuildOperational(
+ trackedId,
+ AuditStatus.Attempted,
+ retryCount: 2,
+ lastError: "HTTP 503 from ERP",
+ httpStatus: 503);
+
+ var packet = new CachedCallTelemetry(audit, operational);
+
+ Assert.Equal(AuditKind.ApiCallCached, packet.Audit.Kind);
+ Assert.Equal(AuditStatus.Attempted, packet.Audit.Status);
+ Assert.Equal(nameof(AuditStatus.Attempted), packet.Operational.Status);
+ // Retry-count alignment: the operational row carries the canonical N;
+ // the audit row's error/http surface the same attempt's outcome.
+ Assert.Equal(packet.Audit.ErrorMessage, packet.Operational.LastError);
+ Assert.Equal(packet.Audit.HttpStatus, packet.Operational.HttpStatus);
+ Assert.Equal(2, packet.Operational.RetryCount);
+ Assert.Null(packet.Operational.TerminalAtUtc);
+ }
+
+ [Fact]
+ public void AttemptedPacket_DbWriteCached_CarriesDbWriteCachedKind()
+ {
+ var trackedId = TrackedOperationId.New();
+ var audit = BuildAuditEvent(
+ trackedId,
+ AuditKind.DbWriteCached,
+ AuditStatus.Attempted,
+ errorMessage: "Timeout",
+ httpStatus: null);
+ var operational = BuildOperational(
+ trackedId,
+ AuditStatus.Attempted,
+ retryCount: 1,
+ lastError: "Timeout");
+
+ var packet = new CachedCallTelemetry(audit, operational);
+
+ Assert.Equal(AuditKind.DbWriteCached, packet.Audit.Kind);
+ Assert.Equal(AuditStatus.Attempted, packet.Audit.Status);
+ Assert.Equal(1, packet.Operational.RetryCount);
+ }
+
+ [Theory]
+ [InlineData(AuditStatus.Delivered)]
+ [InlineData(AuditStatus.Failed)]
+ [InlineData(AuditStatus.Parked)]
+ [InlineData(AuditStatus.Discarded)]
+ public void ResolvePacket_AuditCarriesCachedResolve_OperationalTerminalAtUtcSet(AuditStatus terminalStatus)
+ {
+ var trackedId = TrackedOperationId.New();
+ var terminalAt = FixedNowUtc.AddMinutes(5);
+ var audit = BuildAuditEvent(trackedId, AuditKind.CachedResolve, terminalStatus);
+ var operational = BuildOperational(
+ trackedId,
+ terminalStatus,
+ retryCount: 3,
+ terminalAtUtc: terminalAt);
+
+ var packet = new CachedCallTelemetry(audit, operational);
+
+ Assert.Equal(AuditKind.CachedResolve, packet.Audit.Kind);
+ Assert.Equal(terminalStatus, packet.Audit.Status);
+ Assert.Equal(terminalStatus.ToString(), packet.Operational.Status);
+ Assert.NotNull(packet.Operational.TerminalAtUtc);
+ Assert.Equal(terminalAt, packet.Operational.TerminalAtUtc);
+ }
+
+ [Fact]
+ public void CachedCallTelemetry_RoundTripEquality()
+ {
+ var trackedId = TrackedOperationId.New();
+ var audit = BuildAuditEvent(trackedId, AuditKind.CachedSubmit, AuditStatus.Submitted);
+ var operational = BuildOperational(trackedId, AuditStatus.Submitted, retryCount: 0);
+
+ var a = new CachedCallTelemetry(audit, operational);
+ var b = new CachedCallTelemetry(audit, operational);
+
+ Assert.Equal(a, b);
+ Assert.Equal(a.GetHashCode(), b.GetHashCode());
+
+ var differentOperational = operational with { RetryCount = 1 };
+ var c = a with { Operational = differentOperational };
+
+ Assert.NotEqual(a, c);
+ Assert.Equal(0, a.Operational.RetryCount);
+ Assert.Equal(1, c.Operational.RetryCount);
+ }
+
+ [Fact]
+ public void SiteCallOperational_RoundTripEquality_AndWithExpression()
+ {
+ var trackedId = TrackedOperationId.New();
+ var a = BuildOperational(trackedId, AuditStatus.Submitted, retryCount: 0);
+ var b = BuildOperational(trackedId, AuditStatus.Submitted, retryCount: 0);
+
+ Assert.Equal(a, b);
+ Assert.Equal(a.GetHashCode(), b.GetHashCode());
+
+ var withDifferentRetry = a with { RetryCount = 5 };
+ Assert.NotEqual(a, withDifferentRetry);
+ Assert.Equal(0, a.RetryCount);
+ Assert.Equal(5, withDifferentRetry.RetryCount);
+ }
+}