diff --git a/docs/AlarmTracking.md b/docs/AlarmTracking.md
index 9046cda7..a220a6b1 100644
--- a/docs/AlarmTracking.md
+++ b/docs/AlarmTracking.md
@@ -51,6 +51,49 @@ same condition is wired as an **event notifier of every referencing equipment fo
equipment paths**, and the AdminUI `/alerts` page shows **one row per condition** (primary
identity RawPath + condition NodeId) with the equipment list as display metadata.
+## Condition event identity fields (what a client reads on the wire)
+
+Every condition event — native and scripted — carries the mandatory `BaseEventType` identity
+fields, assigned at materialize time in `OtOpcUaNodeManager.MaterialiseAlarmCondition`. The SDK
+does **not** synthesize them on this path (`Create` builds the children from the type definition
+but leaves them unset; `ReportEvent` / `InstanceStateSnapshot` copy children verbatim), so they
+are set explicitly. Leaving them unset shipped them as **null** on every event — see issue #473.
+
+| Field | Value | Notes |
+|---|---|---|
+| `EventType` | the **concrete** materialized type (`TypeDefinitionId`) | e.g. `OffNormalAlarmType`; falls back to `AlarmConditionType` for an unknown authored type. Readable as a *field*, not only via an `OfType` where-clause |
+| `SourceNode` | the condition's **own NodeId** — equal to `ConditionId` | The condition **is** the source: an alarm-bearing raw tag materializes only the condition, with no sibling value variable, so there is no other node to point at |
+| `SourceName` | the same identifying id string: **RawPath** (native) / **ScriptedAlarmId** (scripted) | Deliberately the *unique* id, **not** the leaf name |
+| `ConditionName` | the leaf / display name (e.g. `HR200`) | Where the short human-readable name lives |
+
+**Why `SourceName` is the id, not the leaf name.** The leaf is ambiguous across devices (`HR200` on
+two PLCs collides) and is already carried by `ConditionName`, so the leaf-name option would add no
+identity while costing uniqueness. Carrying the id makes the `SourceNode`/`SourceName`/`ConditionId`
+triple mutually consistent and unique. This diverges from the loose OPC UA convention that
+`SourceName` mirrors the source node's BrowseName; the divergence is intentional.
+
+**Client guidance:** key on **`ConditionId`** — the condition node's own NodeId, which equals
+`SourceNode`, and whose identifier is the RawPath for native alarms and the ScriptedAlarmId for
+scripted ones. It is the identity ack/confirm/shelve route on. `SourceName` carries the same
+identifier and is unique, so it is safe to key on *by itself* — but do **not** compose it with
+`ConditionName` (`$"{SourceName}.{ConditionName}"`), because `SourceName` already ends in the
+condition's leaf name and the result stutters (`pymodbus/plc/HR200.HR200`).
+
+Wire-level guard: `NativeAlarmEventIdentityFieldDeliveryTests` asserts the three fields arrive
+populated on a real subscription using the standard `[EventType, SourceNode, SourceName, Time,
+Message, Severity]` select clause; `NodeManagerAlarmSourceFieldsTests` guards the node itself
+across both realms.
+
+> **Do not correlate live events to HistoryRead on `SourceName` — the two paths disagree.**
+> The HistoryRead *events* projection (`OtOpcUaNodeManager.ProjectEventField`) returns
+> `Variant.Null` for `EventType` / `SourceNode` **by design**: it projects from the historian's
+> `HistoricalEvent` rows, which do not carry them. It **does** project `SourceName` — but the
+> alarm-history writer stamps that field with the **EquipmentPath**
+> (`AlarmEventMapper`: `SourceName = alarm.EquipmentPath`), not the RawPath a live event carries.
+> So `SourceName` is the one field populated on both paths **with different values**, and it is not
+> a live↔history join key. Correlate on `ConditionId` / the RawPath instead. (Pre-existing; the
+> live-path fix above does not change the history path.)
+
## Galaxy driver path (driver-native)
Restored in PR B.2 of the epic. `GalaxyDriver` implements
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
index e882ad1e..32c74758 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
@@ -799,6 +799,22 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// (Real-server finding from the T14 integration test — not obvious from the SDK notes.)
if (alarm.BranchId is not null) alarm.BranchId.Value = NodeId.Null;
+ // #473 — the mandatory BaseEventType identity fields. Create() builds these three children from
+ // the type's embedded definition but leaves them UNSET (the init string declares them mandatory
+ // with NO default), and nothing downstream fills them in: the auto-filling
+ // BaseEventState.Initialize(context, source, severity, message) overload is only used for
+ // transient events, and ReportEvent / InstanceStateSnapshot copy the children verbatim. Unset
+ // here ⇒ null on the wire on EVERY condition event, so a client cannot attribute the alarm.
+ // SourceNode — the condition's OWN NodeId (== ConditionId): the condition IS the source. An
+ // alarm-bearing raw tag materialises ONLY this condition, with no sibling value
+ // variable (see AddressSpaceApplier's alarm branch), so there is no other node.
+ // SourceName — the same identifying id string (RawPath native / ScriptedAlarmId scripted).
+ // Deliberately the UNIQUE id, NOT the leaf: the leaf collides across devices and
+ // is already carried by ConditionName below. See docs/AlarmTracking.md.
+ alarm.EventType.Value = alarm.TypeDefinitionId;
+ alarm.SourceNode.Value = alarm.NodeId; // Create() assigned this above; do not rebuild it
+ alarm.SourceName.Value = alarmNodeId;
+
// Initial state via the SDK setters (T14: basic state only, NO event firing).
alarm.SetEnableState(SystemContext, true);
alarm.SetActiveState(SystemContext, false);
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/NativeAlarmEventIdentityFieldDeliveryTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/NativeAlarmEventIdentityFieldDeliveryTests.cs
new file mode 100644
index 00000000..eb53a4f1
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/NativeAlarmEventIdentityFieldDeliveryTests.cs
@@ -0,0 +1,257 @@
+using System.Net;
+using System.Net.Sockets;
+using Microsoft.Extensions.Logging.Abstractions;
+using Opc.Ua;
+using Opc.Ua.Client;
+using Shouldly;
+using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
+
+///
+/// Issue #473 — the over-the-wire proof that a live condition event carries the mandatory
+/// BaseEventType identity fields EventType / SourceNode / SourceName. All three
+/// previously arrived null on every condition event: MaterialiseAlarmCondition never assigned
+/// them, and nothing downstream synthesises them (ReportEvent / InstanceStateSnapshot copy the
+/// condition's children verbatim), so a conforming client could not attribute an alarm to its source.
+///
+/// This is the WIRE-level guard the node-level
+/// NodeManagerAlarmSourceFieldsTests cannot give: it proves the values survive the snapshot +
+/// serialization all the way into a real client's EventFieldList. The select clause is deliberately
+/// the exact one a real consumer uses — ScadaBridge's Data Connection Layer selects
+/// [EventType, SourceNode, SourceName, Time, Message, Severity] — so this test fails the way that
+/// client failed.
+///
+/// Boots the real and drives the production
+/// , mirroring NativeAlarmMultiNotifierEventDeliveryTests. Heavy
+/// in-process server+client integration — runs in the serial integration pass.
+///
+public sealed class NativeAlarmEventIdentityFieldDeliveryTests
+{
+ private const string ServerUri = "urn:OtOpcUa.AlarmEventIdentityFields";
+
+ private const string RawDeviceFolder = "pymodbus/plc";
+ private const string RawAlarmPath = "pymodbus/plc/HR200";
+ private const string LeafName = "HR200";
+ private const string Equip1 = "EQ-filling-line1-station1";
+ private const string AlarmMessage = "HR200 over limit (identity-field test)";
+
+ // Field indices in the select clause built by BuildEventFilter — ScadaBridge's exact clause.
+ private const int EventTypeIndex = 0;
+ private const int SourceNodeIndex = 1;
+ private const int SourceNameIndex = 2;
+ private const int TimeIndex = 3;
+ private const int MessageIndex = 4;
+ private const int SeverityIndex = 5;
+
+ /// A live native condition event delivers a populated EventType / SourceNode / SourceName to a
+ /// Server-object subscriber using the standard BaseEventType select clause.
+ [Fact]
+ public async Task Condition_event_carries_populated_EventType_SourceNode_and_SourceName_on_the_wire()
+ {
+ var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-alarm-identity-{Guid.NewGuid():N}");
+ var port = AllocateFreePort();
+ var ct = TestContext.Current.CancellationToken;
+ try
+ {
+ var (server, host) = await BootServerAsync(port, pkiRoot + "-srv", ServerUri, ct);
+ await using var _ = host;
+
+ var sink = new SdkAddressSpaceSink(server.NodeManager!);
+ WireCondition(sink);
+
+ using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
+ // Resolve the RAW namespace index CLIENT-side (from the server's advertised NamespaceArray) so the
+ // expected SourceNode is built exactly as a real client would resolve it.
+ var rawNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri);
+ rawNs.ShouldBeGreaterThan((ushort)0);
+
+ var collector = new EventCollector();
+ var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 };
+ subscription.FastEventCallback = collector.OnEvents;
+ session.AddSubscription(subscription);
+ await subscription.CreateAsync(ct);
+
+ // The collector filters by our unique Message, so the item's ClientHandle is not needed here
+ // (unlike the sibling multi-notifier test, which tallies delivery per monitored item).
+ AddEventItem(subscription, ObjectIds.Server);
+ await subscription.ApplyChangesAsync(ct);
+
+ sink.WriteAlarmCondition(RawAlarmPath, ActiveSnapshot(), DateTime.UtcNow, AddressSpaceRealm.Raw);
+
+ await WaitUntilAsync(() => collector.Fields.Count >= 1, TimeSpan.FromSeconds(5));
+ var fields = collector.Fields.ShouldHaveSingleItem();
+
+ // --- the three fields this issue is about ---
+ // EventType: the concrete condition type, readable as a FIELD (not just via an OfType where-clause).
+ fields[EventTypeIndex].Value.ShouldBe(ObjectTypeIds.OffNormalAlarmType,
+ "EventType must carry the concrete condition type, not null");
+
+ // SourceNode: the condition's own NodeId in the RAW namespace == ConditionId.
+ fields[SourceNodeIndex].Value.ShouldBe(new NodeId(RawAlarmPath, rawNs),
+ "SourceNode must identify the source condition node, not null");
+
+ // SourceName: the full RawPath — unique across devices, matching SourceNode/ConditionId. The leaf
+ // name (HR200) is deliberately NOT used here: it collides across PLCs and is carried by ConditionName.
+ fields[SourceNameIndex].Value.ShouldBe(RawAlarmPath,
+ "SourceName must carry the unique RawPath, not null");
+ fields[SourceNameIndex].Value.ShouldNotBe(LeafName,
+ "SourceName is deliberately the unique RawPath, not the ambiguous leaf name");
+
+ // The rest of the standard envelope still arrives intact (guards against a regression that
+ // populated identity at the cost of the existing fields).
+ fields[TimeIndex].Value.ShouldBeOfType();
+ ((LocalizedText)fields[MessageIndex].Value).Text.ShouldBe(AlarmMessage);
+ fields[SeverityIndex].Value.ShouldBe((ushort)700);
+
+ await subscription.DeleteAsync(true, ct);
+ }
+ finally
+ {
+ SafeDelete(pkiRoot + "-srv");
+ }
+ }
+
+ /// Materialise the raw device folder + the native condition at its RawPath, plus one referencing
+ /// equipment folder wired as a notifier (the production topology).
+ private static void WireCondition(SdkAddressSpaceSink sink)
+ {
+ sink.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "plc", realm: AddressSpaceRealm.Raw);
+ sink.MaterialiseAlarmCondition(RawAlarmPath, RawDeviceFolder, LeafName, "OffNormalAlarm", 700, AddressSpaceRealm.Raw, isNative: true);
+ sink.EnsureFolder(Equip1, parentNodeId: null, displayName: "station1", realm: AddressSpaceRealm.Uns);
+ sink.WireAlarmNotifiers(RawAlarmPath, AddressSpaceRealm.Raw, new[] { Equip1 }, AddressSpaceRealm.Uns);
+ }
+
+ private static AlarmConditionSnapshot ActiveSnapshot() =>
+ new(Active: true, Acknowledged: false, Confirmed: true, Enabled: true,
+ Shelving: AlarmShelvingKind.Unshelved, Severity: 700, Message: AlarmMessage);
+
+ private static MonitoredItem AddEventItem(Subscription subscription, NodeId source)
+ {
+ var item = new MonitoredItem(subscription.DefaultItem)
+ {
+ StartNodeId = source,
+ AttributeId = Attributes.EventNotifier,
+ Filter = BuildEventFilter(),
+ QueueSize = 100,
+ SamplingInterval = 0,
+ };
+ subscription.AddItem(item);
+ return item;
+ }
+
+ /// ScadaBridge's exact select clause: [EventType, SourceNode, SourceName, Time, Message, Severity].
+ private static EventFilter BuildEventFilter()
+ {
+ var filter = new EventFilter();
+ filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.EventType);
+ filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.SourceNode);
+ filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.SourceName);
+ filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Time);
+ filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Message);
+ filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Severity);
+ return filter;
+ }
+
+ /// Captures the delivered event field lists, filtered to our unique alarm Message so unrelated
+ /// server events / refresh brackets never count.
+ private sealed class EventCollector
+ {
+ private readonly object _gate = new();
+ private readonly List _fields = new();
+
+ public IReadOnlyList Fields
+ {
+ get { lock (_gate) return _fields.ToList(); }
+ }
+
+ public void OnEvents(Subscription subscription, EventNotificationList notification, IList stringTable)
+ {
+ lock (_gate)
+ {
+ foreach (var e in notification.Events)
+ {
+ if (e.EventFields.Count > MessageIndex &&
+ e.EventFields[MessageIndex].Value is LocalizedText lt &&
+ lt.Text == AlarmMessage)
+ {
+ _fields.Add(e.EventFields);
+ }
+ }
+ }
+ }
+ }
+
+ private static async Task<(OtOpcUaSdkServer Server, OpcUaApplicationHost Host)> BootServerAsync(
+ int port, string pkiRoot, string appUri, CancellationToken ct)
+ {
+ var options = new OpcUaApplicationHostOptions
+ {
+ ApplicationName = appUri,
+ ApplicationUri = appUri,
+ OpcUaPort = port,
+ PublicHostname = "127.0.0.1",
+ PkiStoreRoot = pkiRoot,
+ EnabledSecurityProfiles = new List { OpcUaSecurityProfile.None },
+ AutoAcceptUntrustedClientCertificates = true,
+ };
+ var server = new OtOpcUaSdkServer();
+ var host = new OpcUaApplicationHost(options, NullLogger.Instance);
+ await host.StartAsync(server, ct);
+ return (server, host);
+ }
+
+ private static async Task OpenSessionAsync(string endpointUrl, CancellationToken ct)
+ {
+ var appConfig = new ApplicationConfiguration
+ {
+ ApplicationName = "OtOpcUa.AlarmEventIdentityFieldsClient",
+ ApplicationUri = $"urn:OtOpcUa.AlarmEventIdentityFieldsClient.{Guid.NewGuid():N}",
+ ApplicationType = ApplicationType.Client,
+ SecurityConfiguration = TestClientSecurity.Build(TestClientSecurity.AllocatePkiRoot()),
+ ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
+ };
+ await appConfig.ValidateAsync(ApplicationType.Client, ct);
+ appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
+
+ var endpoint = await CoreClientUtils.SelectEndpointAsync(
+ appConfig, endpointUrl, false, DefaultTelemetry.Create(_ => { }), ct);
+ var endpointConfiguration = EndpointConfiguration.Create(appConfig);
+ var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfiguration);
+
+ return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync(
+ appConfig, configuredEndpoint, updateBeforeConnect: false, checkDomain: false,
+ sessionName: "AlarmEventIdentityFieldDeliveryTests", sessionTimeout: 60_000,
+ identity: new UserIdentity(new AnonymousIdentityToken()), preferredLocales: null, ct: ct);
+ }
+
+ private static async Task WaitUntilAsync(Func condition, TimeSpan timeout)
+ {
+ var deadline = DateTime.UtcNow + timeout;
+ while (DateTime.UtcNow < deadline)
+ {
+ if (condition()) return;
+ await Task.Delay(50);
+ }
+ condition().ShouldBeTrue("condition not met within timeout");
+ }
+
+ private static int AllocateFreePort()
+ {
+ var listener = new TcpListener(IPAddress.Loopback, 0);
+ listener.Start();
+ var port = ((IPEndPoint)listener.LocalEndpoint).Port;
+ listener.Stop();
+ return port;
+ }
+
+ private static void SafeDelete(string dir)
+ {
+ if (Directory.Exists(dir))
+ {
+ try { Directory.Delete(dir, recursive: true); }
+ catch { /* best-effort */ }
+ }
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerAlarmSourceFieldsTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerAlarmSourceFieldsTests.cs
new file mode 100644
index 00000000..2156a337
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerAlarmSourceFieldsTests.cs
@@ -0,0 +1,188 @@
+using Microsoft.Extensions.Logging.Abstractions;
+using Opc.Ua;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
+
+namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
+
+///
+/// Issue #473 — every materialised Part 9 condition must carry the mandatory BaseEventType
+/// identity fields EventType / SourceNode / SourceName. The SDK does NOT synthesise
+/// them on this path: initialises from the type's embedded definition,
+/// which declares all three as mandatory children with NO default value, and the auto-filling
+///
+/// overload (which would set SourceNode/SourceName from a source node) is only used for transient
+/// events. ReportEvent and InstanceStateSnapshot copy the children verbatim and synthesise
+/// nothing — so a field left null at materialise arrives null on the wire on EVERY condition event.
+///
+/// The contract locked in here (see docs/AlarmTracking.md): the condition node IS the source
+/// node — a native-alarm raw tag materialises ONLY a condition (no separate value variable), so
+/// SourceNode self-references the condition's own NodeId and SourceName carries the
+/// same identifying id string (alarmNodeId: the RawPath for native, the ScriptedAlarmId for
+/// scripted), matching ConditionId. The leaf/display name stays on ConditionName, so
+/// no information is lost by SourceName carrying the unique id rather than the ambiguous leaf.
+///
+///
+public sealed class NodeManagerAlarmSourceFieldsTests : IDisposable
+{
+ private static CancellationToken Ct => TestContext.Current.CancellationToken;
+
+ private const string RawDeviceFolder = "Plant/Modbus/dev1";
+ private const string RawAlarmPath = "Plant/Modbus/dev1/HR200";
+
+ private readonly string _pkiRoot = Path.Combine(
+ Path.GetTempPath(),
+ $"otopcua-alarm-src-fields-{Guid.NewGuid():N}");
+
+ /// A NATIVE condition (Raw realm, ConditionId = RawPath) carries all three identity fields:
+ /// EventType = its type definition, SourceNode = its own NodeId, SourceName = the RawPath. The leaf name
+ /// remains on ConditionName so the short name is still available to clients.
+ [Trait("Category", "Unit")]
+ [Fact]
+ public async Task Native_condition_carries_EventType_SourceNode_and_SourceName()
+ {
+ await using var host = await BootAsync();
+ var nm = host.Nm;
+ var rawNs = nm.NamespaceIndexForRealm(AddressSpaceRealm.Raw);
+
+ nm.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
+ nm.MaterialiseAlarmCondition(RawAlarmPath, RawDeviceFolder, "HR200", "OffNormalAlarm", 700,
+ realm: AddressSpaceRealm.Raw, isNative: true);
+
+ var condition = nm.TryGetAlarmCondition(RawAlarmPath, AddressSpaceRealm.Raw);
+ condition.ShouldNotBeNull();
+
+ // EventType — the concrete condition type, so a client reading the FIELD (not just an OfType
+ // where-clause) can resolve the type.
+ condition.EventType.ShouldNotBeNull();
+ condition.EventType.Value.ShouldBe(ObjectTypeIds.OffNormalAlarmType);
+
+ // SourceNode — the condition's own NodeId (it IS the source: no separate value variable exists
+ // for an alarm-bearing raw tag). Equal to ConditionId.
+ condition.SourceNode.ShouldNotBeNull();
+ condition.SourceNode.Value.ShouldBe(new NodeId(RawAlarmPath, rawNs));
+
+ // SourceName — the RawPath: unique across devices, matching ConditionId/SourceNode.
+ condition.SourceName.ShouldNotBeNull();
+ condition.SourceName.Value.ShouldBe(RawAlarmPath);
+
+ // The leaf name is NOT lost — it stays on ConditionName.
+ condition.ConditionName!.Value.ShouldBe("HR200");
+
+ }
+
+ /// A SCRIPTED condition (UNS realm, ConditionId = ScriptedAlarmId) follows the SAME uniform rule:
+ /// SourceNode = its own NodeId, SourceName = the ScriptedAlarmId. Scripted and native conditions share the
+ /// materialise path, so neither may regress independently.
+ [Trait("Category", "Unit")]
+ [Fact]
+ public async Task Scripted_condition_carries_EventType_SourceNode_and_SourceName()
+ {
+ await using var host = await BootAsync();
+ var nm = host.Nm;
+ var unsNs = nm.NamespaceIndexForRealm(AddressSpaceRealm.Uns);
+
+ nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Station 1", realm: AddressSpaceRealm.Uns);
+ nm.MaterialiseAlarmCondition("tank-overflow", "eq-1", "Tank Overflow", "OffNormalAlarm", 700,
+ realm: AddressSpaceRealm.Uns, isNative: false);
+
+ var condition = nm.TryGetAlarmCondition("tank-overflow", AddressSpaceRealm.Uns);
+ condition.ShouldNotBeNull();
+
+ condition.EventType!.Value.ShouldBe(ObjectTypeIds.OffNormalAlarmType);
+ condition.SourceNode!.Value.ShouldBe(new NodeId("tank-overflow", unsNs));
+ condition.SourceName!.Value.ShouldBe("tank-overflow");
+ // The human-readable name stays on ConditionName.
+ condition.ConditionName!.Value.ShouldBe("Tank Overflow");
+
+ }
+
+ /// EventType tracks the ACTUAL materialised type, not a hardcoded constant: an unknown alarm type
+ /// falls back to the base and its EventType must report that base type
+ /// rather than a subtype the node does not have.
+ [Trait("Category", "Unit")]
+ [Fact]
+ public async Task EventType_reflects_the_fallback_base_type_for_an_unknown_alarm_type()
+ {
+ await using var host = await BootAsync();
+ var nm = host.Nm;
+
+ nm.EnsureFolder("eq-2", parentNodeId: null, displayName: "Station 2", realm: AddressSpaceRealm.Uns);
+ nm.MaterialiseAlarmCondition("alm-x", "eq-2", "Generic", "LimitAlarm", 500,
+ realm: AddressSpaceRealm.Uns, isNative: false);
+
+ var condition = nm.TryGetAlarmCondition("alm-x", AddressSpaceRealm.Uns);
+ condition.ShouldNotBeNull();
+ condition.GetType().ShouldBe(typeof(AlarmConditionState)); // base-type fallback
+ condition.EventType!.Value.ShouldBe(ObjectTypeIds.AlarmConditionType);
+
+ }
+
+ /// The identity fields survive a re-materialise that DROPS and re-creates the node (the native↔scripted
+ /// kind-swap path), so a redeploy can never leave a condition emitting null identity.
+ [Trait("Category", "Unit")]
+ [Fact]
+ public async Task Identity_fields_survive_a_kind_swap_rematerialise()
+ {
+ await using var host = await BootAsync();
+ var nm = host.Nm;
+ var unsNs = nm.NamespaceIndexForRealm(AddressSpaceRealm.Uns);
+
+ nm.EnsureFolder("eq-3", parentNodeId: null, displayName: "Station 3", realm: AddressSpaceRealm.Uns);
+ nm.MaterialiseAlarmCondition("alm-swap", "eq-3", "Swap", "OffNormalAlarm", 700,
+ realm: AddressSpaceRealm.Uns, isNative: false);
+ // Kind swap (scripted → native) drops the prior instance and re-creates it.
+ nm.MaterialiseAlarmCondition("alm-swap", "eq-3", "Swap", "OffNormalAlarm", 700,
+ realm: AddressSpaceRealm.Uns, isNative: true);
+
+ var condition = nm.TryGetAlarmCondition("alm-swap", AddressSpaceRealm.Uns);
+ condition.ShouldNotBeNull();
+ condition.EventType!.Value.ShouldBe(ObjectTypeIds.OffNormalAlarmType);
+ condition.SourceNode!.Value.ShouldBe(new NodeId("alm-swap", unsNs));
+ condition.SourceName!.Value.ShouldBe("alm-swap");
+
+ }
+
+ /// A booted server + its node manager, disposed via await using so an assertion failure
+ /// cannot leak a live server (and its bound port) into the rest of the test run.
+ private sealed class BootedServer(OpcUaApplicationHost host, OtOpcUaNodeManager nm) : IAsyncDisposable
+ {
+ public OtOpcUaNodeManager Nm { get; } = nm;
+ public ValueTask DisposeAsync() => host.DisposeAsync();
+ }
+
+ private async Task BootAsync()
+ {
+ var host = new OpcUaApplicationHost(
+ new OpcUaApplicationHostOptions
+ {
+ ApplicationName = "OtOpcUa.AlarmSourceFieldsTest",
+ ApplicationUri = $"urn:OtOpcUa.AlarmSourceFieldsTest:{Guid.NewGuid():N}",
+ OpcUaPort = AllocateFreePort(),
+ PublicHostname = "localhost",
+ PkiStoreRoot = _pkiRoot,
+ },
+ NullLogger.Instance);
+
+ var server = new OtOpcUaSdkServer();
+ await host.StartAsync(server, Ct);
+ return new BootedServer(host, server.NodeManager!);
+ }
+
+ private static int AllocateFreePort()
+ {
+ using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
+ listener.Start();
+ return ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
+ }
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_pkiRoot))
+ {
+ try { Directory.Delete(_pkiRoot, recursive: true); }
+ catch { /* best-effort */ }
+ }
+ }
+}