diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ListValueNormalizer.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ListValueNormalizer.cs
new file mode 100644
index 00000000..abac3825
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ListValueNormalizer.cs
@@ -0,0 +1,123 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using ZB.MOM.WW.ScadaBridge.Commons.Types;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
+
+namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
+
+///
+/// Idempotent central startup normalizer that rewrites already-persisted List attribute
+/// values from the old array-of-strings JSON form (["10","20"]) to the new
+/// native-typed form ([10,20]).
+///
+///
+/// This is a safety net: at the time of writing there is no deployed List attribute data,
+/// so in practice it finds nothing to rewrite. It runs on every central startup after
+/// migrations apply and MUST never abort startup for data reasons — every row's work is
+/// wrapped in a per-row try/catch that logs and skips on malformed data. A second run
+/// finds nothing to change (native → native re-encode is byte-identical).
+///
+///
+public static class ListValueNormalizer
+{
+ ///
+ /// Rewrites old-form List attribute values to the native-typed JSON form across
+ /// and
+ /// . Idempotent and
+ /// best-effort: malformed rows are logged and skipped, never rethrown.
+ ///
+ /// The configuration database context.
+ /// Optional logger for diagnostics.
+ /// Cancellation token.
+ /// A task that represents the asynchronous operation.
+ public static async Task NormalizeAsync(
+ ScadaBridgeDbContext db,
+ ILogger? logger = null,
+ CancellationToken ct = default)
+ {
+ var rewritten = 0;
+
+ // TemplateAttributes: List rows carry the element type on the row itself.
+ var templateRows = await db.TemplateAttributes
+ .Where(a => a.DataType == DataType.List)
+ .ToListAsync(ct);
+
+ foreach (var a in templateRows)
+ {
+ try
+ {
+ var native = AttributeValueCodec.Encode(
+ AttributeValueCodec.Decode(a.Value, DataType.List, a.ElementDataType));
+ if (native != a.Value)
+ {
+ a.Value = native;
+ rewritten++;
+ }
+ }
+ catch (FormatException ex)
+ {
+ logger?.LogWarning(ex,
+ "List value normalizer: skipping unparseable list value for TemplateAttribute {Id}.",
+ a.Id);
+ }
+ }
+
+ // InstanceAttributeOverrides: only rows that carry an element type are List rows.
+ // Rows with a null ElementDataType are scalar/legacy rows (no deployed List data
+ // exists, so none in practice) and are skipped.
+ var overrideRows = await db.InstanceAttributeOverrides
+ .Where(o => o.ElementDataType != null)
+ .ToListAsync(ct);
+
+ foreach (var o in overrideRows)
+ {
+ if (o.ElementDataType is null)
+ {
+ logger?.LogDebug(
+ "List value normalizer: skipping InstanceAttributeOverride {Id} with no element type.",
+ o.Id);
+ continue;
+ }
+
+ try
+ {
+ var native = AttributeValueCodec.Encode(
+ AttributeValueCodec.Decode(o.OverrideValue, DataType.List, o.ElementDataType));
+ if (native != o.OverrideValue)
+ {
+ o.OverrideValue = native;
+ rewritten++;
+ }
+ }
+ catch (FormatException ex)
+ {
+ logger?.LogWarning(ex,
+ "List value normalizer: skipping unparseable list value for InstanceAttributeOverride {Id}.",
+ o.Id);
+ }
+ }
+
+ try
+ {
+ await db.SaveChangesAsync(ct);
+ }
+ catch (Exception ex)
+ {
+ // A catastrophic DB failure on SaveChanges may propagate, but log it first so
+ // startup diagnostics are not silent. Per-row data problems are already handled
+ // above and never reach here.
+ logger?.LogError(ex, "List value normalizer: SaveChanges failed.");
+ throw;
+ }
+
+ if (rewritten > 0)
+ {
+ logger?.LogInformation(
+ "List value normalizer: rewrote {n} attribute value(s) to native JSON.", rewritten);
+ }
+ else
+ {
+ logger?.LogDebug("List value normalizer: no attribute values required rewriting.");
+ }
+ }
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/MigrationHelper.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/MigrationHelper.cs
index c5b9ba73..c5b15d1a 100644
--- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/MigrationHelper.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/MigrationHelper.cs
@@ -47,6 +47,12 @@ public static class MigrationHelper
"Apply migrations using 'dotnet ef database update' or the generated SQL scripts before starting in production mode.");
}
}
+
+ // Safety-net normalizer: rewrite any already-persisted List attribute values from the
+ // old array-of-strings JSON form to the new native-typed form. Idempotent and never
+ // aborts startup for data reasons (per-row skip + log). Safe even if both central
+ // nodes run it concurrently on startup.
+ await ListValueNormalizer.NormalizeAsync(dbContext, logger, cancellationToken);
}
private static async Task WaitForDatabaseReadyAsync(
diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/ListValueNormalizerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/ListValueNormalizerTests.cs
new file mode 100644
index 00000000..fc224c46
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/ListValueNormalizerTests.cs
@@ -0,0 +1,231 @@
+using Microsoft.EntityFrameworkCore;
+using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
+using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
+using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
+using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
+
+namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
+
+///
+/// Tests for — the idempotent startup normalizer that
+/// rewrites already-persisted List attribute values from the old array-of-strings JSON
+/// form (["10","20"]) to the new native-typed form ([10,20]).
+///
+public class ListValueNormalizerTests : IDisposable
+{
+ private readonly ScadaBridgeDbContext _context;
+
+ public ListValueNormalizerTests()
+ {
+ _context = SqliteTestHelper.CreateInMemoryContext();
+ }
+
+ public void Dispose()
+ {
+ _context.Database.CloseConnection();
+ _context.Dispose();
+ }
+
+ // Seeds a Template parent (satisfies the TemplateAttribute -> Template FK) and returns its Id.
+ private async Task SeedTemplateAsync()
+ {
+ var template = new Template("T1");
+ _context.Templates.Add(template);
+ await _context.SaveChangesAsync();
+ return template.Id;
+ }
+
+ // Seeds Site + Template + Instance (satisfies the InstanceAttributeOverride -> Instance FK)
+ // and returns the Instance Id.
+ private async Task SeedInstanceAsync()
+ {
+ var site = new Site("Site1", "S-001");
+ var template = new Template("T1");
+ _context.Sites.Add(site);
+ _context.Templates.Add(template);
+ await _context.SaveChangesAsync();
+
+ var instance = new Instance("Inst1") { SiteId = site.Id, TemplateId = template.Id };
+ _context.Instances.Add(instance);
+ await _context.SaveChangesAsync();
+ return instance.Id;
+ }
+
+ [Fact]
+ public async Task TemplateAttribute_OldStringForm_RewrittenToNative()
+ {
+ var templateId = await SeedTemplateAsync();
+ _context.TemplateAttributes.Add(new TemplateAttribute("intList")
+ {
+ TemplateId = templateId,
+ DataType = DataType.List,
+ ElementDataType = DataType.Int32,
+ Value = "[\"10\",\"20\"]",
+ });
+ await _context.SaveChangesAsync();
+
+ await ListValueNormalizer.NormalizeAsync(_context);
+
+ var reloaded = await _context.TemplateAttributes.AsNoTracking().SingleAsync();
+ Assert.Equal("[10,20]", reloaded.Value);
+ }
+
+ [Fact]
+ public async Task TemplateAttribute_AlreadyNative_IsNotRewritten()
+ {
+ var templateId = await SeedTemplateAsync();
+ _context.TemplateAttributes.Add(new TemplateAttribute("intList")
+ {
+ TemplateId = templateId,
+ DataType = DataType.List,
+ ElementDataType = DataType.Int32,
+ Value = "[10,20]",
+ });
+ await _context.SaveChangesAsync();
+
+ await ListValueNormalizer.NormalizeAsync(_context);
+
+ // No tracked entity should be marked Modified — idempotent no-op.
+ var tracked = _context.ChangeTracker.Entries()
+ .Where(e => e.State == EntityState.Modified)
+ .ToList();
+ Assert.Empty(tracked);
+
+ var reloaded = await _context.TemplateAttributes.AsNoTracking().SingleAsync();
+ Assert.Equal("[10,20]", reloaded.Value);
+ }
+
+ [Fact]
+ public async Task TemplateAttribute_StringList_IsUnchanged()
+ {
+ var templateId = await SeedTemplateAsync();
+ _context.TemplateAttributes.Add(new TemplateAttribute("stringList")
+ {
+ TemplateId = templateId,
+ DataType = DataType.List,
+ ElementDataType = DataType.String,
+ Value = "[\"a\",\"b\"]",
+ });
+ await _context.SaveChangesAsync();
+
+ await ListValueNormalizer.NormalizeAsync(_context);
+
+ var reloaded = await _context.TemplateAttributes.AsNoTracking().SingleAsync();
+ Assert.Equal("[\"a\",\"b\"]", reloaded.Value);
+ }
+
+ [Fact]
+ public async Task TemplateAttribute_Malformed_IsSkipped_AndSiblingStillNormalized()
+ {
+ var templateId = await SeedTemplateAsync();
+ _context.TemplateAttributes.Add(new TemplateAttribute("badList")
+ {
+ TemplateId = templateId,
+ DataType = DataType.List,
+ ElementDataType = DataType.Int32,
+ Value = "[\"a\"", // malformed JSON
+ });
+ _context.TemplateAttributes.Add(new TemplateAttribute("goodList")
+ {
+ TemplateId = templateId,
+ DataType = DataType.List,
+ ElementDataType = DataType.Int32,
+ Value = "[\"5\"]", // old form, valid
+ });
+ await _context.SaveChangesAsync();
+
+ // Must NOT throw despite the malformed row.
+ await ListValueNormalizer.NormalizeAsync(_context);
+
+ var bad = await _context.TemplateAttributes.AsNoTracking()
+ .SingleAsync(a => a.Name == "badList");
+ var good = await _context.TemplateAttributes.AsNoTracking()
+ .SingleAsync(a => a.Name == "goodList");
+
+ Assert.Equal("[\"a\"", bad.Value); // skipped, untouched
+ Assert.Equal("[5]", good.Value); // normalized
+ }
+
+ [Fact]
+ public async Task TemplateAttribute_NonListRow_IsUnchanged()
+ {
+ var templateId = await SeedTemplateAsync();
+ _context.TemplateAttributes.Add(new TemplateAttribute("scalar")
+ {
+ TemplateId = templateId,
+ DataType = DataType.Int32,
+ ElementDataType = null,
+ Value = "42",
+ });
+ await _context.SaveChangesAsync();
+
+ await ListValueNormalizer.NormalizeAsync(_context);
+
+ var reloaded = await _context.TemplateAttributes.AsNoTracking().SingleAsync();
+ Assert.Equal("42", reloaded.Value);
+ }
+
+ [Fact]
+ public async Task InstanceAttributeOverride_OldStringForm_RewrittenToNative()
+ {
+ var instanceId = await SeedInstanceAsync();
+ _context.InstanceAttributeOverrides.Add(new InstanceAttributeOverride("intList")
+ {
+ InstanceId = instanceId,
+ ElementDataType = DataType.Int32,
+ OverrideValue = "[\"5\"]",
+ });
+ await _context.SaveChangesAsync();
+
+ await ListValueNormalizer.NormalizeAsync(_context);
+
+ var reloaded = await _context.InstanceAttributeOverrides.AsNoTracking().SingleAsync();
+ Assert.Equal("[5]", reloaded.OverrideValue);
+ }
+
+ [Fact]
+ public async Task InstanceAttributeOverride_NullElementType_IsUntouched()
+ {
+ var instanceId = await SeedInstanceAsync();
+ _context.InstanceAttributeOverrides.Add(new InstanceAttributeOverride("scalar")
+ {
+ InstanceId = instanceId,
+ ElementDataType = null,
+ OverrideValue = "42",
+ });
+ await _context.SaveChangesAsync();
+
+ await ListValueNormalizer.NormalizeAsync(_context);
+
+ var reloaded = await _context.InstanceAttributeOverrides.AsNoTracking().SingleAsync();
+ Assert.Equal("42", reloaded.OverrideValue);
+ }
+
+ [Fact]
+ public async Task NormalizeAsync_IsIdempotent_SecondRunChangesNothing()
+ {
+ var templateId = await SeedTemplateAsync();
+ _context.TemplateAttributes.Add(new TemplateAttribute("intList")
+ {
+ TemplateId = templateId,
+ DataType = DataType.List,
+ ElementDataType = DataType.Int32,
+ Value = "[\"10\",\"20\"]",
+ });
+ await _context.SaveChangesAsync();
+
+ await ListValueNormalizer.NormalizeAsync(_context);
+ var afterFirst = await _context.TemplateAttributes.AsNoTracking().SingleAsync();
+ Assert.Equal("[10,20]", afterFirst.Value);
+
+ await ListValueNormalizer.NormalizeAsync(_context);
+ var afterSecond = await _context.TemplateAttributes.AsNoTracking().SingleAsync();
+ Assert.Equal("[10,20]", afterSecond.Value);
+
+ var tracked = _context.ChangeTracker.Entries()
+ .Where(e => e.State == EntityState.Modified)
+ .ToList();
+ Assert.Empty(tracked);
+ }
+}