# Description
A live OPC UA compliance audit of `samples/PumpDeviceIntegrationServer`
against the **OPC 40223 Pumps** and **OPC 10000-100 (DI)** companion
specifications found **13 address-space violations** plus a
serialization bug in the MCP tool. Every fix lands at its **root cause
in the shared stack** rather than as a workaround in the sample, so all
servers benefit.
The sample now exposes **N fully simulated pumps** (`--pumps N`) and
advertises the DI conformance facets it actually satisfies.
> This branch has been merged with `master` including **#4116** ("Add
simulation to PumpDeviceIntegrationServer and Event modelling"), which
independently reworked the same pump simulation and the same fluent
alarm builders. See [Relationship to #4116](#relationship-to-4116) below
for how the two were reconciled.
## Findings and fixes
| # | Finding | Root cause | Fix |
|---|---------|-----------|-----|
| 1 | `OverTempAlarm` created by the fluent API did not exist in the
address space | `AttachAlarm` attached the alarm with `AddChild` but
never indexed it into the node manager | Register the node; the same gap
existed in `AddObject`, `CreateInstance` and the state-machine creators
|
| 2 | Alarms were unsubscribable — `EventNotifier = 0` everywhere, no
`HasNotifier` from `Server` | No event-source wiring at all | Set
`HasCondition`, initialise
`SourceNode`/`SourceName`/`ConditionName`/`InputNode`, promote
`EventNotifier` up the ancestor chain, publish `HasNotifier` from the
`Server` object |
| 3 | Every instance NodeId was minted in the **DI standard namespace**
(`ns=4;s=5001_Pump #1`) | `DiNodeManager.New` inherited the parent's
namespace index | New `DiNodeManager.InstanceNamespaceIndex`; instances
move to the server's application namespace |
| 4 | `Server/Namespaces` described only 2 of 6 namespaces | The
NodeSet2 importer drops nodes parented to namespace 0, so companion-spec
metadata objects never reached the address space — and no route covered
the server's own namespace | New `NamespaceMetadataPublisher` walks the
`NamespaceArray` and fills version/publication date from
`ModelDependencyAttribute` |
| 5 | Vendor BrowseNames (`Diagnostics`, `LastError`, …) sat in the DI
namespace | Builders defaulted to the parent's namespace | Default to
the server namespace; **spec-defined BrowseNames are unchanged** |
| 6 | `AccessLevel="5"` from the Pumps NodeSet was reported as `1` | The
ModelDesign `AccessLevel` enum is not `[Flags]` and cannot represent
`CurrentRead \| HistoryRead` | Carry the verbatim NodeSet2 bitmask and
emit named `AccessLevels` constants |
| 7 | `Historizing = true` while `HistoryRead` returned
`BadHistoryOperationUnsupported` | Nothing reconciled the declared
history surface with the wired historians | Startup clears the
advertisement (including masking the attribute read callbacks) when no
provider resolves; the sample wires a real historian |
| 8 | Mandatory `ProductInstanceUri` unset | Sample omission | Populated
on every pump |
| 9 | Mandatory `TrueState`/`FalseState` empty on every supervision
boolean | Absent in the official NodeSet; a server must still populate
them | Populated on every pump |
| 10 | Machinery `Machines` folder empty (OPC 40001-1 §9.2 says
**shall**) | `TryAddToMachinesFolder` used `AddChild`, which
*re-parents* the device away from `DeviceSet` | Add the `Organizes`
reference instead |
| 11 | BrowseName contained a space and `#` | Sample naming | `Pump_1`
with the readable label in `DisplayName` |
| 12 | `Maintenance` group materialised but empty | Sample omission |
Populated |
| 13 | Uninitialised values reported `Good` | No initial-value status |
`BadWaitingForInitialData` until the first simulation tick |
| — | `ServerProfileArray` advertised only `StandardUA2017` |
`DiNodeManager` did not implement the existing `IConformanceContributor`
| Contribute DI 1.05 server facets computed at runtime from what is
actually wired, merged with `StandardUA2017` |
| — | MCP tool serialised boolean `false` and numeric `0` as JSON `null`
| `Variant.Null` is `default`, so `Variant.Equals` adopts the non-null
type and compares against the default value | Dispatch on `BuiltInType`
and use typed `TryGetValue` accessors instead of the prohibited
`AsBoxedObject` |
## Relationship to #4116
#4116 landed on `master` while this branch was in flight and targeted
overlapping ground. The merge is a **union of both intents**, not a
fast-forward:
- **`AlarmBuilderExtensions`** — kept both sides. #4116 contributes the
parent-must-be-an-Object guard, `SetEnableState`, and the
`HasEventSource` references; this branch contributes the `HasCondition`
reference, condition source initialisation, **registration of the alarm
with the node manager so it is browsable**, notifier promotion up the
whole ancestor chain, and root-notifier registration.
- **`SupervisionBuilderExtensions`** — took #4116's `SetAlarmActive`. It
is more spec-correct than this branch's version: it honours
`EnabledState`, resets Acked/Confirmed on activation, and computes
`Retain` per OPC 10000-9 so an unacknowledged Condition stays retained
after going inactive. This branch's null guard was kept so a malformed
alarm cannot dereference a missing `EnabledState`.
- **`PumpNodeManager.Configure.cs`** — took #4116's **push-based**
simulation (`RegisterPumpSimulation`, `CreatePumpSimulation`,
`IValueUpdater<T>`, `Initialize`/`Advance`/`Publish`, deterministic
per-pump phase offsets) as the structure, then folded in this branch's
compliance work: configurable pump count, Maintenance group,
`TrueState`/`FalseState`, historian wiring, per-pump mandatory
Identification values, and `BadWaitingForInitialData` until the first
tick.
- **README** — union, keeping #4116's address-space validation workflow
section.
Two test adjustments were needed, both because the merge legitimately
changed behaviour the tests pinned:
- `PumpHostedReferenceTests` now resolves pump NodeIds from the browse
result instead of hard-coding the DI namespace, and waits for the first
published value. `PumpCreatedAfterStartupJoinsTheLiveSimulationAsync`
was **added** to restore #4116's coverage that a pump created after
startup through `ConfigureDevicesFor` joins the live simulation.
- `FluentAlarmRegistrationIntegrationTests` no longer asserts that
`Retain` clears when an alarm goes inactive — that pinned this branch's
weaker semantics, and #4116's Part 9 behaviour is correct.
## Breaking change
Runtime-created DI device instance NodeIds move from the companion-spec
namespace to the server's application namespace, e.g.
`ns=<DI>;s=5001_Pump #1` becomes `ns=<application>;s=5001_Pump #1`. This
invalidates NodeIds cached by clients from earlier 2.0 previews. It is
**not** a model change — standard DI/Machinery/Pumps type NodeIds and
spec-defined BrowseNames are untouched.
Device integration hosting is new in 2.0, so there is nothing to migrate
from 1.5.378 and this is deliberately *not* in `docs/MigrationGuide.md`.
Anyone tracking 2.0 previews should rediscover devices by browsing
`DeviceSet` rather than persisting NodeIds, and resolve namespace
indexes from `NamespaceArray` per connection.
## Verification
Compliance is pinned by regression tests that assert **through the
server's service surface** (`BrowseAsync` / `ReadAsync` /
`HistoryReadAsync`), not by inspecting `NodeState` objects in memory.
That distinction mattered: three defects survived in-memory assertions
and were only caught through the read path — most subtly, `Historizing`
was cleared while `AccessLevel` still advertised `HistoryRead`, because
attribute read callbacks re-applied the NodeSet bits.
Post-merge on net10.0:
| Suite | Result |
|---|---|
| `Opc.Ua.Di.Tests` | 326 passed |
| `Opc.Ua.Server.Tests` | 3846 passed, 5 skipped |
| `Opc.Ua.Tools.Tests` | 288 passed |
| `Opc.Ua.History.Tests` | 506 passed, 27 skipped |
| `Opc.Ua.SourceGeneration.Core.Tests` | 3722 passed, 8 skipped |
The full solution was also built and tested on **net48** before the
merge.
Review feedback from `copilot-pull-request-reviewer` has been addressed
in commit `badad31dc`: the shared `ServerObjectState` lock was removed
(the forward `HasNotifier` edge is published through the owning node
manager instead), MCP array serialization now preserves element types,
and the pump option guards report the specific rejected option. The only
failure is
`ConfigureApplicationBuildsSharedClientAndServerConfigurationAsync`, a
pre-existing certificate-store issue that **passes when run in
isolation** and is unrelated to this change.
## Documentation
Updated `docs/DeviceIntegration.md`, `docs/MigrationGuide.md`,
`docs/HistoricalAccess.md`, `docs/DeveloperGuide.md`,
`docs/McpServer.md`, `docs/README.md`,
`samples/PumpDeviceIntegrationServer/README.md` and
`tools/Opc.Ua.Mcp/README.md`, and consolidated the four node-manager
documents into a single [`docs/NodeManagers.md`](docs/NodeManagers.md) —
overview, built-in managers (including `MasterNodeManager`), core vs
custom, registration, server address-space metadata, and source
generation — with a TOC and all inbound links repointed.
## Related Issues
No tracking issue exists for this work yet — it originated from an
ad-hoc compliance audit of the pump sample. Given the size of the change
and the runtime-breaking NodeId move, please open (or link) a tracking
issue so the design is recorded as an ADR before merging.
- Fixes #
## 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.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [x] 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.
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0d50c3fc-58b1-4c99-85a4-1e32c88666d9
22 KiB
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
- Server side: emitting model changes
- Client side: tracking model changes
- NodeVersion correlation
- Security considerations
- 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() |
| 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,AddNodeAsyncandDeleteNodeAsyncalso keep the node manager's internal component cache in sync with the change. A deleted node is evicted from the cache, and the parent's cached view is refreshed after a runtime add or remove, so a Browse, Read or Call issued afterwards reflects the committed child set instead of a stale, cached view. Re-registering or replacing a node id (for example swapping a passive child for a typed proxy) refreshes the cached instance only when that node is already cached.
If your node manager mutates the address space without going through
CreateNodeAsync / DeleteNodeAsync (for example by editing an
in-memory NodeStateCollection), you can either:
-
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 } } -
Leave auto-emit on and call the aggregator +
EmitModelChangefrom custom mutation paths. EveryCreateNode/DeleteNodecontinues 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:
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. Given an INamespaceTableRefresher it
re-reads the namespace table before it invalidates the cache and before
it raises ModelChanged, so subscribers that re-browse from the handler
already see 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 5 §9.32.2 ties ModelChangeEvent emission to the
NodeVersion property:
"Every time a
ModelChangeEventis issued for a Node, itsNodeVersionshall be changed, and every time theNodeVersionis changed, aModelChangeEventshall be generated. A Server shall support both theModelChangeEventand theNodeVersionProperty or neither, but never only one of the two mechanisms.""Only those Nodes of the AddressSpace having a
NodeVersionshall trigger aModelChangeEvent. Other Nodes shall not trigger aModelChangeEvent."
The framework enforces both halves:
-
Suppression.
AsyncCustomNodeManager.RaiseGeneralModelChangeEventfilters theChanges[]array, dropping every entry whoseAffectednode is owned by the node manager and does not carry aNodeVersionproperty. When the filtered array is empty no event is raised at all. Entries whoseAffectednode belongs to a different node manager pass through unchanged (the local manager cannot inspect the foreign node's state — this is documented behaviour). -
Bumping. Every Affected node that survives the filter has its
NodeVersionincremented as a decimalulongstring ("1"→"2"→ …) before theGeneralModelChangeEventis reported. The increment is wrapped in anAsyncLocalguard so the OnWriteValue handler installed byEnableModelChangeTrackingFordoes not echo the framework bump as aBaseModelChangeEvent. -
External writes. When
EnableModelChangeTrackingFor(or the underlyingNodeState.EnableModelChangeTrackingextension) attaches aNodeVersionit also wiresOnWriteValueso a write from an external client (or aWriteservice call) raises aBaseModelChangeEvent(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
DefaultRolePermissionsorDefaultUserRolePermissionschange, - the notifier's own
RolePermissionschange (signaled viaNodeStateChangeMasks.RolePermissionsto 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:
- Emit on a private View. Build a
ViewStatethat contains only the public subset of the address space, give it a distinctSourceNode, and callRaiseGeneralModelChangeEventwith that view as the source. Subscribers withoutReceiveEventson the private view's source node will not receive the event at all. - Split sensitive subtrees into a dedicated
AsyncCustomNodeManager. SetModelChangeEmissionEnabled = falseon that manager and emit model-change events on a notifier that is itself protected byReceiveEvents. Public node managers continue to auto-emit onObjects.Serveras normal. - Drive emission manually with
ModelChangeAggregator. Inspect the pending entries before callingRaiseGeneralModelChangeEventand 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 inAsyncCustomNodeManager) - 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