Files
ScadaBridge/docs/plans/2026-06-16-multivalue-attribute-design.md
T
Joseph Doherty 734c161383 docs: mark multi-value (List) attribute feature complete; document DataType.List + ElementDataType in Component-Commons
MV-15 integration checkpoint: full solution builds 0/0; feature-targeted tests
green across Commons, TemplateEngine, SiteRuntime, DataConnectionLayer,
Communication, Transport, ManagementService, CLI, CentralUI (255 tests).
2026-06-16 16:34:56 -04:00

11 KiB
Raw Blame History

Structured Multi-Value Attribute — Design

Date: 2026-06-16 Status: Implemented (MV-1 … MV-15, branch feature/multivalue-attribute) — full solution builds clean; feature-targeted tests green across Commons, TemplateEngine, SiteRuntime, DataConnectionLayer, Communication, Transport, ManagementService, CLI, and CentralUI. Branch: feature/multivalue-attribute

Problem

ScadaBridge's DataType enum (Boolean·Int32·Float·Double·String·DateTime·Binary) has no collection type. An attribute is persisted as a single string? Value plus a DataType discriminator. So a Galaxy/object attribute that is conceptually a list of values — e.g. MoveInWorkOrderNumbers, MoveInPartNumbers (string arrays arriving via the IpsenMESMoveIn inbound-API method) — can only be stored by collapsing it to a single DataType.String blob. We want these stored as a first-class structured multi-value attribute that round-trips through every path.

Scope (decided)

  • Element types: a homogeneous list of any existing scalar DataTypeString, Int32, Float, Double, Boolean, DateTime. Excluded: Binary elements and nested lists (no lists-of-lists, no lists-of-objects).
  • Lifecycle paths — all four in scope:
    1. Script writes the attribute at runtime and reads it back (the IpsenMESMoveIn case); persists as a static attribute (site SQLite, survives failover).
    2. Statically authored default in a template, optionally overridden per instance, via Central UI and CLI.
    3. Read in from an OPC UA array node (DCL subscription → live attribute value).
    4. Written out to an OPC UA array node via the DCL write path.

Decision summary (defaults locked in)

Decision Choice
Type modeling One DataType.List member + nullable ElementDataType companion
Value encoding JSON array, invariant culture (round-trippable)
Display encoding ValueFormatter.FormatDisplayValue stays comma-joined, display-only
Element types 6 scalars; no Binary, no nesting
Override granularity Whole-list replacement (no per-element override)
Element type mutability Fixed by base attribute; not overridable on derived/instance
Bad OPC UA element Coerce/validate; mismatch → Bad quality + log (non-fatal)
Empty vs unset null = unset/cleared; "[]" = explicit empty list
gRPC wire Existing string value field carries the canonical JSON (additive)

Architecture

The key insight from the blast-radius survey: most of the runtime is already collection-capable. ScriptParameters already converts to typed List<T>/T[]; the OPC UA write path wraps values in a Variant (which supports arrays natively); AttributeValueChanged already carries object?. The real work concentrates in (a) the type model, (b) a canonical encode/decode codec, (c) the InstanceActor / script-accessor boundary, (d) validation, and (e) the UI/CLI editors.

1. Type model

  • Add one enum member: DataType.List (Commons/Types/Enums/DataType.cs).
  • Add nullable ElementDataType (DataType?) to:
    • Commons/Entities/Templates/TemplateAttribute.cs
    • Commons/Entities/Instances/InstanceAttributeOverride.cs
    • Commons/Types/Flattening/FlattenedConfiguration.csResolvedAttribute
  • Invariant: ElementDataType required when DataType == List, null otherwise; element type must be one of the 6 allowed scalars.

2. Canonical value codec — new AttributeValueCodec

One round-trippable codec used everywhere a value is stored or transmitted:

  • Encode(object? value):
    • scalar → invariant-culture string (identical to today's behavior; existing scalars unchanged)
    • non-string IEnumerable → JSON array, invariant culture (["WO-1","WO-2"], [1,2,3], ["2026-06-16T00:00:00Z"])
  • Decode(string? value, DataType dataType, DataType? elementType):
    • List → typed List<T> (reuse the existing ScriptParameters element-conversion machinery where practical)
    • scalar → string (unchanged)
  • null value = unset/cleared; "[]" = explicit empty list.

ValueFormatter.FormatDisplayValue (comma-joined) remains display-only — diff text, log lines, KPI strings — and is not the persisted/wire form.

3. Persistence & migration

  • One EF Core migration, written idempotent (per the open follow-up #70 lesson — guard inserts/alters so re-running against a partially-migrated DB is safe):
    • add ElementDataType nvarchar(50) NULL to TemplateAttributes and InstanceAttributeOverrides
    • widen Value / OverrideValue from nvarchar(4000)nvarchar(max) (lists can exceed 4000)
  • Site SQLite static-attribute store: no schema change (already a string column) — it simply stores the canonical JSON.
  • EF config: TemplateConfiguration.cs, InstanceConfiguration.cs map the new column (HasConversion<string>().HasMaxLength(50) for ElementDataType).

4. Flatten / resolve

  • TemplateEngine/Flattening/FlatteningService.cs carries ElementDataType into ResolvedAttribute next to DataType.
  • Inheritance / compose / override shape unchanged.
  • Override replaces the whole list (no per-element merge).
  • A derived template or instance override cannot change ElementDataType — it is fixed by the base attribute, enforced like the existing LockedInDerived path.

5. Site runtime (the core change)

  • SiteRuntime/Scripts/ScopeAccessors.cs:56,73AttributeAccessor set / SetAsync: replace value?.ToString() with AttributeValueCodec.Encode(value). (Today a List<string> would .ToString() to "System.Collections.Generic.List1[...]"` — the central bug this fixes.) Scalars produce the same string as before.
  • SiteRuntime/Actors/InstanceActor.cs:116-121 — on load, for List attributes Decode the JSON Value into a typed List<T> and store that in _attributes (scalars unchanged).
  • HandleSetStaticAttribute — for List attributes: decode the canonical string → typed list for _attributes, validate element types, persist the canonical JSON to SQLite, stream the canonical JSON.
  • HandleGetAttribute — unchanged; returns the live _attributes object, now a real List<T> for list attributes. Scripts read a first-class list.

6. Data Connection Layer (OPC UA read + write)

  • Read: an OPC UA array node yields a CLR array → attribute value object. Coerce / validate element type against ElementDataType; on mismatch → Bad quality + log, never crash the actor.
  • Write: DataConnectionLayer/Adapters/RealOpcUaClient.cs:619-628 already wraps values in new Variant(value), which supports arrays natively; a List<T>/T[] writes as an OPC UA array. Minimal change beyond passing the typed list through.

7. Validation (pre-deploy semantic)

TemplateEngine/Validation/SemanticValidator.cs + ValidationService.cs:

  • ElementDataType present and a valid scalar when DataType == List; absent otherwise.
  • Authored default Value (if present) must parse as a JSON array whose elements match ElementDataType.
  • List attributes rejected as operands in numeric alarm triggers (HiLo / RangeViolation) and binary triggers — extends the existing NumericDataTypes guard.

8. Streaming / DebugView

  • Communication/Actors/StreamRelayActor.cs:48 — encode via AttributeValueCodec (JSON for lists) instead of FormatDisplayValue. Additive: lists are a new type, so no existing wire consumer breaks; the proto string value field (sitestream.proto:57) is unchanged.
  • Central DebugView renders the canonical string; for lists it shows the JSON array. Optional polish: chip rendering of list elements.

9. Central UI

  • CentralUI/Components/Pages/Design/TemplateEdit.razor — attribute form: when type = List, reveal an ElementDataType dropdown and a list editor (repeatable add/remove rows) bound to the JSON value; inline per-element validation.
  • CentralUI/Components/Pages/Deployment/InstanceConfigure.razor — override panel: the same list editor for overriding a list attribute.

10. CLI

  • CLI/Commands/TemplateCommands.cs (and the instance-override command): add --element-type <Scalar>; --value accepts a JSON array ('["a","b"]'). Optional ergonomic alternative: repeatable --value. Validate element type before submit.
  • ManagementService/ManagementActor.cs add/update attribute handlers: parse + validate ElementDataType, validate Value against it before persisting.

11. Transport (import/export)

  • Transport/Serialization/EntityDtos.cs TemplateAttributeDto already carries the DataType enum + string? Value; add ElementDataType. Enum/value round-trip is otherwise automatic. BundleImporter carries the new field through.

Error handling

  • Malformed JSON in a persisted Value: caught at deploy-time validation; if it ever reaches runtime decode, log + treat the attribute as Bad quality — never crash the Instance Actor (audit/best-effort principle).
  • OPC UA element-type mismatch on read: Bad quality + log (non-fatal).
  • Script writes a non-list to a list attribute (or vice versa): rejected at HandleSetStaticAttribute validation, surfaced to the script as an error.

Testing strategy

  • Codec round-trip: every element type; empty list; embedded commas/quotes/escaping; DateTime; culture-invariance.
  • Flatten: override replaces whole list; element-type lock enforced.
  • Validation: good/bad authored defaults; trigger-operand rejection; missing/extra ElementDataType.
  • InstanceActor: set/get typed list; SQLite persistence survives simulated failover.
  • DCL: OPC UA array read coercion; Bad quality on element mismatch; Variant array write.
  • Streaming: StreamRelayActor emits canonical JSON for a list attribute.
  • CLI: --element-type + JSON --value parse/validate.
  • UI: list-editor component behavior (if harness present).

Out of scope (deferred)

  • Nested objects / lists-of-records; heterogeneous lists.
  • Binary element lists.
  • Per-element overrides; element-level alarm triggers.

Blast-radius reference (file:area)

Area Key locations Change size
Enum + entities DataType.cs, TemplateAttribute.cs, InstanceAttributeOverride.cs, FlattenedConfiguration.cs small
Codec (new) Commons/Types/AttributeValueCodec.cs (new) smallmed
EF + migration TemplateConfiguration.cs, InstanceConfiguration.cs, new migration small
Flatten FlatteningService.cs:177 small
Runtime boundary ScopeAccessors.cs:56,73, InstanceActor.cs:116-121,246-315 med (core)
DCL RealOpcUaClient.cs read coercion (write already array-capable) smallmed
Validation SemanticValidator.cs:18-21,130-193, ValidationService.cs smallmed
Streaming StreamRelayActor.cs:48 small
UI TemplateEdit.razor, InstanceConfigure.razor med (largest)
CLI + mgmt TemplateCommands.cs, ManagementActor.cs:1441-1461 smallmed
Transport EntityDtos.cs:77-83, BundleImporter.cs:2302 small

Note: the Inbound API type system (ParameterDefinition with Object/List, InboundApiSchema) is genuinely separate from attribute DataType and is not modified; its ScriptParameters.ConvertToList/ConvertToArray conversion helpers are reusable by the codec.