diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs
index bb0f2752..961319f5 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs
@@ -219,6 +219,9 @@ public sealed class AddressSpaceApplier
ok = false;
}
if (!ok) { allApplied = false; break; }
+ RecordReshape(AddressSpaceRealm.Raw, d.Current.NodeId,
+ d.Previous.DataType, d.Previous.IsArray, d.Previous.ArrayLength,
+ d.Current.DataType, d.Current.IsArray, d.Current.ArrayLength);
}
// v3 UNS reference variables — TWO shapes, split on whether the delta moves the node's
// IDENTITY (2026-08-15 review I2).
@@ -270,6 +273,9 @@ public sealed class AddressSpaceApplier
updated = false;
}
if (!updated) { allApplied = false; break; }
+ RecordReshape(AddressSpaceRealm.Uns, d.Current.NodeId,
+ d.Previous.DataType, d.Previous.BackingIsArray, d.Previous.BackingArrayLength,
+ d.Current.DataType, d.Current.BackingIsArray, d.Current.BackingArrayLength);
continue;
}
@@ -1156,30 +1162,49 @@ public sealed class AddressSpaceApplier
try
{
_sink.EnsureVariable(nodeId, parentNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength, refuseArrayOnScalar);
- _ensuredVariables?.Add((realm, nodeId));
+ _recording?.Ensured.Add((realm, nodeId));
return true;
}
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: EnsureVariable threw for {Node}", nodeId); return false; }
}
- /// While non-null, every variable this applier ensures (realm, NodeId). See .
- private HashSet<(AddressSpaceRealm, string)>? _ensuredVariables;
-
- ///
- /// Starts recording every variable the following materialise passes ensure, until
- /// . The publish actor scopes the replay of writes it deferred while a
- /// plan was in flight to these (a surgical apply ensures only its delta), so a variable the apply kept is not
- /// rewritten. Single-caller (the publish actor's thread), like the rest of the apply.
- ///
- /// The live set the recording fills.
- public IReadOnlySet<(AddressSpaceRealm, string)> BeginRecordingEnsuredVariables()
+ /// Records an in-place UpdateTagAttributes that changed the node's SHAPE (DataType / array-ness /
+ /// array length): the node manager drops such a node's value to BadWaitingForInitialData.
+ private void RecordReshape(
+ AddressSpaceRealm realm, string nodeId,
+ string previousDataType, bool previousIsArray, uint? previousArrayLength,
+ string dataType, bool isArray, uint? arrayLength)
{
- _ensuredVariables = new HashSet<(AddressSpaceRealm, string)>();
- return _ensuredVariables;
+ if (_recording is null) return;
+ if (string.Equals(previousDataType, dataType, StringComparison.Ordinal)
+ && previousIsArray == isArray
+ && (!isArray || previousArrayLength == arrayLength))
+ {
+ return;
+ }
+
+ _recording.Reshaped[(realm, nodeId)] = new VariableShape(dataType, isArray, arrayLength);
}
- /// Stops the recording started (the returned set is kept).
- public void EndRecordingEnsuredVariables() => _ensuredVariables = null;
+ /// The recording in progress, or null. See .
+ private MaterialisationRecording? _recording;
+
+ ///
+ /// Starts recording what the following apply and materialise passes do to VARIABLES, until
+ /// : every variable ensured, and every variable an in-place attribute update
+ /// re-shaped (which resets its value). The publish actor scopes the replay of writes it deferred while a plan
+ /// was in flight to these — a variable the apply kept took the live write already, and rewriting it would
+ /// revert a client write made in the window. Single-caller (the publish actor's thread), like the apply.
+ ///
+ /// The live recording.
+ public MaterialisationRecording BeginRecording()
+ {
+ _recording = new MaterialisationRecording();
+ return _recording;
+ }
+
+ /// Stops the recording started (the returned recording is kept).
+ public void EndRecording() => _recording = null;
// The tag/vtag surgical-eligibility predicates moved to AddressSpaceChangeClassifier so the
// whole delta classification is one pure, table-testable unit; the applier references them there (and the
@@ -1293,3 +1318,91 @@ public sealed record AddressSpaceApplyOutcome(
bool RebuildCalled,
bool RebuildFailed = false,
int FailedNodes = 0);
+
+/// What one apply did to variables (see ).
+public sealed class MaterialisationRecording
+{
+ /// Every variable an EnsureVariable was issued for, as (realm, NodeId).
+ public HashSet<(AddressSpaceRealm, string)> Ensured { get; } = new();
+
+ /// Every variable an in-place attribute update RE-SHAPED (its value reset), with its new shape.
+ public Dictionary<(AddressSpaceRealm, string), VariableShape> Reshaped { get; } = new();
+}
+
+/// A variable's value shape: its Tag.DataType name, and its array-ness / 1-D length.
+/// The Tag.DataType name ("Int32", "Float", …; unknown ⇒ BaseDataType, any value fits).
+/// Whether the node is a one-dimensional array.
+/// The fixed array length, when known (null / 0 ⇒ any length).
+public sealed record VariableShape(string DataType, bool IsArray, uint? ArrayLength)
+{
+ ///
+ /// Fits to this shape for a write onto a re-shaped node: unchanged when it already
+ /// fits, converted (invariant culture) when a lossless-enough conversion exists, otherwise refused — an
+ /// old-typed value must never be served under the new DataType (the reset exists to prevent exactly that).
+ /// A null value always fits (a status annotation carries none).
+ ///
+ /// The value to fit.
+ /// The fitted value, when this returns true.
+ /// True when the value fits (as is, or converted).
+ public bool TryFit(object? value, out object? fitted)
+ {
+ fitted = null;
+ if (value is null) return true;
+ var element = ElementType(DataType);
+ if (!IsArray)
+ {
+ if (value is Array) return false;
+ return TryConvert(value, element, out fitted);
+ }
+
+ if (value is not Array array || value is string) return false;
+ if (ArrayLength is > 0 && array.Length != ArrayLength) return false;
+ if (element is null) { fitted = value; return true; }
+ var result = Array.CreateInstance(element, array.Length);
+ for (var i = 0; i < array.Length; i++)
+ {
+ if (!TryConvert(array.GetValue(i), element, out var item) || item is null) return false;
+ result.SetValue(item, i);
+ }
+
+ fitted = result;
+ return true;
+ }
+
+ private static bool TryConvert(object? value, Type? target, out object? converted)
+ {
+ converted = value;
+ if (value is null || target is null || target.IsInstanceOfType(value)) return true;
+ if (value is not IConvertible) return false;
+ try
+ {
+ converted = Convert.ChangeType(value, target, System.Globalization.CultureInfo.InvariantCulture);
+ return true;
+ }
+ catch (Exception ex) when (ex is InvalidCastException or FormatException or OverflowException)
+ {
+ converted = null;
+ return false;
+ }
+ }
+
+ /// The CLR type the node manager serves for a Tag.DataType name (its ResolveBuiltInDataType set);
+ /// null ⇒ BaseDataType, where any value fits.
+ private static Type? ElementType(string dataType) => dataType switch
+ {
+ "Boolean" => typeof(bool),
+ "SByte" => typeof(sbyte),
+ "Byte" => typeof(byte),
+ "Int16" => typeof(short),
+ "UInt16" => typeof(ushort),
+ "Int32" => typeof(int),
+ "UInt32" => typeof(uint),
+ "Int64" => typeof(long),
+ "UInt64" => typeof(ulong),
+ "Float" => typeof(float),
+ "Double" => typeof(double),
+ "String" => typeof(string),
+ "DateTime" => typeof(DateTime),
+ _ => null,
+ };
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs
index 94f2f24d..a1667540 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs
@@ -187,8 +187,21 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
///
/// Variables: after a FULL rebuild every one (each was recreated, its live write landed on the
/// discarded node); otherwise exactly those the apply asked the sink to ensure (the applier records them).
- /// Conditions: those in the applied composition but not the previous one — on a rebuild too, because the
- /// node manager carries a kept condition's state over to its re-materialised instance.
+ /// Variables an in-place attribute update RE-SHAPED (review N4): the node manager reset their value to
+ /// BadWaitingForInitialData, so the window's value is replayed — but only once fitted to the new shape
+ /// (); a value that does not fit is dropped and the node left waiting.
+ /// The raw node and its UNS mirror are re-shaped by the same edit, so both get the same treatment.
+ /// Conditions: those in the applied composition but not the previous one; after a full rebuild ALSO
+ /// every kept SCRIPTED condition (review N3) — the node manager carries only NATIVE conditions' state over
+ /// a rebuild, so a scripted one is recreated fresh and needs the window's state. A kept native condition
+ /// is restored from the carry-over (which already holds the live write and any SDK ack), so it is not
+ /// replayed. Accepted cost on the scripted path: an SDK ack on the discarded instance in the window is
+ /// lost with it; the engine's own ack emission (a scripted ack routes to the engine) restores it.
+ /// Known gaps (degraded path only, review m1 / m2): a degraded non-rebuild apply does not advance
+ /// _lastApplied, so the next apply counts its conditions as added again and may replay onto them;
+ /// and the empty-plan repair of a degraded composition scopes no condition (the composition is
+ /// unchanged), so a condition the repair newly places gets no window state — writes to it between the
+ /// two applies were already dropped before this replay existed.
///
/// (Precise "did the live write land" would need the sink to report it; the production sink chain runs through
/// Commons' DeferredAddressSpaceSink, outside this lane — follow-up F7.)
@@ -198,10 +211,12 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
private sealed record ReplayScope(
bool AllVariables,
IReadOnlySet<(AddressSpaceRealm, string)> Variables,
+ IReadOnlyDictionary<(AddressSpaceRealm, string), VariableShape> Reshaped,
IReadOnlySet<(AddressSpaceRealm, string)> Conditions)
{
private static readonly IReadOnlySet<(AddressSpaceRealm, string)> None = new HashSet<(AddressSpaceRealm, string)>();
- public static readonly ReplayScope Nothing = new(false, None, None);
+ public static readonly ReplayScope Nothing =
+ new(false, None, new Dictionary<(AddressSpaceRealm, string), VariableShape>(), None);
}
/// True while a rebuild's parse / plan step is running off-thread. Rebuilds are applied strictly in
@@ -851,8 +866,28 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
private void ReplayDeferredWrites(ReplayScope scope)
{
if (_deferred.IsEmpty) return;
- var (values, statuses) = _deferred.VariableWrites(
- key => scope.AllVariables || scope.Variables.Contains(key));
+ var (allValues, allStatuses) = _deferred.VariableWrites(
+ key => scope.AllVariables || scope.Variables.Contains(key) || scope.Reshaped.ContainsKey(key));
+ var values = new List(allValues.Count);
+ var statuses = new List(allStatuses.Count);
+ var unfit = 0;
+ foreach (var v in allValues)
+ {
+ if (!scope.Reshaped.TryGetValue((v.Realm, v.NodeId), out var shape)) { values.Add(v); continue; }
+ if (shape.TryFit(v.Value, out var fitted)) { values.Add(v with { Value = fitted }); continue; }
+ unfit++;
+ _log.Debug(
+ "OpcUaPublish: a value deferred for re-shaped {Realm} node {Node} does not fit its new shape ({DataType}, array={IsArray}) — not replayed; the node waits for the driver's next value",
+ v.Realm, v.NodeId, shape.DataType, shape.IsArray);
+ }
+
+ foreach (var st in allStatuses)
+ {
+ // A status-only annotation onto a re-shaped (value-less) node would serve a null value under it; the
+ // node's honest state is BadWaitingForInitialData until a value of the new shape arrives.
+ if (scope.Reshaped.ContainsKey((st.Realm, st.NodeId))) { unfit++; continue; }
+ statuses.Add(st);
+ }
if (values.Count > 0)
{
try { _sink.WriteValues(values); }
@@ -865,22 +900,27 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
catch (Exception ex) { _log.Warning(ex, "OpcUaPublish: replaying {Count} deferred status writes threw", statuses.Count); }
}
+ var alarms = 0;
foreach (var alarm in _deferred.Alarms)
{
if (!scope.Conditions.Contains((alarm.Realm, alarm.AlarmNodeId))) continue;
+ alarms++;
try { _sink.WriteAlarmCondition(alarm.AlarmNodeId, alarm.State, alarm.TimestampUtc, alarm.Realm); }
catch (Exception ex) { _log.Warning(ex, "OpcUaPublish: replaying a deferred alarm write threw for {Node}", alarm.AlarmNodeId); }
}
+ var qualities = 0;
foreach (var quality in _deferred.AlarmQualities)
{
if (!scope.Conditions.Contains((quality.Realm, quality.AlarmNodeId))) continue;
+ qualities++;
try { _sink.WriteAlarmQuality(quality.AlarmNodeId, quality.StatusCode, quality.TimestampUtc, quality.Realm); }
catch (Exception ex) { _log.Warning(ex, "OpcUaPublish: replaying a deferred alarm-quality write threw for {Node}", quality.AlarmNodeId); }
}
- _log.Debug("OpcUaPublish: replayed {Values} value and {Statuses} status writes (of {Held} held) deferred during a rebuild plan",
- values.Count, statuses.Count, _deferred.Count);
+ _log.Debug(
+ "OpcUaPublish: replayed {Values} value, {Statuses} status, {Alarms} alarm-state and {Qualities} alarm-quality writes (of {Held} held; {Unfit} not fitting a re-shaped node) deferred during a rebuild plan",
+ values.Count, statuses.Count, alarms, qualities, _deferred.Count, unfit);
}
///
@@ -1019,17 +1059,28 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
private (bool Degraded, string? Reason) ApplyPlan(PlanReady ready)
{
var previous = _lastApplied;
- var ensured = _applier!.BeginRecordingEnsuredVariables();
+ var recording = _applier!.BeginRecording();
try
{
var result = ApplyPlanCore(ready, out var rebuilt);
- // What this apply created (see _replayScope). A throwing pass still scopes to what it did create.
- _replayScope = new ReplayScope(rebuilt, ensured, AddedConditions(previous, ready.Composition));
+ // What this apply created or reset (see _replayScope). A throwing pass still scopes to what it did.
+ var conditions = AddedConditions(previous, ready.Composition);
+ if (rebuilt)
+ {
+ // Review N3: a full rebuild recreates every SCRIPTED condition fresh — the node manager carries only
+ // NATIVE conditions' state over (OtOpcUaNodeManager.RebuildAddressSpace). The engine's re-assert
+ // (or any transition) handled in the window landed on the discarded instance, so every kept scripted
+ // condition must be replayed onto its new one. Native kept conditions stay "added only".
+ foreach (var a in ready.Composition.EquipmentScriptedAlarms)
+ if (a.Enabled) conditions.Add((AddressSpaceRealm.Uns, a.ScriptedAlarmId));
+ }
+
+ _replayScope = new ReplayScope(rebuilt, recording.Ensured, recording.Reshaped, conditions);
return result;
}
finally
{
- _applier.EndRecordingEnsuredVariables();
+ _applier.EndRecording();
}
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/VariableShapeTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/VariableShapeTests.cs
new file mode 100644
index 00000000..52e6daff
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/VariableShapeTests.cs
@@ -0,0 +1,64 @@
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
+
+///
+/// Lane B review N4 — decides whether a value deferred during a plan window may
+/// be replayed onto a node an in-place edit re-shaped: never an old-typed value the new DataType contradicts.
+///
+public sealed class VariableShapeTests
+{
+ [Fact]
+ public void A_value_of_the_new_type_fits_unchanged()
+ {
+ new VariableShape("Int32", false, null).TryFit(7, out var fitted).ShouldBeTrue();
+ fitted.ShouldBe(7);
+ }
+
+ [Fact]
+ public void A_convertible_old_typed_value_is_converted_to_the_new_type()
+ {
+ new VariableShape("Int32", false, null).TryFit(5.0f, out var fitted).ShouldBeTrue();
+ fitted.ShouldBeOfType().ShouldBe(5);
+ new VariableShape("Double", false, null).TryFit((short)3, out var d).ShouldBeTrue();
+ d.ShouldBeOfType().ShouldBe(3d);
+ }
+
+ [Theory]
+ [InlineData("Int32", "fast")] // not a number
+ [InlineData("Byte", 300)] // overflow
+ [InlineData("DateTime", 12)] // no conversion
+ public void A_value_the_new_type_cannot_hold_does_not_fit(string dataType, object value)
+ {
+ new VariableShape(dataType, false, null).TryFit(value, out var fitted).ShouldBeFalse();
+ fitted.ShouldBeNull();
+ }
+
+ [Fact]
+ public void Scalar_and_array_never_cross()
+ {
+ new VariableShape("Int32", false, null).TryFit(new[] { 1, 2 }, out _).ShouldBeFalse();
+ new VariableShape("Int32", true, 2).TryFit(1, out _).ShouldBeFalse();
+ new VariableShape("String", true, null).TryFit("ab", out _).ShouldBeFalse("a string is not an array of strings");
+ }
+
+ [Fact]
+ public void An_array_is_converted_element_wise_and_its_fixed_length_enforced()
+ {
+ new VariableShape("Int16", true, 3).TryFit(new[] { 1, 2, 3 }, out var fitted).ShouldBeTrue();
+ fitted.ShouldBeOfType().ShouldBe(new short[] { 1, 2, 3 });
+ new VariableShape("Int16", true, 4).TryFit(new[] { 1, 2, 3 }, out _).ShouldBeFalse("wrong length");
+ new VariableShape("Int16", true, null).TryFit(new[] { 1, 2, 3 }, out _).ShouldBeTrue("no fixed length");
+ new VariableShape("Byte", true, null).TryFit(new[] { 1, 999 }, out _).ShouldBeFalse("an element overflows");
+ }
+
+ [Fact]
+ public void Null_and_unknown_types_always_fit()
+ {
+ new VariableShape("Int32", false, null).TryFit(null, out var n).ShouldBeTrue();
+ n.ShouldBeNull();
+ new VariableShape("Structure", false, null).TryFit("anything", out var any).ShouldBeTrue();
+ any.ShouldBe("anything");
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorDeferredWriteTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorDeferredWriteTests.cs
index 82bc6d8a..2ab304a8 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorDeferredWriteTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorDeferredWriteTests.cs
@@ -277,13 +277,11 @@ public sealed class OpcUaPublishActorDeferredWriteTests : RuntimeActorTestBase
///
/// N2 — a condition the plan KEEPS took the live write already; replaying it would roll back an operator's
- /// Acknowledge applied in the window (the SDK mutates the node directly) and fire spurious events. Driven over
- /// both apply shapes: a surgical pure-add, and a full rebuild (which carries the condition's state over).
+ /// Acknowledge applied in the window (the SDK mutates the node directly) and fire spurious events. The surgical
+ /// apply shape; the rebuild shapes are the two tests below (scripted: replayed; native: carried over).
///
- [Theory]
- [InlineData(false)]
- [InlineData(true)]
- public void An_out_of_band_ack_on_a_kept_condition_is_not_reverted_by_the_replay(bool rebuild)
+ [Fact]
+ public void An_out_of_band_ack_on_a_kept_condition_is_not_reverted_by_the_replay()
{
var sink = new ExistenceSink();
using var gate = new ManualResetEventSlim(true);
@@ -293,8 +291,8 @@ public sealed class OpcUaPublishActorDeferredWriteTests : RuntimeActorTestBase
sink.ConditionExists("al-1").ShouldBeTrue();
gate.Reset();
- // A surgical pure-add (new raw tags) or a full rebuild (an equipment rename) that keeps al-1.
- actor.Tell(Rebuild(rebuild ? AlarmArtifact(equipmentName: "Eq-renamed") : AlarmArtifact(rawTags: 2)));
+ // A surgical pure-add (new raw tags) that keeps al-1.
+ actor.Tell(Rebuild(AlarmArtifact(rawTags: 2)));
actor.Tell(new OpcUaPublishActor.AlarmStateUpdate("al-1", ActiveUnacked(OpcUaStatus.Good), DateTime.UtcNow, AddressSpaceRealm.Uns));
AwaitAssert(() => sink.AlarmWritesTo("al-1").ShouldBe(1), PlanBudget); // the live write landed
sink.AcknowledgeOutOfBand("al-1"); // an operator acks in the window
@@ -302,7 +300,7 @@ public sealed class OpcUaPublishActorDeferredWriteTests : RuntimeActorTestBase
gate.Set();
ExpectMsg(PlanBudget).Degraded.ShouldBeFalse();
- sink.SawRebuild.ShouldBe(rebuild, "non-vacuity: the intended apply shape ran");
+ sink.SawRebuild.ShouldBeFalse("non-vacuity: a surgical apply");
sink.AlarmStateOf("al-1")!.Acknowledged.ShouldBeTrue("the operator's ack must survive the apply");
sink.AlarmWritesTo("al-1").ShouldBe(1, "no replayed write (and so no extra Part 9 event) on a kept condition");
}
@@ -422,6 +420,149 @@ public sealed class OpcUaPublishActorDeferredWriteTests : RuntimeActorTestBase
second.Degraded.ShouldBeTrue();
}
+
+ ///
+ /// N3 (i) — a full rebuild recreates every KEPT SCRIPTED condition fresh (only native conditions are carried
+ /// over), so the engine's re-assert handled in the window — landing on the discarded instance — must be
+ /// replayed onto the new one, or the active alarm reads Normal until its next transition (#487).
+ ///
+ [Fact]
+ public void A_rebuild_replays_the_window_state_onto_every_kept_scripted_condition()
+ {
+ var sink = new ExistenceSink();
+ using var gate = new ManualResetEventSlim(true);
+ var actor = Spawn(sink, gate);
+ actor.Tell(Rebuild(AlarmArtifact()));
+ ExpectMsg(PlanBudget).Degraded.ShouldBeFalse();
+
+ gate.Reset();
+ actor.Tell(Rebuild(AlarmArtifact(equipmentName: "Eq-renamed"))); // ChangedEquipment ⇒ full rebuild, al-1 kept
+ actor.Tell(new OpcUaPublishActor.AlarmStateUpdate("al-1", ActiveUnacked(OpcUaStatus.Good), DateTime.UtcNow, AddressSpaceRealm.Uns));
+ AwaitAssert(() => sink.AlarmWritesTo("al-1").ShouldBe(1), PlanBudget); // landed live on the old instance
+
+ gate.Set();
+ ExpectMsg(PlanBudget).Degraded.ShouldBeFalse();
+
+ sink.SawRebuild.ShouldBeTrue("non-vacuity: a full rebuild ran");
+ sink.AlarmStateOf("al-1").ShouldNotBeNull("the re-assert must reach the recreated condition")
+ .Active.ShouldBeTrue();
+ }
+
+ ///
+ /// N3 (ii) — the native counterpart: a kept NATIVE condition is restored from the node manager's carry-over
+ /// (which already holds the live write and the operator's ack), so it is not replayed — the ack survives and no
+ /// extra write lands.
+ ///
+ [Fact]
+ public void A_rebuild_does_not_replay_onto_a_kept_native_condition()
+ {
+ var sink = new ExistenceSink();
+ using var gate = new ManualResetEventSlim(true);
+ var actor = Spawn(sink, gate);
+ actor.Tell(Rebuild(AlarmArtifact(nativeAlarm: true)));
+ ExpectMsg(PlanBudget).Degraded.ShouldBeFalse();
+ sink.ConditionExists(NativeAlarm).ShouldBeTrue();
+
+ gate.Reset();
+ actor.Tell(Rebuild(AlarmArtifact(equipmentName: "Eq-renamed", nativeAlarm: true)));
+ actor.Tell(new OpcUaPublishActor.AlarmStateUpdate(NativeAlarm, ActiveUnacked(OpcUaStatus.Good), DateTime.UtcNow, AddressSpaceRealm.Raw));
+ AwaitAssert(() => sink.AlarmWritesTo(NativeAlarm).ShouldBe(1), PlanBudget);
+ sink.AcknowledgeOutOfBand(NativeAlarm);
+
+ gate.Set();
+ ExpectMsg(PlanBudget).Degraded.ShouldBeFalse();
+
+ sink.SawRebuild.ShouldBeTrue("non-vacuity: a full rebuild ran");
+ sink.AlarmStateOf(NativeAlarm)!.Acknowledged.ShouldBeTrue("the carried-over ack survives");
+ sink.AlarmWritesTo(NativeAlarm).ShouldBe(1, "no replayed write on a carried-over native condition");
+ }
+
+ ///
+ /// N4 — a surgical in-place DataType edit resets the raw node AND its UNS mirror (the node manager drops an
+ /// old-typed value), and neither is ensured by the apply. The window's value is replayed onto both, fitted to
+ /// the new type, so the two NodeIds of one value agree.
+ ///
+ [Fact]
+ public void A_surgical_shape_edit_replays_the_window_value_fitted_onto_the_raw_node_and_its_mirror()
+ {
+ var sink = new ExistenceSink();
+ using var gate = new ManualResetEventSlim(true);
+ var actor = Spawn(sink, gate);
+ actor.Tell(Rebuild(ShapeArtifact("Float")));
+ ExpectMsg(PlanBudget).Degraded.ShouldBeFalse();
+ sink.Exists(RawSpeed).ShouldBeTrue();
+ sink.Exists(UnsSpeed).ShouldBeTrue("non-vacuity: the mirror exists");
+
+ gate.Reset();
+ actor.Tell(Rebuild(ShapeArtifact("Int32")));
+ var at = DateTime.UtcNow;
+ actor.Tell(new OpcUaPublishActor.AttributeValueBatchUpdate(
+ [
+ new ValueWrite(RawSpeed, 5.0f, OpcUaStatus.Good, at, AddressSpaceRealm.Raw),
+ new ValueWrite(UnsSpeed, 5.0f, OpcUaStatus.Good, at, AddressSpaceRealm.Uns),
+ ]));
+ AwaitAssert(() => sink.ValueOf(RawSpeed)!.Value.Value.ShouldBe(5.0f), PlanBudget);
+
+ gate.Set();
+ ExpectMsg(PlanBudget).Degraded.ShouldBeFalse();
+
+ sink.SawRebuild.ShouldBeFalse("non-vacuity: a surgical in-place edit");
+ sink.ReshapedNodes.ShouldContain(RawSpeed, "non-vacuity: the raw node was reset");
+ sink.ReshapedNodes.ShouldContain(UnsSpeed, "non-vacuity: the mirror was reset");
+ var raw = sink.ValueOf(RawSpeed).ShouldNotBeNull();
+ raw.Value.ShouldBeOfType().ShouldBe(5);
+ sink.ValueOf(UnsSpeed).ShouldBe(raw, "the raw node and its UNS mirror agree");
+ }
+
+ /// N4 — a window value that cannot be fitted to the new shape is never written: both NodeIds stay
+ /// value-less (BadWaitingForInitialData) until the driver's next value.
+ [Fact]
+ public void A_surgical_shape_edit_never_replays_a_value_that_does_not_fit_the_new_shape()
+ {
+ var sink = new ExistenceSink();
+ using var gate = new ManualResetEventSlim(true);
+ var actor = Spawn(sink, gate);
+ actor.Tell(Rebuild(ShapeArtifact("String")));
+ ExpectMsg(PlanBudget);
+
+ gate.Reset();
+ actor.Tell(Rebuild(ShapeArtifact("Int32")));
+ var at = DateTime.UtcNow;
+ actor.Tell(new OpcUaPublishActor.AttributeValueBatchUpdate(
+ [
+ new ValueWrite(RawSpeed, "fast", OpcUaStatus.Good, at, AddressSpaceRealm.Raw),
+ new ValueWrite(UnsSpeed, "fast", OpcUaStatus.Good, at, AddressSpaceRealm.Uns),
+ ]));
+ AwaitAssert(() => sink.ValueOf(RawSpeed)!.Value.Value.ShouldBe("fast"), PlanBudget);
+
+ gate.Set();
+ ExpectMsg(PlanBudget);
+
+ sink.ReshapedNodes.ShouldContain(RawSpeed, "non-vacuity: the raw node was reset");
+ sink.ValueOf(RawSpeed).ShouldBeNull("\"fast\" is not an Int32 — the node keeps waiting");
+ sink.ValueOf(UnsSpeed).ShouldBeNull();
+ }
+
+ private const string NativeAlarm = "Plant/Modbus/dev1/Hi";
+ private const string RawSpeed = "Plant/Modbus/dev1/Speed";
+ private const string UnsSpeed = "Area/Line/Eq/Speed";
+
+ /// One raw tag (Speed) of and one UNS reference to it under Area/Line/Eq.
+ private static byte[] ShapeArtifact(string dataType) =>
+ JsonSerializer.SerializeToUtf8Bytes(new
+ {
+ UnsAreas = new[] { new { UnsAreaId = "area-1", ClusterId = "c1", Name = "Area" } },
+ UnsLines = new[] { new { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "Line" } },
+ Equipment = new[] { new { EquipmentId = "eq-1", DriverInstanceId = (string?)null, UnsLineId = "line-1", Name = "Eq", MachineCode = "EQ-1" } },
+ RawFolders = new[] { new { RawFolderId = "rf-1", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
+ DriverInstances = new[] { new { DriverInstanceId = "drv-1", DriverType = "Modbus", DriverConfig = "{}", Name = "Modbus", RawFolderId = "rf-1", ClusterId = "c1" } },
+ Devices = new[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "dev1", DeviceConfig = "{}" } },
+ TagGroups = Array.Empty