diff --git a/src/Opc.Ua.Core.Types/State/SelectionListState.cs b/src/Opc.Ua.Core.Types/State/SelectionListState.cs
new file mode 100644
index 0000000000..f1749a44b6
--- /dev/null
+++ b/src/Opc.Ua.Core.Types/State/SelectionListState.cs
@@ -0,0 +1,112 @@
+/* ========================================================================
+ * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+
+namespace Opc.Ua
+{
+ ///
+ /// Enforces the SelectionListType write contract from OPC UA Part 5 section 7.18:
+ /// when the optional RestrictToList property is present and true, a written value
+ /// must be one of the entries in the mandatory Selections property. Access level,
+ /// data type and index range validation continue to be handled by the base class.
+ ///
+ public partial class SelectionListState
+ {
+ ///
+ protected override ServiceResult WriteValueAttribute(
+ ISystemContext context,
+ NumericRange indexRange,
+ Variant value,
+ StatusCode statusCode,
+ DateTimeUtc sourceTimestamp)
+ {
+ // The Selections restriction only applies to a value that the base class would
+ // otherwise accept. Checking index range, access level, user access and data
+ // type up front keeps the base error codes (BadNotWritable, BadUserAccessDenied,
+ // BadTypeMismatch, BadIndexRangeInvalid) in precedence over BadOutOfRange.
+ if (indexRange.IsNull &&
+ IsRestrictedToList(context) &&
+ WouldBaseAcceptWrite(context, value) &&
+ !IsMemberOfSelections(context, value))
+ {
+ return StatusCodes.BadOutOfRange;
+ }
+
+ return base.WriteValueAttribute(context, indexRange, value, statusCode, sourceTimestamp);
+ }
+
+ ///
+ /// Returns true when the RestrictToList property exists and is set to true.
+ ///
+ private bool IsRestrictedToList(ISystemContext context)
+ {
+ return FindChild(context, new QualifiedName(BrowseNames.RestrictToList)) is BaseVariableState restrictToList &&
+ restrictToList.WrappedValue.TryGetValue(out bool enabled) &&
+ enabled;
+ }
+
+ ///
+ /// Mirrors the base class access level, user access and data type checks so that
+ /// their error codes keep precedence over the Selections membership restriction.
+ /// The membership restriction is only applied to a value the base class accepts.
+ ///
+ private bool WouldBaseAcceptWrite(ISystemContext context, Variant value)
+ {
+ if ((AccessLevel & AccessLevels.CurrentWrite) == 0 ||
+ (UserAccessLevel & AccessLevels.CurrentWrite) == 0)
+ {
+ return false;
+ }
+
+ return !TypeInfo.IsInstanceOfDataType(
+ value,
+ DataType,
+ ValueRank,
+ context.NamespaceUris,
+ context.TypeTable).IsUnknown;
+ }
+
+ ///
+ /// Determines whether is one of the entries in the
+ /// mandatory Selections property. Selections is mandatory: a missing or non-array
+ /// value is a malformed node and rejects the write, and an empty array permits no
+ /// value. Expand lifts each entry into a Variant, so membership is a data-type
+ /// independent Variant equality check across every OPC UA built-in type.
+ ///
+ private bool IsMemberOfSelections(ISystemContext context, Variant value)
+ {
+ Variant selections = FindChild(context, new QualifiedName(BrowseNames.Selections)) is BaseVariableState property
+ ? property.WrappedValue
+ : Variant.Null;
+
+ return selections.TypeInfo.IsArray && selections.Expand().Contains(value);
+ }
+ }
+}
diff --git a/tests/Opc.Ua.Core.Tests/Stack/State/SelectionListStateTests.cs b/tests/Opc.Ua.Core.Tests/Stack/State/SelectionListStateTests.cs
new file mode 100644
index 0000000000..8b3822c500
--- /dev/null
+++ b/tests/Opc.Ua.Core.Tests/Stack/State/SelectionListStateTests.cs
@@ -0,0 +1,351 @@
+/* ========================================================================
+ * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using NUnit.Framework;
+using Opc.Ua.Tests;
+
+namespace Opc.Ua.Core.Tests.Stack.State
+{
+ ///
+ /// Tests for the handwritten SelectionListState write validation which
+ /// rejects values that are not contained in the node's Selections property
+ /// when RestrictToList is true.
+ ///
+ [TestFixture]
+ [Category("NodeState")]
+ [SetCulture("en-us")]
+ [SetUICulture("en-us")]
+ [Parallelizable]
+ public class SelectionListStateTests
+ {
+ private static readonly string[] s_colors = ["Red", "Green", "Blue"];
+ private static readonly Variant[] s_colorVariants =
+ [Variant.From("Red"), Variant.From("Green"), Variant.From("Blue")];
+ private static readonly int[] s_numbers = [1, 2, 3];
+
+ private SystemContext m_context;
+
+ [OneTimeSetUp]
+ public void OneTimeSetUp()
+ {
+ ITelemetryContext telemetry = NUnitTelemetryContext.Create();
+ var messageContext = ServiceMessageContext.CreateEmpty(telemetry);
+ m_context = new SystemContext(telemetry)
+ {
+ NamespaceUris = messageContext.NamespaceUris,
+ ServerUris = messageContext.ServerUris,
+ EncodeableFactory = messageContext.Factory,
+ TypeTable = new TypeTable(messageContext.NamespaceUris)
+ };
+ }
+
+ [Test]
+ public void WriteMemberOfStringSelectionsSucceeds()
+ {
+ SelectionListState node = CreateSelectionList(
+ Variant.From(s_colors.ToArrayOf()));
+
+ ServiceResult result = WriteValue(node, "Green");
+
+ Assert.That(ServiceResult.IsGood(result), Is.True);
+ Assert.That(node.WrappedValue.TryGetValue(out string written), Is.True);
+ Assert.That(written, Is.EqualTo("Green"));
+ }
+
+ [Test]
+ public void WriteNonMemberOfStringSelectionsReturnsBadOutOfRange()
+ {
+ SelectionListState node = CreateSelectionList(
+ Variant.From(s_colors.ToArrayOf()));
+
+ ServiceResult result = WriteValue(node, "Purple");
+
+ Assert.That(result.StatusCode.Code, Is.EqualTo(StatusCodes.BadOutOfRange));
+ }
+
+ [Test]
+ public void WriteWithoutSelectionsChildFallsThroughToBase()
+ {
+ var node = new SelectionListState(null)
+ {
+ NodeId = new NodeId(1000),
+ BrowseName = new QualifiedName("Colors"),
+ DataType = DataTypeIds.String,
+ ValueRank = ValueRanks.Scalar,
+ AccessLevel = AccessLevels.CurrentReadOrWrite,
+ UserAccessLevel = AccessLevels.CurrentReadOrWrite,
+ Value = Variant.From("Red")
+ };
+
+ ServiceResult result = WriteValue(node, "AnythingGoes");
+
+ Assert.That(ServiceResult.IsGood(result), Is.True);
+ Assert.That(node.WrappedValue.TryGetValue(out string written), Is.True);
+ Assert.That(written, Is.EqualTo("AnythingGoes"));
+ }
+
+ [Test]
+ public void WriteWithEmptyStringSelectionsAndRestrictToListReturnsBadOutOfRange()
+ {
+ SelectionListState node = CreateSelectionList(
+ Variant.From(ArrayOf.Empty));
+
+ ServiceResult result = WriteValue(node, "AnythingGoes");
+
+ Assert.That(result.StatusCode.Code, Is.EqualTo(StatusCodes.BadOutOfRange));
+ }
+
+ [Test]
+ public void WriteNonMemberWhenRestrictToListFalseSucceeds()
+ {
+ SelectionListState node = CreateSelectionList(
+ Variant.From(s_colors.ToArrayOf()),
+ addRestrictToList: true,
+ restrictToList: false);
+
+ ServiceResult result = WriteValue(node, "Purple");
+
+ Assert.That(ServiceResult.IsGood(result), Is.True);
+ Assert.That(node.WrappedValue.TryGetValue(out string written), Is.True);
+ Assert.That(written, Is.EqualTo("Purple"));
+ }
+
+ [Test]
+ public void WriteNonMemberWhenRestrictToListAbsentSucceeds()
+ {
+ SelectionListState node = CreateSelectionList(
+ Variant.From(s_colors.ToArrayOf()),
+ addRestrictToList: false);
+
+ ServiceResult result = WriteValue(node, "Purple");
+
+ Assert.That(ServiceResult.IsGood(result), Is.True);
+ Assert.That(node.WrappedValue.TryGetValue(out string written), Is.True);
+ Assert.That(written, Is.EqualTo("Purple"));
+ }
+
+ [Test]
+ public void WriteNonStringValueWhenStringSelectionsFallsThroughToBaseTypeCheck()
+ {
+ SelectionListState node = CreateSelectionList(
+ Variant.From(s_colors.ToArrayOf()));
+
+ ServiceResult result = node.WriteAttribute(
+ m_context,
+ Attributes.Value,
+ NumericRange.Null,
+ new DataValue(Variant.From(42)));
+
+ Assert.That(result.StatusCode.Code, Is.EqualTo(StatusCodes.BadTypeMismatch));
+ }
+
+ [Test]
+ public void WriteMemberOfVariantSelectionsSucceeds()
+ {
+ SelectionListState node = CreateSelectionList(
+ Variant.From(s_colorVariants.ToArrayOf()));
+
+ ServiceResult result = WriteValue(node, "Blue");
+
+ Assert.That(ServiceResult.IsGood(result), Is.True);
+ }
+
+ [Test]
+ public void WriteIncompatibleTypeWhenVariantSelectionsFallsThroughToBaseTypeCheck()
+ {
+ SelectionListState node = CreateSelectionList(
+ Variant.From(s_colorVariants.ToArrayOf()));
+
+ ServiceResult result = node.WriteAttribute(
+ m_context,
+ Attributes.Value,
+ NumericRange.Null,
+ new DataValue(Variant.From(42)));
+
+ Assert.That(result.StatusCode.Code, Is.EqualTo(StatusCodes.BadTypeMismatch));
+ }
+
+ [Test]
+ public void WriteMemberOfInt32SelectionsSucceeds()
+ {
+ SelectionListState node = CreateSelectionList(
+ Variant.From(s_numbers.ToArrayOf()),
+ DataTypeIds.Int32,
+ Variant.From(1));
+
+ ServiceResult result = WriteValue(node, Variant.From(2));
+
+ Assert.That(ServiceResult.IsGood(result), Is.True);
+ Assert.That(node.WrappedValue.TryGetValue(out int written), Is.True);
+ Assert.That(written, Is.EqualTo(2));
+ }
+
+ [Test]
+ public void WriteNonMemberOfInt32SelectionsReturnsBadOutOfRange()
+ {
+ SelectionListState node = CreateSelectionList(
+ Variant.From(s_numbers.ToArrayOf()),
+ DataTypeIds.Int32,
+ Variant.From(1));
+
+ ServiceResult result = WriteValue(node, Variant.From(99));
+
+ Assert.That(result.StatusCode.Code, Is.EqualTo(StatusCodes.BadOutOfRange));
+ }
+
+ [Test]
+ public void WriteMemberOfDoubleSelectionsSucceeds()
+ {
+ double[] doubles = [1.5, 2.5, 3.5];
+ SelectionListState node = CreateSelectionList(
+ Variant.From(doubles.ToArrayOf()),
+ DataTypeIds.Double,
+ Variant.From(1.5));
+
+ ServiceResult result = WriteValue(node, Variant.From(2.5));
+
+ Assert.That(ServiceResult.IsGood(result), Is.True);
+ Assert.That(node.WrappedValue.TryGetValue(out double written), Is.True);
+ Assert.That(written, Is.EqualTo(2.5));
+ }
+
+ [Test]
+ public void WriteMemberOfNodeIdSelectionsSucceeds()
+ {
+ NodeId[] nodeIds = [new NodeId(1), new NodeId(2), new NodeId(3)];
+ SelectionListState node = CreateSelectionList(
+ Variant.From(nodeIds.ToArrayOf()),
+ DataTypeIds.NodeId,
+ Variant.From(new NodeId(1)));
+
+ ServiceResult result = WriteValue(node, Variant.From(new NodeId(2)));
+
+ Assert.That(ServiceResult.IsGood(result), Is.True);
+ Assert.That(node.WrappedValue.TryGetValue(out NodeId written), Is.True);
+ Assert.That(written, Is.EqualTo(new NodeId(2)));
+ }
+
+ [Test]
+ public void WriteNonMemberOfVariantSelectionsReturnsBadOutOfRange()
+ {
+ SelectionListState node = CreateSelectionList(
+ Variant.From(s_colorVariants.ToArrayOf()));
+
+ ServiceResult result = WriteValue(node, "Purple");
+
+ Assert.That(result.StatusCode.Code, Is.EqualTo(StatusCodes.BadOutOfRange));
+ }
+
+ [Test]
+ public void WriteMemberButNotWritableStillReturnsBadNotWritable()
+ {
+ SelectionListState node = CreateSelectionList(
+ Variant.From(s_colors.ToArrayOf()));
+ node.AccessLevel = AccessLevels.CurrentRead;
+ node.UserAccessLevel = AccessLevels.CurrentRead;
+
+ ServiceResult result = WriteValue(node, "Green");
+
+ Assert.That(result.StatusCode.Code, Is.EqualTo(StatusCodes.BadNotWritable));
+ }
+
+ private SelectionListState CreateSelectionList(
+ Variant selectionsValue,
+ bool addRestrictToList = true,
+ bool restrictToList = true)
+ {
+ return CreateSelectionList(
+ selectionsValue,
+ DataTypeIds.String,
+ Variant.From("Red"),
+ addRestrictToList,
+ restrictToList);
+ }
+
+ private SelectionListState CreateSelectionList(
+ Variant selectionsValue,
+ NodeId dataType,
+ Variant initialValue,
+ bool addRestrictToList = true,
+ bool restrictToList = true)
+ {
+ var node = new SelectionListState(null)
+ {
+ NodeId = new NodeId(1000),
+ BrowseName = new QualifiedName("Colors"),
+ DataType = dataType,
+ ValueRank = ValueRanks.Scalar,
+ AccessLevel = AccessLevels.CurrentReadOrWrite,
+ UserAccessLevel = AccessLevels.CurrentReadOrWrite,
+ Value = initialValue
+ };
+
+ var selections = new BaseDataVariableState(node)
+ {
+ NodeId = new NodeId(1001),
+ BrowseName = new QualifiedName(BrowseNames.Selections),
+ DataType = dataType,
+ ValueRank = ValueRanks.OneDimension,
+ Value = selectionsValue
+ };
+
+ node.AddChild(selections);
+
+ if (addRestrictToList)
+ {
+ var restrictToListNode = new BaseDataVariableState(node)
+ {
+ NodeId = new NodeId(1002),
+ BrowseName = new QualifiedName("RestrictToList"),
+ DataType = DataTypeIds.Boolean,
+ ValueRank = ValueRanks.Scalar,
+ Value = Variant.From(restrictToList)
+ };
+
+ node.AddChild(restrictToListNode);
+ }
+
+ return node;
+ }
+
+ private ServiceResult WriteValue(SelectionListState node, string value)
+ {
+ return WriteValue(node, Variant.From(value));
+ }
+
+ private ServiceResult WriteValue(SelectionListState node, Variant value)
+ {
+ return node.WriteAttribute(
+ m_context,
+ Attributes.Value,
+ NumericRange.Null,
+ new DataValue(value));
+ }
+ }
+}