Merge #597/#599: real UnshelveTime, commented re-ack reports, native ack refused off-Primary, /alerts ack-path test; surgically-added raw nodes announced, equipment-with-references removal keeps references severed; Backing* parity guard

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 07:12:59 -04:00
16 changed files with 873 additions and 89 deletions
@@ -44,6 +44,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
private readonly ActorRegistry _actorRegistry;
private readonly ILoggerFactory _loggerFactory;
private readonly ILogger<OtOpcUaServerHostedService> _logger;
private readonly IDriverPrimaryRoleSource? _primaryRoleSource;
private OpcUaApplicationHost? _appHost;
private OtOpcUaSdkServer? _server;
@@ -88,6 +89,11 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
/// <param name="time">The shared clock — the one instance the authorizer and the API-key store bound
/// also read. Its <c>CreateTimer</c> arms the revalidation tick.</param>
/// <param name="loggerFactory">The logger factory for creating loggers.</param>
/// <param name="primaryRoleSource">Gitea #597 — the data-plane Primary verdict <c>DriverHostActor</c>
/// publishes on every redundancy snapshot (the same one its native-ack route and device-write gates apply).
/// Wired onto the node manager so a native-condition Acknowledge on a non-Primary node is refused rather than
/// applied and then reverted. Optional: a node with no driver role registers none, and null means "allow" —
/// the single-node posture <c>UnmanagedDriverPrimaryRoleSource</c> also answers with.</param>
public OtOpcUaServerHostedService(
IOptions<OpcUaApplicationHostOptions> options,
DeferredAddressSpaceSink deferredSink,
@@ -101,7 +107,8 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
DataPlaneAuthorizer? dataPlaneAuthorizer,
IOptions<AclOptions> aclOptions,
TimeProvider time,
ILoggerFactory loggerFactory)
ILoggerFactory loggerFactory,
IDriverPrimaryRoleSource? primaryRoleSource = null)
{
ArgumentNullException.ThrowIfNull(aclOptions);
ArgumentNullException.ThrowIfNull(time);
@@ -123,6 +130,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
_actorSystemAccessor = actorSystemAccessor;
_actorRegistry = actorRegistry;
_loggerFactory = loggerFactory;
_primaryRoleSource = primaryRoleSource;
_logger = loggerFactory.CreateLogger<OtOpcUaServerHostedService>();
}
@@ -225,6 +233,21 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
}
});
// Gitea #597: a native ack is accepted only on the Primary. The verdict is the one DriverHostActor
// publishes and gates the route above on, so a Secondary refuses (BadInvalidState) instead of applying an
// ack the driver will never see and then reverting it. A volatile property read — safe under the SDK Lock.
if (_primaryRoleSource is { } primaryRoleSource)
{
_server.SetNativeAlarmAckPrimaryGate(() => primaryRoleSource.ShouldActAsPrimary);
}
else
{
// Every driver node registers one (AddOtOpcUaRuntime), so this is a wiring regression, not a mode:
// say so rather than let a Secondary silently go back to accepting acks it will revert.
_logger.LogWarning(
"OtOpcUaServerHostedService: no IDriverPrimaryRoleSource registered — native-alarm acknowledges are accepted on every node regardless of redundancy role");
}
// Wire the reverse-path inbound operator-write gateway: a client write to a writable equipment-tag
// node that passes the node manager's WriteOperate gate routes the write to the owning driver child
// (RouteNodeWrite → NodeWriteResult) via the local DriverHostActor. The node manager calls the
@@ -345,6 +368,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
_server?.SetNodeWriteGateway(null);
// Clear the native-alarm-ack router so a late ack doesn't Tell a stopping DriverHostActor.
_server?.SetNativeAlarmAckRouter(null);
_server?.SetNativeAlarmAckPrimaryGate(null);
// Restore the Null historian so a late HistoryRead doesn't hit a disposed read client.
_server?.SetHistorianDataSource(null);
// Restore the empty authority so a late session activation cannot authorize against a stopping node.
@@ -397,29 +397,40 @@ public sealed class AddressSpaceApplier
if (!SafeRemoveAlarmCondition(surgical, alarm.ScriptedAlarmId, AddressSpaceRealm.Uns)) return false;
}
// Removed equipment — one subtree teardown each (subsumes their child tags/vtags/alarms). The
// top-of-Apply block already wrote the equipment id's terminal condition state.
foreach (var eq in plan.RemovedEquipment)
{
if (!SafeRemoveEquipmentSubtree(surgical, eq.EquipmentId, AddressSpaceRealm.Uns)) return false;
}
// v3 Batch 4 — removed UNS reference variables (Uns realm, plain value nodes): terminal Bad then
// in-place remove.
//
// WHAT REACHES THIS LOOP (the "→ rebuild" this comment used to claim is long dead — re-points are
// surgical now): only genuine DEREFERENCES, i.e. the equipment dropped the reference row, so the node
// is gone for good. A CHANGED reference never lands in RemovedUnsReferenceVariables at all — the
// planner reports it as a UnsReferenceDelta, which Apply routes above either to an in-place
// UpdateTagAttributes (identity unchanged) or to its own remove-the-previous + recreate-the-current
// pair (identity changed). That second shape does remove a node, but it removes it from Apply's delta
// loop, not from here.
// is gone for good — INCLUDING every reference of a removed equipment, which the planner lists here
// too. A CHANGED reference never lands in RemovedUnsReferenceVariables at all — the planner reports it
// as a UnsReferenceDelta, which Apply routes above either to an in-place UpdateTagAttributes (identity
// unchanged) or to its own remove-the-previous + recreate-the-current pair (identity changed). That
// second shape does remove a node, but it removes it from Apply's delta loop, not from here.
//
// Gitea #599 (archreview 03/P5): this runs BEFORE the equipment-subtree removal below, deliberately,
// instead of skipping a removed equipment's references with the NotSubsumed filter the loops above
// use. Whether the subtree removal reaches a reference depends on its NodeId, not on its parent: the
// node manager scopes a subtree by id prefix, and a reference's NodeId is Area/Line/Equipment/Name
// while the equipment folder's is its EquipmentId — so in production the subtree does NOT reach it,
// and a filter would leak the node. Where an id does sit under the equipment id, the old order had the
// subtree take the node first, this loop then reported it unknown, and the deploy ratcheted to a full
// rebuild — severing every subscription on the server to remove one equipment. Removing references
// first is correct for both shapes: each is still present, gets its terminal Bad, and goes through
// RemoveVariableNode, which also severs its inverse Organizes edge on the surviving raw node.
foreach (var v in plan.RemovedUnsReferenceVariables)
{
SafeWriteValue(v.NodeId, null, OpcUaStatus.Bad, ts, AddressSpaceRealm.Uns);
if (!SafeRemoveVariable(surgical, v.NodeId, AddressSpaceRealm.Uns)) return false;
}
// Removed equipment — one subtree teardown each (subsumes their child tags/vtags/alarms). The
// top-of-Apply block already wrote the equipment id's terminal condition state.
foreach (var eq in plan.RemovedEquipment)
{
if (!SafeRemoveEquipmentSubtree(surgical, eq.EquipmentId, AddressSpaceRealm.Uns)) return false;
}
// v3 Batch 4 — removed RAW tags (Raw realm): an alarm-bearing raw tag is a condition node (terminal
// RemovedConditionState + RemoveAlarmConditionNode); a plain raw value tag is a variable (terminal Bad
// + RemoveVariableNode).
@@ -522,10 +533,8 @@ public sealed class AddressSpaceApplier
foreach (var a in plan.AddedAlarms) ids.Add(a.EquipmentId);
// v3 Batch 4 / review I5 — an added UNS reference variable is materialised directly under its
// equipment folder (MaterialiseUnsReferences passes EquipmentId as the parent), and the equipment
// folder is a UNS-realm node — the realm AnnounceAddedNodes hardcodes. So it announces correctly with
// no realm plumbing. (Added RAW containers/tags are still NOT announced: their parents are Raw-realm
// nodes, which needs a realm-aware RaiseNodesAddedModelChange call — see the gap note in
// OpcUaPublishActor.HandleRebuild.)
// folder is a UNS-realm node, announced in the Uns realm like every id this method returns. Added RAW
// containers/tags have Raw-realm parents and are computed separately (ComputeRawAddAnnouncements).
foreach (var v in plan.AddedUnsReferenceVariables) ids.Add(v.EquipmentId);
foreach (var eq in plan.AddedEquipment)
ids.Add(string.IsNullOrWhiteSpace(eq.UnsLineId) ? eq.EquipmentId : eq.UnsLineId);
@@ -535,6 +544,42 @@ public sealed class AddressSpaceApplier
return list;
}
/// <summary>
/// Gitea #599 (archreview 01/U-8 = 03/P6) — the Raw-realm counterpart of
/// <see cref="ComputeAddAnnouncements"/>: the deduplicated, ordinal-sorted RAW parent node ids to announce
/// after a non-rebuild apply materialised added raw containers (Folder/Driver/Device/TagGroup) and raw
/// tags. Each added node announces its <c>ParentNodeId</c> — the exact parent
/// <c>MaterialiseRawSubtree</c> hangs it under. A parent that is ITSELF added in the same plan is skipped:
/// its own parent is announced, and a client re-browsing there discovers the whole new subtree, so the
/// inner announcements would only be noise. A top-level container (no parent — it hangs under the shared
/// root folder, which has no realm-qualified id to announce) announces itself, the same convention an
/// added equipment with no line uses.
/// </summary>
/// <param name="plan">The plan whose added raw nodes' parents to announce.</param>
/// <returns>The distinct affected Raw-realm parent node ids, ordinal-sorted.</returns>
public IReadOnlyList<string> ComputeRawAddAnnouncements(AddressSpacePlan plan)
{
ArgumentNullException.ThrowIfNull(plan);
var added = new HashSet<string>(StringComparer.Ordinal);
foreach (var c in plan.AddedRawContainers) added.Add(c.NodeId);
foreach (var t in plan.AddedRawTags) added.Add(t.NodeId);
var ids = new HashSet<string>(StringComparer.Ordinal);
void Announce(string nodeId, string? parentNodeId)
{
if (string.IsNullOrWhiteSpace(parentNodeId)) ids.Add(nodeId);
else if (!added.Contains(parentNodeId)) ids.Add(parentNodeId);
}
foreach (var c in plan.AddedRawContainers) Announce(c.NodeId, c.ParentNodeId);
foreach (var t in plan.AddedRawTags) Announce(t.NodeId, t.ParentNodeId);
var list = ids.ToList();
list.Sort(StringComparer.Ordinal);
return list;
}
/// <summary>The materialise-parent node id a tag/vtag's variable (or native-alarm condition) hangs
/// under: the equipment folder when <paramref name="folderPath"/> is null/empty, else the FolderPath
/// sub-folder. Mirrors the parent derivation the Materialise passes use.</summary>
@@ -561,21 +606,29 @@ public sealed class AddressSpaceApplier
/// <summary>
/// Announce a Part 3 <c>GeneralModelChangeEvent(NodeAdded)</c> per distinct affected parent from
/// <see cref="ComputeAddAnnouncements"/>, so model-aware clients re-browse the parents the new nodes
/// were added under. Called by <c>OpcUaPublishActor</c> AFTER the Materialise passes (the nodes exist
/// by then) and ONLY on a non-rebuild apply — after a full rebuild the announcement is moot
/// (subscriptions are dead anyway). Uses the existing, guard-covered
/// <see cref="ComputeAddAnnouncements"/> (Uns realm) and <see cref="ComputeRawAddAnnouncements"/>
/// (Raw realm), so model-aware clients re-browse the parents the new nodes were added under. Called by
/// <c>OpcUaPublishActor</c> AFTER the Materialise passes (the nodes exist by then) and ONLY on a
/// non-rebuild apply — after a full rebuild the announcement is moot (subscriptions are dead anyway).
/// Uses the existing, guard-covered, realm-aware
/// <see cref="IOpcUaAddressSpaceSink.RaiseNodesAddedModelChange"/> — no new sink member. Each call is
/// Safe-wrapped: a faulting announcement can never break a deploy (the nodes already stand).
/// <para>Gitea #599: until the raw half existed, surgically-added raw containers/tags were materialised
/// but never announced, so a connected model-aware client could not discover them until it happened to
/// re-browse. (Before surgical deploys every raw add rebuilt the address space, which hid the gap.)</para>
/// </summary>
/// <param name="plan">The plan whose added nodes to announce.</param>
public void AnnounceAddedNodes(AddressSpacePlan plan)
{
foreach (var id in ComputeAddAnnouncements(plan))
{
try { _sink.RaiseNodesAddedModelChange(id, AddressSpaceRealm.Uns); }
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: RaiseNodesAddedModelChange threw for {Node}", id); }
}
foreach (var id in ComputeAddAnnouncements(plan)) SafeAnnounce(id, AddressSpaceRealm.Uns);
foreach (var id in ComputeRawAddAnnouncements(plan)) SafeAnnounce(id, AddressSpaceRealm.Raw);
}
/// <summary>Raise one NodeAdded model change, swallowing (and Warning-logging) any sink fault.</summary>
private void SafeAnnounce(string nodeId, AddressSpaceRealm realm)
{
try { _sink.RaiseNodesAddedModelChange(nodeId, realm); }
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: RaiseNodesAddedModelChange threw for {Node} ({Realm})", nodeId, realm); }
}
/// <summary>
@@ -205,6 +205,26 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// </summary>
public Action<NativeAlarmAck>? NativeAlarmAckRouter { get; set; }
/// <summary>
/// Gitea #597 — whether this node may accept an OPC UA Acknowledge of a NATIVE (driver-fed) condition
/// right now: the redundancy data-plane verdict (<c>PrimaryGatePolicy.ShouldServiceAsPrimary</c>, as
/// published to <c>IDriverPrimaryRoleSource</c>) — the same verdict <c>DriverHostActor</c> gates the
/// upstream ack route and device writes on.
/// <para>
/// <c>false</c> ⇒ the Acknowledge is REFUSED with <c>BadInvalidState</c> before anything is routed or
/// applied. Without this the Secondary applied and reported the ack locally while
/// <c>DriverHostActor</c> dropped the route upstream, so the driver never acknowledged and its next
/// transition reverted the node — the client saw acked → unacked. Scripted conditions are not gated:
/// their engine runs on both nodes, so an ack against the Secondary is real.
/// </para>
/// <para>
/// Read under <c>Lock</c> on the SDK's Call thread, so it MUST be a cheap, non-blocking read (the
/// host passes a volatile-backed property). Null (the default) allows — the single-node posture, the
/// same default <c>UnmanagedDriverPrimaryRoleSource</c> answers with.
/// </para>
/// </summary>
public Func<bool>? NativeAlarmAckPrimaryGate { get; set; }
private volatile IOpcUaNodeWriteGateway _nodeWriteGateway = NullOpcUaNodeWriteGateway.Instance;
/// <summary>
@@ -849,13 +869,16 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
if (condition.ShelvingState is not null)
{
// SetShelvingState(shelved, oneShot, shelvingTime): map our 3-way kind onto the SDK's
// (shelved, oneShot) flag pair. Timed shelving's expiry is owned by the engine, not the
// SDK timer, so we pass shelvingTime=0 (no SDK-managed auto-unshelve).
// (shelved, oneShot) flag pair. Timed shelving's expiry is owned by the engine; the time
// passed here is the engine's remaining time, so the node's UnshelveTime counts down to the
// real expiry (Gitea #597 — a 0 here reads as MaxTimeShelved, ~24.8 days). The SDK timer it
// arms only routes an Unshelve back to the engine (OnTimedUnshelve), a no-op once the
// engine's own expiry has run.
condition.SetShelvingState(
SystemContext,
shelved: state.Shelving != AlarmShelvingKind.Unshelved,
oneShot: state.Shelving == AlarmShelvingKind.OneShot,
shelvingTime: 0);
shelvingTime: state.Shelving == AlarmShelvingKind.Timed ? RemainingShelveMs(state.UnshelveAtUtc) : 0);
}
condition.SetSeverity(SystemContext, MapSeverity(state.Severity));
condition.Message.Value = new LocalizedText(state.Message);
@@ -909,6 +932,22 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
}
}
/// <summary>
/// Gitea #597 — the <c>shelvingTime</c> (ms) to hand <c>SetShelvingState</c> for a timed shelve expiring at
/// <paramref name="unshelveAtUtc"/>. <c>0</c> when the expiry is unknown (the SDK then uses
/// <c>MaxTimeShelved</c>, the pre-#597 behaviour); otherwise the remaining time, clamped to at least 1 ms
/// (an already-passed expiry must not read as "unknown" — the engine's own expiry is about to land) and to
/// at most <c>int.MaxValue</c> (the SDK timer's range).
/// </summary>
/// <param name="unshelveAtUtc">The engine's expiry instant, or null.</param>
/// <returns>The shelving time in milliseconds.</returns>
internal static double RemainingShelveMs(DateTime? unshelveAtUtc)
{
if (unshelveAtUtc is not { } at) return 0;
var remaining = (at - DateTime.UtcNow).TotalMilliseconds;
return Math.Clamp(Math.Ceiling(remaining), 1, int.MaxValue);
}
/// <summary>
/// #477 Layer 2 — apply a source-data quality annotation to a materialised condition, OUT OF BAND
/// from any alarm transition. Used by the driver-connectivity path (comms lost → BadNoCommunication,
@@ -1545,9 +1584,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// </para>
/// <para>
/// <b>Shelving is applied here.</b> The SDK's shelve methods never change <c>ShelvingState</c>
/// themselves, so an accepted shelve call applies it — with the same <c>(shelved, oneShot, 0)</c>
/// mapping <see cref="WriteAlarmCondition"/> projects — before reporting. The engine's re-projection of
/// the shelve then finds no delta. Every other method's state the SDK has already applied.
/// themselves, so an accepted shelve call applies it — with the same <c>(shelved, oneShot)</c> mapping
/// <see cref="WriteAlarmCondition"/> projects, and a TimedShelve with its requested duration so
/// <c>UnshelveTime</c> is real (Gitea #597) — before reporting. The engine's re-projection of the shelve
/// then finds no delta. Every other method's state the SDK has already applied.
/// </para>
/// <para>
/// <b>Exactly once.</b> The whole call runs under <c>Lock</c>. The method handlers hand the command
@@ -1569,7 +1609,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// the SDK handler, and nothing is reported when it did not move. The SDK accepts acknowledging an
/// acknowledged condition and sets <c>Confirmed = false</c> again; a re-ack therefore also restores the
/// pre-call Confirmed and Retain, so a scripted node never drifts from its engine (which ignores a
/// re-ack) and the call is silent. AddComment always reports: adding a comment is itself the change.
/// re-ack) and the call is silent — unless it carried a comment, which the SDK writes and which is then
/// reported once like AddComment (Gitea #597). AddComment always reports: adding a comment is itself
/// the change. A NATIVE re-ack is still routed to the driver: the route is fire-and-forget, so a node
/// showing Acked does not prove the upstream system acknowledged, and re-acking is the documented retry.
/// </para>
/// <para>
/// A rejected call (bad <see cref="ServiceResult"/>, e.g. a denied gate, or a refused method on a native
@@ -1582,8 +1625,12 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
if (alarm.Acknowledge?.OnCall is { } acknowledge)
{
// A commented acknowledge always reports (Gitea #597): the SDK writes the comment even on a re-ack,
// and a new comment is a Part 9 change in its own right — exactly as AddComment always reports. An
// uncommented re-ack still changes nothing and stays silent.
alarm.Acknowledge.OnCall = (context, m, objectId, eventId, comment) =>
ReportAfterMethod(() => acknowledge(context, m, objectId, eventId, comment), apply: null, isAcknowledge: true);
ReportAfterMethod(() => acknowledge(context, m, objectId, eventId, comment), apply: null, isAcknowledge: true,
alwaysReport: !LocalizedText.IsNullOrEmpty(comment));
}
WrapComment(alarm.Confirm, alwaysReport: false);
@@ -1597,11 +1644,13 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
WrapGeneric(shelving.Unshelve, () => alarm.SetShelvingState(SystemContext, shelved: false, oneShot: false, shelvingTime: 0));
if (shelving.TimedShelve is { OnCall: { } timed } timedShelve)
{
// shelvingTime 0, as WriteAlarmCondition projects it: the scripted engine owns the expiry and
// re-projects Unshelved when it passes (the SDK's own timer only routes an Unshelve back to it).
// The requested duration, so UnshelveTime counts down to the real expiry (Gitea #597 — 0 reads as
// MaxTimeShelved, ~24.8 days). The scripted engine still owns the expiry and re-projects Unshelved
// when it passes (with its own remaining time meanwhile — WriteAlarmCondition); the SDK timer this
// arms only routes an Unshelve back to it (OnTimedUnshelve), a no-op once the engine has expired it.
timedShelve.OnCall = (context, method, objectId, shelvingTime) => ReportAfterMethod(
() => timed(context, method, objectId, shelvingTime),
() => alarm.SetShelvingState(SystemContext, shelved: true, oneShot: false, shelvingTime: 0));
() => alarm.SetShelvingState(SystemContext, shelved: true, oneShot: false, shelvingTime: shelvingTime));
}
}
@@ -1729,7 +1778,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// folder-scoped condition node id the driver-bound router resolves back to a driver ref.</param>
/// <param name="comment">The acknowledge comment text, or <c>null</c> when none was supplied.</param>
/// <returns><c>ServiceResult.Good</c> when allowed (the SDK then applies state; the method path reports the event);
/// <c>BadUserAccessDenied</c> when the gate vetoes (no route, no state mutation).</returns>
/// <c>BadUserAccessDenied</c> when the gate vetoes, <c>BadInvalidState</c> when this node is not the redundancy
/// Primary (<see cref="NativeAlarmAckPrimaryGate"/>, Gitea #597) — in both cases no route, no state mutation.</returns>
private ServiceResult HandleNativeAlarmAck(ISystemContext context, ConditionState condition, LocalizedText? comment)
{
// Resolve + gate the SAME way HandleAlarmCommand does so native and scripted acks share one authz
@@ -1738,6 +1788,16 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
var gate = EvaluateAlarmCommandGate(identity, condition, "Acknowledge");
if (gate is not null) return gate;
// Gitea #597 — PRIMARY GATE, after authorization (a caller who may not ack learns nothing about the
// node's role). Only the Primary routes a native ack upstream (DriverHostActor applies the same verdict
// to the route), so on any other node the ack is refused outright rather than applied locally and then
// reverted by the driver's next transition. Nothing is routed, applied or reported.
if (NativeAlarmAckPrimaryGate is { } primary && !primary())
{
return new ServiceResult(StatusCodes.BadInvalidState,
new LocalizedText("This server is not the redundancy Primary; acknowledge the alarm on the Primary (the server with the higher ServiceLevel)."));
}
// Same condition-node-id extraction HandleAlarmCommand uses (condition.NodeId.Identifier?.ToString()):
// for a native condition this is the folder-scoped NodeId string the DriverHostActor inverse map
// (_alarmNodeIdByDriverRef value set) keys by, so the next task can resolve it back to (driver, ref).
@@ -3046,6 +3106,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
if (_variables.TryRemove(id, out var v))
{
// Gitea #599 (archreview 03/S20): sever the inverse half of each cross-tree Organizes edge
// first, exactly as RemoveVariableNode does — a UNS reference variable in scope points at a
// RAW node that survives this UNS-scoped removal and would otherwise keep a dangling edge.
SeverOutgoingReferences(v);
v.Parent?.RemoveChild(v);
PredefinedNodes?.Remove(v.NodeId);
}
@@ -60,6 +60,22 @@ public sealed class OtOpcUaSdkServer : StandardServer
return true;
}
/// <summary>
/// Gitea #597 — wire the redundancy verdict that decides whether this node accepts an OPC UA Acknowledge
/// of a NATIVE condition (<see cref="OtOpcUaNodeManager.NativeAlarmAckPrimaryGate"/>); on any other node
/// the ack is refused with <c>BadInvalidState</c> instead of being applied locally and reverted by the
/// driver's next transition. Passing <c>null</c> clears it (allow). No-op (returns <c>false</c>) when the
/// node manager has not been created yet (mirrors <see cref="SetNativeAlarmAckRouter"/>).
/// </summary>
/// <param name="isPrimary">A cheap, non-blocking read of the data-plane Primary verdict; null to allow.</param>
/// <returns><c>true</c> when set on a live node manager; <c>false</c> when no node manager exists yet.</returns>
public bool SetNativeAlarmAckPrimaryGate(Func<bool>? isPrimary)
{
if (_otOpcUaNodeManager is null) return false;
_otOpcUaNodeManager.NativeAlarmAckPrimaryGate = isPrimary;
return true;
}
/// <summary>
/// Wire the reverse-path gateway for inbound operator writes to writable equipment-tag nodes onto the
/// created <see cref="OtOpcUaNodeManager"/>. The host calls this after start with an
@@ -592,15 +592,11 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
// (subscriptions are dead anyway) — the guard skips it. AnnounceAddedNodes is Safe-wrapped
// internally, so a faulting announce can never break the deploy.
//
// KNOWN GAP, and NOT a neutral pre-existing one — the delta work made it matter. Before this
// branch, a plan carrying raw adds went through a full RebuildAddressSpace, so every client had to
// re-browse anyway and the missing announcement changed nothing observable. Now those adds are
// materialised surgically while existing subscriptions stay alive, so an unannounced add is
// genuinely INVISIBLE until a client happens to browse again.
// Added UNS reference variables ARE announced (their equipment folder is the parent, which is the
// realm AnnounceAddedNodes hardcodes). Added RAW containers/tags are still not: the announce is
// Uns-only, and a raw parent needs a realm-aware RaiseNodesAddedModelChange call. That is a real
// follow-up, not an accepted limitation.
// The announce is realm-aware (Gitea #599): UNS parents in the Uns realm, and the parents of
// added RAW containers/tags in the Raw realm. It matters because of the delta work — before it, a
// plan carrying raw adds went through a full RebuildAddressSpace, so every client re-browsed
// anyway; now those adds are materialised surgically while existing subscriptions stay alive, and
// an unannounced add would stay invisible until a client happened to browse again.
if (!outcome.RebuildCalled) _applier.AnnounceAddedNodes(plan);
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "rebuild"));
@@ -739,7 +739,9 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
// #478 — the condition's status is the worst status across the script's input tags at evaluation
// time (carried on the event by the engine). A transition fired while an input is Uncertain projects
// Uncertain here so the full-snapshot write doesn't clobber quality back to Good.
StatusCode: worstInputStatusCode);
StatusCode: worstInputStatusCode,
// Gitea #597 — the engine's own expiry, so the node's UnshelveTime counts down to it.
UnshelveAtUtc: condition.Shelving.Kind == ShelvingKind.Timed ? condition.Shelving.UnshelveAtUtc : null);
/// <summary>Maps the Core <see cref="ShelvingKind"/> onto the Commons <see cref="AlarmShelvingKind"/>
/// mirror (the Commons assembly can't see the Core enum).</summary>