173 Commits

Author SHA1 Message Date
Roman Ettlinger 6812621abd Exempt unremovable preview.g<commit> versions from the preview ordering gate
Two master NuGet Publish runs on 2026-10-06 (37461428995, 37462705289)
pushed 2.0.0-preview.gfd360271d9 and 2.0.0-preview.ge78c648295 to GitHub
Packages while master briefly carried the stable root version 2.0.0.
SemVer 2 ranks the alphanumeric "g<commit>" identifier above every numeric
one, so no PreviewPackageBuildNumber can outrank them and the stable 2.0.0
candidate on release/2.0 fails validate-preview-package-ordering.ps1.

Add an explicit PreviewPackageOrderingExemptions list to
preview-version.props that the gate skips by exact (case-insensitive)
match; everything else still fails closed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 06:42:07 +02:00
romanett fa06c66dbb Stop versioning DI and ISA95 packages as preview (#4686)
## Summary
Removes the **DI** (`Opc.Ua.Di*`) and **ISA95** (`Opc.Ua.ISA95*`)
package families from the preview-only set, so they ship with the stable
root version (e.g. `2.0.1` instead of `2.0.1-preview.N`).

- `version.targets`: drop the two `_IsPreviewPackage` conditions.
- `.azurepipelines/package-version-policy.ps1`: drop `ISA95|Di` from
`Test-PreviewPackageId`. It must match `version.targets`, because the
package-set validator fails if the two disagree.
- `PackageVersionPolicyTests`: ISA95/Di IDs are now expected to be
non-preview, they are removed from the classifier-agreement token list,
and the "already-preview base version" case now uses Robotics.
- Docs (`DeveloperGuide.md`, `ReleaseProcess.md`, `docs/README.md`): the
preview-family list no longer includes DI or ISA95, and the "preview
package" label is removed from the DI, Software Update (part of the DI
packages), and ISA-95 entries.

## Test
`dotnet test tests/Opc.Ua.Tools.Tests --filter PackageVersionPolicy`
(net10.0): 87/87 passed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:37:20 +00:00
Copilot c03c128384 Treat code analysis (CA) warnings as errors and fix all surfaced analyzer warnings (#4680)
# Description

`TreatWarningsAsErrors` was already enabled, but
`CodeAnalysisTreatWarningsAsErrors` was `false`, so Microsoft Code
Analysis (`CAxxxx`) warnings were non-fatal. This PR sets it to `true`
in `common.props` and fixes all 55 analyzer warnings the net10.0 build
reported.

**Production code**
- `SamplingGroup.cs`: CA2213 is a false positive here because the
detached `CancellationTokenSource` is disposed by `CancelSamplingLoop`
once the loop completes. It is suppressed with a pragma, a justification
and a TODO, following the existing pattern in
`ServerBase.RequestQueue.cs`.

**Tests**
- CA1861: constant array arguments are moved to `static readonly` fields
or replaced with `ArrayOf<T>` collection expressions. In the legacy
interop server I used locals instead, because those arrays become node
values that writes can change in place.
- CA2025: tasks that finish before disposal (`Assert.ThrowsAsync`, or
awaited before `using` scope exit) are suppressed with justification
comments, following existing test patterns.
- CA1812: classes created by the DI container are suppressed with a
comment and a TODO.
- CA2208: benchmark switch defaults now throw
`InvalidOperationException`.
- CA1845: `Substring(0, 8)` is replaced with `[..8]`.
- CA1307: `string.Replace` is replaced with `Split`/`Join`, which also
works on net48.
- CA5394: `Random` is replaced with `RandomNumberGenerator`.

**Docs**
- `docs/DeveloperGuide.md` now says CA warnings also fail the build, and
that any suppression needs a comment explaining why plus a TODO.

**Validation**
- I fixed the 55 warnings from a net10.0 build of `UA.slnx`
(`CustomTestTarget=net10.0`). That build ran before the setting was
turned on, and I have not rebuilt net10.0 since the fixes.
- `CustomTestTarget=net48` build of `UA.slnx`: 0 warnings, 0 errors.
- I also started a full build across all target frameworks with the
setting on. Its log showed no errors or warnings while it ran, but I
couldn't read its final result.
- No tests have been run yet; CI should confirm the multi-TFM build and
the tests.

## Related Issues

- N/A

## Checklist

- [ ] 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.
- [ ] 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.
- [ ] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] 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-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: romanett <7413710+romanett@users.noreply.github.com>
Co-authored-by: Marc Schier <marcschier@users.noreply.github.com>
2026-10-06 18:54:28 +02:00
romanett 21e04bdba5 Make Bad_CertificateChainIncomplete unsuppressible; add UpdateCertificate renewal regression tests (#4683)
# Description

Follow-up to the master378 fix in #4678 (CTT: application certificate
renewal must use the configured trust stores). Master already ignores
the supplied `issuerCertificates` for the default application group (OPC
10000-12 §7.10.5), but it had no regression tests for CA-signed
renewals. While adding them I found a real gap:

**The validator treated `Bad_CertificateChainIncomplete` as
suppressible.** OPC 10000-4 Table 100 (Build Certificate Chain) says:
"An error during the chain creation may not be suppressed." §6.1.3 adds:
"Processing fails with Bad_SecurityChecksFailed if an element in the
chain cannot be found." UpdateCertificate for the application group
accepts every *suppressible* error (as §7.10.5 requires). As a result it
accepted a renewal signed by a CA that is **not** in the TrustList and
was only passed in the ignored issuer list, which is exactly what
§7.10.5 rules out.

## Changes
- `CertificateValidationCore`: remove `BadCertificateChainIncomplete`
from the suppressible status codes. An accept callback (`AcceptError`)
is no longer asked about it.
- `TrustList`: remove the local workaround that filtered out this error;
plain "accept suppressible errors" is now enough.
- Docs (`Certificates.md`, `CertificateManager.md`): drop it from the
suppressible list, and fix a sample that suppressed it.

## Tests
- `ConfigurationNodeManagerPushTests`:
- A CA-signed renewal with the CA installed succeeds with a missing,
empty, unrelated or malformed issuer list, and the trust stores stay
unchanged.
- A renewal whose CA is only supplied as an issuer is rejected with
`BadCertificateChainIncomplete`; stores and the current certificate are
untouched.
- End to end on an isolated server: a CA-signed renewal with no issuer
list is committed through ApplyChanges.
- `ConfigurationNodeManagerPushValidationTests`: the group TrustList
validator accepts a CA from the trusted or issuer store and rejects an
uninstalled CA.
- `CertificateValidationCoreTests` / `CertificateValidatorTest`:
- An incomplete chain is unsuppressible and never reaches the callback.
- A leaf trusted directly **stays valid when the peer supplies its CA**
(§6.1.3: chain certificates "may be provided with the application
Certificate").
- One existing test attached its approver only after validating, so it
never exercised suppression; it now validates with the approver
attached.
- Tests that only passed because of the bug
(`PushCertificateRequestRegressionTests`, the regenerated-key helper and
the rollback test) now install the signing CA in the TrustList first.

The two new rejection tests fail on master and pass with this change.

## Reviewer notes / behavior change
This was suppressible in 1.5.x as well. Application code whose accept
handler approved a CA-signed peer certificate without its CA (CA neither
installed nor sent by the peer) will now get a rejection. Without a
handler such certificates were already rejected, and the stack's own
handlers (`ApplicationInstance`, push validation) never accepted this
error. Worth a release note.

Local runs (net10.0), all passing:

| Suite | Passed | Failed |
|---|---|---|
| Server.Tests | 8310 | 0 |
| Core.Tests (security/transport filter) | 2039 | 0 |
| Security.Certificates.Tests | 358 | 0 |
| Core.Security.Tests | 558 | 0 |
| Client.Tests | 2761 | 0 |
| Gds.Tests (PushTest and TrustList tests) | 345 | 0 |

The net48 builds of Server.Tests and Core.Tests also succeed.

## Related Issues

- Related to #4678 (master378 counterpart)

## 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.
- [ ] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] 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.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 18:14:17 +02:00
Copilot e78c648295 Images CI: publish samples once natively and stop compiling under QEMU (#4679)
# Description

Images CI took 45–100 min per image. The arm64 half of every
`linux/amd64,linux/arm64` build ran `dotnet restore` and `dotnet
publish` in an arm64 SDK container under QEMU. For `refserver`, publish
took 8 min natively for amd64 and 92 min emulated for arm64. On top of
that, every one of the 10 matrix legs recompiled the whole stack.

The output is framework-dependent portable IL, so the compile is now
done once, natively. The image legs only copy files onto the
per-platform runtime base image.

### Dockerfiles (all 10)
- **SDK stage runs on the build platform.** The `build` stage is pinned
to `--platform=$BUILDPLATFORM`. `-p:UseAppHost=false` keeps an x64 app
host out of the arm64 image.
- **New `prebuilt` stage.** It is chosen with a global `ARG
PUBLISH_SOURCE`, which defaults to `build`. Local `docker build` and the
`docker-compose` files are unchanged.
  ```dockerfile
  FROM scratch AS prebuilt
  ARG PUBLISH_DIR=artifacts/docker/publish
  COPY ${PUBLISH_DIR}/ /app/publish/

  FROM ${PUBLISH_SOURCE} AS publish
  ```
- **Pump: final-stage `RUN`s moved to a native stage.** The `sed` on
`openssl.cnf` and the `mkdir`/`chown`/`chmod` steps now run in a
`$BUILDPLATFORM` `layout` stage. The final stage copies `/layout/` to
`/`, so `/app` and `/diag` keep the `$APP_UID:0` owner and `g=u` mode
they had before. `openssl.cnf` therefore comes from the build-platform
base image. It is a plain config file, so it is expected to match
between architectures.

### `docker-image.yml`
- **Single image list.** The image list lives once, in `env.IMAGES`
(image, project, Dockerfile). `discover` emits it as the matrix.
- **New `publish-samples` job.** It runs `dotnet publish` for each
sample sequentially, so shared libraries compile once. It uses the same
options and NBGV version properties as the Dockerfiles and uploads the
output as the `docker-publish` artifact.
- **`build-and-push-image` legs.** Each leg:
  - downloads the artifact;
- builds with `PUBLISH_SOURCE=prebuilt` and
`PUBLISH_DIR=artifacts/docker/publish/<image>`;
  - no longer uses `setup-qemu-action`;
  - is named `build-and-push-image (<image>)`.
- **`images summary`.** It now also fails when `publish-samples` fails.

### Docs
- `ContainerReferenceServer.md` describes the publish-once / copy-only
flow and the build arguments.
- `DeveloperGuide.md` mentions the `publish-samples` job in the Images
CI section.

Expected: one native publish of about 7 min, then 10 parallel
copy-and-push legs of a few minutes each. Not yet confirmed on CI: the
sandbox could not pull MCR base images, so the prebuilt multi-platform
builds were checked against a Debian-based stand-in base, and the
build-from-source path was not built.

## Related Issues

## Checklist

- [ ] 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.
- [ ] 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.
- [ ] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] 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-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: romanett <7413710+romanett@users.noreply.github.com>
2026-10-06 14:21:44 +02:00
Klaus Landsdorf cc7908eaa1 WebApi: serve a generated OpenAPI document of the REST binding (opt-in) and map the session-less service set (#4657)
Reworked after the review: the OpenAPI document is generated from the
code, not embedded; the route is opt-in; `StatusCode` is not accepted.
The branch is a new history on top of master (the first version
conflicted); master with #4674 is merged in.

# Description

The REST binding implements the routes of the OPC Foundation OpenAPI
mapping (OPC 10000-6 §G.3), but it does not describe them: `GET
/openapi.json` is answered with HTTP 405, and a client generator has
nothing to read from the server. `docs/WebApi.md` listed a generated
OpenAPI document as a deferred follow-up.

The coded API is the source of truth, so this PR **generates** the
document from the route table and from the structure definitions of the
request and response types, and compares it with the document the model
compiler publishes in tests. Nothing from `UA-Nodeset/OpenApi` ships in
the package.

## Changes

### Service sets (`Opc.Ua.Core`)

- `WebApiServiceSet` (`AllServices`, `Sessionless`) and
`WebApiServiceRoutes.GetRoutes(serviceSet)`. `Sessionless` is the eight
services of `opc.ua.openapi.sessionless.json` (OPC 10000-4 §6.3): Read,
Write, HistoryRead, HistoryUpdate, Call, Browse, BrowseNext,
TranslateBrowsePathsToNodeIds.

### Generator (`Opc.Ua.Core.Schema`, `Opc.Ua.Schema.OpenApi`)

- `WebApiOpenApiGenerator.Generate(serviceSet, includeSchemas,
serverUrl)` returns the OpenAPI 3.0 document as a `JsonObject`. It is
built at runtime from `WebApiServiceRoutes` and the
`StructureDefinition`/`EnumDefinition` of the message types (the same
definitions the schema generation uses, resolved through
`IDataTypeDefinitionResolver`). No reflection, so it is NativeAOT
compatible; the AOT test project covers it.
- **Two levels of detail**, because full schemas make big documents:

  | | Content | All 28 services |
  | --- | --- | --- |
| default | paths, `operationId`s, every body a JSON object | about 12
KB |
| `includeSchemas: true` | one component schema per message, structure
and enumeration (100), for typed client generators | about 46 KB |

  The two published files are about 300 KB together.
- The schemas describe the Compact JSON encoding, with the property
names, types, formats and integer ranges of the published documents.
DataValue is the one structure that differs: its status property is
`Status`, as in Part 6 Table 42.
- `OpenApiBuiltInSchemas` holds the schemas of the built-in types
(StatusCode, LocalizedText, Variant, DataValue, DiagnosticInfo,
ExtensionObject and the scalar types) as the encoder writes them.
Abstract `Structure` fields are ExtensionObjects, abstract number fields
are Variants.

### Serving the document (`Opc.Ua.Bindings.Https`)

- `WebApiTransportOptions`: `ServiceSet`, `OpenApiDocumentPath` (default
`null`: **off**) and `OpenApiIncludeSchemas` (default `false`).
- `MapWebApiEndpoints(WebApiTransportOptions)` maps the routes of the
service set and, when a path is set, the `GET` route of the document
**in the same route group**. The route has no `AllowAnonymous` metadata.
When an authentication scheme is registered, the contributor's
`group.RequireAuthorization()` covers the document like `POST /read`;
only `/findservers` and `/getendpoints` stay anonymous. The existing
`MapWebApiEndpoints()` maps all routes as before.
- The document is generated once per path base (listed as a relative
`servers` URL; without a path base the list is omitted) by the
`WebApiOpenApiGenerator` that `AddWebApiTransport` registers, or by a
default one.
- `Opc.Ua.Bindings.Https` references `Opc.Ua.Core.Schema` on net8.0 and
later.

### Documentation

- `docs/OpenApi.md` (new, linked from `docs/README.md`): serving the
document, the two levels, generating clients, the JSON generated clients
send, known issues, how the document is kept correct, troubleshooting.
- `docs/WebApi.md`: new section "Service sets and the OpenAPI document";
the deferred follow-up is removed. `docs/SchemaGeneration.md` and both
`NugetREADME.md` files describe the generator and the options.

## Comparison with the model compiler documents

The two published documents are test resources of
`Opc.Ua.Core.Schema.Tests` only (README next to them: source commit and
update procedure). `WebApiOpenApiConformanceTests` compares the
generated document with them for both service sets:

- paths, `operationId`s, and the request and response schema of every
operation;
- the set of component schemas the operations reach: equal (100 for all
services);
- every enumeration (members and values);
- for every structure: property names, types, formats, integer ranges,
and inheritance.

Everything both describe is equal. The test lists the differences and
fails when a listed one disappears or a new one appears:

| Difference | Generated | Published | Reason |
| --- | --- | --- | --- |
| DataValue status property | `Status` | `StatusCode` | Part 6 Table 42;
UA-Nodeset#146 is open. The stack only knows `Status` (encoder and
decoder). |
| `description`, `default` keywords | not written | spec links, .NET
defaults | documentation and defaults, not contract; Compact omits
default values |

`WebApiOpenApiWireFormatTests` validates what the binding really writes
(the default request and response of all 28 routes, plus populated
requests and responses with variants, status codes, references, identity
tokens and notifications) against the generated component schemas.

## Tests

- `Opc.Ua.Core.Schema.Tests` `OpenApi/`: `WebApiOpenApiGeneratorTests`
(shape of the document, both levels, both service sets, servers list,
sorting and determinism, concurrency, argument and resolver errors,
every built-in type, arrays and ranks, enumerations, option sets,
unions, base types, self-references, unknown definitions),
`WebApiOpenApiConformanceTests`, `WebApiOpenApiWireFormatTests`,
`OpenApiBuiltInSchemasTests`: 81 tests.
- `Opc.Ua.Bindings.Https.WebApi.Tests`:
`WebApiOpenApiDocumentEndpointTests` (TestServer with the startup
contributor: off by default, configured path, both levels, service set
maps and describes only the sessionless routes, path base, repeated
requests, registered generator is used, **a request without credentials
gets 401 when an authentication scheme is registered while one with
credentials gets 200, the route carries `IAuthorizeData` and no
`IAllowAnonymous`**, argument checks), options defaults and DI
registration of the generator.
- `Opc.Ua.Core.Tests`: `GetRoutes` for both sets and an undefined value.
- `Opc.Ua.Aot.Tests`: the generator and a served document under the AOT
fixture.
- Results (macOS arm64): `Opc.Ua.Core.Schema.Tests` 260/260,
`Opc.Ua.Bindings.Https.WebApi.Tests` 457/457, `Opc.Ua.Core.Tests` WebApi
tests 110/110, each on net10.0 and net8.0 (Release). `Opc.Ua.Aot.Tests`
(JIT run) SchemaAotTests 5/5, WebApiAotTests 8/8.
- `Opc.Ua.Core`, `Opc.Ua.Core.Schema` and `Opc.Ua.Bindings.Https` and
the three test projects build for net48, net8.0, net9.0 and net10.0
(Release) with 0 warnings and 0 errors (net48 builds only; the Windows
legs of the fork CI run its tests). `dotnet format` whitespace, style
and analyzers (`--severity info`) are clean on every changed file; the
two `RCS1090` hits in `SchemaAotTests.cs` are on lines that master
already has.
- Patch coverage with `.azurepipelines/check-coverage.ps1` against
master (`Opc.Ua.Core.Schema.Tests`, `Opc.Ua.Bindings.Https.WebApi.Tests`
and the WebApi tests of `Opc.Ua.Core.Tests`, net10.0): 362/362 changed
lines covered.
- Fork CI (full matrix incl. macOS) and CodeQL on `f990af5db` (master
with #4674 merged in): [CI run
37310622902](https://github.com/biancode/UA-.NETStandard/actions/runs/37310622902)
green, [CodeQL run
37310626786](https://github.com/biancode/UA-.NETStandard/actions/runs/37310626786)
green. The run on the earlier head `abda0e491` had one failure outside
this PR:
`McpServerProcessTests.HttpTransportAliasesExposeOnlyMcpRouteAsync("http")`
timed out after 30 s on Windows net10.0; this PR does not touch
`tools/`.

## Not in this PR

- Authentication: the fix discussed in the review is @romanett's #4674.
The document route uses whatever the REST routes use; this PR does not
change authentication.
- `StatusCode` tolerance in the decoder: dropped, the spec text is the
reference. The first commit of the previous version is gone.
- Samples.
- Session-less invocation on the server side (OPC 10000-4 §6.3) is a
separate draft PR. With `ServiceSet = Sessionless` this PR only limits
the mapped routes and the document.

## Questions

1. `Opc.Ua.Bindings.Https` now references `Opc.Ua.Core.Schema` (net8.0
and later) because the generator lives next to the other schema
generators and is trimmed away when unused. Fine, or should it move into
`Opc.Ua.Core`?
2. Should `OpenApiIncludeSchemas` default to `false` as here, or should
a host get typed clients out of the box?

## Related Issues

- Fixes #4656

## 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.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed. (Affected
suites on net10.0 and net8.0 locally on macOS; .NET Framework is covered
by the fork CI's Windows net48 legs.)
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.
2026-10-06 09:43:28 +02:00
Klaus Landsdorf b0d8ca0d3a Server: session-less service invocation (OPC 10000-4 §6.3) with AddSessionlessInvocation (#4659)
Marc answered the five design questions (2026-10-05). This update
implements the answers; see "Design decisions" at the end.

# Description

OPC 10000-4 §6.3 lets a client invoke a subset of the services without
CreateSession/ActivateSession. The request then carries either an Access
Token in `RequestHeader.authenticationToken` or no token at all. The
server today only offers the low-level
`ISessionManager.ValidateSessionLessRequest` event. Without a handler,
every request whose token names no session is answered with
`Bad_SessionIdInvalid`, including a request without a token. That is the
code a client of an expired session expects; for a session-less request,
§6.3.1 has a server that does not support the invocation answer
`Bad_ServiceUnsupported`.

This PR adds built-in session-less invocation behind an opt-in and keeps
the existing event as the way to take over completely.

## Changes

### `AddSessionlessInvocation()`
(`src/Opc.Ua.Server/Hosting/OpcUaServerBuilderExtensions.cs`)

```csharp
services.AddOpcUa()
    .AddServer(options => { /* … */ })
    .AddSessionlessInvocation(o => o.AllowAnonymous = false)  // the default
    .AddJwtIssuer(/* … */);   // validates the Access Tokens
```

- Registers `SessionlessInvocationOptions`.
`DependencyInjectionStandardServer` applies them to the `SessionManager`
in use, including one created by a registered session manager factory,
unless that manager already has options.
- Without dependency injection: `SessionManager.SessionlessInvocation =
new SessionlessInvocationOptions()`.
- Without either, nothing changes apart from the
`Bad_ServiceUnsupported` answer below.

### `SessionlessInvocationOptions` (new, `src/Opc.Ua.Server/Session/`)

- `AcceptAccessTokens` (default `true`): an Access Token in
`RequestHeader.authenticationToken`, carried as a String NodeId, is
handed to the server's identity registry (`IServerIdentityRegistry`, the
one ActivateSession uses) as an issued JWT token (OPC 10000-6
§7.6.5.2.3). The resulting identity runs the request. §6.3.1 requires an
encrypted channel for the token, so it is only accepted over
SignAndEncrypt or HTTPS.
- `AllowAnonymous` (default `false`): a request without a token runs as
an anonymous user. Without it, such a request is rejected with
`Bad_IdentityTokenInvalid`.
- `MaxConcurrentRequests` (default 64) and
`MaxConcurrentRequestsPerChannel` (default 16): the budget for
session-less requests, see below. Zero does not limit; a negative value
is rejected.

### Budget for session-less requests (`SessionlessRequestBudget`, new,
internal)

#4541 bounds channels and incomplete messages. A session-less request
has no Session to be accounted for, but it occupies a worker and keeps
its channel active. It gets a budget of its own:

- **Two limits**, checked and taken together under one
`System.Threading.Lock`: concurrent requests on the server, and on one
channel. A request refused by the channel limit takes no server-wide
place. The channel is the SecureChannel on opc.tcp. The HTTPS bindings
use one SecureChannel id per listener, so their requests are grouped by
peer address (IPv4-mapped IPv6 addresses count as IPv4).
- **Answer**: `Bad_ServerTooBusy` ("The Server does not have the
resources to process the request at this time."), the code #4541's
request queue uses when it has no room. `Bad_TooManyOperations` means
too many operations in one request, so it does not fit.
- **Lifetime**: the place is taken before the identity is checked, which
also bounds the validations that run at the same time. The lease is
attached to the `OperationContext` and returned when the context is
disposed, which every service does with `using`, however the request
ends (completed, failed, cancelled). A rejected token, a handler error
or exception, and a cancelled validation return it at once. A lease
releases once, however often it is disposed. Channel entries are removed
with their last request.
- **Scope**: it applies to every session-less request on a manager that
has `SessionlessInvocation` set, including requests a
`ValidateSessionLessRequest` handler decides. A handler without
`AddSessionlessInvocation` runs without a limit, as before. Requests
with a Session token and services outside the §6.3.1 set are not
counted.
- The limits are read for each request, so a change of the options
applies to the next request.

### `SessionManager`

- **Service sets:** a request without a session is limited to the
service sets §6.3.1 allows: View (without
RegisterNodes/UnregisterNodes), Attribute, Method, NodeManagement and
Query. Every other service keeps answering `Bad_SessionIdInvalid`.
`IsSessionlessService(RequestType)` is public.
- **Request without a token:** with session-less invocation not enabled
and no `ValidateSessionLessRequest` handler, it is answered with
`Bad_ServiceUnsupported` (§6.3.1). A token that names no session stays
`Bad_SessionIdInvalid`.
- **Precedence:** a registered `ValidateSessionLessRequest` handler
still decides first, unchanged apart from the service-set limit.
`ValidateSessionlessRequestAsync` is `protected virtual` for servers
that need their own rules.
- **Roles:** the identity of a session-less request goes through the
role resolution ActivateSession applies to the effective identity of a
Session (the `ResolveRoles` part of `AddMandatoryRoles`: the
`MustChangePassword` restriction, `IRoleManager.ResolveGrantedRoles`, so
mapped Access Token claims and the built-in AuthenticatedUser role).
This includes an identity a `ValidateSessionLessRequest` handler sets.
There is no client application certificate without a Session, so no
TrustedApplication role and no application-based mapping.
- **Cancellation:** cancelling an Access Token validation propagates as
`OperationCanceledException` so the endpoint maps it to the status of
the request lifetime (previously it became `Bad_UnexpectedError`). The
budget place is still returned.
- **Log:** a rejected Access Token is logged with a dedicated
`[LoggerMessage]` (`ServerEventIds.SessionManager + 18`, the next free
id after #4632).

### Documentation

- `docs/SessionlessInvocation.md` (new, linked from `docs/README.md`):
which services, enabling it, caller identity, the answers without the
feature, the request limits, the handler, calling the server over
opc.tcp and the REST binding.
- `docs/ResourceIsolation.md`: a short section "Requests without a
Session" pointing to the budget.
- `docs/DependencyInjection.md`: `AddSessionlessInvocation` in the
identity extension tables.

## Tests

- `Opc.Ua.Server.Tests/SessionlessInvocationTests` (mocked server
internals):
- `SessionlessRequestIsUnsupportedUnlessEnabled`,
`AnUnknownSessionTokenStaysBadSessionIdInvalid`,
`ServicesOutsideTheSessionlessSetStillNeedASession` (7 services),
`TheServiceSetLimitAppliesBeforeAValidateSessionLessRequestHandler`,
`AValidateSessionLessRequestHandlerTakesPrecedenceAsync`;
-
`SessionlessRequestWithoutIdentityIsRejectedUnlessAnonymousIsAllowedAsync`,
`SessionlessAccessTokenIsValidatedByTheIdentityRegistryAsync`,
`AnAccessTokenNoAuthenticatorHandlesIsRejected`,
`SessionlessAccessTokenRequiresAnEncryptedChannel` (None, Sign),
`SessionlessAccessTokenIsAcceptedOverSignAndEncryptAsync`,
`AccessTokensCanBeDisabled`,
`IsSessionlessServiceFollowsTheServiceSetsOfPartFour` (21 cases),
`ValidateSessionlessRequestRejectsMissingArguments`;
- wiring: `AddSessionlessInvocationRegistersTheConfiguredOptions`,
`AddSessionlessInvocationRejectsANullBuilder`,
`TheHostedServerAppliesTheOptionsToItsSessionManager`,
`TheHostedServerAppliesTheOptionsToAFactoryCreatedSessionManager`,
`TheHostedServerKeepsOptionsTheSessionManagerAlreadyHas`.
- End to end against the reference server,
`Opc.Ua.Sessions.Tests/SessionlessServiceInvocationTests` (opc.tcp,
`SessionClient` on a channel without CreateSession):
- `ReadWithAnAccessTokenNeedsNoSessionAsync` (SignAndEncrypt),
`AnAccessTokenOnAnUnencryptedChannelIsRejectedAsync`,
`ARequestWithoutAnIdentityIsRejectedAsync`,
`SubscriptionsStillNeedASessionAsync`;
- one sample per service set: `ViewServicesRunWithoutASessionAsync`
(Browse, TranslateBrowsePathsToNodeIds),
`AttributeServicesRunWithoutASessionAsync` (Write, HistoryRead),
`MethodServicesRunWithoutASessionAsync` (Call),
`NodeManagementServicesRunWithoutASessionAsync` (AddNodes). They check
that the service runs and reports its operation result, not a missing
session or an internal error, since #4632 hardened only
Browse/BrowseNext against a missing session. Query is not sampled:
`StandardServer` does not implement QueryFirst/QueryNext.
- REST binding,
`HttpsWebApiIntegrationTests.SessionlessReadOverRestRunsWithAnAccessTokenAsync`
(Compact and Verbose): `POST /read` with the Access Token as String
NodeId (`"AuthenticationToken": "s=<token>"`), and the same request
without a token.
- **Budget**, `Opc.Ua.Server.Tests/SessionlessRequestBudgetTests` (22
tests):
- the budget itself: server limit and release, channel limit that takes
nothing from the server, server limit checked first, zero does not
limit, a lease releases once, HTTPS grouped by peer address (IPv4-mapped
form too), 200 concurrent callers against the limits (exactly the limit
granted, none above the channel limit, parallel release leaves zero and
no tracked channel), 16 callers acquiring and releasing in a loop
(high-water mark never above the limit);
- through `SessionManager.ValidateRequestAsync`: `Bad_ServerTooBusy`
over the server and the channel limit, disposing a context twice
releases once, places returned after a rejected Access Token, an
unencrypted channel, a missing identity, a cancelled validation (while
it waits, the place is taken), a handler error and a handler exception;
the budget also limits requests a handler decides, and a handler-only
server has none; requests outside the service set and requests a
disabled server refuses take no place; changed limits apply to the next
request and new options start a new budget;
  - options: finite defaults, negative values rejected.
- **Budget end to end**,
`Opc.Ua.Sessions.Tests/SessionlessServiceInvocationTests.SessionlessRequestsRunInABudgetOfTheirOwnAsync`
(opc.tcp, requests held in the Access Token validation): the third
request over a limit of two gets `Bad_ServerTooBusy`; after release,
five sequential reads and five reads of an unknown node (operation
result bad) all pass; a channel limit of one refuses the second request
on that channel while another channel's request is admitted.
`ARequestWithoutATokenIsUnsupportedWhileTheFeatureIsOffAsync`:
`Bad_ServiceUnsupported` for a request without token on a server without
the feature, `Bad_SessionIdInvalid` for a token that names no Session.
- **Fail without the change**: the places-returned tests fail when the
budget does not release on failure (4 of 22 fail with the release in
`SessionManager` removed; the other budget tests use API this change
adds). `SessionlessRequestIsUnsupportedUnlessEnabled` and
`TheServiceSetLimitAppliesBeforeAValidateSessionLessRequestHandler` are
unchanged from the first version.
- **Review follow-up (`e9a35980b`)**:
`SessionlessAccessTokenReceivesTheRolesMappedFromItsClaimsAsync`,
`SessionlessAccessTokenWithoutTheMappedClaimIsNotGrantedTheRoleAsync`,
`AnAnonymousSessionlessRequestIsGrantedTheAnonymousRoleOnlyAsync`,
`TheIdentityOfAValidateSessionLessRequestHandlerReceivesTheMappedRolesAsync`,
`AValidateSessionLessRequestHandlerThatSetsNoIdentityKeepsNoIdentityAsync`;
end to end
`ARoleMappedNodeIsReadWithTheRolesMappedFromTheAccessTokenAsync` (the
ServerDiagnosticsSummary, readable for SecurityAdmin only, is denied for
a token whose claim is not mapped, readable once the rule maps it,
denied for another token) and
`ACancelledAccessTokenValidationAnswersTheTimeoutAndReturnsItsPlaceAsync`.
The anonymous and no-identity tests guard unchanged behavior. With the
`SessionManager.cs` change removed the three claim-mapping tests, the
end-to-end role test (`Bad_UserAccessDenied`), the end-to-end
cancellation test (`Bad_UnexpectedError` instead of `Bad_Timeout`) and
`ACancelledValidationReturnsItsPlaceAsync` fail. Lines of this commit
were not re-measured for patch coverage; the figure below is for
`496f84fce`.
- **`ClientTest`**: the three assertions that accepted
`Bad_ServiceUnsupported` too are back to master (`Bad_SessionIdInvalid`
only), so the PR no longer touches that file. Since #4646 a closed
`Session` refuses a request without authenticationToken itself; the
server code is not reached. The three tests pass with the single code
(12 cases on net10.0).
- Results on macOS: `Opc.Ua.Server.Tests` full run on net10.0 8076
passed / 9 skipped / 1 failed
(`ACancelledValidationReturnsItsPlaceAsync`, which asserted the old
`Bad_UnexpectedError` mapping; updated to the propagated cancellation
and passing); the session-less, SessionManager and role filters 368
passed / 0 failed on net10.0 and on net8.0, and the session-less
invocation and budget tests 73 passed on net10.0 after the last test was
added. `Opc.Ua.Sessions.Tests` with the
HTTPS/WebApi/WSS/OpenApi/Sessionless/ClientTest filter: 400 passed / 241
skipped / 0 failed on net10.0 and on net8.0 (the earlier host-name
failures were an environment issue of the test machine and are gone).
- Builds: `Opc.Ua.Server`, `Opc.Ua.Server.Tests` and
`Opc.Ua.Sessions.Tests` for net48, net8.0, net9.0 and net10.0 and
`Opc.Ua.Server` in Release net10.0: 0 errors, no new warnings (the
CA2213 in `SamplingGroup.cs` and the CA1861/CA2025 in other test files
are on master too).
- `dotnet format` (whitespace, style, analyzers `--severity info`, one
`--include` per changed file): no finding on a changed line. What
remains is on master lines (`SessionManager.cs` 552, 1374, 2205 and
`DependencyInjectionStandardServer.cs` 228) and the line-ending finding
of a macOS checkout.
- Coverage (`.azurepipelines/check-coverage.ps1` against master, only
the session-less tests of `Opc.Ua.Server.Tests` on net10.0): patch
105/116 changed lines (90.5 %). The 11 uncovered lines are Session-bound
lines of `ValidateRequestAsync` and the lockout logger that moved with
the change; the rest of the suite covers them. The project floor is not
meaningful for this subset.
- Fork CI and CodeQL: previous head `0fba9f21d` (master incl. #4674
merged): [CI run
37307824769](https://github.com/biancode/UA-.NETStandard/actions/runs/37307824769)
green (one net48 timing test, `KeepAliveHandlerV2Async`, failed in the
first attempt and passed on the job rerun; the PR does not touch
subscriptions), [CodeQL run
37307829138](https://github.com/biancode/UA-.NETStandard/actions/runs/37307829138)
green. Current head `e9a35980b`: [CI run
37364178979](https://github.com/biancode/UA-.NETStandard/actions/runs/37364178979),
[CodeQL run
37364182697](https://github.com/biancode/UA-.NETStandard/actions/runs/37364182697)
(results pending).

## Design decisions

Marc's answers (2026-10-05) and what the PR does:

1. **Built in** when `AddSessionlessInvocation` was used. Done.
2. **Service-set limit for all paths**: it applies before a
`ValidateSessionLessRequest` handler too. Done, unchanged.
3. **`Bad_ServiceUnsupported`** for a request without a token on a
server without the feature. Done, unchanged. A token that names no
Session stays `Bad_SessionIdInvalid`.
4. **Identity model**: an Access Token in the RequestHeader, validated
as an issued JWT token through the identity registry, anonymous only by
opt-in. Unchanged. The REST authentication is #4674, now in master.
5. **Separate budget** for session-less requests: added, see "Budget for
session-less requests". Open: no limit per identity yet (identities are
claims-defined, so the keys would be unbounded); say if you want one.

## Related Issues

- Fixes #4658
- Relates to #4657 (the REST binding's session-less service set), #4646
(a closed `Session` no longer sends requests without token), #4541
(resource isolation) and #4674 (REST authentication)

## 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.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed. (Affected
suites on net10.0 and net8.0 locally on macOS; .NET Framework is covered
by the fork CI's Windows net48 legs.)
- [ ] 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: Marc Schier <marcschier@users.noreply.github.com>
2026-10-06 07:07:52 +00:00
romanett 7212607f16 OPC UA Part 3 compliance: permissions, Write semantics, NodeManagement, NodeSet DataTypes, locales (#4675)
# Description

Fixes the High and Medium findings of an OPC UA Part 3 (OPC 10000-3,
Address Space Model) compliance audit. Each finding was checked against
the full spec text (Parts 3, 4, 5, 6, 14 and 18) before it was fixed.
Every fix comes with regression tests that fail without it.

### Role permissions (Part 3 §8.55, Part 4 §5.12.2.2)
- The type-hierarchy exemption in
`MasterNodeManager.ValidateRolePermissions` /
`ValidateAccessRestrictions` now covers only Browse, Read and
ReadRolePermissions. Type nodes stay visible to generic clients, which
was the intent of #3705; every other permission bit is now enforced on
them.
- `ReceiveEvents` is enforced on the EventType node. The standard
NodeSet grants it on `AuditEventType` and its subtypes only to
SecurityAdmin, so only SecurityAdmin sessions now receive audit events.
- Call checks the `Call` permission and AccessRestrictions on the Object
as well as on the Method.
- When a Call's `methodId` is the Method declaration of the ObjectType,
the Object's Method with the same BrowseName is permission-checked and
invoked. The declaration is used only if the Object has no such Method.
This applies to `AsyncCustomNodeManager`, `CustomNodeManager2` and the
sample node manager.

### Write service (Part 3 §5.6.2, §8.40, §8.57; Part 4 §5.11.4)
- The synchronous `OnWriteValue` path (fluent `OnWrite`) checks DataType
and ValueRank first (`Bad_TypeMismatch`). After an IndexRange write it
merges the slice into the cached value instead of replacing it.
- StatusWrite / TimestampWrite are enforced (`Bad_WriteNotSupported`).
- Undefined enumeration values and invalid OptionSet writes return
`Bad_OutOfRange`. OptionSet writes merge only the valid bits into the
stored value.
- New `ReportPropertyValueChanged` / `ReportSemanticChange` let server
code raise SemanticChangeEvents for changes it makes itself.
- Required follow-up (Part 14 §6.2.11.1): if an external target rejects
a timestamp, the PubSub subscriber's TargetVariables writer retries
without it.

### NodeManagement and Call arguments (Part 3 §5.5.2, §5.7.3, §6.4.2;
Part 4 §5.8, §5.12.2)
- AddNodes creates the Mandatory InstanceDeclarations of the
TypeDefinition and rejects abstract or Interface types
(`Bad_TypeDefinitionInvalid`).
- AddReferences, DeleteReferences and DeleteNodes bump NodeVersion and
raise GeneralModelChangeEvents.
- Clients may omit trailing optional input arguments
(`HasOptionalInputArgumentDescription`).

### NodeSet DataTypes (Part 3 §5.8.3, §8.48; Part 6 Annex F.12)
- `UANodeSet.Import` completes structure definitions with the base
type's fields (base fields first). It creates definitions for structures
without fields and sets `DefaultEncodingId`.
`StructureDefinition.FirstExplicitFieldIndex` marks the subtype's own
fields.
- `DataTypeState` no longer mutates the stored definition on read.

### Locales (Part 3 §5.2.5, §8.5; Part 4 §5.4)
- Read, Browse and data change notifications return the translation that
best matches the session's LocaleIds, or all translations for `mul`. The
selection lives in the server dispatcher and is built on
`ResourceManager`: the new `TranslateValue` calls the overridable
`Translate`.
- Locale ids are matched case-insensitively, and fallback translations
carry their real locale.

### Client complex types (Part 6 §5.1.7)
- The Reflection.Emit builder encodes only `IsOptional` subtyped fields
as ExtensionObject. It also supports OptionSet subtypes: an unsupported
type is skipped instead of aborting the whole load.
- `TypeInfo.GetXmlName` uses the data type's XML name for dynamic
structures and OptionSets (the XML body element was `<Structure>`). Both
builders now produce identical XML.

## Behaviour changes
All are documented in `docs/MigrationGuide.md`.
- **Audit events:** only SecurityAdmin receives them. The CTT "Auditing
Connections" unit needs a SecurityAdmin user (e.g. sysadmin);
`AuditingOperationTests` was updated.
- **Write timestamps and status:** clients writing a SourceTimestamp or
a non-Good StatusCode get `Bad_WriteNotSupported` unless the Variable
has StatusWrite/TimestampWrite. No variable in `src/` sets these bits.
- **AddNodes:** it rejects abstract types (including `BaseVariableType`
and `BaseEventType`), and new instances now come with child nodes.
- **Call:** the Object needs the Call permission. The Object's own
Method runs even when the call names the type declaration.

## Not included
- Low-severity findings: missing validation on optional services and
author-side NodeSet rules.
- LocalizedText nested inside structures or Variant arrays, and in
HistoryRead or Call outputs.

## Testing
net10.0, Debug, one suite at a time on the merged branch. All suites
below pass:

| Suite | Passed |
|---|---|
| Types | 11430 |
| Core.Encoders | 2947 |
| Core.Schema | 179 |
| Core.Security | 558 |
| Client | 2761 |
| Server | 8210 |
| InformationModel | 1080 |
| Features | 145 |
| History | 536 |
| Di | 455 |
| Machinery | 167 |
| Robotics | 661 |
| Robotics.Intent | 22 |
| Pumps | 75 |
| Scales | 122 |
| Positioning | 69 |
| ISA95 | 144 |
| Vision | 484 |
| PubSub | 1490 |
| PubSub.Adapter | 151 |
| PubSub.Server | 177 |
| Redundancy.Server | 856 |
| Lds | 173 |
| Configuration | 241 |
| WotCon | 1496 |

- **Client.ComplexTypes:** one test wrote a SourceTimestamp to a
variable without TimestampWrite. It is fixed in this PR, and the suite
has not been re-run since.
- **Core:** 6068 passed, 1 failure in
`ActivityTraceFlags_Metrics_ShouldRecordMetricsOnly`. That telemetry
test passed when re-run alone, so it looks flaky.
- **Still running:** Sessions, Subscriptions (Classic, Durable,
standard) and Interop. Results will follow on this PR.
- `UA.slnx` builds with 0 errors for net10.0 and net48, with no new
analyzer warnings in changed files.

## Related Issues

- Part 3 compliance audit (no tracking issue)

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

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:43:59 +00:00
romanett b03575b4e2 Run the interop tests against six foreign OPC UA stacks and extend feature coverage (#4644)
## Proposed changes

Runs the interop fixtures added in #4630 against peers built on six
other open source OPC UA stacks, so the 2.0 client and server are tested
against independent implementations, not only the released 1.5.378
stack. It also extends the fixtures beyond read/write/browse to events,
subscriptions, identity and more services.

### Peers (`tests/Opc.Ua.Interop.Peers`)

| Peer | Stack | Toolchain | Roles |
| --- | --- | --- | --- |
| `node-opcua` | node-opcua 2.186.15 (MIT) | Node.js 24 | server, client
|
| `milo` | Eclipse Milo 1.1.7 (EPL-2.0) | Java 17+, Maven | server,
client |
| `asyncua` | opcua-asyncio 2.0.1 (LGPL-3.0) | Python 3.12 | server,
client |
| `open62541` | open62541 1.5.8 (MPL-2.0), OpenSSL backend | C, CMake |
server, client, ECC |
| `async-opcua` | async-opcua 0.19.0 (MPL-2.0) | Rust | server, client |
| `gopcua` | gopcua 0.9.1 (MIT) | Go | client only |

Every peer speaks the command line and stdout contract of
`Opc.Ua.Interop.LegacyPeer` (`server` / `client --checks`, `PEER-INFO
{json}`, `RESULT {json}`). The stacks are fetched at build time and run
as separate processes; nothing is linked into or shipped with any
package.

### Harness

- **Peer selection:** `OPCUA_INTEROP_PEER_HOST` +
`OPCUA_INTEROP_PEER_HOST_ARGUMENTS` choose the host (`node`, `java
-jar`, `python`) for the peer named by `OPCUA_INTEROP_LEGACY_PEER`.
Native peers (open62541, async-opcua, gopcua) run without a host. Unset,
the 1.5.378 peer runs exactly as before, so the main CI is unchanged.
- **`PEER-INFO {json}`:** stack, version, application URI, software
version and, optionally, the security policies the peer implements.
`LegacyServerInteropTests` compares the ApplicationUri and
SoftwareVersion against it. `EccInteropTests` runs policies beyond the
four 1.05 ECC policies only against a peer that declares them.
- **`expected-differences.json` per peer:** confirmed defects of a
stack, keyed by check name, `Fixture.Test` or a `*` prefix, each with
the source file or spec section that shows it. A listed failure is
reported as inconclusive with its reason; a listed test that passes
produces a warning. The 2.0 client tests apply it through the
fixture-level `[PeerDifferences]` action.

### New coverage (all peers, including the 1.5.378 peer)

- **16 client checks**, run by each peer's client against the 2.0 server
(`LegacyClientFeatureCheckAsync`, Basic256Sha256 SignAndEncrypt):
EventSubscription, ConditionRefresh, AlarmAcknowledge, DeadbandFilter,
QueueOverflow, Triggering, Republish, TransferSubscription,
WrongPasswordRejected, X509UserToken, RegisterNodes, HistoryReadRaw,
NodeManagement, IndexRange, FindServers, SessionReconnect.
- **`ServerFeatureInteropTests`**: the 2.0 client against each peer
server. It covers RaiseEvent, ConditionRefresh, absolute deadband, queue
overflow bit, triggering, republish errors, transfer to a new session,
X509 user token, RegisterNodes, IndexRange, AddNodes/DeleteNodes,
FindServers and session reconnect. Every peer server gained a
`RaiseEvent` method and accepts X509 user tokens.
- **ECC:** with open62541's OpenSSL backend, `EccInteropTests` adds
`ECC_nistP256_AesGcm` and `ECC_nistP256_ChaChaPoly` (both directions;
SignAndEncrypt anonymous and username, Sign, and token renewal across
two renewals). It also adds `ECC_curve25519`/`ECC_curve448` cases, which
are ignored while 2.0 marks those policies unsupported.

### CI and docs

- **Workflow `interop-foreign.yml`:** opt-in (nightly,
`workflow_dispatch`, PRs labelled `interop`), not a required check. It
builds all six peers on Windows, Linux and macOS (open62541 from source
on Linux only, against the distro OpenSSL), with dependency caching.
- **`tests/Opc.Ua.Interop.Peers/README.md`:** how to install the
toolchains, build each peer (including Windows without Visual Studio),
run the fixtures against it, maintain expected differences and add a new
stack. `DeveloperGuide.md` links to it.

## Results

Run locally, sequentially, net10.0, Windows. Not passed = expected
differences, inconclusive by design (e.g. AddNodes on a server without
NodeManagement), or ECC cases a peer or the platform does not support.

| Peer | Fixtures | Total | Passed | Failed |
| --- | --- | --- | --- | --- |
| 1.5.378 | whole interop project | 220 | 218 | 0 |
| node-opcua | server + client + feature | 145 | 143 | 0 |
| Eclipse Milo | server + client + feature | 145 | 141 | 0 |
| asyncua | server + client + feature | 145 | 133 | 0 |
| open62541 (OpenSSL) | server + client + feature + ECC | 199 | 175 | 0
|
| async-opcua | server + client + feature | 145 | 137 | 0 |
| gopcua | client | 104 | 81 | 0 |

After the AEAD cases were added, `EccInteropTests` against the 1.5.378
peer ran separately: 54 total, 26 passed, 0 failed. The new cases are
ignored there because the peer does not declare them.

## Findings

No 2.0 defects beyond the two now fixed in #4654:
- Browse with a null ViewId and a Timestamp now returns
`Bad_ViewTimestampInvalid`.
- Subscription transfer no longer fails when the optional
`Server.GetMonitoredItems` is unavailable.

After #4654 merges, `TransferSubscriptionToNewSessionAsync` can drop its
expected differences for asyncua.

Upstream defects found, all listed in the peers'
`expected-differences.json` with evidence; issues are still to be filed:

- **All five non-.NET stacks:** cannot handle the TestData
`ScalarStructureDataType` (fields of the abstract
Enumeration/Structure/BaseDataType). Milo encodes the abstract
`Enumeration` field as a Variant (Part 6 5.2.4 says Int32).
- **open62541:**
- **Sign-only ECC keys:** derives encryption key and IV into the HKDF
length for Sign-only ECC (Part 6 6.8.1), so Sign mode fails for the four
1.05 curves. The AEAD policies are not affected.
  - **HMAC mismatch:** reported as `Bad_InternalError`.
  - **ConditionRefresh:** needs the experimental A&C option.
- **Response too large:** closes the connection instead of answering
`Bad_ResponseTooLarge`.
  - **Array limit:** no MaxArrayLength.
- **gopcua:**
- **RsaPss:** `RSAOAEPMinPaddingSHA256` is 130 instead of 66, which
breaks `Aes256_Sha256_RsaPss`.
  - **Nil LocalizedText:** encoding a nil `*LocalizedText` panics.
- **asyncua server:**
  - **Queue overflow:** no Overflow bit.
  - **SetTriggering:** not implemented.
- **Republish:** answers Good for an unknown sequence number or
subscription.
  - **GetMonitoredItems:** not implemented.
  - **IndexRange:** ignored on Read and Write.
- **Reconnect:** closes a session without subscriptions on transport
loss.
- **Limits:** no BrowseNext; operation limits not enforced; no
`Bad_ResponseTooLarge`.
- **async-opcua:**
- **X509 user token (client):** treats an empty token SecurityPolicyUri
as None.
- **Reconnect (client):** overwrites the session nonce with the channel
nonce on reconnect.
  - **ConditionRefresh (server):** not implemented.
  - **Overflow bit (server):** set without the DataValue InfoType bit.
  - **ECC:** no ECC policies.
- **Milo:**
- **Wrong password:** `BadIdentityTokenInvalid` instead of
`BadUserAccessDenied` / `BadIdentityTokenRejected`.
  - **Array limit:** accepts arrays above its advertised MaxArrayLength.
  - **AddNodes:** answers `BadNotSupported`.

## Related Issues

- Follows #4630. Related fixes: #4654.

## Types of changes

- [x] Test / CI only

## Checklist

- [x] Interop fixtures pass locally against all seven peers (net10.0,
Windows).
- [x] `dotnet format whitespace --verify-no-changes` on the changed C#
files.
- [ ] `interop-foreign.yml` has not run on GitHub yet; it needs a manual
dispatch or the `interop` label.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:03:05 +02:00
Klaus Landsdorf b25c1f6ad4 Asset Management Basics: the OPC 10000-110 companion SDK (#4662)
# Description

Stacked on #4650. Until that is merged, this PR also shows its commits;
the six commits of this PR start after `fix(server): keep conformance
unit aggregation idempotent and ordered with registrations`.

This adds OPC 10000-110 Asset Management Basics 1.01.1
(`http://opcfoundation.org/UA/AMB/`) as a companion SDK, in the pattern
of the DI, Machinery, Pumps and Scales packages. The tracking issue
#4649 describes the scope, the design and the open questions. In short:

| Package | Content |
|---|---|
| `Opc.Ua.AMB` | the model generated by the source generator from the
unmodified NodeSet, plus the contracts server and client share
(`AssetFaultSeverity`, `AssetLocationKind`, `MaintenanceStateKind`,
`DocumentationLinkProperties`) |
| `Opc.Ua.AMB.Server` | a sidecar node manager next to the owner of the
Device Integration address space (`AddOpcUaDi`, `AddMachinery`,
`AddPumps` or `AddScales`), the asset registry `IAssetManagement` with a
fluent builder, `AddAssetManagement()` hosting, memory and file system
stores, the AMB conformance units and server facet |
| `Opc.Ua.AMB.Client` | `AmbClient`: discovery, identification, health
alarm and maintenance streams, documentation links, locations, structure
and snapshots, plus `AddAssetManagementClient()` |

**Sidecar.** An asset stays in the node manager that owns it. Everything
AMB adds to it (`DeviceHealth`, health alarms, maintenance conditions,
the DocumentationLinks AddIn, AssetId and the other properties) is
created there. The AMB node manager serves the AMB model, the alias
categories `Assets`, `AssetsByProductInstanceUri` and `AssetsByAssetId`
below `0:Aliases`, the location hierarchies below `0:Locations` and the
server-specific alarm and condition types. Owners register assets with
the shared registry from their setup, in either startup order.

**Implemented conformance units** (Table 54): Asset Identification,
Configurable Asset Identification, Discovery by ProductInstanceUri and
by AssetId, Health Status Base, Alarms, Root Causes and Alarm
Categories, Version Information, Operation Counters (inspected only; AMB
defines no counters), DocumentationLinks Base, Edit Base and Edit
Advanced, Requirements, Capabilities, Classification, Current and Future
Maintenance Activities, Local Time, the three location properties and
both location object hierarchies, Sub-assets and Asset relations. The
two history units (Health Tracking, Past Maintenance Activities) are not
part of this PR. `docs/AMB.md` has the matrix with the rule the server
applies for each unit and whether a test checks it over a session.

**Dependency on #4650.** The health alarms and maintenance conditions
are created through `CreateAlarm<TState>`, and the conformance units
follow the registered assets through the recomputation in
`ConformanceUnitsManager`.

## Changes

Six commits, each building on its own:

1. **`feat(amb)`: model package** (`src/Opc.Ua.AMB/`, about 2.4k lines,
of which the NodeSet and the two identifier tables are 1.6k).
- The NodeSet is the publication from `UA-Nodeset/latest/AMB`, byte for
byte (git blob `21f9475b646e9c5508171277ffe5b74edbeb0a58`).
`.gitattributes` checks it out with LF endings, because a test pins its
SHA-256.
- The published identifier table is vendored unchanged; a derived table
pins the symbol-to-NodeId mapping, and the generator fails the build
when they disagree. `tools/nodesets/README.md` documents the one new
kind of renamed symbol (top-level instances below `0:Aliases` and
`0:Locations`).
2. **`feat(amb)`: server package** (`src/Opc.Ua.AMB.Server/`, about
10.7k lines). Node manager, registry and builders, discovery, health,
maintenance (`MaintenanceEventStateMachine` with all transitions),
documentation links, locations, version information, classification
(IRDI dictionary entries below `Server/Dictionaries`) and structure,
stores, conformance evaluation, hosting. Log messages use the EventId
block 8300 to 8379, after Pumps (8100) and Scales (8200).
3. **`feat(amb)`: client package** (`src/Opc.Ua.AMB.Client/`, about 3.0k
lines). Calls AddLink and RemoveLink through the generated
`DocumentationLinksTypeClient`.
4. **`test(amb)`: test suite** (`tests/Opc.Ua.AMB.Tests/`, 163 tests,
about 8.8k lines; commit 6 adds one more).
5. **`docs(amb)`: guide and samples**: `docs/AMB.md`, links from
`README.md`, `docs/README.md`, `docs/Samples.md` and
`docs/AliasNames.md`; `samples/AMB/AmbServer` (AMB next to
`AddMachinery`: two DI devices and a Machinery press as assets, with a
simulation) and `samples/AMB/AmbClient`. The sample server listens on
62553, which no other sample uses.

6. **`feat(amb)`: tell the links users added from the links of the
manufacturer.** OPC 10000-110 gives a client no way to see which links
`RemoveLink` accepts (§10.5.4: only those `AddLink` created). As §10.5.1
lets vendors add metadata Properties to the links, the server gives
every link `AddLink` created, and every such link restored after a
restart, the `Boolean` Property `UserLink` (`true`), qualified with its
namespace of server-specific types, with a NodeId derived from the
persisted link identifier; declared links carry none, and `RemoveLink`
deletes it with the link. `DocumentationLinkRecord` gets the additive
`IsUserLink`, which `ReadDocumentationLinksAsync` fills with one Browse
over all links and one Read of the markers found (comparing the name
only, as the namespace is configurable; `false` for a server that does
not mark links). The name is shared as
`DocumentationLinkProperties.UserLink`.

`UA.slnx` and `.azurepipelines/expected-packages.txt` get the new
projects and packages in the commit that adds them.

All projects target `net48;net8.0;net9.0;net10.0` through the repository
properties (`LibTargetFrameworks`, `TestsTargetFrameworks`,
`AppTargetFrameworks`); `IsAotCompatible` and `PublishAot` are set from
net10.0 on, as in the other companion projects.

## Tests

- **`Opc.Ua.AMB.Tests`: 164 tests**, most of them against a hosted
server over real client sessions:
- the model: the SHA-256 of the NodeSet and the generator behaviours AMB
is the first companion model to rely on;
- each building block in-process and over a session: discovery through
every category with NodeVersion and model change events, AssetId writes,
health alarms and maintenance activities through all transitions with
acknowledgement, AddLink and RemoveLink, locations and structure, the
advertised conformance units and the facet;
  - a restart with the file system stores (links and AssetId survive);
- the `UserLink` marker: present on an added link only, the same NodeId
after a restart, removed with `RemoveLink`, and `IsUserLink` on the
client in the snapshot, after `AddLink`, after a restart and after
`RemoveLink`;
- composition with `AddOpcUaDi` (both startup orders), `AddPumps` and
`AddMachinery`;
- `AmbClient` end to end against a hosted server, including a Machinery
machine as an asset.
- **Results (macOS, local):** 164 passed / 0 failed on net10.0 and on
net8.0. .NET Framework is covered by the fork CI's Windows legs.
- **Coverage** of the three AMB assemblies (coverlet with
`tests/coverlet.runsettings.xml`, net10.0, generated `*.g.cs`,
`*TypeClient` proxies and `*Log` classes excluded): lines 93.9 % (4890
of 5206), branches 84.0 % (1294 of 1539); model 100 %, client 94.5 %,
server 93.4 %.
- **Build:** the three packages, the tests and the two samples build
without warnings for net48, net8.0, net9.0 and net10.0; `UA.slnx` builds
in Release (its warnings are all in files this PR does not touch, as on
master). `dotnet format` (whitespace, style, analyzers with `--severity
info`) reports nothing on the 95 changed C# files.
- **Samples:** `AmbServer` and `AmbClient` ran against each other on
net10.0: the client discovered the three assets, printed their snapshots
and the location hierarchies, added and removed a documentation link and
streamed alarms and maintenance.
- Fork CI (full matrix incl. macOS) and CodeQL: green on the same five
package commits before the rebase onto the current #4650 head — [CI run
37144486107](https://github.com/biancode/UA-.NETStandard/actions/runs/37144486107),
[CodeQL run
37144487873](https://github.com/biancode/UA-.NETStandard/actions/runs/37144487873).
The run on the current head (rebased, plus the user-link commit; AMB
tests 164/164 locally on net10.0) is in progress: [CI run
37203517457](https://github.com/biancode/UA-.NETStandard/actions/runs/37203517457).

## Notes for review

- **Size.** About 26.8k lines, larger than each of the companion PRs
#4577 to #4580. Without changes to the code it splits into the model,
server and server tests, and the client, end-to-end tests, samples and
docs on top; the issue asks which you prefer.
- **Interfaces on types.** §9.4.1 and §12.1 ask for alarm and condition
types that implement `IRootCauseIndicationType` and
`IMaintenanceEventType`. The server creates subtypes of the DI alarm
types in its own type namespace (`urn:opcua-netstandard:amb:types`);
`UseServerDefinedAlarmTypes = false` uses `HasInterface` on the instance
instead, and then does not report the units that need the type-level
interfaces.
- **Conformance unit names** follow Tables 54 to 56; the profile
database has no AMB entries yet. The QualifiedNames are in namespace 0,
as DI and Machinery publish theirs (question 5 in the issue).
- **Generator gap.** Children a NodeSet gives to a top-level instance
(here the `NodeVersion` properties of the alias categories) get no
identifier constants. The fix changes the identifiers of other generated
models, the reference server's among them, so it is not part of this PR;
`AmbModelTests.NodeVersionHasNoIdentifierConstant` records it and fails
once the generator emits the constants.
- **net48.** The code that the SDK so far only built for .NET 8 and
later uses the `NET5_0_OR_GREATER` pattern of `JsonEncoder` for
`Enum.IsDefined` and API forms that .NET Framework has (`string.Format`,
`Guid.ToString(format, provider)`); the tests use a small async file
helper instead of `File.WriteAllTextAsync`.
- **User links.** The `UserLink` Property is metadata of this server,
not an AMB name: commit 6 explains why and how a client uses it. Changed
lines of that commit covered: 69/70 (the one line left is the loop
continuation for a link that has other Properties but no marker).
- **Known limitations** are listed in the guide: the `DeviceHealth`
SourceTimestamp across a restart, removing optional maintenance members
once set, `PatchIdentifiers`, locations only at the deepest level, and
the history units.

## Related Issues

- Part of #4649
- Stacked on #4650

## 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.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed. (The AMB
suite on net10.0 and net8.0 locally on macOS; .NET Framework is covered
by the fork CI's Windows net48 legs.)
- [ ] 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: Marc Schier <marcschier@users.noreply.github.com>
2026-10-05 07:45:46 +02:00
Klaus Landsdorf dbee7b9988 Robotics client: fill the engineering units and ranges of the axis values and the load mass, and the gear ratio (#4661)
# Description

Follow-up to #4619. It was prepared stacked on #4619 and is now one
commit on top of master, with #4619's review changes in place.

`AxisSnapshot.Engineering`, `LoadSnapshot.MassEngineering` and
`GearSnapshot.GearRatio` are part of the snapshot contracts and
documented in `docs/Robotics.md` (the URML capability manifest takes the
joint limits from them), but `RoboticsClient` never set them: every axis
came back without units or limits, every load without the unit of its
mass and every gear without its ratio.

OPC 40010-1 declares `ActualPosition` (mandatory), `ActualSpeed` and
`ActualAcceleration` (optional) below the axis's DI `ParameterSet`, and
the load's `Mass`, as `AnalogUnitType` variables with a mandatory
`EngineeringUnits` and an optional `EURange` property, and `GearRatio`
as a mandatory `RationalNumber` variable of the gear. The client now
reads them:

- **Axis:** `ReadAxisAsync`, and with it `ReadSystemAsync`, resolves the
`EngineeringUnits` and `EURange` of the three axis values with one
TranslateBrowsePathsToNodeIds and reads them with one Read per axis
(none when nothing resolves) into `PositionUnit`, `SpeedUnit`,
`AccelerationUnit` and `Limits.Position/Speed/Acceleration`.
- **Load:** `Mass` is resolved and read together with its
`EngineeringUnits` and `EURange` (`MassEngineering`), replacing the
separate resolve and read.
- **Gear:** `GearRatio` is read in the same request as `Pitch`.
- Loads and gears cost no extra request.
- A property the server does not publish, whose read fails or whose
value has a bad status stays `null`. The structures go through the
existing `ToStructure<T>`, which checks `StatusCode.IsGood` as for
`MotorTemperatureEngineering`. The rest of the snapshot is still
returned.
- Because `Mass` and `Pitch` are now part of that request, a failed
`Mass` or `Pitch` read keeps the server's status in the `DataValue`,
like the axis values and `MotorTemperature`. Before,
`Session.ReadValueAsync` threw and failed the whole snapshot read.

The public API does not change.

## Changes

- `src/Opc.Ua.Robotics.Client/RoboticsClient.Topology.cs`:
  - new `ReadAxisEngineeringAsync`, called from `ReadAxisAsync`;
- `ReadLoadAsync` and `ReadGearAsync` use the existing
`ReadChildrenAsync`.
- `docs/Robotics.md`:
  - describes the members and their null cases;
  - adds them to the values that carry no NodeId;
- names `AxisSnapshot.Engineering` instead of the non-existent
`AxisSnapshot.Limits` in the URML section.

## Tests

- **New, unit** (`Opc.Ua.Robotics.Tests`, `RoboticsClientFacadeTests`):
- `EngineeringValuesAndTheGearRatioComeFromTheVariablesTheyDescribe`:
the values are checked against same-named decoys below the axis and a
`GearRatio` in namespace 0.
- `AbsentOrUnreadableEngineeringValuesAndGearRatiosStayNull`: a missing
`EURange`, a load without engineering properties, a failing unit read
and a failing ratio read.
- `BadQualityEngineeringValuesAndGearRatiosReadAsNoStructure`: a range,
a unit and a ratio with a bad status read as `null`.
  - `AFailedMassOrPitchReadKeepsTheStatusAndTheSnapshot`.
  - `AnAxisWithoutAParameterSetHasNoEngineeringValues`.
- **New, end-to-end**
(`RoboticsClientServerE2eTests.EngineeringValuesAndGearRatiosAreReadAsync`):
- the test cell now publishes a unit and range for `ActualSpeed`, a unit
without a range for `ActualAcceleration`, a range for the flange load's
mass and a tool load with a mass only;
- the test reads the units, ranges and gear ratios back through
`ReadSystemAsync` and `ReadAxisAsync`.
- **Fail without the fix:** with `src/Opc.Ua.Robotics.Client` from
master, five of the six new tests fail. Four get `null` units, ranges or
ratios. `AFailedMassOrPitchReadKeepsTheStatusAndTheSnapshot` fails with
`ServiceResultException` `Bad_NodeIdUnknown`.
`AnAxisWithoutAParameterSetHasNoEngineeringValues` passes there by
design: it pins that an axis without a `ParameterSet` still reads
without error.
- `Opc.Ua.Robotics.Tests` passed 661/661 on net10.0 and 601/601 on
net8.0 (macOS).
- `Opc.Ua.Robotics.Client` builds for net48, net8.0, net9.0 and net10.0
with 0 warnings.
- `dotnet format` (whitespace, style, analyzers) on the changed files:
nothing on the changed lines beyond the `IDE0002` info that the existing
`RoboticsSessionHarness.Ua(Opc.Ua.BrowseNames.…)` calls in the same test
class also get.
- Changed lines covered: 52/52 (100 %).
- Fork CI (full matrix incl. macOS) and CodeQL: [CI run
37147764118](https://github.com/biancode/UA-.NETStandard/actions/runs/37147764118)
and [CodeQL run
37147766527](https://github.com/biancode/UA-.NETStandard/actions/runs/37147766527).
CodeQL is green; CI is green except `Opc.Ua.Interop.Tests`
(`LegacyClientExtendedCheckAsync("ComplexTypes")` /
`LoadAndDecodeLegacyComplexTypesAsync`), which is flaky on master since
#4630 (details in #4617), and except one Windows run of
`StartIsIdempotentAndDisposeStopsListenerAsync` in
`Opc.Ua.Redundancy.Kubernetes.Tests` (HttpListener prefix already
registered), which this change does not touch either.

## Notes for review

- `ReadAxisAsync` costs one TranslateBrowsePathsToNodeIds and at most
one Read more per axis. The other snapshots keep their request count.
- `AxisSnapshot.Engineering`, `LoadSnapshot.MassEngineering` and
`GearSnapshot.GearRatio` are read once and carry no NodeId, like
`MotorTemperatureEngineering`.

## Related Issues

- Fixes #4660
- Follows up #4619

## 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.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed. (Affected
suite on net10.0 and net8.0 locally on macOS; .NET Framework is covered
by the fork CI's Windows net48 legs.)
- [ ] 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: Marc Schier <marcschier@users.noreply.github.com>
2026-10-05 06:27:12 +02:00
Klaus Landsdorf 3690341c80 DI: wire the PrepareForUpdate and Installation methods per spec and advertise only the facets that are met (#4617)
# Description

**Software update methods** (`fix(di): wire PrepareForUpdate
Abort/Resume and Installation Resume`)
- New internal `SoftwareUpdateTransitions` (one per `SoftwareUpdate`)
  checks the current state and moves it under one lock, so concurrent
  calls cannot both pass a check.
- `Prepare` (§8.4.8.3) is accepted only from `Idle`. The application's
`OnPrepare` callback gets a token that `Abort` cancels; the token source
is disposed only after both the Prepare call and a racing Abort are done
  with it, so a handler still running after Abort can use its token.
- `Abort` (§8.4.8.4) moves `Preparing` to `Idle` (`PreparingToIdle`),
  cancels the preparation in flight and reports `Failed` ("Aborted")
  through `OnPrepareStateChanged`; the aborted `Prepare` call returns
  `Bad_InvalidState`. Outside `Preparing` it returns `Bad_InvalidState`.
- `Resume` (§8.4.8.5) no longer runs the Prepare callback. It moves
  `PreparedForUpdate` through `Resuming` to `Idle` and is rejected with
`Bad_InvalidState` outside `PreparedForUpdate` or while the installation
  is `Installing`.
- `Installation.Resume` (§8.4.9) moves `Error` to `Idle`
(`ErrorToIdle`),
  `Bad_InvalidState` otherwise.
- The dispatcher knows the `Resuming` state and the `ResumingToIdle`
  transition, so `CurrentState` / `LastTransition` show their names.

**Advertising** (`fix(di): advertise only the DI facets whose mandatory
units are met`)
- `DI Offline`, `DI SU UpdateStatus`, `DI SU FileSystem Loading`,
`DI SU Installation for File System` and `DI SU Installation for Cached
  Loading` are no longer claimed (see the issue for why each is unmet).
- The facets are derived from the advertised units with the mandatory
  units of the DI 1.05 profile database, so a facet cannot be listed
  without them. Today this leaves Locking and Software Update Base;
  DeviceIntegrationHost, FileSystem, Direct and Cached Loading come back
  by themselves once the missing pieces are built.
- `docs/DeviceIntegration.md` documents both tables and the missing
pieces.

Tests: `SoftwareUpdateMethodTransitionTests` (9 tests) and
`DiNodeManagerConformanceTests` (5 tests) are new;
`DiConformanceFacetTests`
and `PumpAddressSpaceComplianceTests` expected the old claims and are
updated. All 9 transition tests and 8 of the 10 conformance tests fail
against master; the other two are the unchanged locking test and a guard
that every advertised facet carries its mandatory units (master
over-claims
the units too, so the guard alone holds there).

Behaviour change for users: a plain DI server no longer lists the
DeviceIntegrationHost facet, and Direct/Cached/Package software updates
list only Software Update Base.

Fork CI (full matrix incl. macOS) and CodeQL: green — [CI run
36779552603](https://github.com/biancode/UA-.NETStandard/actions/runs/36779552603),
[CodeQL run
36779556125](https://github.com/biancode/UA-.NETStandard/actions/runs/36779556125).

## Related Issues

- Fixes #4616

## 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.
- [ ] 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: Marc Schier <marcschier@users.noreply.github.com>
2026-10-05 06:26:33 +02:00
Klaus Landsdorf a3166fa560 WebApiClient: report HTTP failures as StatusCodes and accept gzip responses (#4648)
# Description

`WebApiClient` threw the exceptions of `HttpClient` for every HTTP
failure except 429/503. It now throws `ServiceResultException` with a
matching StatusCode, as the transport channels already do. It can also
ask for and read gzip compressed JSON (OPC 10000-6 §7.4.5). Two commits:
the error mapping, then gzip.

## Changes

### HTTP failures (`src/Opc.Ua.Client/WebApi/WebApiClient.cs`, new
internal `WebApiHttpErrors`)

| HTTP / failure | StatusCode |
|---|---|
| 400 | `Bad_DecodingError` |
| 401 | `Bad_IdentityTokenInvalid` when the client sent no credentials
(bearer token or basic credentials), otherwise `Bad_UserAccessDenied` |
| 403 | `Bad_UserAccessDenied` |
| 404, 405, 501 | `Bad_ServiceUnsupported` |
| 408, 504 | `Bad_Timeout` |
| 413 | `Bad_RequestTooLarge` |
| 415 | `Bad_DataEncodingUnsupported` |
| 429, 503 | `Bad_ServerTooBusy` with the Retry-After hint (unchanged) |
| 500 | `Bad_InternalError` |
| any other status | `Bad_CommunicationError` |
| elapsed `RequestTimeout` / `HttpClient.Timeout` |
`Bad_RequestTimeout`, as in `HttpsTransportChannel` and
`WebApiTransportChannel` |
| failure below HTTP (connection, TLS handshake) | the code
`HttpsTransportChannel.MapRequestFailure` assigns, e.g.
`Bad_NotConnected` for a refused connection |

- The message names the HTTP status, the reason phrase and the route.
- For an HTTP status, the inner exception is an `HttpRequestException`
naming it (with `StatusCode` set on .NET 5 and later). For a failure
below HTTP, it is the original `HttpRequestException`; for a timeout,
the `OperationCanceledException`.
- A cancellation of the caller's token stays an
`OperationCanceledException`.
- `WebApiTransportChannel` sends through `WebApiClient`. For an HTTP
error status it now reports the mapped code instead of
`Bad_UnknownResponse`; its codes for timeouts and connection failures do
not change.
- `docs/WebApi.md`: new section "Client errors".

### gzip (`WebApiClientOptions.AcceptCompressedResponses`,
`src/Opc.Ua.Core/Stack/Https/HttpResponseBodyReader.cs`)

- **Requesting gzip:** `AcceptCompressedResponses` (default `false`)
sends `Accept-Encoding: gzip`; on a shared `HttpClient` it goes on each
request instead of the default headers. `WebApiTransportChannel` passes
the option through.
- **Reading gzip:** `HttpResponseBodyReader` inflates a body with
`Content-Encoding: gzip`.
- `MaxMessageSize` applies to the inflated size, so a small compressed
body cannot expand past it.
  - A corrupt gzip body is `Bad_DecodingError`.
- A content coding other than gzip or identity is rejected with
`Bad_DecodingError` instead of being decoded as if it were plain.
- **Stream ownership:** the reader releases only its inflating wrapper.
The response stream stays with the `HttpContent` the caller disposes.
- `Opc.Ua.Core` references `System.IO.Compression` on net48 explicitly
(it compiled through the expanded .NET Standard facades before, but
should not depend on them).
- `Opc.Ua.Client` grants `InternalsVisibleTo` to
`Opc.Ua.Bindings.Https.WebApi.Tests` for the internal
shared-`HttpClient` constructor.
- `docs/WebApi.md`: new section "Compressed responses".

## Tests

- **New:** `WebApiClientErrorAndCompressionTests` in
`Opc.Ua.Bindings.Https.WebApi.Tests` (stub `HttpMessageHandler`, no
server):
- error mapping: `HttpErrorStatusSurfacesAsServiceResultException` (14
cases, every row of the status table),
`ThrottledRequestSurfacesAsServerTooBusyWithRetryAfter` (429, 503),
`ConnectionFailureMapsLikeTheTransportChannel`,
`TlsHandshakeFailureMapsLikeTheTransportChannel`,
`ElapsedRequestTimeoutSurfacesAsBadRequestTimeout`,
`CallerCancellationStaysOperationCanceled`;
- gzip: `AcceptCompressedResponsesAdvertisesGzipAndDecompressesAsync`,
`SharedClientCarriesAcceptEncodingPerRequestAsync`,
`GzipResponseIsDecompressedEvenWhenNotAskedForAsync`,
`GzipBodyIsBoundedByMaxMessageSizeAfterInflating`,
`CorruptGzipIsADecodingError`, `UnsupportedContentEncodingIsRejected`,
`IdentityContentEncodingIsReadAsIsAsync`,
`ResponseStreamIsLeftToItsContentAsync` (plain and gzip).
- **Updated** (they expected the old exceptions):
-
`WebApiClientTests.InvokeAsyncThrowsServiceResultExceptionOnNon2xxStatus`
(was `InvokeAsyncThrowsHttpRequestExceptionOnNon2xxStatus`):
`Bad_InternalError` with the `HttpRequestException` as inner exception;
-
`WebApiResponseQuotaRegressionTests.RequestTimeoutRemainsActiveWhileReadingTheOpenApiBodyAsync`:
the elapsed timeout is `Bad_RequestTimeout` with the
`OperationCanceledException` as inner exception.
- **Fail without the fix:** with `WebApiClient.cs` and
`HttpResponseBodyReader.cs` from master, 24 of the 29 new test cases
fail, as do `InvokeAsyncThrowsServiceResultExceptionOnNon2xxStatus` and
three of the six
`RequestTimeoutRemainsActiveWhileReadingTheOpenApiBodyAsync` cases. The
five that pass there guard behaviour that must not change: throttling
(2), caller cancellation, identity coding, and a plain body's stream.
With a reader that disposes the response stream, both
`ResponseStreamIsLeftToItsContentAsync` cases fail.
- `Opc.Ua.Bindings.Https.WebApi.Tests` 436/436 and `Opc.Ua.Client.Tests`
2733/2733 on net10.0 and net8.0 (macOS).
- `Opc.Ua.Client` and `Opc.Ua.Core` build for net48, net8.0, net9.0 and
net10.0 with 0 warnings; `dotnet format` (whitespace, style, analyzers)
is clean on every changed file; changed lines covered 112/112 (100 %)
with the two suites above (`.azurepipelines/check-coverage.ps1` against
master).
- Fork CI (full matrix incl. macOS) and CodeQL: green — [CI run
37128530025](https://github.com/biancode/UA-.NETStandard/actions/runs/37128530025)
(154 jobs; one net48 job, `ReconnectAsyncSwapsFaultedLeaseEntryAsync` in
`Opc.Ua.Client.Tests`, timed out on the first attempt and passed on
rerun; this change does not touch reconnect), [CodeQL run
37128531971](https://github.com/biancode/UA-.NETStandard/actions/runs/37128531971).

## Notes for review

- `HttpResponseBodyReader` is shared with the binary
`HttpsTransportChannel`, so rejecting unknown content codings applies
there too. Before, such a body was decoded as plain bytes and failed
later with a less helpful error.
- The failure-below-HTTP and timeout codes reuse what the transport
channels report, so `WebApiTransportChannel`, which sends through
`WebApiClient`, keeps its codes for those cases. Its own
`HttpRequestException` path no longer sees exceptions from
`WebApiClient`; I left it in place.

## Related Issues

- Fixes #4647

## 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.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed. (Affected
suites on net10.0 and net8.0 locally on macOS; .NET Framework is covered
by the fork CI's Windows net48 legs.)
- [ ] 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: Marc Schier <marcschier@users.noreply.github.com>
2026-10-05 06:26:01 +02:00
Klaus Landsdorf 89bbc3bd75 Server: public CreateAlarm<TState> fluent overload, and conformance units that follow their contributors (#4650)
# Description

Two changes to `Opc.Ua.Server`, one commit each. Both are independent of
any companion model; the Asset Management Basics contribution (#4649) is
the first code that needs them, but any companion node manager can use
them.

**1. `CreateAlarm<TState>` on `INodeBuilder`.**

- The fluent alarm surface offers `CreateLimitAlarm`,
`CreateExclusiveLimitAlarm` and `CreateOffNormalAlarm`. The pipeline
behind them (`AttachAlarm`) is private, and `FluentNodeRegistration` is
internal.
- A node manager that needs any other condition type cannot reach that
pipeline from outside the assembly. Examples are an alarm type of a
companion specification, a `TripAlarmType` or `DiscrepancyAlarmType`, or
a server-specific subtype that implements an interface such as
`IRootCauseIndicationType`. Such a node manager has to copy the attach,
notifier and release logic, or it loses the automatic release on
teardown.
- `CreateAlarm<TState>(INodeBuilder, QualifiedName, Func<NodeState,
TState>)` exposes the same pipeline for a state the caller's factory
creates. The alarm is initialized, enabled and attached with
`HasCondition`. The parent becomes its `SourceNode`, `SourceName` and
event notifier. A fluent node manager releases all of it on teardown.
- A factory that returns `null` fails with `InvalidOperationException`
before anything is changed. The three existing helpers never take that
path, so they behave as before.

**2. `ConformanceUnitsManager` recomputes the advertised units from its
contributors.**

- On master the manager reads a contributor once, in `Register`, and
adds its units and profiles to two sets that only grow.
- A unit or profile that a contributor stops reporting therefore stays
in `ConformanceUnits` and `ServerProfileArray` until the server
restarts. One that it starts reporting after registration is never
published.
- The ServerCapabilities describe the current configuration of the
server (OPC 10000-5 §6.3.2), so both have to follow the contributor.
This matters for contributors whose support changes at runtime, for
example a node manager whose units depend on the instances registered
with it, or `HistorianConformanceContributor` after `RefreshAsync`.
- The manager now keeps the registered contributors and reads all of
them again on every `PublishAsync`:
  - `Register` is idempotent;
  - `Unregister` is new;
- publishing is serialized (`SemaphoreSlim`), so the address space ends
with the set read last;
  - the contributors are read outside the manager's lock.
- `DiagnosticsNodeManager.PublishConformanceUnitsAsync` keeps the
profiles that were in `ServerProfileArray` before the first publish,
which are the configured ones, and replaces the contributed ones on
every publish.

**Behaviour change for custom contributors.** A profile or unit that no
contributor reports any more is now withdrawn on the next publish.
Registering a contributor twice no longer reads it twice; the result is
the same set as before. A contributor whose sets are fixed at startup
sees no difference. The DI, Machinery, Pumps, Scales, Robotics and
Vision node managers register once at startup and publish the same sets
as before (see Tests).

## Changes

- `src/Opc.Ua.Server/Fluent/AlarmBuilderExtensions.cs`:
`CreateAlarm<TState>`; the null check on the factory result in
`AttachAlarm`.
- `src/Opc.Ua.Server/Server/ConformanceUnitsManager.cs`: contributor
list, `Unregister`, recompute in `PublishAsync`, publish gate, `Dispose`
releases the gate.
- `src/Opc.Ua.Server/Diagnostics/DiagnosticsNodeManager.cs`: configured
profiles captured on the first publish, contributed profiles replaced.
`IDiagnosticsNodeManager.cs` and `HistorianConformanceContributor.cs`:
documentation only.
- `docs/NodeManagers.md`: `CreateAlarm<TState>` next to the dedicated
helpers. `docs/Profiles.md`: how contributed units and profiles are
published and withdrawn.

## Tests

- **New, part 1** (`Opc.Ua.Server.Tests`, `Fluent`):
- `AlarmBuilderExtensionsTests`:
`CreateAlarmAttachesTheFactoryStateLikeTheDedicatedHelpers`,
`CreateAlarmAcceptsAConditionThatIsNoAlarm`, `CreateAlarmNullArgsThrow`,
`CreateAlarmRejectsAFactoryWithoutAnAlarm`,
`CreateAlarmOnVariableThrowsBadTypeMismatch`;
-
`FluentAlarmRegistrationIntegrationTests.CreateAlarmIsBrowsableAndFindableAsync`;
- `NodeAttachBuilderExtensionsTests`:
`GenericAlarmWiringIsUndoneOnTeardownAsync` (release on teardown).
- **New, part 2** (`Opc.Ua.Server.Tests`):
- `ConformanceUnitsManagerTests`:
`PublishWithdrawsWhatAContributorNoLongerReportsAsync`,
`RegisteringAgainReadsTheContributorOnceAsync`,
`UnregisterWithdrawsTheUnitsOfTheContributorAsync`;
-
`DiagnosticsNodeManagerTests.PublishConformanceUnits_KeepsDeclaredProfilesAndWithdrawsContributedOnesAsync`.
- **Fail without the change:** with the `src/` files of master, the part
1 tests and `UnregisterWithdrawsTheUnitsOfTheContributorAsync` do not
compile (`CreateAlarm` and `Unregister` do not exist). With that test
left out, `PublishWithdrawsWhatAContributorNoLongerReportsAsync` (the
withdrawn unit is still published),
`RegisteringAgainReadsTheContributorOnceAsync` (the contributor is not
read on publish) and
`PublishConformanceUnits_KeepsDeclaredProfilesAndWithdrawsContributedOnesAsync`
(the contributed profile stays) fail; the seven existing
`ConformanceUnitsManagerTests` pass.
- **Existing contributors:** the conformance, facet, profile and
compliance tests of the companion suites pass unchanged on net10.0:
`Opc.Ua.Di.Tests` 33, `Opc.Ua.Machinery.Tests` 19, `Opc.Ua.Scales.Tests`
5, `Opc.Ua.Robotics.Tests` 128 (incl.
`RobotIntentHostingComplianceTests`), `Opc.Ua.Vision.Tests` 9
(`VisionNodeManagerIntegrationTests`), and the whole
`Opc.Ua.Pumps.Tests` suite (73). They check the units, facets and
profiles each node manager advertises against what it built, and pass as
they do on master.
- `Opc.Ua.Server.Tests`: 7966 passed / 9 skipped / 0 failed on net10.0
(full suite); the affected tests (`Fluent`,
`ConformanceUnitsManagerTests`, `DiagnosticsNodeManagerTests`, the
historian and other conformance tests, 934) pass on net10.0 and net8.0
(macOS).
- `Opc.Ua.Server` builds for net48, net8.0, net9.0 and net10.0 without
new warnings (the only one, CA2213 in `SamplingGroup.cs`, is on master
since #4629); `dotnet format` (whitespace, style, analyzers) reports
nothing on the changed files; changed lines covered 70/70 (100 %) by
`.azurepipelines/check-coverage.ps1` with the `Opc.Ua.Server.Tests` run
on net10.0.
- Fork CI (full matrix incl. macOS) and CodeQL: [CI run
37135986886](https://github.com/biancode/UA-.NETStandard/actions/runs/37135986886)
and [CodeQL run
37135988487](https://github.com/biancode/UA-.NETStandard/actions/runs/37135988487).
CodeQL is green; CI is green except `test-windows-net48 (11/32)`, the
`Opc.Ua.Interop.Tests` legacy ComplexTypes check ("1 of N structures
were not decoded") that came with #4630 and fails the same way on other
open pull requests; this change does not touch ComplexTypes.

## Notes for review

- The two commits are independent. If you prefer to discuss the
behaviour change of part 2 separately, part 1 can go in on its own.
- `ConformanceUnitsManager` stays a non-sealed public class, as on
master.
- The Robotics Intent and Vision node managers also merge their facet
URIs into `ServerProfileArray` directly, at startup and when a
controller host is added. They report the same URIs through
`IConformanceContributor`, so a later publish keeps them. The entries
they write before the first publish count as configured, as everything
else that is in the array at that point.
- `IServerInternal` does not expose the manager since the
service-locator cleanup (#4184). A contributor that changes at runtime
reaches it through `ServerInternalData.ConformanceUnitsManager`. If you
would rather offer a narrower hook (for example a
`RepublishConformanceAsync` on the server), we can add it in this PR.

## Related Issues

- Part of #4649

## 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.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed. (Affected
suites on net10.0 and net8.0 locally on macOS; .NET Framework is covered
by the fork CI's Windows net48 legs.)
- [ ] 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: Marc Schier <marcschier@users.noreply.github.com>
2026-10-04 19:07:20 +02:00
Klaus Landsdorf 34b5b494fd docs: add Machinery, Pumps and Scales to What's New in 2.0 (#4652)
# Description

Adds the companion SDKs contributed in #4577–#4580 to
`docs/WhatsNewIn2.0.md`, short and in the style of the existing table:

- **Machinery:** OPC 40001-1/-2/-3/-4/-101 (Basic Building Blocks,
Process Values, Job Management, Energy, Result Transfer), with OPC 34100
(Energy Consumption Management) and OPC 30081 (PA-DIM) →
[Machinery](docs/Machinery.md)
- **Pumps:** OPC 40223 (Pumps and Vacuum Pumps) → [Pumps](docs/Pumps.md)
- **Scales:** OPC 40200 (Weighing Technology) with OPC 30050 (PackML) →
[Scales](docs/Scales.md)
- The Robotics row notes that OPC 10000-200 (Industrial Automation) is
its own `Opc.Ua.IA` package now.
- The paragraph that described Pumps and Machinery as sample-local
models is narrowed to the draft Generators model, which is still
sample-local.

Documentation only.

## Related Issues

- Fixes #4651

## 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.
- [ ] I have added tests that prove my fix is effective or that my
feature works and increased code coverage. (Documentation only.)
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
(Documentation only.)
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.
2026-10-04 10:27:38 +00:00
Klaus Landsdorf c801ac1d64 Robotics client: expose value NodeIds, operation states and the missing identification and motor fields (#4619)
# Description

A Robotics snapshot returns the values it read, but not the variables
they came from. A client that shows a snapshot and wants live updates
has to resolve every value again before it can subscribe. This PR adds
that information to the snapshots, along with the operation state and
the DI and motor fields the contracts did not cover. Every addition is
an init property with a default; no existing member changes.

- **Value NodeIds:** every snapshot value comes with the NodeId of its
variable, or `NodeId.Null` when the server does not publish it:
-
`AxisStateSnapshot.ActualPositionId`/`ActualSpeedId`/`ActualAccelerationId`
- `MotionDeviceSnapshot.SpeedOverrideId`, `LoadSnapshot.MassId`,
`GearSnapshot.PitchId`, `TaskModuleSnapshot.IsReferencedId`
-
`SafetyStateSnapshot.EmergencyStopId`/`OperationalModeId`/`ProtectiveStopId`,
`SafetyFunctionSnapshot.ActiveId`/`EnabledId`
-
`TaskControlSnapshot.ExecutionModeId`/`TaskProgramLoadedId`/`TaskProgramNameId`

Each id comes from the path resolution the value read already does, so
it costs no extra request.
- **Operation state:** `ControllerSnapshot.SystemOperationId`.
`ControllerSnapshot` and `TaskControlSnapshot` add `CurrentStateId`, the
`CurrentState` variable of the SystemOperationStateMachine or
TaskControlStateMachine, and `CurrentState`, the
`RoboticsOperationState` it named when read. `CurrentState` is `null`
when the state machine is absent, its read fails, or it names a state
other than Idle, Ready or Executing; the rest of the snapshot is still
returned.
- **Identification:**
`RoboticsComponentIdentification.HardwareRevision`, `SoftwareRevision`,
`ManufacturerUri`, `ProductInstanceUri` (DI properties).
- **Motor:** `MotorSnapshot.MotorTemperature` with
`MotorTemperatureEngineering` (EngineeringUnits, EURange),
`BrakeReleased` and `EffectiveLoadRate`, each with its NodeId. They are
read from the motor's DI `ParameterSet` with one
TranslateBrowsePathsToNodeIds and one Read per motor.
- The reads use the path helpers from #4609 and the generated proxies;
the per-node structure is unchanged. `docs/Robotics.md` describes the
new members with an example.

Tests:

- **`RoboticsClientServerE2eTests`** (hosted server over opc.tcp). The
fixture now publishes the identification revisions and URIs, the motor's
unit, range and load rate, the gear pitch, and a SystemOperation that
starts in Ready. The new tests:
- compare the ids with the server's own nodes, and check that reading
each id returns the snapshot value;
  - subscribe to an `ActualPositionId` and receive changing values;
- follow the task control's `CurrentState` through LoadByName and
UnloadProgram;
  - read the identification and motor fields.
- **`RoboticsClientFacadeTests`:** check the ids against the
ParameterSet children next to same-named decoys, read `CurrentState`
alongside its `Id` child, and cover an unknown state, a failing
CurrentState read, and a controller and task control without operations.
- On the base branch, the new tests do not compile because the members
are missing.

Local results:
- `UA.slnx` Release builds on all target frameworks with no new
warnings.
- `Opc.Ua.Robotics.Tests`: 650/650 on net10.0 and 590/590 on net8.0.
- Changed-line coverage against #4609: 100 % (168/168,
`check-coverage.ps1`).
- Fork CI (full matrix incl. macOS) and CodeQL on the current head
(`3dbb91392`, with master merged in): green — [CI run
36976259619](https://github.com/biancode/UA-.NETStandard/actions/runs/36976259619)
(189 jobs), [CodeQL run
36976262134](https://github.com/biancode/UA-.NETStandard/actions/runs/36976262134).

## Related Issues

- Fixes #4618

## 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.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed. (Affected
suites on net10.0 and net8.0 locally on macOS; .NET Framework via the
fork CI's Windows legs.)
- [ ] 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: Marc Schier <marcschier@users.noreply.github.com>
2026-10-03 18:14:23 +02:00
romanett efad0ddeff Server subscriptions: fix spec defects in Subscription/MonitoredItem handling found by audit (#4629)
# Description

Fixes defects found in a spec-grounded audit of the server
**Subscription / MonitoredItem** implementation
(`src/Opc.Ua.Server/Subscription/**`, `NodeManager/MonitoredItem/**`,
`EventManager`, monitored-item service dispatch and filter validation).
Each behaviour change was checked against OPC 10000-4 (§5.13, §5.14,
§7.21, §7.22), Part 5 (subscription diagnostics) and Part 9
(ConditionRefresh). Every fix has a regression test.

The audit found 56 defects (1 Critical, 3 High, 20 Medium, 32 Low). A
follow-up max-effort code review of the fix branch found 27 more issues,
mostly introduced by the first round of fixes. All but one are fixed
here.

### Most important fixes
- **Critical — remote hang:** an `AggregateFilter` with
`processingInterval = NaN` was not revised, and `AggregateCalculator`
looped forever while holding the node-manager lock, so one
CreateMonitoredItems request blocked the node manager. Non-finite, ≤0
and sub-tick intervals are now revised (Part 4 §5.13.2.1), and the
calculator rejects them as a backstop.
- **Monitored-item limits:** new
`ServerConfiguration.MaxMonitoredItemCount` /
`MaxMonitoredItemsPerSubscription`; Bad_TooManyMonitoredItems is
returned when a limit is hit, and ServerCapabilities now report the
limits. Both default to `0` (no limit), so existing behaviour is
unchanged.
- **Sampling groups:** a client could get one dedicated thread per
distinct sampling interval above 1 h; intervals above the last rate
group are now rounded, and the loop is async. An item could also end up
in two sampling groups and keep being sampled after it was deleted.
- **Subscription state machine** (Part 4 §5.14.1.2):
- the lifetime counter resets while a Publish is queued and counts
otherwise;
  - the lifetime and keep-alive stay within `MaxSubscriptionLifetime`;
  - LatePublishRequestCount is counted once per entry into LATE;
- messages held while publishing is disabled are no longer sent, and are
not offered by Transfer/Republish either;
- ConditionRefresh returns Bad_RefreshInProgress for any refresh already
queued or running on the subscription.
- **Triggering** (Part 4 §5.13.1.6):
- linked items are triggered by an item still draining a backlog, by the
first value after re-enable, and by an item created Disabled;
  - links are persisted for durable subscriptions;
- Disabled/Sampling items follow the publish rules in the shared
pipeline, so custom `IMonitoredItem` implementations follow them too.
- **Queues** (Part 4 §5.13.1.5, §7.21):
  - no duplicate value after a queue-size increase;
- EventQueueOverflow events are placed where the events were lost,
including on queue shrink;
  - the duplicate-event check scans the newest entries;
- EventQueueOverflowCount and MonitoringQueueOverflowCount are
maintained correctly;
- changing select clauses rebuilds queued events in the new layout, and
is safe on durable queues and on failure.
- **Filters:**
- per-clause EventFilterResult instead of rejecting the whole item (Part
4 §7.22.3);
- corrected status codes: Bad_DeadbandFilterInvalid,
Bad_MonitoredItemFilterInvalid, Bad_EventFilterInvalid,
Bad_MonitoringModeInvalid, Bad_TimestampsToReturnInvalid;
- aggregate filter in subscriptions: processing interval at least 2× the
revised sampling interval, revised StartTime on the requested grid,
ServerTimestamp = end of interval (Part 4 §7.22.4).
- **Lifecycle and races:**
- expired subscriptions are always cleaned up, even when the session
closes during cleanup;
  - Create/Transfer racing CloseSession returns Bad_SessionClosed;
  - session diagnostics counts no longer drift;
  - a large but valid Publish TimeoutHint no longer fails the request;
- a ready subscription is served by queued Publish requests instead of
going Late;
  - restored persisted state is validated.
- **Access:** a value change notification is checked against each
subscriber's UserAccessLevel, not the writer's.
- **Redundancy / durable store:**
  - the first-unsent sequence number is mirrored;
  - sequence numbers are no longer reused after a second failover;
- triggering links are stored (definition format 6, written only when
links exist, so older replicas can still read link-less subscriptions).

### Behaviour changes reviewers should know
- With the default config, publishing intervals above
`MaxSubscriptionLifetime / 3` (1,200,000 ms) are revised down, so
lifetime ≥ 3×keep-alive fits the configured maximum.
- The revised keep-alive is capped at a third of the max lifetime.
- Idle subscriptions expire after exactly MaxLifetimeCount cycles
without a queued Publish.
- A partly invalid EventFilter select list is accepted with per-clause
errors. The public `EventFilter.Validate` keeps Status Good if at least
one clause is valid; callers that cannot return per-clause results must
also check the new `EventFilter.Result.HasSelectClauseErrors`. There is
a migration note in `docs/migrate/2.0.x/sessions-subscriptions.md`.
- Aggregate processing intervals may be revised higher (≥ 2× sampling
interval); aggregate ServerTimestamp is now the interval end.
- Sampling intervals above 3,600,000 ms are rounded to multiples of
900,000 ms (default rate groups).

### Public API
Additive only:
- `ServerConfiguration.MaxMonitoredItemCount` and
`MaxMonitoredItemsPerSubscription`;
- `EventFilter.Result.HasSelectClauseErrors`;
- `IStoredSubscriptionTriggering`,
`ISubscriptionRetransmissionSendStateStore`,
`SubscriptionRetransmissionState.FirstUnsentSequenceNumber` (optional
store capabilities, detected with `as`);
- new `EventQueueHandler` constructor overloads.

### Known open items (not in this PR)
- **Part 4 §5.13.2.1:** CreateMonitoredItems should create an item even
when the user lacks Read permission and report the error in Publish.
This is not done: the create-time RolePermissions check is the only role
check on the initial value, so following the spec would leak values
until every node manager re-checks per sample.
- **Sampling loop:** it runs on thread-pool threads; node managers with
blocking synchronous reads should not use sampling groups heavily. The
alternative, a dedicated thread per group, was the thread-exhaustion
issue fixed here.
- **Restarts:** a pending QueueSize-1 value and a pending overflow flag
are not kept across a restart; that needs persistence interface
extensions.
- **Limit defaults:** decide whether the new limits should get finite
defaults (currently 0 = unlimited).

## Related Issues

- No tracking issue; the findings come from an internal audit of the
server subscription code.

## Checklist

- [ ] 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.
- [ ] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] 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.

### Local verification (net10.0, all green)
| Suite | Passed | Failed |
|---|---|---|
| Opc.Ua.Server.Tests | 7963 | 0 |
| Opc.Ua.Subscriptions.Tests | 643 | 0 |
| Opc.Ua.Subscriptions.Classic.Tests | 57 | 0 |
| Opc.Ua.Subscriptions.Durable.Tests | 36 | 0 |
| Opc.Ua.Redundancy.Server.Tests | 847 | 0 |
| Opc.Ua.Core.Tests (filter tests) | 283 | 0 |

net48 build of `Opc.Ua.Server.Tests`: 0 errors. net48 tests and the full
UA.slnx were not run locally.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Marc Schier <marcschier@users.noreply.github.com>
2026-10-03 05:43:16 +02:00
romanett d98eb6b28c Fix server session defects found by a spec-checked review of CreateSession, ActivateSession, session lifecycle and continuation points (#4632)
# Description

Fixes defects found by a spec-checked review of the server session
implementation: CreateSession, ActivateSession, the session lifecycle
(per-request validation, CloseSession, Cancel, timeout, shutdown) and
session continuation points. Each finding was checked against OPC
10000-4 (§5.7, §7.9, §7.32), OPC 10000-5 (session diagnostics and audit
events) and OPC 10000-6 §6.8.2.

There is one commit per finding, and each commit message cites the spec
section. A max-effort code review of the fixes then found 15 more
issues, mostly regressions the first round introduced; they are fixed in
the `revfix` merges.

## What changed

**CreateSession**
- At MaxSessionCount the oldest session that was never activated is
closed to make room (§5.7.2.1). Sessions that are activated, already
closing, or have an activation in flight are never evicted. The victim
is claimed atomically, so a late activation commit on it is rejected.
- `requestedSessionTimeout` NaN is revised. A malformed `endpointUrl` no
longer fails the call with Bad_UnexpectedError. `serverUri` is ignored,
as §5.7.2.2 requires.
- Unnamed sessions get distinct names.
- Session diagnostics now report `MaxResponseMessageSize` and
`ServerUri`, and `SessionSecurityDiagnostics.Encoding` uses the
specified names ("UA Binary", "XML", "JSON").
- AuditCreateSessionEvent carries SecureChannelId and the request
certificate, also on failure.
- Every rejected CreateSession is counted, and only once. A rejection
during server startup no longer turns into Bad_UnexpectedError.
- If an `OnApplicationCertificateError` override accepts an error, the
certificate is now kept and the remaining checks still run. A
certificate accepted this way does not grant TrustedApplication or
application-based roles.

**ActivateSession**
- EphemeralKey handling follows OPC 10000-6 §6.8.2:
- A key used to decrypt a UserName or IssuedToken secret is retired and
replaced in the same commit.
  - A key that was not used is kept, and nothing is returned.
- ECDHPolicyUri is honoured; an unsupported policy gets
Bad_SecurityPolicyRejected.
  - Request parameters are no longer echoed back.
- After the activation commits, a failure or cancellation no longer
faults the response, which would hide the new server nonce. Post-commit
callbacks run uncancellably.
- An activation that overlaps a session close or timeout fails with
Bad_SessionClosed instead of returning Good.
- The PolicyId of an AnonymousIdentityToken is validated.
- Status codes now follow §5.7.3.3:
- Bad_ApplicationSignatureInvalid for a mismatched client-signature
algorithm
- Bad_UserSignatureInvalid for a mismatched X509 user-token signature
algorithm
- Bad_IdentityTokenInvalid for an undecodable token or an unknown policy
- `ClientUserIdOfSession` keeps the user of the first activation.

**Lifecycle and requests**
- Requests on an expired or closing session are rejected with
Bad_SessionClosed (§5.7.2.1). A CloseSession on an expired session that
is not yet closing is still processed as the client's close and honours
`deleteSubscriptions`.
- Closing a session aborts its outstanding requests with
Bad_SessionClosed. A request that registers after that abort pass is
rejected too.
- Cancel completes the matching requests with
Bad_RequestCancelledByClient (§5.7.5.1). It also reaches requests still
waiting in the request queue, but only when both timestamps prove the
request was sent earlier.
- There is one AuditCancelEvent per Cancel call, carrying the client's
audit entry id and user.
- Every close path goes through a single close claim, so
SessionAbortCount, SessionTimeoutCount and the "Session/Terminated" and
"Session/Timeout" audits are each counted once.
- Teardown that started cannot be cancelled. Requests rejected by
session validation are counted.

**Continuation points**
- HistoryRead returns Bad_NoContinuationPoints instead of evicting
points of the same request (§7.9). Only the request that saved a point
pins it, so points from prior requests can still be freed.
- Points saved by a HistoryRead that faults are released.
- Session-less Browse and BrowseNext no longer dereference a null
session.

## Behaviour changes reviewers should know about
- At the session cap, CreateSession evicts a non-activated session
instead of returning Bad_TooManySessions. The eviction is counted in
SessionAbortCount.
- Clients see Bad_SessionClosed for requests on expired or closing
sessions, and Bad_RequestCancelledByClient for cancelled requests.
- `ActivateSessionProcessAdditionalParameters` is now called on every
successful activation, with or without an AdditionalHeader.
- If an `OnApplicationCertificateError` override returns instead of
throwing, CreateSession now accepts the certificate.
- Timeout and termination audits are now raised after the session
teardown.
- The only public API changes are additive overloads in `AuditEvents`
and `RequestManager`.

**Known partial fixes:**
- The session's `maxResponseMessageSize` is reported but not enforced;
only the channel limit applies.
- With overlapping ActivateSession calls on one session, an earlier
response can still carry a key that a later activation has already
replaced. Closing that fully needs a new `ISession` member.

## Related Issues

- The Redundancy.Server session-mirroring findings from the same review
are tracked separately in #4627.

## Tests
- Regression tests cover each finding. The new files are:
  - `CreateSessionSpecRegressionTests`
  - `ActivateSessionValidationRegressionTests`
  - `StandardServerActivateSessionRegressionTests`
  - `SessionLifecycleRegressionTests`
  - `SessionRequestAdmissionRegressionTests`
  - `HistoryReadContinuationDispatchTests`
  
  Existing test classes were extended too.
- Local results on net10.0:
  - `Opc.Ua.Server.Tests`: 7883 passed / 0 failed / 5 skipped
- `Opc.Ua.Sessions.Tests`: 790 passed / 0 failed / 379 skipped (same as
master)
- net48 Release build of `Opc.Ua.Server.Tests`: 0 errors / 0 warnings.

## 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.
- [ ] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] 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.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 05:42:01 +02:00
Klaus Landsdorf 5d26681ac3 Machinery client: return the NodeId of each metering point measurement (#4642)
# Description

`MachineryMeasurementValue` gets an additive init-only `NodeId`; the
positional constructor is unchanged. `ReadMeteringPointAsync` fills it,
and so do `ReadMainMeteringPointAsync` and `ReadSubMetersAsync`, which
use it.

The values are now read by the NodeIds the browse returned, in one
batched Read, instead of translating each browse name back to a NodeId
first. That is one round trip less, and value and NodeId come from the
same node.

`docs/Machinery.md` shows how to subscribe to the measurements.

Tests:

- New end-to-end test
`MeteringPointMeasurementsCarryTheNodeIdOfTheirVariableAsync` against
`MachineryIntegrationServer`, covering the main metering point and the
sub-meter. It reads `BrowseName` and `Value` behind every returned
NodeId. It fails on master.
- `Opc.Ua.Machinery.Tests` 165/165 on net10.0; all target frameworks
build with 0 warnings.
- Fork CI (full matrix incl. macOS) and CodeQL: green on `afa00065a`
(the same change on master `ce88955f7`) — [CI run
36826789991](https://github.com/biancode/UA-.NETStandard/actions/runs/36826789991)
(189 jobs), [CodeQL run
36826793071](https://github.com/biancode/UA-.NETStandard/actions/runs/36826793071).
Rebasing onto master with #4607 needed one conflict resolution in
`MachineryEndToEndTests.cs` (both new tests kept);
`Opc.Ua.Machinery.Tests` passes 165/165 on net10.0 locally afterwards
and CodeQL is green on that state ([run
36979020583](https://github.com/biancode/UA-.NETStandard/actions/runs/36979020583)).
The run on the current head is in progress: [CI run
37016200389](https://github.com/biancode/UA-.NETStandard/actions/runs/37016200389).

## Related Issues

- Fixes #4641

## 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.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed. (Affected
suites on net10.0 and net8.0 locally on macOS; .NET Framework is covered
by the fork CI's Windows net48 legs.)
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.
2026-10-02 23:21:25 +02:00
romanett fbdd0af13b Add 1.5.378 <-> 2.0 interop tests; return BadAttributeIdInvalid for unset optional security attributes (#4630)
# Description

Adds an interop test suite between this stack (2.0) and the released
**1.5.378** NuGet stack, in both directions, and fixes a 2.0 bug the
suite found.

## Interop suite

- **`tests/Opc.Ua.Interop.LegacyPeer`** is a console peer built from the
`OPCFoundation.NetStandard.Opc.Ua.*` 1.5.378 NuGet packages.
- It runs as a child process, because the two stacks share assembly
names.
- It deliberately does not import the repository build settings (its own
`Directory.Build.props/targets/Packages.props`).
- Server mode hosts a small interop server with tight limits, or the 1.5
Quickstarts reference server.
- Client mode runs named checks and prints one JSON result line per
check.
- **`tests/Opc.Ua.Interop.Tests`** builds the peer into `legacy-peer/`
and runs in every CI test leg as a normal `*.Tests.csproj`. It covers:
- sessions over None, the RSA policies and the ECC policies (Sign and
SignAndEncrypt), with anonymous and user name logon, plus
read/write/browse/call/subscribe;
- the existing 2.0 `ClientTest` workers run against the 1.5 reference
server, through the new `ClientTestFramework.ExternalServerUrl`;
- custom data types (structures, unions, optional fields, enums) loaded,
decoded and written back by each side's complex type system;
- certificate trust with auto-accept off: untrusted peers, CA-issued
certificates, revoked certificates;
- multi-chunk messages, encoding limits, response size limits, operation
limits, continuation points and security token renewal (RSA and ECC).
- Every peer check is reported as its own test result.
- `-p:LegacyStackVersion=<version>` tests another 1.5 release.
- Docs: `docs/DeveloperGuide.md`.

## 2.0 fix

**Unset optional security attributes now return
`Bad_AttributeIdInvalid`** (`NodeState.ReadNonValueAttribute`).
- Since 3e5f129c6, unset
RolePermissions/UserRolePermissions/AccessRestrictions returned Good
with a null value.
- This made the 1.5 ComplexTypes loader crash against any 2.0 server.
- 1.5 and Part 4 (Read) return `Bad_AttributeIdInvalid` here.

The suite also found two ECC secure-channel bugs: the Sign-only HKDF
salt length, and renewal chaining the previous secret for policies
without SecureChannelEnhancements. #4626 fixed both on master in the
meantime, so this branch now uses master's version. The ECC Sign and ECC
token-renewal interop tests here cover them.
## Reviewer notes

- **Behaviour change:** `NodeStateOptionalStorageTests` previously
asserted Good with a null value for unset optional attributes. It now
asserts `BadAttributeIdInvalid`.
- Monitored items on such unset attributes now fail with
`BadAttributeIdInvalid`, as in 1.5.
- **Known gaps, not changed here:**
- An unset Description still reads Good with a null value (same root
cause).
- A Null write to AccessRestrictions is accepted, as `NodeStateTests`
asserts.
- **Test runtime:** the suite adds roughly 7 minutes per CI test leg. On
net48 the ECC cases skip, because 2.0 has no ECC on .NET Framework.

## Related Issues

- None filed.

## Checklist

- [ ] 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.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- Ran: interop suite on net10 (188 passed) and on net48 (passed before
the final review fixes), plus Types, Core, InformationModel,
Core.Security and Sessions on net10.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 19:35:44 +02:00
Copilot 6b8b2eb79b Tag promoted NuGet package releases (#4605)
# Description

Promoted NuGet packages lacked a corresponding repository tag. The
promotion workflow now records each published candidate with an
immutable version tag and matching GitHub Release.

### Promotion tagging
- Creates a tag from the signed candidate manifest after both feeds
accept the package set.
- Creates a GitHub Release; preview channels are marked prerelease.
- Validates existing tags/releases on recovery and rejects mismatched
targets.

```text
2.0.0-preview.5 -> bfc4c1b03c
```

### Coverage and documentation
- Adds workflow-contract regression coverage.
- Updates release procedures to describe automatic tagging and
verification.

## Related Issues

## Checklist

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

<!-- START COPILOT CODING AGENT SUFFIX -->

- Fixes #4601

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: marcschier <11168470+marcschier@users.noreply.github.com>
Co-authored-by: romanett <7413710+romanett@users.noreply.github.com>
Co-authored-by: Marc Schier <marcschier@users.noreply.github.com>
2026-10-02 18:43:50 +02:00
romanett 61edaa1ed0 Remove netstandard2.1 and net472 target frameworks (#4624)
# Description

Drops support for **netstandard2.1** and **.NET Framework 4.7.2**.
Libraries now target `net48`, `net8.0`, `net9.0` and `net10.0`.
`Opc.Ua.Types` and `Opc.Ua.SourceGeneration.Core` still also target
`netstandard2.0`, but only because the Roslyn source generators load
them. There is no general netstandard2.0 support.

**Preprocessor directives** (112 files)
- Every `#if`/`#elif` mentioning `NETSTANDARD2_1*`, `NET472*`,
`NET_STANDARD_TESTS`, `SKIP_ECC_CERTIFICATE_REQUEST_SIGNING` or
`NETSTANDARD*` was evaluated against the remaining TFM set (net48,
net8/9/10, plus netstandard2.0 inside `Opc.Ua.Types`/`tools`) and
simplified. Dead branches were removed.
- Example: `NETSTANDARD2_1 || NET8_0_OR_GREATER` → `NET8_0_OR_GREATER`.
- Example: `NET8_0_OR_GREATER || NET472_OR_GREATER` → removed (always
true).
- Netstandard-only code was removed: `UnixFileModeShim`, the CRDT
reflection path in `ReplicatedGossipOptions.CreateReaderOptions`, and
the netstandard2.1-only test variants.

**Custom test logic**
- `targets.props` drops the `net472`, `netstandard2.0` and
`netstandard2.1` `CustomTestTarget` profiles, `NetStandardTests` and
`SKIP_ECC_CERTIFICATE_REQUEST_SIGNING`. Supported values are now
`net48`, `net8.0`, `net9.0`, `net10.0`.
- The `NET_STANDARD_TESTS` define is removed from 29 test projects.
- The "build netstandard shells as net10.0" overrides are removed from
18 projects.
- `RestrictForLegacyTfm` now only applies to `net48`.
- `tests/customtest.bat` is updated to the new values.

**Build / packaging / CI**
- MSBuild TFM conditions are simplified: Kestrel package choice, MQTTnet
v4 (net48 only), BouncyCastle, Di/OpenUsd/Vision/Stress test excludes,
and `System.IO.FileSystem.AccessControl` (dropped from Core).
- The MigrationAnalyzer nuspec drops its net472/netstandard2.1 lib
groups. The Azure signing lists are updated.
- `get-ci-matrix.ps1` removes the net472/netstandard test profiles
(including the PR-gating `linux-netstandard2.1`) and the matching build
legs.

**Breaking API change (2.0 preview):** removed
`HttpsTransportListener.IsWssTransportSupported` and
`ReplicatedGossipOptions.IsGossipStateDecodingSupported`. They only
returned `false` on netstandard2.1 and are now always `true`. The test
guards that used them were removed as well.

**Docs**
- NuGet READMEs, `README.md`, `DeveloperGuide.md`, `PubSub.md`, the
certificate/ECC/identity docs and the 2.0 migration guide (plus its
`.agents` skill mirror) are updated.
- The migration guide notes that 4.7.2 consumers must move to 4.8 and
netstandard2.1 consumers to net8.0+.

### Reviewer notes
- The `System.IO.FileSystem.AccessControl` `PackageVersion` pin is left
in `Directory.Packages.props`. It has no direct reference any more, but
transitive pinning is on, so it was kept to avoid shifting resolved
versions.
- The `#if` rewrite was done mechanically by a truth-table evaluator.
The diff in the `.cs` files is limited to directive lines, removed dead
branches, and stale-comment updates.

### Verified locally
- `dotnet build UA.slnx -p:CustomTestTarget=net48`: 0 errors.
- `dotnet build UA.slnx -p:CustomTestTarget=net10.0`: 0 errors.
- Default multi-target builds (no `CustomTestTarget`) of Opc.Ua.Types,
Bindings.Https, PubSub.Mqtt and Redundancy.Server succeeded.
- net10.0 tests:
  - Opc.Ua.Redundancy.Server.Tests: 840/840 passed.
  - Opc.Ua.Types.Tests: 11363 passed, 1 skipped.
  - ModelDependencyScanner tests: 20/20 passed.
- The full test suite was **not** run locally.

## Related Issues

- None

## Checklist

- [ ] 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.
- [ ] 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.
- [ ] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] 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.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 17:09:49 +02:00
Klaus Landsdorf 029ac0f33f Kubernetes readiness: create the listener on Start and move the default probe port off 8080 (breaking default) (#4613)
# Description

Two commits that can be taken separately.

1. **`fix(kubernetes): create the readiness listener only when
started`** (not breaking). The managed HttpListener (Linux, macOS) binds
its endpoint when a listener that has prefixes is closed. Because
`KubernetesReadinessServer` created the listener in its constructor,
disposing a server that was never started bound the probe port and threw
"address already in use" if anything else held it. The listener is now
created in `Start`, and `Start` after `DisposeAsync` throws
`ObjectDisposedException` explicitly. With port 8080 occupied, master
fails 4 of 99 tests in `Opc.Ua.Redundancy.Kubernetes.Tests`; with this
commit all pass. New test:
`DisposingAnUnstartedServerDoesNotBindItsPort`.

2. **`fix(kubernetes)!: move the readiness probe off the web-application
port 8080`** — **breaking (default only):**
`KubernetesReadinessOptions.Port` now defaults to the new
`KubernetesReadinessOptions.DefaultPort` = 4852 instead of 8080.
- 8080 is the de-facto default of web applications and reverse proxies,
and the probe shares the pod (with hostNetwork, the node) with the
application and its sidecars.
- 4852 sits next to the OPC UA ports 4840/4843 and is unassigned in the
IANA registry (4852–4866, checked 2026-09-30). One de-facto use we
found: Beckhoff IPCs run an "IPC Diagnostics" OPC UA server on 4852 on
Windows images; that only matters with hostNetwork on such a node. If
you prefer another port from the unassigned range, commit 1 stands on
its own.
- Migration: set the manifest `containerPort` to 4852, or keep the old
port with `UseKubernetesReadiness(o => o.Port = 8080)`.
`docs/Kubernetes.md`, `NugetREADME.md` and a new
`docs/MigrationGuide.md` section are updated.
- The package has shipped only in 2.0.0 previews, so changing the
default before GA avoids locking the collision in.

Tests: `Opc.Ua.Redundancy.Kubernetes.Tests` 100/100 on net10.0 and
net8.0. Fork CI (full matrix incl. macOS) and CodeQL: green — [CI run
36769864949](https://github.com/biancode/UA-.NETStandard/actions/runs/36769864949)
(full matrix incl. macOS), [CodeQL run
36769869626](https://github.com/biancode/UA-.NETStandard/actions/runs/36769869626).

## Related Issues

- Fixes #4612

## 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.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed. (Affected
suites on net10.0 and net8.0 locally on macOS; .NET Framework is covered
by the fork CI's Windows net48 legs.)
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.
2026-10-01 15:06:06 +00:00
Klaus Landsdorf f20d94fcac Robotics: resolve client reads with namespaces and ParameterSet; call ResetToProgramStart on the ReadySubstateMachine (#4609)
# Description

`Opc.Ua.Robotics.Client` read almost nothing from a real OPC 40010-1
server. Every member was looked up with a namespace-0 browse name, and
process values were looked for directly below the component instead of
below the DI `ParameterSet`. #4569 made motors, gears and drives
resolve; the values, identification and enumerations of every snapshot
still came back empty, and the Observe methods tried to monitor
`NodeId.Null`. Separately, `ResetToProgramStartAsync` called the method
on the `Ready` state, which has no such method.

This is the minimal fix on the current structure; the generated proxies,
the per-node reads, the motor and gear lookup by type and the IsDrivenBy
drive lookup with its de-duplication stay as they are. The public API is
unchanged.

- **Reads:** the resolve helpers take a `QualifiedName[]` path built
from the session's namespace table — DI browse names for identification
properties, Robotics browse names for members of the component, and
`DI:ParameterSet/<Robotics name>` for process values
(ActualPosition/Speed/Acceleration, SpeedOverride, EmergencyStop,
OperationalMode, ProtectiveStop, ExecutionMode, TaskProgramLoaded,
TaskProgramName). A namespace the server does not publish makes the path
not resolve instead of matching namespace 0.
- **Observe methods:** `ObserveAxisAsync` and `ObserveSafetyAsync`
subscribe only to values that resolve, and end when none do.
- **ResetToProgramStart:** called on the TaskControlStateMachine's
`ReadySubstateMachine`; if it is missing, the call fails with
Bad_NotFound.
- `docs/Robotics.md` describes how the snapshot reads map to the OPC
40010-1 layout.

Tests:

- `RoboticsClientServerE2eTests` (new, all target frameworks): a
six-axis cell built with the Robotics builders and hosted over opc.tcp.
It checks the `ReadSystemAsync` values, that `ObserveAxisAsync` delivers
changing positions, and that the server's `ResetToProgramStart` handler
runs. All three fail on master.
- `RoboticsClientFacadeTests`: the scripted session now matches browse
paths by the full QualifiedName, lays nodes out as the NodeSet does, and
adds namespace-0 and wrong-level decoys; two new tests check the object
ResetToProgramStart is called on and the Bad_NotFound case.

Local results on the branch: `UA.slnx` Release builds on all target
frameworks with no new warnings; `Opc.Ua.Robotics.Tests` 641/641 on
net10.0 and 581/581 on net8.0; changed-line coverage 97.9 %
(`check-coverage.ps1`). Fork CI (full matrix incl. macOS) and CodeQL:
green — [CI run
36755707494](https://github.com/biancode/UA-.NETStandard/actions/runs/36755707494)
(189 jobs incl. macOS), [CodeQL run
36755711056](https://github.com/biancode/UA-.NETStandard/actions/runs/36755711056).

Possible follow-ups, not part of this PR: reading the topology level by
level in batches (one TranslateBrowsePathsToNodeIds, Browse and Read per
level) and filling the axis engineering units and limits, the load mass
unit and range, and `GearRatio`, which the snapshot contracts define but
the client does not read yet.

## Related Issues

- Fixes #4608

## 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.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed. (Affected
suites on net10.0 and net8.0 locally on macOS; .NET Framework is covered
by the fork CI's Windows net48 legs.)
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.
2026-10-01 13:30:06 +00:00
Klaus Landsdorf e0ab811c03 Scales and Machinery: advertise only the facets and units the server satisfies (#4615)
# Description

The Scales and Machinery node managers publish their facets and
conformance units through `IConformanceContributor`. This aligns what
they advertise with the published tables.

**Machinery** (`fix(machinery): advertise the ProcessIrregularity job
result unit`)
- Adds *Machinery Job Management Result ProcessIrregularity* to the
units
  reported for `WithPredefinedParameters()`. OPC 40001-3 Table 24 lists
  it, the Jobs NodeSet carries it as a `<Category>`, and
  `MachineryJobParameters` already recognises the parameter.
- New test
`PredefinedParametersAdvertiseEveryParameterUnitOfTheBaseFacetAsync`
  checks all Table 24 parameter units.

**Scales** (`fix(scales): advertise only the facets the runtime
satisfies`)
- Base Scale (and every kind facet), Scale System, Feeder Module and
  Printer Module include the Machinery Machine Identification facet
  (OPC 40200 Tables 164 to 167). While every published scale and scale
  system is organized into the `Machines` folder, the server now also
advertises that facet and its mandatory units *Machinery Find Machines*
and *Machinery Machine Identification*. When a scale is not in the
folder
  (`OrganizeIntoMachinesFolder = false`, or the folder is missing) those
facets are no longer advertised. Production preset and SI unit facets do
  not include Machine Identification and are unaffected.
- The Totalizing Hopper, Catchweigher and AutomaticWeightPriceLabeler
kind
  facets and units are no longer claimed: no controller drives them yet
(known limitations in `docs/Scales.md`). The scales still count towards
  Base Scale.
- *PackML State Information* (Table 164, optional) is advertised when a
  scale or scale system runs a PackML state machine, qualified with the
PackML namespace as the table writes it. Machinery units use namespace
0,
  as `MachineryNodeManager` reports them.
- `docs/Scales.md` updated (Machines folder, options table, conformance
  matrix, known limitations).
- New tests: `ScaleFacetsBringTheMachineIdentificationTheyIncludeAsync`,
  `ScaleFacetsAreNotClaimedOutsideTheMachinesFolderAsync`,
  `KindFacetsWithoutAControllerAreNotClaimedAsync`,
  `PackMLStateInformationIsClaimedWithAPackMLStateMachineAsync`.

Each new test fails against master and passes with the change.

Fork CI (full matrix incl. macOS) and CodeQL: green — [CI run
36779541067](https://github.com/biancode/UA-.NETStandard/actions/runs/36779541067),
[CodeQL run
36779544537](https://github.com/biancode/UA-.NETStandard/actions/runs/36779544537).

## Related Issues

- Fixes #4614

## 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.
- [ ] 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.
2026-10-01 15:12:44 +02:00
Klaus Landsdorf c189cd6fcb Include subtypes of AliasNameCategoryType in AliasNameClient.EnumerateSubCategoriesAsync (#4611)
# Description

`AliasNameClient.EnumerateSubCategoriesAsync` kept a browsed child only
when its TypeDefinition was exactly `AliasNameCategoryType`. OPC
10000-17 §6.3.1 declares `<SubAliasNameCategories>` as an
OptionalPlaceholder of `AliasNameCategoryType` and does not rule out
subtypes, so sub-categories modelled with a subtype were silently
dropped.

The exact type is still accepted without a round trip; any other type
definition is checked against the type hierarchy with
`Session.NodeCache.IsTypeOfAsync` (the pattern `RoleManagementClient`
uses for `RoleType`). The node cache keeps the type references, so each
distinct type is resolved once. The check runs on the first browse page
and on every BrowseNext page. `docs/AliasNames.md` notes that subtypes
are included.

Tests: the mocked session harness gets a node cache that answers
`IsTypeOfAsync` from a subtype map; a new test covers the exact type, a
direct subtype, a subtype of a subtype, a FolderType child and an
untyped child. It fails on master (`Missing: < "Vendor", "Derived" >`)
and passes with the fix. `Opc.Ua.Client.Tests` 2733/2733 on net10.0 and
net8.0; `Opc.Ua.Features.Tests` AliasNameIntegrationTests (real server)
7/7. Fork CI (full matrix incl. macOS) and CodeQL: green — [CI run
36769850776](https://github.com/biancode/UA-.NETStandard/actions/runs/36769850776)
(full matrix incl. macOS), [CodeQL run
36769855329](https://github.com/biancode/UA-.NETStandard/actions/runs/36769855329).

## Related Issues

- Fixes #4610

## 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.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed. (Affected
suites on net10.0 and net8.0 locally on macOS; .NET Framework is covered
by the fork CI's Windows net48 legs.)
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.
2026-10-01 06:52:04 +00:00
Klaus Landsdorf dadd609ec0 Scales: the OPC 40200 companion SDK with the OPC 30050 PackML model (#4580)
> Tracking issue #4576. #4577, #4578 and #4579 are merged, so this
branch is
> rebased onto `master` and contains only the Scales and PackML commits.

## Summary

Last of the four PRs of #4576. Adds OPC 40200 Weighing Technology 2.00
as a
model package plus a server and a client SDK, and the OPC 30050 PackML
model
it composes.

| Package | Content |
|---|---|
| `Opc.Ua.PackML` | OPC 30050 PackML 1.01, plus
`PackMLStateMachineController`, which drives a base state machine
together with its nested machine and execute state machines (activation
and deactivation of the sub-state machines, the PackML methods with an
`Executable` attribute that follows the state, acting states left
automatically or by the equipment) |
| `Opc.Ua.Scales` | OPC 40200 2.00
(`http://opcfoundation.org/UA/Scales/V2/`), generated from the
unmodified UA-Nodeset publication, plus the contracts client and server
share |
| `Opc.Ua.Scales.Server` | `AddScales()` / `ConfigureScales(...)`; all
fourteen scale kinds and scale systems below `DeviceSet` and `Machines`;
a runtime that does the weighing arithmetic (ranges, rounding to d or e,
zero band, overload/underload, zero, tare, preset tare, registration),
production presets with DI locking, statistics, recipes and recipe
files, Annex C events and alarms over a `HasNotifier` hierarchy, and the
methods of every scale type |
| `Opc.Ua.Scales.Client` | discovery, weight reads and streaming,
zero/tare/register, products, PackML state and commands, type-specific
methods, events and alarms, snapshots |

`docs/Scales.md` includes a conformance matrix against the 21 OPC 40200
server
facets with test evidence, the places where the specification and the
NodeSet disagree, and known limitations.

## Review follow-ups

Separate commits after the docs/samples commit, each with a test that
fails without it where the defect can be observed.

- Copilot review: `CurrentProductsChanged` fires only when the set of
  current products changes; the new public collection APIs
  (`ScalesServerOptions.AdditionalNamespaceUris`,
  `ScaleNamespaceIndices.SearchOrder`, `ScalesModel.AllKinds`) are
  `ArrayOf<T>`.
- Own review with the rubric in `.github/copilot-instructions.md`:
  - PackML raises `StateChanged` after its lock is released (queued, in
    order), reads `StartParameters` under the lock, and reports the
    handler's status when an acting state cannot complete.
- Scales: concurrent first creations share one runtime (the lock service
    refused a second attach); two creations of the same name cannot both
publish; a removed product id stays reserved until its node is gone; the
    zone, target-count and vehicle handlers change products under the
preset lock; the product factory and `configure` delegate run outside
    it; the recipe upload reads `ActiveRecipe` under the lock; resetting
    the statistics also resets the tolerance percentage.
- The client lets a failed browse call propagate instead of reading it
as
    "no children".

## Testing

- `tests/Opc.Ua.Scales.Tests`: 115 tests on net8 and net10 and with the
netstandard2.1 libraries, including the PackML runtime; the
client/server
  tests connect a real session over `opc.tcp` to a hosted
  `ScalesNodeManager`.
- `UA.slnx` builds for net472, net48, netstandard2.0/2.1 and net8/9/10
with
  SDK 10.0.401 and no new warnings.
- Changed-line coverage of this layer (`check-coverage.ps1`): 94.59 %
(4585 of 4847 changed lines, against `master`).
- Fork CI, full PR matrix incl. macOS: in progress, [run
36676067489](https://github.com/biancode/UA-.NETStandard/actions/runs/36676067489);
CodeQL: in progress, [run
36676070427](https://github.com/biancode/UA-.NETStandard/actions/runs/36676070427).
- On the current `master` (with Pumps merged), `UA.slnx` builds for
every target
  framework and the Scales and Pumps tests pass.
- Beyond the in-repo tests the implementation has been validated end to
end
  against a separate, non-public OPC UA client and server.

## Reviewing

Commits: PackML → Scales model → server → client → tests → docs and
samples
→ review follow-ups.
2026-09-30 10:54:48 +02:00
Klaus Landsdorf 281596a24d Pumps: the OPC 40223 companion SDK (#4579)
> Tracking issue #4576. #4577 and #4578 are merged, so this branch is
rebased
> onto `master` and contains only the Pumps commits.

## Summary

Third of four stacked PRs. Adds OPC 40223 Pumps and Vacuum Pumps as a
model
package plus a server and a client SDK, over the Machinery and DI
packages.

| Package | Content |
|---|---|
| `Opc.Ua.Pumps` | OPC 40223 1.0.0, generated from the unmodified
UA-Nodeset publication, plus the snapshot contracts both sides share
(`PumpNameplate`, `PumpConfigurationData`, `PumpOperationalData`,
`PumpMaintenanceData`, `PumpSupervisionStatus`, `PumpPortDescriptor`,
`MultiPumpConfiguration`, gathered into `PumpSnapshot`) |
| `Opc.Ua.Pumps.Server` | `AddPumps()` / `ConfigurePumps(...)`;
`PumpsNodeManager : DiNodeManager` places each pump below `DeviceSet`
and organizes it into the Machinery `Machines` folder;
browse-name-addressed builders reach all ~3000 optional children without
a method per child and create them through the model's own `CreateChild`
|
| `Opc.Ua.Pumps.Client` | discovery in both places (de-duplicated,
vendor subtypes included), a reader per `PumpType` group, whole-pump
snapshots; the open groups come back as a `PumpValueSet` keyed by the
generated `BrowseNames` |

The model requires Machinery 1.0.0 and DI 1.02.2 and resolves against
the
newer Machinery 1.04.1 and DI packages in this repository, since a
`RequiredModel` version is a lower bound. The OPC 40223 NodeSet is
large, so
the model commit is too; the hand-written code starts at the server
commit.

`samples/DI/PumpDeviceIntegrationServer` is left as it is: its local
NodeSet
copies are also fixtures of `Opc.Ua.Types.Tests` and
`Opc.Ua.WotCon.Tests`.
Moving it onto these packages would be a separate follow-up (see #4576).

## Review follow-ups

Separate commits after the docs/samples commit, each with a test that
fails without it.

- Copilot review: `PumpValue.AsStringArray` and `PumpValueSet.Names`
  return `ArrayOf<string>` with its null sentinel instead of
  `Nullable<ArrayOf<string>>`; the `PumpBuilder` uses `Variant.Null`.
- Own review with the rubric in `.github/copilot-instructions.md`: pump
creation is serialized from the duplicate check to the registration, so
  two concurrent calls with the same name no longer both add a pump; the
client lets a failed browse call propagate instead of reading it as "no
children"; the `ReadSupervisionAsync` docs say which read options apply.

## Testing

- `tests/Opc.Ua.Pumps.Tests`: 73 tests on net8 and net10 and with the
netstandard2.1 libraries; the client/server tests connect a real session
  over `opc.tcp` to a hosted `PumpsNodeManager`.
- `UA.slnx` builds for net472, net48, netstandard2.0/2.1 and net8/9/10
with
  SDK 10.0.401 and no warnings.
- Changed-line coverage of this layer (`check-coverage.ps1`): 97.30 %
(1550 of 1593 changed lines, against `master`).
- Fork CI, full PR matrix incl. macOS: green, all 184 jobs
([run
36622243774](https://github.com/biancode/UA-.NETStandard/actions/runs/36622243774));
CodeQL: green
([run
36622248255](https://github.com/biancode/UA-.NETStandard/actions/runs/36622248255)).
- Together with the other layer (Pumps and Scales merged on top of
Machinery),
built for every target framework and tested on net8, net10 and with the
  netstandard2.1 libraries, with the samples published.
- Beyond the in-repo tests the implementation has been validated end to
end
  against a separate, non-public OPC UA client and server.

## Reviewing

Commits: model package → server → client → tests → docs and samples →
review follow-ups.
2026-09-30 07:23:29 +02:00
Klaus Landsdorf 01d6b5d26c Machinery: the OPC 40001 companion SDK with Jobs, Result Transfer, Process Values and Energy (#4578)
> Tracking issue #4576. #4577 is merged, so this branch is rebased onto
> `master` and contains only the Machinery commits.

## Summary

Second of four stacked PRs. Adds the OPC UA for Machinery series as
model
packages plus a server and a client SDK, following the companion pattern
of
DI and Robotics, and the two models it depends on that the repository
does
not carry yet.

| Package | Content |
|---|---|
| `Opc.Ua.IA` | OPC 10000-200 IA 1.01.4, **moved out of
`Opc.Ua.Robotics`** (see below) |
| `Opc.Ua.ECM` | OPC 34100 Energy Consumption Management 1.0.1 |
| `Opc.Ua.PADIM` | OPC 30081 PA-DIM 1.02.0 with the IRDI dictionary |
| `Opc.Ua.Machinery` | OPC 40001-1 Basic Building Blocks 1.04.1 |
| `Opc.Ua.Machinery.ProcessValues` | OPC 40001-2 Process Values 1.00.0 |
| `Opc.Ua.Machinery.Jobs` | OPC 40001-3 Job Management 1.0.1 |
| `Opc.Ua.Machinery.Energy` | OPC 40001-4 Energy 1.00 |
| `Opc.Ua.Machinery.Result` | OPC 40001-101 Result Transfer 1.01.0 |
| `Opc.Ua.Machinery.Server` | `AddMachinery()` /
`ConfigureMachinery(...)`, fluent machine builder, server-driven state
machines, process values, job management over the shared ISA-95 V2
binder, energy, result management incl. `GenerateFileForRead` |
| `Opc.Ua.Machinery.Client` | machine discovery, identification, state
observation, process values, job and result access |

OPC 40001-101 `ResultTransferType` redeclares `GenerateFileForRead` with
its
own arguments; the generator fix for such overrides, merged with #4577,
is
what makes it appear on the `ResultTransfer` instances.

All NodeSets are byte-identical to `UA-Nodeset/latest`. Each model
carries a
pinned `Model/*.NodeIds.csv` the generator validates, next to the
unmodified
upstream table; `tools/nodesets/derive-identifier-table.py` derives the
former from the latter.

## Changes to existing code

- **IA moves out of Robotics.** Machinery needs IA as well (OPC 40001-1
types
`Stacklight` with `BasicStacklightType`), and two assemblies generating
the
same model under the same prefix collide at any consumer referencing
both.
The NodeSet moves verbatim, so no generated type changes; the prefix
stays
`Opc.Ua.IA`. Source-compatible, but IA types now live in `Opc.Ua.IA.dll`
rather than `Opc.Ua.Robotics.dll` — binary-breaking for consumers of the
  Robotics package.
- **`samples/OpenUsd/GeneratorServer`** drops its hand-reduced Machinery
  NodeSet copy and the script that produced it (a workaround for the
generator defect fixed by #4577) and references `Opc.Ua.Machinery`
instead.
- `README.md` and `docs/README.md` list the new models; the README also
  corrects the Industrial Automation entry (Part 200, not OPC 40001-1).

## Review follow-ups

Separate commits after the docs/samples commit, so the reviewed state
stays
comparable. Each fix comes with a test that fails without it.

- Copilot review: a transition is committed only while the state machine
is
still in its source state; optional Jobs/Result namespaces the server
does
not load are reported as absent, as documented; resources of registered
machines (result transfer, job-management change pumps) are released
with
  the node manager (`DisposeMachineryResourcesAsync` for a custom DI
  manager); `NodeId`/`LocalizedText` use their null sentinels instead of
  `Nullable<T>`.
- Own review with the rubric in `.github/copilot-instructions.md`: a
failed
browse call propagates from the client instead of reading as "no
children";
  the predefined result metadata compares `CreationTime` against the
  `DateTimeUtc` sentinels (the `DateTime` conversion shifted
  `DateTime.MaxValue` by the host's UTC offset); process values below
`Monitoring` are found; a second metering point named `Main` is
rejected;
  the zero-point event is reported when the handler throws; smaller
  state-sharing and documentation corrections.

## Testing

- `tests/Opc.Ua.Machinery.Tests`: 162 tests on net8 and net10 and with
the
netstandard2.1 libraries; the integration tests connect a real client
session over `opc.tcp` to a hosted
  server.
- With IA extracted: Robotics 636, OpenUsd 921, DI 433 and
SourceGeneration
  263 tests pass.
- `UA.slnx` builds for net472, net48, netstandard2.0/2.1 and net8/9/10
with
  SDK 10.0.401 and no warnings.
- Changed-line coverage of this layer (`check-coverage.ps1`): 91.68 %
(6059 of 6609 changed lines, against `master`).
- Fork CI, full PR matrix incl. macOS, on the current master: green, all
184
jobs ([run
36582685653](https://github.com/biancode/UA-.NETStandard/actions/runs/36582685653)).
CodeQL: green ([run
36582689589](https://github.com/biancode/UA-.NETStandard/actions/runs/36582689589)).
- Beyond the in-repo tests the implementation has been validated end to
end
  against a separate, non-public OPC UA client and server.

## Reviewing

Commits are ordered so each builds: IA extraction → identifier-table
tool →
ECM/PA-DIM → Machinery models → server → client → tests → docs/samples →
GeneratorServer sample → review follow-ups. The model commits are large
only because of the
NodeSets and identifier tables; the hand-written code starts at the
server
commit.

---------

Co-authored-by: Marc Schier <marcschier@users.noreply.github.com>
2026-09-29 19:59:34 +02:00
romanett 878301895b Document the nuget.org package changes: no .Debug packages, Opc.Ua.Symbols discontinued (#4589)
# Description

Documents the 2.0 package changes on nuget.org for consumers: `.Debug`
packages are no longer published there, and
`OPCFoundation.NetStandard.Opc.Ua.Symbols` is discontinued.

Up to `1.5.378` (for example
`OPCFoundation.NetStandard.Opc.Ua.Core.Debug` `1.5.378.176`) and through
`2.0.0-preview.5`, every package also had a `.Debug` build on nuget.org.
From `2.0.0-preview.6` on, `release.yml` publishes only non-`.Debug`
package IDs and their `snupkg` symbols to nuget.org. The `.Debug`
packages are still built for every release and go to GitHub Packages
only.

## Changes

- **`docs/migrate/2.0.x/packages.md`**: new section "`.Debug` packages
are no longer published to nuget.org". It gives two ways forward:
- Switch to the Release package, using the nuget.org symbol packages and
Source Link for debugging.
- Keep the `.Debug` package by adding the authenticated GitHub Packages
feed, with a `nuget.config` example. The feed also carries `master`
builds (`…preview.<N>.g<commit>`) that sort above published previews, so
an **exact version pin is required**. Package source mapping is an
optional addition: it controls the source, not the version.
- **`OPCFoundation.NetStandard.Opc.Ua.Symbols` discontinued**:
`nuget/Opc.Ua.Symbols.nuspec` and its `UA.slnx` entry are deleted. It
was the Debug counterpart of the meta-package: every dependency except
`Types` is a `.Debug` ID, and those no longer reach nuget.org, so it
would be unrestorable there. The guide names `2.0.0-preview.5` as its
last version.
- **`docs/DeveloperGuide.md`**: the packages paragraph still said
nothing ever pushes a preview to nuget.org, which #4574 changed. It now
describes both `release.yml` paths (stable promotion and numbered
preview release) and links the new migration note.
- **Bundled migration skill**:
`.agents/skills/opcua-v20-migration/references/stack-migration/packages.md`
is regenerated with `validate-migration-plugin.ps1 -Update`. Its CI
check had failed "Discover test projects".

## Meta-package still ships

`OPCFoundation.NetStandard.Opc.Ua` is still packed from
`nuget/Opc.Ua.nuspec` by the "Pack legacy NuGet packages" step in
`nuget-publish.yml`. For example, `2.0.0-preview.20.g46fa7d364a` was
pushed to GitHub Packages on 2026-09-25. A `release/2.0` preview
promoted through `release.yml` publishes it to nuget.org.

## Related Issues

- Follow-up to #4574 (preview promotion to nuget.org).

## Checklist

- [ ] 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.
- [ ] 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.
- [ ] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] 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.

Documentation, plus removal of the `Opc.Ua.Symbols` nuspec.
`validate-migration-plugin.ps1` passes locally.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:17:40 +00:00
romanett 76e5efde99 Keep GDS roles on hosted logins, add GDS user token policies and startup tasks (#4590)
## Proposed changes

Fixes three problems with a GDS hosted through `AddGdsServer`.

### #4584: admin users lost their GDS roles
The hosted service registered the generic
`UserNamePasswordAuthenticator` and `X509Authenticator` after start.
They replaced the GDS's own authenticators, which grant the roles
`IUserDatabase` assigns, so `RegisterApplication` returned
`Bad_UserAccessDenied` once an `IUserManagement` was registered.

- The GDS builder's `AddDefaultIdentityAuthenticators` now keeps the
GDS's own authenticators for UserName and X.509. The same options
configure them: `EnableAnonymous`, `EnableUserNamePassword`,
`EnableX509`, `UserDatabase` and `UserCertificateTrustList`.
`EnableAnonymous = false` now actually rejects anonymous tokens on the
GDS.
- The generic default registration is left as it is, so a regular server
sharing the container still gets it.
- DI authenticators and augmenters are now staged before start and
registered in `OnServerStarted`, before the endpoints open. They come
after the built-ins, so `AddIdentityAuthenticator<T>()` still replaces a
built-in.
- `ConfigureRoles` now reaches the GDS: its `IRoleManager` is used
through `CreateRoleManager`.

### #4585: GDS offered only Anonymous logins
- Added `GdsServerOptions.UserTokenPolicies`, which is bindable from
`OpcUa:Gds:Server:UserTokenPolicies`.
- When the list is empty, the GDS advertises Anonymous plus UserName. It
leaves out any type the default authenticator options disable. Anonymous
covers the pull flow (§7.6); `RegisterApplication` needs DiscoveryAdmin
or ApplicationAdmin (§6.5.6).
- Corrected the `ConfigureBuilder` doc. The callback runs *before* the
security configuration and the GDS extension are added, not after.

### #4586: GDS never ran startup tasks
- The hosted GDS now runs `IServerPreStartupTask` and
`IServerStartupTask` with the same order, cancellation and failure
handling as `OpcUaServerHostedService`.
- New `IGdsServerBuilder` extensions: `AddStartupTask<T>()`,
`AddStartupTask(callback)` and `AddPreStartupTask<T>()`.
- Scope, as asked in the issue: tasks registered through the GDS builder
run on the GDS only. Plain `IServerStartupTask` and
`IServerPreStartupTask` registrations, and a shared `IRoleManager`,
apply to the GDS only when no regular server (`AddServer`) shares the
container. This keeps a task meant for the regular server from running
twice.
- `StandardServer.AddPreStartupTask` is now `protected internal`, so
server subclasses such as the GDS can stage pre-startup tasks.

## Related Issues
- Fixes #4584
- Fixes #4585
- Fixes #4586

## Types of changes
- [x] Bugfix (non-breaking change which fixes an issue)
- [x] Enhancement (non-breaking change which adds functionality)

## Checklist
- [x] I have read the CONTRIBUTING doc.
- [x] I have added tests that prove my fix is effective or that my
feature works.
- [x] I have added necessary documentation
(`docs/DependencyInjection.md`, GDS section).

## Further comments
New tests are in
`tests/Opc.Ua.Gds.Tests/Hosting/GdsServerHostedServiceIdentityTests.cs`:
- A UserName login keeps DiscoveryAdmin and CertificateAuthorityAdmin,
with and without `IUserManagement`, and neither generic authenticator
appears in the registry.
- A wrong password is rejected.
- A custom UserName authenticator still wins.
- Disabled Anonymous is rejected and not advertised.
- Default and configured token policies, plus config binding.
- `ConfigureRoles` reaches the server.
- Startup and pre-startup task order, scoping when a regular server is
registered, and a failing task stopping startup.

`GdsIdentityForwardingTests` now expects the extra GDS defaults marker
descriptor.

Local runs: `Opc.Ua.Gds.Tests` hosting fixtures on net10.0 give 74
passed / 0 failed. The GDS server and tests also build for net48, and
`dotnet format whitespace` is clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 14:33:24 +00:00
romanett c56d58d094 Fix GDS FindApplications authorization and CSR key type checks (#4587, #4588) (#4591)
Fixes #4587
Fixes #4588

## #4587: FindApplications rejects anonymous / ApplicationSelfAdmin
sessions

OPC 10000-12 §6.5.3 says FindApplications "can be called by any Client",
and §6.5.4 names no Role. `OnFindApplications` required
`AuthenticatedUser`, which anonymous identities never have. So a §7.6
pull client (Anonymous + ApplicationSelfAdmin) could not find its own
ApplicationId, and got `Bad_UserAccessDenied` even for unregistered
URIs.

- Removed the role check from `OnFindApplications`. This matches
`QueryApplications` / `QueryServers`, which also have no handler check.
- Moved `GetApplication` to the `SelfAdministered` node wiring so it
matches its handler check (`AuthenticatedUserOrSelfAdmin`). The handler
still limits self-admin callers to their own record.

## #4588: CSR key vs. requested CertificateType; FinishRequest error
mapping

**StartSigningRequest (§7.9.3)** now compares the CSR's public key with
the resolved CertificateTypeId before queuing the request:

| Case | Result |
|---|---|
| Key algorithm doesn't match the type (RSA CSR for ECC type, ECC CSR
for RSA type) | `Bad_InvalidArgument`; the text names both |
| ECC curve doesn't match (e.g. P-384 key for `EccNistP256…`) |
`Bad_InvalidArgument` |
| RSA key size outside the type's range (`RsaMin…` 1024–2048,
`RsaSha256…` 2048–4096; §7.8.4.8/9) | `Bad_NotSupported` |
| ECC type the group can't issue from a CSR (e.g. Curve25519/448) |
`Bad_NotSupported` |

The check is a new
`ICertificateGroup.VerifySigningRequestAsync(application,
certificateType, request, ct)` overload, which `StartSigningRequest`
calls through the interface. `CertificateGroup` implements it as a
virtual method that runs the existing CSR checks and then the new static
`CertificateGroup.VerifySigningRequestKey`. `SigningRequestAsync` runs
the same check, so requests queued before this change also fail with the
exact reason.

**FinishRequest (§7.9.5)** no longer replaces signing and key-pair
failures with `Bad_ConfigurationError`, which the spec doesn't list. It
now uses `ServiceResult.Create(Exception, …)`: a
`ServiceResultException` keeps its status, other exceptions fall back to
`Bad_RequestNotAllowed`, and the exception stays attached. The failure
is also logged at Warning (new event id `ApplicationsNodeManager + 21`).
An empty `ApplicationNames` no longer throws while the message is being
built.

## Behaviour changes
- **API:** `ICertificateGroup` gains a member. Custom implementations
that don't derive from `CertificateGroup` must add the typed
`VerifySigningRequestAsync` overload; they can call
`CertificateGroup.VerifySigningRequestKey` for the key check.
- Anonymous sessions can call FindApplications. Per the spec it returns
only public registration data, the same data QueryApplications already
returned.
- StartSigningRequest can now fail on requests that it used to accept
and that later failed in FinishRequest.
- An RSA CSR whose key size is outside the requested type's range is
rejected with `Bad_NotSupported`. Before, it was signed anyway.
- FinishRequest status codes change from `Bad_ConfigurationError` to the
underlying code, or to `Bad_RequestNotAllowed`.

## Tests
- `CertificateGroupSigningRequestTests`: 6 new unit tests covering
algorithm mismatch in both directions, curve mismatch, RSA size bounds,
an unsupported ECC type, matching keys, and an undecodable CSR.
- `ClientTest.FindApplicationsAsSelfAdminAsync` /
`StartSigningRequestWithMismatchedKeyTypeAsSelfAdminAsync`: end to end
over an anonymous self-admin session.
- Local, net10.0: full `Opc.Ua.Gds.Tests` 1205 passed / 0 failed / 56
skipped. `ClientTest` 112/112, and both new tests pass in both fixture
variants. The net48 build is clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 14:29:39 +00:00
romanett 87c35b84b6 Fix Opc.Ua.Client audit findings (security fallback, reconnect, subscriptions, redundancy, browse, files, WebApi) (#4572)
## Summary

Fixes the findings of a bug audit of `Opc.Ua.Client` and
`Opc.Ua.Client.ComplexTypes`, done on master plus #4516 (now merged).

- **Audit:** 82 findings across 12 areas, each checked against the OPC
UA specification. Final severities: 2 High, 37 Medium, 43 Low.
- **Fixes:** 76 fixed, 4 partial, 2 skipped (see [Not
fixed](#not-fixed--partial)).
- **Review:** a follow-up review of the fixes found 15 more defects in
them, all fixed here.
- **Tests:** every fix has a regression test, except a few races that
cannot be reproduced deterministically; the commit messages say which.

The finding IDs (`L<area>-<n>` for the audit, `G<n>` for the review)
appear in the commit titles.

## Behaviour and user-facing changes

### Security and endpoint selection
- **Reconnect no longer drops to weaker security (L3-1, High).** When
the server rejects the configured security policy or mode during
reconnect, `SessionReconnectHandler` no longer switches to whatever
endpoint the server advertises, None included. It only picks an endpoint
at least as strong as the configured one, judged by a locally computed
security level. The server-advertised `SecurityLevel` is not trusted. If
nothing qualifies, reconnect fails with the original error (new log
event `SessionReconnectHandler + 13`) and keeps retrying with the
original settings.
- **`CoreClientUtils.SelectEndpoint(useSecurity: true)` never returns a
SecurityMode None endpoint over opc.tcp (L9-9, High).** It now returns
`null` when no secure endpoint matches.
- When the discovery URL is `https`/`opc.https`, an HTTPS endpoint with
SecurityMode None is still accepted as a last resort, because TLS
protects it (Part 6 §7.4.1) (G17).
  - Endpoints with SecurityMode `Invalid` no longer count as secure.
- `GlobalDiscoveryServerClient` and `ServerPushConfigurationClient` now
throw `BadSecurityPolicyRejected` when no secure endpoint matches,
instead of passing `null` on. The ConsoleReferenceClient sample reports
this with a clear error.
- **No Bearer/Basic credentials over `http://` (L12-2).**
`WebApiClient.Create` and WebApi channel open/reconnect reject them with
`BadSecurityChecksFailed`. The public `WebApiClient(HttpClient, …)`
constructor also rejects them when the client's `BaseAddress` is not
https, and every send checks the address it actually goes to (covers a
`BaseAddress` set later).

### Session, keep-alive and identity
- **Faster detection of a silent server (L1-1).** The keep-alive Read is
now bounded by its `TimeoutHint` (2 × `KeepAliveInterval`), which is
what Part 4 §7.32 says the client should do. It then fails with
`BadNoCommunication`, so reconnect starts after about 2 ×
`KeepAliveInterval` instead of `OperationTimeout` (120 s by default).
- **Operations fail fast during a channel-manager recovery (L1-2, G9).**
`UpdateIdentityAsync`, `TransferSubscriptionsAsync` and
`ReactivateSubscriptionsAsync` fail with `BadSecureChannelClosed`
(retryable) instead of deadlocking. This covers the operation holding
the reconnect lock and any queued behind it.
- **Closing or disposing from a handler no longer waits on itself (L1-3,
G5, G6).**
- `Session` and `ManagedSession` Close/Dispose from a Notification
handler no longer wait 30 s for their own background drain.
- Disposing a `ManagedSession` from a keep-alive `PublishStatusChanged`
handler no longer deadlocks.
- Tasks spawned by a handler no longer count as "the handler" (the owner
marker is scoped to the work item).
- **Switching to an ECC user-token policy takes two ActivateSession
calls (L1-4, G4).** This follows Part 6 §6.8.2 (fetch the ephemeral key,
then activate).
- If the server returns no key, the switch fails with
`BadSecurityPolicyRejected`.
- If the second activation is rejected, the old identity stays on the
override policy with the key just fetched. Restoring the old key was
tried: against the reference server the next reconnect then failed to
decrypt the identity token.
- **`ManagedSession.ConfiguredEndpoint` follows failover (L2-6).** It
now reports the inner session's endpoint, so it changes after a failover
or network-path rotation.
- **An explicit identity update replaces the identity provider (L2-7).**
`ManagedSession.UpdateSessionAsync(identity)` stops the provider's
refresh loop and drops the provider for good. If the update fails, the
loop is restarted.
- **Reconnect waits for in-flight calls on a healthy channel (L2-3,
G8a).** Reconnect/failover on a healthy channel (certificate or
trust-list change, manual reconnect) waits for in-flight service calls.
The wait is bounded (max(`KeepAliveInterval`, 1 s)) only once the
channel is unhealthy (keep-alive stopped, channel not Ready), so a dead
channel no longer holds recovery for `OperationTimeout`.
- **Smaller fixes:**
- Restore reads the server certificate as a chain; this failed on macOS
(L1-5).
  - Session recovery can no longer run twice at once (L1-6).
  - Identity expiries more than 49.7 days out no longer throw (L2-2).
- Keep-alive reconnect suppression is recalculated on a lease swap
(L2-4).

### Reconnect state machine and reverse connect
- **⚠ `MaxTotalReconnectTime = 0` now means "no time limit" (L3-7).
Reviewer decision needed.** Before, 0 disabled reconnect entirely.
Negative values other than `Timeout.InfiniteTimeSpan` now throw
`ArgumentOutOfRangeException` when the state machine is constructed;
before, they crashed the worker. The XML docs of
`ReconnectPolicy`/`ReconnectPolicyOptions` are updated. If 0 should keep
meaning "no reconnect", this is a one-line revert.
- **A close racing the end of the reconnect budget is no longer lost
(L3-4).** It goes through the normal close instead of switching to
failover.
- **The "reconnect worker" marker covers one step only (L2-1).** It
lasts for a single connect/reconnect/failover/close step. Keep-alive,
publish and identity loops started in a step no longer count as the
worker afterwards.
- **Reverse-connect strategies `Undefined` and `Any` alone are removed
after the first callback (L8-1).** This matches the documentation;
before, they behaved like `Always`.
- **`AddEndpoint` for a URL that is already added disposes the replaced
listener (L8-2).**

### Service calls
- **Batched calls clean up after a failed later batch (L3-2).** When a
later batch of
`Browse`/`BrowseNext`/`HistoryRead`/`CreateMonitoredItems` fails, the
continuation points and monitored items created by earlier batches are
released or deleted before the exception is rethrown. This adds one
request. `AddNodes`/`RegisterNodes` are not undone.
- **Batched diagnostic infos point at the right strings (L3-3).** The
string-table indexes are rebased when diagnostics are padded.
- **`ReadBytesAsync` rejects oversized responses (L3-5).** It throws
`BadUnknownResponse` when the server returns more bytes than requested,
and `BadEncodingLimitsExceeded` beyond `int` range.
- **`ReadByteStringInChunksAsync` handles an unknown server limit
(L3-6).** When the server's `MaxByteStringLength` is 0 (absent) or
larger than `int`, it falls back to the client's
`MessageContext.MaxByteStringLength`. It throws only when both are
unknown.

### Browse and NodeCache
- **Empty browse pages with a continuation point are followed (L9-1,
L9-4, L9-5, G16).** Part 4 §5.9.2.1 allows a server to return an empty
page with a continuation point, so browsing keeps following it. After 10
consecutive empty rounds it gives up with `BadNoData` and releases the
continuation point.
- `BrowseAsync(NodeId)` now throws instead of silently returning a
truncated list.
  - `BrowseStreamAsync` yields a `BadNoData` result for that node.
- The same cap, through one shared helper, now also protects
`RoleManagementClient.ListRolesAsync` and the FileSystem
enumerate/empty-check loops.
- **Continuation points are released on failure (L9-3).**
`BrowseAsync(NodeId)` releases them on any exception, and
`BrowseStreamAsync` releases all points of the current page when the
consumer stops early.
- **NodeCache lookups propagate cancellation (L9-6, L9-8).**
`FindAsync`, `IsKnownAsync`, `FetchNodeAsync`,
`FindReferenceTypeNameAsync`, the reference-table fill and the
type-hierarchy search now let `OperationCanceledException` through.
Before, a cancelled lookup looked like "node does not exist".
- **A NodeClass mismatch fails only that node (L9-7).** It returns
`BadNodeClassInvalid` for that node instead of throwing
`KeyNotFoundException` for the whole batch.
- **Smaller fixes:**
- `MaxNodesPerBrowse` ≥ 2^31 no longer breaks the managed browse (L9-2).
  - Null discovery URLs are skipped (L9-10).
  - `ModelChangeTracker` no longer cancels a joined start (L9-11).
- **Log changes:** `BrowserContinuationPointExistsButBrowse` (Browser +
0) was removed, and `BrowserServerReturnedEmptyReferencesBut` now
includes the round count.

### Subscriptions (default engine)
- **Recovery after a transfer delivers each message exactly once (L5-1,
G22).** Messages already dispatched are acknowledged instead of
republished and delivered again. Messages whose earlier republish failed
are now republished and delivered, with the Republish flag and after
newer messages, instead of being acknowledged and lost.
- **A keep-alive that proves a gap triggers republish at once (L5-5,
G23).** The dedup mark moves only past messages that were actually
recovered, so a failed republish is retried by the next data message.
- **Item changes that fail are retried with backoff (L5-3).** They are
retried on the next apply pass after the state manager's 250 ms–5 s
backoff, instead of six times back to back. The six attempts now span
about 13 s.
- **Extreme revised intervals no longer throw (L5-4, L6-1).** Very large
or NaN intervals and counts revised by the server are capped (NaN falls
back to the requested value). Before, the overflow could stop every
publish worker.
- **`SetTriggeringAsync` from a callback no longer hangs (L5-7, G21).**
While the subscription is deleted, recreated or disposed, it returns
`BadOperationAbandoned`. A cancelled reset no longer makes later calls
fail.
- **Publish responses for unknown subscription ids (L6-3, L6-4, G20).**
The client no longer re-sends acknowledgements the server already
processed. An unknown id seen while a creation is pending is left alone
instead of being deleted.
- **Smaller fixes:**
- A null and an empty `IndexRange` are treated as equal, and restored
snapshots use `null` (L5-6).
  - A failed partition attach during restore is rolled back (L6-2).
- `StreamingSubscription` throws `ObjectDisposedException` after dispose
(L6-6).

### Subscriptions (classic `Subscription`)
- **Republish no longer abandons other missing messages (L7-1).** A
Republish response no longer overwrites `AvailableSequenceNumbers`, so
other missing messages are not given up while the server still holds
them.
- **Late copies are no longer delivered twice (L7-2).** Copies of
already-processed sequence numbers are ignored, and creating a new
server subscription resets the sequence state.
- **Disposing from a keep-alive handler no longer deadlocks (L7-3).**
`Dispose()` from a keep-alive `PublishStatusChanged` handler can return
while that callback is still finishing.
- **Transfer of a restored subscription (L7-4, G27).**
  - It succeeds even when some saved items were never created.
- The server subscription is deleted only when this session's own
TransferSubscriptions just moved it and adoption failed definitively
(item count mismatch or a non-transient status). Transient errors and
`ReactivateSubscriptionsAsync` never delete it.
  - The public `Subscription.TransferAsync` never deletes.
- **Smaller fixes:**
  - Cloned items keep their triggering links (L7-5).
  - `CreateAsync` clears stale server ids (L7-6).
- A `Good_SubscriptionTransferred` for the client's own transfer is
ignored (L7-7).
- An item removed while its create is in flight is deleted afterwards
(L7-8).
- The publish timeout for very long keep-alives no longer overflows
(L7-9).
  - A negative `MaxMessageCount` is handled (L7-10).
  - `LastMessage` tolerates a wrong notification type (L7-11).
  - Pending acks exactly 2^31 away no longer throw (L4-1).
- **Log changes:** new log events `Subscription + 68..70`.

### Redundancy
- **Warm/Hot demotion (L4-2, G1, G3).**
- Without token reuse, the demoted leader closes its session and
reconnects as a fresh standby.
- With token reuse, it abandons the shared server session locally,
without CloseSession, so the new leader can reactivate it and keep its
subscriptions. This uses the new internal methods
`ManagedSession.AbandonServerSessionAsync` and
`Session.AbandonServerSession`.
- `RoleChanged(false)` is now raised before the standby connect and no
longer depends on it, so `CurrentSession` can briefly be `null`.
- Exceptions from `RoleChanged` handlers are logged and do not stop the
teardown.
- **Promotion (L4-3, G2).** A promoted standby that adopts the leader's
session first closes its own server session. A retried promotion keeps
the session it already adopted.
- **Client failover takeover (L4-4, partial).** Subscriptions the backup
prepared in advance (not yet created, `TransferId` set to the active
client's id) are moved through the session's own transfer and keep
publishing. `docs/HighAvailability.md` is updated.
- **`ShouldFailover` through the interface (L4-6).** Calling it through
`IServerRedundancyHandler` no longer fails over away from a server that
is still Healthy.

### Fluent / dependency injection
- **Shared DI session (L10-5).** The shared session from `AddClient` is
reconnected after a caller disposes it, and is disposed with the
container. After that the connect delegate throws
`ObjectDisposedException`.
- **New public API:
`ManagedSessionBuilder.WithServerRedundancyOptions(ServerRedundancyOptions)`
(L10-6).** This is additive. The `Session.ServerRedundancy` options are
now actually applied.
- **`AddDiscoveryAndConnect` rewrites the advertised address (L10-7).**
It replaces the host and port the server advertises with those of the
discovery URL (Part 4 §5.6.2.1, NAT/containers), and prefers endpoints
with the same scheme.

### State machines and role management
- **`GetSubStateMachineAsync` returns the instance's own sub-state
machine (L10-1).** Before, it returned the type's placeholder.
- **`WaitForStateAsync` reports failures with OPC UA status codes
(L10-2).** It throws `BadNotFound` when `CurrentState/Id` cannot be
resolved, and `BadInvalidState` when observation ends without reaching
the state. Before, both surfaced as `OperationCanceledException`.
- **`RoleManagementClient.ListRolesAsync` pages correctly (L10-3).** It
follows continuation points, releases them on any failure, and throws on
a Bad browse status instead of returning an empty list.
- **Role properties are read into the right fields (L10-4).** Properties
whose namespace cannot be resolved are skipped instead of shifting the
other values.

### File system, temporary files and alias names
- **Path escaping (L11-8, G12).**
- Path segments escape `:` and `/` with `&`, and `&` itself as `&&`
before those characters or at the end of a name.
- `Format` and `Parse` round-trip all names. Typed input ending in `&/`
now reads the slash as part of the name.
- A segment without a namespace prefix also matches a namespace-0 child
when nothing matches in the parent's namespace.
- **Stale path cache (L11-1).** When a cached lookup fails,
`GetInfoAsync` retries once from the server. `ExistsAsync` and
`GetFileAsync` report "not found" instead of throwing.
- **`GenerateFileForReadAsync` waits for the file (L11-2).** It waits
until the file-generation state machine reaches ReadTransfer (`Idle`
counts as not started yet), and throws `IOException` if it reports
Error. The wait is bounded only by the caller's token.
- **Non-recursive delete refuses hidden children (L11-3).** It refuses
when the directory holds children that the subtype options hide from
enumeration. Before, the server deleted them all.
- **Alias names (L11-5, L11-6, L11-7).**
- `BadMethodInvalid` maps to `NotSupportedException` for all alias
calls, so `UseVerbose` falls back to `FindAlias`.
  - A duplicate alias name resolves to all its targets.
- If the refresh strategy fails to start, the resolver logs a warning
and keeps working without automatic invalidation. New log event block
`AliasNameResolver = 560`.
- **Null profile entries (L11-4).** Null `ServerProfileArray` entries no
longer crash `GetConformanceInfoAsync`.

### WebApi transport
- **Failures come back as `ServiceResultException` (L12-1).**
  - A timeout becomes `BadRequestTimeout`.
- HTTP 429/503 becomes `BadServerTooBusy` with the Retry-After hint, on
every target framework. `WebApiClient` checks the status itself (reusing
`HttpsTransportChannel.CreateServerTooBusyException`), so callers of
`WebApiClient` also get a `ServiceResultException` instead of an
`HttpRequestException` for these two statuses (G15).
  - An IO error becomes `BadNotConnected`.
- **Timeouts (L12-1, G14).**
- `OperationTimeout` applies to each request. When `RequestTimeout` is
unset, HttpClient's 100 s default no longer cuts off long Publish
requests.
- The GetEndpoints call at open is bounded by `OperationTimeout`, and
cancelling the open now throws.
- **Core change:** `HttpsTransportChannel.MapRequestFailure` went from
private to internal so the WebApi channel can reuse it.

### ComplexTypes
- **Out-of-range union selector (L12-3).** A union whose SwitchField is
larger than its field count now fails with `BadDecodingError` when
decoded and `BadEncodingError` when encoded (Part 6 §5.2.8).
- **Verbose JSON unions (L12-4).** They now decode their selected field
instead of `null`.

## Not fixed / partial
- **L2-5 (skipped):** an identity provider combined with a
caller-supplied reverse connection still can't connect to an
undiscovered endpoint. Fixing it means choosing the identity after
CreateSession, a design change.
- **L4-5 (skipped):** the one-time KeyCredential token needs a fresh
token on each reactivation. Today reactivation fails and the client
falls back to a new session.
- **L4-4 (partial):** only subscriptions the backup prepared in advance
survive a takeover.
- **L3-2 (partial):** `AddNodes`/`RegisterNodes` batches are not rolled
back.
- **L2-3 (partial):** service calls still hold the session lock for
their whole round trip.
- **Known from the review, not fixed here:**
  - G7: the batch cleanup request can't be cancelled.
- G11: the DI session's close at host shutdown never reaches the server,
and a closed session keeps being handed out.
- G13: a file renamed by another client is still found under its old
path.
  - G25: a clone-based transfer doesn't mark the original subscriptions.
  - G27b: the transfer item check matches by count, not by ID.
- **Outside this PR:** the reference server ignores `ECDHPolicyUri` on
ActivateSession (`SessionSecurityPolicyHelper`), which breaks Part 6
§6.8.2. As a result, the L1-4 two-step ECC switch ends in
`BadSecurityPolicyRejected` against our own server.

## Testing
All suites on net10.0, on the branch before the master merge. Every
suite had 0 failures:

| Suite | Passed | Skipped |
|---|---|---|
| Opc.Ua.Client.Tests | 2698 | 0 |
| Opc.Ua.Redundancy.Client.Tests | 134 | 0 |
| Opc.Ua.Subscriptions.Classic.Tests | 57 | 0 |
| Opc.Ua.Bindings.Https.WebApi.Tests | 385 | 0 |
| Opc.Ua.Sessions.Tests | 782 | 379 |
| Opc.Ua.Subscriptions.Tests | 640 | 6 |
| Opc.Ua.Client.ComplexTypes.Tests | 2858 | 0 |

After merging current master (net10.0), every suite had 0 failures
except one, a flaky new test fixed in 9f52952b2:

| Suite | Passed | Skipped | Failed |
|---|---|---|---|
| Opc.Ua.Client.Tests | 2719 | 0 | 1 (the flaky test) |
| Opc.Ua.Redundancy.Client.Tests | 134 | 0 | 0 |
| Opc.Ua.Client.ComplexTypes.Tests | 2858 | 0 | 0 |
| Opc.Ua.Subscriptions.Classic.Tests | 57 | 0 | 0 |
| Opc.Ua.Bindings.Https.WebApi.Tests | 397 | 0 | 0 |
| Opc.Ua.Sessions.Tests | 790 | 379 | 0 |
| Opc.Ua.Subscriptions.Tests | 640 | 6 | 0 |

The touched test projects also build on net48 with 0 warnings.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: marcschier <11168470+marcschier@users.noreply.github.com>
Co-authored-by: Marc Schier <marcschier@users.noreply.github.com>
2026-09-29 09:34:03 +00:00
romanett a430239fde Allow release.yml to promote release-line previews to nuget.org (#4574)
# Description

Adds a way to publish a numbered release-line preview (e.g.
`2.0.0-preview.6`) to nuget.org.

Since #4513, `nuget-publish.yml` pushes previews only to GitHub
Packages, and `release.yml` (the only nuget.org path) refused every
non-stable candidate. That left no way to continue the `2.0.0-preview.N`
series on nuget.org (last published there: `2.0.0-preview.5`).

## Changes

- **`release.yml`** gains a `channel` input (`stable` | `preview`,
default `stable`).
  - `stable` behaves exactly as before.
- `preview` accepts only a public release-line preview,
`M.m.p-preview.N` with no commit id, from the matching canonical
`release/M.m` branch. `master` / PR builds (`-preview.N.g<sha>`) are
rejected.
- The candidate manifest's recorded `channel` must equal the dispatched
one, so a stable promotion can never publish a preview by accident, or
the reverse.
- **`package-version-policy.ps1`**: new
`Test-PromotablePreviewPackageVersion`, and an `-AllowPreview` switch on
`Test-CanonicalReleaseBranchForPackageVersion`. Callers without the
switch (the stable gate in `nuget-publish.yml`) behave exactly as
before.
- **`docs/ReleaseProcess.md`**: new "Preview release" procedure,
including how to pick the preview number with `versionHeightOffset`.
- **Tests**: predicate cases, the `-AllowPreview` switch, and a pin that
`release.yml` defaults to `stable` and relaxes the gate only for
`channel=preview`.

## GitHub Packages preview feed is unchanged

- `nuget-publish.yml` is not touched. `master` keeps auto-publishing
`2.0.0-preview.<height>.g<sha>` to GitHub Packages, and release-branch
builds keep pushing their own `<base>-preview.N` there.
- The commit-id suffix means `master` versions can never collide with a
bare `2.0.0-preview.N`.
- For a preview, `release.yml`'s GitHub Packages push is a verified
no-op. The candidate's own `nuget-publish.yml` run already pushed those
bytes, and the existing identity preflight and post-push check prove
they match.

## Getting `2.0.0-preview.6` after merge

1. Cut `release/2.0` from `master` after this merges. `release.yml`
executes the policy script from the candidate commit, so that commit
must contain this change.
2. On `release/2.0`, commit a `versionHeightOffset` so the build lands
on `.6`. Verified locally with nbgv 3.10.94: at the current `master` tip
(height 24), one commit setting `-19` yields `2.0.0-preview.6` and the
next yields `.7`. Recompute for the actual cut point with `nbgv
get-version -v NuGetPackageVersion`.
3. Dispatch `release.yml --ref release/2.0 -f release_run_id=<run> -f
channel=preview`, with a dry run first.

`2.0.0-preview.6` is currently free: nuget.org has `.2`–`.5`, and the
retired `release/2.0.0` pushed up to `.5` to GitHub Packages.

## Related Issues

- Follow-up to #4513. Supersedes the `master` → `release/2.0.0` merge
attempts #4566 / #4570; `release/2.0.0` is retired and cannot publish.

## Checklist

- [ ] 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.
- [ ] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] 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.

Locally: `PackageVersionPolicyTests` + `NuGetPackageSetValidationTests`
on net10.0, 96/96 passed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 07:45:30 +02:00
romanett d623007a3f Remove the Azure Pipelines CI and publishing definitions from master (#4575)
# Description

Removes the Azure Pipelines CI and package-publishing definitions from
`master`. GitHub Actions already owns all CI and package publishing for
`master`, but pushes to `master` still queued the Azure definitions.
They duplicated the Actions work and posted failing checks, for example
[`Nuget Preview (Push Nuget Preview Pack Nugets
Debug)`](https://github.com/OPCFoundation/UA-.NETStandard/runs/109065914524)
on `19dc504c5`.

## Removed

- `azure-pipelines.yml`, `azure-pipelines-preview.yml`
- `.azurepipelines/` templates: `ci.yml`, `coverage.yml`, `preview.yml`,
`sample-ha-longhaul.yml`, `sln.yml`, `test.yml`, `test-aot.yml`
- Helpers only those templates used: `get-matrix.ps1`, `get-root.ps1`,
`evaluate-test-results.ps1`
- `TrxGateScriptTests`: it tested only `evaluate-test-results.ps1`. The
Actions equivalent, `.github/scripts/get-test-verdict.ps1`, keeps its
own `CiTestVerdictTests`.

## Kept

**OneFuzz:** `.azurepipelines/onefuzz.yml` stays. Submitting to the
internal OneFuzz service needs the Azure DevOps-only `onefuzz-task`,
which has no GitHub Actions equivalent. The pipeline has no push, PR or
schedule trigger, so it never runs on `master` pushes. The public drop
build keeps running in the GitHub `fuzz-public-drop` job.

**Scripts GitHub Actions still invokes:** these stay in
`.azurepipelines/`, so every workflow path and `release.yml`'s
candidate-bound checkout keep working. Only `generate-slnx.ps1` changes,
and only its comment.

| Script | Used by |
| --- | --- |
| `set-version.ps1` | buildandtest, nightly, nuget-publish,
docker-image, stress / stability / long-haul, `run-dotnet-tests` action
|
| `generate-slnx.ps1`, `signlist{Debug,Release}.txt`,
`validate-source-generator-packages.ps1` (+ `expected-packages.txt`) |
nuget-publish |
| `package-version-policy.ps1`, `validate-nuget-package-set.ps1`,
`validate-preview-package-ordering.ps1`,
`assert-published-packages-match.ps1` | nuget-publish, release |
| `check-coverage.ps1` | buildandtest, nightly |
| `validate-migration-plugin.ps1` | buildandtest |

I didn't rename the directory on purpose. `release.yml` checks out the
candidate commit and runs `./.azurepipelines/package-version-policy.ps1`
from it, so moving the scripts would break promotion of any candidate
built before the move.

## Docs and comments

- `UA.slnx` drops the deleted files.
- `codecov.yml`, `coverage-thresholds.json`, `targets.props`, the
Redundancy sample test comments, the `.github` scripts and workflows,
`CONTRIBUTING.md`, `docs/` and `fuzzing/` no longer describe Azure as
running on `master` (`/azp run`, the `azure` Codecov flag, "the Azure
gate", the Azure long-haul and AoT legs).
- In the Developer Guide, the "Triggering a pipeline run" and "Migration
status" sections are replaced by "Pull requests from outside
contributors" and "Azure Pipelines on other branches".
- The new section names `onefuzz.yml` as the one remaining Azure
definition.
- It keeps the warning that the `Release` ruleset's Azure required check
must be lifted before cutting a new `release/2.<minor>` branch.
- `master` is merged in, including the #4555 docs rewrite. Its new
`docs/RedundancySampleTests.md` needed the Azure references removed
again. `docs/README.md` already had none.

## Reviewer notes

- **Azure DevOps side (admin action, not in this PR):** the definitions
are configured in the Azure DevOps portal. With the YAML gone, `master`
pushes give the CI and preview definitions nothing to run. Disabling
definition 14's push trigger/schedule, definition 16's build-completion
trigger, and the stale definition 13 stops any residual "file not found"
runs. Leave the OneFuzz definition enabled.
- `master378`, the 1.x `release/*` lines and the frozen `release/2.0.0`
keep their own `azure-pipelines.yml`. This PR only touches `master`.

## Related Issues

- Follow-up to #4513 (GitHub Actions for CI and nightly).

## Checklist

- [ ] 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.
- [ ] 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.
- [ ] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] 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.

This PR removes a test and adds none. Locally, at `9b00da817`, the full
`Opc.Ua.Tools.Tests` suite passed on net10.0: 884 passed, 1 skipped. The
later commits (OneFuzz restore, merge with `master`) touch only YAML and
docs.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 07:44:36 +02:00
romanett a0d569d756 Fix Server and GDS audit findings, review findings and spec-conformance gaps (#4571)
## Summary

Fixes the findings of a bug audit of `Opc.Ua.Server`,
`Opc.Ua.Gds.Server` and `Opc.Ua.Gds.Client`. Rebased on master after
#4516.

What went in:
- **Audit:** 93 findings, each checked against the OPC UA specification.
- **Code review of those fixes:** 35 further findings.
- **Spec grounding of every fix commit:** 18 conformance gaps, now
closed.

Commits are one per finding, named `Fix <ID>`, `Fix review <ID>` or `Fix
spec gap <name>`. The IDs refer to the audit report (S = Server areas,
G1 = GDS server, G2 = GDS client).

Validation:
- Each finding was traced in code and checked against OPC 10000 (parts
3, 4, 5, 9, 11, 12, 13, 18, 20).
- Every fix commit was checked against the spec text again afterwards: 0
non-conformant, remaining gaps fixed.
- Regression tests were added for most fixes.
- Suites run on net10.0 before the rebase: Server, GDS, History,
Configuration, Features, Core.Security, DI, Redundancy, LDS,
InformationModel, PubSub.Server, Client, Sessions and the three
Subscriptions suites, all green. The same suites on the rebased branch
are still running; results will be posted as a comment.

## Behaviour and user-facing changes

### Secure defaults
- **`Historize()` / `HistorianBuilder`** grant HistoryRead only. History
writes need an explicit `historyAccessLevel: HistoryRead |
HistoryWrite`.
- **File-system mount:**
- Anonymous sessions can no longer create, delete, move or open files
for writing (`Bad_UserAccessDenied`).
  - UserWritable and UserExecutable now reflect the user.
- `AllowAnonymousWrite` restores the old behaviour; the ReferenceServer
sample turns it on.
- **Default PKI root** for the hosted server, hosted GDS, hosted LDS and
`OpcUaApplicationConfigurationProvider`:
- Moved from the shared temp directory to `{LocalApplicationData}/OPC
Foundation/{ApplicationName}/pki`, and startup fails if no per-user
directory exists.
- When certificate stores of an earlier version still exist under temp,
a warning names both paths. Existing deployments that relied on the
default get a new application certificate (and the hosted GDS a new CA)
unless `PkiRoot` is set to the old path.
- **GDS:**
  - `GdsServerOptions.AutoApprove` defaults to `false`.
- The default KeyCredential store grants no roles unless
`AuthorizeRoles` allows them.
- Certificate requests (StartSigningRequest, StartNewKeyPairRequest,
FinishRequest) need an encrypted channel.
- **Hosted server anonymous logins:**
- Anonymous is rejected (`Bad_IdentityTokenRejected`) when the default
authenticator options disable it.
- A custom `AddIdentityAuthenticator<T>()` on its own no longer disables
anonymous.
- With anonymous disabled and no configured token policies, the endpoint
advertises the authenticators' token types instead of Anonymous.
- **Runtime-added roles:** their properties and methods need
SecurityAdmin over an encrypted channel, like the well-known roles.
- **Disabled users:** users disabled through Part 18 UserManagement can
no longer log in through `RegisterDefaultAuthenticators(IUserDatabase)`.

### GDS server (Part 12)
- **Certificate requests:**
- FinishRequest / ReadRequest with another application's RequestId
returns `Bad_InvalidArgument`.
  - Every Start*Request returns a new RequestId.
- A CSR without the ApplicationUri is rejected
(`Bad_CertificateUriInvalid`).
- An abstract or unsupported certificate type is rejected at Start
(`Bad_InvalidArgument`).
- **Private-key password:** no longer persisted in the JSON applications
database. A password saved by an earlier version is read into memory
once and dropped on the next save.
- **CheckRevocationStatus:** validates offline against the GDS's own
trust lists and CRLs. It returns Good for good GDS-issued certificates
and `Bad_CertificateInvalid` for input that is not a certificate.
- **Certificate-group trust lists:**
- Writes need CertificateAuthorityAdmin; SecurityAdmin and application
admins are read-only.
  - Writes are validated with the Part 4 certificate validation.
- **AuthorizationService:**
- The legacy RequestAccessToken applies `AccessControl` and issues the
token for the session identity.
- StartRequestToken passes the session identity to every identity-aware
provider.
  - Unknown ResourceIds return `Bad_NotFound`.
- Expired token requests and refresh tokens are pruned, with at most 10
000 pending requests (`Bad_TooManyOperations`).
- **KeyCredential:** requests with PublicKey + SecurityPolicyUri are
rejected with `Bad_SecurityPolicyRejected` (secret encryption is not
implemented); a PublicKey without SecurityPolicyUri is rejected with
`Bad_InvalidArgument`.
- **Audit events:** access-token audit events no longer contain the
password or token data.
- **RegisterTickets / UnregisterTickets:** need the RegistrarAdmin role
when called from a session.
- **Managed application nodes:** moved from namespace 0 to their own
namespace (`ManagedApplicationsNamespaceUri`); the NodeId is the
ApplicationUri, and the mandatory child properties exist.
- **Hosted GDS:**
- New `GdsServerOptions.CertificateGroups`
(`GdsCertificateGroupOptions`), bindable from configuration.
  - The mandatory DefaultApplicationGroup is always present.
  - The default CA subject uses the resolved application name.
- **JSON applications database:**
- A corrupt file throws instead of being silently replaced by an empty
database.
- Saves are atomic and write in place on bind mounts; on net8+ builds
they also follow symlinks and keep the Unix file mode.
  - Issued certificates and trust-list links survive a reload.

### GDS client
- **Trust-list download:**
  - Reads until an empty chunk (Part 20 short reads).
- A size-limit rejection of the final end-of-file read, as sent by
released servers, counts as end of file.
- **Default certificate group:** a null group means
DefaultApplicationGroup, and vendor groups in other namespaces are no
longer mistaken for well-known groups.
- **Status codes and errors:**
  - Missing directory proxies report `Bad_ServerNotConnected`.
- Short or wrongly typed method outputs throw `BadUnexpectedError` /
`BadTypeMismatch`.
- **GdsAccessTokenProvider:** no longer caches a failed client task, and
one caller's cancellation no longer fails other callers.

### Trust lists, push and ConfigurationFile (Part 12 / Part 20)
- **AddCertificate:**
  - `IsTrustedCertificate=false` returns `Bad_CertificateInvalid`.
- Added certificates are validated (Part 4), and a certificate revoked
by a current CRL returns `Bad_CertificateRevoked`.
- **CloseAndUpdate:** validates the uploaded list against its own
certificates and CRLs, not the stores it replaces. Validation failures
are no longer recorded in the server's Rejected store.
- **RemoveCertificate:** removing a trusted or issuer CA that other
certificates still need returns `Bad_CertificateChainIncomplete`.
- **Handles:**
  - Several read handles may be open at once.
- Any open while a write handle exists returns `Bad_NotReadable` /
`Bad_NotWritable`.
  - A write open while any handle is open returns `Bad_NotWritable`.
  - Handles of closed sessions are released.
  - Idle handles close after ActivityTimeout (default 60 s).
- **Status codes:**
- Read clamps to the remaining bytes and no longer enforces the size
limit.
  - Oversized Write / AddCertificate returns `Bad_RequestTooLarge`.
  - Unsupported open modes return `Bad_NotSupported`.
- **ConfigurationFile.CloseAndUpdate:**
  - Empty Targets return `Bad_InvalidArgument`.
- The ConfigurationUpdatedAuditEvent is raised with the configuration
owner as source.
- With the new `IApplicationConfigurationFileTargetProvider`, providers
receive the Targets, a bad target returns Uncertain with per-target
results, and session-interrupting updates are applied after
RestartDelayTime.
- **Default namespace permissions:** namespaces registered after the
first permission lookup now get their NamespaceMetadata default
RolePermissions and AccessRestrictions (no stale negative cache).

### Sessions, identity and roles (Part 18)
- **Session timeouts:** a failure closing one timed-out session no
longer stops the session-timeout monitor.
- **ChangePassword:** an empty old password returns
`Bad_IdentityTokenInvalid`.
- **Removing or disabling a user:** closes only that user's
UserName-token sessions.
- **Role configuration changes:** roles granted by an `ImpersonateUser`
mapping are kept.
- **X509Subject rules:** a certificate subject value containing a quote
never matches an X509Subject rule.
- **`ImpersonateUser` callback:** no longer runs under the global
session lock.
- **Session diagnostics:** LocaleIds are kept as the client sent them,
while null LocaleIds are ignored for translation.

### Subscriptions and monitored items (Part 4)
- **Acknowledgements:** a failing acknowledgement returns a per-entry
Bad status instead of failing the whole Publish.
- **`DiscardedMessageCount`:** counts messages dropped from the
retransmission queue and unsent overflow messages.
- **Durable subscriptions:** transferring a restored anonymous
subscription works (no NullReferenceException).
- **Transferred subscriptions:** sampling and permission checks use the
item's current owner session.
- **Access denied:** a monitored item whose user may no longer read the
attribute gets a `Bad_UserAccessDenied` notification instead of going
silent. Non-Value attributes need Browse permission (RolePermissions
needs ReadRolePermissions), and User* attributes are evaluated per
subscriber.
- **ModifyMonitoredItems:** a SamplingInterval of -1 uses the
subscription's publishing interval.
- **AggregateFilter priming:** needs HistoryRead / ReadHistory
permission and is bounded (100 000 values, bounded page size).
- **Resource leak:** event monitored items no longer leak a consumer
task per create/delete cycle.

### Node management services (Part 3 / Part 4)
- **Reference types:** AddNodes, AddReferences, DeleteReferences and
Browse reject ids that are not ReferenceTypes with
`Bad_ReferenceTypeIdInvalid`. AddNodes, AddReferences and
DeleteReferences also reject abstract ReferenceTypes; Browse still
accepts them (e.g. `HierarchicalReferences` with includeSubtypes).
- **Concurrent AddNodes:** duplicate BrowseNames under one parent get
`Bad_BrowseNameDuplicated`.
- **TranslateBrowsePathsToNodeIds:**
- Requires Browse permission on intermediate nodes and final targets
(RolePermissions and AccessRestrictions).
  - A long RelativePath no longer rejects the whole request.
- **SemanticChangeEvent:**
- Raised once per actual change of a semantic property, whether or not
it is monitored.
  - Also raised for properties with the SemanticChange access-level bit.
  - Reports the owning node's TypeDefinition as AffectedType.
- **Localized diagnostics:** operation-level text is translated when
OperationLocalizedText is requested.

### History and aggregates (Part 11 / Part 13)
- **DeleteRawModified:** an unspecified or reversed range returns
`Bad_InvalidTimestampArgument`, and StartTime == EndTime deletes the
value at StartTime for every provider.
- **ReadAtTime fallback:** reads only the values around each requested
time. A time whose bound is not found within the search limit returns
`Bad_BoundNotSupported` instead of failing the request.
- **ReadProcessed:** a ProcessingInterval shorter than one tick returns
`Bad_AggregateInvalidInputs`.
- **Delta / DeltaBounds:** widen to the next larger signed type when the
result does not fit the source type (e.g. a decreasing unsigned counter,
or an Int16 overflow).
- **Range / Range2:** return the source data type.

### Alarms and fluent API (Part 9)
- **Suppression:** an alarm stays suppressed while any suppression group
is active, and re-evaluation without a state change does not move
TransitionTime.
- **`MonitorVariable(variable)`:** sets SourceNode, SourceName and
InputNode to the variable and makes it a ConditionSource (HasCondition
to the alarm, HasEventSource from the object).
- **Fluent input/output arguments:** InputArguments / OutputArguments
added through the fluent API to a registered method are readable.
- **Root notifiers:** a failed root-notifier registration no longer
leaves an orphaned event worker.
- **State-machine handlers:** exceptions are logged at Error.

### File system (Part 20)
- **Delete / MoveOrCopy:**
  - Only act on direct children of the directory.
- A NodeId whose kind does not match the entry returns `Bad_NotFound` (a
file NodeId can no longer delete a directory).
- Copying a directory into its own subtree returns
`Bad_InvalidArgument`.
- **Locking:**
- An open file, or a directory containing one, cannot be deleted or
moved (`Bad_InvalidState`).
  - The check is atomic with opens.
- While a Delete or Move runs, read opens get `Bad_NotReadable` and
write opens `Bad_InvalidState`.
- **Open:**
- Reserved mode bits or EraseExisting without Write return
`Bad_InvalidArgument`.
  - Read+Write returns `Bad_NotSupported`.
  - Read while a writer is open returns `Bad_NotReadable`.
- **Client `FileSystemErrors`:** maps `Bad_NotReadable` to
`IOException`.
- **RuntimeNodeSet:** well-formed NodeSets with a stray `Model` element,
or two models in one file where one requires the other, now load.

### Server core, hosting and lifecycle
- **`SessionEstablishmentQueueLimit` > 0:**
CreateSession/ActivateSession now queue for a permit. The permit is
acquired before the request is registered, and a waiting handshake
releases its worker through the request-parking policy.
- **Node manager lifecycle:**
  - Add, reload and remove no longer wait for parked Publish requests.
- A Migrate reload racing with CreateMonitoredItems no longer blocks
later lifecycle operations.
  - Late items on retired or removed managers get `Bad_NodeIdUnknown`.
- **HistoryUpdate:** a batch mixing data and event details is bounded by
both limits.
- **Server stop/start:** stopping the server disposes the configuration
watcher and certificate subscription; starting recreates them.
- **Monitored-item queue factory:** a factory supplied by the caller or
the DI container is no longer disposed by the server.
- **Audit events:** each certificate validation error raises its own
AuditCertificate event. A failed OpenSecureChannel without a service
result is audited as failed.

## New public API (additive)
- `IQueuedSessionEstablishmentLimiter`
- `StandardServer.OwnsMonitoredItemQueueFactory` (protected virtual)
- `DefaultPkiRoot.Get` (Opc.Ua.Configuration)
- `TrustList.SetCertificateValidation`
- `NamespaceMetadataRegistry.Invalidate`
- `IApplicationConfigurationFileTargetProvider` and
`ApplicationConfigurationUpdatePlan`
- `GdsServerOptions.CertificateGroups` / `GdsCertificateGroupOptions`
- `InMemoryKeyCredentialRequestStore.AuthorizeRoles`
-
`DefaultManagedApplicationsNodeManager.ManagedApplicationsNamespaceUri`
- `AuthorizationServiceManager.RequestAccessTokenAsync(...,
IUserIdentity?, ...)`
- `AllowAnonymousWrite` on the file-system node manager and its factory
- `IFileSystemHost.TryBeginMutation` / `EndMutation`, replacing
`HasOpenHandles`

## Not fixed / known limitations
- **KeyCredential:** secret encryption with the client's PublicKey
(requests needing it are rejected).
- **CSR SAN restriction:** restricting subject / SAN values copied from
a CSR (CA policy).
- **Ticket signatures:** ticket signature verification (needs a
trusted-signer configuration).
- **FileType Read+Write open:** needs a read/write stream contract in
`IFileSystemProvider`.
- **Custom `IUserDatabase` implementations:** the disabled-user check
still scans all users per login. The built-in stores do a single lookup.
- **64-bit Delta precision:** limited by the calculator's double
arithmetic.

## Tests with changed expectations
Existing tests were updated where they asserted the old behaviour:
- File-system status codes
- Trust-list handle eviction
- `DiscardedMessageCount`
- Localized diagnostics bits
- Delta on unsigned sources
- SourceNode of fluent alarms
- ReferenceServer transfer permission test (now expects
`Bad_UserAccessDenied` notifications)
- `UserDeactivationShutdownReviewTests` (the revoked session is a
UserName login)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 07:26:05 +02:00
romanett 442889cba5 Fix source generator audit findings; conform structure-field matrices and type metadata to OPC UA 1.05 (#4569)
# Description

This PR fixes a bug audit of the source generation projects
(`tools/Opc.Ua.SourceGeneration*`: model loading, NodeSet import, all
emitters, the Roslyn hosts and packaging). It also fixes a max-effort
code review of those fixes, and a check of every spec-relevant change
against the OPC UA 1.05 text.

- **Scope:** about 85 audit findings, 48 review findings and 32
spec-grounding items were addressed. Each fix has a regression test that
fails without it.
- **Wire change:** it includes one deliberate, stack-wide wire change:
**structure-field matrices now follow OPC 10000-6 5.2.5 Table 28**.

Most changes are internal correctness fixes. The sections below list
every change a user can observe: wire formats, generated code and API,
generated address-space content, diagnostics and runtime behaviour.

## Behaviour / user-facing changes

### Wire format (encoders)
- **Binary inline matrices** (OPC 10000-6 5.2.5 Table 28).
- **Layout:** a multi-dimensional *structure field* is encoded as
`Int32[] Dimensions`, then the values **without a length prefix**, with
at least 2 dimensions.
- **Empty and null:** any dimension <= 0 means no values follow, and a
null matrix is `-1`. An empty matrix is written as `[0,0]`.
- **Compatibility:** this is **not compatible with earlier builds of
this SDK** for matrix fields. It applies to generated types,
`[DataType]` types, `Structure`, client ComplexTypes and
`WriteEncodeableMatrix`.
  - **Unchanged:** Variant matrices (5.2.2.16) keep their old encoding.
- **Declared rank decides matrix vs array.** A 1-D `MatrixOf<T>` in an
array field or a Variable value is written as an array again.
Matrix-declared fields go through the new public
`EncoderExtensions.WriteInlineMatrixValue`, which generated code also
uses. A non-empty 1-D value in a matrix field throws `BadEncodingError`.
- **Decoder hardening:**
- Before allocating, the decoder bounds all matrix dimensions against
overflow, `MaxArrayLength` and the remaining message length.
  - The decoder rejects:
    - a matrix with fewer than 2 dimensions;
- a populated matrix whose rank differs from the field's declared rank;
    - a union SwitchField greater than the number of fields;
    - an EncodingMask with unassigned bits set.
- **Encoder `MaxArrayLength`:** the encoder enforces `MaxArrayLength`
for inline matrices, as it already did for arrays.
- **XML structure fields** (5.3.1, 5.3.4, 5.3.5).
- **Fields:** `Structure` and client ComplexTypes write fields like
typed fields (`<A>1</A>`, array elements directly) instead of a Variant
body.
- **Matrix fields:** `Dimensions` and `Elements` go directly inside the
field element, with no `<Matrix>` wrapper.
- **Empty matrices:** written as nil, because XML dimensions must be
greater than 0.
  - **Compatibility:** decoders still accept the old shapes.
- **Enum fixes:** enum array elements with value 0 are no longer
dropped, and enum array element names are fixed.
- **Other encoding fixes:**
- A null `DiagnosticInfo` structure field writes `0x00` instead of
nothing.
- A null scalar field in a runtime `Structure` is written as its default
instead of zero bytes, which used to shift every following field.
- A Variant holding an empty matrix encodes as an empty array instead of
throwing.
  - `Variant.Copy` keeps the matrix identity of null and empty matrices.
- **NodeSet export and the DataContract surrogate** write Variable
values with the Variant rules again. A null or empty `MatrixOf` variable
no longer produces invalid XML or unreadable NodeSets.
- **`JsonEncoder.CanOmitFields`** is `true` only for Compact encoding.
Verbose and RawData JSON include default-valued fields.

### `[DataType]` source-generated types
- **Default-value handling** (OPC 10000-6 5.4.1, Table 1).
- **Encode:** a field is omitted only when its value *and* its declared
default are the type's default (0, false, null). Fields with a
non-default initializer or constructor default, and empty strings, are
always written, so configuration files get slightly larger.
- **JSON decode:** a field missing from JSON decodes to the type
default.
- **XML decode:** a field missing from XML keeps its declared default,
which older configuration files rely on.
- **Why:** this fixes silent round-trip loss, for example
`SamplingInterval = 0` read back as `-1`.
- **Nested `[DataType]` types:** they are now generated inside their
containing types. Without an explicit id, their default TypeId and
XmlName are qualified (e.g. `s=Outer.Foo`); **wire identity change** for
nested types. Colliding activator names get a `_2` suffix.
- **Activators:** they are `internal` when the type, or any containing
type, is not public (was CS0060).
- **Matrix properties:**
  - `MatrixOf<T>` properties use the inline matrix encoding.
- Matrices of non-sealed structures, or with
`StructureHandling.ExtensionObject`, are written as ExtensionObjects.
- Their published ValueRank is 2, because the rank can't be known from
`MatrixOf<T>`.
- **Published StructureDefinitions:**
  - They include base-class fields.
  - Nullable properties are no longer marked optional.
  - Inline structure fields name their concrete type.
- **`[Flags]` enums:** they are published as plain enumerations with
their member values, which matches how they are encoded.
- **Other generated code:**
- `Clone` deep-copies arrays and matrices of structures, Variants,
ExtensionObjects and DataValues.
- `IsEqual` and `GetHashCode` are consistent: EncodingMask, active union
member and NaN handling agree.
- Pooled `Reuse()` restores the freshly constructed state through the
new `protected virtual ResetForReuse()`, and returns an instance to its
runtime type's pool.
- **Enum capture:** enum member values are captured culture-invariantly.
Negative values were lost on cultures that use U+2212 as the minus sign.

### Model-driven generator: generated code, API and file names
- **Removed API:** generated `*_Placeholder` proxy accessors (e.g.
`PowerTrainTypeClient.GetMotorIdentifier_PlaceholderAsync`) and fluent
`X_Placeholder()` accessors are removed; they could never resolve
anything.
- Proxy accessors now look children up by BrowseName with the right
namespace.
- `RoboticsClient` finds motors and gears by type and drives via
`IsDrivenBy`. Its topology snapshots used to be empty.
- **Renamed files:** generated files for a bound `[NodeManager]` are now
named `{Namespace}.{Class}.NodeManager.g.cs` (also `.Factory` and
`.FluentBuilders`).
- **Generated NodeState code:**
- Attributes are set through `baseState`/`nodeState` locals, so children
named like base attributes (e.g. `DataType`, `EventNotifier`) compile.
- Children that hide base members are declared `public new`, using names
reflected from the runtime.
  - `global::` qualification is used throughout.
- **Proxies and fluent builders:** a proxy whose supertype is excluded
(e.g. Draft) derives from the nearest emitted ancestor. Fluent wrappers
and NodeState factories fall back the same way; they used to fail with
CS0246.
- **Derived structures:**
  - The optional-field `EncodingMask` continues across inheritance.
- A derived structure over a base type from another model no longer
relies on `base.ResetForReuse`.
- **Default values:**
- NodeId, QualifiedName and ExpandedNodeId defaults resolve by namespace
URI (`context.NamespaceUris.GetIndexOrAppend`) and server URI.
- StatusCode, negative enum, `double`/`float` (shortest round-trip
literal) and escaped string defaults now compile.
- **Event records:**
- Vendor event records have `Decoder.GetStandardFields(NamespaceTable)`,
and their browse paths use the model's namespace.
- A field whose namespace is unknown to the session no longer matches a
namespace-0 field.
  - Companion `StandardFields` is `[Obsolete]`.
- Vendor simple subtypes such as `Percent : Double` map to their
built-in type.
- **Other fixes:**
- `MethodState.CallAsync` passes the real `objectId` to synchronous
`OnCallMethod2` handlers; it used to pass `NodeId.Null`.
- Enumeration fields with an explicit `Identifier` keep it, instead of
taking a trailing `_<digits>`.
  - Explicit NumericIds are no longer reused by automatic numbering.
  - Dependencies are loaded in topological order.

### Generated address-space content
- **Arguments:**
- Methods without arguments no longer get empty
`InputArguments`/`OutputArguments` properties (OPC 10000-3 5.7).
- About 2,660 automatically assigned namespace-0 NodeIds of 15000 and
above shift. Every NodeId in `StandardTypes.csv` is unchanged.
- **Permissions:** type nodes and method declarations on types now carry
the model's `RolePermissions`/`AccessRestrictions`.
- **Restored nodes:** NodeSet import keeps nodes that were silently
dropped before:
- `NamespaceMetadata` objects and the NodeSet's own TypeDictionary
variables;
  - ISA95 placeholders linked only non-hierarchically;
  - instances whose parent is in another namespace.
- **Import details:**
  - ValueRank > 1 without ArrayDimensions keeps its rank.
  - `svr=` indexes map correctly.
  - Invalid MinimumSamplingInterval values import as -1, not 0.
  - Localized texts prefer the invariant or English translation.
- **StructureDefinitions:**
- They include inherited fields, including for subtypes without their
own fields (e.g. `AnonymousIdentityToken` now publishes PolicyId).
  - The StructureType kind covers inherited AllowSubTypes fields.
  - Unions ignore IsOptional.
- StructureFields never publish ValueRank 0; a OneOrMoreDimensions field
without dimensions becomes rank 2.
- Fields with ValueRank -2/-3 are published as scalar BaseDataType,
which is what is on the wire.
- **OptionSets:**
  - Implicit fields get the next free bit.
- `OptionSetValues` uses null for undefined bits instead of "Reserved",
and structure OptionSets publish all their bits (no 32/96-bit cap).
- Numeric OptionSets are limited to their base type's width, and UInt64
bit 63 works.
- **Schemas:**
- Structures with inline-matrix fields are left out of the `.bsd`
(5.2.5).
- In the BSD, Variant-encoded and non-structure subtyped fields are
described as `ua:Variant`.
- In the `.xsd`, matrix fields are typed `ua:Matrix` with
`Dimensions`/`Elements`, and `EncodingMask` is required.

### NodeSet / model input handling
- **Model selection:**
- The newest NodeSet of a model is chosen by `ModelVersion` (SemVer 2.0
precedence), then PublicationDate. The `Version` attribute, which Annex
F says is not for comparison, is only a tie-break.
- The order is total and does not depend on input order; referenced
assemblies use the same rule.
- **Default names:**
- Invalid default Name/Prefix values are repaired (`201301ISA95` becomes
`_201301ISA95`, `class` becomes `class_`).
  - Valid names are unchanged.
  - Sanitising no longer depends on the OS.
- **`ModelUri` metadata** that doesn't match the NodeSet raises a
warning instead of silently losing the model.
- **Input detection:**
- Only XML files whose root is `ModelDesign` or `UANodeSet` are treated
as models.
- A malformed NodeSet stays a NodeSet input, so it no longer breaks the
ModelDesign pass.
- **`IdentifierFile` paths:** they are normalized (`./`, `../`), and the
suffix match requires a folder boundary.
- **Dependency payloads** (`ModelDependencyV1`) carry field IsOptional,
AllowSubTypes, ArrayDimensions and the producer's namespace tables in
skippable trailers, so old and new readers stay compatible.

### Roslyn host, diagnostics, packaging
- **New diagnostics:**
- MODELGEN036: `[NodeManager]` target not supported (nested, generic,
file-local or global namespace).
- MODELGEN037: `[DataType]` target not supported (struct, generic,
abstract, file-local or global namespace).
- MODELGEN038: the definition of an encodeable base type can't be found.
- **New validator errors:**
- a structure with both optional and subtyped fields (OPC 10000-6 F.13);
  - multi-bit or duplicate OptionSet masks.
- **Diagnostic locations:** `[DataType]`/`[NodeManager]` diagnostics
point at real source locations again, so `#pragma` and `.editorconfig`
apply. MODELGEN010 is the exception.
- **Incremental caching:** pipeline values compare by value, so
unrelated edits no longer regenerate the whole model or all
`[DataType]`s.
- **`ModelSourceGeneratorIgnore`** on a ModelDesign input is honoured;
the design still resolves as a dependency.
- **Cancellation:** a cancelled generation run propagates
`OperationCanceledException` instead of reporting a MODELGEN003 error.
- **Stack package:** the `Stack` NuGet package now ships
`build/OPCFoundation.NetStandard.Opc.Ua.SourceGeneration.Stack.props`,
so `StackSourceGeneratorMode` and `StackSourceGeneratorExclude` reach
the compiler.

## Related Issues

- No tracking issue. The audit, review and spec-grounding reports are
available on request. Contains the matrix wire-format change discussed
for D-5 (OPC 10000-6 5.2.5 Table 28).

## Checklist

- [ ] 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.
(`docs/SourceGeneratedDataTypes.md`, `docs/NodeManagers.md`, generator
readme)
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings. (`UA.slnx` 0 warnings / 0 errors on net48 and
net10.0)
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed. The
affected suites were run on net10.0: SourceGeneration Core/Roslyn/Stack,
Types, Core.Encoders, Encoders.Fuzz, Client, Client.ComplexTypes,
PubSub, Core, Server, GDS, Configuration, Di, Robotics, ISA95,
Positioning, OpenUsd, XRegistry. net48 was built; its test legs, and the
post-rebase verification, run in CI.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 00:47:40 +02:00
Marc Schier c4e0721a29 Add a documentation learning path and fix issues found while verifying it (#4555)
## Summary

### Documentation
- Stage the documentation index as a learning path: a first application,
independent client, server, and PubSub paths, then data and services,
companion models, production, advanced integration, and draft or
experimental capabilities.
- Add Getting started (repository samples or NuGet packages), OPC UA
concepts, Your first information model, and a production readiness
checklist.
- Add Native AOT application publishing, a first PubSub publisher and
subscriber exchange, and the UDP transport options.
- Add or expand contents navigation throughout `docs/`, and keep
contributor documentation separate.
- Correct outdated SDK examples and capability guidance, consolidate
repeated explanations into canonical guides, and repair local links and
heading structure.
- Rewrite What's New in 2.0 as a concise feature overview with short
linked details; keep migration instructions in the Migration Guide.
- Expand the feature catalogue with specification IDs, GDS, LDS, the OPC
10000-21 onboarding registrar, ISA-95, xRegistry, OpenUSD, and
sample-local models; distinguish draft status, explain crypto
pluggability, and bring performance guidance forward.
- Address review feedback on wording, active monitored items during
shadow reload, and the `Samples.md` filename and references.
- Regenerate the `opcua-v20-migration` plugin's bundled copy of the
migration guides so it matches the edited `docs/migrate/2.0.x` guides.
- Merge the latest `master`, preserving upstream resource-isolation
guidance within the task-based documentation index.
- Retain deferred Lingity findings where appropriate; this PR does not
claim that every document meets the Lingity score thresholds.

### Fixes found while verifying the guides
Each fix has regression tests that fail without it.
- **Client:** V2 subscriptions compared their lifetime with the
operation timeout instead of the session timeout, and logged a spurious
warning. `ISubscriptionEngineContext`, which is new in 2.0, gains a
`SessionTimeout` property; implementations must add it.
- **PubSub:** a subscriber now registers the `DataSetMetaData`
configured on its readers (OPC 10000-14 6.2.9.4), and the UADP decoder
decodes DataSetMessages that carry no ConfigurationVersion. Before, a
secured subscriber of the reference sample received no DataSets.
- **Source generator:** a ModelDesign method that declares its arguments
inline, without a method type, produced code that did not compile; it
now uses `MethodState`. The generated code of existing models is
unchanged.
- **MinimalClient sample:** waits for its first data-change notification
before it disconnects.

### Known issues, not addressed here
- UDP multicast: the publisher does not send on the interface that the
subscriber joins, and `MulticastLoopback` defaults to false. The PubSub
guide uses unicast for the exchange on one computer.
- The UDP metadata announcement is sent unsecured on secured writer
groups; a secured subscriber drops it with a warning.
- A server does not start in globalization-invariant mode, because
`ResourceManager` creates `CultureInfo("en-US")`. The Native AOT guide
cautions against the mode.

## Validation
- `git diff --check` and
`./.azurepipelines/validate-migration-plugin.ps1` (15 bundled migration
docs validated, plugin manifests synchronized).
- Local Markdown links, anchors, file-link casing, and contents lists
checked across all 87 documents under `docs/`. The index links every
top-level guide and every target of the previous index.
- Both Getting started routes, Your first information model, the PubSub
exchange, and the Native AOT publish of the Getting started client and
server were run as documented. The NuGet route and the tutorial use the
public `2.0.0-preview.5` packages.
- The changed libraries build for all target frameworks in Release
without warnings.
- Tests on .NET 10: Opc.Ua.SourceGeneration.Tests 199 passed;
Opc.Ua.SourceGeneration.Core.Tests 3,977 passed, 8 skipped;
Opc.Ua.Server.Tests `Fluent` category 621 passed; Opc.Ua.Client.Tests
subscription-engine tests 70 passed; Opc.Ua.PubSub.Tests 1,374 passed, 2
skipped; PubSub fuzz replay 145 passed. The new and affected tests also
pass on .NET Framework 4.8.
- Earlier merge validation: 174 resource-isolation tests passed on .NET
10.
- The full solution suite was not run locally.

Review and validation artifacts are maintained outside the repository.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-09-28 19:19:39 +00:00
Klaus Landsdorf 797ca6fae4 Evaluate ContentFilter elements with null operands to NULL per OPC 10000-4 §7.7.3 (#4564)
# Description

`FilterEvaluator` returned FALSE for comparisons with a null operand, so
`Not(…)` over such an
element became TRUE and where-clauses selected events that do not have
the tested field.
OPC 10000-4 1.05.07 §7.7.3 requires that an element with a null operand
evaluates to NULL and
that a filter ending as NULL is FALSE.

Changes:

- `FilterEvaluator`: `Equals`, `GreaterThan`, `GreaterThanOrEqual`,
`LessThan`,
`LessThanOrEqual`, `Between`, `Like`, `BitwiseAnd` and `BitwiseOr`
evaluate to NULL when an
operand is a null value. `InList` returns TRUE on the first match
(unchanged), NULL if the value
  or an unmatched list operand is null, and FALSE otherwise.
- `IsNull` follows the nullable built-in types of OPC 10000-6 Table 1
and treats a null or empty
  array as null (OPC 10000-6 §5.1.11).
- `Between` with operands that have no common type is FALSE (§7.7.3), no
longer NULL.
- `Variant.IsEmptyArray` (new, public): true for a null or empty array
or matrix, implemented
through an internal `IElementContainer` on `ArrayOf<T>` and
`MatrixOf<T>`, so no boxing.
- Tests for every operator with null operands on either side, a missing
event field through a
`SimpleAttributeOperand`, `IsNull` per built-in type, `InList` with and
without a match next to
a null operand, and `Variant.IsEmptyArray`. A NULL result is asserted by
checking that both the
element and its negation are FALSE, which a TRUE or FALSE result cannot
satisfy. 14 of the
  test cases fail on master without the change.
- Changed existing tests: `EqualsWithNullOperands` expected
`Equals(Null, Null) == TRUE` and now
expects NULL; `ContentFilterTests.NonBoolWithBinaryTestCases` checks a
NULL result with `IsNull`
  instead of `Equals(x, Null)`.
- `docs/MigrationGuide.md`: new section on the changed NULL handling.

## Related Issues

- Fixes #4556

## Checklist

- [x] I have signed the CLA and read the CONTRIBUTING 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.
- [x] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.

Test evidence:
- `dotnet build` on all seven target frameworks (net472, net48,
netstandard2.0/2.1, net8.0, net9.0,
  net10.0): 0 warnings, 0 errors.
- Locally on net10.0/net9.0/net8.0 (macOS): `Opc.Ua.Core.Tests`
ContentFilter/FilterEvaluator suite
(228 tests) and `Opc.Ua.Types.Tests` Variant/ArrayOf/MatrixOf suite (10
549 tests) green.
- `dotnet format whitespace|style|analyzers --verify-no-changes` on
every changed file: no new
  diagnostic beyond what is already present on unmodified `master`.
- Fork CI (this branch, full matrix incl. Windows net48/net472, macOS,
Linux, AOT, coverage gate):
green, 149/149 jobs —
https://github.com/biancode/UA-.NETStandard/actions/runs/36329950927
- CodeQL: success —
https://github.com/biancode/UA-.NETStandard/actions/runs/36329952444

Full disclosure, AI tooling (Claude Code) was used to investigate this
issue, implement the fix and tests, and validate the build/format/tests
locally and on the fork CI before opening this PR.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Marc Schier <marcschier@users.noreply.github.com>
2026-09-28 16:30:00 +02:00
Marc Schier f9892020a3 Add configurable server resource isolation (#4541)
# Description

Add configurable server resource isolation on top of the merged #4522
foundation.

- Coordinate connection, handshake, retained-message,
session-establishment, queue, execution, request-cost and parked-request
limits through one server-scoped provider.
- Keep configured startup, continuity/reconnect, control and
trusted-owner reservations inside existing totals. Shared capacity
remains usable up to explicit owner ceilings.
- Use authoritative Session membership for reassembly continuity
capacity without granting unrelated resource or rate privileges.
Preserve explicit owner mappings and bounded tracking.
- Cache immutable classification evidence while retaining current
Session and classifier checks.
- Support weighted request ordering or FIFO independently of resource
accounting. Installed providers enforce decoded-request limits in both
modes.
- Preserve fail-fast reassembly, valid Abort handling, fixed deadlines,
active-work protection, reverse-connect handoff and bounded OpenAPI
processing.
- Support custom-handler parking through `IRequestParkingPolicy`,
`WithRequestParking(...)`, and `RequestLifetime.ParkSink`. Parking
releases execution capacity only, keeping retained-data and parked
charges until completion. Built-in Publish and the global opt-out remain
unchanged.
- Provide direct, fluent and DI configuration, public incoming-request
parking support, effective startup limit logs and user-facing
documentation.

Balanced remains the default. SharedOnly without an explicit provider
retains shared behavior for intentionally incompatible/unlimited
configurations. Reservations are finite and are not one full-message
allowance per Session.

## Related Issues

Builds on merged #4522. Fixes #4554.

## Review and CI corrections

All three previously missed findings in [review
5326734718](https://github.com/OPCFoundation/UA-.NETStandard/pull/4541#pullrequestreview-5326734718)
are addressed in `9e25e4ecc`:

1. A custom FIFO provider receives RequestQueue, RequestQueueBytes,
RequestExecution and ParkedRequest accounting. A blocked head cannot be
overtaken.
2. `IResourceIsolationReassemblyProvider` replaces the sessionless
threshold independently of `UseFairScheduling`. The aggregate byte
ceiling remains enforced.
3. Runtime `ReservedReassemblyBytes` includes all configured protected
stage floors, including ControlReserved.

The review's two already-resolved changes remain intact.

The CI collection race was fixed at its source in `e1a994a4b`:
`ArrayOf.ToArrayOf` treats a separately read collection count as an
initial capacity hint and grows/trims for the actual enumeration.
Deterministic tests cover growth, shrinkage and stale-zero counts.

The subsequent recovery-test race was fixed in `05753ce77`: fake time
advances only after a gated keepalive response has been processed and
reset its timer, not merely after request arrival. Recovery-order and
timeout assertions remain intact, with additional response-state
assertions. Instrumentation reproduced the original failure twice; both
fixed variants passed 400 executions per framework.

CodeQL alert 388 was verified and dismissed as a false positive. Startup
logs contain aggregate numeric capacities, not credentials or caller
identities.

## Validation

| Scope | Result |
| --- | --- |
| Latest automated checks at `05753ce7775a58801b4ba9d8f6d7781b709d0b89`
| 167 successful, one package-publication check skipped, no failures |
| Remaining external prerequisite | `license/cla` pending: Contributor
License Agreement is not signed yet |
| FIFO/weighted queues, custom parking and server-channel selection |
175 passed on each net10.0/net48 |
| Isolation, configuration and parking-hosting selection | 259 passed
net10.0; 257 passed net48 |
| ArrayOf conversion tests | 73 passed on each net10.0/net48 |
| Recovery-audit and keepalive fixtures | 40 passed on each
net10.0/net48 |
| NativeAOT smoke from preceding updates | Published and executed
successfully |

No tests, assertions, coverage thresholds, or required checks were
weakened. The remaining CLA prerequisite must be completed through [CLA
Assistant](https://cla-assistant.io/OPCFoundation/UA-.NETStandard?pullRequest=4541).

This is not CTT certification or an unconditional network-DDoS,
whole-process-memory or fleet-wide guarantee.

## Checklist

- [ ] 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.
- [ ] 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.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [x] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [x] I have addressed **all** PR feedback received.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: marcschier <11168470+marcschier@users.noreply.github.com>
2026-09-27 21:11:52 +02:00
Marc Schier 46fa7d364a Harden recovery, concurrency, and resource lifetimes across the OPC UA stack (#4516)
# Description

Bound managed-session recovery, preserve Publish workers and recovery
ownership, and address the client, server, transport, and certificate
audit findings and subsequent concurrency review. Also reduce the memory
retained by exhaustive session-test diagnostics.

## Recovery and Publish ownership

- Add `ManagedSessionOptions.ChannelReconnectTimeout` through direct
construction, the fluent builder, and DI. The automatic duration is
`max(3 * KeepAliveInterval, revised SessionTimeout, OperationTimeout)`,
sampled at cycle start. Positive overrides are supported;
`Timeout.InfiniteTimeSpan` explicitly opts out. Raw sessions retain
their unlimited default.
- Add `CreateReconnectBudget(TimeProvider)` as a required
`IReconnectParticipant` member, as agreed in review. Custom participants
implement it and may return null for no participant-specific bound.
- Enforce the earliest participant/caller deadline through transport
reconnect, activation/recreation, and subscription restoration. Late
callers can shorten an active cycle but cannot extend it.
- Cancel and unwind recovery, fence late responses, and release channel
recovery ownership before the existing `Faulted` event hands control to
the outer reconnect/failover policy. Preserve session recreate admission
through final Publish cleanup and keepalive suppression until shared
recovery is complete.
- Treat participant budget creation/evaluation failures, including
exceptions at the exact deadline, as terminal shared-recovery failures.
Notify participants before exposing `Faulted`, preserve the original
diagnostic, and never silently replace a finite budget with unlimited
recovery.
- Keep caller-supplied replacement channels, managed bindings, wrapper
event subscriptions, and recovery-owner identities consistent across
success, failure, and cancellation. Reject replacement during active
recovery and fence retired callbacks so they cannot clear newer
ownership or suppress later keepalive recovery.
- Retain eligible Publish workers through temporary
subscription-identifier clearing without retaining workers for
never-created or removed subscriptions. Preserve cancellation,
acknowledgement rollback, and drain-before-replacement ordering.
- Coordinate automatic subscription updates with session recovery:
cancel admitted updates, keep them paused across a failed-restoration
handoff, and retry only after explicit restoration completes. This
prevents competing CreateSubscription requests and updates against a
retiring session, while preserving manual recreation, transfer fallback,
and pending option changes.
- Exercise the real ManagedSession, V2 subscription engine, and channel
ready gate with scripted transports and an injected clock. Cover
transfer fallback, deadline handoff, late completions, and resumed
notification delivery.

## Audit and review corrections

- Correct certificate/trust-list transactions, change-pump lifetimes,
validation, peer revalidation, and reset/resource ownership.
- Harden node admission, attribute/range/dimension validation,
monitored-item sampling and lifecycle transitions, event fanout, and
server shutdown. Run lifecycle readiness and compensation outside shared
monitored-item admission, with stale-completion ownership checks.
- Close synchronous node-manager admission before teardown and retain
resources until admitted service, lifecycle, and asynchronous method
work returns. Dispose the monitored-item manager outside both locks,
reject late calls before they can reach it, and forward asynchronous
disposal through the adapter so the master awaits the same completion.
Preserve reentrant shutdown initiation, cancellation/failure unwinding,
and shared cleanup-failure reporting.
- Bound WSS pending-request retention using connection-local wire
handles while preserving caller-visible request handles and translating
Cancel targets. Safely ignore retired replies and drain admitted
send/close users before resource disposal.
- Clarify that the shared `ClientBase` caller-handle sequence is
distinct from the non-wrapping WSS wire allocator. Existing
exhaustion/reopen, late-reply, and Cancel-translation regressions verify
the connection-local contract; no allocator behavior was changed by this
clarification.
- Preserve virtual-node validation and resolver service errors instead
of converting them to cached misses. A mismatched NodeId reports
`BadNodeIdInvalid`; callback type mismatches and resolver service errors
keep their exact status. Per-item boundaries preserve unaffected reads,
writes, history operations, method calls, browse paths, and
monitored-item creation/restoration, including permission-validation
paths. Caller cancellation still propagates; stored-item validation
failures are logged without aborting later restores.
- Correct client file/role/state-machine/streaming APIs, node-cache
behavior, redundancy/failover ownership, and session
configuration/identity handling.
- Cancel each owned peer-discovery scope before disposing it, including
when the bounded wait wins a cancellation-delivery race. Keep late
results out of the endpoint cache and preserve single-flight lookup
ownership.
- Keep materialized alias Browse results synchronized with the live
store by default, preserving explicit startup-only opt-out. Apply
HistoryUpdate service results per value while retaining explicit
all-or-nothing provider APIs.
- Update the directly related feature documentation and add focused
positive, negative, concurrency, and ownership regressions.

## Latest review-feedback corrections

- Fence stale failed lease observations with a successful-confirmation
generation distinct from the revocation epoch. Snapshot it before each
store read, including failed-CAS revalidation, so an old failure cannot
revoke or reschedule a newer lease while fresh ownership loss still
revokes leadership. Preserve independent overlapping acquisitions,
expiry, disposal, and late-success protections.
- Isolate attributable WSS response-decoding errors to their request,
including retired handles. Keep admitted socket sends connection-owned
so caller timeout/cancellation cannot abort sibling requests; shutdown
still cancels and drains those sends.
- Restart keepalive and classic Publish replenishment after successful
session reuse. Retire V2 worker demand after intentional
deletion/disable without confusing that state with temporary recovery
resets.
- Browse virtual directories lazily across continuation pages without
the 1,024-entry snapshot cap. Preserve active-page cancellation, bounded
metadata retention, pushback, and exactly-once asynchronous cursor
cleanup.
- Apply certificate/CRL staging in last-operation order, preflight
required issuer storage before writes, select actual policy-compatible
RSA keys for None endpoints, frame certificate chains independently of
signature OIDs, and preserve admitted validation results during
rejected-writer shutdown.
- Preserve base asynchronous disposal when a legacy override omits its
base call; avoid double derived disposal in the adapter. Let genuinely
admitted nested work finish while rejecting stale captured contexts and
returning null from closed lookup APIs.
- Re-park recovered monitored items if AddNodes rolls back. Give event
compensation a fresh finite deadline, and preserve healthy
Browse/Translate siblings at every reference/path boundary while
retaining starting-node errors and caller cancellation.
- Give final reconnect notifications a live, bounded shutdown-linked
lifetime. Synchronize child state before parent lifecycle handlers
outside the parent transition lock.
- Document the accepted existing policies: direct reverse connections
are single-use without a ReverseConnectManager, and multi-node stream
capacity is shared rather than reserved per node.

The expanded readiness controls also exposed a real generation-handoff
race: an old source could finish draining after activation was deferred
but before readiness tasks were collected, causing the wanted
replacement to be omitted from the wait set. A deterministic test gates
that exact interval; the fix keeps the replacement in the readiness
wait. The original race passed 200 repetitions after the fix. A separate
control now awaits both producer readiness and iterator entry under the
same original deadline, rather than treating those independent signals
as equivalent. Temporary logging and repetition are removed.

## CI integration contracts

The History and Server CI failures exposed integration tests that still
asserted the behavior intentionally changed by the audit fixes. A
Windows WoT negative test raced its caller deadline against upstream
retries. A subsequent Ubuntu Client failure exposed a real
automatic-update versus explicit-restoration race:

- History insertion collisions now verify exact per-value statuses and
all persisted timestamps/values, including preservation of the existing
conflicting entry. Explicit atomic-provider rollback controls remain.
- Alias materialization tests wait for actual publication and verify the
added node plus forward/inverse `AliasFor` references, instead of racing
a startup-only snapshot expectation. Explicit refresh opt-out controls
remain.
- The unavailable-upstream WoT test uses an explicit finite retry policy
through existing DI configuration, preserving its original four-minute
guard and requiring a mapped-value service error before caller
cancellation. Production retry defaults remain unchanged.
- The Client fix retains the exact one-create and late-response
assertions. Three deterministic real-subscription/real-manager cases
cover paused creation, disposal while paused, and cancellation of an
admitted create followed by explicit restoration and a pending option
update. Both composed restoration-deadline cases also prove automatic
updates stay blocked at the outer handoff and resume after restoration.
- The merged-head net48 correction prevents lost peer-resolver
cancellation and distinguishes the harness's delayed initial `Connected`
event from actual outer recovery completion. A deterministic timer-order
test covers both cooperative and cancellation-ignoring resolvers,
cancellation before return, rejection of late cached results, and a
fresh subsequent lookup.
- The remaining CodeQL alert was a false content flow through
`ArrayOf.Count` into an integer-only trust-list summary. Projecting the
same count through `Span.Length` keeps cardinality distinct from
serialized contents without allocating, dropping diagnostics,
suppressing the rule, or dismissing the alert. Five log-contract cases
cover null/empty/nonempty results and mixed chain-capable/fallback
listeners, asserting the exact count, message, and structured
properties.
- Classic transfer acknowledges messages selected for asynchronous
Republish only after their responses arrive. The existing locked
replay-selection decision returns the immediate-acknowledgement subset
to Session, preserving the public transfer API, advertised availability,
and immediate acknowledgement of older gapped messages not selected for
replay. A callback changing `RepublishAfterTransfer` cannot change the
already-selected acknowledgement ownership.
- Transfer integration tests explicitly control acknowledged versus
retained origin messages. Exact assertions distinguish retained replay
from requested fresh initial values by sequence number, client handles,
values, status, and source timestamp. Atomic counters, observed
delivery/keepalive progress, and failure-safe session/file cleanup
replace timing assumptions and racy resets.
- Server lifecycle tests verify the documented independent five-second
compensation deadline rather than requiring a non-cancellable token.
They cancel the actual request, prove compensation and unrelated
monitored-item creation still proceed one tick before the deadline,
preserve the original failure and ownership assertions, and verify the
completed deadline is retired.
- Synchronous and asynchronous directory-browser tests require the exact
provider exception instead of an empty-directory success, then verify
the failed cursor is not enumerated again. These correct expectations
left behind by the intentional, documented provider-error propagation
change; production behavior is unchanged.

No tests were disabled, assertions loosened, coverage thresholds
lowered, or CI checks made optional. Existing platform exclusions and
Explicit debug variants remain.

## Related Issues

- Fixes #4514
- Fixes #4515
- Builds on merged #4503 and retains the #4508 publishing-drain
behavior.

## Validation

**Lease-election review correction
(`a13afa641182c70983e16bbcfebf507e666a45a5`):**

The correction is based on the existing upstream merge
`43079e2b851fe338a4082066f8d14abfdafb6e32`. Three deterministic
stale-verdict
cases fail on the original source and pass with separate confirmation
fencing.
Six new cases cover the initial foreign-owner verdict, failed-CAS
revalidation
in both invocation orders, and fresh ownership loss through both read
paths,
including revalidation begun after another successful confirmation.

| Target and configuration | Passed | Failed | Skipped |
| --- | ---: | ---: | ---: |
| net10.0 Debug | 120 | 0 | 0 |
| net48 Debug | 120 | 0 | 0 |

The selection is the full `Opc.Ua.Core.Tests.Redundancy` namespace,
including
all 38 `SharedStoreLeaseElectionTests` cases and the existing expiry,
clock
rollback, disposal, and overlapping-success regressions. New witnesses
are
`StaleForeignLeaseReadDoesNotRevokeNewerAcquisitionAsync`,
`StaleFailedRevalidationDoesNotRevokeNewerAcquisitionAsync`, and
`FreshForeignLeaseObservationRevokesLeadershipAsync`. They assert
resulting
leadership, exact transitions, independent progress, and the unchanged
expiry
boundary. Retaining the initial generation for the fallback read
independently
makes the fresh-loss-after-confirmation control fail; that mutation was
removed
before the final rebuilt runs.

The successful scoped command is
`dotnet test tests\Opc.Ua.Core.Tests\Opc.Ua.Core.Tests.csproj -c Debug
--no-restore --filter
"FullyQualifiedName~Opc.Ua.Core.Tests.Redundancy"`,
with `CustomTestTarget` and `-f` matching each row and that framework's
assets
restored. Both changed C# files pass formatting and analyzers through
informational severity on both frameworks. These are scoped results, not
a
full-solution test or measured coverage-delta claim. CI monitoring is
paused
for this feedback-only operation; no current-head CI success is claimed.

**Latest CI contract correction
(`1cd1f2047bb5b50c9bf300b1ff710b0b2e935a29`):**

All four failing Server shard 23 jobs in
[run
36120946740](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/36120946740)
reported the same six cases. Two local replays reproduced all six
failures.
The source changes and documentation from `cd43b831a` establish both
intentional
contracts: independent bounded event compensation and surfaced provider
errors.
Only the two test files are corrected; production code and CI policy are
unchanged.

| Target and configuration | Passed | Failed | Skipped |
| --- | ---: | ---: | ---: |
| net48 Debug | 113 | 0 | 0 |
| net10.0 Release, CI Coverlet settings | 113 | 0 | 0 |

The selection contains `MonitoredItemManagerLifecycleTests`,
`DirectoryObjectStateTests`, `DirectoryBrowserBoundedEnumerationTests`,
and

`EventCompensationReadinessHasFiniteDeadlineWithInfiniteSourceTimeoutAsync`.
Both changed files pass formatting and analyzers through informational
severity
on both frameworks. Independent mutations restoring a non-cancellable
compensation token and swallowing provider errors fail all four
lifecycle
cases and both browser cases, respectively. Both mutations were removed
before
the final passing runs and commit.

The scoped command is `dotnet test
tests\Opc.Ua.Server.Tests\Opc.Ua.Server.Tests.csproj`
with `CustomTestTarget` and `-f` set to the selected framework,
`--no-restore`, and
`--filter
"FullyQualifiedName~MonitoredItemManagerLifecycleTests|FullyQualifiedName~DirectoryObjectStateTests|FullyQualifiedName~DirectoryBrowserBoundedEnumerationTests|FullyQualifiedName~EventCompensationReadinessHasFiniteDeadlineWithInfiniteSourceTimeoutAsync"`.
The modern run additionally uses `-c Release /p:CollectCoverage=true
--collect "XPlat Code Coverage" --settings
tests\coverlet.runsettings.xml`.
These are scoped results, not a full-solution test or measured
coverage-delta claim.
Hosted checks for that commit were pending at publication. The fail-fast
CI
watch was stopped when the requested operation switched to review
feedback;
no current-head CI success is claimed.

**Earlier master merge (`8b15ea6ef9fc2214ac8ec886f373bb3846dd6d6f`, base
`b4136d8e9bab0df680f41d9ef985f519160534b1`):**

The `TcpListenerChannel` conflict preserves both lifetime
responsibilities:
disposal clears the retained peer-certificate chain and unregisters the
quota-scoped message-assembly scheduler. Both fields and all upstream
deadline
handling remain intact. The only additional manual source adjustment
adds the
parentheses required by the existing analyzer around the session-count
fallback.

Master advanced during validation. The initial transport merge
`80303c434ce813cf75aa7fe54a6a32fe2aeafc30` was retained, and the newer
base was
merged normally in `8b15ea6ef9fc2214ac8ec886f373bb3846dd6d6f`, without
rewriting
history. The additional recovery, fluent-pipeline, and test-framework
overlaps
were inspected; the branch's cancellation, validation-error, and
acknowledgement
ownership fixes remain alongside the upstream changes.

Full `UA.slnx` builds passed on net10.0 and net48 with **0 warnings and
0 errors**.
The final scoped tests rebuilt against the committed tree
`2074f8efcb61d8d9c6106e7d6f76ee941940e358`:

| Target | Passed | Failed | Skipped |
| --- | ---: | ---: | ---: |
| net10.0 | 2,653 | 0 | 0 |
| net48 | 2,529 | 0 | 7 |

The net48 skips are existing platform exclusions: six RSA_DH_AesGcm
nonce cases
and one ClientWebSocket certificate-validation case requiring .NET 7 or
later.
The selection covers transport cleanup, reassembly
budgets/deadlines/admission,
peer-chain ownership, server session binding, fluent lifecycles and
source
generation, reconnect and keepalive, transfer acknowledgements, Publish
queues,
and JSON encoding. Modern-.NET WSS transport and real-server integration
cases
also pass. The resolved file passed solution-relative formatting and
analyzer
verification through informational severity on both frameworks.

These are scoped test results, not unfiltered full-suite or
measured-coverage
claims. Hosted CI was not monitored or repaired as part of this
merge-only
operation.

Selected local results below are overlapping validation runs, not
distinct-test totals.

**Earlier review-feedback batch
(`cd43b831a8f11de3f7d65c3681712a61e3c669a5`):**
The final net10.0 scoped selection passed **471 tests, with no failures
or skips**.
The final net48 solution-root selection passed **439 tests, with no
failures and
one unchanged platform skip**
(`ValidateServerCertificateFallsBackToTlsPolicyErrors`,
which requires newer ClientWebSocket certificate-validation support).
Both full nonincremental solution builds completed with **0 warnings and
0 errors**,
and all **40 changed C# files** passed informational-level
formatting/analyzer
verification. Both frameworks validated the identical final tracked
diff.
The focused matrix covers every one of the 20 current review threads,
including
real mid-frame WSS cancellation, stale admission contexts, directory
continuation
ownership, multi-element Browse/Translate failures, and the readiness
drain race.
The net10.0 solution-root test command has a separate pre-existing
runner conflict:
three unrelated AOT projects reject the VSTest target under
Microsoft.Testing.Platform
2.4.0 and SDK 10. Per-project net10.0 results are recorded separately;
no runner
migration, test exclusion, assertion relaxation, or CI-policy change is
included.
These are bounded selections, not unfiltered full-suite or measured
coverage claims.
At publication of `cd43b831a`, hosted CI was not green: the
`OPCFoundation.UA-.NETStandard` check reported `ACTION_REQUIRED`,
AppVeyor reported
`FAILURE`, and the two signed-build jobs were in progress. No CI jobs
were rerun,
cancelled, approved, or repaired as part of that feedback-only
operation.

**Classic transfer correction
(`f425376fca045794122e19aaf9cafebc4a717d91`):**
The preceding head's
[macOS net10.0 shard
25/30](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/35980326299/job/107570512458)
failed
`TransferSubscriptionTest(DisconnectedRepublish).TransferSubscriptionAsync(True,False)`
at the static-node count assertion: expected 10, received 20. The trace
contains a successful,
explicitly requested republish of retained sequence 1 and a fresh
initial-value batch at sequence 3,
not duplicate dispatch of one response.

Controlled lost origin acknowledgements reproduced that incorrect test
expectation on both the
preceding PR head and exact master base
`a2539cb53dd1666cef856f33812d7a8830d68909`.
The maintainer explicitly authorized correcting this existing issue in
the PR. Retained replay
and requested initial values are independent, as specified by
[OPC UA Part 4,
TransferSubscriptions](https://reference.opcfoundation.org/specs/OPC-10000-4/5.14.7).
The reason that the original hosted run retained its particular origin
message remains unproved.

The expanded full-suite run then exposed a **real production race**, not
just a count assertion:
Session queued acknowledgements for all transferred available messages
before the asynchronous
Republish responses arrived. Publish could delete those messages first,
causing
`BadMessageNotAvailable`. A gated real-Session/classic-engine regression
failed both replay-enabled
cases on the original implementation while both no-replay controls
passed. The ownership fix
passes all 24 combinations of empty/consecutive/unsorted-gapped queues,
replay preference,
sequential delivery, and a reentrant preference change.

All 40 normal real-server transfer cases pass on both frameworks.
Independent mutations
suppressing actual replay and duplicating observed sequence records each
fail the intended
assertion; both mutations were removed. The full classic suite passes on
both frameworks.
Hosted CI was pending when that correction was published; the earlier
failed hosted job was not
rerun for a lucky pass.

**Local net48 caveat:** the full Client run timed out in the
pre-existing fake-clock test
`ReconnectAsyncSwapsFaultedLeaseEntryAsync`, which does not exercise
subscription transfer.
Its unchanged isolated replay passed, but that does not resolve the
full-suite timeout.
No unrelated code or assertions were changed, and a clean full Client
net48 run is not claimed.

| Scope | net10.0 | net48 |
| --- | --- | --- |
| Full Client.Tests after transfer acknowledgement correction | 2,549
passed | 2,552 passed, 1 timeout failure noted above, 1 existing
platform skip |
| Full classic-subscription project after transfer correction | 57
passed, including all 40 normal transfer cases | 57 passed, including
all 40 normal transfer cases |
| Gated transferred-message acknowledgement ownership | 24 passed,
included above | 24 passed, included above |
| Saved-session consumer of the shared subscription helper | 40 passed |
16 passed, 24 existing unsupported-security-policy skips |
| Audit regression matrix | 2,339 passed | 1,934 passed, 34 existing
skips |
| Full Client.Tests after review follow-ups | 2,520 passed | Focused
selections only |
| Full Client.Tests after CI recovery correction | 2,523 passed, CI
coverage collector | 2,527 passed, 1 existing platform skip |
| Latest merged-head recovery/redundancy selection | 139 passed | 139
passed |
| Full Client.Tests after merged-head net48 correction | Focused
selection above | 2,529 passed, 1 existing platform skip |
| Lifecycle and actual-dimension regressions | 146 passed | 146 passed |
| Focused recovery follow-ups | 7 passed | 7 passed |
| WSS-related fixtures | 93 passed | Not applicable |
| CI History integration corrections | 28 passed | 28 passed |
| CI alias, dispatcher, and explicit-atomic controls | 63 passed | 63
passed |
| Full WoT sample project with CI coverage collector | 29 passed | Not
applicable |
| Trust-list effects and count-only summary contract | 22 passed | 22
passed |
| Virtual-node feedback, node-manager, dispatcher, and lifecycle
selections | 661 passed, 63 existing not-executed cases; all 48
virtual-node cases passed | 661 passed, 63 existing not-executed cases;
all 48 virtual-node cases passed |
| WSS wire-handle exhaustion, late replies, and Cancel translation | 9
passed | Not applicable |
| Synchronous node-manager disposal and related service/lifecycle suites
| 1,037 passed, 64 not-executed cases from existing fixture assumptions;
all 93 sampling/disposal cases passed | 1,037 passed, 64 not-executed
cases from existing fixture assumptions; all 93 sampling/disposal cases
passed |

- Full solution builds passed on net10.0 and net48 during the audit
work. The subsequent full non-incremental net10.0 build also completed
with zero warnings/errors.
- Modified source/test formatting and analyzer checks passed through
informational severity.
- Original composed recovery regressions passed repeated runs on both
frameworks. Independent mutations removing Publish cancellation,
bypassing the recreate drain call, or ignoring ready-gate cancellation
each failed the intended assertion; all mutations were restored before
passing runs.
- The latest recovery correction passed 100 executions of each original
restoration-deadline scenario with unchanged assertions. Restoring
premature update admission independently made both strengthened handoff
cases fail; the correct retention behavior was restored afterward.
- Startup-event and cancellation-delivery timing probes reproduced the
subsequent net48 failures, then passed 100 executions of each of the
three scenarios with the fixes. All timing probes and repetition were
removed. The permanent timeout-order regression also went RED on the
original cancellation cleanup without sleeps or scheduling
amplification.
- Final post-format recovery/subscription/engine checks passed all 151
selected cases on net10.0. The first local full net48 attempt aborted in
`clr.dll` with native access violation `0xc0000005`; an
unchanged-assembly full diagnostic rerun passed as recorded above. That
native host crash has not been attributed or claimed fixed.
- A minimal replay with the same CodeQL CLI and query-pack versions
reproduced the custom-count alert and confirmed that the standard
span-length projection is clean while direct sensitive-value and
raw-identifier logging controls remain flagged. The source was already
logging only a count; no identifier or certificate disclosure was
established at the reported location.
- The virtual-node feedback regressions first reproduced five
error-preservation/cache failures; expanded service-dispatch cases
exposed two additional whole-batch failures before the
operation-boundary fixes. The final selections pass on both frameworks.
Genuine misses remain cached, successful results remain reusable, failed
validation allows same-cache retry, and request cancellation still stops
the batch.
- Three deterministic synchronous-manager disposal regressions
reproduced early resource release and lifecycle calls reaching a manager
during disposal. The fix passes direct and adapted calls, a
sampling-worker join after the last operation exits, reentrant shutdown,
cancellation and failure paths, repeated disposal, and exact rejection
across 39 service/lifecycle entry points both during drain and after
cleanup. The original outside-node-lock disposal regression remains and
now requires the worker to observe closed admission.
- Prior-head CI on `c985f8bded77a60449d9cd381c8a004d8590ccf6` completed
with **168 passing checks, one expected release-package-publication
skip, and no failures or pending checks**. All test shards, signed
builds, container builds, the coverage check, and the CodeQL PR check
passed. Hosted CodeQL analysis `1829262912` completed successfully and
marks count-flow alert 385 fixed. This is evidence for that head, not a
claim that subsequent review follow-ups have completed hosted CI.

### Session-test diagnostic memory

Complete browse listings are streamed to unique NUnit attachments rather
than retained in inline NUnit/VSTest output. Detached regression-test
results are cleaned up without deleting runner-collected attachments.

- Two covered session-suite runs each completed with 782 passed and 379
existing skips. Peak working set fell from 4.86 GiB to 2.44-2.78 GiB.
- TRX output shrank from 303.1 MiB to approximately 40.7 MiB, retaining
all 46 complete browse attachments.
- Seven output regressions cover exact content, empty/single/large
listings, disabled/enabled output, bounded inline summaries, and unique
filenames.

The separate Azure build-18452 VM deprovisioning cause remains unproved
without provider logs. The memory measurements do not establish its
cause.

Unfiltered local `UA.slnx` tests, including the long stress/soak suites,
have not been run on both frameworks. A local coverage delta was not
collected; builds and focused tests are not substitutes for those
claims.

## Checklist

- [ ] 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.
- [ ] 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.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [ ] All checks on the latest review-follow-up head have completed
successfully; prior-head CI passed as recorded above.
- [x] I have addressed **all** PR feedback received.

Tests have been added and pass as recorded above; the hosted coverage
check passed on the prior head identified above. The combined
tests/coverage-increase checkbox remains open because no local coverage
delta was measured.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 68f26810-22b8-476f-abcd-0a8c82d6e208
2026-09-25 14:59:34 +00:00
Cristi Pogacean 5fa272d89a Fix instance-scoped certificate store resolution and consumers (#4546)
## Summary
Route certificate operations through the providers registered on the
configured manager, so custom stores work consistently during
validation, key loading, provisioning and GDS operations.

- Add optional `ICertificateStoreResolver`, implemented by
`CertificateManager`, without adding members to `ICertificateManager` or
removing existing public signatures.
- Use scoped resolution for configuration validation, startup and
cold-cache private-key lookup, application certificate
provisioning/reuse/deletion, existing-key GDS signing requests, and
hosted TrustList access.
- Preserve built-in lookup when no resolver is available, private-key
lookup fallbacks, access checks and transaction behavior. Resolver
failures do not silently fall back to Directory. Returned stores remain
caller-owned; TrustList retains cached-store disposal.
- Add regression coverage for instance isolation, mixed provider types,
real private-key reuse, rotation lookup, cancellation/failure disposal,
unavailable-key rejection, CSR output and TrustList lifetime. Document
the optional capability.

Fixes #4527
Fixes #4528
Fixes #4529
Fixes #4530
Fixes #4531

## Validation
Change-scoped Release tests on Windows, on base `5d26f6f43`:

| Fixture selection | net10.0 | net48 |
| --- | ---: | ---: |
| CertificateManagerFactoryTests + CertificateManagerTests | 71 passed |
71 passed |
| ApplicationInstanceTests | 56 passed | 56 passed |
| TrustListTests + ConfigurationNodeManagerPushTests | 147 passed | 147
passed |

All six selections exited 0 with zero failures or skips. Changed-file
whitespace verification and `git diff --check` passed.

Fresh no-fix comparisons retained the new resolver API/manager
implementation, but reversed the validation, provisioning and CSR
consumer fixes: validation had 2 custom-store failures / 1 built-in
control pass; provisioning had 4 custom-store failures / 1 built-in
control pass; CSR had 1 custom-store failure / 1 built-in control pass.
Failures reported unknown custom store types or invalid custom trust
stores. All consumer source changes were restored, rebuilt, and the full
focused net10.0 selections passed again.

Earlier TrustList regression evidence is recorded in #4531: its before
comparison retained the constructor injection boundary while exercising
the old store-opening path. It was not a test against an entirely
unextended API.

The complete platform/framework matrix is left to PR CI; no full local
matrix or completed CI qualification is claimed. Local restore used
command-line-only NuGet v2/feed-audit overrides; no repository feed,
test or contribution guidance was changed.
2026-09-25 12:13:31 +00:00
romanett e0c56a7c00 Replay the fuzz crash corpus from the fuzz-corpus orphan branch (#4539)
# Description

Since the move to GitHub Actions, CI replays only the checked-in fuzz
corpus. The full crash corpus used to be the Azure DevOps secure file
`FuzzingArtifacts.zip`, which `.azurepipelines/test.yml` overlaid onto
`Opc.Ua.Encoders.Fuzz.Tests`. The nightly `private-corpus` job that was
meant to replace it depended on an Azure storage account, federated
credentials and a protected environment. None of these were ever
provisioned, so the job never ran.

**The corpus now lives on the orphan branch
[`fuzz-corpus`](https://github.com/OPCFoundation/UA-.NETStandard/tree/fuzz-corpus)**
(commit `843ba944a7b144a7ae6429151846d9e5ab28d29f`, already pushed):
- It contains 22,267 `crash-*`, 25 `slow-unit-*` and 1 `timeout-*` input
under `Opc.Ua.Encoders.Fuzz.Tests/Assets/`.
- The files are byte-identical to the secure file (SHA-256
`85198e46…fe1d61b`), recovered from the `drop` artifact of Azure build
18397.
- The branch shares no history with `master`, so clones of `master`
don't get 22k extra files in their checkout.

**Why the corpus is no longer treated as private:**
- The inputs date from April–June 2024.
- The `drop` artifacts could be downloaded anonymously.
- Every master build printed up to about 7k base64 reproducers into
anonymously readable Azure logs.
- Current `master` replays every input without a test failure.

New, unfixed crashes still go through
[SECURITY.md](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/SECURITY.md)
and are added to the branch only after the fix ships.

## Changes

- **`nightly.yml`:** `private-corpus` is replaced by `crash-corpus`. The
new job:
- checks out the commit pinned in `FUZZ_CORPUS_COMMIT` (no secrets,
environment or OIDC);
- verifies the SHA and copies each top-level folder over
`fuzzing/<folder>`;
- runs `Opc.Ua.Encoders.Fuzz.Tests` on the 12 non-macOS profiles through
the shared `run-dotnet-tests` action and uploads
`crash-corpus-results-*`.

The `run_private_corpus` input and the INCOMPLETE label are removed. The
job runs on every scheduled and manual run, and the nightly summary
fails if it was skipped.
- **`fuzzing/CrashCorpus.md` (new):** the Azure history, branch layout,
how CI replays the corpus, local replay, and how to add inputs and move
the pin. `Fuzzing.md` links to it.
- **`docs/DeveloperGuide.md`:** the private-corpus section and its
secrets table are replaced, and the migration to-do list is updated.
- **`nightly.yml` AOT job (fix for a failure found by the first nightly
run):** the Linux and macOS legs looked up the published binary by
`BaseName`/`Extension`. A NativeAOT executable has no extension on those
platforms, so PowerShell splits `Opc.Ua.Aot.Tests` into `Opc.Ua.Aot` +
`.Tests`, and every leg reported *No NativeAOT executable was produced*
even though publish succeeded ([run
36017168717](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/36017168717),
`aot-ubuntu-latest`). The lookup now matches the exact file name. A
failure now reports the publish errors, a missing publish directory, or
which files were produced instead.
- **`Opc.Ua.Redundancy.Samples.Tests` / `Opc.Ua.WotCon.Samples.Tests`
(fix for failures found by the first nightly run):** the test runner's
framework guard rejected both projects on the nightly net8.0, net9.0,
net472 and netstandard2.1 profiles. Redundancy built a net48 placeholder
for every target except net10.0, and WotCon was pinned to net10.0. Both
now build the requested target on net8.0/9.0/10.0 and use
`RestrictForLegacyTfm` to become explicit not-applicable shells on
net48, net472, netstandard2.0 and netstandard2.1. The redundancy tests
no longer hard-code `bin/<cfg>/net10.0` when locating the sample
binaries, and `SampleAppProcess` falls back to an `object` lock below
net9.0. The net48 placeholder remains for Azure's plain `dotnet test -f
net48` leg. Checked locally by building Redundancy for
`CustomTestTarget=net9.0` and WotCon for `netstandard2.1`.
- **`DefaultServerRedundancyHandler` (fix for a failure found by the
first nightly run):** `ResolveServerAsync` gave the resolver a token
linked to the caller's, but waited on the caller's token directly. On
cancellation the wait could finish, and its `using` could dispose the
linked source, before the linked source's own callback had run. That
unhooks it, so the in-flight peer lookup never saw the cancellation
(`PeerDiscoveryPropagatesCallerCancellationAsync`, netstandard2.0 build
on net48). The wait now uses the linked token. A timeout is still logged
as a discovery failure, as before. A companion test fix makes
`PeerTimeoutPreservesOtherEndpointsAndCoalescesPendingDiscoveryAsync(False)`
deterministic. The timed-out lookup drains on the thread pool, so the
test's retry could join it and then have its own time limit expire
before it started a fresh lookup. The test now waits for the second
resolver call before advancing the fake clock.
- **`Session` keep-alive logging (found while investigating the net48
nightly failures):** `OnKeepAliveError` and the keep-alive read failure
log used `Endpoint?.EndpointUrl`. `ClientBase.Endpoint` dereferences the
transport channel, so once the channel was detached it threw a
`NullReferenceException` on every keep-alive tick, and
`BadNoCommunication` never reached the `KeepAlive` subscribers. Both now
log the configured endpoint's URL. Three new `SessionKeepAliveTests`
cover a detached channel.
- **`ClientTestFramework` shared session (fix for 67
`Opc.Ua.Sessions.Tests` failures on the net48 nightly):** the
fixture-wide shared session used the 10 s default session timeout. A
test-host stall of about 11–18 s while other fixtures restarted their
servers let the server expire it, and every later test in the
`ClientTest("https")` and `ClientTest("opc.https")` fixtures failed with
`BadSessionIdInvalid`. The shared session now requests
`SharedSessionTimeout` = 120 s; per-test sessions keep 10 s.
- **`run-dotnet-tests.ps1`:** `-QuietOutput` help text only; it no
longer names the removed job.

The Azure secure file stays in place, because `master378` and the 1.x
release pipelines keep their own `test.yml` and still use it.

## Verification

- `nightly.yml` parses as valid YAML, and no job, output or summary
field still refers to the removed job.
- I ran the job's overlay script against a clone of `fuzz-corpus` at the
pinned commit. It overlaid all 22,293 files, left the tracked
`Assets/Repo` untouched, removed the checkout folder and wrote the
summary line.
- **Not yet run:** the workflow itself. It is
`schedule`/`workflow_dispatch` only, so it needs a manual `nightly.yml`
run on this branch. On master the corpus has so far only been replayed
on Windows (net48/net10.0 in Azure), so the Linux legs are new ground.

## Follow-ups (admin)

- Add a ruleset for `fuzz-corpus`: require pull requests and block force
pushes and deletion, so the pinned commit stays reachable.
- The `fuzz-private-corpus` environment, if it was ever created, can be
deleted.

## Related Issues

- No issue exists yet; this is part of the Azure DevOps → GitHub Actions
migration (#4513, #4524).

## Checklist

- [ ] 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.
- [ ] 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.
- [ ] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] 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.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:49:00 +02:00
romanett 96c5ef1623 Run the fluent configure pipeline in FluentNodeManagerBase (#4537) (#4538)
## Proposed changes

Closes #4537. The configure pipeline now lives in
`FluentNodeManagerBase`, so a hand-written fluent node manager only
overrides `ConfigureAsync` and no longer writes
`CreateAddressSpaceAsync` itself.

`FluentNodeManagerBase.CreateAddressSpaceAsync` now:
1. loads the predefined nodes (as `AsyncCustomNodeManager` did before),
2. creates a builder for `NamespaceIndex` with `CreateFluentBuilder`,
3. awaits `ConfigureAsync(builder, ct)`,
4. runs `RegisterAuthoredNodesAsync` and then `CompleteConfigureAsync`,
5. finishes with `SealConfigurationAsync`, which seals the builder,
replays `NotifyNodeAdded`, then activates behaviors and simulations.

### Breaking change (2.0)
Some managers need a different order, for example a post-setup runner
before sealing, a builder for another namespace, or work after sealing.
They must stop calling `base.CreateAddressSpaceAsync`, because that
would now run a second, empty pipeline and seal the manager-owned
registries too early. These managers now call
`LoadPredefinedNodesAsync(SystemContext, externalReferences, ct)` and
keep driving their own pipeline:
- the source-generated `NodeManager` template (the ordering test in
`NodeManagerGeneratorTests` is updated)
- `DiNodeManager`, `Isa95NodeManager`, `PositioningNodeManager`,
`RobotIntentNodeManager`, `VisionNodeManager`
- `samples/OpenUsd/SiteCompositionServer/SiteNodeManager`

These now use the base pipeline:
- `samples/FluentApi/BasicFluentApiServer/BasicFluentNodeManager`
becomes a single `ConfigureAsync` override. Before this change it never
sealed its builder.
- the hosting `FluentNodeManager` (from `AddNodeManager(namespaceUri,
build)`) becomes a `ConfigureAsync` override. It now seals through
`SealConfigurationAsync`, so `OnNodeAdded` handlers wired in the build
delegate fire. Before, it called `builder.SealAsync()` directly and they
did not.

Docs (`NodeManagers.md`, `DeviceIntegration.md`) are updated.
`NodeManagers.md` gets a short hand-written `ConfigureAsync` example.

## Types of changes

- [x] Breaking change (fix or feature that would cause existing
functionality to change)
- [x] Enhancement (non-breaking change which adds functionality)

## Checklist

- [x] I have read the CONTRIBUTING doc.
- [x] I have signed the CLA.
- [x] I ran tests locally with my changes, all passed.
- [x] I fixed all failing tests in the CI pipelines.
- [x] I fixed all introduced issues with CodeQL and LGTM.
- [x] I have added tests that prove my fix is effective or that my
feature works.
- [x] I have added necessary documentation (if appropriate).

## Tests

New `FluentNodeManagerBasePipelineTests`: nodes authored in
`ConfigureAsync` get registered, the Objects-folder reference is
mirrored into `externalReferences`, `OnNodeAdded` fires once, and the
builder is sealed afterwards.

Local run on net10.0, all passing:
- Opc.Ua.Server.Tests (Fluent/Hosting/StateMachine/RuntimeNodeSet
filter): 1286
- Opc.Ua.SourceGeneration.Core.Tests (NodeManager filter): 40
- Opc.Ua.Di.Tests 426, ISA95 136, Positioning 69, Robotics 636,
Robotics.Intent 22, Vision 484 (+1 skipped), Vision.Intent 14, OpenUsd
921

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:48:29 +02:00
Marc Schier 57c0a32ed4 Fix incomplete-message buffering and channel cleanup (#4522)
# Description

Fix incomplete-message buffering, channel cleanup, session resource
accounting, and enforcement of existing transport admission settings.

- Share a targeted `ChunkReassemblyBudget` across server listeners. The
reference default is 64 MiB, with a 32 MiB global occupancy threshold
for sessionless intermediate chunks. This is priority headroom for
activated sessions, not a guaranteed bootstrap partition. Single/final
chunks and unrelated response/client buffers remain outside this budget.
- Retain precise incoming/final payload quotas, valid Abort handling
without channel teardown, terminal late-final rejection, and
exactly-once buffer/reservation cleanup.
- Use one assembly timer per quota/clock scope, asynchronous cleanup, a
fixed deadline, a 30-second fallback for non-positive lifetimes, and
best-effort ERR `BadTimeout` before closure.
- Apply negotiated TCP/WebSocket/Pipe receive limits and size pipe
rentals to actual chunks.
- Add the optional `ISessionBindingProvider` and immutable
`SessionBindingContext` seam. Managed membership counts distinct
committed sessions, not responses, and handles reactivation, transfer,
closure, expiry, restoration and shutdown. Read-only snapshots require
token/channel/security validation and never replace service
authorization.
- Wire default and explicitly injected binding providers through raw
TCP, Kestrel and UACP WebSockets; preserve existing callback/manager
interfaces and direct construction.
- Enforce existing `ConnectionRateLimiter` and channel capacity settings
on Kestrel TCP and UACP WebSocket admission, with physical-transport
leases surviving reverse handoff and releasing on cancellation, close,
failed attachment and stop. HTTP/JSON requests are not treated as UASC
channels.
- Add regression coverage and documentation. Stabilize the CI
cancellation test by waiting for the resolver to enter before canceling.

This is the foundation only. New Balanced/FairShare/TrustedReservations
policies, tenant isolation and guaranteed bootstrap reserves belong in
the separate stacked follow-up, not this PR.

## Related Issues

Fixes #4521.

## Validation

| Scope | Result |
| --- | --- |
| Core transport, budget, binding-context and DI tests, net10.0 | 717
passed |
| Core transport, budget, binding-context and DI tests, net48 | 664
passed, 6 platform-specific skips |
| Session bindings, transfer/security/expiry/restore and hosted DI,
net10.0 | 69 passed |
| Same focused Server suite, net48 | 69 passed |
| ServerRedundancyHandlerTests, Release/net48 | 39 passed |
| ConsoleReferenceServer net10.0 | Build succeeded, zero warnings/errors
|
| Server and HTTPS binding netstandard2.1 | Both builds succeeded, zero
warnings/errors |

The new admission fixtures also passed five repeated runs after
correcting asynchronous connection-context disposal. Full-solution
validation, coverage comparison and CI for this update remain pending.

## Checklist

- [ ] 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.
- [ ] 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.
- [ ] 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.
2026-09-24 20:12:55 +02:00
Marc Schier 2646337446 Move full-scope nightly schedule to GitHub Actions (#4524)
## Summary

- move the weekly full-scope nightly schedule from Azure Pipelines to
`.github/workflows/nightly.yml`
- remove the redundant scheduled run from
`.github/workflows/buildandtest.yml`
- leave Azure Pipelines available for push/manual recovery runs while
retiring its YAML weekly full-scope cron
- update `docs/DeveloperGuide.md` to describe the new CI ownership and
remaining Azure follow-ups

## Validation

- Parsed `azure-pipelines.yml` and all GitHub workflow YAML files
successfully with `yaml.safe_load`.

## Notes

- The Azure service-side schedule for definition 14 still needs to stay
disabled in Azure DevOps so the YAML and service schedules do not
duplicate the nightly workload.
2026-09-24 08:08:25 +00:00
Marc Schier e9313ef957 Only use GitHub action for CI and nightly, establish release-branch-only package publication (#4513)
## Description

Two related changes to how the 2.0 line is built and shipped.

### 1. Release-branch-only package publication

- `master` produces development previews only.
- Stable packages can only be built from a canonical
`release/<major>.<minor>` branch and are promoted manually from the
exact signed candidate artifact.
- Core packages use explicit `major.minor.patch` versions (`2.0.0`,
`2.0.1`, then `2.1.0`); git height never changes a stable package patch.
- XRegistry, WoT Connectivity, Vision, Robotics, Redundancy,
Positioning, OpenUSD, ISA95, AI, DI, the relevant MCP extensions, and
OpenUSD connector tools remain `-preview.N` while following the core
numeric version.
- nuget.org receives only non-`.Debug` package IDs; GitHub Packages
retains the complete signed Release/Debug candidate.
- `docs/ReleaseProcess.md` is a reproducible runbook for developers and
agents, with each step stating its completion evidence.

Promotion is source-bound and byte-checked: `release.yml` checks out
nothing until the candidate SHA is known, and
`assert-published-packages-match.ps1` proves that any version already on
a feed holds *this* candidate's content before `--skip-duplicate` is
allowed to skip it. Canonical release-branch naming is matched
case-sensitively with no leading zeroes, and `version.json`'s
`publicReleaseRefSpec`, both policy helpers and the copy `release.yml`
inlines are pinned to one expression by tests.

### 2. PR CI moved to GitHub-hosted runners

- `.github/workflows/buildandtest.yml` (**CI**) now owns pull-request
validation outright, covering the Windows Debug/Release net48 + net10.0
solution builds and fast tests that only Azure Pipelines ran, alongside
the existing Linux/macOS, all-target and NativeAOT legs. `build-and-test
summary` remains the stable required check name.
- `.github/workflows/nightly.yml` is a new **manual-only**
(`workflow_dispatch`) full-scope workflow covering the Azure nightly
workload: all solutions x seven target profiles x Debug/Release, the
net472/netstandard profiles, Windows + Linux NativeAOT, and the
long-running and durable tiers.
- Shared deterministic discovery/batching lives in
`.github/scripts/get-ci-matrix.ps1` with a local composite action, so
both entry points expand the same workload manifest instead of
duplicating a large embedded script.
- `azure-pipelines.yml` is set to `pr: none`.
- The standalone pump sample image workflow is folded into the
consolidated **Images CI** workflow, which now builds all ten images and
publishes an `images summary` rollup suitable as a required check.

## Related Issues

No related issue provided.

## Checklist

- [ ] 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.
- [ ] 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.
- [x] I have addressed **all** PR feedback received.

## Validation

- `Opc.Ua.Tools.Tests` full `net10.0` suite: **959 passed, 1 skipped, 0
failed**.
- Version/package-policy regression subset
(`PackageVersionPolicyTests`): **60 passed, 0 failed**.
- GitHub Actions **CI** run 35719807620: **149/149 jobs green** on
GitHub-hosted runners.
- Canonical release-branch rule verified against the pinned nbgv 3.7.115
by building throwaway branches: `release/2.0`, `release/0.9`,
`release/10.42` -> `PublicRelease=True`; `release/02.0`, `release/2.00`,
`Release/2.0` -> `False`.
- Package identity gate verified against a live nuget.org package:
served packages carry a repository `.signature.p7s`, so equality is
decided on content excluding that part. Re-signing and zip reordering
produce an identical digest; a one-byte content change does not.
- Synthetic Release-only, Debug-only, combined, preview, duplicate,
orphaned-symbol, mismatched-root and accidental-stable-family package
sets exercise the shared validator.
- Stable-release rehearsal in an isolated `release/2.0` clone:
`Opc.Ua.Di` packed as `2.0.0-preview.6` depending on stable Core
`2.0.0`; `Opc.Ua.Di.Client` likewise against stable Client `2.0.0`.
- All workflow YAML, `version.json`, the package-policy PowerShell
scripts and the internal release-runbook anchors parse.

## Known validation limits

- Full `UA.slnx` `net48` and `net10.0` local validation remains blocked
by pre-existing `NETSDK1005` and dependency-version failures in
unmodified fuzz/sample projects; the corresponding checklist items
intentionally stay open.
- The TRX-driven test verdict added for the intermittent at-exit host
stall is proven by `CiTestVerdictTests`; the stall itself has not
recurred since.
- `nightly.yml` has not yet been run against a full-scope Azure run for
tuple-level parity — see below.

## Operational follow-up

This remains a preparation PR targeting `master`. It does **not**
authorize a release.

Release sequence:

1. Merge this PR.
2. Create canonical `release/2.0`, advance `master` to
`2.1.0-preview.{height}`, and open a separate `2.0.0` version PR against
`release/2.0`.
3. Follow
[`docs/ReleaseProcess.md`](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/docs/ReleaseProcess.md)
for candidate dry-run, environment approval, promotion, tag/release
finalization, and next-patch preview setup.

CI migration steps still needing administrator access are tracked in
[`docs/DeveloperGuide.md#migration-status`](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/docs/DeveloperGuide.md#migration-status):
proving `nightly.yml` parity on a trusted SHA with the private fuzz
corpus provisioned, turning off definition 14's service-side PR
override, retiring the Azure full-scope schedules and push trigger, and
retiring the duplicate `azure-pipelines-preview.yml` publisher.

> **Before cutting `release/2.0`:** the `Release` ruleset requires the
Azure context for *every* `refs/heads/release/*`, but a new 2.x branch
inherits `pr: none`, so that context would never report and would block
every PR into the new line. Exclude the new ref from that ruleset, or
add a 2.x ruleset requiring `build-and-test summary`, as part of
creating the branch.

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-09-23 15:47:43 +00:00
Marc Schier 138086863b Fix audit findings and review regressions across the OPC UA stack (#4503)
## Scope

Audit fixes and review follow-ups across the OPC UA Core, Client,
Server, transport, and redundancy components. This includes implemented
Tier 1, Tier 2, and Tier 3 work; it does not claim that every item in
the 244-finding audit backlog is complete.

The review batch fixes mandatory full-key record protection and owned
plaintext buffers; transactional pending-certificate claims;
shared-channel nonce and certificate ownership; discovery and
token-policy validation; reconnect/callback ordering without changing
unbounded buffering defaults; server permissions, filters,
monitored-item lifetime, and alarm topology; historian paging,
retention, and annotations; atomic user/password metadata; and canonical
file identities with bounded alias and role refresh workers.

## Review-batch verification at `ab686ccae`

The following results come from fresh test builds using `dotnet test`,
`--no-restore`, `-m:1`, and an explicit `CustomTestTarget`. They replace
the earlier description's library-build/`--no-build` totals, which did
not establish the state of the merged test sources.

| Scope | Target | Passed | Skipped |
|---|---|---:|---:|
| Core review regressions | net10.0 | 827 | 0 |
| Client and lifecycle regressions | net10.0 | 630 | 0 |
| Server review regressions | net10.0 | 1970 | 0 |
| Redundancy Server / Client / PubSub | net10.0 | 276 / 10 / 11 | 0 |
| Sessions / file-and-alias / streaming / HTTPS-WSS integration |
net10.0 | 8 / 21 / 30 / 20 | 0 |
| Core review regressions | net48 | 786 | 39 |
| Client recovery and policy regressions | net48 | 54 | 0 |
| Server ownership, aliases, queues, and user-state regressions | net48
| 137 | 0 |

Every listed run exited successfully. The 39 net48 skips are existing
platform/capability cases. New key-agreement tests assert
unavailable-policy behavior on unsupported targets rather than adding
skips or pretending those targets performed an unsupported handshake.
Additional exact node/permission/filter/alarm checks passed 13 Core and
46 Server cases.

The PR-scoped formatter verification covers 26 projects and 389
non-generated C# files. A Roslyn syntax audit covers 53 changed
interfaces and 263 members, with no missing
parameter/type-parameter/return contracts and a 120-column limit for
their declarations and documentation. No full-solution,
all-target-framework, or NativeAOT matrix is claimed from these local
runs; the new CI run must establish its own result.

## CI follow-up at `e1bc3ac3b`

The follow-up fixes the failures reported by Azure build 18400. TLS
certificates retain configured DNS names, including mixed-case URI
schemes, without relaxing hostname validation. Reverse-WSS fixtures use
the DNS identity in the client certificate. Reconnect completion is
published only after cycle cleanup, so a subsequent reconnect cannot
join an old completed cycle and retain old certificates.

Certificate-alarm roots now support event subscriptions. The pump sample
has one continuous notifier path, with no duplicate shortcut. Historian
and aggregate fixtures share the provider's retention clock. Alarm
collection lets the server expire idle Publish requests rather than
canceling only the client wait. The corrected server, WoT, and
managed-session fixtures exercise valid requests and initialized
lifecycle contracts. Downlevel crypto tests preserve real credential and
key-ownership assertions without enabling unsupported raw-ECDH policies
or adding skips.

Fresh local evidence after integration includes:

| Scope | Target | Passed | Skipped |
|---|---|---:|---:|
| Client lifecycle / channel ownership / builder fixtures | net10.0 and
net48, each | 96 / 103 / 123 | 0 |
| Combined server lifecycle, events, aggregates, crypto, and contracts |
net48 | 358 | 0 |
| Full lifecycle plus certificate-alarm and aggregate fixtures | net10.0
| 116 | 0 |
| Final server-contract and history-routing delta | net10.0 and net48,
each | 84 | 0 |
| Subscription / XRegistry / WoT event regressions | net10.0 | 7 / 2 / 3
| 0 |
| Full Sessions project | net10.0 | 780 | 379 |
| Full ComplexTypes project / downlevel resolver fixtures | net10.0 /
net48 | 2854 / 15 | 0 |
| Full WoT project / downlevel structured-write fixtures | net10.0 /
net48 | 1496 / 27 | 0 |
| TLS domain-name regressions | net10.0 and net48, each | 21 | 0 |

All listed runs exited 0. The Sessions skips come from existing endpoint
and policy capability guards; they are not counted as passes. No skip
was added. Worker validation also covered full DI (426 passes), full
History (540 passes, 21 existing skips), 38 exact downlevel
event/history failures, and 60 repeated certificate-rotation race cases.
The final crypto fixtures passed 73 cases on each target with the
unchanged leak detector; a temporary premature-disposal mutation made
all three borrower tests fail and was then removed.

All 29 C# files in this follow-up pass included-file formatting at info
severity across 12 projects. The complete PR interface audit now covers
54 interfaces and 271 members with zero missing contracts or line-width
findings. No full-solution or NativeAOT matrix is claimed. New-head CI
has started and remains authoritative for the remote result.

## Test-result gate follow-up at `4b6e1203a`

Azure build 18401 reported the net48 Client job as successful despite a
test-host crash after only 264 passing tests. Its old evaluator counted
per-test failures but ignored the failed run verdict. This was a partial
run, not a benign exit after the suite had completed.

The shared result evaluator now rejects aborted, errored, incomplete,
malformed, or inconsistent TRX reports. Each file must describe a
completed run, so a passing file cannot mask another file's failure. It
retains support for completed runs followed by a benign nonzero host
exit, existing skipped-result conventions, and no-TRX MTP jobs.

All 78 regression cases pass after integration using Windows PowerShell
5.1. Real completed Sessions and Client reports also pass. The
process-test fixture follows the existing NET10_0-only tools-test
pattern; its net48 build is not counted as a test run. Formatting passes
for the added test file.

The Client host crash itself has not reproduced locally. The isolated
fixture passed 59 tests, and two fresh full fast-CI selections each
completed with 2,392 passes and three existing skips, including a run
with crash collection enabled. The CI merge tree is identical to the
tested source tree. No speculative runtime change was made, and the
original partial CI run is not claimed as a full-suite pass. Azure build
18402 has now completed the full net48 Client run: 2,392 passed, three
existing console skips, and a successful test-host exit. The corrected
evaluator independently validated outcome=Completed. Across the matrix,
119 test-project jobs succeeded; Sessions net10.0 was canceled when the
remote provider deprovisioned its runner, so the overall Azure pipeline
was canceled. That cancellation is not claimed as a code failure or a
successful run. CodeQL and three Docker image jobs were still running at
the latest check. The migration discovery failure remains separate.

## Documentation follow-up at `57ed02bea`

The environment blocker has been removed by the maintainer. The three
bundled migration references now exactly match the canonical documents
after deterministic link rewriting; all 15 bundled documents pass
`validate-migration-plugin.ps1`. The canonical migration prose was not
rewritten. `FileSystemClient.md` now documents that extended Windows
roots preserve literal trailing-dot names, completing the filesystem
review follow-up.

The filesystem thread has been replied to and resolved after
publication. The review pass now has 108 of 110 threads resolved. The
two explanation-only threads remain open as requested; no unfinished
change request is being represented as resolved.

## Cross-platform follow-up at `0d9d936a8`

With migration discovery restored, the Linux and macOS test matrix
exposed two stale durable-subscription expectations. The expected value
83,442 encoded the old 32-bit overflow when converting 3,600 hours to
milliseconds. The existing overflow-safe server correctly returns
14,400,000 lifetime intervals at a 900 ms publishing interval. Only
those two expected constants changed; production behavior,
negotiated-hour checks, and all other assertions remain unchanged.

The exact local lifetime selection reproduced both failures before the
correction. The full durable suite now passes 36 cases with 11 existing
skips on each of net10.0 and net48. All four lifetime cases executed and
passed on both targets, and included-file info-severity formatting
passes.

## Session shutdown follow-up at `8daf7f091`

A late net48 Sessions failure exposed a shutdown race. Canceling an
in-flight keepalive read can produce a transport
`ServiceResultException`. The retiring worker treated its own
cancellation as proof that the peer was unavailable, so `CloseAsync` or
`DisposeAsync` could skip the server-side `CloseSession` request. The
worker now ignores that service error only when its own token is
canceled; genuine peer errors still update liveness.

The full integration fixture reproduced the original error-code
mismatch. Four deterministic mock-transport cases then produced three
failures and one passing genuine-error control before the fix. They
verify successful close, returned server error, asynchronous disposal,
and unchanged behavior for a real keepalive failure. Fresh related runs
pass 75 client and 101 integration cases on each of net48 and net10.0,
with no skips. Ten repetitions of the formerly flaky full net48 fixture
pass all 1,010 cases. All three changed files pass info-severity
formatting, and temporary diagnostic instrumentation was removed.

## Atomic listener snapshot follow-up at `82dc22b5d`

Ubuntu Core CI exposed an unsafe formatter rewrite in TCP listener
disposal: `ConcurrentDictionary.ToArray()` had become a collection
spread. A concurrent channel removal between the spread's count and copy
operations could leave a default entry, causing a null dereference
during disposal. The listener now uses the dictionary's atomic `Values`
snapshot and disposes those channels outside the listener lock.

Deterministic regressions close zero, one, or all channels at the copy
boundary. Before the fix, the one/all-removal cases reproduced the exact
null dereference; the unchanged-count control passed. All 23
admission/listener cases now pass on net10.0 and net48, and the full
fast Core net10.0 suite passes 5,267 cases with 88 existing platform
skips. Both changed files pass info-severity formatting. A bounded audit
of the formatter's other snapshot rewrites found no further verified
unsafe concurrent-registry copies.

## Shutdown-test observation follow-up at `58403ee8b`

The net48 shutdown-order test raced its own observation: it read
`m_publishWorkerTask` after starting shutdown, which can legitimately
drain the worker and clear that field immediately. The test now captures
the worker before shutdown starts and confirms it is initially running.
It still requires the worker to finish while shutdown remains blocked by
the held manager semaphore. Production behavior is unchanged.

A temporary scheduling perturbation reproduced the exact null-field
failure. The corrected capture passed 100 perturbed iterations; both the
temporary repeat and yield were then removed. The final 126
subscription/lifecycle cases pass on each of net48 and net10.0, and
formatting passes. A complete local Sessions net10.0 run also passed 780
cases with 379 existing capability skips in 7m20s after the earlier
keepalive correction.

## Retirement and alias-startup follow-up at `1a048ff39`

The shadow-generation test now observes its documented asynchronous
cleanup through the existing bounded helper. Removing the replacement
does not join a background drain that already claimed the retired
generation. The test still checks live-registration removal immediately
and requires the old node to disappear; no timeout or production
lifecycle behavior changed. All 81 lifecycle cases pass on each of
net10.0 and net48.

Initial alias-node materialization now retries one timed-out, read-only
snapshot query before creating nodes for that root. The retry is logged
through source-generated logging. Each query retains the original shared
100 ms deadline; repeated timeouts, other errors, and cancellation still
abort startup. Client searches and live refresh do not gain this
immediate retry. Fault-injection tests cover success after one timeout,
a persistent timeout, a different store failure, and cancellation. All
187 related alias/hosting cases and the original certificate-rotation
integration case pass on each target; the positive test records exactly
one retry warning. The alias documentation now accurately describes the
shared request budget.

All changed C# files pass formatter checks, and the complete
54-interface/271-member documentation audit passes. Fresh CI is running
on `1a048ff39`; the previous completed matrix exposed only the two
failures corrected in this batch and their dependent summaries.

## Sample endpoint allocation follow-up at `d08a94925`

The strong-cluster test harness allocated OPC UA and Raft endpoints in
separate released-port batches. Uniqueness was enforced only inside each
batch, so the same OS-assigned port could appear in both protocol
groups. The harness now requests one distinct batch for both groups and
splits it before launching any replicas.

Deterministic one- and three-replica allocation cases reproduced
overlapping groups with the old plan and pass with the corrected plan.
The complete 17-case short-haul sample suite passes without skips; all
failover, historian-continuation, and service assertions remain
unchanged. Both changed files pass info-severity formatting. This
prevents intra-cluster self-collisions but does not claim to reserve
released ports against unrelated external processes.

## Code-review follow-up at `085fd9636`

A review of the complete branch diff found five defects this PR
introduced. All are now fixed, each in its own commit.

`RelatedTo`'s recursive-to-iterative rewrite defaulted the optional
`includeTypeDefinitionSubtypes` and `includeReferenceTypeSubtypes`
operands to `false`. Part 4 §7.7.4 specifies both default to `true`, so
a filter that omitted them silently stopped matching subtypes.
Dynamically created key-credential nodes derived their namespace from
the containing folder, which is the standard `i=18155` node in namespace
0 — the namespace reserved for the OPC UA standard address space; they
now resolve the server-owned namespace and fail with `BadInternalError`
rather than guessing index 1. The retained audience-less
`CreateTokenData` overload produced tokens that verification always
rejects, so it is now `[Obsolete]` and throws instead. A failed
reference to a remote parent left the new node registered with a
one-sided inverse reference, and is now rolled back. An existing `.pfx`
is restricted before it is rewritten, rather than after its new key
bytes are on disk.

One further reported finding was rejected after investigation.
`JwksIssuerKeyResolver` deliberately does not dispose retired key
snapshots: `RetiredKeysAreCollectibleWhileResolverRemainsAliveAsync`
proves the resolver retains no retired snapshot, and
`ReturnedKeysSurviveRepeatedRefreshAndResolverDisposalAsync` requires
already-returned keys to keep verifying across refreshes and resolver
disposal. Adding disposal would have broken that tested contract, so the
exploratory change was reverted.

Validated on net10.0 and net48: 256 Core and 633 Server cases pass, with
one pre-existing net48 skip. The build reports zero compiler and
analyzer diagnostics, all eight included-file info-format checks pass,
the contract audit covers 58 interfaces and 310 members with no
findings, and the 15 bundled migration documents validate. All 122
review threads are resolved; the key-credential namespace thread was
reopened to retract an earlier incorrect answer and then resolved
against the real fix.
## Review-body follow-up at `da0d59cfb`

The re-review at `8daf7f091 + 82dc22b5d` listed four items in its
summary that had no inline thread of their own, so they had not been
picked up. All four are now fixed.

Both event-dispatch permission gates had been tightened from not-Bad to
`IsGood`, which silently dropped events whose permission verdict is
Uncertain; the data-change path in the same file still treated Uncertain
as deliverable, so the two had diverged. `EventSourceRegistry` only
cleared `ConsecutiveFailures` when a source was deactivated, so a source
that had backed off to the 30-second cap kept that delay for the rest of
the process even after starting successfully.
`UserNameIdentityTokenHandler` built a temporary issuer chain whose
`Add` calls take independent certificate references, and never released
them.

The fourth was the receiver-side sequence rollover, which the reviewer
had correctly re-flagged as still open despite its thread being
resolved. `m_sequenceRollover` was set permanently, so the second legal
wrap on a long-lived channel was rejected and faulted the channel along
with the transport-reconnect OPN. OPC 10000-6 does not cap a
SecureChannel at one wrap. The flag is now cleared once an accepted
number has traversed the normal range, which keeps rejecting a replayed
low number that never left the rollover window.

Validated on net10.0 and net48: 636 / 593 Core cases and 135 / 134
Server cases pass, including the new
`UaSCBinaryChannelSequenceRolloverTests` and the Uncertain-permission
delivery cases. The build reports zero compiler and analyzer diagnostics
and the changed files pass included-file info-format checks on both
targets.

The previous head `085fd9636` was fully green: 279 checks passed and
Azure build 18414 succeeded with 137 of 137 jobs, after the platform
automatically retried a job whose NuGet tool download had hit a
transient TLS error.
## Reported issues #4508 and #4509 at `6ad1ecf16`

**#4508 (V2 subscription recreate deadlocks against a faulted transport)
was not yet fixed and is now closed by `bac260a06`.** Both drain sites
the report identifies were still unbounded. A publish worker that
started its request before the quiesce parks inside the service call on
the channel ready gate while holding an active publish request; the
recreate that would make the channel ready is the very operation
awaiting the drain, so the wait never ends.

The drain now cancels in-flight attempts through a per-attempt token
linked to the worker token. An aborted worker rolls its acknowledgements
back, releases the request and returns to the paused park instead of
terminating, so it resumes when the caller leaves the quiesce. Publish
ingress stays quiesced for the caller's operation, so
`DropPendingForSubscription` keeps the guarantee the report asks to
preserve and no blind timeout is added. The new regression parks a
worker on a cancellable gate that never completes on its own: it
reproduces the hang for the full 30-second budget before the change and
passes after it.

**#4509 (identity provider fails the initial connect) was already fixed
for its primary symptom.** `HandleConnectAsync` materializes the
provider's identity through `CreateIdentitySelectionContext` before the
first `CreateAsync`, so Anonymous is no longer substituted against a
UserName-only endpoint. `6ad1ecf16` adds the two remaining details from
the report: the enabled-policy set now includes the negotiated channel
policy, so a client cannot reject the policy it already accepted as
`NotEnabledByClient`; and when discovery has not populated the
endpoint's token policies the connect keeps the configured identity so
the attempt fails at the transport layer for the reconnect policy to
retry, instead of failing at the identity layer.

Validated on net10.0 and net48: 477 / 473 client session and
subscription cases pass, the build is free of compiler and analyzer
diagnostics, and the changed files pass included-file info-format checks
on both targets. The full `Opc.Ua.Sessions.Tests` suite also passes
locally on net10.0 with 782 passed and 379 pre-existing capability
skips.

GitHub checks are green on this head: Build and Test .NET 10.0, Docker
Sample Images CI, and NuGet Publish all succeeded. Azure build 18417
completed 134 of its 135 jobs successfully; its net10.0 Sessions job was
cancelled by the hosted-agent provider ("Received request to
deprovision"), the same infrastructure flake that hit build 18409
earlier on a head predating these changes. That job is not claimed as
passing, and the local full-suite run above covers the same project. The
previous head `da0d59cfb` completed Azure build 18415 with every job
succeeded.
## Review follow-up at `a08f0c12b`

Eleven new review threads arrived, nearly all documentation. Before
acting on each "this is new in 2.0" remark I checked the API against
`master378` rather than assuming, because a genuine 1.5 API still needs
its migration entry.

`ManagedSession`'s server-redundancy refresh had two hard-coded
two-second bounds. The new `ServerRedundancyOptions` exposes
`RefreshTimeout` for the metadata read and `PeerDiscoveryTimeout` for
each peer lookup, defaults both to the previous two seconds, validates
that a bound is positive or `Timeout.InfiniteTimeSpan`, and flows
through `ManagedSessionOptions`, `ManagedSession.CreateAsync`, the
fluent builder, and `DefaultServerRedundancyHandler`.

`AsyncCustomNodeManager` and `KeyCredentialBridgeAuthenticator` are both
absent from `master378`, so their obsolete members had no compatibility
constraint and are removed rather than carried:
`AsyncCustomNodeManager.ReadInitialValue`, the audience-less
`CreateTokenData` that only threw, and the audience-less `CreateProof`
that produced a proof no server accepts.
`CustomNodeManager2.ReadInitialValue` and the `MonitoredNode2` migration
section are genuine 1.5 APIs and are untouched.

The documentation now states master's behaviour instead of the delta
from 1.5.378, with each moved item already covered under
`docs/migrate/2.0.x/`. `IHistorianAnnotationProvider` is described by
its annotation-time key rather than as "legacy" — it is new in 2.0, so
the label implied a compatibility constraint that does not exist. The
KeyCredential bridge payload's `"version"` field is explained as a
vendor-extension format version with no OPC UA counterpart, and its
version-1 upgrade narrative is gone. `CertificateManager.md`'s migration
walkthrough is replaced by the resolver API it targets.
`NodeManagers.md`, `Sessions.md`, `AsyncServerSupport.md`,
`Certificates.md`, and `Profiles.md` no longer announce features
relative to 1.5. `Benchmarks.md`, `WhatsNewIn2.0.md`,
`DeveloperGuide.md`, and `docs/migrate/` keep their 1.5.378 references,
which are their subject.

CI then exposed a defect this batch introduced: five fixtures resolve
`ManagedSession`'s private constructor by its exact positional parameter
types, so the added parameter compiled cleanly but made `GetConstructor`
return `null` at run time. `7df1d5c40` updates every site;
`Redundancy.Client` passes 126 of 126 and the affected client fixtures
112 of 112.

The BouncyCastle question is answered and tracked in #4510:
`Opc.Ua.Core` already project-references `Opc.Ua.Security.Certificates`,
which already carries the conditional BouncyCastle reference, so the
raw-ECDH polyfill belongs there rather than as a new Core dependency.
The `IUserDatabase` metadata question is addressed to another maintainer
and is deliberately left open.

## Reported issue #4509 follow-up at `5d4d13d63`

The reporter verified `6ad1ecf16` and found one case still failing,
correctly. The empty-token-policies branch kept the configured identity
so a server that is down surfaces a transport failure rather than an
identity error. A provider-only configuration has no configured
identity, so it kept `null`: `OpenCoreAsync` turned that into Anonymous
and `OpenValidateIdentity` rejected it with `BadIdentityTokenInvalid`,
silently ignoring the configured provider.

The branch now requires a configured identity to defer to. Without one
the provider is consulted, so its own `BadIdentityTokenRejected` names
the offered and rejected policy set instead. Two regression tests cover
both configurations; the first fails against the previous behaviour by
reaching the session factory with a `null` identity, which is the exact
step that produced the reported error. That branch had shipped without a
test, which is why the case was missed.

## Review and CI status at `5d4d13d63`

**All actionable review threads are resolved: 132 of 133.** The single
open thread is a question the reviewer addressed to another maintainer
about moving user metadata into `IUserDatabase`; it is left open for
them rather than resolved silently. Every resolved thread carries a
reply naming its fix commit.

**Latest-head CI is green on `5d4d13d63`: 279 checks passed, zero
failed, zero pending**, with one conditional signed-package publishing
job skipped. Local validation for this batch: the full
`Opc.Ua.Client.Tests` suite passes 2,430 of 2,430 on net10.0,
`Opc.Ua.Redundancy.Client.Tests` 126 of 126, and `Opc.Ua.Server.Tests`
590 of 590 for the affected selections. Both net10.0 and net48 build
clean for `Opc.Ua.Client`, `Opc.Ua.Server`, and the changed test
projects, and all changed files pass included-file info-severity
formatting on both targets.

The PR remains open; no merge was performed. As stated at the top, this
does not claim that every item in the 244-finding audit backlog is
complete.
## Latest CI follow-up at `b6f2dfa43`

Azure build 18442 exposed a net48 compile regression in the Kafka
adapter: `RuntimeHelpers.GetUninitializedObject` is unavailable on .NET
Framework. Commit `2cf1e0eed` now uses normal `ProducerMessage`
construction on `NETFRAMEWORK` while retaining reflection-based
construction for modern targets that can load a different Dekaf runtime
asset. The exact net48 CI selection passes 92 of 92 tests, and the
netstandard2.1 adapter guard selection passes 11 of 11.

After merging the latest `master`, GitHub Actions run 35643592139 is
green. The initial Ubuntu Core.Schema job built successfully and passed
160 of 160 tests, then hit an external 403 while finalizing its
test-result artifact; its failed-job rerun completed successfully. Azure
build 18451 was canceled when its remote provider deprovisioned the
net10.0 Sessions runner (`Received request to deprovision: The request
was cancelled by the remote provider`). Azure attached no test task log
or source error, and the canceled parent left that child check stale as
in-progress. No source workaround, test skip, or check relaxation was
made for either infrastructure failure.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 68f26810-22b8-476f-abcd-0a8c82d6e208
2026-09-22 09:10:58 +02:00
Marc Schier 331a71ac2c Fix AI model version and inference argument handling (#4512)
## Summary
- Derive the AI server''s advertised specification version from
generated NodeSet metadata (new source-generated `ModelVersions.Target`
constant), so `AINodeManager.SpecificationVersion` can no longer drift
from the shipped model.
- Preserve inference parameters through inline, asynchronous, fallback,
and oversized-transfer execution instead of silently discarding them.
- Reject unsupported `PayloadUri` input explicitly with
`BadNotSupported` before any backend call, job creation, or transfer
allocation.
- Apply the supported `temperature` / `max_tokens` / `top_p` parameters
in both built-in backends via a shared parser.

Fixes #4505
Fixes #4506

## Review follow-ups (f8754f618)
- REST backend: a JSON body field is dropped only when its matching call
parameter was actually supplied, so an explicit `temperature` no longer
erases payload-supplied `max_tokens` / `top_p`.
- Call parameters accept every built-in integer encoding (`Int64`,
`UInt64`, `Int16`, `UInt16`, `Byte`, `SByte`) and booleans, instead of
failing the whole `Invoke` when a client encodes `max_tokens` as
`Int64`.
- A null `String` Variant is rejected rather than inserted into the
non-nullable backend parameter dictionary.
- The invariant-formatting regression now runs under a comma-decimal
culture so it genuinely discriminates.

## Testing
- `dotnet test tests\Opc.Ua.AI.Tests\Opc.Ua.AI.Tests.csproj -f net10.0
--no-restore` — 186 passed, 0 failed
- `dotnet test
tests\Opc.Ua.SourceGeneration.Core.Tests\Opc.Ua.SourceGeneration.Core.Tests.csproj
-f net10.0 --no-restore` — 3962 passed, 8 skipped
- `dotnet format whitespace --verify-no-changes` clean on the changed
files; build emits no warnings.
2026-09-21 17:35:46 +00:00
Copilot 37b552254e Recover transferred notifications immediately after a subscription transfer (#4500)
# Description

The V2 (managed) subscription engine republishes retransmission-queue
entries only while gap-walking sequence numbers in
`MessageProcessor.ProcessMessageCoreAsync`, which requires a *new data
message* to arrive. `Subscription.TryCompleteTransferAsync` merely
stored the `AvailableSequenceNumbers` returned by
`TransferSubscriptions`, so a subscription that stayed quiet — or that
only emitted keep-alives, which deliberately do not advance the data
dedup gate — never recovered the notifications the previous session left
behind. The V2 engine therefore did not deliver the classic
`RepublishAfterTransfer` guarantee that `docs/Subscriptions.md` claimed
it did implicitly.

Changes:

- **`MessageProcessor.RecoverTransferredMessagesAsync` (new,
protected)** — republishes every sequence number reported available by
`TransferSubscriptions`, oldest-first with wrap-aware ordering (Part 4
§7.30.5), serialized on the message dispatch gate and marked as dispatch
context. Afterwards it advances `LastDataSequenceNumberProcessed` /
`LastSequenceNumberProcessed` past the whole set — the server already
sent those messages, so the next publish must not be treated as the
first message after create — and clears the recovered entries from
`AvailableInRetransmissionQueue`. Empty input short-circuits before
taking the gate.
- **`Subscription.TryCompleteTransferAsync`** — calls the recovery after
releasing the state lock, so a notification handler may re-enter the
subscription. The transfer has already succeeded at that point, so
recovery errors are logged
(`SubscriptionFailedToRecoverTransferredMessages`) rather than reported
as a failed transfer; caller-requested cancellation still propagates.
- **Docs** — the `s.RepublishAfterTransfer` row in the classic→V2
mapping table now describes the actual (always-on, transfer-driven)
behavior.
- **Tests** — `MessageProcessorTests`: ordered recovery with no
subsequent publish and no duplicate republish afterwards, empty-input
no-op, ordering across the `uint.MaxValue → 1` wrap, and a failed
republish that still advances the gate.

Notes for review:
- The helper takes `IReadOnlyList<uint>` rather than `ArrayOf<uint>` to
match the surrounding transfer surface
(`IManagedSubscription.TryCompleteTransferAsync`,
`MessageProcessor.OnPublishReceivedAsync`, the internal
`AvailableInRetransmissionQueue` field); wrapping only this member would
add conversions without changing public API.
- Unlike classic, only *consecutive* top-of-queue entries are not
special-cased — the whole available set is recovered, matching the
existing V2 first-message-after-create path.

## Related Issues

The managed engine's transfer path did not recover pending notifications
on quiet subscriptions; see the classic-engine counterpart discussion
referenced in the issue.

## Checklist

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

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

<!-- START COPILOT CODING AGENT SUFFIX -->

- Fixes #4498

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: marcschier <11168470+marcschier@users.noreply.github.com>
2026-09-18 09:12:40 +02:00