# OPC UA WoT Connectivity (OPC 10100-1) This repository implements the OPC UA **WoT Connectivity** companion specification (OPC 10100-1, "WoT Connectivity for OPC UA") through the model, client, server, and protocol-binding libraries plus integration tests and runnable aggregation samples: | Project | Purpose | |----------------------------------|---------------------------------------------------------------| | `Opc.Ua.WotCon` | Source-generated information model (NodeStates, NodeIds, generated ObjectType client proxies) generated once from the combined **WoT Connectivity 1.1** NodeSet2 (incorporating the OPC 10100-1 v1.02 model plus additive registry nodes in one namespace) and the draft **xRegistry** base NodeSet2 (see §11) | | `Opc.Ua.WotCon.Server` | Server-side node manager (`WotConnectivityNodeManager` → `AsyncCustomNodeManager`) and the extensible provider model | | `Opc.Ua.WotCon.Client` | Client wrappers + extension methods that compose the generated proxies without inheritance, covering both the OPC 10100-1 v1.02 asset-connection surface (`WotConnectivityClient`) and the WoT Connectivity 1.1 registry surface (`WotRegistryClient`, see §11.8) | | `Opc.Ua.WotCon.Bindings` | Protocol-binding abstractions, planners, codecs, credential references, HTTP/Modbus/OPC UA executors on net8+, and the generic target-mapping channel factory | | `Opc.Ua.WotCon.Bindings.Mqtt` | Optional MQTT executor package | | `Opc.Ua.WotCon.Tests` | NUnit tests covering the TD parser, mappers, simulated provider, discovery facade | The model namespace URI is `http://opcfoundation.org/UA/WoT-Con/`, combined model version `1.1`, publication 2026-09-21 (`1.1-draft8`). It incorporates the unchanged published `1.02.0` asset model. For current protocol-runtime architecture and the contributor guide for adding a protocol see [WoT protocol bindings](WotBindings.md), and the runnable end-to-end topology is documented in the [WoT aggregation sample](../samples/WotCon/README.md). ## Contents - [1. Hosting a WoT Connectivity server](#1-hosting-a-wot-connectivity-server) - [Lifecycle](#lifecycle) - [Mirroring assets into the WoT xRegistry](#mirroring-assets-into-the-wot-xregistry) - [2. Writing a custom `IWotAssetProvider`](#2-writing-a-custom-iwotassetprovider) - [Event affordances](#event-affordances) - [3. Using the client](#3-using-the-client) - [FileSystem extensions](#filesystem-extensions) - [Method invocation and server interoperability](#method-invocation-and-server-interoperability) - [4. Persistence limits](#4-persistence-limits) - [5. Name validation](#5-name-validation) - [6. Endpoint policy](#6-endpoint-policy) - [The generated Thing Description is untrusted too](#the-generated-thing-description-is-untrusted-too) - [7. Error reporting](#7-error-reporting) - [8. Security: management access policy](#8-security-management-access-policy) - [9. Limitations and known issues](#9-limitations-and-known-issues) - [10. References](#10-references) - [11. WoT Connectivity 1.1 registry and materialization (preview)](#11-wot-connectivity-11-registry-and-materialization-preview) - [11.1 Architecture](#111-architecture) - [11.2 Registry service and persistence](#112-registry-service-and-persistence) - [11.3 Materialization coordinator](#113-materialization-coordinator) - [11.4 Binder integration seam](#114-binder-integration-seam) - [11.5 Legacy 1.02 compatibility](#115-legacy-102-compatibility) - [11.6 Protocol and projection scope](#116-protocol-and-projection-scope) - [11.7 Browseable registry projection and management Methods](#117-browseable-registry-projection-and-management-methods) - [11.8 Binding-vocabulary alignment (NodeSet2 ↔ WoT)](#118-binding-vocabulary-alignment-nodeset2--wot) - [11.9 Registry client](#119-registry-client) - [12. Conformance to WoT Connectivity 1.1](#12-conformance-to-wot-connectivity-11) - [12.1 Model identity](#121-model-identity) - [12.2 Conformance units and profiles](#122-conformance-units-and-profiles) - [12.3 Grouping](#123-grouping) - [12.4 Projection documents and the View NodeClass](#124-projection-documents-and-the-view-nodeclass) - [12.4.1 Parent placement through `uav:componentOf`](#1241-parent-placement-through-uavcomponentof) - [12.4.2 Event notifier behaviour in projection Views](#1242-event-notifier-behaviour-in-projection-views) - [12.4.3 Carried dependencies and View membership](#1243-carried-dependencies-and-view-membership) - [12.4.4 Archive context for single-document conversion](#1244-archive-context-for-single-document-conversion) - [12.5 Portable identifiers](#125-portable-identifiers) - [12.6 The 1.02 asset surface](#126-the-102-asset-surface) - [13. Runtime provider contracts](#13-runtime-provider-contracts) - [Ownership and publication](#ownership-and-publication) - [Provider requirements](#provider-requirements) - [Dependency capture and observations](#dependency-capture-and-observations) - [Version leases](#version-leases) - [Prepared registry store](#prepared-registry-store) - [Canonical Views](#canonical-views) - [Dry-run validation](#dry-run-validation) - [Recovery](#recovery) - [Bridge deletion and recovery](#bridge-deletion-and-recovery) --- ## 1. Hosting a WoT Connectivity server The node manager is exposed through `WotConnectivityNodeManagerFactory`, which plugs into a `StandardServer` via the standard `AdditionalNodeManagers` mechanism. A typical setup: ```csharp var options = new WotConnectivityServerOptions { ThingDescriptionStorageFolder = Path.Combine(AppContext.BaseDirectory, "wot-assets") }; options.Bindings.Add(new MyHttpWotAssetProviderFactory()); options.Bindings.Add(new MyModbusWotAssetProviderFactory()); options.Discovery = new MyDiscoveryProvider(); // optional server.NodeManagerFactories.Add(new WotConnectivityNodeManagerFactory(options)); ``` The factory advertises two namespaces: * `http://opcfoundation.org/UA/WoT-Con/` — the static model (loaded through the source-generator's `AddOpcUaWotCon` extension). * `http://opcfoundation.org/UA/WoT-Con/Assets/` (default) — the dynamic namespace where assets, property variables, and action methods land. Override with `WotConnectivityServerOptions.AssetNamespaceUri`. `WoTAssetConnectionManagement` is automatically organized below `Objects`. During startup, the server wires the management Methods `CreateAsset`, `DeleteAsset`, `DiscoverAssets`, `CreateAssetForEndpoint`, and `ConnectionTest`. The configuration Object is separate and is created when configuration entries or license information are supplied. Any persisted TDs in the storage folder are re-materialised on startup. ### Lifecycle 1. Client calls `CreateAsset(name)` → server creates an `IWoTAssetType` instance (`HasInterface` reference) with a single `WoTFile` child. 2. Client opens `WoTFile` with mode `Write|EraseExisting` for an upload, writes a JSON TD, and calls `CloseAndUpdate`. 3. Server parses the TD, selects a registered `IWotAssetProviderFactory` whose `CanHandle` accepts it, connects the resulting provider, and materialises a property variable for each WoT property (mapped per Table 14) and a method node for each WoT action (mapped per §6.3.9). `CloseAndUpdate` awaits materialization and persistence. Original uploaded bytes remain authoritative for downloads/persistence/mirroring, including an accepted BOM and unknown members. Readers may coexist; writers exclude other handles through completion. Session closure discards uncommitted writes. Replacement removes obsolete interaction subtrees and callbacks; arguments belong beneath their action. Discovery-generated and persisted TDs are prepared before provider effects; invalid persisted files are retained. Native/type-bound TDs use the shared semantic converter before provider effects, applying the converted instance to the existing asset, not another executing root. Qualified ObjectType and `ua:HasTypeDefinition` bindings use loaded type context; missing/conflicting/wrong-class bindings fail, rather than falling back by name. Declarations retain native names, references, types, ranks, dimensions, and argument Properties; Variables retain `HasWoTComponent`. Root identity/placement must agree with the existing owner, and instance Nodes must belong to its asset namespace. Rejection retains the prior provider/graph/document; acquired-reference cleanup does not imply whole-asset graph/file atomicity. The default converter receives the running AddressSpace context. Override through `options.DocumentConverter` or DI registration of `IWotDocumentConverter`. Optional flow when `DiscoverAssets` / `CreateAssetForEndpoint` / `ConnectionTest` are wired: 1. `DiscoverAssets` returns a list of asset endpoints. 2. `ConnectionTest` verifies one of them. 3. `CreateAssetForEndpoint(name, endpoint)` synthesises a TD via `IWotAssetDiscoveryProvider.CreateThingDescriptionAsync` and runs the same materialisation path — no client upload needed. ### Mirroring assets into the WoT xRegistry WoT Connectivity can mirror each successfully materialised asset Thing Description into the WoT xRegistry. The bridge is default-off: when `WotConnectivityServerOptions.RegistryBridge` is `null`, asset create, update, and delete do not call the registry. This keeps existing deployments unchanged. Enable the bridge by assigning an `IWotRegistryService` directly, or by using the DI builder helper when the registry service is registered in the same service collection: ```csharp builder.AddServer(serverOptions) .AddWotConServer(wotOptions => { wotOptions.ThingDescriptionStorageFolder = "wot-assets"; }) .AddWotRegistryBridge(); ``` `AddWotRegistryBridge()` resolves `IWotRegistryService` from DI and mirrors TDs into the `thingdescriptions` group by default. Pass a custom group id to override it. Mirroring is independent of legacy TD file persistence: the registry is itself a durable store, so `RebuildAsync(..., persistOnSuccess: false, ...)` still mirrors the live TD when the bridge is enabled. Create/update mirroring is best-effort: registry failures are logged without invalidating an otherwise usable asset. Deletion instead requires the backing registry decision before local removal; a rejection retains the asset, and a committed warning returns `GoodResultsMayBeIncomplete`. The asset retains the assigned Resource and same registry owner for retries. See [bridge deletion and recovery](#bridge-deletion-and-recovery) for persisted delete intents and incomplete local cleanup. --- ## 2. Writing a custom `IWotAssetProvider` A provider drives a single asset's data plane. The interface is deliberately small so a binding driver only owns the parts that change between protocols: ```csharp public sealed class MyHttpWotAssetProvider : IWotAssetProvider { public ValueTask<(ServiceResult, Variant)> ReadAsync(WotPropertyTag tag, CancellationToken ct); public ValueTask WriteAsync(WotPropertyTag tag, Variant value, CancellationToken ct); public ValueTask SubscribeAsync(WotPropertyTag tag, uint id, OnWotValueChange cb, CancellationToken ct); public ValueTask UnsubscribeAsync(WotPropertyTag tag, uint id, CancellationToken ct); public ValueTask InvokeActionAsync(WotActionTag action, IReadOnlyList inputs, IList outputs, CancellationToken ct); public ValueTask SubscribeEventAsync(WotEventTag tag, uint id, OnWotEvent cb, CancellationToken ct); public ValueTask UnsubscribeEventAsync(WotEventTag tag, uint id, CancellationToken ct); public ValueTask DisposeAsync(); } ``` ### Event affordances A TD `events` entry (OPC 10100-1 §6.3.10) materializes as a non-abstract `BaseEventType` subtype whose event fields come from the event's `data` schema. The asset object becomes an event notifier (`EventNotifier = SubscribeToEvents`) and is registered as a root notifier, so a client subscribing to the asset — or to the Server object — receives every occurrence. The asset is a plain `BaseObjectType` instance, so it does not carry a `GeneratesEvent` reference to the materialized type: OPC 10000-3 §7.15 allows only ObjectTypes, VariableTypes and Methods as the source of that reference. ```jsonc "events": { "Overheating": { "title": "Overheating", "data": { "type": "object", "properties": { "Temperature": { "type": "number" } } } } } ``` The registry subscribes the provider once per affordance when the TD is applied and keeps that subscription for the lifetime of the generation; the server's subscription machinery decides which clients receive each occurrence, so a provider never tracks per-client state. The provider reports an occurrence by invoking the `OnWotEvent` callback with one value per `WotEventTag.Fields` entry, in order: ```csharp public ValueTask SubscribeEventAsync( WotEventTag tag, uint id, OnWotEvent cb, CancellationToken ct) { m_client.Overheated += (temperature, at) => cb(tag, [new Variant(temperature)], new LocalizedText("Pump is overheating"), 700, at); return default; } ``` `message` and `severity` are optional: a null `message` publishes the event name and a null `severity`, or one outside 1–1000, uses the server's medium fallback. Severity is occurrence data supplied by the provider; the Thing Description carries no default-severity metadata. Separately, when an affordance is skipped while applying the Thing Description — for example, because of an invalid child name or a duplicate name — the result is `GoodResultsMayBeIncomplete` rather than `Good`, with the number of skipped affordances and one log entry per skip explaining why. The code stays in the Good class, so a caller testing `ServiceResult.IsGood` is unaffected and the asset remains usable; but a caller that inspects the code learns the Thing Description was not applied in full. Reporting a plain `Good` would leave an operator believing an alarm they authored is configured when it silently does not exist. Pair it with an `IWotAssetProviderFactory` that advertises the WoT binding URIs it understands (surfaced through `SupportedWoTBindings` per Spec §6.3.1.1): ```csharp public sealed class MyHttpWotAssetProviderFactory : IWotAssetProviderFactory { public IReadOnlyCollection SupportedBindings { get; } = new[] { "https://www.w3.org/2019/wot/http" }; public bool CanHandle(ThingDescription td) => td?.Base?.StartsWith("http://", StringComparison.OrdinalIgnoreCase) == true || td?.Base?.StartsWith("https://", StringComparison.OrdinalIgnoreCase) == true; public ValueTask ConnectAsync(ThingDescription td, CancellationToken ct) => new(new MyHttpWotAssetProvider(td)); } ``` Each WoT property's binding-specific `forms` element is passed through on the `WotPropertyTag.Form` (raw `JsonElement`); providers parse it into whatever protocol metadata they need. For Discover / CreateForEndpoint / ConnectionTest, register an `IWotAssetDiscoveryProvider` on `WotConnectivityServerOptions.Discovery`. Any individual method may throw `NotSupportedException` — the node manager translates that into `BadNotSupported`. The repository ships with a canonical `SimulatedWotAssetProvider` in the test project. It is a complete, working example of the contract (read / write / observe / action echo) and serves as the default provider for the test suite. --- ## 3. Using the client `WotConnectivityClient` composes the generated `WoTAssetConnectionManagementTypeClient` and adds asset enumeration, NodeId resolution, and `WotAssetClient` construction: ```csharp WotConnectivityClient client = await WotConnectivityClient.ForServerAsync( session, session.MessageContext.Telemetry, ct); WotAssetClient asset = await client.CreateAssetAsync("PressureSensor01", ct); await asset.UploadThingDescriptionAsync(File.ReadAllBytes("sensor.td.jsonld"), ct); await foreach (WotAssetVariableEntry property in asset.EnumeratePropertiesAsync(ct)) { DataValue value = (await session.ReadValueAsync(property.NodeId, ct))!; Console.WriteLine($"{property.BrowseName} = {value.WrappedValue}"); } await client.DeleteAssetAsync(asset.AssetId, ct); ``` ### FileSystem extensions The client does **not** subclass any of the existing `Opc.Ua.Client.FileSystem` types. Instead it ships extension methods on the generated `FileTypeClient` / `WoTAssetFileTypeClient` proxies that add what the spec needs but the base FileSystem client cannot offer (`CloseAndUpdate` exists only on `WoTAssetFileType`): * `FileTypeClient.UploadAsync(bytes, …)` — chunked write with automatic `Open(Write|EraseExisting)` → `Write*` → `Close`. * `FileTypeClient.UploadAsync(Stream, …)` — same flow but reads the content from a `System.IO.Stream` so callers don't have to buffer the entire payload in memory. Non-seekable streams (`NetworkStream`, `GZipStream`, …) are supported. * `FileTypeClient.DownloadAllAsync(…)` — chunked read until end-of-file. * `FileTypeClient.DownloadToAsync(Stream, …)` — chunked read that writes each chunk directly to the supplied `System.IO.Stream`. * `WoTAssetFileTypeClient.UploadAndUpdateAsync(td, …)` — uploads the TD (as `ReadOnlyMemory` or `System.IO.Stream`) and then calls `CloseAndUpdate` (Spec §6.3.10). `WotAssetClient` exposes the same upload / download convenience pair — `UploadThingDescriptionAsync` and `DownloadThingDescriptionAsync` — both with a `ReadOnlyMemory` / `byte[]` overload and a `System.IO.Stream` overload, e.g.: ```csharp await using FileStream tdFile = File.OpenRead("device.td.json"); await asset.UploadThingDescriptionAsync(tdFile, ct); ``` Stream-based callers retain ownership of the stream — the WoT Connectivity client never disposes the caller's stream. These work on any `FileType` instance, including ones that are not anchored under `Server.FileSystem` (e.g. the WoT asset file living under `WoTAssetConnectionManagement/`). ### Method invocation and server interoperability The generated `…TypeClient` proxies invoke methods through the shared `ObjectTypeClient.CallMethodAsync` helper using the **type-declaration** `MethodId` (the Method node on the `ObjectType`). This is fully spec-conformant: OPC UA Part 4 §5.12.2.2 (v1.04 §5.11.2.2) states that, for a `Call` on an `Object` instance, the `methodId` may be **either** the instance Method's NodeId **or** the NodeId of the Method on the `ObjectType` that defines it. This stack's own server accepts both forms. A few non-conformant servers only bind the method handler on the instance and reject the type-declaration `MethodId` with `Bad_MethodInvalid`. To interoperate with those servers, `CallMethodAsync` transparently falls back: on `Bad_MethodInvalid` it resolves the instance `MethodId` via a `HasComponent` browse path (`TranslateBrowsePathsToNodeIds`), caches it on the proxy, and retries the call once. Conformant servers never trigger the fallback and therefore pay no extra round-trip; subsequent calls against a non-conformant server reuse the cached instance `MethodId`. --- ## 4. Persistence limits The persisted-TD loader (`AssetRegistry.EnumeratePersistedAsync`) walks the configured `ThingDescriptionStorageFolder` and re-materialises every `*.jsonld` file at startup. The following options constrain the number of files, per-file size and JSON nesting depth accepted by the loader. | Option | Default | Effect | |---|---|---| | `MaxThingDescriptionSize` | `1 MiB` | Per-file size cap. Files larger than this are skipped at load time with a warning that names the file and reports the size. Also enforced on the write path via the OPC UA file primitives. | | `MaxPersistedThingDescriptionFiles` | `10 000` | Hard cap on the number of `*.jsonld` files processed per startup. When reached, the loader emits a single warning and stops; the server still comes up with the assets that *were* loaded. Set to `0` (or negative) to disable persistence loading entirely without removing the directory. | | `MaxThingDescriptionJsonDepth` | `64` | Maximum JSON nesting depth enforced through `JsonSerializerOptions.MaxDepth`. Files exceeding the configured depth are skipped with a warning. | Bumping the defaults is appropriate for controlled environments that have audited the source of the persisted files; for example: ```csharp var options = new WotConnectivityServerOptions { ThingDescriptionStorageFolder = "/var/lib/myapp/wot", MaxThingDescriptionSize = 4 * 1024 * 1024, // 4 MiB MaxPersistedThingDescriptionFiles = 50_000, // ~50k assets MaxThingDescriptionJsonDepth = 128 // headroom for deeper TDs }; ``` `OperationCanceledException` is propagated unmodified — cancelling the startup token cancels the enumeration without losing the cancellation type. `JsonException` and `IOException` are caught and surfaced as per-file warnings; no other exception type is silently swallowed. --- ## 5. Name validation Two validators harden the path from third-party input to address-space nodes: * **`WotAssetNameValidator`** (asset names from `CreateAsset` / `CreateAssetForEndpoint`) — rejects names that would escape the persistence folder, contain NUL bytes, hit a Windows reserved device name (`CON`, `PRN`, `AUX`, `NUL`, `COM1..9`, `LPT1..9`), start with `.`, ` `, or `~`, or end with `.` or ` `. * **`WotChildNameValidator`** (TD `properties` / `actions` keys) — rejects names that would corrupt the OPC UA address space or enable visual-spoofing in a browse viewer: * empty / whitespace-only / `> 128` chars, * leading or trailing whitespace, * any `char.IsControl` or BIDI / format character (LRM, RLM, LRE, RLE, PDF, LRO, RLO, LRI, RLI, FSI, PDI — see [Unicode TR9 §2.1]( https://www.unicode.org/reports/tr9/#Bidirectional_Character_Types)), * any of `/`, `\`, `.`, `#`, `:`, `!` — characters that have syntactic meaning in `NodeId` / browse-path expressions or that re-interpret to a path separator at the file-system layer. Invalid names produce a single `LogWarning` (with the offending name passed through `WotChildNameValidator.SanitiseForLog` so a hostile name cannot reshape the rendered log line) and are skipped — the remaining valid children still materialise so one bad TD entry does not poison the whole asset. Duplicate child names (case-sensitive) are also rejected after validation: only the first occurrence wins, the rest are logged as duplicates. --- ## 6. Endpoint policy `CreateAssetForEndpoint` and `ConnectionTest` accept an endpoint URI from a remote OPC UA client. Before that string flows into the discovery provider, it passes through `AssetEndpointValidator` against the configured `WotConnectivityServerOptions.AssetEndpointPolicy`. Safe defaults: * `AllowedSchemes` = `{ http, https, opc.tcp }` — anything else (`file:`, `gopher:`, `javascript:`, custom OS-vendor schemes, …) returns `Bad_SecurityChecksFailed`. * `AllowLoopback = false` — blocks `127.0.0.0/8`, `::1`, and the literal host names `localhost`, `ip6-localhost`, `ip6-loopback`. * `AllowPrivateAddresses = false` — blocks RFC1918 (10/8, 172.16/12, 192.168/16), IPv4 link-local (169.254/16 — including the AWS / Azure IMDS address `169.254.169.254`), IPv6 ULA (`fc00::/7`), and IPv6 link-local (`fe80::/10`). * `AllowedHosts` (empty) and `BlockedHosts` (empty) — optional exclusive allow-list and always-deny list of host names. * `MaxOperationTimeout = 30 s` — wraps every provider call with a linked `CancellationTokenSource.CancelAfter`; on expiry the call returns `Bad_Timeout` even when the upstream provider hangs. Opening up a single internal device while keeping the global block-list: ```csharp var options = new WotConnectivityServerOptions { AssetEndpointPolicy = new AssetEndpointPolicy { // Default safe scheme list; add a private-network device // explicitly via AllowedHosts. AllowPrivateAddresses = false } }; options.AssetEndpointPolicy.AllowedHosts.Add("10.20.30.40"); ``` **Security note.** The validator does NOT resolve DNS. Resolving a host name to an IP at validation time and then re-resolving it at connect time is itself a TOCTOU SSRF vector — a hostile DNS could return a public IP to the validator and a private IP to the connector. Operators who need IP-range enforcement must either pin `AllowedHosts` to IP literals or accept that the IP-range gates only fire when the host portion of the URI itself is an IP literal. The same policy applies to the endpoints `DiscoverAssets` returns: they are filtered through `AssetEndpointValidator` before they reach the caller, so a provider cannot use discovery to hand a client an address the policy would have refused on `ConnectionTest`. ### The generated Thing Description is untrusted too §11 of the specification requires a Thing Description auto-generated from a caller-chosen endpoint to be treated as untrusted input, subject to the same `Wot-Con 1.02` format validation an uploaded document gets. `CreateAssetForEndpoint` therefore validates what `IWotAssetDiscoveryProvider.CreateThingDescriptionAsync` returns before materialising anything from it: the document must identify itself by carrying a non-empty `name` or `title`. A document that fails materialises nothing — the asset created to hold it is removed again — and the call returns `Bad_DecodingError`. Deserializing into `ThingDescription` is not that check. Every member of that type is optional, so an empty object deserializes happily; neither the endpoint (chosen by the caller) nor the provider (pluggable) is a trusted source. The rule lives in one place, `ThingDescriptionFormatValidator`, which both the upload path and this path call. --- ## 7. Error reporting `AssetRegistry` never propagates the raw `Exception.Message` / `StackTrace` / `GetType().Name` from a discovery or provider call to the remote OPC UA client. The returned `ServiceResult` carries only a mapped `StatusCode` and a generic operation name (e.g. `"DiscoverAssets failed."`, `"ConnectionTest failed."`, `"Asset property read failed."`). The full exception detail — including the inner `ex.Message`, the stack trace, and the asset / endpoint context — is logged via `ITelemetryContext`-derived `m_logger` at `LogError` (for control-plane operations) or `LogWarning` (for per-property / per-action data-plane operations). Exception → `StatusCode` mapping: | Exception type | Status | |---|---| | `NotSupportedException` | `Bad_NotSupported` | | `ArgumentException` | `Bad_InvalidArgument` | | `IOException` | `Bad_ResourceUnavailable` | | any other | `Bad_InternalError` (control plane) / `Bad_CommunicationError` (data plane) | | `OperationCanceledException` | **rethrown unchanged** — never mapped to a status code | Internal endpoint URIs, file-system paths, provider implementation details, and stack-trace fragments therefore never leak across the OPC UA wire. Operators retain the full diagnostic detail through the server log. --- ## 8. Security: management access policy The five management methods on the standard `WoTAssetConnectionManagement` object — `CreateAsset`, `DeleteAsset`, `DiscoverAssets`, `CreateAssetForEndpoint`, `ConnectionTest` — mutate the asset registry and trigger outbound network activity. Anonymous, unauthenticated callers must not be able to reach them. The node manager therefore enforces a `WotManagementAccessPolicy` as the very first action of every method handler. Defaults: | Knob | Default | Rationale | |---|---|---| | `MinimumSecurityMode` | `SignAndEncrypt` | Confidentiality + integrity required. | | `AllowAnonymous` | `false` | Anonymous identity rejected even on encrypted channels. | | `RequiredRoleId` | `WellKnownRole_SecurityAdmin` | Mirrors `Opc.Ua.Server.ConfigurationNodeManager` for the equivalent `ServerConfiguration` methods. | On denial the handler logs a warning (with operation, token type and granted-role list) and throws `ServiceResultException(BadUserAccessDenied)`. Internal callers that invoke the underlying `AssetRegistry` APIs directly — startup restoration, persisted-asset replay, in-process tests — flow an `OperationContext`-less `SystemContext`; the policy check is skipped in that path so server bootstrap continues to work. Override the policy via DI: ```csharp services.AddOpcUa() .AddServer(...) .AddWotConServer(opts => { opts.ManagementAccess = new WotManagementAccessPolicy { RequiredRoleId = ObjectIds.WellKnownRole_ConfigureAdmin, MinimumSecurityMode = MessageSecurityMode.SignAndEncrypt, AllowAnonymous = false }; }); ``` To loosen identity policy in a closed lab, set `AllowAnonymous = true` and grant the anonymous identity the chosen role via your role-mapping layer. A conformant registry mutation surface still requires `MinimumSecurityMode = MessageSecurityMode.SignAndEncrypt`; lowering it is suitable only for isolated test harnesses. Read-only registry access may be exposed over `MessageSecurityMode.None` by deployment policy. --- ## 9. Limitations and known issues * The unbound 1.02 action input/output path handles the flat `type:object` shape illustrated by Spec §6.3.9 (a `properties` bag with scalar / array members). Deeper schemas — nested objects, oneOf, items-of-object — are collapsed to a single `BaseDataType` argument with the JSON schema preserved in the description. * Unbound 1.02 property mapping follows Spec Table 14: `number → Double`, `integer → Int64`, `boolean → Boolean`, `string → String`. Properties with `type: object` or `type: null` (or no `type` at all) are materialised with status `BadConfigurationError` on read (per Spec §6.3.8 last paragraph). Native/type-bound TDs instead use the semantic conversion path described under [Lifecycle](#lifecycle), retaining native types and ranks. * File operations require supported read/write combinations; append requires write and erase cannot combine with append. Close without `CloseAndUpdate` does not materialize an uploaded asset. --- ## 10. References * OPC 10100-1, *WoT Connectivity for OPC UA*: https://reference.opcfoundation.org/specs/OPC-10100-1/full * W3C Web of Things Thing Description 1.1: https://www.w3.org/TR/wot-thing-description11/ * W3C WoT Binding Templates: https://w3c.github.io/wot-binding-templates/ --- ## 11. WoT Connectivity 1.1 registry and materialization (preview) The `Opc.Ua.WotCon` assembly is source-generated once from the combined **WoT Connectivity 1.1** NodeSet2, which incorporates the published OPC 10100-1 v1.02 model (NodeIds `1..172`, superseded in capability but **not** deprecated) plus the additive registry nodes (`64000+`) in one namespace, and from the abstract **xRegistry** base model the registry types build on: | Model | Namespace | Emitted C# namespace | |-------|-----------|----------------------| | xRegistry (abstract registry base) | `http://opcfoundation.org/UA/xRegistry/` | `Opc.Ua.XRegistry` | | WoT Connectivity 1.1 (combined) | `http://opcfoundation.org/UA/WoT-Con/` | `Opc.Ua.WotCon` | Both NodeSet2 models are *pinned* from the OPC UA drafts authoring repository into `src/Opc.Ua.WotCon/Design` (as `*.NodeSet2.xml` + `*.NodeSet2.csv`) and added as `AdditionalFiles`. The legacy 1.02 `WotConnection.xml` / `WotConnection.csv` sources are retained under `Design/` for reference only — they are incorporated into the combined NodeSet and are **not** source-generated a second time, so the preserved 1.02 constants and the additive registry constants coexist in one `Opc.Ua.WotCon` namespace under their exact NodeIds. The tooling that refreshes the pinned copies from the draft repository lives in that authoring repository, not here. ### 11.1 Architecture The 1.1 runtime separates a **stable registry** from **ephemeral projections**: * `WotRegistryNodeManager` (stable) exposes the well-known `WoTRegistry` object, its Thing Description / Thing Model groups, the `Refresh` Method, registry settings and the registry event types. It never re-creates itself. Every service group and document resource is additionally materialized as a browseable `ThingDescriptionGroupType` / `ThingModelGroupType` and `ThingDescriptionFileType` / `ThingModelFileType` node beneath `WoTRegistry`, kept in sync with the registry snapshot (see §11.7). * Registry documents are projected into the AddressSpace as **separate runtime NodeManagers** through the public `INodeManagerLifecycle` (`AddRuntimeNodeSetAsync` for first activation, `ShadowReloadRuntimeNodeSetAsync` or `ImmediateReloadRuntimeNodeSetAsync` for updates). Graceful retirement keeps the previous generation serving existing monitored items until they drain. Immediate retirement reports `BadNodeIdUnknown` for affected monitored items and disposes the previous generation without waiting for drain. Register it on an OPC UA server host: ```csharp builder .AddServer(server => { /* ... */ }) .AddWotRegistryServer(options => { options.StorageFolder = Path.Combine(AppContext.BaseDirectory, "wot-registry"); options.AutoRefresh = true; // re-project after every content mutation options.StrictBindings = false; // materialize degraded nodes for unsupported forms options.RetirementPolicy = WotProjectionRetirementPolicy.Graceful; }); ``` Hosting and runtime factory registration await [readiness](NodeManagers.md#awaited-readiness), including persisted materialization/recovery when `AutoRefresh` is false. A committed image restores its exact active inputs, not newer desired/default Versions, before readiness returns; failures fail startup. Custom hosts must invoke readiness after AddressSpace creation/server initialization. Native Refresh during startup fails `BadServerTooBusy`. Refresh publication needs prepared source/registry providers. The default lifecycle and file store with genuine immutable-content leases support all four atomicity modes; `InMemoryWotRegistryStore` supports storage/capture but not publication. Unsupported providers fail `BadNotSupported` before effects. See [provider requirements](#provider-requirements) for platform constraints. ### 11.2 Registry service and persistence #### Materialization extension points Two optional seams let a protocol driver supply what a Thing Description alone cannot express. Both are resolved from DI; registering neither leaves materialization exactly as it was. * **`IWotNodeSetContributor`** — runs once per resource *after* the Thing Description has been converted to a NodeSet and *before* any variable is created, and may add nodes to that NodeSet. This is the hook for custom `StructureType` DataTypes that have no NodeSet to import because they belong to one controller program — Rockwell/Studio 5000 UDTs, TIA Portal PLC data types, TwinCAT structured types — generated from the controller's own symbol table at onboarding time. It runs at that point precisely because a `uav:mapByFieldPath` mapping can only resolve once its structured DataType is registered. A document that *can* express its types declaratively does not need this seam: the native projection (`uav:NodeModel`) already carries `DataType` nodes with their `DataTypeDefinition`. * **`IWotDocumentConverter`** — replaces the Thing Description → NodeSet conversion wholesale, and is now resolvable from DI as well as by direct construction. #### Resolving referenced companion-specification NodeSets Thing Descriptions are uploaded at run time through the standard `WoTAssetFileType` upload, so the namespaces a server will be asked to serve are not known at start-up and static pre-loading is not sufficient. `IWotNodeSetResolver` closes that gap: for every namespace a converted document requires but neither declares itself nor finds on the server, the resolver is asked for a NodeSet2. Resolution is recursive over the resolved model's own dependencies, resolved models are projected *before* the document that requires them, and a namespace that stays unresolved is reported rather than silently dropped, so an operator can see exactly what is missing. ```csharp public interface IWotNodeSetResolver { ValueTask TryResolveAsync(string namespaceUri, CancellationToken ct = default); } ``` Returning `null` is the contract's way of declining and is not an error. **No implementation ships with the library** — resolving a namespace means reaching out to a catalogue (a UA Cloud Library instance, a corporate model repository, a folder on disk), which is a deployment decision, so the library takes no dependency on any of them. A document that carries its own model needs no resolver at all: the `uav:nodeSet` envelope embeds the NodeSet2 in the Thing Description itself. `IWotRegistryService` owns an immutable `WotRegistrySnapshot`. Every mutation produces a new snapshot with a strictly greater `Generation` (epoch); readers hold a snapshot and never observe a partial change. A resource carries its versions (raw source bytes + SHA-256 content digest), desired/active version pointers, `WoTLoadStateEnum`, `WoTValidationOutcomeDataType` and diagnostics. Two persistence back-ends are provided: * `InMemoryWotRegistryStore` — volatile; the registry starts empty. * `FileWotRegistryStore` — durable; metadata uses a **bounded atomic replace** (write-to-temp then `File.Replace`), with content-addressed blobs shared by digest. Invalid documents retain their failure state across restart. #### Keeping the document bytes in a shared store `WotRegistryServerOptions.ResourceStore` stores document bytes through the injectable [`IXRegistryResourceStore`](XRegistry.md#resource-storage). A shared store makes those bytes reachable from every node; manifests, mutation coordination and per-node refresh still need separate replication and coordination. A store registered in DI wins over one set on the options, matching the xRegistry server's precedence: ```csharp options.StorageFolder = "/var/lib/myapp/wot-registry"; // manifest options.ResourceStore = new WotBlobResourceStore("/mnt/shared/wot-documents"); ``` `FileWotRegistryStore` serializes loads and commits across processes sharing the same storage root and rejects stale generations. Those protections require filesystem semantics that support its locking, atomic replacement and durability operations; sharing only the document store does not extend them to independent manifest roots. `WotBlobResourceStore` is the default WoT implementation, keyed by document SHA-256 digest. It retains the `{root}/{digest}.bin` layout, so adopting the resource-store interface requires no on-disk migration. It is validated against the shared `XRegistryResourceStoreContractTests`; replacement stores must also meet the runtime's separate immutable-lease requirements for prepared publication. The registry still switches its own manifest; only content storage moves. Documents are durable before the manifest references them. Content addressing alone does not enforce immutability: a supplied store owns byte durability and must protect bytes and ResourceKey mappings for prepared publication. See [prepared store contracts](#prepared-registry-store). ##### Staging and promotion Writing bytes before the manifest that names them collides with the way the file store recognises trouble: a `blobs/` directory with no manifest means a lost generation or a crashed commit, and the store fails closed rather than report an empty registry and discard data. If the writer put bytes straight into `blobs/`, the very first write on a fresh deployment would look exactly like that. So writes land in `staging/` and the commit **promotes** the entries its snapshot references into `blobs/` as artifacts it owns, before switching the manifest: ```text {root}/staging/… writer's durable scratch — never evidence of prior state {root}/blobs/{digest}.bin promoted by the commit, named by the manifest {root}/manifest.json switched last ``` Reads prefer `blobs/` and fall back to `staging/`, so content written but not yet committed is still readable by the transaction that wrote it. A staged entry that never gets promoted is inert and safe to delete: nothing can reference a document until a manifest names it, and only promoted entries are ever named. A commit that is refused therefore leaves a staged orphan and changes nothing else. Promotion also checks content integrity. Each referenced document is streamed and hashed as the snapshot is validated, so a blob whose bytes were altered without changing its length, or that cannot be read at all, fails the commit closed. The hash is computed incrementally over chunks, so verifying a document never requires holding it in memory. Structural validation runs first, so a malformed snapshot is reported for what is wrong with it rather than for content it was never entitled to reference. A blob is written once per digest, so editing one document leaves every other document's file untouched — asserted by `MutatingOneResourceDoesNotRewriteAnotherResourceBytes`. A decorator around `IWotRegistryStore` **must** forward `IWotRegistryResourceStoreProvider`. A decorator that drops it leaves the registry service writing bytes into a private in-memory store while the wrapped store validates against its own, and every commit then reports the documents missing. Resource bounds (`WotRegistryPersistenceBounds`) cap document size, versions per resource, resources per group, and group count. #### Manifest schema Schema 6 is the local `manifest.json` format, not the vocabulary/NodeSet/JSON Schema version. It retains exact authorities, Version metadata/selections, validation observations, and committed publication state. `DependencyGraphs` stores distinct ordered graphs once; each Version observation keeps its own source/request/time/generation/digest and a `GraphIndex`. Invalid/conflicting or out-of-range indexes fail loading. Schemas 3-5 remain readable; older records without authority stay explicitly unbound unless configured identity bindings establish an unambiguous mapping. The next durable commit writes schema 6. Downgrade requires a compatible manifest and its referenced blobs. [Prepared metadata commits](#prepared-registry-store) retain verified content evidence rather than rereading unchanged independent blobs; their immutable leases and commit outcomes are separate provider contracts. #### Deleting a document, and the documents that could not be read `DeleteResourceAsync` first walks the dependency graph, which means reading every stored document to find its outgoing references. A registry is a set of blobs, so some of those reads can fail: the blob is gone, or its bytes no longer match the digest the manifest recorded. An unreadable document is **recorded, not propagated** — `WotDependencyGraph.FindDependentsWithFaultsAsync` returns it in `WotDependentSet.Unreadable` and it contributes no edges. Letting the read failure out of the walk would make one corrupt blob anywhere in the registry wedge every policy, including `Force`, whose entire purpose is to remove a target when the tidy answer is unavailable. Each policy then states what it did about it, and `WotDeleteResult.Unreadable` names them: | Policy | Proven dependents | Documents that could not be read | | --- | --- | --- | | `Reject` | Refuses while any exists. | **Refuses.** The safety it asserts — that nothing is still using the document — was never established. | | `Retire` | Keeps the document stored and resolvable; only the projection comes down. | Nothing loses a reference, so they are not this policy's problem. | | `Cascade` | Unloads only the ones that lost a reference. | **Left alone** and reported. Unloading one would take a projection down on a guess. | | `Force` | Deletes the target and marks every dependent `Failed`. | Marked `Failed` too, with a diagnostic saying why: `Force` cannot claim they were unaffected, and its contract is to say what it broke. | The target's own blob is the one exception: it is being removed anyway, so its readability never blocks the delete under any policy. ### 11.3 Materialization coordinator `WotMaterializationCoordinator.RefreshAsync` drives projection: 1. Parses/validates each registry document with `Opc.Ua.Wot`. 2. Builds the TD/TM dependency graph from `links` (`rel = tm:extends / type / tm:submodel`), a top-level `tm:extends`, and `tm:ref` pointers, resolving references against the registry by Thing id / xid / resource id. It never follows an arbitrary external URL; an unresolved absolute URL remains a missing dependency unless a configured xRegistry federation layer has registered it. 3. Partitions the graph into **dependency closures** (weakly-connected components) with type/inheritance order respected. Shared declarations can join closures; semantic reference cycles can coactivate, while inheritance/ ordering cycles and missing dependencies produce diagnostics. 4. Converts each closure to one or more NodeSet2 documents and projects the closure as one runtime NodeManager (Add, or graceful/immediate reload on update according to `RetirementPolicy`). Behaviours: * The NodeSet2 a closure converts to is **loadable by construction**. Step 4 is literally `ConvertAsync` → serialize → `UANodeSet.Read` → `Import`, and the importer rejects any name used where a NodeId is expected that the document does not declare in ``. The converter therefore completes the `` table of everything it returns, on all three restoration paths (readable synthesis, `uav:nodeSet` envelope restore and `uav:nodes` native restore), without rewriting any name a document brought. See [Importable output](WotNodeSetConversion.md#importable-output). A vendor alias a source document uses but never declares still fails the load, and is reported as a closure diagnostic naming it. * Independent publication units commit independently; a failed or invalid unit **retains its previous active generations**. * An **unchanged** closure (same content digest, options and binder version) returns `WoTOutcomeEnum.Unchanged` and emits no model change. * `WotProjectionRetirementPolicy.Graceful` preserves existing monitored items on the previous generation until drain. `WotProjectionRetirementPolicy.Immediate` invalidates affected items with `BadNodeIdUnknown` and disposes the previous generation. The proof rejects immediate retirement when the old generation owns a durable monitored item; configure `Graceful` for that closure. * `Refresh` returns a detailed `WoTRefreshSummaryDataType` plus a per-resource `WoTResourceLoadResultDataType[]` and the new generation, matching the generated Method signature. * The coordinator's concrete failure/completion events are re-emitted as `WoTValidationFailureEventType` / `WoTLoadFailureEventType` / `WoTBindingFailureEventType` / `WoTRefreshCompletedEventType`. The abstract `WoTResourceEventType` is not emitted as a wire occurrence. Empty selectors mean enabled Resources with committed content; nonempty unmatched selectors do no work. Exact-Version selections do not follow a new default. `IncludeDependents` adds the reverse closure. Capture retains original bytes, contextual references, and Version leases; disabled dependencies may supply definitions without executing. PerResource, PerGroup, PerClosure, and PerRegistry apply to selected/required work; dependency safety may coarsen the requested boundary. Inspect `Summary.Atomicity` and `LastRefreshPlan` for the applied plan. `ExpectedGeneration` uses `RefreshGeneration`; stale nonzero guards fail `BadInvalidState`. `MaxParallelism` is an upper bound (current preparation is serial). Timeout covers the whole invocation including admission/capture; zero disables it. Dry runs prepare private candidates without changing observations, plans, caches, generations, View tokens, or events. `DependencySnapshot` records successful exact-Version activation; `LastDependencyAttempt` records a completed actual attempt. Initially absent observations report `BadWaitingForInitialData`. Logical reads follow the default; exact-Version reads do not. Decode `WoTDependencySnapshotDataType` through the Session's registered factory/message context. Remote observation reads require the management policy and native Read/Browse authorization on the source group and exact Version, and on every captured target's local registry origin, group and exact Version, including native channel access restrictions and namespace permission defaults. A denied or unavailable target fails the entire read without a partial value or target identifiers in diagnostics. Pruning a target therefore makes remote disclosure fail closed; it does not erase the retained committed observation. These checks cover local registry pins, not an authorization policy for arbitrary federated or non-registry providers. Validate reports unperformed format/compatibility policies as `Skipped`, not passed. See [capture contracts](#dependency-capture-and-observations). ### 11.4 Binder integration seam `IWotBinderRegistry` is the runtime-neutral seam the coordinator uses during Prepare/Activate/Deactivate. `WotProtocolBinderRegistry` implements that seam and `IWotBindingChannelFactory`, compiling immutable plans from the registered binders and opening channels through independently registered executors. The base `Opc.Ua.WotCon.Bindings` package ships all eight planners and bundles HTTP, Modbus TCP, and OPC UA executors on `net8.0`, `net9.0`, and `net10.0`; MQTT remains in `Opc.Ua.WotCon.Bindings.Mqtt`. The base package retains the full `net48;net8.0;net9.0;net10.0` matrix, where planner-only validation remains available even when concrete executor namespaces are not compiled. The generic projection runtime is implemented in `Opc.Ua.WotCon.Server.Materialization`. It resolves affordance-level OPC 10101 target mappings against freshly imported runtime NodeSets, wires async read/write handlers, opens one lazy channel per compiled form per generation, lets local monitored items sample the same read handler, supports reflection-free structured field mapping, and disposes channels with their owning generation. Updates use shadow reload, so existing monitored items keep the retired generation alive until they drain while new reads and monitored items use the replacement generation. The default `NullWotBinderRegistry` remains the no-binding baseline. With it, affordance forms either **fail a strict closure** (`StrictBindings = true`) or **materialize as degraded nodes** (`BadConfigurationError`) when non-strict. Protocol support is opt-in: `AddWotProtocolBinders()` registers all eight planners, while `AddHttpWotBinding()`, `AddMqttWotBinding()`, `AddModbusWotBinding()`, and `AddOpcUaWotBinding()` add their concrete executors. The core server registers none of these by default; see [WoT protocol bindings](WotBindings.md). ### 11.5 Legacy 1.02 compatibility The generated 1.02 namespace/NodeIds/Method signatures and client APIs are preserved. `LegacyGroupId` remains the configuration property for its group; `RegistryBridge` or `AddWotRegistryBridge` explicitly enables mirroring. Hosting both NodeManagers alone does not turn it on, and the flat asset list does not become the canonical registry store. ### 11.6 Protocol and projection scope The implemented data plane covers executable HTTP, Modbus TCP, MQTT, and OPC UA binding forms according to the operation coverage documented in [WoT protocol bindings](WotBindings.md). CoAP, BACnet, PROFINET, and LoRaWAN currently ship as planner-only binders: their forms are validated and represented in plans, but a non-strict closure is degraded until an executor is registered. OPC 10101 target mapping is authored on property affordances, not forms. `uav:mapByFieldPath` requires `uav:mapToType`, portable `nsu=` NodeIds are resolved against the runtime generation's namespace table, and the mapping is protocol-neutral as illustrated by [OPC 10101 §8.2](https://reference.opcfoundation.org/specs/OPC-10101/8.2). See [OPC 10101 §6.5.4](https://reference.opcfoundation.org/specs/OPC-10101/6.5.4) and the [binding-authoring guide](WotBindings.md#adding-your-own-binding) for the exact validation and runtime semantics. ### 11.7 Browseable registry projection and management Methods The stable `WoTRegistryNodeManager` materializes the registry snapshot as a browseable object tree and wires the inherited xRegistry / registry Methods: * For every service group a `ThingDescriptionGroupType` or `ThingModelGroupType` object is created beneath `WoTRegistry`, and for every resource its `ThingDescriptionFileType` / `ThingModelFileType` document node is created beneath the group. NodeIds are stable and deterministic, derived from the registry Xid (for example `WoTRegistry/groups/{groupId}/resources/{resourceId}`). The projection is reconciled on every registry `Changed` event — including projection-only callbacks, which never re-trigger materialization — and removes group and resource nodes as they disappear from the snapshot. * Each node carries its xRegistry and registry metadata (ids/Xid/epoch/name/ description/timestamps/format/content type, desired/default/active version, enabled/load state, validation outcome, content digest, materialized-node count, the materialized `RootNodeId`, and selected bindings). `HasNotifier` references chain `WoTRegistry` → group → resource → `Server`, and resource lifecycle failure events are sourced at the specific resource node (the registry object remains the source for the refresh-completed summary event). * The xRegistry `CreateGroup` / `GetOrCreateGroup` (on `WoTRegistry`), `CreateResource` / `GetOrCreateResource` / `Delete` (on a group) and the document `Delete`, `Validate`, `SetEnabled` and `SetDefaultVersion` (on a resource) Methods are wired to the registry service, enforcing `ExpectedEpoch` optimistic concurrency and the management access policy. Registry mutations require a `SignAndEncrypt` SecureChannel; deployments may separately permit read-only registry access over `SecurityMode.None`. * The inherited FileType (`Open` / `Read` / `Write` / `Close` / `GetPosition` / `SetPosition`) transfers the document body with per-session handles, a single exclusive writer and bounds. Closing a write handle commits the buffer as a new version; a document that fails validation is still stored as an invalid version so the bytes are never lost and the previous active projection is retained. * Every browseable registry/group/resource node also carries the inherited optional `Labels` (`AttributesType`) container. Each label is persisted as an ordinally-ordered key/value pair on the owning `WotRegistrySnapshot` / `WotResourceGroup` / `WotResource` model and materializes as its own `PropertyType` child with a deterministic NodeId (for example `WoTRegistry/groups/{groupId}/labels/{key}`) and a safe, collision-checked BrowseName. The container's `AddAttribute(Key, Value, ExpectedEpoch)` and `RemoveAttribute(Key, ExpectedEpoch)` Methods enforce the management access policy, optimistic-concurrency `ExpectedEpoch` (the group/resource's own epoch; the registry singleton has no separate epoch so its Labels compare against the snapshot `Generation`), the configured `WotRegistryPersistenceBounds` (`MaxLabelsPerEntity`, `MaxLabelKeyLength`, `MaxLabelValueLength`) and reject invalid/control/BIDI/ path characters or a key colliding with the container's own fixed `AddAttribute`/`RemoveAttribute` member names, using the shared `WotChildNameValidator`. `IWotRegistryService` exposes matching `Add`/`RemoveRegistryLabelAsync`, `Add`/`RemoveGroupLabelAsync` and `Add`/`RemoveResourceLabelAsync` service APIs; label mutations raise a projection-only registry change so they update the browseable Labels container without re-triggering materialization. Labels survive a registry restart and file-store reload (persisted alongside their owning group/resource, and — for the registry-level set — in a small `registry.json`) and remain visible after every projection reconciliation. Version-level labels are stored on the immutable `WotResourceVersion` model for API completeness but are not materialized as a separate AddressSpace node, since the xRegistry model does not define a `VersionType.Labels` container (only Registry/Group/Resource expose one). ### 11.8 Binding-vocabulary alignment (NodeSet2 ↔ WoT) `Opc.Ua.Wot.WotNodeSetConverter` maps a NodeSet2 model to a WoT Thing Model / Thing Description and back. The deterministic, versioned `uav:nodes` projection covers the complete UANodeSet schema and is emitted only when the semantic/readable mapping cannot reproduce all source facts; `uav:nodeSet` is emitted only for explicit byte archival or a demonstrated final fallback. Unmapped WoT JSON members are stored individually by RFC 6901 pointer in a `WoTJsonResidue` NodeSet Extension, not by copying the source document. The readable surface tracks the current [OPC UA WoT Binding](https://reference.opcfoundation.org/) revision: * **Semantic conversion is the default.** `WotNodeSetPreservationMode` selects `WhenRequired` (default), `Always` (explicit byte archive), or `Never` (conformance/completeness tests). The converter first reconstructs the readable document and omits `uav:nodes` when it is equivalent; it then validates the structured projection when fallback is required. Tests that prove completeness use `Never` and assert that no opaque envelope exists. `WotNodeSetRoundtripReport.NativeProjectionPreserved` and `UsedPreservationEnvelope` distinguish the two paths (`Opc.Ua.Wot.WotNodeSetRoundtrip.Run`). * **Unknown members survive as residue, not an envelope.** During TD/TM-to-NodeSet synthesis, only unrecognized or unmapped JSON values are stored in the root `Extensions` collection as digest-protected `WoTJsonResidue/Member` entries. Reverse conversion regenerates mapped facts from OPC UA and applies the pointer-addressed values. A collision with a regenerated model fact is reported as `WotDiagnosticCode.ResidueConflict`. * **Event affordances carry `uav:eventType`.** An OPC UA EventType (a `BaseEventType` subtype) projects to an event affordance annotated `@type: uav:eventType`; a NodeSet whose root is an EventType is annotated the same way. That annotation is the whole statement of event identity — WoT Binding 1.1 defines no parallel boolean flag — and reverse conversion recreates a `BaseEventType` subtype from it. A legacy document that still carries `uav:isEvent` is consumed permissively: the member survives as ordinary unknown residue and changes nothing, while strict authoring reports it as an unknown term. * **Identity terms are portable ExpandedNodeIds.** Every persisted identity term — `uav:id`, each `uav:hasComponent` / `uav:componentOf` entry, `uav:mapToNodeId` / `uav:mapToType`, a NodeId-valued `uav:refId`, and a generated `?id=` href — is emitted as an OPC 10000-6 `nsu=;...` ExpandedNodeId, resolved through the source NodeSet's `NamespaceUris` table so the value survives a namespace-table reordering; namespace 0 keeps its canonical `i=` form and the session-local `ns=` form is never emitted. On input the converter diagnoses an `ns=` in any of these terms (`WotDiagnosticCode.NonPortableIdentity`). The `uav:nodeSet` envelope and NodeSet-local fields inside `uav:nodes` keep their own namespace tables and are excluded from this readable-identity rule. * **BrowseNames are portable QualifiedNames.** Generated readable `uav:browseName` values use OPC 10000-6 `nsu=;` for non-base namespaces and the bare Name for namespace 0. Numeric `namespaceIndex:name` is retained only inside `uav:nodes`, which carries its own `namespaceUris` table. * **Model concepts carry NamespaceUri-qualified names.** Generated contexts bind `ua` to the base OPC UA namespace and deterministic `ns1`, `ns2`, … prefixes to companion NamespaceUris. A typed link emits the ReferenceType model name directly in `rel` (for example `ua:HasOrderedComponent`) beside its definitive `uav:refId` ExpandedNodeId. Authored `uav:mapToTypeName` hints are validated and preserved beside their definitive identifiers. Compact model names are never used for arbitrary instance targets. * **`observable` advertises binding support.** A generated `observable: true` / `observeproperty` form states that the TD exposes observation through this binding. It is not a claim that other OPC UA Variables are technically unmonitorable; any Variable can be a MonitoredItem when the Server grants access. * **HasComponent subtypes are pinned by a typed link.** `uav:hasComponent` / `uav:componentOf` expose parent-child ownership for discovery across `HasComponent` and its subtypes. When the source ReferenceType is a subtype (for example `HasOrderedComponent`, `i=49`), the converter additionally emits a link whose `rel` is `ua:HasOrderedComponent`, whose `uav:refId` is `i=49`, and whose `uav:refName` names the reference. Reverse conversion resolves the name, verifies the identifier when both are present, recreates the exact subtype, and otherwise falls back to plain `HasComponent`. * **The readable surface carries more than it used to.** Documents this server generates now also carry event severity, `Method` argument schemas, event `data` and the Section 13 Condition terms, engineering units and ranges, `titles` / `descriptions`, `uav:valueRank` / `uav:arrayDimensions`, and typed links for arbitrary companion ReferenceTypes in both directions. Each is stated once, in [WoT / NodeSet conversion](WotNodeSetConversion.md); the relation and type-binding resolution rules are stated once in [WoT protocol bindings](WotBindings.md#resolving-a-type-binding-the-local-context). Converted NodeSets are also alias-complete, which makes the materialization path in §11.3 loadable rather than merely convertible. * **Conformance strictness is opt-in.** `WotNodeSetConverterOptions.ConformanceMode` defaults to `Permissive`, as Sections 4.1, 6.6, 9.4 and 10.2 require of a consumer: an unknown `uav:` term is preserved as residue rather than reported. Registry materialization keeps that default, so a document authored against a later revision still loads. `Strict` is for authoring and conformance testing; see [Conformance claims and strict mode](WotNodeSetConversion.md#conformance-claims-and-strict-mode-sections-41-61-66-and-11). ### 11.9 Registry client `Opc.Ua.WotCon.Client` ships a registry client surface alongside the existing `WotConnectivityClient`. `WotRegistryClient` **derives from the shared xRegistry `XRegistryClient`** — the WoT registry model subtypes the xRegistry base model, so it is a *domain client* in the sense of [xRegistry — Extending for a domain registry](XRegistry.md#extending-for-a-domain-registry) and inherits the base group/resource lifecycle, `Session` and `RegistryNodeId`. The registry root is not the provisional well-known `65000`: the browse-resolved `WoTRegistry` NodeId is passed to the base constructor. The generated `WoTRegistryTypeClient` / xRegistry `GroupTypeClient` / `ResourceTypeClient` proxies are still *composed* rather than inherited, so a typed proxy is reused directly instead of being re-resolved per call: * `WotRegistryClient.ForServerAsync(session, telemetry, ct)` resolves the well-known `WoTRegistry` object (a `HasComponent` child of the `Server` object) via `TranslateBrowsePaths`, exactly like `WotConnectivityClient.ForServerAsync` resolves `WoTAssetConnectionManagement`. Both now share the same internal `TranslateBrowsePaths` helper. The resolved NodeId is surfaced as the inherited `RegistryNodeId`. * Typed provisioning keys groups by document Kind and exact CatalogUri, and Resources by their owning group and exact ThingId/ModelId. URI spelling/case/query/fragment/escaping matter; assigned identifiers are not guessed. `OpenGroupAsync`/`OpenResourceAsync` discover without provisioning or activation. * Generic `CreateResourceAsync` / `GetOrCreateResourceAsync` remain available for existing assignments. New authorities need explicit `IdentityBindings.Groups` / `.Resources` aliases; missing/conflicting authorities fail. `IWotTypedRegistryService` is an optional capability of the same service owner. * `WotRegistryResourceClient.UploadNewVersionAsync(ByteString | Stream, …)` uses inherited `FileType` write/Close primitives. `DownloadAsync`/`DownloadToAsync` read the logical Resource's **default** Version, not its active/desired Version; an already open handle retains its exact Version/cursor/writer reservation/retention lease. Thus failed v2 activation may leave v1 serving while new downloads return default v2. `ValidateAsync`, `SetEnabledAsync`, `SetDefaultVersionAsync`, and `DeleteAsync` use generated document Methods. * `WotRegistryClient.RefreshAsync` / `RefreshAllAsync` call the generated `Refresh` Method and return a typed `WotRegistryRefreshResult` (`Summary`, `Results`, `NewGeneration`, `HasFailures`, `EnsureSuccess()`). * `WotRegistryClient.LoadDocumentsAsync` loads a caller-supplied `ArrayOf` (an immutable `Kind`/`GroupId`/`ResourceId`/`Content` (`ByteString`)/`VersionId` descriptor), get-or-creating each target group/resource and uploading its content, then optionally calls `RefreshAllAsync` — one workflow. Thing Models are always processed before Thing Descriptions (preserving the caller's relative order within each kind) so referenced models are materialised before the descriptions that depend on them. A mutation failure or a group/document kind mismatch aborts immediately (`ServiceResultException`); a refresh failure is *not* thrown — it is surfaced on the returned `WotRegistryBulkLoadResult.Refresh` for the caller to inspect, since a partial refresh outcome is legitimate application data. ```csharp WotRegistryClient registry = await WotRegistryClient.ForServerAsync( session, session.MessageContext.Telemetry, ct); (WotRegistryGroupClient group, _) = await registry.GetOrCreateDocumentGroupAsync( WoTDocumentKindEnum.ThingDescription, "https://contoso.org/plant/things/", ct); WotRegistryResourceAllocation allocation = await group.CreateThingDescriptionResourceAsync( "urn:plant:sensor01", requestFileOpen: true, ct: ct); ByteString document = ByteString.From(await File.ReadAllBytesAsync("sensor01.td.json", ct)); await allocation.Version.Proxy.WriteAsync(allocation.FileHandle, document, ct); await allocation.Version.Proxy.CloseAsync(allocation.FileHandle, ct); WotRegistryRefreshResult refresh = await registry.RefreshAllAsync(ct: ct); refresh.EnsureSuccess(); ``` The uploaded `id` must match the supplied ThingId. Allocation returns distinct logical Resource/exact Version identities and assigned IDs; GetOrCreate reports independent CreatedResource/CreatedVersion flags. Empty VersionId allocates for Create but selects the default for GetOrCreate; allocation does not select default. `requestFileOpen: true` prepares a preserving session-owned write handle before durable allocation (committed initial bytes, position zero, neither erase nor append). Clean Close changes nothing; dirty Close validates identity/kind. Preparation failure allocates nothing. Session closure before commit rejects it; after commit it discards the handle without undoing allocation. Other providers without transactional requested-open support fail before mutation. Register the registry client with DI alongside `AddWotConClient` via `AddWotRegistryClient` (on `IOpcUaBuilder` or `IOpcUaClientBuilder`, bindable from `IConfiguration`/`IConfigurationSection`, default section `OpcUa:WotCon:RegistryClient`). It follows the same lazy `ManagedSession`-backed factory pattern: resolve `Func>` for the lazily connected form, or `Func>` to wrap an already-connected session. Bounded consumers can use the stream overload of `UploadNewVersionAndGetResultAsync` with `maxBytes`; pending structural versions are claimed before consuming a non-seekable source, without buffering it for replay. The returned version id and NodeId identify the bytes actually written. `WotRegistryRefreshResult.MethodStatus` retains the actual method completion status alongside the summary and per-resource outcomes. For the legacy file workflow, `UploadAndUpdateWithResultAsync` returns the consumed byte count and actual `CloseAndUpdate` status, including `GoodResultsMayBeIncomplete`. `ValidateWithResultAsync` on a generated document proxy validates that exact node and returns the method status plus the typed outcome. These result-bearing helpers do not replace completed results with late cancellation. ## 12. Conformance to WoT Connectivity 1.1 This clause describes what the model requires and what this implementation provides. ### 12.1 Model identity The information model is generated from the NodeSets the specifications publish, adopted verbatim rather than maintained by hand. | Model | Version | PublicationDate | |---|---|---| | WoT Connectivity | `1.1` | 2026-09-21 | | WoT Binding (OPC 10101-1) | `1.1` | 2026-07-29 | | xRegistry (`RequiredModel`) | `0.7.0` | 2026-09-12 | | OPC UA Core (`RequiredModel`) | `1.05.04` | 2025-01-08 | xRegistry contributes 131 nodes and the combined Connectivity model 349. The registry honours its reverse-authority construction algorithm for `GroupId` and `ResourceId` (§ 11.4), `SignAndEncrypt` on every mutating operation, and optional generic event semantics. OPC 10101-2 (WoT Projections) defines the projection-plan conformance unit used alongside the separate Connectivity projection-materialization unit; it adds no NodeSet model. The [Part 2 implementation and evidence map](WotBindings.md#opc-10101-2-wot-projections) records the resolver/View behavior and the mandatory result-validation blocker. It does not claim full `WoT-Projection` conformance. Draft iterations are identified by the specification release label, for example `1.1-draft8`; they do not increment the information model version. ### 12.2 Conformance units and profiles Three profiles form a lattice rather than a ladder: | Profile | Covers | |---|---| | *WoT-Con Minimal* | `Wot-Con 1.02` alone — the published OPC 10100-1 v1.02 shape and nothing else | | *WoT-Con Registry Server* | the registry surface without federation, change events, projections or the atomicity modes; includes `WOTC-Defaulting` | | *WoT-Con Full* | every unit, `Wot-Con 1.02` included | Minimal and Registry Server are each a subset of Full, and neither is a subset of the other: they share no conformance unit. A server may implement either surface or both. These are specification profile definitions, not blanket implementation claims. Full JSON Schema/compatibility-policy validation, distributed publication, and other deferred requirements are not proved by prepared publication or conversion tests. `Wot-Con 1.02` is implementable on its own, so it covers serving the data points of an uploaded Thing Description — and with it, format-validating that document before any Node is materialized from it. Client-supplied input never reaches the AddressSpace unchecked; a document that fails validation materializes nothing and returns `Bad_DecodingError`. `WOTC-ProjectionMaterialization` is carried by `ThingDescriptionFileType`, `ThingModelFileType` and `HasWoTProjection`. `WOTC-Defaulting` is a behavior-only unit required by both Registry Server and Full. It covers accepting ordinary TD/TM documents when no optional `uav` terms are needed, applying only the specified defaults, and reporting a normal validation or projection failure when an explicitly used construct lacks a required dependent value. Its defaults are described in the materialization tables below; this unit introduces no NodeSet nodes. ### 12.3 Grouping A grouping is an ordinary document whose members are reached by `ua:Organizes` links; across documents it is a projection document (§ 12.4). The binding has no separate grouping vocabulary, because a grouping is an Object and `Organizes` is a ReferenceType — the two constructs the model already has. ### 12.4 Projection documents and the View NodeClass A **projection plan**, as defined by OPC 10101-2, names the source documents whose affordances contribute to a view and may also define complete local affordances. A local definition replaces a source selection with the same affordance name; it is not an annotation on that source definition. This completes the NodeClass binding. Seven OPC UA NodeClasses bind to a WoT construct that defines something; `View` is the eighth and the only one whose purpose is to select rather than define. A View owns no Node — it organizes Nodes that already exist so a client can browse a subset shaped for one task — and a projection document is that construct in WoT. A plan is stored as an ordinary Thing Description or Thing Model, with the corresponding TD/TM format and media type. Its `@type` contains `uav:projection` and exactly one ordinary root role: `Thing` or `tm:ThingModel`. It declares: | Term | Meaning | |---|---| | `uav:scenario` | absolute IRI naming the purpose the view serves | | `uav:projects` | non-empty manifest of the documents it projects | | `uav:sourceName` | alias for a source, unique in the manifest | | `uav:routing` | `source` (default) or `projection` | | `uav:sourceDigest` | `sha-256:` pinning a source revision | | `uav:namePrefix` | prefix applied to bulk-selected names | Direct upserts supply Format, ContentType, and result Kind. `DetectProjectionFormat` classifies role; the registry FileType adapter uses classification. Asset upload/ordinary conversion reject unresolved plans. Bulk/supporting projection-routed forms need the host's real `IWotProjectionFormProvider` (DI or converter options). DI wins over registry options; explicitly registered converter options retain their own provider. Enumerated selections author their own forms. Provider output describes endpoints, does not publish them, and changing it does not itself invalidate generations. Host security does not grant credentials on copied source endpoints. Selection has three source-selection forms. An enumerated `tm:ref` that names an affordance in a manifest source is the only form that can annotate it; `uav:selectAll` takes every affordance of a source; and `uav:select` filters on affordance kind, semantic identifier and type tokens. The predicate set is closed — a filter carrying any other key is rejected rather than ignored — so a filter stays decidable by inspection. A complete affordance definition without a source-selection `tm:ref` is local to the projection. A `tm:ref` that does not select an affordance in a manifest source retains its ordinary TD/TM reference meaning and remains part of that local definition. Local definitions take precedence over imported selections with the same affordance name. During server materialization, local definitions are converted through the ordinary TD/TM and Binding path into Nodes owned by the projection resource. The canonical View remains separately identified and organizes those local Nodes alongside selected source Nodes; imported Nodes are organized by identity and are not cloned. Enumerated source selections may carry only the permitted annotations described below. Authored local `uav:id` values are preserved; generated identities use a resource-scoped namespace so two projections with the same title and member names do not share local Nodes. View membership uses the converter's actual affordance identities, not an identifier guessed from the local container root. For a closure spanning several models, fluent runtime configuration uses the first converter-resolved active root namespace as its default; individual bindings still target their converter-resolved identities in their own namespaces. A document without portable root evidence retains the runtime NodeSet builder's normal namespace inference. An enumerated selection may annotate the affordance it names, but OPC 10101-2 closes the set of members it may annotate with. Permitted beside `tm:ref` are `title`, `titles`, `description`, `descriptions`, additional `@type` values, `uav:semanticId` and `uav:metadata` — presentation and semantics — plus `forms` and `security` where `uav:routing` is `projection`. Every other member is rejected with `ProjectionAnnotationNotPermitted`, including a restated `type`, `unit`, `minimum`, `maximum` or `enum`. Merging one of those would silently override what the source says about the Node, which is exactly what a document that declares rather than defines must not be able to do. The rule mirrors the closed predicate set of `uav:select`: both are decidable by inspection. A member selected from a **`source`-routed** source carries the source's own form, so one that states `forms` or `security` of its own makes the document **invalid** — the same `ProjectionAnnotationNotPermitted`, reported at resolution time where the routing is known, and the view does not resolve. It is not dropped: a dropped form is one the author wrote and the consumer silently did not use, which reads at run time as the source endpoint answering a request the document appeared to address elsewhere. Selections are applied in the total order defined by OPC 10101-2, and the **first** selection of a name wins: by the position of the source in `uav:projects`; within one source, every enumerated selection before every bulk one; within each group, by affordance kind in the fixed order `properties`, `actions`, `events`; within one kind, by ascending Unicode code point of the name the selection takes **in the view**; and, where two selections still compare equal, by ascending Unicode code point of the affordance's name **in the source**. The last key makes the order total: `uav:namePrefix` upper-cases the first character of the source name, so `serialNumber` and `SerialNumber` in one source both become `deviceSerialNumber` in the view and nothing before it separates them. The order is stated over names rather than over document order because `properties`, `actions` and `events` are JSON objects, which RFC 8259 defines as unordered — a rule that ranked selections by member position would let two conforming consumers resolve identical bytes into different views. Materialization produces a `View` Node that `Organizes` imported and locally materialized Nodes. The View graph itself owns only the View and any organizational Objects. The resource's `MaterializedNodeCount` also includes the Nodes created through ordinary Binding conversion of its local definitions, but never counts imported Nodes it merely organizes. Dry-run, committed and unchanged refresh results use that same total. `RootNodeId` remains the View, and the document resource points at it through `HasWoTProjection`, navigable back through `WoTProjectionOf`. `ViewVersion` is a deterministic function of the resolved membership alone, computed exactly as *WoT Projections* specifies: each resolved member's ExpandedNodeId in the portable `nsu=` form, **deduplicated**, sorted ascending by Unicode code point, each written as its length in UTF-8 octets, a colon, the string and U+000A, UTF-8 encoded, and the first four octets of the SHA-256 digest read as a big-endian `UInt32`, with `0` reported as `1` because OPC 10000-3 §5.4 requires a value greater than zero. The membership is a **set**. A Node the view reaches through more than one organized group is one member of the View and contributes once, because a View `Organizes` a Node or it does not, and the same `Organizes` Reference is not created twice. A server that counted a shared Node twice would compute a different value from one that organized it under a single group, for the same View. The sort is by Unicode **code point**, which on this platform is not the same as `StringComparer.Ordinal`: an ordinal comparison orders UTF-16 code *units*, so every supplementary character sorts below U+E000..U+FFFF instead of above them. A Server that sorted ordinally would compute a different `ViewVersion` from a conforming one for the same membership whenever a NodeId string identifier carries a character outside the Basic Multilingual Plane. `Opc.Ua.Wot.WotCodePointComparer` is the one implementation of that order, shared by this computation, the projection selection order defined by OPC 10101-2 and the endpoint tie-break defined by OPC 10101-1, so a second one cannot drift from the first. The length prefix makes the encoding injective. A NodeId string identifier may itself contain U+000A, so joining on the separator alone would let a single member that embeds a newline serialize byte-for-byte as the two members it imitates — a structural collision an author can construct deliberately, distinct from the statistical one below. Specifying the function makes the property testable: two servers that resolved the same membership compute the same value, which a per-server counter could not promise across a redundant pair. It needs no persisted state, so it survives a restart or a rebuild from the registry, and it records *what* a View contains rather than how it is arranged — reordering the same members does not change it. It is not monotonic and carries no ordering. A `UInt32` cannot separate every possible membership, so a client treats inequality as proof that the membership changed and equality as evidence rather than proof that it did not. A projection over Thing Models materializes to a `View` in the same way, organizing the ObjectType and VariableType Nodes its source Thing Models materialized. A source that is not in the address space is omitted from the View and reported in `WoTResourceLoadResultDataType.Message`; the resource still reaches `LoadState = Active`, because an omission is a reported detail rather than a failure. A View that omitted anything reports `Outcome = Warning`, so a client can tell a complete View from a partial one without diffing its membership. Two omission causes are distinguished, because they have different remedies. **A source that is itself a projection.** `uav:resolvedFrom` names, per *WoT Projections* §12, "the reference the selection was made by" — the immediate selection, not the ultimate origin. A projection that selects from another projection therefore names an intermediate document. Its imported affordances refer to existing Nodes rather than clones; its local definitions materialize their own Nodes. The conversion records exactly that immediate reference: a `uav:resolvedFrom` the selected source already carries is validated (it must be a non-empty string, otherwise `ProjectionSourceUnresolved` is reported) but is not propagated as the new selection's provenance, so the ultimate origin stays reachable by walking the intermediate's own metadata. Resolution follows the chain depth-first to the Nodes the source materialized, stopping at a projection-local Node when applicable, as WoT Connectivity §7.13 requires; only when no document in the chain materialized the affordance is the member omitted, and the omission names the deepest source the walk reached. **A member whose Node does not exist.** An affordance resolves to a NodeId by its authored `uav:id` when it has one and by the converter's recorded identity otherwise (or the deterministic root-based fallback for an older source index). Neither is proof that the Node was materialized: a document whose affordances never synthesized anything resolves to plausible identifiers that address nothing. Every planned member is therefore tested against the address space before the View is built, and one that no NodeManager owns is dropped and reported instead of organized. Organizing it would leave the View advertising a membership no client can browse — a `Browse` drops a reference whose target does not resolve, so the View would report a count it does not have. Authoring `uav:id` on an affordance makes the first mechanism exact. A document that materializes its Nodes from a `uav:nodes` native projection should carry `uav:id` per affordance, because the deterministic scheme describes the shape *synthesis* produces and does not hold for restored Nodes. ### 12.4.1 Parent placement through `uav:componentOf` WoT Connectivity §7.3 lets a Thing Description place the Object it projects under an existing parent. The materializer supports a `links` entry whose `rel` is `uav:componentOf` in two target forms: | `href` target | Materialized result | |---|---| | another document in the same registry snapshot | the parent is that document's projection root | | an OPC UA NodeId / ExpandedNodeId already present in the AddressSpace | the parent is that existing Node | When either form resolves, the projected root receives the inverse `HasComponent` reference to that parent, so normal hierarchical browsing from the parent reaches the new Object. This is a placement operation, not a binding fallback: if the parent cannot be resolved, the resource fails projection with `LoadState = Failed`, raises `WoTLoadFailureEventType`, and reports `Phase = Projection`. The server does not silently drop the parent reference. Two §7.3 forms are not implemented yet and therefore also fail loudly rather than being ignored: a target expressed as `uav:browsePath`, and the Thing Model projection-root fallback described by the specification. Authors should use one of the two supported forms above until those gaps are closed. ### 12.4.2 Event notifier behaviour in projection Views Projection Views and the organizational group Objects they contain organize existing Nodes; they do not become event sources. Their `EventNotifier` is `None`, and materialization does not synthesize `GeneratesEvent` from a View or group to the event affordances it organizes. This matters to consumers because a subscription on a View or group is not enough to receive events. Event delivery still depends on the Object that is the event notifier, on the type that declares the events it generates, and on an event-producing runtime path behind that Object. The aggregation sample declares both pumps, management Methods, and alarm EventTypes in its source NodeSet before generating the linked documents. Each pump is the notifier clients subscribe to (`EventNotifier="1"`). OPC 10000-3 §7.15 lets only an ObjectType, a VariableType or a Method be the source of `GeneratesEvent`, so the pump Object does not carry it. Each pump is typed by its own subtype of the companion `PumpType` (`Pump_1Type`, `Pump_2Type`), which declares the alarm EventTypes through `GeneratesEvent`. The pump's Thing Description still lists the alarms as event affordances, because an instance raises the events its type declares. The generic runtime publishes selected upstream occurrences and routes the declared `uav:conditionAction` / `uav:actsOn` Methods back to their owning source. A local EventType is distinct from its mutable Condition instance and occurrence EventIds. Calling a generated Condition Method does not grant the caller authority on the original action receiver. Remote aliases re-enter normal Call dispatch with the original ObjectId/MethodId and the same caller context, so original permissions, channel/session restrictions and live executability must also allow the action. If the original nodes have been retired, the alias cannot dispatch upstream. This routing remains authoritative after address-space registration initializes the Condition's typed callbacks; an alias never falls back to a local-only transition. The OPC UA binding similarly applies outbound endpoint policy to discovery-selected destinations before connecting, not just to the document's initial endpoint; custom factories must preserve that policy when rediscovering or rewriting URLs. The Supervision and Management projections organize existing signal Variables, Methods, and EventTypes; they do not create copies or overlay missing Nodes on an authoritative native partition. See [the sample README](../samples/WotCon/README.md) for the exact per-pump membership and supported alarm workflow, and [the binding runtime](WotBindings.md#projected-methods-events-and-conditions) for channel, subscription, and occurrence-route ownership. ### 12.4.3 Carried dependencies and View membership A View's canonical graph, source partitions, and membership/version/root/count fields publish in one captured read image; navigation does not create another executing source. For prepared-owner lifetimes and cold/warm recovery see [canonical Views](#canonical-views) and [recovery](#recovery). A resolved projection includes the dependencies of its selected affordances, such as the unit property named by `uav:unitProperty` or the targets of `uav:actsOn`. Those dependencies remain in the resolved document (and in its JSON) so that conversion and native materialization keep their `HasProperty` and action relationships. They are structural support, not members the projection selected. `WotDocument.IsSupportOnly(kind, name)` reports, per resolved document and per collection, which affordances were carried only as support. The state is not serialized, is set only by `WotProjectionResolver`, and is `false` for any document it did not produce. An affordance the projection selects explicitly is never support-only, including through an explicit `uav:selectAll` of an outer projection over an inner one. A value-only selection from an inner projection keeps its carried unit support-only in the outer. The View builder organizes only affordances that are not support-only, so the View lists the selected measurements and not their units unless the units were selected. ### 12.4.4 Archive context for single-document conversion In a partitioned NodeSet export, authoritative reference facts such as a reverse reference or a symmetric ReferenceType can reside in sibling partitions. A caller converting one document at a time supplies those siblings through `IWotArchiveContextProvider` backed by a `WotNodeSetConverter.ArchiveContextIndex`. The index groups partitions by their declared model identity (URI, version, publication date) together with the namespace table, never by the namespace table alone. An archive that declares no model has no authoritative identity and neither contributes to nor receives a context. Partitions of a group must agree on server URIs and shared aliases. If two partitions declare the same NodeId (in any equivalent spelling), the group is conflicted and a request for its context fails; one sibling cannot override another. A returned context is a completed, read-only snapshot. An index whose build was cut short by a bound is marked incomplete, so a document that carries preserved native nodes fails explicitly rather than validating against a subset. A missing native event is accepted only when both of these hold: the owning preserved type declares it through a native `GeneratesEvent` reference (directly or through its type or supertype chain), and a bounded, validated selected EventType carrier supplies an identity equal to the normalized authored native target. When the native node is present it is compared in full, and an ambiguous native key or a missing map stays strict. A readable document is never archive authority by itself. The resolver document budget (`WotNodeSetConverterOptions.MaxResolverDocuments`) defaults to 256 documents. The aggregation sample states a larger budget (1024) because its corpus exceeds the default; hosts with a larger corpus must do the same. The canonical corpus inventory lists 540 JSON documents, and the verified registry manifest holds 535 documents (394 Thing Descriptions and 141 Thing Models). These statements describe the conversion contract only. Complete XML validation and full JSON Schema validation remain unclaimed and deferred. ### 12.5 Portable identifiers Two identifier forms are errors, because a document carrying either binds to the wrong namespace as soon as the namespace table is reordered: * the session-local `ns=` form in any NodeId-valued term — `uav:id`, `uav:hasComponent`, `uav:componentOf`, `uav:mapToNodeId`, `uav:mapToType`, `uav:refId` and form `href`s; * a numeric namespace prefix in `uav:browseName` or `uav:browsePath`, such as `3:PaintingRobot_1`. Authors write `nsu=;=` and either a context-bound non-numeric prefix or `nsu=;` instead. `WotNodeSetConverterOptions.AllowNonPortableIdentifiers` keeps a document written against OPC 10101 v1.00 readable while it is rewritten; see [WoT protocol bindings](WotBindings.md#compatibility-switch-for-non-portable-identifiers) for the forms and worked examples. That option changes conversion diagnostics, not the rules used to execute a Binding. Select the published 1.00 consumer mode separately; see [Published OPC 10101 v1.00 consumer mode](WotBindings.md#published-opc-10101-v100-consumer-mode). ### 12.6 The 1.02 asset surface The incorporated OPC 10100-1 v1.02 management and upload surface (NodeIds `1..172`) is superseded in capability by the registry but is **not** deprecated: serving a WoT asset that way is legitimate, and *WoT-Con Minimal* is built on it. It is also a **separate management code path**. `AssetRegistry` uses the POCO mapping for unbound TDs; native/type-bound TDs use `Opc.Ua.Wot.WotNodeSetConverter` with the running AddressSpace context, as described under [Lifecycle](#lifecycle). Unknown members remain in the original document bytes, but are not interpreted as additional AddressSpace instructions. Using the converter does not establish full JSON Schema or profile conformance. It carries its security obligation directly rather than by reference to the optional registry backing, so a server implementing only this surface still inherits it. `CreateAsset`, `DeleteAsset`, `CreateAssetForEndpoint`, `ConnectionTest` and the `WoTFile` `Open` (write mode), `Write` and `CloseAndUpdate` operations require role-based access control and a `SignAndEncrypt` channel for every mutation, whether or not the registry backs them. The rule against dereferencing a URI found in a document extends to the `WoTFile` upload path, which reaches the same materializer. ## 13. Runtime provider contracts ### Ownership and publication `IWotRegistryService` owns an immutable `WotRegistrySnapshot`. Its Int64 `Generation` tracks store mutations; UInt32 `RefreshGeneration` tracks committed materialization. Metadata-only observations need not replace runtime Nodes. The stable registry NodeManager owns metadata/files; source generations own converted Nodes and binding runtimes; canonical Views own navigation, not copies of source affordances. The coordinator captures routing/type/factory revisions through `IWotInvocationProjectionHost` and reserves the registry owner through `IWotInvocationRegistryPublicationService.BeginPublicationAsync`. Candidates, read images, and source/View changes prepare privately before the deciding store commits. Reservations remain through notification/completion bookkeeping. Confirmed noncommit disposes candidates and preserves the old image; confirmed commit switches routes/read images together. Cancellation after that decision cannot turn commitment into rollback. Cleanup/readiness/observer failures are committed warnings. An indeterminate decision fences mutation until recovery. Conflicting lifecycle operations fail `BadServerTooBusy` before effects; callbacks must not synchronously wait on a mutation excluded by their invocation. If serving references change during batch preparation, the coordinator disposes the uncommitted candidates and rebuilds that unit, with at most three attempts. Only that exact conflict before the durable decision is retried; unrelated failures and failures after the decision are not replayed. Exhausted conflicts are reported without publishing a partial unit. | Requested atomicity | Initial publication unit | | --- | --- | | PerResource | One selected activation Resource | | PerGroup | Selected work in one group | | PerClosure | One dependency closure | | PerRegistry | All selected/required activation work | Dependencies, shared declarations, grouping cycles, and affected old/new closures can coarsen these boundaries. Disabled definition inputs are not activation members; unrelated registry rows are not added to activation. Semantic reference cycles can coactivate, but inheritance/order cycles fail. Replacements and affected retiring owners publish in the same batch. Independent units commit independently; no-op/dry-run units allocate no generation. Result rows describe the unit that changed them, including removal-only work. `LastRefreshPlan` records RequestId, PreparationGeneration, requested/applied atomicity, and UnitCount before the first switch of an actual invocation. Read-only capture does not publish it. A stale nonzero `ExpectedGeneration` fails `BadInvalidState`; the guard is the committed refresh generation, not the last completion summary. ### Provider requirements `LifecycleWotProjectionHost` and `LifecycleWotViewProjectionHost` require the same actual `INodeManagerBatchLifecycle`. The default lifecycle and file registry with genuine immutable-content leases support the four modes. Immediate-only custom hosts and `InMemoryWotRegistryStore` do not provide Refresh publication; unsupported capabilities fail `BadNotSupported` before acquisition/effects. Storage and read-only capture remain available independently. The default immutable lease requires `LocalFileSystem` on Windows, whose kernel sharing protects bytes and key mappings against write/replacement/deletion. POSIX advisory sharing, arbitrary `IFileSystem` implementations, copied buffers, and unrelated snapshot capabilities are not equivalent guarantees. Other deployments must inject a genuine immutable version/key lease provider. Custom source hosts bind registry read images before the deciding commit; topology-free observations use `PrepareReadImagesAsync` without source reload. Custom View owners expose `IWotCanonicalViewReadImage` for captured native reads. ### Dependency capture and observations Selection resolves Kind and exact Resource/Version from metadata before body acquisition. Empty selectors select enabled committed content; unmatched nonempty selectors do no work. `IncludeDependents` adds reverse dependencies. Conflicting exact Versions fail before fetch when known; URI suffixes do not establish identity. Acquisition failures stay with the affected Resource/closure. Contentless inputs remain unresolved, not fabricated digest-pinned definitions. ```csharp using WotRefreshCapture capture = await captureProvider.CaptureAsync(request, ct); WotCapturedRefreshRequest invocation = capture.Request; ArrayOf selected = capture.Inputs.Selection; ``` DI resolves `IWotRefreshCaptureProvider` to the coordinator. Capture copies the request before yielding, retains exact bytes/Version leases, and performs no conversion, activation, retirement, or publication. `GetContent` checks the captured incarnation/epoch/digest without reacquiring a new default. `GetRegistryInputDigest` covers registry inputs, not uncaptured external artifacts; `CreateRefreshPlan` observes the owner's applied boundary rather than choosing it. `WotDependencySnapshot` retains exact source Version, request/time/generation, effective input digest, edges, targets, and authoritative origins. `DependencySnapshot` commits with successful activation; `LastDependencyAttempt` records a completed actual attempt. Failure/no-op retains the committed graph; dry runs publish neither. Incomplete preparation has no complete effective digest. Native reads require a verified `WotRegistryOrigin`; the hosted NodeManager supplies ApplicationUri, registry root, and exact Version NodeIds. Unconfigured direct/native reads report `BadNotSupported`. OriginRegistry/VersionNodeId and DocumentUri are authorities/identities, not execution-partition selectors. Dependency indexes live in Version metadata and are hydrated from exact old content. Prepared initialization persists derived indexes with full validation without changing content, epochs, selections, or committed refresh generation. Shared graph storage is described in [manifest schema](#persistence-and-manifest-schema). ### Version leases `WotRegistryService` implements `IWotRegistryVersionLeaseProvider`, aliased by DI to the same singleton. A lease prevents automatic removal of an exact Version incarnation independently of active/default/desired pointers: ```csharp IWotRegistryVersionLeaseProvider leases = registry; using IWotRegistryVersionLease lease = await leases.AcquireVersionLeaseAsync( groupId, resourceId, version, ct); ByteString document = await registry.ReadContentAsync(lease.Version, ct); ``` Acquisition shares the mutation owner with retention/publication. Stale/deleted incarnations fail `BadInvalidState`/`BadNodeIdUnknown`; delete/recreate cannot reconnect an old lease. Same-owner reload preserves surviving incarnations. Native file handles retain their own Version/cursor/writer reservation through in-flight reads and dirty Close; failed Open, cancellation, and Session abandonment release only their reservations. Async file dispatch acquires leases without sync-over-async; lease-aware synchronous Open reports `BadNotSupported`. A leased unselected Version still counts toward retention limits. Releasing the last lease makes it eligible for normal retention, not immediate deletion. Leases change no snapshot/epoch/event, are not persisted, and do not protect against explicit deletion, content writes, or independent distributed owners. Immutable-byte leasing and writer exclusion are separate contracts. ### Prepared registry store `IWotRegistryPreparedStore` retains validated generation/content evidence so projection-metadata commits need not reread independent blobs. The service discovers the capability on its injected store and retains it through metadata generations. Reload through `WotRegistryService.InitializeAsync`; calling `LoadAsync` on its live store invalidates existing captures. | Member | Contract | | --- | --- | | `SupportsPreparedCommits` | Actual content provider's immutable-lease capability | | `CaptureValidatedGenerationAsync` | Store-owned validated manifest/content capture | | `PrepareCommitAsync` | Validate intended snapshot against that capture, without publication | | `IWotRegistryPreparedCommit.CommitAsync` | Recheck the manifest and make one durable decision | Only `ApplyProjectionResultsAsync` selects `ProjectionMetadata` scope; a `ProjectionOnly` notification alone does not. That scope preserves identities, membership, content, labels, dependency-source metadata, and epochs while allowing observations/graphs/committed refresh state. Other mutations use full validation. Foreign, disposed, reload-invalidated, stale, or same-generation changed-manifest captures fail. Prepared owners are single-use; disposal before decision aborts, and after decision does not roll back. Direct consumers acquire the next validated generation before disposing a successful prepared owner. `IWotRegistryContentLeaseProvider` must belong to the real resource-store owner. Its lease protects immutable bytes and their ResourceKey association; validation hashes those bytes, not trusted digest/length/timestamp claims. Decorators forward only genuine underlying capabilities. `WotBlobResourceStore` fixes its root at construction. Sharing bytes alone does not coordinate distributed publication. | Commit outcome | Required handling | | --- | --- | | `WotRegistryCommitNotCommittedException` | Keep the old image | | `WotRegistryCommitDurabilityUncertainException` | Use `CommittedSnapshot` and surface the warning | | `WotRegistryCommitIndeterminateException` | Fence mutation and recover authoritative state | Validated-generation handoff precedes `Changed` observers. Observer errors remain in `PersistenceFailure`; exact-Version warnings retain `ValidatedVersionXid`. Pending validation intents release once only when recovery proves commitment. Post-decision completion ignores caller cancellation; read-only preparation creates no storage root, and pristine rollback releases leases before cleanup. ### Canonical Views `WotCommittedPublicationState` pairs registry, canonical graph, and active plans; construction does not publish it. `IWotPreparedViewProjectionHost.PrepareAsync` prepares the complete remaining graph, including affected ancestors/shared children. Its publication contributes ordered lifecycle `Changes`, binds complete graph bytes/handles/`AffectedResourceXids`, and performs non-failing `OnPublished` bookkeeping without I/O or a second decision. Disposal discards unpublished state. DI aliases this capability to the same View host, not a replacement provider. Authored portable View IDs must already resolve in the captured source namespace table; default roots use Resource-relative `/View` identities. Ownership checks use actual source partitions/roles, not prefixes or a predecessor belonging to another registration. Source/View `HasWoTProjection` correlations switch together. Views/groups have `EventNotifier.None` and own no binding runtime. `CanonicalViewGraphState` is the root immutable graph carrier, not independent per-Resource authority. `WithPublicationState` preserves it for default/null `ByteString`, clears it for explicit `ByteString.Empty`, and retains token/allocation history even without live Views. `ViewVersion` hashes membership as described in [projection Views](#124-projection-documents-and-the-view-nodeclass); the full `ProjectionMembershipDigest` is 32 bytes. Native membership/version/root/count/load/refresh fields read from the same captured image as routing; an inactive captured image does not fall through to newer state. Projection inputs retain source context/location/provenance; host routing owns forms/security/URI variables, not source DataTypes. Supporting definitions share bounded document/node/byte/depth budgets. Security names are qualified by source (`q:s::`) or host (`q:p:`). `E` percent-encodes the exact UTF-8 bytes using uppercase hexadecimal, preserves only RFC 3986 unreserved bytes (`A-Z`, `a-z`, `0-9`, `-`, `.`, `_`, `~`), and performs no Unicode normalization. Dependency-support names continue to use unpadded base64url. Consumers use the security names in the resolved TD. Combos must be complete/acyclic and cannot confer host credentials on source origins. Literal instance values/opaque metadata are not searched for references. ### Dry-run validation `IWotProjectionValidationPublication` prepares private source/View candidates, mapping/routing/type/reference checks, and registry read images, then disposes them without a durable decision. Earlier private prerequisites can support later units without becoming live. Rejected retirements retain serving metadata. No observations, LastRefreshPlan/completion caches, generations, View tokens, or management/model-change events change. Predicted roots in unregistered namespaces are `NodeId.Null`; counts remain predictions. Custom hosts without private validation report failure, not an unsupported successful preview. ### Recovery Readiness calls `RecoverAsync` after loading the deciding store. Fresh registries use ordinary materialization; a committed publication, including an empty one, restores before readiness even with `AutoRefresh = false`. Recovery uses retained exact committed inputs, not newer defaults/desired Versions or edits under the same VersionId. Missing/conflicting evidence fails rather than guessing. Source/View candidates prepare privately; `IWotRecoverableViewProjectionHost` restores the recorded graph in the common batch. Runtime namespace indexes may rebase without changing durable identities, graph bytes, or generations. `IWotRegistryRecoveryStore` retains validation through the switch; `IWotRegistryRecoveryPublication` prepares the local image. Recovery writes no manifest/generation and replays no events. Repeated healthy recovery is a no-op. Direct hosts initialize then call `RecoverAsync`; ordinary Refresh is not cold recovery merely because the coordinator is new. For an interrupted warm publication, `IWotRegistryRecoveryResolver` uses the same owner/store rules. Missing primary plus staging/backup is an error, not a choice among equal candidates. Mutation remains fenced until routes/read images and native registry projections acknowledge the authoritative image. `Publish`/`CompleteAsync` reconcile silently before lifting the fence; failed acknowledgment retains it for retry. Nested mutation/reload is rejected. The coordinator retries recovery before a later Refresh after its unresolved decision/warning. This is local recovery, not cross-replica consensus. ### Bridge deletion and recovery The optional [1.02 bridge](#mirroring-assets-into-the-wot-xregistry) retains the assigned Resource and registry owner. Backing deletion precedes local provider/graph/file removal. Reject/noncommit retains the asset; committed warning returns `GoodResultsMayBeIncomplete`; indeterminate outcomes retain it until that same registry recovers. Retry does not repeat a confirmed deletion. Post-commit cleanup ignores caller cancellation and blocks replacement uploads until complete; an already disabled/retired `Retire` is unchanged. With asset persistence enabled, bounded `.delete-pending` intents retain assigned identity and the last confirmed generation, not proof of commitment. Startup restores management/TD state without reconnecting/remirroring; initialize/recover the same registry and retry DeleteAsset. An uninitialized empty snapshot proves nothing. Invalid records/missing backing registry fail startup; successful cleanup removes the TD and recovery record.