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 SemanticsChanged StatusCode bit on Subscriptions.

NodeOPCUA Implementation: all code references below were verified against the node-opcua-address-space package.

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 changeSemantic change
What movedThe shape: nodes or references added or deleted, DataType changedThe meaning: a Property value that defines how to interpret a value
ExampleA new Device_42 object appears under a folderEngineeringUnits flips from °C to °F; EnumStrings are edited
SignalModelChangeEvent plus NodeVersionSemanticChangeEvent plus the SemanticsChanged StatusCode bit
Client reactionRe-browse and re-map the affected subtreeRe-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:

FieldTypeMeaning
affectedNodeIdthe node that changed
affectedTypeNodeIdits TypeDefinition (if Object or Variable), else null
verbBytea bit mask of what happened

The verb bit mask:

BitValueVerbMeaning
00x01NodeAddedthe affected node was created
10x02NodeDeletedthe affected node was deleted
20x04ReferenceAddeda reference was added (affected = source or target)
30x08ReferenceDeleteda reference was deleted
40x10DataTypeChangedthe 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:

  1. NodeVersion is the opt-in switch. A node without a NodeVersion property 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.
  2. It gives clients two poll-free ways to detect change. Either subscribe to the NodeVersion property and take the DataChange when it increments ("0""1""2"…), or subscribe to GeneralModelChangeEventType on the Server and get the precise Changes array.

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, or ArrayDimensions.
  • ❌ Ordinary Value or attribute changes — those are not structural.
  • ❌ Semantic (Property value) changes — those use the SemanticsChanged path instead and explicitly do not touch NodeVersion.

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:

TestActionResult
MCEVT-1 / 2add a componentOf or organizedBy childNodeVersion "1"→"2", 2 change structures
MCEVT-3delete a childNodeVersion "2"→"3"
MCEVT-4add a referenceNodeVersion "1"→"2", 2 change structures
MCEVT-54 edits inside one modelChangeTransaction8 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_changed bumps semantic_version and 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 SemanticsChanged bit 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 GeneralModelChangeEventType and its Changes array. For semantic change it uses the SemanticsChanged StatusCode bit on subscriptions — the mechanism clients rely on in practice — rather than raising a distinct SemanticChangeEventType event.


The Server Author's Cheat Sheet

You want clients to notice…MechanismWhat you do in NodeOPCUA
A folder or device gains or loses child nodesGeneralModelChangeEventType + NodeVersionAdd nodeVersion: "0" to the container. Done.
A node's references get re-wiredsamesame — the gate is the NodeVersion on a participating node
A Variable's DataType or rank changesModelChangeEvent + NodeVersion bumphandled on the versioned node
Engineering units or enum strings changeSemanticsChanged status bituse the standard DataAccess types; automatic
Whole-view membership changesViewVersionapplies 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.