Follow-up to #4128, which merged the WoT Connectivity 1.1 work against **draft2** of the companion specifications. Both drafts have moved since, and this branch tracks them through to **draft3**, adds the projection feature they introduced, and closes the gaps a bidirectional re-audit found. The specifications are the private [`OPCF-Members/spec-drafts`](https://github.com/OPCF-Members/spec-drafts) drafts of *OPC UA — WoT Connectivity 1.1* and *OPC UA — WoT Binding 1.1*. They are still drafts, which is why this is opened as a draft PR — see [Status](#status) at the bottom. ## What changed in the specifications Two revisions landed upstream while #4128 was in flight. **draft2** replaced the `eventGroup` / `actionGroup` grouping constructs with a single **projection / view** concept, added fifteen Section 6 vocabulary terms, and made two identifier rules normative. **draft3** ([spec PR #8](https://github.com/OPCF-Members/spec-drafts/pull/8)) un-deprecated the incorporated OPC 10100-1 v1.02 surface, restructured the conformance clause into three profiles, and tightened two security obligations. The draft3 NodeSet is **structurally identical** to draft2 — I diffed all 286 nodes node-by-node. Every change is metadata: 96 `ReleaseStatus="Deprecated"` attributes removed, `PublicationDate` advanced, and `WOTC-ProjectionMaterialization` claimed by three Type Nodes that previously implemented it in name only. ## Projections (WoT Binding Section 12) The substantial addition. A projection document selects affordances from other Thing Descriptions or Thing Models and resolves to a document of its own; materialized, it becomes an OPC UA `View` that **creates no affordance Node** and only `Organizes` what its sources already materialized. | Piece | Where | |---|---| | Document model and validation | `Opc.Ua.Types/Wot/WotProjection.cs`, `WotProjectionModel.cs` | | Four-stage resolution engine | `Opc.Ua.Types/Wot/WotProjectionResolver.cs` | | Materialization plan | `Opc.Ua.WotCon.Server/Materialization/WotViewProjectionPlan.cs`, `WotProjectionViewBuilder.cs` | | Live address-space host | `WotProjectionViewNodeManager.cs`, `LifecycleWotViewProjectionHost.cs`, `IWotViewProjectionHost.cs` | | Node lookup seam | `IWotMaterializedNodeIndex.cs` | Groups reached by `ua:Organizes` become organizational Objects rather than nested Views, only the outermost materialization is a `View`, and a member whose source is served by another server is omitted and reported rather than recreated — the federation case the clause calls out. ### How this is validated The specification publishes twenty worked examples, two of which are a **golden pair**: a projection document and the resolved view it is defined to produce. `WotSpecExampleTests` embeds all twenty and runs the pair through the resolver, asserting against *the specification's own expected output* rather than against my reading of the prose. That single document exercises all three selection forms, the bulk naming rule, the security closure naming and the provenance term. This is the strongest evidence in the PR, and it is worth more than the unit tests around it: it is the same check the upstream `validate_local.py` performs. ## Other spec work - **Section 6 vocabulary** — fifteen missing model and platform terms with their validation rules (`WotNodeSetConverter.ModelVocabulary.cs`). - **Portable identifiers** — the two rules release 1.1 made normative, with `WotNodeSetConverterOptions.AllowNonPortableIdentifiers` as a documented downgrade-to-warning switch for documents authored against v1.00. Defaults to `false`. - **xRegistry §6.9 identifier construction** (`XRegistryIdentifier.cs`) — reverse-authority symbolic identifiers; all seven published worked examples are test cases. ## Gaps found by re-auditing against draft3 I audited in both directions — every normative requirement to code, and every `uav:` term in code back to a spec that defines it. Three findings, all fixed: 1. **`Wot-Con 1.02` accepted anything well-formed.** draft3 makes format validation part of the unit, because *WoT-Con Minimal* is one unit wide and materialization projects a *valid* document. The 1.02 upload path only deserialized into the `ThingDescription` POCO, which happily accepts JSON that is not a WoT document at all — every member of that type is optional, so `{}` deserialized and was materialized. It now parses via `WotDocument.Parse` and requires the document to identify itself by a non-empty `name` or `title`, returning `Bad_DecodingError` and materializing nothing otherwise. The rule lives in `ThingDescriptionFormatValidator` and is shared with `CreateAssetForEndpoint`, whose auto-generated Thing Descriptions §11 also treats as untrusted input. 2. **Two `WoTFile` operations were ungated.** draft3 §11 names `Write` and `CloseAndUpdate` alongside the five management methods; both reached the materializer without an access check. `EnforceManagementAccess` is now wired into `WotAssetFileManager`. 3. **Two invented `uav:` terms.** `uav:NodeSet2Preservation` was declared and never used — removed. `uav:eventFields` is read by `OpcUaBindingPlanner` and defined in *neither* spec, while a comment claimed it came from OPC 10101. It still works for compatibility but is now documented as a non-standard extension at both sites rather than misattributed. The SSRF prohibition is satisfied by construction: there is no HTTP client anywhere in these assemblies, and all resolution goes through the injected `IWotThingResolver`. ## Two ambiguities fixed in the specifications themselves The audit turned up two places where a conforming implementer had to invent an answer. Rather than pick silently, I took both upstream — they are pushed to [spec-drafts PR #8](https://github.com/OPCF-Members/spec-drafts/pull/8) (all checks green) and are now normative in *Binding* §12.6 and *Connectivity* §7.13: - **`ViewVersion`** had one sentence saying only *that* it changes. It now changes iff the resolved membership changes, is updated at commit time alongside the `NodeVersion` stamping, and is a deterministic function of the membership alone in a canonical order — so two servers that resolved the same membership agree, which a per-server counter would not across a redundant pair. Explicitly **not monotonic**: compare for inequality only. - **"Type-level view"** was used once in §12.2 and defined nowhere, while every other statement of materialization was universal. §12.6 now *defines* it rather than contradicting §12.2: the NodeClass is `View` in both cases. Both were already what this code did, so neither changed behaviour — but the order-insensitivity the clause added was **not** covered by any test, and `ViewVersionIsUnchangedWhenOnlyTheOrderOfTheMembershipChanges` now holds it. It is mutation-verified: removing the ordinal sort turns it red. `docs/WotBindings.md` no longer lists these as points "the specification leaves open". ## Validation | Suite | Result | |---|---| | `Opc.Ua.Types.Tests` | 8525 ✅ (8518 on net48) | | `Opc.Ua.WotCon.Tests` | 1057 ✅ (net10.0 and net48) | | `Opc.Ua.WotCon.Bindings.Tests` | 559 ✅ | | `Opc.Ua.XRegistry.Tests` | 196 ✅ | | `Opc.Ua.Server.Tests` | 4027 ✅ | | `Opc.Ua.SourceGeneration.Core.Tests` | 3771 ✅ | | `Opc.Ua.WotCon.Samples.Tests` | 9 ✅ | The full `UA.slnx` builds with **0 errors and 0 warnings attributable to this PR** on all 6 TFMs (`TreatWarningsAsErrors` is on). `Opc.Ua.Server.Tests` and the source-generation suites are listed because this PR now also touches `MasterNodeManager` and the WoT-Con NodeSet. ## Compatibility No public API is removed or broken. Everything added is new surface using `ArrayOf<T>` / `ByteString` per the repo conventions, wired for DI with a direct-construction fallback, and the one behavioural tightening (`Wot-Con 1.02` format validation) rejects input that was never valid to begin with. One pre-existing issue is **not** addressed here because it came in with #4128 and is out of scope: `WotAssetFileManager.OnCloseAndUpdate` uses `.GetAwaiter().GetResult()`, which the repo rules ban. Worth a follow-up. ## Landed since this PR was opened - **DI upgraded to 1.05.0.** `ConnectsTo` was declared a subtype of `HierarchicalReferences`, contradicting OPC 10000-100 §5.5 Table 48. That is an upstream error in every official DI NodeSet up to 1.04, corrected by the OPC Foundation in 1.05.0, so the fixture is replaced with the verbatim 1.05.0 file rather than hand-patched. A guard test pins the corrected form. This also closed a second gap: six DI types the WoT model was missing are 1.05 additions. - **The sample Pump has the type definition OPC 40223 declares for it.** `Pump1.Identification` carried DI's `FunctionalGroupType` instead of `PumpIdentificationType`. Every test, the sample documents and the README agreed with each other about it, which is why it survived. - **`WotPumpAddressSpaceComparisonTests`** compares this server against `PumpDeviceIntegrationServer`, which builds the same OPC 40223 Pump from generated NodeSets and hand-written wiring. An independent oracle rather than a restatement of the same assumptions - it is what found the defect above. - **Error containment in `MaterializeProjectionViewsAsync`.** An exception mid-loop discarded the applied View handles before they were published, leaving View Nodes in the address space that nothing could remove. - **`HasWoTProjection`** is now created; it was documented and plumbed but never wired. - **The default in-memory View host** no longer deletes the View it has just applied. - **An undeclared NodeSet alias now says so.** It previously surfaced as `IdentifierMissing`, naming neither the value nor the fact that an alias was expected. - **`ViewVersion` follows the algorithm the specification now states.** My earlier spec text was rejected on review, correctly: it named neither the function nor the canonical order it demanded, ignored that OPC 10000-3 §5.4 requires a value greater than zero, and its "when, and only when" is unsatisfiable for a 32-bit value. §12.6 now specifies the algorithm outright and this implementation follows it exactly, pinned by an independently computed test vector. Implementing it surfaced a further defect in the clause - joining members on U+000A is not injective, because a NodeId string identifier may itself contain U+000A - which is fixed in the specification by length-prefixing each member. - **An auto-generated Thing Description is treated as untrusted input.** §11 requires it; `CreateAssetForEndpoint` validated the endpoint but handed whatever the discovery provider returned straight to the materializer. The `Wot-Con 1.02` format rule now gates both paths from one place. - **Document bytes moved out of the registry snapshot** into a content-addressed store, so a commit no longer rewrites the whole corpus to edit one document. Writes stage and are promoted during the commit, which preserves both durability-first ordering and the fail-closed rule that a blob directory without a manifest means operator recovery. Restoring streaming digest verification along the way closed a real gap: content integrity had degenerated to a length comparison. - **External references now reach a NodeManager registered later.** A reference to a Node that does not exist yet is dropped rather than queued, and the dynamic-registration path had no second phase to apply them in, so registering a NodeSet before the one it points into lost the forward edge permanently. Review of that change then found a state leak in its rollback path, fixed with a red-to-green regression test. - **`WOTC-Legacy` is renamed `Wot-Con 1.02`** in the specification and here. Editorial: the NodeSet diff is `<Category>` values only. ## Status Open for review. Two things may still move: 1. **The specifications are unpublished drafts.** Anything here can change if the working group decides differently. The `ViewVersion` definition has since been settled - §12.6 specifies the algorithm and this follows it - but the same could happen to any other clause tracked here. 2. **`uav:eventFields`** is kept as a documented non-standard extension. That is a judgement call the WG may want to reverse, either by adopting the term or removing it. Two known items are recorded rather than fixed: - `Pump1` has no hierarchical parent, so it is reachable by NodeId but not by browsing down from `Objects`. The cross-NodeSet ordering defect this was originally attributed to is fixed above, and cross-manager references are now symmetric in both registration orders. The Pump itself is still blocked on a different cause found while verifying that fix: with the reference added, `DeviceSet` exists but the Pump has **zero** inverse references, so the edge is dropped at *import* - before any external-reference machinery runs. That is a separate defect in the materialization path, not the one that was fixed, and it is left recorded rather than guessed at. - `WotAssetFileManager.OnCloseAndUpdate` uses `.GetAwaiter().GetResult()`, which the repo rules ban. Pre-existing from #4128 and out of scope here. Review of the projection resolver and the materialization host is the most valuable thing to look at; the vocabulary and identifier commits are mechanical by comparison. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
15 KiB
Runtime NodeSets
This guide explains how to load one or more NodeSet2 XML documents into the server's address space at startup without writing a source-generated or hand-coded NodeManager. You configure which files or streams to load; the server imports them in dependency order and registers the resulting nodes.
When to use the runtime NodeSet path
Use AddRuntimeNodeSet when:
- You receive a NodeSet2 XML from a companion-specification vendor and want to host its nodes without regenerating source.
- Your information model changes frequently enough that rebuilding the source-generated manager for every XML update would be disruptive.
- You are prototyping or testing a new NodeSet2 design.
Use the source-generated path when you want compile-time safety, strong typing, and AOT-safe named constants for every node in your model. The runtime path gives you generic NodeState objects and untyped browse-path wiring.
Startup and live lifecycle semantics
AddRuntimeNodeSet on IOpcUaServerBuilder remains the startup path: its factory is created before the server starts and its NodeSet is imported during CreateAddressSpaceAsync.
Running servers also expose INodeManagerLifecycle. Resolve it from dependency injection in a hosted server, or use StandardServer.NodeManagerLifecycle when constructing the server directly. The lifecycle provider can add, reload, shadow-reload, and remove runtime NodeSets without restarting the server.
public sealed class ModelLoader(INodeManagerLifecycle lifecycle)
{
private NodeManagerRegistration? m_registration;
public async ValueTask LoadAsync(CancellationToken ct)
{
m_registration = await lifecycle.AddRuntimeNodeSetAsync(
new RuntimeNodeSetOptions
{
Sources = [RuntimeNodeSetSource.FromFile("Models/MyMachine.NodeSet2.xml")]
},
callerContext: null,
ct);
}
public async ValueTask ReloadAsync(CancellationToken ct)
{
m_registration = await lifecycle.ReloadRuntimeNodeSetAsync(
m_registration!,
new RuntimeNodeSetOptions
{
Sources = [RuntimeNodeSetSource.FromFile("Models/MyMachine.NodeSet2.xml")]
},
callerContext: null,
ct);
}
public ValueTask RemoveAsync(CancellationToken ct)
{
return lifecycle.RemoveAsync(m_registration!, callerContext: null, ct);
}
}
Each add returns an immutable NodeManagerRegistration, and reload returns the next generation while invalidating the previous handle.
AddRuntimeNodeSetAsync, ReloadRuntimeNodeSetAsync, and RemoveAsync take the operation the caller is running under. Pass context.GetOperationContext() when calling from a NodeManager or Method callback: a lifecycle operation drains the requests that are in flight, so one started from inside a request would wait for itself and is rejected with an InvalidOperationException. A control-plane caller such as the ModelLoader above is not serving a request and passes null. See Registering NodeManagers.
The rules that apply to every NodeManager registered at runtime -- what happens to MonitoredItems, Browse continuation points, namespace indexes, DataTypes, and change notifications, and which NodeManagers may be reloaded at all -- are described once in Registering NodeManagers. Runtime NodeSets follow those rules, and the built-in runtime NodeSet manager already implements the INodeManagerReloadParticipant contract that reload requires.
Shadow reload
ShadowReloadRuntimeNodeSetAsync (backed by INodeManagerLifecycle.ShadowReloadAsync) replaces a live registration the same way ReloadRuntimeNodeSetAsync does, but without the active-monitored-item guard:
public async ValueTask ShadowReloadAsync(CancellationToken ct)
{
m_registration = await lifecycle.ShadowReloadRuntimeNodeSetAsync(
m_registration!,
new RuntimeNodeSetOptions
{
Sources = [RuntimeNodeSetSource.FromFile("Models/MyMachine.NodeSet2.xml")]
},
ct);
}
The replacement generation is prepared and published through the same transactional prepare/publish/commit/rollback path as ReloadAsync, so a failure during preparation, publication, or the routing switch leaves the current generation fully active and cleans up the replacement, exactly as a normal reload does. Once committed, every new service request is atomically routed to the replacement generation, including for namespaces the current and replacement generations share.
The current generation is not torn down immediately. It is moved to the same retired-generation bookkeeping used for an ordinary reload, but its existing monitored items and any request or continuation point that already captured it keep being served by it, unaffected by the routing switch. The retired generation is disposed automatically, without deleting any client subscription, once its monitored items and in-flight state drain; a later lifecycle operation (or shutdown) opportunistically retries that cleanup until it succeeds. ShadowReloadAsync returns the replacement NodeManagerRegistration immediately and invalidates the current handle for further lifecycle mutations, the same as ReloadAsync.
Use ShadowReloadAsync when a model update must take effect for new requests without waiting for existing subscriptions to unsubscribe first; use the fail-closed ReloadAsync when a stale generation must never remain reachable, even briefly, for already-open monitored items.
Immediate reload
ImmediateReloadRuntimeNodeSetAsync (backed by INodeManagerLifecycle.ImmediateReloadAsync) performs the same atomic replacement but does not retain the previous generation until monitored items drain. After requests that already captured the old routing generation finish, every affected data-change monitored item is made publishable with BadNodeIdUnknown, event monitored items stop producing events, continuation points are invalidated, and the old NodeManager is disposed. The subscription and monitored-item records remain available so clients can receive the status and delete or recreate the affected items.
Use immediate reload only when continuity through the previous generation is not required. Durable monitored items are not eligible for immediate retirement because their terminal state would have to survive restart; choose shadow reload for any generation that owns them.
Quick-start examples
Single file
services.AddOpcUa()
.AddServer(o => { /* … */ })
.AddRuntimeNodeSet("Models/MyMachine.NodeSet2.xml");
Single file with a fluent callback
Wire read/write/method handlers on top of the imported nodes using the existing untyped INodeManagerBuilder surface:
services.AddOpcUa()
.AddServer(o => { /* … */ })
.AddRuntimeNodeSet(
"Models/MyMachine.NodeSet2.xml",
nodes =>
{
nodes.Node("Machines/Machine1/Start")
.OnCall(StartMachineAsync);
nodes.Variable<double>("Machines/Machine1/Temperature")
.OnRead(ReadTemperature);
});
Group of dependent NodeSets
Register multiple NodeSet2 sources that depend on each other. The factory resolves the import order automatically from the RequiredModel declarations in each document.
services.AddOpcUa()
.AddServer(o => { /* … */ })
.AddRuntimeNodeSet(options =>
{
options.Sources =
[
RuntimeNodeSetSource.FromFile("Models/Opc.Ua.Di.NodeSet2.xml"),
RuntimeNodeSetSource.FromFile("Models/MyMachine.NodeSet2.xml")
];
options.DefaultNamespaceUri = "urn:example:MyMachine";
options.Configure = nodes =>
{
nodes.Node("Machines/Machine1/Start").OnCall(StartMachineAsync);
};
});
Custom stream source
Use RuntimeNodeSetSource.FromStream when you want to open the NodeSet2 document lazily — for example from a database, a blob store, or an assembly resource. The delegate is called for each startup load or live generation and must return a fresh, readable Stream each time.
services.AddOpcUa()
.AddServer(o => { /* … */ })
.AddRuntimeNodeSet(options =>
{
options.Sources =
[
RuntimeNodeSetSource.FromStream(
name: "MyMachine",
openStream: ct => OpenNodeSetStreamAsync(ct),
modelNamespaceUris: ["urn:example:MyMachine"])
];
});
Direct factory registration
If you prefer to create the factory manually — for example to inject it into an existing StandardServer subclass — construct a RuntimeNodeSetNodeManagerFactory directly:
var factory = new RuntimeNodeSetNodeManagerFactory(new RuntimeNodeSetOptions
{
Sources = [RuntimeNodeSetSource.FromFile("Models/MyMachine.NodeSet2.xml")]
});
// In a StandardServer subclass constructor:
AddNodeManager(factory);
Stream ownership contract
When FromStream is used, the runtime loader calls the openStream delegate while each NodeManager generation is created and closes the returned stream after deserialization. You must ensure that:
- Each call to
openStreamreturns a new stream positioned at the beginning. - The stream is readable and contains a valid NodeSet2 XML document.
- You do not close the stream yourself; the factory disposes it after
UANodeSet.Read.
If the delegate returns null or the stream does not contain valid NodeSet2 XML, server startup fails with a clear InvalidOperationException that names the source.
Default namespace for unqualified browse paths
When the Configure callback uses browse paths without an explicit ns=N; prefix (for example "Machines/Machine1/Start"), the runtime loader must know which namespace index to apply for the first path segment. The resolution is:
- If
RuntimeNodeSetOptions.DefaultNamespaceUriis set, that URI is used. - Otherwise the factory infers the unique leaf model — the one model in the loaded group that is not required by any other included source.
- If inference is ambiguous (multiple leaf models and a
Configurecallback is present), startup fails with an error message that lists the candidates. In this case, setDefaultNamespaceUriexplicitly.
When no Configure callback is registered, DefaultNamespaceUri has no effect and may be omitted.
Dependency sorting
The factory reads the Models/Model/RequiredModel entries from each parsed NodeSet document and performs a topological sort (Kahn's algorithm) before importing. Import order guarantees that a required model's nodes are in the address space before any document that depends on them imports its nodes.
Dependencies on models not included in the group — for example the OPC UA base namespace or a third-party model hosted by a generated NodeManager — are silently allowed and treated as external. The server resolves cross-manager references through the normal AddReverseReferencesAsync mechanism.
Referencing a Node another NodeManager owns
A NodeSet may declare a Reference whose other endpoint belongs to a different NodeManager — an inverse Organizes placing an Object under a folder some other model defines, for instance. OPC 10000-3 requires such a Reference to be visible from both ends, so the forward edge has to appear on a Node this NodeManager does not own.
That works in both registration orders. At startup the master collects every NodeManager's external references first and applies them afterwards, so creation order does not matter. A NodeManager added after startup has no such second phase, so the master retains the startup references and each dynamic NodeManager's references, and replays them to any NodeManager registered later. Without that replay a Reference into a NodeManager that did not exist yet would simply be dropped — a reference to an absent Node is discarded rather than queued — leaving the two ends permanently disagreeing: the target browses to the source, the source does not list the target.
Both orders are pinned by RuntimeNodeSetCrossSourceReferenceTests.
Cycles among the included sources cause InvalidOperationException at startup with an error message that lists the participating sources.
Complex types
Runtime complex type loading (structures, enumerations, union types) is on by default and requires no extra configuration. After all NodeManagers have built their address spaces, StandardServer.OnNodeManagerStartedAsync scans every DataType node whose DataTypeDefinition attribute is populated and registers a NativeAOT-safe stand-in encodeable in the server's factory. The same stand-ins are reused for client decode/encode via the OPC UA Part 6 binary protocol.
You can tune this behaviour through the existing ServerComplexTypeOptions:
services.AddOpcUa()
.AddServer(o => { /* … */ })
.AddRuntimeNodeSet("Models/MyMachine.NodeSet2.xml")
.AddComplexTypeSystem(o => o.Enabled = false); // disable if not needed
Setting StandardServer.LoadComplexTypes = false or disabling the complex type system entirely suppresses the stand-in loading without producing a warning. No second complex-type loading path is introduced by the runtime NodeSet feature.
For a complete description of the server-side complex type system, see ComplexTypes.md.
Comparison with source-generated NodeManagers
| Aspect | Runtime NodeSet (AddRuntimeNodeSet) |
Source-generated ([NodeManager]) |
|---|---|---|
| Node access in callbacks | Generic NodeState / BaseVariableState via untyped browse paths |
Strongly typed, compiler-checked fluent accessors per node |
| Compilation required on model change | No — reload through INodeManagerLifecycle |
Yes — regenerate and rebuild |
| AOT / trimming compatibility | Full (uses the existing UANodeSet.Read XmlSerializer path) |
Full (generated code is static) |
| Named NodeId constants | Not generated | Generated (Variables.*, Objects.*, etc.) |
| Multiple namespaces in one manager | Yes — group multiple sources | One namespace per generator run |
| DI registration | AddRuntimeNodeSet(...) |
AddNodeManager<TFactory>() |
| Stream / file input | Files and custom stream factories | MSBuild AdditionalFiles only |
Source-generated managers are the recommended path for production code where type safety and compile-time validation matter. Runtime NodeSets are the recommended path for rapid prototyping, model-file delivery scenarios, and cases where the XML content changes independently of the server binary.
Related documentation
- Source-Generated NodeManagers — strongly typed alternative.
- Dependency Injection —
IOpcUaServerBuilderand service registration. - ComplexTypes.md — server-side complex type loading and client-side decoding.