diff --git a/milo-examples/client-examples/src/main/java/org/eclipse/milo/examples/client/AliasNamesExample.java b/milo-examples/client-examples/src/main/java/org/eclipse/milo/examples/client/AliasNamesExample.java
new file mode 100644
index 0000000000..14cbd36aee
--- /dev/null
+++ b/milo-examples/client-examples/src/main/java/org/eclipse/milo/examples/client/AliasNamesExample.java
@@ -0,0 +1,166 @@
+/*
+ * Copyright (c) 2026 the Eclipse Milo Authors
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+
+package org.eclipse.milo.examples.client;
+
+import static java.util.Objects.requireNonNull;
+import static java.util.Objects.requireNonNullElse;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import org.eclipse.milo.opcua.sdk.client.OpcUaClient;
+import org.eclipse.milo.opcua.stack.core.NodeIds;
+import org.eclipse.milo.opcua.stack.core.StatusCodes;
+import org.eclipse.milo.opcua.stack.core.UaException;
+import org.eclipse.milo.opcua.stack.core.types.builtin.DataValue;
+import org.eclipse.milo.opcua.stack.core.types.builtin.ExpandedNodeId;
+import org.eclipse.milo.opcua.stack.core.types.builtin.ExtensionObject;
+import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId;
+import org.eclipse.milo.opcua.stack.core.types.builtin.Variant;
+import org.eclipse.milo.opcua.stack.core.types.enumerated.TimestampsToReturn;
+import org.eclipse.milo.opcua.stack.core.types.structured.AliasNameDataType;
+import org.eclipse.milo.opcua.stack.core.types.structured.AliasNameVerboseDataType;
+import org.eclipse.milo.opcua.stack.core.types.structured.CallMethodRequest;
+import org.eclipse.milo.opcua.stack.core.types.structured.CallMethodResult;
+import org.eclipse.milo.opcua.stack.core.types.structured.CallResponse;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Looks up OPC UA Part 17 Alias Names on the example server: calls {@code FindAlias} and {@code
+ * FindAliasVerbose} on the standard {@code Aliases} Object with the pattern {@code "Demo.%"},
+ * decodes the results, then reads the Value of a resolved target to prove end-to-end resolution.
+ *
+ *
The demo aliases are created by the ExampleServer's AliasManager in a "MiloDemo" category
+ * organized under the standard {@code TagVariables} Object; because the {@code FindAlias} search is
+ * recursive, they are found from the {@code Aliases} root.
+ */
+public class AliasNamesExample implements ClientExample {
+
+ public static void main(String[] args) throws Exception {
+ AliasNamesExample example = new AliasNamesExample();
+
+ new ClientExampleRunner(example).run();
+ }
+
+ private final Logger logger = LoggerFactory.getLogger(getClass());
+
+ @Override
+ public void run(OpcUaClient client, CompletableFuture future) throws Exception {
+ client.connect();
+
+ // FindAlias is a standard Method instance defined by the base NodeSet; the pattern argument
+ // uses the Part 4 "Like" grammar, so "Demo.%" matches every alias name starting "Demo.".
+ AliasNameDataType[] aliases = findAlias(client, "Demo.%");
+
+ for (AliasNameDataType alias : aliases) {
+ logger.info(
+ "FindAlias: {} -> {}",
+ alias.getAliasName().name(),
+ Arrays.toString(alias.getReferencedNodes()));
+ }
+
+ if (aliases.length == 0) {
+ throw new UaException(StatusCodes.Bad_NotFound, "no aliases matched \"Demo.%\"");
+ }
+
+ // Prove end-to-end resolution: take the first alias's first target (targets are returned in
+ // order of preference) and read its Value attribute.
+ readFirstTarget(client, aliases[0]);
+
+ // FindAliasVerbose is an Optional Method the standard NodeSet does not define an instance of;
+ // the example server enables it on its AliasManager, which materializes the Method Node with
+ // a NodeId allocated in the example namespace.
+ NodeId findAliasVerboseId = NodeId.parse("ns=2;s=Aliases/FindAliasVerbose");
+
+ AliasNameVerboseDataType[] verboseAliases =
+ findAliasVerbose(client, findAliasVerboseId, "Demo.%");
+
+ for (AliasNameVerboseDataType alias : verboseAliases) {
+ logger.info(
+ "FindAliasVerbose: {} -> {} (category={})",
+ alias.getAliasName().name(),
+ Arrays.toString(alias.getReferencedNodes()),
+ alias.getAliasNameCategoryId());
+ }
+
+ future.complete(client);
+ }
+
+ private AliasNameDataType[] findAlias(OpcUaClient client, String pattern) throws UaException {
+ ExtensionObject[] xos = callFindMethod(client, NodeIds.Aliases_FindAlias, pattern);
+
+ var aliases = new AliasNameDataType[xos.length];
+ for (int i = 0; i < xos.length; i++) {
+ aliases[i] = (AliasNameDataType) xos[i].decode(client.getStaticEncodingContext());
+ }
+ return aliases;
+ }
+
+ private AliasNameVerboseDataType[] findAliasVerbose(
+ OpcUaClient client, NodeId methodId, String pattern) throws UaException {
+
+ ExtensionObject[] xos = callFindMethod(client, methodId, pattern);
+
+ var aliases = new AliasNameVerboseDataType[xos.length];
+ for (int i = 0; i < xos.length; i++) {
+ aliases[i] = (AliasNameVerboseDataType) xos[i].decode(client.getStaticEncodingContext());
+ }
+ return aliases;
+ }
+
+ /**
+ * Call a FindAlias-shaped Method on the standard {@code Aliases} Object and return the encoded
+ * result entries; both FindAlias and FindAliasVerbose take an alias name search pattern and a
+ * ReferenceType filter (a null NodeId means no restriction) and return a single output array.
+ */
+ private ExtensionObject[] callFindMethod(OpcUaClient client, NodeId methodId, String pattern)
+ throws UaException {
+
+ var request =
+ new CallMethodRequest(
+ NodeIds.Aliases,
+ methodId,
+ new Variant[] {new Variant(pattern), new Variant(NodeId.NULL_VALUE)});
+
+ CallResponse response = client.call(List.of(request));
+
+ CallMethodResult result = requireNonNull(response.getResults())[0];
+
+ if (!result.getStatusCode().isGood()) {
+ throw new UaException(result.getStatusCode());
+ }
+
+ Variant[] outputs = requireNonNull(result.getOutputArguments());
+
+ return requireNonNullElse((ExtensionObject[]) outputs[0].value(), new ExtensionObject[0]);
+ }
+
+ private void readFirstTarget(OpcUaClient client, AliasNameDataType alias) throws UaException {
+ ExpandedNodeId firstTarget = requireNonNull(alias.getReferencedNodes())[0];
+
+ NodeId targetNodeId =
+ firstTarget
+ .toNodeId(client.getNamespaceTable())
+ .orElseThrow(
+ () ->
+ new UaException(
+ StatusCodes.Bad_NodeIdUnknown, "target is not local: " + firstTarget));
+
+ DataValue value = client.readValue(0.0, TimestampsToReturn.Both, targetNodeId);
+
+ logger.info(
+ "read {} (target {}): {}",
+ alias.getAliasName().name(),
+ targetNodeId.toParseableString(),
+ value.getValue().value());
+ }
+}
diff --git a/milo-examples/server-examples/src/main/java/org/eclipse/milo/examples/server/ExampleServer.java b/milo-examples/server-examples/src/main/java/org/eclipse/milo/examples/server/ExampleServer.java
index 310e19623e..76b3920b8a 100644
--- a/milo-examples/server-examples/src/main/java/org/eclipse/milo/examples/server/ExampleServer.java
+++ b/milo-examples/server-examples/src/main/java/org/eclipse/milo/examples/server/ExampleServer.java
@@ -25,6 +25,7 @@
import java.util.List;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
@@ -34,12 +35,18 @@
import org.eclipse.milo.opcua.sdk.server.OpcUaServer;
import org.eclipse.milo.opcua.sdk.server.OpcUaServerConfig;
import org.eclipse.milo.opcua.sdk.server.OpcUaServerConfigBuilder;
+import org.eclipse.milo.opcua.sdk.server.aliases.AliasCategoryConfig;
+import org.eclipse.milo.opcua.sdk.server.aliases.AliasManager;
+import org.eclipse.milo.opcua.sdk.server.aliases.AliasManagerConfig;
+import org.eclipse.milo.opcua.sdk.server.aliases.AliasTarget;
import org.eclipse.milo.opcua.sdk.server.identity.AnonymousIdentityValidator;
import org.eclipse.milo.opcua.sdk.server.identity.CompositeValidator;
import org.eclipse.milo.opcua.sdk.server.identity.UsernameIdentityValidator;
import org.eclipse.milo.opcua.sdk.server.identity.X509IdentityValidator;
import org.eclipse.milo.opcua.sdk.server.util.HostnameUtil;
+import org.eclipse.milo.opcua.stack.core.NodeIds;
import org.eclipse.milo.opcua.stack.core.StatusCodes;
+import org.eclipse.milo.opcua.stack.core.UaException;
import org.eclipse.milo.opcua.stack.core.UaRuntimeException;
import org.eclipse.milo.opcua.stack.core.security.AbstractCertificateFactory;
import org.eclipse.milo.opcua.stack.core.security.DefaultApplicationGroup;
@@ -52,6 +59,8 @@
import org.eclipse.milo.opcua.stack.core.transport.TransportProfile;
import org.eclipse.milo.opcua.stack.core.types.builtin.DateTime;
import org.eclipse.milo.opcua.stack.core.types.builtin.LocalizedText;
+import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId;
+import org.eclipse.milo.opcua.stack.core.types.builtin.QualifiedName;
import org.eclipse.milo.opcua.stack.core.types.enumerated.MessageSecurityMode;
import org.eclipse.milo.opcua.stack.core.types.structured.BuildInfo;
import org.eclipse.milo.opcua.stack.core.util.CertificateUtil;
@@ -93,6 +102,7 @@ public static void main(String[] args) throws Exception {
private final OpcUaServer server;
private final ExampleNamespace exampleNamespace;
private final AlarmConditionsNamespace alarmConditionsNamespace;
+ private final AliasManager aliasManager;
public ExampleServer() throws Exception {
this(DEFAULT_TCP_BIND_PORT, builder -> {});
@@ -242,6 +252,53 @@ protected X509Certificate[] createRsaSha256CertificateChain(KeyPair keyPair) {
alarmConditionsNamespace = new AlarmConditionsNamespace(server);
alarmConditionsNamespace.startup();
+
+ // Opt-in OPC UA Part 17 Alias Names support: binds FindAlias on the standard Aliases,
+ // TagVariables, and Topics Objects and, with FindAliasVerbose enabled, materializes
+ // FindAliasVerbose Method instances alongside them, with NodeIds allocated in the example
+ // namespace. The manager is started in startup(), after the server itself has started.
+ aliasManager =
+ new AliasManager(
+ server,
+ AliasManagerConfig.builder()
+ .nodeNamespaceIndex(exampleNamespace.getNamespaceIndex())
+ .findAliasVerboseEnabled(true)
+ .build());
+ }
+
+ /**
+ * Creates a demo alias category, "MiloDemo", organized under the standard {@code TagVariables}
+ * Object, with aliases targeting HelloWorld scalar Variables. The AliasNamesExample client
+ * example resolves these aliases via FindAlias/FindAliasVerbose and reads the targets.
+ */
+ private void addDemoAliases() throws UaException {
+ var categoryConfig =
+ new AliasCategoryConfig(
+ new NodeId(exampleNamespace.getNamespaceIndex(), "Aliases/MiloDemo"),
+ NodeIds.TagVariables,
+ new QualifiedName(exampleNamespace.getNamespaceIndex().intValue(), "MiloDemo"),
+ exampleNamespace.getNodeManager(),
+ name -> new NodeId(exampleNamespace.getNamespaceIndex(), "Aliases/" + name),
+ false,
+ false,
+ false);
+
+ NodeId categoryId = aliasManager.addCategory(categoryConfig).nodeId();
+
+ addDemoAlias(categoryId, "Demo.ScalarDouble", "HelloWorld/ScalarTypes/Double");
+ addDemoAlias(categoryId, "Demo.ScalarInt32", "HelloWorld/ScalarTypes/Int32");
+ }
+
+ private void addDemoAlias(NodeId categoryId, String aliasName, String targetIdentifier)
+ throws UaException {
+
+ var target =
+ new AliasTarget(
+ new NodeId(exampleNamespace.getNamespaceIndex(), targetIdentifier).expanded(),
+ null,
+ NodeIds.AliasFor);
+
+ aliasManager.addAlias(categoryId, aliasName, List.of(target));
}
private Set createEndpointConfigs(X509Certificate certificate) {
@@ -319,10 +376,30 @@ public OpcUaServer getServer() {
}
public CompletableFuture startup() {
- return server.startup();
+ return server
+ .startup()
+ .thenApply(
+ s -> {
+ // The standard alias Objects and their FindAlias Method Nodes exist once the
+ // OpcUaServer is constructed, but the AliasManager is documented to start after
+ // the server itself has started.
+ aliasManager.startup();
+
+ try {
+ addDemoAliases();
+ } catch (UaException e) {
+ throw new CompletionException(e);
+ }
+
+ return s;
+ });
}
public CompletableFuture shutdown() {
+ if (aliasManager.isRunning()) {
+ aliasManager.shutdown();
+ }
+
alarmConditionsNamespace.shutdown();
exampleNamespace.shutdown();
diff --git a/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasManagerWireEntryShutdownTest.java b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasManagerWireEntryShutdownTest.java
new file mode 100644
index 0000000000..c7084e698c
--- /dev/null
+++ b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasManagerWireEntryShutdownTest.java
@@ -0,0 +1,89 @@
+/*
+ * Copyright (c) 2026 the Eclipse Milo Authors
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+
+package org.eclipse.milo.opcua.sdk.server.aliases;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import org.eclipse.milo.opcua.sdk.test.AbstractClientServerTest;
+import org.eclipse.milo.opcua.stack.core.NodeIds;
+import org.eclipse.milo.opcua.stack.core.StatusCodes;
+import org.eclipse.milo.opcua.stack.core.UaException;
+import org.eclipse.milo.opcua.stack.core.types.builtin.ExpandedNodeId;
+import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId;
+import org.eclipse.milo.opcua.stack.core.types.builtin.StatusCode;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests that {@link AliasManager}'s wire entry points reject calls once the manager is no longer
+ * running.
+ *
+ * Deliberately located in the manager's package: the guarded state is only reachable over the
+ * wire in the narrow race where a dispatched Call loses the manager-lock race to {@code shutdown()}
+ * (afterwards the Method Nodes are deleted, so a fresh Call never resolves them), which cannot be
+ * produced deterministically from a client. Calling the package-private entry points on a shut-down
+ * manager exercises exactly the state that race leaves behind.
+ */
+class AliasManagerWireEntryShutdownTest extends AbstractClientServerTest {
+
+ // WHY: a Call dispatched just before shutdown() can pass authorization and then wait on the
+ // manager lock; if shutdown wins the lock first, the AddressSpace fragment is unregistered by
+ // the time the handler proceeds, so mutating would create ghost Nodes and persist spurious
+ // LastChange values. The entry point must fail with a defined code instead.
+ @Test
+ void addAliasEntriesOnShutDownManagerFailsWithBadInvalidState() {
+ AliasManager manager = newStartedManager();
+ manager.shutdown();
+
+ UaException e =
+ assertThrows(
+ UaException.class,
+ () ->
+ manager.addAliasEntries(
+ NodeIds.Aliases,
+ new String[] {"PostShutdownAddAlias"},
+ new ExpandedNodeId[] {newNodeId("TestInt32").expanded()},
+ new String[0],
+ NodeId.NULL_VALUE));
+
+ assertEquals(StatusCode.of(StatusCodes.Bad_InvalidState), e.getStatusCode());
+ }
+
+ // WHY: the same mid-dispatch-vs-shutdown race exists for DeleteAliasesFromCategory, and the
+ // Delete path additionally touches application NodeManagers, so a post-shutdown call must fail
+ // with the same defined code instead of mutating ghost state.
+ @Test
+ void deleteAliasEntriesOnShutDownManagerFailsWithBadInvalidState() {
+ AliasManager manager = newStartedManager();
+ manager.shutdown();
+
+ UaException e =
+ assertThrows(
+ UaException.class,
+ () ->
+ manager.deleteAliasEntries(
+ NodeIds.Aliases, new String[] {"PostShutdownDeleteAlias"}, null));
+
+ assertEquals(StatusCode.of(StatusCodes.Bad_InvalidState), e.getStatusCode());
+ }
+
+ private AliasManager newStartedManager() {
+ AliasManager manager =
+ new AliasManager(
+ server,
+ AliasManagerConfig.builder()
+ .configurationEnabled(true)
+ .nodeNamespaceIndex(testNamespace.getNamespaceIndex())
+ .build());
+ manager.startup();
+ return manager;
+ }
+}
diff --git a/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/server/aliases/package-info.java b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/server/aliases/package-info.java
new file mode 100644
index 0000000000..d2453c7d3a
--- /dev/null
+++ b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/server/aliases/package-info.java
@@ -0,0 +1,23 @@
+/*
+ * Copyright (c) 2026 the Eclipse Milo Authors
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+
+/**
+ * White-box integration tests that must live in {@link
+ * org.eclipse.milo.opcua.sdk.server.aliases.AliasManager}'s own package to reach its
+ * package-private wire entry points against a running server.
+ *
+ *
Black-box alias integration tests, which exercise the manager purely through a client
+ * connection, live in {@code org.eclipse.milo.opcua.sdk.test.aliases}. Add tests here only when the
+ * scenario cannot be produced deterministically over the wire.
+ */
+@NullMarked
+package org.eclipse.milo.opcua.sdk.server.aliases;
+
+import org.jspecify.annotations.NullMarked;
diff --git a/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/aliases/AliasAdoptCategoryTest.java b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/aliases/AliasAdoptCategoryTest.java
new file mode 100644
index 0000000000..b3d9f4cc46
--- /dev/null
+++ b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/aliases/AliasAdoptCategoryTest.java
@@ -0,0 +1,473 @@
+/*
+ * Copyright (c) 2026 the Eclipse Milo Authors
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+
+package org.eclipse.milo.opcua.sdk.test.aliases;
+
+import static java.util.Objects.requireNonNull;
+import static org.eclipse.milo.opcua.sdk.test.aliases.AliasTestSupport.readLastChange;
+import static org.eclipse.milo.opcua.sdk.test.aliases.AliasTestSupport.requireLastChange;
+import static org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.Unsigned.uint;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+import org.eclipse.milo.opcua.sdk.core.Reference;
+import org.eclipse.milo.opcua.sdk.server.UaNodeManager;
+import org.eclipse.milo.opcua.sdk.server.aliases.AliasCategory;
+import org.eclipse.milo.opcua.sdk.server.aliases.AliasManager;
+import org.eclipse.milo.opcua.sdk.server.aliases.AliasManagerConfig;
+import org.eclipse.milo.opcua.sdk.server.aliases.AliasTarget;
+import org.eclipse.milo.opcua.sdk.server.methods.AbstractMethodInvocationHandler;
+import org.eclipse.milo.opcua.sdk.server.methods.MethodInvocationHandler;
+import org.eclipse.milo.opcua.sdk.server.nodes.UaMethodNode;
+import org.eclipse.milo.opcua.sdk.server.nodes.UaNode;
+import org.eclipse.milo.opcua.sdk.server.nodes.UaNodeContext;
+import org.eclipse.milo.opcua.sdk.server.nodes.UaObjectNode;
+import org.eclipse.milo.opcua.sdk.server.nodes.UaVariableNode;
+import org.eclipse.milo.opcua.sdk.test.AbstractClientServerTest;
+import org.eclipse.milo.opcua.stack.core.NodeIds;
+import org.eclipse.milo.opcua.stack.core.StatusCodes;
+import org.eclipse.milo.opcua.stack.core.UaException;
+import org.eclipse.milo.opcua.stack.core.types.builtin.LocalizedText;
+import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId;
+import org.eclipse.milo.opcua.stack.core.types.builtin.QualifiedName;
+import org.eclipse.milo.opcua.stack.core.types.builtin.StatusCode;
+import org.eclipse.milo.opcua.stack.core.types.builtin.Variant;
+import org.eclipse.milo.opcua.stack.core.types.structured.Argument;
+import org.eclipse.milo.opcua.stack.core.types.structured.CallMethodRequest;
+import org.eclipse.milo.opcua.stack.core.types.structured.CallMethodResult;
+import org.eclipse.milo.opcua.stack.core.types.structured.CallResponse;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Integration tests for {@link AliasManager#adoptCategory}: managing an {@code
+ * AliasNameCategoryType} instance the application (e.g. a NodeSet loader) created, rather than one
+ * the manager instantiated itself.
+ *
+ *
Adoptable categories are built by hand in the test namespace — an Object with a {@code
+ * HasTypeDefinition} Reference to {@code AliasNameCategoryType} and component Method Nodes carrying
+ * the default {@code NotImplementedHandler} — mirroring what a NodeSet loader produces. An {@link
+ * AliasManager} is one-shot, so every test constructs a fresh instance against the shared server
+ * and shuts it down before returning; the hand-built category Nodes stay behind, with per-test
+ * names so tests remain order-independent.
+ */
+// Fields below are assigned in @BeforeAll, which the nullability inspection does not model.
+@SuppressWarnings("NotNullFieldNotInitialized")
+public class AliasAdoptCategoryTest extends AbstractClientServerTest {
+
+ private UaNodeContext testNodeContext;
+ private UaNodeManager testNodeManager;
+
+ @BeforeAll
+ void captureTestNamespaceInternals() {
+ testNamespace.configure(
+ (context, nodeManager) -> {
+ testNodeContext = context;
+ testNodeManager = nodeManager;
+ });
+ }
+
+ // WHY: the core adoption contract — a NodeSet-style category becomes searchable over the wire,
+ // and the aliases added to it live in the manager's own AddressSpace fragment (leaving with the
+ // manager at shutdown), because the manager must not write into Nodes it does not own beyond
+ // the adopted Method bindings.
+ @Test
+ void adoptionBindsFindAliasAndAliasesLiveInTheManagersFragment() throws UaException {
+ NodeId categoryId = buildCategory("AdoptedRoot", true, false, false, false);
+ NodeId findAliasNodeId = newNodeId("AdoptedRoot/FindAlias");
+ NodeId aliasNodeId;
+
+ AliasManager manager = newStartedManager();
+ try {
+ AliasCategory category = manager.adoptCategory(categoryId);
+
+ assertEquals(categoryId, category.nodeId());
+ assertFalse(category.lastChangeEnabled());
+ assertFalse(category.findAliasVerboseEnabled());
+ assertFalse(category.configurationEnabled());
+
+ // The default alias NodeId factory allocates "/Alias/" in the
+ // configured Node namespace.
+ aliasNodeId =
+ manager.addAlias(categoryId, "AdoptedAlias", List.of(aliasFor(newNodeId("TestInt32"))));
+ assertEquals(
+ new NodeId(
+ testNamespace.getNamespaceIndex(),
+ categoryId.toParseableString() + "/Alias/AdoptedAlias"),
+ aliasNodeId);
+
+ // Hosted in the manager's fragment, not the application NodeManager the category lives in.
+ assertTrue(server.getAddressSpaceManager().getManagedNode(aliasNodeId).isPresent());
+ assertFalse(testNodeManager.containsNode(aliasNodeId));
+
+ // The adopted FindAlias is callable over the wire and finds the alias.
+ CallMethodResult result = callFind(categoryId, findAliasNodeId);
+ assertEquals(StatusCode.GOOD, result.getStatusCode());
+ assertEquals(1, manager.findAlias(categoryId, "AdoptedAlias", null).size());
+ } finally {
+ manager.shutdown();
+ }
+
+ // Shutdown removed the fragment-hosted alias but left the application's category Nodes,
+ // returning the adopted FindAlias to its unbound, non-executable state.
+ assertTrue(server.getAddressSpaceManager().getManagedNode(aliasNodeId).isEmpty());
+ assertTrue(server.getAddressSpaceManager().getManagedNode(categoryId).isPresent());
+
+ UaMethodNode findAliasNode = serverMethodNode(findAliasNodeId);
+ assertInstanceOf(
+ MethodInvocationHandler.NotImplementedHandler.class, findAliasNode.getInvocationHandler());
+ assertFalse(findAliasNode.isExecutable());
+ assertFalse(findAliasNode.isUserExecutable());
+ }
+
+ // WHY: a pre-existing FindAliasVerbose instance is the category definition's decision to offer
+ // verbose lookup; adoption must give it behavior like the other optional Methods instead of
+ // leaving a permanently non-executable Method, and the returned handle must report it.
+ @Test
+ void adoptionBindsPresentUnboundFindAliasVerbose() throws UaException {
+ NodeId categoryId = buildCategory("AdoptedVerbose", true, true, false, false);
+ NodeId verboseNodeId = newNodeId("AdoptedVerbose/FindAliasVerbose");
+
+ AliasManager manager = newStartedManager();
+ try {
+ AliasCategory category = manager.adoptCategory(categoryId);
+ assertTrue(category.findAliasVerboseEnabled());
+
+ manager.addAlias(
+ categoryId, "AdoptedVerboseAlias", List.of(aliasFor(newNodeId("TestInt32"))));
+
+ CallMethodResult result = callFind(categoryId, verboseNodeId);
+ assertEquals(StatusCode.GOOD, result.getStatusCode());
+ } finally {
+ manager.shutdown();
+ }
+
+ UaMethodNode verboseNode = serverMethodNode(verboseNodeId);
+ assertInstanceOf(
+ MethodInvocationHandler.NotImplementedHandler.class, verboseNode.getInvocationHandler());
+ assertFalse(verboseNode.isExecutable());
+ }
+
+ // WHY: pre-existing mutation Method Nodes are bound opportunistically and reported through
+ // configurationEnabled; the two-layer deny posture still applies (the default policy denies
+ // every session), so binding alone must not open network mutation.
+ @Test
+ void adoptionBindsPresentUnboundMutationMethodsAndReportsConfigurationEnabled()
+ throws UaException {
+ NodeId categoryId = buildCategory("AdoptedMutable", true, false, true, false);
+ NodeId addMethodNodeId = newNodeId("AdoptedMutable/AddAliasesToCategory");
+
+ AliasManager manager = newStartedManager();
+ try {
+ AliasCategory category = manager.adoptCategory(categoryId);
+
+ assertTrue(category.configurationEnabled());
+
+ UaMethodNode addMethodNode = serverMethodNode(addMethodNodeId);
+ assertFalse(
+ addMethodNode.getInvocationHandler()
+ instanceof MethodInvocationHandler.NotImplementedHandler);
+ assertTrue(addMethodNode.isExecutable());
+ } finally {
+ manager.shutdown();
+ }
+ }
+
+ // WHY: "bound only if still unbound" — an optional Method another component already gave
+ // behavior must be left untouched by adoption AND by the manager's shutdown, and the returned
+ // handle must not claim it.
+ @Test
+ void adoptionLeavesAlreadyBoundFindAliasVerboseUntouched() throws UaException {
+ NodeId categoryId = buildCategory("AdoptedForeignVerbose", true, true, false, false);
+ UaMethodNode verboseNode =
+ serverMethodNode(newNodeId("AdoptedForeignVerbose/FindAliasVerbose"));
+
+ MethodInvocationHandler foreignHandler = dummyHandler(verboseNode);
+ verboseNode.setInvocationHandler(foreignHandler);
+ try {
+ AliasManager manager = newStartedManager();
+ try {
+ AliasCategory category = manager.adoptCategory(categoryId);
+
+ assertFalse(category.findAliasVerboseEnabled());
+ assertSame(foreignHandler, verboseNode.getInvocationHandler());
+ } finally {
+ manager.shutdown();
+ }
+
+ // Shutdown unbinds only what adoption bound; the foreign handler survives.
+ assertSame(foreignHandler, verboseNode.getInvocationHandler());
+ } finally {
+ verboseNode.setInvocationHandler(MethodInvocationHandler.NOT_IMPLEMENTED);
+ }
+ }
+
+ // WHY: an adopted category with a LastChange Property participates in §6.3.1 version
+ // maintenance: the handle reports it and mutations through the manager publish a value.
+ @Test
+ void adoptedCategoryWithLastChangePropertyGetsVersionMaintenance() throws UaException {
+ NodeId categoryId = buildCategory("AdoptedVersioned", true, false, false, true);
+
+ AliasManager manager = newStartedManager();
+ try {
+ AliasCategory category = manager.adoptCategory(categoryId);
+ assertTrue(category.lastChangeEnabled());
+
+ assertNull(readLastChange(server, categoryId));
+
+ manager.addAlias(
+ categoryId, "AdoptedVersionedAlias", List.of(aliasFor(newNodeId("TestInt32"))));
+
+ requireLastChange(server, categoryId);
+ } finally {
+ manager.shutdown();
+ }
+ }
+
+ // WHY: the five documented adoption failure codes, each of which must reject the call without
+ // managing the category (adoptCategory validates before binding anything).
+
+ @Test
+ void adoptingAnAlreadyManagedCategoryFailsWithBadNodeIdExists() throws UaException {
+ NodeId categoryId = buildCategory("AdoptedTwice", true, false, false, false);
+
+ AliasManager manager = newStartedManager();
+ try {
+ manager.adoptCategory(categoryId);
+
+ UaException e = assertThrows(UaException.class, () -> manager.adoptCategory(categoryId));
+ assertEquals(StatusCode.of(StatusCodes.Bad_NodeIdExists), e.getStatusCode());
+ } finally {
+ manager.shutdown();
+ }
+ }
+
+ @Test
+ void adoptingAStandardCategoryFailsWithBadInvalidArgument() {
+ AliasManager manager = newStartedManager();
+ try {
+ UaException e =
+ assertThrows(UaException.class, () -> manager.adoptCategory(NodeIds.TagVariables));
+ assertEquals(StatusCode.of(StatusCodes.Bad_InvalidArgument), e.getStatusCode());
+ } finally {
+ manager.shutdown();
+ }
+ }
+
+ @Test
+ void adoptingANonexistentNodeFailsWithBadNodeIdUnknown() {
+ AliasManager manager = newStartedManager();
+ try {
+ UaException e =
+ assertThrows(UaException.class, () -> manager.adoptCategory(newNodeId("NoSuchCategory")));
+ assertEquals(StatusCode.of(StatusCodes.Bad_NodeIdUnknown), e.getStatusCode());
+ } finally {
+ manager.shutdown();
+ }
+ }
+
+ @Test
+ void adoptingANonCategoryNodeFailsWithBadNodeIdUnknown() {
+ AliasManager manager = newStartedManager();
+ try {
+ UaException e =
+ assertThrows(UaException.class, () -> manager.adoptCategory(newNodeId("TestInt32")));
+ assertEquals(StatusCode.of(StatusCodes.Bad_NodeIdUnknown), e.getStatusCode());
+ } finally {
+ manager.shutdown();
+ }
+ }
+
+ @Test
+ void adoptingACategoryWithoutFindAliasFailsWithBadNotFound() throws UaException {
+ NodeId categoryId = buildCategory("AdoptedNoFindAlias", false, false, false, false);
+
+ AliasManager manager = newStartedManager();
+ try {
+ UaException e = assertThrows(UaException.class, () -> manager.adoptCategory(categoryId));
+ assertEquals(StatusCode.of(StatusCodes.Bad_NotFound), e.getStatusCode());
+ } finally {
+ manager.shutdown();
+ }
+ }
+
+ @Test
+ void adoptingACategoryWhoseFindAliasIsAlreadyBoundFailsWithBadInvalidState() throws UaException {
+ NodeId categoryId = buildCategory("AdoptedConflicted", true, false, false, false);
+ UaMethodNode findAliasNode = serverMethodNode(newNodeId("AdoptedConflicted/FindAlias"));
+
+ findAliasNode.setInvocationHandler(dummyHandler(findAliasNode));
+ try {
+ AliasManager manager = newStartedManager();
+ try {
+ UaException e = assertThrows(UaException.class, () -> manager.adoptCategory(categoryId));
+ assertEquals(StatusCode.of(StatusCodes.Bad_InvalidState), e.getStatusCode());
+ } finally {
+ manager.shutdown();
+ }
+ } finally {
+ findAliasNode.setInvocationHandler(MethodInvocationHandler.NOT_IMPLEMENTED);
+ }
+ }
+
+ /**
+ * Build a NodeSet-style adoptable category in the test namespace: an Object typed {@code
+ * AliasNameCategoryType}, organized under the standard root {@code Aliases} Object, with the
+ * requested component Method Nodes (all carrying the default {@code NotImplementedHandler}) and
+ * optionally a {@code LastChange} Property.
+ */
+ private NodeId buildCategory(
+ String name,
+ boolean withFindAlias,
+ boolean withFindAliasVerbose,
+ boolean withAddMethod,
+ boolean withLastChange) {
+
+ NodeId categoryId = newNodeId(name);
+
+ var categoryNode =
+ new UaObjectNode(
+ testNodeContext,
+ categoryId,
+ newQualifiedName(name),
+ LocalizedText.english(name),
+ LocalizedText.NULL_VALUE,
+ uint(0),
+ uint(0));
+ testNodeManager.addNode(categoryNode);
+
+ categoryNode.addReference(
+ new Reference(
+ categoryId,
+ NodeIds.HasTypeDefinition,
+ NodeIds.AliasNameCategoryType.expanded(),
+ Reference.Direction.FORWARD));
+
+ categoryNode.addReference(
+ new Reference(
+ categoryId,
+ NodeIds.Organizes,
+ NodeIds.Aliases.expanded(),
+ Reference.Direction.INVERSE));
+
+ if (withFindAlias) {
+ buildComponentMethod(categoryId, name, "FindAlias");
+ }
+ if (withFindAliasVerbose) {
+ buildComponentMethod(categoryId, name, "FindAliasVerbose");
+ }
+ if (withAddMethod) {
+ buildComponentMethod(categoryId, name, "AddAliasesToCategory");
+ }
+
+ if (withLastChange) {
+ var propertyNode =
+ new UaVariableNode.UaVariableNodeBuilder(testNodeContext)
+ .setNodeId(newNodeId(name + "/LastChange"))
+ .setBrowseName(new QualifiedName(0, "LastChange"))
+ .setDisplayName(LocalizedText.english("LastChange"))
+ .setDataType(NodeIds.VersionTime)
+ .setTypeDefinition(NodeIds.PropertyType)
+ .build();
+ testNodeManager.addNode(propertyNode);
+
+ propertyNode.addReference(
+ new Reference(
+ propertyNode.getNodeId(),
+ NodeIds.HasProperty,
+ categoryId.expanded(),
+ Reference.Direction.INVERSE));
+ }
+
+ return categoryId;
+ }
+
+ /** Build an unbound Method Node named {@code methodName} as a component of the category. */
+ private void buildComponentMethod(NodeId categoryId, String categoryName, String methodName) {
+ UaMethodNode.build(
+ testNodeContext,
+ b -> {
+ b.setNodeId(newNodeId(categoryName + "/" + methodName));
+ b.setBrowseName(new QualifiedName(0, methodName));
+ b.setDisplayName(LocalizedText.english(methodName));
+
+ b.addReference(
+ new Reference(
+ b.getNodeId(),
+ NodeIds.HasComponent,
+ categoryId.expanded(),
+ Reference.Direction.INVERSE));
+
+ return b.buildAndAdd();
+ });
+ }
+
+ private AliasManager newStartedManager() {
+ AliasManager manager =
+ new AliasManager(
+ server,
+ AliasManagerConfig.builder()
+ .nodeNamespaceIndex(testNamespace.getNamespaceIndex())
+ .build());
+ manager.startup();
+ return manager;
+ }
+
+ private AliasTarget aliasFor(NodeId targetNodeId) {
+ return new AliasTarget(targetNodeId.expanded(), null, NodeIds.AliasFor);
+ }
+
+ /** A handler standing in for "some other component already bound this Method". */
+ private static AbstractMethodInvocationHandler dummyHandler(UaMethodNode methodNode) {
+ return new AbstractMethodInvocationHandler(methodNode) {
+ @Override
+ public Argument[] getInputArguments() {
+ return new Argument[0];
+ }
+
+ @Override
+ public Argument[] getOutputArguments() {
+ return new Argument[0];
+ }
+
+ @Override
+ protected Variant[] invoke(InvocationContext invocationContext, Variant[] inputValues) {
+ return new Variant[0];
+ }
+ };
+ }
+
+ private UaMethodNode serverMethodNode(NodeId nodeId) {
+ UaNode node = server.getAddressSpaceManager().getManagedNode(nodeId).orElseThrow();
+ return assertInstanceOf(UaMethodNode.class, node);
+ }
+
+ /**
+ * Call {@code FindAlias}/{@code FindAliasVerbose} on {@code objectId} through the client with
+ * pattern {@code "%"} and no ReferenceType filter.
+ */
+ private CallMethodResult callFind(NodeId objectId, NodeId methodId) throws UaException {
+ CallResponse response =
+ client.call(
+ List.of(
+ new CallMethodRequest(
+ objectId,
+ methodId,
+ new Variant[] {new Variant("%"), new Variant(NodeId.NULL_VALUE)})));
+
+ return requireNonNull(response.getResults())[0];
+ }
+}
diff --git a/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/aliases/AliasConfigMethodsTest.java b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/aliases/AliasConfigMethodsTest.java
new file mode 100644
index 0000000000..247c5d06cf
--- /dev/null
+++ b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/aliases/AliasConfigMethodsTest.java
@@ -0,0 +1,1309 @@
+/*
+ * Copyright (c) 2026 the Eclipse Milo Authors
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+
+package org.eclipse.milo.opcua.sdk.test.aliases;
+
+import static java.util.Objects.requireNonNull;
+import static org.eclipse.milo.opcua.sdk.test.aliases.AliasTestSupport.assertStrictlyGreater;
+import static org.eclipse.milo.opcua.sdk.test.aliases.AliasTestSupport.readAttribute;
+import static org.eclipse.milo.opcua.sdk.test.aliases.AliasTestSupport.readLastChange;
+import static org.eclipse.milo.opcua.sdk.test.aliases.AliasTestSupport.requireLastChange;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.eclipse.milo.opcua.sdk.core.Reference;
+import org.eclipse.milo.opcua.sdk.server.AddressSpaceComposite;
+import org.eclipse.milo.opcua.sdk.server.AddressSpaceFilter;
+import org.eclipse.milo.opcua.sdk.server.ManagedAddressSpaceFragmentWithLifecycle;
+import org.eclipse.milo.opcua.sdk.server.OpcUaServer;
+import org.eclipse.milo.opcua.sdk.server.Session;
+import org.eclipse.milo.opcua.sdk.server.SimpleAddressSpaceFilter;
+import org.eclipse.milo.opcua.sdk.server.UaNodeManager;
+import org.eclipse.milo.opcua.sdk.server.aliases.AliasAuthorizationPolicy;
+import org.eclipse.milo.opcua.sdk.server.aliases.AliasCategory;
+import org.eclipse.milo.opcua.sdk.server.aliases.AliasCategoryConfig;
+import org.eclipse.milo.opcua.sdk.server.aliases.AliasLimits;
+import org.eclipse.milo.opcua.sdk.server.aliases.AliasManager;
+import org.eclipse.milo.opcua.sdk.server.aliases.AliasManagerConfig;
+import org.eclipse.milo.opcua.sdk.server.aliases.AliasTarget;
+import org.eclipse.milo.opcua.sdk.server.aliases.AliasVersionStore;
+import org.eclipse.milo.opcua.sdk.server.items.DataItem;
+import org.eclipse.milo.opcua.sdk.server.items.MonitoredItem;
+import org.eclipse.milo.opcua.sdk.server.nodes.UaMethodNode;
+import org.eclipse.milo.opcua.sdk.server.nodes.UaNode;
+import org.eclipse.milo.opcua.sdk.test.AbstractClientServerTest;
+import org.eclipse.milo.opcua.stack.core.AttributeId;
+import org.eclipse.milo.opcua.stack.core.NamespaceTable;
+import org.eclipse.milo.opcua.stack.core.NodeIds;
+import org.eclipse.milo.opcua.stack.core.StatusCodes;
+import org.eclipse.milo.opcua.stack.core.UaException;
+import org.eclipse.milo.opcua.stack.core.types.builtin.DataValue;
+import org.eclipse.milo.opcua.stack.core.types.builtin.ExpandedNodeId;
+import org.eclipse.milo.opcua.stack.core.types.builtin.ExtensionObject;
+import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId;
+import org.eclipse.milo.opcua.stack.core.types.builtin.QualifiedName;
+import org.eclipse.milo.opcua.stack.core.types.builtin.StatusCode;
+import org.eclipse.milo.opcua.stack.core.types.builtin.Variant;
+import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UInteger;
+import org.eclipse.milo.opcua.stack.core.types.structured.AliasNameDataType;
+import org.eclipse.milo.opcua.stack.core.types.structured.CallMethodRequest;
+import org.eclipse.milo.opcua.stack.core.types.structured.CallMethodResult;
+import org.eclipse.milo.opcua.stack.core.types.structured.CallResponse;
+import org.jspecify.annotations.Nullable;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+
+/**
+ * Integration tests for {@link AliasManager}'s network mutation path: the {@code
+ * AddAliasesToCategory} and {@code DeleteAliasesFromCategory} Methods (Part 17 §6.3.4/§6.3.5)
+ * exercised over the wire with real client Calls — the two independent deny layers (Method
+ * materialization and authorization policy), the per-entry StatusCode contract, call-level
+ * validation, and the per-category configuration flag.
+ *
+ * An {@link AliasManager} is one-shot and only one at a time may manage the standard alias
+ * Objects, so each test group starts its own manager (per test, or per nested class through its own
+ * lifecycle methods) and shuts it down when done. Alias Nodes the wire path creates in standard
+ * categories are hosted in the manager's AddressSpace fragment and leave the AddressSpace with it,
+ * so groups do not observe each other's aliases.
+ */
+// Fields here and in the nested classes are assigned in @BeforeAll/@BeforeEach, which the
+// nullability inspection does not model.
+@SuppressWarnings("NotNullFieldNotInitialized")
+class AliasConfigMethodsTest extends AbstractClientServerTest {
+
+ /**
+ * The operations-per-call limit configured by {@link #grantedConfig}; small so oversized-array
+ * behavior is testable with a handful of entries.
+ */
+ private static final int MAX_OPERATIONS_PER_CALL = 4;
+
+ /** Grants find and mutate to every session; the opposite of the deny-by-default default. */
+ private static final AliasAuthorizationPolicy ALLOW_FIND_AND_MUTATE =
+ new AliasAuthorizationPolicy() {
+ @Override
+ public boolean checkFind(@Nullable Session session, NodeId categoryId) {
+ return true;
+ }
+
+ @Override
+ public boolean checkMutate(@Nullable Session session, NodeId categoryId) {
+ return true;
+ }
+ };
+
+ private UaNodeManager testNodeManager;
+
+ @BeforeAll
+ void captureTestNodeManager() {
+ testNamespace.configure((context, nodeManager) -> testNodeManager = nodeManager);
+ }
+
+ @Nested
+ class ConfigurationDisabled {
+
+ // WHY: the first deny layer of the two-layer mutation posture — Part 17 §6.3.1 marks the
+ // mutation Methods Optional, and the manager only materializes them when configuration is
+ // enabled. With the default (disabled) config the Nodes must be entirely absent: a Read fails
+ // with Bad_NodeIdUnknown and a Call fails Method resolution with Bad_MethodInvalid instead of
+ // reaching any handler.
+ @Test
+ void mutationMethodNodesAreAbsentWithDefaultConfig() throws UaException {
+ AliasManager manager =
+ new AliasManager(
+ server,
+ AliasManagerConfig.builder()
+ .nodeNamespaceIndex(testNamespace.getNamespaceIndex())
+ .build());
+ manager.startup();
+ try {
+ for (NodeId methodNodeId : List.of(aliasesAddMethodId(), aliasesDeleteMethodId())) {
+ DataValue value = readAttribute(client, methodNodeId, AttributeId.BrowseName);
+ assertEquals(StatusCode.of(StatusCodes.Bad_NodeIdUnknown), value.getStatusCode());
+ }
+
+ CallMethodResult result =
+ callAddAliases(
+ NodeIds.Aliases,
+ aliasesAddMethodId(),
+ new String[] {"AbsentMethodAlias"},
+ new ExpandedNodeId[] {newNodeId("TestInt32").expanded()},
+ new String[0],
+ NodeId.NULL_VALUE);
+
+ assertEquals(StatusCode.of(StatusCodes.Bad_MethodInvalid), result.getStatusCode());
+ } finally {
+ manager.shutdown();
+ }
+ }
+ }
+
+ @Nested
+ class MutationDeniedByDefaultPolicy {
+
+ // WHY: the second deny layer is independent of materialization — with the Methods
+ // materialized (configurationEnabled true) but the DEFAULT authorization policy in place,
+ // every session is denied mutation: the Call fails with Bad_UserAccessDenied (the code Part
+ // 17 §6.3.4/§6.3.5 name for a caller without rights) and the category is unchanged.
+ @Test
+ void addAndDeleteCallsAreDeniedWithTheDefaultPolicy() throws UaException {
+ AliasManager manager =
+ new AliasManager(
+ server,
+ AliasManagerConfig.builder()
+ .configurationEnabled(true)
+ .nodeNamespaceIndex(testNamespace.getNamespaceIndex())
+ .build());
+ manager.startup();
+ try {
+ CallMethodResult addResult =
+ callAddAliases(
+ NodeIds.Aliases,
+ aliasesAddMethodId(),
+ new String[] {"DeniedWireAlias"},
+ new ExpandedNodeId[] {newNodeId("TestInt32").expanded()},
+ new String[0],
+ NodeId.NULL_VALUE);
+
+ assertEquals(StatusCode.of(StatusCodes.Bad_UserAccessDenied), addResult.getStatusCode());
+ assertTrue(manager.findAlias(NodeIds.Aliases, "DeniedWireAlias", null).isEmpty());
+
+ CallMethodResult deleteResult =
+ callDeleteAliases(
+ NodeIds.Aliases, aliasesDeleteMethodId(), new String[] {"DeniedWireAlias"}, null);
+
+ assertEquals(StatusCode.of(StatusCodes.Bad_UserAccessDenied), deleteResult.getStatusCode());
+ } finally {
+ manager.shutdown();
+ }
+ }
+ }
+
+ @Nested
+ @TestInstance(TestInstance.Lifecycle.PER_CLASS)
+ class VersionPersistenceFailure {
+
+ /**
+ * An in-memory store whose saves can be switched to fail — all of them, or all after the next N
+ * successes — simulating persistence that breaks after a successful startup or partway through
+ * a multi-category operation.
+ */
+ final class TogglableStore implements AliasVersionStore {
+
+ private final Map entries = new HashMap<>();
+
+ volatile boolean failSaves = false;
+
+ /** When non-negative: this many further saves succeed, then every save fails. */
+ volatile int failAfterSaves = -1;
+
+ @Override
+ public Map load() {
+ return Map.copyOf(entries);
+ }
+
+ @Override
+ public void save(ExpandedNodeId categoryId, UInteger value) throws UaException {
+ if (failSaves || failAfterSaves == 0) {
+ throw new UaException(StatusCodes.Bad_ResourceUnavailable, "save failed");
+ }
+ if (failAfterSaves > 0) {
+ failAfterSaves--;
+ }
+ entries.put(categoryId, value);
+ }
+ }
+
+ private TogglableStore store;
+ private AliasManager aliasManager;
+
+ @BeforeAll
+ void startManager() {
+ store = new TogglableStore();
+ aliasManager = new AliasManager(server, grantedConfig(store));
+ aliasManager.startup();
+ }
+
+ @AfterAll
+ void shutdownManager() {
+ aliasManager.shutdown();
+ }
+
+ // WHY: save-before-mutate — §6.3.1's persisted-LastChange contract only holds if no
+ // observable version was ever unpersisted, so an entry whose LastChange save fails must fail
+ // with Bad_InternalError BEFORE creating anything: were the alias created anyway, a restart
+ // could re-produce an already-observed LastChange value for different content and Client
+ // caches would go undetectably stale.
+ @Test
+ void wireEntryWhoseVersionSaveFailsIsRejectedWithoutCreatingTheAlias() throws UaException {
+ UInteger lastChangeBefore = requireLastChange(server, NodeIds.Aliases);
+
+ store.failSaves = true;
+ try {
+ CallMethodResult result =
+ callAddAliases(
+ NodeIds.Aliases,
+ aliasesAddMethodId(),
+ new String[] {"UnpersistableWireAlias"},
+ new ExpandedNodeId[] {newNodeId("TestInt32").expanded()},
+ new String[0],
+ NodeId.NULL_VALUE);
+
+ assertEquals(StatusCode.GOOD, result.getStatusCode());
+ assertArrayEquals(
+ new StatusCode[] {StatusCode.of(StatusCodes.Bad_InternalError)}, errorCodes(result));
+ } finally {
+ store.failSaves = false;
+ }
+
+ assertTrue(aliasManager.findAlias(NodeIds.Aliases, "UnpersistableWireAlias", null).isEmpty());
+ assertEquals(lastChangeBefore, requireLastChange(server, NodeIds.Aliases));
+ }
+
+ // WHY: the same guarantee on the programmatic path — addAlias must throw Bad_InternalError
+ // with nothing applied when the LastChange save fails, and the identical call must succeed
+ // once persistence recovers, proving the failed attempt left no residue behind.
+ @Test
+ void programmaticAddAliasFailsCleanlyWhenVersionSaveFailsAndRecovers() throws UaException {
+ UInteger lastChangeBefore = requireLastChange(server, NodeIds.Aliases);
+ var target = new AliasTarget(newNodeId("TestInt32").expanded(), null, NodeIds.AliasFor);
+
+ store.failSaves = true;
+ try {
+ UaException e =
+ assertThrows(
+ UaException.class,
+ () ->
+ aliasManager.addAlias(NodeIds.Aliases, "UnpersistableAlias", List.of(target)));
+
+ assertEquals(StatusCode.of(StatusCodes.Bad_InternalError), e.getStatusCode());
+ } finally {
+ store.failSaves = false;
+ }
+
+ assertTrue(aliasManager.findAlias(NodeIds.Aliases, "UnpersistableAlias", null).isEmpty());
+ assertEquals(lastChangeBefore, requireLastChange(server, NodeIds.Aliases));
+
+ aliasManager.addAlias(NodeIds.Aliases, "UnpersistableAlias", List.of(target));
+ assertEquals(1, aliasManager.findAlias(NodeIds.Aliases, "UnpersistableAlias", null).size());
+ }
+
+ // WHY: the prepare/publish pairing invariant — a removeCategory whose ancestor-version save
+ // fails partway must abort with nothing removed, still publish the values it persisted
+ // before the failure (a bump without a content change is the safe direction), and leave
+ // nothing stranded in the pending set: the next mutation must compute a fresh version, not
+ // republish a stale prepared one.
+ @Test
+ void removeCategoryAncestorSaveFailurePartwayAbortsAndDrainsPending() throws UaException {
+ AliasCategory parent =
+ aliasManager.addCategory(categoryConfig("PendingDrainParent", NodeIds.Aliases));
+ AliasCategory child =
+ aliasManager.addCategory(categoryConfig("PendingDrainChild", parent.nodeId()));
+
+ UInteger parentBefore = requireLastChange(server, parent.nodeId());
+
+ // Removing the child prepares its ancestors in discovery order [parent, root Aliases]:
+ // let the parent's save through and fail the root's.
+ store.failAfterSaves = 1;
+ UaException e;
+ try {
+ e = assertThrows(UaException.class, () -> aliasManager.removeCategory(child.nodeId()));
+ } finally {
+ store.failAfterSaves = -1;
+ }
+ assertEquals(StatusCode.of(StatusCodes.Bad_InternalError), e.getStatusCode());
+
+ // Nothing was removed: the category Node still exists and is still managed (the retry
+ // during cleanup below succeeds).
+ assertTrue(server.getAddressSpaceManager().getManagedNode(child.nodeId()).isPresent());
+
+ // The parent's persisted-before-the-failure value was still published...
+ UInteger parentAfterFailure = requireLastChange(server, parent.nodeId());
+ assertStrictlyGreater(parentBefore, parentAfterFailure);
+
+ // ...and drained from the pending set: the next mutation computes a fresh version.
+ aliasManager.addAlias(
+ parent.nodeId(),
+ "PendingDrainAlias",
+ List.of(new AliasTarget(newNodeId("TestInt32").expanded(), null, NodeIds.AliasFor)));
+ assertStrictlyGreater(parentAfterFailure, readLastChange(server, parent.nodeId()));
+
+ aliasManager.deleteAlias(parent.nodeId(), "PendingDrainAlias", null);
+ aliasManager.removeCategory(child.nodeId());
+ aliasManager.removeCategory(parent.nodeId());
+ }
+
+ private AliasCategoryConfig categoryConfig(String name, NodeId parentCategoryId) {
+ return new AliasCategoryConfig(
+ newNodeId(name),
+ parentCategoryId,
+ newQualifiedName(name),
+ testNodeManager,
+ aliasName -> newNodeId(name + "/" + aliasName),
+ true,
+ false,
+ false);
+ }
+ }
+
+ @Nested
+ @TestInstance(TestInstance.Lifecycle.PER_CLASS)
+ class AddAliasesToCategoryCalls {
+
+ private RecordingVersionStore versionStore;
+ private AliasManager aliasManager;
+
+ @BeforeAll
+ void startManager() {
+ versionStore = new RecordingVersionStore(server);
+ aliasManager = new AliasManager(server, grantedConfig(versionStore));
+ aliasManager.startup();
+ }
+
+ @AfterAll
+ void shutdownManager() {
+ aliasManager.shutdown();
+ }
+
+ // WHY: Part 17 §6.3.4 — AddAliasesToCategory reports one StatusCode per entry, parallel to
+ // the inputs, and the created aliases must be visible to FindAlias on the same category.
+ // §6.3.1 requires the change to surface through LastChange; the manager's contract is a
+ // single version bump per call — persisted exactly once — no matter how many entries changed
+ // the category.
+ @Test
+ void addCallCreatesAliasesAndBumpsLastChangeOncePerCall() throws UaException {
+ UInteger lastChangeBefore = requireLastChange(server, NodeIds.Aliases);
+ long savesBefore = versionStore.savesFor(NodeIds.Aliases);
+
+ CallMethodResult result =
+ callAddAliases(
+ NodeIds.Aliases,
+ aliasesAddMethodId(),
+ new String[] {"WireAddedA", "WireAddedB"},
+ new ExpandedNodeId[] {
+ newNodeId("TestInt32").expanded(), newNodeId("TestAnalogValue").expanded()
+ },
+ new String[0],
+ NodeId.NULL_VALUE);
+
+ assertEquals(StatusCode.GOOD, result.getStatusCode());
+ assertArrayEquals(new StatusCode[] {StatusCode.GOOD, StatusCode.GOOD}, errorCodes(result));
+
+ List foundA = findRootAliases("WireAddedA");
+ assertEquals(1, foundA.size());
+ assertArrayEquals(
+ new ExpandedNodeId[] {newNodeId("TestInt32").expanded()},
+ foundA.get(0).getReferencedNodes());
+
+ List foundB = findRootAliases("WireAddedB");
+ assertEquals(1, foundB.size());
+ assertArrayEquals(
+ new ExpandedNodeId[] {newNodeId("TestAnalogValue").expanded()},
+ foundB.get(0).getReferencedNodes());
+
+ assertStrictlyGreater(lastChangeBefore, readLastChange(server, NodeIds.Aliases));
+ assertEquals(savesBefore + 1, versionStore.savesFor(NodeIds.Aliases));
+ }
+
+ // WHY: Part 17 §6.3.4 — results are per entry and a failed entry must not affect any other
+ // entry: an unknown local TargetNode fails its own entry with Bad_NodeIdUnknown while the
+ // sibling entry in the SAME call still creates its alias.
+ @Test
+ void failedEntryDoesNotAffectSiblingEntryInSameCall() throws UaException {
+ CallMethodResult result =
+ callAddAliases(
+ NodeIds.Aliases,
+ aliasesAddMethodId(),
+ new String[] {"MissingTargetAlias", "IsolatedGoodAlias"},
+ new ExpandedNodeId[] {
+ newNodeId("NoSuchTargetNode").expanded(), newNodeId("TestInt32").expanded()
+ },
+ new String[0],
+ NodeId.NULL_VALUE);
+
+ assertEquals(StatusCode.GOOD, result.getStatusCode());
+ assertArrayEquals(
+ new StatusCode[] {StatusCode.of(StatusCodes.Bad_NodeIdUnknown), StatusCode.GOOD},
+ errorCodes(result));
+
+ assertTrue(aliasManager.findAlias(NodeIds.Aliases, "MissingTargetAlias", null).isEmpty());
+ assertEquals(1, aliasManager.findAlias(NodeIds.Aliases, "IsolatedGoodAlias", null).size());
+ }
+
+ // WHY: Part 17 §6.3.4 — an entry duplicating an existing alias/target association "shall be
+ // ignored and no error shall be generated": the repeat call reports Good, adds no Reference,
+ // and neither bumps nor persists LastChange because nothing changed.
+ @Test
+ void duplicateOfExistingAssociationIsIgnoredWithGood() throws UaException {
+ addRootAliasOverWire("DupWireAlias", newNodeId("TestInt32"));
+
+ UInteger lastChangeBefore = requireLastChange(server, NodeIds.Aliases);
+ long savesBefore = versionStore.savesFor(NodeIds.Aliases);
+
+ CallMethodResult result =
+ callAddAliases(
+ NodeIds.Aliases,
+ aliasesAddMethodId(),
+ new String[] {"DupWireAlias"},
+ new ExpandedNodeId[] {newNodeId("TestInt32").expanded()},
+ new String[0],
+ NodeId.NULL_VALUE);
+
+ assertEquals(StatusCode.GOOD, result.getStatusCode());
+ assertArrayEquals(new StatusCode[] {StatusCode.GOOD}, errorCodes(result));
+
+ List found = findRootAliases("DupWireAlias");
+ assertEquals(1, found.size());
+ assertArrayEquals(
+ new ExpandedNodeId[] {newNodeId("TestInt32").expanded()},
+ found.get(0).getReferencedNodes());
+
+ assertEquals(lastChangeBefore, readLastChange(server, NodeIds.Aliases));
+ assertEquals(savesBefore, versionStore.savesFor(NodeIds.Aliases));
+ }
+
+ // WHY: Part 17 §6.3.4 — idempotency extends to duplicates within one request: an entry that
+ // repeats an earlier entry of the same call must be ignored with Good, leaving exactly one
+ // AliasFor association behind.
+ @Test
+ void repeatedEntryWithinOneCallIsIgnoredWithGood() throws UaException {
+ CallMethodResult result =
+ callAddAliases(
+ NodeIds.Aliases,
+ aliasesAddMethodId(),
+ new String[] {"IntraDupAlias", "IntraDupAlias"},
+ new ExpandedNodeId[] {
+ newNodeId("TestInt32").expanded(), newNodeId("TestInt32").expanded()
+ },
+ new String[0],
+ NodeId.NULL_VALUE);
+
+ assertEquals(StatusCode.GOOD, result.getStatusCode());
+ assertArrayEquals(new StatusCode[] {StatusCode.GOOD, StatusCode.GOOD}, errorCodes(result));
+
+ List found = findRootAliases("IntraDupAlias");
+ assertEquals(1, found.size());
+ assertArrayEquals(
+ new ExpandedNodeId[] {newNodeId("TestInt32").expanded()},
+ found.get(0).getReferencedNodes());
+ }
+
+ // WHY: Part 17 §6.3.4 defines Bad_NotSupported for a server that does not support aliases
+ // with targets on remote Servers, which this manager does not; a non-empty TargetServers
+ // entry marks its target remote, so the entry fails and nothing is created.
+ // (Uncertain_ReferenceOutOfServer is reserved for servers that ACCEPT a remote target they
+ // cannot verify.)
+ @Test
+ void remoteTargetEntryIsRejectedWithBadNotSupported() throws UaException {
+ CallMethodResult result =
+ callAddAliases(
+ NodeIds.Aliases,
+ aliasesAddMethodId(),
+ new String[] {"RemoteTargetWireAlias"},
+ new ExpandedNodeId[] {newNodeId("TestInt32").expanded()},
+ new String[] {"urn:test:remote-server"},
+ NodeId.NULL_VALUE);
+
+ assertEquals(StatusCode.GOOD, result.getStatusCode());
+ assertArrayEquals(
+ new StatusCode[] {StatusCode.of(StatusCodes.Bad_NotSupported)}, errorCodes(result));
+
+ assertTrue(aliasManager.findAlias(NodeIds.Aliases, "RemoteTargetWireAlias", null).isEmpty());
+ }
+
+ // WHY: Part 17 §6.3.4 — "The ServerIndex in the ExpandedNodeId shall be ignored and the
+ // TargetServers Uri shall be used": with a null/empty TargetServers entry the target is
+ // local, so a wire ExpandedNodeId carrying serverIndex=1 must still resolve to the local
+ // Node instead of the ServerIndex being treated as a remote-target signal.
+ @Test
+ void serverIndexInTargetNodeIsIgnoredWhenTargetServersEntryIsEmpty() throws UaException {
+ NodeId localTarget = newNodeId("TestInt32");
+ var targetWithServerIndex =
+ new ExpandedNodeId(
+ ExpandedNodeId.ServerReference.of(1),
+ ExpandedNodeId.NamespaceReference.of(localTarget.getNamespaceIndex()),
+ localTarget.getIdentifier());
+
+ CallMethodResult result =
+ callAddAliases(
+ NodeIds.Aliases,
+ aliasesAddMethodId(),
+ new String[] {"ServerIndexIgnoredAlias"},
+ new ExpandedNodeId[] {targetWithServerIndex},
+ new String[0],
+ NodeId.NULL_VALUE);
+
+ assertEquals(StatusCode.GOOD, result.getStatusCode());
+ assertArrayEquals(new StatusCode[] {StatusCode.GOOD}, errorCodes(result));
+
+ List found = findRootAliases("ServerIndexIgnoredAlias");
+ assertEquals(1, found.size());
+ assertArrayEquals(
+ new ExpandedNodeId[] {localTarget.expanded()}, found.get(0).getReferencedNodes());
+ }
+
+ // WHY: Part 17 §9.3 — aliases organized under TagVariables must target Variables; a Method
+ // Node target violates the NodeClass constraint, and the violation is an entry-level failure
+ // (Bad_InvalidArgument), not a call-level one.
+ @Test
+ void tagVariablesEntryTargetingNonVariableFailsWithBadInvalidArgument() throws UaException {
+ CallMethodResult result =
+ callAddAliases(
+ NodeIds.TagVariables,
+ tagVariablesAddMethodId(),
+ new String[] {"MethodTagWireAlias"},
+ new ExpandedNodeId[] {newNodeId("sqrt(x)").expanded()},
+ new String[0],
+ NodeId.NULL_VALUE);
+
+ assertEquals(StatusCode.GOOD, result.getStatusCode());
+ assertArrayEquals(
+ new StatusCode[] {StatusCode.of(StatusCodes.Bad_InvalidArgument)}, errorCodes(result));
+
+ assertTrue(
+ aliasManager.findAlias(NodeIds.TagVariables, "MethodTagWireAlias", null).isEmpty());
+ }
+ }
+
+ @Nested
+ @TestInstance(TestInstance.Lifecycle.PER_CLASS)
+ class AddCallLevelValidation {
+
+ private AliasManager aliasManager;
+
+ @BeforeAll
+ void startManager() {
+ aliasManager = new AliasManager(server, grantedConfig(new RecordingVersionStore(server)));
+ aliasManager.startup();
+ }
+
+ @AfterAll
+ void shutdownManager() {
+ aliasManager.shutdown();
+ }
+
+ // WHY: Part 17 §6.3.4 requires AliasNames and TargetNodes to be parallel arrays; a length
+ // mismatch invalidates the whole call with Bad_InvalidArgument, and no entry may be applied.
+ @Test
+ void aliasNamesAndTargetNodesOfDifferentLengthFailTheCall() throws UaException {
+ CallMethodResult result =
+ callAddAliases(
+ NodeIds.Aliases,
+ aliasesAddMethodId(),
+ new String[] {"MismatchAliasA", "MismatchAliasB"},
+ new ExpandedNodeId[] {newNodeId("TestInt32").expanded()},
+ new String[0],
+ NodeId.NULL_VALUE);
+
+ assertEquals(StatusCode.of(StatusCodes.Bad_InvalidArgument), result.getStatusCode());
+ assertTrue(aliasManager.findAlias(NodeIds.Aliases, "MismatchAliasA", null).isEmpty());
+ }
+
+ // WHY: Part 17 §6.3.4 Table 11 defines Bad_InvalidArgument for a call where "the size of
+ // the arrays for all arguments except TargetServers is not the same or if all arrays are
+ // empty" — a zero-entry Add call is a call-level failure, not a vacuous success.
+ @Test
+ void addWithAllArraysEmptyFailsTheCallWithBadInvalidArgument() throws UaException {
+ CallMethodResult result =
+ callAddAliases(
+ NodeIds.Aliases,
+ aliasesAddMethodId(),
+ new String[0],
+ new ExpandedNodeId[0],
+ new String[0],
+ NodeId.NULL_VALUE);
+
+ assertEquals(StatusCode.of(StatusCodes.Bad_InvalidArgument), result.getStatusCode());
+ }
+
+ // WHY: Part 17 §6.3.4 — a null or empty TargetServers array means all targets are local, but
+ // a NON-empty one must be parallel to the other arrays; any other length invalidates the
+ // whole call with Bad_InvalidArgument.
+ @Test
+ void nonEmptyTargetServersOfDifferentLengthFailTheCall() throws UaException {
+ CallMethodResult result =
+ callAddAliases(
+ NodeIds.Aliases,
+ aliasesAddMethodId(),
+ new String[] {"ServerMismatchAliasA", "ServerMismatchAliasB"},
+ new ExpandedNodeId[] {
+ newNodeId("TestInt32").expanded(), newNodeId("TestAnalogValue").expanded()
+ },
+ new String[] {"urn:test:remote-server"},
+ NodeId.NULL_VALUE);
+
+ assertEquals(StatusCode.of(StatusCodes.Bad_InvalidArgument), result.getStatusCode());
+ assertTrue(aliasManager.findAlias(NodeIds.Aliases, "ServerMismatchAliasA", null).isEmpty());
+ }
+
+ // WHY: Part 17 §6.3.4 defaults a null TargetReferenceType to AliasFor, and §6.3.1 requires
+ // every alias to reference its targets with AliasFor or a subtype; the parameter applies to
+ // the whole call, so a ReferenceType outside the hierarchy (Organizes) fails the call with
+ // Bad_InvalidArgument rather than any individual entry.
+ @Test
+ void targetReferenceTypeOutsideAliasForHierarchyFailsTheCall() throws UaException {
+ CallMethodResult result =
+ callAddAliases(
+ NodeIds.Aliases,
+ aliasesAddMethodId(),
+ new String[] {"BadRefTypeAlias"},
+ new ExpandedNodeId[] {newNodeId("TestInt32").expanded()},
+ new String[0],
+ NodeIds.Organizes);
+
+ assertEquals(StatusCode.of(StatusCodes.Bad_InvalidArgument), result.getStatusCode());
+ assertTrue(aliasManager.findAlias(NodeIds.Aliases, "BadRefTypeAlias", null).isEmpty());
+ }
+
+ // WHY: Part 17 §6.3.4 names no code for oversized arrays, but Part 4 §7.38.2 defines
+ // Bad_TooManyOperations as "the request specified too many operations" — exactly the
+ // condition when the entry count exceeds the configured maxOperationsPerCall — so the whole
+ // call fails with it before any entry is processed.
+ @Test
+ void addWithMoreEntriesThanMaxOperationsPerCallFailsTheCall() throws UaException {
+ int entryCount = MAX_OPERATIONS_PER_CALL + 1;
+ var aliasNames = new String[entryCount];
+ var targetNodes = new ExpandedNodeId[entryCount];
+ for (int i = 0; i < entryCount; i++) {
+ aliasNames[i] = "OversizedAddAlias" + i;
+ targetNodes[i] = newNodeId("TestInt32").expanded();
+ }
+
+ CallMethodResult result =
+ callAddAliases(
+ NodeIds.Aliases,
+ aliasesAddMethodId(),
+ aliasNames,
+ targetNodes,
+ new String[0],
+ NodeId.NULL_VALUE);
+
+ assertEquals(StatusCode.of(StatusCodes.Bad_TooManyOperations), result.getStatusCode());
+ assertTrue(aliasManager.findAlias(NodeIds.Aliases, "OversizedAddAlias0", null).isEmpty());
+ }
+
+ // WHY: the same Part 4 §7.38.2 operations limit applies to DeleteAliasesFromCategory; an
+ // oversized AliasNames array fails the whole call with Bad_TooManyOperations before any
+ // entry is evaluated.
+ @Test
+ void deleteWithMoreEntriesThanMaxOperationsPerCallFailsTheCall() throws UaException {
+ int entryCount = MAX_OPERATIONS_PER_CALL + 1;
+ var aliasNames = new String[entryCount];
+ for (int i = 0; i < entryCount; i++) {
+ aliasNames[i] = "OversizedDeleteAlias" + i;
+ }
+
+ CallMethodResult result =
+ callDeleteAliases(NodeIds.Aliases, aliasesDeleteMethodId(), aliasNames, null);
+
+ assertEquals(StatusCode.of(StatusCodes.Bad_TooManyOperations), result.getStatusCode());
+ }
+ }
+
+ @Nested
+ @TestInstance(TestInstance.Lifecycle.PER_CLASS)
+ class DeleteAliasesFromCategoryCalls {
+
+ private RecordingVersionStore versionStore;
+ private AliasManager aliasManager;
+
+ @BeforeAll
+ void startManager() {
+ versionStore = new RecordingVersionStore(server);
+ aliasManager = new AliasManager(server, grantedConfig(versionStore));
+ aliasManager.startup();
+ }
+
+ @AfterAll
+ void shutdownManager() {
+ aliasManager.shutdown();
+ }
+
+ // WHY: Part 17 §6.3.5 — TargetNodes entries "further restrict what is deleted": deleting one
+ // explicit target removes only that association, and the alias survives with its remaining
+ // target still visible to FindAlias.
+ @Test
+ void explicitTargetEntryRemovesOnlyThatAssociation() throws UaException {
+ addRootAliasOverWire(
+ "PartialWireAlias", newNodeId("TestInt32"), newNodeId("TestAnalogValue"));
+
+ CallMethodResult result =
+ callDeleteAliases(
+ NodeIds.Aliases,
+ aliasesDeleteMethodId(),
+ new String[] {"PartialWireAlias"},
+ new ExpandedNodeId[] {newNodeId("TestAnalogValue").expanded()});
+
+ assertEquals(StatusCode.GOOD, result.getStatusCode());
+ assertArrayEquals(new StatusCode[] {StatusCode.GOOD}, errorCodes(result));
+
+ List found = findRootAliases("PartialWireAlias");
+ assertEquals(1, found.size());
+ assertArrayEquals(
+ new ExpandedNodeId[] {newNodeId("TestInt32").expanded()},
+ found.get(0).getReferencedNodes());
+ }
+
+ // WHY: Part 17 §6.3.1 — every alias shall have at least one AliasFor Reference, so removing
+ // the last target over the wire must delete the alias Object from EVERY organizing category,
+ // not just the one the Method was called on, and §6.3.1's LastChange contract requires every
+ // one of those categories to be bumped.
+ @Test
+ void removingLastTargetDeletesAliasFromEveryOrganizingCategory() throws UaException {
+ addRootAliasOverWire("SharedWireAlias", newNodeId("TestInt32"));
+
+ AliasCategory catB =
+ aliasManager.addCategory(
+ new AliasCategoryConfig(
+ newNodeId("DeleteWireCatB"),
+ NodeIds.Aliases,
+ newQualifiedName("DeleteWireCatB"),
+ testNodeManager,
+ name -> newNodeId("DeleteWireCatB/" + name),
+ true,
+ false,
+ false));
+ try {
+ // Organize the alias by a second category, the multi-parent arrangement §6.3.1 allows.
+ NodeId aliasNodeId =
+ requireNonNull(
+ findOrganizedAliasNodeId(NodeIds.Aliases, "SharedWireAlias"),
+ "alias Node not found under Aliases");
+ UaNode aliasNode =
+ server.getAddressSpaceManager().getManagedNode(aliasNodeId).orElseThrow();
+ aliasNode.addReference(
+ new Reference(
+ aliasNodeId,
+ NodeIds.Organizes,
+ catB.nodeId().expanded(),
+ Reference.Direction.INVERSE));
+
+ UInteger rootBefore = requireLastChange(server, NodeIds.Aliases);
+ UInteger catBBefore = requireLastChange(server, catB.nodeId());
+
+ CallMethodResult result =
+ callDeleteAliases(
+ NodeIds.Aliases,
+ aliasesDeleteMethodId(),
+ new String[] {"SharedWireAlias"},
+ new ExpandedNodeId[] {newNodeId("TestInt32").expanded()});
+
+ assertEquals(StatusCode.GOOD, result.getStatusCode());
+ assertArrayEquals(new StatusCode[] {StatusCode.GOOD}, errorCodes(result));
+
+ assertTrue(server.getAddressSpaceManager().getManagedNode(aliasNodeId).isEmpty());
+ assertTrue(aliasManager.findAlias(catB.nodeId(), "SharedWireAlias", null).isEmpty());
+
+ assertStrictlyGreater(catBBefore, readLastChange(server, catB.nodeId()));
+ assertStrictlyGreater(rootBefore, readLastChange(server, NodeIds.Aliases));
+ } finally {
+ aliasManager.removeCategory(catB.nodeId());
+ }
+ }
+
+ // WHY: Part 17 §6.3.5 defines Bad_NotFound as "The AliasName was not located": a name the
+ // category does not contain fails its own entry, and since nothing changed, LastChange is
+ // neither bumped nor persisted.
+ @Test
+ void unknownAliasNameEntryFailsWithBadNotFound() throws UaException {
+ long savesBefore = versionStore.savesFor(NodeIds.Aliases);
+
+ CallMethodResult result =
+ callDeleteAliases(
+ NodeIds.Aliases, aliasesDeleteMethodId(), new String[] {"NoSuchWireAlias"}, null);
+
+ assertEquals(StatusCode.GOOD, result.getStatusCode());
+ assertArrayEquals(
+ new StatusCode[] {StatusCode.of(StatusCodes.Bad_NotFound)}, errorCodes(result));
+ assertEquals(savesBefore, versionStore.savesFor(NodeIds.Aliases));
+ }
+
+ // WHY: Part 17 §6.3.5 — each entry succeeds or fails on its own, so a Bad_NotFound entry
+ // must not prevent the sibling entry in the SAME call from deleting its alias. A null
+ // TargetNodes ARRAY is accepted as "no restriction for any entry" — §6.3.5 only defines the
+ // meaning of null entries, and the reading mirrors §6.3.4's null-or-empty TargetServers.
+ @Test
+ void failedEntryDoesNotAffectSiblingEntryInSameDeleteCall() throws UaException {
+ addRootAliasOverWire("DeleteSurvivorAlias", newNodeId("TestInt32"));
+
+ CallMethodResult result =
+ callDeleteAliases(
+ NodeIds.Aliases,
+ aliasesDeleteMethodId(),
+ new String[] {"NoSuchWireAlias", "DeleteSurvivorAlias"},
+ null);
+
+ assertEquals(StatusCode.GOOD, result.getStatusCode());
+ assertArrayEquals(
+ new StatusCode[] {StatusCode.of(StatusCodes.Bad_NotFound), StatusCode.GOOD},
+ errorCodes(result));
+
+ assertTrue(aliasManager.findAlias(NodeIds.Aliases, "DeleteSurvivorAlias", null).isEmpty());
+ }
+ }
+
+ @Nested
+ @TestInstance(TestInstance.Lifecycle.PER_CLASS)
+ class ApplicationCategories {
+
+ private AliasManager aliasManager;
+
+ @BeforeAll
+ void startManager() {
+ aliasManager = new AliasManager(server, grantedConfig(new RecordingVersionStore(server)));
+ aliasManager.startup();
+ }
+
+ @AfterAll
+ void shutdownManager() {
+ aliasManager.shutdown();
+ }
+
+ // WHY: AliasCategoryConfig.configurationEnabled materializes and binds the category's own
+ // AddAliasesToCategory/DeleteAliasesFromCategory instances (Optional members per Part 17
+ // §6.3.1), so a client granted mutation can manage the category's aliases entirely over the
+ // wire, with the §6.3.4/§6.3.5 per-entry contract.
+ @Test
+ void categoryWithConfigurationEnabledGetsCallableMutationMethods() throws UaException {
+ AliasCategory category =
+ aliasManager.addCategory(
+ new AliasCategoryConfig(
+ newNodeId("ConfiguredWireCategory"),
+ NodeIds.Aliases,
+ newQualifiedName("ConfiguredWireCategory"),
+ testNodeManager,
+ name -> newNodeId("ConfiguredWireCategory/" + name),
+ false,
+ false,
+ true));
+ try {
+ assertTrue(category.configurationEnabled());
+
+ NodeId addMethodId =
+ requireNonNull(
+ findComponentMethodId(category.nodeId(), "AddAliasesToCategory"),
+ "AddAliasesToCategory Method not found");
+ NodeId deleteMethodId =
+ requireNonNull(
+ findComponentMethodId(category.nodeId(), "DeleteAliasesFromCategory"),
+ "DeleteAliasesFromCategory Method not found");
+
+ CallMethodResult addResult =
+ callAddAliases(
+ category.nodeId(),
+ addMethodId,
+ new String[] {"CategoryWireAlias"},
+ new ExpandedNodeId[] {newNodeId("TestInt32").expanded()},
+ new String[0],
+ NodeId.NULL_VALUE);
+
+ assertEquals(StatusCode.GOOD, addResult.getStatusCode());
+ assertArrayEquals(new StatusCode[] {StatusCode.GOOD}, errorCodes(addResult));
+ assertEquals(
+ 1, aliasManager.findAlias(category.nodeId(), "CategoryWireAlias", null).size());
+
+ CallMethodResult deleteResult =
+ callDeleteAliases(
+ category.nodeId(), deleteMethodId, new String[] {"CategoryWireAlias"}, null);
+
+ assertEquals(StatusCode.GOOD, deleteResult.getStatusCode());
+ assertArrayEquals(new StatusCode[] {StatusCode.GOOD}, errorCodes(deleteResult));
+ assertTrue(aliasManager.findAlias(category.nodeId(), "CategoryWireAlias", null).isEmpty());
+ } finally {
+ aliasManager.removeCategory(category.nodeId());
+ }
+ }
+
+ // WHY: entries execute application-supplied code (the category's aliasNodeIdFactory and its
+ // NodeManager), which can throw unchecked; Part 17 §6.3.4 requires per-entry independence
+ // and §6.3.1 requires LastChange to reflect what changed, so a RuntimeException from one
+ // entry must map to that entry's Bad_InternalError while the sibling entry still creates
+ // its alias and the category's LastChange bumps for the applied entry.
+ @Test
+ void runtimeExceptionFromAliasNodeIdFactoryFailsOnlyItsEntry() throws UaException {
+ AliasCategory category =
+ aliasManager.addCategory(
+ new AliasCategoryConfig(
+ newNodeId("ThrowingFactoryCategory"),
+ NodeIds.Aliases,
+ newQualifiedName("ThrowingFactoryCategory"),
+ testNodeManager,
+ name -> {
+ if ("ThrowingFactoryAlias".equals(name)) {
+ throw new IllegalStateException("factory failure for " + name);
+ }
+ return newNodeId("ThrowingFactoryCategory/" + name);
+ },
+ true,
+ false,
+ true));
+ try {
+ NodeId addMethodId =
+ requireNonNull(
+ findComponentMethodId(category.nodeId(), "AddAliasesToCategory"),
+ "AddAliasesToCategory Method not found");
+
+ UInteger lastChangeBefore = requireLastChange(server, category.nodeId());
+
+ CallMethodResult result =
+ callAddAliases(
+ category.nodeId(),
+ addMethodId,
+ new String[] {"FactorySurvivorAlias", "ThrowingFactoryAlias"},
+ new ExpandedNodeId[] {
+ newNodeId("TestInt32").expanded(), newNodeId("TestAnalogValue").expanded()
+ },
+ new String[0],
+ NodeId.NULL_VALUE);
+
+ assertEquals(StatusCode.GOOD, result.getStatusCode());
+ assertArrayEquals(
+ new StatusCode[] {StatusCode.GOOD, StatusCode.of(StatusCodes.Bad_InternalError)},
+ errorCodes(result));
+
+ assertEquals(
+ 1, aliasManager.findAlias(category.nodeId(), "FactorySurvivorAlias", null).size());
+ assertTrue(
+ aliasManager.findAlias(category.nodeId(), "ThrowingFactoryAlias", null).isEmpty());
+
+ assertStrictlyGreater(lastChangeBefore, readLastChange(server, category.nodeId()));
+ } finally {
+ aliasManager.removeCategory(category.nodeId());
+ }
+ }
+
+ // WHY: a failure after the alias Node was added to the NodeManager but before it was fully
+ // wired must not leave the Node behind — untyped, unlinked, or targetless it violates the
+ // Part 17 §6.3.1 alias model and would be visible to searches forever. The entry reports
+ // Bad_InternalError (§6.3.4 per-entry contract for unchecked application failures) and the
+ // best-effort cleanup deletes the Node again.
+ @Test
+ void nodeManagerFailureAfterNodeAddFailsEntryAndRemovesPartiallyCreatedAlias()
+ throws UaException {
+ // The category's Nodes must live in a routable AddressSpace for the wire Call to reach
+ // them, so the throwing NodeManager is hosted by a registered fragment.
+ var fragment = new AliasForRejectingFragment(server);
+ fragment.startup();
+ try {
+ AliasCategory category =
+ aliasManager.addCategory(
+ new AliasCategoryConfig(
+ newNodeId("RejectingCategory"),
+ NodeIds.Aliases,
+ newQualifiedName("RejectingCategory"),
+ fragment.getNodeManager(),
+ name -> newNodeId("RejectingCategory/" + name),
+ false,
+ false,
+ true));
+ try {
+ NodeId addMethodId =
+ requireNonNull(
+ findComponentMethodId(category.nodeId(), "AddAliasesToCategory"),
+ "AddAliasesToCategory Method not found");
+
+ CallMethodResult result =
+ callAddAliases(
+ category.nodeId(),
+ addMethodId,
+ new String[] {"HalfWiredAlias"},
+ new ExpandedNodeId[] {newNodeId("TestInt32").expanded()},
+ new String[0],
+ NodeId.NULL_VALUE);
+
+ assertEquals(StatusCode.GOOD, result.getStatusCode());
+ assertArrayEquals(
+ new StatusCode[] {StatusCode.of(StatusCodes.Bad_InternalError)}, errorCodes(result));
+
+ // Cleanup deleted the partially created Node: it is absent from the AddressSpace and
+ // the category organizes nothing by that name.
+ NodeId aliasNodeId = newNodeId("RejectingCategory/HalfWiredAlias");
+ assertTrue(server.getAddressSpaceManager().getManagedNode(aliasNodeId).isEmpty());
+ assertNull(findOrganizedAliasNodeId(category.nodeId(), "HalfWiredAlias"));
+ } finally {
+ aliasManager.removeCategory(category.nodeId());
+ }
+ } finally {
+ fragment.shutdown();
+ }
+ }
+
+ // WHY: the mutation surface is per category — with configurationEnabled false the category
+ // gets its mandatory FindAlias but no AddAliasesToCategory or DeleteAliasesFromCategory
+ // instance at all (Optional per Part 17 §6.3.1): no Method Node, no callable surface.
+ @Test
+ void categoryWithConfigurationDisabledHasNoMutationMethods() throws UaException {
+ AliasCategory category =
+ aliasManager.addCategory(
+ new AliasCategoryConfig(
+ newNodeId("UnconfiguredWireCategory"),
+ NodeIds.Aliases,
+ newQualifiedName("UnconfiguredWireCategory"),
+ testNodeManager,
+ name -> newNodeId("UnconfiguredWireCategory/" + name),
+ false,
+ false,
+ false));
+ try {
+ assertFalse(category.configurationEnabled());
+
+ assertNotNull(findComponentMethodId(category.nodeId(), "FindAlias"));
+ assertNull(findComponentMethodId(category.nodeId(), "AddAliasesToCategory"));
+ assertNull(findComponentMethodId(category.nodeId(), "DeleteAliasesFromCategory"));
+ } finally {
+ aliasManager.removeCategory(category.nodeId());
+ }
+ }
+ }
+
+ /**
+ * A manager config with the mutation Methods materialized on the standard categories, a policy
+ * granting mutation to every session, and a small operations-per-call limit so oversized-array
+ * behavior is testable.
+ */
+ private AliasManagerConfig grantedConfig(AliasVersionStore versionStore) {
+ return AliasManagerConfig.builder()
+ .configurationEnabled(true)
+ .authorizationPolicy(ALLOW_FIND_AND_MUTATE)
+ .versionStore(versionStore)
+ .limits(new AliasLimits(1000, 512, MAX_OPERATIONS_PER_CALL))
+ .nodeNamespaceIndex(testNamespace.getNamespaceIndex())
+ .build();
+ }
+
+ /** The NodeId the manager allocates for the {@code AddAliasesToCategory} Node on Aliases. */
+ private NodeId aliasesAddMethodId() {
+ return newNodeId("Aliases/AddAliasesToCategory");
+ }
+
+ /** The NodeId the manager allocates for the {@code DeleteAliasesFromCategory} Node on Aliases. */
+ private NodeId aliasesDeleteMethodId() {
+ return newNodeId("Aliases/DeleteAliasesFromCategory");
+ }
+
+ /** The NodeId the manager allocates for the {@code AddAliasesToCategory} Node on TagVariables. */
+ private NodeId tagVariablesAddMethodId() {
+ return newNodeId("TagVariables/AddAliasesToCategory");
+ }
+
+ /**
+ * Add {@code aliasName} to the root {@code Aliases} category over the wire, one Add entry per
+ * target, asserting every entry reports {@code Good}.
+ */
+ private void addRootAliasOverWire(String aliasName, NodeId... targets) throws UaException {
+ var aliasNames = new String[targets.length];
+ var targetNodes = new ExpandedNodeId[targets.length];
+ for (int i = 0; i < targets.length; i++) {
+ aliasNames[i] = aliasName;
+ targetNodes[i] = targets[i].expanded();
+ }
+
+ CallMethodResult result =
+ callAddAliases(
+ NodeIds.Aliases,
+ aliasesAddMethodId(),
+ aliasNames,
+ targetNodes,
+ new String[0],
+ NodeId.NULL_VALUE);
+
+ assertEquals(StatusCode.GOOD, result.getStatusCode());
+ for (StatusCode statusCode : errorCodes(result)) {
+ assertEquals(StatusCode.GOOD, statusCode);
+ }
+ }
+
+ /** Call {@code AddAliasesToCategory} through the client. */
+ private CallMethodResult callAddAliases(
+ NodeId objectId,
+ NodeId methodId,
+ String[] aliasNames,
+ ExpandedNodeId[] targetNodes,
+ String[] targetServers,
+ NodeId targetReferenceType)
+ throws UaException {
+
+ return callMethod(
+ objectId,
+ methodId,
+ new Variant[] {
+ new Variant(aliasNames),
+ new Variant(targetNodes),
+ new Variant(targetServers),
+ new Variant(targetReferenceType)
+ });
+ }
+
+ /** Call {@code DeleteAliasesFromCategory} through the client. */
+ private CallMethodResult callDeleteAliases(
+ NodeId objectId,
+ NodeId methodId,
+ String[] aliasNames,
+ ExpandedNodeId @Nullable [] targetNodes)
+ throws UaException {
+
+ return callMethod(
+ objectId, methodId, new Variant[] {new Variant(aliasNames), new Variant(targetNodes)});
+ }
+
+ private CallMethodResult callMethod(NodeId objectId, NodeId methodId, Variant[] inputs)
+ throws UaException {
+
+ CallResponse response = client.call(List.of(new CallMethodRequest(objectId, methodId, inputs)));
+
+ return requireNonNull(response.getResults())[0];
+ }
+
+ /** The per-entry {@code ErrorCodes} output of a mutation call that reached its handler. */
+ private StatusCode[] errorCodes(CallMethodResult result) {
+ Variant[] outputs = requireNonNull(result.getOutputArguments());
+
+ return (StatusCode[]) requireNonNull(outputs[0].value());
+ }
+
+ /**
+ * Call {@code FindAlias} on the root {@code Aliases} Object through the client and decode the
+ * result entries.
+ */
+ private List findRootAliases(String pattern) throws UaException {
+ CallMethodResult result =
+ callMethod(
+ NodeIds.Aliases,
+ NodeIds.Aliases_FindAlias,
+ new Variant[] {new Variant(pattern), new Variant(NodeId.NULL_VALUE)});
+
+ assertEquals(StatusCode.GOOD, result.getStatusCode());
+
+ Variant[] outputs = requireNonNull(result.getOutputArguments());
+ ExtensionObject[] xos = (ExtensionObject[]) requireNonNull(outputs[0].value());
+
+ var entries = new ArrayList();
+ for (ExtensionObject xo : xos) {
+ entries.add((AliasNameDataType) xo.decode(client.getStaticEncodingContext()));
+ }
+ return entries;
+ }
+
+ /**
+ * The NodeId of the Method component of {@code objectId} with ns=0 BrowseName {@code methodName},
+ * or null if the Object has no such component.
+ */
+ private @Nullable NodeId findComponentMethodId(NodeId objectId, String methodName) {
+ List references =
+ server
+ .getAddressSpaceManager()
+ .getManagedReferences(objectId, Reference.HAS_COMPONENT_PREDICATE);
+
+ for (Reference reference : references) {
+ UaNode node =
+ reference
+ .getTargetNodeId()
+ .toNodeId(server.getNamespaceTable())
+ .flatMap(id -> server.getAddressSpaceManager().getManagedNode(id))
+ .orElse(null);
+
+ if (node instanceof UaMethodNode
+ && new QualifiedName(0, methodName).equals(node.getBrowseName())) {
+ return node.getNodeId();
+ }
+ }
+ return null;
+ }
+
+ /**
+ * The NodeId of the alias Object named {@code aliasName} directly organized by {@code categoryId}
+ * (BrowseName text match, namespace ignored), or null if there is none.
+ */
+ private @Nullable NodeId findOrganizedAliasNodeId(NodeId categoryId, String aliasName) {
+ List references =
+ server
+ .getAddressSpaceManager()
+ .getManagedReferences(categoryId, Reference.ORGANIZES_PREDICATE);
+
+ for (Reference reference : references) {
+ UaNode node =
+ reference
+ .getTargetNodeId()
+ .toNodeId(server.getNamespaceTable())
+ .flatMap(id -> server.getAddressSpaceManager().getManagedNode(id))
+ .orElse(null);
+
+ if (node != null && aliasName.equals(node.getBrowseName().name())) {
+ return node.getNodeId();
+ }
+ }
+ return null;
+ }
+
+ /**
+ * A registered AddressSpace fragment whose {@link UaNodeManager} rejects {@code AliasFor}
+ * Reference adds with a RuntimeException — the application-NodeManager failure mode that can
+ * strike after an alias Node was added but before its target Reference was. Node and Reference
+ * removal stay permitted so the manager's best-effort cleanup can run.
+ *
+ * Registers first in the composite (mirroring the manager's own fragment) so Call requests for
+ * the Nodes it hosts route here instead of to the TestNamespace, whose filter matches the whole
+ * test namespace index but whose NodeManager does not contain them.
+ */
+ private static final class AliasForRejectingFragment
+ extends ManagedAddressSpaceFragmentWithLifecycle {
+
+ private final AddressSpaceFilter filter =
+ SimpleAddressSpaceFilter.create(getNodeManager()::containsNode);
+
+ AliasForRejectingFragment(OpcUaServer server) {
+ super(
+ server,
+ new UaNodeManager() {
+ @Override
+ public synchronized void addReferences(
+ Reference reference, NamespaceTable namespaceTable) {
+ if (NodeIds.AliasFor.equals(reference.getReferenceTypeId())) {
+ throw new IllegalStateException("simulated NodeManager failure: " + reference);
+ }
+ super.addReferences(reference, namespaceTable);
+ }
+ });
+ }
+
+ @Override
+ public AddressSpaceFilter getFilter() {
+ return filter;
+ }
+
+ @Override
+ protected void registerWithComposite(AddressSpaceComposite composite) {
+ composite.registerFirst(this);
+ }
+
+ @Override
+ public void onDataItemsCreated(List dataItems) {}
+
+ @Override
+ public void onDataItemsModified(List dataItems) {}
+
+ @Override
+ public void onDataItemsDeleted(List dataItems) {}
+
+ @Override
+ public void onMonitoringModeChanged(List monitoredItems) {}
+ }
+}
diff --git a/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/aliases/AliasFindTest.java b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/aliases/AliasFindTest.java
new file mode 100644
index 0000000000..8d90271748
--- /dev/null
+++ b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/aliases/AliasFindTest.java
@@ -0,0 +1,671 @@
+/*
+ * Copyright (c) 2026 the Eclipse Milo Authors
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+
+package org.eclipse.milo.opcua.sdk.test.aliases;
+
+import static java.util.Objects.requireNonNull;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Stream;
+import org.eclipse.milo.opcua.sdk.core.Reference;
+import org.eclipse.milo.opcua.sdk.server.UaNodeManager;
+import org.eclipse.milo.opcua.sdk.server.aliases.AliasCategoryConfig;
+import org.eclipse.milo.opcua.sdk.server.aliases.AliasLimits;
+import org.eclipse.milo.opcua.sdk.server.aliases.AliasManager;
+import org.eclipse.milo.opcua.sdk.server.aliases.AliasManagerConfig;
+import org.eclipse.milo.opcua.sdk.server.aliases.AliasTarget;
+import org.eclipse.milo.opcua.sdk.server.model.objects.AliasNameTypeNode;
+import org.eclipse.milo.opcua.sdk.server.nodes.UaNode;
+import org.eclipse.milo.opcua.sdk.server.nodes.UaNodeContext;
+import org.eclipse.milo.opcua.sdk.server.nodes.UaReferenceTypeNode;
+import org.eclipse.milo.opcua.sdk.test.AbstractClientServerTest;
+import org.eclipse.milo.opcua.sdk.test.TestNamespace;
+import org.eclipse.milo.opcua.stack.core.NodeIds;
+import org.eclipse.milo.opcua.stack.core.StatusCodes;
+import org.eclipse.milo.opcua.stack.core.UaException;
+import org.eclipse.milo.opcua.stack.core.types.builtin.ExpandedNodeId;
+import org.eclipse.milo.opcua.stack.core.types.builtin.ExtensionObject;
+import org.eclipse.milo.opcua.stack.core.types.builtin.LocalizedText;
+import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId;
+import org.eclipse.milo.opcua.stack.core.types.builtin.QualifiedName;
+import org.eclipse.milo.opcua.stack.core.types.builtin.StatusCode;
+import org.eclipse.milo.opcua.stack.core.types.builtin.Variant;
+import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UInteger;
+import org.eclipse.milo.opcua.stack.core.types.structured.AliasNameDataType;
+import org.eclipse.milo.opcua.stack.core.types.structured.AliasNameVerboseDataType;
+import org.eclipse.milo.opcua.stack.core.types.structured.CallMethodRequest;
+import org.eclipse.milo.opcua.stack.core.types.structured.CallMethodResult;
+import org.eclipse.milo.opcua.stack.core.types.structured.CallResponse;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+/**
+ * FindAlias/FindAliasVerbose search semantics (Part 17 §6.3.2/§6.3.3) exercised against a real
+ * server, through both the {@link AliasManager} programmatic API and over-the-wire Call requests.
+ *
+ * All fixture categories are built once per class under the standard {@code Aliases} Object;
+ * every search is scoped to its own category subtree so the per-category fixtures stay independent
+ * of each other (over-the-wire searches from the {@code Aliases} root use literal patterns that
+ * match exactly one fixture alias).
+ */
+// Fields below are assigned in @BeforeAll, which the nullability inspection does not model.
+@SuppressWarnings("NotNullFieldNotInitialized")
+public class AliasFindTest extends AbstractClientServerTest {
+
+ private static final int MAX_RESULTS = 10;
+ private static final int MAX_PATTERN_LENGTH = 64;
+
+ private AliasManager aliasManager;
+
+ private UaNodeContext nodeContext;
+ private UaNodeManager nodeManager;
+
+ /** A ReferenceType registered as a subtype of {@code AliasFor} for filter tests. */
+ private NodeId customRefTypeId;
+
+ private NodeId testInt32Id;
+
+ private NodeId patternCatId;
+ private NodeId wireCatId;
+ private NodeId recursiveParentId;
+ private NodeId recursiveChildId;
+ private NodeId cycleAId;
+ private NodeId dedupParentId;
+ private NodeId dedupChildId;
+ private NodeId filterCatId;
+ private NodeId orderCatId;
+ private NodeId nsCatId;
+ private NodeId bulkCatId;
+ private NodeId remoteCatId;
+
+ /** The remote target of the mixed local/remote alias in {@link #remoteCatId}. */
+ private ExpandedNodeId remoteTargetId;
+
+ @Override
+ protected void configureTestNamespace(TestNamespace namespace) {
+ namespace.configure(
+ (context, uaNodeManager) -> {
+ nodeContext = context;
+ nodeManager = uaNodeManager;
+
+ // The standard model ships no concrete subtype of AliasFor, so the subtype-filter
+ // behavior can only be exercised against a custom ReferenceType.
+ customRefTypeId = newNodeId("HasTestAliasTarget");
+
+ var refTypeNode =
+ new UaReferenceTypeNode(
+ context,
+ customRefTypeId,
+ newQualifiedName("HasTestAliasTarget"),
+ LocalizedText.english("HasTestAliasTarget"),
+ LocalizedText.NULL_VALUE,
+ UInteger.valueOf(0),
+ UInteger.valueOf(0),
+ false,
+ false,
+ LocalizedText.english("TestAliasTargetOf"));
+
+ uaNodeManager.addNode(refTypeNode);
+
+ refTypeNode.addReference(
+ new Reference(
+ customRefTypeId,
+ NodeIds.HasSubtype,
+ NodeIds.AliasFor.expanded(),
+ Reference.Direction.INVERSE));
+ });
+
+ // The ReferenceTypeTree is built lazily and cached; rebuild it so the custom subtype is
+ // visible to the search engine's filter validation and subtype checks.
+ server.updateReferenceTypeTree();
+ }
+
+ @BeforeAll
+ void startAliasManagerAndBuildFixtures() throws Exception {
+ aliasManager =
+ new AliasManager(
+ server,
+ AliasManagerConfig.builder()
+ .limits(new AliasLimits(MAX_RESULTS, MAX_PATTERN_LENGTH, 10))
+ .findAliasVerboseEnabled(true)
+ .nodeNamespaceIndex(testNamespace.getNamespaceIndex())
+ .build());
+
+ aliasManager.startup();
+
+ testInt32Id = newNodeId("TestInt32");
+
+ // Pattern semantics: four aliases whose names exercise wildcard, class, and case matching.
+ patternCatId = addCategory("PatternCat", NodeIds.Aliases);
+ for (String name : List.of("Apple", "Apricot", "Banana", "apple")) {
+ aliasManager.addAlias(patternCatId, name, aliasForTargets(testInt32Id));
+ }
+
+ // Over-the-wire cases search from the Aliases root with the literal pattern "WireAlias".
+ wireCatId = addCategory("WireCat", NodeIds.Aliases);
+ aliasManager.addAlias(wireCatId, "WireAlias", aliasForTargets(testInt32Id));
+
+ // Recursive scope: an alias organized only by a subcategory of the searched category.
+ recursiveParentId = addCategory("RecursiveParent", NodeIds.Aliases);
+ recursiveChildId = addCategory("RecursiveChild", recursiveParentId);
+ aliasManager.addAlias(recursiveChildId, "DeepAlias", aliasForTargets(testInt32Id));
+
+ // Cycle: CycleA organizes CycleB (via addCategory) and a raw Organizes Reference makes
+ // CycleB organize CycleA right back. The manager API cannot create this, so it is wired
+ // with a raw Reference after the alias is added.
+ cycleAId = addCategory("CycleA", NodeIds.Aliases);
+ NodeId cycleBId = addCategory("CycleB", cycleAId);
+ aliasManager.addAlias(cycleBId, "CycleAlias", aliasForTargets(testInt32Id));
+ UaNode cycleBNode = getNode(cycleBId);
+ cycleBNode.addReference(
+ new Reference(
+ cycleBId, NodeIds.Organizes, cycleAId.expanded(), Reference.Direction.FORWARD));
+
+ // Dedup + verbose category selection: one alias organized by both a parent category and its
+ // subcategory, so two traversal paths reach it from the parent.
+ dedupParentId = addCategory("DedupParent", NodeIds.Aliases);
+ dedupChildId = addCategory("DedupChild", dedupParentId);
+ NodeId sharedAliasId =
+ aliasManager.addAlias(dedupChildId, "SharedAlias", aliasForTargets(testInt32Id));
+ UaNode sharedAliasNode = getNode(sharedAliasId);
+ sharedAliasNode.addReference(
+ new Reference(
+ sharedAliasId,
+ NodeIds.Organizes,
+ dedupParentId.expanded(),
+ Reference.Direction.INVERSE));
+
+ // ReferenceType filtering: one alias with an AliasFor target and a custom-subtype target.
+ filterCatId = addCategory("FilterCat", NodeIds.Aliases);
+ aliasManager.addAlias(
+ filterCatId,
+ "FilteredAlias",
+ List.of(
+ new AliasTarget(testInt32Id.expanded(), null, NodeIds.AliasFor),
+ new AliasTarget(NodeIds.Server.expanded(), null, customRefTypeId)));
+
+ // Ordering: two identically named aliases with different NodeIds plus a third name. Raw
+ // Nodes because addAlias treats a same-named alias as the same alias and extends it.
+ orderCatId = addCategory("OrderCat", NodeIds.Aliases);
+ addRawAlias(orderCatId, newNodeId("OrderCat/a1"), newQualifiedName("OrderA"));
+ addRawAlias(orderCatId, newNodeId("OrderCat/a2"), new QualifiedName(0, "OrderA"));
+ addRawAlias(orderCatId, newNodeId("OrderCat/b1"), newQualifiedName("OrderB"));
+
+ // BrowseName namespace: same name text in two different BrowseName namespaces.
+ nsCatId = addCategory("NsCat", NodeIds.Aliases);
+ aliasManager.addAlias(nsCatId, "NsAlias", aliasForTargets(testInt32Id));
+ addRawAlias(nsCatId, newNodeId("NsCat/NsAlias-ns0"), new QualifiedName(0, "NsAlias"));
+
+ // Verbose ServerUris: an alias with a local target plus a remote AliasFor Reference whose
+ // target ExpandedNodeId carries its Server URI directly (no ServerTable entry needed). The
+ // manager rejects remote targets (Bad_NotSupported), so the Reference is wired raw.
+ remoteCatId = addCategory("RemoteCat", NodeIds.Aliases);
+ NodeId remoteAliasId =
+ aliasManager.addAlias(remoteCatId, "RemoteMixAlias", aliasForTargets(testInt32Id));
+ remoteTargetId =
+ new ExpandedNodeId(
+ ExpandedNodeId.ServerReference.of("urn:remote:server"),
+ ExpandedNodeId.NamespaceReference.of(testInt32Id.getNamespaceIndex()),
+ "RemoteCat/RemoteTarget");
+ getNode(remoteAliasId)
+ .addReference(
+ new Reference(
+ remoteAliasId, NodeIds.AliasFor, remoteTargetId, Reference.Direction.FORWARD));
+
+ // Result cap: one more matching alias than the configured maxResults.
+ bulkCatId = addCategory("BulkCat", NodeIds.Aliases);
+ for (int i = 0; i <= MAX_RESULTS; i++) {
+ aliasManager.addAlias(bulkCatId, String.format("Bulk%02d", i), aliasForTargets(testInt32Id));
+ }
+ }
+
+ @AfterAll
+ void shutdownAliasManager() {
+ aliasManager.shutdown();
+ }
+
+ @Nested
+ class PatternSemantics {
+
+ // Part 17 §6.3.2 defines the search string as a Part 4 §7.7.3 (Table 120) Like pattern:
+ // '%' matches any run, '_' exactly one character, '[...]' a character list/range,
+ // '[^...]' its negation, and everything else matches literally.
+ @ParameterizedTest
+ @MethodSource("patternCases")
+ void patternMatchingFollowsPart4LikeGrammar(String pattern, List expectedNames)
+ throws UaException {
+
+ assertEquals(expectedNames, findNames(patternCatId, pattern));
+ }
+
+ static Stream patternCases() {
+ return Stream.of(
+ Arguments.of("Apple", List.of("Apple")),
+ Arguments.of("Grape", List.of()),
+ Arguments.of("Ap%", List.of("Apple", "Apricot")),
+ Arguments.of("%", List.of("Apple", "Apricot", "Banana", "apple")),
+ Arguments.of("Appl_", List.of("Apple")),
+ Arguments.of("_pple", List.of("Apple", "apple")),
+ Arguments.of("[AB]%", List.of("Apple", "Apricot", "Banana")),
+ Arguments.of("[A-C]%", List.of("Apple", "Apricot", "Banana")),
+ Arguments.of("[^A]%", List.of("Banana", "apple")));
+ }
+
+ // Part 4 §7.7.3 Like matching is case-sensitive; the alias design binds this with
+ // no Unicode folding, so "apple" and "Apple" are distinct names.
+ @Test
+ void matchingIsCaseSensitive() throws UaException {
+ assertEquals(List.of("apple"), findNames(patternCatId, "apple"));
+ assertEquals(List.of(), findNames(patternCatId, "APPLE"));
+ }
+ }
+
+ @Nested
+ class InvalidArguments {
+
+ // Part 17 §6.3.2 maps an invalid search string to Bad_InvalidArgument; a malformed
+ // pattern must fail the call rather than silently matching nothing.
+ @ParameterizedTest
+ @ValueSource(strings = {"abc\\", "[]", "[abc", "[z-a]"})
+ void malformedPatternFailsWithBadInvalidArgument(String pattern) {
+ UaException e =
+ assertThrows(
+ UaException.class, () -> aliasManager.findAlias(patternCatId, pattern, null));
+
+ assertEquals(StatusCodes.Bad_InvalidArgument, e.getStatusCode().value());
+ }
+
+ // The configured maxPatternLength caps pattern cost before parsing; an oversized pattern
+ // is an invalid argument, not a truncated search.
+ @Test
+ void patternLongerThanMaxPatternLengthFailsWithBadInvalidArgument() {
+ String pattern = "A".repeat(MAX_PATTERN_LENGTH + 1);
+
+ UaException e =
+ assertThrows(
+ UaException.class, () -> aliasManager.findAlias(patternCatId, pattern, null));
+
+ assertEquals(StatusCodes.Bad_InvalidArgument, e.getStatusCode().value());
+ }
+
+ // Binding policy: a ReferenceType filter that is not a known ReferenceType fails the call
+ // with Bad_InvalidArgument instead of being treated as "matches nothing".
+ @Test
+ void unknownReferenceTypeFilterFailsWithBadInvalidArgument() {
+ NodeId unknown = newNodeId("NoSuchReferenceType");
+
+ UaException e =
+ assertThrows(UaException.class, () -> aliasManager.findAlias(filterCatId, "%", unknown));
+
+ assertEquals(StatusCodes.Bad_InvalidArgument, e.getStatusCode().value());
+ }
+
+ // Part 17 defines no paging for FindAlias; when more entries match than the configured
+ // maxResults the call must fail with Bad_ResponseTooLarge so the Client narrows its pattern.
+ @Test
+ void moreMatchesThanMaxResultsFailsWithBadResponseTooLarge() {
+ UaException e =
+ assertThrows(UaException.class, () -> aliasManager.findAlias(bulkCatId, "Bulk%", null));
+
+ assertEquals(StatusCodes.Bad_ResponseTooLarge, e.getStatusCode().value());
+ }
+ }
+
+ @Nested
+ class OverTheWire {
+
+ // End-to-end proof that the standard Aliases FindAlias Method (bound by the manager) is
+ // callable by a Client and returns the matching alias with its targets.
+ @Test
+ void findAliasCallReturnsMatchingAliasAndTargets() throws UaException {
+ CallMethodResult result =
+ call(NodeIds.Aliases, NodeIds.Aliases_FindAlias, "WireAlias", NodeId.NULL_VALUE);
+
+ assertEquals(StatusCode.GOOD, result.getStatusCode());
+
+ ExtensionObject[] xos = outputArray(result);
+ assertEquals(1, xos.length);
+
+ var entry = (AliasNameDataType) xos[0].decode(client.getStaticEncodingContext());
+ assertEquals("WireAlias", entry.getAliasName().name());
+ assertArrayEquals(new ExpandedNodeId[] {testInt32Id.expanded()}, entry.getReferencedNodes());
+ }
+
+ // The FindAliasVerbose Method is materialized by the manager (it is not in the standard
+ // NodeSet) and hosted in the manager's fragment; this proves it routes and that the verbose
+ // entry reports the organizing category and, per Part 17 §7.3, no server URI for a local
+ // target.
+ @Test
+ void findAliasVerboseCallReportsCategoryAndNoServerUriForLocalTargets() throws UaException {
+ NodeId verboseMethodId = newNodeId("Aliases/FindAliasVerbose");
+
+ CallMethodResult result =
+ call(NodeIds.Aliases, verboseMethodId, "WireAlias", NodeId.NULL_VALUE);
+
+ assertEquals(StatusCode.GOOD, result.getStatusCode());
+
+ ExtensionObject[] xos = outputArray(result);
+ assertEquals(1, xos.length);
+
+ var entry = (AliasNameVerboseDataType) xos[0].decode(client.getStaticEncodingContext());
+ assertEquals("WireAlias", entry.getAliasName().name());
+ assertEquals(wireCatId, entry.getAliasNameCategoryId());
+
+ // Binary encoding may surface a null array as null or empty; both mean "all local".
+ String[] serverUris = entry.getServerUris();
+ assertTrue(
+ serverUris == null || serverUris.length == 0,
+ "local targets must have no server URIs, got: " + Arrays.toString(serverUris));
+ }
+
+ // Part 17 §6.3.2: an invalid search string fails the Call with Bad_InvalidArgument in the
+ // CallMethodResult, observable by a real Client.
+ @Test
+ void malformedPatternCallFailsWithBadInvalidArgument() throws UaException {
+ CallMethodResult result =
+ call(NodeIds.Aliases, NodeIds.Aliases_FindAlias, "[unclosed", NodeId.NULL_VALUE);
+
+ assertEquals(StatusCode.of(StatusCodes.Bad_InvalidArgument), result.getStatusCode());
+ }
+
+ // The maxPatternLength limit must hold on the network path too, before any parsing.
+ @Test
+ void oversizedPatternCallFailsWithBadInvalidArgument() throws UaException {
+ String pattern = "A".repeat(MAX_PATTERN_LENGTH + 1);
+
+ CallMethodResult result =
+ call(NodeIds.Aliases, NodeIds.Aliases_FindAlias, pattern, NodeId.NULL_VALUE);
+
+ assertEquals(StatusCode.of(StatusCodes.Bad_InvalidArgument), result.getStatusCode());
+ }
+ }
+
+ @Nested
+ class RecursiveScope {
+
+ // Part 17 §6.3.2: FindAlias searches the category and all of its subcategories, so an alias
+ // organized only by a subcategory is found from the parent.
+ @Test
+ void aliasInSubcategoryIsFoundFromParentCategory() throws UaException {
+ assertEquals(List.of("DeepAlias"), findNames(recursiveParentId, "DeepAlias"));
+ assertEquals(List.of("DeepAlias"), findNames(recursiveChildId, "DeepAlias"));
+ }
+
+ // Binding policy 7: results are deduplicated by alias NodeId, so an alias reachable both
+ // directly and through a subcategory appears exactly once.
+ @Test
+ void aliasReachableThroughTwoPathsAppearsOnce() throws UaException {
+ List results = aliasManager.findAlias(dedupParentId, "SharedAlias", null);
+
+ assertEquals(1, results.size());
+ assertEquals("SharedAlias", results.get(0).getAliasName().name());
+ }
+
+ // A cycle in the category graph (possible with raw References) must terminate via
+ // visited-category tracking instead of hanging the search.
+ @Test
+ void categoryCycleDoesNotHangSearch() {
+ List names =
+ assertTimeoutPreemptively(
+ Duration.ofSeconds(10), () -> findNames(cycleAId, "CycleAlias"));
+
+ assertEquals(List.of("CycleAlias"), names);
+ }
+ }
+
+ @Nested
+ class ReferenceTypeFilter {
+
+ // Part 17 §6.3.3: with no ReferenceType filter, targets of AliasFor and all of its
+ // subtypes are returned.
+ @Test
+ void nullFilterReturnsTargetsOfAliasForAndItsSubtypes() throws UaException {
+ List results = aliasManager.findAlias(filterCatId, "FilteredAlias", null);
+
+ assertEquals(1, results.size());
+ assertArrayEquals(
+ new ExpandedNodeId[] {NodeIds.Server.expanded(), testInt32Id.expanded()},
+ results.get(0).getReferencedNodes());
+ }
+
+ // Part 17 §6.3.3: "any ReferenceType includes all subtypes", so filtering on AliasFor
+ // still returns the target referenced by the custom AliasFor subtype.
+ @Test
+ void aliasForFilterIncludesSubtypeReferences() throws UaException {
+ List results =
+ aliasManager.findAlias(filterCatId, "FilteredAlias", NodeIds.AliasFor);
+
+ assertEquals(1, results.size());
+ assertArrayEquals(
+ new ExpandedNodeId[] {NodeIds.Server.expanded(), testInt32Id.expanded()},
+ results.get(0).getReferencedNodes());
+ }
+
+ // Filtering on a subtype must exclude targets referenced only by the supertype.
+ @Test
+ void subtypeFilterReturnsOnlyTargetsOfThatSubtype() throws UaException {
+ List results =
+ aliasManager.findAlias(filterCatId, "FilteredAlias", customRefTypeId);
+
+ assertEquals(1, results.size());
+ assertArrayEquals(
+ new ExpandedNodeId[] {NodeIds.Server.expanded()}, results.get(0).getReferencedNodes());
+ }
+
+ // Part 17 §6.3.2/§6.3.3: a valid ReferenceType that no alias Reference satisfies yields an
+ // empty list, not an error — an alias with no passing targets is omitted entirely.
+ @Test
+ void filterOutsideAliasForHierarchyYieldsEmptyResult() throws UaException {
+ List results =
+ aliasManager.findAlias(filterCatId, "FilteredAlias", NodeIds.Organizes);
+
+ assertEquals(List.of(), results);
+ }
+ }
+
+ @Nested
+ class Ordering {
+
+ // Binding policy 10: entries are ordered by alias name text, then by alias NodeId, so
+ // identically named aliases and repeat calls return a deterministic sequence.
+ @Test
+ void entriesAreOrderedByNameTextThenNodeId() throws UaException {
+ List results = aliasManager.findAlias(orderCatId, "Order%", null);
+
+ List names = results.stream().map(AliasNameDataType::getAliasName).toList();
+
+ // "OrderCat/a1" < "OrderCat/a2" decides the two OrderA entries; name text puts OrderB last.
+ assertEquals(
+ List.of(
+ newQualifiedName("OrderA"),
+ new QualifiedName(0, "OrderA"),
+ newQualifiedName("OrderB")),
+ names);
+ }
+ }
+
+ @Nested
+ class Verbose {
+
+ // Binding policy 4: the verbose aliasNameCategoryId is the organizing category with the
+ // smallest depth from the searched category, so the same alias reports a different category
+ // depending on where the search starts.
+ @Test
+ void verboseCategoryIsSmallestDepthOrganizingCategoryWithinSearchedSubtree()
+ throws UaException {
+
+ List fromParent =
+ aliasManager.findAliasVerbose(dedupParentId, "SharedAlias", null);
+ assertEquals(1, fromParent.size());
+ assertEquals(dedupParentId, fromParent.get(0).getAliasNameCategoryId());
+
+ List fromChild =
+ aliasManager.findAliasVerbose(dedupChildId, "SharedAlias", null);
+ assertEquals(1, fromChild.size());
+ assertEquals(dedupChildId, fromChild.get(0).getAliasNameCategoryId());
+ }
+
+ // Part 17 §7.3 permits a null server URI for local Nodes; every target this server returns
+ // is local, so the ServerUris field must be absent.
+ @Test
+ void verboseServerUrisAreNullForLocalTargets() throws UaException {
+ List results =
+ aliasManager.findAliasVerbose(wireCatId, "WireAlias", null);
+
+ assertEquals(1, results.size());
+ assertNull(results.get(0).getServerUris());
+ }
+
+ // Part 17 §7.3 — once any target is remote, ServerUris must be present and parallel to
+ // ReferencedNodes: the remote target's Server URI in its slot and null (permitted for local
+ // Nodes) in the local slots. The default target ordering puts local targets before remote
+ // ones, fixing which slot is which.
+ @Test
+ void verboseServerUrisParallelReferencedNodesWhenAnyTargetIsRemote() throws UaException {
+ List results =
+ aliasManager.findAliasVerbose(remoteCatId, "RemoteMixAlias", null);
+
+ assertEquals(1, results.size());
+ AliasNameVerboseDataType entry = results.get(0);
+
+ assertArrayEquals(
+ new ExpandedNodeId[] {testInt32Id.expanded(), remoteTargetId},
+ entry.getReferencedNodes());
+
+ String[] serverUris = entry.getServerUris();
+ assertNotNull(serverUris, "a remote target must make the ServerUris array present");
+ assertArrayEquals(new String[] {null, "urn:remote:server"}, serverUris);
+ }
+ }
+
+ @Nested
+ class BrowseNameNamespace {
+
+ // Part 17 §6.2: alias name matching considers only the BrowseName's name text; the
+ // namespace index is ignored, so identically named aliases in different BrowseName
+ // namespaces both match a literal pattern.
+ @Test
+ void browseNameNamespaceIsIgnoredInMatching() throws UaException {
+ List results = aliasManager.findAlias(nsCatId, "NsAlias", null);
+
+ List names = results.stream().map(AliasNameDataType::getAliasName).toList();
+
+ // Same name text sorts by NodeId: "NsCat/NsAlias" < "NsCat/NsAlias-ns0".
+ assertEquals(List.of(newQualifiedName("NsAlias"), new QualifiedName(0, "NsAlias")), names);
+ }
+ }
+
+ private NodeId addCategory(String name, NodeId parentCategoryId) throws UaException {
+ return aliasManager
+ .addCategory(
+ new AliasCategoryConfig(
+ newNodeId(name),
+ parentCategoryId,
+ newQualifiedName(name),
+ nodeManager,
+ aliasName -> newNodeId(name + "/" + aliasName),
+ false,
+ false,
+ false))
+ .nodeId();
+ }
+
+ /**
+ * Create an alias Node directly, bypassing {@link AliasManager#addAlias}: needed for a second
+ * alias with the same name text (addAlias would extend the existing one) and for BrowseNames in a
+ * namespace other than the alias NodeId's.
+ */
+ private void addRawAlias(NodeId categoryId, NodeId aliasNodeId, QualifiedName browseName) {
+ var aliasNode =
+ new AliasNameTypeNode(
+ nodeContext,
+ aliasNodeId,
+ browseName,
+ LocalizedText.english(browseName.name()),
+ LocalizedText.NULL_VALUE,
+ UInteger.valueOf(0),
+ UInteger.valueOf(0),
+ null,
+ null,
+ null);
+
+ nodeManager.addNode(aliasNode);
+
+ aliasNode.addReference(
+ new Reference(
+ aliasNodeId,
+ NodeIds.HasTypeDefinition,
+ NodeIds.AliasNameType.expanded(),
+ Reference.Direction.FORWARD));
+
+ aliasNode.addReference(
+ new Reference(
+ aliasNodeId, NodeIds.Organizes, categoryId.expanded(), Reference.Direction.INVERSE));
+
+ aliasNode.addReference(
+ new Reference(
+ aliasNodeId, NodeIds.AliasFor, testInt32Id.expanded(), Reference.Direction.FORWARD));
+ }
+
+ private List aliasForTargets(NodeId targetId) {
+ return List.of(new AliasTarget(targetId.expanded(), null, NodeIds.AliasFor));
+ }
+
+ private List findNames(NodeId categoryId, String pattern) throws UaException {
+ var names = new ArrayList();
+ for (var entry : aliasManager.findAlias(categoryId, pattern, null)) {
+ names.add(requireNonNull(entry.getAliasName().name()));
+ }
+ return names;
+ }
+
+ private UaNode getNode(NodeId nodeId) {
+ return server
+ .getAddressSpaceManager()
+ .getManagedNode(nodeId)
+ .orElseThrow(() -> new IllegalStateException("node not found: " + nodeId));
+ }
+
+ private CallMethodResult call(
+ NodeId objectId, NodeId methodId, String pattern, NodeId referenceTypeFilter)
+ throws UaException {
+
+ var request =
+ new CallMethodRequest(
+ objectId,
+ methodId,
+ new Variant[] {new Variant(pattern), new Variant(referenceTypeFilter)});
+
+ CallResponse response = client.call(List.of(request));
+
+ return requireNonNull(response.getResults())[0];
+ }
+
+ private ExtensionObject[] outputArray(CallMethodResult result) {
+ Variant[] outputs = requireNonNull(result.getOutputArguments());
+ return (ExtensionObject[]) requireNonNull(outputs[0].value());
+ }
+}
diff --git a/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/aliases/AliasManagerLifecycleTest.java b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/aliases/AliasManagerLifecycleTest.java
new file mode 100644
index 0000000000..0120f7348a
--- /dev/null
+++ b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/aliases/AliasManagerLifecycleTest.java
@@ -0,0 +1,531 @@
+/*
+ * Copyright (c) 2026 the Eclipse Milo Authors
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+
+package org.eclipse.milo.opcua.sdk.test.aliases;
+
+import static java.util.Objects.requireNonNull;
+import static org.eclipse.milo.opcua.sdk.test.aliases.AliasTestSupport.readAttribute;
+import static org.eclipse.milo.opcua.sdk.test.aliases.AliasTestSupport.requireLastChange;
+import static org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.Unsigned.uint;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.stream.Stream;
+import org.eclipse.milo.opcua.sdk.server.UaNodeManager;
+import org.eclipse.milo.opcua.sdk.server.aliases.AliasCategoryConfig;
+import org.eclipse.milo.opcua.sdk.server.aliases.AliasManager;
+import org.eclipse.milo.opcua.sdk.server.aliases.AliasManagerConfig;
+import org.eclipse.milo.opcua.sdk.server.aliases.AliasTarget;
+import org.eclipse.milo.opcua.sdk.server.aliases.AliasVersionStore;
+import org.eclipse.milo.opcua.sdk.server.methods.AbstractMethodInvocationHandler;
+import org.eclipse.milo.opcua.sdk.server.methods.MethodInvocationHandler;
+import org.eclipse.milo.opcua.sdk.server.nodes.UaMethodNode;
+import org.eclipse.milo.opcua.sdk.server.nodes.UaNode;
+import org.eclipse.milo.opcua.sdk.test.AbstractClientServerTest;
+import org.eclipse.milo.opcua.stack.core.AttributeId;
+import org.eclipse.milo.opcua.stack.core.NodeIds;
+import org.eclipse.milo.opcua.stack.core.StatusCodes;
+import org.eclipse.milo.opcua.stack.core.UaException;
+import org.eclipse.milo.opcua.stack.core.types.builtin.DataValue;
+import org.eclipse.milo.opcua.stack.core.types.builtin.ExpandedNodeId;
+import org.eclipse.milo.opcua.stack.core.types.builtin.ExtensionObject;
+import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId;
+import org.eclipse.milo.opcua.stack.core.types.builtin.QualifiedName;
+import org.eclipse.milo.opcua.stack.core.types.builtin.StatusCode;
+import org.eclipse.milo.opcua.stack.core.types.builtin.Variant;
+import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UInteger;
+import org.eclipse.milo.opcua.stack.core.types.structured.Argument;
+import org.eclipse.milo.opcua.stack.core.types.structured.CallMethodRequest;
+import org.eclipse.milo.opcua.stack.core.types.structured.CallMethodResult;
+import org.eclipse.milo.opcua.stack.core.types.structured.CallResponse;
+import org.jspecify.annotations.Nullable;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+
+/**
+ * Lifecycle integration tests for {@link AliasManager}: the no-manager baseline the standard
+ * namespace establishes at server startup, the state a manager startup applies, the state its
+ * shutdown restores, and the no-trace guarantee of a failed startup.
+ *
+ * An {@link AliasManager} is one-shot, so every test constructs a fresh instance and returns the
+ * shared server to the no-manager baseline before finishing.
+ */
+public class AliasManagerLifecycleTest extends AbstractClientServerTest {
+
+ /** The standard {@code FindAlias} Method instances the manager binds at startup. */
+ private static final List STANDARD_FIND_ALIAS_NODE_IDS =
+ List.of(NodeIds.Aliases_FindAlias, NodeIds.TagVariables_FindAlias, NodeIds.Topics_FindAlias);
+
+ /**
+ * The NodeId the manager allocates for the {@code FindAliasVerbose} Method it materializes on the
+ * standard {@code Aliases} Object, derived from the default Node namespace index (1).
+ */
+ private static final NodeId ALIASES_FIND_ALIAS_VERBOSE_NODE_ID =
+ new NodeId(1, "Aliases/FindAliasVerbose");
+
+ static Stream standardFindAliasNodeIds() {
+ return STANDARD_FIND_ALIAS_NODE_IDS.stream();
+ }
+
+ @Nested
+ class WithoutManager {
+
+ // WHY: the standard NodeSet loads the FindAlias instances executable, but a server without an
+ // AliasManager has no behavior behind them; OpcUaNamespace must surface alias support as an
+ // absent feature by clearing both executable flags instead of leaving a callable Method that
+ // always fails (interop honesty for the Part 17 FindAlias contract).
+ @ParameterizedTest
+ @MethodSource(
+ "org.eclipse.milo.opcua.sdk.test.aliases.AliasManagerLifecycleTest#standardFindAliasNodeIds")
+ void standardFindAliasNodeIsNotExecutableWithoutManager(NodeId findAliasNodeId) {
+ UaMethodNode methodNode = serverMethodNode(findAliasNodeId);
+
+ assertFalse(methodNode.isExecutable());
+ assertFalse(methodNode.isUserExecutable());
+ assertInstanceOf(
+ MethodInvocationHandler.NotImplementedHandler.class, methodNode.getInvocationHandler());
+ }
+
+ // WHY: DefaultAccessController enforces UserExecutable on Call, so the no-manager baseline
+ // must reject a client Call with Bad_UserAccessDenied at the access-control gate rather than
+ // reaching the NotImplemented handler and answering Bad_NotImplemented.
+ @Test
+ void callingFindAliasWithoutManagerIsDeniedUserAccess() throws UaException {
+ CallMethodResult result = callFindAlias(NodeIds.Aliases, NodeIds.Aliases_FindAlias);
+
+ assertEquals(StatusCode.of(StatusCodes.Bad_UserAccessDenied), result.getStatusCode());
+ }
+ }
+
+ @Nested
+ class AfterStartup {
+
+ // WHY: startup must restore both executable flags on every standard FindAlias Node when it
+ // binds its handlers — UserExecutable is the flag access control enforces on Call, and
+ // Executable is what clients browse to discover the feature is present.
+ @Test
+ void startupRestoresExecutableFlagsOnStandardFindAliasNodes() {
+ AliasManager manager = new AliasManager(server, AliasManagerConfig.builder().build());
+ manager.startup();
+ try {
+ for (NodeId findAliasNodeId : STANDARD_FIND_ALIAS_NODE_IDS) {
+ UaMethodNode methodNode = serverMethodNode(findAliasNodeId);
+
+ assertTrue(methodNode.isExecutable());
+ assertTrue(methodNode.isUserExecutable());
+ }
+ } finally {
+ manager.shutdown();
+ }
+ }
+
+ // WHY: Part 17 §6.3.2 — FindAlias returns the array of matching aliases; a pattern that
+ // matches nothing yields an empty array in the output argument, not a null value or a Bad
+ // result.
+ @Test
+ void findAliasCallSucceedsWithEmptyResultArrayWhenNothingMatches() throws UaException {
+ AliasManager manager = new AliasManager(server, AliasManagerConfig.builder().build());
+ manager.startup();
+ try {
+ CallMethodResult result = callFindAlias(NodeIds.Aliases, NodeIds.Aliases_FindAlias);
+
+ assertEquals(StatusCode.GOOD, result.getStatusCode());
+
+ Variant[] outputs = requireNonNull(result.getOutputArguments());
+ assertEquals(1, outputs.length);
+
+ ExtensionObject[] aliasNodeList =
+ assertInstanceOf(ExtensionObject[].class, outputs[0].value());
+ assertEquals(0, aliasNodeList.length);
+ } finally {
+ manager.shutdown();
+ }
+ }
+
+ // WHY: the materialized FindAliasVerbose Node lives in the manager's own AddressSpace
+ // fragment, not in any application namespace; a client Read of its attributes and argument
+ // Properties pins that the fragment registration routes services to fragment-hosted Nodes,
+ // and a client Call pins that Call resolution finds the Method on the ns=0 Aliases Object.
+ @Test
+ void materializedFindAliasVerboseIsReadableAndCallableByClient() throws UaException {
+ AliasManager manager =
+ new AliasManager(
+ server, AliasManagerConfig.builder().findAliasVerboseEnabled(true).build());
+ manager.startup();
+ try {
+ DataValue browseName =
+ readAttribute(client, ALIASES_FIND_ALIAS_VERBOSE_NODE_ID, AttributeId.BrowseName);
+ assertEquals(StatusCode.GOOD, browseName.getStatusCode());
+ assertEquals(new QualifiedName(0, "FindAliasVerbose"), browseName.value().value());
+
+ // The argument Properties are created alongside the Method Node, with NodeIds derived
+ // from it, and must be readable through the fragment as well.
+ DataValue inputArguments =
+ readAttribute(
+ client,
+ new NodeId(1, "Aliases/FindAliasVerbose.InputArguments"),
+ AttributeId.Value);
+ assertEquals(StatusCode.GOOD, inputArguments.getStatusCode());
+ ExtensionObject[] inputArgumentsValue =
+ assertInstanceOf(ExtensionObject[].class, inputArguments.value().value());
+ assertEquals(2, inputArgumentsValue.length);
+
+ DataValue outputArguments =
+ readAttribute(
+ client,
+ new NodeId(1, "Aliases/FindAliasVerbose.OutputArguments"),
+ AttributeId.Value);
+ assertEquals(StatusCode.GOOD, outputArguments.getStatusCode());
+ ExtensionObject[] outputArgumentsValue =
+ assertInstanceOf(ExtensionObject[].class, outputArguments.value().value());
+ assertEquals(1, outputArgumentsValue.length);
+
+ CallMethodResult result =
+ callFindAlias(NodeIds.Aliases, ALIASES_FIND_ALIAS_VERBOSE_NODE_ID);
+ assertEquals(StatusCode.GOOD, result.getStatusCode());
+
+ Variant[] outputs = requireNonNull(result.getOutputArguments());
+ ExtensionObject[] aliasNodeList =
+ assertInstanceOf(ExtensionObject[].class, outputs[0].value());
+ assertEquals(0, aliasNodeList.length);
+ } finally {
+ manager.shutdown();
+ }
+ }
+ }
+
+ @Nested
+ class AfterShutdown {
+
+ // WHY: shutdown must return the standard FindAlias Nodes to the no-manager baseline —
+ // handlers unbound and flags cleared — so a client Call is again denied at the
+ // access-control gate exactly as before the manager existed.
+ @Test
+ void shutdownResetsHandlersAndFlagsSoCallIsDeniedAgain() throws UaException {
+ AliasManager manager = new AliasManager(server, AliasManagerConfig.builder().build());
+ manager.startup();
+ try {
+ CallMethodResult before = callFindAlias(NodeIds.Aliases, NodeIds.Aliases_FindAlias);
+ assertEquals(StatusCode.GOOD, before.getStatusCode());
+ } finally {
+ manager.shutdown();
+ }
+
+ assertNoManagerBaseline();
+
+ CallMethodResult after = callFindAlias(NodeIds.Aliases, NodeIds.Aliases_FindAlias);
+ assertEquals(StatusCode.of(StatusCodes.Bad_UserAccessDenied), after.getStatusCode());
+ }
+
+ // WHY: Node hosting rule — Nodes the manager creates in its own fragment leave the
+ // AddressSpace with the manager, so after shutdown a client Read of the materialized
+ // FindAliasVerbose Node must fail with Bad_NodeIdUnknown instead of exposing a Method with
+ // no behavior behind it.
+ @Test
+ void shutdownRemovesMaterializedFindAliasVerboseNodes() throws UaException {
+ AliasManager manager =
+ new AliasManager(
+ server, AliasManagerConfig.builder().findAliasVerboseEnabled(true).build());
+ manager.startup();
+ try {
+ DataValue whileRunning =
+ readAttribute(client, ALIASES_FIND_ALIAS_VERBOSE_NODE_ID, AttributeId.BrowseName);
+ assertEquals(StatusCode.GOOD, whileRunning.getStatusCode());
+ } finally {
+ manager.shutdown();
+ }
+
+ DataValue afterShutdown =
+ readAttribute(client, ALIASES_FIND_ALIAS_VERBOSE_NODE_ID, AttributeId.BrowseName);
+ assertEquals(StatusCode.of(StatusCodes.Bad_NodeIdUnknown), afterShutdown.getStatusCode());
+ }
+
+ // WHY: Node hosting rule — category and alias Nodes created through addCategory live in the
+ // application-supplied NodeManager, so they must remain in the AddressSpace after the manager
+ // shuts down; only the manager's behavior (handlers, flags, fragment Nodes) is removed.
+ @Test
+ void categoryAndAliasNodesCreatedInApplicationNamespaceSurviveShutdown() throws UaException {
+ var nodeManagerRef = new AtomicReference<@Nullable UaNodeManager>();
+ testNamespace.configure((context, nodeManager) -> nodeManagerRef.set(nodeManager));
+ UaNodeManager nodeManager = requireNonNull(nodeManagerRef.get());
+
+ AliasManager manager = new AliasManager(server, AliasManagerConfig.builder().build());
+ manager.startup();
+
+ NodeId categoryId = newNodeId("SurvivingCategory");
+ NodeId aliasNodeId = newNodeId("AliasCat/SurvivingAlias");
+ try {
+ try {
+ var categoryConfig =
+ new AliasCategoryConfig(
+ newNodeId("SurvivingCategory"),
+ NodeIds.Aliases,
+ newQualifiedName("SurvivingCategory"),
+ nodeManager,
+ name -> newNodeId("AliasCat/" + name),
+ false,
+ false,
+ false);
+
+ assertEquals(categoryId, manager.addCategory(categoryConfig).nodeId());
+
+ NodeId createdAliasNodeId =
+ manager.addAlias(
+ categoryId,
+ "SurvivingAlias",
+ List.of(
+ new AliasTarget(newNodeId("TestInt32").expanded(), null, NodeIds.AliasFor)));
+ assertEquals(aliasNodeId, createdAliasNodeId);
+ } finally {
+ manager.shutdown();
+ }
+
+ assertTrue(server.getAddressSpaceManager().getManagedNode(categoryId).isPresent());
+ assertTrue(server.getAddressSpaceManager().getManagedNode(aliasNodeId).isPresent());
+
+ DataValue categoryBrowseName = readAttribute(client, categoryId, AttributeId.BrowseName);
+ assertEquals(StatusCode.GOOD, categoryBrowseName.getStatusCode());
+ assertEquals(newQualifiedName("SurvivingCategory"), categoryBrowseName.value().value());
+
+ DataValue aliasBrowseName = readAttribute(client, aliasNodeId, AttributeId.BrowseName);
+ assertEquals(StatusCode.GOOD, aliasBrowseName.getStatusCode());
+ } finally {
+ // Delete the surviving Nodes so other tests observe an alias-free hierarchy; the alias
+ // first, so its category linkage is gone before the category (and its children) go.
+ server.getAddressSpaceManager().getManagedNode(aliasNodeId).ifPresent(UaNode::delete);
+ server.getAddressSpaceManager().getManagedNode(categoryId).ifPresent(UaNode::delete);
+ }
+ }
+ }
+
+ @Nested
+ class StartupFailure {
+
+ // WHY: startup validates every standard FindAlias Node for an application-bound handler
+ // before mutating anything, so a conflict must fail startup with IllegalStateException and
+ // leave no trace — flags unchanged, no other Node's handler touched, and the conflicting
+ // handler still in place.
+ @Test
+ void handlerConflictFailsStartupAndLeavesNoStateBehind() {
+ UaMethodNode conflictedNode = serverMethodNode(NodeIds.TagVariables_FindAlias);
+
+ var conflictingHandler =
+ new AbstractMethodInvocationHandler(conflictedNode) {
+ @Override
+ public Argument[] getInputArguments() {
+ return new Argument[0];
+ }
+
+ @Override
+ public Argument[] getOutputArguments() {
+ return new Argument[0];
+ }
+
+ @Override
+ protected Variant[] invoke(InvocationContext invocationContext, Variant[] inputValues) {
+ return new Variant[0];
+ }
+ };
+
+ conflictedNode.setInvocationHandler(conflictingHandler);
+ try {
+ AliasManager manager = new AliasManager(server, AliasManagerConfig.builder().build());
+
+ assertThrows(IllegalStateException.class, manager::startup);
+
+ // The conflicting handler is untouched and the other standard Nodes are still in the
+ // unbound baseline state.
+ assertSame(conflictingHandler, conflictedNode.getInvocationHandler());
+ assertFalse(conflictedNode.isExecutable());
+ assertFalse(conflictedNode.isUserExecutable());
+
+ for (NodeId nodeId : List.of(NodeIds.Aliases_FindAlias, NodeIds.Topics_FindAlias)) {
+ UaMethodNode methodNode = serverMethodNode(nodeId);
+
+ assertInstanceOf(
+ MethodInvocationHandler.NotImplementedHandler.class,
+ methodNode.getInvocationHandler());
+ assertFalse(methodNode.isExecutable());
+ assertFalse(methodNode.isUserExecutable());
+ }
+ } finally {
+ conflictedNode.setInvocationHandler(MethodInvocationHandler.NOT_IMPLEMENTED);
+ }
+
+ // A failed startup left nothing behind, so a fresh manager starts up cleanly afterwards.
+ AliasManager freshManager = new AliasManager(server, AliasManagerConfig.builder().build());
+ freshManager.startup();
+ try {
+ assertTrue(serverMethodNode(NodeIds.TagVariables_FindAlias).isUserExecutable());
+ } finally {
+ freshManager.shutdown();
+ }
+ }
+
+ // WHY: Part 17 §6.3.1 requires the root LastChange version to persist across restarts;
+ // starting with silently reset versions would leave client caches undetectably stale, so an
+ // unreadable version store must fail startup — and, failing in the mutation phase, must roll
+ // back to the no-manager baseline.
+ @Test
+ void versionStoreLoadFailureFailsStartupAndLeavesNoStateBehind() {
+ AliasManager manager =
+ new AliasManager(
+ server, AliasManagerConfig.builder().versionStore(failingVersionStore()).build());
+
+ IllegalStateException e = assertThrows(IllegalStateException.class, manager::startup);
+ assertInstanceOf(UaException.class, e.getCause());
+
+ assertNoManagerBaseline();
+ }
+
+ // WHY: a manager whose startup failed must be safely disposable — the lifecycle lands in
+ // the stopped state, so isNotRunning() reports the failure and a subsequent shutdown() is a
+ // silent no-op. Were shutdown() to run onShutdown against never-applied state, it would
+ // throw (the manager's AddressSpace fragment was never started here) and turn routine
+ // disposal into a second failure.
+ @Test
+ void failedStartupLeavesManagerStoppedAndSubsequentShutdownIsANoOp() {
+ AliasManager manager =
+ new AliasManager(
+ server, AliasManagerConfig.builder().versionStore(failingVersionStore()).build());
+
+ assertThrows(IllegalStateException.class, manager::startup);
+
+ assertTrue(manager.isNotRunning());
+
+ // The failed startup already rolled back, so there is nothing to undo: this must return
+ // silently without touching the AddressSpace.
+ manager.shutdown();
+
+ assertTrue(manager.isNotRunning());
+ assertNoManagerBaseline();
+ }
+
+ // WHY: the no-trace guarantee covers loaded versions too — loadPersisted must not publish to
+ // the LastChange Properties before every fallible startup step has succeeded, or a failed
+ // startup would leave persisted values published into the AddressSpace.
+ @Test
+ void failedStartupDoesNotPublishLoadedVersionsToLastChangeProperties() throws UaException {
+ var nodeManagerRef = new AtomicReference<@Nullable UaNodeManager>();
+ testNamespace.configure((context, nodeManager) -> nodeManagerRef.set(nodeManager));
+ UaNodeManager nodeManager = requireNonNull(nodeManagerRef.get());
+
+ NodeId categoryId = newNodeId("DeferralCategory");
+
+ // Setup: a first manager creates a category whose LastChange Property gets a published
+ // value, then shuts down; the category survives in the application namespace.
+ AliasManager setupManager = new AliasManager(server, AliasManagerConfig.builder().build());
+ setupManager.startup();
+ try {
+ setupManager.addCategory(
+ new AliasCategoryConfig(
+ categoryId,
+ NodeIds.Aliases,
+ newQualifiedName("DeferralCategory"),
+ nodeManager,
+ name -> newNodeId("DeferralCat/" + name),
+ true,
+ false,
+ false));
+ } finally {
+ setupManager.shutdown();
+ }
+
+ try {
+ UInteger publishedBefore = requireLastChange(server, categoryId);
+ UInteger farFuture = uint(4_000_000_000L);
+
+ // A store that claims a far-future persisted version for the category, has no entry for
+ // the root, and cannot save: startup loads the value and then fails at the root
+ // LastChange initialization — i.e. AFTER the load.
+ var store =
+ new AliasVersionStore() {
+ @Override
+ public Map load() {
+ return Map.of(categoryId.expanded(server.getNamespaceTable()), farFuture);
+ }
+
+ @Override
+ public void save(ExpandedNodeId id, UInteger value) throws UaException {
+ throw new UaException(StatusCodes.Bad_ResourceUnavailable, "save failed");
+ }
+ };
+
+ AliasManager manager =
+ new AliasManager(server, AliasManagerConfig.builder().versionStore(store).build());
+
+ assertThrows(IllegalStateException.class, manager::startup);
+
+ // The loaded far-future value was never published.
+ assertEquals(publishedBefore, requireLastChange(server, categoryId));
+ assertNoManagerBaseline();
+ } finally {
+ server.getAddressSpaceManager().getManagedNode(categoryId).ifPresent(UaNode::delete);
+ }
+ }
+
+ /** An {@link AliasVersionStore} whose load fails, simulating unreadable persisted state. */
+ private AliasVersionStore failingVersionStore() {
+ return new AliasVersionStore() {
+ @Override
+ public Map load() throws UaException {
+ throw new UaException(StatusCodes.Bad_InternalError, "persisted state unreadable");
+ }
+
+ @Override
+ public void save(ExpandedNodeId categoryId, UInteger value) {}
+ };
+ }
+ }
+
+ /** Resolve a standard Method Node from the running server, failing the test if it is absent. */
+ private UaMethodNode serverMethodNode(NodeId nodeId) {
+ UaNode node = server.getAddressSpaceManager().getManagedNode(nodeId).orElseThrow();
+
+ return assertInstanceOf(UaMethodNode.class, node);
+ }
+
+ /** Assert every standard {@code FindAlias} Node is in the unbound, non-executable baseline. */
+ private void assertNoManagerBaseline() {
+ for (NodeId nodeId : STANDARD_FIND_ALIAS_NODE_IDS) {
+ UaMethodNode methodNode = serverMethodNode(nodeId);
+
+ assertInstanceOf(
+ MethodInvocationHandler.NotImplementedHandler.class, methodNode.getInvocationHandler());
+ assertFalse(methodNode.isExecutable());
+ assertFalse(methodNode.isUserExecutable());
+ }
+ }
+
+ /**
+ * Call {@code FindAlias}/{@code FindAliasVerbose} on {@code objectId} through the client with
+ * pattern {@code "%"} and no ReferenceType filter.
+ */
+ private CallMethodResult callFindAlias(NodeId objectId, NodeId methodId) throws UaException {
+ CallResponse response =
+ client.call(
+ List.of(
+ new CallMethodRequest(
+ objectId,
+ methodId,
+ new Variant[] {new Variant("%"), new Variant(NodeId.NULL_VALUE)})));
+
+ return requireNonNull(response.getResults())[0];
+ }
+}
diff --git a/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/aliases/AliasMutationTest.java b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/aliases/AliasMutationTest.java
new file mode 100644
index 0000000000..0637814e5d
--- /dev/null
+++ b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/aliases/AliasMutationTest.java
@@ -0,0 +1,727 @@
+/*
+ * Copyright (c) 2026 the Eclipse Milo Authors
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+
+package org.eclipse.milo.opcua.sdk.test.aliases;
+
+import static org.eclipse.milo.opcua.sdk.test.aliases.AliasTestSupport.assertStrictlyGreater;
+import static org.eclipse.milo.opcua.sdk.test.aliases.AliasTestSupport.readAttribute;
+import static org.eclipse.milo.opcua.sdk.test.aliases.AliasTestSupport.readLastChange;
+import static org.eclipse.milo.opcua.sdk.test.aliases.AliasTestSupport.requireLastChange;
+import static org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.Unsigned.uint;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Stream;
+import org.eclipse.milo.opcua.sdk.core.Reference;
+import org.eclipse.milo.opcua.sdk.server.UaNodeManager;
+import org.eclipse.milo.opcua.sdk.server.aliases.AliasCategory;
+import org.eclipse.milo.opcua.sdk.server.aliases.AliasCategoryConfig;
+import org.eclipse.milo.opcua.sdk.server.aliases.AliasManager;
+import org.eclipse.milo.opcua.sdk.server.aliases.AliasManagerConfig;
+import org.eclipse.milo.opcua.sdk.server.aliases.AliasTarget;
+import org.eclipse.milo.opcua.sdk.server.nodes.UaNode;
+import org.eclipse.milo.opcua.sdk.test.AbstractClientServerTest;
+import org.eclipse.milo.opcua.stack.core.AttributeId;
+import org.eclipse.milo.opcua.stack.core.NodeIds;
+import org.eclipse.milo.opcua.stack.core.StatusCodes;
+import org.eclipse.milo.opcua.stack.core.UaException;
+import org.eclipse.milo.opcua.stack.core.types.builtin.DataValue;
+import org.eclipse.milo.opcua.stack.core.types.builtin.LocalizedText;
+import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId;
+import org.eclipse.milo.opcua.stack.core.types.builtin.QualifiedName;
+import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UInteger;
+import org.eclipse.milo.opcua.stack.core.types.enumerated.BrowseDirection;
+import org.eclipse.milo.opcua.stack.core.types.enumerated.BrowseResultMask;
+import org.eclipse.milo.opcua.stack.core.types.enumerated.TimestampsToReturn;
+import org.eclipse.milo.opcua.stack.core.types.structured.AliasNameDataType;
+import org.eclipse.milo.opcua.stack.core.types.structured.BrowseDescription;
+import org.eclipse.milo.opcua.stack.core.types.structured.BrowseResult;
+import org.eclipse.milo.opcua.stack.core.types.structured.ReadResponse;
+import org.eclipse.milo.opcua.stack.core.types.structured.ReadValueId;
+import org.eclipse.milo.opcua.stack.core.types.structured.ReferenceDescription;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+/**
+ * Integration tests for {@link AliasManager}'s programmatic mutation API ({@code addAlias}, {@code
+ * deleteAlias}, {@code addCategory}, {@code removeCategory}, {@code touch}) and the {@code
+ * LastChange} version invariant, exercised against a running client/server pair.
+ *
+ * The manager is started once for the class, after the server, and shut down before it; tests
+ * use per-test alias and category names, and assert version changes relative to values captured
+ * within the same test, so they are order-independent.
+ */
+// Fields below are assigned in @BeforeAll, which the nullability inspection does not model.
+@SuppressWarnings("NotNullFieldNotInitialized")
+class AliasMutationTest extends AbstractClientServerTest {
+
+ private RecordingVersionStore versionStore;
+ private AliasManager aliasManager;
+ private UaNodeManager testNodeManager;
+
+ @BeforeAll
+ void startAliasManager() {
+ testNamespace.configure((context, nodeManager) -> testNodeManager = nodeManager);
+
+ versionStore = new RecordingVersionStore(server);
+
+ AliasManagerConfig config =
+ AliasManagerConfig.builder()
+ .versionStore(versionStore)
+ .nodeNamespaceIndex(testNamespace.getNamespaceIndex())
+ .build();
+
+ aliasManager = new AliasManager(server, config);
+ aliasManager.startup();
+ }
+
+ @AfterAll
+ void shutdownAliasManager() {
+ aliasManager.shutdown();
+ }
+
+ @Nested
+ class AddAlias {
+
+ // WHY: pins the AddressSpace-fragment hosting rule: alias Nodes created in a standard (ns=0)
+ // category live in the manager's own fragment, and Read and Browse service calls must still
+ // route to them (Part 17 §6.3.1 alias model; fragment registration design invariant).
+ @Test
+ void addAliasToStandardCategoryCreatesClientBrowseableAliasNode() throws Exception {
+ NodeId target = newNodeId("TestInt32");
+
+ NodeId aliasNodeId =
+ aliasManager.addAlias(NodeIds.Aliases, "BrowseableAlias", List.of(aliasFor(target)));
+
+ // The client can Read the alias Node's BrowseName attribute.
+ ReadResponse response =
+ client.read(
+ 0.0,
+ TimestampsToReturn.Neither,
+ List.of(
+ new ReadValueId(
+ aliasNodeId, AttributeId.BrowseName.uid(), null, QualifiedName.NULL_VALUE)));
+
+ DataValue[] results = response.getResults();
+ assertNotNull(results);
+ DataValue browseNameValue = results[0];
+ assertTrue(
+ browseNameValue.statusCode().isGood(),
+ () -> "BrowseName read failed: " + browseNameValue.statusCode());
+ assertEquals(
+ new QualifiedName(aliasNodeId.getNamespaceIndex(), "BrowseableAlias"),
+ browseNameValue.value().value());
+
+ // The client can Browse the alias Node's forward AliasFor Reference to the target.
+ BrowseResult aliasBrowse =
+ client.browse(
+ new BrowseDescription(
+ aliasNodeId,
+ BrowseDirection.Forward,
+ NodeIds.AliasFor,
+ true,
+ uint(0),
+ uint(BrowseResultMask.All.getValue())));
+
+ assertTrue(
+ aliasBrowse.getStatusCode().isGood(),
+ () -> "Browse failed: " + aliasBrowse.getStatusCode());
+ ReferenceDescription[] aliasReferences = aliasBrowse.getReferences();
+ assertNotNull(aliasReferences);
+ assertEquals(1, aliasReferences.length);
+ assertEquals(
+ target,
+ aliasReferences[0].getNodeId().toNodeId(server.getNamespaceTable()).orElseThrow());
+
+ // Browsing the standard category shows the alias, even though the category Node and the
+ // alias Node live in different NodeManagers (composite Reference gathering).
+ BrowseResult categoryBrowse =
+ client.browse(
+ new BrowseDescription(
+ NodeIds.Aliases,
+ BrowseDirection.Forward,
+ NodeIds.Organizes,
+ true,
+ uint(0),
+ uint(BrowseResultMask.All.getValue())));
+
+ assertTrue(
+ categoryBrowse.getStatusCode().isGood(),
+ () -> "Browse failed: " + categoryBrowse.getStatusCode());
+ ReferenceDescription[] categoryReferences = categoryBrowse.getReferences();
+ assertNotNull(categoryReferences);
+ boolean organized =
+ Stream.of(categoryReferences)
+ .anyMatch(
+ reference ->
+ aliasNodeId.equals(
+ reference.getNodeId().toNodeId(server.getNamespaceTable()).orElse(null)));
+ assertTrue(organized, "Aliases category does not organize the created alias Node");
+ }
+
+ // WHY: Part 17 §6.2 — "The string part of the BrowseName shall be the DisplayName with an
+ // empty locale id and no other locale shall be provided." A locale like "en" violates the
+ // spec and breaks Clients that compare the DisplayName as a whole LocalizedText.
+ @Test
+ void aliasDisplayNameIsBrowseNameTextWithEmptyLocale() throws Exception {
+ NodeId aliasNodeId =
+ aliasManager.addAlias(
+ NodeIds.Aliases, "EmptyLocaleAlias", List.of(aliasFor(newNodeId("TestInt32"))));
+
+ DataValue value = readAttribute(client, aliasNodeId, AttributeId.DisplayName);
+ Object rawDisplayName = value.value().value();
+ assertNotNull(rawDisplayName);
+ LocalizedText displayName = (LocalizedText) rawDisplayName;
+
+ assertEquals("EmptyLocaleAlias", displayName.text());
+ assertNull(displayName.locale(), "Part 17 §6.2 requires an empty locale id");
+ }
+
+ // WHY: Part 17 §6.3.4 — adding an alias that already exists with the same target is not a
+ // change; the same Node must be reused and LastChange must not advance (no store write, no
+ // Property change).
+ @Test
+ void identicalReAddReturnsSameNodeIdWithoutBumpingLastChange() throws Exception {
+ NodeId target = newNodeId("TestInt32");
+
+ NodeId first =
+ aliasManager.addAlias(NodeIds.Aliases, "IdempotentAlias", List.of(aliasFor(target)));
+
+ UInteger before = requireLastChange(server, NodeIds.Aliases);
+ int savesBefore = versionStore.saves().size();
+
+ NodeId second =
+ aliasManager.addAlias(NodeIds.Aliases, "IdempotentAlias", List.of(aliasFor(target)));
+
+ assertEquals(first, second);
+ assertEquals(before, readLastChange(server, NodeIds.Aliases));
+ assertEquals(savesBefore, versionStore.saves().size());
+ }
+
+ // WHY: Part 17 §6.3.4 — adding an existing alias name with a new target extends the alias
+ // with another AliasFor Reference, which is an AddressSpace change and must bump LastChange
+ // (§6.3.1: LastChange covers changes to alias References).
+ @Test
+ void addingNewTargetToExistingAliasAddsReferenceAndBumpsLastChange() throws Exception {
+ NodeId firstTarget = newNodeId("TestInt32");
+ NodeId secondTarget = newNodeId("TestAnalogValue");
+
+ NodeId aliasNodeId =
+ aliasManager.addAlias(NodeIds.Aliases, "ExtendedAlias", List.of(aliasFor(firstTarget)));
+
+ UInteger before = requireLastChange(server, NodeIds.Aliases);
+
+ NodeId again =
+ aliasManager.addAlias(NodeIds.Aliases, "ExtendedAlias", List.of(aliasFor(secondTarget)));
+
+ assertEquals(aliasNodeId, again);
+ assertEquals(2, aliasForReferences(aliasNodeId).size());
+ assertStrictlyGreater(before, readLastChange(server, NodeIds.Aliases));
+ }
+ }
+
+ @Nested
+ @TestInstance(TestInstance.Lifecycle.PER_CLASS)
+ class AddAliasValidation {
+
+ // WHY: Part 17 §6.3.4 defines the per-alias failure statuses for invalid add requests, and
+ // Part 17 §9.3 restricts TagVariables to aliases whose AliasFor References point to
+ // Variables; the programmatic path must reject each case with the corresponding status.
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("invalidTargets")
+ void addAliasRejectsInvalidTarget(
+ String description,
+ NodeId categoryId,
+ String aliasName,
+ AliasTarget target,
+ long expectedStatus) {
+
+ UaException e =
+ assertThrows(
+ UaException.class,
+ () -> aliasManager.addAlias(categoryId, aliasName, List.of(target)));
+
+ assertEquals(expectedStatus, e.getStatusCode().getValue(), description);
+ }
+
+ Stream invalidTargets() {
+ return Stream.of(
+ Arguments.of(
+ "non-AliasFor ReferenceType is rejected",
+ NodeIds.Aliases,
+ "InvalidRefTypeAlias",
+ new AliasTarget(newNodeId("TestInt32").expanded(), null, NodeIds.Organizes),
+ StatusCodes.Bad_InvalidArgument),
+ Arguments.of(
+ "unknown local target is rejected",
+ NodeIds.Aliases,
+ "UnknownTargetAlias",
+ aliasFor(newNodeId("NoSuchTargetNode")),
+ StatusCodes.Bad_NodeIdUnknown),
+ Arguments.of(
+ "remote target is rejected",
+ NodeIds.Aliases,
+ "RemoteTargetAlias",
+ new AliasTarget(
+ newNodeId("TestInt32").expanded(), "urn:remote:server", NodeIds.AliasFor),
+ StatusCodes.Bad_NotSupported),
+ Arguments.of(
+ "Method target under TagVariables violates the NodeClass constraint",
+ NodeIds.TagVariables,
+ "MethodTagAlias",
+ aliasFor(newNodeId("sqrt(x)")),
+ StatusCodes.Bad_InvalidArgument));
+ }
+
+ // WHY: Part 17 §9.3 — TagVariables restricts alias targets to Variables; a Variable target
+ // is the allowed case and must succeed, proving the constraint rejects NodeClass, not the
+ // category itself.
+ @Test
+ void variableTargetUnderTagVariablesIsAccepted() throws Exception {
+ NodeId aliasNodeId =
+ aliasManager.addAlias(
+ NodeIds.TagVariables, "VariableTagAlias", List.of(aliasFor(newNodeId("TestInt32"))));
+
+ assertTrue(server.getAddressSpaceManager().getManagedNode(aliasNodeId).isPresent());
+ }
+ }
+
+ @Nested
+ class DeleteAlias {
+
+ // WHY: Part 17 §6.3.5 delete semantics — removing one explicit target only removes that
+ // AliasFor Reference; the alias Object survives while other targets remain, and the change
+ // bumps the category's LastChange (§6.3.1).
+ @Test
+ void removingOneExplicitTargetKeepsAliasNodeWhileAnotherTargetRemains() throws Exception {
+ NodeId keptTarget = newNodeId("TestInt32");
+ NodeId removedTarget = newNodeId("TestAnalogValue");
+
+ NodeId aliasNodeId =
+ aliasManager.addAlias(
+ NodeIds.Aliases,
+ "PartialDeleteAlias",
+ List.of(aliasFor(keptTarget), aliasFor(removedTarget)));
+
+ UInteger before = requireLastChange(server, NodeIds.Aliases);
+
+ aliasManager.deleteAlias(
+ NodeIds.Aliases, "PartialDeleteAlias", List.of(aliasFor(removedTarget)));
+
+ assertTrue(server.getAddressSpaceManager().getManagedNode(aliasNodeId).isPresent());
+
+ List remaining = aliasForReferences(aliasNodeId);
+ assertEquals(1, remaining.size());
+ assertEquals(keptTarget.expanded(), remaining.get(0).getTargetNodeId());
+
+ assertStrictlyGreater(before, readLastChange(server, NodeIds.Aliases));
+ }
+
+ // WHY: Part 17 §6.3.1 — an alias without at least one AliasFor Reference violates the model,
+ // so removing the last target deletes the alias Object from EVERY organizing category, and
+ // every one of those categories (plus their ancestors) gets a LastChange bump.
+ @Test
+ void removingLastTargetDeletesAliasFromEveryOrganizingCategoryAndBumpsEach() throws Exception {
+ AliasCategory catA = aliasManager.addCategory(categoryConfig("DeleteCatA", NodeIds.Aliases));
+ AliasCategory catB = aliasManager.addCategory(categoryConfig("DeleteCatB", NodeIds.Aliases));
+
+ NodeId target = newNodeId("TestInt32");
+ NodeId aliasNodeId =
+ aliasManager.addAlias(catA.nodeId(), "SharedAlias", List.of(aliasFor(target)));
+
+ // Organize the alias by a second category, the multi-parent arrangement §6.3.1 allows.
+ UaNode aliasNode = server.getAddressSpaceManager().getManagedNode(aliasNodeId).orElseThrow();
+ aliasNode.addReference(
+ new Reference(
+ aliasNodeId,
+ NodeIds.Organizes,
+ catB.nodeId().expanded(),
+ Reference.Direction.INVERSE));
+
+ UInteger rootBefore = requireLastChange(server, NodeIds.Aliases);
+ UInteger catABefore = requireLastChange(server, catA.nodeId());
+ UInteger catBBefore = requireLastChange(server, catB.nodeId());
+
+ aliasManager.deleteAlias(catA.nodeId(), "SharedAlias", List.of(aliasFor(target)));
+
+ assertTrue(server.getAddressSpaceManager().getManagedNode(aliasNodeId).isEmpty());
+ assertTrue(aliasManager.findAlias(catB.nodeId(), "SharedAlias", null).isEmpty());
+
+ assertStrictlyGreater(catABefore, readLastChange(server, catA.nodeId()));
+ assertStrictlyGreater(catBBefore, readLastChange(server, catB.nodeId()));
+ assertStrictlyGreater(rootBefore, readLastChange(server, NodeIds.Aliases));
+ }
+
+ // WHY: only alias linkage (AliasFor or a subtype) may be removed through deleteAlias; a
+ // caller-supplied structural ReferenceType like HasTypeDefinition must be rejected with
+ // Bad_InvalidArgument BEFORE anything is removed, or the alias Node would be corrupted and
+ // become invisible to lookup (Part 17 §6.3.1 model integrity).
+ @Test
+ void nonAliasForReferenceTypeIsRejectedAndAliasNodeIsLeftUndamaged() throws Exception {
+ NodeId target = newNodeId("TestInt32");
+
+ NodeId aliasNodeId =
+ aliasManager.addAlias(NodeIds.Aliases, "GuardedAlias", List.of(aliasFor(target)));
+
+ UInteger before = requireLastChange(server, NodeIds.Aliases);
+
+ // The malicious shape: the alias's own HasTypeDefinition Reference expressed as a target.
+ UaException e =
+ assertThrows(
+ UaException.class,
+ () ->
+ aliasManager.deleteAlias(
+ NodeIds.Aliases,
+ "GuardedAlias",
+ List.of(
+ new AliasTarget(
+ NodeIds.AliasNameType.expanded(), null, NodeIds.HasTypeDefinition))));
+
+ assertEquals(StatusCodes.Bad_InvalidArgument, e.getStatusCode().getValue());
+
+ // The HasTypeDefinition Reference is intact.
+ List typeDefinitions =
+ server
+ .getAddressSpaceManager()
+ .getManagedReferences(aliasNodeId, Reference.HAS_TYPE_DEFINITION_PREDICATE);
+ assertEquals(1, typeDefinitions.size());
+ assertEquals(NodeIds.AliasNameType.expanded(), typeDefinitions.get(0).getTargetNodeId());
+
+ // The alias is still found by lookup (which depends on the type definition)...
+ List found = aliasManager.findAlias(NodeIds.Aliases, "GuardedAlias", null);
+ assertEquals(1, found.size());
+ assertEquals("GuardedAlias", found.get(0).getAliasName().name());
+
+ // ...and by the idempotent add path, which returns the same undamaged Node.
+ NodeId reFound =
+ aliasManager.addAlias(NodeIds.Aliases, "GuardedAlias", List.of(aliasFor(target)));
+ assertEquals(aliasNodeId, reFound);
+
+ // Nothing changed, so nothing bumped.
+ assertEquals(before, readLastChange(server, NodeIds.Aliases));
+ }
+ }
+
+ @Nested
+ class AddCategory {
+
+ // WHY: AliasCategoryConfig's aliasNodeIdFactory is reserved for alias Nodes — the category
+ // Node's NodeId comes from categoryNodeId — so category creation must not consume a factory
+ // invocation. A stateful (e.g. sequence-allocating) factory stays aligned with the aliases
+ // actually created, and an alias may share its name with its category without colliding on
+ // the category's NodeId.
+ @Test
+ void addCategoryDoesNotInvokeAliasNodeIdFactoryAndAliasMayShareCategoryName() throws Exception {
+ var factoryInvocations = new AtomicInteger();
+ NodeId categoryId = newNodeId("FactoryReservedCat");
+
+ AliasCategory category =
+ aliasManager.addCategory(
+ new AliasCategoryConfig(
+ categoryId,
+ NodeIds.Aliases,
+ newQualifiedName("FactoryReservedCat"),
+ testNodeManager,
+ aliasName -> {
+ factoryInvocations.incrementAndGet();
+ return newNodeId("FactoryReservedCat/alias/" + aliasName);
+ },
+ false,
+ false,
+ false));
+ try {
+ assertEquals(categoryId, category.nodeId());
+ assertEquals(
+ 0,
+ factoryInvocations.get(),
+ "addCategory must not invoke the aliasNodeIdFactory for the category Node");
+
+ // An alias whose name is identical to the category's name text.
+ NodeId aliasNodeId =
+ aliasManager.addAlias(
+ categoryId, "FactoryReservedCat", List.of(aliasFor(newNodeId("TestInt32"))));
+
+ assertEquals(1, factoryInvocations.get());
+ assertNotEquals(categoryId, aliasNodeId);
+ assertTrue(server.getAddressSpaceManager().getManagedNode(aliasNodeId).isPresent());
+ } finally {
+ server
+ .getAddressSpaceManager()
+ .getManagedNode(newNodeId("FactoryReservedCat/alias/FactoryReservedCat"))
+ .ifPresent(UaNode::delete);
+ aliasManager.removeCategory(categoryId);
+ }
+ }
+ }
+
+ @Nested
+ class RemoveCategory {
+
+ // WHY: removeCategory's contract keeps after-added alias Nodes alive while deleting the
+ // created category Node; an Organizes linkage left behind would be a Reference targeting a
+ // deleted NodeId — dangling for Browse, and silently reattaching the alias if a category
+ // with the same NodeId were created later — so the manager must detach every directly
+ // organized alias from both sides before deleting the category.
+ @Test
+ void removeCategoryDetachesSurvivingAliasesFromTheDeletedCategory() throws Exception {
+ AliasCategory category =
+ aliasManager.addCategory(categoryConfig("DetachCat", NodeIds.Aliases));
+ NodeId categoryId = category.nodeId();
+
+ NodeId aliasNodeId =
+ aliasManager.addAlias(
+ categoryId, "DetachedAlias", List.of(aliasFor(newNodeId("TestInt32"))));
+ try {
+ aliasManager.removeCategory(categoryId);
+
+ // The category Node is gone, but the alias Node survives, per the contract.
+ assertTrue(server.getAddressSpaceManager().getManagedNode(categoryId).isEmpty());
+ assertTrue(server.getAddressSpaceManager().getManagedNode(aliasNodeId).isPresent());
+
+ // No Reference on the alias Node targets the deleted category NodeId anymore.
+ List danglingReferences =
+ server
+ .getAddressSpaceManager()
+ .getManagedReferences(
+ aliasNodeId,
+ reference ->
+ categoryId.equals(
+ reference
+ .getTargetNodeId()
+ .toNodeId(server.getNamespaceTable())
+ .orElse(null)));
+ assertEquals(List.of(), danglingReferences);
+ } finally {
+ server.getAddressSpaceManager().getManagedNode(aliasNodeId).ifPresent(UaNode::delete);
+ if (server.getAddressSpaceManager().getManagedNode(categoryId).isPresent()) {
+ aliasManager.removeCategory(categoryId);
+ }
+ }
+ }
+ }
+
+ @Nested
+ class LastChange {
+
+ // WHY: Part 17 §9.2 — the LastChange Property is mandatory on the root Aliases instance; the
+ // manager must initialize (or restore) it at startup and seed the persistent store.
+ @Test
+ void rootAliasesLastChangeIsNonNullAfterStartup() {
+ assertNotNull(readLastChange(server, NodeIds.Aliases));
+ assertTrue(
+ versionStore.load().containsKey(NodeIds.Aliases.expanded(server.getNamespaceTable())));
+ }
+
+ // WHY: Part 4 §7.43 — VersionTime values must let a Client detect every change, so each
+ // mutation must produce a strictly greater value even within the same wall-clock second.
+ @Test
+ void everyMutationBumpsRootLastChangeStrictlyMonotonically() throws Exception {
+ NodeId firstTarget = newNodeId("TestInt32");
+ NodeId secondTarget = newNodeId("TestAnalogValue");
+
+ var observed = new ArrayList();
+ observed.add(requireLastChange(server, NodeIds.Aliases));
+
+ aliasManager.addAlias(NodeIds.Aliases, "MonotonicAlias", List.of(aliasFor(firstTarget)));
+ observed.add(requireLastChange(server, NodeIds.Aliases));
+
+ aliasManager.addAlias(NodeIds.Aliases, "MonotonicAlias", List.of(aliasFor(secondTarget)));
+ observed.add(requireLastChange(server, NodeIds.Aliases));
+
+ aliasManager.deleteAlias(NodeIds.Aliases, "MonotonicAlias", List.of(aliasFor(secondTarget)));
+ observed.add(requireLastChange(server, NodeIds.Aliases));
+
+ aliasManager.deleteAlias(NodeIds.Aliases, "MonotonicAlias", null);
+ observed.add(requireLastChange(server, NodeIds.Aliases));
+
+ for (int i = 0; i < observed.size() - 1; i++) {
+ UInteger earlier = observed.get(i);
+ UInteger later = observed.get(i + 1);
+ assertTrue(
+ later.longValue() > earlier.longValue(),
+ () -> "expected strictly increasing LastChange, got " + observed);
+ }
+ }
+
+ // WHY: Part 17 §6.3.1 — LastChange reflects changes to the category "or any AliasNames below
+ // it", so a mutation in a nested category must bump every category on the Organizes chain up
+ // to the root Aliases Object.
+ @Test
+ void addAliasInNestedCategoryBumpsParentChainUpToRoot() throws Exception {
+ AliasCategory child = aliasManager.addCategory(categoryConfig("PropChild", NodeIds.Aliases));
+ AliasCategory grandchild =
+ aliasManager.addCategory(categoryConfig("PropGrandchild", child.nodeId()));
+
+ UInteger rootBefore = requireLastChange(server, NodeIds.Aliases);
+ UInteger childBefore = requireLastChange(server, child.nodeId());
+ UInteger grandchildBefore = requireLastChange(server, grandchild.nodeId());
+
+ aliasManager.addAlias(
+ grandchild.nodeId(), "PropagationAlias", List.of(aliasFor(newNodeId("TestInt32"))));
+
+ assertStrictlyGreater(grandchildBefore, readLastChange(server, grandchild.nodeId()));
+ assertStrictlyGreater(childBefore, readLastChange(server, child.nodeId()));
+ assertStrictlyGreater(rootBefore, readLastChange(server, NodeIds.Aliases));
+ }
+
+ // WHY: Part 17 §9.2 requires the root LastChange value to survive restarts; persisting AFTER
+ // publishing would open a window where a Client caches a version that is lost by a crash and
+ // silently regresses. The store must therefore see each value before the Property does.
+ @Test
+ void lastChangeIsPersistedToStoreBeforeThePropertyPublishesIt() throws Exception {
+ int savesBefore = versionStore.saves().size();
+
+ aliasManager.touch(NodeIds.Aliases);
+
+ List newSaves =
+ versionStore.saves().subList(savesBefore, versionStore.saves().size()).stream()
+ .filter(record -> NodeIds.Aliases.equals(record.categoryId()))
+ .toList();
+
+ assertEquals(1, newSaves.size());
+ RecordingVersionStore.SaveRecord record = newSaves.get(0);
+
+ // At save time the Property still showed the previous value...
+ assertNotEquals(record.value(), record.lastChangeAtSave());
+ // ...and afterwards the Property shows exactly the persisted value.
+ assertEquals(record.value(), readLastChange(server, NodeIds.Aliases));
+ assertEquals(record.value(), versionStore.load().get(record.storeKey()));
+
+ // The store key is namespace-URI-qualified, so the persisted entry stays valid across
+ // restarts even when the namespace table assigns the namespace a different index.
+ assertTrue(
+ record.storeKey().isAbsolute(),
+ () -> "expected a URI-qualified store key, got " + record.storeKey().toParseableString());
+ }
+
+ // WHY: Part 17 §6.3.1 — removing a category is a change below its ancestors, so the
+ // surviving ancestor chain must be bumped even though the removed category itself is gone.
+ @Test
+ void removeCategoryBumpsSurvivingAncestorCategories() throws Exception {
+ AliasCategory category =
+ aliasManager.addCategory(categoryConfig("RemovableCategory", NodeIds.Aliases));
+
+ UInteger rootBefore = requireLastChange(server, NodeIds.Aliases);
+
+ aliasManager.removeCategory(category.nodeId());
+
+ assertTrue(server.getAddressSpaceManager().getManagedNode(category.nodeId()).isEmpty());
+ assertStrictlyGreater(rootBefore, readLastChange(server, NodeIds.Aliases));
+
+ // Removal also deletes the category's persisted entry, so durable stores do not
+ // accumulate entries for categories that no longer exist.
+ assertFalse(
+ versionStore.load().containsKey(category.nodeId().expanded(server.getNamespaceTable())));
+ }
+
+ // WHY: a target change is observable from EVERY category that organizes the alias, not just
+ // the one the mutation was addressed to (design ambiguity-resolution principle; Part 17
+ // §6.3.1's LastChange contract covers changes to the aliases below a category), so adding or
+ // removing a target through one organizing category must bump the other organizing
+ // category's LastChange too.
+ @Test
+ void targetChangeThroughOneCategoryBumpsLastChangeOfEveryOrganizingCategory() throws Exception {
+ AliasCategory catA =
+ aliasManager.addCategory(categoryConfig("SharedTargetCatA", NodeIds.Aliases));
+ AliasCategory catB =
+ aliasManager.addCategory(categoryConfig("SharedTargetCatB", NodeIds.Aliases));
+
+ NodeId keptTarget = newNodeId("TestInt32");
+ NodeId extraTarget = newNodeId("TestAnalogValue");
+
+ NodeId aliasNodeId =
+ aliasManager.addAlias(catA.nodeId(), "SharedTargetAlias", List.of(aliasFor(keptTarget)));
+ try {
+ // Organize the alias by a second category, the multi-parent arrangement §6.3.1 allows.
+ UaNode aliasNode =
+ server.getAddressSpaceManager().getManagedNode(aliasNodeId).orElseThrow();
+ aliasNode.addReference(
+ new Reference(
+ aliasNodeId,
+ NodeIds.Organizes,
+ catB.nodeId().expanded(),
+ Reference.Direction.INVERSE));
+
+ UInteger catBBeforeAdd = requireLastChange(server, catB.nodeId());
+
+ aliasManager.addAlias(catA.nodeId(), "SharedTargetAlias", List.of(aliasFor(extraTarget)));
+
+ assertStrictlyGreater(catBBeforeAdd, readLastChange(server, catB.nodeId()));
+
+ UInteger catBBeforeDelete = requireLastChange(server, catB.nodeId());
+
+ aliasManager.deleteAlias(
+ catA.nodeId(), "SharedTargetAlias", List.of(aliasFor(extraTarget)));
+
+ // The alias survives with its remaining target — this was a target change, not a
+ // deletion — and the un-addressed organizing category is still bumped.
+ assertTrue(server.getAddressSpaceManager().getManagedNode(aliasNodeId).isPresent());
+ assertEquals(1, aliasForReferences(aliasNodeId).size());
+ assertStrictlyGreater(catBBeforeDelete, readLastChange(server, catB.nodeId()));
+ } finally {
+ server.getAddressSpaceManager().getManagedNode(aliasNodeId).ifPresent(UaNode::delete);
+ aliasManager.removeCategory(catA.nodeId());
+ aliasManager.removeCategory(catB.nodeId());
+ }
+ }
+
+ // WHY: touch is the documented escape hatch for out-of-band AddressSpace edits (Part 17
+ // §6.3.1 requires LastChange to cover them); it must bump even though the manager itself
+ // observed no mutation.
+ @Test
+ void touchBumpsCategoryLastChange() throws Exception {
+ UInteger before = requireLastChange(server, NodeIds.Aliases);
+
+ aliasManager.touch(NodeIds.Aliases);
+
+ assertStrictlyGreater(before, readLastChange(server, NodeIds.Aliases));
+ }
+ }
+
+ private AliasTarget aliasFor(NodeId targetNodeId) {
+ return new AliasTarget(targetNodeId.expanded(), null, NodeIds.AliasFor);
+ }
+
+ private AliasCategoryConfig categoryConfig(String name, NodeId parentCategoryId) {
+ return new AliasCategoryConfig(
+ newNodeId(name),
+ parentCategoryId,
+ newQualifiedName(name),
+ testNodeManager,
+ aliasName -> newNodeId(name + "/" + aliasName),
+ true,
+ false,
+ false);
+ }
+
+ /** The forward {@code AliasFor} References of the alias Node, across all NodeManagers. */
+ private List aliasForReferences(NodeId aliasNodeId) {
+ return server
+ .getAddressSpaceManager()
+ .getManagedReferences(
+ aliasNodeId,
+ reference ->
+ reference.isForward() && NodeIds.AliasFor.equals(reference.getReferenceTypeId()));
+ }
+}
diff --git a/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/aliases/AliasTestSupport.java b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/aliases/AliasTestSupport.java
new file mode 100644
index 0000000000..b6ed67105c
--- /dev/null
+++ b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/aliases/AliasTestSupport.java
@@ -0,0 +1,78 @@
+/*
+ * Copyright (c) 2026 the Eclipse Milo Authors
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+
+package org.eclipse.milo.opcua.sdk.test.aliases;
+
+import static java.util.Objects.requireNonNull;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+import org.eclipse.milo.opcua.sdk.client.OpcUaClient;
+import org.eclipse.milo.opcua.sdk.server.OpcUaServer;
+import org.eclipse.milo.opcua.sdk.server.model.objects.AliasNameCategoryType;
+import org.eclipse.milo.opcua.stack.core.AttributeId;
+import org.eclipse.milo.opcua.stack.core.UaException;
+import org.eclipse.milo.opcua.stack.core.types.builtin.DataValue;
+import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId;
+import org.eclipse.milo.opcua.stack.core.types.builtin.QualifiedName;
+import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UInteger;
+import org.eclipse.milo.opcua.stack.core.types.enumerated.TimestampsToReturn;
+import org.eclipse.milo.opcua.stack.core.types.structured.ReadResponse;
+import org.eclipse.milo.opcua.stack.core.types.structured.ReadValueId;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Static helpers shared by the alias integration tests: client-side attribute reads and server-side
+ * {@code LastChange} Property access and assertions.
+ */
+final class AliasTestSupport {
+
+ private AliasTestSupport() {}
+
+ /** Read a single attribute of a Node through the client. */
+ static DataValue readAttribute(OpcUaClient client, NodeId nodeId, AttributeId attributeId)
+ throws UaException {
+ ReadResponse response =
+ client.read(
+ 0.0,
+ TimestampsToReturn.Neither,
+ List.of(new ReadValueId(nodeId, attributeId.uid(), null, QualifiedName.NULL_VALUE)));
+
+ return requireNonNull(response.getResults())[0];
+ }
+
+ /** Read the category's LastChange Property, asserting that it has one with a non-null value. */
+ static UInteger requireLastChange(OpcUaServer server, NodeId categoryId) {
+ UInteger value = readLastChange(server, categoryId);
+ assertNotNull(value, () -> "no LastChange value on " + categoryId.toParseableString());
+ return value;
+ }
+
+ static void assertStrictlyGreater(UInteger before, @Nullable UInteger after) {
+ assertNotNull(after);
+ assertTrue(
+ after.longValue() > before.longValue(),
+ () -> "expected LastChange > " + before + " but was " + after);
+ }
+
+ /**
+ * The current value of the category's {@code LastChange} Property Node, or null if the category
+ * has no such Property or it has no value yet.
+ */
+ static @Nullable UInteger readLastChange(OpcUaServer server, NodeId categoryId) {
+ return server
+ .getAddressSpaceManager()
+ .getManagedNode(categoryId)
+ .flatMap(categoryNode -> categoryNode.getPropertyNode(AliasNameCategoryType.LAST_CHANGE))
+ .map(propertyNode -> (UInteger) propertyNode.getValue().value().value())
+ .orElse(null);
+ }
+}
diff --git a/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/aliases/RecordingVersionStore.java b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/aliases/RecordingVersionStore.java
new file mode 100644
index 0000000000..ba842e0aa1
--- /dev/null
+++ b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/aliases/RecordingVersionStore.java
@@ -0,0 +1,85 @@
+/*
+ * Copyright (c) 2026 the Eclipse Milo Authors
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+
+package org.eclipse.milo.opcua.sdk.test.aliases;
+
+import static org.eclipse.milo.opcua.sdk.test.aliases.AliasTestSupport.readLastChange;
+
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
+import org.eclipse.milo.opcua.sdk.server.OpcUaServer;
+import org.eclipse.milo.opcua.sdk.server.aliases.AliasVersionStore;
+import org.eclipse.milo.opcua.stack.core.types.builtin.ExpandedNodeId;
+import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId;
+import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UInteger;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * An in-memory {@link AliasVersionStore} that records every save together with the {@code
+ * LastChange} Property value visible at save time, letting tests assert the persist-then-publish
+ * ordering, inspect exactly which categories were persisted (and that their store keys are
+ * namespace-URI-qualified), and count how many times a mutation call persisted a category's {@code
+ * LastChange}.
+ */
+final class RecordingVersionStore implements AliasVersionStore {
+
+ /**
+ * One recorded {@link #save}: the URI-qualified store key and its runtime NodeId resolution, the
+ * persisted value, and the {@code LastChange} Property value that was observable at the moment
+ * the store was called.
+ */
+ record SaveRecord(
+ ExpandedNodeId storeKey,
+ NodeId categoryId,
+ UInteger value,
+ @Nullable UInteger lastChangeAtSave) {}
+
+ private final Map entries = new ConcurrentHashMap<>();
+ private final List saves = new CopyOnWriteArrayList<>();
+
+ private final OpcUaServer server;
+
+ RecordingVersionStore(OpcUaServer server) {
+ this.server = server;
+ }
+
+ @Override
+ public Map load() {
+ return Map.copyOf(entries);
+ }
+
+ @Override
+ public void save(ExpandedNodeId categoryId, UInteger value) {
+ NodeId localCategoryId = categoryId.toNodeId(server.getNamespaceTable()).orElseThrow();
+
+ // Capture the Property value BEFORE recording, so tests can verify the manager persists
+ // each version before publishing it via the Property.
+ saves.add(
+ new SaveRecord(
+ categoryId, localCategoryId, value, readLastChange(server, localCategoryId)));
+ entries.put(categoryId, value);
+ }
+
+ @Override
+ public void delete(ExpandedNodeId categoryId) {
+ entries.remove(categoryId);
+ }
+
+ List saves() {
+ return saves;
+ }
+
+ /** The number of {@link #save} calls recorded for the category with {@code categoryId}. */
+ long savesFor(NodeId categoryId) {
+ return saves.stream().filter(record -> categoryId.equals(record.categoryId())).count();
+ }
+}
diff --git a/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/aliases/package-info.java b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/aliases/package-info.java
new file mode 100644
index 0000000000..577d97a89d
--- /dev/null
+++ b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/aliases/package-info.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright (c) 2026 the Eclipse Milo Authors
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+
+/**
+ * Client/server integration tests for OPC UA Part 17 Alias Names support ({@link
+ * org.eclipse.milo.opcua.sdk.server.aliases.AliasManager} and collaborators).
+ *
+ * Test model
+ *
+ * Tests run against the shared per-class client and server owned by {@link
+ * org.eclipse.milo.opcua.sdk.test.AbstractClientServerTest}. An {@code AliasManager} has a one-shot
+ * lifecycle (no restart after shutdown), so tests never share a manager: each test constructs a
+ * fresh instance against the running server, starts it if the scenario calls for it, and shuts it
+ * down (or discards it, for failed-startup scenarios) before returning.
+ *
+ *
Because a manager shutdown restores exactly the no-manager baseline that the standard
+ * namespace establishes at server startup — {@code FindAlias} Methods unbound and non-executable —
+ * tests that assert the "no manager installed" state and tests that install a manager can coexist
+ * in one server without ordering constraints, provided every test cleans up the Nodes it created.
+ */
+@NullMarked
+package org.eclipse.milo.opcua.sdk.test.aliases;
+
+import org.jspecify.annotations.NullMarked;
diff --git a/opc-ua-sdk/sdk-core/src/main/java/org/eclipse/milo/opcua/sdk/core/Reference.java b/opc-ua-sdk/sdk-core/src/main/java/org/eclipse/milo/opcua/sdk/core/Reference.java
index 191395fbcc..7d4c41c3c2 100644
--- a/opc-ua-sdk/sdk-core/src/main/java/org/eclipse/milo/opcua/sdk/core/Reference.java
+++ b/opc-ua-sdk/sdk-core/src/main/java/org/eclipse/milo/opcua/sdk/core/Reference.java
@@ -193,6 +193,10 @@ public String toString() {
(reference) ->
reference.isForward() && NodeIds.Organizes.equals(reference.getReferenceTypeId());
+ public static final Predicate ORGANIZED_BY_PREDICATE =
+ (reference) ->
+ reference.isInverse() && NodeIds.Organizes.equals(reference.getReferenceTypeId());
+
public static final Predicate HAS_ENCODING_PREDICATE =
(reference) ->
reference.isForward() && NodeIds.HasEncoding.equals(reference.getReferenceTypeId());
diff --git a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/AbstractLifecycle.java b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/AbstractLifecycle.java
index f23d59f51d..f7bca13423 100644
--- a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/AbstractLifecycle.java
+++ b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/AbstractLifecycle.java
@@ -21,6 +21,10 @@ public abstract class AbstractLifecycle implements Lifecycle {
*
* Subsequent invocations throw {@link IllegalStateException}.
*
+ *
If {@link #onStartup()} throws, the state transitions to stopped: {@link #isRunning()} is
+ * false, and a subsequent {@link #shutdown()} has no effect, so a lifecycle whose startup failed
+ * (and cleaned up after itself) is safely disposable.
+ *
* @throws IllegalStateException on subsequent invocations.
*/
@Override
@@ -29,7 +33,12 @@ public final void startup() throws IllegalStateException {
state.getAndUpdate(prev -> prev == LifecycleState.NEW ? LifecycleState.RUNNING : prev);
if (previous == LifecycleState.NEW) {
- this.onStartup();
+ try {
+ this.onStartup();
+ } catch (Throwable t) {
+ state.set(LifecycleState.STOPPED);
+ throw t;
+ }
} else {
throw new IllegalStateException("cannot call startup when state=" + previous);
}
diff --git a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AddAliasesToCategoryMethodImpl.java b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AddAliasesToCategoryMethodImpl.java
new file mode 100644
index 0000000000..28fa174a4c
--- /dev/null
+++ b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AddAliasesToCategoryMethodImpl.java
@@ -0,0 +1,70 @@
+/*
+ * Copyright (c) 2026 the Eclipse Milo Authors
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+
+package org.eclipse.milo.opcua.sdk.server.aliases;
+
+import org.eclipse.milo.opcua.sdk.server.Session;
+import org.eclipse.milo.opcua.sdk.server.methods.Out;
+import org.eclipse.milo.opcua.sdk.server.model.objects.AliasNameCategoryType;
+import org.eclipse.milo.opcua.sdk.server.nodes.UaMethodNode;
+import org.eclipse.milo.opcua.stack.core.StatusCodes;
+import org.eclipse.milo.opcua.stack.core.UaException;
+import org.eclipse.milo.opcua.stack.core.types.builtin.ExpandedNodeId;
+import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId;
+import org.eclipse.milo.opcua.stack.core.types.builtin.StatusCode;
+
+/**
+ * Network-facing {@code AddAliasesToCategory} implementation: authorizes the calling session
+ * through the {@link AliasAuthorizationPolicy}, then delegates to the {@link AliasManager}'s
+ * per-entry mutation path, targeting the category Object the Method was called on.
+ *
+ *
The category is re-resolved from the call's Object NodeId on every invocation, so a call
+ * racing a category removal fails with {@code Bad_NodeIdUnknown} instead of observing stale state.
+ *
+ *
Call-level failures (invalid array shapes, an invalid {@code TargetReferenceType}, operation
+ * count over the configured limit, denied authorization) fail the whole call; everything else is
+ * reported per entry through the {@code ErrorCodes} output, with one StatusCode per input entry.
+ */
+class AddAliasesToCategoryMethodImpl extends AliasNameCategoryType.AddAliasesToCategoryMethod {
+
+ private final AliasManager aliasManager;
+ private final AliasAuthorizationPolicy policy;
+
+ AddAliasesToCategoryMethodImpl(
+ UaMethodNode node, AliasManager aliasManager, AliasAuthorizationPolicy policy) {
+
+ super(node);
+
+ this.aliasManager = aliasManager;
+ this.policy = policy;
+ }
+
+ @Override
+ protected void invoke(
+ InvocationContext context,
+ String[] aliasNames,
+ ExpandedNodeId[] targetNodes,
+ String[] targetServers,
+ NodeId targetReferenceType,
+ Out errorCodes)
+ throws UaException {
+
+ Session session = context.getSession().orElse(null);
+ NodeId categoryId = context.getObjectId();
+
+ if (!policy.checkMutate(session, categoryId)) {
+ throw new UaException(StatusCodes.Bad_UserAccessDenied);
+ }
+
+ errorCodes.set(
+ aliasManager.addAliasEntries(
+ categoryId, aliasNames, targetNodes, targetServers, targetReferenceType));
+ }
+}
diff --git a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasAuthorizationPolicy.java b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasAuthorizationPolicy.java
new file mode 100644
index 0000000000..7e4450ac23
--- /dev/null
+++ b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasAuthorizationPolicy.java
@@ -0,0 +1,101 @@
+/*
+ * Copyright (c) 2026 the Eclipse Milo Authors
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+
+package org.eclipse.milo.opcua.sdk.server.aliases;
+
+import org.eclipse.milo.opcua.sdk.server.Session;
+import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * SPI deciding whether a session may search or mutate an alias category over the network.
+ *
+ * The policy is consulted only for network Method calls ({@code FindAlias}, {@code
+ * FindAliasVerbose}, {@code AddAliasesToCategory}, {@code DeleteAliasesFromCategory}); the
+ * manager's programmatic API is trusted application code and bypasses it. A null session means an
+ * internal, trusted call.
+ *
+ *
The policy runs in addition to the Server's general access control (RolePermissions
+ * and {@code UserExecutable} checks), which remains the first gate. Implementations that need role
+ * information can use {@link Session#getRoleIds()}, which resolves through the configured {@code
+ * RoleMapper}.
+ *
+ *
A denial surfaces to the Client as {@code Bad_UserAccessDenied}.
+ */
+public interface AliasAuthorizationPolicy {
+
+ /**
+ * The default policy: every session may search, no session may mutate.
+ *
+ *
Note that search results are not filtered by the Server's Browse access control:
+ * unlike the Browse service, {@code FindAlias} reads the AddressSpace directly, so
+ * RolePermissions and AccessRestrictions that hide Nodes from Browse do not hide the
+ * corresponding aliases or targets from search. Deployments that restrict Browse visibility per
+ * session should supply a policy that compensates via {@link #checkFind} and {@link
+ * #includeResult}. Mutation is deny-by-default; enabling network mutation requires an explicit
+ * policy grant in addition to enabling the mutation Methods.
+ */
+ AliasAuthorizationPolicy ALLOW_FIND_DENY_MUTATE =
+ new AliasAuthorizationPolicy() {
+ @Override
+ public boolean checkFind(@Nullable Session session, NodeId categoryId) {
+ return true;
+ }
+
+ @Override
+ public boolean checkMutate(@Nullable Session session, NodeId categoryId) {
+ return false;
+ }
+ };
+
+ /**
+ * Decide whether a session may call {@code FindAlias} or {@code FindAliasVerbose} on a category.
+ *
+ *
Consulted once per call, for the category the Method was invoked on; search authorization is
+ * all-or-nothing per call. The search then recurses into every subcategory of that category
+ * without consulting this method again, so denying find on a subcategory does not stop
+ * its aliases from appearing when an ancestor is searched — use {@link #includeResult} to filter
+ * individual aliases regardless of which category the search entered through.
+ *
+ * @param session the calling session, or null for an internal, trusted call.
+ * @param categoryId the NodeId of the category the Method was invoked on.
+ * @return {@code true} to allow the call, {@code false} to deny it with {@code
+ * Bad_UserAccessDenied}.
+ */
+ boolean checkFind(@Nullable Session session, NodeId categoryId);
+
+ /**
+ * Decide whether a session may call {@code AddAliasesToCategory} or {@code
+ * DeleteAliasesFromCategory} on a category.
+ *
+ * @param session the calling session, or null for an internal, trusted call.
+ * @param categoryId the NodeId of the category being mutated.
+ * @return {@code true} to allow the call, {@code false} to deny it with {@code
+ * Bad_UserAccessDenied}.
+ */
+ boolean checkMutate(@Nullable Session session, NodeId categoryId);
+
+ /**
+ * Decide whether a single alias may appear in a search result for a session.
+ *
+ *
Applied per matched alias after {@link #checkFind} has allowed the call. This is the
+ * only per-result filter: the Server's Browse access control (RolePermissions,
+ * AccessRestrictions) is not applied to search results, because the engine reads the AddressSpace
+ * directly rather than going through the Browse service. The default includes every result;
+ * override it in deployments where sessions must not learn of Nodes they cannot Browse.
+ *
+ * @param session the calling session, or null for an internal, trusted call.
+ * @param aliasNodeId the NodeId of the matched alias Node.
+ * @return {@code true} to include the alias in the result, {@code false} to omit it.
+ */
+ default boolean includeResult(@Nullable Session session, NodeId aliasNodeId) {
+ return true;
+ }
+}
diff --git a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasCategory.java b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasCategory.java
new file mode 100644
index 0000000000..8719562738
--- /dev/null
+++ b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasCategory.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright (c) 2026 the Eclipse Milo Authors
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+
+package org.eclipse.milo.opcua.sdk.server.aliases;
+
+import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId;
+import org.eclipse.milo.opcua.stack.core.types.builtin.QualifiedName;
+
+/**
+ * An immutable handle describing an alias category under management.
+ *
+ *
Returned when a category is added or adopted; use {@link #nodeId()} with the manager's
+ * programmatic API to add, delete, or look up aliases in the category.
+ *
+ * @param nodeId the NodeId of the category Node.
+ * @param browseName the BrowseName of the category Node.
+ * @param lastChangeEnabled whether the manager maintains a {@code LastChange} Property on this
+ * category.
+ * @param findAliasVerboseEnabled whether a {@code FindAliasVerbose} Method instance is bound on
+ * this category.
+ * @param configurationEnabled whether the {@code AddAliasesToCategory} and {@code
+ * DeleteAliasesFromCategory} Method instances are bound on this category.
+ */
+public record AliasCategory(
+ NodeId nodeId,
+ QualifiedName browseName,
+ boolean lastChangeEnabled,
+ boolean findAliasVerboseEnabled,
+ boolean configurationEnabled) {}
diff --git a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasCategoryConfig.java b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasCategoryConfig.java
new file mode 100644
index 0000000000..60696e3ccb
--- /dev/null
+++ b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasCategoryConfig.java
@@ -0,0 +1,56 @@
+/*
+ * Copyright (c) 2026 the Eclipse Milo Authors
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+
+package org.eclipse.milo.opcua.sdk.server.aliases;
+
+import java.util.function.Function;
+import org.eclipse.milo.opcua.sdk.server.NodeManager;
+import org.eclipse.milo.opcua.sdk.server.nodes.UaNode;
+import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId;
+import org.eclipse.milo.opcua.stack.core.types.builtin.QualifiedName;
+
+/**
+ * Configuration for an application-defined alias category.
+ *
+ *
The application controls where the category and its alias Nodes live and how their NodeIds are
+ * allocated; the framework materializes the category Node, its Method instances, and the parent
+ * linkage.
+ *
+ * @param categoryNodeId the NodeId of the category Node itself.
+ * @param parentCategoryId the NodeId of the category this category is organized by, e.g. {@code
+ * NodeIds.TagVariables} or another application category.
+ * @param browseName the BrowseName of the category Node. The name text is also used as the
+ * category's DisplayName.
+ * @param nodeManager the {@link NodeManager} the category Node, its Method Nodes, and the alias
+ * Nodes added to it are created in.
+ * @param aliasNodeIdFactory allocates the NodeId for an alias Node from its alias name. Called
+ * exactly once per alias creation — never for the category itself — so stateful factories are
+ * safe; must yield NodeIds that are unique within {@code nodeManager} and distinct from {@code
+ * categoryNodeId}.
+ * @param lastChangeEnabled whether the category gets a {@code LastChange} Property maintained by
+ * the manager. The Property is Optional per category; the root {@code Aliases} value is
+ * maintained regardless.
+ * @param findAliasVerboseEnabled whether a {@code FindAliasVerbose} Method instance is materialized
+ * and bound on the category.
+ * @param configurationEnabled whether the {@code AddAliasesToCategory} and {@code
+ * DeleteAliasesFromCategory} Methods are materialized and bound on the category. The Methods
+ * are network-callable only for sessions the {@link AliasAuthorizationPolicy} grants mutation
+ * to; the default policy denies every session, so enabling network mutation requires this flag
+ * and an explicit policy grant.
+ */
+public record AliasCategoryConfig(
+ NodeId categoryNodeId,
+ NodeId parentCategoryId,
+ QualifiedName browseName,
+ NodeManager nodeManager,
+ Function aliasNodeIdFactory,
+ boolean lastChangeEnabled,
+ boolean findAliasVerboseEnabled,
+ boolean configurationEnabled) {}
diff --git a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasLimits.java b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasLimits.java
new file mode 100644
index 0000000000..965c754998
--- /dev/null
+++ b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasLimits.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright (c) 2026 the Eclipse Milo Authors
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+
+package org.eclipse.milo.opcua.sdk.server.aliases;
+
+/**
+ * Limits applied to alias lookup and mutation calls.
+ *
+ * All limits are enforced before any expensive work happens: patterns are length-checked before
+ * parsing, array arguments are length-checked before validation, and searches stop as soon as the
+ * result cap is exceeded.
+ *
+ * @param maxResults the maximum number of result entries a single {@code FindAlias} or {@code
+ * FindAliasVerbose} call may produce. Exceeding it fails the call with {@code
+ * Bad_ResponseTooLarge}; Part 17 defines no paging, so the Client must narrow its pattern.
+ * @param maxPatternLength the maximum length, in {@code char}s, of a search pattern. Longer
+ * patterns fail the call with {@code Bad_InvalidArgument}.
+ * @param maxOperationsPerCall the maximum number of entries in the array arguments of a single
+ * {@code AddAliasesToCategory} or {@code DeleteAliasesFromCategory} call. Longer arrays fail
+ * the call with {@code Bad_TooManyOperations}. Each entry locates aliases by scanning the
+ * category's directly organized members, so a call costs O(entries × category size) under the
+ * manager lock; servers with very large categories should size this limit accordingly.
+ */
+public record AliasLimits(int maxResults, int maxPatternLength, int maxOperationsPerCall) {
+
+ /**
+ * Create an {@link AliasLimits}.
+ *
+ * @throws IllegalArgumentException if any limit is not positive.
+ */
+ public AliasLimits {
+ if (maxResults <= 0) {
+ throw new IllegalArgumentException("maxResults must be positive: " + maxResults);
+ }
+ if (maxPatternLength <= 0) {
+ throw new IllegalArgumentException("maxPatternLength must be positive: " + maxPatternLength);
+ }
+ if (maxOperationsPerCall <= 0) {
+ throw new IllegalArgumentException(
+ "maxOperationsPerCall must be positive: " + maxOperationsPerCall);
+ }
+ }
+
+ /**
+ * The default limits: 1000 results, 512 pattern chars, 1000 operations per call.
+ *
+ * @return the default {@link AliasLimits}.
+ */
+ public static AliasLimits defaults() {
+ return new AliasLimits(1000, 512, 1000);
+ }
+}
diff --git a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasManager.java b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasManager.java
new file mode 100644
index 0000000000..4916ecff98
--- /dev/null
+++ b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasManager.java
@@ -0,0 +1,2164 @@
+/*
+ * Copyright (c) 2026 the Eclipse Milo Authors
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+
+package org.eclipse.milo.opcua.sdk.server.aliases;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.locks.ReentrantLock;
+import java.util.function.Function;
+import java.util.function.Predicate;
+import org.eclipse.milo.opcua.sdk.core.Reference;
+import org.eclipse.milo.opcua.sdk.server.AbstractLifecycle;
+import org.eclipse.milo.opcua.sdk.server.AddressSpaceComposite;
+import org.eclipse.milo.opcua.sdk.server.AddressSpaceFilter;
+import org.eclipse.milo.opcua.sdk.server.ManagedAddressSpaceFragmentWithLifecycle;
+import org.eclipse.milo.opcua.sdk.server.NodeManager;
+import org.eclipse.milo.opcua.sdk.server.OpcUaServer;
+import org.eclipse.milo.opcua.sdk.server.SimpleAddressSpaceFilter;
+import org.eclipse.milo.opcua.sdk.server.items.DataItem;
+import org.eclipse.milo.opcua.sdk.server.items.MonitoredItem;
+import org.eclipse.milo.opcua.sdk.server.methods.AbstractMethodInvocationHandler;
+import org.eclipse.milo.opcua.sdk.server.methods.MethodInvocationHandler;
+import org.eclipse.milo.opcua.sdk.server.model.objects.AliasNameCategoryType;
+import org.eclipse.milo.opcua.sdk.server.model.objects.AliasNameCategoryTypeNode;
+import org.eclipse.milo.opcua.sdk.server.model.objects.AliasNameTypeNode;
+import org.eclipse.milo.opcua.sdk.server.nodes.UaMethodNode;
+import org.eclipse.milo.opcua.sdk.server.nodes.UaNode;
+import org.eclipse.milo.opcua.sdk.server.nodes.UaNodeContext;
+import org.eclipse.milo.opcua.sdk.server.nodes.instantiation.BrowsePath;
+import org.eclipse.milo.opcua.sdk.server.nodes.instantiation.InstantiationRequest;
+import org.eclipse.milo.opcua.sdk.server.nodes.instantiation.InstantiationResult;
+import org.eclipse.milo.opcua.sdk.server.nodes.instantiation.NodeInstantiator;
+import org.eclipse.milo.opcua.sdk.server.util.SubscriptionModel;
+import org.eclipse.milo.opcua.stack.core.NodeIds;
+import org.eclipse.milo.opcua.stack.core.StatusCodes;
+import org.eclipse.milo.opcua.stack.core.UaException;
+import org.eclipse.milo.opcua.stack.core.types.builtin.ExpandedNodeId;
+import org.eclipse.milo.opcua.stack.core.types.builtin.LocalizedText;
+import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId;
+import org.eclipse.milo.opcua.stack.core.types.builtin.QualifiedName;
+import org.eclipse.milo.opcua.stack.core.types.builtin.StatusCode;
+import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UInteger;
+import org.eclipse.milo.opcua.stack.core.types.enumerated.NodeClass;
+import org.eclipse.milo.opcua.stack.core.types.structured.AliasNameDataType;
+import org.eclipse.milo.opcua.stack.core.types.structured.AliasNameVerboseDataType;
+import org.jspecify.annotations.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Opt-in server-side support for OPC UA Part 17 Alias Names: binds {@code FindAlias} (and
+ * optionally {@code FindAliasVerbose}, {@code AddAliasesToCategory}, and {@code
+ * DeleteAliasesFromCategory}) behavior onto the standard {@code Aliases}, {@code TagVariables}, and
+ * {@code Topics} Objects, manages application-defined alias categories, and maintains the {@code
+ * LastChange} version invariant.
+ *
+ *
Construct with the {@link OpcUaServer} and an {@link AliasManagerConfig}, then call {@link
+ * #startup()} after the server has started. Startup fails if a standard {@code FindAlias}
+ * Method already has an application-bound invocation handler, if a NodeId needed for a materialized
+ * Method Node is already in use, or if the configured {@link AliasVersionStore} cannot be read; a
+ * failed startup rolls back anything it had already applied, leaving no trace. On {@link
+ * #shutdown()} the manager unbinds its handlers, clears the executable flags it restored, removes
+ * the Method Nodes it materialized, and unregisters its AddressSpace fragment.
+ *
+ *
Node hosting
+ *
+ * The manager owns an AddressSpace fragment (with its own NodeManager) that it registers with
+ * the server's AddressSpaceManager at startup and unregisters at shutdown; the fragment claims
+ * exactly the Nodes it contains, so services like Read and Browse route to them regardless of their
+ * NodeId's namespace. The hosting rule is: every Node the manager itself creates outside an
+ * application namespace — the Optional Method Nodes materialized on the standard Objects, and alias
+ * Nodes created by {@link #addAlias} in standard or adopted categories — lives in the manager's
+ * fragment, and therefore leaves the AddressSpace when the manager shuts down. Category and alias
+ * Nodes created through {@link #addCategory} live in the application-supplied {@link NodeManager},
+ * whose owning namespace claims them; they remain in place at shutdown.
+ *
+ *
Categories are registered through {@link #addCategory} (materializes a new {@code
+ * AliasNameCategoryType} instance) or {@link #adoptCategory} (binds behavior onto an existing
+ * instance, e.g. one loaded from a NodeSet file). Aliases are created and removed through {@link
+ * #addAlias} and {@link #deleteAlias}; these programmatic paths are trusted application code and
+ * are not subject to the {@link AliasAuthorizationPolicy}. All mutation must flow through the
+ * manager (or be followed by {@link #touch}) for {@code LastChange} correctness; lookups read the
+ * live AddressSpace and need no registration.
+ *
+ *
Mutations are serialized by a manager-wide lock. Lookups take no lock and may observe a
+ * concurrent mutation partially applied — the same weak consistency Browse has.
+ */
+public final class AliasManager extends AbstractLifecycle {
+
+ private static final List STANDARD_CATEGORY_IDS =
+ List.of(NodeIds.Aliases, NodeIds.TagVariables, NodeIds.Topics);
+
+ private static final List STANDARD_FIND_ALIAS_NODE_IDS =
+ List.of(NodeIds.Aliases_FindAlias, NodeIds.TagVariables_FindAlias, NodeIds.Topics_FindAlias);
+
+ private static final BrowsePath FIND_ALIAS_PATH =
+ BrowsePath.of(new QualifiedName(0, "FindAlias"));
+
+ private static final BrowsePath FIND_ALIAS_VERBOSE_PATH =
+ BrowsePath.of(new QualifiedName(0, "FindAliasVerbose"));
+
+ private static final BrowsePath ADD_ALIASES_TO_CATEGORY_PATH =
+ BrowsePath.of(new QualifiedName(0, "AddAliasesToCategory"));
+
+ private static final BrowsePath DELETE_ALIASES_FROM_CATEGORY_PATH =
+ BrowsePath.of(new QualifiedName(0, "DeleteAliasesFromCategory"));
+
+ private static final BrowsePath LAST_CHANGE_PATH =
+ BrowsePath.of(new QualifiedName(0, "LastChange"));
+
+ private final Logger logger = LoggerFactory.getLogger(getClass());
+
+ private final ReentrantLock lock = new ReentrantLock();
+
+ /** Managed categories, keyed by category NodeId. Guarded by {@link #lock}. */
+ private final Map categories = new HashMap<>();
+
+ /** The standard {@code FindAlias} Nodes bound at startup. Guarded by {@link #lock}. */
+ private final List boundStandardMethodNodes = new ArrayList<>();
+
+ /** Method Nodes materialized on the standard Objects at startup. Guarded by {@link #lock}. */
+ private final List materializedMethodNodes = new ArrayList<>();
+
+ private final AliasSearchEngine searchEngine;
+ private final AliasVersionManager versionManager;
+ private final AliasTypes aliasTypes;
+
+ /** Hosts the Nodes the manager creates outside application namespaces. */
+ private final AliasFragment fragment;
+
+ private final OpcUaServer server;
+ private final AliasManagerConfig config;
+
+ /**
+ * Create an {@link AliasManager} for {@code server}.
+ *
+ * The manager does nothing until {@link #startup()} is called, which must happen after the
+ * server itself has started (the standard namespace Nodes must exist).
+ *
+ * @param server the server whose AddressSpace the manager operates on.
+ * @param config the manager configuration.
+ */
+ public AliasManager(OpcUaServer server, AliasManagerConfig config) {
+ this.server = server;
+ this.config = config;
+
+ searchEngine = new AliasSearchEngine(server, config.getLimits(), config.getTargetOrdering());
+ versionManager = new AliasVersionManager(server, config.getVersionStore());
+ aliasTypes = new AliasTypes(server);
+ fragment = new AliasFragment(server);
+ }
+
+ @Override
+ protected void onStartup() {
+ lock.lock();
+ try {
+ // Validation phase: check everything that can foreseeably fail BEFORE mutating any state,
+ // so an expected failure (handler conflict, NodeId collision, unreadable version store)
+ // leaves no trace behind.
+ List findAliasNodes = resolveStandardFindAliasNodes();
+
+ for (UaMethodNode methodNode : findAliasNodes) {
+ MethodInvocationHandler handler = methodNode.getInvocationHandler();
+
+ if (!(handler instanceof MethodInvocationHandler.NotImplementedHandler)) {
+ throw new IllegalStateException(
+ "FindAlias Method %s already has an invocation handler: %s"
+ .formatted(
+ methodNode.getNodeId().toParseableString(), handler.getClass().getName()));
+ }
+ }
+
+ List methodPlans = resolveStandardMethodPlans();
+
+ // Mutation phase: pre-validation makes failure here unexpected, but if anything throws
+ // anyway, roll back whatever was already applied before rethrowing.
+ boolean fragmentStarted = false;
+ try {
+ Map persisted;
+ try {
+ // A load failure throws before the version manager mutates any state; on success the
+ // persisted values are seeded in memory only — they are republished to the LastChange
+ // Properties at the end of startup, after every step that can fail.
+ persisted = versionManager.loadPersisted();
+ } catch (UaException e) {
+ throw new IllegalStateException("failed to load persisted LastChange versions", e);
+ }
+
+ fragment.startup();
+ fragmentStarted = true;
+
+ for (UaMethodNode methodNode : findAliasNodes) {
+ bindHandler(
+ methodNode,
+ new FindAliasMethodImpl(methodNode, searchEngine, config.getAuthorizationPolicy()));
+
+ boundStandardMethodNodes.add(methodNode);
+ }
+
+ for (MethodPlan methodPlan : methodPlans) {
+ materializeMethod(methodPlan);
+ }
+
+ // The root Aliases Object's LastChange value is mandatory and persisted; when nothing was
+ // persisted yet, initialize it to a freshly computed VersionTime. Done last so the store
+ // is not written to until every other startup step has succeeded.
+ if (!persisted.containsKey(NodeIds.Aliases)) {
+ try {
+ versionManager.touch(NodeIds.Aliases);
+ } catch (UaException e) {
+ // A store that cannot save is as disqualifying as one that cannot load: continuing
+ // without a persisted root version would violate the §6.3.1 persistence contract.
+ throw new IllegalStateException("failed to persist initial LastChange version", e);
+ }
+ }
+
+ // Publish the loaded versions to the LastChange Properties only now, after every step
+ // that can fail, so a failed startup leaves no trace in the AddressSpace.
+ versionManager.publishLoaded();
+ } catch (RuntimeException | Error e) {
+ rollbackStartup(fragmentStarted);
+ throw e;
+ }
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ /**
+ * Undo the state a partially completed startup applied: unbind handlers, delete materialized
+ * Nodes, and unregister the fragment. Best-effort; failures are logged, not propagated, so the
+ * original startup failure is the one the caller sees.
+ */
+ private void rollbackStartup(boolean fragmentStarted) {
+ for (UaMethodNode methodNode : boundStandardMethodNodes) {
+ try {
+ unbindHandler(methodNode);
+ } catch (Exception e) {
+ logger.warn("Rollback failed to unbind handler: {}", methodNode.getNodeId(), e);
+ }
+ }
+ boundStandardMethodNodes.clear();
+
+ for (UaMethodNode methodNode : materializedMethodNodes) {
+ try {
+ methodNode.delete();
+ } catch (Exception e) {
+ logger.warn("Rollback failed to delete Method Node: {}", methodNode.getNodeId(), e);
+ }
+ }
+ materializedMethodNodes.clear();
+
+ if (fragmentStarted) {
+ try {
+ fragment.shutdown();
+ } catch (Exception e) {
+ logger.warn("Rollback failed to shut down AddressSpace fragment", e);
+ }
+ }
+ }
+
+ @Override
+ protected void onShutdown() {
+ lock.lock();
+ try {
+ for (UaMethodNode methodNode : boundStandardMethodNodes) {
+ unbindHandler(methodNode);
+ }
+ boundStandardMethodNodes.clear();
+
+ for (UaMethodNode methodNode : materializedMethodNodes) {
+ methodNode.delete();
+ }
+ materializedMethodNodes.clear();
+
+ for (CategoryRecord record : categories.values()) {
+ for (UaMethodNode methodNode : record.boundMethodNodes()) {
+ unbindHandler(methodNode);
+ }
+ }
+ categories.clear();
+
+ // Last, mirroring startup order: the fragment (and the alias Nodes it still hosts) leaves
+ // the AddressSpace only after every handler is unbound and every materialized Node deleted.
+ fragment.shutdown();
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ /**
+ * Materialize a new {@code AliasNameCategoryType} instance and manage it.
+ *
+ * The category Node's NodeId is the config's {@code categoryNodeId}; member Nodes get NodeIds
+ * derived from it, and the {@code aliasNodeIdFactory} is reserved for alias Nodes (it is never
+ * invoked here, so stateful factories are safe). The mandatory {@code FindAlias} Method is always
+ * bound; {@code FindAliasVerbose}, the {@code LastChange} Property, and the mutation Methods
+ * ({@code AddAliasesToCategory} / {@code DeleteAliasesFromCategory}) are materialized when
+ * enabled by the config. Mutation Methods are network-callable only for sessions the {@link
+ * AliasAuthorizationPolicy} grants mutation to; the default policy denies every session.
+ *
+ * @param categoryConfig describes the category to create.
+ * @return a handle for the created category.
+ * @throws UaException with {@code Bad_InvalidArgument} if the BrowseName has no name text; {@code
+ * Bad_NodeIdExists} if a category with the same NodeId is already managed; {@code
+ * Bad_InternalError} if the category's initial {@code LastChange} version cannot be persisted
+ * (the instantiation is undone, so nothing is created); or if instantiation fails (e.g. the
+ * parent does not exist or a NodeId collides).
+ * @throws IllegalStateException if the manager is not running.
+ */
+ public AliasCategory addCategory(AliasCategoryConfig categoryConfig) throws UaException {
+ checkRunning();
+
+ lock.lock();
+ try {
+ String name = categoryConfig.browseName().getName();
+ if (name == null || name.isEmpty()) {
+ throw new UaException(
+ StatusCodes.Bad_InvalidArgument, "category BrowseName has no name text");
+ }
+
+ NodeId categoryId = categoryConfig.categoryNodeId();
+
+ if (categories.containsKey(categoryId)) {
+ throw new UaException(
+ StatusCodes.Bad_NodeIdExists,
+ "category already managed: " + categoryId.toParseableString());
+ }
+
+ var boundMethodNodes = new ArrayList();
+
+ InstantiationRequest.Builder builder =
+ InstantiationRequest.of(AliasNameCategoryTypeNode.class, NodeIds.AliasNameCategoryType)
+ .nodeId(categoryId)
+ .browseName(categoryConfig.browseName())
+ .displayName(LocalizedText.english(name))
+ .parent(categoryConfig.parentCategoryId(), NodeIds.Organizes)
+ .target(categoryConfig.nodeManager());
+
+ bindMethodAt(
+ builder,
+ FIND_ALIAS_PATH,
+ boundMethodNodes,
+ methodNode ->
+ new FindAliasMethodImpl(methodNode, searchEngine, config.getAuthorizationPolicy()));
+
+ if (categoryConfig.findAliasVerboseEnabled()) {
+ builder.includeOptional(FIND_ALIAS_VERBOSE_PATH);
+ bindMethodAt(
+ builder,
+ FIND_ALIAS_VERBOSE_PATH,
+ boundMethodNodes,
+ methodNode ->
+ new FindAliasVerboseMethodImpl(
+ methodNode, searchEngine, config.getAuthorizationPolicy()));
+ }
+
+ if (categoryConfig.configurationEnabled()) {
+ builder.includeOptional(ADD_ALIASES_TO_CATEGORY_PATH);
+ bindMethodAt(
+ builder,
+ ADD_ALIASES_TO_CATEGORY_PATH,
+ boundMethodNodes,
+ methodNode ->
+ new AddAliasesToCategoryMethodImpl(
+ methodNode, this, config.getAuthorizationPolicy()));
+
+ builder.includeOptional(DELETE_ALIASES_FROM_CATEGORY_PATH);
+ bindMethodAt(
+ builder,
+ DELETE_ALIASES_FROM_CATEGORY_PATH,
+ boundMethodNodes,
+ methodNode ->
+ new DeleteAliasesFromCategoryMethodImpl(
+ methodNode, this, config.getAuthorizationPolicy()));
+ }
+
+ if (categoryConfig.lastChangeEnabled()) {
+ builder.includeOptional(LAST_CHANGE_PATH);
+ }
+
+ InstantiationResult result =
+ new NodeInstantiator(server).instantiate(builder.build());
+
+ if (categoryConfig.lastChangeEnabled()) {
+ try {
+ // Initialize the new category's LastChange Property (and propagate to its ancestors).
+ versionManager.touch(categoryId);
+ } catch (UaException e) {
+ // The initial version could not be persisted, so the category must not come into
+ // existence: an unpersisted LastChange could repeat after a restart and leave Client
+ // caches undetectably stale. Undo the instantiation and report a clean failure.
+ for (UaMethodNode methodNode : boundMethodNodes) {
+ unbindHandler(methodNode);
+ }
+ result.deleteCreated();
+ versionManager.remove(categoryId);
+ throw e;
+ }
+ }
+
+ var category =
+ new AliasCategory(
+ categoryId,
+ categoryConfig.browseName(),
+ categoryConfig.lastChangeEnabled(),
+ categoryConfig.findAliasVerboseEnabled(),
+ categoryConfig.configurationEnabled());
+
+ categories.put(
+ categoryId,
+ new CategoryRecord(
+ categoryConfig.aliasNodeIdFactory(),
+ categoryConfig.nodeManager(),
+ boundMethodNodes,
+ result));
+
+ return category;
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ /**
+ * Manage an existing {@code AliasNameCategoryType} instance, e.g. one loaded from a NodeSet file.
+ *
+ * Adoption is non-recursive: exactly the given category is adopted, and subcategories found in
+ * the graph must be adopted individually if they should be bound too (they are still
+ * searched by any enclosing category's {@code FindAlias} regardless). The mandatory
+ * {@code FindAlias} Method is always bound. Pre-existing optional Method Nodes ({@code
+ * FindAliasVerbose}, {@code AddAliasesToCategory}, {@code DeleteAliasesFromCategory}) — the
+ * category's definition (e.g. its NodeSet) decided to offer them — are each bound only if still
+ * unbound; an instance that already has an invocation handler is left untouched, and a Method the
+ * category does not have is not created. Bound mutation Methods are network-callable only for
+ * sessions the {@link AliasAuthorizationPolicy} grants mutation to; the default policy denies
+ * every session. Alias Nodes created by {@link #addAlias} on an adopted category are hosted in
+ * the manager's own AddressSpace fragment, with NodeIds allocated in the config's Node namespace
+ * as {@code "/Alias/"}.
+ *
+ * Binding publishes the {@code InputArguments}/{@code OutputArguments} Properties the handler
+ * declares onto the adopted Method Nodes. Shutdown unbinds the handlers but does not remove or
+ * restore those Properties — they remain, correctly describing the (again non-executable)
+ * Methods.
+ *
+ * @param categoryId the NodeId of the category to adopt.
+ * @return a handle for the adopted category; {@code lastChangeEnabled} reflects whether the
+ * category has a {@code LastChange} Property Node, {@code findAliasVerboseEnabled} reflects
+ * whether a {@code FindAliasVerbose} Method was bound, and {@code configurationEnabled}
+ * reflects whether at least one mutation Method was bound.
+ * @throws UaException with {@code Bad_NodeIdExists} if the category is already managed; {@code
+ * Bad_InvalidArgument} if it is a standard category (those are managed automatically); {@code
+ * Bad_NodeIdUnknown} if it does not exist or is not an {@code AliasNameCategoryType}
+ * instance; {@code Bad_NotFound} if it has no {@code FindAlias} Method Node; {@code
+ * Bad_InvalidState} if its {@code FindAlias} Method already has an invocation handler.
+ * @throws IllegalStateException if the manager is not running.
+ */
+ public AliasCategory adoptCategory(NodeId categoryId) throws UaException {
+ checkRunning();
+
+ lock.lock();
+ try {
+ if (categories.containsKey(categoryId)) {
+ throw new UaException(
+ StatusCodes.Bad_NodeIdExists,
+ "category already managed: " + categoryId.toParseableString());
+ }
+
+ if (STANDARD_CATEGORY_IDS.contains(categoryId)) {
+ throw new UaException(
+ StatusCodes.Bad_InvalidArgument,
+ "standard categories are managed automatically: " + categoryId.toParseableString());
+ }
+
+ UaNode categoryNode =
+ server
+ .getAddressSpaceManager()
+ .getManagedNode(categoryId)
+ .orElseThrow(
+ () ->
+ new UaException(
+ StatusCodes.Bad_NodeIdUnknown,
+ "category not found: " + categoryId.toParseableString()));
+
+ if (!aliasTypes.isAliasNameCategoryInstance(categoryId)) {
+ throw new UaException(
+ StatusCodes.Bad_NodeIdUnknown,
+ "not an AliasNameCategoryType instance: " + categoryId.toParseableString());
+ }
+
+ UaMethodNode findAliasNode =
+ findComponentMethodNode(categoryId, new QualifiedName(0, "FindAlias"));
+
+ if (findAliasNode == null) {
+ throw new UaException(
+ StatusCodes.Bad_NotFound,
+ "category has no FindAlias Method: " + categoryId.toParseableString());
+ }
+
+ MethodInvocationHandler handler = findAliasNode.getInvocationHandler();
+ if (!(handler instanceof MethodInvocationHandler.NotImplementedHandler)) {
+ throw new UaException(
+ StatusCodes.Bad_InvalidState,
+ "FindAlias Method %s already has an invocation handler: %s"
+ .formatted(
+ findAliasNode.getNodeId().toParseableString(), handler.getClass().getName()));
+ }
+
+ bindHandler(
+ findAliasNode,
+ new FindAliasMethodImpl(findAliasNode, searchEngine, config.getAuthorizationPolicy()));
+
+ var boundMethodNodes = new ArrayList();
+ boundMethodNodes.add(findAliasNode);
+
+ // Pre-existing optional Method Nodes were the category definition's decision to offer;
+ // give them behavior, but only where no other component already has.
+ boolean verboseBound =
+ bindOptionalMethodIfUnbound(
+ categoryId,
+ "FindAliasVerbose",
+ methodNode ->
+ new FindAliasVerboseMethodImpl(
+ methodNode, searchEngine, config.getAuthorizationPolicy()),
+ boundMethodNodes);
+
+ boolean addBound =
+ bindOptionalMethodIfUnbound(
+ categoryId,
+ "AddAliasesToCategory",
+ methodNode ->
+ new AddAliasesToCategoryMethodImpl(
+ methodNode, this, config.getAuthorizationPolicy()),
+ boundMethodNodes);
+
+ boolean deleteBound =
+ bindOptionalMethodIfUnbound(
+ categoryId,
+ "DeleteAliasesFromCategory",
+ methodNode ->
+ new DeleteAliasesFromCategoryMethodImpl(
+ methodNode, this, config.getAuthorizationPolicy()),
+ boundMethodNodes);
+
+ boolean lastChangeEnabled =
+ categoryNode.getPropertyNode(AliasNameCategoryType.LAST_CHANGE).isPresent();
+
+ var category =
+ new AliasCategory(
+ categoryId,
+ categoryNode.getBrowseName(),
+ lastChangeEnabled,
+ verboseBound,
+ addBound || deleteBound);
+
+ categories.put(
+ categoryId,
+ new CategoryRecord(
+ defaultAliasNodeIdFactory(categoryId),
+ fragment.getNodeManager(),
+ boundMethodNodes,
+ null));
+
+ return category;
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ /**
+ * Stop managing a category: unbind its Method handlers and, if the category was created by {@link
+ * #addCategory}, delete the Nodes and References that creation added.
+ *
+ * Alias Nodes added to the category after its creation are not deleted — their {@code
+ * Organizes} linkage to the category is removed so no References target the deleted NodeId, but
+ * the alias Nodes themselves survive. Delete them first with {@link #deleteAlias} if they should
+ * not outlive the category.
+ *
+ *
When the category Node is deleted, {@code LastChange} is bumped for its former ancestor
+ * categories (captured before deletion) and the category's own in-memory version entry is
+ * dropped. Removing an adopted category only unbinds handlers — the AddressSpace is
+ * unchanged, so no version is bumped.
+ *
+ * @param categoryId the NodeId of the category to remove.
+ * @throws UaException with {@code Bad_InvalidArgument} if {@code categoryId} is a standard
+ * category (those cannot be removed); {@code Bad_NodeIdUnknown} if it is not managed; {@code
+ * Bad_InternalError} if the ancestor categories' new {@code LastChange} versions cannot be
+ * persisted (the category is left untouched).
+ * @throws IllegalStateException if the manager is not running.
+ */
+ public void removeCategory(NodeId categoryId) throws UaException {
+ checkRunning();
+
+ lock.lock();
+ try {
+ if (STANDARD_CATEGORY_IDS.contains(categoryId)) {
+ throw new UaException(
+ StatusCodes.Bad_InvalidArgument,
+ "standard categories cannot be removed: " + categoryId.toParseableString());
+ }
+
+ CategoryRecord record = categories.get(categoryId);
+ if (record == null) {
+ throw new UaException(
+ StatusCodes.Bad_NodeIdUnknown,
+ "category not managed: " + categoryId.toParseableString());
+ }
+
+ try {
+ if (record.instantiationResult() != null) {
+ // Capture the ancestor chain before deletion — once the category Node and its parent
+ // linkage are gone, the ancestors are no longer discoverable from it — and persist
+ // their new versions before any mutation: a failed save aborts the removal with
+ // nothing changed, leaving the category managed. Values saved before the failure
+ // stay pending and are published by the finally, per the prepare contract.
+ List ancestors = versionManager.getAncestorCategories(categoryId);
+
+ if (!ancestors.isEmpty()) {
+ versionManager.prepare(ancestors);
+ }
+ }
+
+ categories.remove(categoryId);
+
+ for (UaMethodNode methodNode : record.boundMethodNodes()) {
+ unbindHandler(methodNode);
+ }
+
+ if (record.instantiationResult() != null) {
+ // Detach aliases organized after creation: deleteCreated removes only what the
+ // instantiation recorded, so their Organizes linkage would otherwise dangle on the
+ // alias side, pointing at the deleted category — and silently reattach if a category
+ // with the same NodeId were created later. The alias Nodes themselves survive, per
+ // this method's contract.
+ for (NodeId aliasNodeId : findOrganizedAliases(categoryId)) {
+ server
+ .getAddressSpaceManager()
+ .getManagedNode(aliasNodeId)
+ .ifPresent(aliasNode -> removeFromCategory(aliasNode, categoryId));
+ }
+
+ record.instantiationResult().deleteCreated();
+
+ versionManager.remove(categoryId);
+ }
+ } finally {
+ versionManager.publishPending();
+ }
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ /**
+ * Create an alias in a category, or extend an existing alias of the same name with additional
+ * targets.
+ *
+ * The category must be managed (via {@link #addCategory} or {@link #adoptCategory}) or one of
+ * the standard categories ({@code Aliases}, {@code TagVariables}, {@code Topics}). The alias
+ * Node's NodeId is allocated by the owning category's alias NodeId factory; for standard
+ * categories a default factory allocates {@code "/Alias/"} in the
+ * config's Node namespace, and the Node is hosted in the manager's own AddressSpace fragment (it
+ * leaves the AddressSpace when the manager shuts down). The alias Node's BrowseName uses its own
+ * NodeId's namespace index — lookup ignores the BrowseName namespace, so this is a presentation
+ * choice only.
+ *
+ * Targets organized under the standard {@code TagVariables} and {@code Topics} Objects are
+ * constrained: when {@code categoryId} is {@code TagVariables} or {@code Topics}, or a category
+ * whose ancestor {@code Organizes} chain reaches one of them, each target Node must have the
+ * Variable NodeClass (TagVariables) or be an instance of {@code PublishedDataSetType} or a
+ * subtype (Topics).
+ *
+ *
Adding is idempotent: if an alias of the same name already exists in the category, missing
+ * target References are added and the existing NodeId is returned; if every given target is
+ * already associated, nothing changes. {@code LastChange} is bumped for the category (and its
+ * ancestors) whenever something actually changed.
+ *
+ *
The existing-alias lookup scans the category's directly organized members (the AddressSpace
+ * is the single source of truth; there is no name index), so each call costs O(category size)
+ * under the manager lock — bulk-loading N aliases one by one into the same category is O(N²).
+ *
+ *
This programmatic path is trusted application code: the {@link AliasAuthorizationPolicy} is
+ * not consulted.
+ *
+ * @param categoryId the NodeId of the organizing category.
+ * @param aliasName the alias name; becomes the alias Node's BrowseName text.
+ * @param targets the targets to associate; at least one, all local.
+ * @return the NodeId of the created (or pre-existing) alias Node.
+ * @throws UaException with {@code Bad_NodeIdUnknown} if the category is not managed-or-standard,
+ * does not exist, or a target Node does not exist; {@code Bad_InvalidArgument} if the alias
+ * name is empty, {@code targets} is empty, a target's ReferenceType is not {@code AliasFor}
+ * or a subtype, or a target violates the TagVariables/Topics NodeClass constraint; {@code
+ * Bad_NotSupported} for a remote target; {@code Bad_NodeIdExists} if the allocated alias
+ * NodeId is already in use by a different Node; {@code Bad_InternalError} if the affected
+ * categories' new {@code LastChange} versions cannot be persisted (the mutation is not
+ * applied).
+ * @throws IllegalStateException if the manager is not running.
+ */
+ public NodeId addAlias(NodeId categoryId, String aliasName, List targets)
+ throws UaException {
+
+ checkRunning();
+
+ lock.lock();
+ try {
+ CategoryRecord record = resolveCategoryRecord(categoryId);
+
+ if (aliasName.isEmpty()) {
+ throw new UaException(StatusCodes.Bad_InvalidArgument, "aliasName is empty");
+ }
+ if (targets.isEmpty()) {
+ throw new UaException(StatusCodes.Bad_InvalidArgument, "at least one target is required");
+ }
+
+ TargetConstraint constraint = getTargetConstraint(categoryId);
+
+ var resolvedTargets = new LinkedHashSet();
+ for (AliasTarget target : targets) {
+ resolvedTargets.add(resolveTarget(target, constraint));
+ }
+
+ try {
+ return applyAddAlias(record, categoryId, aliasName, resolvedTargets);
+ } finally {
+ versionManager.publishPending();
+ }
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ /**
+ * Create an alias in a category, or extend an existing alias of the same name with the missing
+ * targets among {@code resolvedTargets} — the shared apply step of the programmatic {@link
+ * #addAlias} and the per-entry Method path, called with the lock held and every input already
+ * validated. The caller publishes the prepared versions in a {@code finally}.
+ *
+ * The affected categories' new versions are prepared (persisted) as soon as a change is about
+ * to be applied — before the mutating call, so the LastChange bump both aborts the mutation when
+ * persistence fails and survives a mutation that throws partway through. A fully duplicate
+ * association changes nothing, prepares nothing, and is not an error (Part 17 §6.3.4
+ * idempotency).
+ *
+ * @return the NodeId of the created (or pre-existing) alias Node.
+ */
+ private NodeId applyAddAlias(
+ CategoryRecord record,
+ NodeId categoryId,
+ String aliasName,
+ Collection resolvedTargets)
+ throws UaException {
+
+ NodeId existingAliasId = findAliasInCategory(categoryId, aliasName);
+
+ if (existingAliasId != null) {
+ UaNode aliasNode =
+ server
+ .getAddressSpaceManager()
+ .getManagedNode(existingAliasId)
+ .orElseThrow(
+ () ->
+ new UaException(
+ StatusCodes.Bad_NodeIdUnknown,
+ "alias not found: " + existingAliasId.toParseableString()));
+
+ // One Reference scan covers the whole batch; testing each target against a fresh scan
+ // would repeat the aggregated AddressSpace query per target.
+ Set existingAssociations = collectExistingAssociations(existingAliasId);
+
+ boolean prepared = false;
+
+ for (ResolvedTarget target : resolvedTargets) {
+ if (!existingAssociations.contains(target)) {
+ // A target change is observable from every category that organizes the alias, not
+ // just the one addressed, so all of them get a LastChange bump. Prepared BEFORE the
+ // mutation so a failed save aborts the change and a Reference add that throws
+ // partway through still gets its bumps published.
+ if (!prepared) {
+ var bumped = new ArrayList();
+ bumped.add(categoryId);
+ bumped.addAll(getOrganizingCategories(existingAliasId));
+ versionManager.prepare(bumped);
+ prepared = true;
+ }
+
+ aliasNode.addReference(
+ new Reference(
+ existingAliasId,
+ target.referenceTypeId(),
+ target.nodeId().expanded(),
+ Reference.Direction.FORWARD));
+ }
+ }
+
+ return existingAliasId;
+ }
+
+ NodeId aliasNodeId = record.aliasNodeIdFactory().apply(aliasName);
+
+ if (server.getAddressSpaceManager().getManagedNode(aliasNodeId).isPresent()) {
+ throw new UaException(
+ StatusCodes.Bad_NodeIdExists,
+ "alias NodeId already in use: " + aliasNodeId.toParseableString());
+ }
+
+ var aliasNode =
+ new AliasNameTypeNode(
+ new ManagedNodeContext(server, record.nodeManager()),
+ aliasNodeId,
+ new QualifiedName(aliasNodeId.getNamespaceIndex(), aliasName),
+ // §6.2: the DisplayName is the BrowseName text "with an empty locale id and no
+ // other locale shall be provided" — hence not the single-argument constructor,
+ // which defaults the locale to "en".
+ new LocalizedText(null, aliasName),
+ LocalizedText.NULL_VALUE,
+ UInteger.valueOf(0),
+ UInteger.valueOf(0),
+ null,
+ null,
+ null);
+
+ // Prepared BEFORE the mutations so a failed save aborts the creation with nothing applied,
+ // and the LastChange bump publishes even if the Node add or a Reference add throws partway
+ // through.
+ versionManager.prepare(List.of(categoryId));
+
+ record.nodeManager().addNode(aliasNode);
+
+ try {
+ aliasNode.addReference(
+ new Reference(
+ aliasNodeId,
+ NodeIds.HasTypeDefinition,
+ NodeIds.AliasNameType.expanded(),
+ Reference.Direction.FORWARD));
+
+ aliasNode.addReference(
+ new Reference(
+ aliasNodeId, NodeIds.Organizes, categoryId.expanded(), Reference.Direction.INVERSE));
+
+ for (ResolvedTarget target : resolvedTargets) {
+ aliasNode.addReference(
+ new Reference(
+ aliasNodeId,
+ target.referenceTypeId(),
+ target.nodeId().expanded(),
+ Reference.Direction.FORWARD));
+ }
+ } catch (RuntimeException e) {
+ // A partially wired alias Node — untyped, unlinked, or targetless — violates the Part 17
+ // model and would be visible to searches forever, so a failure after addNode removes the
+ // Node again. Best-effort only: the removal runs through the same NodeManager that just
+ // threw, so its own failure is logged and the original failure propagates.
+ try {
+ aliasNode.delete();
+ } catch (RuntimeException suppressed) {
+ e.addSuppressed(suppressed);
+ logger.error(
+ "Failed to remove partially created alias Node: {}",
+ aliasNodeId.toParseableString(),
+ suppressed);
+ }
+ throw e;
+ }
+
+ return aliasNodeId;
+ }
+
+ /**
+ * Delete an alias from a category, or remove individual target References from it.
+ *
+ * With {@code targets == null} the alias Object is removed from this category; if no other
+ * category organizes it, the Object and all its References are deleted. With explicit targets,
+ * the matching {@code AliasFor}-or-subtype References are removed (removing a target that is not
+ * associated is a no-op); if the last such Reference is removed, the alias Object is deleted from
+ * every organizing category, because an alias without a target violates the Part 17
+ * model. {@code LastChange} is bumped for every affected category.
+ *
+ *
This programmatic path is trusted application code: the {@link AliasAuthorizationPolicy} is
+ * not consulted.
+ *
+ * @param categoryId the NodeId of the organizing category.
+ * @param aliasName the alias name, matched against alias BrowseName text (namespace ignored).
+ * @param targets the targets to disassociate, or null to remove the alias from the category.
+ * @throws UaException with {@code Bad_NodeIdUnknown} if the category is not managed-or-standard
+ * or does not exist; {@code Bad_NotFound} if no alias of that name exists in the category;
+ * {@code Bad_InvalidArgument} if {@code targets} is non-null but empty, or an explicit
+ * target's ReferenceType is not {@code AliasFor} or a subtype; {@code Bad_InternalError} if
+ * the affected categories' new {@code LastChange} versions cannot be persisted (the mutation
+ * is not applied).
+ * @throws IllegalStateException if the manager is not running.
+ */
+ public void deleteAlias(NodeId categoryId, String aliasName, @Nullable List targets)
+ throws UaException {
+
+ checkRunning();
+
+ lock.lock();
+ try {
+ resolveCategoryRecord(categoryId);
+
+ NodeId aliasNodeId = findAliasInCategory(categoryId, aliasName);
+ if (aliasNodeId == null) {
+ throw new UaException(
+ StatusCodes.Bad_NotFound,
+ "alias \"%s\" not found in category %s"
+ .formatted(aliasName, categoryId.toParseableString()));
+ }
+
+ UaNode aliasNode =
+ server
+ .getAddressSpaceManager()
+ .getManagedNode(aliasNodeId)
+ .orElseThrow(
+ () ->
+ new UaException(
+ StatusCodes.Bad_NotFound,
+ "alias not found: " + aliasNodeId.toParseableString()));
+
+ if (targets == null) {
+ try {
+ // Persist the category's new version before mutating: a failed save aborts the
+ // removal with nothing changed.
+ versionManager.prepare(List.of(categoryId));
+
+ removeFromCategory(aliasNode, categoryId);
+
+ if (getOrganizingCategories(aliasNodeId).isEmpty()) {
+ aliasNode.delete();
+ }
+ } finally {
+ versionManager.publishPending();
+ }
+ return;
+ }
+
+ if (targets.isEmpty()) {
+ throw new UaException(
+ StatusCodes.Bad_InvalidArgument,
+ "targets is empty; pass null to remove the alias from the category");
+ }
+
+ // Validate every explicit target's ReferenceType before removing anything: only alias
+ // linkage (AliasFor or a subtype) may be removed here. Without this check a caller-supplied
+ // ReferenceType like HasTypeDefinition would strip structural References and corrupt the
+ // Node.
+ for (AliasTarget target : targets) {
+ if (!aliasTypes.isAliasForOrSubtype(target.referenceTypeId())) {
+ throw new UaException(
+ StatusCodes.Bad_InvalidArgument,
+ "ReferenceType is not AliasFor or a subtype: "
+ + target.referenceTypeId().toParseableString());
+ }
+ }
+
+ // Collect the matching References before removing anything, so the affected categories'
+ // new versions can be persisted before the first mutation.
+ var matching = new ArrayList();
+ for (AliasTarget target : targets) {
+ NodeId targetNodeId = target.nodeId().toNodeId(server.getNamespaceTable()).orElse(null);
+ if (targetNodeId == null) {
+ // No local Node can match a non-local target; nothing to remove.
+ continue;
+ }
+
+ matching.addAll(
+ findTargetReferences(aliasNodeId, targetNodeId, target.referenceTypeId()::equals));
+ }
+
+ if (matching.isEmpty()) {
+ return;
+ }
+
+ try {
+ // A target change is observable from every category that organizes the alias, not just
+ // the one addressed, so all of them get a LastChange bump — persisted before the
+ // mutation so a failed save aborts it.
+ var bumped = new ArrayList();
+ bumped.add(categoryId);
+ bumped.addAll(getOrganizingCategories(aliasNodeId));
+ versionManager.prepare(bumped);
+
+ for (Reference reference : matching) {
+ aliasNode.removeReference(reference);
+ }
+
+ deleteIfTargetless(aliasNode);
+ } finally {
+ versionManager.publishPending();
+ }
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ /**
+ * Apply the entries of one {@code AddAliasesToCategory} Method call to a category, returning one
+ * StatusCode per entry (Part 17 §6.3.4).
+ *
+ * Call-level validation failures fail the whole call before any entry is processed: null,
+ * empty, or non-parallel {@code AliasNames}/{@code TargetNodes} arrays, a non-empty {@code
+ * TargetServers} array of a different length, more entries than the configured
+ * operations-per-call limit, or a {@code TargetReferenceType} that is not a known ReferenceType
+ * in the {@code AliasFor} hierarchy. Everything else is reported per entry, and a failed entry
+ * does not affect any other entry. {@code LastChange} is bumped once per affected category after
+ * all entries are processed. Each entry locates its alias by scanning the category's directly
+ * organized members (see {@link AliasLimits#maxOperationsPerCall}), so a call costs O(entries
+ * × category size) under the manager lock.
+ *
+ *
Unlike the programmatic {@link #addAlias} — where a non-local {@link ExpandedNodeId} with a
+ * null server URI is rejected with {@code Bad_NotSupported} — this wire path follows §6.3.4's
+ * rule that "the ServerIndex in the ExpandedNodeId shall be ignored and the TargetServers Uri
+ * shall be used": when an entry's {@code TargetServers} element is null or empty the target is
+ * local, and any server reference carried by the wire {@code ExpandedNodeId} is dropped before
+ * resolution. A non-empty {@code TargetServers} element still marks the entry's target remote,
+ * which fails that entry with {@code Bad_NotSupported}.
+ *
+ *
Called by the network-facing Method handler after authorization; the {@link
+ * AliasAuthorizationPolicy} is not consulted here.
+ *
+ * @param categoryId the NodeId of the category the Method was called on.
+ * @param aliasNames the alias names, parallel to {@code targetNodes}.
+ * @param targetNodes the target Nodes, parallel to {@code aliasNames}.
+ * @param targetServers the target server URIs, either null/empty (all targets local) or parallel
+ * to {@code aliasNames}.
+ * @param targetReferenceType the ReferenceType for every created association; null (or a
+ * null-valued NodeId) defaults to {@code AliasFor}.
+ * @return one StatusCode per entry, parallel to the inputs.
+ * @throws UaException with {@code Bad_InvalidArgument} for invalid array shapes (including all
+ * arrays empty) or an invalid {@code TargetReferenceType}; {@code Bad_TooManyOperations} if
+ * the arrays exceed the operations-per-call limit; {@code Bad_NodeIdUnknown} if the category
+ * is not managed-or-standard or no longer exists; {@code Bad_InvalidState} if the manager is
+ * no longer running.
+ */
+ StatusCode[] addAliasEntries(
+ NodeId categoryId,
+ String @Nullable [] aliasNames,
+ ExpandedNodeId @Nullable [] targetNodes,
+ String @Nullable [] targetServers,
+ @Nullable NodeId targetReferenceType)
+ throws UaException {
+
+ // §6.3.4 requires AliasNames and TargetNodes to be parallel; absent arrays cannot satisfy it.
+ if (aliasNames == null || targetNodes == null || aliasNames.length != targetNodes.length) {
+ throw new UaException(
+ StatusCodes.Bad_InvalidArgument, "AliasNames and TargetNodes must be parallel arrays");
+ }
+
+ // §6.3.4 Table 11 defines Bad_InvalidArgument for a call where "the size of the arrays for
+ // all arguments except TargetServers is not the same or if all arrays are empty" — so a
+ // zero-entry call is a call-level failure, not an empty success.
+ if (aliasNames.length == 0) {
+ throw new UaException(StatusCodes.Bad_InvalidArgument, "all arrays are empty");
+ }
+
+ // §6.3.4: a null or empty TargetServers array means every target is on the local server;
+ // a non-empty one must be parallel to the other arrays.
+ if (targetServers != null
+ && targetServers.length > 0
+ && targetServers.length != aliasNames.length) {
+ throw new UaException(
+ StatusCodes.Bad_InvalidArgument,
+ "TargetServers must be null, empty, or parallel to AliasNames");
+ }
+
+ // Part 17 §6.3.4 names only Bad_InvalidArgument and Bad_UserAccessDenied as call-level
+ // results, but Part 4 defines Bad_TooManyOperations for a request that specifies more
+ // operations than the Server supports — exactly this condition, and the code Milo's
+ // service-level operation limits already use — so it is preferred over the generic code.
+ if (aliasNames.length > config.getLimits().maxOperationsPerCall()) {
+ throw new UaException(
+ StatusCodes.Bad_TooManyOperations,
+ "%d entries exceed the maximum of %d"
+ .formatted(aliasNames.length, config.getLimits().maxOperationsPerCall()));
+ }
+
+ // §6.3.4: a null TargetReferenceType defaults to AliasFor. Anything else must be a known
+ // ReferenceType in the AliasFor hierarchy — every alias must have an AliasFor-or-subtype
+ // Reference, so an invalid type fails the whole call, not individual entries.
+ NodeId referenceTypeId;
+ if (targetReferenceType == null || targetReferenceType.isNull()) {
+ referenceTypeId = NodeIds.AliasFor;
+ } else {
+ if (!server.getReferenceTypeTree().containsType(targetReferenceType)) {
+ throw new UaException(
+ StatusCodes.Bad_InvalidArgument,
+ "unknown ReferenceType: " + targetReferenceType.toParseableString());
+ }
+ if (!aliasTypes.isAliasForOrSubtype(targetReferenceType)) {
+ throw new UaException(
+ StatusCodes.Bad_InvalidArgument,
+ "ReferenceType is not AliasFor or a subtype: "
+ + targetReferenceType.toParseableString());
+ }
+ referenceTypeId = targetReferenceType;
+ }
+
+ return processEntries(
+ categoryId,
+ aliasNames.length,
+ record -> {
+ TargetConstraint constraint = getTargetConstraint(categoryId);
+
+ return i -> {
+ String targetServer =
+ targetServers != null && targetServers.length > 0 ? targetServers[i] : null;
+
+ return addAliasEntry(
+ record,
+ categoryId,
+ constraint,
+ aliasNames[i],
+ targetNodes[i],
+ targetServer,
+ referenceTypeId);
+ };
+ });
+ }
+
+ /**
+ * Apply one {@code AddAliasesToCategory} entry, mapping failures to the entry's StatusCode.
+ *
+ *
Validation happens before any mutation, so an entry that fails validation leaves the
+ * AddressSpace untouched. A duplicate of an existing association — whether pre-existing or
+ * created by an earlier entry of the same call — changes nothing and reports {@code Good}
+ * (§6.3.4: such entries "shall be ignored and no error shall be generated"). Application-supplied
+ * code (the category's alias NodeId factory, its {@link NodeManager}) can throw unchecked
+ * mid-entry, however; such a failure is confined to its entry as {@code Bad_InternalError}, and
+ * any category whose new version was prepared before the mutation still gets its LastChange
+ * published.
+ */
+ private StatusCode addAliasEntry(
+ CategoryRecord record,
+ NodeId categoryId,
+ TargetConstraint constraint,
+ @Nullable String aliasName,
+ @Nullable ExpandedNodeId targetNode,
+ @Nullable String targetServer,
+ NodeId referenceTypeId) {
+
+ try {
+ if (aliasName == null || aliasName.isEmpty()) {
+ // §6.3.4's per-entry table defines no code for an invalid alias name (its
+ // Bad_NodeIdInvalid is about the TargetNode), so the generic code is used.
+ return new StatusCode(StatusCodes.Bad_InvalidArgument);
+ }
+
+ if (targetNode == null || targetNode.isNull()) {
+ // §6.3.4: "The syntax of the NodeId is not valid."
+ return new StatusCode(StatusCodes.Bad_NodeIdInvalid);
+ }
+
+ if (targetServer != null && !targetServer.isEmpty()) {
+ // §6.3.4 defines Bad_NotSupported for servers that do not support aliases with remote
+ // targets, which this manager does not; Uncertain_ReferenceOutOfServer is reserved for
+ // servers that accept a remote target they cannot verify.
+ return new StatusCode(StatusCodes.Bad_NotSupported);
+ }
+
+ // §6.3.4: "The ServerIndex in the ExpandedNodeId shall be ignored and the TargetServers
+ // Uri shall be used." The entry's TargetServers element is null or empty here, so the
+ // target is local and any server reference the wire ExpandedNodeId carries is dropped
+ // before resolution. (The programmatic addAlias keeps its stricter contract: a non-local
+ // ExpandedNodeId with a null serverUri is rejected with Bad_NotSupported.)
+ var localTargetNode =
+ new ExpandedNodeId(
+ ExpandedNodeId.ServerReference.of(0),
+ targetNode.namespace(),
+ targetNode.identifier());
+
+ // resolveTarget rejects unresolvable or missing local targets with Bad_NodeIdUnknown and
+ // TagVariables/Topics constraint violations with Bad_InvalidArgument — the §6.3.4
+ // per-entry codes.
+ ResolvedTarget resolved =
+ resolveTarget(new AliasTarget(localTargetNode, null, referenceTypeId), constraint);
+
+ applyAddAlias(record, categoryId, aliasName, List.of(resolved));
+
+ return StatusCode.GOOD;
+ } catch (UaException e) {
+ return e.getStatusCode();
+ } catch (RuntimeException e) {
+ // Application-supplied code runs inside an entry (the category's aliasNodeIdFactory, its
+ // NodeManager) and can throw unchecked; confine the failure to this entry so the others
+ // stay independent (§6.3.4's per-entry contract).
+ logger.error("AddAliasesToCategory entry failed: alias \"{}\"", aliasName, e);
+ return new StatusCode(StatusCodes.Bad_InternalError);
+ }
+ }
+
+ /**
+ * Apply the entries of one {@code DeleteAliasesFromCategory} Method call to a category, returning
+ * one StatusCode per entry (Part 17 §6.3.5).
+ *
+ *
Call-level validation failures fail the whole call before any entry is processed: a null
+ * {@code AliasNames} array, a non-null {@code TargetNodes} array of a different length, or more
+ * entries than the configured operations-per-call limit. §6.3.5 defines {@code TargetNodes} as a
+ * restriction on what is deleted and gives a null entry the meaning "all aliases with
+ * the provided name are deleted from the category"; a null {@code TargetNodes} array is
+ * read as every entry being null — no restriction anywhere — mirroring how §6.3.4 treats an
+ * absent {@code TargetServers} array. Per-entry failures affect only their own entry, and {@code
+ * LastChange} is bumped once per affected category after all entries are processed. Each entry
+ * locates its aliases by scanning the category's directly organized members (see {@link
+ * AliasLimits#maxOperationsPerCall}), so a call costs O(entries × category size) under the
+ * manager lock.
+ *
+ *
Called by the network-facing Method handler after authorization; the {@link
+ * AliasAuthorizationPolicy} is not consulted here.
+ *
+ * @param categoryId the NodeId of the category the Method was called on.
+ * @param aliasNames the alias names to delete.
+ * @param targetNodes the per-entry target restrictions, or null for no restrictions.
+ * @return one StatusCode per entry, parallel to the inputs.
+ * @throws UaException with {@code Bad_InvalidArgument} for invalid array shapes; {@code
+ * Bad_TooManyOperations} if the arrays exceed the operations-per-call limit; {@code
+ * Bad_NodeIdUnknown} if the category is not managed-or-standard or no longer exists; {@code
+ * Bad_InvalidState} if the manager is no longer running.
+ */
+ StatusCode[] deleteAliasEntries(
+ NodeId categoryId, String @Nullable [] aliasNames, ExpandedNodeId @Nullable [] targetNodes)
+ throws UaException {
+
+ if (aliasNames == null) {
+ throw new UaException(StatusCodes.Bad_InvalidArgument, "AliasNames is null");
+ }
+
+ if (targetNodes != null && targetNodes.length != aliasNames.length) {
+ throw new UaException(
+ StatusCodes.Bad_InvalidArgument, "TargetNodes must be null or parallel to AliasNames");
+ }
+
+ // Deliberately NO empty-arrays failure here, unlike addAliasEntries: §6.3.5 Table 15's
+ // Bad_InvalidArgument covers only "an argument is of the wrong type or the size of the
+ // arrays for all arguments is not the same" — it lacks §6.3.4 Table 11's "or if all arrays
+ // are empty" clause — so a zero-entry Delete call succeeds vacuously with empty results.
+
+ // See addAliasEntries for why Bad_TooManyOperations is preferred over Bad_InvalidArgument.
+ if (aliasNames.length > config.getLimits().maxOperationsPerCall()) {
+ throw new UaException(
+ StatusCodes.Bad_TooManyOperations,
+ "%d entries exceed the maximum of %d"
+ .formatted(aliasNames.length, config.getLimits().maxOperationsPerCall()));
+ }
+
+ return processEntries(
+ categoryId,
+ aliasNames.length,
+ record ->
+ i ->
+ deleteAliasEntry(
+ categoryId, aliasNames[i], targetNodes != null ? targetNodes[i] : null));
+ }
+
+ /**
+ * The shared skeleton of the wire mutation paths ({@link #addAliasEntries}, {@link
+ * #deleteAliasEntries}): serialize under the manager lock, re-check the running state, resolve
+ * the category, apply one entry at a time, and publish the {@code LastChange} values the entries
+ * prepared.
+ *
+ *
Each entry persists the new versions of the categories it affects before its first
+ * mutation (a failed save fails the entry with nothing applied); the publish runs in a finally
+ * because entries applied before an escaping failure already mutated the AddressSpace, and
+ * §6.3.1's LastChange invariant must hold for them regardless.
+ */
+ private StatusCode[] processEntries(
+ NodeId categoryId, int entryCount, Function entrySetup)
+ throws UaException {
+
+ lock.lock();
+ try {
+ // A Call dispatched before shutdown() can still be mid-dispatch when shutdown wins the
+ // lock first; by the time it gets here the fragment is unregistered, so mutating would
+ // create ghost state and persist spurious LastChange values. Fail with a defined code
+ // instead.
+ if (isNotRunning()) {
+ throw new UaException(StatusCodes.Bad_InvalidState, "AliasManager is not running");
+ }
+
+ EntryFunction entryFunction = entrySetup.apply(resolveCategoryRecord(categoryId));
+
+ var results = new StatusCode[entryCount];
+
+ try {
+ for (int i = 0; i < entryCount; i++) {
+ results[i] = entryFunction.apply(i);
+ }
+ } finally {
+ versionManager.publishPending();
+ }
+
+ return results;
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ /** Applies one wire mutation entry, reporting its outcome as the entry's StatusCode. */
+ @FunctionalInterface
+ private interface EntryFunction {
+ StatusCode apply(int index);
+ }
+
+ /**
+ * Apply one {@code DeleteAliasesFromCategory} entry, mapping failures to the entry's StatusCode.
+ *
+ * Unlike the programmatic {@link #deleteAlias}, which operates on one deterministically chosen
+ * alias, an entry applies to every alias Object of the given name directly organized by
+ * the category, per §6.3.5 ("all AliasNames with the provided name"). A null or null-valued
+ * target restriction removes those aliases from the category (an alias no other category
+ * organizes is deleted entirely); an explicit target removes the matching AliasFor-or-subtype
+ * References, and an alias whose last target Reference is removed is deleted from every
+ * organizing category, because an alias without a target violates the Part 17 model. Removing a
+ * target that is not associated changes nothing and reports {@code Good}; §6.3.5 reserves {@code
+ * Bad_NotFound} for an alias name the category does not contain.
+ *
+ *
Validation happens before any mutation, and reference removal through the manager's own
+ * fragment cannot partially fail, so an entry ordinarily either fully applies or leaves its state
+ * untouched (§6.3.5: "If all targets for an AliasNames array entry cannot be deleted, then none
+ * of the targets are deleted"). Aliases living in an application {@link NodeManager} can throw
+ * unchecked mid-entry, however; such a failure is confined to its entry as {@code
+ * Bad_InternalError}, and any category whose new version was prepared before the mutation still
+ * gets its LastChange published.
+ */
+ private StatusCode deleteAliasEntry(
+ NodeId categoryId, @Nullable String aliasName, @Nullable ExpandedNodeId targetNode) {
+
+ if (aliasName == null || aliasName.isEmpty()) {
+ // §6.3.5 defines no code for an invalid alias name; the generic code is used, matching
+ // the Add path.
+ return new StatusCode(StatusCodes.Bad_InvalidArgument);
+ }
+
+ try {
+ List aliasNodeIds = findAliasesInCategory(categoryId, aliasName);
+ if (aliasNodeIds.isEmpty()) {
+ // §6.3.5: "The AliasName was not located."
+ return new StatusCode(StatusCodes.Bad_NotFound);
+ }
+
+ if (targetNode == null || targetNode.isNull()) {
+ // §6.3.5: a null or empty TargetNodes entry deletes all aliases with the provided name
+ // from the category.
+ //
+ // Prepared BEFORE the mutations so a failed save fails the entry with nothing applied,
+ // and the category's LastChange bump publishes even if a removal throws partway
+ // through.
+ versionManager.prepare(List.of(categoryId));
+
+ for (NodeId aliasNodeId : aliasNodeIds) {
+ UaNode aliasNode =
+ server.getAddressSpaceManager().getManagedNode(aliasNodeId).orElse(null);
+ if (aliasNode == null) {
+ continue;
+ }
+
+ removeFromCategory(aliasNode, categoryId);
+
+ if (getOrganizingCategories(aliasNodeId).isEmpty()) {
+ aliasNode.delete();
+ }
+ }
+
+ return StatusCode.GOOD;
+ }
+
+ NodeId targetNodeId = targetNode.toNodeId(server.getNamespaceTable()).orElse(null);
+ if (targetNodeId == null) {
+ // No local Reference can match a non-local target restriction; nothing to remove.
+ return StatusCode.GOOD;
+ }
+
+ for (NodeId aliasNodeId : aliasNodeIds) {
+ UaNode aliasNode = server.getAddressSpaceManager().getManagedNode(aliasNodeId).orElse(null);
+ if (aliasNode == null) {
+ continue;
+ }
+
+ // The Method carries no ReferenceType argument, so any AliasFor-or-subtype Reference to
+ // the target is removed.
+ List matching =
+ findTargetReferences(aliasNodeId, targetNodeId, aliasTypes::isAliasForOrSubtype);
+
+ if (matching.isEmpty()) {
+ continue;
+ }
+
+ // A target change is observable from every category that organizes the alias, not just
+ // the one addressed, so all of them get a LastChange bump. Prepared BEFORE the mutation
+ // so a failed save fails the entry before this alias is touched, and the bumps publish
+ // even if a Reference removal throws partway through.
+ var bumped = new ArrayList();
+ bumped.add(categoryId);
+ bumped.addAll(getOrganizingCategories(aliasNodeId));
+ versionManager.prepare(bumped);
+
+ for (Reference reference : matching) {
+ aliasNode.removeReference(reference);
+ }
+
+ deleteIfTargetless(aliasNode);
+ }
+
+ return StatusCode.GOOD;
+ } catch (UaException e) {
+ // A failed LastChange save aborts the entry before its mutation is applied.
+ return e.getStatusCode();
+ } catch (RuntimeException e) {
+ // Application-supplied code runs inside an entry (the aliases and their References can
+ // live in an application NodeManager) and can throw unchecked; confine the failure to
+ // this entry so the others stay independent (§6.3.5's per-entry contract).
+ logger.error("DeleteAliasesFromCategory entry failed: alias \"{}\"", aliasName, e);
+ return new StatusCode(StatusCodes.Bad_InternalError);
+ }
+ }
+
+ /**
+ * Find aliases under {@code categoryId} whose name matches {@code pattern}.
+ *
+ * Uses the same engine the {@code FindAlias} Method uses, but as a trusted programmatic call:
+ * the {@link AliasAuthorizationPolicy} is not consulted. No lock is taken; a lookup overlapping a
+ * mutation may observe it partially applied.
+ *
+ * @param categoryId the NodeId of the category to search from.
+ * @param pattern a Part 4 {@code Like} pattern matched against alias name text.
+ * @param referenceTypeFilter restricts targets to References of this type or a subtype; null
+ * means no restriction beyond {@code AliasFor} and its subtypes.
+ * @return the matching entries, ordered by alias name text then alias NodeId.
+ * @throws UaException see {@link AliasSearchEngine#findAlias(NodeId, String, NodeId)}.
+ * @throws IllegalStateException if the manager is not running.
+ */
+ public List findAlias(
+ NodeId categoryId, String pattern, @Nullable NodeId referenceTypeFilter) throws UaException {
+
+ checkRunning();
+
+ return searchEngine.findAlias(categoryId, pattern, referenceTypeFilter);
+ }
+
+ /**
+ * Find aliases under {@code categoryId} whose name matches {@code pattern}, with containing
+ * category and target server details.
+ *
+ * Uses the same engine the {@code FindAliasVerbose} Method uses, but as a trusted programmatic
+ * call: the {@link AliasAuthorizationPolicy} is not consulted. No lock is taken; a lookup
+ * overlapping a mutation may observe it partially applied.
+ *
+ * @param categoryId the NodeId of the category to search from.
+ * @param pattern a Part 4 {@code Like} pattern matched against alias name text.
+ * @param referenceTypeFilter restricts targets to References of this type or a subtype; null
+ * means no restriction beyond {@code AliasFor} and its subtypes.
+ * @return the matching entries, ordered by alias name text then alias NodeId.
+ * @throws UaException see {@link AliasSearchEngine#findAliasVerbose(NodeId, String, NodeId)}.
+ * @throws IllegalStateException if the manager is not running.
+ */
+ public List findAliasVerbose(
+ NodeId categoryId, String pattern, @Nullable NodeId referenceTypeFilter) throws UaException {
+
+ checkRunning();
+
+ return searchEngine.findAliasVerbose(categoryId, pattern, referenceTypeFilter);
+ }
+
+ /**
+ * Bump the {@code LastChange} version of a category and its ancestor categories.
+ *
+ * An escape hatch for applications that deliberately edited the alias hierarchy directly
+ * through a NodeManager: such edits are found by searches but bypass version maintenance, so they
+ * must be followed by a {@code touch} for Client caches to invalidate correctly.
+ *
+ * @param categoryId the NodeId of the category to bump.
+ * @throws UaException with {@code Bad_InternalError} if the new version cannot be persisted; a
+ * version is never published without being persisted first.
+ * @throws IllegalStateException if the manager is not running.
+ */
+ public void touch(NodeId categoryId) throws UaException {
+ checkRunning();
+
+ lock.lock();
+ try {
+ versionManager.touch(categoryId);
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ private void checkRunning() {
+ if (isNotRunning()) {
+ throw new IllegalStateException("AliasManager is not running");
+ }
+ }
+
+ /** Resolve the standard {@code FindAlias} Nodes; a missing Node is logged and skipped. */
+ private List resolveStandardFindAliasNodes() {
+ var methodNodes = new ArrayList();
+
+ for (NodeId nodeId : STANDARD_FIND_ALIAS_NODE_IDS) {
+ UaNode node = server.getAddressSpaceManager().getManagedNode(nodeId).orElse(null);
+
+ if (node instanceof UaMethodNode methodNode) {
+ methodNodes.add(methodNode);
+ } else {
+ logger.warn("FindAlias UaMethodNode not found: {}", nodeId.toParseableString());
+ }
+ }
+
+ return methodNodes;
+ }
+
+ /**
+ * Resolve the Optional Method Nodes to materialize on the standard Objects — {@code
+ * FindAliasVerbose} when verbose lookup is enabled, {@code AddAliasesToCategory} and {@code
+ * DeleteAliasesFromCategory} when configuration is enabled — and verify their NodeIds are free; a
+ * missing Object is logged and skipped, a NodeId collision fails startup.
+ *
+ * Pure validation: no state is mutated, so a throw here leaves no trace.
+ */
+ private List resolveStandardMethodPlans() {
+ var plans = new ArrayList();
+
+ for (NodeId objectId : STANDARD_CATEGORY_IDS) {
+ UaNode objectNode = server.getAddressSpaceManager().getManagedNode(objectId).orElse(null);
+ if (objectNode == null) {
+ logger.warn(
+ "Cannot materialize Optional Methods; Object not found: {}",
+ objectId.toParseableString());
+ continue;
+ }
+
+ // The ns=0 BrowseName of the standard Object seeds the materialized Method NodeIds, e.g.
+ // "Aliases/FindAliasVerbose".
+ String objectName = objectNode.getBrowseName().name();
+ if (objectName == null) {
+ logger.warn(
+ "Cannot materialize Optional Methods; Object has no BrowseName text: {}",
+ objectId.toParseableString());
+ continue;
+ }
+
+ if (config.isFindAliasVerboseEnabled()) {
+ plans.add(
+ newMethodPlan(
+ objectNode,
+ objectName,
+ "FindAliasVerbose",
+ methodNode ->
+ new FindAliasVerboseMethodImpl(
+ methodNode, searchEngine, config.getAuthorizationPolicy())));
+ }
+
+ if (config.isConfigurationEnabled()) {
+ plans.add(
+ newMethodPlan(
+ objectNode,
+ objectName,
+ "AddAliasesToCategory",
+ methodNode ->
+ new AddAliasesToCategoryMethodImpl(
+ methodNode, this, config.getAuthorizationPolicy())));
+
+ plans.add(
+ newMethodPlan(
+ objectNode,
+ objectName,
+ "DeleteAliasesFromCategory",
+ methodNode ->
+ new DeleteAliasesFromCategoryMethodImpl(
+ methodNode, this, config.getAuthorizationPolicy())));
+ }
+ }
+
+ return plans;
+ }
+
+ /**
+ * Build the plan for one Method Node to materialize, verifying its NodeId — {@code
+ * "/"} in the configured Node namespace — is free.
+ */
+ private MethodPlan newMethodPlan(
+ UaNode objectNode,
+ String objectName,
+ String methodName,
+ Function handlerFactory) {
+
+ var methodNodeId = new NodeId(config.getNodeNamespaceIndex(), objectName + "/" + methodName);
+
+ if (server.getAddressSpaceManager().getManagedNode(methodNodeId).isPresent()) {
+ throw new IllegalStateException("NodeId already in use: " + methodNodeId.toParseableString());
+ }
+
+ return new MethodPlan(
+ objectNode, methodNodeId, new QualifiedName(0, methodName), handlerFactory);
+ }
+
+ /**
+ * Materialize the Method Node described by {@code plan} as a component of its standard Object,
+ * bind a handler, and record it for removal at shutdown.
+ *
+ * The Node lives in the manager's own fragment; its NodeId is in the configured Method-Node
+ * namespace and its BrowseName is the ns=0 name of the standard type member it instantiates.
+ */
+ private void materializeMethod(MethodPlan plan) {
+ NodeId methodNodeId = plan.methodNodeId();
+ QualifiedName browseName = plan.browseName();
+
+ var methodNode =
+ new UaMethodNode(
+ fragment.getNodeContext(),
+ methodNodeId,
+ browseName,
+ LocalizedText.english(browseName.name()),
+ LocalizedText.NULL_VALUE,
+ UInteger.valueOf(0),
+ UInteger.valueOf(0),
+ true,
+ true);
+
+ fragment.getNodeManager().addNode(methodNode);
+
+ methodNode.addReference(
+ new Reference(
+ methodNodeId,
+ NodeIds.HasComponent,
+ plan.objectNode().getNodeId().expanded(),
+ Reference.Direction.INVERSE));
+
+ bindHandler(methodNode, plan.handlerFactory().apply(methodNode));
+
+ materializedMethodNodes.add(methodNode);
+ }
+
+ /**
+ * Add a {@code bindMethod} step to {@code builder}: bind the handler the factory produces and
+ * record the bound Node in {@code boundMethodNodes} for unbinding later.
+ */
+ private void bindMethodAt(
+ InstantiationRequest.Builder builder,
+ BrowsePath path,
+ List boundMethodNodes,
+ Function handlerFactory) {
+
+ builder.bindMethod(
+ path,
+ methodNode -> {
+ bindHandler(methodNode, handlerFactory.apply(methodNode));
+ boundMethodNodes.add(methodNode);
+ });
+ }
+
+ /**
+ * Bind {@code handler} to {@code methodNode}, set the argument Properties from the handler's
+ * definitions, and restore the executable flags.
+ */
+ private static void bindHandler(
+ UaMethodNode methodNode, AbstractMethodInvocationHandler handler) {
+
+ methodNode.bindInvocationHandler(handler);
+
+ methodNode.setExecutable(true);
+ methodNode.setUserExecutable(true);
+ }
+
+ /**
+ * Reset {@code methodNode} to its unbound state: no handler, not executable.
+ *
+ * Deliberately not the full inverse of {@link #bindHandler}: argument Properties the bind
+ * published stay in place, still correctly describing the now non-executable Method.
+ */
+ private static void unbindHandler(UaMethodNode methodNode) {
+ methodNode.setInvocationHandler(MethodInvocationHandler.NOT_IMPLEMENTED);
+ methodNode.setExecutable(false);
+ methodNode.setUserExecutable(false);
+ }
+
+ /**
+ * Resolve the record of a managed or standard category, verifying the category Node exists.
+ *
+ *
Standard categories are usable without registration; their record hosts alias Nodes in the
+ * manager's own fragment and uses the default alias NodeId factory.
+ */
+ private CategoryRecord resolveCategoryRecord(NodeId categoryId) throws UaException {
+ CategoryRecord record = categories.get(categoryId);
+
+ if (record == null && !STANDARD_CATEGORY_IDS.contains(categoryId)) {
+ throw new UaException(
+ StatusCodes.Bad_NodeIdUnknown, "category not managed: " + categoryId.toParseableString());
+ }
+
+ if (server.getAddressSpaceManager().getManagedNode(categoryId).isEmpty()) {
+ throw new UaException(
+ StatusCodes.Bad_NodeIdUnknown, "category not found: " + categoryId.toParseableString());
+ }
+
+ if (record != null) {
+ return record;
+ }
+
+ return new CategoryRecord(
+ defaultAliasNodeIdFactory(categoryId), fragment.getNodeManager(), List.of(), null);
+ }
+
+ /**
+ * The alias NodeId factory used for standard and adopted categories, which have no
+ * application-supplied factory: {@code "/Alias/"} in the configured
+ * Node namespace.
+ */
+ private Function defaultAliasNodeIdFactory(NodeId categoryId) {
+ return aliasName ->
+ new NodeId(
+ config.getNodeNamespaceIndex(), categoryId.toParseableString() + "/Alias/" + aliasName);
+ }
+
+ /**
+ * The target constraint the organizing category imposes: Variable NodeClass under {@code
+ * TagVariables}, {@code PublishedDataSetType} instances under {@code Topics}, none elsewhere.
+ *
+ * A constraint applies when {@code categoryId} is the standard Object or its ancestor
+ * {@code Organizes} chain (through {@code AliasNameCategoryType} instances) reaches it. A
+ * category reachable from both — a degenerate multi-parent arrangement — gets both constraints,
+ * which no target can satisfy.
+ */
+ private TargetConstraint getTargetConstraint(NodeId categoryId) {
+ boolean tagVariable = NodeIds.TagVariables.equals(categoryId);
+ boolean topic = NodeIds.Topics.equals(categoryId);
+
+ if (!tagVariable && !topic) {
+ List ancestors = versionManager.getAncestorCategories(categoryId);
+ tagVariable = ancestors.contains(NodeIds.TagVariables);
+ topic = ancestors.contains(NodeIds.Topics);
+ }
+
+ return new TargetConstraint(tagVariable, topic);
+ }
+
+ /**
+ * Validate an {@link AliasTarget} and resolve its NodeId: the ReferenceType must be {@code
+ * AliasFor} or a subtype, the target must be local and resolvable against the namespace table,
+ * the target Node must exist, and the organizing category's NodeClass/type-definition constraint
+ * (if any) must hold.
+ */
+ private ResolvedTarget resolveTarget(AliasTarget target, TargetConstraint constraint)
+ throws UaException {
+
+ if (!aliasTypes.isAliasForOrSubtype(target.referenceTypeId())) {
+ throw new UaException(
+ StatusCodes.Bad_InvalidArgument,
+ "ReferenceType is not AliasFor or a subtype: "
+ + target.referenceTypeId().toParseableString());
+ }
+
+ if (!target.isLocal() || !target.nodeId().isLocal()) {
+ throw new UaException(
+ StatusCodes.Bad_NotSupported,
+ "remote targets are not supported: " + target.nodeId().toParseableString());
+ }
+
+ NodeId targetNodeId =
+ target
+ .nodeId()
+ .toNodeId(server.getNamespaceTable())
+ .orElseThrow(
+ () ->
+ new UaException(
+ StatusCodes.Bad_NodeIdUnknown,
+ "target not resolvable: " + target.nodeId().toParseableString()));
+
+ UaNode targetNode =
+ server
+ .getAddressSpaceManager()
+ .getManagedNode(targetNodeId)
+ .orElseThrow(
+ () ->
+ new UaException(
+ StatusCodes.Bad_NodeIdUnknown,
+ "target not found: " + targetNodeId.toParseableString()));
+
+ if (constraint.requireVariable() && targetNode.getNodeClass() != NodeClass.Variable) {
+ throw new UaException(
+ StatusCodes.Bad_InvalidArgument,
+ "aliases organized under TagVariables must target Variable Nodes; %s has NodeClass %s"
+ .formatted(targetNodeId.toParseableString(), targetNode.getNodeClass()));
+ }
+
+ if (constraint.requirePublishedDataSet()) {
+ NodeId typeDefinitionId = aliasTypes.getTypeDefinitionId(targetNodeId);
+
+ boolean isPublishedDataSet =
+ typeDefinitionId != null
+ && (NodeIds.PublishedDataSetType.equals(typeDefinitionId)
+ || server
+ .getObjectTypeTree()
+ .isSubtypeOf(typeDefinitionId, NodeIds.PublishedDataSetType));
+
+ if (!isPublishedDataSet) {
+ throw new UaException(
+ StatusCodes.Bad_InvalidArgument,
+ "aliases organized under Topics must target PublishedDataSetType instances: "
+ + targetNodeId.toParseableString());
+ }
+ }
+
+ return new ResolvedTarget(targetNodeId, target.referenceTypeId());
+ }
+
+ /**
+ * Find the alias Object named {@code aliasName} directly organized by {@code categoryId},
+ * matching on BrowseName text alone (namespace index ignored). When several same-named alias
+ * Objects exist, the one with the smallest parseable NodeId is chosen, deterministically.
+ */
+ private @Nullable NodeId findAliasInCategory(NodeId categoryId, String aliasName) {
+ return findAliasesInCategory(categoryId, aliasName).stream()
+ .min(Comparator.comparing(NodeId::toParseableString))
+ .orElse(null);
+ }
+
+ /** Find every alias Object directly organized by {@code categoryId}, regardless of name. */
+ private List findOrganizedAliases(NodeId categoryId) {
+ List organizes =
+ server
+ .getAddressSpaceManager()
+ .getManagedReferences(categoryId, Reference.ORGANIZES_PREDICATE);
+
+ var found = new ArrayList();
+ for (Reference reference : organizes) {
+ reference
+ .getTargetNodeId()
+ .toNodeId(server.getNamespaceTable())
+ .filter(aliasTypes::isAliasNameInstance)
+ .ifPresent(found::add);
+ }
+ return found;
+ }
+
+ /**
+ * Find every alias Object named {@code aliasName} directly organized by {@code categoryId},
+ * matching on BrowseName text alone (namespace index ignored).
+ */
+ private List findAliasesInCategory(NodeId categoryId, String aliasName) {
+ List organizes =
+ server
+ .getAddressSpaceManager()
+ .getManagedReferences(categoryId, Reference.ORGANIZES_PREDICATE);
+
+ var found = new ArrayList();
+ for (Reference reference : organizes) {
+ NodeId organizedId =
+ reference.getTargetNodeId().toNodeId(server.getNamespaceTable()).orElse(null);
+ if (organizedId == null) {
+ continue;
+ }
+
+ // Name before type: the name comparison is an in-memory check, while the type test costs
+ // another Reference query plus a type-tree walk, and most organized Nodes won't match.
+ UaNode node = server.getAddressSpaceManager().getManagedNode(organizedId).orElse(null);
+ if (node == null || !aliasName.equals(node.getBrowseName().name())) {
+ continue;
+ }
+
+ if (aliasTypes.isAliasNameInstance(organizedId)) {
+ found.add(organizedId);
+ }
+ }
+ return found;
+ }
+
+ /**
+ * The (target, ReferenceType) pairs of the alias's existing forward References, for batch
+ * duplicate checks against {@link ResolvedTarget}s.
+ */
+ private Set collectExistingAssociations(NodeId aliasNodeId) {
+ var associations = new HashSet();
+
+ List references =
+ server.getAddressSpaceManager().getManagedReferences(aliasNodeId, Reference::isForward);
+
+ for (Reference reference : references) {
+ reference
+ .getTargetNodeId()
+ .toNodeId(server.getNamespaceTable())
+ .ifPresent(
+ targetId ->
+ associations.add(new ResolvedTarget(targetId, reference.getReferenceTypeId())));
+ }
+
+ return associations;
+ }
+
+ /**
+ * The alias's forward References to {@code targetNodeId} whose ReferenceType passes {@code
+ * refTypeOk}, resolving each Reference's target against the namespace table.
+ */
+ private List findTargetReferences(
+ NodeId aliasNodeId, NodeId targetNodeId, Predicate refTypeOk) {
+
+ return server
+ .getAddressSpaceManager()
+ .getManagedReferences(
+ aliasNodeId,
+ reference ->
+ reference.isForward()
+ && refTypeOk.test(reference.getReferenceTypeId())
+ && reference
+ .getTargetNodeId()
+ .toNodeId(server.getNamespaceTable())
+ .map(targetNodeId::equals)
+ .orElse(false));
+ }
+
+ /**
+ * Delete the alias Object if its last target Reference is gone — an alias without a target
+ * violates the Part 17 model, so it is deleted from every category that organizes it. The
+ * organizing categories' new versions are prepared (persisted) before deletion, so a
+ * failed save aborts the deletion and a deletion that throws partway through still gets its
+ * {@code LastChange} bumps published.
+ *
+ * @return {@code true} if the alias was targetless and deleted.
+ */
+ private boolean deleteIfTargetless(UaNode aliasNode) throws UaException {
+ NodeId aliasNodeId = aliasNode.getNodeId();
+
+ if (!collectRemainingTargets(aliasNodeId).isEmpty()) {
+ return false;
+ }
+
+ versionManager.prepare(getOrganizingCategories(aliasNodeId));
+
+ aliasNode.delete();
+
+ return true;
+ }
+
+ /** The forward {@code AliasFor}-or-subtype References of an alias Node. */
+ private List collectRemainingTargets(NodeId aliasNodeId) {
+ return server
+ .getAddressSpaceManager()
+ .getManagedReferences(
+ aliasNodeId,
+ reference ->
+ reference.isForward()
+ && aliasTypes.isAliasForOrSubtype(reference.getReferenceTypeId()));
+ }
+
+ /**
+ * The categories that organize {@code aliasNodeId}, resolved via inverse Organizes References
+ * aggregated across every registered NodeManager.
+ *
+ * Limitation: a category-side-only linkage — a forward {@code Organizes} Reference recorded
+ * without its inverse, possible when References are added out-of-band, e.g. by a NodeSet loader
+ * that does not write inverses — is invisible from the alias side and is missed here, so such a
+ * category is not treated as organizing the alias.
+ */
+ private List getOrganizingCategories(NodeId aliasNodeId) {
+ List references =
+ server
+ .getAddressSpaceManager()
+ .getManagedReferences(aliasNodeId, Reference.ORGANIZED_BY_PREDICATE);
+
+ var categoryIds = new ArrayList();
+ for (Reference reference : references) {
+ reference
+ .getTargetNodeId()
+ .toNodeId(server.getNamespaceTable())
+ .filter(aliasTypes::isAliasNameCategoryInstance)
+ .ifPresent(categoryIds::add);
+ }
+ return categoryIds;
+ }
+
+ /**
+ * Remove the Organizes linkage between {@code categoryId} and the alias, from both Nodes'
+ * NodeManagers — the linkage may have been stored by either side.
+ */
+ private void removeFromCategory(UaNode aliasNode, NodeId categoryId) {
+ NodeId aliasNodeId = aliasNode.getNodeId();
+
+ aliasNode.removeReference(
+ new Reference(
+ aliasNodeId, NodeIds.Organizes, categoryId.expanded(), Reference.Direction.INVERSE));
+
+ server
+ .getAddressSpaceManager()
+ .getManagedNode(categoryId)
+ .ifPresent(
+ categoryNode ->
+ categoryNode.removeReference(
+ new Reference(
+ categoryId,
+ NodeIds.Organizes,
+ aliasNodeId.expanded(),
+ Reference.Direction.FORWARD)));
+ }
+
+ /**
+ * Find a Method component of {@code nodeId} by BrowseName.
+ *
+ * Reference-based rather than typed-node-based so that NodeSet-loaded plain Object Nodes
+ * qualify.
+ */
+ private @Nullable UaMethodNode findComponentMethodNode(NodeId nodeId, QualifiedName browseName) {
+ List references =
+ server
+ .getAddressSpaceManager()
+ .getManagedReferences(nodeId, Reference.HAS_COMPONENT_PREDICATE);
+
+ for (Reference reference : references) {
+ UaNode node =
+ reference
+ .getTargetNodeId()
+ .toNodeId(server.getNamespaceTable())
+ .flatMap(id -> server.getAddressSpaceManager().getManagedNode(id))
+ .orElse(null);
+
+ if (node instanceof UaMethodNode methodNode && browseName.equals(node.getBrowseName())) {
+ return methodNode;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Bind a handler on the optional {@code methodName} component of an adopted category, if that
+ * Method Node exists and is still unbound; a missing or already-bound Node is left untouched.
+ *
+ * @return {@code true} if a handler was bound.
+ */
+ private boolean bindOptionalMethodIfUnbound(
+ NodeId categoryId,
+ String methodName,
+ Function handlerFactory,
+ List boundMethodNodes) {
+
+ UaMethodNode methodNode = findComponentMethodNode(categoryId, new QualifiedName(0, methodName));
+
+ if (methodNode == null) {
+ return false;
+ }
+
+ if (!(methodNode.getInvocationHandler()
+ instanceof MethodInvocationHandler.NotImplementedHandler)) {
+ return false;
+ }
+
+ bindHandler(methodNode, handlerFactory.apply(methodNode));
+ boundMethodNodes.add(methodNode);
+
+ return true;
+ }
+
+ /** A validated, locally resolved alias target. */
+ private record ResolvedTarget(NodeId nodeId, NodeId referenceTypeId) {}
+
+ /**
+ * The NodeClass/type-definition constraint an organizing category imposes on alias targets; see
+ * {@link #getTargetConstraint}.
+ */
+ private record TargetConstraint(boolean requireVariable, boolean requirePublishedDataSet) {}
+
+ /** An Optional Method Node to materialize on a standard Object, with its handler recipe. */
+ private record MethodPlan(
+ UaNode objectNode,
+ NodeId methodNodeId,
+ QualifiedName browseName,
+ Function handlerFactory) {}
+
+ /**
+ * Everything the manager tracks per managed category: where its alias Nodes live and how their
+ * NodeIds are allocated, the Method Nodes whose handlers must be reset, and — for categories the
+ * manager created — the instantiation journal used to delete them again.
+ */
+ private record CategoryRecord(
+ Function aliasNodeIdFactory,
+ NodeManager nodeManager,
+ List boundMethodNodes,
+ @Nullable InstantiationResult instantiationResult) {}
+
+ /** A minimal {@link UaNodeContext} binding manually created Nodes to their NodeManager. */
+ private record ManagedNodeContext(OpcUaServer server, NodeManager nodeManager)
+ implements UaNodeContext {
+
+ @Override
+ public OpcUaServer getServer() {
+ return server;
+ }
+
+ @Override
+ public NodeManager getNodeManager() {
+ return nodeManager;
+ }
+ }
+
+ /**
+ * The manager's AddressSpace fragment: hosts the Nodes the manager creates outside application
+ * namespaces (materialized Method Nodes, alias Nodes in standard and adopted categories) and
+ * claims exactly the Nodes its NodeManager contains, so service operations route to them
+ * regardless of NodeId namespace.
+ *
+ * Startup registers the fragment and its NodeManager with the server's AddressSpaceManager;
+ * shutdown unregisters both. A SubscriptionModel provides sampling for MonitoredItems created on
+ * hosted Nodes.
+ *
+ *
The fragment registers itself first in the composite: service routing picks the
+ * first registered AddressSpace whose filter matches, and hosted Nodes have NodeIds allocated in
+ * an application namespace, so an application Namespace registered earlier (whose filter matches
+ * its entire namespace index) would otherwise shadow them. Because the filter matches exactly the
+ * Nodes the fragment contains, registering first diverts no other traffic.
+ */
+ private static final class AliasFragment extends ManagedAddressSpaceFragmentWithLifecycle {
+
+ private final AddressSpaceFilter filter =
+ SimpleAddressSpaceFilter.create(getNodeManager()::containsNode);
+
+ private final SubscriptionModel subscriptionModel;
+
+ AliasFragment(OpcUaServer server) {
+ super(server);
+
+ subscriptionModel = new SubscriptionModel(server, this);
+
+ getLifecycleManager().addLifecycle(subscriptionModel);
+ }
+
+ @Override
+ public AddressSpaceFilter getFilter() {
+ return filter;
+ }
+
+ @Override
+ protected void registerWithComposite(AddressSpaceComposite composite) {
+ composite.registerFirst(this);
+ }
+
+ @Override
+ public void onDataItemsCreated(List dataItems) {
+ subscriptionModel.onDataItemsCreated(dataItems);
+ }
+
+ @Override
+ public void onDataItemsModified(List dataItems) {
+ subscriptionModel.onDataItemsModified(dataItems);
+ }
+
+ @Override
+ public void onDataItemsDeleted(List dataItems) {
+ subscriptionModel.onDataItemsDeleted(dataItems);
+ }
+
+ @Override
+ public void onMonitoringModeChanged(List monitoredItems) {
+ subscriptionModel.onMonitoringModeChanged(monitoredItems);
+ }
+ }
+}
diff --git a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasManagerConfig.java b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasManagerConfig.java
new file mode 100644
index 0000000000..a0e2cfd9d0
--- /dev/null
+++ b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasManagerConfig.java
@@ -0,0 +1,280 @@
+/*
+ * Copyright (c) 2026 the Eclipse Milo Authors
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+
+package org.eclipse.milo.opcua.sdk.server.aliases;
+
+import java.util.Comparator;
+import java.util.Objects;
+import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UShort;
+
+/**
+ * Configuration for an alias manager.
+ *
+ * Every setting has a usable default, so {@code AliasManagerConfig.builder().build()} yields a
+ * working configuration — but note that the default {@link AliasVersionStore} is in-memory only and
+ * does not satisfy the Part 17 persistence requirement; production applications should supply their
+ * own via {@link Builder#versionStore(AliasVersionStore)}.
+ */
+public interface AliasManagerConfig {
+
+ /**
+ * The store used to persist category {@code LastChange} versions across restarts.
+ *
+ * @return the configured {@link AliasVersionStore}.
+ */
+ AliasVersionStore getVersionStore();
+
+ /**
+ * The policy deciding whether sessions may search or mutate categories over the network.
+ *
+ * @return the configured {@link AliasAuthorizationPolicy}.
+ */
+ AliasAuthorizationPolicy getAuthorizationPolicy();
+
+ /**
+ * The limits applied to alias lookup and mutation calls.
+ *
+ * @return the configured {@link AliasLimits}.
+ */
+ AliasLimits getLimits();
+
+ /**
+ * The ordering applied to the targets within each result entry, expressing the application's
+ * target preference; Clients use the first usable entry.
+ *
+ *
The default is {@link AliasTarget#DEFAULT_ORDERING}: local targets before remote ones, then
+ * by the target NodeId's parseable string form, so results are deterministic.
+ *
+ * @return the configured target {@link Comparator}.
+ */
+ Comparator getTargetOrdering();
+
+ /**
+ * The namespace index used to allocate NodeIds for the Nodes the manager itself creates: the
+ * Method Nodes it materializes on the standard ns=0 alias Objects (the Optional Methods the
+ * standard NodeSet does not define), and the alias Nodes produced by the default alias NodeId
+ * factory of standard and adopted categories.
+ *
+ * A Node's NodeId namespace is independent of its parent's; this only controls where the
+ * manager-allocated identifiers live. Categories added via {@link
+ * AliasManager#addCategory(AliasCategoryConfig)} supply their own factory and are unaffected.
+ *
+ * @return the configured namespace index.
+ */
+ UShort getNodeNamespaceIndex();
+
+ /**
+ * Whether {@code FindAliasVerbose} Method instances are materialized and bound on the standard
+ * {@code Aliases}, {@code TagVariables}, and {@code Topics} Objects.
+ *
+ * @return {@code true} if {@code FindAliasVerbose} is enabled on the standard categories.
+ */
+ boolean isFindAliasVerboseEnabled();
+
+ /**
+ * Whether the {@code AddAliasesToCategory} and {@code DeleteAliasesFromCategory} Methods are
+ * materialized and bound on the standard {@code Aliases}, {@code TagVariables}, and {@code
+ * Topics} Objects.
+ *
+ *
Materialized Methods are network-callable only for sessions the {@link
+ * AliasAuthorizationPolicy} grants mutation to; the default policy denies every session, so
+ * enabling network mutation requires this flag and an explicit policy grant.
+ *
+ * @return {@code true} if the mutation Methods are enabled on the standard categories.
+ */
+ boolean isConfigurationEnabled();
+
+ /**
+ * Create a new {@link Builder} with every setting at its default.
+ *
+ * @return a new {@link Builder}.
+ */
+ static Builder builder() {
+ return new Builder();
+ }
+
+ /** Builds immutable {@link AliasManagerConfig} instances. */
+ final class Builder {
+
+ private AliasVersionStore versionStore = new InMemoryAliasVersionStore();
+ private AliasAuthorizationPolicy authorizationPolicy =
+ AliasAuthorizationPolicy.ALLOW_FIND_DENY_MUTATE;
+ private AliasLimits limits = AliasLimits.defaults();
+ private Comparator targetOrdering = AliasTarget.DEFAULT_ORDERING;
+ private UShort nodeNamespaceIndex = UShort.valueOf(1);
+ private boolean findAliasVerboseEnabled = false;
+ private boolean configurationEnabled = false;
+
+ private Builder() {}
+
+ /**
+ * Set the store used to persist category {@code LastChange} versions.
+ *
+ * Default: {@link InMemoryAliasVersionStore} (test/demo use only).
+ *
+ * @param versionStore the {@link AliasVersionStore} to use.
+ * @return this {@link Builder}.
+ */
+ public Builder versionStore(AliasVersionStore versionStore) {
+ this.versionStore = Objects.requireNonNull(versionStore, "versionStore must be non-null");
+ return this;
+ }
+
+ /**
+ * Set the policy deciding whether sessions may search or mutate categories over the network.
+ *
+ *
Default: {@link AliasAuthorizationPolicy#ALLOW_FIND_DENY_MUTATE}.
+ *
+ * @param authorizationPolicy the {@link AliasAuthorizationPolicy} to use.
+ * @return this {@link Builder}.
+ */
+ public Builder authorizationPolicy(AliasAuthorizationPolicy authorizationPolicy) {
+ this.authorizationPolicy =
+ Objects.requireNonNull(authorizationPolicy, "authorizationPolicy must be non-null");
+ return this;
+ }
+
+ /**
+ * Set the limits applied to alias lookup and mutation calls.
+ *
+ *
Default: {@link AliasLimits#defaults()}.
+ *
+ * @param limits the {@link AliasLimits} to use.
+ * @return this {@link Builder}.
+ */
+ public Builder limits(AliasLimits limits) {
+ this.limits = Objects.requireNonNull(limits, "limits must be non-null");
+ return this;
+ }
+
+ /**
+ * Set the ordering applied to the targets within each result entry.
+ *
+ *
Default: local targets before remote, then by the target NodeId's parseable string form.
+ *
+ * @param targetOrdering the target {@link Comparator} to use.
+ * @return this {@link Builder}.
+ */
+ public Builder targetOrdering(Comparator targetOrdering) {
+ this.targetOrdering =
+ Objects.requireNonNull(targetOrdering, "targetOrdering must be non-null");
+ return this;
+ }
+
+ /**
+ * Set the namespace index used to allocate NodeIds for the Nodes the manager itself creates:
+ * materialized Method Nodes and default-factory alias Nodes.
+ *
+ * Default: namespace index 1.
+ *
+ * @param nodeNamespaceIndex the namespace index to use.
+ * @return this {@link Builder}.
+ */
+ public Builder nodeNamespaceIndex(UShort nodeNamespaceIndex) {
+ this.nodeNamespaceIndex =
+ Objects.requireNonNull(nodeNamespaceIndex, "nodeNamespaceIndex must be non-null");
+ return this;
+ }
+
+ /**
+ * Set whether {@code FindAliasVerbose} is enabled on the standard categories.
+ *
+ *
Default: {@code false}.
+ *
+ * @param findAliasVerboseEnabled {@code true} to enable {@code FindAliasVerbose}.
+ * @return this {@link Builder}.
+ */
+ public Builder findAliasVerboseEnabled(boolean findAliasVerboseEnabled) {
+ this.findAliasVerboseEnabled = findAliasVerboseEnabled;
+ return this;
+ }
+
+ /**
+ * Set whether the {@code AddAliasesToCategory} and {@code DeleteAliasesFromCategory} Methods
+ * are materialized and bound on the standard categories.
+ *
+ *
Enabling the Methods does not by itself allow network mutation: the default {@link
+ * AliasAuthorizationPolicy} denies every session, so an explicit policy grant is also required.
+ *
+ *
Default: {@code false}.
+ *
+ * @param configurationEnabled {@code true} to enable {@code AddAliasesToCategory} and {@code
+ * DeleteAliasesFromCategory}.
+ * @return this {@link Builder}.
+ */
+ public Builder configurationEnabled(boolean configurationEnabled) {
+ this.configurationEnabled = configurationEnabled;
+ return this;
+ }
+
+ /**
+ * Build an immutable {@link AliasManagerConfig} from the current settings.
+ *
+ * @return a new, immutable {@link AliasManagerConfig}.
+ */
+ public AliasManagerConfig build() {
+ return new ConfigImpl(
+ versionStore,
+ authorizationPolicy,
+ limits,
+ targetOrdering,
+ nodeNamespaceIndex,
+ findAliasVerboseEnabled,
+ configurationEnabled);
+ }
+
+ /** The immutable {@link AliasManagerConfig} produced by {@link Builder#build()}. */
+ private record ConfigImpl(
+ AliasVersionStore versionStore,
+ AliasAuthorizationPolicy authorizationPolicy,
+ AliasLimits limits,
+ Comparator targetOrdering,
+ UShort nodeNamespaceIndex,
+ boolean findAliasVerboseEnabled,
+ boolean configurationEnabled)
+ implements AliasManagerConfig {
+
+ @Override
+ public AliasVersionStore getVersionStore() {
+ return versionStore;
+ }
+
+ @Override
+ public AliasAuthorizationPolicy getAuthorizationPolicy() {
+ return authorizationPolicy;
+ }
+
+ @Override
+ public AliasLimits getLimits() {
+ return limits;
+ }
+
+ @Override
+ public Comparator getTargetOrdering() {
+ return targetOrdering;
+ }
+
+ @Override
+ public UShort getNodeNamespaceIndex() {
+ return nodeNamespaceIndex;
+ }
+
+ @Override
+ public boolean isFindAliasVerboseEnabled() {
+ return findAliasVerboseEnabled;
+ }
+
+ @Override
+ public boolean isConfigurationEnabled() {
+ return configurationEnabled;
+ }
+ }
+ }
+}
diff --git a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasSearchEngine.java b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasSearchEngine.java
new file mode 100644
index 0000000000..fa583ad536
--- /dev/null
+++ b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasSearchEngine.java
@@ -0,0 +1,496 @@
+/*
+ * Copyright (c) 2026 the Eclipse Milo Authors
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+
+package org.eclipse.milo.opcua.sdk.server.aliases;
+
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.function.Predicate;
+import org.eclipse.milo.opcua.sdk.core.Reference;
+import org.eclipse.milo.opcua.sdk.server.OpcUaServer;
+import org.eclipse.milo.opcua.sdk.server.nodes.UaNode;
+import org.eclipse.milo.opcua.sdk.server.util.LikeMatcher;
+import org.eclipse.milo.opcua.stack.core.StatusCodes;
+import org.eclipse.milo.opcua.stack.core.UaException;
+import org.eclipse.milo.opcua.stack.core.types.builtin.ExpandedNodeId;
+import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId;
+import org.eclipse.milo.opcua.stack.core.types.builtin.QualifiedName;
+import org.eclipse.milo.opcua.stack.core.types.structured.AliasNameDataType;
+import org.eclipse.milo.opcua.stack.core.types.structured.AliasNameVerboseDataType;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Evaluates {@code FindAlias} and {@code FindAliasVerbose} queries against the live AddressSpace.
+ *
+ * A search starts at a category Node and walks forward {@code Organizes} References, recursing
+ * into every {@code AliasNameCategoryType} instance it reaches and collecting every {@code
+ * AliasNameType} instance. Each alias Object appears at most once in the result, no matter how many
+ * subtree paths reach it. An alias matches when the text of its BrowseName — the name alone,
+ * ignoring the namespace index, per Part 17 §6.2 — matches the Part 4 {@code Like} pattern, and at
+ * least one of its forward {@code AliasFor}-or-subtype References passes the ReferenceType filter;
+ * an alias with no passing target References is omitted entirely, so a filter that nothing
+ * satisfies produces an empty result (§6.3.2/§6.3.3: if no Nodes "match the search string or have
+ * the appropriate ReferenceType, the list shall be empty").
+ *
+ *
Results are deterministically ordered: entries by alias name text, then by alias NodeId;
+ * targets within an entry by the configured target ordering (default: local-before-remote, then by
+ * target NodeId). Verbose results report, for each alias, the containing category closest to the
+ * search root (ties broken by category BrowseName, then category NodeId).
+ *
+ *
The engine reads whatever the AddressSpace currently contains, so aliases loaded from a
+ * NodeSet file or created by other components are found without registration. Searches take no
+ * lock: a search overlapping a concurrent mutation may observe a partially applied change — the
+ * same weak consistency Browse has over the live AddressSpace.
+ *
+ *
Instances are stateless between calls and safe for concurrent use.
+ */
+public final class AliasSearchEngine {
+
+ /**
+ * Verbose category selection: smallest depth from the search root, then category BrowseName, then
+ * category NodeId.
+ */
+ private static final Comparator CATEGORY_ORDER =
+ Comparator.comparingInt(CategoryRef::depth)
+ .thenComparing(c -> c.browseName().toParseableString())
+ .thenComparing(c -> c.nodeId().toParseableString());
+
+ /** Result entries: alias name text first, then alias NodeId for identically named aliases. */
+ private static final Comparator ENTRY_ORDER =
+ Comparator.comparing((MatchedAlias m) -> m.nameText).thenComparing(m -> m.parseableNodeId);
+
+ private final LikeMatcher likeMatcher = new LikeMatcher();
+
+ private final OpcUaServer server;
+ private final AliasTypes aliasTypes;
+ private final AliasLimits limits;
+ private final Comparator targetOrdering;
+
+ /**
+ * Create an engine that searches {@code server}'s AddressSpace.
+ *
+ * @param server the server whose AddressSpace is searched.
+ * @param limits the limits enforced on every call.
+ * @param targetOrdering the ordering applied to the targets within each result entry.
+ */
+ public AliasSearchEngine(
+ OpcUaServer server, AliasLimits limits, Comparator targetOrdering) {
+
+ this.server = server;
+ this.aliasTypes = new AliasTypes(server);
+ this.limits = limits;
+ this.targetOrdering = targetOrdering;
+ }
+
+ /**
+ * Find aliases under {@code categoryId} whose name matches {@code pattern}.
+ *
+ * @param categoryId the NodeId of the {@code AliasNameCategoryType} instance to search from.
+ * @param pattern a Part 4 {@code Like} pattern matched against alias name text.
+ * @param referenceTypeFilter restricts targets to References of this type or a subtype; a null
+ * (or null-valued) NodeId means no restriction beyond {@code AliasFor} and its subtypes.
+ * @return the matching entries, each with at least one target, ordered by alias name text then
+ * alias NodeId.
+ * @throws UaException with {@code Bad_InvalidArgument} if the pattern is too long or malformed,
+ * or if the filter is not a known ReferenceType; {@code Bad_NodeIdUnknown} if {@code
+ * categoryId} is not an {@code AliasNameCategoryType} instance; {@code Bad_ResponseTooLarge}
+ * if more entries match than the configured maximum.
+ */
+ public List findAlias(
+ NodeId categoryId, String pattern, @Nullable NodeId referenceTypeFilter) throws UaException {
+
+ return findAlias(categoryId, pattern, referenceTypeFilter, aliasNodeId -> true);
+ }
+
+ /**
+ * Find aliases under {@code categoryId} whose name matches {@code pattern}, applying a per-result
+ * authorization filter.
+ *
+ * Entries whose alias NodeId the filter rejects are omitted from the result. Filtering happens
+ * after matching, so rejected entries still count toward the configured result maximum.
+ *
+ * @param categoryId the NodeId of the {@code AliasNameCategoryType} instance to search from.
+ * @param pattern a Part 4 {@code Like} pattern matched against alias name text.
+ * @param referenceTypeFilter restricts targets to References of this type or a subtype; a null
+ * (or null-valued) NodeId means no restriction beyond {@code AliasFor} and its subtypes.
+ * @param resultFilter decides, by alias NodeId, whether a matched alias may appear in the result.
+ * @return the matching, filter-approved entries, ordered by alias name text then alias NodeId.
+ * @throws UaException under the same conditions as {@link #findAlias(NodeId, String, NodeId)}.
+ */
+ List findAlias(
+ NodeId categoryId,
+ String pattern,
+ @Nullable NodeId referenceTypeFilter,
+ Predicate resultFilter)
+ throws UaException {
+
+ List matches = search(categoryId, pattern, referenceTypeFilter);
+
+ List results = new ArrayList<>(matches.size());
+ for (MatchedAlias match : matches) {
+ if (!resultFilter.test(match.nodeId)) {
+ continue;
+ }
+ results.add(
+ new AliasNameDataType(
+ match.browseName,
+ match.targets.stream().map(AliasTarget::nodeId).toArray(ExpandedNodeId[]::new)));
+ }
+ return results;
+ }
+
+ /**
+ * Find aliases under {@code categoryId} whose name matches {@code pattern}, with containing
+ * category and target server details.
+ *
+ * An entry's {@code ServerUris} field is null when every target is local (permitted by Part 17
+ * §7.3, which allows a null URI for any local Node). When an alias carries remote target
+ * References — possible for aliases created outside the manager, e.g. loaded from a NodeSet file
+ * — {@code ServerUris} is an array parallel to the referenced Nodes: the remote targets' Server
+ * URIs (or, for a server index the ServerTable cannot resolve, the raw index as text), null for
+ * local targets.
+ *
+ * @param categoryId the NodeId of the {@code AliasNameCategoryType} instance to search from.
+ * @param pattern a Part 4 {@code Like} pattern matched against alias name text.
+ * @param referenceTypeFilter restricts targets to References of this type or a subtype; a null
+ * (or null-valued) NodeId means no restriction beyond {@code AliasFor} and its subtypes.
+ * @return the matching entries, each with at least one target, ordered by alias name text then
+ * alias NodeId.
+ * @throws UaException with {@code Bad_InvalidArgument} if the pattern is too long or malformed,
+ * or if the filter is not a known ReferenceType; {@code Bad_NodeIdUnknown} if {@code
+ * categoryId} is not an {@code AliasNameCategoryType} instance; {@code Bad_ResponseTooLarge}
+ * if more entries match than the configured maximum.
+ */
+ public List findAliasVerbose(
+ NodeId categoryId, String pattern, @Nullable NodeId referenceTypeFilter) throws UaException {
+
+ return findAliasVerbose(categoryId, pattern, referenceTypeFilter, aliasNodeId -> true);
+ }
+
+ /**
+ * Find aliases under {@code categoryId} whose name matches {@code pattern}, with containing
+ * category and target server details, applying a per-result authorization filter.
+ *
+ * Entries whose alias NodeId the filter rejects are omitted from the result. Filtering happens
+ * after matching, so rejected entries still count toward the configured result maximum.
+ *
+ * @param categoryId the NodeId of the {@code AliasNameCategoryType} instance to search from.
+ * @param pattern a Part 4 {@code Like} pattern matched against alias name text.
+ * @param referenceTypeFilter restricts targets to References of this type or a subtype; a null
+ * (or null-valued) NodeId means no restriction beyond {@code AliasFor} and its subtypes.
+ * @param resultFilter decides, by alias NodeId, whether a matched alias may appear in the result.
+ * @return the matching, filter-approved entries, ordered by alias name text then alias NodeId.
+ * @throws UaException under the same conditions as {@link #findAliasVerbose(NodeId, String,
+ * NodeId)}.
+ */
+ List findAliasVerbose(
+ NodeId categoryId,
+ String pattern,
+ @Nullable NodeId referenceTypeFilter,
+ Predicate resultFilter)
+ throws UaException {
+
+ List matches = search(categoryId, pattern, referenceTypeFilter);
+
+ List results = new ArrayList<>(matches.size());
+ for (MatchedAlias match : matches) {
+ if (!resultFilter.test(match.nodeId)) {
+ continue;
+ }
+ results.add(
+ new AliasNameVerboseDataType(
+ match.browseName,
+ match.targets.stream().map(AliasTarget::nodeId).toArray(ExpandedNodeId[]::new),
+ serverUrisOf(match.targets),
+ match.category.nodeId()));
+ }
+ return results;
+ }
+
+ private List search(
+ NodeId categoryId, String pattern, @Nullable NodeId referenceTypeFilter) throws UaException {
+
+ if (pattern.length() > limits.maxPatternLength()) {
+ throw new UaException(
+ StatusCodes.Bad_InvalidArgument,
+ "pattern length %d exceeds maximum %d"
+ .formatted(pattern.length(), limits.maxPatternLength()));
+ }
+
+ // Compile the pattern up front: a malformed pattern fails the call before any traversal
+ // happens (§6.3.2 maps invalid search strings to Bad_InvalidArgument), and the per-alias
+ // match then runs against the compiled form, free of the matcher's shared pattern cache.
+ LikeMatcher.CompiledPattern compiledPattern;
+ try {
+ compiledPattern = likeMatcher.compile(pattern);
+ } catch (IllegalArgumentException e) {
+ throw new UaException(StatusCodes.Bad_InvalidArgument, "malformed pattern: " + pattern, e);
+ }
+
+ // A null NodeId on the wire arrives as NodeId.NULL_VALUE; both spellings mean "no filter".
+ NodeId filter =
+ referenceTypeFilter == null || referenceTypeFilter.isNull() ? null : referenceTypeFilter;
+ if (filter != null && !server.getReferenceTypeTree().containsType(filter)) {
+ throw new UaException(
+ StatusCodes.Bad_InvalidArgument, "unknown ReferenceType: " + filter.toParseableString());
+ }
+
+ UaNode categoryNode =
+ server
+ .getAddressSpaceManager()
+ .getManagedNode(categoryId)
+ .orElseThrow(
+ () ->
+ new UaException(
+ StatusCodes.Bad_NodeIdUnknown,
+ "category not found: " + categoryId.toParseableString()));
+
+ if (!aliasTypes.isAliasNameCategoryInstance(categoryId)) {
+ throw new UaException(
+ StatusCodes.Bad_NodeIdUnknown,
+ "not an AliasNameCategoryType instance: " + categoryId.toParseableString());
+ }
+
+ return traverse(
+ new CategoryRef(categoryId, categoryNode.getBrowseName(), 0), compiledPattern, filter);
+ }
+
+ /**
+ * Breadth-first walk of the category subtree rooted at {@code root}, collecting matched aliases.
+ *
+ * Breadth-first order visits categories in non-decreasing depth, which the verbose
+ * category-selection rule (smallest depth first) relies on only loosely: candidates are compared
+ * explicitly, so ties at equal depth are still broken deterministically.
+ */
+ private List traverse(
+ CategoryRef root, LikeMatcher.CompiledPattern pattern, @Nullable NodeId filter)
+ throws UaException {
+
+ // LinkedHashMap only for deterministic iteration while debugging; results are re-sorted below.
+ Map matched = new LinkedHashMap<>();
+ Set rejected = new HashSet<>();
+ Set visitedCategories = new HashSet<>();
+ ArrayDeque queue = new ArrayDeque<>();
+
+ visitedCategories.add(root.nodeId());
+ queue.addLast(root);
+
+ while (!queue.isEmpty()) {
+ CategoryRef category = queue.removeFirst();
+
+ List organizes =
+ server
+ .getAddressSpaceManager()
+ .getManagedReferences(category.nodeId(), Reference.ORGANIZES_PREDICATE);
+
+ for (Reference reference : organizes) {
+ Optional targetId =
+ reference.getTargetNodeId().toNodeId(server.getNamespaceTable());
+ if (targetId.isEmpty()) {
+ continue;
+ }
+ NodeId organizedId = targetId.get();
+
+ NodeId typeDefinitionId = aliasTypes.getTypeDefinitionId(organizedId);
+ if (typeDefinitionId == null) {
+ continue;
+ }
+
+ if (aliasTypes.isAliasNameCategoryType(typeDefinitionId)) {
+ if (visitedCategories.add(organizedId)) {
+ server
+ .getAddressSpaceManager()
+ .getManagedNode(organizedId)
+ .ifPresent(
+ subcategoryNode ->
+ queue.addLast(
+ new CategoryRef(
+ organizedId,
+ subcategoryNode.getBrowseName(),
+ category.depth() + 1)));
+ }
+ } else if (aliasTypes.isAliasNameType(typeDefinitionId)) {
+ processAlias(organizedId, category, pattern, filter, matched, rejected);
+ }
+ }
+ }
+
+ List results = new ArrayList<>(matched.values());
+ results.sort(ENTRY_ORDER);
+ return results;
+ }
+
+ private void processAlias(
+ NodeId aliasNodeId,
+ CategoryRef category,
+ LikeMatcher.CompiledPattern pattern,
+ @Nullable NodeId filter,
+ Map matched,
+ Set rejected)
+ throws UaException {
+
+ MatchedAlias existing = matched.get(aliasNodeId);
+ if (existing != null) {
+ // Already matched via another subtree path: only the verbose category selection can change.
+ if (CATEGORY_ORDER.compare(category, existing.category) < 0) {
+ existing.category = category;
+ }
+ return;
+ }
+ if (rejected.contains(aliasNodeId)) {
+ // Whether an alias matches is a property of the alias Node alone, not of the path that
+ // reached it, so a rejection holds for every other path too.
+ return;
+ }
+
+ UaNode aliasNode = server.getAddressSpaceManager().getManagedNode(aliasNodeId).orElse(null);
+ if (aliasNode == null) {
+ rejected.add(aliasNodeId);
+ return;
+ }
+
+ QualifiedName browseName = aliasNode.getBrowseName();
+ String nameText = browseName.name();
+ if (nameText == null || !pattern.matches(nameText)) {
+ rejected.add(aliasNodeId);
+ return;
+ }
+
+ List targets = collectTargets(aliasNodeId, filter);
+ if (targets.isEmpty()) {
+ // No target Reference passed the filter; an entry without targets is not returned
+ // (§6.3.2/§6.3.3: Nodes that don't "have the appropriate ReferenceType" contribute nothing).
+ rejected.add(aliasNodeId);
+ return;
+ }
+
+ if (matched.size() >= limits.maxResults()) {
+ throw new UaException(
+ StatusCodes.Bad_ResponseTooLarge,
+ "more than %d aliases match".formatted(limits.maxResults()));
+ }
+
+ matched.put(
+ aliasNodeId, new MatchedAlias(aliasNodeId, browseName, nameText, targets, category));
+ }
+
+ /**
+ * Collect the targets of {@code aliasNodeId}: forward References whose type is {@code AliasFor}
+ * or a subtype and, when a filter is present, also the filter type or a subtype of it.
+ *
+ * Targets are sorted with the configured ordering; when several References of different types
+ * reach the same target Node, the earliest-ordered occurrence decides its position.
+ */
+ private List collectTargets(NodeId aliasNodeId, @Nullable NodeId filter) {
+ List references =
+ server
+ .getAddressSpaceManager()
+ .getManagedReferences(
+ aliasNodeId,
+ reference ->
+ reference.isForward()
+ && aliasTypes.isAliasForOrSubtype(reference.getReferenceTypeId())
+ && (filter == null
+ || matchesReferenceType(reference.getReferenceTypeId(), filter)));
+
+ List ordered =
+ references.stream().map(this::toAliasTarget).sorted(targetOrdering).toList();
+
+ var seen = new HashSet();
+ var targets = new ArrayList(ordered.size());
+ for (AliasTarget target : ordered) {
+ if (seen.add(target.nodeId())) {
+ targets.add(target);
+ }
+ }
+ return targets;
+ }
+
+ /**
+ * The {@code ServerUris} output entries for {@code targets}: null when every target is local
+ * (permitted by Part 17 §7.3), otherwise an array parallel to the referenced Nodes whose entries
+ * are the remote targets' Server URIs and null for local targets.
+ */
+ private static @Nullable String @Nullable [] serverUrisOf(List targets) {
+ if (targets.stream().allMatch(AliasTarget::isLocal)) {
+ return null;
+ }
+
+ var serverUris = new @Nullable String[targets.size()];
+ for (int i = 0; i < serverUris.length; i++) {
+ serverUris[i] = targets.get(i).serverUri();
+ }
+ return serverUris;
+ }
+
+ /**
+ * Adapt a target Reference to an {@link AliasTarget} so the configured {@code
+ * Comparator} can order it.
+ *
+ * The server URI of a remote target is resolved through the ServerTable; if the target's
+ * server index has no ServerTable entry, the raw index is carried in the URI position so the
+ * target still classifies as remote for ordering purposes.
+ */
+ private AliasTarget toAliasTarget(Reference reference) {
+ ExpandedNodeId targetId = reference.getTargetNodeId();
+
+ String serverUri = null;
+ if (!targetId.isLocal()) {
+ serverUri = targetId.getServerUri(server.getServerTable());
+ if (serverUri == null) {
+ serverUri = String.valueOf(targetId.getServerIndex());
+ }
+ }
+
+ return new AliasTarget(targetId, serverUri, reference.getReferenceTypeId());
+ }
+
+ /** "Any ReferenceType includes all subtypes" (§6.3.3), so a filter admits itself and subtypes. */
+ private boolean matchesReferenceType(NodeId referenceTypeId, NodeId filter) {
+ return referenceTypeId.equals(filter)
+ || server.getReferenceTypeTree().isSubtypeOf(referenceTypeId, filter);
+ }
+
+ /** A category on the traversal frontier, with its depth from the search root (root = 0). */
+ private record CategoryRef(NodeId nodeId, QualifiedName browseName, int depth) {}
+
+ /** A matched alias accumulated during traversal; {@code category} is refined as paths arrive. */
+ private static final class MatchedAlias {
+
+ final NodeId nodeId;
+ final QualifiedName browseName;
+ final String nameText;
+ final String parseableNodeId;
+ final List targets;
+
+ CategoryRef category;
+
+ MatchedAlias(
+ NodeId nodeId,
+ QualifiedName browseName,
+ String nameText,
+ List targets,
+ CategoryRef category) {
+ this.nodeId = nodeId;
+ this.browseName = browseName;
+ this.nameText = nameText;
+ this.parseableNodeId = nodeId.toParseableString();
+ this.targets = targets;
+ this.category = category;
+ }
+ }
+}
diff --git a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasTarget.java b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasTarget.java
new file mode 100644
index 0000000000..d875ec50f9
--- /dev/null
+++ b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasTarget.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright (c) 2026 the Eclipse Milo Authors
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+
+package org.eclipse.milo.opcua.sdk.server.aliases;
+
+import java.util.Comparator;
+import org.eclipse.milo.opcua.stack.core.types.builtin.ExpandedNodeId;
+import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * A single target of an alias: the Node an alias name resolves to, and the ReferenceType that
+ * associates the alias with it.
+ *
+ * An alias may have any number of targets; Clients treat the order they are returned in as an
+ * order of preference. Target ordering is controlled by {@link
+ * AliasManagerConfig#getTargetOrdering()}.
+ *
+ * @param nodeId the NodeId of the target Node. May identify a Node on a remote Server, in which
+ * case {@code serverUri} identifies that Server.
+ * @param serverUri the URI of the Server the target Node resides on, or {@code null} if the target
+ * is on the local Server.
+ * @param referenceTypeId the NodeId of the ReferenceType associating the alias with the target.
+ * Must be {@code AliasFor} or a subtype; this is validated where the target is applied, not at
+ * construction, because subtype checks require the Server's ReferenceType hierarchy.
+ */
+public record AliasTarget(
+ ExpandedNodeId nodeId, @Nullable String serverUri, NodeId referenceTypeId) {
+
+ /**
+ * The default target ordering: local targets before remote ones, then by the target NodeId's
+ * parseable string form, so results are deterministic.
+ *
+ *
This is the single definition of the default, applied by {@link AliasManagerConfig.Builder}
+ * when no ordering is configured.
+ */
+ public static final Comparator DEFAULT_ORDERING =
+ Comparator.comparing((AliasTarget target) -> !target.isLocal())
+ .thenComparing(target -> target.nodeId().toParseableString());
+
+ /**
+ * Whether this target resides on the local Server.
+ *
+ * @return {@code true} if this target resides on the local Server, i.e. {@link #serverUri()} is
+ * {@code null}.
+ */
+ public boolean isLocal() {
+ return serverUri == null;
+ }
+}
diff --git a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasTypes.java b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasTypes.java
new file mode 100644
index 0000000000..830627e6ad
--- /dev/null
+++ b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasTypes.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright (c) 2026 the Eclipse Milo Authors
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+
+package org.eclipse.milo.opcua.sdk.server.aliases;
+
+import org.eclipse.milo.opcua.sdk.core.Reference;
+import org.eclipse.milo.opcua.sdk.server.OpcUaServer;
+import org.eclipse.milo.opcua.stack.core.NodeIds;
+import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * The Part 17 type tests shared by the alias components: type-definition resolution and
+ * equals-or-subtype checks against {@code AliasNameCategoryType}, {@code AliasNameType}, and {@code
+ * AliasFor}.
+ *
+ * A Node's type definition is the target of its first {@code HasTypeDefinition} Reference; a
+ * well-formed Node has exactly one. {@code TypeTree.isSubtypeOf} is strict, so every check handles
+ * the equals case explicitly.
+ *
+ *
Stateless: every call reads the server's live AddressSpace and type trees.
+ */
+final class AliasTypes {
+
+ private final OpcUaServer server;
+
+ AliasTypes(OpcUaServer server) {
+ this.server = server;
+ }
+
+ /** The type definition of the Node identified by {@code nodeId}, or null if it has none. */
+ @Nullable NodeId getTypeDefinitionId(NodeId nodeId) {
+ return server
+ .getAddressSpaceManager()
+ .getManagedReferences(nodeId, Reference.HAS_TYPE_DEFINITION_PREDICATE)
+ .stream()
+ .findFirst()
+ .flatMap(reference -> reference.getTargetNodeId().toNodeId(server.getNamespaceTable()))
+ .orElse(null);
+ }
+
+ /** Whether {@code typeDefinitionId} is {@code AliasNameCategoryType} or a subtype. */
+ boolean isAliasNameCategoryType(NodeId typeDefinitionId) {
+ return NodeIds.AliasNameCategoryType.equals(typeDefinitionId)
+ || server.getObjectTypeTree().isSubtypeOf(typeDefinitionId, NodeIds.AliasNameCategoryType);
+ }
+
+ /** Whether {@code typeDefinitionId} is {@code AliasNameType} or a subtype. */
+ boolean isAliasNameType(NodeId typeDefinitionId) {
+ return NodeIds.AliasNameType.equals(typeDefinitionId)
+ || server.getObjectTypeTree().isSubtypeOf(typeDefinitionId, NodeIds.AliasNameType);
+ }
+
+ /** Whether the Node identified by {@code nodeId} is an {@code AliasNameCategoryType} instance. */
+ boolean isAliasNameCategoryInstance(NodeId nodeId) {
+ NodeId typeDefinitionId = getTypeDefinitionId(nodeId);
+
+ return typeDefinitionId != null && isAliasNameCategoryType(typeDefinitionId);
+ }
+
+ /** Whether the Node identified by {@code nodeId} is an {@code AliasNameType} instance. */
+ boolean isAliasNameInstance(NodeId nodeId) {
+ NodeId typeDefinitionId = getTypeDefinitionId(nodeId);
+
+ return typeDefinitionId != null && isAliasNameType(typeDefinitionId);
+ }
+
+ /** Whether {@code referenceTypeId} is {@code AliasFor} or a subtype. */
+ boolean isAliasForOrSubtype(NodeId referenceTypeId) {
+ return NodeIds.AliasFor.equals(referenceTypeId)
+ || server.getReferenceTypeTree().isSubtypeOf(referenceTypeId, NodeIds.AliasFor);
+ }
+}
diff --git a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasVersionManager.java b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasVersionManager.java
new file mode 100644
index 0000000000..7934bada30
--- /dev/null
+++ b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasVersionManager.java
@@ -0,0 +1,360 @@
+/*
+ * Copyright (c) 2026 the Eclipse Milo Authors
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+
+package org.eclipse.milo.opcua.sdk.server.aliases;
+
+import java.time.Instant;
+import java.util.ArrayDeque;
+import java.util.Collection;
+import java.util.Deque;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import org.eclipse.milo.opcua.sdk.core.Reference;
+import org.eclipse.milo.opcua.sdk.core.nodes.VariableNode;
+import org.eclipse.milo.opcua.sdk.server.OpcUaServer;
+import org.eclipse.milo.opcua.sdk.server.model.objects.AliasNameCategoryType;
+import org.eclipse.milo.opcua.sdk.server.model.objects.AliasNameCategoryTypeNode;
+import org.eclipse.milo.opcua.sdk.server.nodes.UaNode;
+import org.eclipse.milo.opcua.stack.core.NodeIds;
+import org.eclipse.milo.opcua.stack.core.StatusCodes;
+import org.eclipse.milo.opcua.stack.core.UaException;
+import org.eclipse.milo.opcua.stack.core.types.builtin.DataValue;
+import org.eclipse.milo.opcua.stack.core.types.builtin.ExpandedNodeId;
+import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId;
+import org.eclipse.milo.opcua.stack.core.types.builtin.Variant;
+import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UInteger;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Owns the {@code LastChange} ({@code VersionTime}) state of alias categories: computes new values,
+ * propagates bumps through ancestor categories, persists values through the {@link
+ * AliasVersionStore}, and writes the {@code LastChange} Property Nodes.
+ *
+ *
Version maintenance is split into two phases so persistence happens before the
+ * AddressSpace mutation a bump describes: {@link #prepare} computes the next value for each
+ * affected category (and its ancestors) and saves it through the store, throwing — and thereby
+ * aborting the mutation before it is applied — if a save fails; {@link #publishPending} then writes
+ * the prepared values to the {@code LastChange} Property Nodes, called from a {@code finally} so
+ * values whose mutation partially applied are still published. Because no value becomes observable
+ * before it is persisted, a restart can never re-produce an observed {@code LastChange} value for
+ * different content — the failure mode that would leave Client caches undetectably stale.
+ *
+ *
Store entries are keyed by namespace-URI-qualified {@link ExpandedNodeId}s (see {@link
+ * AliasVersionStore}); this class converts to and from runtime NodeIds at the store boundary.
+ *
+ *
Internal to the aliases package. Not thread-safe on its own: the owning manager serializes
+ * every call under its write lock, so version computation, persistence, and Property publication
+ * never interleave.
+ *
+ *
{@code VersionTime} values are seconds since 2000-01-01T00:00:00Z. Each bump computes {@code
+ * next = max(secondsSince2000(now), previous + 1)}, so values are strictly monotonic per category
+ * even under clock rollback. Wraparound of the 32-bit range (year 2136) is not handled; Part 4
+ * §7.43 defines no wraparound behavior.
+ */
+class AliasVersionManager {
+
+ /** The {@code VersionTime} epoch, 2000-01-01T00:00:00Z, as a Unix epoch second. */
+ private static final long VERSION_TIME_EPOCH_SECOND = 946684800L;
+
+ private final Logger logger = LoggerFactory.getLogger(getClass());
+
+ private final Map versions = new HashMap<>();
+
+ /**
+ * Values persisted by {@link #prepare} but not yet written to their {@code LastChange} Property
+ * Nodes, in preparation order. Bounded by the categories one mutation call touches: every
+ * prepare/publish pair runs under the owning manager's lock.
+ */
+ private final Map pending = new LinkedHashMap<>();
+
+ private final OpcUaServer server;
+ private final AliasTypes aliasTypes;
+ private final AliasVersionStore store;
+
+ AliasVersionManager(OpcUaServer server, AliasVersionStore store) {
+ this.server = server;
+ this.aliasTypes = new AliasTypes(server);
+ this.store = store;
+ }
+
+ /**
+ * Load persisted category versions from the store and seed the in-memory version state.
+ *
+ * Called once at manager startup, before any bump. Nothing is written to the {@code
+ * LastChange} Property Nodes yet — the caller invokes {@link #publishLoaded} once the rest of
+ * startup has succeeded, so a startup that fails after loading leaves no trace in the
+ * AddressSpace. A stored entry whose namespace URI is not in the Server's namespace table cannot
+ * belong to any live category; it is skipped with a warning and remains untouched in the store.
+ *
+ * @return an immutable copy of the loaded versions, keyed by category NodeId.
+ * @throws UaException if the store cannot be read; the caller must fail startup, because
+ * continuing with silently reset versions would violate the persistence contract.
+ */
+ Map loadPersisted() throws UaException {
+ var loaded = new HashMap();
+
+ store
+ .load()
+ .forEach(
+ (storeKey, value) -> {
+ Optional categoryId = storeKey.toNodeId(server.getNamespaceTable());
+
+ if (categoryId.isPresent()) {
+ loaded.put(categoryId.get(), value);
+ } else {
+ logger.warn(
+ "Ignoring persisted LastChange with unregistered namespace: key={}, value={}",
+ storeKey.toParseableString(),
+ value);
+ }
+ });
+
+ versions.putAll(loaded);
+
+ return Map.copyOf(loaded);
+ }
+
+ /**
+ * Write every version loaded by {@link #loadPersisted} to its category's {@code LastChange}
+ * Property Node, where that Node exists.
+ *
+ * Called as the final step of a successful startup — after every step that can fail — so a
+ * failed startup never leaves loaded values published.
+ */
+ void publishLoaded() {
+ versions.forEach(this::writeLastChangeProperty);
+ }
+
+ /**
+ * Compute and persist the next version of each category in {@code categoryIds} and of every
+ * ancestor category reachable from them, without publishing anything yet.
+ *
+ *
Called before the AddressSpace mutation the bump describes. A category already
+ * prepared since the last {@link #publishPending} is skipped, so a multi-entry mutation call
+ * persists (and ultimately publishes) one new value per category, no matter how many entries
+ * touch it.
+ *
+ *
Ancestors are discovered by walking inverse {@code Organizes} References while the parent is
+ * an {@code AliasNameCategoryType} instance, stopping at (and including) the standard root {@code
+ * Aliases} Object when reached. Categories without a {@code LastChange} Property (it is Optional
+ * per category; the standard {@code TagVariables} and {@code Topics} Objects have none) still get
+ * their version persisted and their in-memory value advanced — only the eventual Property write
+ * is skipped.
+ *
+ * @param categoryIds the NodeIds of the categories directly affected by the impending mutation.
+ * @throws UaException with {@code Bad_InternalError} if a store save fails; the caller must abort
+ * the mutation. Values saved before the failure stay pending and are still published, which
+ * at worst bumps a category without a content change — the safe direction.
+ */
+ void prepare(Collection categoryIds) throws UaException {
+ Set affected = new LinkedHashSet<>();
+
+ for (NodeId categoryId : categoryIds) {
+ if (affected.add(categoryId)) {
+ collectAncestorCategories(categoryId, affected);
+ }
+ }
+
+ for (NodeId categoryId : affected) {
+ if (pending.containsKey(categoryId)) {
+ continue;
+ }
+
+ UInteger previous = versions.get(categoryId);
+ long previousValue = previous != null ? previous.longValue() : 0L;
+
+ long next = Math.max(secondsSince2000(), previousValue + 1);
+ UInteger value = UInteger.valueOf(next);
+
+ try {
+ store.save(categoryId.expanded(server.getNamespaceTable()), value);
+ } catch (Exception e) {
+ throw new UaException(
+ StatusCodes.Bad_InternalError,
+ "failed to persist LastChange for category " + categoryId.toParseableString(),
+ e);
+ }
+
+ versions.put(categoryId, value);
+ pending.put(categoryId, value);
+ }
+ }
+
+ /**
+ * Write every value {@link #prepare} persisted since the last publish to its category's {@code
+ * LastChange} Property Node, then clear the pending set.
+ *
+ * Called from a {@code finally} after the mutation, so prepared values are published even when
+ * the mutation partially applied. Publishing when nothing is pending is a no-op. A Property write
+ * failure is logged and the remaining values still publish: the value is already persisted and in
+ * memory, and a secondary failure must not mask the mutation's own outcome.
+ */
+ void publishPending() {
+ pending.forEach(
+ (categoryId, value) -> {
+ try {
+ writeLastChangeProperty(categoryId, value);
+ } catch (RuntimeException e) {
+ logger.warn(
+ "Failed to write LastChange Property: category={}, value={}",
+ categoryId.toParseableString(),
+ value,
+ e);
+ }
+ });
+
+ pending.clear();
+ }
+
+ /**
+ * Bump the version of a single category and its ancestor categories: {@link #prepare} and {@link
+ * #publishPending} as one step, for callers with no surrounding mutation to order against.
+ *
+ * @param categoryId the NodeId of the category to bump.
+ * @throws UaException with {@code Bad_InternalError} if a store save fails.
+ */
+ void touch(NodeId categoryId) throws UaException {
+ try {
+ prepare(List.of(categoryId));
+ } finally {
+ publishPending();
+ }
+ }
+
+ /**
+ * The current version of a category, if one has been loaded or computed.
+ *
+ * @param categoryId the NodeId of the category.
+ * @return the category's current version, or empty if it has never been bumped or loaded.
+ */
+ Optional get(NodeId categoryId) {
+ return Optional.ofNullable(versions.get(categoryId));
+ }
+
+ /**
+ * The ancestor categories of {@code categoryId}: every {@code AliasNameCategoryType} instance
+ * reachable by walking inverse {@code Organizes} References, up to and including the standard
+ * root {@code Aliases} Object. {@code categoryId} itself is not included.
+ *
+ * Subject to the visibility limitation documented on {@link #collectAncestorCategories}:
+ * parent linkage stored only as a one-directional forward Reference on the parent's side is not
+ * discovered.
+ *
+ * @param categoryId the NodeId of the category whose ancestors are collected.
+ * @return the ancestor category NodeIds, in discovery order.
+ */
+ List getAncestorCategories(NodeId categoryId) {
+ var ancestors = new LinkedHashSet();
+ collectAncestorCategories(categoryId, ancestors);
+ return List.copyOf(ancestors);
+ }
+
+ /**
+ * Drop the version entry of a category that no longer exists, in memory and — best-effort — in
+ * the store.
+ *
+ * The store delete is cleanup, not correctness: a failure is logged and the removal proceeds,
+ * and stores whose {@link AliasVersionStore#delete} is the default no-op simply keep the entry. A
+ * leftover entry is inert unless a category with the same NodeId is created again, in which case
+ * the monotonic version sequence resumes from the persisted value — which is the safe direction.
+ *
+ * @param categoryId the NodeId of the removed category.
+ */
+ void remove(NodeId categoryId) {
+ versions.remove(categoryId);
+ pending.remove(categoryId);
+
+ try {
+ store.delete(categoryId.expanded(server.getNamespaceTable()));
+ } catch (Exception e) {
+ logger.warn(
+ "Failed to delete persisted LastChange for category {}",
+ categoryId.toParseableString(),
+ e);
+ }
+ }
+
+ private long secondsSince2000() {
+ return Math.max(0L, Instant.now().getEpochSecond() - VERSION_TIME_EPOCH_SECOND);
+ }
+
+ /**
+ * Walk inverse {@code Organizes} References from {@code categoryId}, adding every ancestor that
+ * is an {@code AliasNameCategoryType} instance to {@code affected}, stopping at (and including)
+ * the standard root {@code Aliases} Object.
+ *
+ *
References are read through the AddressSpaceManager, which aggregates every registered
+ * NodeManager, so linkage stored by either side's NodeManager is visible as long as both
+ * directions were recorded. A parent linkage recorded only as a one-directional forward Reference
+ * on the parent category's side — possible when References are added out-of-band, e.g. by a
+ * NodeSet loader that does not write inverses — is invisible from the child and is missed, so
+ * version bumps do not propagate across such an edge.
+ */
+ private void collectAncestorCategories(NodeId categoryId, Set affected) {
+ Deque queue = new ArrayDeque<>();
+ queue.push(categoryId);
+
+ while (!queue.isEmpty()) {
+ NodeId current = queue.pop();
+
+ List references =
+ server
+ .getAddressSpaceManager()
+ .getManagedReferences(current, Reference.ORGANIZED_BY_PREDICATE);
+
+ for (Reference reference : references) {
+ Optional parentId =
+ reference.getTargetNodeId().toNodeId(server.getNamespaceTable());
+
+ parentId.ifPresent(
+ id -> {
+ if (NodeIds.Aliases.equals(id)) {
+ // The standard root: include it, but never walk beyond it.
+ affected.add(id);
+ } else if (aliasTypes.isAliasNameCategoryInstance(id) && affected.add(id)) {
+ queue.push(id);
+ }
+ });
+ }
+ }
+ }
+
+ /**
+ * Write {@code value} to the category's {@code LastChange} Property Node, if that Node exists.
+ *
+ * The Property is never created here: it is Optional per category, and whether a category has
+ * one is decided when the category is materialized (or by the NodeSet that defined it).
+ */
+ private void writeLastChangeProperty(NodeId categoryId, UInteger value) {
+ Optional node = server.getAddressSpaceManager().getManagedNode(categoryId);
+
+ node.ifPresent(
+ categoryNode -> {
+ if (categoryNode instanceof AliasNameCategoryTypeNode typedNode) {
+ // setLastChange would create the Property if absent; only write where it exists.
+ if (typedNode.getLastChangeNode() != null) {
+ typedNode.setLastChange(value);
+ }
+ } else {
+ Optional propertyNode =
+ categoryNode.getPropertyNode(AliasNameCategoryType.LAST_CHANGE);
+
+ propertyNode.ifPresent(
+ property -> property.setValue(new DataValue(new Variant(value))));
+ }
+ });
+ }
+}
diff --git a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasVersionStore.java b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasVersionStore.java
new file mode 100644
index 0000000000..37f68ecd1e
--- /dev/null
+++ b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasVersionStore.java
@@ -0,0 +1,81 @@
+/*
+ * Copyright (c) 2026 the Eclipse Milo Authors
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+
+package org.eclipse.milo.opcua.sdk.server.aliases;
+
+import java.util.Map;
+import org.eclipse.milo.opcua.stack.core.UaException;
+import org.eclipse.milo.opcua.stack.core.types.builtin.ExpandedNodeId;
+import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UInteger;
+
+/**
+ * SPI for persisting alias category {@code LastChange} versions across Server restarts.
+ *
+ * Part 17 §6.3.1 requires the {@code LastChange} value of the root {@code Aliases} Object to be
+ * persisted: Clients cache alias lookups keyed by it, and a value that silently resets after a
+ * restart would leave those caches undetectably stale. The store persists nothing else — alias and
+ * category definitions are the application's concern.
+ *
+ *
Entries are keyed by a namespace-URI-qualified {@link ExpandedNodeId} of the category's
+ * NodeId, with the category's current {@code VersionTime} (seconds since 2000-01-01T00:00:00Z) as
+ * the value. Keys carry the namespace URI rather than a namespace index because indices are a
+ * runtime artifact of namespace-table registration order: a URI-qualified key stays valid across
+ * restarts even when the table assigns the namespace a different index. A store only ever holds
+ * entries written by {@link #save}, so the key set mirrors the categories whose versions have been
+ * bumped at least once.
+ *
+ *
Implementations do not need to be thread-safe; the manager serializes all store access.
+ */
+public interface AliasVersionStore {
+
+ /**
+ * Load all persisted category versions.
+ *
+ *
Called once, at manager startup. A thrown exception fails startup deliberately: continuing
+ * with silently reset versions would violate the Part 17 §6.3.1 persistence contract
+ * undetectably. A store with nothing persisted yet returns an empty map.
+ *
+ * @return the persisted versions, keyed by namespace-URI-qualified category ExpandedNodeId; empty
+ * if nothing has been persisted.
+ * @throws UaException if the persisted state cannot be read.
+ */
+ Map load() throws UaException;
+
+ /**
+ * Persist the version of a single category.
+ *
+ * Called whenever a category's {@code LastChange} value is about to be bumped, before
+ * the AddressSpace mutation the bump describes is applied and before the new value becomes
+ * observable. A thrown exception aborts the mutating operation ({@code Bad_InternalError}), so
+ * every value a Client can ever observe has been persisted first — after a restart, {@code
+ * LastChange} can then never repeat an observed value for different content, which would leave
+ * Client caches undetectably stale.
+ *
+ * @param categoryId the namespace-URI-qualified ExpandedNodeId of the category the version
+ * belongs to.
+ * @param value the category's new {@code VersionTime} value.
+ * @throws UaException if the value cannot be persisted.
+ */
+ void save(ExpandedNodeId categoryId, UInteger value) throws UaException;
+
+ /**
+ * Remove the persisted version of a category that no longer exists.
+ *
+ *
Called when a manager-created category is removed, so durable stores do not accumulate
+ * entries forever. Best-effort cleanup: a thrown exception is logged by the caller and the
+ * removal proceeds — a leftover entry is inert unless a category with the same NodeId is created
+ * again, in which case the version sequence resumes from it, which is harmless. The default
+ * implementation does nothing, for stores that prefer to keep (or externally expire) old entries.
+ *
+ * @param categoryId the namespace-URI-qualified ExpandedNodeId of the removed category.
+ * @throws UaException if the entry cannot be removed.
+ */
+ default void delete(ExpandedNodeId categoryId) throws UaException {}
+}
diff --git a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/DeleteAliasesFromCategoryMethodImpl.java b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/DeleteAliasesFromCategoryMethodImpl.java
new file mode 100644
index 0000000000..62a6e925c8
--- /dev/null
+++ b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/DeleteAliasesFromCategoryMethodImpl.java
@@ -0,0 +1,68 @@
+/*
+ * Copyright (c) 2026 the Eclipse Milo Authors
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+
+package org.eclipse.milo.opcua.sdk.server.aliases;
+
+import org.eclipse.milo.opcua.sdk.server.Session;
+import org.eclipse.milo.opcua.sdk.server.methods.Out;
+import org.eclipse.milo.opcua.sdk.server.model.objects.AliasNameCategoryType;
+import org.eclipse.milo.opcua.sdk.server.nodes.UaMethodNode;
+import org.eclipse.milo.opcua.stack.core.StatusCodes;
+import org.eclipse.milo.opcua.stack.core.UaException;
+import org.eclipse.milo.opcua.stack.core.types.builtin.ExpandedNodeId;
+import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId;
+import org.eclipse.milo.opcua.stack.core.types.builtin.StatusCode;
+
+/**
+ * Network-facing {@code DeleteAliasesFromCategory} implementation: authorizes the calling session
+ * through the {@link AliasAuthorizationPolicy}, then delegates to the {@link AliasManager}'s
+ * per-entry mutation path, targeting the category Object the Method was called on.
+ *
+ *
The category is re-resolved from the call's Object NodeId on every invocation, so a call
+ * racing a category removal fails with {@code Bad_NodeIdUnknown} instead of observing stale state.
+ *
+ *
Call-level failures (a null {@code AliasNames} array, a {@code TargetNodes} array of a
+ * different length, operation count over the configured limit, denied authorization) fail the whole
+ * call; everything else is reported per entry through the {@code ErrorCodes} output, with one
+ * StatusCode per input entry.
+ */
+class DeleteAliasesFromCategoryMethodImpl
+ extends AliasNameCategoryType.DeleteAliasesFromCategoryMethod {
+
+ private final AliasManager aliasManager;
+ private final AliasAuthorizationPolicy policy;
+
+ DeleteAliasesFromCategoryMethodImpl(
+ UaMethodNode node, AliasManager aliasManager, AliasAuthorizationPolicy policy) {
+
+ super(node);
+
+ this.aliasManager = aliasManager;
+ this.policy = policy;
+ }
+
+ @Override
+ protected void invoke(
+ InvocationContext context,
+ String[] aliasNames,
+ ExpandedNodeId[] targetNodes,
+ Out errorCodes)
+ throws UaException {
+
+ Session session = context.getSession().orElse(null);
+ NodeId categoryId = context.getObjectId();
+
+ if (!policy.checkMutate(session, categoryId)) {
+ throw new UaException(StatusCodes.Bad_UserAccessDenied);
+ }
+
+ errorCodes.set(aliasManager.deleteAliasEntries(categoryId, aliasNames, targetNodes));
+ }
+}
diff --git a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/FindAliasMethodImpl.java b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/FindAliasMethodImpl.java
new file mode 100644
index 0000000000..856cc49286
--- /dev/null
+++ b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/FindAliasMethodImpl.java
@@ -0,0 +1,68 @@
+/*
+ * Copyright (c) 2026 the Eclipse Milo Authors
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+
+package org.eclipse.milo.opcua.sdk.server.aliases;
+
+import java.util.List;
+import org.eclipse.milo.opcua.sdk.server.Session;
+import org.eclipse.milo.opcua.sdk.server.methods.Out;
+import org.eclipse.milo.opcua.sdk.server.model.objects.AliasNameCategoryType;
+import org.eclipse.milo.opcua.sdk.server.nodes.UaMethodNode;
+import org.eclipse.milo.opcua.stack.core.UaException;
+import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId;
+import org.eclipse.milo.opcua.stack.core.types.structured.AliasNameDataType;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Network-facing {@code FindAlias} implementation: authorizes the calling session through the
+ * {@link AliasAuthorizationPolicy}, then delegates to the {@link AliasSearchEngine}, searching from
+ * the category Object the Method was called on.
+ *
+ * The category is re-resolved from the call's Object NodeId on every invocation, so a call
+ * racing a category removal fails with {@code Bad_NodeIdUnknown} instead of observing stale state.
+ */
+class FindAliasMethodImpl extends AliasNameCategoryType.FindAliasMethod {
+
+ private final AliasSearchEngine engine;
+ private final AliasAuthorizationPolicy policy;
+
+ FindAliasMethodImpl(
+ UaMethodNode node, AliasSearchEngine engine, AliasAuthorizationPolicy policy) {
+
+ super(node);
+
+ this.engine = engine;
+ this.policy = policy;
+ }
+
+ @Override
+ protected void invoke(
+ InvocationContext context,
+ @Nullable String aliasNameSearchPattern,
+ @Nullable NodeId referenceTypeFilter,
+ Out aliasNodeList)
+ throws UaException {
+
+ Session session = context.getSession().orElse(null);
+ NodeId categoryId = context.getObjectId();
+
+ String pattern =
+ FindMethodSupport.checkFindCall(policy, session, categoryId, aliasNameSearchPattern);
+
+ List results =
+ engine.findAlias(
+ categoryId,
+ pattern,
+ referenceTypeFilter,
+ aliasNodeId -> policy.includeResult(session, aliasNodeId));
+
+ aliasNodeList.set(results.toArray(new AliasNameDataType[0]));
+ }
+}
diff --git a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/FindAliasVerboseMethodImpl.java b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/FindAliasVerboseMethodImpl.java
new file mode 100644
index 0000000000..812272c7a9
--- /dev/null
+++ b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/FindAliasVerboseMethodImpl.java
@@ -0,0 +1,68 @@
+/*
+ * Copyright (c) 2026 the Eclipse Milo Authors
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+
+package org.eclipse.milo.opcua.sdk.server.aliases;
+
+import java.util.List;
+import org.eclipse.milo.opcua.sdk.server.Session;
+import org.eclipse.milo.opcua.sdk.server.methods.Out;
+import org.eclipse.milo.opcua.sdk.server.model.objects.AliasNameCategoryType;
+import org.eclipse.milo.opcua.sdk.server.nodes.UaMethodNode;
+import org.eclipse.milo.opcua.stack.core.UaException;
+import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId;
+import org.eclipse.milo.opcua.stack.core.types.structured.AliasNameVerboseDataType;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Network-facing {@code FindAliasVerbose} implementation: authorizes the calling session through
+ * the {@link AliasAuthorizationPolicy}, then delegates to the {@link AliasSearchEngine}, searching
+ * from the category Object the Method was called on.
+ *
+ * The category is re-resolved from the call's Object NodeId on every invocation, so a call
+ * racing a category removal fails with {@code Bad_NodeIdUnknown} instead of observing stale state.
+ */
+class FindAliasVerboseMethodImpl extends AliasNameCategoryType.FindAliasVerboseMethod {
+
+ private final AliasSearchEngine engine;
+ private final AliasAuthorizationPolicy policy;
+
+ FindAliasVerboseMethodImpl(
+ UaMethodNode node, AliasSearchEngine engine, AliasAuthorizationPolicy policy) {
+
+ super(node);
+
+ this.engine = engine;
+ this.policy = policy;
+ }
+
+ @Override
+ protected void invoke(
+ InvocationContext context,
+ @Nullable String aliasNameSearchPattern,
+ @Nullable NodeId referenceTypeFilter,
+ Out aliasNodeList)
+ throws UaException {
+
+ Session session = context.getSession().orElse(null);
+ NodeId categoryId = context.getObjectId();
+
+ String pattern =
+ FindMethodSupport.checkFindCall(policy, session, categoryId, aliasNameSearchPattern);
+
+ List