diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs
index fe6d6e7e..038b3a14 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs
@@ -211,7 +211,11 @@ public sealed class Phase7Applier
string? historianTagname = tag.IsHistorized
? (string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.FullName : tag.HistorianTagname)
: null;
- SafeEnsureVariable(nodeId, parent, tag.Name, tag.DataType, tag.Writable, historianTagname, tag.IsArray, tag.ArrayLength);
+ // 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).
+ var writable = tag.Writable && !tag.IsArray;
+ SafeEnsureVariable(nodeId, parent, tag.Name, tag.DataType, writable, historianTagname, tag.IsArray, tag.ArrayLength);
}
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs
index bebfef41..bc9bd25c 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs
@@ -378,6 +378,66 @@ public sealed class Phase7ApplierTests
call.ArrayLength.ShouldBeNull();
}
+ /// Review M-1 — an array equipment tag authored with Writable: true must be
+ /// materialised as READ-ONLY (writable == false) because array writes are out of scope
+ /// (Phase 4c read-only surface). The driver write path does not handle arrays and would crash
+ /// (e.g. S7 BoxValueForWrite). Guards against a future refactor that accidentally enables the
+ /// writable path for arrays.
+ [Fact]
+ public void MaterialiseEquipmentTags_array_writable_true_is_forced_read_only()
+ {
+ var sink = new RecordingSink();
+ var applier = new Phase7Applier(sink, NullLogger.Instance);
+
+ var composition = new Phase7CompositionResult(
+ Array.Empty(), Array.Empty(), Array.Empty())
+ {
+ EquipmentTags = new[]
+ {
+ // Authored ReadWrite AND IsArray — the applier must clamp to read-only.
+ new EquipmentTagPlan("tag-arr-rw", "eq-1", "drv", FolderPath: "", Name: "Buffer", DataType: "Int16",
+ FullName: "40001", Writable: true, Alarm: null, IsArray: true, ArrayLength: 8u),
+ },
+ };
+
+ applier.MaterialiseEquipmentTags(composition);
+
+ // writable must be false (array writes out of scope), isArray must be true (forwarded verbatim).
+ var varCall = sink.VariableCalls.ShouldHaveSingleItem();
+ varCall.Writable.ShouldBeFalse(); // clamped to read-only despite Writable: true
+ var arrCall = sink.ArrayCalls.ShouldHaveSingleItem();
+ arrCall.IsArray.ShouldBeTrue();
+ arrCall.ArrayLength.ShouldBe(8u);
+ }
+
+ /// Review M-1 regression — a scalar tag authored with Writable: true must still
+ /// be materialised as read/write (writable == true). The array-clamp must NOT affect
+ /// scalar tags.
+ [Fact]
+ public void MaterialiseEquipmentTags_scalar_writable_true_stays_writable()
+ {
+ var sink = new RecordingSink();
+ var applier = new Phase7Applier(sink, NullLogger.Instance);
+
+ var composition = new Phase7CompositionResult(
+ Array.Empty(), Array.Empty(), Array.Empty())
+ {
+ EquipmentTags = new[]
+ {
+ // Authored ReadWrite, scalar — must pass through writable: true unchanged.
+ new EquipmentTagPlan("tag-scalar-rw", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float",
+ FullName: "40002", Writable: true, Alarm: null, IsArray: false, ArrayLength: null),
+ },
+ };
+
+ applier.MaterialiseEquipmentTags(composition);
+
+ var varCall = sink.VariableCalls.ShouldHaveSingleItem();
+ varCall.Writable.ShouldBeTrue(); // scalar: writable unchanged
+ var arrCall = sink.ArrayCalls.ShouldHaveSingleItem();
+ arrCall.IsArray.ShouldBeFalse();
+ }
+
/// Verifies MaterialiseEquipmentVirtualTags creates one Variable per VirtualTag directly
/// under its existing equipment folder, with a folder-scoped NodeId (EquipmentId/Name — NOT the
/// VirtualTagId or Expression), parent == EquipmentId, displayName == Name, and does NOT re-create