Merge #610: server-side array writes gated per driver capability (DriverArrayWriteSupport); ArrayWritable on the surgical whitelist + UNS Backing; shape/IndexRange refusal; ValueRank>1 out of scope

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe

# Conflicts:
#	docs/drivers/S7.md
This commit is contained in:
Joseph Doherty
2026-10-08 07:39:40 -04:00
20 changed files with 907 additions and 35 deletions
@@ -229,9 +229,9 @@ public sealed class AddressSpaceApplier
}
// Compute writable + historian EXACTLY as MaterialiseRawSubtree does so the in-place update
// matches what a rebuild would have produced: arrays are forced read-only (the driver write
// path can't handle them) and a blank historian override falls back to the RawPath (== NodeId).
var writable = d.Current.Writable && !d.Current.IsArray;
// matches what a rebuild would have produced: an array is writable only when its driver can
// write arrays (#610) and a blank historian override falls back to the RawPath (== NodeId).
var writable = RawNodeWritable(d.Current);
var historian = d.Current.IsHistorized
? (string.IsNullOrWhiteSpace(d.Current.HistorianTagname) ? d.Current.NodeId : d.Current.HistorianTagname)
: null;
@@ -275,9 +275,9 @@ public sealed class AddressSpaceApplier
if (IsIdentityPreservingUnsRefDelta(d))
{
// Resolve writable + historian EXACTLY as MaterialiseUnsReferences does when it creates
// the node: arrays are forced read-only, and a blank override falls back to the backing
// RawPath (which IS the backing raw tag's NodeId).
var writable = d.Current.Writable && !d.Current.BackingIsArray;
// the node: an array is writable only when its backing driver can write arrays (#610), and
// a blank override falls back to the backing RawPath (which IS the backing raw tag's NodeId).
var writable = UnsReferenceNodeWritable(d.Current);
var historian = d.Current.BackingIsHistorized
? (string.IsNullOrWhiteSpace(d.Current.BackingHistorianTagname) ? d.Current.BackingRawPath : d.Current.BackingHistorianTagname)
: null;
@@ -467,6 +467,21 @@ public sealed class AddressSpaceApplier
/// </summary>
/// <param name="d">The UNS reference delta to classify.</param>
/// <returns><c>true</c> when the delta is attribute-only and may be applied in place.</returns>
/// <summary>
/// Gitea #610 — the ONE writability rule for a raw value node, shared by the materialise pass and the
/// surgical in-place update so the two can never disagree: authored <c>ReadWrite</c>, and — for an array —
/// a driver that can write the array (<see cref="RawTagPlan.ArrayWritable"/>, from
/// <see cref="ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverArrayWriteSupport"/>). An array tag on a driver
/// with no array encoder stays read-only, so a client write is refused at the node (<c>BadNotWritable</c>)
/// instead of being optimistically applied and reverted.
/// </summary>
internal static bool RawNodeWritable(RawTagPlan t) => t.Writable && (!t.IsArray || t.ArrayWritable);
/// <summary>The UNS-mirror counterpart of <see cref="RawNodeWritable"/>, off the inherited <c>Backing*</c>
/// fields — a mirror is writable exactly when its raw node is.</summary>
internal static bool UnsReferenceNodeWritable(UnsReferenceVariable v) =>
v.Writable && (!v.BackingIsArray || v.BackingArrayWritable);
private static bool IsIdentityPreservingUnsRefDelta(AddressSpacePlan.UnsReferenceDelta d) =>
string.Equals(d.Previous.NodeId, d.Current.NodeId, StringComparison.Ordinal) &&
string.Equals(d.Previous.EquipmentId, d.Current.EquipmentId, StringComparison.Ordinal) &&
@@ -972,11 +987,12 @@ public sealed class AddressSpaceApplier
else
{
// A historized tag materialises Historizing + HistoryRead; the effective historian tagname is
// the override else the RawPath (== NodeId). Array writes are out of scope → force read-only.
// the override else the RawPath (== NodeId). An array is writable only when its driver can
// write arrays (#610) — see RawNodeWritable.
string? historianTagname = t.IsHistorized
? (string.IsNullOrWhiteSpace(t.HistorianTagname) ? t.NodeId : t.HistorianTagname)
: null;
var writable = t.Writable && !t.IsArray;
var writable = RawNodeWritable(t);
if (!SafeEnsureVariable(t.NodeId, t.ParentNodeId, t.Name, t.DataType, writable, AddressSpaceRealm.Raw, historianTagname, t.IsArray, t.ArrayLength)) failed++;
}
}
@@ -1033,7 +1049,7 @@ public sealed class AddressSpaceApplier
string? historianTagname = v.BackingIsHistorized
? (string.IsNullOrWhiteSpace(v.BackingHistorianTagname) ? v.BackingRawPath : v.BackingHistorianTagname)
: null;
var writable = v.Writable && !isArray;
var writable = UnsReferenceNodeWritable(v);
if (!SafeEnsureVariable(v.NodeId, v.EquipmentId, v.EffectiveName, v.DataType, writable, AddressSpaceRealm.Uns, historianTagname, isArray, arrayLength))
{
failed++;
@@ -1120,9 +1136,9 @@ public sealed class AddressSpaceApplier
string? historianTagname = tag.IsHistorized
? (string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.FullName : tag.HistorianTagname)
: null;
// Array writes are out of scope (Phase 4c read-only surface): force array tags read-only
// even if authored ReadWrite, so a client write cannot reach the driver write path which
// does not handle arrays (e.g. S7 BoxValueForWrite would crash).
// Retired equipment-tag path (structurally empty in v3): array tags stay forced read-only
// here. The live array-write capability (#610) is carried on RawTagPlan /
// UnsReferenceVariable only — see RawNodeWritable.
var writable = tag.Writable && !tag.IsArray;
if (!SafeEnsureVariable(nodeId, parent, tag.Name, tag.DataType, writable, AddressSpaceRealm.Uns, historianTagname, tag.IsArray, tag.ArrayLength)) failed++;
}
@@ -166,8 +166,10 @@ public static class AddressSpaceChangeClassifier
}).Equals(d.Current);
// v3 surgical raw-tag eligibility: a NON-alarm raw tag whose only differences are the in-place-updatable
// attributes (Writable / IsHistorized / HistorianTagname / DataType / IsArray / ArrayLength) and/or its
// ReferencingEquipmentPaths. The referencing set is node-inert for a VALUE tag — it only drives native-alarm
// attributes (Writable / IsHistorized / HistorianTagname / DataType / IsArray / ArrayLength / ArrayWritable)
// and/or its ReferencingEquipmentPaths. ArrayWritable (Gitea #610) only feeds the node's writability — the
// same AccessLevel + OnWriteValue swap Writable already drives in place — so it must be listed here or
// every array-tag edit that flips it would fall through to a full rebuild. The referencing set is node-inert for a VALUE tag — it only drives native-alarm
// notifier wiring (the WP4 guard), which is why Alarm != null on EITHER side disqualifies. Identity fields
// (NodeId is the diff key; TagId / DriverInstanceId / Name / ParentNodeId / Realm) still force a rebuild via
// the with-equality default-closed pattern.
@@ -181,6 +183,7 @@ public static class AddressSpaceChangeClassifier
DataType = d.Current.DataType,
IsArray = d.Current.IsArray,
ArrayLength = d.Current.ArrayLength,
ArrayWritable = d.Current.ArrayWritable,
ReferencingEquipmentPaths = d.Current.ReferencingEquipmentPaths,
}).Equals(d.Current);
}
@@ -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));
@@ -1928,7 +1928,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// synchronously so the SDK never applies it (no optimistic-Good-then-revert round-trip + no needless
// device dispatch). Runs AFTER the authz gate (so we never leak structure detail to an unauthorised
// caller) but BEFORE the optimistic dispatch below.
var structure = EvaluateEquipmentWriteStructure(value, node);
var structure = EvaluateEquipmentWriteRange(indexRange) ?? EvaluateEquipmentWriteStructure(value, node);
if (structure is not null) return structure;
// Capture the optimistic value + the REAL prior value/status BEFORE the SDK applies the write
@@ -2045,6 +2045,25 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
return new ServiceResult(StatusCodes.BadTypeMismatch, "null value write rejected");
}
// Gitea #610 — an array node (writable only when its driver encodes whole-array writes) takes a whole
// 1-D array of its declared length: every array-capable driver refuses a short or long block (Modbus
// FC15/FC16, S7's contiguous block), so a wrong shape is rejected here instead of being optimistically
// applied, dispatched, refused and reverted. ArrayDimensions [0] / absent means unbounded ⇒ any length.
if (node is BaseDataVariableState { ValueRank: ValueRanks.OneDimension } arrayNode)
{
if (value is not Array { Rank: 1 } array)
{
return new ServiceResult(StatusCodes.BadTypeMismatch, "a 1-D array node takes a 1-D array value");
}
var declared = arrayNode.ArrayDimensions is { Count: > 0 } dims ? dims[0] : 0u;
if (declared > 0 && array.Length != declared)
{
return new ServiceResult(
StatusCodes.BadTypeMismatch,
$"array write of {array.Length} element(s) to a {declared}-element array node");
}
}
// Cheap, confidence-gated built-in-type compatibility check. Only when the node is a value variable
// with a concretely-resolvable expected built-in type AND the payload's built-in type is also cheaply
// resolvable do we compare; otherwise proceed and let the SDK's WriteValue coercion be authoritative.
@@ -2072,6 +2091,20 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
return null;
}
/// <summary>
/// Gitea #610 — an equipment-tag write must be a WHOLE-value write. A partial (IndexRange) write to an
/// array node is refused: every array-capable driver writes the whole block, so honouring a range would
/// mean a read-modify-write of the device array the server does not hold authoritatively (the node value
/// can be stale or optimistic), and the SDK would apply the range to the node before the device answered.
/// A range on a scalar node is meaningless and refused the same way. Pure + static.
/// </summary>
/// <param name="indexRange">The write's parsed IndexRange (<see cref="NumericRange.Empty"/> when absent).</param>
/// <returns><c>null</c> to proceed; otherwise <c>BadWriteNotSupported</c>.</returns>
internal static ServiceResult? EvaluateEquipmentWriteRange(NumericRange indexRange) =>
indexRange == NumericRange.Empty
? null
: new ServiceResult(StatusCodes.BadWriteNotSupported, "IndexRange writes are not supported; write the whole array");
/// <summary>
/// Conservative built-in-type compatibility test for <see cref="EvaluateEquipmentWriteStructure"/>.
/// Returns true (compatible — allow through) unless there is a confident mismatch. Exact matches pass;