# Phase 4c — cross-driver OPC UA array support — Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development to implement this plan task-by-task. **Goal:** an authored equipment tag marked `isArray` materializes as a 1-D OPC UA array variable node and its backing driver reads + publishes array values — across Modbus, S7, AbCip, TwinCAT, AbLegacy. **Architecture:** driver-agnostic plumbing core (sink contract → plan metadata → applier → authoring UI) first, then per-driver array read fanned out across the 5 disjoint driver projects. Array-ness rides in the existing `TagConfig` JSON blob (`isArray`/`arrayLength`) — NO EF migration. All new fields default to scalar ⇒ byte-parity unchanged for every existing tag. **Tech Stack:** .NET 10, OPC UA Foundation UA-.NETStandard (`BaseDataVariableState`, `ValueRanks`, `UInt32Collection`), Akka.NET, EF Core (read-only here), Blazor Server (AdminUI), S7.Net, libplctag (AbCip/AbLegacy), TwinCAT ADS. **Design:** `docs/plans/2026-06-16-stillpending-phase-4c-array-support-design.md` (committed `efccd8d1`). Branch `feat/stillpending-phase-4c-array-support` off master `050164b2`. **Hard rules (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; NO wire/proto contract change (the `IOpcUaAddressSpaceSink` edit is an in-process abstraction — fine); NO bUnit; `dangerouslyDisableSandbox: true` for all build/test/rig commands. Concurrent sibling implementers touch disjoint projects — build ONLY your own project(s); a half-written sibling driver breaking the whole-solution build is NOT your bug. **Dependency graph:** `T1 → T2 → T3`; then `{T4 ∥ T5 ∥ T6 ∥ T7 ∥ T8 ∥ T9}` (all after T1) → `T10 → T11 → T12`. --- ## Task 1: Sink contract — `EnsureVariable` array params (§A) **Classification:** high-risk **Estimated implement time:** ~5 min **Parallelizable with:** none (foundation) **Files:** - Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs:74` (interface) + `:107` (`NullOpcUaAddressSpaceSink`) - Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs:65` - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs:63` - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs` (`EnsureVariable:1299-1349`, `CreateVariable` fallback near `:2049`) - Test: `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/` (the node-manager test class that covers `EnsureVariable`/`WriteValue`) **Step 1 — failing tests.** In the OpcUaServer test project, add: - `EnsureVariable_isArray_creates_OneDimension_node_with_ArrayDimensions` — call `EnsureVariable(node, parent, "arr", "Int32", writable:false, isArray:true, arrayLength:8)`; assert the created `BaseDataVariableState` has `ValueRank == ValueRanks.OneDimension` and `ArrayDimensions` == `[8]`. - `EnsureVariable_scalar_default_unchanged` — existing scalar call still yields `ValueRank == ValueRanks.Scalar` and null/empty `ArrayDimensions`. - `WriteValue_array_roundtrips` — `EnsureVariable(..., isArray:true, arrayLength:3)` then `WriteValue(node, new int[]{1,2,3}, Good, ts)`; assert the node's `Value` is the `int[]` and (if the harness exposes it) reads back as an array Variant. **Step 2 — run, verify fail.** `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests --filter FullyQualifiedName~EnsureVariable` (compile error — new params). `dangerouslyDisableSandbox:true`. **Step 3 — implement.** Add the two trailing optional params to all four implementers: ```csharp void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null); ``` `DeferredAddressSpaceSink` delegates them through; `NullOpcUaAddressSpaceSink` ignores them. In `OtOpcUaNodeManager.EnsureVariable` change the node initializer (`:1327`): ```csharp ValueRank = isArray ? ValueRanks.OneDimension : ValueRanks.Scalar, ArrayDimensions = isArray ? new UInt32Collection(new[] { arrayLength ?? 0u }) : null, ``` Apply the same two lines in the `CreateVariable` fallback (near `:2049`). `ResolveBuiltInDataType` unchanged. `WriteValue` (`:257`) unchanged. **Step 4 — run, verify pass.** Same filter. Then the full OpcUaServer suite. **Step 5 — commit.** `git add` the 4 sink files + node manager + the test file; `git commit -m "feat(opcua): EnsureVariable array params (ValueRank=OneDimension + ArrayDimensions)"`. --- ## Task 2: Plan metadata — `EquipmentTagPlan.IsArray/ArrayLength` + composer + applier (§B) **Classification:** high-risk **Estimated implement time:** ~5 min **Parallelizable with:** none (blockedBy T1) **Files:** - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs` (record `:87-98`; `Compose` construction `:354-366`; add `ExtractTagArray` mirroring `ExtractTagHistorize:519`) - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs:214` (`SafeEnsureVariable` call + signature) - Test: `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/` (composer test class) **Step 1 — failing tests.** Composer test: a Tag whose `TagConfig` has `"isArray":true,"arrayLength":16` produces an `EquipmentTagPlan` with `IsArray==true`, `ArrayLength==16`; absent keys ⇒ `false/null`; malformed JSON ⇒ `false/null`. A changed-array-flag diff is a `ChangedEquipmentTags` entry (record equality). **Step 2 — verify fail.** **Step 3 — implement.** Extend the record (trailing optional, after `HistorianTagname`): ```csharp bool IsHistorized = false, string? HistorianTagname = null, bool IsArray = false, uint? ArrayLength = null); ``` Add `ExtractTagArray(string? tagConfig)` modeled exactly on `ExtractTagHistorize` (`:519`): parse `TagConfig`, read `isArray` (bool; absent/non-bool/non-object/blank/malformed ⇒ false) and `arrayLength` (uint; only when `isArray` and a number). In `Compose` (`:354`): ```csharp var (isArray, arrayLength) = ExtractTagArray(t.TagConfig); return new EquipmentTagPlan( ..., IsHistorized: isHistorized, HistorianTagname: historianTagname, IsArray: isArray, ArrayLength: arrayLength); ``` In `Phase7Applier` (`:214` + the `SafeEnsureVariable` helper `:312`): thread `tag.IsArray`, `tag.ArrayLength` into `EnsureVariable`. (The other `SafeEnsureVariable` caller at `:267` — VirtualTags/non-equipment — passes `isArray:false`.) **Step 4 — verify pass.** Composer suite. **Step 5 — commit.** `git add` Phase7Composer + Phase7Applier + tests; `git commit -m "feat(opcua): EquipmentTagPlan IsArray/ArrayLength + composer ExtractTagArray + applier wire-in"`. --- ## Task 3: Artifact decode — byte-parity mirror (§B) **Classification:** high-risk **Estimated implement time:** ~4 min **Parallelizable with:** none (blockedBy T2) **Files:** - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs` (`BuildEquipmentTagPlans:404-452`; add `ExtractTagArray` mirroring its `ExtractTagHistorize:700`) - Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/` (DeploymentArtifact tests) **Step 1 — failing test.** `BuildEquipmentTagPlans` decodes `isArray`/`arrayLength` from the serialized tag JSON identically to the composer; a **byte-parity round-trip** test (compose → snapshot JSON → decode) yields `IsArray`/`ArrayLength` equal on both sides for a tag with the keys set. **Step 2 — verify fail.** **Step 3 — implement.** Add `ExtractTagArray` modeled on this file's `ExtractTagHistorize` (`:700`). In `BuildEquipmentTagPlans` (`:440`): ```csharp var (isArray, arrayLength) = ExtractTagArray(tagConfig); result.Add(new EquipmentTagPlan( ..., IsHistorized: isHistorized, HistorianTagname: historianTagname, IsArray: isArray, ArrayLength: arrayLength)); ``` The two `ExtractTagArray` copies (composer + artifact) MUST parse identically (byte-parity invariant). **Step 4 — verify pass.** DeploymentArtifact suite + the byte-parity test. **Step 5 — commit.** `git add` DeploymentArtifact + tests; `git commit -m "feat(runtime): decode IsArray/ArrayLength byte-parity in DeploymentArtifact"`. --- ## Task 4: Authoring UI — driver-agnostic `isArray`/`arrayLength` (§C) **Classification:** standard **Estimated implement time:** ~5 min **Parallelizable with:** T5, T6, T7, T8, T9 (blockedBy T1) **Files:** - Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagArrayConfig.cs` (mirror `TagHistorizeConfig.cs`) - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagModal.razor` (add the control beside the historize control) - Test: `tests/.../AdminUI.Tests/Uns/` (the test class covering `TagHistorizeConfig`) **Step 1 — failing tests.** `TagArrayConfig` merge: reading a `TagConfig` with `isArray`/`arrayLength` populates the model; writing back preserves unknown keys + emits the two keys; clearing `isArray` removes `arrayLength`. Validate: `arrayLength > 0` required when `isArray`. **Step 2 — verify fail.** **Step 3 — implement.** `TagArrayConfig` = pure merge helper exactly like `TagHistorizeConfig` (`ParseOrNew` → read/write `isArray` bool + `arrayLength` uint via `TagConfigJson`, preserve-unknown). In `TagModal.razor`, add an `IsArray` checkbox + an `ArrayLength` numeric (shown when checked), driver-agnostic (alongside the historize fields, applied to the raw `TagConfig` for both typed editors and the raw-JSON fallback). Client-side validation message when `isArray && arrayLength<=0`. **Step 4 — verify pass.** AdminUI.Tests (cores only; NO bUnit). **Step 5 — commit.** `git add` TagArrayConfig + TagModal.razor + tests; `git commit -m "feat(adminui): driver-agnostic isArray/arrayLength Tag-modal control"`. --- ## Task 5: Modbus — String/BitInRegister array decode + resolver (§D) **Classification:** small **Estimated implement time:** ~5 min **Parallelizable with:** T4, T6, T7, T8, T9 (blockedBy T1) **Files:** - Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs` (array decode `:482-484` throw; the equipment-tag resolver path that builds a transient `ModbusTagDefinition` — thread `arrayLength → ArrayCount`) - Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/` **Context:** numeric arrays already decode (`:454-481` build `int[]`/`ulong[]`/`float[]`/`double[]`). Only String + BitInRegister throw (`:482-484`). **Step 1 — failing tests.** Array decode for `String` (N contiguous string blocks → `string[]`) and `BitInRegister` (a register block → per-bit `bool[]`); the equipment-tag resolver maps `arrayLength` into `ModbusTagDefinition.ArrayCount` so an array-config equipment tag reads an array. **Step 2 — verify fail.** **Step 3 — implement.** Replace the `:482-484` `default: throw` with `String` + `BitInRegister` cases (loop over `count`, decode each element with the existing scalar decoder, return the typed array). Thread `arrayLength` through the Modbus equipment-tag resolver into `ArrayCount`. **Step 4 — verify pass.** `dotnet test ...Driver.Modbus.Tests` (`dangerouslyDisableSandbox:true`). **Step 5 — commit.** `git add` ModbusDriver.cs + tests; `git commit -m "feat(modbus): String + BitInRegister array decode + equipment-tag arrayLength"`. --- ## Task 6: AbCip — libplctag array read (§D) **Classification:** standard **Estimated implement time:** ~5 min **Parallelizable with:** T4, T5, T7, T8, T9 (blockedBy T1) **Files:** - Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs` (`IsArray:false` sites `:948-949`, `:1002-1003`; read path; resolver) - Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/` **Step 1 — failing tests.** Discovery flips `IsArray` for an array-typed atomic tag + UDT member; the read path returns a CLR array (`object[]`/typed) for an array tag (against the fake libplctag backend); resolver threads `arrayLength`. **Step 2 — verify fail.** **Step 3 — implement.** libplctag reads array elements natively — read `arrayLength` elements, box to the element-typed array; flip the `IsArray:false` discovery hard-wires using the element count. **Step 4 — verify pass.** `...Driver.AbCip.Tests`. **Step 5 — commit.** `git commit -m "feat(abcip): 1-D array read via libplctag + IsArray discovery"`. --- ## Task 7: TwinCAT — ADS array symbol read (§D) **Classification:** standard **Estimated implement time:** ~5 min **Parallelizable with:** T4, T5, T6, T8, T9 (blockedBy T1) **Files:** - Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs:370-371`; `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs` (read path) - Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/` **Step 1 — failing tests.** Discovery flips `IsArray` for array symbols (pre-declared + discovered); the ADS read returns a CLR array (against the fake ADS client); resolver threads `arrayLength`. **Step 2 — verify fail.** **Step 3 — implement.** ADS reads array symbols natively — read + box to the element-typed array; flip the `IsArray:false` discovery hard-wires from the symbol's array metadata. **Step 4 — verify pass.** `...Driver.TwinCAT.Tests`. **Step 5 — commit.** `git commit -m "feat(twincat): 1-D array symbol read via ADS + IsArray discovery"`. --- ## Task 8: S7 — block-read array (§D) **Classification:** high-risk **Estimated implement time:** ~5 min **Parallelizable with:** T4, T5, T6, T7, T9 (blockedBy T1) **Files:** - Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs:642` (IsArray) + the read path (block read + decode loop) + resolver - Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/` **Step 1 — failing tests.** Discovery flips `IsArray` for an array tag; the read path reads `arrayLength` contiguous elements (block read) and decodes them to a CLR array (against the fake S7 backend); resolver threads `arrayLength`. **Step 2 — verify fail.** **Step 3 — implement.** Read a contiguous block sized `arrayLength × elementBytes` from the tag's start address, decode each element with the existing per-type decoder into a typed array; flip the `:642` `IsArray:false`. Keep the scalar path unchanged when `arrayLength` is null/1. **Step 4 — verify pass.** `...Driver.S7.Tests`. **Step 5 — commit.** `git commit -m "feat(s7): 1-D array block read + decode loop + IsArray discovery"`. --- ## Task 9: AbLegacy — PCCC multi-element array read (§D, heaviest) **Classification:** high-risk **Estimated implement time:** ~5 min **Parallelizable with:** T4, T5, T6, T7, T8 (blockedBy T1) **Files:** - Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs:431-438` (IsArray hard-wire) + the read path + resolver - Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/` **Context:** a PCCC data file (e.g. `N7`) is inherently an array of up to 256 words; the driver addresses one word at a time today. Array support = multi-element file read via libplctag (`N7:0` reading `arrayLength` words). **Step 1 — failing tests.** Discovery flips `IsArray` for a multi-element file tag; the read path reads `arrayLength` words from the file base element and returns a CLR array (against the fake libplctag backend); resolver threads `arrayLength`. Cap at the PCCC file max (256). **Step 2 — verify fail.** **Step 3 — implement.** libplctag reads PCCC file arrays natively — read `arrayLength` elements from the base file address, box to the element-typed array; flip the `:431-438` hard-wire. If the addressing rework proves materially larger than estimated, STOP and surface it to the parent (do not thrash) — the honest fallback is flag-wired + a clear `BadNotSupported` read (FOCAS-4b pattern), but attempt the full read first. **Step 4 — verify pass.** `...Driver.AbLegacy.Tests`. **Step 5 — commit.** `git commit -m "feat(ablegacy): PCCC multi-element file array read + IsArray discovery"`. --- ## Task 10: Docs + bookkeeping **Classification:** small **Estimated implement time:** ~4 min **Parallelizable with:** none (blockedBy T1–T9) **Files:** - Modify: `docs/drivers/Modbus.md`, `docs/drivers/Galaxy.md`-style notes for S7/AbCip/AbLegacy/TwinCAT array support (whichever driver docs exist), a short array section (the `isArray`/`arrayLength` `TagConfig` keys + the per-driver coverage matrix + the read-only/1-D/no-write scope). - Modify: `docs/plans/2026-06-16-stillpending-phase-4c-array-support.md.tasks.json` (bookkeeping at close-out). Document: the two new `TagConfig` keys; the per-driver coverage matrix (all 5 read; live-verify status per driver); record the 3 stale-done `§2` items (Modbus-Int64-node-type, Historian-Total, Historian-poison-dead-letter — all Phase 4); the named out-of-scope deferrals (array writes, multi-dim, array historization). Do NOT edit `stillpending.md`. **Commit:** `git add` the doc files; `git commit -m "docs(phase4c): cross-driver array support + TagConfig keys + coverage matrix"`. --- ## Task 11: Full build + test + final integration review **Classification:** standard **Estimated implement time:** ~6 min **Parallelizable with:** none (blockedBy T1–T10) `dotnet build ZB.MOM.WW.OtOpcUa.slnx` clean (`dangerouslyDisableSandbox:true`); run OpcUaServer + Runtime + AdminUI.Tests + all 5 driver test suites green. Dispatch a **final integration reviewer** over `git diff 050164b2..HEAD` checking: byte-parity (composer↔artifact `ExtractTagArray` identical); defaults preserve scalar (no regression); the sink contract change is additive-only across all 4 implementers; no EF migration / no wire-proto change / no bUnit; each driver's array read is gated on `isArray`/`arrayLength` and the scalar path is untouched. Apply any Critical/Important findings via a fresh implementer. --- ## Task 12: Live `/run` acceptance **Classification:** standard **Estimated implement time:** ~6 min **Parallelizable with:** none (blockedBy T11) Rebuild docker-dev central-1 from the branch (`docker compose -f docker-dev/docker-compose.yml build central-1` then `up -d --force-recreate central-1 traefik`, `dangerouslyDisableSandbox:true`). Drive AdminUI `http://localhost:9200` (login disabled) via the Chrome tools: author a **Modbus array** equipment tag (`isArray` + `arrayLength`) on the seeded `MAIN-modbus-eq` → deploy → browse via Client.CLI and confirm the node is an array (`ValueRank`/`ArrayDimensions`) + an array value flows from the Modbus sim (`10.100.0.35:5020`). S7 / AbCip / TwinCAT / AbLegacy are **unit-proven** (fixtures down — honest, operator-gated live). Record results; then finish the branch (merge to master + push).