# Description Correct ownership, endpoint validation, policy propagation, subscription delivery, file handling and generated-model behavior across the stack, tools and samples. Add focused regression tests and update the corresponding guides and sample documentation. ### Previously included corrections - Require an active, matching session to generate or access transient ResultTransfer file handles. Cover both sessionless generation and reads against a handle owned by an active session, using live session owners in the Machinery tests. - Keep physical filesystem operations within their configured mount and apply the same confinement checks throughout file operations. - Validate LDS semaphore paths consistently during server registration and registration-store maintenance. ### Individual issues addressed | # | Component | Issue and correction | |---|---|---| | 1 | AI jobs and transfers | Jobs and transfers did not consistently retain their creator's identity and session. Apply ownership checks to descendants, file handles, subscriptions and execution, and recheck deployment permissions before dispatch. | | 2 | Password-management client | Password-bearing calls could use signing-only channels or follow a replacement connection. Capture an encrypted transport generation and keep dispatch bound to it; preserve ordinary non-password edits. | | 3 | GDS and certificate-push clients | Connections omitted hostname verification. Validate the original configured DNS name or IP address before session activation. | | 4 | Certificate push | Private-key uploads could use signing-only calls. Require an encrypted, generation-bound dispatch while preserving certificate-only updates and restoring the caller's identity afterward. | | 5 | MQTT client | The .NET Framework custom-trust callback could discard TLS hostname, certificate-availability and usage errors. Retain those checks while supporting configured issuer trust. | | 6 | DTLS handshake | Certificate trust was not tied to the configured peer address. Match the certificate's DNS or IP identity to the intended endpoint. | | 7 | Replicated address spaces | Serialized nodes lost per-node and descendant permission metadata. Preserve role permissions, user-role permissions and access restrictions, including updates to existing replica nodes. | | 8 | MCP PubSub imports | Imports without groups skipped checks on the effective broker destination. Check the actual endpoint before resolving credentials or activating the connection. | | 9 | Reference-server accounts | A disabled username account could authenticate again with its previous roles. Reject disabled accounts during fresh authentication. | | 10 | Console reference client | The sample disabled hostname checks when connecting to an otherwise trusted peer. Retain the original requested host and validate it before activation. | | 11 | Capture replay and export | Replay and raw export could accept non-capture files or include key material. Require complete PCAP/PCAPNG containers and exclude key sidecars and decryption-secret blocks. | | 12 | PubSub adapter fallback | The fallback client configuration automatically accepted peer certificates. Apply normal trust and hostname validation before activation or reads. | | 13 | Kafka transport | Explicit TLS enablement was checked but did not reach both final Confluent configurations. Use the same effective protocol for validation, producers and consumers. | | 14 | PubSub actions | Configuration rebuilds could widen a connection-local action handler's scope. Preserve its original connection association and reject mismatched dispatch. | | 15 | PubSub readers | Reader-level message-protection settings were accepted but ignored. Apply the effective group and reader settings before accepting and publishing received values. | | 16 | PubSub key groups | Different `SecurityGroupId` values could share one connection-wide key context. Reject mixed groups on a connection and require separate connections for distinct groups. | | 17 | MCP session registrations | Session-name replacement or reuse could retain another registration's credential bindings. Bind access to the registration, caller and authenticated endpoint, and preserve that identity when borrowing sessions. | | 18 | Viewer HTTP host | Requests reached the viewer's OPC UA session without consistent Host, Origin and port checks. Validate the request destination and browser origin before using the session, while retaining supported local MCP clients. | | 19 | Vision file operations | File operations did not consistently apply protected-file checks, and remote handles lacked caller and connection context. Apply the shared checks and use host-side opaque handles bound to caller, target and generation. | | 20 | DTLS record integrity | The integrity-only MAC used a truncated sequence number. Authenticate the full reconstructed sequence number and epoch so records remain distinct across rollover. | | 21 | AMB client sample | Connection setup omitted hostname validation. Preserve the originally requested host and verify the peer identity before activation. | | 22 | Robot sample writes | The R2 `SpeedOverride` callback could target R1 state. Bind each callback to its own robot and apply that robot's write policy. | | 23 | AI result properties | Optional result children inherited remotely writable defaults. Preserve their declaration restrictions and keep server-produced results read-only. | | 24 | WSS peer-trust updates | Existing channels did not consistently respond to peer-trust changes. Revalidate complete chains for forward and reverse WSS channels and close only those that no longer validate. | | 25 | GDS revocation checks | Caller-provided certificates could cause issuer retrieval from embedded URLs. Resolve validation from configured local trust, issuers and CRLs without fetching caller-provided locations. | | 26 | GMS observations | Published correction observations retained writable model defaults. Reject remote writes while preserving publisher-side updates. | | 27 | Production-dataset monitoring | Subscription admission and delivery could use the reporter's context instead of each subscriber's permissions. Evaluate the subscriber at admission and on every delivery, including cancellation and disposal paths. | | 28 | WoT MQTT destinations | Hostless MQTT forms could imply a localhost destination before loopback checks. Reject them before credential lookup or connection attempts and validate the effective endpoint. | | 29 | XRegistry opaque reads | Content-ID reads and notifications did not consistently honor encrypted-read requirements or the currently accepted transport. Recheck the bound endpoint and reject transport downgrades before rebinding, including when protected values are already queued. | | 30 | PubSub capture dissection | Key loading did not consistently check the configured capture-root boundary. Check each actual key path immediately before opening it. | | 31 | PubSub publication queues | Imported WriterGroup queues were not fully checked against publish-topic grants. Check the destination actually used by each group. | | 32 | MCP PubSub mutations | A caller-supplied mode could select subscribe grants for an operation that publishes. Derive the required direction and parent context from the actual mutation. | | 33 | Generated write masks | Model import and generation could widen an explicit `UserWriteMask`. Preserve it through import, copy, merge, inheritance and dependency metadata. | | 34 | Generated method flags | An explicit `UserExecutable=false` could be lost. Preserve its specified state and resulting method behavior through the same model paths. | | 35 | Generated role identifiers | Unresolved role identifiers could cause permission entries to be omitted. Preserve namespace-qualified role IDs and report a diagnostic when a policy cannot be represented. | | 36 | Aggregation-server defaults | The `localhost` default did not reliably constrain listener binding. Use numeric loopback for the default endpoint and command-line fallback. | | 37 | Certificate-validation cache | Cached results could skip current leaf or issuer validity checks. Recheck validity windows on cache hits and keep hostname validation independent of cached chain trust. | | 38 | Software-update folders | Revision names and marker contents containing dot segments could select paths outside the software root. Normalize and check every derived path against that root. | | 39 | USDA arrays | Array elements were not encoded for their string or asset context. Escape them as data and preserve valid composition and round-trip behavior. | | 40 | Adapter hot reload | A changed dataset definition with the same name could keep old fields, subscriptions or cached values active. Compare complete immutable definitions and retire old state before replacement. | | 41 | Adapter index ranges | Subscription publication could ignore `IndexRange` and return the complete value. Preserve the requested projection and its status and timestamps; reject unsupported range providers. | | 42 | PubSub metadata and targets | Metadata from one connection could change another connection's target mapping. Scope metadata to its connection and provenance, and resolve targets using actual typed field IDs rather than field names or unintended positional fallback. | | 43 | Scales product locks | Product locks were not applied across every remote mutation path. Apply them consistently to value writes, filling calculations and related recipe/controller operations while retaining permitted updates. | | 44 | Event permission caching | Cached event decisions could outlive identity, role or node-policy changes. Revalidate the current subscriber, event type and source permissions instead of reusing a stale decision. | | 45 | WoT Modbus destinations | Hostless Modbus forms could imply localhost before loopback checks. Reject them before connecting and validate the effective endpoint. | | 46 | MCP local PKI tools | Peer-trust mutations did not consistently require the caller's `CertificateAdministration` grant. Check the capability before trust and untrust operations. | | 47 | Robot-control HTTP bridge | The bridge lacked consistent Host, Origin and port validation and caller-specific session grants. Apply those checks before borrowing the OPC UA session and retain supported local clients. | | 48 | Subscription transfers | Transferred subscriptions could retain cached notifications after the destination's permissions changed. Require authorization continuity between source and destination identities and roles, invalidate stale retransmission data when continuity fails, and preserve retransmission behavior for compatible transfers. | ### Compatibility notes - Replication uses the versioned NSRP v1 policy envelope. Upgrade peers together and re-encode or reseed legacy topology from the authoritative model; missing permission metadata is not inferred from old records. - Integrity-only DTLS peers must be upgraded together for the full sequence/epoch MAC. AEAD behavior is unchanged. - Distinct PubSub key groups require separate connections. - Custom sessions that cannot capture the actual encrypted transport, and subscription providers that cannot honor a requested range, are rejected rather than silently changing the requested behavior. - Subscription transfers retain retransmission data only when the destination authorization remains compatible with the source snapshot; otherwise the old sequence numbers and cached data are invalidated. ## Related Issues No separate tracking issue is linked. The individual corrections are listed above. ## Checklist _Put an `x` in the boxes that apply. You can complete these step by step after opening the PR._ - [ ] I have signed the [CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf) and read the [CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md) doc. - [x] I have added tests that prove my fix is effective or that my feature works and increased code coverage. - [ ] I have added all necessary documentation. - [x] I have verified that my changes do not introduce (new) build or analyzer warnings. - [ ] I ran **all** tests locally using the **UA.slnx** solution against at least .net **framework** and .net **10**, and all passed. - [ ] I fixed **all** failing and flaky tests in the CI pipelines and **all** CodeQL warnings. - [ ] I have addressed **all** PR feedback received.
18 KiB
Runtime NodeSets
This guide explains how to import one or more NodeSet2 XML documents into a server's address space at startup, without writing a source-generated or hand-coded NodeManager. Configure the files or streams to load. The server imports them in dependency order and registers the resulting nodes.
Within each imported set, types are registered in inheritance order rather than
XML record order. A derived type may precede its supertype in the document;
an inheritance cycle is rejected before registration. Imported namespace-zero
InputArguments and OutputArguments Properties populate their Method's typed
signature, retaining the declared Property NodeIds, argument order, and values.
Contents
- When to use the runtime NodeSet path
- Startup and live lifecycle semantics
- Quick-start examples
- Stream ownership contract
- Default namespace for unqualified browse paths
- Dependency sorting
- Complex types
- Imported security policy
- Comparison with source-generated NodeManagers
- Related documentation
When to use the runtime NodeSet path
Use AddRuntimeNodeSet when:
- You receive a NodeSet2 XML from a companion-specification vendor and want to host its nodes without regenerating source.
- Your information model changes frequently enough that rebuilding the source-generated manager for every XML update would be disruptive.
- You are prototyping or testing a new NodeSet2 design.
Use the source-generated path when you want compile-time safety, strong typing, and AOT-safe named constants for every node in your model. The runtime path gives you generic NodeState objects and untyped browse-path wiring.
Imported security policy
Runtime import retains node-local RolePermissions and AccessRestrictions.
When a node omits either attribute, import applies the corresponding default
from its owning Models/Model entry. Ownership follows the node's namespace
URI, not its BrowseName, the order of model entries, or the server's namespace
index. Defaults are materialized before the nodes enter the address space, so
ordinary Read, Write, Call and subscription authorization use the same policy.
An explicit empty node ACL or AccessRestrictions="0" remains an explicit
local override; it does not inherit the model setting. An omitted model
restriction has the NodeSet schema's default of zero and does not manufacture
a node-local restriction. Normal namespace defaults and application permission
callbacks still apply. Type-hierarchy visibility exemptions are enforced by the
server, not by removing imported policy: metadata remains available for
operations that require authorization.
Startup and live lifecycle semantics
AddRuntimeNodeSet on IOpcUaServerBuilder remains the startup path: its factory is created before the server starts and its NodeSet is imported during CreateAddressSpaceAsync. After startup, the resulting generation-1 registration is available from INodeManagerLifecycle.Registrations, so a model composed at startup can use the same reload and removal APIs as one added while the server is already running.
For example, a startup task can locate the registration by an owned model namespace:
public sealed class ModelRegistration(INodeManagerLifecycle lifecycle)
: IServerStartupTask
{
public NodeManagerRegistration? Registration { get; private set; }
public ValueTask OnServerStartedAsync(
IServerContext server,
CancellationToken ct)
{
Registration = lifecycle.Registrations.Find(registration =>
registration.NamespaceUris.Contains("urn:example:MyMachine"));
return default;
}
}
Running servers also expose INodeManagerLifecycle. Resolve it from dependency injection in a hosted server, or use StandardServer.NodeManagerLifecycle when constructing the server directly. The lifecycle provider can add, reload, shadow-reload, and remove runtime NodeSets without restarting the server.
public sealed class ModelLoader(INodeManagerLifecycle lifecycle)
{
private NodeManagerRegistration? m_registration;
public async ValueTask LoadAsync(CancellationToken ct)
{
m_registration = await lifecycle.AddRuntimeNodeSetAsync(
new RuntimeNodeSetOptions
{
Sources = [RuntimeNodeSetSource.FromFile("Models/MyMachine.NodeSet2.xml")]
},
callerContext: null,
ct);
}
public async ValueTask ReloadAsync(CancellationToken ct)
{
m_registration = await lifecycle.ReloadRuntimeNodeSetAsync(
m_registration!,
new RuntimeNodeSetOptions
{
Sources = [RuntimeNodeSetSource.FromFile("Models/MyMachine.NodeSet2.xml")]
},
callerContext: null,
ct);
}
public ValueTask RemoveAsync(CancellationToken ct)
{
return lifecycle.RemoveAsync(m_registration!, callerContext: null, ct);
}
}
Each add returns an immutable NodeManagerRegistration, and reload returns the next generation while invalidating the previous handle.
A registration obtained from Registrations for an AddRuntimeNodeSet model is already the first
live generation; pass it directly to ReloadRuntimeNodeSetAsync,
ShadowReloadRuntimeNodeSetAsync, ImmediateReloadRuntimeNodeSetAsync, or RemoveAsync.
AddRuntimeNodeSetAsync, ReloadRuntimeNodeSetAsync, and RemoveAsync take the operation the caller is running under. Pass context.GetOperationContext() when calling from a NodeManager or Method callback: a lifecycle operation drains the requests that are in flight, so one started from inside a request would wait for itself and is rejected with an InvalidOperationException. A control-plane caller such as the ModelLoader above is not serving a request and passes null. See Registering NodeManagers.
The rules that apply to every NodeManager registered at runtime -- what happens to MonitoredItems, Browse continuation points, namespace indexes, DataTypes, and change notifications, and which NodeManagers may be reloaded at all -- are described once in Registering NodeManagers. Runtime NodeSets follow those rules, and the built-in runtime NodeSet manager already implements the INodeManagerReloadParticipant contract that reload requires.
Shadow reload
ShadowReloadRuntimeNodeSetAsync permits replacing a generation that still owns
active monitored items. Those items stay on the retired generation and continue
to be served there until they are deleted or otherwise released; new requests use
the replacement generation. Normal reload instead migrates compatible monitored items.
public async ValueTask ShadowReloadAsync(CancellationToken ct)
{
m_registration = await lifecycle.ShadowReloadRuntimeNodeSetAsync(
m_registration!,
new RuntimeNodeSetOptions
{
Sources = [RuntimeNodeSetSource.FromFile("Models/MyMachine.NodeSet2.xml")]
},
ct);
}
The returned registration replaces the old handle for lifecycle operations. Choose shadow reload only when retaining the old model for existing subscriptions is intentional; its resources remain allocated until those subscriptions drain.
Immediate reload
ImmediateReloadRuntimeNodeSetAsync does not migrate existing monitored items.
Affected data-change items receive BadNodeIdUnknown, and clients may recreate
items against the replacement model.
The node-manager reload matrix is authoritative for all three modes, including request draining, continuation points, and generation cleanup. Runtime NodeSet reload methods use those same lifecycle operations.
Quick-start examples
Single file
services.AddOpcUa()
.AddServer(o => { /* … */ })
.AddRuntimeNodeSet("Models/MyMachine.NodeSet2.xml");
Single file with a fluent callback
Wire read/write/method handlers on top of the imported nodes using the existing untyped INodeManagerBuilder surface:
services.AddOpcUa()
.AddServer(o => { /* … */ })
.AddRuntimeNodeSet(
"Models/MyMachine.NodeSet2.xml",
nodes =>
{
nodes.Node("Machines/Machine1/Start")
.OnCall(StartMachineAsync);
nodes.Variable<double>("Machines/Machine1/Temperature")
.OnRead(ReadTemperature);
});
Standard Method argument children are bound to their typed NodeState properties during import.
A NodeSet2 Method's InputArguments and OutputArguments Variables populate
MethodState.InputArguments and MethodState.OutputArguments, so normal Call argument validation
uses the declarations from the document.
Binding uses the authored HasProperty relationship, declared either on the Method or
as an inverse reference on the argument Property; ParentNodeId is optional. Local
namespace-URI reference targets are resolved against the import context's namespace table.
The Property must use the namespace-zero standard BrowseName, PropertyType, the Argument
DataType, and a one-dimensional value rank. Ambiguous or conflicting declarations and
custom or malformed properties do not bind the Method's typed signature; their authored
nodes and references are retained. Other authored reference types are preserved.
Group of dependent NodeSets
Register multiple NodeSet2 sources that depend on each other. The factory resolves the import order automatically from the RequiredModel declarations in each document.
services.AddOpcUa()
.AddServer(o => { /* … */ })
.AddRuntimeNodeSet(options =>
{
options.Sources =
[
RuntimeNodeSetSource.FromFile("Models/Opc.Ua.Di.NodeSet2.xml"),
RuntimeNodeSetSource.FromFile("Models/MyMachine.NodeSet2.xml")
];
options.DefaultNamespaceUri = "urn:example:MyMachine";
options.Configure = nodes =>
{
nodes.Node("Machines/Machine1/Start").OnCall(StartMachineAsync);
};
});
Custom stream source
Use RuntimeNodeSetSource.FromStream when you want to open the NodeSet2 document lazily — for example from a database, a blob store, or an assembly resource. The delegate is called for each startup load or live generation and must return a fresh, readable Stream each time.
services.AddOpcUa()
.AddServer(o => { /* … */ })
.AddRuntimeNodeSet(options =>
{
options.Sources =
[
RuntimeNodeSetSource.FromStream(
name: "MyMachine",
openStream: ct => OpenNodeSetStreamAsync(ct),
modelNamespaceUris: ["urn:example:MyMachine"])
];
});
Direct factory registration
If you prefer to create the factory manually — for example to inject it into an existing StandardServer subclass — construct a RuntimeNodeSetNodeManagerFactory directly:
var factory = new RuntimeNodeSetNodeManagerFactory(new RuntimeNodeSetOptions
{
Sources = [RuntimeNodeSetSource.FromFile("Models/MyMachine.NodeSet2.xml")]
});
// In a StandardServer subclass constructor:
AddNodeManager(factory);
Stream ownership contract
When FromStream is used, the runtime loader calls the openStream delegate while each NodeManager generation is created and closes the returned stream after deserialization. You must ensure that:
- Each call to
openStreamreturns a new stream positioned at the beginning. - The stream is readable and contains a valid NodeSet2 XML document.
- You do not close the stream yourself; the factory disposes it after
UANodeSet.Read.
If the delegate returns null or the stream does not contain valid NodeSet2 XML, server startup fails with a clear InvalidOperationException that names the source.
Default namespace for unqualified browse paths
When the Configure callback uses browse paths without an explicit ns=N; prefix (for example "Machines/Machine1/Start"), the runtime loader must know which namespace index to apply for the first path segment. The resolution is:
- If
RuntimeNodeSetOptions.DefaultNamespaceUriis set, that URI is used. - Otherwise the factory infers the unique leaf model — the one model in the loaded group that is not required by any other included source.
- If inference is ambiguous (multiple leaf models and a
Configurecallback is present), startup fails with an error message that lists the candidates. In this case, setDefaultNamespaceUriexplicitly.
When no Configure callback is registered, DefaultNamespaceUri has no effect and may be omitted.
Dependency sorting
The factory reads the Models/Model/RequiredModel entries from each parsed NodeSet document and performs a topological sort (Kahn's algorithm) before importing. Import order guarantees that a required model's nodes are in the address space before any document that depends on them imports its nodes.
All documents of one manager are parsed into a single batch whose parent-child relationships are linked exactly once, after the last document has been parsed. A node may therefore declare a ParentNodeId that lives in another document of the group regardless of the order the two were parsed in; a duplicate NodeId across documents is rejected while parsing. A fluent Configure callback can add further documents to the manager with builder.Import — see Importing a NodeSet2 overlay at runtime.
Dependencies on models not included in the group — for example the OPC UA base namespace or a third-party model hosted by a generated NodeManager — are silently allowed and treated as external. The server resolves cross-manager references through the normal AddReverseReferencesAsync mechanism.
Referencing a Node another NodeManager owns
A NodeSet may declare a Reference whose other endpoint belongs to a different NodeManager — an inverse Organizes placing an Object under a folder some other model defines, for instance. OPC 10000-3 requires such a Reference to be visible from both ends, so the forward edge has to appear on a Node this NodeManager does not own.
That works in both registration orders. At startup the master collects every NodeManager's external references first and applies them afterwards, so creation order does not matter. A NodeManager added after startup has no such second phase, so the master retains the startup references and each dynamic NodeManager's references, and replays them to any NodeManager registered later. Without that replay a Reference into a NodeManager that did not exist yet would simply be dropped — a reference to an absent Node is discarded rather than queued — leaving the two ends permanently disagreeing: the target browses to the source, the source does not list the target.
Both orders are pinned by RuntimeNodeSetCrossSourceReferenceTests.
Cycles among the included sources cause InvalidOperationException at startup with an error message that lists the participating sources.
Complex types
Runtime complex type loading (structures, enumerations, union types) is on by default and requires no extra configuration. After all NodeManagers have built their address spaces, StandardServer.OnNodeManagerStartedAsync scans every DataType node whose DataTypeDefinition attribute is populated and registers a NativeAOT-safe stand-in encodeable in the server's factory. The same stand-ins are reused for client decode/encode via the OPC UA Part 6 binary protocol.
You can tune this behaviour through the existing ServerComplexTypeOptions:
services.AddOpcUa()
.AddServer(o => { /* … */ })
.AddRuntimeNodeSet("Models/MyMachine.NodeSet2.xml")
.AddComplexTypeSystem(o => o.Enabled = false); // disable if not needed
Setting StandardServer.LoadComplexTypes = false or disabling the complex type system entirely suppresses the stand-in loading without producing a warning. No second complex-type loading path is introduced by the runtime NodeSet feature.
For a complete description of the server-side complex type system, see ComplexTypes.md.
Comparison with source-generated NodeManagers
| Aspect | Runtime NodeSet (AddRuntimeNodeSet) |
Source-generated ([NodeManager]) |
|---|---|---|
| Node access in callbacks | Generic NodeState / BaseVariableState via untyped browse paths |
Strongly typed, compiler-checked fluent accessors per node |
| Compilation required on model change | No — reload through INodeManagerLifecycle |
Yes — regenerate and rebuild |
| AOT / trimming compatibility | Full (uses the existing UANodeSet.Read XmlSerializer path) |
Full (generated code is static) |
| Named NodeId constants | Not generated | Generated (Variables.*, Objects.*, etc.) |
| Multiple namespaces in one manager | Yes — group multiple sources | One namespace per generator run |
| DI registration | AddRuntimeNodeSet(...) |
AddNodeManager<TFactory>() |
| Stream / file input | Files and custom stream factories | MSBuild AdditionalFiles only |
Source-generated managers are the recommended path for production code where type safety and compile-time validation matter. Runtime NodeSets are the recommended path for rapid prototyping, model-file delivery scenarios, and cases where the XML content changes independently of the server binary.
Related documentation
- Source-Generated NodeManagers — strongly typed alternative.
- Dependency Injection —
IOpcUaServerBuilderand service registration. - ComplexTypes.md — server-side complex type loading and client-side decoding.