15 tasks (MV-1..MV-15) with classifications, dependencies, and TDD steps: type model, AttributeValueCodec, idempotent migration, flatten, validation, runtime encode/decode, DCL array coercion, stream encode, management, CLI, transport, two UI editors, and integration verification.
32 KiB
Structured Multi-Value (List) Attribute — Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task.
Goal: Add a first-class DataType.List attribute type (a homogeneous list of any scalar element type) that round-trips through authoring, flatten, site runtime, OPC UA read/write, streaming, UI, and CLI.
Architecture: One new DataType.List enum member + a nullable ElementDataType companion on the attribute entities and ResolvedAttribute. A single round-trippable AttributeValueCodec (JSON array, invariant culture) encodes/decodes list values everywhere they are stored or transmitted; scalars keep their current string behavior unchanged. The script-accessor .ToString() boundary and the InstanceActor in-memory store are the core runtime changes.
Tech Stack: C#/.NET 10, Akka.NET 1.5, EF Core 10 (MS SQL + SQLite), gRPC, Blazor Server, System.CommandLine CLI.
Design doc: docs/plans/2026-06-16-multivalue-attribute-design.md (approved).
Branch: feature/multivalue-attribute (off main; design committed b238228).
Conventions for every task:
- TDD: write the failing test, see it fail, implement, see it pass, commit.
- Targeted builds/tests only — build the affected project(s) and run the filtered test(s); a full-solution build runs once in the final task.
- Build a project:
dotnet build src/<Project>/<Project>.csproj - Run filtered tests:
dotnet test tests/<TestProject>/<TestProject>.csproj --filter <Name> - Allowed element types (the 6 scalars):
String, Int32, Float, Double, Boolean, DateTime. NotBinary, not nestedList.
Task MV-1: Type model — enum member + ElementDataType companion
Classification: standard Estimated implement time: ~4 min Parallelizable with: none (foundation; everything else depends on it)
Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/DataType.cs - Modify:
src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Templates/TemplateAttribute.cs - Modify:
src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Instances/InstanceAttributeOverride.cs - Modify:
src/ZB.MOM.WW.ScadaBridge.Commons/Types/Flattening/FlattenedConfiguration.cs(theResolvedAttributerecord, ~line 57)
Step 1 — Add the enum member. Append List as the last member of DataType (append-only; do not reorder — the enum is persisted by name via HasConversion<string>, but appended-last is the safe convention):
public enum DataType
{
Boolean,
Int32,
Float,
Double,
String,
DateTime,
Binary,
List
}
Step 2 — Add ElementDataType to the two entities and the resolved record.
TemplateAttribute.cs (after the DataType DataType property, ~line 26):
/// <summary>
/// For <see cref="Enums.DataType.List"/> attributes: the scalar type of each
/// element (String, Int32, Float, Double, Boolean, DateTime). Null for scalar
/// attributes. The element type is fixed by the base attribute and cannot be
/// changed on a derived template or instance override.
/// </summary>
public DataType? ElementDataType { get; set; }
InstanceAttributeOverride.cs (after OverrideValue, ~line 12): add the same public DataType? ElementDataType { get; set; } property (add using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;).
FlattenedConfiguration.cs — ResolvedAttribute record (after DataType at ~line 68). NOTE: ResolvedAttribute.DataType is a string; keep ElementDataType a nullable string here for symmetry with that record's existing style:
/// <summary>For List attributes: the element scalar type name; null otherwise.</summary>
public string? ElementDataType { get; init; }
Step 3 — Build the project.
Run: dotnet build src/ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj
Expected: 0 errors (purely additive).
Step 4 — Commit.
git add src/ZB.MOM.WW.ScadaBridge.Commons
git commit -m "feat(commons): add DataType.List + ElementDataType companion for multi-value attributes"
Acceptance: Commons compiles; DataType.List exists; both entities and ResolvedAttribute carry the nullable element-type field.
Task MV-2: AttributeValueCodec (round-trippable JSON encode/decode) + tests
Classification: standard Estimated implement time: ~5 min Parallelizable with: MV-3, MV-4 Blocked by: MV-1
Files:
- Create:
src/ZB.MOM.WW.ScadaBridge.Commons/Types/AttributeValueCodec.cs - Create:
tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/AttributeValueCodecTests.cs
This codec is the single canonical encoder used for: persisted attribute Value, the gRPC wire value, and decode-on-load. ValueFormatter stays display-only and is untouched.
Step 1 — Write failing tests. Cover: scalar passthrough (string returned as-is; int/double/bool/DateTime → invariant string), list encode (List<string> → ["a","b"]), embedded comma/quote escaping, empty list → "[]", null → null, DateTime list round-trips ISO-8601, culture-invariance (set CultureInfo.CurrentCulture to de-DE and assert Encode(1.5) is "1.5"), decode round-trip for each element type, and decode of malformed JSON throws FormatException (caught by callers).
using System.Globalization;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using Xunit;
public class AttributeValueCodecTests
{
[Fact]
public void Encode_StringList_ProducesJsonArray() =>
Assert.Equal("[\"WO-1\",\"WO-2\"]",
AttributeValueCodec.Encode(new List<string> { "WO-1", "WO-2" }));
[Fact]
public void Encode_Scalar_String_ReturnedAsIs() =>
Assert.Equal("hello", AttributeValueCodec.Encode("hello"));
[Fact]
public void Encode_Scalar_Double_IsInvariant()
{
CultureInfo.CurrentCulture = new CultureInfo("de-DE");
Assert.Equal("1.5", AttributeValueCodec.Encode(1.5));
}
[Fact]
public void Encode_EmptyList_IsBracketPair() =>
Assert.Equal("[]", AttributeValueCodec.Encode(new List<string>()));
[Fact]
public void Encode_StringWithComma_IsEscaped() =>
Assert.Equal("[\"ACME, Inc.\"]",
AttributeValueCodec.Encode(new List<string> { "ACME, Inc." }));
[Fact]
public void RoundTrip_Int32List()
{
var json = AttributeValueCodec.Encode(new List<int> { 1, 2, 3 });
var back = (IList<int>)AttributeValueCodec.Decode(json, DataType.List, DataType.Int32)!;
Assert.Equal(new[] { 1, 2, 3 }, back);
}
[Fact]
public void Decode_Scalar_ReturnsString() =>
Assert.Equal("42", AttributeValueCodec.Decode("42", DataType.Int32, null));
[Fact]
public void Decode_MalformedJson_Throws() =>
Assert.Throws<FormatException>(() =>
AttributeValueCodec.Decode("not json", DataType.List, DataType.String));
}
Step 2 — Run, expect FAIL (AttributeValueCodec not defined).
Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/ZB.MOM.WW.ScadaBridge.Commons.Tests.csproj --filter AttributeValueCodecTests
Step 3 — Implement. Use System.Text.Json with invariant formatting. For decode, reuse the element-conversion idea from ScriptParameters.ConvertScalar (src/ZB.MOM.WW.ScadaBridge.Commons/Types/ScriptParameters.cs:161) — keep the codec self-contained (small private scalar-parser switch on DataType).
using System.Collections;
using System.Globalization;
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Types;
/// <summary>
/// Canonical, round-trippable codec for attribute values. Scalars encode to an
/// invariant-culture string (identical to the historical representation); List
/// attributes encode to a JSON array. Used wherever a value is stored or
/// transmitted (DB Value column, site SQLite, gRPC wire). <see cref="ValueFormatter"/>
/// remains a separate, display-only (comma-joined) formatter.
/// </summary>
public static class AttributeValueCodec
{
private static readonly JsonSerializerOptions JsonOpts = new() { WriteIndented = false };
/// <summary>Encodes a value to its canonical string form.</summary>
public static string? Encode(object? value)
{
switch (value)
{
case null: return null;
case string s: return s; // already canonical
case IFormattable f: return f.ToString(null, CultureInfo.InvariantCulture);
case IEnumerable e:
var items = e.Cast<object?>()
.Select(x => x is IFormattable xf
? xf.ToString(null, CultureInfo.InvariantCulture)
: x?.ToString());
return JsonSerializer.Serialize(items, JsonOpts);
default: return value.ToString();
}
}
/// <summary>
/// Decodes a canonical string. For <see cref="DataType.List"/> returns a typed
/// <c>List<T></c>; for scalars returns the string unchanged. Throws
/// <see cref="FormatException"/> on malformed list JSON or an un-parseable element.
/// </summary>
public static object? Decode(string? value, DataType dataType, DataType? elementType)
{
if (dataType != DataType.List) return value; // scalar: unchanged
if (string.IsNullOrEmpty(value)) return null;
if (elementType is null)
throw new FormatException("List attribute requires an element type.");
string?[] raw;
try { raw = JsonSerializer.Deserialize<string?[]>(value) ?? []; }
catch (JsonException ex) { throw new FormatException("Malformed list JSON.", ex); }
var clrType = ElementClrType(elementType.Value);
var listType = typeof(List<>).MakeGenericType(clrType);
var result = (IList)Activator.CreateInstance(listType)!;
foreach (var item in raw)
result.Add(ParseScalar(item, elementType.Value));
return result;
}
private static Type ElementClrType(DataType t) => t switch
{
DataType.String => typeof(string),
DataType.Int32 => typeof(int),
DataType.Float => typeof(float),
DataType.Double => typeof(double),
DataType.Boolean => typeof(bool),
DataType.DateTime => typeof(DateTime),
_ => throw new FormatException($"Unsupported list element type '{t}'.")
};
private static object? ParseScalar(string? s, DataType t)
{
if (s is null) throw new FormatException("List elements may not be null.");
var c = CultureInfo.InvariantCulture;
try
{
return t switch
{
DataType.String => s,
DataType.Int32 => int.Parse(s, c),
DataType.Float => float.Parse(s, c),
DataType.Double => double.Parse(s, c),
DataType.Boolean => bool.Parse(s),
DataType.DateTime => DateTime.Parse(s, c, DateTimeStyles.RoundtripKind),
_ => throw new FormatException($"Unsupported list element type '{t}'.")
};
}
catch (Exception ex) when (ex is FormatException or OverflowException)
{
throw new FormatException($"List element '{s}' is not a valid {t}.", ex);
}
}
/// <summary>True if the type may be a List element scalar.</summary>
public static bool IsValidElementType(DataType t) =>
t is DataType.String or DataType.Int32 or DataType.Float
or DataType.Double or DataType.Boolean or DataType.DateTime;
}
Step 4 — Run tests, expect PASS.
Step 5 — Commit.
git add src/ZB.MOM.WW.ScadaBridge.Commons/Types/AttributeValueCodec.cs tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/AttributeValueCodecTests.cs
git commit -m "feat(commons): AttributeValueCodec for canonical list value encode/decode"
Acceptance: all codec tests pass; scalars unchanged; lists round-trip; malformed JSON throws FormatException.
Task MV-3: EF mapping + idempotent migration
Classification: high-risk Estimated implement time: ~5 min Parallelizable with: MV-2, MV-4 Blocked by: MV-1
Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/TemplateConfiguration.cs:111-122 - Modify:
src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/InstanceConfiguration.cs:102-103 - Create: a new migration under
src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/
Step 1 — EF config. In TemplateConfiguration.Configure: widen Value and map the new column:
builder.Property(a => a.Value); // remove .HasMaxLength(4000) → defaults to nvarchar(max)
builder.Property(a => a.ElementDataType)
.HasConversion<string>()
.HasMaxLength(50);
In InstanceConfiguration (the InstanceAttributeOverride config): drop .HasMaxLength(4000) from OverrideValue, and add the same ElementDataType mapping.
Step 2 — Generate the migration.
dotnet ef migrations add AddListAttributeElementType \
--project src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase \
--startup-project src/ZB.MOM.WW.ScadaBridge.Host
Step 3 — Make Up/Down idempotent (per open follow-up #70 — re-running against a partially-migrated prod DB must not throw). Wrap the generated AddColumn/AlterColumn calls with existence guards via migrationBuilder.Sql(...):
migrationBuilder.Sql(@"
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE Name='ElementDataType' AND Object_ID=Object_ID('TemplateAttributes'))
ALTER TABLE [TemplateAttributes] ADD [ElementDataType] nvarchar(50) NULL;");
migrationBuilder.Sql(@"
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE Name='ElementDataType' AND Object_ID=Object_ID('InstanceAttributeOverrides'))
ALTER TABLE [InstanceAttributeOverrides] ADD [ElementDataType] nvarchar(50) NULL;");
migrationBuilder.Sql("ALTER TABLE [TemplateAttributes] ALTER COLUMN [Value] nvarchar(max) NULL;");
migrationBuilder.Sql("ALTER TABLE [InstanceAttributeOverrides] ALTER COLUMN [OverrideValue] nvarchar(max) NULL;");
Replace the auto-generated AddColumn/AlterColumn statements with the guarded SQL above (keep the .Designer.cs snapshot the tool generated — only the Up/Down body becomes guarded SQL). Provide a Down that drops the columns if present and restores nvarchar(4000).
Step 4 — Verify no model drift.
Run: dotnet build src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.csproj
Then confirm there are no pending model changes:
dotnet ef migrations has-pending-model-changes \
--project src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase \
--startup-project src/ZB.MOM.WW.ScadaBridge.Host
Expected: "No changes have been made to the model since the last migration" (or exit 0).
Step 5 — Commit.
git add src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase
git commit -m "feat(db): migration for ElementDataType + widen attribute Value to nvarchar(max) (idempotent)"
Acceptance: project builds; no pending model changes; migration Up/Down are guarded/idempotent.
Task MV-4: Flatten carries ElementDataType into ResolvedAttribute
Classification: small Estimated implement time: ~3 min Parallelizable with: MV-2, MV-3 Blocked by: MV-1
Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/FlatteningService.cs:177(and any other site that constructs aResolvedAttribute) - Test:
tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/FlatteningServiceTests.cs
Step 1 — Failing test: flatten a template whose attribute is DataType.List / ElementDataType=String and assert the ResolvedAttribute has DataType=="List" and ElementDataType=="String"; assert an instance override of that attribute keeps ElementDataType (override replaces value, not element type).
Step 2 — Run, expect FAIL.
Step 3 — Implement: wherever ResolvedAttribute is built (e.g. DataType = attr.DataType.ToString()), also set ElementDataType = attr.ElementDataType?.ToString(). Grep for new ResolvedAttribute and DataType = in FlatteningService.cs to find every construction site and the override-merge path.
Step 4 — Run, expect PASS.
Step 5 — Commit: feat(template): carry ElementDataType through flatten/override.
Task MV-5: Semantic validation for List attributes
Classification: standard Estimated implement time: ~5 min Parallelizable with: MV-6, MV-8, MV-9 (different files) Blocked by: MV-1, MV-2
Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/SemanticValidator.cs:18-21,130-193 - Modify:
src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ValidationService.cs - Test:
tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/SemanticValidatorTests.cs
Rules to enforce (write a failing test for each first):
DataType.Listrequires a non-nullElementDataTypethat is a valid element scalar (AttributeValueCodec.IsValidElementType); a scalar attribute must haveElementDataType == null. Violation → validation Error.- An authored default
Valueon a List attribute, if present, mustAttributeValueCodec.Decodewithout throwing (catchFormatException→ Error with the element type and message). - A List attribute used as an operand in a numeric trigger (HiLo / RangeViolation) or a binary trigger → Error. Extend the existing
NumericDataTypesoperand check (~line 130-193): aListoperand is never numeric and never a valid binary operand.
Steps: failing tests → run (FAIL) → implement the three checks → run (PASS) → commit feat(validation): semantic checks for List attributes (element type, default value, trigger operands).
Task MV-6: Script-accessor encode boundary
Classification: small Estimated implement time: ~3 min Parallelizable with: MV-8, MV-9 Blocked by: MV-2
Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScopeAccessors.cs:56,73 - Test:
tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/...(accessor or runtime-context test; create if absent)
Problem: AttributeAccessor set/SetAsync currently do value?.ToString(), which turns a List<string> into "System.Collections.Generic.List1[System.String]"`.
Step 1 — Failing test: a fake/seam ScriptRuntimeContext.SetAttribute(name, encoded) capturing the encoded string; assert setting a List<string>{"a","b"} sends ["a","b"] and setting a scalar "x" still sends "x".
Step 2 — Run, expect FAIL.
Step 3 — Implement: replace both .ToString() sites with the codec:
set => _ctx.SetAttribute(Resolve(key), AttributeValueCodec.Encode(value) ?? string.Empty)
.GetAwaiter().GetResult();
// and in SetAsync:
=> _ctx.SetAttribute(Resolve(key), AttributeValueCodec.Encode(value) ?? string.Empty);
Add using ZB.MOM.WW.ScadaBridge.Commons.Types;.
Step 4 — Run, expect PASS.
Step 5 — Commit: fix(siteruntime): encode list attribute writes via AttributeValueCodec (was .ToString()).
Task MV-7: InstanceActor decode (load + static set + override merge)
Classification: high-risk Estimated implement time: ~5 min Parallelizable with: none Blocked by: MV-1, MV-2, MV-4, MV-6
Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs:114-122(load),:307-320(HandleSetStaticAttributeCore), and the static-override merge on load (grepGetStaticOverridesAsyncusage) - Test:
tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTests.cs
Goal: _attributes holds a typed List<T> for List attributes so HandleGetAttribute returns a real list to scripts, and the canonical JSON string is what gets persisted + streamed.
Step 1 — Failing tests (use the existing InstanceActor test harness / TestKit):
- Load a flattened config with a List attribute default
["a","b"]→GetAttributeRequestreturns anIEnumerableof{"a","b"}(not the raw string). SetStaticAttributeCommandwithValue = "[\"x\",\"y\"]"on a List attribute → subsequent get returns{"x","y"}; assertSetStaticOverrideAsyncwas called with the JSON string["x","y"](persisted form is canonical JSON).- A persisted SQLite override
["p","q"]applied on load decodes to a list. - Malformed stored value → attribute loads with Bad quality, actor does not throw (wrap
Decodein try/catch → log + set quality Bad).
Step 2 — Run, expect FAIL.
Step 3 — Implement.
- Add a small private helper on the actor:
private object? DecodeAttributeValue(ResolvedAttribute attr, string? raw) { var dataType = Enum.Parse<DataType>(attr.DataType, ignoreCase: true); var elementType = string.IsNullOrEmpty(attr.ElementDataType) ? (DataType?)null : Enum.Parse<DataType>(attr.ElementDataType, ignoreCase: true); try { return AttributeValueCodec.Decode(raw, dataType, elementType); } catch (FormatException ex) { _logger.LogWarning(ex, "Attribute '{Attr}' on '{Instance}' has an undecodable value; marking Bad quality", attr.CanonicalName, _instanceUniqueName); return null; // caller sets quality Bad } } - Load loop (
:116-121):_attributes[attr.CanonicalName] = DecodeAttributeValue(attr, attr.Value);and set qualityBadwhen a List value failed to decode (non-null raw but null result). - Apply the same decode when merging persisted static overrides (the
GetStaticOverridesAsyncmerge). HandleSetStaticAttributeCore(:309): look up theResolvedAttributeforcommand.AttributeName; storeDecodeAttributeValue(resolved, command.Value)in_attributes(so reads are typed) while continuing to persist and publishcommand.Value(the canonical JSON string) unchanged. The publishedAttributeValueChangedkeeps carrying the canonical string —StreamRelayActor(MV-9) handles encoding uniformly.
Step 4 — Run, expect PASS.
Step 5 — Commit: feat(siteruntime): decode List attributes to typed lists in InstanceActor (load/set/override).
Acceptance: scripts read List attributes as List<T>; persisted + streamed form is canonical JSON; undecodable values degrade to Bad quality without crashing the actor.
Task MV-8: DCL OPC UA array read coercion
Classification: standard Estimated implement time: ~5 min Parallelizable with: MV-5, MV-6, MV-9 Blocked by: MV-1, MV-2
Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs(read/subscription value handling) — and the InstanceActor handler that ingests a DCL value for a data-sourced attribute (grep for where incoming tag values update_attributes/ publishAttributeValueChanged). - Test:
tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/...
Goal: when a data-sourced attribute is declared DataType.List, an incoming OPC UA array value becomes a typed list with each element coerced to ElementDataType; element-type mismatch → Bad quality + log (non-fatal). The write path needs no change (RealOpcUaClient.WriteValueAsync already wraps in Variant, which serializes a List<T>/T[] as an OPC UA array — add a test asserting an array value writes without exception).
Steps: failing tests (array read → typed list of correct element type; mismatched element → Bad quality; array write via Variant succeeds) → run (FAIL) → implement coercion using AttributeValueCodec/Convert per element → run (PASS) → commit feat(dcl): coerce OPC UA array reads to typed list attributes; Bad quality on element mismatch.
Task MV-9: StreamRelayActor canonical-JSON encode
Classification: small Estimated implement time: ~2 min Parallelizable with: MV-5, MV-6, MV-8 Blocked by: MV-2
Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.Communication/Actors/StreamRelayActor.cs:48 - Test:
tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/...
Step 1 — Failing test: relay an AttributeValueChanged whose Value is a List<string>{"a","b"} → assert the produced AttributeValueUpdate.Value == "[\"a\",\"b\"]"; relay a scalar string "x" → assert Value == "x" (unchanged).
Step 2 — Run, expect FAIL.
Step 3 — Implement: replace Value = ValueFormatter.FormatDisplayValue(msg.Value), with Value = AttributeValueCodec.Encode(msg.Value) ?? string.Empty, (add the using). This is additive — List is a new type, no existing wire consumer relies on comma-joined lists; the proto string value field is unchanged.
Step 4 — Run, expect PASS.
Step 5 — Commit: feat(comm): stream List attribute values as canonical JSON.
Task MV-10: ManagementActor add/update attribute handlers
Classification: standard Estimated implement time: ~5 min Parallelizable with: MV-11 not (blocks it); MV-12/13 share contract Blocked by: MV-1, MV-2, MV-5
Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs:~1441-1461(add/update attribute),:526(theDataType.ToString()serialization for read-back) - Modify: the management command/message contract that carries an attribute (add
ElementDataType— additive field; grep for theAddAttribute/UpdateAttributecommand records inCommons/Messages/...) - Test:
tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/...
Goal: the add/update-attribute path accepts an optional element-type, validates (DataType, ElementDataType, Value) via the MV-5 rules + AttributeValueCodec, and persists both columns. Read-back includes ElementDataType.
Steps: failing tests (add a List attribute with element type String + JSON default → persisted with both columns; add List without element type → rejected; add List with bad default → rejected) → run (FAIL) → implement parse/validate/persist → run (PASS) → commit feat(mgmt): accept + validate ElementDataType on attribute add/update.
Task MV-11: CLI element-type + JSON value
Classification: standard Estimated implement time: ~4 min Parallelizable with: MV-12, MV-13 Blocked by: MV-10
Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.CLI/Commands/TemplateCommands.cs:140-199(attribute add/update) and the instance-override command if present - Modify:
src/ZB.MOM.WW.ScadaBridge.CLI/README.md(document--element-type+ JSON--value) - Test:
tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/...(option parsing)
Goal: add --element-type <Scalar>; --value accepts a JSON array for List attributes. Validate element type client-side before sending; surface server validation errors.
Steps: failing test (parse --data-type List --element-type String --value '["a","b"]' → request carries List + String + JSON) → run (FAIL) → implement option + plumb to the management call → run (PASS) → commit feat(cli): --element-type and JSON --value for List attributes.
Task MV-12: Transport DTO + importer field
Classification: small Estimated implement time: ~3 min Parallelizable with: MV-4, MV-9, MV-11, MV-13 Blocked by: MV-1
Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/...orsrc/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntityDtos.cs:77-83(TemplateAttributeDto) - Modify:
src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs:~2300-2306 - Test:
tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/...(round-trip export→import of a List attribute)
Goal: the export/import DTO carries ElementDataType; a List attribute survives an export→import round-trip. Old bundles without the field import as scalars (null element type) — assert backward-compat.
Steps: failing round-trip test → run (FAIL) → add ElementDataType to the DTO + importer mapping → run (PASS) → commit feat(transport): round-trip ElementDataType for List attributes.
Task MV-13: Central UI — TemplateEdit list editor
Classification: standard Estimated implement time: ~5 min Parallelizable with: MV-11, MV-12, MV-14 Blocked by: MV-10
Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TemplateEdit.razor:82,484-491,556-566 - (Optional) Create: a small
AttributeListEditor.razorshared component underCentralUI/Components/Shared/ - Test/verify: Playwright fixture if the harness covers TemplateEdit; otherwise manual verification noted in the task.
Use the frontend-design skill for the editor UI (clean corporate Bootstrap, no third-party component frameworks — per CLAUDE.md).
Goal: when the attribute DataType dropdown = List, reveal an ElementDataType dropdown (the 6 scalars) and a repeatable add/remove row editor bound to the JSON value. Inline per-element validation by element type. The dropdown already enumerates Enum.GetValues<DataType>(), so List appears automatically — gate the element-type + list editor on _attrDataType == DataType.List.
Steps: implement the conditional editor; bind to AttributeValueCodec.Encode of the rows for submit; decode existing JSON into rows on edit; build dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI/...; verify (Playwright or manual) → commit feat(ui): List attribute editor in TemplateEdit.
Task MV-14: Central UI — InstanceConfigure override list editor
Classification: standard Estimated implement time: ~5 min Parallelizable with: MV-13 Blocked by: MV-10
Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor - Reuse: the
AttributeListEditor.razorcomponent from MV-13 if created.
Goal: overriding a List attribute on an instance uses the same list editor (whole-list replacement; element type shown read-only — fixed by base). Clearing the override removes it.
Steps: implement using the shared editor; element-type dropdown is read-only here; build CentralUI; verify → commit feat(ui): List attribute override editor in InstanceConfigure.
Task MV-15: Integration verification + docs/README sync
Classification: standard Estimated implement time: ~5 min Parallelizable with: none Blocked by: all (MV-1 … MV-14)
Files:
- Modify:
README.mdand/ordocs/requirements/Component-*.mdif a DataType/attribute capability note needs updating (Template Engine / Commons). - Modify:
docs/plans/2026-06-16-multivalue-attribute-design.md— mark Status complete.
Steps:
- Full-solution build:
dotnet build ZB.MOM.WW.ScadaBridge.slnx— expect 0 errors. - Run the feature's targeted test classes across the touched test projects (codec, flatten, validation, InstanceActor, DCL, comm, transport, CLI).
- End-to-end smoke (optional, if exercising the cluster):
bash docker/deploy.sh, then via CLI create a template with a List attribute (--data-type List --element-type String --value '["WO-1","WO-2"]'), deploy an instance, and confirm the value flows to the DebugView as["WO-1","WO-2"]. - Update docs; sync the design-decision note if the high-level requirements track the DataType set.
- Commit:
docs: mark multi-value attribute feature complete; sync README/component notes.
Acceptance: full build green; all targeted tests pass; (if run) the value round-trips end-to-end through the cluster.
Parallelization summary
- Wave 1: MV-1 (foundation, solo).
- Wave 2 (after MV-1): MV-2, MV-3, MV-4, MV-12 in parallel (disjoint files).
- Wave 3 (after MV-2): MV-5, MV-6, MV-8, MV-9 in parallel; MV-7 after MV-6.
- Wave 4 (after MV-5): MV-10; then MV-11, MV-13, MV-14 in parallel after MV-10.
- Wave 5: MV-15 (final, solo).
Risk notes
- MV-3 (migration) and MV-7 (actor model) are the high-risk tasks — full review chain.
- The migration must be idempotent (open follow-up #70 is the cautionary precedent).
- The gRPC change (MV-9) is additive —
Listis a brand-new type, so no existing wire consumer breaks; the proto field is unchanged.