Files
UA-.NETStandard/docs/ModelChangeTracking.md
Marc Schier 0374e19121 Add MachineTool, WMTP, GMS and CranesHoists companions (#4765)
# Description

_Describe the changes here to communicate to the maintainers why they
should accept this pull request. By default - this will become the
Commit message after merging and thus define history._

Add provider-backed MachineTool, bundled WMTP, GMS and CranesHoists
companions using the existing Machinery/MachineVision fluent, DI and
typed-client approach. **The PR is open/non-draft by user choice.
Current head `ecc4a7d6ca3e8004cf28ab54da605da302d026bf` is mergeable and
has 108 successful checks, six intentionally skipped checks, zero
failures and zero pending checks. All five primary workflows completed
successfully at that exact head.**

| Family | Released specification/model | Packaging |
| --- | --- | --- |
| MachineTool | OPC 40501-1, 1.02.0 | Model, Server, Client and MCP;
normal root versions |
| WMTP | OPC 40505, 1.0.0 | Bundled in MachineTool; distinct CLR/wire
namespace and independent WMTP-only hosting |
| GMS | OPC 40210 reference 1.0.1, normative model 1.0.0 | Model,
Server, Client and MCP; normal root versions |
| CranesHoists | OPC 40020-1, 1.00 | Model, Server, Client and MCP; all
four plus Debug variants forced preview |

There are **12 new packable projects**. No separate WMTP
package/profile/guide, root version bump, tag, release or package
publication is included. Mandatory instance structure is the default;
selected optional features require explicit providers. The
implementation reuses Machinery results/jobs/files/ProcessValues and
Robotics topology, including retained MachineTool Production, defined
GMS jobs/parts, modern ISA95/Machinery jobs, shared-owner hosting and
isolated instances alongside MachineVision.

Runtime and integration fixes preserve raw quality/timestamps, coherent
acquisition, namespace ownership, accepted-configuration state,
borrowed-resource lifetime, session/node/file cleanup, terminal-value
publication, event propagation and endpoint-aware capability
publication. Firmware/licence transfer means provider acceptance, not
installation/activation. Cancellation does not silently abort another
caller's operation. The later upstream MCP expansion is integrated
without losing either family set or its default-off privileged/live
execution policy.

### Conformance and scope

Canonical model inputs are pinned to UA-Nodeset revision
`96a08945425d0295edb3607d2a38f6841ef44046`.

**GMS upstream exception:** [Annex
A.1](https://reference.opcfoundation.org/specs/OPC-40210/annex-a) makes
the released NodeSet normative, but §12.3.2.7 requires undefined
`GMS_ProductionStatisticsType`. Inherited statistics and defined
jobs/parts are implemented without inventing a standard type/NodeId; the
complete GMS Production facet is withheld.

The requirement ledger records **102 positively evidenced CU rows, 96
directly negative-tested rows and six explicit negative-evidence
limitations**. All 648 test-class/name/TRX links were independently
resolved to passing cases. The six limitations concern Stacklight,
retained legacy PowerOnDuration, Machinery PowerOnDuration, WorkingUnit,
Obligation and MaintenanceMode. They are not claims that every
invalid-input or hardware-control behavior was tested. The undefined GMS
requirement is outside the 102 defined CUs. No external certification
claim is made.

Retained baseline complete-assembly line coverage, with generated code
retained and no source exclusions:

| Family | Model | Server | Client |
| --- | ---: | ---: | ---: |
| MachineTool/WMTP | 83.80% | 91.98% | 84.74% |
| GMS | 95.51% | 90.63% | 88.38% |
| CranesHoists | 92.76% | 94.45% | 92.24% |

These nine measurements predate the later focused review fixes and
merges; they are not relabelled as new `ecc4a7d6c` percentages. The
current head's separate CI code-coverage check succeeded. No new
exclusions or threshold relaxations were introduced.

## Related Issues

- Fixes
[#4764](https://github.com/OPCFoundation/UA-.NETStandard/issues/4764),
the implementation/design and acceptance tracker.

## 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.
- [x] I have added all necessary documentation.
- [ ] 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.
- [x] I have addressed **all** PR feedback received.
2026-10-15 15:15:46 +02:00

24 KiB

Model Change Tracking

OPC UA Part 5 §6.4.32 defines GeneralModelChangeEventType. Servers use it to notify clients about address-space changes, including added or deleted nodes and references, and changed data types.

The Opc.Ua.Client.ModelChange namespace provides an opt-in client-side tracker. It consumes these events, removes affected entries from the client's INodeCache, and surfaces the changes through a strongly typed event. Application code can then browse the affected subtrees again.

The server-side counterpart lives in Opc.Ua.Server.Alarms. ModelChangeAggregator batches per-call changes. CustomNodeManager and AsyncCustomNodeManager automatically emit GeneralModelChangeEvent from CreateNode and DeleteNode. Set a flag to opt out when you need manual control.

Quick reference

Concern Server entry point Client entry point
Emit a model change CustomNodeManager.RaiseGeneralModelChangeEvent(...) (auto from CreateNode/DeleteNode) n/a
Aggregate before emit ModelChangeAggregator.RecordNodeAdded/Deleted/... n/a
Opt out of auto-emit node manager.ModelChangeEmissionEnabled = false n/a
Subscribe to changes n/a ManagedSession.EnableModelChangeTrackingAsync()
Inspect changes n/a IModelChangeTracker.ModelChanged event
Invalidate one node n/a INodeCache.InvalidateNode(nodeId)
Invalidate everything n/a INodeCache.Clear()
Refresh the namespace table Live NodeManager registration updates NamespaceArray / UrisVersion Automatic — see Namespace table refresh

Server side: emitting model changes

Auto-emit from CreateNode and DeleteNode

AsyncCustomNodeManager.CreateNodeAsync(...) and DeleteNodeAsync(...) (and the sync-compat overloads on CustomNodeManager) automatically record the change in a per-instance ModelChangeAggregator and emit a GeneralModelChangeEvent at the end of the call.

public class MyNodeManager : AsyncCustomNodeManager
{
    public async ValueTask AddDeviceTwinAsync(NodeId parent, string name, CancellationToken ct)
    {
        var device = new BaseObjectState(null);
        // CreateNodeAsync emits a GeneralModelChangeEvent with verbs
        //   NodeAdded(device.NodeId) + ReferenceAdded(parent)
        NodeId id = await CreateNodeAsync(SystemContext, parent,
            ReferenceTypeIds.HasComponent,
            new QualifiedName(name, NamespaceIndex),
            device,
            ct).ConfigureAwait(false);
    }
}

Browse consistency. CreateNodeAsync, AddNodeAsync and DeleteNodeAsync also keep the node manager's internal component cache in sync with the change. A deleted node is evicted from the cache. After a runtime add or remove, the manager refreshes the parent's cached view. A subsequent Browse, Read, or Call therefore sees the committed child set instead of stale data. When the node is already cached, re-registering it refreshes the cached instance. Replacing a cached node with a typed proxy also refreshes it.

If your node manager mutates the address space without going through CreateNodeAsync / DeleteNodeAsync (for example by editing an in-memory NodeStateCollection), you can either:

  1. Opt out of auto-emit and drive it yourself:

    public class MyNodeManager : AsyncCustomNodeManager
    {
        public MyNodeManager(...)
        {
            ModelChangeEmissionEnabled = false;   // disable auto-emit
        }
    
        public void RewireGraph()
        {
            // ...mutate nodes...
            ModelChangeAggregator.RecordReferenceAdded(parent.NodeId);
            ModelChangeAggregator.RecordNodeDeleted(stale.NodeId, stale.TypeDefinitionId);
            EmitModelChange(SystemContext);       // single event covers the batch
        }
    }
    
  2. Leave auto-emit on and call the aggregator + EmitModelChange from custom mutation paths. Every CreateNode / DeleteNode continues to emit its own event for the change it made.

EmitModelChange is a no-op when the aggregator has no pending changes, so it is safe to call at the end of an arbitrary service implementation.

Deletion is not an atomic subtree transaction. If removal fails after deleting some nodes, monitored items for removed nodes report Bad_NodeIdUnknown, while items for descendants that remain registered are restored to their node manager. The original failure is still reported; restoration failures are also surfaced. Each asynchronous restoration gets its own five-second cleanup budget, so one timed-out item does not cancel attempts to restore the remaining items.

Deletion captures the subtree's model-change eligibility before mutation. Completion reports only nodes actually removed and references actually removed from surviving local nodes, including when later cleanup fails or the request is cancelled. A versioned descendant is reported even if its root is unversioned. The deletion error is still returned; notification failures are not suppressed. For reference endpoints owned by another node manager, automatic reporting requires requested and successful target-reference cleanup because their remaining references cannot be inspected locally. A bad per-reference cleanup result from an owning node manager is propagated as a deletion failure, not treated as successful cleanup. Committed local changes are still reported, but unconfirmed foreign reference removals are not. A successful DeleteNodes call with DeleteTargetReferences = false does not imply that foreign references were removed. This reporting covers both directions of hierarchical and non-hierarchical references to other node managers in the same server. Absolute targets are excluded, matching the deletion cleanup path.

The asynchronous runtime deletion overload accepts emitModelChange: false for callers that own aggregation or roll back unpublished staging. This suppresses automatic reporting for that call, including after a committed deletion's cleanup fails, without changing the manager-wide policy or deletion lifecycle handling.

Manual aggregation across a transaction

ModelChangeAggregator is thread-safe and supports arbitrary batching:

var aggregator = new ModelChangeAggregator();
aggregator.RecordNodeAdded(motor.NodeId, ObjectTypeIds.BaseObjectType);
aggregator.RecordReferenceAdded(parent.NodeId);
aggregator.RecordDataTypeChanged(speedVar.NodeId);
aggregator.RecordReferenceDeleted(oldParent.NodeId);

if (aggregator.HasPending)
{
    RaiseGeneralModelChangeEvent(context, aggregator.Drain());
}

Drain clears the pending list and returns the batch in insertion order. The aggregator can be reused across many transactions.

The Verb field in ModelChangeStructureDataType is a bitmask (the ModelChangeVerbs flags enum on the server side mirrors the same spec values):

Flag Value Meaning
NodeAdded 1 A new node was added.
NodeDeleted 2 An existing node was deleted.
ReferenceAdded 4 A reference was added.
ReferenceDeleted 8 A reference was deleted.
DataTypeChanged 16 The DataType attribute changed.

Semantic change events

RaiseSemanticChangeEvent(...) emits SemanticChangeEventType for property semantics changes — for example when an EngineeringUnit, EURange, or Description changes in a way that affects the meaning of a value. This is independent of GeneralModelChangeEvent; both are subtypes of BaseModelChangeEventType and both are observed by the client-side tracker.

Client side: tracking model changes

Enabling tracking on a ManagedSession

The simplest path is to opt in via ManagedSessionOptions / ManagedSessionBuilder — tracking auto-starts after connect:

ManagedSession session = await new ManagedSessionBuilder(configuration, telemetry)
    .UseEndpoint(endpoint)
    .WithModelChangeTracking()      // opt in
    .ConnectAsync(ct);

session.ModelChange!.ModelChanged += (sender, args) =>
{
    foreach (ModelChange change in args.Changes)
    {
        Console.WriteLine($"{change.Verb}: {change.AffectedNode}");
    }
};

Tracking can also be enabled / disabled at runtime:

await session.EnableModelChangeTrackingAsync(ct);
// ...
await session.DisableModelChangeTrackingAsync(ct);

Disposing the session disposes the tracker.

Manual construction

If you need the tracker without a ManagedSession — for example with a raw Session that has the V2 engine enabled — construct it yourself:

var streaming = new StreamingSubscription(session.SubscriptionManager);
var tracker = new ModelChangeTracker(
    streaming,
    nodeCache: session.NodeCache,
    logger: logger,
    namespaceTables: session);

await tracker.StartTrackingAsync(ct);

The tracker takes an IStreamingSubscription, an optional INodeCache to invalidate, an optional ILogger, and an optional INamespaceTableRefresher (see Namespace table refresh). It subscribes to BaseModelChangeEventType on the Server object's notifier (ObjectIds.Server), which covers both GeneralModelChangeEventType and SemanticChangeEventType payloads.

Reacting to changes

The ModelChanged event fires once per server event with a structured payload:

public sealed class ModelChangedEventArgs
{
    public IReadOnlyList<ModelChange> Changes { get; }
    public bool RequiresFullCacheInvalidation { get; }
    public bool NamespaceTableRefreshed { get; }
}

public record struct ModelChange(
    ModelChangeVerb Verb,
    NodeId AffectedNode,
    NodeId TypeDefinition);

TypeDefinition is NodeId.Null when the server did not report one — test it with change.TypeDefinition.IsNull.

RequiresFullCacheInvalidation is true when the server reports a change without per-node detail (a Part 5 semantic change without a populated payload). In that case the tracker calls INodeCache.Clear() for you, but applications may also need to re-browse interesting subtrees:

session.ModelChange!.ModelChanged += async (sender, args) =>
{
    if (args.RequiresFullCacheInvalidation)
    {
        await RefreshUiTreeAsync();
        return;
    }

    foreach (ModelChange c in args.Changes)
    {
        if (c.Verb.HasFlag(ModelChangeVerb.NodeDeleted))
        {
            ui.RemoveNode(c.AffectedNode);
        }
        else if (c.Verb.HasFlag(ModelChangeVerb.NodeAdded))
        {
            await ui.BrowseAndAddAsync(c.AffectedNode);
        }
    }
};

Namespace table refresh

A Server may append namespace uris while it is running — most notably when a NodeManager is registered live. The Server updates Server_NamespaceArray, bumps Server_UrisVersion, and then reports the address-space change. Without a refresh, a Client resolves NodeIds from the new namespace against a stale NamespaceUris table and silently gets the wrong index.

The tracker closes that gap when given an INamespaceTableRefresher. It refreshes the namespace table before invalidating the cache and raising ModelChanged. Subscribers can then re-browse from the event handler using the new URIs:

public interface INamespaceTableRefresher
{
    NamespaceTable NamespaceUris { get; }
    Task FetchNamespaceTablesAsync(CancellationToken ct = default);
}

Both Session and ManagedSession implement it, and EnableModelChangeTrackingAsync wires the session in automatically — opting into tracking is all an application has to do:

session.ModelChange!.ModelChanged += (sender, args) =>
{
    if (args.NamespaceTableRefreshed)
    {
        // The namespace table grew. Re-resolve any ExpandedNodeIds
        // the application cached as namespace-index based NodeIds.
        RemapCachedNodeIds(session.NamespaceUris);
    }
};

A refresh is triggered when any of the following holds:

Trigger Why
The event carries no per-node detail (RequiresFullCacheInvalidation) This is the BaseModelChangeEvent shape a live NodeManager registration emits; the Client cannot tell whether namespaces changed, so it re-reads.
An affected node is Server_NamespaceArray The Server explicitly reported the namespace array as changed.
An affected node (or its type definition) has a namespace index beyond the Client's table The Client provably cannot resolve the NodeId.

There is no throttling. An unqualified change already forces INodeCache.Clear(), which is far more expensive for an application than one two-node Read, so the namespace read rides along with it. A failing refresh is logged and swallowed: the ModelChanged event is still raised (with NamespaceTableRefreshed == false) and tracking continues.

Pass namespaceTables: null when constructing the tracker manually to opt out entirely, or supply a custom INamespaceTableRefresher to control how and when the table is re-read.

Namespace indexes are append-only for the lifetime of a running Server, so a refresh can only ever extend the Client table. Indexes the Client already resolved stay valid.

Per-node NodeCache invalidation

INodeCache exposes InvalidateNode(NodeId) for targeted eviction. The NodeCache shipped with the stack drops the cached node, value, and reference list for just that NodeId; alternative implementations can fall back to Clear().

The tracker calls InvalidateNode for every non-NoneVerb change, so the cache stays in sync without application help. If you want to intercept (e.g. to drop dependents):

class MyInvalidatingCache : INodeCache
{
    private readonly NodeCache m_inner;
    public void InvalidateNode(NodeId id)
    {
        m_inner.InvalidateNode(id);
        // Also drop dependents tracked elsewhere
        m_dependentIndex.RemoveAllReferencing(id);
    }
    // ... delegate everything else to m_inner ...
}

NodeVersion correlation

OPC UA Part 3 §9.32.2 ties ModelChangeEvent emission to the NodeVersion property:

Automatic deletion captures a node's NodeVersion eligibility before removing it from the address space. A removed local node must not bypass this check as an unresolved external node. Unversioned session diagnostics therefore do not emit NodeDeleted events on session closure; versioned nodes and the explicit RequireNodeVersionForModelChange = false compatibility mode retain their events.

"Every time a ModelChangeEvent is issued for a Node, its NodeVersion shall be changed, and every time the NodeVersion is changed, a ModelChangeEvent shall be generated. A Server shall support both the ModelChangeEvent and the NodeVersion Property or neither, but never only one of the two mechanisms." "Only those Nodes of the AddressSpace having a NodeVersion shall trigger a ModelChangeEvent. Other Nodes shall not trigger a ModelChangeEvent."

The framework enforces both halves:

  1. Suppression. AsyncCustomNodeManager.RaiseGeneralModelChangeEvent filters the Changes[] array, dropping every entry whose Affected node is owned by the node manager and does not carry a NodeVersion property. When the filtered array is empty no event is raised at all. Entries whose Affected node belongs to a different node manager pass through unchanged (the local manager cannot inspect the foreign node's state — this is documented behaviour).

  2. Bumping. Every Affected node that survives the filter has its NodeVersion incremented as a decimal ulong string ("1" → "2" → …) before the GeneralModelChangeEvent is reported. The increment is wrapped in an AsyncLocal guard so the OnWriteValue handler installed by EnableModelChangeTrackingFor does not echo the framework bump as a BaseModelChangeEvent.

  3. External writes. When EnableModelChangeTrackingFor (or the underlying NodeState.EnableModelChangeTracking extension) attaches a NodeVersion it also wires OnWriteValue so a write from an external client (or a Write service call) raises a BaseModelChangeEvent (Part 5 §9.32.5) with the owning node as the SourceNode.

Marking a node as trackable

AsyncCustomNodeManager exposes the EnableModelChangeTrackingFor(node) convenience method that wraps the more general NodeState.EnableModelChangeTracking(namespaceIndex, callback) extension:

public class MyNodeManager : AsyncCustomNodeManager
{
    public async ValueTask InstallDynamicAreaAsync(CancellationToken ct)
    {
        var folder = new FolderState(null)
        {
            SymbolicName = "Dynamic",
            BrowseName = new QualifiedName("Dynamic", NamespaceIndex)
        };

        // Attaches a NodeVersion property and wires its OnWriteValue
        // so any external write raises a BaseModelChangeEvent on this
        // folder. Idempotent — calling it twice returns the same
        // PropertyState and does not attach a second copy.
        EnableModelChangeTrackingFor(folder);

        await CreateNodeAsync(SystemContext, default,
            ReferenceTypeIds.Organizes,
            folder.BrowseName,
            folder,
            ct).ConfigureAwait(false);

        // Now every CreateNodeAsync / DeleteNodeAsync touching the
        // folder will emit a GeneralModelChangeEvent and bump
        // folder.NodeVersion accordingly.
    }
}

If you only need the helper without a node manager (for example in a unit test or a fluent builder) call the extension directly:

using Opc.Ua.Server.NodeManager;

PropertyState<string> version = node.EnableModelChangeTracking(
    namespaceIndex: 1,
    raiseBaseModelChangeEvent: (ctx, owner) =>
    {
        // emit a BaseModelChangeEvent via your own pipeline
    });

Opting out (legacy compatibility)

For servers that have not yet annotated their address space with NodeVersion properties and still expect every CreateNodeAsync / DeleteNodeAsync to fire an event, set RequireNodeVersionForModelChange = false on the affected node manager. The filter is then skipped and the legacy unconditional behaviour is restored. Default: true (strict spec compliance).

public sealed class LegacyNodeManager : AsyncCustomNodeManager
{
    public LegacyNodeManager(...)
    {
        // Opt back into pre-Part5 §9.32.2 unconditional emission.
        RequireNodeVersionForModelChange = false;
    }
}

Security considerations

Model-change events are governed by the same per-event permission rules as every other UA event, with one practical caveat that operators should understand when deploying on shared address spaces.

Per-event ReceiveEvents is enforced

OPC UA Part 3 §8.55 (PermissionType, bit 11 ReceiveEvents):

A Client only receives an Event if this bit is set on the Node identified by the EventTypeId field and on the Node identified by the SourceNode field.

The stack honors this on every live event delivery — see MonitoredNode2.ProcessEventSnapshotAsync → IAsyncNodeManager.ValidateEventRolePermissionsAsync (which delegates to ValidateEventReceivePermissionsAsync for the two ValidateRolePermissionsAsync(..., PermissionType.ReceiveEvents) checks). For GeneralModelChangeEvent the two nodes are GeneralModelChangeEventType and (typically) the Server object — a session lacking ReceiveEvents on either node will not receive the event even when the session has a valid event monitor on the notifier.

The verdict is cached per (MonitoredItemId, EventTypeId, SourceNodeId) so a busy notifier does not pay the two role-permission lookups on every event. The cache is invalidated when:

  • the namespace DefaultRolePermissions or DefaultUserRolePermissions change,
  • the notifier's own RolePermissions change (signaled via NodeStateChangeMasks.RolePermissions to subscribed nodes),
  • the receiving session's user identity changes (InvalidatePermissionCacheForSession),
  • the monitored item is removed.

If you publish events via your own helper that pushes directly into a list of IEventMonitoredItem, use EventManager.ReportEventAsync(event, nodeManager, items, ct) — it applies the same per-item permission gate before queueing. The legacy EventManager.ReportEvent(IFilterTarget, IList<IEventMonitoredItem>) overload is marked [Obsolete] because it bypasses the gate.

The Affected array is NOT filtered

GeneralModelChangeEvent.Changes[] carries a ModelChangeStructureDataType per address-space change with an Affected and an AffectedType NodeId. These names are placed into the event payload as-is — the stack does NOT redact entries the receiving session cannot browse or read. The OPC UA specification does not require such filtering, and applying it would force a per-item metadata lookup for every affected node on every delivery.

When the affected subtree is sensitive (e.g. you do not want unauthorized sessions to learn that a node with a given browse name exists), choose one of the following:

  1. Emit on a private View. Build a ViewState that contains only the public subset of the address space, give it a distinct SourceNode, and call RaiseGeneralModelChangeEvent with that view as the source. Subscribers without ReceiveEvents on the private view's source node will not receive the event at all.
  2. Split sensitive subtrees into a dedicated AsyncCustomNodeManager. Set ModelChangeEmissionEnabled = false on that manager and emit model-change events on a notifier that is itself protected by ReceiveEvents. Public node managers continue to auto-emit on Objects.Server as normal.
  3. Drive emission manually with ModelChangeAggregator. Inspect the pending entries before calling RaiseGeneralModelChangeEvent and drop entries that name sensitive nodes.

The initial address-space build does not emit

AsyncCustomNodeManager.CreateAddressSpaceAsync walks the predefined node set through AddPredefinedNodeAsync — that path does not feed the ModelChangeAggregator and does not call EmitModelChange. Only the dynamic CreateNodeAsync / DeleteNodeAsync (and any explicit calls you add) raise GeneralModelChangeEvent. This is intentional: no clients are subscribed during startup, and Part 5 §9.32.2 ties ModelChangeEvent emission to nodes that carry a NodeVersion property — predefined static nodes typically do not.

If your node manager mutates the address space late (e.g. installing nodes after StartAsync returns) and uses AddPredefinedNodeAsync rather than CreateNodeAsync, no event will fire. Either use CreateNodeAsync or record the change with the aggregator and call EmitModelChange explicitly. The AsyncCustomNodeManager.ModelChangeEmissionEnabled flag opts the entire manager out of auto-emit if you want full manual control.

Reference

  • Server source: src/Opc.Ua.Server/NodeManager/ModelChangeAggregator.cs
  • Server events: CustomNodeManager.RaiseGeneralModelChangeEvent, RaiseSemanticChangeEvent (and the async equivalents in AsyncCustomNodeManager)
  • Client source: src/Opc.Ua.Client/ModelChange/
  • Cache: src/Opc.Ua.Client/NodeCache/NodeCache.cs — InvalidateNode
  • Sessions architecture: Sessions.md
  • Spec: OPC UA Part 5 §6.4.32 — BaseModelChangeEventType