# Phase 4c — cross-driver OPC UA array support (big-bang, all 5 drivers) — design

**Status:** approved 2026-06-16. Branch `feat/stillpending-phase-4c-array-support` off master `050164b2`.

**Goal:** an authored equipment tag marked `isArray` materializes as a 1-D OPC UA array
variable node (`ValueRank=OneDimension` + `ArrayDimensions`) and its backing driver reads +
publishes array values — across **Modbus, S7, AbCip, TwinCAT, AbLegacy**. Closes `stillpending.md`
§2 "Arrays — discovery hard-wires `IsArray:false` (arrays surface as scalars)" and "Modbus —
String / BitInRegister array decode unsupported".

**Scope decision (AskUserQuestion, 2026-06-16):** the user chose **big-bang — all 5 drivers**,
and **full AbLegacy array read now** (not the flag-wired/read-deferred fallback). This is the
largest, highest-risk backlog phase; the design front-loads the driver-agnostic plumbing so the
five per-driver reads fan out against a stable contract.

## Architecture

A small **driver-agnostic plumbing core** (sink contract → plan metadata → applier → authoring UI),
then **per-driver array read** fanned out across the five disjoint driver projects. Array-ness
rides in the existing **`TagConfig` JSON blob** as two new keys, `isArray` (bool) + `arrayLength`
(uint) — **NO EF migration, NO new schema column**. All new fields default to scalar so existing
artifact decode + every existing tag is **byte-parity unchanged** (zero regression).

The value path is already array-capable: a driver read publishes a bare `object?`
(`DriverInstanceActor.AttributeValuePublished`) → `DriverHostActor.ForwardToMux` →
`OpcUaPublishActor` → `IOpcUaAddressSpaceSink.WriteValue` → `OtOpcUaNodeManager.WriteValue:257`
(`variable.Value = value;`). Assigning a CLR array (`int[]`, `string[]`) works as-is. The array-ness
lives **entirely in node creation** (`EnsureVariable`'s hard-wired `ValueRank=ValueRanks.Scalar`).

## Hard constraints (every task)

Stage by explicit path, never `git add .`; never stage `sql_login.txt` /
`src/Server/ZB.MOM.WW.OtOpcUa.Host/pki/` / `pending.md` / `current.md` /
`docker-dev/docker-compose.yml` / `stillpending.md`; no force-push / no `--no-verify`;
**NO EF migration** (array-ness lives in the `TagConfig` blob); **NO wire/proto contract change**
(the `IOpcUaAddressSpaceSink` edit is an in-process abstraction over the SDK address space — not a
serialized wire/proto contract; same class of change as the Phase-C `historianTagname` param);
**NO bUnit** (Razor proven by live `/run`). Use `dangerouslyDisableSandbox: true` for all
rig/docker/network/build/test commands.

---

## §A — Sink contract (the one high-risk contract change)

**Files:** `src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs`
(interface `:74` + `NullOpcUaAddressSpaceSink` `:107`),
`src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs:65`,
`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs:63`,
`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs` (`EnsureVariable:1299`,
`CreateVariable` fallback `:2049`).

`EnsureVariable` gains two **trailing optional** params so every existing call site compiles
unchanged:

```csharp
void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName,
    string dataType, bool writable, string? historianTagname = null,
    bool isArray = false, uint? arrayLength = null);
```

In `OtOpcUaNodeManager.EnsureVariable` (`:1319-1334`) and the `CreateVariable` fallback (`:2049`):

```csharp
ValueRank       = isArray ? ValueRanks.OneDimension : ValueRanks.Scalar,
ArrayDimensions = isArray ? new UInt32Collection(new[] { arrayLength ?? 0u }) : null,
```

`ResolveBuiltInDataType` (`:1355`) is **unchanged** — array elements share the scalar's base
DataType; `ValueRank` + `ArrayDimensions` carry the dimensionality. `arrayLength == 0`/`null` ⇒
unfixed-length array (valid OPC UA; `ArrayDimensions[0] = 0` means "unknown").

**Value path needs no change** (`WriteValue:257`); a TDD test proves an array-ranked node accepts a
CLR array and a client reads it back as an array Variant.

**Classification: high-risk** (in-process contract, 4 implementers, SDK node semantics).

---

## §B — Plan metadata, byte-parity pair

**Files:** `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs` (the `EquipmentTagPlan`
record + `Compose` construction + a new `ExtractTagArray` helper),
`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs` (`BuildEquipmentTagPlans`
decode), `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs:214`
(`MaterialiseEquipmentTags` → `EnsureVariable`).

`EquipmentTagPlan` gains `bool IsArray = false, uint? ArrayLength = null` as **trailing optional
record params** (existing construction sites + artifact decode produce identical plans; the record's
value-equality makes a toggle diff as a *change* — same mechanism as `Historize`/`IsHistorized`).

`Phase7Composer` adds `ExtractTagArray(string? tagConfig)` (mirrors the existing
`ExtractTagFullName` / `ExtractTagHistorize`): parse the raw `TagConfig` JSON, read `isArray`
(bool) + `arrayLength` (uint, only when `isArray`), tolerate malformed JSON → `(false, null)`.
`DeploymentArtifact.BuildEquipmentTagPlans` decodes the **same** two keys identically (byte-parity
is a tested invariant). `Phase7Applier.MaterialiseEquipmentTags` threads
`tag.IsArray`/`tag.ArrayLength` into `SafeEnsureVariable` → `EnsureVariable`.

**Classification: high-risk** (composer↔artifact byte-parity; a divergence silently corrupts the
running address space).

---

## §C — Authoring UI, driver-agnostic (no bUnit)

**Files:** a new pure `TagArrayConfig` merge helper under
`src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/` (mirror the Phase-6 `TagHistorizeConfig`),
`src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagModal.razor` (an `isArray` checkbox +
`arrayLength` numeric, driver-agnostic — alongside the historize controls), and the validator seam
the historize control already uses.

The control is **driver-agnostic**: it authors the `isArray`/`arrayLength` keys into the raw
`TagConfig` (preserve-unknown-keys), so it works for both the typed per-driver editors and the
raw-JSON fallback. The materializer (§A/§B) reads the keys for rank/dimensions; each driver's
equipment-tag resolver (§D) reads them for read-count. Client-side validation: `arrayLength > 0`
when `isArray` is set.

**Classification: standard.** **Proven by live `/run`, not bUnit.**

---

## §D — Per-driver array read (parallelizable across disjoint projects)

Each driver's **equipment-tag resolver** threads `arrayLength` into its transient tag-def's
array-count field; the read path returns a CLR array `object`; discovery flips the existing
`IsArray:false` hard-wire. `DriverAttributeInfo` (`Core.Abstractions`) **already** carries
`IsArray` + `ArrayDim` — no DTO change.

| Driver | Sites | Work | Class | Live |
|---|---|---|---|---|
| **Modbus** | `ModbusDriver.cs:482-484` throw; discovery `:276-277` already arrays; resolver | numeric arrays already read end-to-end; **complete String + BitInRegister array decode** (remove throw) + resolver `arrayLength → ModbusTagDefinition.ArrayCount` | small | **Mac-live** (sim `10.100.0.35:5020`) |
| **AbCip** | `AbCipDriver.cs:948-949,1002-1003` | libplctag native array read → box to `object[]`; flip `IsArray` on declared + discovered + UDT-member sites | standard | unit (fixture down) |
| **TwinCAT** | `TwinCATDriver.cs:370-371`; `AdsTwinCATClient.cs` | ADS native array symbol read → box; flip `IsArray` pre-declared + discovered | standard | unit (fixture down) |
| **S7** | `S7Driver.cs:642`; read path | S7.Net byte-buffer **block-read** refactor + decode loop + flip flag | **high-risk** | unit (fixture down) |
| **AbLegacy** | `AbLegacyDriver.cs:431-438` | PCCC multi-element file addressing (`N7:0,LEN`, up to 256 words) via libplctag array read + flip flag | **high-risk** | unit (fixture down) |

The five are **disjoint projects** → implementers dispatch concurrently after §A/§B land the
contract. AbLegacy is the single biggest task (addressing-model rework); if it proves intractable
mid-execution, surface it rather than thrash (the user chose full read, so we attempt it; the honest
fallback would be flag-wired/read-deferred per the FOCAS-4b pattern).

---

## §E — Error handling / robustness

- Defaults = scalar ⇒ **byte-parity, no regression** for every existing tag.
- `isArray` on a driver tag that reads a scalar (mismatch) → the node stays array-typed and the
  driver surfaces Bad quality; documented (authoring `isArray` requires the tag to actually read an
  array).
- `arrayLength = 0`/`null` ⇒ unfixed-length array (valid OPC UA).
- Array nodes are always `EnsureVariable`'d before any subscribe (Phase7Applier materializes ahead
  of driver subscribe), so the scalar lazy-create `CreateVariable` fallback is not hit for them —
  but it is fixed too (defensive).

## §F — Testing

**Unit:** `EnsureVariable` builds a OneDimension node with the right `ArrayDimensions`; `WriteValue`
array round-trip (a client reads back an array Variant); `EquipmentTagPlan` composer↔artifact
**byte-parity** round-trip with `isArray`/`arrayLength` set; `ExtractTagArray` (valid / malformed /
absent); `TagArrayConfig` merge + validate; **each driver's array decode/read against its fake
backend** (Modbus String + BitInRegister arrays; S7 block decode; AbCip/TwinCAT box-to-array;
AbLegacy multi-element). **NO bUnit** (Razor proven by live `/run`).

**Live `/run`:** author a Modbus array equipment tag on the docker-dev rig (login disabled) →
confirm the node materializes as an array (browse `ValueRank`/`ArrayDimensions`) + an array value
flows from the Modbus sim. S7 / AbCip / TwinCAT / AbLegacy are **unit-proven** (fixtures down on this
Mac — honest, operator-gated live).

## §G — Out of scope (named deferrals)

- **Array writes** (inbound client → device): 1-D **read**-surface only this phase.
- **Multi-dimensional arrays** (`ValueRank > 1`): 1-D only (`DriverAttributeInfo.ArrayDim` is 1-D).
- **Array historization** (HistoryRead over array nodes).
- AbCip whole-UDT / TwinCAT Structure-UDT / bit-index RMW writes / OpcUaClient `ReadEventsAsync` /
  Galaxy writer item-handle cache / Historian #400 — separate `§2` slices, not arrays.

## Stale-done `stillpending.md §2` items to record (close in the design record, do NOT edit stillpending.md)

- **Modbus Int64/UInt64 node DataType** — fixed Phase 4 (`bd8fee61`).
- **Historian `HistoryAggregateType.Total`** — derived client-side Phase 4 (`5e27b5f7`).
- **Historian poison alarm events retry indefinitely** — `maxAttempts` dead-letter cap Phase 4
  (`fcb38014`, task `#437`).

## Done criteria

- `dotnet build ZB.MOM.WW.OtOpcUa.slnx` clean.
- Affected suites green: OpcUaServer (EnsureVariable + WriteValue), Runtime (artifact byte-parity),
  AdminUI.Tests (TagArrayConfig), and all 5 driver test projects (array read/decode).
- **Live `/run`:** a Modbus array tag materializes as an array node + value flows on the rig;
  the other 4 drivers unit-proven (operator-gated live).
- Subagent-driven, per-task review by classification, final integration review, then merge + push.
