Skip to content

Add generic xRegistry libraries (abstract registry base model) - #4097

Merged
marcschier merged 29 commits into
masterfrom
marcschier/xregistry
Jul 28, 2026
Merged

Add generic xRegistry libraries (abstract registry base model)#4097
marcschier merged 29 commits into
masterfrom
marcschier/xregistry

Conversation

@marcschier

@marcschier marcschier commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Description

xRegistry is the abstract registry base model: a registry-agnostic, content-addressed
resource registry. It knows 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 in #4007 is the first specialization (its resources are schema documents),
and #4093 (WoT Binding) carries a byte-identical copy of the same base NodeSet, so a
shared canonical home is worth having.

Packages

Package Depends on Contains
OPCFoundation.NetStandard.Opc.Ua.XRegistry Opc.Ua.Core Abstract base companion model (compiled by the model source generator), XRegistryWellKnown, IResourceContentIdProvider
OPCFoundation.NetStandard.Opc.Ua.XRegistry.Client + Opc.Ua.Client XRegistryClient (abstract) / GenericXRegistryClient (sealed), built on the generated ObjectType proxies
OPCFoundation.NetStandard.Opc.Ua.XRegistry.Server + Opc.Ua.Server Fast-path / registration / federation node managers, XRegistryServerOptions, IXRegistryResourceStore

Opc.Ua.XRegistry deliberately depends on neither SDK, so a codec or a shared contracts assembly can
reference the identity abstraction without pulling in the client or the server.

The model is compiled, not loaded

The companion model is compiled into the assemblies by the OPC UA model source generator.
Opc.Ua.XRegistry.NodeSet2.xml is an AdditionalFiles generator input only — there is no
EmbeddedResource, no runtime NodeSet parsing and no hand-written NodeId tables for anything the
model declares. All three node managers load it the same way:

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

XRegistryWellKnown is reduced to the namespace URI plus the identifiers of the instances a
registry materializes at runtime, which live above the model's own 63000-63999 range so they can
never collide with it.

What it does

  • Content-derived identityIResourceContentIdProvider maps a document + format to a
    fingerprint and names the (canonicalization, hash) algorithm. Identity is derived from the bytes,
    not assigned by the server, so it is stable across registries — which is what makes de-duplication
    and federation work.
  • Opaque-NodeId fast path — a registered resource is reachable at an Opaque NodeId whose
    Identifier is the raw content-id bytes, so a consumer that received the id on the wire resolves the
    document in a single Read: no Browse, no fingerprint recomputation. The client reads it through
    the ReadBytesAsync session extension, so a document larger than MaxByteStringLength is fetched
    with range-based reads rather than failing. Optionally pre-publishes a seed resource so a fresh
    server can resolve one resource before any registration.
  • The model's own lifecycle — the registry root is a RegistryType instance serving
    CreateGroup / GetOrCreateGroup; each group serves CreateResource / GetOrCreateResource; and
    because ResourceType : FileType, the document is the file — content is streamed through the
    inherited Open / Read / Write / Close. Close fingerprints the accumulated bytes and
    publishes the Opaque fast-path node at runtime. Identical bytes reuse the existing node, whose
    lifetime is reference counted so it survives until the last resource resolving to those bytes is
    gone and is retired when a resource is re-written with different content.
  • Optimistic concurrencyDelete(ExpectedEpoch) on both resources and groups, and the same
    epoch check on label mutations, so a caller working from a stale read is rejected rather than
    clobbering a concurrent change.
  • LabelsRegistryType, GroupType and ResourceType each expose an AttributesType Object
    with AddAttribute / RemoveAttribute; labels are published as addressable String Properties.
  • Federation — a resource hosted by another registry is published as a real ResourceType
    instance whose ExternalReference (ExpandedNodeId + ServerIndex into the local ServerArray)
    and ResourceUrl carry the link and whose Xid carries the content-id. Because it is an ordinary
    ResourceType, a generic client drives a federated resource through exactly the same proxy as a
    local one.

Extensibility

A domain registry subtypes the base model — the PubSub Schema Registry declares
SchemaFileType : ResourceType — and the generator mirrors that hierarchy across assemblies, so the
proxy chain follows automatically (SchemaFileTypeClient : ResourceTypeClient : FileTypeClient).

abstract XRegistryClient
   ├── sealed GenericXRegistryClient   // any registry namespace
   ├── SchemaRegistryClient  (domain)
   └── WotRegistryClient     (domain)
  • 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 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. This is covered by a test that defines a domain subclass over the base.

State

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 and a ranged read
touches only the bytes it needs.

The document is transferred with the inherited FileType Methods, whose mode bits are honoured: a
write that does not set EraseExisting starts from the stored bytes and replaces only the range it
writes rather than truncating the rest. A handle is valid only on the resource and the session that
opened it, and a session's handles are released when it closes.

Two implementations ship: InMemoryResourceStore (the default, in-process) and
FileSystemResourceStore, built on the IFileSystem abstraction so documents outlive the
process and a shared volume can back a cluster. XRegistryResourceStoreContractTests is a reusable
fixture that validates any implementation against the contract.

Transport security

Registry writes always require a SignAndEncrypt secure channel — a document and its
content-derived identity are integrity-critical, so every mutation is rejected with
BadSecurityModeInsufficient on a channel that is only signed or unprotected. This is not
configurable. Reads are allowed on any secure channel by default; RequireEncryptionForReads extends
the requirement to them when the documents themselves are confidential. An in-process call carries no
channel and is always allowed.

Hardening

The lifecycle Methods are remotely callable, so every unbounded dimension is bounded via
XRegistryServerOptions; exceeding a bound fails the call rather than the server:

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

A source generator bug fixed along the way

Building on the compiled model surfaced a defect in shared generator infrastructure.
NodeSetToModelDesign.CreateDecoder decoded values with an empty EncodeableFactory, so a NodeSet2's
method InputArguments / OutputArguments — encoded as a list of Argument ExtensionObjects — never
decoded, while HasArguments was still set. Every NodeSet2-sourced model in the stack was
silently generating argument-less MethodStates and ObjectType proxies. ModelDesign-sourced models
(the core stack) were unaffected, which is why this went unnoticed. Fixed in b87726af with a
regression test that fails without it.

Two defects in the xRegistry NodeSet itself were fixed at the same time: BrowseName="1:InputArguments"
→ ns-0 InputArguments (published NodeSets never namespace-qualify these), and the CreateResource /
GetOrCreateResource output VersionId renamed to AssignedVersionId, which collided with the
input of the same name and produced a duplicate C# parameter.

Other fixes applied while extracting

  • XRegistryRegistrationNodeManager uses System.Threading.Lock instead of an object-typed sync
    root, per the repository locking rules.
  • Dropped the InternalsVisibleTo entries granting internals to Opc.Ua.PubSub.Server.Tests. That
    project only consumes public XRegistryWellKnown API.
  • The UA.slnx edit in DRAFT Add experimental data encodings #4007 accidentally replaced the
    tests/Opc.Ua.Redundancy.Kubernetes.Tests entry instead of adding a line, silently dropping that
    project from the solution. The entry is preserved here and the change is purely additive.

Tests

tests/Opc.Ua.XRegistry.Tests (168) covers the group and resource lifecycles, the FileType
transfer, delete and epoch conflicts, labels, federation, both resource stores, the fast path,
transport security and the extensibility contract — positive and negative paths. Passing on net10.0
and net48. Patch line coverage is 98.5%.

Also adds tests/Directory.Build.props / .targets: the .NET 10 SDK prunes
Microsoft.Extensions.Logging.Abstractions out of the test output, and coverlet's Mono.Cecil
resolver only searches the output directory, so it silently fails to instrument every module that
references ILogger and reports 0% coverage for them. These force the package back into the restore
graph and copy its implementation next to the test binary.

Documentation

New docs/XRegistry.md, linked from the documentation index.

Follow-ups (deliberately not in this PR)

  • The libraries currently emit no logging. Adding source-generated [LoggerMessage] logging is worth
    a follow-up.
  • WoT Binding and WoT Connectivity 1.1 #4093 (WoT Binding) carries an identical copy of Opc.Ua.XRegistry.NodeSet2.xml under
    src/Opc.Ua.WotCon/Design/; de-duplicating it against this package is a natural follow-up.
  • The NodeIds in XRegistryWellKnown are provisional — final identifiers are assigned by the OPC
    Foundation.

Related

marcschier and others added 4 commits July 25, 2026 13:16
Adds three new packages implementing the generic, registry-agnostic xRegistry
abstract base model (Annex B) so that any concrete registry can reuse the same
content-addressed resource identity, registration lifecycle and federation:

- Opc.Ua.XRegistry: the abstract base companion NodeSet (source generated),
  well-known provisional NodeIds and the IResourceContentIdProvider abstraction
  that maps a resource document + format to its content-derived identity.
  Depends only on Opc.Ua.Core.
- Opc.Ua.XRegistry.Client: resolves a resource by its content-derived id via the
  Opaque NodeId fast path and registers a resource through the
  CreateResource/Write/Close lifecycle.
- Opc.Ua.XRegistry.Server: server-side node managers for the content-addressed
  fast path, the registration lifecycle with auto-bootstrap on Close, and
  federated resource proxies.

This code was previously developed inside the experimental data encodings work
as the substrate underneath the PubSub Schema Registry. It has no dependency on
Avro, Arrow or PubSub, so it is factored out here to be reviewed and shipped on
its own.

Two fixes were applied while extracting:

- XRegistryRegistrationNodeManager now uses System.Threading.Lock instead of an
  object-typed sync root, per the repository locking rules.
- Dropped the InternalsVisibleTo entries granting internals to
  Opc.Ua.PubSub.Server.Tests; that project only consumes public
  XRegistryWellKnown API, and Opc.Ua.XRegistry/.Client have no internals at all.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 000aa394-d085-4bf5-b605-a7adbe7d27f4
…ucture

Adds tests/Opc.Ua.XRegistry.Server.Tests covering the registration lifecycle
node manager: the CreateResource/Write/Close happy path plus the negative
paths for the concurrent-upload, per-resource-size and registered-resource
bounds that guard against remote memory / address-space exhaustion.

Also adds tests/Directory.Build.props and tests/Directory.Build.targets. The
.NET 10 SDK prunes Microsoft.Extensions.Logging.Abstractions out of the test
output, and coverlet's Mono.Cecil resolver only searches the output directory,
so it silently fails to instrument every module that references ILogger and
reports 0% coverage for them. These files force the package back into the
restore graph and copy its implementation next to the test binary.

The UA.slnx entry for the new test project is added without disturbing the
existing Opc.Ua.Redundancy.Kubernetes.Tests entry.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 000aa394-d085-4bf5-b605-a7adbe7d27f4
Adds docs/XRegistry.md covering the content-derived resource identity
(IResourceContentIdProvider), the Opaque-NodeId fast path, the
CreateResource/Write/Close registration lifecycle with auto-bootstrap on Close,
federated resource proxies, the resource-exhaustion bounds enforced by the
registration Methods, and server-side plus client-side usage examples. Links it
from the documentation index.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 000aa394-d085-4bf5-b605-a7adbe7d27f4
Pure style / analyzer pass over the new xRegistry files; no behaviour changes.
Clears all 28 info-level diagnostics (IDE0005, IDE0090, IDE0270, IDE0305,
RCS0027, RCS1085, RCS1140, RCS1181) so the projects build clean at the
repository's "all"/preview analysis level.

- Move private fields to the end of each class, ordered public -> private, per
  the repository member-ordering convention, and move the private static
  AddMethod helper below the internal Method handlers.
- Expand every single-line /// <summary>...</summary> to the required
  multi-line form.
- Drop unused usings; document the InvalidOperationException thrown by the two
  CreateAddressSpace overrides; convert the Method signature comments into XML
  doc comments.
- XRegistryClient: replace the m_namespaceIndex field plus expression-bodied
  property with a get-only auto-property (public API unchanged), and put the
  binary operator at end of line per roslynator_binary_operator_new_line.

The three node managers are intentionally left unsealed: they are the
derivation points a concrete registry specializes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 000aa394-d085-4bf5-b605-a7adbe7d27f4
marcschier added a commit that referenced this pull request Jul 25, 2026
…odings branch

The generic xRegistry stack (Opc.Ua.XRegistry, Opc.Ua.XRegistry.Client and
Opc.Ua.XRegistry.Server) was factored out of this branch into #4097 so it can be
reviewed and shipped independently of the experimental Avro / Arrow encodings.
This branch is now stacked on that PR and keeps only the encoding work and the
PubSub Schema Registry specialization that derives from the generic node
managers.

Also brings in the 13 master commits #4097 is based on.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 000aa394-d085-4bf5-b605-a7adbe7d27f4

# Conflicts:
#	docs/README.md
#	src/Opc.Ua.XRegistry.Client/Opc.Ua.XRegistry.Client.csproj
#	src/Opc.Ua.XRegistry.Client/XRegistryClient.cs
#	src/Opc.Ua.XRegistry.Server/Opc.Ua.XRegistry.Server.csproj
#	src/Opc.Ua.XRegistry.Server/XRegistryFastPathNodeManager.cs
#	src/Opc.Ua.XRegistry.Server/XRegistryFederationNodeManager.cs
#	src/Opc.Ua.XRegistry.Server/XRegistryRegistrationNodeManager.cs
#	src/Opc.Ua.XRegistry.Server/XRegistryServerNodeSets.cs
#	src/Opc.Ua.XRegistry.Server/XRegistryServerOptions.cs
#	src/Opc.Ua.XRegistry/IResourceContentIdProvider.cs
#	src/Opc.Ua.XRegistry/Opc.Ua.XRegistry.csproj
#	src/Opc.Ua.XRegistry/XRegistryNodeSets.cs
#	src/Opc.Ua.XRegistry/XRegistryWellKnown.cs
#	tests/Opc.Ua.XRegistry.Server.Tests/XRegistryRegistrationNodeManagerTests.cs
marcschier and others added 4 commits July 25, 2026 15:34
The NodeSet2 importer decoded Value elements with an empty encodeable factory, so a method's InputArguments/OutputArguments (a list of Argument ExtensionObjects) decoded to an empty list. HasArguments was still set, so generation silently emitted argument-less NodeState handlers and ObjectType proxies for every NodeSet2-sourced model. Decode with a factory that registers Argument, and cover it with a regression test.

Also correct the xRegistry NodeSet: InputArguments/OutputArguments BrowseNames must be in namespace 0, and rename the CreateResource/GetOrCreateResource output VersionId to AssignedVersionId so it no longer collides with the input of the same name.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 19cb13c4-ea7c-4f1c-b6a6-ab037e418c6b
The NodeSet2 was both a source-generator input and an embedded resource that XRegistryNodeSets/XRegistryServerNodeSets re-parsed at runtime through the RuntimeNodeSetSource pipeline, so the model was materialized twice by two mechanisms.

The generator is now the single source: the node managers return the generated model from LoadPredefinedNodes (the Opc.Ua.Di.Server pattern) and the embedded resource and both runtime loader types are gone. Generated code is emitted into the project's own Opc.Ua.XRegistry namespace, which also yields the typed RegistryTypeClient / GroupTypeClient / ResourceTypeClient (: FileTypeClient) ObjectType proxies.

The hand-written instance identifiers in XRegistryWellKnown collided with the model - 63001-63004 are GroupType, ResourceType, AttributesType and RegistryCapabilitiesDataType - so they move above the range the model occupies.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 19cb13c4-ea7c-4f1c-b6a6-ab037e418c6b
XRegistryClient hand-rolled Session.CallAsync/ReadValueAsync against invented Write/Close methods. It is now an abstract base whose wire interactions go through the source-generated RegistryTypeClient / GroupTypeClient / ResourceTypeClient proxies and the model's real lifecycle: CreateResource opens the version for writing and the document streams through the FileType methods ResourceType inherits.

GenericXRegistryClient is the sealed implementation for the base model; a domain registry client derives from the same abstract base. Because a domain model subtypes the base types, the generated proxy chain (SchemaFileTypeClient : ResourceTypeClient : FileTypeClient) lets domain proxies reuse the ResourceTypeClient extensions directly and lets a generic client drive a domain registry.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 19cb13c4-ea7c-4f1c-b6a6-ab037e418c6b
Adds tests/Opc.Ua.XRegistry.Client.Tests (registered in UA.slnx) covering the proxy-based client: constructor validation and namespace resolution, the Opaque-NodeId fast path including the not-registered and propagating fault paths, chunked registration through the inherited FileType methods, and the document read-back loop.

A test-only domain client derived from the abstract XRegistryClient proves the extensibility contract - a domain registry inherits the whole lifecycle - and the proxy chain assertions pin ResourceTypeClient to FileTypeClient.

On the server side the mocked IServerInternal harness moves into XRegistryServerTestHarness (seeding the base types the compiled model derives from) and new fixtures cover the fast-path and federation managers.

xRegistry source coverage is 97.1% (530/546), against the 80% patch gate.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 19cb13c4-ea7c-4f1c-b6a6-ab037e418c6b
@codecov

codecov Bot commented Jul 25, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.18484% with 56 lines in your changes missing coverage. Please review.
✅ Project coverage is 79.68%. Comparing base (a03bca9) to head (2a9182c).
⚠️ Report is 2 commits behind head on master.

Files with missing lines Patch % Lines
...egistry.Server/XRegistryRegistrationNodeManager.cs 92.12% 12 Missing and 37 partials ⚠️
src/Opc.Ua.XRegistry.Client/XRegistryClient.cs 97.56% 0 Missing and 2 partials ⚠️
...a.XRegistry.Server/XRegistryFastPathNodeManager.cs 94.28% 0 Missing and 2 partials ⚠️
...XRegistry.Server/XRegistryFederationNodeManager.cs 95.00% 0 Missing and 2 partials ⚠️
...Opc.Ua.XRegistry.Server/FileSystemResourceStore.cs 98.75% 0 Missing and 1 partial ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##           master    #4097      +/-   ##
==========================================
- Coverage   79.95%   79.68%   -0.27%     
==========================================
  Files        1472     1481       +9     
  Lines      199303   200265     +962     
  Branches    34534    34724     +190     
==========================================
+ Hits       159347   159584     +237     
- Misses      27833    28543     +710     
- Partials    12123    12138      +15     
Files with missing lines Coverage Δ
.../Opc.Ua.XRegistry.Client/GenericXRegistryClient.cs 100.00% <100.00%> (ø)
...a.XRegistry.Client/ResourceTypeClientExtensions.cs 100.00% <100.00%> (ø)
...c/Opc.Ua.XRegistry.Server/InMemoryResourceStore.cs 100.00% <100.00%> (ø)
...try.Server/XRegistryServiceCollectionExtensions.cs 100.00% <100.00%> (ø)
...urceGeneration.Core/Schema/NodeSetToModelDesign.cs 71.25% <100.00%> (+2.48%) ⬆️
...Opc.Ua.XRegistry.Server/FileSystemResourceStore.cs 98.75% <98.75%> (ø)
src/Opc.Ua.XRegistry.Client/XRegistryClient.cs 97.56% <97.56%> (ø)
...a.XRegistry.Server/XRegistryFastPathNodeManager.cs 94.28% <94.28%> (ø)
...XRegistry.Server/XRegistryFederationNodeManager.cs 95.00% <95.00%> (ø)
...egistry.Server/XRegistryRegistrationNodeManager.cs 92.12% <92.12%> (ø)

... and 59 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Comment thread src/Opc.Ua.XRegistry.Server/XRegistryServerNodeSets.cs Outdated
marcschier and others added 8 commits July 26, 2026 14:56
Resource documents are about to move off the node manager's in-process buffers and behind a provider, so a registry can keep them in a shared store and survive a failover instead of pinning that state to one process. Mirrors the IFileSystemProvider / HistoricalAccess provider model; Opc.Ua.Server's FileObjectState is internal so the FileType plumbing cannot be reused directly.

The provider is document-oriented rather than stream-oriented because a resource's identity is derived from the whole document, so an upload is committed in one piece once the content id has been computed.

XRegistryServerOptions.ResourceStore defaults to the in-process implementation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 19cb13c4-ea7c-4f1c-b6a6-ab037e418c6b
The registry root is now materialized through the source-generated RegistryType factory (SystemContext.CreateInstanceOfRegistryType), the same pattern DiNodeManager uses for DeviceType. A bare new RegistryState(parent) does not carry the type's children - the generated states have an empty constructor, no Initialize override and no InitializationString - so the optional children, including the lifecycle Methods, are materialized explicitly through the generated Add* builders.

RegistryType.CreateGroup and GetOrCreateGroup are bound to async handlers and create GroupType instances at runtime, each carrying the model's own resource lifecycle Methods. CreateGroup is strict (BadNodeIdExists on a duplicate id) and GetOrCreateGroup is idempotent, reporting Created. Runtime instances are allocated above the range the compiled model occupies.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 19cb13c4-ea7c-4f1c-b6a6-ab037e418c6b
GroupType.CreateResource and GetOrCreateResource now materialize ResourceType instances from the generated factory and return the model's real outputs (ResourceNodeId, AssignedVersionId, FileHandle, Created). An empty VersionId makes the server assign the next one, a duplicate (ResourceId, VersionId) fails with BadNodeIdExists, and GetOrCreateResource is idempotent.

Because ResourceType is a FileType, the document is transferred through the inherited Open/Write/Read/Close rather than a registry-specific mechanism. Closing a write handle computes the content-derived id, commits the document to IXRegistryResourceStore, bumps the epoch and publishes the Opaque content-id fast-path node. The handlers are async, so the store is awaited rather than blocked on.

MaxConcurrentUploads, MaxResourceBytes and MaxRegisteredResources are enforced on the new lifecycle.

xRegistry coverage: 96.3% (1144/1188).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 19cb13c4-ea7c-4f1c-b6a6-ab037e418c6b
Delete is bound on both GroupType and ResourceType with the model's optimistic-concurrency semantics: a stale ExpectedEpoch is rejected with BadInvalidState instead of deleting a newer version. Deleting a resource also removes its Opaque fast-path node and its document from the resource store; deleting a group removes every version it owns and frees its GroupId.

With the model's own lifecycle in place, the hand-written CreateResource/Write/Close/Delete handlers, the ResourceGroup object they hung off and the well-known identifiers that addressed them are removed. Those methods were never part of the compiled model, and the PR is unmerged, so no [Obsolete] shim is warranted. The tests that drove them are replaced by tests against the real contracts - the DoS bounds are still covered.

xRegistry coverage: 97.4% (1046/1074).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 19cb13c4-ea7c-4f1c-b6a6-ab037e418c6b
Adds CreateGroupAsync / GetOrCreateGroupAsync, an idempotent GetOrRegisterResourceAsync that only streams the document when it actually created the version, and DeleteResourceAsync / DeleteGroupAsync carrying the model's ExpectedEpoch. All of them go through the generated proxies.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 19cb13c4-ea7c-4f1c-b6a6-ab037e418c6b
Every RegistryType, GroupType and ResourceType instance now materializes its optional Labels Object and binds the AttributesType Methods on it. Labels are published as addressable String Properties in the registry namespace and both mutations take the owning node's epoch as an optimistic-concurrency check, so a stale caller is rejected rather than silently overwriting.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 19cb13c4-ea7c-4f1c-b6a6-ab037e418c6b
The federated proxy is now a ResourceType instance created from the generated type factory instead of a hand-built BaseObjectState with invented Properties, so a generic xRegistry client drives a federated resource through exactly the same proxy as a local one. The last hand-written Federation* NodeIds are gone.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 19cb13c4-ea7c-4f1c-b6a6-ab037e418c6b
Adds tests for the paths the patch left uncovered: both MaxConcurrentUploads gates (CreateResource and Open), Read with an unknown handle, Read on a write handle, Read at EOF and with zero length, Close with an unknown handle, Close of a read handle, and the de-duplication guard where two resources carrying identical bytes resolve to one fast-path node.

These are the claims the hardening table and the content-derived identity design make, so they are worth asserting rather than inferring. Patch coverage goes from 97.59% to 99.84%.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 19cb13c4-ea7c-4f1c-b6a6-ab037e418c6b
Comment thread docs/XRegistry.md Outdated
Comment thread docs/XRegistry.md Outdated
Comment thread docs/XRegistry.md
Comment thread docs/XRegistry.md Outdated
Comment thread docs/XRegistry.md
Comment thread docs/XRegistry.md Outdated
Comment thread docs/XRegistry.md Outdated
Comment thread src/Opc.Ua.XRegistry.Server/IXRegistryResourceStore.cs Outdated
Comment thread src/Opc.Ua.XRegistry.Server/InMemoryXRegistryResourceStore.cs Outdated
Comment thread tests/Opc.Ua.XRegistry.Tests/Opc.Ua.XRegistry.Tests.csproj
@marcschier marcschier changed the title DRAFT Add generic xRegistry libraries (abstract registry base model) Add generic xRegistry libraries (abstract registry base model) Jul 26, 2026
marcschier added a commit that referenced this pull request Jul 26, 2026
marcschier added a commit that referenced this pull request Jul 26, 2026
)

The xRegistry base branch replaced the invented registration lifecycle with the
companion model's own: a registry root owns groups, groups own resources, and a
resource is a FileType whose document transfers over the inherited file Methods.
The base companion model is now compiled into Opc.Ua.XRegistry by the model
source generator, so XRegistryNodeSets, XRegistryServerNodeSets and the
CreateResource/Write/Close/Delete NodeIds on XRegistryWellKnown are all gone.

Client side:

- SchemaRegistryClient follows XRegistryClient, which is now abstract and takes
  an ITelemetryContext for the generated ObjectType proxies.
- RegisterSchemaAsync now registers into a SchemaGroup by schema resource id and
  returns the resource NodeId plus the version the server assigned. Added
  GetOrCreateSchemaGroupAsync and the idempotent GetOrRegisterSchemaAsync.
- SchemaRegistrySink registers each DataSet's schema as one resource keyed by the
  DataSet identity and carries the MetaData MajorVersion as the resource version,
  so a schema growth versions the same resource instead of creating an unrelated
  entry. It uses the idempotent call, so re-announcing a known schema succeeds.
- SchemaRegistrySinkOptions collapses to the SchemaGroup NodeId and chunk size,
  and no longer models NodeId as System.Nullable, per the INullable rule.

Server side:

- SchemaRegistryOptions maps the new XRegistryServerOptions surface: the
  injectable resource store plus the registry root BrowseName and identity.
- The Schema Registry NodeSet declares a RequiredModel on the xRegistry base, and
  the runtime importer must resolve those supertypes. Since Opc.Ua.XRegistry no
  longer publishes the base as a runtime NodeSet, Opc.Ua.PubSub embeds the base
  document and SchemaRegistryNodeSets exposes it, keeping the import path
  self-contained.

Tests keep their original coverage - namespace registration, the type model in
the address space, fast-path resolution, the register/resolve round trip,
deletion and federation - expressed through the new model. No test was dropped.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 000aa394-d085-4bf5-b605-a7adbe7d27f4
marcschier added a commit that referenced this pull request Jul 26, 2026
Test-only change on the base branch; no API impact on the PubSub Schema
Registry specialization.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 000aa394-d085-4bf5-b605-a7adbe7d27f4
Resource store (thread on IXRegistryResourceStore):
- Reshaped the store to be offset and length based so it maps the FileType access model, and reads now stream the requested slice out of the store instead of materializing the whole document. Documented random-access writes, the read clamping rules and how error conditions are reported.
- Added FileSystemXRegistryResourceStore built on the IFileSystem abstraction from Opc.Ua.Types.
- Added a store contract test suite that runs against both implementations.

Client:
- ResolveResourceAsync now goes through the ReadBytesAsync session extension, so a document larger than MaxByteStringLength is fetched with range based reads rather than failing.

Security:
- Registry writes always require a SignAndEncrypt channel; added RequireEncryptionForReads to extend that to reads.

Tests:
- Merged Opc.Ua.XRegistry.Client.Tests and Opc.Ua.XRegistry.Server.Tests into a single Opc.Ua.XRegistry.Tests project.

Docs:
- Dropped the Annex B reference, generalized the schema document wording, reworded the extensibility example, and added sections on implementing a resource store and on transport security.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 19cb13c4-ea7c-4f1c-b6a6-ab037e418c6b
@marcschier
marcschier marked this pull request as ready for review July 26, 2026 16:51
Copilot AI review requested due to automatic review settings July 26, 2026 16:51
Comment thread src/Opc.Ua.XRegistry.Server/FileSystemResourceStore.cs
Comment thread src/Opc.Ua.XRegistry.Client/XRegistryClient.cs Outdated
- Renamed InMemoryXRegistryResourceStore to InMemoryResourceStore and FileSystemXRegistryResourceStore to FileSystemResourceStore. The XRegistry prefix was redundant inside the Opc.Ua.XRegistry.Server namespace.
- Replaced the value tuples returned by the client convenience API with ResourceRegistrationResult and GroupRegistrationResult readonly record structs, matching the result type convention used across the stack. All three tuple returning methods were converted rather than only the one flagged, so the convenience layer stays consistent.

RegisterResourceAsync now reports Created as well; it drives the strict CreateResource, so it only returns when it created the version.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 19cb13c4-ea7c-4f1c-b6a6-ab037e418c6b
marcschier added a commit that referenced this pull request Jul 27, 2026
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 000aa394-d085-4bf5-b605-a7adbe7d27f4
marcschier added a commit that referenced this pull request Jul 27, 2026
The base branch replaced the client's value tuples with named record structs,
added a chunk-size parameter to the resolve path, renamed the resource store
implementations, and moved the seed/federation documents to ByteString.

- SchemaRegistryClient returns GroupRegistrationResult and
  ResourceRegistrationResult instead of tuples, and forwards the new
  maxByteStringLength argument on ResolveSchemaAsync.
- SchemaRegistryOptions constructs InMemoryResourceStore under its new name and
  holds the seed and federated schema documents as ByteString, matching the
  ByteString-over-byte[] preference the base now follows.
- The Schema Registry integration tests read those documents through .Span.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 000aa394-d085-4bf5-b605-a7adbe7d27f4
Comment thread src/Opc.Ua.XRegistry.Server/XRegistryRegistrationNodeManager.cs
Comment thread tools/Opc.Ua.SourceGeneration.Core/Schema/NodeSetToModelDesign.cs Outdated
Comment thread src/Opc.Ua.XRegistry.Server/XRegistryRegistrationNodeManager.cs
Comment thread src/Opc.Ua.XRegistry.Server/XRegistryRegistrationNodeManager.cs Outdated
Comment thread src/Opc.Ua.XRegistry.Server/XRegistryRegistrationNodeManager.cs Outdated
marcschier and others added 5 commits July 27, 2026 12:35
Four defects found in review, all around the shared content-addressed fast-path node and the write handle:

- The fast-path node is shared by every resource whose document has the same bytes, but its lifetime was tied to whichever resource happened to be deleted first, which silently broke resolution for the others. Its lifetime is now reference counted: publishing takes a reference and the node is only unpublished once the last resource that resolves to those bytes lets it go.
- Re-writing a resource with different content left the superseded content id published forever. Closing a write handle now releases the previous content id before taking a reference on the new one, and skips the churn entirely when the bytes did not change.
- Committing a document wrote at offset 0 without truncating, so replacing a version with a shorter one left the tail of the previous version behind. The stored document is now removed before the new one is written, which is what the store contract prescribes for a wholesale replacement.
- GetOrCreateResource handed out a write handle on the existing resource path without checking MaxConcurrentUploads, and the client never closed that handle when it had not created the version, so every idempotent re-registration leaked one handle. The bound now covers every path that hands out a handle and the client always releases it.

Also widened the source generator value decoding factory to register every encodeable type exported by the built-in assembly rather than only Argument, so a NodeSet carrying any other standard structure decodes too.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 19cb13c4-ea7c-4f1c-b6a6-ab037e418c6b
OnFileReadAsync took the handle's Position outside the lock and only advanced it after awaiting the store, so two overlapping Reads on one handle both started at the same offset: both returned the same slice and the cursor then skipped one. StoreKey and Writing are immutable for the life of the handle, but Position is not.

The cursor is now taken and the range reserved inside the same lock that validates the handle, and the optimistic reservation is corrected down afterwards so a short read at the end of the document leaves the cursor at the real end rather than past it.

Added a gated resource store that holds both readers inside ReadAsync until each has taken its offset, which makes the overlap deterministic instead of timing dependent. The test fails against the old code with both reads starting at offset 0.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 19cb13c4-ea7c-4f1c-b6a6-ab037e418c6b
The spec defines ExpectedEpoch as optimistic concurrency where a non-zero value that does not match the entity's epoch fails Bad_InvalidState and 0 disables the check. The implementation compared for equality unconditionally, and since Epoch starts at 1 and only increments, passing 0 could never succeed: the documented force path was unreachable on Delete for resources and groups and on AddAttribute and RemoveAttribute.

Also syncs the compiled NodeSet2 byte for byte with the regenerated spec model so the spec stays the single source of truth.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 19cb13c4-ea7c-4f1c-b6a6-ab037e418c6b
Review found that the idempotent-registration fix landed earlier was destructive. GetOrCreateResource hands out a write handle even when it returned an existing version, and the client releases it without writing; Close treated every write handle as a completed upload, so it erased the stored document, re-fingerprinted the resource as empty and dropped the fast-path node. A handle now tracks whether anything was written and Close only commits when it was.

File handles were also server-wide, sequential and unvalidated: a caller could drive another resource's document through its own resource's Methods, and Close would store one resource's bytes while marking a different one. A handle now records the resource it was opened on and is rejected elsewhere.

Close was gated unconditionally on the write policy, so a read handle opened on a channel the read policy allows could never be closed and leaked, permanently consuming the upload budget. Close is now gated on what the handle actually does.

Handles open on a deleted resource were never released, and a repeated or racing Delete double-released the shared fast-path reference and drifted the registration count. Removal is now idempotent and drops the resource's handles.

Close could also publish a fast-path node for a resource deleted while the store call was in flight; it now re-validates before touching fast-path state.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 19cb13c4-ea7c-4f1c-b6a6-ab037e418c6b
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 19cb13c4-ea7c-4f1c-b6a6-ab037e418c6b
marcschier added a commit that referenced this pull request Jul 27, 2026
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 000aa394-d085-4bf5-b605-a7adbe7d27f4
marcschier added a commit that referenced this pull request Jul 27, 2026
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 000aa394-d085-4bf5-b605-a7adbe7d27f4
marcschier and others added 3 commits July 27, 2026 15:56
Open reduced the mode to a single bool. Per OPC 10000-5 the combinations with neither Read nor Write, with both, and with EraseExisting or Append but no Write are now rejected with BadInvalidArgument. More importantly EraseExisting and Append are honoured: a write-open that does not erase seeds the buffer from the stored document and overwrites at the cursor, so a partial rewrite no longer silently truncates the rest of it. The inherited Size and OpenCount Properties are kept current.

Auto-assigned VersionId could collide with a version the caller created explicitly, which failed the create outright or made GetOrCreateResource return an unrelated version. The counter is now scoped to the owning group, advances past any existing version, and is pruned when a resource's last version goes so the map stays bounded.

File handles are now bound to the session that opened them (a null session id means an in-process call) and released when that session closes, so an abandoned session no longer holds the upload budget forever.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 19cb13c4-ea7c-4f1c-b6a6-ab037e418c6b
Repo guidance mandates ByteString over ReadOnlyMemory in public API, and the store surface was inconsistent with itself since ReadAsync already returned ByteString. Switched IXRegistryResourceStore.WriteAsync, both stores, WriteDocumentAsync and the two client registration methods. ByteString wraps ReadOnlyMemory with no copy, so the per-chunk allocation removed earlier stays removed.

XRegistryServerOptions is now sealed. The three node managers stay unsealed on purpose: subclassing is the server-side extension seam a domain registry uses, mirroring how a domain client derives from XRegistryClient, and that is now stated in their docs.

Added XRegistryServiceCollectionExtensions so the server pieces wire into DI, with direct construction still supported.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 19cb13c4-ea7c-4f1c-b6a6-ab037e418c6b
Adds the coverage the plan called for but the previous commits omitted: version assignment (skipping an explicitly created version, per-group scoping, counter pruning on delete) and session ownership (a handle rejected from another session, and a closing session releasing its handles). Both suites were verified to fail against the pre-fix behaviour.

Two existing security tests were opening a handle in-process and then operating on it from a real session, which the session check now correctly rejects. They are restructured to hold the session constant and vary only the channel, so they test the security policy rather than two rules at once.

Also covers the new service collection extensions, taking the patch from 95.8 to 98.5 percent.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 19cb13c4-ea7c-4f1c-b6a6-ab037e418c6b
marcschier added a commit that referenced this pull request Jul 27, 2026
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 000aa394-d085-4bf5-b605-a7adbe7d27f4
marcschier added a commit that referenced this pull request Jul 27, 2026
The base branch moved the client's document parameter from ReadOnlyMemory<byte>
to ByteString, matching the repository's preference for ByteString over
byte[] / ReadOnlyMemory<byte> in public API.

SchemaRegistryClient takes ByteString on RegisterSchemaAsync and
GetOrRegisterSchemaAsync, and SchemaRegistrySink now hands the notification's
schema document straight to the client instead of copying it into a new array
on every publish.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 000aa394-d085-4bf5-b605-a7adbe7d27f4
Comment thread src/Opc.Ua.XRegistry.Server/XRegistryRegistrationNodeManager.cs
OnFileCloseAsync writes the document to the resource store outside the lock and only then re-checks that the resource still exists. A concurrent Delete can complete its own store cleanup inside that window, so the bytes the commit wrote survive it. Store keys are the resource NodeId and instance ids only ever increase, so the key is never reused and nothing would ever collect them: a permanent entry in the in-memory store, or a permanent file on disk for the file-backed one.

The post-write block now returns the abort decision instead of returning from inside the lock, and deletes the just-written document before failing the call.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 19cb13c4-ea7c-4f1c-b6a6-ab037e418c6b
@marcschier
marcschier requested a review from koepalex July 28, 2026 06:19
@marcschier marcschier added the ready Ready to merge once CI Passes label Jul 28, 2026
@marcschier
marcschier merged commit 38c7356 into master Jul 28, 2026
167 of 169 checks passed
@marcschier
marcschier deleted the marcschier/xregistry branch July 28, 2026 06:45
marcschier added a commit that referenced this pull request Jul 28, 2026
#4097 was squash-merged into master, so master now carries the xRegistry
libraries as a single commit with no shared history with the copies this branch
carried while it was stacked on that PR. Resolve every xRegistry path in favour
of master, which is now authoritative and also newer.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 000aa394-d085-4bf5-b605-a7adbe7d27f4

# Conflicts:
#	docs/README.md
#	src/Opc.Ua.XRegistry.Server/XRegistryRegistrationNodeManager.cs
marcschier added a commit that referenced this pull request Jul 28, 2026
Merges origin/master, which introduced the generic xRegistry libraries
(src/Opc.Ua.XRegistry{,.Client,.Server}, #4097), and drops this branch's
private copy of that model in favour of the shared one.

xRegistry consolidation
- Opc.Ua.WotCon no longer vendors src/Opc.Ua.WotCon/Design/Opc.Ua.XRegistry.NodeSet2.xml.
  It now takes a ProjectReference on Opc.Ua.XRegistry and links master's NodeSet as a
  dependency-only AdditionalFile (ModelSourceGeneratorIgnore=true), so the model resolves
  for source generation but the types come from the shared assembly.
  The vendored copy was also subtly wrong: it namespace-qualified the standard
  namespace-0 properties InputArguments/OutputArguments as 1:InputArguments.
- Verified there are now zero duplicate xRegistry types in Opc.Ua.WotCon.dll and that
  the generated WoT proxies derive from the shared ones
  (WoTDocumentTypeClient : Opc.Ua.XRegistry.ResourceTypeClient).
- Call sites moved from the generated XRegistry.Namespaces.XRegistry constant to the
  package's canonical XRegistryWellKnown.XRegistryNamespaceUri.

Fix a request-drain self-wait deadlock
The base-branch merge replaced AsyncLocal<uint?> m_currentRequestId with
AsyncLocal<bool> m_inServiceDispatch, losing the request *id*. WaitForCurrentRequestsAsync
therefore waited on the very request that triggered the lifecycle operation. The WoT
registry hits this legitimately: LifecycleWotProjectionHost sets
AllowLifecycleFromRequestCallback = true and Refresh is an OPC UA Method that triggers a
shadow reload, so Refresh timed out with BadRequestTimeout after the 2 minute drain
timeout (7 WotCon tests).

Current-request tracking is restored via AsyncLocal<StrongBox<uint?>>. The indirection is
required: an AsyncLocal *assignment* made inside an async method is not visible to the
awaiting caller, and EnterRequestScope is reached through ValidateRequestAsync. The slot
is established synchronously by EnterServiceDispatchScope (before any await, so it does
flow), and EnterRequestScope mutates the box rather than reassigning the AsyncLocal.
EnterRequestScope also creates a slot when none exists, for callers that use it directly
without going through the dispatcher.

Validation (all builds warning-free)
- net10.0: Server 3862 passed, WotCon 823, WotCon.Bindings 464, Types 8390,
  SourceGeneration 94. The single Server failure is the known pre-existing local PKI
  flake (ConfigureApplicationBuildsSharedClientAndServerConfigurationAsync).
- net48: Types 8383 and WotCon 823 pass; all four test projects build clean.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready Ready to merge once CI Passes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants