# OPC UA Part 17 — Alias Names OPC UA Part 17 defines a small but valuable address-space pattern: a hierarchy of human-readable **alias names** that point at one or more nodes via a non-hierarchical reference type. Clients can search the hierarchy by wildcard pattern and resolve a name to its targets without needing to know the target NodeId in advance — useful for tag-naming schemes (PI / SCADA / DCS), pub/sub topic registries, MES integration, and any scenario where humans pick names but machines need ids. This stack ships full Part 17 support in **`Opc.Ua.Server`** (server side) and **`Opc.Ua.Client`** (client side). The implementation covers: | Spec section | Type / Method | Status | | ------------ | ---------------------------------------- | ------ | | §6.2 | `AliasNameType` (browsable instance nodes) | ✔ opt-in — see below | | §6.3.1 | `AliasNameCategoryType` (incl. nesting) | ✔ (nested instances opt-in — see below) | | §6.3.1 | `LastChange` (`VersionTime`) | ✔ | | §6.3.2 | `FindAlias` | ✔ | | §6.3.3 | `FindAliasVerbose` | ✔ | | §6.3.4 | `AddAliasesToCategory` | ✔ | | §6.3.5 | `DeleteAliasesFromCategory` | ✔ | | §7.2 | `AliasNameDataType` | ✔ | | §7.3 | `AliasNameVerboseDataType` | ✔ | | §8.2 | `AliasFor` reference type | ✔ | | §9.2 | Well-known `Aliases (i=23470)` | ✔ wired; optional methods opt-in — see below | | §9.3 | Well-known `TagVariables (i=23479)` | ✔ wired; optional methods opt-in — see below | | §9.4 | Well-known `Topics (i=23488)` | ✔ wired; optional methods opt-in — see below | | Annex D | PubSub replication (LastChange notifications) | ✔ transport-agnostic — see below | ## Server side — `Opc.Ua.Server.AliasNames` The server library exposes a pluggable backend (`IAliasNameStore`) plus a default in-memory implementation. Apps assemble their alias inventory inside a store, then either: 1. Register the store directly with the server-wide `IAliasNameStoreRegistry` so the standard well-known `Aliases`/`TagVariables`/`Topics` nodes start dispatching through it, **or** 2. Wrap the store in an `AliasNameNodeManager` to expose application- defined categories under a custom namespace (with full `AddAliasesToCategory` / `DeleteAliasesFromCategory` support). Both approaches can be combined. ### Quick start — serving standard categories ```csharp using Opc.Ua; using Opc.Ua.Server; using Opc.Ua.Server.AliasNames; // inside CreateMasterNodeManager(IServerInternal server, ...): var tagVariables = new AliasNameCategoryDescriptor( ObjectIds.TagVariables, QualifiedName.From(BrowseNames.TagVariables), AliasNameCapabilities.FindAliasVerbose); var store = new InMemoryAliasNameStore([tagVariables]); store.Seed(ObjectIds.TagVariables, "TIC101_Setpoint", new ExpandedNodeId("Scalar_Static_Double", refServerNs), serverUri: null, referenceTypeId: ReferenceTypeIds.AliasFor); // ... seed more entries ... ((IAliasNameStoreRegistryProvider)server) .AliasNameStoreRegistry.Register(store); ``` When a client calls `Aliases.FindAlias` (`i=23476`), `TagVariables.FindAlias` (`i=23485`) or `Topics.FindAlias` (`i=23494`), `DiagnosticsNodeManager`'s late binder routes the call through the registry to the matching store. ### Quick start — application-defined categories `AliasNameNodeManager` is a `CustomNodeManager2` that owns a namespace and creates its own category tree from the store's `RootCategories`. Add it to your server's node-manager list: ```csharp var myRoot = new AliasNameCategoryDescriptor( new NodeId("My/Category", myNamespaceIndex), new QualifiedName("My/Category", myNamespaceIndex), AliasNameCapabilities.All); // expose every optional method var store = new InMemoryAliasNameStore([myRoot]); nodeManagers.Add(new AliasNameNodeManager(server, configuration, store)); ``` Options: * `NamespaceUri` — controls the namespace under which the manager registers its category instances. Defaults to `http://opcfoundation.org/UA/AliasName/`. Every category descriptor's NodeId must lie in this namespace; descriptors pointing anywhere else are skipped with a warning rather than claiming another manager's ids. * `MaterializeAliasNodes` (default `true`) — creates one browsable `AliasNameType` node per alias, with `AliasFor` references to its targets, exactly as the standard-node materialization below does. Disable to expose only the category tree. * `LinkToStandardAliasesObject` (default `true`) — adds `Organizes` external references from the well-known `Aliases (i=23470)` object to the manager's root categories so they show up in the standard browse tree. * `RequireSecurityAdminForMutations` (default `true`) — rejects `AddAliasesToCategory` / `DeleteAliasesFromCategory` calls from unauthenticated users or sessions without the `WellKnownRole_SecurityAdmin` role on a `SignAndEncrypt` channel. * `RegisterWithServerRegistry` (default `true`) — also registers the store with `IAliasNameStoreRegistry` so the well-known standard nodes see it. ### Browsable alias nodes Registering a store makes `FindAlias` answer from it, but the aliases themselves stay inside the store — nothing in the address space shows them. Part 17 §6.2 clients (the OPC Foundation CTT among them) also *browse* for aliases, so a server under conformance test needs the alias hierarchy materialized as real nodes. Both materialization paths — `AliasNameNodeManager` for application-defined namespaces and `DiagnosticsNodeManager.MaterializeRegisteredAliasNameNodesAsync` for the standard well-known nodes — run the same shared walker (`AliasNameNodeMaterializer`), so they produce structurally identical Part 17 trees and every fix lands in both at once. `DiagnosticsNodeManager.MaterializeRegisteredAliasNameNodesAsync` does that for every registered store: one `AliasNameType` instance per alias — BrowseName carrying the alias name, `AliasFor` references to its targets, and the inverse `HasAlias` reference added on each local target — plus an `AliasNameCategoryType` instance for every store category the standard NodeSet does not already ship (nested categories included; a root category from the store is organized under the standard `Aliases` object so it stays browse-discoverable). Remote targets get their server URI registered in the server's `ServerUris` table and referenced with the matching `ServerIndex`. Server-defined BrowseNames that a descriptor left in namespace 0 — reserved for OPC-Foundation-defined names — are re-homed into the diagnostics namespace; Part 17 clients compare alias names ignoring the namespace, so this is transparent to them. The pass is idempotent, and only creates category nodes whose NodeId lies in the diagnostics namespace — a descriptor pointing anywhere else is skipped with a warning rather than claiming another manager's (or the standard NodeSet's) ids. Call it from an overridden `CreateAddressSpaceAsync`, after `base` has loaded the standard categories: ```csharp public override async ValueTask CreateAddressSpaceAsync( IDictionary> externalReferences, CancellationToken cancellationToken = default) { await base.CreateAddressSpaceAsync(externalReferences, cancellationToken) .ConfigureAwait(false); await MaterializeRegisteredAliasNameNodesAsync( externalReferences, cancellationToken).ConfigureAwait(false); } ``` The same pass also instantiates the **optional Part 17 methods** — `FindAliasVerbose`, `AddAliasesToCategory`, `DeleteAliasesFromCategory` and `LastChange` — on every category whose descriptor declares them through `AliasNameCapabilities`, including the well-known ones the standard NodeSet ships without them: ```csharp var tagVariables = new AliasNameCategoryDescriptor( ObjectIds.TagVariables, QualifiedName.From(BrowseNames.TagVariables), AliasNameCapabilities.All); // verbose + add + delete + LastChange ``` Mutation calls are gated on a `SecurityAdmin` caller over a `SignAndEncrypt` channel and return `BadUserAccessDenied` otherwise. It is opt-in: servers that only need `FindAlias` to answer from their store pay nothing. The created nodes are a snapshot taken at address-space creation — aliases added or removed later through `AddAliasesToCategory` / `DeleteAliasesFromCategory` change what `FindAlias` returns and advance `LastChange`, but do not add or remove `AliasNameType` nodes. The browse view carries `AliasFor` associations only: an entry a store holds under an unrelated reference type is not a Part 17 §6.2 alias association, so it is served by `FindAlias` (with a matching filter) but not materialized. Entries stored under an `AliasFor` *subtype* are materialized, but always as the base `AliasFor` reference — the verbose record does not carry the concrete reference type. Two things the caller controls: * Give category descriptors a BrowseName in a namespace the server owns. The store stamps that namespace onto every alias `QualifiedName` it reports, and the materializer re-homes ns=0 BrowseNames out of the reserved OPC Foundation namespace — with a server-owned descriptor namespace the query results and the browsable nodes agree, so a returned alias name resolves via `TranslateBrowsePathsToNodeIds`. The reference server uses the diagnostics namespace for all its descriptors. * Part 17 §9 constrains `Topics` aliases to `PublishedDataSetType` targets. Neither the store nor `AddAliasesToCategory` enforces this — a server that exposes the mutation methods on `Topics` should only grant them to operators who preserve the constraint. `Quickstarts.ReferenceServer` uses this in `ReferenceServerConfigurationNodeManager`, which also creates the `PublishedDataSet` instances and seeds the `Topics` aliases that point at them — in the same place, so an alias can never target a dataset that was not created. The nested `Devices` sub-category under `TagVariables` is declared with the store seeding in `ReferenceServer.ConfigureAliasNameStore`. ### Custom backend Implement `IAliasNameStore` to back the alias inventory with your own storage (DB, file, MES, …). The interface is small: ```csharp public interface IAliasNameStore { IReadOnlyList RootCategories { get; } event EventHandler? Changed; uint? GetLastChange(NodeId categoryId); bool OwnsCategory(NodeId categoryId); ValueTask> FindAliasAsync(...); ValueTask> FindAliasVerboseAsync(...); ValueTask AddAliasesAsync(...); ValueTask DeleteAliasesAsync(...); } ``` The reference `InMemoryAliasNameStore` is thread-safe (SemaphoreSlim), supports nested categories and emits `Changed` events that bubble up to the address-space `LastChange` property. A mutation on a nested category bumps — and notifies for — every ancestor category as well, per Part 17 §6.3.1/§9.2: a category's `LastChange` reflects the most recent change anywhere in its subtree, and the root `Aliases` value reflects any change at all. ## Client side — `Opc.Ua.Client.AliasNames` The client library provides a high-level `AliasNameClient` plus a caching `AliasNameResolver`. ```csharp using Opc.Ua.Client.AliasNames; // Standard categories have hardcoded method NodeIds so the first call // is one round-trip — no extra TranslateBrowsePaths probe needed. AliasNameClient client = AliasNameClient.OpenStandardTagVariables(session); IReadOnlyList result = await client.FindAliasAsync("TIC%", referenceTypeFilter: null, ct); ``` `AliasNameClient` exposes the full Part 17 method surface: * `FindAliasAsync(pattern, referenceTypeFilter, ct)` * `FindAliasVerboseAsync(...)` — throws `NotSupportedException` when the category does not expose the optional method. * `AddAliasesToCategoryAsync(IEnumerable, ct)` * `DeleteAliasesFromCategoryAsync(IEnumerable, ct)` * `EnumerateSubCategoriesAsync(ct)` — `IAsyncEnumerable` of child `AliasNameSubCategoryInfo`. * `ReadLastChangeAsync(ct)` — returns the `VersionTime` (or `null` when the category does not expose `LastChange`). Per-call errors map to typed exceptions: | Status code | Exception | | -------------------------- | ----------------------------------- | | `BadUserAccessDenied` | `UnauthorizedAccessException` | | `BadNotSupported` | `NotSupportedException` | | `BadNotImplemented` | `NotSupportedException` | | other `BadXxx` | `ServiceResultException` | ### `AliasNameResolver` — cached alias→NodeId ```csharp await using var resolver = new AliasNameResolver( AliasNameClient.OpenStandardTagVariables(session)); IReadOnlyList targets = await resolver.ResolveAsync("TIC101_Setpoint", ct); string aliasName = await resolver.ResolveAliasNameAsync(targets[0], ct); ``` Default refresh mode is `Manual` — callers invoke `RefreshAsync` (or rely on lazy-load via `ResolveAsync`). Opt in to automatic cache invalidation via one of: | `AliasNameResolverRefreshMode` | Strategy | When to use | | ---------------------------------- | ----------------------------------------- | ----------- | | `Manual` (default) | `ManualAliasNameRefreshStrategy` | Caller drives refresh explicitly. Safe everywhere. | | `AutoOnLastChangePolling` | `PollingAliasNameRefreshStrategy` | Server lacks Subscriptions or you want a fixed `Read` cadence. | | `AutoOnLastChangeMonitoredItem` | `MonitoredItemAliasNameRefreshStrategy` | Server supports Subscriptions. Push-based — no `Read` per interval. | Custom strategies (e.g. the Annex D PubSub bridge in `Opc.Ua.Client.AliasNames.PubSub` — see Annex D below) plug in via: ```csharp new AliasNameResolverOptions { RefreshStrategy = new MyCustomStrategy() // takes precedence over RefreshMode } ``` The `IAliasNameRefreshStrategy` contract is tiny: ```csharp public interface IAliasNameRefreshStrategy : IAsyncDisposable { ValueTask StartAsync(AliasNameClient client, Action onInvalidate, CancellationToken ct); } ``` Implementations watch for stale-cache triggers and invoke `onInvalidate` whenever they detect a change. `MonitoredItemAliasNameRefreshStrategyOptions` controls the underlying `Subscription`: it can be left owned (default — created + deleted by the strategy) or set via `SharedSubscription` to plug the monitored item into an externally managed subscription. Disposing the resolver (`await using` / `DisposeAsync`) tears down the strategy: timer for polling, `MonitoredItem` + `Subscription` for the monitored-item variant. Disposal is idempotent and never throws. ## Spec deviations / wrinkles * **`AliasNameDataType.ReferencedNodes`** — the wire format defines this as `ExpandedNodeId[]` (NodeSet `i=18`), not `NodeId[]`. The source-generated `AliasNameDataType` is correct; the historical Quickstart sample used `NodeId[]` and has been removed. * **Standard well-known nodes** — the OPC UA NodeSet instantiates only `FindAlias` on `Aliases`/`TagVariables`/`Topics` (plus `LastChange` on `Aliases`), so the always-on binder wires just those. The optional methods are added by `MaterializeRegisteredAliasNameNodesAsync` through the generated `AddFindAliasVerbose` / `AddAddAliasesToCategory` / `AddDeleteAliasesFromCategory` optional- child helpers, for each category whose descriptor declares the matching `AliasNameCapabilities`. Each child is created at the NodeId the OPC Foundation reserves for it (`Aliases.FindAliasVerbose` = `i=24054`, `TagVariables.LastChange` = `i=32854`, and so on). Those identifiers are allocated in the standard identifier registry (`StandardTypes.csv`) but are deliberately **not** declared in the ModelDesign and **not** present in the published NodeSet — the standard address space does not instantiate optional children. A reserved id is a number set aside for the node *if* a server chooses to expose it, not a node the server must have. Consequently the source generator emits no parent-to-instance mapping and no `MethodIds` constant for them, and `DiagnosticsNodeManager.ReservedChildIds` supplies the nine method ids and the two missing `LastChange` ids explicitly, each traceable to its registry row. The argument properties do have generated `VariableIds` constants and are referenced through those. Do not "fix" this by declaring the children in `StandardTypes.xml`: that file is a verbatim copy of the OPC Foundation's ModelDesign and is re-synced from upstream, so a local edit is lost on the next sync and the NodeIds silently revert to factory-minted ones. * **`AliasNameCapabilities.AddAliasesToCategory` / `DeleteAliasesFromCategory`** — on an `AliasNameNodeManager` this defaults to `SecurityAdmin`-only via `AliasNameNodeManagerOptions.RequireSecurityAdminForMutations`; on the standard well-known nodes the same check is always applied and cannot be opted out of. It requires both the role grant AND a `SignAndEncrypt` channel. * **`ReferenceTypeFilter` semantics** — null/empty and `ReferenceTypeIds.References` match every alias regardless of reference type. Otherwise matches are limited to aliases whose reference type is, or is a subtype of, the filter (using `Server.TypeTree.IsTypeOf`). ## Annex D — PubSub LastChange notifications Part 17 Annex D defines a lightweight PubSub schema for alias-change notifications between servers. The schema carries only each category's current `LastChange` value (a `VersionTime`/`uint`) — subscribers learn that a publisher's category changed, then refetch alias contents via `FindAlias`/`FindAliasVerbose` if needed. The data types (already emitted by the source generator): | NodeId | Type | Fields | | -------- | ----------------------------- | ------ | | `i=24052` | `AliasCategoryUpdateDataType` | `Category : PortableNodeId`, `LastChange : VersionTime` | | `i=24053` | `AliasUpdateDataType` | `ApplicationUri : string`, `Categories : AliasCategoryUpdateDataType[]` | ### Server side — `Opc.Ua.Server.AliasNames.PubSub` The server library exposes a transport-agnostic publisher that emits fully-built `AliasUpdateDataType` messages whenever an `IAliasNameStoreRegistry`-tracked store changes: ```csharp using Opc.Ua.Server.AliasNames; using Opc.Ua.Server.AliasNames.PubSub; // inside server startup (after the alias store is registered): var resolver = new ServerPortableNodeIdResolver(server); var publisher = new AliasNamePublisher( registry: ((IAliasNameStoreRegistryProvider)server).AliasNameStoreRegistry, portableResolver: resolver, applicationUri: configuration.ApplicationUri); publisher.AliasUpdateProduced += (_, e) => { // Hand `e.Update` to your transport — e.g. publish a DataSetMessage // through Opc.Ua.PubSub.UaPubSubApplication with the DataSet // built by AliasUpdateDataSetFactory.Create(...). }; ``` Helpers shipped: * `IPortableNodeIdResolver` + `ServerPortableNodeIdResolver` — converts a local `NodeId` into the spec-required `PortableNodeId` (NamespaceUri + Identifier with namespace index stripped). * `AliasUpdateDataSetFactory.Create(dataSetClassId)` — builds the fixed-by-spec `DataSetMetaDataType` describing the `AliasUpdate` DataSet (`ApplicationUri : string`, `Categories : AliasCategoryUpdateDataType[]`). * `AliasNamePublisher` — subscribes to the registry, builds and emits `AliasUpdateDataType` messages via the `AliasUpdateProduced` event. The library deliberately stays transport-agnostic: it raises the fully-built `AliasUpdateDataType` and lets the application wire it into `Opc.Ua.PubSub.UaPubSubApplication` (UDP / JSON / MQTT) or any other transport that can carry the `AliasUpdateDataType` payload. ### Client side — `Opc.Ua.Client.AliasNames.PubSub` The client library mirrors the publisher: ```csharp using Opc.Ua.Client.AliasNames; using Opc.Ua.Client.AliasNames.PubSub; var reader = new AliasNamePubSubReader( new AliasNamePubSubReaderOptions { ExpectedApplicationUri = "urn:opcfoundation:publisher", }); // Hand incoming AliasUpdateDataType messages off to the reader from // your transport (e.g. UaPubSubApplication.DataReceived, an MQTT // subscriber callback, ...): reader.Submit(receivedAliasUpdate); // Wire reader into the resolver so the cache invalidates on every // LastChange bump observed via PubSub. await using var resolver = new AliasNameResolver( AliasNameClient.OpenStandardAliases(session), new AliasNameResolverOptions { RefreshStrategy = new AliasNamePubSubRefreshStrategy(reader), }); ``` Helpers shipped: * `AliasNamePubSubReader` — surfaces incoming `AliasUpdateDataType` messages as the `AliasUpdateReceived` event. Optional `ExpectedApplicationUri` filter drops messages from other publishers. * `AliasNamePubSubRefreshStrategy : IAliasNameRefreshStrategy` — bridges the reader into the resolver. Matches incoming entries by the resolver's category `NamespaceUri` + identifier; fires `Invalidate` on any value-difference (wrap-safe — the comparison is inequality, not strict greater-than). The PubSub bridge plugs into the same `IAliasNameRefreshStrategy` extension point as the polling and monitored-item strategies — apps can mix-and-match, e.g. fall back to polling on a particular category while letting PubSub drive the rest. ## See also * OPC UA Part 17 specification: https://reference.opcfoundation.org/v105/Core/docs/Part17/ * `tools/Opc.Ua.SourceGeneration.Core/Design/StandardTypes.xml` — Part 17 type definitions consumed by the source generator. * `tests/Opc.Ua.Server.Tests/AliasNames/` — server-side unit tests. * `tests/Opc.Ua.Client.Tests/AliasNames/` — mocked-session and live integration tests. * `samples/Quickstarts.Servers/ReferenceServer/ReferenceServer.cs` — `ConfigureAliasNameStore` shows how to seed and register a store.