Files
UA-.NETStandard/docs/DeveloperGuide.md
T
Marc Schier 8a10139b8c Fix OPC UA address-space compliance in the device integration stack (#4117)
# Description

A live OPC UA compliance audit of `samples/PumpDeviceIntegrationServer`
against the **OPC 40223 Pumps** and **OPC 10000-100 (DI)** companion
specifications found **13 address-space violations** plus a
serialization bug in the MCP tool. Every fix lands at its **root cause
in the shared stack** rather than as a workaround in the sample, so all
servers benefit.

The sample now exposes **N fully simulated pumps** (`--pumps N`) and
advertises the DI conformance facets it actually satisfies.

> This branch has been merged with `master` including **#4116** ("Add
simulation to PumpDeviceIntegrationServer and Event modelling"), which
independently reworked the same pump simulation and the same fluent
alarm builders. See [Relationship to #4116](#relationship-to-4116) below
for how the two were reconciled.

## Findings and fixes

| # | Finding | Root cause | Fix |
|---|---------|-----------|-----|
| 1 | `OverTempAlarm` created by the fluent API did not exist in the
address space | `AttachAlarm` attached the alarm with `AddChild` but
never indexed it into the node manager | Register the node; the same gap
existed in `AddObject`, `CreateInstance` and the state-machine creators
|
| 2 | Alarms were unsubscribable — `EventNotifier = 0` everywhere, no
`HasNotifier` from `Server` | No event-source wiring at all | Set
`HasCondition`, initialise
`SourceNode`/`SourceName`/`ConditionName`/`InputNode`, promote
`EventNotifier` up the ancestor chain, publish `HasNotifier` from the
`Server` object |
| 3 | Every instance NodeId was minted in the **DI standard namespace**
(`ns=4;s=5001_Pump #1`) | `DiNodeManager.New` inherited the parent's
namespace index | New `DiNodeManager.InstanceNamespaceIndex`; instances
move to the server's application namespace |
| 4 | `Server/Namespaces` described only 2 of 6 namespaces | The
NodeSet2 importer drops nodes parented to namespace 0, so companion-spec
metadata objects never reached the address space — and no route covered
the server's own namespace | New `NamespaceMetadataPublisher` walks the
`NamespaceArray` and fills version/publication date from
`ModelDependencyAttribute` |
| 5 | Vendor BrowseNames (`Diagnostics`, `LastError`, …) sat in the DI
namespace | Builders defaulted to the parent's namespace | Default to
the server namespace; **spec-defined BrowseNames are unchanged** |
| 6 | `AccessLevel="5"` from the Pumps NodeSet was reported as `1` | The
ModelDesign `AccessLevel` enum is not `[Flags]` and cannot represent
`CurrentRead \| HistoryRead` | Carry the verbatim NodeSet2 bitmask and
emit named `AccessLevels` constants |
| 7 | `Historizing = true` while `HistoryRead` returned
`BadHistoryOperationUnsupported` | Nothing reconciled the declared
history surface with the wired historians | Startup clears the
advertisement (including masking the attribute read callbacks) when no
provider resolves; the sample wires a real historian |
| 8 | Mandatory `ProductInstanceUri` unset | Sample omission | Populated
on every pump |
| 9 | Mandatory `TrueState`/`FalseState` empty on every supervision
boolean | Absent in the official NodeSet; a server must still populate
them | Populated on every pump |
| 10 | Machinery `Machines` folder empty (OPC 40001-1 §9.2 says
**shall**) | `TryAddToMachinesFolder` used `AddChild`, which
*re-parents* the device away from `DeviceSet` | Add the `Organizes`
reference instead |
| 11 | BrowseName contained a space and `#` | Sample naming | `Pump_1`
with the readable label in `DisplayName` |
| 12 | `Maintenance` group materialised but empty | Sample omission |
Populated |
| 13 | Uninitialised values reported `Good` | No initial-value status |
`BadWaitingForInitialData` until the first simulation tick |
| — | `ServerProfileArray` advertised only `StandardUA2017` |
`DiNodeManager` did not implement the existing `IConformanceContributor`
| Contribute DI 1.05 server facets computed at runtime from what is
actually wired, merged with `StandardUA2017` |
| — | MCP tool serialised boolean `false` and numeric `0` as JSON `null`
| `Variant.Null` is `default`, so `Variant.Equals` adopts the non-null
type and compares against the default value | Dispatch on `BuiltInType`
and use typed `TryGetValue` accessors instead of the prohibited
`AsBoxedObject` |

## Relationship to #4116

#4116 landed on `master` while this branch was in flight and targeted
overlapping ground. The merge is a **union of both intents**, not a
fast-forward:

- **`AlarmBuilderExtensions`** — kept both sides. #4116 contributes the
parent-must-be-an-Object guard, `SetEnableState`, and the
`HasEventSource` references; this branch contributes the `HasCondition`
reference, condition source initialisation, **registration of the alarm
with the node manager so it is browsable**, notifier promotion up the
whole ancestor chain, and root-notifier registration.
- **`SupervisionBuilderExtensions`** — took #4116's `SetAlarmActive`. It
is more spec-correct than this branch's version: it honours
`EnabledState`, resets Acked/Confirmed on activation, and computes
`Retain` per OPC 10000-9 so an unacknowledged Condition stays retained
after going inactive. This branch's null guard was kept so a malformed
alarm cannot dereference a missing `EnabledState`.
- **`PumpNodeManager.Configure.cs`** — took #4116's **push-based**
simulation (`RegisterPumpSimulation`, `CreatePumpSimulation`,
`IValueUpdater<T>`, `Initialize`/`Advance`/`Publish`, deterministic
per-pump phase offsets) as the structure, then folded in this branch's
compliance work: configurable pump count, Maintenance group,
`TrueState`/`FalseState`, historian wiring, per-pump mandatory
Identification values, and `BadWaitingForInitialData` until the first
tick.
- **README** — union, keeping #4116's address-space validation workflow
section.

Two test adjustments were needed, both because the merge legitimately
changed behaviour the tests pinned:

- `PumpHostedReferenceTests` now resolves pump NodeIds from the browse
result instead of hard-coding the DI namespace, and waits for the first
published value. `PumpCreatedAfterStartupJoinsTheLiveSimulationAsync`
was **added** to restore #4116's coverage that a pump created after
startup through `ConfigureDevicesFor` joins the live simulation.
- `FluentAlarmRegistrationIntegrationTests` no longer asserts that
`Retain` clears when an alarm goes inactive — that pinned this branch's
weaker semantics, and #4116's Part 9 behaviour is correct.

## Breaking change

Runtime-created DI device instance NodeIds move from the companion-spec
namespace to the server's application namespace, e.g.
`ns=<DI>;s=5001_Pump #1` becomes `ns=<application>;s=5001_Pump #1`. This
invalidates NodeIds cached by clients from earlier 2.0 previews. It is
**not** a model change — standard DI/Machinery/Pumps type NodeIds and
spec-defined BrowseNames are untouched.

Device integration hosting is new in 2.0, so there is nothing to migrate
from 1.5.378 and this is deliberately *not* in `docs/MigrationGuide.md`.
Anyone tracking 2.0 previews should rediscover devices by browsing
`DeviceSet` rather than persisting NodeIds, and resolve namespace
indexes from `NamespaceArray` per connection.

## Verification

Compliance is pinned by regression tests that assert **through the
server's service surface** (`BrowseAsync` / `ReadAsync` /
`HistoryReadAsync`), not by inspecting `NodeState` objects in memory.
That distinction mattered: three defects survived in-memory assertions
and were only caught through the read path — most subtly, `Historizing`
was cleared while `AccessLevel` still advertised `HistoryRead`, because
attribute read callbacks re-applied the NodeSet bits.

Post-merge on net10.0:

| Suite | Result |
|---|---|
| `Opc.Ua.Di.Tests` | 326 passed |
| `Opc.Ua.Server.Tests` | 3846 passed, 5 skipped |
| `Opc.Ua.Tools.Tests` | 288 passed |
| `Opc.Ua.History.Tests` | 506 passed, 27 skipped |
| `Opc.Ua.SourceGeneration.Core.Tests` | 3722 passed, 8 skipped |

The full solution was also built and tested on **net48** before the
merge.

Review feedback from `copilot-pull-request-reviewer` has been addressed
in commit `badad31dc`: the shared `ServerObjectState` lock was removed
(the forward `HasNotifier` edge is published through the owning node
manager instead), MCP array serialization now preserves element types,
and the pump option guards report the specific rejected option. The only
failure is
`ConfigureApplicationBuildsSharedClientAndServerConfigurationAsync`, a
pre-existing certificate-store issue that **passes when run in
isolation** and is unrelated to this change.

## Documentation

Updated `docs/DeviceIntegration.md`, `docs/MigrationGuide.md`,
`docs/HistoricalAccess.md`, `docs/DeveloperGuide.md`,
`docs/McpServer.md`, `docs/README.md`,
`samples/PumpDeviceIntegrationServer/README.md` and
`tools/Opc.Ua.Mcp/README.md`, and consolidated the four node-manager
documents into a single [`docs/NodeManagers.md`](docs/NodeManagers.md) —
overview, built-in managers (including `MasterNodeManager`), core vs
custom, registration, server address-space metadata, and source
generation — with a TOC and all inbound links repointed.

## Related Issues

No tracking issue exists for this work yet — it originated from an
ad-hoc compliance audit of the pump sample. Given the size of the change
and the runtime-breaking NodeId move, please open (or link) a tracking
issue so the design is recorded as an ADR before merging.

- Fixes #

## Checklist

_Put an `x` in the boxes that apply. You can complete these step by step
after opening the PR._

- [ ] 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.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0d50c3fc-58b1-4c99-85a4-1e32c88666d9
2026-07-30 16:04:18 +02:00

24 KiB

Developer Guide

This guide is the starting point for contributing to the OPC UA .NET Standard stack. It covers what to install, how to build and test, the coding standards ("dos and don'ts"), and task-oriented "how to" recipes (starting with how to add logging). It links out to the topic-specific documents in docs/README.md rather than repeating them.

If you are new here, read the sections in order: Prerequisites → Repository layout → Building → Running tests → Coding standards. The How-to guides and Packages, platform support, and versioning sections are reference material you can jump to as needed.

Prerequisites

  • .NET SDK 10.0 — the whole repository builds and restores with the .NET 10 SDK. Older SDKs are not supported for building main. The class libraries still target older frameworks (see Packages, platform support, and versioning), but you build them with the .NET 10 SDK.
  • An IDE (optional but recommended) — Visual Studio 2026, Visual Studio Code with the C# Dev Kit, or JetBrains Rider. Everything can also be done from the command line with dotnet.
  • git — to clone and to create feature branches.
  • Docker Desktop (optional) — only needed to run the containerized reference server; see ContainerReferenceServer.md.

The C# language version is pinned (LangVersion 14) and analyzer/style rules are enforced by the build, so no extra tooling install is required to get the same diagnostics locally that CI produces.

Repository layout

Path Contents
src/ The core stack and higher-level libraries: Opc.Ua.Types, Opc.Ua.Core*, Opc.Ua.Client, Opc.Ua.Server, Opc.Ua.Configuration, Opc.Ua.PubSub (+ transports), the GDS / DI / LDS / WoT libraries, and the Opc.Ua.Redundancy* family.
samples/ Reference and sample apps: ConsoleReferenceServer, ConsoleReferenceClient, Quickstarts.Servers, the Minimal* / PumpDeviceIntegrationServer NativeAOT samples, Redundant*, etc.
tests/ Unit and integration test projects, mirroring the library structure, plus shared test frameworks.
tools/ Source generators, migration analyzers, and the installable Opc.Ua.Mcp tool.
docs/ This documentation set (indexed by docs/README.md).
fuzzing/ SharpFuzz / libFuzzer fuzz targets (see Fuzzing.md).

Central build configuration lives at the repository root and is imported by every project:

  • UA.slnx — the solution containing all projects.
  • Directory.Build.props / Directory.Build.targets — global MSBuild properties and targets.
  • Directory.Packages.props — Central Package Management: every NuGet version is declared here.
  • common.props / targets.props — shared properties, analyzer settings, and the target-framework matrix.
  • .editorconfig — the authoritative code-style and analyzer-severity rules (enforced at build time).

Building

From the repository root:

dotnet restore UA.slnx
dotnet build UA.slnx

Notes:

  • Warnings are errors. TreatWarningsAsErrors is enabled, so compiler (CSxxxx) and Roslynator (RCSxxxx) diagnostics fail the build. Microsoft Code Analysis (CAxxxx) diagnostics are emitted as non-fatal warnings unless a rule is promoted to error in .editorconfig. Fix all of them before opening a pull request.

  • Building a single target framework. By default the libraries multi-target the whole matrix (see Packages, platform support, and versioning). To restrict a local build to one framework, pass -p:CustomTargetFrameworks, for example:

    dotnet build src/Opc.Ua.Core/Opc.Ua.Core.csproj -f net10.0 -p:CustomTargetFrameworks=net10.0
    
  • Offline / restricted networks. NuGetAudit is enabled and fails the build with NU1900 when it cannot reach the audit service. If you build offline, pass -p:NuGetAudit=false.

Running tests

Run the whole suite from the solution:

dotnet test UA.slnx

Conventions and requirements:

  • Frameworks. Test projects use either NUnit (with Assert.That assertions and Moq for mocking) or TUnit (with its own assertions and mock helpers). Do not mix the two in one project, and do not use the classic NUnit asserts (Assert.AreEqual, …).
  • Coverage. Coverage is measured with Coverlet and must not regress; every non-application, non-test project should stay at or above 80 %.
  • Before a pull request the UA.slnx suite must pass on at least .NET Framework 4.8 and .NET 10.0.
  • Testing a specific target framework. The libraries multi-target, but the test executables run on one framework at a time. To run the suite against a non-default framework, set CustomTestTarget (supported values: netstandard2.0, netstandard2.1, net472, net48, net8.0, net9.0, net10.0). The batch file tests/customtest.bat cleans, restores, and runs the tests for a chosen target; in Visual Studio, uncomment and set the CustomTestTarget property in targets.props. A clean build for the target is recommended when switching.
  • CI matrix. To keep pull-request builds fast, only net48 and net8.0 are exercised in the qualifying CI build; the other frameworks run in scheduled/manual CI. Fix all failing, flaky, and CodeQL findings in the pipelines.

Coding standards (dos and don'ts)

All rules apply to new code and to existing code you touch. The .editorconfig is authoritative and enforced at build time; the highlights below are the ones most often missed.

Formatting and style

  • Add the OPC Foundation MIT license header to every new source file.
  • 4-space indentation, max line length 120, CRLF line endings, UTF-8, final newline, no trailing whitespace.
  • Allman braces; always specify access modifiers explicitly; member order is constructors → properties/events → methods → fields, each public → protected → internal → private.
  • Do not use #region/#endregion or comment-only section dividers. Do not add #nullable enable to a file when the project already sets <Nullable>enable</Nullable>.
  • Put every XML-doc <summary> text on its own line (never a single-line /// <summary> … </summary>).
  • Follow standard C# naming; no underscores in method or test-method names (tests use PascalCase).

API and language

  • Async only. New code uses async/await (TAP). Do not add APM or sync-over-async (.Result, .Wait(), GetAwaiter().GetResult()) unless explicitly requested.
  • No object in public API (except when overriding Equals). For OPC UA values use Variant.
  • INullable types must not be wrapped in System.Nullable<T> (T?); use .IsNull / .Null instead. On struct types prefer TryGet/TryGetValue over casting; never use Variant.AsBoxedValue or IUnion.Value.
  • Prefer ArrayOf<T> over read-only collection types / IReadOnlyList<T> / arrays in new public API; prefer ByteString over byte[]; prefer Span<byte>/ReadOnlySpan<byte> over byte[].
  • Do not use [Obsolete] API (outside test code) and do not add API that is not NativeAOT-compatible.
  • Maintain backward compatibility with 1.5.378; mark replaced API [Obsolete] rather than removing it.

Concurrency

  • Never expose locks in any API surface. For a synchronous lock use System.Threading.Lock (a polyfill is provided for older TFMs) — never private readonly object m_lock = new(). Prefer SemaphoreSlim where async coordination is needed.

Architecture

  • Make non-abstract public classes sealed by default; prefer a provider model with injectable providers over inheritance.
  • Wire new functionality into the dependency-injection infrastructure (with a direct "construct it yourself" fallback) and expose it through the fluent API where possible.
  • Reuse the existing base services (telemetry, file system, certificate/secret stores, state machines, sessions, source generators, …) instead of re-implementing them.

Security

  • Never hardcode credentials, certificates, or secrets. Manage certificates through the certificate store system and secrets through the secret store (see CertificateManager.md and Certificates.md).
  • Use only SHA-2 or stronger hash algorithms; use the audit and redaction APIs for sensitive data.

Logging — use source-generated logging; never call ILogger.LogInformation/LogError/… directly. See Add a log message (source-generated).

How-to guides

Add a log message (source-generated)

The stack uses LoggerMessageAttribute source-generated logging everywhere. It avoids boxing value-type arguments, caches the message formatter, and emits an IsEnabled check so a disabled level costs nothing. Direct ILogger.LogInformation/LogError/… calls are not allowed. The runtime/observability side (how the ILogger is created from ITelemetryContext) is documented in Diagnostics.md; this section is the authoring recipe.

Recipe

  1. Get a logger. Obtain an ILogger from the ambient ITelemetryContext (telemetry.CreateLogger<T>()); most types already hold one in an m_logger field.
  2. Find or create the log class. Each file that logs has, at its end, an internal static partial class <PrimaryClass>Log holding [LoggerMessage] extension methods on ILogger. Add your message there. If several closely-related files emit the same messages, use one shared <Area>Log class instead of duplicating (for example the encoders/decoders in Opc.Ua.Types share EncodingLog).
  3. Reserve an event id. Each project has one internal static class <AssemblyToken>EventIds at its root (see Event-id convention). Use the existing per-class offset.
  4. Declare the message. Add a partial method with [LoggerMessage(EventId = <AssemblyToken>EventIds.<Class> + <index>, Level = LogLevel.<Level>, Message = "…")] (see Log class convention).
  5. Call it. Replace the old logger.LogXxx(...) call with logger.<MethodName>(args).

Event-id convention

Each project owns exactly one event-id class, named <AssemblyToken>EventIds, in namespace Opc.Ua, in a file EventIds.cs at the project root. <AssemblyToken> is the assembly name with the Opc.Ua. prefix removed and dots dropped — for example Opc.Ua.Core → CoreEventIds, Opc.Ua.Core.Types → CoreTypesEventIds, Opc.Ua.Client → ClientEventIds.

The token prefix is required because the stack uses InternalsVisibleTo: two internal classes with the same name in the same namespace collide across an IVT boundary (CS0436). The class holds one public const int offset per log class. Offsets are assigned in class-alphabetical order starting at 0; each block reserves at least five spare slots for future messages and is then rounded up to the next multiple of ten, so ids stay documented and managed in one place. Every log method sets EventId = <AssemblyToken>EventIds.<Class> + <zero-based message index within that class>.

Narrow exception: retained EventSource-compatibility ids

The four legacy System.Diagnostics.Tracing.EventSource providers (OPC-UA-Core, OPC-UA-Client, OPC-UA-Server, Opc.Ua.ChannelManager) were removed and replaced with [LoggerMessage] equivalents. Their compatibility log methods are a deliberate, narrow exception to the convention above: each keeps the exact numeric id, event name, level, message template, and structured fields the corresponding ETW event had, so consumers can preserve event identity when they migrate from ETW to ILogger. Concretely:

  • The compatibility log class uses the old provider name as its ILogger category (e.g. "OPC-UA-Core", "Opc.Ua.ChannelManager") instead of the typed, per-class category used elsewhere in the project.
  • EventId resolves to the literal legacy numeric id (e.g. 10 for OPC-UA-Core's former ServiceCallStart) rather than a normal per-class offset. Keep these values in the affected project's EventIds.cs; compatibility ids are scoped to their own logger category, so they may intentionally overlap ordinary per-assembly values.
  • Every compatibility method sets EventName explicitly ([LoggerMessage(EventId = 10, EventName = "ServiceCallStart", Level = LogLevel.Trace, Message = "...")]) so EventId.Name matches the original ETW event name exactly.
  • ETW-only metadata (provider GUID, Task, Keywords, manifest) is not retained because there is no ILogger equivalent.
  • Do not use this pattern for new log messages. It exists only to preserve the event identities that previously shipped through the four EventSource providers; see Diagnostics.md and migrate/2.0.x/telemetry.md for the full removal/compatibility mapping.

Log class convention

  • One log class per file, named <PrimaryClass>Log, internal static partial, appended at the end of the file inside the same namespace.
  • Methods are extension methods on ILogger (public static partial void <Name>(this ILogger logger, …)) so call sites read naturally as logger.<Name>(…).
  • Identical this ILogger overloads (same name and parameter types) declared in more than one class of the same namespace collide (CS0121) — deduplicate them into a single shared <Area>Log class. Overloads that differ by name or by parameter type are fine.

Message, level, and parameter rules

  • Message text is exact and static; use named placeholders ({ChannelId}) that match a parameter of the same name. Never interpolate ($"…"). An Exception argument is detected by its type and does not need a placeholder.
  • Parameter types must match the real argument type. Do not use object/object?, and do not call .ToString() on an argument (type the parameter instead, e.g. an enum or int); an unnecessary .ToString() trips RCS1097/CA1305. Declare a parameter nullable (string?, Uri?, Exception?) only when the argument can actually be null, otherwise the compiler reports CS8604.
  • Guard only expensive arguments. If a call passes an expensive computed argument (string.Join(...), a LINQ projection, .ToString() on a complex object) wrap it in if (logger.IsEnabled(<level>)); source generation does not suppress eager evaluation of the arguments, and CA1873 flags it. Do not guard cheap arguments (locals, fields, ids) — over-guarding trips RCS1006/RCS1061. A guard must never gate an expression that has an observable side effect.
  • Dynamic levels stay hand-written. [LoggerMessage] needs a compile-time Level. A call whose level is only known at runtime keeps the structured logger.Log(logLevel, "{Template}", args) form wrapped in if (logger.IsEnabled(logLevel)). These are the only remaining direct ILogger.Log calls.
  • Shared/linked source files that are <Compile Include>-d into more than one project (for example a sample file linked into a test project) cannot reference another assembly's <AssemblyToken>EventIds class — give their log class literal EventId integers in a high, dedicated range instead.
  • Duplicate generator on netstandard. A project that also references an R9 package (Microsoft.Extensions.Http.Resilience, .Compliance, .Telemetry, …) gets the Microsoft.Gen.Logging generator in addition to the in-box one; on netstandard both implement every partial method (CS0757). The repo's Directory.Build.targets removes the R9 analyzer on netstandard only — no per-project action is needed.

Worked example

// EventIds.cs (project root) — the assembly-token prefix avoids CS0436 across
// InternalsVisibleTo boundaries.
namespace Opc.Ua
{
    internal static class TypesEventIds
    {
        public const int Encoding = 20;   // shared codec block (reserves 20)
        public const int Matrix = 50;     // per-file block (reserves 10)
    }
}

// end of Matrix.cs
internal static partial class MatrixLog
{
    [LoggerMessage(EventId = TypesEventIds.Matrix + 0, Level = LogLevel.Debug,
        Message = "ReadArray read dimensions[{Index}] = {Dimensions}. Matrix will have 0 elements.")]
    public static partial void ReadArrayZeroDimension(this ILogger logger, int index, int[] dimensions);
}

// call site
logger.ReadArrayZeroDimension(index, dimensions);

Checklist

  • Message text and level are unchanged from the original call (behavior-preserving).
  • Placeholders are named and match parameter names; no interpolation.
  • Parameter types match the arguments; nullable only where needed; no object.
  • Expensive arguments are guarded with IsEnabled; cheap ones are not.
  • EventId uses the project's <AssemblyToken>EventIds offset (or a literal range for a shared/linked file, or a literal legacy id with an explicit EventName for a retained EventSource-compatibility message — see the narrow exception).
  • When testing with a mocked ILogger, stub IsEnabled(...) => true and match on EventId.Name, not the (empty) source-generated state ToString().

Other common tasks

  • Add a new feature — implement it in the right library, add unit and (for client/server/pubsub) integration tests, update or add a doc under docs/, and keep backward compatibility (see Coding standards).
  • Add a document — put it in docs/ and link it from docs/README.md.
  • Add a dependency — declare the version in Directory.Packages.props (Central Package Management), prefer AOT/trimmable and permissively licensed packages, and get maintainer approval first.
  • Certificates and secrets — see Certificates.md and CertificateManager.md.
  • Source-generated node managers / data types — see NodeManagers.md and SourceGeneratedDataTypes.md.
  • Server namespace metadata / history advertisement — see NodeManagers.md.
  • Dependency injection — see DependencyInjection.md.
  • NativeAOT — see NativeAoT.md.

Packages, platform support, and versioning

Released packages

The following NuGet packages are released on a monthly cadence (with hot fixes for security issues). The OPCFoundation prefix is reserved, and the assemblies and packages are signed by the OPC Foundation.

For improved source-level debugging, symbol packages are published on nuget.org in snupkg format, and Debug-compiled packages are available with a .Debug suffix. In addition, every successful master build publishes preview packages to the Azure DevOps preview feed.

Supported target frameworks

The class libraries currently target:

  1. .NET Standard 2.0 (Opc.Ua.Types only)
  2. .NET Standard 2.1
  3. .NET Framework 4.7.2 (limited support)
  4. .NET Framework 4.8
  5. .NET 8.0
  6. .NET 9.0
  7. .NET 10.0

To keep pull-request CI fast, only (4) and (6) are part of the qualifying build; the other platforms are covered by scheduled or manual CI. See Running tests for how to build and test a specific framework locally with CustomTestTarget / tests/customtest.bat.

Versioning

From 2.0 onward, package versions are produced by Nerdbank.GitVersioning (nbgv) from the version.json file at the repository root. That file holds the base version (currently 2.0-preview) and requests SemVer 2.0 package versions (nugetPackageVersion.semVer: 2); nbgv derives the version height, prerelease tag, and build metadata from the git history, and version.props maps the computed values onto the assembly and package version properties. Stable (public-release) versions are produced only on the main, master, develop/*, and release/<x.y.z> branches — every other branch yields a prerelease build.

The earlier 1.x packages used a different, spec-derived scheme in which the first two digits encoded the embedded NodeSet spec version (for example 1.5.378.x corresponds to OPC UA spec V1.05, mapped to release branches such as release/1.4.372). That scheme no longer applies from 2.0 onward.

Contributing and pull requests

  • Fork the repository (or, if you have write access, push a branch prefixed with your username) and open a pull request. You must agree to the Contributor License Agreement; the "I AGREE" prompt appears automatically on your first PR. See CONTRIBUTING.md.
  • Before submitting: all tests pass, code analysis is clean (no new warnings), the change keeps backward compatibility, and security implications are reviewed.
  • The pull-request template asks you to confirm the CLA, added tests/coverage, documentation, a warning-free build, that the UA.slnx suite passed on .NET Framework 4.8 and .NET 10.0, and that CI and CodeQL are green.
  • You can run the opc-ua-codestyle-enforcer agent to drive analyzer warnings to zero before opening the PR.