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
@@ -34,6 +34,13 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
=> _inner.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, realm, isNative);
/// <inheritdoc />
// Forward the WP4 multi-notifier wiring to the inner sink. Without this the native-alarm fan-out to the
// referencing equipment folders ships INERT on every driver-role host (actors inject THIS wrapper, not the
// inner SdkAddressSpaceSink) — the F10b / PR#423 forwarding trap the reflection guard exists to catch.
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm)
=> _inner.WireAlarmNotifiers(alarmNodeId, alarmRealm, notifierFolderNodeIds, notifierFolderRealm);
/// <inheritdoc />
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm)
=> _inner.EnsureFolder(folderNodeId, parentNodeId, displayName, realm);