diff --git a/src/ScadaLink.Commons/Entities/Notifications/Notification.cs b/src/ScadaLink.Commons/Entities/Notifications/Notification.cs
index f9305d0..7113008 100644
--- a/src/ScadaLink.Commons/Entities/Notifications/Notification.cs
+++ b/src/ScadaLink.Commons/Entities/Notifications/Notification.cs
@@ -25,6 +25,15 @@ public class Notification
/// Resolved delivery targets snapshotted at delivery time, for audit.
public string? ResolvedTargets { get; set; }
public string SourceSiteId { get; set; }
+
+ ///
+ /// The cluster node on which the notification was emitted — `node-a` / `node-b`
+ /// for site rows (qualified by ), `central-a` / `central-b`
+ /// for central-originated rows. Carried from the site on the
+ /// and persisted at
+ /// central; nullable so rows submitted before the column existed don't block ingest.
+ ///
+ public string? SourceNode { get; set; }
public string? SourceInstanceId { get; set; }
public string? SourceScript { get; set; }
diff --git a/src/ScadaLink.Commons/Messages/Notification/NotificationMessages.cs b/src/ScadaLink.Commons/Messages/Notification/NotificationMessages.cs
index 1c5a868..2b2486c 100644
--- a/src/ScadaLink.Commons/Messages/Notification/NotificationMessages.cs
+++ b/src/ScadaLink.Commons/Messages/Notification/NotificationMessages.cs
@@ -18,6 +18,15 @@ namespace ScadaLink.Commons.Messages.Notification;
/// NotifyDeliver audit rows. Additive trailing member — null for messages built
/// before the field existed, or for non-routed runs.
///
+///
+/// The cluster node on which the notification was emitted — `node-a` / `node-b` for site
+/// submissions, `central-a` / `central-b` for central-originated rows. Stamped by the
+/// emitting node from INodeIdentityProvider and carried, inside the serialized
+/// payload, through the site store-and-forward buffer so the central dispatcher can
+/// persist it on the Notifications row and echo it onto the NotifyDeliver
+/// audit rows. Additive trailing member — null for messages built before the field
+/// existed.
+///
public record NotificationSubmit(
string NotificationId,
string ListName,
@@ -28,7 +37,8 @@ public record NotificationSubmit(
string? SourceScript,
DateTimeOffset SiteEnqueuedAt,
Guid? OriginExecutionId = null,
- Guid? OriginParentExecutionId = null);
+ Guid? OriginParentExecutionId = null,
+ string? SourceNode = null);
///
/// Central -> Site: ack sent after the notification row is persisted.
diff --git a/tests/ScadaLink.Commons.Tests/Entities/NotificationEntityTests.cs b/tests/ScadaLink.Commons.Tests/Entities/NotificationEntityTests.cs
index bb872cd..518958f 100644
--- a/tests/ScadaLink.Commons.Tests/Entities/NotificationEntityTests.cs
+++ b/tests/ScadaLink.Commons.Tests/Entities/NotificationEntityTests.cs
@@ -51,6 +51,20 @@ public class NotificationEntityTests
Assert.Equal(parentExecutionId, n.OriginParentExecutionId);
}
+ [Fact]
+ public void SourceNode_DefaultsToNull_AndIsSettable()
+ {
+ // SourceNode identifies the cluster node that emitted the notification
+ // (site node-a/node-b or central-a/central-b). Additive nullable
+ // property — defaults to null on rows submitted before the column
+ // existed, and round-trips its value when set.
+ var n = new Notification("id-1", NotificationType.Email, "ops-team", "subj", "body", "SiteA");
+ Assert.Null(n.SourceNode);
+
+ n.SourceNode = "node-a";
+ Assert.Equal("node-a", n.SourceNode);
+ }
+
[Fact]
public void Constructor_NullArguments_Throw()
{
diff --git a/tests/ScadaLink.Commons.Tests/Messages/NotificationMessagesTests.cs b/tests/ScadaLink.Commons.Tests/Messages/NotificationMessagesTests.cs
index 20c090a..2b6569c 100644
--- a/tests/ScadaLink.Commons.Tests/Messages/NotificationMessagesTests.cs
+++ b/tests/ScadaLink.Commons.Tests/Messages/NotificationMessagesTests.cs
@@ -126,6 +126,32 @@ public class NotificationMessagesTests
Assert.Equal(parentExecutionId, roundTripped!.OriginParentExecutionId);
}
+ [Fact]
+ public void NotificationSubmit_carries_SourceNode()
+ {
+ // SourceNode is an additive trailing member — old call sites and old
+ // serialized payloads leave it null. When supplied it round-trips
+ // through both construction and JSON (the buffered S&F payload IS a
+ // serialized NotificationSubmit).
+ var defaulted = new NotificationSubmit(
+ "notif-9", "Operators", "Subject", "Body",
+ "site-01", "inst-1", "OnAlarm", DateTimeOffset.UtcNow);
+ Assert.Null(defaulted.SourceNode);
+
+ var stamped = new NotificationSubmit(
+ "notif-10", "Operators", "Subject", "Body",
+ "site-01", "inst-1", "OnAlarm", DateTimeOffset.UtcNow,
+ OriginExecutionId: null,
+ OriginParentExecutionId: null,
+ SourceNode: "node-a");
+ Assert.Equal("node-a", stamped.SourceNode);
+
+ var json = System.Text.Json.JsonSerializer.Serialize(stamped);
+ var roundTripped = System.Text.Json.JsonSerializer.Deserialize(json);
+ Assert.NotNull(roundTripped);
+ Assert.Equal("node-a", roundTripped!.SourceNode);
+ }
+
[Fact]
public void NotificationSubmit_ValueEquality_EqualWhenAllFieldsMatch()
{