diff --git a/src/ScadaLink.Commons/Messages/Notification/NotificationMessages.cs b/src/ScadaLink.Commons/Messages/Notification/NotificationMessages.cs
new file mode 100644
index 0000000..d5652ea
--- /dev/null
+++ b/src/ScadaLink.Commons/Messages/Notification/NotificationMessages.cs
@@ -0,0 +1,42 @@
+namespace ScadaLink.Commons.Messages.Notification;
+
+///
+/// Site -> Central: submit a notification for central delivery.
+/// Fire-and-forget with ack; the site retries until a is received.
+///
+public record NotificationSubmit(
+ string NotificationId,
+ string ListName,
+ string Subject,
+ string Body,
+ string SourceSiteId,
+ string? SourceInstanceId,
+ string? SourceScript,
+ DateTimeOffset SiteEnqueuedAt);
+
+///
+/// Central -> Site: ack sent after the notification row is persisted.
+/// Idempotent — safe to re-send for the same .
+///
+public record NotificationSubmitAck(
+ string NotificationId,
+ bool Accepted,
+ string? Error);
+
+///
+/// Site -> Central: query the central delivery status for a .
+///
+public record NotificationStatusQuery(
+ string CorrelationId,
+ string NotificationId);
+
+///
+/// Central -> Site: response carrying the current delivery status for a queried notification.
+///
+public record NotificationStatusResponse(
+ string CorrelationId,
+ bool Found,
+ string Status,
+ int RetryCount,
+ string? LastError,
+ DateTimeOffset? DeliveredAt);
diff --git a/src/ScadaLink.Commons/Messages/Notification/NotificationOutboxQueries.cs b/src/ScadaLink.Commons/Messages/Notification/NotificationOutboxQueries.cs
new file mode 100644
index 0000000..9bb7c4d
--- /dev/null
+++ b/src/ScadaLink.Commons/Messages/Notification/NotificationOutboxQueries.cs
@@ -0,0 +1,92 @@
+namespace ScadaLink.Commons.Messages.Notification;
+
+///
+/// Outbox UI -> Central: paginated, filtered query over the notification outbox.
+/// All filter fields are optional; restricts results to stuck notifications.
+///
+public record NotificationOutboxQueryRequest(
+ string CorrelationId,
+ string? StatusFilter,
+ string? TypeFilter,
+ string? SourceSiteFilter,
+ string? ListNameFilter,
+ bool StuckOnly,
+ string? SubjectKeyword,
+ DateTimeOffset? From,
+ DateTimeOffset? To,
+ int PageNumber,
+ int PageSize);
+
+///
+/// A single notification row summarised for outbox UI display.
+///
+public record NotificationSummary(
+ string NotificationId,
+ string Type,
+ string ListName,
+ string Subject,
+ string Status,
+ int RetryCount,
+ string? LastError,
+ string SourceSiteId,
+ string? SourceInstanceId,
+ DateTimeOffset CreatedAt,
+ DateTimeOffset? DeliveredAt,
+ bool IsStuck);
+
+///
+/// Central -> Outbox UI: paginated response for a .
+///
+public record NotificationOutboxQueryResponse(
+ string CorrelationId,
+ bool Success,
+ string? ErrorMessage,
+ IReadOnlyList Notifications,
+ int TotalCount);
+
+///
+/// Outbox UI -> Central: request to immediately retry delivery of a notification.
+///
+public record RetryNotificationRequest(
+ string CorrelationId,
+ string NotificationId);
+
+///
+/// Central -> Outbox UI: result of a .
+///
+public record RetryNotificationResponse(
+ string CorrelationId,
+ bool Success,
+ string? ErrorMessage);
+
+///
+/// Outbox UI -> Central: request to discard (cancel) a pending or stuck notification.
+///
+public record DiscardNotificationRequest(
+ string CorrelationId,
+ string NotificationId);
+
+///
+/// Central -> Outbox UI: result of a .
+///
+public record DiscardNotificationResponse(
+ string CorrelationId,
+ bool Success,
+ string? ErrorMessage);
+
+///
+/// Outbox UI -> Central: request for the notification outbox KPI summary.
+///
+public record NotificationKpiRequest(
+ string CorrelationId);
+
+///
+/// Central -> Outbox UI: KPI summary for the notification outbox dashboard.
+///
+public record NotificationKpiResponse(
+ string CorrelationId,
+ int QueueDepth,
+ int StuckCount,
+ int ParkedCount,
+ int DeliveredLastInterval,
+ TimeSpan? OldestPendingAge);
diff --git a/tests/ScadaLink.Commons.Tests/Messages/NotificationMessagesTests.cs b/tests/ScadaLink.Commons.Tests/Messages/NotificationMessagesTests.cs
new file mode 100644
index 0000000..0fba09e
--- /dev/null
+++ b/tests/ScadaLink.Commons.Tests/Messages/NotificationMessagesTests.cs
@@ -0,0 +1,188 @@
+using ScadaLink.Commons.Messages.Notification;
+
+namespace ScadaLink.Commons.Tests.Messages;
+
+///
+/// Notification Outbox: construction and value-equality tests for the
+/// site/central notification message contracts and the outbox UI query/action contracts.
+///
+public class NotificationMessagesTests
+{
+ // ── Task 7: site/central notification message contracts ──
+
+ [Fact]
+ public void NotificationSubmit_PositionalConstruction_SetsAllFields()
+ {
+ var enqueuedAt = DateTimeOffset.UtcNow;
+ var msg = new NotificationSubmit(
+ "notif-1", "Operators", "Tank overflow", "Tank 3 has overflowed.",
+ "site-01", "inst-7", "OnAlarm", enqueuedAt);
+
+ Assert.Equal("notif-1", msg.NotificationId);
+ Assert.Equal("Operators", msg.ListName);
+ Assert.Equal("Tank overflow", msg.Subject);
+ Assert.Equal("Tank 3 has overflowed.", msg.Body);
+ Assert.Equal("site-01", msg.SourceSiteId);
+ Assert.Equal("inst-7", msg.SourceInstanceId);
+ Assert.Equal("OnAlarm", msg.SourceScript);
+ Assert.Equal(enqueuedAt, msg.SiteEnqueuedAt);
+ }
+
+ [Fact]
+ public void NotificationSubmit_AllowsNullOptionalSourceFields()
+ {
+ var msg = new NotificationSubmit(
+ "notif-2", "Operators", "Subject", "Body",
+ "site-01", null, null, DateTimeOffset.UtcNow);
+
+ Assert.Null(msg.SourceInstanceId);
+ Assert.Null(msg.SourceScript);
+ }
+
+ [Fact]
+ public void NotificationSubmit_ValueEquality_EqualWhenAllFieldsMatch()
+ {
+ var enqueuedAt = DateTimeOffset.UtcNow;
+ var a = new NotificationSubmit("n", "L", "S", "B", "site", "inst", "scr", enqueuedAt);
+ var b = new NotificationSubmit("n", "L", "S", "B", "site", "inst", "scr", enqueuedAt);
+
+ Assert.Equal(a, b);
+ Assert.Equal(a.GetHashCode(), b.GetHashCode());
+ }
+
+ [Fact]
+ public void NotificationSubmitAck_WithExpression_ChangesSingleField()
+ {
+ var ack = new NotificationSubmitAck("notif-1", true, null);
+ var rejected = ack with { Accepted = false, Error = "duplicate" };
+
+ Assert.True(ack.Accepted);
+ Assert.False(rejected.Accepted);
+ Assert.Equal("duplicate", rejected.Error);
+ Assert.Equal("notif-1", rejected.NotificationId);
+ Assert.NotEqual(ack, rejected);
+ }
+
+ [Fact]
+ public void NotificationStatusQuery_PositionalConstruction_SetsAllFields()
+ {
+ var msg = new NotificationStatusQuery("corr-1", "notif-9");
+
+ Assert.Equal("corr-1", msg.CorrelationId);
+ Assert.Equal("notif-9", msg.NotificationId);
+ }
+
+ [Fact]
+ public void NotificationStatusResponse_PositionalConstruction_SetsAllFields()
+ {
+ var deliveredAt = DateTimeOffset.UtcNow;
+ var msg = new NotificationStatusResponse(
+ "corr-1", true, "Delivered", 2, null, deliveredAt);
+
+ Assert.Equal("corr-1", msg.CorrelationId);
+ Assert.True(msg.Found);
+ Assert.Equal("Delivered", msg.Status);
+ Assert.Equal(2, msg.RetryCount);
+ Assert.Null(msg.LastError);
+ Assert.Equal(deliveredAt, msg.DeliveredAt);
+ }
+
+ // ── Task 8: outbox UI query/action contracts ──
+
+ [Fact]
+ public void NotificationOutboxQueryRequest_PositionalConstruction_SetsAllFields()
+ {
+ var from = DateTimeOffset.UtcNow.AddDays(-1);
+ var to = DateTimeOffset.UtcNow;
+ var msg = new NotificationOutboxQueryRequest(
+ "corr-1", "Stuck", "Email", "site-01", "Operators", true, "overflow",
+ from, to, 2, 50);
+
+ Assert.Equal("corr-1", msg.CorrelationId);
+ Assert.Equal("Stuck", msg.StatusFilter);
+ Assert.Equal("Email", msg.TypeFilter);
+ Assert.Equal("site-01", msg.SourceSiteFilter);
+ Assert.Equal("Operators", msg.ListNameFilter);
+ Assert.True(msg.StuckOnly);
+ Assert.Equal("overflow", msg.SubjectKeyword);
+ Assert.Equal(from, msg.From);
+ Assert.Equal(to, msg.To);
+ Assert.Equal(2, msg.PageNumber);
+ Assert.Equal(50, msg.PageSize);
+ }
+
+ [Fact]
+ public void NotificationSummary_ValueEquality_EqualWhenAllFieldsMatch()
+ {
+ var createdAt = DateTimeOffset.UtcNow;
+ var a = new NotificationSummary(
+ "n", "Email", "Ops", "S", "Pending", 1, null, "site-01", "inst-1",
+ createdAt, null, false);
+ var b = new NotificationSummary(
+ "n", "Email", "Ops", "S", "Pending", 1, null, "site-01", "inst-1",
+ createdAt, null, false);
+
+ Assert.Equal(a, b);
+ Assert.Equal(a.GetHashCode(), b.GetHashCode());
+ }
+
+ [Fact]
+ public void NotificationOutboxQueryResponse_PositionalConstruction_SetsAllFields()
+ {
+ var summary = new NotificationSummary(
+ "n", "Email", "Ops", "S", "Delivered", 0, null, "site-01", null,
+ DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, false);
+ var msg = new NotificationOutboxQueryResponse(
+ "corr-1", true, null, new[] { summary }, 1);
+
+ Assert.Equal("corr-1", msg.CorrelationId);
+ Assert.True(msg.Success);
+ Assert.Null(msg.ErrorMessage);
+ Assert.Single(msg.Notifications);
+ Assert.Equal(1, msg.TotalCount);
+ }
+
+ [Fact]
+ public void RetryNotificationRequestAndResponse_RoundTripFields()
+ {
+ var request = new RetryNotificationRequest("corr-1", "notif-1");
+ var response = new RetryNotificationResponse("corr-1", true, null);
+
+ Assert.Equal("corr-1", request.CorrelationId);
+ Assert.Equal("notif-1", request.NotificationId);
+ Assert.True(response.Success);
+ Assert.Null(response.ErrorMessage);
+ }
+
+ [Fact]
+ public void DiscardNotificationRequestAndResponse_RoundTripFields()
+ {
+ var request = new DiscardNotificationRequest("corr-1", "notif-1");
+ var response = new DiscardNotificationResponse("corr-1", false, "not found");
+
+ Assert.Equal("notif-1", request.NotificationId);
+ Assert.False(response.Success);
+ Assert.Equal("not found", response.ErrorMessage);
+ }
+
+ [Fact]
+ public void NotificationKpiResponse_WithExpression_ChangesSingleField()
+ {
+ var kpi = new NotificationKpiResponse(
+ "corr-1", 10, 2, 1, 5, TimeSpan.FromMinutes(3));
+ var updated = kpi with { QueueDepth = 12 };
+
+ Assert.Equal(10, kpi.QueueDepth);
+ Assert.Equal(12, updated.QueueDepth);
+ Assert.Equal(2, updated.StuckCount);
+ Assert.Equal(TimeSpan.FromMinutes(3), updated.OldestPendingAge);
+ Assert.NotEqual(kpi, updated);
+ }
+
+ [Fact]
+ public void NotificationKpiRequest_PositionalConstruction_SetsCorrelationId()
+ {
+ var msg = new NotificationKpiRequest("corr-1");
+ Assert.Equal("corr-1", msg.CorrelationId);
+ }
+}