fix(address-space): exact element-type check for array writes + array-capability shape edges (#610 review R4)

MAJOR-1: the SDK skips TypeInfo.IsInstanceOfDataType on any node with an
OnWriteValue handler (every writable equipment node), and the node manager's
fail-fast treated numeric<->numeric as compatible, so a UInt32[] to a Galaxy
Int32 array matched MxValueEncoder's int[] arm by CLR covariance and was
written bit-reinterpreted with a Good result. EvaluateEquipmentWriteStructure
now matches an ARRAY write's element type exactly (Byte[]/ByteString the only
equivalence; Variant[]/object[] refused) -> BadTypeMismatch, nothing
dispatched. MxValueEncoder's int[]/long[] arms are exact-type guarded too, and
the Galaxy parity test's false "the SDK refuses" premise is corrected (the
UInt32/UInt64 exclusion is now the encoder's own answer). Scalar writes keep
numeric widening by decision: consumers write CLR-typed values (Double to a
Float node) and every driver's scalar encoder range-checks.

MINOR: Modbus row honours maxRegistersPerWrite (smallest of DriverConfig /
the tag's DeviceConfig, default 123) and FC15's 1968-coil ceiling, and
requires region + numeric address; Modbus/S7/Galaxy require isArray + integer
arrayLength >= 1 (Galaxy: an unbounded node would let a short write resize the
attribute); an array value to a scalar node is refused inline. Docs: Uns.md
array-write rules + upgrade note (ReadWrite array tags become client-writable
on first boot), Modbus.md, Galaxy.md.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 08:07:36 -04:00
parent b7a48e2b93
commit dabd8eb955
14 changed files with 532 additions and 85 deletions
+32 -5
View File
@@ -323,12 +323,17 @@ carried on the plan records (`RawTagPlan.ArrayWritable`, mirrored onto every UNS
| Driver | Array writes | Element types |
|---|---|---|
| **Modbus** | Yes | `region: Coils` (FC15, any element); `region: HoldingRegisters` with `Int16`/`UInt16`/`Bcd16`/`Int32`/`UInt32`/`Bcd32`/`Float32`/`Int64`/`UInt64`/`Float64` (FC16). Not BitInRegister / String / Bool-in-register; input regions are read-only |
| **Modbus** | Yes | `region: Coils` (FC15, any element, ≤ 1968 coils); `region: HoldingRegisters` with `Int16`/`UInt16`/`Bcd16`/`Int32`/`UInt32`/`Bcd32`/`Float32`/`Int64`/`UInt64`/`Float64` (FC16) whose `arrayLength × registers-per-element` fits `maxRegistersPerWrite` (default 123; the smallest value set on the driver or the tag's device wins) — the driver refuses rather than splits an over-cap FC16, so e.g. a 62-element `Int32` array (124 registers) stays read-only. Not BitInRegister / String / Bool-in-register; input regions are read-only |
| **S7** | Yes | `Byte`/`Int16`/`UInt16`/`Int32`/`UInt32`/`Float32` (one contiguous block). Not `Bool` (would clobber neighbouring bits) nor the wide / String / DateTime types |
| **OpcUaClient** | Yes | All — forwarded upstream unchanged; the upstream server decides |
| **Galaxy** | Yes | OPC UA `Boolean`/`Int32`/`Int64`/`Float`/`Double`/`String`/`DateTime` (the `MxValueEncoder` array variants) |
| **OpcUaClient** | Yes | All — forwarded upstream unchanged; the upstream server decides (shape included) |
| **Galaxy** | Yes | OPC UA `Boolean`/`Int32`/`Int64`/`Float`/`Double`/`String`/`DateTime` (the `MxValueEncoder` array variants) — **only with a declared `arrayLength`**: an undeclared length materialises an unbounded node, and a short whole-array write would *resize* the Galaxy attribute (mxaccessgw contract), so such a node stays read-only |
| AB CIP, AB Legacy, TwinCAT, FOCAS, MQTT, MTConnect, Sql, Calculation | **No** — scalar-only write encoders; array nodes stay read-only and a client write is `BadNotWritable` | — |
Modbus, S7 and Galaxy additionally need a **declared length** — `isArray: true` *and* an integer
`arrayLength >= 1`. The Modbus and S7 tag factories read anything else as a **scalar** tag (whose
scalar encoder cannot take an array), even though the composer still materialises an array node;
that node stays read-only.
Each "Yes" row is pinned against the driver's real encoder by a parity test in that driver's
test project (`*ArrayWriteSupportParityTests`), so a driver gaining or losing an array encoder
fails a test until the table follows.
@@ -336,8 +341,20 @@ fails a test until the table follows.
What a client sees on a writable array node:
- **Whole-array writes only.** The value must be a 1-D array of exactly `arrayLength` elements
(an unbounded `arrayLength`/`ArrayDimensions = [0]` takes any length); anything else is
`BadTypeMismatch`, refused inline before dispatch. A partial **IndexRange** write is
(an unbounded `arrayLength`/`ArrayDimensions = [0]` takes any length — reachable only on
OpcUaClient); anything else is `BadTypeMismatch`, refused inline before dispatch.
- **The element type must match the node's DataType exactly** — an `Int32[]` to an `Int16` array,
a `UInt32[]` to an `Int32` array or a `Double[]` to a `Float` array is `BadTypeMismatch`, refused
inline (the one equivalence is a `Byte` array, which arrives as a ByteString). This is the
check the OPC UA SDK would normally make itself: it skips its own type check on any node with an
`OnWriteValue` handler, which every writable equipment node has, so the node manager makes it
instead. Without it a `UInt32[]` reached Galaxy's `int[]` encoder by CLR array covariance and was
written bit-reinterpreted (4,000,000,000 → −294,967,296) with a Good result. **Scalar** writes keep
their looser rule on purpose — any numeric to any numeric node — because consumers (ScadaBridge)
write CLR-typed values such as a `Double` to a `Float` node, and every driver's scalar encoder
converts with a range check, so an out-of-range scalar fails the device write and is reverted.
- An **array value to a scalar node** is likewise `BadTypeMismatch`, refused inline.
- A partial **IndexRange** write is
`BadWriteNotSupported` — every array-capable driver writes the whole block, and the server
does not hold the device array authoritatively enough to read-modify-write it.
- The same realm-qualified `WriteOperate` / ACL gate as a scalar applies to both NodeIds, and a
@@ -347,6 +364,16 @@ What a client sees on a writable array node:
array tag) is a **surgical** in-place writability update, not a rebuild: `ArrayWritable` is on
the classifier's raw-tag whitelist, and the mirrors repair through `BackingArrayWritable`.
> ⚠️ **Upgrade note — array tags become client-writable without a deploy.** Before #610 every
> array node was read-only regardless of its authored `AccessLevel`. On the first boot after
> upgrading, every array tag **authored `ReadWrite`** on Modbus, S7, OpcUaClient or Galaxy whose
> shape the table above accepts is materialised **writable** — no deployment needed, since the
> capability is derived at compose time (a `FetchAndCache` node re-composes from its cached
> artifact). Any client holding `WriteOperate` (or, with `Security:Acl:Enabled`, a `WriteOperate`
> grant covering the tag) can then write whole PLC arrays. Before upgrading, audit array tags
> authored `ReadWrite` that were never meant to be written and set them `Read`; array tags on the
> other drivers are unaffected.
### Out of scope (named deferrals)
- **Multi-dimensional arrays** (`ValueRank > 1`) — **out of scope by decision** (#610 item 3),