Files
Marc Schier 397db603db Add OPC UA for Asset Administration Shell: the published OPC 30270 and v3 draft (#4244)
# Description

Implements the **Asset Administration Shell** companion specifications
on top of `marcschier/wot-type-binding`, in two halves that a Server
chooses between:

* **AAS V3** — the
[draft](https://github.com/marcschier/opcua-drafts/tree/main/companion-specs/AAS)
(`http://opcfoundation.org/UA/I4AAS/v3/`), all sixteen conformance
units: the metamodel, the registry built on the abstract xRegistry base,
generational materialization, federation, packages with digest-bound
integrity, the Digital Product Passport vocabulary, and the Annex F
bridge to WoT Thing Descriptions.
* **AAS V2** — the published [OPC
30270](https://reference.opcfoundation.org/specs/OPC-30270/full)
(`http://opcfoundation.org/UA/I4AAS/`), which maps the AAS V2.0.1
metamodel: object model, ingestion of XML, JSON and AASX,
materialization, server and client.

The two are different metamodel generations rather than two revisions of
one model — V2 keeps `Asset` as a first-class Identifiable,
`identification.idType`, Views and ordered collections, none of which
survive into V3 — so they are **alternatives**. A Server hosts one or
the other; this is documented rather than enforced at startup. Each is
named for the generation it maps (`Opc.Ua.Aas.V2`, `Opc.Ua.Aas.V3`,
`AddAasV2Server`, `AddAasV3Server`), and what genuinely serves both
carries no marker and sits at the root of `Opc.Ua.Aas`: the
deterministic NodeId encoding and idShortPath conventions,
`AasOptional<T>`, the digest rule, the materialization diagnostics, the
AASX container walk, and the value and operation providers, which are
keyed on `NodeId` and `Variant` and so carry no version at all.

## What is in here

**New libraries**: `Opc.Ua.Aas`, `Opc.Ua.Aas.Server`,
`Opc.Ua.Aas.Client`, `Opc.Ua.Aas.WoT`, plus `samples/Aas` and
`docs/Aas.md`.

**Enabling changes to existing code**, each needed by the above:

* The OPC UA `Decimal` DataType added to `Opc.Ua.Types`, with its Part 6
§5.1.10 wire encoding. `BinaryDecoder` gained one internal member,
`TryReadRemainingBodyBytes`, because a trailing field with no
self-describing length cannot otherwise be read; `Decimal.Decode` is its
only caller. Part 6 Table 3 types the unscaled `Value` as an
**OctetString whose byte count is `Length` − 2**, not a length-prefixed
`ByteString`, so it carries no length of its own —
`DecimalEncodingTests` pins the body byte-for-byte against that mistake,
and against the `Length ≤ 2` values the same table calls invalid.
* The xRegistry projection engine extracted into
`Opc.Ua.XRegistry.Server` and `Opc.Ua.WotCon.Server` migrated onto it
(−848/+387 lines), with the WoT Connectivity tests passing **unedited**.
* Two source-generator fixes, both of which made a model impossible to
build (see below).

## Defects found and fixed along the way

Running the stack end to end, rather than trusting per-area test suites,
surfaced defects that no unit test caught. The ones worth a maintainer's
attention:

* **The generator emitted non-terminating constructors.** A structure
field with no declared default was initialized with `new T()`, so a
self-referential DataType (`AASReferenceDataType.referredSemanticId`)
recursed until the stack overflowed and `AddOpcUaAas` could not load the
model at all. The test host *died* rather than failing. Only the field
that closes a cycle is now left null.
* **The generator omitted `new` on a redeclared placeholder.**
`AASOrderedSubmodelElementCollectionType` redeclares the
`SubmodelElement` slot of its base type, and the generated `Add` method
hid the base one without saying so, so the V2 model would not compile.
No existing model could have hit either defect — both are build
failures, so anything that builds today is unaffected.
* **The metamodel half had never worked outside its unit tests**: the
emitted NodeSet declared no reference-type aliases and was rejected by
the loader; the Mandatory `Invoke` Method was never emitted, because a
NodeSet is imported as written rather than instantiated from its type;
the runtime bound at the authored namespace index instead of the one the
Server assigns; and `MethodDeclarationId` was addressed in the wrong
namespace. Every materializer test inspected the in-memory object graph,
which cannot see whether the graph is loadable — two tests now import it
exactly as `RuntimeNodeSetNodeManager` does.
* **The client could not see any Property member.** It browsed
`HasComponent` with subtypes, but `HasProperty` is a sibling under
`Aggregates`, so reading a value failed and invoking failed argument
validation.
* **Every repeated registry write forked a duplicate group and
resource** instead of adding a version, because the identifier was
re-derived against a sibling set that already contained the entity
itself. This also made the persistence bounds unreachable.
* `.gitignore`'s `**/packages/*` silently swallowed the whole
package-integrity implementation and its tests, which would have
vanished on a fresh clone.

## Specification findings

Nine findings against the V3 draft are fixed in
[marcschier/opcua-drafts#75](https://github.com/marcschier/opcua-drafts/pull/75).
The substantive one: clause 6.1.5's absent-versus-empty rule was
**unsatisfiable** for five container fields that materialize as parent
components with no node of their own, which made `AAS-LosslessRoundTrip`
unachievable.

OPC 30270 contradicts itself twice. Both are recorded in
`AasV2ConformanceUnits.cs` and the documentation rather than smoothed
over: Table 83 spells the unit `I4AAS MultiLangaugeProperty` where Table
85 spells it correctly, and Table 85 lists an `I4AAS Security` unit that
Table 83 never defines. Table 84, which would assign profile URIs, is
empty, so `ServerProfileArray` gains no I4AAS entry.

## Known limitation

Calling an `Operation` **with arguments** answers `BadTooManyArguments`.
Method resolution works from both the instance NodeId and the
`MethodDeclarationId`, and the argument definitions are published, but
`NodeState.AddChild` does not route through `MethodState.FindChild`, so
a Method imported from a NodeSet leaves its typed `InputArguments` null.
Closing this means changing how the shared NodeSet import claims typed
children, which affects every consumer of runtime NodeSets and is out of
scope here. It is documented in `docs/Aas.md` §11, the conformance
matrix marks `AAS-OperationInvoke` partial rather than claiming it, and
`samples/Aas` exercises the path and reports the status rather than
working around it. V2 is unaffected — its `Operation` is an
OptionalPlaceholder with no declared arguments.

## Related Issues

- No tracking issue yet. Opening as a **draft** for design discussion
first, per the ADR guidance below; happy to open one and link it once
the shape is agreed.

## Checklist

- [x] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [x] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.

### Verification

| Check | Result |
|---|---|
| AAS tests | **670 passed, 0 failed** on net472, net48, net8.0, net9.0,
net10.0 |
| Coverage | Client 80.8 %, Server 81.2 %, `Opc.Ua.Aas` 82.0 %, WoT 82.4
% — all above the 80 % gate |
| Libraries | `Opc.Ua.Aas`, `.Server`, `.Client`, `.WoT` build clean on
every target framework, zero warnings |
| Regression | `Opc.Ua.Types` 8677, `Opc.Ua.WotCon` 1094,
`Opc.Ua.XRegistry` 198, `Opc.Ua.Server` 4050 — all green |
| Sample | `samples/Aas` builds warning-free and runs end to end |
| Merge validation | Solution builds clean on net10.0 and net48; WoT
Types 504, WotCon 1111, AAS 670, XRegistry 198 tests passed |
| CI | All 99 checks green on `ff9298e44`; only the CLA signature status
is outstanding |

### Review pass

A full review against both specifications, and for API consistency and
correctness, produced twelve
fixes. Each one has a test that fails without it.

| Defect | Where | Why it mattered |
|---|---|---|
| V2 projection aborted on **every** `File` read from a document |
`AasV2EnvironmentRuntime` | The runtime bound `File/Value`
unconditionally, the materializer emitted it only with content, and the
builder throws `BadNotFound` for an unknown NodeId — aborting the whole
registration. No reader ever populates `AasFile.File`, so no AAS V2
document could be hosted at all. |
| `aas-suppl` anchored on the wrong part | `AasxPackageReaderCore`,
`AasxPackageWriter` | IDTA and the Package Explorer anchor it on the
environment part; we used the origin part. Every real `.aasx` returned
zero supplementary files, silently. Affects both halves. |
| Federation failed closed on pooled connections | `AasFederation` |
`ConnectCallback` fires only for new connections, so the second fetch to
an origin — and all of net472/net48/netstandard2.1 — failed with a
security-flavoured error. The peer is now validated *inside* the
callback, which makes reuse safe by construction. |
| Malformed `Decimal` escaped as `ArgumentOutOfRangeException` |
`BinaryDecoder` | A declared body shorter than `Scale` produced a
negative read length. Two of the three test cases had been passing for
the wrong reason. |
| V2 and V3 cross-wired their value providers |
`OpcUaAasServerBuilderExtensions` | Both registered `IAasValueProvider`;
whichever was added first served both AddressSpaces. V2 now has its own
contract, so co-hosting is harmless rather than silently wrong.
`EnvironmentFolder` was also bound and ignored on the V2 path. |
| Export filter skipped three nesting kinds | `AasEnvironmentExporter` |
Content under `Entity`, `AnnotatedRelationshipElement` and `Operation`
was exported unevaluated *and* stamped with a Digest presenting it as
complete (clause 6.5.10). List authorization paths also shifted after a
denial. |
| Concealment held at the Method but not in the projection |
`AasRegistryModel` | `GetSubmodel` answered `BadNotFound` while an
anonymous Browse revealed the node, its identifier, semanticId and
digest (clause 6.5.7). |
| File handles were unsynchronized and unowned | `AasElementFileManager`
| The async node manager holds no lock, so concurrent Calls raced a bare
`Dictionary`; sequential handles let any Session read, seek or commit
another's. Writes were also unbounded. |
| Projection host was unimplementable outside the stack |
`AasEnvironmentProjection` | The handle demanded a type with an internal
constructor — our own tests used
`FormatterServices.GetUninitializedObject` to get round it. Now mirrors
WotCon's interface-typed registration. |
| Embedded `FileType` omitted 7 of 10 Mandatory members |
`AasEnvironmentMaterializer` (V2) | OPC 10000-3 6.4.4 requires them all;
without `Size` and `OpenCount` a Client cannot size a read or detect an
abandoned handle. |
| Round-trip oracle ignored most of the model | `AasRoundTripComparer` |
`Blob`, `File`, `ReferenceElement` and `BasicEventElement` had no case
arm, so their fields compared equal by falling off the switch;
`valueType`, `semanticId`, `entityType` and `Key.type` were never read.
The clause 6.4 losslessness claim rested on it. |

Known, unchanged: calling an `Operation` with arguments answers
`BadTooManyArguments`, because
`NodeState.AddChild` does not route through `MethodState.FindChild`.
Documented in `docs/Aas.md` §11.

Not addressed in this pass, and worth a maintainer decision: OPC 30270
§9.2 says all NodeIds of
Nodes not defined in the companion specification **shall not** use the
standard namespaces, and
Table 87 marks a vendor-specific instance namespace Mandatory. Both
halves currently mint instances
in the companion namespace. Separating them touches NodeId minting, the
runtime and the client
together, so it is better done deliberately than folded into a review
pass.
This is a large change and touches shared code (`Opc.Ua.Types`, the
source generator, the xRegistry projection). It is deliberately a draft:
the enabling changes are separable if maintainers would prefer them
split into their own PRs. The latest `master` is merged into this branch
in `0dd0ff0e1`.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
Copilot-Session: c3d88950-4a65-4ff6-87d2-1f545a875c9b
Copilot-Session: b8ab3763-b9bd-46ae-b0ef-045db26e5782
2026-08-18 08:11:47 -07:00

23 KiB
Raw Permalink Blame History

xRegistry — abstract registry base model

The xRegistry libraries implement the generic, registry-agnostic abstract registry base model for OPC UA. They provide the substrate a concrete registry builds on: a content-addressed resource identity, an Opaque-NodeId fast path that resolves a resource in a single Read, a model-driven registration lifecycle with auto-bootstrap, and a federation model for resources hosted by another registry.

The libraries are deliberately domain-neutral: they know nothing about what a resource contains. A concrete registry supplies its own companion namespace and a fingerprinting strategy, and reuses everything else. The PubSub Schema Registry, WoT Connectivity registry and AAS V3 registry are concrete specializations: PubSub resources are schema documents, WoT resources are Thing Description / Thing Model documents, and AAS resources are shell, submodel, concept-description, package and environment documents.

Packages

Package Depends on Contains
OPCFoundation.NetStandard.Opc.Ua.XRegistry Opc.Ua.Core The source-generated base companion model (types, NodeIds, NodeStates and *TypeClient proxies), XRegistryWellKnown, IResourceContentIdProvider
OPCFoundation.NetStandard.Opc.Ua.XRegistry.Client Opc.Ua.XRegistry, Opc.Ua.Client XRegistryClient — fast-path resolve and lifecycle registration
OPCFoundation.NetStandard.Opc.Ua.XRegistry.Server Opc.Ua.XRegistry, Opc.Ua.Server The three node managers and XRegistryServerOptions

Opc.Ua.XRegistry has no dependency on either SDK, so a codec or a shared contracts assembly can reference the identity abstraction without pulling in the client or the server.

Core concepts

Content-derived resource identity

A resource's identity is derived from its bytes, not assigned by the server. Every registry supplies an IResourceContentIdProvider that maps a document plus its format to a fingerprint:

public interface IResourceContentIdProvider
{
    ByteString ComputeContentId(string format, ReadOnlySpan<byte> document);

    string? GetAlgorithm(string format);
}

GetAlgorithm names the (canonicalization, hash) pair used for a format and returns null for formats the registry does not handle. Because the identity is content-derived it is stable across registries: the same document registered in two different servers yields the same id, which is what makes de-duplication and federation work.

Opaque-NodeId fast path

A registered resource is reachable at an Opaque NodeId in the registry namespace whose Identifier is the raw content-id bytes. A consumer that received the id on the wire therefore needs no Browse and no fingerprint recomputation — one Read of that node's Value returns the document:

var fastPathNodeId = new NodeId(contentId, registryNamespaceIndex);

// Use the ranged read extension rather than a plain ReadValue: a document larger than the
// session's MaxByteStringLength is fetched in slices instead of failing.
ByteString document = await session.ReadBytesAsync(fastPathNodeId, 0, ct).ConfigureAwait(false);

XRegistryFastPathNodeManager serves these nodes and can optionally pre-publish a seed resource so a freshly started server resolves at least one content-addressed resource before any registration has happened.

Registration lifecycle and auto-bootstrap

The registry serves the model's own Methods. A registry root (RegistryType) is materialized from the compiled model, and groups and resource versions are created beneath it at runtime:

  1. RegistryType.CreateGroup(GroupId) returns the new group's NodeId; GetOrCreateGroup is the idempotent form and also reports Created.
  2. GroupType.CreateResource(ResourceId, VersionId, RequestFileOpen) creates a resource version and returns (ResourceNodeId, AssignedVersionId, FileHandle). An empty VersionId lets the server assign the next one; GetOrCreateResource additionally reports Created.
  3. Because ResourceType is a FileType, the document is streamed with the standard Write/Read file Methods against the handle — there is no registry-specific transfer.
  4. Close finalizes the upload. The server computes the content-id from the accumulated bytes through the configured IResourceContentIdProvider, commits the document to the resource store, bumps the resource's Epoch, and — this is the auto-bootstrap — publishes the Opaque fast-path node.
  5. Delete(ExpectedEpoch) on a resource or a group removes it. The epoch is an optimistic-concurrency check: a caller holding a stale epoch is rejected with Bad_InvalidState rather than deleting a newer version. Passing 0 disables the check, which is how a caller deliberately forces the operation without having read the entity first.

Registration is idempotent by construction: re-registering identical bytes produces the same content-id, so the existing fast-path node is reused rather than duplicated.

File open modes

The handle returned by CreateResource / GetOrCreateResource is opened with EraseExisting semantics — a newly created version starts empty. GetOrCreateResource returns a write handle for an existing version too, so a caller can replace its document in the same call; a caller that only wanted to look the version up closes that handle without writing, which releases it and leaves the resource untouched.

Reopening a version with the inherited Open uses the standard FileType mode bits (OPC 10000-5 §C: Read = 1, Write = 2, EraseExisting = 4, Append = 8):

Mode Behaviour
Read Read the committed document.
Write | EraseExisting Replace the document wholesale.
Write | Append Start from the stored bytes with the cursor at the end.
Write Start from the stored bytes with the cursor at 0 — writes replace only the range they cover and do not truncate the remainder.

A mode requesting neither read nor write, both together, or EraseExisting/Append without Write is rejected with Bad_InvalidArgument. A handle is valid only on the resource and the session that opened it, and a session's handles are released when it closes.

Resource storage

Document bytes live behind an injectable IXRegistryResourceStore. Because a resource is a ResourceType, which is a FileType, the store mirrors the file access model: reads and writes are offset and length based, so a document never has to be materialized as a whole.

var options = new XRegistryServerOptions
{
    // Keeps the documents in the server process (the default).
    ResourceStore = new InMemoryResourceStore()
};

// Or back them with files so they outlive the process and a shared volume can serve a cluster.
options.ResourceStore = new FileSystemResourceStore("/var/lib/xregistry");

FileSystemResourceStore is built on the IFileSystem abstraction, so a deployment can substitute its own — and a test can run it against a VirtualFileSystem without touching disk:

using var fileSystem = new VirtualFileSystem();
using var store = new FileSystemResourceStore("resources", fileSystem);

The server pieces are also wired for dependency injection, with direct construction still supported:

services
    .AddXRegistryContentIdProvider<MyContentIdProvider>()
    .AddXRegistryFileSystemResourceStore("/var/lib/xregistry")
    .AddXRegistryServer(options => options.RequireEncryptionForReads = true);

XRegistryServerOptions is sealed. The three node managers are deliberately not sealed: subclassing them is the server-side extension seam a domain registry uses to serve its own companion model on top of the base one, mirroring how a domain client derives from XRegistryClient.

Implementing a store

A store has four operations. The example below is a complete, if naive, implementation that keeps each document in a dictionary — enough to show what each contract clause means:

public sealed class MyResourceStore : IXRegistryResourceStore
{
    public ValueTask<ByteString> ReadAsync(
        string resourceKey, long offset, int count, CancellationToken ct = default)
    {
        // Argument faults throw; an unknown key is a *null* ByteString so the caller can tell
        // "no such resource" from "resource is empty".
        if (!m_documents.TryGetValue(resourceKey, out byte[]? document))
        {
            return new ValueTask<ByteString>(default(ByteString));
        }

        // Return fewer bytes than asked for at the end of the document; never throw for that.
        if (offset >= document.Length || count == 0)
        {
            return new ValueTask<ByteString>(ByteString.From([]));
        }
        int take = (int)Math.Min(count, document.Length - offset);
        return new ValueTask<ByteString>(
            ByteString.From(document.AsSpan((int)offset, take).ToArray()));
    }

    public ValueTask WriteAsync(
        string resourceKey, long offset, ByteString data, CancellationToken ct = default)
    {
        // Random access: the chunk may land anywhere. Writing past the end grows the document and
        // the gap reads back as zeros; writing at 0 does *not* truncate what follows.
        m_documents.TryGetValue(resourceKey, out byte[]? existing);
        existing ??= [];
        var merged = new byte[Math.Max(existing.Length, offset + data.Length)];
        existing.CopyTo(merged.AsSpan());
        data.Span.CopyTo(merged.AsSpan((int)offset));
        m_documents[resourceKey] = merged;
        return default;
    }

    public ValueTask<long> GetLengthAsync(string resourceKey, CancellationToken ct = default)
    {
        // -1 signals an unknown key rather than throwing.
        return new ValueTask<long>(
            m_documents.TryGetValue(resourceKey, out byte[]? d) ? d.Length : -1);
    }

    public ValueTask<bool> DeleteAsync(string resourceKey, CancellationToken ct = default)
    {
        // Deleting an absent key is a no-op, not a fault.
        return new ValueTask<bool>(m_documents.Remove(resourceKey));
    }

    private readonly Dictionary<string, byte[]> m_documents = [];
}

Two rules make a store substitutable:

  • Error reporting. Argument faults throw — ArgumentException for a null or empty key, ArgumentOutOfRangeException for a negative offset or count. Everything a caller is expected to handle is a return value instead: a null ByteString, a -1 length, a false delete. Genuine infrastructure failures (an unreachable share, a permission fault) should throw a ServiceResultException with an appropriate status code, which the node manager surfaces as the Method's result rather than faulting the server.
  • Concurrency. Implementations must be safe for concurrent calls.

The contract is exercised by XRegistryResourceStoreContractTests; deriving a fixture from it is the quickest way to validate a new implementation.

Opc.Ua.WotCon.Server is a worked example: WotBlobResourceStore implements this interface over the {root}/{digest}.bin layout the WoT registry has always written, so a domain registry can adopt the shared byte layer without an on-disk migration. The AAS registry follows the same projection model through AasRegistryProjection, while adding the AAS-specific source identities and discovery methods described in OPC UA for Asset Administration Shell V3. See WoT Connectivity — keeping the document bytes in a shared store.

Transport security

Registry writes always require a SignAndEncrypt secure channel. A document and its content-derived identity are integrity-critical, so CreateGroup, GetOrCreateGroup, CreateResource, GetOrCreateResource, Delete, AddAttribute, RemoveAttribute, opening a file for writing, Write and Close are all rejected with BadSecurityModeInsufficient on a channel that is merely signed or unprotected. This is not configurable.

Reads are permitted on any secure channel by default, because a registry is usually a public catalogue. Set RequireEncryptionForReads when the documents themselves are confidential:

var options = new XRegistryServerOptions
{
    RequireEncryptionForReads = true
};

An in-process call carries no channel at all — the server's own bootstrap, or a test — and is always allowed.

Federation

XRegistryFederationNodeManager publishes a proxy for a resource hosted by another registry. The proxy is itself a ResourceType instance, so a generic xRegistry client drives it through exactly the same generated proxy as a locally hosted resource. It carries an ExternalReference — an ExpandedNodeId whose ServerIndex names the remote server through the local ServerArray, and whose NamespaceUri and Identifier are the remote resource node's identity — and/or a plain ResourceUrl, with the content-id in Xid. Since the content-id is stable across registries, the same resource federated from several endpoints keeps one identity and can be de-duplicated by consumers.

Labels

RegistryType, GroupType and ResourceType each expose a Labels Object of type AttributesType with AddAttribute(Key, Value, ExpectedEpoch) and RemoveAttribute(Key, ExpectedEpoch). Labels are published as addressable String Properties in the registry namespace, so a client can read them with a plain Read. Both mutations take the owning node's epoch and advance it on success, which makes a concurrent update visible instead of silently lost; 0 disables the check.

Server-side usage

Configure the node managers through XRegistryServerOptions and add them to the server's node manager list. The options object carries the registry namespace, the content-id provider, the optional seed and federation resources, and the resource-exhaustion bounds:

var options = new XRegistryServerOptions
{
    RegistryNamespaceUri = "http://example.org/UA/MyRegistry/",
    ContentIdProvider = new MyContentIdProvider(),

    // Optional: pre-publish one resource on the fast path at start-up.
    PublishSeedResource = true,
    SeedDocument = seedBytes,
    SeedFormat = "avro",
};

var registration = new XRegistryRegistrationNodeManager(server, configuration, options);
var fastPath = new XRegistryFastPathNodeManager(server, configuration, options);
var federation = new XRegistryFederationNodeManager(server, configuration, options);

The registry's companion model is compiled into the assembly by the OPC UA model source generator: Opc.Ua.XRegistry.NodeSet2.xml is a generator input (AdditionalFiles), so the ObjectTypes, Methods, Variables, NodeId constants, NodeState classes and typed ObjectType proxies are emitted at build time. No NodeSet2 XML is parsed at runtime — each node manager simply returns the generated model from LoadPredefinedNodes:

protected override NodeStateCollection LoadPredefinedNodes(ISystemContext context)
{
    return new NodeStateCollection().AddOpcUaXRegistry(context);
}

A concrete registry composes its own companion model on top of the base model in dependency order, declaring RequiredModel on the xRegistry namespace in its NodeSet.

Note: the model occupies NodeIds 63000-63999 in the registry namespace. The instance identifiers in XRegistryWellKnown live above that range so a materialized instance can never collide with a model node.

Resource-exhaustion bounds

The registration Methods are remotely callable, so XRegistryServerOptions bounds every unbounded dimension. Exceeding a bound fails the call rather than the server:

Option Default Enforced on Status code
MaxConcurrentUploads 64 CreateResource / file Open BadTooManyOperations
MaxResourceBytes 16 MiB file Write BadRequestTooLarge
MaxRegisteredResources 4096 CreateResource BadTooManyOperations

Client-side usage

The client layer is built entirely on the source-generated ObjectType proxies — no hand-rolled service calls. XRegistryClient is an abstract base carrying the xRegistry-level API; GenericXRegistryClient is the sealed implementation for callers that only need the base model, and a domain registry client derives from the same base:

abstract XRegistryClient
   ├── sealed GenericXRegistryClient   // any registry namespace
   ├── SchemaRegistryClient  (domain)
   └── WotRegistryClient     (domain)

Resolving a resource from an id received on the wire is a single call. It reads through ReadBytesAsync, so a document larger than the session's MaxByteStringLength is fetched with range-based reads rather than failing. It returns a null ByteString — check IsNull — when no fast-path node is registered, so the caller can fall back to a Browse or a registry-specific download:

var client = new GenericXRegistryClient(session, "http://example.org/UA/MyRegistry/", telemetry);

ByteString document = await client.ResolveResourceAsync(contentId, ct: ct).ConfigureAwait(false);
if (document.IsNull)
{
    // Not registered on this server — fall back.
}

Registering a document drives the model's own lifecycle: the group's CreateResource creates the version and opens it for writing, and the document is streamed through the FileType methods that ResourceType inherits.

ResourceRegistrationResult registered = await client.RegisterResourceAsync(
    groupNodeId,
    resourceId: "urn:my:resource",
    document: documentBytes,
    ct: ct).ConfigureAwait(false);

NodeId resourceNodeId = registered.ResourceNodeId;
string assignedVersionId = registered.AssignedVersionId;

Groups, idempotent registration and deletion are covered by the same convenience layer. Delete takes the node's ExpectedEpoch, so a caller working from a stale read is rejected rather than clobbering a concurrent change:

// The registry root sits at a well-known identifier in the registry namespace, so there is no
// need to Browse for it.
GroupRegistrationResult group = await client
    .GetOrCreateGroupAsync(client.RegistryNodeId, "schemas", ct)
    .ConfigureAwait(false);

// Only streams the document when it actually created the version.
ResourceRegistrationResult resource = await client.GetOrRegisterResourceAsync(
    group.GroupNodeId, "urn:my:resource", documentBytes, ct: ct).ConfigureAwait(false);

if (resource.Created)
{
    // The version is new on this server.
}

await client.DeleteResourceAsync(resource.ResourceNodeId, expectedEpoch, ct).ConfigureAwait(false);
await client.DeleteGroupAsync(group.GroupNodeId, groupEpoch, ct).ConfigureAwait(false);

Both results are readonly record structs, so they carry named members instead of positional tuple elements and still deconstruct when that reads better:

(NodeId nodeId, string versionId, bool created) = resource;

The typed proxies are also available directly, which is what a domain client builds on:

GroupTypeClient group = client.GetGroup(groupNodeId);
(NodeId nodeId, string versionId, uint fileHandle) =
    await group.GetOrCreateResourceAsync("urn:my:resource", string.Empty, true, ct)
        .ConfigureAwait(false);

ResourceTypeClient resource = client.GetResource(nodeId);
await resource.WriteDocumentAsync(fileHandle, documentBytes, ct: ct).ConfigureAwait(false);

Extending for a domain registry

A domain model subtypes the xRegistry base types — for example a schema registry declares SchemaFileType : ResourceType — so the generator emits a proxy chain that mirrors the OPC UA hierarchy (SchemaFileTypeClient : ResourceTypeClient : FileTypeClient). Two things follow:

  • A domain client derives from XRegistryClient and inherits the whole lifecycle. Helpers written as extension methods over a base proxy (such as WriteDocumentAsync on ResourceTypeClient) are directly callable on the domain proxy, with no inheritance in the client layer.
  • A generic client still drives a domain registry, because a domain instance is an instance of the base type. Discovery must be subtype-aware (browse with includeSubtypes, test with IsTypeOf) rather than comparing TypeDefinition NodeIds for equality.

Registry roots that are not well-known

XRegistryWellKnown.RegistryObject (65000) is provisional, and a domain registry generally declares its own root instead — the WoT Connectivity registry publishes WoTRegistry as a HasComponent child of the Server object, which a client discovers by Browse. Pass the resolved NodeId to the constructor so the root is a construction-time input that cannot subsequently drift:

public sealed class WotRegistryClient : XRegistryClient
{
    public WotRegistryClient(ISession session, NodeId registryObjectId, ITelemetryContext telemetry)
        : base(session, Namespaces.WotCon, registryObjectId, telemetry)
    {
        Proxy = new WoTRegistryTypeClient(session, registryObjectId, telemetry);
    }
}

RegistryNodeId then reports that root, and every inherited lifecycle method targets it. Passing a null NodeId selects the well-known root, so the overload stays equivalent to the namespace-only constructor. GenericXRegistryClient exposes the same overload, so a caller can drive a domain registry without deriving a client at all:

var registry = new GenericXRegistryClient(
    session, Namespaces.WotCon, wotRegistryNodeId, telemetry);

Opc.Ua.WotCon.Client is the worked example: WotRegistryClient derives from XRegistryClient, inherits Session, RegistryNodeId and the group/resource lifecycle, and adds only WoT-specific surface — the ForServerAsync Browse resolution, the reserved Thing Description / Thing Model groups, a typed Refresh result and a dependency-ordered bulk load. Opc.Ua.Aas.Client follows the same pattern: AasRegistryClient derives from XRegistryClient, resolves the well-known AASRegistry object and adds LookupShellsByAssetLink, GetSubmodel and typed AAS group/resource clients.

Well-known identifiers

XRegistryWellKnown carries the base companion namespace URI and the provisional NodeIds a generic registry materializes. A concrete registry reuses the same numeric identifiers inside its own namespace, so the client-side lookup logic is shared.

Note: the NodeIds are provisional. Final identifiers are assigned by the OPC Foundation.

Member Value Meaning
XRegistryNamespaceUri http://opcfoundation.org/UA/xRegistry/ Abstract base companion namespace
RegistryObject 65000 The registry root, a RegistryType instance
FederationProxyObject 66001 Federated resource proxy, a ResourceType instance
FirstDynamicInstance 100000 Start of the range allocated to runtime groups and resources

Everything else — the ObjectTypes, their Methods and their Variables — comes from the compiled model via the generated ObjectTypeIds, MethodIds and VariableIds classes. The model occupies 63000–63999, so the instance identifiers above can never collide with it.

  • Source generation — how the companion model and its typed proxies are compiled into the assembly.
  • Node management — the node manager model the three managers build on.
  • PubSub (Part 14) — the PubSub Schema Registry specialization of this model.