diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs
index c8bd3afa..64c65d3c 100644
--- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs
@@ -948,10 +948,31 @@ public class InstanceActor : ReceiveActor
if (_resolvedAttributeByName.TryGetValue(kvp.Key, out var resolved)
&& IsListAttribute(resolved))
{
+ // NJ-4: decode the stored List override (both old array-of-strings
+ // and native-typed forms decode) and re-persist the native form if
+ // the stored value is still in the OLD form. Re-encoding the decoded
+ // list and comparing to the stored string detects old-form values
+ // (native → native is byte-identical, so a native value is a no-op).
+ // The re-persist is fire-and-forget and never throws into the actor.
var decoded = DecodeAttributeValue(resolved, kvp.Value);
_attributes[kvp.Key] = decoded;
if (decoded is null && !string.IsNullOrEmpty(kvp.Value))
+ {
_attributeQualities[kvp.Key] = "Bad";
+ }
+ else if (decoded is not null)
+ {
+ var native = AttributeValueCodec.Encode(decoded);
+ if (native != kvp.Value) // stored value was old-form → normalize on disk
+ {
+ var key = kvp.Key;
+ _storage.SetStaticOverrideAsync(_instanceUniqueName, key, native!)
+ .ContinueWith(t => _logger.LogWarning(t.Exception?.GetBaseException(),
+ "Failed to normalize static override {Instance}.{Attr} to native JSON",
+ _instanceUniqueName, key),
+ TaskContinuationOptions.OnlyOnFaulted);
+ }
+ }
}
else
{
diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTests.cs
index f93a238e..72dbb49c 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTests.cs
@@ -1016,4 +1016,159 @@ public class InstanceActorTests : TestKit, IDisposable
Assert.Single(overrides);
Assert.Equal("[]", overrides["Labels"]);
}
+
+ // ── NJ-4: old-form List static override normalization on load ────────────
+
+ ///
+ /// NJ-4: an OLD array-of-strings static override (["10","20"]) for an
+ /// Int32 List attribute must be re-persisted in the native form ([10,20])
+ /// when the actor loads it at startup. The in-memory read still returns the
+ /// typed list {10,20}; the on-disk value is normalized to native JSON.
+ ///
+ [Fact]
+ public async Task InstanceActor_OldFormListOverride_NormalizedToNativeOnLoad()
+ {
+ await _storage.SetStaticOverrideAsync("Pump-OldForm", "Counts", "[\"10\",\"20\"]");
+
+ var config = new FlattenedConfiguration
+ {
+ InstanceUniqueName = "Pump-OldForm",
+ Attributes =
+ [
+ new ResolvedAttribute
+ {
+ CanonicalName = "Counts", Value = "[1,2]",
+ DataType = "List", ElementDataType = "Int32"
+ }
+ ]
+ };
+
+ var actor = CreateInstanceActor("Pump-OldForm", config);
+
+ // Wait for the async override load (PipeTo) + fire-and-forget normalization.
+ await Task.Delay(1000);
+
+ // In-memory read returns the typed list, decoded from the old form.
+ actor.Tell(new GetAttributeRequest("corr-of", "Pump-OldForm", "Counts", DateTimeOffset.UtcNow));
+ var response = ExpectMsg(TimeSpan.FromSeconds(5));
+ Assert.True(response.Found);
+ var list = Assert.IsType>(response.Value);
+ Assert.Equal(new[] { 10, 20 }, list);
+
+ // The on-disk override has been normalized to the native form.
+ var overrides = await _storage.GetStaticOverridesAsync("Pump-OldForm");
+ Assert.Single(overrides);
+ Assert.Equal("[10,20]", overrides["Counts"]);
+ }
+
+ ///
+ /// NJ-4: a NATIVE-form static override ([10,20]) is already canonical, so
+ /// load-time normalization must be a no-op — the on-disk value is unchanged
+ /// (idempotent: native → native is byte-identical, so no re-persist occurs).
+ ///
+ [Fact]
+ public async Task InstanceActor_NativeFormListOverride_NotRePersistedOnLoad()
+ {
+ await _storage.SetStaticOverrideAsync("Pump-Native", "Counts", "[10,20]");
+
+ var config = new FlattenedConfiguration
+ {
+ InstanceUniqueName = "Pump-Native",
+ Attributes =
+ [
+ new ResolvedAttribute
+ {
+ CanonicalName = "Counts", Value = "[1,2]",
+ DataType = "List", ElementDataType = "Int32"
+ }
+ ]
+ };
+
+ var actor = CreateInstanceActor("Pump-Native", config);
+ await Task.Delay(1000);
+
+ actor.Tell(new GetAttributeRequest("corr-nat", "Pump-Native", "Counts", DateTimeOffset.UtcNow));
+ var response = ExpectMsg(TimeSpan.FromSeconds(5));
+ Assert.True(response.Found);
+ var list = Assert.IsType>(response.Value);
+ Assert.Equal(new[] { 10, 20 }, list);
+
+ // The native value is left untouched on disk.
+ var overrides = await _storage.GetStaticOverridesAsync("Pump-Native");
+ Assert.Single(overrides);
+ Assert.Equal("[10,20]", overrides["Counts"]);
+ }
+
+ ///
+ /// NJ-4: a scalar static override is unaffected by the List normalization path —
+ /// its on-disk value is left exactly as stored (no native re-encode).
+ ///
+ [Fact]
+ public async Task InstanceActor_ScalarOverride_NotTouchedByListNormalization()
+ {
+ await _storage.SetStaticOverrideAsync("Pump-ScalarOf", "Temperature", "200.0");
+
+ var config = new FlattenedConfiguration
+ {
+ InstanceUniqueName = "Pump-ScalarOf",
+ Attributes =
+ [
+ new ResolvedAttribute { CanonicalName = "Temperature", Value = "100.0", DataType = "Double" }
+ ]
+ };
+
+ var actor = CreateInstanceActor("Pump-ScalarOf", config);
+ await Task.Delay(1000);
+
+ actor.Tell(new GetAttributeRequest("corr-sof", "Pump-ScalarOf", "Temperature", DateTimeOffset.UtcNow));
+ var response = ExpectMsg(TimeSpan.FromSeconds(5));
+ Assert.True(response.Found);
+ Assert.Equal("200.0", response.Value);
+
+ var overrides = await _storage.GetStaticOverridesAsync("Pump-ScalarOf");
+ Assert.Single(overrides);
+ Assert.Equal("200.0", overrides["Temperature"]);
+ }
+
+ ///
+ /// NJ-4: a malformed stored List override (truncated JSON) must NOT crash the
+ /// actor and must NOT be re-persisted — it loads with Bad quality (as today),
+ /// the actor stays alive, and the poison on-disk value is left unchanged.
+ ///
+ [Fact]
+ public async Task InstanceActor_MalformedListOverride_BadQuality_NotRePersisted()
+ {
+ await _storage.SetStaticOverrideAsync("Pump-BadOf", "Counts", "[\"a\"");
+
+ var config = new FlattenedConfiguration
+ {
+ InstanceUniqueName = "Pump-BadOf",
+ Attributes =
+ [
+ new ResolvedAttribute
+ {
+ CanonicalName = "Counts", Value = "[1,2]",
+ DataType = "List", ElementDataType = "Int32"
+ }
+ ]
+ };
+
+ var actor = CreateInstanceActor("Pump-BadOf", config);
+ Watch(actor);
+ await Task.Delay(1000);
+
+ actor.Tell(new GetAttributeRequest("corr-bof", "Pump-BadOf", "Counts", DateTimeOffset.UtcNow));
+ var response = ExpectMsg(TimeSpan.FromSeconds(5));
+ Assert.True(response.Found);
+ Assert.Equal("Bad", response.Quality);
+ Assert.Null(response.Value);
+
+ // The actor must still be alive — no crash from the normalization path.
+ ExpectNoTerminated(actor, TimeSpan.FromMilliseconds(500));
+
+ // The malformed value must NOT have been re-persisted (left exactly as stored).
+ var overrides = await _storage.GetStaticOverridesAsync("Pump-BadOf");
+ Assert.Single(overrides);
+ Assert.Equal("[\"a\"", overrides["Counts"]);
+ }
}