# 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).
