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

217 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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** `DataType`
`String, 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.cs``ResolvedAttribute`
- **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,73`** — `AttributeAccessor` set / `SetAsync`:
replace `value?.ToString()` with `AttributeValueCodec.Encode(value)`. (Today a
`List<string>` would `.ToString()` to `"System.Collections.Generic.List`1[...]"` — 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.