feat(v3-batch4-wp4): multi-notifier native alarms (single ReportEvent → raw + equipment notifiers) + teardown symmetry

Materialize each native alarm ONCE at the raw tag (ConditionId = RawPath, Raw
realm); wire the single condition as an SDK event notifier of each referencing
equipment's UNS folder so one ReportEvent fans to every root without
re-reporting per root (which would break Server-object dedup + Part 9 ack
correlation).

- New sink method WireAlarmNotifiers(alarmNodeId, alarmRealm,
  notifierFolderNodeIds, notifierFolderRealm) on IOpcUaAddressSpaceSink,
  forwarded through DeferredAddressSpaceSink + SdkAddressSpaceSink +
  NullOpcUaAddressSpaceSink (the forwarding-trap guard); auto-covered by the
  DeferredSinkForwardingReflectionTests realm + forwarding guards + a
  hand-written forward test.
- OtOpcUaNodeManager: the normative AddNotifier(isInverse) pair + idempotent
  EnsureFolderIsEventNotifier per equipment folder; tracked per condition in
  _alarmNotifierWiring. Teardown symmetry: RemoveNotifier(bidirectional:true) on
  RebuildAddressSpace, RemoveAlarmConditionNode, and RemoveEquipmentSubtree so no
  inverse-notifier entry leaks across redeploys.
- AddressSpaceApplier.MaterialiseRawSubtree wires notifiers for each native alarm
  tag, resolving its ReferencingEquipmentPaths (Area/Line/Equipment) to the
  EquipmentId folder NodeIds via BuildEquipmentIdByFolderPath.
- AlarmTransitionEvent gains ReferencingEquipmentPaths (empty default); /alerts
  renders the referencing-equipment list as display metadata.
- Un-skipped + rewrote the native-alarm dark tests
  (DriverHostActorNativeAlarmTests x6, DriverHostActorNativeAlarmAckRoutingTests
  x1) for the v3 raw-condition model; new NodeManagerMultiNotifierAlarmTests
  proves multi-notifier wiring + teardown symmetry (no leaked duplicates after a
  re-trip) + applier wiring test.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
This commit is contained in:
Joseph Doherty
2026-07-16 12:07:11 -04:00
parent 9d09523675
commit 8ebc712eff
21 changed files with 744 additions and 182 deletions
@@ -51,6 +51,28 @@ public interface IOpcUaAddressSpaceSink
/// live in.</param>
void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false);
/// <summary>
/// v3 Batch 4 (multi-notifier native alarms) — wire an already-materialised native alarm condition
/// (<paramref name="alarmNodeId"/>, materialised at the raw tag via <see cref="MaterialiseAlarmCondition"/>
/// with ConditionId = the RawPath) as an event notifier of EACH referencing equipment's UNS folder, so
/// the condition's <b>single</b> <c>ReportEvent</c> fans one event to every referencing equipment root
/// WITHOUT re-reporting per root (distinct EventIds would break Server-object dedup + Part 9 ack
/// correlation). Per folder the sink wires the SDK's bidirectional notifier pattern
/// (<c>alarm.AddNotifier(isInverse:true, folder)</c> + <c>folder.AddNotifier(isInverse:false, alarm)</c>)
/// and promotes the folder to an event notifier. <b>Idempotent</b> (a re-wire of the same pair updates,
/// never duplicates); a <b>missing endpoint is a no-op</b> (logged, never thrown) so a mid-rebuild race
/// can't fault a deploy. The sink tracks each wired pair so a later rebuild / condition-removal /
/// equipment-subtree-removal tears the notifier down bidirectionally (no inverse-notifier entry leaks
/// across redeploys).
/// </summary>
/// <param name="alarmNodeId">The native alarm condition's node id (== the backing tag's RawPath).</param>
/// <param name="alarmRealm">The namespace realm the condition lives in (Raw for native alarms).</param>
/// <param name="notifierFolderNodeIds">The equipment folder node ids (their <c>s=</c> ids) to wire as
/// extra event-notifier roots for this condition. An unknown / not-yet-materialised folder id is skipped.</param>
/// <param name="notifierFolderRealm">The namespace realm the notifier folders live in (Uns for equipment folders).</param>
void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm,
IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm);
/// <summary>
/// Ensure a folder node exists under the given parent. Used by <c>AddressSpaceApplier</c> to
/// materialise the UNS Area/Line/Equipment hierarchy in the address space. When
@@ -147,6 +169,9 @@ public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink
/// <inheritdoc />
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
/// <inheritdoc />
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
/// <inheritdoc />
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm) { }