diff --git a/src/Opc.Ua.WotCon.Server/EventIds.cs b/src/Opc.Ua.WotCon.Server/EventIds.cs
index 9a0f746df9..ca03f5b0eb 100644
--- a/src/Opc.Ua.WotCon.Server/EventIds.cs
+++ b/src/Opc.Ua.WotCon.Server/EventIds.cs
@@ -46,5 +46,6 @@ internal static class WotConServerEventIds
public const int AssetRegistry = 0;
public const int WotAssetFileManager = 40;
public const int WotConnectivityNodeManager = 50;
+ public const int WotRegistryNodeManager = 60;
}
}
diff --git a/src/Opc.Ua.WotCon.Server/Hosting/OpcUaWotRegistryServerBuilderExtensions.cs b/src/Opc.Ua.WotCon.Server/Hosting/OpcUaWotRegistryServerBuilderExtensions.cs
new file mode 100644
index 0000000000..93f9a2bc9c
--- /dev/null
+++ b/src/Opc.Ua.WotCon.Server/Hosting/OpcUaWotRegistryServerBuilderExtensions.cs
@@ -0,0 +1,204 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Options;
+using Opc.Ua;
+using Opc.Ua.Server;
+using Opc.Ua.Server.Hosting;
+using Opc.Ua.Wot;
+using Opc.Ua.WotCon.Bindings;
+using Opc.Ua.WotCon.Server;
+using Opc.Ua.WotCon.Server.Materialization;
+using Opc.Ua.WotCon.Server.Registry;
+using Opc.Ua.XRegistry.Server;
+
+namespace Microsoft.Extensions.DependencyInjection
+{
+ ///
+ /// extensions that host the WoT Connectivity 1.1
+ /// registry (WoTRegistry) on the OPC UA server registered via
+ /// .AddServer(...). The registry service, materialization coordinator,
+ /// binder registry and projection host are registered as singletons; the
+ /// stable is attached at server start.
+ ///
+ public static class OpcUaWotRegistryServerBuilderExtensions
+ {
+ ///
+ /// Default configuration section for the registry options.
+ ///
+ public const string DefaultConfigurationSection = "OpcUa:WotConRegistry:Server";
+
+ ///
+ /// Registers the WoT Connectivity 1.1 registry NodeManager configured by
+ /// .
+ ///
+ ///
+ ///
+ public static IOpcUaBuilder AddWotRegistryServer(
+ this IOpcUaBuilder builder,
+ Action? configure = null)
+ {
+ if (builder is null)
+ {
+ throw new ArgumentNullException(nameof(builder));
+ }
+ if (configure is not null)
+ {
+ builder.Services.AddOptions().Configure(configure);
+ }
+ else
+ {
+ builder.Services.AddOptions();
+ }
+ RegisterCommonServices(builder.Services);
+ return builder;
+ }
+
+ ///
+ /// Registers the WoT Connectivity 1.1 registry NodeManager with options
+ /// bound from the supplied configuration section.
+ ///
+ ///
+ ///
+ public static IOpcUaBuilder AddWotRegistryServer(
+ this IOpcUaBuilder builder,
+ IConfiguration configuration)
+ {
+ if (builder is null)
+ {
+ throw new ArgumentNullException(nameof(builder));
+ }
+ if (configuration is null)
+ {
+ throw new ArgumentNullException(nameof(configuration));
+ }
+ return builder.AddWotRegistryServer(
+ configuration.GetSection(DefaultConfigurationSection));
+ }
+
+ ///
+ /// Registers the WoT Connectivity 1.1 registry NodeManager with options
+ /// bound from the supplied configuration section.
+ ///
+ ///
+ ///
+ public static IOpcUaBuilder AddWotRegistryServer(
+ this IOpcUaBuilder builder,
+ IConfigurationSection section)
+ {
+ if (builder is null)
+ {
+ throw new ArgumentNullException(nameof(builder));
+ }
+ if (section is null)
+ {
+ throw new ArgumentNullException(nameof(section));
+ }
+ builder.Services.AddOptions().Bind(section);
+ RegisterCommonServices(builder.Services);
+ return builder;
+ }
+
+ private static void RegisterCommonServices(IServiceCollection services)
+ {
+ services.TryAddSingleton(sp =>
+ sp.GetRequiredService>().Value
+ ?? new WotRegistryServerOptions());
+
+ services.EnsureWotBinderRegistry();
+
+ services.TryAddSingleton(new WotTargetVariableResolver());
+
+ services.TryAddSingleton(sp =>
+ new WotProjectionBindingRuntimeFactory(
+ sp.GetRequiredService(),
+ sp.GetRequiredService()));
+
+ services.TryAddSingleton(sp =>
+ {
+ WotRegistryServerOptions options =
+ sp.GetRequiredService();
+ // A store registered in DI wins over one set on the options, so a deployment can
+ // supply a shared store the same way the xRegistry server does.
+ IXRegistryResourceStore? resourceStore =
+ sp.GetService() ?? options.ResourceStore;
+ IWotRegistryStore store = string.IsNullOrEmpty(options.StorageFolder)
+ ? new InMemoryWotRegistryStore()
+ : resourceStore is null
+ ? new FileWotRegistryStore(options.StorageFolder!)
+ : new FileWotRegistryStore(options.StorageFolder!, resourceStore);
+ return new WotRegistryService(store, options.Bounds);
+ });
+
+ services.TryAddSingleton(sp =>
+ new LifecycleWotProjectionHost(
+ sp.GetRequiredService(),
+ sp.GetRequiredService()));
+
+ services.TryAddSingleton(sp =>
+ {
+ WotRegistryServerOptions options =
+ sp.GetRequiredService();
+ var converterOptions = new WotNodeSetConverterOptions
+ {
+ MaxJsonDocumentSize = options.Bounds.MaxDocumentBytes,
+ MaxResolverDocumentBytes = options.Bounds.MaxDocumentBytes
+ };
+ return new WotMaterializationCoordinator(
+ sp.GetRequiredService(),
+ sp.GetRequiredService(),
+ sp.GetRequiredService(),
+ converterOptions,
+ // Both seams are optional: a deployment that registers neither gets exactly
+ // the previous behaviour. Registering an IWotDocumentConverter replaces the
+ // Thing Description to NodeSet conversion; registering IWotNodeSetContributor
+ // instances adds nodes (typically controller-specific StructureType DataTypes)
+ // to the converted NodeSet before it is materialized.
+ sp.GetService(),
+ sp.GetServices(),
+ sp.GetService());
+ });
+
+ services.TryAddSingleton(sp =>
+ new WotRegistryNodeManagerFactory(
+ sp.GetRequiredService(),
+ sp.GetRequiredService(),
+ sp.GetRequiredService()));
+
+ services.AddSingleton(sp =>
+ new OpcUaServerNodeManagerRegistration(
+ sp.GetRequiredService()));
+
+ services.AddOpcUa();
+ }
+ }
+}
diff --git a/src/Opc.Ua.WotCon.Server/Materialization/IWotDocumentConverter.cs b/src/Opc.Ua.WotCon.Server/Materialization/IWotDocumentConverter.cs
new file mode 100644
index 0000000000..2f1b10cf29
--- /dev/null
+++ b/src/Opc.Ua.WotCon.Server/Materialization/IWotDocumentConverter.cs
@@ -0,0 +1,180 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Collections.Immutable;
+using System.Threading;
+using System.Threading.Tasks;
+using Opc.Ua.Export;
+using Opc.Ua.Wot;
+using Opc.Ua.WotCon.Server.Registry;
+
+namespace Opc.Ua.WotCon.Server.Materialization
+{
+ ///
+ /// The result of converting one registry document to a NodeSet2 model.
+ ///
+ public sealed class WotConversionOutput
+ {
+ ///
+ /// Initializes a successful or failed conversion output.
+ ///
+ public WotConversionOutput(
+ UANodeSet? nodeSet,
+ ImmutableArray errors,
+ ExpandedNodeId rootNodeId = default)
+ {
+ NodeSet = nodeSet;
+ Errors = errors.IsDefault ? [] : errors;
+ RootNodeId = rootNodeId;
+ }
+
+ ///
+ /// Gets the produced NodeSet2, or null on failure.
+ ///
+ public UANodeSet? NodeSet { get; }
+
+ ///
+ /// Gets the conversion error messages.
+ ///
+ public ImmutableArray Errors { get; }
+
+ ///
+ /// Gets the root node of the projection (the type a Thing Model
+ /// materializes or the top-level instance a Thing Description projects),
+ /// as an absolute whose namespace URI is
+ /// resolved from the produced NodeSet, or ExpandedNodeId.Null
+ /// when the document has no identifiable root.
+ ///
+ public ExpandedNodeId RootNodeId { get; }
+
+ ///
+ /// Gets whether the conversion succeeded.
+ ///
+ public bool Succeeded => NodeSet is not null && Errors.IsEmpty;
+
+ ///
+ /// Creates a successful output.
+ ///
+ public static WotConversionOutput Success(UANodeSet nodeSet)
+ {
+ return new WotConversionOutput(
+ nodeSet,
+ [],
+ WotNodeSetConverter.TrySelectProjectionRoot(nodeSet));
+ }
+
+ ///
+ /// Creates a failed output.
+ ///
+ public static WotConversionOutput Failure(params string[] errors)
+ {
+ return new WotConversionOutput(null, [.. errors]);
+ }
+ }
+
+ ///
+ /// Converts a stored registry document to a NodeSet2 model. The default
+ /// implementation delegates to and resolves
+ /// TM references from the registry snapshot; a test double can substitute a
+ /// deterministic conversion.
+ ///
+ public interface IWotDocumentConverter
+ {
+ ///
+ /// Converts a resource's default document to a NodeSet2 model.
+ ///
+ ValueTask ConvertAsync(
+ WotResource resource,
+ ReadOnlyMemory content,
+ WotRegistrySnapshot snapshot,
+ CancellationToken cancellationToken);
+ }
+
+ ///
+ /// The production converter over .
+ ///
+ public sealed class WotNodeSetDocumentConverter : IWotDocumentConverter
+ {
+ ///
+ /// Initializes a new converter with the supplied options.
+ ///
+ public WotNodeSetDocumentConverter(WotNodeSetConverterOptions? options = null)
+ {
+ m_options = options ?? new WotNodeSetConverterOptions();
+ }
+
+ ///
+ public async ValueTask ConvertAsync(
+ WotResource resource,
+ ReadOnlyMemory content,
+ WotRegistrySnapshot snapshot,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ using var document = WotDocument.Parse(content, m_options);
+ var resolver = new SnapshotThingResolver(snapshot);
+ // One resolution context per top-level conversion, seeded from
+ // the configured converter options, so depth/document/byte
+ // bounds and cycle detection apply across every link resolved
+ // while converting this resource.
+ var resolution = new WotResolutionContext(m_options.ToResolverOptions());
+ WotConversionResult result = await WotNodeSetConverter.ToNodeSetResultAsync(
+ document, m_options, resolver, resolution, cancellationToken).ConfigureAwait(false);
+ ImmutableArray.Builder errors = ImmutableArray.CreateBuilder();
+ foreach (WotDiagnostic diagnostic in result.Diagnostics)
+ {
+ if (diagnostic.Severity == WotDiagnosticSeverity.Error)
+ {
+ errors.Add(diagnostic.ToString());
+ }
+ }
+ if (result.Value is null && errors.Count == 0)
+ {
+ errors.Add("The document could not be converted to a NodeSet.");
+ }
+ if (errors.Count != 0 || result.Value is null)
+ {
+ return new WotConversionOutput(null, errors.ToImmutable());
+ }
+ return new WotConversionOutput(
+ result.Value,
+ [],
+ WotNodeSetConverter.TrySelectProjectionRoot(result.Value));
+ }
+ catch (Exception ex) when (ex is System.Text.Json.JsonException or FormatException)
+ {
+ return WotConversionOutput.Failure(ex.Message);
+ }
+ }
+
+ private readonly WotNodeSetConverterOptions m_options;
+ }
+}
diff --git a/src/Opc.Ua.WotCon.Server/Materialization/IWotNodeSetContributor.cs b/src/Opc.Ua.WotCon.Server/Materialization/IWotNodeSetContributor.cs
new file mode 100644
index 0000000000..ae29f3ef95
--- /dev/null
+++ b/src/Opc.Ua.WotCon.Server/Materialization/IWotNodeSetContributor.cs
@@ -0,0 +1,79 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System.Threading;
+using System.Threading.Tasks;
+using Opc.Ua.Export;
+using Opc.Ua.WotCon.Server.Registry;
+
+namespace Opc.Ua.WotCon.Server.Materialization
+{
+ ///
+ /// Contributes additional nodes to a converted document before it is materialized into the
+ /// AddressSpace — typically custom StructureType DataTypes that have no NodeSet to
+ /// import because they are specific to one controller program.
+ ///
+ ///
+ ///
+ /// Many industrial protocols expose user-defined types — Rockwell / Studio 5000 UDTs, TIA
+ /// Portal PLC data types, Beckhoff TwinCAT structured types — that must be generated from the
+ /// controller's own symbol table at onboarding time. A contributor runs once per resource,
+ /// after the Thing Description has been converted to a NodeSet and before any variable is
+ /// created, which is the point at which such a DataType has to exist: a
+ /// uav:mapByFieldPath mapping into a structured type can only resolve once that type is
+ /// registered.
+ ///
+ ///
+ /// A document that can already express its types declaratively does not need this seam — the
+ /// native projection (uav:NodeModel) carries DataType nodes with their
+ /// DataTypeDefinition directly. This interface is for types that can only be discovered
+ /// programmatically.
+ ///
+ ///
+ /// Contributors are resolved from dependency injection; registering none leaves conversion
+ /// unchanged. Implementations must be safe for concurrent calls across resources.
+ ///
+ ///
+ public interface IWotNodeSetContributor
+ {
+ ///
+ /// Contributes nodes for to .
+ ///
+ /// The registry resource being materialized.
+ ///
+ /// The converted NodeSet, mutated in place. A contributor adds nodes; it must not remove or
+ /// rewrite nodes produced by the conversion.
+ ///
+ /// The cancellation token.
+ ValueTask ContributeAsync(
+ WotResource resource,
+ UANodeSet nodeSet,
+ CancellationToken cancellationToken = default);
+ }
+}
diff --git a/src/Opc.Ua.WotCon.Server/Materialization/IWotNodeSetResolver.cs b/src/Opc.Ua.WotCon.Server/Materialization/IWotNodeSetResolver.cs
new file mode 100644
index 0000000000..5dbf307ac0
--- /dev/null
+++ b/src/Opc.Ua.WotCon.Server/Materialization/IWotNodeSetResolver.cs
@@ -0,0 +1,80 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Opc.Ua.WotCon.Server.Materialization
+{
+ ///
+ /// Resolves the NodeSet2 for an OPC UA namespace a Thing Description depends on but the server
+ /// does not already know.
+ ///
+ ///
+ ///
+ /// Thing Descriptions are uploaded at run time through the standard WoTAssetFileType
+ /// upload, so the set of namespaces a server will be asked to serve is not known at start-up
+ /// and static pre-loading is not sufficient. A resolver closes that gap: it is consulted for
+ /// each unknown namespace, and what it returns is loaded through the server's existing runtime
+ /// NodeSet support.
+ ///
+ ///
+ /// A document that carries its own model does not need a resolver at all — the
+ /// uav:nodeSet envelope embeds the NodeSet2 in the Thing Description itself.
+ ///
+ ///
+ /// Resolution is recursive: a resolved NodeSet's own dependencies are resolved the same way.
+ /// A namespace that cannot be resolved is reported as a diagnostic rather than failing the
+ /// whole onboarding, so an operator can see exactly what is missing.
+ ///
+ ///
+ /// No implementation ships with the library: resolving a namespace means reaching out to some
+ /// catalogue — a UA Cloud Library instance, a corporate model repository, a folder on disk —
+ /// which is a deployment decision. Registering none leaves behaviour unchanged.
+ /// Implementations must be safe for concurrent calls.
+ ///
+ ///
+ public interface IWotNodeSetResolver
+ {
+ ///
+ /// Attempts to resolve the NodeSet2 for .
+ ///
+ /// The namespace URI to resolve.
+ /// The cancellation token.
+ ///
+ /// A readable stream positioned at the start of the NodeSet2 XML, which the caller
+ /// disposes; or null when this resolver does not know the namespace. Returning
+ /// null is the expected way to decline — it is not an error.
+ ///
+ ValueTask TryResolveAsync(
+ string namespaceUri,
+ CancellationToken cancellationToken = default);
+ }
+}
diff --git a/src/Opc.Ua.WotCon.Server/Materialization/IWotProjectionBindingRuntimeFactory.cs b/src/Opc.Ua.WotCon.Server/Materialization/IWotProjectionBindingRuntimeFactory.cs
new file mode 100644
index 0000000000..97254f4b4f
--- /dev/null
+++ b/src/Opc.Ua.WotCon.Server/Materialization/IWotProjectionBindingRuntimeFactory.cs
@@ -0,0 +1,83 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Opc.Ua.Server.Fluent;
+using Opc.Ua.WotCon.Bindings;
+
+namespace Opc.Ua.WotCon.Server.Materialization
+{
+ ///
+ /// Builds the per-generation OPC UA target-mapping binding runtime for a
+ /// runtime NodeSet. Injected into ,
+ /// which invokes it from
+ /// once the generation's NodeSet2 content has been imported, so target
+ /// mappings are resolved against the freshly materialized predefined nodes.
+ /// The returned (if any) is owned by that
+ /// NodeSet generation and is disposed with it.
+ ///
+ public interface IWotProjectionBindingRuntimeFactory
+ {
+ ///
+ /// Wires the OPC UA target-mapping bindings declared by
+ /// onto the freshly imported predefined
+ /// nodes exposed by .
+ ///
+ ///
+ /// The fluent builder for the node manager generation being activated.
+ ///
+ ///
+ /// The prepared binding plans for the projected closure. Forms without
+ /// a target mapping, and non-executable forms, are ignored.
+ ///
+ /// The cancellation token.
+ ///
+ /// The generation-owned binding runtime, or null when no target
+ /// mapping was wired (for example an empty ).
+ ///
+ ///
+ /// A target mapping is missing, malformed, ambiguous, resolves to the
+ /// wrong node class, mismatches its declared target type, conflicts
+ /// with another mapping to the same target, duplicates a read or write
+ /// mapping, or is declared on an unsupported operation. Structured
+ /// (uav:mapByFieldPath) validation that depends on the
+ /// target's structure type being registered — the structure lookup,
+ /// root instance validation, and per-field path resolution — is
+ /// deferred past this call; see the class remarks on
+ /// for why, and for how a
+ /// first structured read or write fails deterministically instead.
+ ///
+ ValueTask CreateAsync(
+ INodeManagerBuilder builder,
+ ArrayOf bindingPlans,
+ CancellationToken cancellationToken = default);
+ }
+}
diff --git a/src/Opc.Ua.WotCon.Server/Materialization/IWotProjectionHost.cs b/src/Opc.Ua.WotCon.Server/Materialization/IWotProjectionHost.cs
new file mode 100644
index 0000000000..aef9b80401
--- /dev/null
+++ b/src/Opc.Ua.WotCon.Server/Materialization/IWotProjectionHost.cs
@@ -0,0 +1,262 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Collections.Immutable;
+using System.Threading;
+using System.Threading.Tasks;
+using Opc.Ua.WotCon.Bindings;
+
+namespace Opc.Ua.WotCon.Server.Materialization
+{
+ ///
+ /// Selects how the previous projection generation is retired after a
+ /// successful replacement.
+ ///
+ public enum WotProjectionRetirementPolicy
+ {
+ ///
+ /// Keep the previous generation alive until its monitored items and
+ /// requests drain.
+ ///
+ Graceful,
+
+ ///
+ /// Invalidate its monitored items with BadNodeIdUnknown and dispose the
+ /// previous generation without waiting for drain.
+ ///
+ Immediate
+ }
+
+ ///
+ /// One NodeSet2 document loaded as a runtime NodeManager source. A projection
+ /// closure produces one or more of these (TM type NodeSets loaded before the
+ /// dependent TD instance NodeSet).
+ ///
+ public sealed class WotProjectionSource
+ {
+ ///
+ /// Initializes a new projection source.
+ ///
+ public WotProjectionSource(
+ string name,
+ ImmutableArray modelNamespaceUris,
+ byte[] nodeSetXml)
+ {
+ Name = name ?? throw new ArgumentNullException(nameof(name));
+ ModelNamespaceUris = modelNamespaceUris.IsDefault
+ ? [] : modelNamespaceUris;
+ NodeSetXml = nodeSetXml ?? throw new ArgumentNullException(nameof(nodeSetXml));
+ }
+
+ ///
+ /// Gets the diagnostic source name.
+ ///
+ public string Name { get; }
+
+ ///
+ /// Gets the model namespace URIs this source owns.
+ ///
+ public ImmutableArray ModelNamespaceUris { get; }
+
+ ///
+ /// Gets the serialized NodeSet2 XML bytes.
+ ///
+ public byte[] NodeSetXml { get; }
+ }
+
+ ///
+ /// The full set of NodeSet2 sources for one projection closure to be added
+ /// or shadow-reloaded as a single runtime NodeManager.
+ ///
+ public sealed class WotProjectionDocument
+ {
+ ///
+ /// Initializes a new projection document.
+ ///
+ public WotProjectionDocument(
+ string closureKey,
+ ImmutableArray sources)
+ : this(closureKey, sources, [])
+ {
+ }
+
+ ///
+ /// Initializes a new projection document that also carries the prepared
+ /// binding plans for the closure, so the projection host can wire a
+ /// per-generation binding runtime while it materializes the NodeSet.
+ ///
+ public WotProjectionDocument(
+ string closureKey,
+ ImmutableArray sources,
+ ArrayOf bindingPlans)
+ {
+ ClosureKey = closureKey ?? throw new ArgumentNullException(nameof(closureKey));
+ Sources = sources.IsDefault ? [] : sources;
+ BindingPlans = bindingPlans;
+ }
+
+ ///
+ /// Gets the stable closure key this document projects.
+ ///
+ public string ClosureKey { get; }
+
+ ///
+ /// Gets the ordered NodeSet2 sources.
+ ///
+ public ImmutableArray Sources { get; }
+
+ ///
+ /// Gets the prepared binding plans for the closure's members, in the
+ /// same order the members were projected. Empty when the coordinator
+ /// did not prepare any bindings (for example a dry run).
+ ///
+ public ArrayOf BindingPlans { get; }
+ }
+
+ ///
+ /// Marks the host-specific registration carried by a
+ /// . Implementations are opaque to the
+ /// materialization pipeline; only the owning
+ /// interprets them.
+ ///
+ public interface IWotProjectionRegistration
+ {
+ ///
+ /// Gets the stable identifier the host assigned to this registration. It
+ /// is shared by every generation produced from the same closure and is
+ /// only used for diagnostics.
+ ///
+ Guid Id { get; }
+ }
+
+ ///
+ /// An opaque handle to a live projection generation held by the host. It
+ /// wraps the underlying runtime NodeManager registration and records the
+ /// materialized root NodeIds and node count.
+ ///
+ public sealed class WotProjectionHandle
+ {
+ ///
+ /// Initializes a new projection handle.
+ ///
+ public WotProjectionHandle(
+ string closureKey,
+ long generation,
+ IWotProjectionRegistration? registration,
+ ImmutableArray rootNodeIds,
+ int materializedNodeCount,
+ string warning = "")
+ {
+ ClosureKey = closureKey ?? string.Empty;
+ Generation = generation;
+ Registration = registration;
+ RootNodeIds = rootNodeIds.IsDefault ? [] : rootNodeIds;
+ MaterializedNodeCount = materializedNodeCount;
+ Warning = warning ?? string.Empty;
+ }
+
+ ///
+ /// Gets the closure key.
+ ///
+ public string ClosureKey { get; }
+
+ ///
+ /// Gets the projection generation.
+ ///
+ public long Generation { get; }
+
+ ///
+ /// Gets the underlying runtime registration (host-specific).
+ ///
+ public IWotProjectionRegistration? Registration { get; }
+
+ ///
+ /// Gets the materialized root NodeIds.
+ ///
+ public ImmutableArray RootNodeIds { get; }
+
+ ///
+ /// Gets the materialized node count.
+ ///
+ public int MaterializedNodeCount { get; }
+
+ ///
+ /// Gets a non-fatal host warning produced after the replacement generation
+ /// was committed, for example deferred cleanup of the previous generation.
+ ///
+ public string Warning { get; }
+ }
+
+ ///
+ /// The seam between the materialization coordinator and the live server's
+ /// NodeManager lifecycle. The production implementation adds a runtime
+ /// NodeSet on first activation and shadow-reloads it on update, keeping the
+ /// stable registry NodeManager separate. A test double records the sequence
+ /// of add/shadow-reload/remove operations without a running server.
+ ///
+ public interface IWotProjectionHost
+ {
+ ///
+ /// Adds a projection for its first activation and returns a handle to the
+ /// new live generation.
+ ///
+ ValueTask AddAsync(
+ WotProjectionDocument document,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Shadow-reloads a live projection: new service requests are routed to
+ /// the replacement generation while the previous generation keeps serving
+ /// its existing monitored items until they drain.
+ ///
+ ValueTask ShadowReloadAsync(
+ WotProjectionHandle current,
+ WotProjectionDocument document,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Reloads a live projection and immediately retires the previous
+ /// generation. Affected data-change monitored items report
+ /// .
+ ///
+ ValueTask ImmediateReloadAsync(
+ WotProjectionHandle current,
+ WotProjectionDocument document,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Removes a live projection after its monitored items drain, without
+ /// disconnecting clients.
+ ///
+ ValueTask RemoveAsync(
+ WotProjectionHandle handle,
+ CancellationToken cancellationToken = default);
+ }
+}
diff --git a/src/Opc.Ua.WotCon.Server/Materialization/IWotTargetVariableResolver.cs b/src/Opc.Ua.WotCon.Server/Materialization/IWotTargetVariableResolver.cs
new file mode 100644
index 0000000000..d0ef538aa0
--- /dev/null
+++ b/src/Opc.Ua.WotCon.Server/Materialization/IWotTargetVariableResolver.cs
@@ -0,0 +1,166 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using Opc.Ua.Server.Fluent;
+using Opc.Ua.WotCon.Bindings;
+
+namespace Opc.Ua.WotCon.Server.Materialization
+{
+ ///
+ /// Resolves the OPC UA target variable an OPC 10101 §6.5.4 target-mapping
+ /// descriptor declares, against a node manager's freshly imported
+ /// predefined nodes. Injectable so a host application can supply an
+ /// alternate resolution strategy (for example one that also consults an
+ /// external addressing table); is
+ /// the default, spec-compliant implementation and is always available via
+ /// direct construction.
+ ///
+ public interface IWotTargetVariableResolver
+ {
+ ///
+ /// Resolves the target variable declared by .
+ ///
+ ///
+ /// The fluent builder for the node manager generation whose predefined
+ /// nodes (including the freshly imported NodeSet2 content) the mapping
+ /// is resolved against.
+ ///
+ /// The target-mapping descriptor to resolve.
+ /// The resolved target variable.
+ ///
+ /// The mapping is missing, malformed, ambiguous, resolves to a node
+ /// that is not a , or (for a mapping
+ /// that declares both terms) resolves to a variable whose
+ /// DataType does not equal the declared target type.
+ ///
+ BaseVariableState Resolve(INodeManagerBuilder builder, WotTargetMappingDescriptor mapping);
+ }
+
+ ///
+ /// The default . It implements the
+ /// OPC 10101 §6.5.4 target-mapping resolution rules by reusing the fluent
+ /// lookup surface (so lookup failures
+ /// throw the same deterministic
+ /// statuses the builder already defines for missing, ambiguous, or
+ /// wrong-node-class lookups):
+ ///
+ ///
+ /// uav:mapToNodeId alone resolves the exact target and requires it
+ /// to be a .
+ ///
+ ///
+ /// uav:mapToType alone resolves the unique variable whose
+ /// DataType equals the target type.
+ ///
+ ///
+ /// Both terms resolve the exact target and additionally validate that its
+ /// DataType equals the declared target type.
+ ///
+ ///
+ ///
+ public sealed class WotTargetVariableResolver : IWotTargetVariableResolver
+ {
+ ///
+ public BaseVariableState Resolve(INodeManagerBuilder builder, WotTargetMappingDescriptor mapping)
+ {
+ if (builder is null)
+ {
+ throw new ArgumentNullException(nameof(builder));
+ }
+ if (mapping is null)
+ {
+ throw new ArgumentNullException(nameof(mapping));
+ }
+
+ bool hasTargetNodeId = !string.IsNullOrWhiteSpace(mapping.TargetNodeId);
+ bool hasTargetType = !string.IsNullOrWhiteSpace(mapping.TargetTypeNodeId);
+
+ if (!hasTargetNodeId && !hasTargetType)
+ {
+ throw ServiceResultException.Create(
+ StatusCodes.BadNodeIdInvalid,
+ "The target mapping declares neither 'uav:mapToNodeId' nor 'uav:mapToType'.");
+ }
+
+ if (hasTargetNodeId)
+ {
+ NodeId targetNodeId = ParsePortableNodeId(builder, mapping.TargetNodeId!, "uav:mapToNodeId");
+ BaseVariableState variable = builder.Variable(targetNodeId).Node;
+
+ if (hasTargetType)
+ {
+ NodeId targetTypeId = ParsePortableNodeId(builder, mapping.TargetTypeNodeId!, "uav:mapToType");
+ if (variable.DataType != targetTypeId)
+ {
+ throw ServiceResultException.Create(
+ StatusCodes.BadTypeMismatch,
+ "Target variable '{0}' has DataType '{1}', which does not match the " +
+ "declared 'uav:mapToType' target type '{2}'.",
+ targetNodeId,
+ variable.DataType,
+ targetTypeId);
+ }
+ }
+ return variable;
+ }
+
+ NodeId dataTypeId = ParsePortableNodeId(builder, mapping.TargetTypeNodeId!, "uav:mapToType");
+ return builder.VariableFromDataTypeId(dataTypeId).Node;
+ }
+
+ ///
+ /// Parses a portable NodeId (including nsu= forms) against the
+ /// builder's namespace table, translating every parse failure —
+ /// including a raised by the
+ /// parser itself — into a deterministic
+ /// naming the offending
+ /// term, so callers never need to special-case the parser's own
+ /// exception shape.
+ ///
+ ///
+ private static NodeId ParsePortableNodeId(INodeManagerBuilder builder, string text, string term)
+ {
+ try
+ {
+ return ExpandedNodeId.Parse(text, builder.Context.NamespaceUris);
+ }
+ catch (Exception ex) when (
+ ex is not OperationCanceledException and not OutOfMemoryException)
+ {
+ throw ServiceResultException.Create(
+ StatusCodes.BadNodeIdInvalid,
+ "'{0}' value '{1}' is not a valid portable NodeId: {2}",
+ term,
+ text,
+ ex.Message);
+ }
+ }
+ }
+}
diff --git a/src/Opc.Ua.WotCon.Server/Materialization/LifecycleWotProjectionHost.cs b/src/Opc.Ua.WotCon.Server/Materialization/LifecycleWotProjectionHost.cs
new file mode 100644
index 0000000000..43be66fa9a
--- /dev/null
+++ b/src/Opc.Ua.WotCon.Server/Materialization/LifecycleWotProjectionHost.cs
@@ -0,0 +1,224 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Opc.Ua.Server;
+using Opc.Ua.Server.RuntimeNodeSet;
+using Opc.Ua.WotCon.Bindings;
+
+namespace Opc.Ua.WotCon.Server.Materialization
+{
+ ///
+ /// The production that projects WoT closures
+ /// onto the live server through the public NodeManager lifecycle. First
+ /// activation uses
+ /// ;
+ /// updates use
+ /// ,
+ /// so the previous generation keeps serving its existing monitored items
+ /// until they drain. The stable WoT registry NodeManager is never touched.
+ ///
+ public sealed class LifecycleWotProjectionHost : IWotProjectionHost
+ {
+ ///
+ /// Initializes a new host over the supplied lifecycle.
+ ///
+ /// The node manager lifecycle to project onto.
+ ///
+ /// The optional projection binding runtime factory. When supplied, each
+ /// runtime NodeSet generation created for a document that carries
+ /// prepared owns its own
+ /// binding runtime: it is created after the NodeSet is imported (via
+ /// ) and disposed with
+ /// the generation. When null, no binding runtime is wired (the
+ /// NodeSet is materialized as data only).
+ ///
+ public LifecycleWotProjectionHost(
+ INodeManagerLifecycle lifecycle,
+ IWotProjectionBindingRuntimeFactory? runtimeFactory = null)
+ {
+ m_lifecycle = lifecycle ?? throw new ArgumentNullException(nameof(lifecycle));
+ m_runtimeFactory = runtimeFactory;
+ }
+
+ ///
+ public async ValueTask AddAsync(
+ WotProjectionDocument document,
+ CancellationToken cancellationToken = default)
+ {
+ RuntimeNodeSetOptions options = BuildOptions(document);
+ NodeManagerRegistration registration = await m_lifecycle
+ .AddRuntimeNodeSetAsync(options, callerContext: null, cancellationToken)
+ .ConfigureAwait(false);
+ return new WotProjectionHandle(
+ document.ClosureKey,
+ registration.Generation,
+ new NodeManagerProjectionRegistration(registration),
+ [],
+ 0);
+ }
+
+ ///
+ public async ValueTask ShadowReloadAsync(
+ WotProjectionHandle current,
+ WotProjectionDocument document,
+ CancellationToken cancellationToken = default)
+ {
+ if (current?.Registration is not NodeManagerProjectionRegistration wrapper)
+ {
+ // No live registration to reload; fall back to a fresh add.
+ return await AddAsync(document, cancellationToken).ConfigureAwait(false);
+ }
+ RuntimeNodeSetOptions options = BuildOptions(document);
+ NodeManagerRegistration next;
+ string warning = string.Empty;
+ try
+ {
+ next = await m_lifecycle
+ .ShadowReloadRuntimeNodeSetAsync(wrapper.Registration, options, cancellationToken)
+ .ConfigureAwait(false);
+ }
+ catch (NodeManagerReloadCommittedException ex)
+ {
+ next = ex.Registration;
+ warning = "The replacement is active, but prior-generation cleanup is pending: " +
+ ex.Message;
+ }
+ return new WotProjectionHandle(
+ document.ClosureKey,
+ next.Generation,
+ new NodeManagerProjectionRegistration(next),
+ [],
+ 0,
+ warning);
+ }
+
+ ///
+ public async ValueTask ImmediateReloadAsync(
+ WotProjectionHandle current,
+ WotProjectionDocument document,
+ CancellationToken cancellationToken = default)
+ {
+ if (current?.Registration is not NodeManagerProjectionRegistration wrapper)
+ {
+ return await AddAsync(document, cancellationToken).ConfigureAwait(false);
+ }
+ RuntimeNodeSetOptions options = BuildOptions(document);
+ NodeManagerRegistration next;
+ string warning = string.Empty;
+ try
+ {
+ next = await m_lifecycle
+ .ImmediateReloadRuntimeNodeSetAsync(wrapper.Registration, options, cancellationToken)
+ .ConfigureAwait(false);
+ }
+ catch (NodeManagerReloadCommittedException ex)
+ {
+ next = ex.Registration;
+ warning = "The replacement is active, but prior-generation cleanup is pending: " +
+ ex.Message;
+ }
+ return new WotProjectionHandle(
+ document.ClosureKey,
+ next.Generation,
+ new NodeManagerProjectionRegistration(next),
+ [],
+ 0,
+ warning);
+ }
+
+ ///
+ public async ValueTask RemoveAsync(
+ WotProjectionHandle handle,
+ CancellationToken cancellationToken = default)
+ {
+ if (m_lifecycle.IsShuttingDown)
+ {
+ return;
+ }
+
+ if (handle?.Registration is NodeManagerProjectionRegistration wrapper)
+ {
+ await m_lifecycle
+ .RemoveAsync(wrapper.Registration, callerContext: null, cancellationToken)
+ .ConfigureAwait(false);
+ }
+ }
+
+ private RuntimeNodeSetOptions BuildOptions(WotProjectionDocument document)
+ {
+ var sources = new RuntimeNodeSetSource[document.Sources.Length];
+ for (int i = 0; i < document.Sources.Length; i++)
+ {
+ WotProjectionSource source = document.Sources[i];
+ byte[] xml = source.NodeSetXml;
+ var uris = new ArrayOf(source.ModelNamespaceUris.ToArray());
+ sources[i] = RuntimeNodeSetSource.FromStream(
+ source.Name,
+ _ => new ValueTask(new MemoryStream(xml, writable: false)),
+ uris);
+ }
+ var options = new RuntimeNodeSetOptions
+ {
+ Sources = new ArrayOf(sources),
+ AllowLifecycleFromRequestCallback = true
+ };
+ if (m_runtimeFactory is { } runtimeFactory)
+ {
+ ArrayOf bindingPlans = document.BindingPlans;
+ options.ConfigureAsync = (builder, cancellationToken)
+ => runtimeFactory.CreateAsync(builder, bindingPlans, cancellationToken);
+ }
+ return options;
+ }
+
+ private readonly INodeManagerLifecycle m_lifecycle;
+ private readonly IWotProjectionBindingRuntimeFactory? m_runtimeFactory;
+
+ ///
+ /// Carries the lifecycle registration owned by this host through the
+ /// host-agnostic .
+ ///
+ private sealed class NodeManagerProjectionRegistration : IWotProjectionRegistration
+ {
+ public NodeManagerProjectionRegistration(NodeManagerRegistration registration)
+ {
+ Registration = registration;
+ }
+
+ public Guid Id => Registration.Id;
+
+ public NodeManagerRegistration Registration { get; }
+ }
+ }
+}
diff --git a/src/Opc.Ua.WotCon.Server/Materialization/WotBindingChannelSlot.cs b/src/Opc.Ua.WotCon.Server/Materialization/WotBindingChannelSlot.cs
new file mode 100644
index 0000000000..2497b06e2f
--- /dev/null
+++ b/src/Opc.Ua.WotCon.Server/Materialization/WotBindingChannelSlot.cs
@@ -0,0 +1,152 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Opc.Ua.WotCon.Bindings;
+
+namespace Opc.Ua.WotCon.Server.Materialization
+{
+ ///
+ /// Lazily opens and caches a single live channel for one compiled form,
+ /// shared by every reader/writer wired against that form for the lifetime
+ /// of a projection binding runtime generation. Concurrent first use opens
+ /// the channel exactly once; a failed open is evicted immediately so a
+ /// later call can retry. Disposing the slot disposes the channel only if
+ /// it was opened successfully — a faulted open leaves nothing to dispose
+ /// and never re-surfaces the original open failure. Once disposed, the
+ /// slot never opens another channel: racing with or
+ /// occurring after either observes the channel
+ /// already claimed for disposal or is rejected outright, so no channel
+ /// this slot opens can ever escape disposal.
+ ///
+ internal sealed class WotBindingChannelSlot
+ {
+ ///
+ /// Initializes a new channel slot for a compiled form.
+ ///
+ public WotBindingChannelSlot(WotCompiledForm form, IWotBindingChannelFactory channelFactory)
+ {
+ m_form = form ?? throw new ArgumentNullException(nameof(form));
+ m_channelFactory = channelFactory ?? throw new ArgumentNullException(nameof(channelFactory));
+ }
+
+ ///
+ /// Gets the shared channel, opening it on first use. The channel open
+ /// itself is not bound to any single caller's cancellation token — it
+ /// is a generation-scoped resource shared by every reader/writer wired
+ /// against the same compiled form, so one caller cancelling must not
+ /// tear down the open for concurrent callers.
+ ///
+ ///
+ /// The slot has already been disposed, or is disposed concurrently
+ /// before this call is able to reuse or start an open.
+ ///
+ public ValueTask GetAsync(CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ Task task;
+ lock (m_gate)
+ {
+ if (m_disposed)
+ {
+ throw new ObjectDisposedException(nameof(WotBindingChannelSlot));
+ }
+ task = m_channelTask ??= OpenAsync();
+ }
+ return WaitAsync(task);
+ }
+
+ ///
+ /// Marks the slot disposed so no later call can
+ /// start a new open, then disposes the cached channel if one was
+ /// successfully opened (including one still opening concurrently — the
+ /// disposal awaits it and disposes the result). A faulted or
+ /// never-started open has no resource to release and is silently
+ /// ignored so cleanup never re-reports the original open failure.
+ /// Safe to call more than once; only the first call finds a channel to
+ /// dispose.
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ Task? task;
+ lock (m_gate)
+ {
+ task = m_channelTask;
+ m_channelTask = null;
+ m_disposed = true;
+ }
+ if (task is null)
+ {
+ return;
+ }
+ IWotBindingChannel channel;
+ try
+ {
+ channel = await task.ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is not OutOfMemoryException)
+ {
+ return;
+ }
+ await channel.DisposeAsync().ConfigureAwait(false);
+ }
+
+ private Task OpenAsync()
+ {
+ return m_channelFactory.OpenChannelAsync(m_form, CancellationToken.None).AsTask();
+ }
+
+ private async ValueTask WaitAsync(Task task)
+ {
+ try
+ {
+ return await task.ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is not OutOfMemoryException)
+ {
+ lock (m_gate)
+ {
+ if (ReferenceEquals(m_channelTask, task))
+ {
+ m_channelTask = null;
+ }
+ }
+ throw;
+ }
+ }
+
+ private readonly WotCompiledForm m_form;
+ private readonly IWotBindingChannelFactory m_channelFactory;
+ private readonly Lock m_gate = new();
+ private Task? m_channelTask;
+ private bool m_disposed;
+ }
+}
diff --git a/src/Opc.Ua.WotCon.Server/Materialization/WotDependencyGraph.cs b/src/Opc.Ua.WotCon.Server/Materialization/WotDependencyGraph.cs
new file mode 100644
index 0000000000..1d6e1861eb
--- /dev/null
+++ b/src/Opc.Ua.WotCon.Server/Materialization/WotDependencyGraph.cs
@@ -0,0 +1,565 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Linq;
+using System.Text.Json;
+using Opc.Ua.WotCon.Server.Registry;
+
+namespace Opc.Ua.WotCon.Server.Materialization
+{
+ ///
+ /// One resolved (or unresolved) dependency edge between two documents.
+ ///
+ public sealed class WotDependency
+ {
+ ///
+ /// Initializes a new dependency edge.
+ ///
+ public WotDependency(
+ string sourceXid,
+ string targetHref,
+ string? targetXid,
+ string refType,
+ bool resolved)
+ {
+ SourceXid = sourceXid;
+ TargetHref = targetHref;
+ TargetXid = targetXid;
+ RefType = refType;
+ Resolved = resolved;
+ }
+
+ ///
+ /// Gets the xid of the dependent document.
+ ///
+ public string SourceXid { get; }
+
+ ///
+ /// Gets the raw href/URI of the dependency.
+ ///
+ public string TargetHref { get; }
+
+ ///
+ /// Gets the xid of the resolved target document, if any.
+ ///
+ public string? TargetXid { get; }
+
+ ///
+ /// Gets the dependency kind (tm:extends / tm:ref / links.rel=type).
+ ///
+ public string RefType { get; }
+
+ ///
+ /// Gets whether the dependency resolved to a stored document.
+ ///
+ public bool Resolved { get; }
+ }
+
+ ///
+ /// A dependency closure: a set of resources that must be materialized
+ /// together, with Thing Models topologically ordered before the Thing
+ /// Descriptions that depend on them. A closure is the default unit of
+ /// atomicity for a refresh.
+ ///
+ public sealed class WotDependencyClosure
+ {
+ internal WotDependencyClosure(
+ string key,
+ ImmutableArray members,
+ ImmutableArray orderedResources,
+ ImmutableArray dependencies,
+ ImmutableArray diagnostics,
+ bool hasCycle,
+ bool hasMissingDependency)
+ {
+ Key = key;
+ Members = members;
+ OrderedResources = orderedResources;
+ Dependencies = dependencies;
+ Diagnostics = diagnostics;
+ HasCycle = hasCycle;
+ HasMissingDependency = hasMissingDependency;
+ }
+
+ ///
+ /// Gets the stable closure key (sorted member xids).
+ ///
+ public string Key { get; }
+
+ ///
+ /// Gets every member of the closure (populated even on a cycle).
+ ///
+ public ImmutableArray Members { get; }
+
+ ///
+ /// Gets the resources in topological (dependency-first) order.
+ ///
+ public ImmutableArray OrderedResources { get; }
+
+ ///
+ /// Gets the dependency edges within the closure.
+ ///
+ public ImmutableArray Dependencies { get; }
+
+ ///
+ /// Gets the diagnostics for the closure.
+ ///
+ public ImmutableArray Diagnostics { get; }
+
+ ///
+ /// Gets whether the closure contains a dependency cycle.
+ ///
+ public bool HasCycle { get; }
+
+ ///
+ /// Gets whether the closure has an unresolved dependency.
+ ///
+ public bool HasMissingDependency { get; }
+
+ ///
+ /// Gets whether the closure is projectable (no cycle, no missing dependency).
+ ///
+ public bool IsProjectable => !HasCycle && !HasMissingDependency;
+ }
+
+ ///
+ /// Builds the TD/TM dependency graph from a registry snapshot and partitions
+ /// it into deterministic dependency closures. References are extracted from
+ /// links (rel = tm:extends / type / tm:submodel), a top-level
+ /// tm:extends, and tm:ref pointers, then resolved against the
+ /// registry by Thing id, xid, or resource id.
+ ///
+ public static class WotDependencyGraph
+ {
+ ///
+ /// Resolves a WoT reference href to a stored resource, or null.
+ ///
+ public static WotResource? Resolve(WotRegistrySnapshot snapshot, string href)
+ {
+ if (snapshot is null || string.IsNullOrWhiteSpace(href))
+ {
+ return null;
+ }
+ string trimmed = TrimFragment(href);
+ // Prefer Thing Models, then any resource, matching by thing id, xid or resource id.
+ return MatchIn(snapshot.ResourcesOfKind(WoTDocumentKindEnum.ThingModel), trimmed)
+ ?? MatchIn(snapshot.AllResources(), trimmed);
+ }
+
+ ///
+ /// Extracts the outgoing dependency references of a single document.
+ ///
+ public static IReadOnlyList<(string Href, string RefType)> ExtractReferences(
+ ReadOnlyMemory document,
+ int maxJsonDepth)
+ {
+ var references = new List<(string, string)>();
+ try
+ {
+ var options = new JsonDocumentOptions { MaxDepth = maxJsonDepth };
+ using var json = JsonDocument.Parse(document, options);
+ JsonElement root = json.RootElement;
+ if (root.ValueKind != JsonValueKind.Object)
+ {
+ return references;
+ }
+ CollectLinks(root, references);
+ CollectExtends(root, references);
+ CollectTmRefs(root, references, 0, maxJsonDepth);
+ }
+ catch (JsonException)
+ {
+ // A document that cannot be parsed contributes no edges; its own
+ // projection reports the parse failure.
+ }
+ return references;
+ }
+
+ ///
+ /// Builds the dependency closures for the selected resources. Selected
+ /// resources are grouped into weakly-connected components (so a shared
+ /// Thing Model lands in a single closure), then each component is
+ /// topologically ordered.
+ ///
+ public static ImmutableArray BuildClosures(
+ WotRegistrySnapshot snapshot,
+ IReadOnlyCollection selected,
+ int maxJsonDepth)
+ {
+ if (selected.Count == 0)
+ {
+ return [];
+ }
+
+ // Expand the selection to include resolvable transitive dependencies.
+ var byXid = new Dictionary(StringComparer.Ordinal);
+ var queue = new Queue();
+ foreach (WotResource resource in selected)
+ {
+ if (!byXid.ContainsKey(resource.Xid))
+ {
+ byXid[resource.Xid] = resource;
+ queue.Enqueue(resource);
+ }
+ }
+
+ var edges = new Dictionary>(StringComparer.Ordinal);
+ while (queue.Count > 0)
+ {
+ WotResource resource = queue.Dequeue();
+ var list = new List();
+ edges[resource.Xid] = list;
+ WotResourceVersion? version = resource.DefaultVersion;
+ if (version is null)
+ {
+ continue;
+ }
+ foreach ((string href, string refType) in ExtractReferences(
+ version.Content, maxJsonDepth))
+ {
+ WotResource? target = Resolve(snapshot, href);
+ list.Add(new WotDependency(
+ resource.Xid, href, target?.Xid, refType, target is not null));
+ if (target is not null && !byXid.ContainsKey(target.Xid))
+ {
+ byXid[target.Xid] = target;
+ queue.Enqueue(target);
+ }
+ }
+ }
+
+ // Weakly-connected components via union-find over resolved edges.
+ var parent = new Dictionary(StringComparer.Ordinal);
+ foreach (string xid in byXid.Keys)
+ {
+ parent[xid] = xid;
+ }
+ foreach (List list in edges.Values)
+ {
+ foreach (WotDependency edge in list)
+ {
+ if (edge.Resolved &&
+ edge.TargetXid is not null &&
+ byXid.ContainsKey(edge.TargetXid))
+ {
+ Union(parent, edge.SourceXid, edge.TargetXid);
+ }
+ }
+ }
+
+ var components = new Dictionary>(StringComparer.Ordinal);
+ foreach (KeyValuePair entry in byXid)
+ {
+ string root = Find(parent, entry.Key);
+ if (!components.TryGetValue(root, out List? members))
+ {
+ members = [];
+ components[root] = members;
+ }
+ members.Add(entry.Value);
+ }
+
+ ImmutableArray.Builder closures =
+ ImmutableArray.CreateBuilder();
+ foreach (List members in components.Values)
+ {
+ closures.Add(BuildClosure(members, edges, byXid));
+ }
+ // Deterministic order by closure key.
+ return [.. closures.OrderBy(c => c.Key, StringComparer.Ordinal)];
+ }
+
+ private static WotDependencyClosure BuildClosure(
+ List members,
+ Dictionary> edges,
+ Dictionary byXid)
+ {
+ var memberXids = new HashSet(members.Select(m => m.Xid), StringComparer.Ordinal);
+ ImmutableArray.Builder dependencies = ImmutableArray.CreateBuilder();
+ ImmutableArray.Builder diagnostics = ImmutableArray.CreateBuilder();
+ bool missing = false;
+
+ // Adjacency (source depends on target): target must be ordered first.
+ var adjacency = new Dictionary>(StringComparer.Ordinal);
+ foreach (WotResource member in members)
+ {
+ adjacency[member.Xid] = [];
+ }
+ foreach (WotResource member in members)
+ {
+ if (!edges.TryGetValue(member.Xid, out List? list))
+ {
+ continue;
+ }
+ foreach (WotDependency edge in list)
+ {
+ dependencies.Add(edge);
+ if (!edge.Resolved)
+ {
+ missing = true;
+ diagnostics.Add(
+ $"Unresolved {edge.RefType} dependency '{edge.TargetHref}' " +
+ $"referenced by '{edge.SourceXid}'.");
+ }
+ else if (edge.TargetXid is not null && memberXids.Contains(edge.TargetXid))
+ {
+ adjacency[member.Xid].Add(edge.TargetXid);
+ }
+ }
+ }
+
+ (ImmutableArray ordered, bool hasCycle) = TopologicalSort(
+ members, adjacency, byXid);
+ if (hasCycle)
+ {
+ diagnostics.Add(
+ "Dependency cycle detected among: " +
+ string.Join(", ", members.Select(m => m.Xid).OrderBy(x => x, StringComparer.Ordinal)));
+ }
+
+ string key = string.Join(
+ "|", members.Select(m => m.Xid).OrderBy(x => x, StringComparer.Ordinal));
+ var memberArray = members
+ .OrderBy(m => m.Xid, StringComparer.Ordinal)
+ .ToImmutableArray();
+ return new WotDependencyClosure(
+ key,
+ memberArray,
+ ordered,
+ dependencies.ToImmutable(),
+ diagnostics.ToImmutable(),
+ hasCycle,
+ missing);
+ }
+
+ private static (ImmutableArray Ordered, bool HasCycle) TopologicalSort(
+ List members,
+ Dictionary> adjacency,
+ Dictionary byXid)
+ {
+ // 0 = unvisited, 1 = in-progress, 2 = done.
+ var color = new Dictionary(StringComparer.Ordinal);
+ var ordered = new List();
+ bool hasCycle = false;
+
+ // Deterministic iteration order.
+ IEnumerable roots = members
+ .Select(m => m.Xid)
+ .OrderBy(x => x, StringComparer.Ordinal);
+
+ void Visit(string xid)
+ {
+ if (hasCycle)
+ {
+ return;
+ }
+ color.TryGetValue(xid, out int state);
+ if (state == 2)
+ {
+ return;
+ }
+ if (state == 1)
+ {
+ hasCycle = true;
+ return;
+ }
+ color[xid] = 1;
+ foreach (string dependency in adjacency[xid]
+ .OrderBy(x => x, StringComparer.Ordinal))
+ {
+ Visit(dependency);
+ if (hasCycle)
+ {
+ return;
+ }
+ }
+ color[xid] = 2;
+ ordered.Add(byXid[xid]);
+ }
+
+ foreach (string root in roots)
+ {
+ Visit(root);
+ }
+
+ return hasCycle
+ ? (ImmutableArray.Empty, true)
+ : ([.. ordered], false);
+ }
+
+ private static WotResource? MatchIn(IEnumerable resources, string href)
+ {
+ foreach (WotResource resource in resources)
+ {
+ if (string.Equals(resource.ThingId, href, StringComparison.Ordinal) ||
+ string.Equals(resource.Xid, href, StringComparison.Ordinal) ||
+ string.Equals(RegistryUri(resource), href, StringComparison.Ordinal) ||
+ string.Equals(resource.ResourceId, href, StringComparison.Ordinal) ||
+ href.EndsWith("/" + resource.ResourceId, StringComparison.Ordinal))
+ {
+ return resource;
+ }
+ }
+ return null;
+ }
+
+ private static string RegistryUri(WotResource resource)
+ {
+ return $"urn:wot:{resource.GroupId}/{resource.ResourceId}";
+ }
+
+ private static string TrimFragment(string href)
+ {
+ int hash = href.AsSpan().IndexOf('#');
+ return hash >= 0 ? href[..hash] : href;
+ }
+
+ private static void CollectLinks(
+ JsonElement root, List<(string, string)> references)
+ {
+ if (!root.TryGetProperty("links", out JsonElement links) ||
+ links.ValueKind != JsonValueKind.Array)
+ {
+ return;
+ }
+ foreach (JsonElement link in links.EnumerateArray())
+ {
+ if (link.ValueKind != JsonValueKind.Object ||
+ !link.TryGetProperty("href", out JsonElement hrefElement) ||
+ hrefElement.ValueKind != JsonValueKind.String)
+ {
+ continue;
+ }
+ string rel = link.TryGetProperty("rel", out JsonElement relElement) &&
+ relElement.ValueKind == JsonValueKind.String
+ ? relElement.GetString() ?? string.Empty
+ : string.Empty;
+ if (rel is "tm:extends" or "type" or "tm:submodel" or "collection" or "item")
+ {
+ references.Add((hrefElement.GetString() ?? string.Empty, rel));
+ }
+ }
+ }
+
+ private static void CollectExtends(
+ JsonElement root, List<(string, string)> references)
+ {
+ if (!root.TryGetProperty("tm:extends", out JsonElement extends))
+ {
+ return;
+ }
+ switch (extends.ValueKind)
+ {
+ case JsonValueKind.String:
+ references.Add((extends.GetString() ?? string.Empty, "tm:extends"));
+ break;
+ case JsonValueKind.Array:
+ foreach (JsonElement item in extends.EnumerateArray())
+ {
+ if (item.ValueKind == JsonValueKind.String)
+ {
+ references.Add((item.GetString() ?? string.Empty, "tm:extends"));
+ }
+ else if (item.ValueKind == JsonValueKind.Object &&
+ item.TryGetProperty("href", out JsonElement href) &&
+ href.ValueKind == JsonValueKind.String)
+ {
+ references.Add((href.GetString() ?? string.Empty, "tm:extends"));
+ }
+ }
+ break;
+ }
+ }
+
+ private static void CollectTmRefs(
+ JsonElement element,
+ List<(string, string)> references,
+ int depth,
+ int maxDepth)
+ {
+ if (depth > maxDepth)
+ {
+ return;
+ }
+ switch (element.ValueKind)
+ {
+ case JsonValueKind.Object:
+ foreach (JsonProperty property in element.EnumerateObject())
+ {
+ if (string.Equals(property.Name, "tm:ref", StringComparison.Ordinal) &&
+ property.Value.ValueKind == JsonValueKind.String)
+ {
+ references.Add((property.Value.GetString() ?? string.Empty, "tm:ref"));
+ }
+ else
+ {
+ CollectTmRefs(property.Value, references, depth + 1, maxDepth);
+ }
+ }
+ break;
+ case JsonValueKind.Array:
+ foreach (JsonElement item in element.EnumerateArray())
+ {
+ CollectTmRefs(item, references, depth + 1, maxDepth);
+ }
+ break;
+ }
+ }
+
+ private static string Find(Dictionary parent, string node)
+ {
+ string root = node;
+ while (!string.Equals(parent[root], root, StringComparison.Ordinal))
+ {
+ root = parent[root];
+ }
+ // Path compression.
+ while (!string.Equals(parent[node], root, StringComparison.Ordinal))
+ {
+ string next = parent[node];
+ parent[node] = root;
+ node = next;
+ }
+ return root;
+ }
+
+ private static void Union(Dictionary parent, string a, string b)
+ {
+ string rootA = Find(parent, a);
+ string rootB = Find(parent, b);
+ if (!string.Equals(rootA, rootB, StringComparison.Ordinal))
+ {
+ parent[rootB] = rootA;
+ }
+ }
+ }
+}
diff --git a/src/Opc.Ua.WotCon.Server/Materialization/WotMaterializationCoordinator.cs b/src/Opc.Ua.WotCon.Server/Materialization/WotMaterializationCoordinator.cs
new file mode 100644
index 0000000000..dde3cdf5b6
--- /dev/null
+++ b/src/Opc.Ua.WotCon.Server/Materialization/WotMaterializationCoordinator.cs
@@ -0,0 +1,1387 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Security.Cryptography;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Opc.Ua.Export;
+using Opc.Ua.Wot;
+using Opc.Ua.WotCon.Bindings;
+using Opc.Ua.WotCon.Server.Registry;
+
+namespace Opc.Ua.WotCon.Server.Materialization
+{
+ ///
+ /// Coordinates projecting registry documents into the AddressSpace. It parses
+ /// and validates each document with , builds the TD/TM
+ /// dependency closures, converts each closure to one or more NodeSet2
+ /// documents and projects them through the
+ /// (runtime NodeSet Add for first activation, ShadowReload for updates). The
+ /// stable registry NodeManager is kept separate. Independent closures commit
+ /// independently; a failed or invalid closure retains its previous active
+ /// generation. An unchanged closure (same digest, options and binder version)
+ /// returns and emits no model change.
+ ///
+ public sealed class WotMaterializationCoordinator : IDisposable
+ {
+ ///
+ /// Initializes a new coordinator.
+ ///
+ public WotMaterializationCoordinator(
+ IWotRegistryService registry,
+ IWotProjectionHost projectionHost,
+ IWotBinderRegistry? binderRegistry = null,
+ WotNodeSetConverterOptions? converterOptions = null,
+ IWotDocumentConverter? documentConverter = null,
+ IEnumerable? nodeSetContributors = null,
+ IWotNodeSetResolver? nodeSetResolver = null)
+ {
+ m_registry = registry ?? throw new ArgumentNullException(nameof(registry));
+ m_host = projectionHost ?? throw new ArgumentNullException(nameof(projectionHost));
+ m_binders = binderRegistry ?? NullWotBinderRegistry.Instance;
+ m_converterOptions = converterOptions ?? new WotNodeSetConverterOptions();
+ m_converter = documentConverter
+ ?? new WotNodeSetDocumentConverter(m_converterOptions);
+ m_nodeSetContributors = nodeSetContributors is null
+ ? []
+ : [.. nodeSetContributors];
+ m_nodeSetResolver = nodeSetResolver;
+ }
+
+ ///
+ /// Raised for each materialization event (resource / validation / load / refresh).
+ ///
+ public event EventHandler? Event;
+
+ ///
+ /// Gets the current refresh generation.
+ ///
+ public uint Generation => m_generation;
+
+ ///
+ /// Refreshes (re-projects) the registry into the AddressSpace and returns
+ /// the detailed result.
+ ///
+ ///
+ public async ValueTask RefreshAsync(
+ WotRefreshRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ if (request is null)
+ {
+ throw new ArgumentNullException(nameof(request));
+ }
+
+ if (!TryBeginOperation(allowDisposed: false))
+ {
+ throw new ObjectDisposedException(nameof(WotMaterializationCoordinator));
+ }
+
+ try
+ {
+ await m_mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ DateTime start = DateTime.UtcNow;
+ WotRegistrySnapshot snapshot = m_registry.Current;
+
+ if (request.ExpectedGeneration != 0 &&
+ request.ExpectedGeneration != m_generation)
+ {
+ return RejectedResult(request, start);
+ }
+
+ bool dryRun = request.Options?.DryRun ?? false;
+ bool force = request.Options?.Force ?? false;
+ bool strict = StrictBindings;
+ HashSet selectedXids = ResolveSelection(snapshot, request.Selection);
+
+ var enabled = snapshot.AllResources()
+ .Where(r => r.Enabled && r.DefaultVersion is not null)
+ .ToList();
+ ImmutableArray closures =
+ WotDependencyGraph.BuildClosures(
+ snapshot, enabled, m_converterOptions.MaxJsonDepth);
+
+ var targetKeys = new HashSet(
+ closures.Select(c => c.Key), StringComparer.Ordinal);
+
+ uint newGeneration = m_generation + 1;
+ ImmutableArray.Builder results =
+ ImmutableArray.CreateBuilder();
+ var projections = new List();
+ int succeeded = 0;
+ int unchanged = 0;
+ int failed = 0;
+ int skipped = 0;
+ int retired = 0;
+
+ // Retire tracked closures no longer desired (deleted / disabled /
+ // membership changed) after their monitored items drain.
+ (int retiredCount, ImmutableArray retiredResults) =
+ await ReconcileRetirementsAsync(
+ targetKeys, newGeneration, dryRun, cancellationToken).ConfigureAwait(false);
+ retired += retiredCount;
+ skipped += retiredResults.Length;
+ results.AddRange(retiredResults);
+
+ foreach (WotDependencyClosure closure in closures)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ bool inScope = selectedXids.Count == 0 ||
+ closure.OrderedResources.Any(r => selectedXids.Contains(r.Xid)) ||
+ MembersOf(closure).Any(r => selectedXids.Contains(r.Xid));
+
+ ClosureOutcome outcome = await ProcessClosureAsync(
+ snapshot, closure, newGeneration, force && inScope,
+ dryRun, strict, cancellationToken).ConfigureAwait(false);
+
+ foreach (WoTResourceLoadResultDataType result in outcome.Results)
+ {
+ string resultXid = result.Xid ?? string.Empty;
+ if (selectedXids.Count != 0 && !selectedXids.Contains(resultXid))
+ {
+ continue;
+ }
+ results.Add(result);
+ switch (result.Outcome)
+ {
+ case WoTOutcomeEnum.Success:
+ case WoTOutcomeEnum.Warning:
+ succeeded++;
+ break;
+ case WoTOutcomeEnum.Unchanged:
+ unchanged++;
+ break;
+ case WoTOutcomeEnum.Skipped:
+ skipped++;
+ break;
+ default:
+ failed++;
+ break;
+ }
+ }
+ projections.AddRange(outcome.Projections);
+ }
+
+ if (!dryRun && projections.Count > 0)
+ {
+ await m_registry.ApplyProjectionResultsAsync(
+ projections, cancellationToken).ConfigureAwait(false);
+ }
+ if (!dryRun)
+ {
+ m_generation = newGeneration;
+ }
+
+ WoTOutcomeEnum overall = failed > 0
+ ? (succeeded > 0 ? WoTOutcomeEnum.Warning : WoTOutcomeEnum.Failed)
+ : (succeeded > 0 ? WoTOutcomeEnum.Success : WoTOutcomeEnum.Unchanged);
+
+ var summary = new WoTRefreshSummaryDataType
+ {
+ RequestId = request.RequestId ?? string.Empty,
+ Generation = dryRun ? 0 : newGeneration,
+ Outcome = overall,
+ Atomicity = request.Options?.Atomicity ?? WoTAtomicityEnum.PerClosure,
+ StartTime = start,
+ EndTime = DateTime.UtcNow,
+ Total = (uint)results.Count,
+ Succeeded = (uint)succeeded,
+ Unchanged = (uint)unchanged,
+ Failed = (uint)failed,
+ Skipped = (uint)skipped,
+ Retired = (uint)retired
+ };
+
+ RaiseEvent(new WotMaterializationEventArgs(
+ WotMaterializationEventKind.RefreshCompleted)
+ {
+ Generation = newGeneration,
+ RequestId = request.RequestId ?? string.Empty,
+ Outcome = overall,
+ Summary = summary
+ });
+
+ return new WotRefreshResult(
+ summary, results.ToImmutable(), dryRun ? 0u : newGeneration);
+ }
+ finally
+ {
+ m_mutex.Release();
+ }
+ }
+ finally
+ {
+ EndOperation();
+ }
+ }
+
+ ///
+ /// Removes all live projections (used during NodeManager shutdown).
+ ///
+ public async ValueTask RemoveAllAsync(CancellationToken cancellationToken = default)
+ {
+ if (!TryBeginOperation(allowDisposed: true))
+ {
+ return;
+ }
+
+ try
+ {
+ await m_mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ foreach (ClosureState state in m_closures.Values)
+ {
+ // Deactivate bindings before removing the projection (before
+ // retirement / unload), then release the projection handle.
+ foreach (WotBindingPlan plan in state.BindingPlans)
+ {
+ await m_binders.DeactivateAsync(plan, cancellationToken)
+ .ConfigureAwait(false);
+ }
+ if (state.Handle is not null)
+ {
+ await m_host.RemoveAsync(state.Handle, cancellationToken)
+ .ConfigureAwait(false);
+ }
+ }
+ m_closures.Clear();
+ }
+ finally
+ {
+ m_mutex.Release();
+ }
+ }
+ finally
+ {
+ EndOperation();
+ }
+ }
+
+ ///
+ /// Gets or sets whether unsupported forms fail a strict closure.
+ ///
+ public bool StrictBindings { get; set; }
+
+ ///
+ /// Gets or sets how previous projection generations are retired after a
+ /// successful version switch.
+ ///
+ public WotProjectionRetirementPolicy RetirementPolicy { get; set; } =
+ WotProjectionRetirementPolicy.Graceful;
+
+ ///
+ /// Gets the binding capability snapshots advertised by the registered
+ /// binders. These populate the registry SelectedBindings node and
+ /// contribute to refresh unchanged-detection.
+ ///
+ public IReadOnlyList BindingCapabilities => m_binders.Capabilities;
+
+ ///
+ /// Gets or sets the live server namespace table used to resolve a
+ /// projection's recorded root into a
+ /// concrete server NodeId after its owning namespace is registered by
+ /// the projection host. When null, materialized root NodeIds are
+ /// not reported.
+ ///
+ public NamespaceTable? ServerNamespaceUris { get; set; }
+
+ ///
+ /// Releases the mutex used to serialise refreshes.
+ ///
+ public void Dispose()
+ {
+ bool disposeMutex;
+ lock (m_lifetimeLock)
+ {
+ if (m_disposed != 0)
+ {
+ return;
+ }
+ m_disposed = 1;
+ disposeMutex = TryReserveMutexDisposal();
+ }
+ if (disposeMutex)
+ {
+ m_mutex.Dispose();
+ }
+ }
+
+ private bool TryBeginOperation(bool allowDisposed)
+ {
+ lock (m_lifetimeLock)
+ {
+ if (m_mutexDisposed || (!allowDisposed && m_disposed != 0))
+ {
+ return false;
+ }
+ m_activeOperations++;
+ return true;
+ }
+ }
+
+ private void EndOperation()
+ {
+ bool disposeMutex;
+ lock (m_lifetimeLock)
+ {
+ m_activeOperations--;
+ disposeMutex = TryReserveMutexDisposal();
+ }
+ if (disposeMutex)
+ {
+ m_mutex.Dispose();
+ }
+ }
+
+ private bool TryReserveMutexDisposal()
+ {
+ if (m_disposed == 0 ||
+ m_activeOperations != 0 ||
+ m_closures.Count != 0 ||
+ m_mutexDisposed)
+ {
+ return false;
+ }
+ m_mutexDisposed = true;
+ return true;
+ }
+
+ private static ByteString DigestOf(WotResource resource)
+ {
+ return (ByteString)(resource.DefaultVersion?.Digest ?? []);
+ }
+
+ private async ValueTask ProcessClosureAsync(
+ WotRegistrySnapshot snapshot,
+ WotDependencyClosure closure,
+ uint generation,
+ bool force,
+ bool dryRun,
+ bool strict,
+ CancellationToken cancellationToken)
+ {
+ ImmutableArray.Builder results =
+ ImmutableArray.CreateBuilder();
+ var projections = new List();
+ IReadOnlyList members = MembersOf(closure);
+
+ // Unprojectable closure: cycle or missing dependency. Retain the
+ // previous active generation and mark members failed.
+ if (!closure.IsProjectable)
+ {
+ WoTPhaseEnum phase = closure.HasMissingDependency
+ ? WoTPhaseEnum.DependencyResolution
+ : WoTPhaseEnum.DependencyResolution;
+ string reason = string.Join("; ", closure.Diagnostics);
+ foreach (WotResource member in members)
+ {
+ results.Add(FailResult(member, generation, phase, reason));
+ projections.Add(FailProjection(member, reason));
+ RaiseLoadFailure(member, generation, reason);
+ }
+ return new ClosureOutcome(results.ToImmutable(), projections);
+ }
+
+ // Project in topological (dependency-first) order.
+ members = closure.OrderedResources;
+
+ byte[] aggregateDigest = ComputeAggregateDigest(members);
+ m_closures.TryGetValue(closure.Key, out ClosureState? tracked);
+
+ // Unchanged: same digest/options/binder version, and not forced.
+ if (tracked?.Handle is not null &&
+ !force &&
+ WotContentDigest.Equal(tracked.AggregateDigest, aggregateDigest))
+ {
+ foreach (WotResource member in members)
+ {
+ results.Add(UnchangedResult(member, tracked.Generation));
+ }
+ return new ClosureOutcome(results.ToImmutable(), projections);
+ }
+
+ // Convert every member to a NodeSet2 source in dependency order.
+ ImmutableArray.Builder sources = ImmutableArray.CreateBuilder();
+ var perMemberNodeCount = new Dictionary(StringComparer.Ordinal);
+ var perMemberRoot = new Dictionary(StringComparer.Ordinal);
+ var bindingPlans = new List();
+ bool degraded = false;
+ var requiredNamespaces = new HashSet(StringComparer.Ordinal);
+ var ownedNamespaces = new HashSet(StringComparer.Ordinal);
+
+ foreach (WotResource member in members)
+ {
+ WotResourceVersion? version = member.DefaultVersion;
+ if (version is null)
+ {
+ const string reason = "Resource has no default version.";
+ results.Add(FailResult(member, generation, WoTPhaseEnum.Fetch, reason));
+ projections.Add(FailProjection(member, reason));
+ RaiseLoadFailure(member, generation, reason);
+ return new ClosureOutcome(results.ToImmutable(), projections);
+ }
+
+ (UANodeSet? nodeSet, ExpandedNodeId root, string? conversionError) =
+ await TryConvertAsync(member, snapshot, cancellationToken)
+ .ConfigureAwait(false);
+ if (nodeSet is not null && m_nodeSetContributors.Length > 0)
+ {
+ // Contributors run after conversion and before any variable is created, which
+ // is when a programmatically discovered DataType (a controller UDT, say) has to
+ // exist for a uav:mapByFieldPath mapping to resolve against it.
+ foreach (IWotNodeSetContributor contributor in m_nodeSetContributors)
+ {
+ await contributor
+ .ContributeAsync(member, nodeSet, cancellationToken)
+ .ConfigureAwait(false);
+ }
+ }
+ if (nodeSet is null)
+ {
+ WoTValidationOutcomeDataType validation = FormatFailure(conversionError);
+ results.Add(FailResult(
+ member, generation, WoTPhaseEnum.FormatValidation, conversionError));
+ projections.Add(FailProjection(member, conversionError, validation));
+ RaiseValidationFailure(member, generation, validation, conversionError);
+ return new ClosureOutcome(results.ToImmutable(), projections);
+ }
+
+ WotBindingPlan plan = m_binders.Prepare(BuildPlanRequest(member, version));
+ bindingPlans.Add(plan);
+ if (!plan.FullySupported)
+ {
+ if (strict)
+ {
+ const string reason = "Unsupported binding forms in a strict closure.";
+ results.Add(FailResult(
+ member, generation, WoTPhaseEnum.Projection, reason));
+ projections.Add(FailProjection(member, reason));
+ RaiseBindingFailure(member, reason);
+ return new ClosureOutcome(results.ToImmutable(), projections);
+ }
+ degraded = true;
+ RaiseBindingFailure(member,
+ "Unsupported binding forms materialized as degraded nodes.");
+ }
+ else if (plan.HasNonExecutableForms)
+ {
+ // A validated plan whose binding has no runtime executor (for
+ // example a planner-only protocol): materialize the nodes but
+ // flag the closure as degraded so callers know they cannot be
+ // driven yet.
+ degraded = true;
+ }
+
+ byte[] xml = SerializeNodeSet(nodeSet);
+ perMemberNodeCount[member.Xid] = nodeSet.Items?.Length ?? 0;
+ if (!root.IsNull)
+ {
+ perMemberRoot[member.Xid] = root;
+ }
+ sources.Add(new WotProjectionSource(
+ member.ResourceId, OwnedModelUris(nodeSet), xml));
+ CollectRequiredNamespaces(nodeSet, requiredNamespaces, ownedNamespaces);
+ }
+
+ // Resolve any companion-specification namespace the closure depends on that neither the
+ // closure itself nor the server already provides. Resolved models are prepended to the
+ // sources so they materialize before the documents that reference them. A namespace
+ // that stays unresolved is reported, never silently dropped: the projection then fails
+ // with a message naming exactly what is missing.
+ (ImmutableArray resolved, ImmutableArray unresolved) =
+ await ResolveDependencyModelsAsync(
+ requiredNamespaces, ownedNamespaces, cancellationToken)
+ .ConfigureAwait(false);
+ if (!resolved.IsDefaultOrEmpty)
+ {
+ sources.InsertRange(0, resolved);
+ }
+ if (!unresolved.IsDefaultOrEmpty)
+ {
+ degraded = true;
+ foreach (WotResource member in members)
+ {
+ RaiseBindingFailure(
+ member,
+ "Unresolved dependency namespace(s): " + string.Join(", ", unresolved));
+ }
+ }
+
+ if (dryRun)
+ {
+ foreach (WotResource member in members)
+ {
+ results.Add(new WoTResourceLoadResultDataType
+ {
+ Xid = member.Xid,
+ GroupId = member.GroupId,
+ ResourceId = member.ResourceId,
+ VersionId = member.DefaultVersionId ?? string.Empty,
+ Kind = member.Kind,
+ Outcome = degraded ? WoTOutcomeEnum.Warning : WoTOutcomeEnum.Success,
+ Phase = WoTPhaseEnum.Projection,
+ LoadState = member.LoadState,
+ Generation = generation,
+ MaterializedNodeCount = (uint)(perMemberNodeCount.TryGetValue(
+ member.Xid, out int c) ? c : 0),
+ ContentDigest = DigestOf(member),
+ Message = "Dry run; no projection committed. Candidate generation " +
+ generation.ToString(CultureInfo.InvariantCulture) + "."
+ });
+ }
+ return new ClosureOutcome(results.ToImmutable(), projections);
+ }
+
+ var document = new WotProjectionDocument(
+ closure.Key, sources.ToImmutable(), bindingPlans.ToArrayOf());
+ WotProjectionHandle handle;
+ try
+ {
+ if (tracked?.Handle is null)
+ {
+ handle = await m_host.AddAsync(document, cancellationToken)
+ .ConfigureAwait(false);
+ }
+ else if (RetirementPolicy == WotProjectionRetirementPolicy.Immediate)
+ {
+ handle = await m_host.ImmediateReloadAsync(
+ tracked.Handle, document, cancellationToken).ConfigureAwait(false);
+ }
+ else
+ {
+ handle = await m_host.ShadowReloadAsync(
+ tracked.Handle, document, cancellationToken).ConfigureAwait(false);
+ }
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ // Projection failed: retain the previous active generation and its
+ // tracked binding plans. The shadow switch never happened, so the
+ // old plans remain active and no deactivation is performed
+ // (rollback: old plans survive when the new switch fails).
+ foreach (WotResource member in members)
+ {
+ results.Add(FailResult(
+ member, generation, WoTPhaseEnum.Activation, ex.Message));
+ projections.Add(FailProjection(member, ex.Message));
+ RaiseLoadFailure(member, generation, ex.Message);
+ }
+ return new ClosureOutcome(results.ToImmutable(), projections);
+ }
+
+ string projectionWarning = handle.Warning;
+ if (projectionWarning.Length != 0)
+ {
+ degraded = true;
+ }
+
+ // The shadow switch (or first add) succeeded. On an update, retire the
+ // previously tracked binding plans before publishing the new closure
+ // state so they are not leaked. This runs after the successful switch
+ // and before the closure state is replaced; deactivating the old plans
+ // first (then activating the new plans below) keeps a resource that is
+ // shared between the old and new plan sets continuously bound.
+ if (tracked is not null)
+ {
+ foreach (WotBindingPlan plan in tracked.BindingPlans)
+ {
+ await m_binders.DeactivateAsync(plan, cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ m_closures[closure.Key] = new ClosureState
+ {
+ Key = closure.Key,
+ Handle = handle,
+ AggregateDigest = aggregateDigest,
+ Generation = generation,
+ MemberXids = [.. members.Select(m => m.Xid)],
+ Members = [.. members.Select(m => new ClosureMemberState
+ {
+ Xid = m.Xid,
+ GroupId = m.GroupId,
+ ResourceId = m.ResourceId,
+ VersionId = m.DefaultVersionId ?? string.Empty,
+ Kind = m.Kind,
+ ContentDigest = DigestOf(m)
+ })],
+ ModelNamespaceUris = [.. sources.SelectMany(s => s.ModelNamespaceUris)],
+ BindingPlans = [.. bindingPlans]
+ };
+ foreach (string namespaceUri in sources.SelectMany(s => s.ModelNamespaceUris))
+ {
+ m_projectionNamespaceUris.Add(namespaceUri);
+ }
+
+ foreach (WotBindingPlan plan in bindingPlans)
+ {
+ await m_binders.ActivateAsync(plan, cancellationToken).ConfigureAwait(false);
+ }
+
+ WoTOutcomeEnum memberOutcome = degraded ? WoTOutcomeEnum.Warning : WoTOutcomeEnum.Success;
+ foreach (WotResource member in members)
+ {
+ int nodeCount = perMemberNodeCount.TryGetValue(member.Xid, out int c) ? c : 0;
+ NodeId rootNodeId = perMemberRoot.TryGetValue(member.Xid, out ExpandedNodeId root)
+ ? ResolveRootNodeId(root)
+ : NodeId.Null;
+ WoTValidationOutcomeDataType validation = SuccessValidation();
+ results.Add(new WoTResourceLoadResultDataType
+ {
+ Xid = member.Xid,
+ GroupId = member.GroupId,
+ ResourceId = member.ResourceId,
+ VersionId = member.DefaultVersionId ?? string.Empty,
+ Kind = member.Kind,
+ Outcome = memberOutcome,
+ Phase = WoTPhaseEnum.Activation,
+ LoadState = WoTLoadStateEnum.Active,
+ Generation = generation,
+ MaterializedNodeCount = (uint)nodeCount,
+ RootNodeId = rootNodeId,
+ ContentDigest = DigestOf(member),
+ Message = projectionWarning.Length != 0
+ ? "Projected with warning: " + projectionWarning
+ : degraded ? "Projected with degraded bindings." : "Projected."
+ });
+ projections.Add(new WotResourceProjection(
+ member.GroupId,
+ member.ResourceId,
+ WoTLoadStateEnum.Active,
+ member.DefaultVersionId,
+ generation,
+ nodeCount,
+ rootNodeId,
+ validation,
+ projectionWarning.Length == 0
+ ? []
+ : [projectionWarning],
+ DateTime.UtcNow));
+ RaiseResource(member, generation, memberOutcome, WoTLoadStateEnum.Active);
+ }
+
+ return new ClosureOutcome(results.ToImmutable(), projections);
+ }
+
+ private async ValueTask<(int Retired, ImmutableArray Results)>
+ ReconcileRetirementsAsync(
+ HashSet targetKeys,
+ uint generation,
+ bool dryRun,
+ CancellationToken cancellationToken)
+ {
+ ImmutableArray.Builder results =
+ ImmutableArray.CreateBuilder();
+ int retired = 0;
+ foreach (string key in (List)[.. m_closures.Keys.Where(k => !targetKeys.Contains(k))])
+ {
+ if (m_closures.TryGetValue(key, out ClosureState? state))
+ {
+ if (state.Handle is not null)
+ {
+ retired++;
+ foreach (ClosureMemberState member in state.Members)
+ {
+ results.Add(RetiredResult(member, generation));
+ }
+ if (dryRun)
+ {
+ continue;
+ }
+ // Deactivate bindings before retiring the projection.
+ foreach (WotBindingPlan plan in state.BindingPlans)
+ {
+ await m_binders.DeactivateAsync(plan, cancellationToken)
+ .ConfigureAwait(false);
+ }
+ await m_host.RemoveAsync(state.Handle, cancellationToken)
+ .ConfigureAwait(false);
+ }
+ if (!dryRun)
+ {
+ m_closures.Remove(key);
+ }
+ }
+ }
+ return (retired, dryRun ? results.ToImmutable() : []);
+ }
+
+ private async ValueTask<(UANodeSet? NodeSet, ExpandedNodeId Root, string? Error)> TryConvertAsync(
+ WotResource resource, WotRegistrySnapshot snapshot, CancellationToken cancellationToken)
+ {
+ WotResourceVersion? version = resource.DefaultVersion;
+ if (version is null)
+ {
+ return (null, default, "Resource has no default version.");
+ }
+ WotConversionOutput output = await m_converter
+ .ConvertAsync(resource, version.Content, snapshot, cancellationToken)
+ .ConfigureAwait(false);
+ if (!output.Succeeded)
+ {
+ return (null, default, output.Errors.IsDefaultOrEmpty
+ ? "The document could not be converted to a NodeSet."
+ : string.Join("; ", output.Errors));
+ }
+ return (output.NodeSet, output.RootNodeId, null);
+ }
+
+ ///
+ /// Resolves a projection root, recorded before lifecycle add as an
+ /// absolute , into a concrete server NodeId
+ /// once its owning namespace has been registered by the projection host.
+ /// Returns NodeId.Null when there is no root or the namespace table is
+ /// unavailable or does not yet contain the owning namespace.
+ ///
+ private NodeId ResolveRootNodeId(ExpandedNodeId root)
+ {
+ if (root.IsNull)
+ {
+ return NodeId.Null;
+ }
+ NamespaceTable? namespaces = ServerNamespaceUris;
+ if (namespaces is null)
+ {
+ return NodeId.Null;
+ }
+ var resolved = ExpandedNodeId.ToNodeId(root, namespaces);
+ return resolved.IsNull ? NodeId.Null : resolved;
+ }
+
+ private WotBindingPlanRequest BuildPlanRequest(
+ WotResource resource, WotResourceVersion version)
+ {
+ return WotBindingPlanRequest.FromDocument(
+ resource.Xid, resource.Kind, version.Content, m_converterOptions.MaxJsonDepth);
+ }
+
+ private byte[] ComputeAggregateDigest(IReadOnlyList members)
+ {
+ using var sha = SHA256.Create();
+ using var buffer = new MemoryStream();
+ using (var writer = new BinaryWriter(buffer, Encoding.UTF8, leaveOpen: true))
+ {
+ foreach (WotResource member in members
+ .OrderBy(m => m.Xid, StringComparer.Ordinal))
+ {
+ writer.Write(member.Xid);
+ writer.Write(member.DefaultVersionId ?? string.Empty);
+ byte[] digest = member.DefaultVersion?.Digest ?? [];
+ writer.Write(digest.Length);
+ writer.Write(digest);
+ }
+ writer.Write(m_converterOptions.MaxJsonDepth);
+ writer.Write(BinderVersion);
+ }
+ buffer.Position = 0;
+ // TODO: SHA256.HashData(ReadOnlySpan) is only available on .NET 5+;
+ // this project also targets net472/net48/netstandard2.1, where the instance
+ // ComputeHash API is the portable equivalent. Revisit if the minimum TFM
+ // floor is ever raised to drop those targets.
+#pragma warning disable CA1850
+ return sha.ComputeHash(buffer.ToArray());
+#pragma warning restore CA1850
+ }
+
+ private string BinderVersion
+ {
+ get
+ {
+ IReadOnlyList caps = m_binders.Capabilities;
+ if (caps.Count == 0)
+ {
+ return "none";
+ }
+ var builder = new StringBuilder();
+ foreach (WoTBindingCapabilityDataType cap in caps)
+ {
+ builder.Append(cap.BindingUri).Append(';').Append(cap.ProfileVersion).Append('|');
+ }
+ return builder.ToString();
+ }
+ }
+
+ private static byte[] SerializeNodeSet(UANodeSet nodeSet)
+ {
+ using var stream = new MemoryStream();
+ nodeSet.Write(stream);
+ return stream.ToArray();
+ }
+
+ private static ImmutableArray OwnedModelUris(UANodeSet nodeSet)
+ {
+ if (nodeSet.Models is { Length: > 0 })
+ {
+ var uris = new List(nodeSet.Models.Length);
+ foreach (ModelTableEntry model in nodeSet.Models)
+ {
+ if (!string.IsNullOrEmpty(model.ModelUri))
+ {
+ uris.Add(model.ModelUri);
+ }
+ }
+ if (uris.Count > 0)
+ {
+ return [.. uris];
+ }
+ }
+ if (nodeSet.NamespaceUris is { Length: > 0 })
+ {
+ return
+ [
+ .. nodeSet.NamespaceUris
+ .Where(u => !string.Equals(u, Ua.Namespaces.OpcUa, StringComparison.Ordinal))
+ ];
+ }
+ return [];
+ }
+
+ ///
+ /// Records the namespaces a converted NodeSet owns and the ones it declares a dependency
+ /// on, so the closure's unmet dependencies can be resolved once for the whole projection.
+ ///
+ private static void CollectRequiredNamespaces(
+ UANodeSet nodeSet,
+ HashSet required,
+ HashSet owned)
+ {
+ foreach (string uri in OwnedModelUris(nodeSet))
+ {
+ owned.Add(uri);
+ }
+ if (nodeSet.Models is null)
+ {
+ return;
+ }
+ foreach (ModelTableEntry model in nodeSet.Models)
+ {
+ if (model?.RequiredModel is null)
+ {
+ continue;
+ }
+ foreach (ModelTableEntry dependency in model.RequiredModel)
+ {
+ if (!string.IsNullOrEmpty(dependency?.ModelUri) &&
+ !string.Equals(
+ dependency!.ModelUri, Ua.Namespaces.OpcUa, StringComparison.Ordinal))
+ {
+ required.Add(dependency.ModelUri);
+ }
+ }
+ }
+ }
+
+ ///
+ /// Asks the configured for every dependency namespace the
+ /// closure needs but neither owns nor finds on the server, recursing into whatever it gets
+ /// back. Returns the resolved models in dependency order together with the namespaces that
+ /// stayed unresolved.
+ ///
+ private async ValueTask<(ImmutableArray Resolved,
+ ImmutableArray Unresolved)> ResolveDependencyModelsAsync(
+ HashSet required,
+ HashSet owned,
+ CancellationToken cancellationToken)
+ {
+ var pending = new Queue();
+ foreach (string uri in required)
+ {
+ if (!owned.Contains(uri) && !IsKnownToServer(uri))
+ {
+ pending.Enqueue(uri);
+ }
+ }
+ if (pending.Count == 0)
+ {
+ return ([], []);
+ }
+ if (m_nodeSetResolver is null)
+ {
+ return ([], [.. pending]);
+ }
+
+ ImmutableArray.Builder resolved =
+ ImmutableArray.CreateBuilder();
+ ImmutableArray.Builder unresolved = ImmutableArray.CreateBuilder();
+ var seen = new HashSet(pending, StringComparer.Ordinal);
+ while (pending.Count > 0)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ string uri = pending.Dequeue();
+ Stream? stream = await m_nodeSetResolver
+ .TryResolveAsync(uri, cancellationToken)
+ .ConfigureAwait(false);
+ if (stream is null)
+ {
+ unresolved.Add(uri);
+ continue;
+ }
+
+ byte[] xml;
+ UANodeSet? dependency;
+ using (stream)
+ {
+ MemoryStream? buffer = await CopyResolverDocumentAsync(
+ stream, uri, unresolved, cancellationToken)
+ .ConfigureAwait(false);
+ if (buffer is null)
+ {
+ continue;
+ }
+ using (buffer)
+ {
+ xml = buffer.ToArray();
+ buffer.Position = 0;
+ dependency = UANodeSet.Read(buffer);
+ }
+ }
+ if (dependency is null)
+ {
+ // A resolver that hands back something unreadable is treated exactly like one
+ // that declined, so the namespace is reported rather than faulting onboarding.
+ unresolved.Add(uri);
+ continue;
+ }
+
+ foreach (string ownedUri in OwnedModelUris(dependency))
+ {
+ owned.Add(ownedUri);
+ }
+ // A resolved model may itself depend on further namespaces.
+ var nested = new HashSet(StringComparer.Ordinal);
+ CollectRequiredNamespaces(dependency, nested, owned);
+ foreach (string nestedUri in nested)
+ {
+ if (!owned.Contains(nestedUri) &&
+ !IsKnownToServer(nestedUri) &&
+ seen.Add(nestedUri))
+ {
+ pending.Enqueue(nestedUri);
+ }
+ }
+ resolved.Add(new WotProjectionSource(uri, OwnedModelUris(dependency), xml));
+ }
+
+ // Dependencies are appended in resolution order, so reverse to put the deepest model
+ // first: a model must be materialized before the model that requires it.
+ resolved.Reverse();
+ return (resolved.ToImmutable(), unresolved.ToImmutable());
+ }
+
+ private bool IsKnownToServer(string namespaceUri)
+ {
+ foreach (ClosureState closure in m_closures.Values)
+ {
+ if (closure.ModelNamespaceUris.Contains(namespaceUri, StringComparer.Ordinal))
+ {
+ return true;
+ }
+ }
+ if (m_projectionNamespaceUris.Contains(namespaceUri))
+ {
+ return false;
+ }
+ NamespaceTable? namespaces = ServerNamespaceUris;
+ return namespaces is not null && namespaces.GetIndex(namespaceUri) >= 0;
+ }
+
+ private async ValueTask CopyResolverDocumentAsync(
+ Stream stream,
+ string namespaceUri,
+ ImmutableArray.Builder unresolved,
+ CancellationToken cancellationToken)
+ {
+ int maxBytes = m_converterOptions.MaxResolverDocumentBytes;
+ var buffer = new MemoryStream();
+ var chunk = new byte[81920];
+ bool keepBuffer = false;
+ try
+ {
+ while (true)
+ {
+ int read = await ReadBlockAsync(stream, chunk, cancellationToken)
+ .ConfigureAwait(false);
+ if (read == 0)
+ {
+ buffer.Position = 0;
+ keepBuffer = true;
+ return buffer;
+ }
+ if (buffer.Length + read > maxBytes)
+ {
+ unresolved.Add(
+ namespaceUri +
+ " (resolver response exceeded " +
+ maxBytes.ToString(CultureInfo.InvariantCulture) +
+ " bytes)");
+ return null;
+ }
+ buffer.Write(chunk, 0, read);
+ }
+ }
+ finally
+ {
+ if (!keepBuffer)
+ {
+ buffer.Dispose();
+ }
+ }
+ }
+
+ private static async ValueTask ReadBlockAsync(
+ Stream stream,
+ byte[] buffer,
+ CancellationToken cancellationToken)
+ {
+#if NETFRAMEWORK || NETSTANDARD2_0
+ return await stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken)
+ .ConfigureAwait(false);
+#else
+ return await stream.ReadAsync(buffer.AsMemory(), cancellationToken)
+ .ConfigureAwait(false);
+#endif
+ }
+
+ private static IReadOnlyList MembersOf(WotDependencyClosure closure)
+ {
+ return closure.Members.IsDefaultOrEmpty
+ ? Array.Empty()
+ : closure.Members;
+ }
+
+ private HashSet ResolveSelection(
+ WotRegistrySnapshot snapshot,
+ ImmutableArray selectors)
+ {
+ var set = new HashSet(StringComparer.Ordinal);
+ if (selectors.IsDefaultOrEmpty)
+ {
+ return set;
+ }
+ foreach (WoTResourceSelectorDataType selector in selectors)
+ {
+ foreach (WotResource resource in snapshot.AllResources())
+ {
+ if (Matches(resource, selector))
+ {
+ set.Add(resource.Xid);
+ }
+ }
+ }
+ return set;
+ }
+
+ private static bool Matches(WotResource resource, WoTResourceSelectorDataType selector)
+ {
+ if (!string.IsNullOrEmpty(selector.Xid) &&
+ !string.Equals(selector.Xid, resource.Xid, StringComparison.Ordinal))
+ {
+ return false;
+ }
+ if (!string.IsNullOrEmpty(selector.GroupId) &&
+ !string.Equals(selector.GroupId, resource.GroupId, StringComparison.Ordinal))
+ {
+ return false;
+ }
+ if (!string.IsNullOrEmpty(selector.ResourceId) &&
+ !string.Equals(selector.ResourceId, resource.ResourceId, StringComparison.Ordinal))
+ {
+ return false;
+ }
+ return true;
+ }
+
+ private static WoTResourceLoadResultDataType FailResult(
+ WotResource resource, uint generation, WoTPhaseEnum phase, string? message)
+ {
+ return new()
+ {
+ Xid = resource.Xid,
+ GroupId = resource.GroupId,
+ ResourceId = resource.ResourceId,
+ VersionId = resource.DefaultVersionId ?? string.Empty,
+ Kind = resource.Kind,
+ Outcome = WoTOutcomeEnum.Failed,
+ Phase = phase,
+ LoadState = WoTLoadStateEnum.Failed,
+ Generation = generation,
+ MaterializedNodeCount = 0,
+ ContentDigest = DigestOf(resource),
+ Message = message ?? string.Empty
+ };
+ }
+
+ private static WoTResourceLoadResultDataType UnchangedResult(
+ WotResource resource, uint generation)
+ {
+ return new()
+ {
+ Xid = resource.Xid,
+ GroupId = resource.GroupId,
+ ResourceId = resource.ResourceId,
+ VersionId = resource.ActiveVersionId ?? resource.DefaultVersionId ?? string.Empty,
+ Kind = resource.Kind,
+ Outcome = WoTOutcomeEnum.Unchanged,
+ Phase = WoTPhaseEnum.Activation,
+ LoadState = WoTLoadStateEnum.Active,
+ Generation = generation,
+ MaterializedNodeCount = (uint)resource.MaterializedNodeCount,
+ ContentDigest = DigestOf(resource),
+ Message = "Content digest unchanged."
+ };
+ }
+
+ private static WoTResourceLoadResultDataType RetiredResult(
+ ClosureMemberState member, uint generation)
+ {
+ return new()
+ {
+ Xid = member.Xid,
+ GroupId = member.GroupId,
+ ResourceId = member.ResourceId,
+ VersionId = member.VersionId,
+ Kind = member.Kind,
+ Outcome = WoTOutcomeEnum.Skipped,
+ Phase = WoTPhaseEnum.Activation,
+ LoadState = WoTLoadStateEnum.Unloaded,
+ Generation = generation,
+ MaterializedNodeCount = 0,
+ ContentDigest = member.ContentDigest,
+ Message = "Dry run; projection would be retired at candidate generation " +
+ generation.ToString(CultureInfo.InvariantCulture) + "."
+ };
+ }
+
+ private static WotResourceProjection FailProjection(
+ WotResource resource, string? message, WoTValidationOutcomeDataType? validation = null)
+ {
+ return new(
+ resource.GroupId,
+ resource.ResourceId,
+ WoTLoadStateEnum.Failed,
+ activeVersionId: null,
+ resource.RefreshGeneration,
+ resource.MaterializedNodeCount,
+ rootNodeId: NodeId.Null,
+ validation,
+ string.IsNullOrEmpty(message)
+ ? []
+ : [message!],
+ DateTime.UtcNow)
+ {
+ // Keep the previous active projection when a refresh fails.
+ RetainPreviousActiveVersion = true
+ };
+ }
+
+ private static WoTValidationOutcomeDataType SuccessValidation()
+ {
+ return new()
+ {
+ FormatValidated = true,
+ FormatOutcome = WoTOutcomeEnum.Success,
+ CompatibilityValidated = true,
+ CompatibilityOutcome = WoTOutcomeEnum.Success,
+ ValidatedAt = DateTime.UtcNow,
+ VocabularyVersion = WotNodeSetConverter.VocabularyNamespace
+ };
+ }
+
+ private static WoTValidationOutcomeDataType FormatFailure(string? reason)
+ {
+ return new()
+ {
+ FormatValidated = true,
+ FormatOutcome = WoTOutcomeEnum.Failed,
+ FormatReason = reason ?? string.Empty,
+ CompatibilityValidated = false,
+ CompatibilityOutcome = WoTOutcomeEnum.Skipped,
+ ValidatedAt = DateTime.UtcNow,
+ VocabularyVersion = WotNodeSetConverter.VocabularyNamespace
+ };
+ }
+
+ private void RaiseResource(
+ WotResource resource, uint generation, WoTOutcomeEnum outcome, WoTLoadStateEnum state)
+ {
+ RaiseEvent(new WotMaterializationEventArgs(WotMaterializationEventKind.Resource)
+ {
+ Xid = resource.Xid,
+ ResourceId = resource.ResourceId,
+ VersionId = resource.DefaultVersionId ?? string.Empty,
+ DocumentKind = resource.Kind,
+ Generation = generation,
+ Phase = WoTPhaseEnum.Activation,
+ Outcome = outcome,
+ LoadState = state
+ });
+ }
+
+ private void RaiseLoadFailure(WotResource resource, uint generation, string? reason)
+ {
+ RaiseEvent(new WotMaterializationEventArgs(WotMaterializationEventKind.LoadFailure)
+ {
+ Xid = resource.Xid,
+ ResourceId = resource.ResourceId,
+ VersionId = resource.DefaultVersionId ?? string.Empty,
+ DocumentKind = resource.Kind,
+ Generation = generation,
+ Phase = WoTPhaseEnum.Projection,
+ Outcome = WoTOutcomeEnum.Failed,
+ LoadState = WoTLoadStateEnum.Failed,
+ Reason = reason ?? string.Empty
+ });
+ }
+
+ private void RaiseValidationFailure(
+ WotResource resource, uint generation,
+ WoTValidationOutcomeDataType validation, string? reason)
+ {
+ RaiseEvent(new WotMaterializationEventArgs(
+ WotMaterializationEventKind.ValidationFailure)
+ {
+ Xid = resource.Xid,
+ ResourceId = resource.ResourceId,
+ VersionId = resource.DefaultVersionId ?? string.Empty,
+ DocumentKind = resource.Kind,
+ Generation = generation,
+ Phase = WoTPhaseEnum.FormatValidation,
+ Outcome = WoTOutcomeEnum.Failed,
+ LoadState = WoTLoadStateEnum.Failed,
+ Validation = validation,
+ Reason = reason ?? string.Empty
+ });
+ }
+
+ private void RaiseBindingFailure(WotResource resource, string? reason)
+ {
+ RaiseEvent(new WotMaterializationEventArgs(
+ WotMaterializationEventKind.BindingFailure)
+ {
+ Xid = resource.Xid,
+ ResourceId = resource.ResourceId,
+ DocumentKind = resource.Kind,
+ Outcome = WoTOutcomeEnum.Failed,
+ LoadState = WoTLoadStateEnum.Failed,
+ Reason = reason ?? string.Empty
+ });
+ }
+
+ private void RaiseEvent(WotMaterializationEventArgs args)
+ {
+ Event?.Invoke(this, args);
+ }
+
+ private WotRefreshResult RejectedResult(
+ WotRefreshRequest request, DateTime start)
+ {
+ var summary = new WoTRefreshSummaryDataType
+ {
+ RequestId = request.RequestId ?? string.Empty,
+ Generation = 0,
+ Outcome = WoTOutcomeEnum.Rejected,
+ StartTime = start,
+ EndTime = DateTime.UtcNow
+ };
+ return new WotRefreshResult(
+ summary, [],
+ m_generation);
+ }
+
+ private sealed class ClosureMemberState
+ {
+ public string Xid { get; set; } = string.Empty;
+ public string GroupId { get; set; } = string.Empty;
+ public string ResourceId { get; set; } = string.Empty;
+ public string VersionId { get; set; } = string.Empty;
+ public WoTDocumentKindEnum Kind { get; set; }
+ public ByteString ContentDigest { get; set; } = [];
+ }
+
+ private sealed class ClosureState
+ {
+ public string Key { get; set; } = string.Empty;
+ public WotProjectionHandle? Handle { get; set; }
+ public byte[] AggregateDigest { get; set; } = [];
+ public uint Generation { get; set; }
+ public ImmutableArray MemberXids { get; set; } = [];
+
+ public ImmutableArray Members { get; set; } = [];
+
+ public ImmutableArray ModelNamespaceUris { get; set; } = [];
+
+ public ImmutableArray BindingPlans { get; set; }
+ = [];
+ }
+
+ private sealed class ClosureOutcome
+ {
+ public ClosureOutcome(
+ ImmutableArray results,
+ List projections)
+ {
+ Results = results;
+ Projections = projections;
+ }
+
+ public ImmutableArray Results { get; }
+ public List Projections { get; }
+ }
+
+ private readonly IWotRegistryService m_registry;
+ private readonly IWotProjectionHost m_host;
+ private readonly IWotBinderRegistry m_binders;
+ private readonly IWotDocumentConverter m_converter;
+ private readonly ImmutableArray m_nodeSetContributors;
+ private readonly IWotNodeSetResolver? m_nodeSetResolver;
+ private readonly WotNodeSetConverterOptions m_converterOptions;
+ private readonly SemaphoreSlim m_mutex = new(1, 1);
+ private readonly System.Threading.Lock m_lifetimeLock = new();
+
+ private readonly Dictionary m_closures =
+ new(StringComparer.Ordinal);
+ private readonly HashSet m_projectionNamespaceUris =
+ new(StringComparer.Ordinal);
+
+ private uint m_generation;
+ private int m_activeOperations;
+ private int m_disposed;
+ private bool m_mutexDisposed;
+ }
+}
diff --git a/src/Opc.Ua.WotCon.Server/Materialization/WotMaterializationTypes.cs b/src/Opc.Ua.WotCon.Server/Materialization/WotMaterializationTypes.cs
new file mode 100644
index 0000000000..b47bcd6ce3
--- /dev/null
+++ b/src/Opc.Ua.WotCon.Server/Materialization/WotMaterializationTypes.cs
@@ -0,0 +1,251 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Collections.Immutable;
+using System.Threading;
+using System.Threading.Tasks;
+using Opc.Ua.Wot;
+using Opc.Ua.WotCon.Server.Registry;
+
+namespace Opc.Ua.WotCon.Server.Materialization
+{
+ ///
+ /// A request to refresh (re-project) the registry into the AddressSpace.
+ /// Mirrors the generated WoTRegistryType.Refresh Method signature.
+ ///
+ public sealed class WotRefreshRequest
+ {
+ ///
+ /// Gets or sets the resource selectors; empty selects all resources.
+ ///
+ public ImmutableArray Selection { get; set; }
+ = [];
+
+ ///
+ /// Gets or sets the refresh options.
+ ///
+ public WoTRefreshOptionsDataType Options { get; set; } = new WoTRefreshOptionsDataType();
+
+ ///
+ /// Gets or sets the caller's expected refresh generation. When non-zero
+ /// and it does not match the current generation, the refresh is rejected.
+ ///
+ public uint ExpectedGeneration { get; set; }
+
+ ///
+ /// Gets or sets an opaque request id echoed in the summary.
+ ///
+ public string RequestId { get; set; } = string.Empty;
+ }
+
+ ///
+ /// The detailed result of a refresh, matching the generated
+ /// WoTRegistryType.Refresh output arguments.
+ ///
+ public sealed class WotRefreshResult
+ {
+ internal WotRefreshResult(
+ WoTRefreshSummaryDataType summary,
+ ImmutableArray results,
+ uint newGeneration)
+ {
+ Summary = summary;
+ Results = results;
+ NewGeneration = newGeneration;
+ }
+
+ ///
+ /// Gets the overall refresh summary.
+ ///
+ public WoTRefreshSummaryDataType Summary { get; }
+
+ ///
+ /// Gets the per-resource results.
+ ///
+ public ImmutableArray Results { get; }
+
+ ///
+ /// Gets the committed refresh generation.
+ ///
+ public uint NewGeneration { get; }
+ }
+
+ ///
+ /// The kind of materialization event emitted by the coordinator.
+ ///
+ public enum WotMaterializationEventKind
+ {
+ ///
+ /// A refresh completed.
+ ///
+ RefreshCompleted,
+
+ ///
+ /// A resource projection changed state.
+ ///
+ Resource,
+
+ ///
+ /// A resource failed format/compatibility validation.
+ ///
+ ValidationFailure,
+
+ ///
+ /// A resource failed to load/project.
+ ///
+ LoadFailure,
+
+ ///
+ /// A binding failed.
+ ///
+ BindingFailure
+ }
+
+ ///
+ /// The payload the coordinator raises for each material event. The NodeManager
+ /// maps it to the generated WoTResourceEventType /
+ /// WoTValidationFailureEventType / WoTLoadFailureEventType /
+ /// WoTBindingFailureEventType / WoTRefreshCompletedEventType.
+ ///
+ public sealed class WotMaterializationEventArgs : EventArgs
+ {
+ internal WotMaterializationEventArgs(WotMaterializationEventKind kind)
+ {
+ Kind = kind;
+ }
+
+ ///
+ /// Gets the event kind.
+ ///
+ public WotMaterializationEventKind Kind { get; }
+
+ ///
+ /// Gets or sets the affected resource xid.
+ ///
+ public string Xid { get; init; } = string.Empty;
+
+ ///
+ /// Gets or sets the resource id.
+ ///
+ public string ResourceId { get; init; } = string.Empty;
+
+ ///
+ /// Gets or sets the version id.
+ ///
+ public string VersionId { get; init; } = string.Empty;
+
+ ///
+ /// Gets or sets the document kind.
+ ///
+ public WoTDocumentKindEnum DocumentKind { get; init; }
+
+ ///
+ /// Gets or sets the refresh generation.
+ ///
+ public uint Generation { get; init; }
+
+ ///
+ /// Gets or sets the phase reached.
+ ///
+ public WoTPhaseEnum Phase { get; init; }
+
+ ///
+ /// Gets or sets the outcome.
+ ///
+ public WoTOutcomeEnum Outcome { get; init; }
+
+ ///
+ /// Gets or sets the resulting load state.
+ ///
+ public WoTLoadStateEnum LoadState { get; init; }
+
+ ///
+ /// Gets or sets the validation outcome, if any.
+ ///
+ public WoTValidationOutcomeDataType? Validation { get; init; }
+
+ ///
+ /// Gets or sets the failing node id, if any.
+ ///
+ public NodeId FailedNodeId { get; init; }
+
+ ///
+ /// Gets or sets the binding URI, if any.
+ ///
+ public string BindingUri { get; init; } = string.Empty;
+
+ ///
+ /// Gets or sets a human-readable reason/message.
+ ///
+ public string Reason { get; init; } = string.Empty;
+
+ ///
+ /// Gets or sets the refresh summary (RefreshCompleted only).
+ ///
+ public WoTRefreshSummaryDataType? Summary { get; init; }
+
+ ///
+ /// Gets or sets the request id (RefreshCompleted only).
+ ///
+ public string RequestId { get; init; } = string.Empty;
+ }
+
+ ///
+ /// An that resolves referenced TD/TM
+ /// documents from a registry snapshot, so a Thing Description synthesized by
+ /// the converter can pull in the Thing Models it depends on.
+ ///
+ internal sealed class SnapshotThingResolver : IWotThingResolver
+ {
+ public SnapshotThingResolver(WotRegistrySnapshot snapshot)
+ {
+ m_snapshot = snapshot;
+ }
+
+ ///
+ /// Resolves a Thing Description or Thing Model reference from the registry snapshot.
+ ///
+ public ValueTask ResolveThingAsync(
+ string reference,
+ WotResolutionContext context,
+ CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ WotResource? resource = WotDependencyGraph.Resolve(m_snapshot, reference);
+ WotResourceVersion? version = resource?.DefaultVersion;
+ WotResolverResult result = version is null
+ ? WotResolverResult.NotFound
+ : WotResolverResult.FromBytes(version.Content);
+ return new ValueTask(result);
+ }
+
+ private readonly WotRegistrySnapshot m_snapshot;
+ }
+}
diff --git a/src/Opc.Ua.WotCon.Server/Materialization/WotProjectionBindingRuntime.cs b/src/Opc.Ua.WotCon.Server/Materialization/WotProjectionBindingRuntime.cs
new file mode 100644
index 0000000000..81bb0f3c6f
--- /dev/null
+++ b/src/Opc.Ua.WotCon.Server/Materialization/WotProjectionBindingRuntime.cs
@@ -0,0 +1,546 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Opc.Ua.Server.Fluent;
+using Opc.Ua.WotCon.Bindings;
+
+namespace Opc.Ua.WotCon.Server.Materialization
+{
+ ///
+ /// The per-generation OPC UA target-mapping binding runtime wired onto a
+ /// freshly imported NodeSet by .
+ /// It groups executable, target-mapped compiled forms by their resolved
+ /// target variable, wires either a direct (whole-value) or a structured
+ /// (field-by-field) handler per group, and owns every channel it lazily
+ /// opens for the lifetime of the generation.
+ ///
+ public sealed class WotProjectionBindingRuntime : IAsyncDisposable
+ {
+ internal WotProjectionBindingRuntime(
+ INodeManagerBuilder builder,
+ IWotBindingChannelFactory channelFactory,
+ IWotTargetVariableResolver resolver)
+ {
+ m_builder = builder;
+ m_channelFactory = channelFactory;
+ m_resolver = resolver;
+ }
+
+ ///
+ /// Groups the closure's target-mapped, executable compiled forms by
+ /// resolved target variable and wires each group. Runs entirely
+ /// synchronously against the address space (no transport I/O); channel
+ /// opens are deferred to first use.
+ ///
+ ///
+ /// See .
+ ///
+ internal void Wire(ArrayOf bindingPlans)
+ {
+ var groups = new Dictionary();
+ for (int p = 0; p < bindingPlans.Count; p++)
+ {
+ WotBindingPlan plan = bindingPlans[p];
+ if (plan is null)
+ {
+ continue;
+ }
+ foreach (WotCompiledForm form in plan.CompiledForms)
+ {
+ if (form is null || form.TargetMapping.IsEmpty || !form.IsExecutable)
+ {
+ // Not target-mapped, or validated but not executable:
+ // out of scope for this runtime.
+ continue;
+ }
+
+ if (form.Operation is not (WoTBindingCapabilityEnum.ReadProperty
+ or WoTBindingCapabilityEnum.WriteProperty or WoTBindingCapabilityEnum.ObserveProperty))
+ {
+ throw ServiceResultException.Create(
+ StatusCodes.BadConfigurationError,
+ "Affordance '{0}' carries an OPC UA target mapping on its '{1}' form, but " +
+ "only readproperty, writeproperty and observeproperty support target binding.",
+ form.AffordanceName,
+ form.OpToken);
+ }
+
+ BaseVariableState variable = m_resolver.Resolve(m_builder, form.TargetMapping);
+ if (!groups.TryGetValue(variable.NodeId, out VariableGroup? group))
+ {
+ group = new VariableGroup(variable);
+ groups.Add(variable.NodeId, group);
+ }
+ group.Entries.Add(form);
+ }
+ }
+
+ foreach (VariableGroup group in groups.Values)
+ {
+ WireGroup(group);
+ }
+ }
+
+ ///
+ /// Disposes every channel this generation successfully opened.
+ /// Faulted opens have no resource and are silently skipped by
+ /// ; actual disposal
+ /// failures are aggregated.
+ ///
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ List? errors = null;
+ foreach (WotBindingChannelSlot slot in m_slots.Values)
+ {
+ try
+ {
+ await slot.DisposeAsync().ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is not OutOfMemoryException)
+ {
+ (errors ??= []).Add(ex);
+ }
+ }
+ if (errors is { Count: > 0 })
+ {
+ throw new AggregateException(
+ "One or more WoT projection binding channels failed to dispose.", errors);
+ }
+ }
+
+ private void WireGroup(VariableGroup group)
+ {
+ bool hasDirect = false;
+ bool hasField = false;
+ foreach (WotCompiledForm entry in group.Entries)
+ {
+ if (string.IsNullOrEmpty(entry.TargetMapping.FieldPath))
+ {
+ hasDirect = true;
+ }
+ else
+ {
+ hasField = true;
+ }
+ }
+ if (hasDirect && hasField)
+ {
+ throw ServiceResultException.Create(
+ StatusCodes.BadConfigurationError,
+ "Target '{0}' is mapped both directly ('uav:mapToNodeId' / 'uav:mapToType' alone) and " +
+ "by field path ('uav:mapByFieldPath'); a target cannot be both a whole-value and a " +
+ "structured-field target.",
+ group.Variable.NodeId);
+ }
+
+ INodeBuilder nodeBuilder = m_builder.Node(group.Variable.NodeId);
+ if (hasField)
+ {
+ WireStructuredGroup(group, nodeBuilder);
+ }
+ else
+ {
+ WireDirectGroup(group, nodeBuilder);
+ }
+ }
+
+ private void WireDirectGroup(VariableGroup group, INodeBuilder nodeBuilder)
+ {
+ WotCompiledForm? read = null;
+ WotCompiledForm? write = null;
+ foreach (WotCompiledForm entry in group.Entries)
+ {
+ switch (entry.Operation)
+ {
+ case WoTBindingCapabilityEnum.ObserveProperty:
+ // Local monitored items sample the async read handler;
+ // no separate observe bridge is created.
+ continue;
+ case WoTBindingCapabilityEnum.ReadProperty:
+ if (read is not null)
+ {
+ throw ServiceResultException.Create(
+ StatusCodes.BadConfigurationError,
+ "Target '{0}' has more than one readproperty target mapping.",
+ group.Variable.NodeId);
+ }
+ read = entry;
+ break;
+ case WoTBindingCapabilityEnum.WriteProperty:
+ if (write is not null)
+ {
+ throw ServiceResultException.Create(
+ StatusCodes.BadConfigurationError,
+ "Target '{0}' has more than one writeproperty target mapping.",
+ group.Variable.NodeId);
+ }
+ write = entry;
+ break;
+ }
+ }
+
+ if (read is not null)
+ {
+ nodeBuilder.OnRead(BuildDirectReadHandler(GetOrCreateSlot(read)));
+ }
+ if (write is not null)
+ {
+ nodeBuilder.OnWrite(BuildDirectWriteHandler(GetOrCreateSlot(write)));
+ }
+ }
+
+ ///
+ /// Wires a structured (field-mapped) group. Target-variable resolution
+ /// and read/write duplicate-path detection run now, synchronously
+ /// against the address space; the structure encodeable lookup, root
+ /// instance validation and per-field
+ /// calls are
+ /// deferred to on
+ /// the first structured read or write, because the target's structure
+ /// type is not guaranteed to be registered in the shared
+ /// yet at wiring time (see the class
+ /// remarks on ).
+ ///
+ ///
+ private void WireStructuredGroup(VariableGroup group, INodeBuilder nodeBuilder)
+ {
+ NodeId targetNodeId = group.Variable.NodeId;
+
+ var readByPath = new Dictionary(StringComparer.Ordinal);
+ var writeByPath = new Dictionary(StringComparer.Ordinal);
+ foreach (WotCompiledForm entry in group.Entries)
+ {
+ string fieldPath = entry.TargetMapping.FieldPath ?? string.Empty;
+ switch (entry.Operation)
+ {
+ case WoTBindingCapabilityEnum.ObserveProperty:
+ continue;
+ case WoTBindingCapabilityEnum.ReadProperty:
+ if (readByPath.ContainsKey(fieldPath))
+ {
+ throw ServiceResultException.Create(
+ StatusCodes.BadConfigurationError,
+ "Target '{0}' field '{1}' has more than one readproperty mapping.",
+ targetNodeId,
+ fieldPath);
+ }
+ readByPath.Add(fieldPath, entry);
+ break;
+ case WoTBindingCapabilityEnum.WriteProperty:
+ if (writeByPath.ContainsKey(fieldPath))
+ {
+ throw ServiceResultException.Create(
+ StatusCodes.BadConfigurationError,
+ "Target '{0}' field '{1}' has more than one writeproperty mapping.",
+ targetNodeId,
+ fieldPath);
+ }
+ writeByPath.Add(fieldPath, entry);
+ break;
+ }
+ }
+
+ List<(string Path, WotBindingChannelSlot Slot)> readSlots = [.. readByPath
+ .OrderBy(kv => kv.Key, StringComparer.Ordinal)
+ .Select(kv => (kv.Key, GetOrCreateSlot(kv.Value)))];
+ List<(string Path, WotBindingChannelSlot Slot)> writeSlots = [.. writeByPath
+ .OrderBy(kv => kv.Key, StringComparer.Ordinal)
+ .Select(kv => (kv.Key, GetOrCreateSlot(kv.Value)))];
+
+ var state = new WotStructuredGroupState(
+ m_builder.Context.EncodeableFactory,
+ m_builder.Context.NamespaceUris,
+ group.Variable.DataType,
+ targetNodeId,
+ readSlots,
+ writeSlots);
+
+ if (readSlots.Count > 0)
+ {
+ nodeBuilder.OnRead(BuildStructuredReadHandler(state));
+ }
+ if (writeSlots.Count > 0)
+ {
+ nodeBuilder.OnWrite(BuildStructuredWriteHandler(state));
+ }
+ }
+
+ private WotBindingChannelSlot GetOrCreateSlot(WotCompiledForm form)
+ {
+ if (!m_slots.TryGetValue(form, out WotBindingChannelSlot? slot))
+ {
+ slot = new WotBindingChannelSlot(form, m_channelFactory);
+ m_slots.Add(form, slot);
+ }
+ return slot;
+ }
+
+ private static NodeValueEventHandlerAsync BuildDirectReadHandler(WotBindingChannelSlot slot)
+ {
+ return async (context, node, indexRange, dataEncoding, cancellationToken) =>
+ {
+ IWotBindingChannel channel = await slot.GetAsync(cancellationToken).ConfigureAwait(false);
+ WotReadResult result = await channel.ReadAsync(cancellationToken).ConfigureAwait(false);
+ if (!result.Success)
+ {
+ DateTimeUtc failedTimestamp = result.Value.SourceTimestamp != DateTimeUtc.MinValue
+ ? result.Value.SourceTimestamp
+ : DateTimeUtc.Now;
+ if (node is BaseVariableState failedVariable)
+ {
+ failedVariable.Value = Variant.Null;
+ failedVariable.StatusCode = result.Status;
+ failedVariable.Timestamp = failedTimestamp;
+ }
+ return new AttributeReadResult(
+ new ServiceResult(result.Status),
+ Variant.Null,
+ result.Status,
+ failedTimestamp);
+ }
+ DataValue value = result.Value;
+ DateTimeUtc timestamp = value.SourceTimestamp != DateTimeUtc.MinValue
+ ? value.SourceTimestamp
+ : DateTimeUtc.Now;
+ if (node is BaseVariableState variable)
+ {
+ variable.Value = value.WrappedValue;
+ variable.StatusCode = value.StatusCode;
+ variable.Timestamp = timestamp;
+ }
+ return new AttributeReadResult(
+ ServiceResult.Good,
+ value.WrappedValue,
+ value.StatusCode,
+ timestamp);
+ };
+ }
+
+ private static NodeValueWriteEventHandlerAsync BuildDirectWriteHandler(WotBindingChannelSlot slot)
+ {
+ return async (context, node, indexRange, value, cancellationToken) =>
+ {
+ IWotBindingChannel channel = await slot.GetAsync(cancellationToken).ConfigureAwait(false);
+ WotWriteResult result = await channel
+ .WriteAsync(new DataValue(value), cancellationToken)
+ .ConfigureAwait(false);
+ return new AttributeWriteResult(
+ result.Success ? ServiceResult.Good : new ServiceResult(result.Status));
+ };
+ }
+
+ private static NodeValueEventHandlerAsync BuildStructuredReadHandler(WotStructuredGroupState state)
+ {
+ return async (context, node, indexRange, dataEncoding, cancellationToken) =>
+ {
+ WotStructuredGroupResolution resolution = state.EnsureResolved();
+ if (!resolution.Success)
+ {
+ return new AttributeReadResult(
+ resolution.Error, Variant.Null, resolution.Error.StatusCode, DateTimeUtc.Now);
+ }
+
+ IEncodeable rootEncodeable = resolution.RootType!.CreateInstance();
+ if (rootEncodeable is not IStructure root)
+ {
+ return new AttributeReadResult(
+ new ServiceResult(StatusCodes.BadConfigurationError),
+ Variant.Null,
+ StatusCodes.BadConfigurationError,
+ DateTimeUtc.Now);
+ }
+
+ List<(WotFieldPathPlan Plan, WotBindingChannelSlot Slot)> fields = resolution.ReadFields;
+ var tasks = new Task<(WotFieldPathPlan Plan, WotReadResult Result)>[fields.Count];
+ for (int i = 0; i < fields.Count; i++)
+ {
+ tasks[i] = ReadFieldAsync(fields[i].Plan, fields[i].Slot, cancellationToken);
+ }
+ (WotFieldPathPlan Plan, WotReadResult Result)[] results = await Task.WhenAll(tasks)
+ .ConfigureAwait(false);
+
+ foreach ((WotFieldPathPlan _, WotReadResult result) in results)
+ {
+ if (!result.Success)
+ {
+ DateTimeUtc failedTimestamp = result.Value.SourceTimestamp != DateTimeUtc.MinValue
+ ? result.Value.SourceTimestamp
+ : DateTimeUtc.Now;
+ return new AttributeReadResult(
+ new ServiceResult(result.Status), Variant.Null, result.Status, failedTimestamp);
+ }
+ }
+
+ foreach ((WotFieldPathPlan plan, WotReadResult result) in results)
+ {
+ IStructure parent = WotStructuredFieldNavigator.CreateOrGetChild(root, plan.IntermediateSegments);
+ parent[plan.LeafFieldName] = result.Value.WrappedValue;
+ }
+
+ (StatusCode status, DateTimeUtc timestamp) = AggregateFieldMetadata(results);
+ return new AttributeReadResult(
+ ServiceResult.Good,
+ new Variant(new ExtensionObject(rootEncodeable)),
+ status,
+ timestamp);
+ };
+ }
+
+ private static NodeValueWriteEventHandlerAsync BuildStructuredWriteHandler(WotStructuredGroupState state)
+ {
+ return async (context, node, indexRange, value, cancellationToken) =>
+ {
+ WotStructuredGroupResolution resolution = state.EnsureResolved();
+ if (!resolution.Success)
+ {
+ return new AttributeWriteResult(resolution.Error);
+ }
+
+ IServiceMessageContext messageContext = context.AsMessageContext();
+ if (!value.TryGetValue(out ExtensionObject extensionObject) ||
+ !extensionObject.TryGetValue(out IEncodeable? rootEncodeable, messageContext) ||
+ rootEncodeable is not IStructure root)
+ {
+ return new AttributeWriteResult(new ServiceResult(StatusCodes.BadTypeMismatch));
+ }
+
+ List<(WotFieldPathPlan Plan, WotBindingChannelSlot Slot)> fields = resolution.WriteFields;
+ var tasks = new Task[fields.Count];
+ for (int i = 0; i < fields.Count; i++)
+ {
+ tasks[i] = WriteFieldAsync(
+ root,
+ fields[i].Plan,
+ fields[i].Slot,
+ state.TargetNodeId,
+ messageContext,
+ cancellationToken);
+ }
+ WotWriteResult[] results = await Task.WhenAll(tasks).ConfigureAwait(false);
+
+ foreach (WotWriteResult result in results)
+ {
+ if (!result.Success)
+ {
+ return new AttributeWriteResult(new ServiceResult(result.Status));
+ }
+ }
+ return new AttributeWriteResult(ServiceResult.Good);
+ };
+ }
+
+ ///
+ /// Aggregates the per-field metadata of an all-succeeded structured
+ /// read into a single status/timestamp pair for the composed value:
+ /// the first non-default Good status found across the fields (or
+ /// plain if every field reported it),
+ /// and the oldest non- source
+ /// timestamp across the fields (or now, if none carried one).
+ ///
+ private static (StatusCode Status, DateTimeUtc Timestamp) AggregateFieldMetadata(
+ (WotFieldPathPlan Plan, WotReadResult Result)[] results)
+ {
+ StatusCode status = StatusCodes.Good;
+ DateTimeUtc oldest = DateTimeUtc.MinValue;
+ foreach ((WotFieldPathPlan _, WotReadResult result) in results)
+ {
+ StatusCode fieldStatus = result.Value.StatusCode;
+ if (status == StatusCodes.Good && fieldStatus != StatusCodes.Good)
+ {
+ status = fieldStatus;
+ }
+
+ DateTimeUtc fieldTimestamp = result.Value.SourceTimestamp;
+ if (fieldTimestamp != DateTimeUtc.MinValue &&
+ (oldest == DateTimeUtc.MinValue || fieldTimestamp < oldest))
+ {
+ oldest = fieldTimestamp;
+ }
+ }
+ return (status, oldest == DateTimeUtc.MinValue ? DateTimeUtc.Now : oldest);
+ }
+
+ private static async Task<(WotFieldPathPlan Plan, WotReadResult Result)> ReadFieldAsync(
+ WotFieldPathPlan plan, WotBindingChannelSlot slot, CancellationToken cancellationToken)
+ {
+ IWotBindingChannel channel = await slot.GetAsync(cancellationToken).ConfigureAwait(false);
+ WotReadResult result = await channel.ReadAsync(cancellationToken).ConfigureAwait(false);
+ return (plan, result);
+ }
+
+ private static async Task WriteFieldAsync(
+ IStructure root,
+ WotFieldPathPlan plan,
+ WotBindingChannelSlot slot,
+ NodeId targetNodeId,
+ IServiceMessageContext messageContext,
+ CancellationToken cancellationToken)
+ {
+ IStructure parent;
+ try
+ {
+ parent = WotStructuredFieldNavigator.GetExistingChild(
+ root,
+ plan.IntermediateSegments,
+ targetNodeId,
+ messageContext);
+ }
+ catch (ServiceResultException ex)
+ {
+ return new WotWriteResult(ex.StatusCode, ex.Message);
+ }
+ Variant fieldValue = parent[plan.LeafFieldName];
+ IWotBindingChannel channel = await slot.GetAsync(cancellationToken).ConfigureAwait(false);
+ return await channel.WriteAsync(new DataValue(fieldValue), cancellationToken).ConfigureAwait(false);
+ }
+
+ private readonly INodeManagerBuilder m_builder;
+ private readonly IWotBindingChannelFactory m_channelFactory;
+ private readonly IWotTargetVariableResolver m_resolver;
+ private readonly Dictionary m_slots = [];
+
+ private sealed class VariableGroup
+ {
+ public VariableGroup(BaseVariableState variable)
+ {
+ Variable = variable;
+ }
+
+ public BaseVariableState Variable { get; }
+
+ public List Entries { get; } = [];
+ }
+ }
+}
diff --git a/src/Opc.Ua.WotCon.Server/Materialization/WotProjectionBindingRuntimeFactory.cs b/src/Opc.Ua.WotCon.Server/Materialization/WotProjectionBindingRuntimeFactory.cs
new file mode 100644
index 0000000000..611eedece6
--- /dev/null
+++ b/src/Opc.Ua.WotCon.Server/Materialization/WotProjectionBindingRuntimeFactory.cs
@@ -0,0 +1,107 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Opc.Ua.Server.Fluent;
+using Opc.Ua.WotCon.Bindings;
+
+namespace Opc.Ua.WotCon.Server.Materialization
+{
+ ///
+ /// The default . It builds
+ /// a for the closure's prepared
+ /// binding plans and wires it against the freshly imported predefined
+ /// nodes. Always available via direct construction (no dependency
+ /// injection container required) given an
+ /// — typically the same
+ /// WotProtocolBinderRegistry instance exposed as
+ /// .
+ ///
+ public sealed class WotProjectionBindingRuntimeFactory : IWotProjectionBindingRuntimeFactory
+ {
+ ///
+ /// Initializes a new projection binding runtime factory.
+ ///
+ /// The channel factory used to open live channels.
+ ///
+ /// The target-variable resolver. Defaults to a new
+ /// when null.
+ ///
+ public WotProjectionBindingRuntimeFactory(
+ IWotBindingChannelFactory channelFactory,
+ IWotTargetVariableResolver? resolver = null)
+ {
+ m_channelFactory = channelFactory ?? throw new ArgumentNullException(nameof(channelFactory));
+ m_resolver = resolver ?? new WotTargetVariableResolver();
+ }
+
+ ///
+ public async ValueTask CreateAsync(
+ INodeManagerBuilder builder,
+ ArrayOf bindingPlans,
+ CancellationToken cancellationToken = default)
+ {
+ if (builder is null)
+ {
+ throw new ArgumentNullException(nameof(builder));
+ }
+ if (bindingPlans.IsEmpty)
+ {
+ return null;
+ }
+ return await CreateWiredRuntimeAsync(builder, bindingPlans).ConfigureAwait(false);
+ }
+
+ ///
+ /// Creates the runtime and wires it, disposing it again if wiring
+ /// fails so ownership never escapes unwired: the returned instance is
+ /// always the fully-wired runtime the caller is meant to own.
+ ///
+ private async ValueTask CreateWiredRuntimeAsync(
+ INodeManagerBuilder builder, ArrayOf bindingPlans)
+ {
+ var runtime = new WotProjectionBindingRuntime(builder, m_channelFactory, m_resolver);
+ try
+ {
+ runtime.Wire(bindingPlans);
+ }
+ catch (Exception ex) when (ex is not OutOfMemoryException)
+ {
+ await runtime.DisposeAsync().ConfigureAwait(false);
+ throw;
+ }
+ return runtime;
+ }
+
+ private readonly IWotBindingChannelFactory m_channelFactory;
+ private readonly IWotTargetVariableResolver m_resolver;
+ }
+}
diff --git a/src/Opc.Ua.WotCon.Server/Materialization/WotRefreshArguments.cs b/src/Opc.Ua.WotCon.Server/Materialization/WotRefreshArguments.cs
new file mode 100644
index 0000000000..7ea847076e
--- /dev/null
+++ b/src/Opc.Ua.WotCon.Server/Materialization/WotRefreshArguments.cs
@@ -0,0 +1,364 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using Opc.Ua.Encoders;
+
+namespace Opc.Ua.WotCon.Server.Materialization
+{
+ ///
+ /// Decodes the four WoTRegistryType.Refresh input arguments -
+ /// Selection ([]),
+ /// Options (),
+ /// ExpectedGeneration () and RequestId
+ /// () - into a . Structured
+ /// arguments are accepted in every form a caller may present them: an already
+ /// decoded encodeable, an wrapping the
+ /// encodeable, a binary-encoded ExtensionObject body, and both plain-array and
+ /// array containers. A value whose type does not match
+ /// the argument's schema is rejected with
+ /// rather than silently ignored.
+ ///
+ internal static class WotRefreshArguments
+ {
+ ///
+ /// Decodes the Refresh input arguments into a .
+ ///
+ /// The raw input argument variants.
+ /// The message context used to decode encoded bodies.
+ /// The decoded request on success.
+ ///
+ /// on success, or
+ /// when an argument is present
+ /// but has the wrong type.
+ ///
+ public static ServiceResult TryDecode(
+ ArrayOf inputArguments,
+ IServiceMessageContext context,
+ out WotRefreshRequest request)
+ {
+ request = new WotRefreshRequest();
+
+ ServiceResult selection = TryDecodeSelection(
+ ArgumentAt(inputArguments, 0), context,
+ out ImmutableArray selectors);
+ if (ServiceResult.IsBad(selection))
+ {
+ return selection;
+ }
+
+ ServiceResult options = TryDecodeStructure(
+ ArgumentAt(inputArguments, 1), context,
+ out WoTRefreshOptionsDataType? decodedOptions);
+ if (ServiceResult.IsBad(options))
+ {
+ return options;
+ }
+
+ ServiceResult generation = TryDecodeUInt32(
+ ArgumentAt(inputArguments, 2), out uint expectedGeneration);
+ if (ServiceResult.IsBad(generation))
+ {
+ return generation;
+ }
+
+ ServiceResult requestId = TryDecodeString(
+ ArgumentAt(inputArguments, 3), out string? id);
+ if (ServiceResult.IsBad(requestId))
+ {
+ return requestId;
+ }
+
+ request = new WotRefreshRequest
+ {
+ Selection = selectors,
+ Options = decodedOptions ?? new WoTRefreshOptionsDataType(),
+ ExpectedGeneration = expectedGeneration,
+ RequestId = id ?? string.Empty
+ };
+ return ServiceResult.Good;
+ }
+
+ private static Variant ArgumentAt(ArrayOf inputArguments, int index)
+ {
+ return index < inputArguments.Count ? inputArguments[index] : Variant.Null;
+ }
+
+ private static ServiceResult TryDecodeSelection(
+ Variant value,
+ IServiceMessageContext context,
+ out ImmutableArray selectors)
+ {
+ selectors = [];
+ if (value.IsNull)
+ {
+ return ServiceResult.Good;
+ }
+
+ ImmutableArray.Builder builder =
+ ImmutableArray.CreateBuilder();
+ foreach (object? element in Enumerate(value.AsBoxedObject(Variant.BoxingBehavior.Legacy)))
+ {
+ if (element is null)
+ {
+ return ServiceResult.Create(
+ StatusCodes.BadInvalidArgument,
+ "The Selection argument must be an array of WoTResourceSelectorDataType.");
+ }
+ ServiceResult status = TryCoerce(
+ element, context, out WoTResourceSelectorDataType? selector);
+ if (ServiceResult.IsBad(status) || selector is null)
+ {
+ return ServiceResult.Create(
+ StatusCodes.BadInvalidArgument,
+ "The Selection argument must be an array of WoTResourceSelectorDataType.");
+ }
+ builder.Add(selector);
+ }
+ selectors = builder.ToImmutable();
+ return ServiceResult.Good;
+ }
+
+ private static ServiceResult TryDecodeStructure(
+ Variant value,
+ IServiceMessageContext context,
+ out WoTRefreshOptionsDataType? options)
+ {
+ options = null;
+ if (value.IsNull)
+ {
+ return ServiceResult.Good;
+ }
+ ServiceResult status = TryCoerce(
+ value.AsBoxedObject(Variant.BoxingBehavior.Legacy), context, out options);
+ if (ServiceResult.IsBad(status) || options is null)
+ {
+ return ServiceResult.Create(
+ StatusCodes.BadInvalidArgument,
+ "The Options argument must be a single WoTRefreshOptionsDataType.");
+ }
+ return ServiceResult.Good;
+ }
+
+ private static ServiceResult TryDecodeUInt32(Variant value, out uint result)
+ {
+ result = 0;
+ if (value.IsNull)
+ {
+ return ServiceResult.Good;
+ }
+ switch (value.AsBoxedObject(Variant.BoxingBehavior.Legacy))
+ {
+ case uint u:
+ result = u;
+ return ServiceResult.Good;
+ case int i when i >= 0:
+ result = (uint)i;
+ return ServiceResult.Good;
+ case long l when l is >= 0 and <= uint.MaxValue:
+ result = (uint)l;
+ return ServiceResult.Good;
+ case ushort us:
+ result = us;
+ return ServiceResult.Good;
+ case byte b:
+ result = b;
+ return ServiceResult.Good;
+ default:
+ return ServiceResult.Create(
+ StatusCodes.BadInvalidArgument,
+ "The ExpectedGeneration argument must be a UInt32.");
+ }
+ }
+
+ private static ServiceResult TryDecodeString(Variant value, out string? result)
+ {
+ result = null;
+ if (value.IsNull)
+ {
+ return ServiceResult.Good;
+ }
+ if (value.AsBoxedObject(Variant.BoxingBehavior.Legacy) is string s)
+ {
+ result = s;
+ return ServiceResult.Good;
+ }
+ return ServiceResult.Create(
+ StatusCodes.BadInvalidArgument,
+ "The RequestId argument must be a String.");
+ }
+
+ private static IEnumerable