Files
UA-.NETStandard/docs/RuntimeNodeSets.md
T
Marc Schier 2836da7ec8 Fix sessionless ResultTransfer ownership bypass and other issues. (#4834)
# 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.
2026-10-21 05:37:29 +02:00

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

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:

  1. Each call to openStream returns a new stream positioned at the beginning.
  2. The stream is readable and contains a valid NodeSet2 XML document.
  3. 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:

  1. If RuntimeNodeSetOptions.DefaultNamespaceUri is set, that URI is used.
  2. Otherwise the factory infers the unique leaf model — the one model in the loaded group that is not required by any other included source.
  3. If inference is ambiguous (multiple leaf models and a Configure callback is present), startup fails with an error message that lists the candidates. In this case, set DefaultNamespaceUri explicitly.

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.