feat(address-space): server-side array writes gated per driver capability (#610)

Array nodes were forced read-only for every driver. A new single authority,
Core.Abstractions DriverArrayWriteSupport, says which (driver, element type)
pairs encode a whole-array write: Modbus (Coils / numeric HoldingRegisters),
S7 (Byte/Int16/UInt16/Int32/UInt32/Float32), OpcUaClient (pass-through) and
Galaxy (Boolean/Int32/Int64/Float/Double/String/DateTime). Everything else
stays read-only, so the server never advertises a write the driver refuses.

- Composer derives RawTagPlan.ArrayWritable + UnsReferenceVariable
  .BackingArrayWritable at compose time (both seams, byte-parity); the
  applier's one writability rule is Writable && (!IsArray || ArrayWritable)
  for materialise and surgical paths, raw and UNS alike.
- ArrayWritable is on the classifier's surgical raw-tag whitelist and
  mirrored on the UNS record (UnsReferenceBackingParityTests updated).
- Node manager refuses a wrong-length / non-array value on an array node
  (BadTypeMismatch) and any IndexRange write (BadWriteNotSupported) inline,
  before dispatch; WriteOperate/ACL gates and the revert are unchanged.
- Per-driver parity tests pin the table against the real Modbus write path,
  S7 EncodeArrayBlock and Galaxy MxValueEncoder (UInt32/UInt64 excluded:
  array covariance reinterprets their bits).
- Over-the-wire proof in OpcUaServer.IntegrationTests (ArrayWriteWireTests).
- ValueRank > 1 documented as out of scope by decision (docs/Uns.md).

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 07:39:05 -04:00
parent ca0f1e7735
commit 18f8eb5d2f
20 changed files with 907 additions and 35 deletions
@@ -3,6 +3,7 @@ using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
@@ -137,6 +138,11 @@ public sealed record RawContainerNode(
/// referencing equipment. <see cref="Realm"/> is always <see cref="AddressSpaceRealm.Raw"/>.
/// A null <see cref="HistorianTagname"/> means the historian tagname defaults to the RawPath (resolved
/// later, not here).
/// <see cref="ArrayWritable"/> (Gitea #610) is whether the owning driver can write this tag as a whole
/// 1-D array (<see cref="ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverArrayWriteSupport"/>); always
/// <see langword="false"/> for a scalar. The materialised node is writable iff
/// <c>Writable &amp;&amp; (!IsArray || ArrayWritable)</c>, so an array tag on a driver with no array
/// encoder stays read-only exactly as before #610.
/// <para><b>ADDING A FIELD IS A FOUR-PLACE CONTRACT — the hand-written equality below does NOT close
/// over new members.</b> Because <see cref="ReferencingEquipmentPaths"/> forces a custom
/// <c>Equals</c>/<c>GetHashCode</c>, the compiler's positional equality is suppressed, so a field added
@@ -163,7 +169,8 @@ public sealed record RawTagPlan(
string? HistorianTagname = null,
bool IsArray = false,
uint? ArrayLength = null,
AddressSpaceRealm Realm = AddressSpaceRealm.Raw)
AddressSpaceRealm Realm = AddressSpaceRealm.Raw,
bool ArrayWritable = false)
{
/// <inheritdoc />
// Structural equality: the auto-generated record equality compares ReferencingEquipmentPaths (an
@@ -185,6 +192,7 @@ public sealed record RawTagPlan(
IsArray == other.IsArray &&
ArrayLength == other.ArrayLength &&
Realm == other.Realm &&
ArrayWritable == other.ArrayWritable &&
ReferencingEquipmentPaths.SequenceEqual(other.ReferencingEquipmentPaths, StringComparer.Ordinal);
/// <inheritdoc />
@@ -194,7 +202,7 @@ public sealed record RawTagPlan(
hash.Add(TagId); hash.Add(NodeId); hash.Add(ParentNodeId); hash.Add(DriverInstanceId);
hash.Add(Name); hash.Add(DataType); hash.Add(Writable); hash.Add(Alarm);
hash.Add(IsHistorized); hash.Add(HistorianTagname); hash.Add(IsArray); hash.Add(ArrayLength);
hash.Add(Realm);
hash.Add(Realm); hash.Add(ArrayWritable);
foreach (var p in ReferencingEquipmentPaths) hash.Add(p, StringComparer.Ordinal);
return hash.ToHashCode();
}
@@ -226,7 +234,9 @@ public sealed record RawTagPlan(
/// existing machinery repairs it: the applier removes the previous UNS node and
/// <see cref="AddressSpaceApplier.MaterialiseUnsReferences"/> recreates it from these values.
/// <see cref="DataType"/> / <see cref="Writable"/> were already inherited onto the record and are
/// covered by the same mechanism.</para>
/// covered by the same mechanism, as is <see cref="BackingArrayWritable"/> (Gitea #610 — the backing
/// tag's <see cref="RawTagPlan.ArrayWritable"/>, so a mirror is writable exactly when its raw node
/// is).</para>
/// <para>Equality is the COMPILER-GENERATED positional-record equality: every member is a
/// value/string type (no <c>IReadOnlyList</c> member, unlike <see cref="RawTagPlan"/>), so the four
/// new fields are compared by value with no hand-written <c>Equals</c>/<c>GetHashCode</c> needed. If
@@ -245,7 +255,8 @@ public sealed record UnsReferenceVariable(
string? BackingHistorianTagname = null,
bool BackingIsArray = false,
uint? BackingArrayLength = null,
AddressSpaceRealm Realm = AddressSpaceRealm.Uns);
AddressSpaceRealm Realm = AddressSpaceRealm.Uns,
bool BackingArrayWritable = false);
public sealed record UnsAreaProjection(string UnsAreaId, string DisplayName);
public sealed record UnsLineProjection(string UnsLineId, string UnsAreaId, string DisplayName);
@@ -657,7 +668,7 @@ public static class AddressSpaceComposer
rawFolders, driverInstances, devices, tagGroups, tags, unsTagReferences,
unsAreas, unsLines, equipment, topology);
var unsReferenceVariables = BuildUnsReferenceVariables(
unsTagReferences, tags, unsAreas, unsLines, equipment, topology);
unsTagReferences, tags, unsAreas, unsLines, equipment, topology, driverInstances);
return new AddressSpaceComposition(areas, lines, nodes, plans, alarms)
{
@@ -756,6 +767,7 @@ public static class AddressSpaceComposer
// TagId → the set of referencing equipment UNS folder paths (Area/Line/Equipment), for the
// native-alarm multi-notifier. Distinct + ordinal-sorted for determinism.
var equipPathByTag = BuildReferencingEquipmentPathsByTag(unsTagReferences, unsAreas, unsLines, equipment);
var driverTypeById = BuildDriverTypeById(driverInstances);
var rawTags = new List<RawTagPlan>();
foreach (var t in tags ?? Array.Empty<Tag>())
@@ -767,6 +779,8 @@ public static class AddressSpaceComposer
: topology.DevicePaths.GetValueOrDefault(t.DeviceId);
var driverInstanceId = topology.DriverIdByDevice.GetValueOrDefault(t.DeviceId, string.Empty);
var intent = TagConfigIntent.Parse(t.TagConfig);
var arrayWritable = intent.IsArray && DriverArrayWriteSupport.Supports(
driverTypeById.GetValueOrDefault(driverInstanceId), t.DataType, t.TagConfig);
var alarm = intent.Alarm is { } a
? new EquipmentTagAlarmInfo(a.AlarmType, a.Severity, a.HistorizeToAveva)
: null;
@@ -785,7 +799,8 @@ public static class AddressSpaceComposer
IsHistorized: intent.IsHistorized,
HistorianTagname: intent.HistorianTagname,
IsArray: intent.IsArray,
ArrayLength: intent.ArrayLength));
ArrayLength: intent.ArrayLength,
ArrayWritable: arrayWritable));
}
containers.Sort((x, y) => string.CompareOrdinal(x.NodeId, y.NodeId));
@@ -793,6 +808,13 @@ public static class AddressSpaceComposer
return (containers, rawTags);
}
/// <summary><c>DriverInstanceId → DriverType</c>, for the per-driver array-write capability (Gitea #610).
/// First row wins on a duplicate id, matching the composer's other id maps.</summary>
private static IReadOnlyDictionary<string, string> BuildDriverTypeById(IReadOnlyList<DriverInstance> driverInstances) =>
driverInstances
.GroupBy(d => d.DriverInstanceId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First().DriverType, StringComparer.Ordinal);
/// <summary>
/// Emit the UNS reference Variables: one per <see cref="UnsTagReference"/> whose backing raw tag +
/// equipment path resolve, keyed <c>(realm=Uns, s=Area/Line/Equipment/EffectiveName)</c> and carrying
@@ -806,10 +828,12 @@ public static class AddressSpaceComposer
IReadOnlyList<UnsArea> unsAreas,
IReadOnlyList<UnsLine> unsLines,
IReadOnlyList<Equipment> equipment,
RawTopology topology)
RawTopology topology,
IReadOnlyList<DriverInstance> driverInstances)
{
var refs = unsTagReferences ?? Array.Empty<UnsTagReference>();
if (refs.Count == 0) return Array.Empty<UnsReferenceVariable>();
var driverTypeById = BuildDriverTypeById(driverInstances);
var tagsById = (tags ?? Array.Empty<Tag>())
.GroupBy(t => t.TagId, StringComparer.Ordinal)
@@ -840,6 +864,11 @@ public static class AddressSpaceComposer
// doc-comment; 2026-08-15 surgical-deploys review). Parsed from the SAME TagConfigIntent the raw
// tag plan uses, so the two sides can never disagree.
var intent = TagConfigIntent.Parse(tag.TagConfig);
// Same derivation as BuildRawSubtree's ArrayWritable, off the same row, so a mirror is writable
// exactly when its raw node is.
var backingArrayWritable = intent.IsArray && DriverArrayWriteSupport.Supports(
driverTypeById.GetValueOrDefault(topology.DriverIdByDevice.GetValueOrDefault(tag.DeviceId, string.Empty)),
tag.DataType, tag.TagConfig);
vars.Add(new UnsReferenceVariable(
UnsTagReferenceId: r.UnsTagReferenceId,
@@ -852,7 +881,8 @@ public static class AddressSpaceComposer
BackingIsHistorized: intent.IsHistorized,
BackingHistorianTagname: intent.HistorianTagname,
BackingIsArray: intent.IsArray,
BackingArrayLength: intent.ArrayLength));
BackingArrayLength: intent.ArrayLength,
BackingArrayWritable: backingArrayWritable));
}
vars.Sort((x, y) => string.CompareOrdinal(x.NodeId, y.NodeId));