Files
UA-.NETStandard/Docs/ModelChangeTracking.md
Marc Schier 01d7162313 Part 9 Alarms & Conditions: complete server + client implementation (#3805)
Complete implementation of **OPC UA Part 9 ΓÇö Alarms & Conditions** for
both server and client, **OPC UA Part 16 ΓÇö State Machines** (generic
extensible client + server API), and the supporting infrastructure
(source-generated event records, source-generated ObjectType proxies,
`IAsyncEnumerable` streaming subscriptions, address-space model change
tracking, per-node `INodeCache` invalidation, unified DI integration).

### Server-side (Part 9 complete)

- **`AlarmConditionState`** ΓÇö every Part 9 method wired automatically
in `OnAfterCreate`: `Silence`, `Suppress`/`Suppress2`,
`Unsuppress`/`Unsuppress2`, `RemoveFromService`/`RemoveFromService2`,
`PlaceInService`/`PlaceInService2`, `Reset`/`Reset2`,
`GetGroupMemberships`. Each method generates the spec-mandated audit
event and supports an optional veto-delegate hook.
- **Latched alarms** ΓÇö `ActiveState` reflects real process state;
`LatchedState` persists until explicit `Reset` (with full precondition
validation).
- **Re-alarming** ΓÇö
`ProcessReAlarm`/`ResetReAlarmRepeatCount`/`IsReAlarmEnabled` helpers
(opt-in, no hidden timer).
- **Audible alarms** ΓÇö `UpdateAudibleState` for
`AudibleEnabled`/`AudibleSound`/`SilenceState` lifecycle.
- **Suppression + groups** ΓÇö `AlarmGroup`, `AlarmSuppressionEngine`
(with first-in-group), `AlarmRateTracker` (for `AlarmMetricsType`).
- **Spec-compliance fixes** ΓÇö `SuppressedOrShelved` now includes
`OutOfServiceState`; `GetRetainState` includes `LatchedState`;
`EffectiveDisplayName` composition includes `OutOfServiceState` +
`LatchedState`.

### Client-side Part 9 API

- **`AlarmClient`** ΓÇö typed Part 9 operations. Each method delegates
to the matching source-generated `*TypeClient` proxy
(`ConditionTypeClient`, `AcknowledgeableConditionTypeClient`,
`AlarmConditionTypeClient`, `DialogConditionTypeClient`,
`ShelvedStateMachineTypeClient`) ΓÇö one source of truth for every
method NodeId.
- **Interface hierarchy** — `IConditionOperations` →
`IAcknowledgeableConditionOperations` → `IAlarmOperations` +
`IDialogConditionOperations`.
- **Source-generated event records** ΓÇö new `EventRecordGenerator`
(alongside `ObjectTypeProxyGenerator`) emits `{Type}TypeRecord` per OPC
UA event type: `ConditionTypeRecord` →
`AcknowledgeableConditionTypeRecord` → `AlarmConditionTypeRecord` →
`LimitAlarmTypeRecord`/`ExclusiveLimitAlarmTypeRecord`/… all the way
to vendor subtypes. **Vendor alarm types get records automatically.**
- **`AlarmEventDecoder`** ΓÇö decodes `EventNotification.Fields` into
the most-specific generated record.
- **`AlarmEventFilterBuilder`** ΓÇö fluent builder for Part 9
`EventFilter`s.
- **`ISession.GetAlarmClient(telemetry)`** convenience extension.

### Part 16 state machines (generic, extensible)

- **Client**: extension methods on the source-generated
`StateMachineTypeClient` / `FiniteStateMachineTypeClient` ΓÇö
`GetCurrentStateAsync`, `ObserveFiniteTransitionsAsync`
(`IAsyncEnumerable<FiniteStateSnapshot>`), `WaitForStateAsync`,
`GetAvailableStatesAsync` / `GetAvailableTransitionsAsync`. **Vendor
proxies inherit the API automatically** through the generated
inheritance chain.
- **Server**: `StateMachineBuilder` (fluent: `AddState` /
`AddTransition` / `OnCause` / validation) +
`FluentFiniteStateMachineState` ΓÇö declare a Part 16 state machine
without subclassing the abstract base.
- **Alarms alignment**: `AlarmClient.GetShelvingStateAsync` /
`ObserveShelvingTransitionsAsync` surface the API for every alarm's
`ShelvingState` child.

### IAsyncEnumerable streaming subscription

- **`IStreamingSubscription`** with
`SubscribeDataChangesAsync`/`SubscribeEventsAsync` returning
`IAsyncEnumerable<T>` (Channel-backed).
- **`ManagedSession.DefaultStreaming`** ΓÇö lazy shared subscription;
disposed with the session.
- **Helpers** ΓÇö `TakeUntilAsync`, `WithTimeoutAsync`, `TakeAsync`,
`BufferedAsync` for state-machine waits and bounded observation windows.
- **`AlarmStreamExtensions`** ΓÇö
`SubscribeAlarmsAsync`/`SubscribeConditionsAsync`/`SubscribeDialogsAsync`
returning the source-generated record types.

### Address-space model change tracking

- **Server**: `RaiseGeneralModelChangeEvent` on both `CustomNodeManager`
and `AsyncCustomNodeManager`; `ModelChangeAggregator` (in
`Opc.Ua.Server.NodeManager`, used by every node manager) batches changes
per service call; **auto-emit** from `CreateNode`/`DeleteNode` (opt-out
via `ModelChangeEmissionEnabled = false`).
- **Client**: `IModelChangeTracker` subscribes to
`BaseModelChangeEventType` via the streaming subscription, decodes
`ModelChangeStructureDataType` payloads, invalidates the `INodeCache`
per-node.
- **`INodeCache.InvalidateNode(NodeId)`** ΓÇö new interface member (true
per-node eviction in `NodeCache`).
- **`ManagedSession.EnableModelChangeTrackingAsync()`** +
`ManagedSessionOptions.ModelChangeTracking` +
`ManagedSessionBuilder.WithModelChangeTracking()` fluent method.

### Dependency Injection integration

- **`services.AddOpcUa().AddClient(...).AddAlarms()`** ΓÇö registers
`AlarmClientFactory` so DI-hosted apps can resolve a Part 9 client per
session without `new`-ing one manually.
- Cross-link added to the unified DI surface (`AddServer` / `AddClient`
/ `AddComplexTypes` / `AddGdsServer` / `AddLdsServer` /
`AddWotConServer`).

### Quickstart server modernization

- `Quickstarts.Servers/Alarms/AlarmNodeManager.cs` ported to
`AsyncCustomNodeManager` and demonstrates the new `AlarmGroup` +
`AlarmSuppressionEngine` end-to-end via a writable `MaintenanceMode`
boolean that suppresses every member alarm.

### Documentation

- New: `Docs/AlarmsAndConditions.md`, `Docs/StreamingSubscription.md`,
`Docs/ModelChangeTracking.md`, `Docs/StateMachines.md` ΓÇö full
developer guides.
- Updated: `Docs/DependencyInjection.md` (`AddAlarms()` row + section),
`Docs/README.md` index, `Docs/MigrationGuide.md` (Part 9 / Part 16 /
model change tracking sections covering behavior changes + breaking API
surface).
- Sample: `Applications/ConsoleReferenceClient/AlarmClientSample.cs`.
2026-05-29 23:24:53 +02:00

481 lines
18 KiB
Markdown

# Model Change Tracking
OPC UA Part 5 §6.4.32 defines `GeneralModelChangeEventType` so a
server can notify clients that the address space has changed —
nodes added or deleted, references added or deleted, datatypes
changed. The `Opc.Ua.Client.ModelChange` namespace ships an
opt-in client-side tracker that consumes those events, drops the
affected entries from the client's `INodeCache`, and surfaces the
changes through a strongly-typed event so application code can
re-browse the impacted subtrees.
The server-side counterpart lives in `Opc.Ua.Server.Alarms`:
`ModelChangeAggregator` batches per-call changes and the
`CustomNodeManager` (and `AsyncCustomNodeManager`) emit
`GeneralModelChangeEvent` automatically from `CreateNode` /
`DeleteNode`. Opt out via a flag if you need manual control.
- [Quick reference](#quick-reference)
- [Server side: emitting model changes](#server-side-emitting-model-changes)
- [Auto-emit from `CreateNode` and `DeleteNode`](#auto-emit-from-createnode-and-deletenode)
- [Manual aggregation across a transaction](#manual-aggregation-across-a-transaction)
- [Semantic change events](#semantic-change-events)
- [Client side: tracking model changes](#client-side-tracking-model-changes)
- [Enabling tracking on a `ManagedSession`](#enabling-tracking-on-a-managedsession)
- [Manual construction](#manual-construction)
- [Reacting to changes](#reacting-to-changes)
- [Per-node `NodeCache` invalidation](#per-node-nodecache-invalidation)
- [NodeVersion correlation](#nodeversion-correlation)
- [Security considerations](#security-considerations)
- [Reference](#reference)
## 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()` |
## 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.
```csharp
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);
}
}
```
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:**
```csharp
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.
### Manual aggregation across a transaction
`ModelChangeAggregator` is thread-safe and supports arbitrary batching:
```csharp
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:
```csharp
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:
```csharp
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:
```csharp
var streaming = new StreamingSubscription(session.SubscriptionManager);
var tracker = new ModelChangeTracker(
streaming,
nodeCache: session.NodeCache,
logger: logger);
await tracker.StartTrackingAsync(ct);
```
The tracker takes an `IStreamingSubscription`, an optional `INodeCache`
to invalidate, and an optional `ILogger`. 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:
```csharp
public sealed class ModelChangedEventArgs
{
public IReadOnlyList<ModelChange> Changes { get; }
public bool RequiresFullCacheInvalidation { get; }
}
public readonly record struct ModelChange(
ModelChangeVerb Verb,
NodeId AffectedNode,
NodeId? TypeDefinition);
```
`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:
```csharp
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);
}
}
};
```
### 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):
```csharp
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 5 §9.32.2 ties `ModelChangeEvent` emission to the
`NodeVersion` property:
> "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:
```csharp
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:
```csharp
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).
```csharp
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: `Libraries/Opc.Ua.Server/NodeManager/ModelChangeAggregator.cs`
- Server events: `CustomNodeManager.RaiseGeneralModelChangeEvent`,
`RaiseSemanticChangeEvent` (and the async equivalents in
`AsyncCustomNodeManager`)
- Client source: `Libraries/Opc.Ua.Client/ModelChange/`
- Cache: `Libraries/Opc.Ua.Client/NodeCache/NodeCache.cs` —
`InvalidateNode`
- Sessions architecture: [Sessions.md](Sessions.md)
- Spec: [OPC UA Part 5 §6.4.32 — `BaseModelChangeEventType`](https://reference.opcfoundation.org/v105/Core/docs/Part5/6.4.32/)