TL;DR — Announcing Address-Space Changes
Primary spec: OPC 10000-3 (Address Space Model) §9.32 ModelChangeEvents, §9.33 SemanticChangeEventType, §5.6.2 NodeVersion / SemanticChange AccessLevel bit.
Related: OPC 10000-5 (Information Model) §6.4.31–33 event type definitions and §12.16–12.17 ModelChange / SemanticChange structures · OPC 10000-4 (Services) — the
SemanticsChangedStatusCode bit on Subscriptions.NodeOPCUA Implementation: all code references below were verified against the
node-opcua-address-spacepackage.
An Address Space Is Not Frozen
A naive mental model says the OPC UA AddressSpace is built once at startup and never moves. Real servers are the opposite:
- A device manager discovers a new drive on a fieldbus and adds an object for it.
- A batch or recipe server creates and tears down transient nodes per production run.
- An ordered list — a conveyor queue, a job list — gains and loses entries continuously.
- A DataType or EngineeringUnits is reconfigured while the server is live.
A client that browsed the address space once now holds a stale map. The lazy fix is to re-Browse on a timer, but that is wasteful, slow to react, and scales terribly across hundreds of clients.
The well-designed answer: the server tells clients when the shape or meaning of the address space changes, and clients simply subscribe instead of polling.
flowchart LR
subgraph server["🖥️ OPC UA Server"]
AS["AddressSpace<br/>(nodes + references)"]
EV["Server Object<br/>(event notifier)"]
end
subgraph client["🏢 Client"]
MAP["cached browse map"]
end
AS -->|"structure changes<br/>at runtime"| EV
EV -->|"② GeneralModelChangeEvent<br/>(precise Changes[])"| MAP
MAP -.->|"❌ ① naive: re-Browse on a timer"| AS
style AS fill:#2d6a4f,color:#fff
style EV fill:#1d3557,color:#fff
style MAP fill:#6a040f,color:#fff
Two Different Kinds of "Change"
OPC UA carefully separates two distinct notions, because clients react to them differently:
| Structural change | Semantic change | |
|---|---|---|
| What moved | The shape: nodes or references added or deleted, DataType changed | The meaning: a Property value that defines how to interpret a value |
| Example | A new Device_42 object appears under a folder | EngineeringUnits flips from °C to °F; EnumStrings are edited |
| Signal | ModelChangeEvent plus NodeVersion | SemanticChangeEvent plus the SemanticsChanged StatusCode bit |
| Client reaction | Re-browse and re-map the affected subtree | Re-read the semantic Properties before trusting the value |
OPC 10000-3 §9.32.1: "ModelChangeEvents are generated to indicate a change of the AddressSpace structure. The change may consist of adding or deleting a Node or Reference... changes to the DataType Attribute of a Variable or VariableType are also considered as model changes."
The Event Type Hierarchy
flowchart TB
BASE["BaseEventType"]
BMC["BaseModelChangeEventType<br/>'something changed'<br/>no detail"]
GMC["GeneralModelChangeEventType<br/>'…and here is exactly what'<br/>+ Changes: ModelChangeStructure[]"]
SC["SemanticChangeEventType<br/>'a Property meaning changed'<br/>+ Changes: SemanticChangeStructure[]"]
BASE --> BMC
BMC --> GMC
BMC --> SC
style BASE fill:#1d3557,color:#fff
style BMC fill:#e09f3e,color:#000
style GMC fill:#2d6a4f,color:#fff
style SC fill:#6a040f,color:#fff
BaseModelChangeEventType — "something changed"
The minimal signal. It carries no detail. A client that receives it must assume the worst and re-inspect anything it cares about.
OPC 10000-3 §9.32.5: "does not contain information about the changes but only indicates that changes occurred. Therefore the Client shall assume that any or all of the Nodes may have changed."
It exists for servers that can detect that the model moved but cannot cheaply describe what moved. Any client that handles ModelChangeEvents at all must handle this base type — subtypes it does not understand should be treated as this base type.
GeneralModelChangeEventType — "…and here is exactly what"
A subtype of BaseModelChangeEventType that adds one mandatory property, Changes, of type ModelChangeStructureDataType[]. Each structure (OPC 10000-5 §12.16) carries:
| Field | Type | Meaning |
|---|---|---|
affected | NodeId | the node that changed |
affectedType | NodeId | its TypeDefinition (if Object or Variable), else null |
verb | Byte | a bit mask of what happened |
The verb bit mask:
| Bit | Value | Verb | Meaning |
|---|---|---|---|
| 0 | 0x01 | NodeAdded | the affected node was created |
| 1 | 0x02 | NodeDeleted | the affected node was deleted |
| 2 | 0x04 | ReferenceAdded | a reference was added (affected = source or target) |
| 3 | 0x08 | ReferenceDeleted | a reference was deleted |
| 4 | 0x10 | DataTypeChanged | the DataType attribute of a Variable or VariableType changed |
Because Changes is an array, one event can carry a whole batch of edits — this enables event compression (below). A bidirectional reference add yields two structures, one per endpoint.
SemanticChangeEventType — "a Property's meaning changed"
Also a subtype of BaseModelChangeEventType; its Changes array holds SemanticChangeStructureDataType. It fires when the shape is untouched but the meaning of a value changed — EngineeringUnits, EURange, EnumStrings. Which Properties qualify is gated by the SemanticChange bit (bit 4) of the Property's AccessLevel attribute.
OPC 10000-3 §9.33: "SemanticChangeEvents ... are generated to indicate a change of the AddressSpace semantics. The change consists of a change to the Value Attribute of a Property. ... The ViewVersion and NodeVersion Properties do not change due to the publication of a SemanticChangeEvent."
NodeVersion — The Linchpin
NodeVersion is an optional String property you attach to any Object, Variable, or Type node. The spec binds it to ModelChangeEvents with an iron rule (OPC 10000-3 §9.32.2):
"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."
Two consequences dominate real-world design:
NodeVersionis the opt-in switch. A node without aNodeVersionproperty is deliberately silent — no version bump, no ModelChangeEvent. This is intentional: you must not flood clients with an event for every leaf in a huge address space. Annotate only the container nodes whose membership clients are meant to watch — a device folder, an ordered list, a dynamic set.- It gives clients two poll-free ways to detect change. Either subscribe to the
NodeVersionproperty and take theDataChangewhen it increments ("0"→"1"→"2"…), or subscribe toGeneralModelChangeEventTypeon the Server and get the preciseChangesarray.
What does and does not bump NodeVersion:
- ✅ A reference added or deleted on the node.
- ✅ On a Variable or VariableType only: a change to
DataType,ValueRank, orArrayDimensions. - ❌ Ordinary Value or attribute changes — those are not structural.
- ❌ Semantic (Property value) changes — those use the
SemanticsChangedpath instead and explicitly do not touchNodeVersion.
Related counter — ViewVersion (UInt32): the same idea for the content of a View. It updates even when nodes not directly referenced by the View node are added to or removed from the View.
Event Compression — Why Transactions Matter
Servers are explicitly allowed to group many structural edits into a single event with a fat Changes array, bumping each participating node's version once.
OPC 10000-3 §9.32.4: "An OPC UA Server may be capable of grouping a series of transactions or simple updates into a larger unit... A single ModelChangeEvent may be issued after the last change of the series, to cover all of the changes. This is referred to as Event compression."
flowchart LR
subgraph txn["modelChangeTransaction"]
A["addObject A"]
B["addObject B"]
C["addObject C"]
R["addReference"]
end
txn --> COMPRESS["dedupe affected nodes<br/>bump NodeVersion once<br/>collect all Changes"]
COMPRESS --> ONE["ONE GeneralModelChangeEvent<br/>Changes[] = all edits"]
style txn fill:#1d3557,color:#fff
style COMPRESS fill:#e09f3e,color:#000
style ONE fill:#2d6a4f,color:#fff
How NodeOPCUA Implements All of This
Claim under test: "node-opcua does it all automatically and seamlessly — you just add a NodeVersion node to enable the system." ✅ Confirmed. The evidence follows.
Adding a NodeVersion is a one-liner
Pass nodeVersion to addObject or addVariable and the property is created for you (namespace_impl.ts → _create_node_version_if_needed):
const set = namespace.addObject({
browseName: "DynamicDeviceSet",
nodeVersion: "0" // this is the entire opt-in
});
The NodeVersion presence is the gate
When a child is added, _handle_model_change_event (address_space_change_event_tools.ts) walks up to the parents and containing folders and only acts if a parent has a NodeVersion:
for (const parent of [...parents, ...containingFolders]) {
if (parent?.getNodeVersion()) { // no NodeVersion means total silence
addressSpace.modelChangeTransaction(() => {
addressSpace._collectModelChange(null, new ModelChangeStructureDataType({
affected: node.nodeId,
affectedType: typeDefinitionNodeId,
verb: makeVerb("NodeAdded")
}));
// plus a ReferenceAdded structure for the parent, bidirectional if needed
});
}
}
Deletion (_handle_delete_node_model_change_event) and reference-add (_handle_add_reference_change_event) follow the same gate: no versioned node involved means no event — exactly the spec's "only nodes having a NodeVersion shall trigger a ModelChangeEvent." These handlers are wired straight into the normal add and delete paths in namespace_impl.ts; you never call them yourself. That is the seamless part. The verb flags are defined to match the spec bit for bit.
The transaction batches, bumps versions once, fires one event
modelChangeTransaction in address_space.ts is the compression engine. On the outermost commit it:
const nodeIds = [...new Set(this._modelChanges.map((c) => c.affected))]; // dedupe
nodes.forEach(_increase_version_number); // bump each once
this.rootFolder.objects.server.raiseEvent(
this.findEventType("GeneralModelChangeEventType"), {
changes: { dataType: ExtensionObject, arrayType: Array, value: this._modelChanges }
}); // one event, full array
_increase_version_number parses the NodeVersion string, adds 1, and writes it back. The event is raised on the Server object — the default View, i.e. the whole-address-space context.
The test suite (test_address_space_model_change_event.ts) pins the behaviour:
| Test | Action | Result |
|---|---|---|
| MCEVT-1 / 2 | add a componentOf or organizedBy child | NodeVersion "1"→"2", 2 change structures |
| MCEVT-3 | delete a child | NodeVersion "2"→"3" |
| MCEVT-4 | add a reference | NodeVersion "1"→"2", 2 change structures |
| MCEVT-5 | 4 edits inside one modelChangeTransaction | 8 structures collected, version bumps by only 1 — compression |
Semantic change is surfaced via the SemanticsChanged StatusCode bit
NodeOPCUA implements the semantic side through the subscription status-code path — the mechanism clients actually consume on a monitored variable:
UAVariableImpl.handle_semantic_changedbumpssemantic_versionand emits"semantic_changed".- It is auto-attached for the standard semantic-bearing DataAccess types: multi-state discrete, two-state discrete, and analog EU/range.
- The server's monitored item detects the version delta and sets the
SemanticsChangedbit on the next notification:
const hasSemanticChanged = (this.node).semantic_version !== this._semantic_version;
if (hasSemanticChanged) {
setSemanticChangeBit(dataValue); // client sees SemanticsChanged in the StatusCode
this._enqueue_value(dataValue); // forced through even if the value itself is unchanged
}
A client seeing SemanticsChanged re-reads the semantic Properties — units, ranges, enum strings — before trusting the value, exactly the §9.33 guidance.
Honest caveat, so as not to over-claim: NodeOPCUA signals structural change with the full
GeneralModelChangeEventTypeand its Changes array. For semantic change it uses theSemanticsChangedStatusCode bit on subscriptions — the mechanism clients rely on in practice — rather than raising a distinctSemanticChangeEventTypeevent.
The Server Author's Cheat Sheet
| You want clients to notice… | Mechanism | What you do in NodeOPCUA |
|---|---|---|
| A folder or device gains or loses child nodes | GeneralModelChangeEventType + NodeVersion | Add nodeVersion: "0" to the container. Done. |
| A node's references get re-wired | same | same — the gate is the NodeVersion on a participating node |
| A Variable's DataType or rank changes | ModelChangeEvent + NodeVersion bump | handled on the versioned node |
| Engineering units or enum strings change | SemanticsChanged status bit | use the standard DataAccess types; automatic |
| Whole-view membership changes | ViewVersion | applies to View nodes |
The single most important takeaway: in a well-designed NodeOPCUA server you do not write event-raising code. You declare intent by attaching a NodeVersion property to the nodes whose structure clients should watch. From there NodeOPCUA automatically increments versions, batches concurrent edits into compressed events, and emits a spec-compliant GeneralModelChangeEventType — while leaving unversioned nodes silent so clients are never drowned in noise.
// The complete recipe:
const set = namespace.addObject({ browseName: "DynamicDeviceSet", nodeVersion: "0" });
// ...later, at runtime — no event code needed:
namespace.addObject({ browseName: "Device_42", componentOf: set });
// => set.NodeVersion "0" -> "1", and a GeneralModelChangeEvent fires on the Server object:
// { affected: Device_42, affectedType: <type>, verb: NodeAdded }
// { affected: set, affectedType: null, verb: ReferenceAdded }
Client side: subscribe to GeneralModelChangeEventType events on the Server object, or monitor the NodeVersion property, and re-browse the affected subtree when notified — no polling loop required.
TL;DR compiled from OPC 10000-3 §9.32–9.33 and §5.6.2, OPC 10000-5 §6.4.31–33 and §12.16–12.17, and OPC 10000-4. For normative wording always consult the official specifications.