42 Commits

Author SHA1 Message Date
Marc Schier b1c0cd20fd Fix classic browse-path subscription restoration (#4814)
# Description

Persisted classic subscriptions retain their server/client handles but
previously lost relative-path resolution metadata. Transfer could
succeed, yet a later `ApplyChangesAsync` attempted to resolve an
already-created item and failed. The SDK now owns this restoration
lifecycle without exposing a public resolution-state setter.

- Preserve the originally monitored target in internal snapshot state
and restore both `ResolvedNodeId` and `Status.NodeId`.
Transfer/reactivation use handles without re-browsing; fresh creation
resolves paths again.
- Version classic subscription/configuration files and read existing
unversioned 2.0 files using frozen legacy wire layouts. Legacy missing
targets remain unknown rather than being guessed.
- Exclude existing items from creation-time resolution, reserve creation
before asynchronous translation, and reject stale translation results.
Failed deletion retains identity and pending retry state.
- Support forward-only snapshot input using position tracking and
bounded, per-ExtensionObject buffering in the binary decoder, including
short reads. No whole-file buffering or reflection-based serialization
is introduced.
- Add unit/integration regressions and document the supported lifecycle
and migration implications.

**Compatibility:** Newly written files require the updated SDK. Existing
2.0 files remain readable. Assigning a different `StartNodeId` or
`RelativePath` to a created/in-flight item now immediately throws
`BadInvalidState`; unchanged assignments remain allowed. No V2
subscription-engine changes or 1.5 XML import are included.

## Checklist

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

- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [ ] 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-16 20:06:51 +02:00
Marc Schier 7c965fd636 Capture metadata images and generation-bound session clients (#4752)
# Description

- Add isolated NodeState reference images, TypeTable graph snapshots,
EncodeableFactory registration forks/views, StringTable mutation
versions/private copies, and ServiceMessageContext mapping-holder
generation tracking. Reference images are lazy for empty nodes;
selected-image clones own independent storage and publication ownership.
- Add the complete generic NodeBrowser continuation-dependency
capability. This does not implement server browse-history retention,
NodeManager read images, or server batch/readiness admission.
- Add optional client binding capture for SessionClient, native Session
and ManagedSession, with managed-lease/native-channel generation
composition. Bindings capture authenticated-session identity, mapping
holders/versions and owned endpoint/policy/configuration observations.
Replacement, reconnect, mapping ABA and disposal permanently invalidate
stale bindings; disposing a binding does not dispose its owner.
- Preserve cancellation and master pooled-response ownership: a rejected
pooled response is released exactly once; an accepted response remains
caller-owned. Keep native transport submission under its replacement
gate and completion outside the gate, without routing ordinary unbound
requests through binding machinery.
- Rename the existing server lookup interface and all actual consumers
with minimal hunks; add focused fixtures, generated FileTypeClient usage
guidance, and package/transport documentation.

**Breaking preview type identity:** the existing 2.0-preview server
lookup `Opc.Ua.ISessionBindingProvider` becomes
`Opc.Ua.IServerSessionBindingProvider`. Server lookup consumers must
update their referenced type and rebuild. This is neither
source-compatible nor binary-additive for that preview type. The new
client capability is optional and adds no mandatory members to existing
ISession/ISessionClient/ITransportChannel contracts. This
preview-to-preview rename is documented in SessionBindings/Transports,
not in the 1.5-to-2.0 MigrationGuide.

**Ownership boundary:** no RequestLifetime changes,
certificate-manager/channel-entry adoption, BaseVariableState changes,
Method-result feature extraction, NodeManager batch/readiness or
browse-history retention, session restoration, registry federation, WoT
conversion, or WoT runtime/planners/events.
DependencyInjection/ResourceIsolation/index changes are lookup/link
closure only. SessionManager's PR hunk changes only its implemented
lookup-interface name; newer master public diagnostic masking is not
replaced.

## Related Issues

_Reference all GitHub issues this PR addresses. If there is no issue
yet, open one and link it here._

_If this is a relatively large or complex change, a design must have
been discussed in the related tracking issue and signed off (which
becomes the Architectural Decision Record (ADR))._

- Part of #4511: user-approved combined groups 5+6 Wave A extraction.
This PR does not close the umbrella or publish Wave B.

## Checklist

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

- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [ ] 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.

### Review qualification

Review fixes are published at
**`6b0eb1d5c32a439300fbcaff974b9af8b909161b`**, changing eight
already-owned files. All five received review threads were replied to
and resolved after verification. Factory selector installation and
target-selection-plus-commit share the root publication gate, with
consistent root→image release/capture ordering. TypeTable selector
installation and final mutation routing share its existing gate. Native
bound channel validity uses volatile observations; the two owned XML
defects are corrected.

| Requirement | Evidence |
|---|---|
| Selector installation must not hide TypeTable mutations |
`SelectorInstalledDuringNodePreparationReceivesMutation`: old Add
routing failed on both TFMs; corrected routing passes on both within
**148/148** factory/TypeTable controls |
| Selector installation must not race factory commit routing | Static
common-root-gate/order proof plus baseline-GREEN
`BuilderCapturedBeforeOwnershipUsesSelectedFactory`,
`ViewOwnerReleasePreservesRegistrationsAndAllowsReuse`,
`ViewOwnerReleaseCanRetryAfterPendingPublication`,
`PublicationProtectsPendingAndRetiredFactoryImages` |
| Lock-free validity must observe owner transitions | Static
volatile-read reasoning plus native
`BoundRequestCannotDispatchWhileNativeReplacementIsPendingAsync`,
`BoundResponseRejectsInvalidationAfterNativeDispatchAsync`,
`BoundClientCannotRetryAcrossFaultedManagedEntryReplacementAsync`:
**12/12 each TFM**; Core binding/pooled ownership controls **17/17 each
TFM** |
| Correct owned XML documentation | “an unmanaged” and one multiline
`ReferenceUpdate.Next` summary; scoped whitespace/INFO and build
evidence below |

Factory controls are **not** claimed as a deterministic factory RED, and
public transport controls are **not** claimed as a deterministic
weak-memory RED. No private-lock reflection, artificial production seam,
or source-name assertion was added. The TypeTable RED uses the real
`ILocalNode.NodeClass` callback during node preparation, and both
expected RED receipts/TRX were retained.

All accepted review runs use the unchanged admitted SDK wrapper, normal
project references, sole mutex, one node/no shared compiler, finite
resource guards and canonical short-C build outputs. Source hashes were
frozen per command and matched the committed bytes afterward. SDK
ownership was released **before** commit/push/review replies. No
automatic merge was requested.

Scoped whitespace passed across the four affected projects. Review INFO
classification is **0 owned findings / 13 untouched baseline findings
excluded** (whole-file formatter exits 1,1,0,0); no baseline
RCS1140/RCS0055 expansion or suppression. The final normal net10 public
Client/Core/Types trim/AOT-analyzer closure passed **0 warnings / 0
errors**. This is not a NativeAOT publish/run.

<details>
<summary>Accepted review command payloads and results</summary>

These exact argument payloads were executed through the unchanged
guarded SDK wrapper. `$proof` expands to the protected results-directory
below. Successful explicit builds of the same source preceded the
`--no-build` runs.

```powershell
$proof = 'C:\Users\mschier\.copilot\session-state\a3b74048-c52f-4c87-8773-7a46c9ab0087\files\qualification-review-4752'

# Exit 0: 148 passed, 0 failed
dotnet test tests\Opc.Ua.Types.Tests\Opc.Ua.Types.Tests.csproj -f net48 -p:CustomTestTarget=net48 -p:UseArtifactsOutput=true -p:ArtifactsPath=C:\cp56-a3b7-v2 -nr:false --no-restore --verbosity minimal --filter "FullyQualifiedName~EncodeableFactorySnapshotTests|FullyQualifiedName~TypeTableTests" --logger "trx;LogFileName=review-types-green-net48.trx" --results-directory $proof

# Exit 0: 148 passed, 0 failed
dotnet test tests\Opc.Ua.Types.Tests\Opc.Ua.Types.Tests.csproj -f net10.0 --no-restore --no-build --filter "FullyQualifiedName~EncodeableFactorySnapshotTests|FullyQualifiedName~TypeTableTests" --logger "trx;LogFileName=review-types-green-net10.trx" --results-directory $proof -p:CustomTestTarget=net10.0 -p:UseArtifactsOutput=true -p:ArtifactsPath=C:\cp56-a3b7-v2 -nr:false --verbosity minimal

# The following two per-TFM payloads each exited 0:
# Core: 17/17 net48 and 17/17 net10.0; Client: 12/12 net48 and 12/12 net10.0
foreach ($tfm in @('net48', 'net10.0')) {
    dotnet test tests\Opc.Ua.Core.Tests\Opc.Ua.Core.Tests.csproj -f $tfm --no-restore --no-build --filter "FullyQualifiedName~SessionBindingResponseTests|FullyQualifiedName~SessionBindingContextTests" --logger "trx;LogFileName=review-actual-core-green-$tfm.trx" --results-directory $proof -p:CustomTestTarget=$tfm -p:UseArtifactsOutput=true -p:ArtifactsPath=C:\cp56-a3b7-v2 -nr:false --verbosity minimal
    dotnet test tests\Opc.Ua.Client.Tests\Opc.Ua.Client.Tests.csproj -f $tfm --no-restore --no-build --filter "FullyQualifiedName~BoundRequestCannotDispatchWhileNativeReplacementIsPendingAsync|FullyQualifiedName~BoundResponseRejectsInvalidationAfterNativeDispatchAsync|FullyQualifiedName~BoundClientCannotRetryAcrossFaultedManagedEntryReplacementAsync" --logger "trx;LogFileName=review-actual-client-green-$tfm.trx" --results-directory $proof -p:CustomTestTarget=$tfm -p:UseArtifactsOutput=true -p:ArtifactsPath=C:\cp56-a3b7-v2 -nr:false --verbosity minimal
}

# Exit 0: 0 warnings, 0 errors
dotnet build src\Opc.Ua.Client\Opc.Ua.Client.csproj -f net10.0 -p:CustomTestTarget=net10.0 -p:UseArtifactsOutput=true -p:ArtifactsPath=C:\cp56-a3b7-v2 -nr:false --no-restore --verbosity minimal
```

The six expanded GREEN commands, exact results/TRX hashes and two
expected RED commands are retained in
`qualification-review-4752\qualification-summary.json`, SHA256
`C5E71A4D8D37BAC8803F6DF38B6BCE2B70F3D0AFD5DEC8493FAE9DB440DCFB5A`.
Committed/qualified byte identity is in `publication-identity.json`,
SHA256
`CD9515FE96703F8B241F2B7B9B71F73EF0FF2626C2C8BCCE40307971A8F8E809`.
Per-command raw exits, guards, hashes and reports are retained outside
disposable build roots.

For each record in the protected `format-inputs.json` (Types/Core/Client
libraries and Types.Tests), the recorded Project/Include values expand
these exact payloads:

```powershell
# Workspace properties recorded with the formatter receipts:
$env:CustomTestTarget = 'net10.0'
$env:UseArtifactsOutput = 'true'
$env:ArtifactsPath = 'C:\cp56-a3b7-v2'

# All four exit 0
dotnet format whitespace $Project --no-restore --verify-no-changes --verbosity minimal --include $Include

# Exits 1,1,0,0 due only to untouched baseline findings; owned=0, baseline=13
dotnet format $Project --no-restore --severity info --verify-no-changes --verbosity minimal --report "$proof\formats\review-info-$i" --include $Include
```

Initial fresh matrix evidence remains separately identified and
unchanged: Types274/Core119/Client151/server137 net10;
Types274/Core119/Client151/server135 net48, all passing. Qualification
base remains `0dff94bd378e79b8cf1a47e1aaf340f9b4b40048`; the initial
published head was `9fee4583`. All 16 initial exact command
arrays/results/TRX hashes remain in protected
`qualification-v2\qualification-summary.json`, SHA256
`88E10240C20049B59A3401BE007C6528A85DF2F3DCFAF9D11D908CEF2946DA4E`.

Failures/incidents remain retained rather than relabeled as success:
original disappeared-root historical counts are invalidated; the
large-TRX controller DOM-parser failure is preserved separately from
accepted streaming receipts; the review argument-array
defect/default-D-output controls are preserved but excluded from
canonical review qualification. Correctly separated argument rechecks
produced the accepted short-C Core/Client receipts. The sole SDK wrapper
was not changed.

Full UA.slnx, measured coverage increase, CLA verification, NativeAOT
publication and current-head/master CI/CodeQL remain unclaimed. Actual
merge—not resolved review threads—unlocks Wave B.

</details>
2026-10-13 06:25:43 +02:00
romanett 7fe7794a19 Enable AES-GCM and ChaCha20-Poly1305 policies on net48 via BouncyCastle (stacked on #4703) (#4705)
> Follow-up to #4703 (now merged to master; this PR has been retargeted
to `master`).

Part of #4510. #4703 enabled the CBC ECC policies on net48 through a
BouncyCastle raw-ECDH polyfill. The AEAD policies were still unavailable
there because .NET Framework has no `AesGcm` or `ChaCha20Poly1305`. This
PR adds those ciphers the same way, so on net48 all of these are now
offered:
`ECC_{nistP256,nistP384,brainpoolP256r1,brainpoolP384r1}_AesGcm` /
`_ChaChaPoly`, `RSA_DH_AesGcm`, `RSA_DH_ChaChaPoly`.

## Changes
- **Polyfill**
`src/Opc.Ua.Security.Certificates/Org.BouncyCastle/AeadCiphers.cs`
(internal, `#if NETFRAMEWORK`, next to `EcdhRawAgreement`): `AesGcm` and
`ChaCha20Poly1305` types with the .NET 8 BCL surface. They wrap
BouncyCastle's `GcmBlockCipher(AesEngine)` and
`Org.BouncyCastle.Crypto.Modes.ChaCha20Poly1305`.
- A tag mismatch throws `CryptographicException`, never BC's
`InvalidCipherTextException`, so
`PlatformSymmetricCryptoProvider.DecryptAuthenticated` still returns
`false` and the channel still raises a protocol error.
- GCM releases plaintext blocks before it checks the tag, so plaintext
stays in a scratch buffer and is copied out only after the tag verifies.
On failure the destination is cleared, as the BCL does. Scratch buffers
and the key copy are zeroed.
- **Opc.Ua.Core**: aliases the polyfill types on `NETFRAMEWORK` and
removes the `NET8_0_OR_GREATER` gates in `SecurityPolicies`
(`SupportsAesGcmPolicy` / `SupportsChaCha20Poly1305Policy`),
`CryptoUtils` (`SymmetricEncryptAndSign` / `SymmetricDecryptAndVerify`,
AEAD helpers) and `PlatformSymmetricCryptoProvider`. The channel, the
provider facet and the encrypted ECC user tokens (`EncryptedSecret`,
through `CryptoUtils`) now run the same code on every target. .NET 8+
keeps the BCL ciphers.
- **FIPS** (requested by the #4703 session, after a2ecf13b1): under
`CryptoCompliancePolicy.FipsOnly`, `CryptoCompliance.IsPolicyPermitted`
now also withholds every AEAD policy on .NET Framework. That adds
`RSA_DH_AesGcm`, which is FIPS-approved and not ECC. The BouncyCastle
cipher runs outside any validated module and the user-token path
bypasses the provider registry, so the auditor cannot see it. Tests and
`docs/CryptoProvider.md` are updated to match.
- **Docs**: EccProfiles (Known Limitations), migration `certificates.md`
(and the bundled skill copy), ProductionChecklist, Profiles,
WhatsNewIn2.0, CryptoProvider. They state that on net48 these primitives
run in managed BouncyCastle, which is not a validated (FIPS) module and
not hardware-accelerated or hardened against cache-timing attacks like
CNG AES.

## Tests
- New `AeadPolyfillTests` (Core.Tests):
- Known answers: NIST GCM test cases 2/4 (AES-128) and 14/16 (AES-256),
plus RFC 8439 §2.8.2 for ChaCha20-Poly1305. They run through
`PlatformSymmetricCryptoProvider` on every TFM and through the polyfill
directly.
- Tampering with the tag, the ciphertext or the AAD gives `false` from
the provider, and `CryptographicException` with a cleared plaintext from
the polyfill.
  - Argument validation.
- **BCL cross-check:** on .NET 8+, Core.Tests also compiles
`AeadCiphers.cs` (`AEAD_POLYFILL_CROSSCHECK`, test-only
`BouncyCastle.Cryptography` reference). Polyfill and BCL must produce
identical ciphertext and tag, and each must decrypt the other's output:
3 algorithms × 8 message lengths (0 to 8192) × 3 AAD lengths.
- `CryptoProviderRegistryTests`: `RSA_DH_AesGcm` is permitted under
FipsOnly only off .NET Framework and withheld on it.
- Tests that `Assert.Ignore` when AEAD is unavailable now run on net48,
e.g. `EccSecretRejectsDifferentNegotiatedPolicyAsync` and the
`RSA_DH_AesGcm` server session tests.

### Local results
| Run | Result |
|---|---|
| Core.Tests net48, full (before the FIPS commit) | 6049 passed, 0
failed, 88 skipped |
| Core.Tests crypto/AEAD/ECC/EncryptedSecret/Nonce subset, net48 /
net10.0 | 278 / 343 passed, 0 skipped |
| Server.Tests session-security/ECC subset, net48 | 128 passed, 0
skipped |
| `EccInteropTests` net48 vs **open62541 1.5.8 (OpenSSL)** peer | all 16
AEAD cases pass, 4 of them two-renewal token-renewal runs
(`ECC_nistP256_AesGcm`/`_ChaChaPoly` × Sign/SignAndEncrypt ×
Anonymous/UserName, both directions). Remaining 8 failures are the
documented open62541 CBC Sign-only HKDF differences in
`expected-differences.json` |
| `EccInteropTests` net48 vs 1.5.378 LegacyPeer | unchanged 26 passed /
28 skipped |

Note on the acceptance criterion: the 1.5.378 LegacyPeer does not
implement the AEAD policies, so its
`_AesGcm`/`_ChaChaPoly`/`*AeadTokenRenewalAsync` cases skip on every TFM
("The peer (UA-.NETStandard) does not declare …"). Before this PR they
skipped on the 2.0 side; now they get past it and stop at the peer
check. The open62541 peer is the real cross-stack check of the net48
AEAD path against a non-BouncyCastle implementation.

## PubSub DTLS on net48
The DTLS 1.3 transport (`src/Opc.Ua.PubSub.Udp/Dtls`) was compiled out
on .NET Framework, so net48 registered no DTLS profiles. It now reuses
the same polyfills (`Opc.Ua.Security.Certificates` gains
`InternalsVisibleTo Opc.Ua.PubSub.Udp`):
- The `NET8_0_OR_GREATER` gates in the handshake, ECDHE, record
protection and profile probe are removed.
- Two .NET 8 APIs get net48 fallbacks: the AES-ECB record-number mask
uses `CreateEncryptor`, and the handshake random uses
`RandomNumberGenerator.Create`.
- The HKDF probe now checks the managed `DtlsHkdf` that the key schedule
actually uses.
- The public `IsAeadSupported` / `IsRawSharedSecretSupported` probes are
kept and now return `true` everywhere.
- The DTLS tests now also run on net48. The net48 "registers no
profiles" test now asserts that the NIST AEAD profiles are registered.
The socket transport-peer tests and the benchmarks stay .NET 8-only
because they use .NET 8 socket APIs.
- `docs/PubSub.md` DTLS profile table: the net48 column is updated.

Results: PubSub.Udp.Tests net48, full suite: **229 passed, 0 skipped**.
It includes complete NIST AEAD, brainpool and integrity-only handshakes
and ChaCha20 records. The DTLS subset is 76 on net48 vs 81 on net10.0;
the 5 still-gated socket-peer tests are the difference.

Note: DTLS profile selection does not consult `CryptoCompliancePolicy`
on any target. That is pre-existing and unchanged here.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 06:30:14 +02:00
romanett d552634258 Enable ECC security policies on net48 via a BouncyCastle raw-ECDH polyfill (#4703)
Closes #4510.

## Summary

OPC UA Part 6 feeds the **raw** ECDH shared secret into HKDF. .NET
Framework only exposes `ECDiffieHellman.DeriveKeyMaterial`, which always
hashes it, so the ECC SecureChannel and user-token policies were
unavailable on `net48` (`SecurityPolicies.GetInfo` returned `null`).

This adds a BouncyCastle polyfill for just that primitive:

-
**`Opc.Ua.Security.Certificates/Org.BouncyCastle/EcdhRawAgreement.cs`**
(`net48` only): an internal `DeriveRawSecretAgreement(this
ECDiffieHellman, ECDiffieHellmanPublicKey)` extension using
`ECDHBasicAgreement`. It returns the X coordinate padded to the field
size, exactly like the .NET 8 BCL method. Keys stay CNG keys; ECDSA
signatures and HKDF stay on the platform — only the agreement step is
managed code. The point is validated (on curve, not infinity) and
mismatched curves are rejected.
- Because the extension mirrors the .NET 8 signature,
`Nonce.GenerateSecret` now has one code path on every target.
- `SupportsRawEccSecretAgreement()` is removed; the CBC-based ECC
policies (`ECC_nistP256`, `ECC_nistP384`, `ECC_brainpoolP256r1`,
`ECC_brainpoolP384r1`) are offered on every target (still subject to OS
curve support).
- BouncyCastle was already a `net48` dependency of
`Opc.Ua.Security.Certificates`; no new package. (Only `net48` is
downlevel now — `netstandard2.1`/`net472` from the issue text are no
longer targets.)

**Not in scope:** the `_AesGcm` / `_ChaChaPoly` ECC variants (and PubSub
DTLS) still need .NET 8 because .NET Framework has no AEAD ciphers —
follow-up stacked on this branch.

## Docs

`EccProfiles.md` (Known limitations), `migrate/2.0.x/certificates.md`,
`ProductionChecklist.md`, `Profiles.md`, `WhatsNewIn2.0.md`. Calls out
that on `net48` the agreement step does not run in a FIPS/validated
module.

## Tests (local)

| Suite | Result |
|---|---|
| `Opc.Ua.Core.Tests` full, net48 | 5956 passed, 0 failed |
| `EccInteropTests` net48 vs 1.5.378 peer (net8.0, native raw ECDH) | 26
passed; 28 skipped (AEAD + curve25519/448) — all four CBC curves, both
directions, UserName tokens, token renewal |
| `Opc.Ua.Sessions.Tests` ECC filter, net48 | 52 passed, 0 failed |
| Server `SessionCoverageTests`/`EphemeralNonce*`, Configuration builder
tests, net48 | all pass |
| Nonce/ECC/EncryptedSecret + session tests, net10.0 | all pass |

New tests: symmetric agreement on all four curves; on net48 an oracle
against CNG (`SHA256(raw) == DeriveKeyFromHash`); curve mismatch and
off-curve rejection. Tests that asserted the old net48 limitation were
updated.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:52:44 +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
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 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
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
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
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
Copilot d7dd1b0369 Fix telemetry component assembly identification (#4490)
# Description

Telemetry extensions resolved meters and activity sources as
`Opc.Ua.Types`, with results dependent on helper inlining. This
prevented reliable attribution to the emitting component.

- **Existing telemetry contract**: Add `CreateMeter(Assembly)` and
`GetActivitySource(Assembly)` directly to `ITelemetryContext`. The
separate `IAssemblyTelemetryContext` interface has been removed.
- **Reliable component attribution**: Capture the caller assembly in
non-inlined `CreateMeter`, `GetActivitySource`, and `StartActivity`
extensions, then forward it to the existing telemetry context.
- **Call-site routing and fallback**: Keep non-null meter calls on
`telemetry.CreateMeter()`. Use explicit
`TelemetryExtensions.CreateMeter(telemetry)` for nullable-context
fallback. Preserve default meter/activity-source fallback when a context
returns null.
- **Coverage and documentation**: Cover caller-assembly naming,
direct-context and extension routing, activity creation, and null-result
fallback. Update diagnostics examples, OpenTelemetry filters, and
migration guidance.

**Compatibility:** Direct implementations of `ITelemetryContext` must
implement the two new assembly-aware members. `TelemetryContextBase`
supplies both. This interface addition is documented in the 2.0
migration guide; legacy implementer compatibility is not claimed.

## Local validation

The Release build and focused `TelemetryExtensionsTests` fixture pass on
both target frameworks:

| Target | Result |
| --- | --- |
| net10.0 | Passed: 6, Failed: 0, Skipped: 0 |
| net48 | Passed: 6, Failed: 0, Skipped: 0 |

Both builds emit existing CA5394 warnings in
`NodeStateChildNameIndexTests.cs`, which is unchanged by this PR. The
full solution test suite was not run during this feedback pass.

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

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

- Fixes #4483

---------

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-16 11:07:17 +02:00
romanett ea93c86da2 Fix the Opc.Ua.Client defects reported by the client stack audit (#4471)
# Description

Fixes the 78 `Opc.Ua.Client` findings of a read-only bug audit of
`Opc.Ua.Client` / `Opc.Ua.Types` / `Opc.Ua.SourceGeneration` taken at
a05a36055, plus the regressions a subsequent review of those fixes
turned up. Every behavioural change was checked against the OPC UA
specification, and the citations are in the code comments.

## Session, reconnect and failover

- The reconnect `finally` re-acquired `m_reconnectLock` with the
caller's token, so a cancelled reconnect left the session stuck in
`Reconnecting` forever and every later reconnect failed. It now uses
`CancellationToken.None`.
- An in-place session recreate tore down subscriptions it should not
have: `transferred` is all-or-nothing, so a single failed transfer reset
and recreated the ones the server had just taken over, orphaning them on
the new session. `TransferSubscriptions` now reports the
per-subscription outcome and only the subscriptions the server did not
take over are recreated. A token-reuse failover keeps the same server
session, so nothing is transferred or reset there at all.
- In-place recreate only tears down the channel it created itself; a
caller-supplied channel or a channel-manager lease is left to its owner.
- `Snapshot(out SessionState)` went through the `SessionOptions` record
copy constructor, which drops every member declared on `SessionState`,
so the paired `Snapshot`/`Restore` API could not round-trip.
- The user-token policy override restored the policy URI on failure but
had already disposed the ECC ephemeral key the previous identity needs,
so the identity the rollback claims to preserve could no longer be
reactivated. The key is now parked and restored.
- `SetTriggering` batching sliced the remove list past its end when a
call had more adds than removes (Part 5 §6.3.11 bounds the *sum* of both
arrays, so an individual list may be shorter than the limit).
- The certificate-less `None` attempt dropped the request header, and
with it the ECDH ephemeral key that travels in `additionalHeader` (Part
4 §7.1/§7.15).
- `Session.Equals` compared a `ConfiguredEndpoint` against an
`EndpointDescription`, so a session did not equal a session on the same
endpoint.

## Managed session, pool and connection state machine

- `ManagedSession.Dispose()` blocked on the connection state machine's
worker, which raises `ConnectionStateChanged` synchronously — disposing
from that handler deadlocked permanently. The machine now recognises its
own flow and completes the teardown as the worker's continuation.
- `ManagedSessionFactory` dropped `updateBeforeConnect`, `connection`
and `reverseConnectManager` on all four `CreateAsync` overloads.
- The session pool ran its shared connect on no token at all, so once
the only caller gave up nothing could abort it and
`RemoveAsync`/`DisposeAsync` waited out the whole reconnect budget. Each
entry now owns the token that aborts its connect, and removal closes
*and* disposes the session.
- An explicit `engineFactory` was silently ignored for
`DefaultSessionFactory` subclasses, which quietly downgraded the session
to the classic subscription engine.

## Subscriptions

- Fixed a lock-order inversion between `m_subscriptionLock` and the
partition locks in `CompleteAsync` and `DisposeAsync`.
- `OwnsSubscriptionId` stopped at the first registered subscription
carrying the id — but in the very case it guards, the asker still holds
its own retired id, so the answer depended on `HashSet` enumeration
order and the delete could still tear down a sibling that had been
handed the recycled id.
- `MessageProcessor`'s republish gap walk was unbounded; it is now
bounded by the server's retransmission queue (Part 4 §5.14.5).
- A publish acknowledgement is sent even when a notification handler
throws — the ack means "the server may delete the message" (Part 4
§5.14.5.2), not "I processed it".
- Loaded monitored items did not raise the process-wide client-handle
counter, so a fresh process minted handles that collided with the loaded
ones; a transfer-restored item was also stamped `BadOperationAbandoned`
and left at the default monitoring mode.
- `AutoSetQueueSize` was not idempotent, so the queue size ratcheted up
on every subscription state change.
- The snapshot dropped the partition and recovery options entirely, and
clamped the sampling interval and the secondary-partition idle timeout
to zero — turning the Part 4 §7.21 "use the publishing interval"
sentinel and "never delete an idle partition" into something else.
- The state stream is positional OPC UA binary and the reader cannot
detect an absent field, so appending the partition options would have
mis-decoded every previously saved file. The stream now carries a format
version and the first format is decoded through a frozen copy of its
shape.

## Browse, node cache and the remaining clients

- Managed browse wrote a retry pass's status into whatever node happened
to sit at that offset in the caller's list, and retried
continuation-point errors without any bound. It now keeps the caller's
slot and gives up on passes that make no progress — a fixed pass cap
would cut short a large browse against a conforming server with a small
continuation-point quota.
- Continuation points are released when a consumer stops enumerating
early, as Part 4 §5.9.3.2 requires.
- The node cache reordered its batched results relative to the inputs,
which mispairs them for every consumer that matches positionally; its
supertype walks could also spin forever on a server whose `HasSubtype`
chain loops back on itself.
- A browse error was cached as "this node has no references"; it is now
surfaced to the direct caller, while the bulk and hierarchy walks treat
the node as having no references for that one operation without caching
it, so a single un-browsable node cannot fail a whole batch.
- `WebApiClient` mutated a caller-supplied (typically
`IHttpClientFactory`-pooled) `HttpClient`, which throws once that client
has sent its first request; those instances are configured per request
instead.
- `PathCache` was not thread-safe.

## Review follow-ups

The last two commits are the result of reviewing the audit fixes
themselves. Worth calling out: the `Task.WaitAsync(CancellationToken)`
polyfill in `Opc.Ua.Types` never checked `IsCompleted`, so an
already-finished task plus an already-cancelled token reported
cancellation on every pre-net8.0 target where the BCL overload returns
the result. It is now aligned with the BCL and pinned by a test.

## Related Issues

- No tracking issue; this PR is the result of a read-only audit of the
client stack.

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

### Verification

35 regression tests were added under
`tests/Opc.Ua.Client.Tests/AuditRegressions/` (category
`AuditRegression`), one per fixed defect where the defect is observable
at unit level; most were mutation-checked by reintroducing the bug and
confirming the test fails.

Locally on **net10.0**: `Opc.Ua.Client.Tests` 2252/2252,
`Opc.Ua.Client.ComplexTypes.Tests` 2773/2773,
`Opc.Ua.Bindings.Https.WebApi.Tests` 346/346, and all 23 library and
test projects that reference `Opc.Ua.Client` build clean.
`Opc.Ua.Client` also builds clean for **netstandard2.1**, the target
that actually compiles the `WaitAsync` polyfill. The full **UA.slnx**
solution and the .NET Framework targets have not been run locally — left
to CI.

One pre-existing failure in `Opc.Ua.Types.Tests` is unrelated to this
branch:
`WotSpecFixtureManifestTests.EveryEmbeddedExampleMatchesItsRecordedHash`
(embedded WoT fixture hashes) fails identically with these changes
reverted.

### Notes for reviewers

- Two review findings were deliberately left unfixed and are documented
in the code: `ManagedSession.CreateAsync` has to write the engine choice
onto a caller-supplied `DefaultSessionFactory` subclass (the only
alternative is changing the `ISessionFactory.Create` contract), and the
connection state machine's "am I on my own worker" flag is an
`AsyncLocal`, so it also flows into work a `StateChanged` handler
spawns. The previous behaviour in that second case was a hard deadlock.
- `Session.Dispose()`'s single `GetAwaiter().GetResult()` bridge to
`CloseAsync` is left as is: `Dispose(bool)` must be synchronous and
`DisposeAsync` already exists.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 08:22:28 +02:00
romanett 556c9cb797 Add an async iteration seam to NodeBrowser (#4450)
# Description

`NodeBrowser` was synchronous end to end, so a node manager whose browse
result depends on I/O had nowhere to `await`. The aggregation server
sample is the concrete case: its browser has to browse the aggregated
server, and the only way to write it was `Next() =>
NextAsync().GetAwaiter().GetResult()`, which holds a request worker for
the full remote round trip on every browse.

This is option (1) from the issue, the small non-breaking step:

- `INodeBrowser` and `NodeBrowser` gain `ValueTask<IReference?>
NextAsync(CancellationToken cancellationToken = default)`. The default
completes synchronously with the result of `Next()`, so every existing
browser that only overrides `Next()` keeps working unchanged (the
samples' `MemoryBufferBrowser` and the migration-analyzer test snippets
compile untouched).
- `AsyncCustomNodeManager.BrowseAsync` and `TranslateBrowsePathAsync`
iterate browsers through `NextAsync` with the request's cancellation
token. The loop bodies already awaited; only the iteration could not.
- `DirectoryBrowser` was the one browser the stack ships that blocked on
async I/O. It now awaits the file-system provider enumeration in
`NextAsync` and keeps `Next()` as the bridge for the remaining
synchronous consumers (`UANodeSetHelpers` export, `CustomNodeManager2`).
Cancellation now propagates instead of being reported as an empty
directory.
- The synchronous paths (`CustomNodeManager2`, `CoreNodeManager`
diagnostics, nodeset export) are unchanged on purpose; they have no
place to await.

**Not done, by design.** Browser creation stays synchronous.
`OnCreateBrowser` runs under the node's browse lock, so the documented
contract is that a browser fetches lazily on its first `NextAsync`. The
issue itself notes the async creation seam is not needed for the
aggregation case. `IAsyncEnumerable` (option 3) is not attempted.

**Compatibility note.** A type that implements `INodeBrowser` directly
without deriving from `NodeBrowser` has to add the member. Nothing in
this repo or the samples does that; derived browsers are unaffected.

**Docs** updated in `NodeManagers.md`, `AsyncServerSupport.md`,
`MigrationGuide.md` and `migrate/2.0.x/node-states.md`, with a was/now
example for a browser that previously bridged sync-over-async.

**Tests**
- `NodeBrowserTests` (new): default wrapper completes synchronously and
yields the same sequence as `Next()`, push-back through `NextAsync`, a
`Next`-only override observed through `NextAsync`, an awaited
`NextAsync` override with cancellation.
- `AsyncCustomNodeManagerTests`: a browser whose `Next()` throws is
driven entirely through `NextAsync` by browse, by the continuation-point
round trip (push-back on the async path), and by translate-path.
- `DirectoryObjectStateTests`: the async path of the directory browser,
including the throwing provider and cancellation.

Verified locally: `Opc.Ua.Server` builds on net10.0 and net48 with 0
errors; the browser-related server suites (1342 tests) and the new unit
tests pass on net10.0; `dotnet format whitespace --verify-no-changes` is
clean on the changed files.

## Related Issues

- Fixes #4415

## 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 Fable 5.1 <noreply@anthropic.com>
2026-09-08 19:56:53 +02:00
romanett 1aed93bd03 Make the predefined node create lifecycle cancellable (#4437)
# Description

Registration of predefined nodes could not be cancelled:
`CreateAsPredefinedNode` ran the whole create lifecycle without a token,
and a cancellation that landed inside `AddPredefinedNodeAsync` could
still leave the manager half-indexed.

**Cancellable create lifecycle.** Adds a
`NodeState.CreateAsPredefinedNode(ISystemContext, CancellationToken)`
overload. The token is threaded through the internal
`CallOnBeforeCreate` / `CreateInternal` / `CallOnAfterCreate` walk, is
checked between nodes and on each convergence pass, and is passed to
every `OnAfterCreate` override. Cancellation leaves the nodes already
completed in the created state, so a later call resumes only the
remaining ones. The existing single-argument overload is unchanged and
delegates with `default`.

**Shared registration helper.** `CustomNodeManager` and
`AsyncCustomNodeManager` each carried an identical private
`CompleteCreateLifecycleForRegistration`. Both are replaced by one
internal `NodeStateLifecycle.CompleteForRegistration`.
`AddPredefinedNodeAsync` now checks cancellation on entry, propagates it
into lifecycle completion, and checks again before
`IndexPredefinedNode`, so a cancelled registration cannot leave a node
indexed, half-created, or a behaviour replacement partially wired.

_(An earlier revision also added `RemoveExplicitlyDefinedChild`
overrides on `MethodState` and `BaseDataVariableState`. Those were
dropped in 45c6b3eb0: #4432 covers the same case inside
`CreateOrReplaceInputArguments` / `CreateOrReplaceOutputArguments` /
`CreateOrReplaceEnumStrings` by assigning the typed replacement
directly, so this PR no longer touches those files.)_

The three `<see cref="NodeState.CreateAsPredefinedNode"/>` references
(two in `src`, three occurrences in a generator template) are qualified
with the parameter list because the new overload makes the unqualified
form ambiguous (CS0419).

Public API added: one overload. No new virtual members.

## Related Issues

No tracking issue — this is a self-contained extraction from the
in-progress node-source lifecycle work, kept separate so the
cancellation semantics can be reviewed on their own.

## Testing

- `dotnet build UA.slnx -c Debug` — 0 errors, no new warnings.
- `dotnet format whitespace UA.slnx --verify-no-changes` on the changed
files — clean.
- net10.0: `Opc.Ua.Types.Tests` 9704 passed, `Opc.Ua.Server.Tests` 4965
passed, `Opc.Ua.SourceGeneration.Core.Tests` 3809 passed — 0 failures
(skips pre-existing).

New coverage: six `AsyncCustomNodeManagerTests` for the registration
cancellation seams (already-cancelled token, an already-created node not
being indexed, the helper's own check, cancellation raised from the
behaviour callback for both the replacement and same-instance paths, and
that removing a node neither deletes it nor repeats its create
lifecycle), three `NodeStateTests` for token propagation,
pre-cancellation and resumption between children.

## 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. — ran the
three affected suites on net10.0 only; .NET Framework and the remaining
suites are left to 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 <noreply@anthropic.com>
2026-09-07 20:04:42 +00:00
romanett 1af191ec26 Drop the .Common suffix from the three GDS projects (#4438)
# Description

Every other companion specification in the stack ships as `<Family>`,
`<Family>.Client` and `<Family>.Server` — `Opc.Ua.Di`, `Opc.Ua.ISA95`,
`Opc.Ua.Positioning`, `Opc.Ua.Robotics`, `Opc.Ua.Vision`,
`Opc.Ua.WotCon`, `Opc.Ua.XRegistry`, and `Opc.Ua.Lds.Server` without a
family package. GDS was the one exception, carrying a `.Common` suffix
that named nothing.

| Before | After |
| --- | --- |
| `src/Opc.Ua.Gds.Common` | `src/Opc.Ua.Gds` |
| `src/Opc.Ua.Gds.Client.Common` | `src/Opc.Ua.Gds.Client` |
| `src/Opc.Ua.Gds.Server.Common` | `src/Opc.Ua.Gds.Server` |

## No source changes were needed

`RootNamespace` was already the final name in all three projects — the
namespaces have always been `Opc.Ua.Gds`, `Opc.Ua.Gds.Client` and
`Opc.Ua.Gds.Server`, and only `AssemblyName` and `PackageId` disagreed.
Nothing that compiles against these assemblies changes a `using`
directive; the diff outside the moves is entirely ids, paths and prose.

Directories were moved with `git mv`, and every one of the 73 file moves
is detected as a rename at 100% similarity (bar the handful that also
had a name in their text), so `git log --follow` and blame survive.

## Consumer impact

The package ids change, which is visible to anyone consuming the NuGet
packages:

| 1.5.378 package | 2.0 package |
| --- | --- |
| `OPCFoundation.NetStandard.Opc.Ua.Gds.Common` |
`OPCFoundation.NetStandard.Opc.Ua.Gds` |
| `OPCFoundation.NetStandard.Opc.Ua.Gds.Client.Common` |
`OPCFoundation.NetStandard.Opc.Ua.Gds.Client` |
| `OPCFoundation.NetStandard.Opc.Ua.Gds.Server.Common` |
`OPCFoundation.NetStandard.Opc.Ua.Gds.Server` |

`docs/migrate/2.0.x/packages.md` gains a renamed-packages section — it
is the document that undertakes to cover NuGet renames, and had no GDS
entry — and the migration skill's package table gains the two rows a
1.5.378 consumer needs. That is the only hand-written content in this
PR; everything else is mechanical.

Assembly names follow the package ids, so the section also notes that
binding redirects, trimming descriptors and signing manifests naming the
files directly have to be updated.

## Build infrastructure

Everything naming the projects or their output moves with them:

- `UA.slnx` and `tools/MigrationAnalyzer.slnx` (both re-sorted — the
project lists are alphabetical, and `Gds` now sorts before
`Gds.Client`).
- `.azurepipelines/expected-packages.txt` — 3 ids, also re-sorted for
the same reason.
- `.azurepipelines/signlistDebug.txt` and `signlistRelease.txt` — 18
paths each. Their internal ordering is left alone: that group was
already unsorted, and reordering a signing manifest is diff noise.
- `samples/Reference/ConsoleReferenceServer/Dockerfile`, the two
metapackage nuspecs, the analyzer nuspec's package dependencies, and 8
`.csproj` references across tests, samples and tools.

`preview-pack.slnx` is generated from `UA.slnx` by `generate-slnx.ps1`,
so there is nothing static to edit. Nothing under `.github/workflows`,
and no `.ps1`/`.yml` in `.azurepipelines`, names these projects.

The migration analyzer's shim tree mirrors the source project layout by
its own documented convention, so `Gds.Client.Common/` becomes
`Gds.Client/` and its readme table follows.

## Deliberately not changed

Prose naming past work: `plans/23` still records that its deferral
happened during the "GDS Client.Common modernization", which is the name
that effort had. File *paths* in the same document were updated.

## Validation

`net10.0`:

- `UA.slnx`: 0 errors. 14 warnings, all pre-existing
CA1861/CA1850/CA1307 in `XRegistry`, `Types.Tests` and `WotCon.Tests` —
none in anything this PR touches.
- Assemblies build as `Opc.Ua.Gds.dll`, `Opc.Ua.Gds.Client.dll`,
`Opc.Ua.Gds.Server.dll`, with no `*.Common.dll` produced anywhere.
- `Opc.Ua.Gds.Tests`: 1,116 passed, 0 failed, 54 skipped.
- `Opc.Ua.MigrationAnalyzer.Core.Tests`: 10 passed, 3 skipped.
- A repo-wide grep for the old names returns only the intentional old →
new mappings in the migration docs.

## Checklist

- [x] Existing tests pass
- [x] No new tests — this is a rename with no behaviour change
- [x] Consumer-visible package rename documented in the migration guide

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 20:08:54 +02:00
Marc Schier c4960b542d Fix typed instance child NodeIds during registration (#4395)
## Summary

- selectively repair typed instance subtrees during asynchronous
registration when null, foreign declaration, or type-colliding NodeIds
remain
- preserve caller-assigned IDs in manager-owned namespaces, well-known
namespace-zero roots, and standalone foreign nodes
- assign `CreateProgramStateMachine` child NodeIds at the fluent
creation point without changing generic synchronous registration
semantics
- add `ProgramStateMachineState` regressions proving `CurrentState`
descendants are manager-owned and reachable
- document the registration contract and synchronize the bundled
migration-plugin copy

## Relationship to #4389

This is a compatibility fix for the existing `AsyncCustomNodeManager`
and fluent state-machine paths and should land independently. The
node-source work in #4389 has been rebased over this change locally and
retains its cancellation-aware lifecycle ordering.

## Validation

| Scope | Result |
| --- | --- |
| Focused `net10.0` NodeId/fluent regressions | Passed |
| Focused `net48` NodeId/fluent regressions | Passed |
| Full `Opc.Ua.Server.Tests` `net10.0` | 4,862 passed, 5 skipped |
| `Opc.Ua.Client.ComplexTypes.Tests` `net10.0` / `net48` | 2,773 passed
on each |
| `Opc.Ua.Di.Tests` `net10.0` / `net48` | 424 passed on each |
| `Opc.Ua.InformationModel.Tests` `net10.0` / `net48` | 1,080 passed, 20
skipped on each |
| Migration plugin validation | 15 bundled docs synchronized |
| Full solution build `net10.0` | Succeeded |

## Related Issues

Fixes #4376

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 78e1a2f4-c8a5-4627-aa90-1f2993541ebe
2026-09-03 12:53:34 +02:00
Marc Schier df44b460be Complete CreateInstanceOf node lifecycle during registration (#4360)
# Description

Generated `CreateInstanceOf<Type>` factories build typed node graphs and
assign
per-instance NodeIds, but intentionally leave the node create lifecycle
open.
Hand-written callers commonly registered those graphs without calling
`CreateAsPredefinedNode`, so `OnBeforeCreate`, `OnAfterCreate`, and
change-mask
clearing never ran.

This change:

- adds `NodeState.IsCreated` and makes lifecycle completion idempotent
per node;
- completes newly materialized children, including children created by
  `OnAfterCreate`;
- bounds late-child lifecycle convergence and fails explicitly if
callbacks
  keep adding uncreated nodes;
- completes uncreated nodes automatically before predefined-node
registration
  and behavior wiring in both async and legacy custom node managers;
- assigns/rebases async-node-manager NodeIds before lifecycle callbacks;
- emits a null-safe Debug diagnostic when registration performs
completion;
- documents the generated-factory lifecycle contract and migration
behavior;
- adds regressions for condition/alarm method binding,
finite-state-machine
  namespaces, Fluent state-node rebasing, behavior replacement, cloning,
deletion, late children, non-converging callbacks, and synchronous/async
  registration.

The tracked `src/` and `samples/` call-site audit covered 2,953 C#
files. No
additional ordering-sensitive call sites require explicit completion
beyond
the three existing DI/Robotics sites.

## Validation

- `dotnet build UA.slnx -c Release -p:CustomTestTarget=net10.0`: passed.
- `Opc.Ua.Server.Tests` net10.0: 4,811 passed, 5 skipped.
- `NodeStateTests` net10.0 and net48: 213 passed on each.
- Node-manager lifecycle and null-logger tests net48: 459 passed.
- Source-generation core tests net10.0 and net48: 3,802 passed, 8
skipped on
  each.
- DI, Robotics, ISA95, Positioning, Vision, Vision.Intent, and XRegistry
  suites passed on net10.0 and net48.
- Generated alarm lifecycle regression passed on net10.0 and net48.

The solution build reports six pre-existing CA warnings in untouched
files.
The OpenUSD suite has one pre-existing locale-sensitive failure in
`StructuredCartesianCoordinatesAreAccepted` (`1.5` expected versus `1,5`
under `en-DE`); all other OpenUSD tests passed on both tested
frameworks.

## Related Issues

- Fixes #4320

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7ffdc400-0710-4a2e-a9d1-c0e1a9b3b81e
2026-09-02 13:08:49 +02:00
romanett 6e791517b5 Make CertificateStoreIdentifier a pure store description (#4382)
### Why

Follows up on the [review discussion in
#4357](https://github.com/OPCFoundation/UA-.NETStandard/pull/4357#discussion_r3903422013):
the rule in this codebase is *if you get handed an IDisposable, it is
your responsibility to dispose it*, but
`CertificateStoreIdentifier.OpenStore` handed every caller the **one**
store instance cached on the identifier. A caller honoring the rule
disposed shared state other holders of the same identifier were still
using (it only appeared to work because the store implementations
tolerate use after dispose), while a caller that only called `Close()`
left the cached instance — and its parsed-certificate cache — alive with
no owner, which is exactly what the certificate leak gates flag at suite
end.

### What

Of the two options discussed (caller-owned instance vs. a new
ref-counted wrapper type), this implements the simpler one, modeled on
the existing `CertificateIdentifier` / `Certificate` pair:

- `CertificateStoreIdentifier` is now a pure **description** of a store,
exactly like `CertificateIdentifier` is for a certificate. It caches
nothing and has no disposable-like lifecycle.
- `OpenStore` creates and opens a **new instance on every call**, which
the caller owns and must dispose — like a resolved `Certificate`. If
`Open` throws, the instance is disposed before the exception propagates.
- Components that access a store repeatedly keep their instance open for
as long as they need it instead of relying on identifier-level caching:
the certificate validator already does this via its per-core store cache
(`CertificateValidationCore.m_stores`), which now also disposes the
loser of its benign `GetOrAdd` race instead of leaking it.
- Call sites that relied on the shared cached instance by only calling
`Close()` now dispose the store they own:
`CertificateTrustList.GetCertificatesAsync`, `SecurityConfiguration`
store validation, both `X509Utils` store extensions, the server
`TrustList` store-update helpers and the rejected-certificate list read.
- New `CertificateStoreIdentifierOpenStoreTests` pin the ownership
contract (independent instance per call, disposing one instance does not
affect another, empty path returns null).

A ref-counted wrapper prototype was implemented and discarded: it forced
every short-lived identifier creation site (~20 in src, more in tests)
to release a hidden base reference, spreading the disposable-like
lifecycle the review objected to across the whole codebase instead of
removing it.

### Verified

Release, `net10.0`, with `OPCUA_CERTIFICATE_LEAK_TRACKING=1`:
- Opc.Ua.Core.Tests: 4376 passed
- Opc.Ua.Configuration.Tests: 227 passed
- Opc.Ua.Server.Tests: 4797 passed
- Opc.Ua.Gds.Tests: 1111 passed
- Opc.Ua.Client.Tests (full suite): 2164 passed
- Opc.Ua.Core.Security.Tests: 556 passed
- Opc.Ua.PubSub.Mqtt.Tests: 157 passed (its strict certificate
instance-count test passes unmodified under the new contract)
- Opc.Ua.Tools.Tests: 710 passed

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-02 10:43:13 +02:00
Marc Schier 7fe8b30cc3 Publish migration plugin marketplace and update NuGet dependencies (#4358)
# Description

- Replace the Azure DevOps continuous-preview feed with the GitHub
Packages
NuGet feed, including its classic PAT `read:packages` requirement, while
  retaining nuget.org for official public 2.0 preview releases.
- Document one-shot MCP execution with `dotnet tool exec` and `dnx`.
- Make this repository registerable as the `opcua-dotnet` Copilot
marketplace
  and package `opcua-v20-migration` as an installable plugin.
- Bundle the 15 thematic migration documents for offline plugin use and
add a
  CI check that keeps the bundle and marketplace metadata synchronized.
- Align the migration analyzer guidance with current master, including
the new
`UA0030` analyzer while retaining `UA0029` as a runtime-shim-only marker
with
  reserved diagnostic metadata.
- Restrict generated collection-shim type discovery to consumer source
declarations before the exact `System.<Type>` / `Opc.Ua.<Type>` metadata
  fallback, with regression coverage for dependency-only metadata.
- Update 52 unpinned NuGet package IDs across 60 central entries, plus
two
  schema-test overrides, while preserving documented compatibility and
  security pins.

## Related Issues

- Fixes #4330

## Validation

- `dotnet build UA.slnx -c Release --no-restore`: 0 warnings, 0 errors.
- Migration analyzer tests across net472, net48, net8.0, net9.0, and
net10.0:
  795 passed, 0 failed, 0 skipped.
- The migration plugin bundle, manifests, PowerShell, XML, links, and
isolated
  marketplace installation validate successfully.
- The direct and transitive NuGet vulnerability audit reports no known
  vulnerabilities.
- A full solution test run was completed before merging current master.
Its
remaining failures were existing locale, path-length, GPU, shared-state,
and
flaky integration failures. Persistent cases reproduced on the
pre-update
commit, while isolated reruns cleared the dependency-relevant Core, GDS,
  Sessions, Subscriptions, and durable-queue failures.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e0ecb0e9-e5ef-4b2f-9922-41a7d42b0372
2026-08-31 19:42:24 +00:00
romanett 414957c88a Reduce ISubscription: internalize the publish pipeline, delete the dead members (#4349)
# Description

Reduces the server subscription API surface per the measurements from
issue #4185.

## ISubscription: 42 → 30 members (#4185)

A call-site census of the 11 "publish-pipeline protocol" members plus
`Publish` (all 68 `src/` projects, samples, tools, fuzzing, tests) found
**zero callers outside `Opc.Ua.Server`** and split the members three
ways:

- **Deleted (4):** `ItemReadyToPublish` and `ItemNotificationsAvailable`
were dead — zero callers repo-wide and implementation bodies commented
out since before 1.5.378 — the obsolete parameterless `SessionClosed()`
had zero callers, and `TransferSessionAsync` (the one-shot direct
transfer, which bypassed the reservation protecting a transfer against a
concurrently closing source session) lost its last caller once the
manager always uses the claim/prepare/commit protocol.
- **Internalized (8):** `PublishTimerExpired`, `Acknowledge`,
`PublishTimeout`, `SubscriptionTransferred`,
`AvailableSequenceNumbersForRetransmission`, `QueueOverflowHandler`,
`SessionClosed(ISession)` and `Publish` move to `internal interface
ISubscriptionPublishPipeline : ISubscription`, implemented
**explicitly** by `Subscription` (the
`ISubscriptionMonitoredItemLifecycle` shape) so they are unreachable
through the public concrete class too. `Publish` goes with them
deliberately — it is the same hazard class (consumes notifications,
advances sequence numbers) with the same single production caller.
- **Kept public (1):** `ResendData` drops out of the issue's list — its
only caller is the `Server_ResendData` UA-method handler in
`DiagnosticsNodeManager`, resolving via `TryGetSubscription` like
`GetMonitoredItems` does. It is service-facing, not pipeline.

## The pipeline contract is complete: SessionPublishQueue traffics only
in it

`SessionPublishQueue` becomes `internal sealed` (zero consumers outside
the assembly; `SentMessageQueue` already was) and works **solely with
`ISubscriptionPublishPipeline`** — no `ISubscription` and no concrete
`Subscription` anywhere in the file. To get there, the internal
transfer-claim protocol (`TryBeginTransfer`,
`PrepareSessionTransferAsync`, `CompleteTransfer`, `AbortTransfer`,
`TryRestoreSessionAfterFailedTransfer`) moved from `internal` methods on
the concrete class onto the internal interface, implemented explicitly.

That completes the contract, so `TransferSubscriptionsAsync` loses its
two-tier shape: the ~6 `subscription as Subscription` null-guard
branches and the degraded `TransferSessionAsync` fallback for
non-`Subscription` implementations are gone — one code path, always the
claim/prepare/commit/rollback protocol, and
`ISubscription.TransferSessionAsync` is deleted with it. Subscription
creation fails with `Bad_InternalError` when a `CreateSubscription`
override returns a type not derived from `Subscription`, instead of
publishing partially — such implementations were already second-class
(transfer required the concrete type before this change). This is the
headline behavior change for exotic extenders and is documented
prominently in `docs/migrate/2.0.x/sessions-subscriptions.md`.

The durable-subscription open question in the issue is answered **no**:
`Subscription/Persistence/` never references `ISubscription` (the store
deals in `IStoredSubscription` DTOs; restore constructs the concrete
type via `Subscription.RestoreAsync` inside the manager), so the
internalization cannot break a store implementer.

## #4186: measured, prototyped, and deliberately kept as-is

Body inspection showed only 4 of the 10 `ISubscriptionManager` wrappers
are pure lookup-and-delegate (`Republish`, `SetTriggering`,
`ModifyMonitoredItemsAsync`, `SetMonitoringModeAsync` — one caller
each); the other 6 carry logic with no `ISubscription` substitute. A
collapse of the four was implemented and then reverted on this branch:
it traded ten uniform id-addressed members for a mixed dispatch shape in
`StandardServer` without reducing what a reader must understand. **Every
subscription service keeps going through `ISubscriptionManager`**; the
wrapper bodies now resolve against the pipeline-typed registry. The
decision is recorded in `plans/ServerRuntimeAnalysis.md` (open question
5).

## Compatibility treatment

Removals are outright, per the documented no-`[Obsolete]`-shim policy
for these interfaces (`docs/MigrationGuide.md` — re-adding an interface
member would break every implementer; net48/netstandard2.0 have no
default interface methods). In its place:

- Migration analyzer **UA0030** (pipeline members +
`SessionPublishQueue`, distinguishing deleted no-ops from internalized
protocol), with tests, following the UA0024 syntax-based pattern. The
transfer-claim members were never public in 1.5.378, so they need no
migration story.
- `docs/MigrationGuide.md#ua0030` section with before/after code; the
`docs/migrate/2.0.x/sessions-subscriptions.md` section that told
implementers to *add* `SessionClosed(ISession)` is replaced with the
derive-from-`Subscription` story; the 2.0.x README symptom table routes
the rule.
- All 11 removed/internalized members existed in 1.5.378 (verified
against `master378`), so each is documented as a 2.0 break.

## Tests

Converted, not deleted. `Mock<ISubscription>` setups of pipeline members
become `Mock<ISubscriptionPublishPipeline>` — Moq proxies the internal
interface via a new `InternalsVisibleTo("DynamicProxyGenAssembly2")`
(keyless; the SDK appends `$(PublicKey)` under `SignAssembly`, and
signed pipelines do not run tests, matching the existing
`Opc.Ua.Redundancy.Kubernetes` precedent). A test-side extension shim
forwards the original member names (pipeline + transfer protocol) so the
direct call sites on concrete `Subscription` instances compile
unchanged. The transfer-race tests' typed reflection probes into
`m_abandonedSubscriptions` follow the field's new type.

- `Opc.Ua.Server.Tests` (net10.0): 4795 tests, 0 failures
- `Opc.Ua.Subscriptions.Tests`, `Opc.Ua.Subscriptions.Durable.Tests`,
`Opc.Ua.Subscriptions.Classic.Tests` (net10.0): green
- `Opc.Ua.MigrationAnalyzer.Tests`: green incl. the new UA0030 tests
- net48 gate: solution build green; subscription-area fixtures green
(the known pre-existing local hosting-test environment failures
reproduce identically on unmodified master)


## Related Issues

- Fixes #4185
- #4186 was measured and prototyped on this branch and deliberately
**not** taken (see the section above); the decision is recorded in
`plans/ServerRuntimeAnalysis.md`.

## 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. _(net10
suites fully green locally; net48 build green and subscription-area
fixtures green — the remaining net48 hosting-test failures on my machine
reproduce identically on unmodified master.)_
- [ ] 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 Fable 5 <noreply@anthropic.com>
2026-08-31 10:53:51 +00:00
Marc Schier cef83550cb OPC UA Vision: a vision-guided bin-picking demo an LLM agent can see and drive (#4235)
# Description

Implements **OPC UA — Vision** and uses it to build the demo the
companion specifications were drafted for: an eye-in-hand camera watches
a bin of parts, a language model *looks at the frame*, decides which
part matches the instruction, and commands the robot through Robot
Intent — with the OpenUSD viewer showing the arm move and the bin empty
out.

> **Draft, and stacked.**
[#4195](https://github.com/OPCFoundation/UA-.NETStandard/pull/4195) has
merged and master is merged in, so what is left on top of `master` is
the Vision work plus the unmerged `marcschier/ai-model-management`
branch, which the demo needs for the model/dataset/deployment
provenance. Review the Vision commits; the AI Model Management work
lands with its own PR.

## What is here

**The specification, implemented.** `Opc.Ua.Vision` is generated from
the spec's own `Opc.Ua.Vision.NodeSet2.xml`, vendored byte-identically
(SHA-256 `79C592C6…`, verified against a fresh download and stored
LF-preserved like the Robot Intent NodeSet). `Opc.Ua.Vision.Server`
builds the §4.2 discovery object, sensors, the coordinate-frame tree,
calibrations, media endpoints, inference pipelines and the §9 feedback
methods, with rendering and inference behind injectable providers.
`Opc.Ua.Vision.Client` is built on the generated ObjectType proxies and
composes transforms across the frame tree, which is what turns a
detection in camera coordinates into a pose a robot can act on.

**Two perception paths behind one contract**, which is exactly what
`InferenceLocationEnum` is for. `OnServer` is a deterministic detector
derived from the stage's ground truth — no model, no network, no GPU, so
CI can exercise the whole loop. `EdgeOffServer` is the agent: it sees
the frame over MCP and calls `SubmitDetections`, and the Server
publishes results it did not compute. A client reads
`DetectionResultType` identically either way.

**The agent's eyes.** `tools/Opc.Ua.Mcp.Vision` adds 22 `vision_*`
tools, which with the four shared connection tools makes the `vision`
profile 26; `vision_get_frame` returns the camera image as an MCP
`ImageContentBlock`, so the model looks at pixels rather than reading a
description of them. Tool profiles now compose — `--profile
vision,robotics` yields 62 tools — so one agent can both see and act
without dragging in the full 136-tool catalogue.

**The cell.** `samples/Robotics/BinPickingCell` follows the spec's own
Robotics-Vision Addendum: the `world → robot_base → flange →
gripper_tcp` frame tree with `camera_eih` on the flange, the `HandEye`
extrinsics and the `Intrinsics612x512` intrinsics — the addendum's
2448×2048 calibration scaled to the 4×4-binned grid the camera actually
delivers. `samples/Robotics/BinPickingClient` hosts the composed MCP
catalogue and optionally the viewer.

**Documentation.** `docs/Vision.md` (~800 lines) covers the model,
hosting, the build context, every topology builder, both perception
paths, feedback, the MCP profile and the limitations; linked from
`docs/README.md`, with the profile table in `docs/McpServer.md` and
READMEs for both samples.

## Evidence, not assertions

Each of these was measured on a running system rather than assumed:

| Claim | Evidence |
|---|---|
| The camera sees the bin | 612×512 frame with the bin and all five
parts in view — red `(212,47,47)`, green `(61,193,71)`, blue
`(54,85,218)`, yellow `(215,196,58)`, orange `(209,119,55)` |
| An agent can act on what it sees | Over MCP, `vision_get_frame`
returns a 612×512 PNG that decodes from base64 to 1,254,051 bytes, the
descriptor beside the detections says 612×512, and all five
`BoundingBox2D` centres land **inside** the frame on the pixel colour
their own detection claims |
| The geometry is right | A detection composed `camera_eih → world`
lands on the authored position, residual **0.0000 m** — on both the
on-server and off-server paths |
| The detector tracks the world | Placing a part drops the next scan
from 5 detections to 4, with that part absent |
| The robot obeys the agent | Scripted run took command authority and
completed a pick and a place, both `Succeeded` |
| Tool catalogues are honest | Counted from a running server: vision 26,
robotics 40, `vision,robotics` 62, full 136, **zero duplicates** |
| Every node is browsable by NodeId | Eight representative NodeIds —
both folders, a pipeline, a frame, the sensor, its Calibrations folder,
the HandEye calibration, the Media folder — all `Good` |

That tool-count check matters because the profile tests collapse names
into a `HashSet` and *cannot see* a duplicate registration — so the new
tests count registrations, not names.

## Things that were wrong, and are not now

Writing the tests found three defects of one kind — code that answered
instead of refusing. A zero-norm quaternion was silently rewritten to
identity, so a caller with an all-zero orientation got a confident wrong
grasp pose. `SubmitCorrection` rewrote a null `ResultId` to empty and
called the sink anyway. A bad `StageIdentifier` **killed the host
process** inside native `UsdStage.Open`, via an exception .NET cannot
catch. All three now refuse loudly and are pinned by tests.

**Nodes that existed but could not be reached.** The node manager
indexes the Vision root before configurators run, so everything the
fluent builder grafted on afterwards — both folders, the pipelines with
their Results and Feedback, the frames, the sensors with their optics,
calibrations and media endpoints — was never added to the index.
Browsing *forward from a parent* worked, because that walks
`NodeState.Children` in memory; browsing any of those nodes *by its own
NodeId* returned `BadNodeIdUnknown`, which is how an ordinary client and
the MCP discovery tools navigate. Registration is now deferred and
flushed after each configurator. The first fix covered only the DI path
and left the documented non-DI fallback broken in exactly the same way,
so `ConfigureVisionAsync` now exists and registers what it builds.

That defect survived a green suite because every test browsed children.
The new tests assert by NodeId, and were checked against the broken code
— with the flush removed, they fail.

**Every Method was uncallable.** The Server passed 426 unit tests while
not one of its Methods could be invoked over a real session —
`RunInference` returned `Bad_TooManyArguments`, and so did every other
Method that takes arguments, which is all of them but `StartContinuous`
and `Stop`. Three independent faults, each sufficient on its own: the
Method nodes were never created (they are Optional children, and the
dispatcher guards every attachment with a null check, so it silently
attached nothing); nothing declared the arguments, so the stack
concluded none were expected; and `MethodDeclarationId` pointed at a
synthesised NodeId rather than the NodeSet declaration a client calls
with, so the lookup missed the instance Method entirely. The builder now
creates the Methods, the `Results` folder and the `Feedback` object when
a provider or sink is configured, declares every signature, and names
the right declaration.

Unit tests could not have caught this: they invoke the handler delegates
directly and never go through `MethodState.Call`. It took an end-to-end
test over a real session. Relatedly, children created by the generated
`CreateOrReplace` helpers carry no `ReferenceTypeId`, so nothing could
reach them — fixed once in the build context rather than at the several
dozen call sites.
**And one where I was wrong.** Earlier in this branch I reversed my own
agents' decision and made `SubmitDetections` accept an empty detection
set, reasoning that "I looked and the bin is empty" is a correct
observation and refusing it forces a correct agent to invent a
detection. The reasoning is sound; it is also an argument *against* the
specification rather than an implementation of it. §9.5 is normative and
explicit — its StatusCode table lists `Bad_InvalidArgument` for
"`Detections` empty; or `SubmitCorrection` supplies both or neither
corrected array".

Worse, **none of §9.5's argument rules were enforced anywhere** — the
dispatcher forwarded every submission straight to the feedback sink, so
conformance depended on whichever sink a host installed. They are Server
obligations, so they now live in the dispatcher, ahead of the sink,
proven by strict mocks that would throw if the sink were consulted. The
usability problem is real and does not go away by conforming, so it is
raised where it can be fixed:
[opcua-drafts#70](https://github.com/marcschier/opcua-drafts/issues/70).

## Tracking the updated drafts

The Vision, Robot Intent and AI Model Management NodeSets on
`opcua-drafts` `main` have moved, partly because of feedback this branch
raised. All three are re-vendored byte-identically — verified by
comparing git blob hashes against the upstream files rather than by eye
— and the implementations follow.

**Two statements that were previously inexpressible now have a wire
representation.** `SubmitDetections` takes `SceneIsEmpty` and
`SubmitCorrection` takes `RetractAll`, so an agent can report "I
examined this frame and there is nothing in it" — the terminating
condition of the bin-picking task — and can retract a false positive by
correcting a result down to nothing. Both are checked in both
directions, because the flag is exactly what separates a deliberate
empty observation from a lost payload: an empty array without the flag
is refused, and the flag with an array attached is refused as two
contradictory claims about one frame. Implemented in the dispatcher, the
client, the MCP tools the agent drives, and the sample cell.

Also absorbed: `LampType` and `LightingMode` became enums (§66),
`ProfileName` became `DefaultProfileName` on the endpoint (distinct from
the `ProfileName` a caller passes to `GetStreamEndpoint`, which keeps
its name), AI `Invoke`/`InvokeAsync` gained `PayloadUri` under the same
exactly-one rule, and AI `ListModels` gained a `ContinuationPoint` —
because `MaxResults` alone puts everything past the bound permanently
out of reach.

**OpenUSD is on `0.9.0-alpha`**, verified on the running cell rather
than by restore alone: D3D12 backend, 612×512 frame, the bin and all
five parts in view and detected — the same result `0.8.0-alpha`
produced.

The one local workaround that survives is `VisionMethodArguments`, and
its reason has changed rather than gone away: the builder materialises
these Methods as Optional children through `CreateOrReplace`, which
constructs the state object directly and never runs the generated
factory that now carries the signatures. Checked rather than assumed —
removing the declarations fails five of the eight
`VisionMethodSurfaceTests`.

## Review feedback

Three threads, all addressed in `0fe2803a3`:

- **The AI Model Management work is now a library family, not a
sample.** `src/Opc.Ua.AI` (model), `.Inference` (`IInferenceBackend` and
backends), `.Server` (node manager) and `.Client` (discovery, reads,
calls, artefact transfer), in the same shape as the Robotics and Vision
families; the samples become `samples/AI/ModelManagementServer` and
`ModelManagementClient`. The client library needed real work rather than
relocation: its browsing surface was `private` inside the sample's
scenario runner, so moving the file alone would have produced a library
nobody could call.
- **No cloud-vendor SDK is referenced anywhere.** Inference goes through
`Microsoft.Extensions.AI` — an `IChatClient` is implemented by hosted
services and on-device runtimes alike, which is exactly the property
clause 8.1 asserts. `Azure.Identity` is gone: workload identity is not
an Azure feature, every platform projects a token into a file and each
SDK reads that file behind its own API, so the resolver reads it
directly. The comment claiming `Microsoft.Extensions.AI` would force a
`System.Text.Json` bump was stale — it pins 10.0.10, which this
repository already centralises on.
- **`BinPickingCell` and `BinPickingClient` moved into
`samples/Robotics/`**, which is where they belonged: the cell was
already linking the arm, its kinematics and both USD assets out of
`IntentEnabledRobot`.

## Upstream

Work that belonged elsewhere went elsewhere rather than being worked
around locally:

- **`openusd-dotnet`
[#13](https://github.com/marcschier/openusd-dotnet/pull/13)** — merged
and shipped in `0.8.0-alpha`. `SilkFrameCapture.Capture` rendered only
the first capture of a session and silently returned a blank frame for
every one after it. Also filed
[#14](https://github.com/marcschier/openusd-dotnet/issues/14) (no
camera-by-prim API) and
[#15](https://github.com/marcschier/openusd-dotnet/issues/15) (skipped
tests reported as passed — which fooled me for several runs). Both are
fixed; this branch takes `0.8.0-alpha` and deletes two workarounds,
including 143 lines of hand-rolled projection maths.
- **`opcua-drafts`
[#66](https://github.com/marcschier/opcua-drafts/issues/66)–[#69](https://github.com/marcschier/opcua-drafts/issues/69)**
— a close read of the spec produced 22 candidate ambiguities; 12 were
discarded as misreadings with clause citations and the remaining 10
filed. One is concrete: five members exist in the NodeId CSV that no
clause documents.
- **`opcua-drafts`
[#70](https://github.com/marcschier/opcua-drafts/issues/70)** — §9.5
could not express an empty observation or a false-positive retraction.
**Adopted**, with the explicit-flag resolution argued for:
`SubmitDetections` gained `SceneIsEmpty`, `SubmitCorrection` gained
`RetractAll`, and the corrected-array rule relaxed from *exactly one* to
*at most one*.
- **`opcua-drafts`
[#71](https://github.com/marcschier/opcua-drafts/issues/71)** — the
Vision NodeSet browse-named all 13 `InputArguments` / `OutputArguments`
Properties in the Vision namespace rather than the base namespace, so a
stack could not find them and every Method in the specification was
uncallable. **Fixed**; this branch now tracks the corrected artefact.

## The defect underneath it all

The end-to-end suite was removed earlier in this branch because nine of
its
fourteen tests failed on one symptom: a client could not open a
pipeline's
`Feedback` object or enumerate its `Results`, even though browsing the
same
parent listed both. Restoring it meant explaining that, and the
explanation
was not in Vision.

`RelativePathElement` initialised **`IsInverse` to `true`**. A caller
that
sets a `ReferenceTypeId` and a `TargetName` — which reads as *"the child
reached by this Reference"* — was therefore asking the Server for the
**inverse** Reference, and got `Bad_NoMatch` with nothing in the request
to
suggest why.

`ObjectTypeClient.ResolveChildNodeIdAsync` is such a caller, and it is
what
**every source-generated Optional-child accessor in the stack** is built
on
— the accessor whose own documentation cites
`AlarmConditionTypeClient.GetShelvingStateAsync`
as an example. Vision is merely where it surfaced, because Vision puts
two
Optional children on the path an off-Server agent must take. The sibling
helper in `StateMachineTypeClientExtensions` already set `IsInverse =
false`
by hand, which is the shape of a trap rather than a default.

The default is now forward, `ResolveChildNodeIdAsync` says so
explicitly,
and the existing test that had recorded `true` as *expected* now pins
`false` with the reasoning. Of the forty `RelativePathElement`
construction
sites in the tree, that accessor was **the only one relying on the
default at
all** — every deliberate caller already set it.

Two further defects were found underneath it, both real and both fixed:

- The generated `CreateOrReplace` helpers construct a child state object
directly and leave **`TypeDefinitionId`** unset as well as
`ReferenceTypeId`.
A child referenced by nothing cannot be browsed; one with no type
definition
is a malformed Object that a client filtering by type silently skips —
which
  is exactly what `EnumerateResultsAsync` does. Normalisation moved to
`VisionNodeManager`, so it also covers the case the builder cannot see:
a
result published **at runtime** by an inference provider, long after the
address space was built. That path produced results a client could list
but
  not read.
- `Opc.Ua.AI` shipped without an `AssemblyInfo`, so the solution built
with
  six `CA1014` warnings.

I had initially "fixed" this in the Vision client with Browse-based
fallbacks,
on the strength of a probe that appeared to show
`TranslateBrowsePathsToNodeIds`
failing even for `Server → ServerStatus`. That probe was wrong: it used
an
unqualified `ReferenceTypeIds`, which inside a `Opc.Ua.Vision.*`
namespace
binds to *Vision's* `ReferenceTypeIds` and yields a nonsense NodeId.
Chasing
that to the end is what turned up the real cause. **The fallbacks are
gone** —
they would have hidden a stack-wide bug behind a Vision-shaped
workaround, and
one of them matched on browse name while ignoring the namespace index.

`tests/Opc.Ua.Vision.Intent.Tests` is restored and back in `UA.slnx`,
passing
**14/14** on its own merit rather than through a client-side workaround.
A
registration test now pins the pipeline's `Feedback` and `Results`
children,
which nothing covered before.

## Running it, and what that found

The demo's premise is that a language model **looks at the bin and
reasons
about what it sees**. Running it end to end showed it could not, for
reasons
no test covered, because every one of them lived between components.

**The sample did not compile.** Two `CS8604` nullable errors with
`TreatWarningsAsErrors`, so neither the cell server nor the demo could
be
started at all. Worth recording that my first explanation was wrong: CI
*does*
build this project - the Linux leg's `net10.0` pass compiles it,
log-confirmed -
so this is not a coverage gap in the workflow, and no workflow change
was made.

**`Pick` and `Place` never moved the arm.** They were a delay plus a
gripper
action, so the operations reported `Succeeded` while nothing travelled.
They now
solve IK once and interpolate in joint space. **And the parts never
moved**: the
arm swung but the bin stayed full, because nothing tied the held object
to the
world state. The connector now creates 11 monitored items where it
created 6,
and a scripted run delivers `SeqNo=151` across 10 messages where it
delivered
`SeqNo=3` across 1.

**The frame was smuggled through a string field.** The media provider
put a
base64 *data URI* of the PNG into `VisionImageReferenceDataType.Uri` - a
field
meant to be a *reference*, while the bytes were already returned in the
inline
`ByteString`. Every response therefore shipped the image twice, and a
1,311,400-byte PNG became a 1,748,536-character string against a
`MaxStringLength` of 65,535: **26.7x over**. That is why raising
`MaxByteStringLength`, `MaxArrayLength` and `MaxMessageSize` never
helped - none
of them govern a `String`. Separately the MCP tool assigned raw bytes
where
`ImageContentBlock` requires base64, so the wire carried
`"data":"�PNG..."` -
every non-UTF-8 byte replaced by U+FFFD, destroying the image and
inflating the
response to 6.8 MB.

**The camera was aimed at nothing.** The arm's home pose, the flange
scan pose
the Vision model declared and the joint angles in the USD stage were
three
independent claims about where the camera was, and they disagreed - the
declared
flange orientation pointed the view 86 degrees away from the projection
camera.
Solving them as one thing exposed two further constraints that are easy
to miss:
the camera has to sit off the tool axis or it photographs its own
gripper, and
the IK branch has to be elbow-back or a link parks under the camera and
fills the
frame with the arm's own upper arm. A third was a regression this branch
introduced and `Opc.Ua.Robotics.Tests` caught - aiming a straight-down
camera
from a point on the base's own X-Z plane puts the wrist on the J4/J6
singularity, so every motion away from home fails to solve. A 15-degree
camera
roll, free because the camera still looks straight down, gets 25 degrees
clear,
which is the margin the poses before this branch had.

**The frame and the detections were different images.** The sensor
declared
2448x2048, the clip endpoint 1280x1024, and the renderer produced
640x512 - not
even the same aspect ratio. The ground-truth detector projects through
the
declared intrinsics, so its boxes lived in 2448x2048 space while an
agent was
handed a 640x512 picture: *"pick the red cube you can see"* arrived with
coordinates that pointed off the image. Everything now agrees at
612x512, an
exact 4x4 bin of the native sensor with the intrinsics scaled to match;
the
native size stays in the model and serial number, where it describes the
hardware rather than the image.

**`LatestClip` was created and never written**, so it reported
`Bad_NoDataAvailable` for the life of the Server and a consumer
following the
model - read the published frame, call the method only if there is none
- never
got a frame. The dispatcher now publishes a clip it has just encoded,
for every
Vision server rather than this sample alone.

One genuine API gap turned up on the way: `OpcUaServerOptions` exposes
`MaxByteStringLength`, `MaxArrayLength` and `MaxMessageSize` and **no
`MaxStringLength`**, so a Server that legitimately needs a long string
could not
raise the limit through the hosting API at all. Added, with tests. It is
not the
fix for anything above - that was a design bug - but the missing knob
was real.

## Not finished

- **Two projects sit below the repository's 80% line coverage.**
`Opc.Ua.Vision.OpenUsd` is at 71.1% — what is left only executes with a
real OpenUSD stage and plugin tree, which is not on every build agent.
`Opc.Ua.AI.Inference` is now at **86.7%**, up from 12.9%: the code this
branch *wrote* is covered by 25 tests, and `RestChatCompletionsBackend`
— previously the whole of the shortfall — is covered by 18 more against
a stubbed `HttpMessageHandler`, exercising success, refusal, throttling,
retry-after, timeout and cancellation without a network. Everything else
clears the bar — `Opc.Ua.Vision.Server` 92.1%, `Opc.Ua.AI` 91.4%,
`.Vision.Client` 88.1%, `Opc.Ua.Vision` 85.7%, `Opc.Ua.AI.Server` 80.1%,
all measured with coverlet. The CI coverage gate reports itself as
advisory and non-blocking.
- **`SamplesCollected` is not counted here, and `docs/Vision.md` now
says so.** Section 9.4 requires a Server to count a negative example
(`SceneIsEmpty` / `RetractAll` carrying a `GroundTruthLabel`) exactly as
it counts one carrying geometry. `VisionNodeManager` does not, and
structurally cannot: `SamplesCollected` is a property of
`LearningJobType`, which the *AI Model Management* companion defines,
and Vision reaches the job through a `NodeId` value rather than a
Reference **precisely so this model takes no dependency on the model
that defines it**. What the Server does guarantee is that the negative
example survives the hop intact — both flags are carried verbatim to
`IVisionFeedbackSink` — so a host that binds a learning job has
everything it needs to satisfy the clause on the counter it owns. Stated
in the limitations rather than left implied.

## Related Issues

Was stacked on #4195, now merged. No tracking issue yet for the Vision
implementation itself — happy to open one if you would like the design
recorded as an ADR before this goes further.

## 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. — *Types 8562, Server 4072,
Client 2123, InformationModel 1063, Robotics 540, Tools 466, Vision 442,
AI 74 and the restored Vision+Intent end-to-end suite 14/14, all green
on net10.0. Only `.OpenUsd` (71.1%) is below the 80% bar — see Not
finished.*
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings. — *`dotnet build UA.slnx -c Release`: 0 errors, 0
warnings.*
- [x] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed. — *net48:
Types 8555, Client 2126, Vision 385 green. Two net48 Server tests fail
(`CreateUserManagementSeamBindsTheModelToTheServerAsync`, and
`DurableDataValueQueueVerifyReferenceBatchingAsync` only under parallel
load); both were confirmed **pre-existing** by re-running them with the
`IsInverse` change reverted, which fails identically.*
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings. — *The net48 build legs failed because the
restored end-to-end project used `ValueTask.FromResult` /
`string.Create`, which do not exist on .NET Framework; fixed in
`fb57522da` and verified building on every TFM.
`DisposeDrainsHeldConnectionCallbackBeforeListenerDisposal` failed once
on the Ubuntu Client leg — a 13 ms timing-sensitive reverse-connect
disposal test that passes locally 2123/2123 and touches no browse
paths.*
- [ ] I have addressed **all** PR feedback received.
## Typed Vision-guided Robotics MCP workflows

The branch now also makes the demo workflow first-class in the Full MCP
profile:

- replaces stringified Robotics intent and mission inputs with typed
request schemas and exact name-or-NodeId selectors;
- adds bounded operation/mission paging, stable step-to-operation
identity, retained mission discovery, and `robotics_wait_mission`;
- makes `vision_run_inference` return bounded typed detection,
inspection, or segmentation summaries in one call;
- adds same-session `robotics_vision_pick` to select a detection and
submit Pick or Pick/Place work without implicitly taking authority,
retrying, cancelling, or waiting; and
- starts Publish workers for classic subscriptions so an independently
launched OpenUSD viewer receives live robot and part updates.

The migration guide documents the intentionally breaking MCP
request-schema changes.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1dab1302-19c5-4a9b-a50c-d97d389713aa
Copilot-Session: a248a589-9a20-4372-868e-5d347e57001b
Copilot-Session: 99d1ab42-f83a-41ab-bf3e-85bf81fadace
Copilot-Session: bcbc8ac9-5abd-42a8-8bac-e10533790e2e
2026-08-29 19:34:15 +02:00
romanett 70c8ce4649 Allow passing an existing config XML to the client DI AddClient (#4328)
# Description

> **Stacked PR**: based on `romanett/server-di-config-xml-e30c4f`
(#4325) so the diff shows only the client changes. Once #4325 merges
this will be rebased onto `master`.

Gives the **client** DI surface the same migration path for existing
configuration XML files that #4325 gave the server: applications that
already own a classic `MyClient.Config.xml` can pass it to `AddClient`
and keep **all** of their settings.

New API surface:

- `AddClient(string configurationFile, Action<OpcUaClientOptions>?
configure = null)` loads the `ApplicationConfiguration` from the file;
the optional callback configures the remaining client options (session
endpoint, identity, reverse connect, ...).
- `AddClient(Stream configurationStream, ...)` for documents that are
not files on disk, e.g. embedded resources. The stream is read once and
disposed after loading.
- `OpcUaClientOptions.ConfigurationFile` / `ConfigurationStream` back
the overloads, so the options-callback route works too and the file path
can be bound from configuration (`OpcUa:Client:ConfigurationFile`).
- `OpcUaClientOptions.ConfigureLoadedConfiguration` runs after the
document is loaded and validated but before the application-instance
certificate check, for code-side overrides of individual file settings.

Mechanism and behavior:

- A new internal `ClientSuppliedConfigurationProvider` implements
`IOpcUaApplicationConfigurationProvider` and plugs into the client's
existing configuration-provider seam. The document loads **lazily on
first use** — the first session connect, reverse-connect startup, or an
explicit `GetAsync` — through
`ApplicationInstance.LoadApplicationConfigurationAsync`, so every
setting (security configuration, certificate stores, transport quotas,
client configuration) applies exactly as on the classic path, including
localhost fix-up.
- The load **ensures the application-instance certificate** (mirroring
the shared `ConfigureApplication(...)` provider), which the pre-existing
manual workaround — pre-loading and assigning
`OpcUaClientOptions.Configuration` — never received. DI-registered
`ICertificateManager` / `ICertificatePasswordProvider` are honored.
Single-flight loading; `IAsyncDisposable` like the shared provider.
- The supplied document is authoritative and takes precedence over a
shared application registered with `ConfigureApplication(...)`.
Ambiguous combinations are rejected eagerly at `AddClient` with clear
`InvalidOperationException`s: file + stream, document + explicit
`Configuration`, document + application identity properties, and a
white-space path arriving through configuration binding. The start-time
`IValidateOptions` validator accepts a supplied document as a
configuration source.
- `ConnectManagedSessionAsync` publishes the lazily loaded configuration
onto the resolved options after `GetAsync`, so the channel manager and
session builder see it exactly as on the other configuration paths;
reverse connect consumes the provider through its existing async-startup
path unchanged.

Coverage: new `ClientConfigurationFileTests` fixture (8 tests) — loading
from file and stream with assertions on the document settings, the
override callback, the certificate bootstrap, provider caching and lazy
semantics, precedence over `ConfigureApplication`, load failure for a
missing file, argument validation, ambiguous-combination rejection,
configuration-section binding, and validator acceptance. All 323
existing ClientBuilder tests and all 180 server hosting tests (including
the combined client+server fixtures) pass; `Opc.Ua.Client` builds
warning-free on net472, net8.0, and net10.0 (AOT analyzers).

Docs: new "Client: using an existing configuration XML file" section in
`docs/DependencyInjection.md` with cross-links from the server section,
an updated pointer in the 2.0 migration guide, and a NuGet README
mention.

## Related Issues

- Fixes #4327
- Stacked on #4325 (server-side counterpart)

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

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

---------

Co-authored-by: Claude Fable 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>
2026-08-29 13:33:40 +00:00
romanett b9657e5f0d Allow passing an existing config XML to the hosted server DI AddServer (#4325)
# Description

Extends the hosted-server dependency-injection surface so applications
that already own a classic `ApplicationConfiguration` XML file
(`*.Config.xml`) have a direct migration path to DI with **all** of
their existing settings applied.

New API surface:

- `AddServer(string configurationFile, Action<ApplicationConfiguration>?
configure = null)` and the `AddServer<TServer>` variant load the
configuration from the file instead of building it from
`OpcUaServerOptions`.
- `AddServer(Stream configurationStream, ...)` (plus generic variant)
for documents that are not files on disk, e.g. embedded resources. The
stream is read once and disposed by the hosted service during startup.
- `OpcUaServerOptions.ConfigurationFile` / `ConfigurationStream` back
the overloads, so the options-callback route works too and the file path
can be bound from configuration (`OpcUa:Server:ConfigurationFile`).
- `OpcUaServerOptions.ConfigureLoadedConfiguration` runs after the
document is loaded and validated but before certificates are checked, so
individual settings can be overridden from code without editing the
file.

Behavior:

- The document is loaded through
`ApplicationInstance.LoadApplicationConfigurationAsync`, so every
setting (base addresses, security policies, certificate stores,
transport quotas, operation limits, user token policies) applies exactly
as on the classic path, including localhost fix-up.
- The supplied document is authoritative: the builder-feeding options
(`ApplicationName`, `EndpointUrls`, `PkiRoot`, policy toggles,
`ConfigureBuilder`, ...) are not applied on this path, and it takes
precedence over a shared application registered with
`ConfigureApplication(...)`. Options acting on the hosted server itself
(`Identity`, `ConfigureRateLimits`) and all fluent registrations
(`AddNodeManager`, `ConfigureRoles`, identity authenticators, ...)
compose unchanged.
- DI-registered `ICertificateManager` / `ICertificatePasswordProvider`
are honored, the "listening at" log reports the loaded base addresses,
and the unmatched user-token-policy startup warning is derived from the
policies the document advertises.
- Setting both `ConfigurationFile` and `ConfigurationStream` fails
startup with a clear `InvalidOperationException`; a missing file fails
startup with the original `ServiceResultException`.

Coverage: new `ConfigurationFileHostingTests` fixture (12 tests) with
end-to-end hosted starts from file and stream asserting the loaded
settings, custom `TServer` types, the override callback, precedence over
`ConfigureApplication`, warning derivation from the document,
missing-file and file+stream startup failures, argument validation, the
duplicate-registration guard, and configuration-section binding.

Docs: new "Migrating with an existing configuration XML file" section in
`docs/DependencyInjection.md` (plus first-class options table rows), a
pointer in the 2.0 migration guide, and a NuGet README mention.

## Related Issues

None — additive convenience overloads on the existing hosted-server
dependency-injection surface; no behavior change for existing
registrations.

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

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 10:48:24 +02:00
Copilot de3476d332 Remove HTTPS binding dependency from server packages (#4297)
# Description

`Opc.Ua.Server` and `Opc.Ua.Gds.Server.Common` depended directly on
`Opc.Ua.Bindings.Https`, which pulled ASP.NET Core into server
applications
even when optional HTTPS-based transports were not used.

- Remove the HTTPS binding project references from Server and GDS
entirely.
- Add a Core-owned `IOpcUaTransportBuilder` contract for builders that
support
  transport registration.
- Move the type-preserving HTTPS, WSS, Kestrel OPC TCP, and Web API
fluent
  extensions into `Opc.Ua.Bindings.Https`.
- Remove the repository-wide `NoHttps` / `NOHTTPS` build switch.
- Keep ConsoleReferenceServer HTTPS/WSS support through an explicit
binding
  reference and the ASP.NET Core container runtime.
- Document that applications must reference the optional binding package
  directly.

## Validation

- Built `Opc.Ua.Bindings.Https` in Release for all target frameworks.
- Built Server and GDS for .NET 10 and .NET Framework 4.8.
- Built ConsoleReferenceServer and PumpDeviceIntegrationServer for .NET
10.
- Passed 65 HTTPS binding dependency-injection tests.
- Passed focused Server and GDS transport-builder tests.
- Verified refreshed Server/GDS restore assets and output manifests
contain no
  `Opc.Ua.Bindings.Https` dependency.

## Related Issues

Fixes #4296

## 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.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce build or analyzer
warnings.
- [ ] I ran all tests locally using the `UA.slnx` solution against .NET
Framework and .NET 10.
- [ ] 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>
Co-authored-by: Marc Schier <mschier@microsoft.com>
Co-authored-by: romanett <7413710+romanett@users.noreply.github.com>
Copilot-Session: 41b98d43-8bb8-4587-8f3b-9532544dd582
2026-08-21 07:29:18 -07:00
Copilot d525bc3f28 Bound reconnect cleanup and organize samples by domain (#4276)
# Description

This PR fixes bounded ManagedSession reconnect cleanup and reorganizes
the sample applications into domain-focused folders.

## Reconnect cleanup

- Bounds reconnect integration cleanup with independent 30-second close
and dispose limits.
- Sends `CloseSession` through the awaited transport request path during
`Session.DisposeAsync`.
- Preserves explicit `Session.CloseAsync` behavior that waits for or
cancels outstanding publish requests.
- Covers the disposal request path with a regression test.

## Sample organization

- `samples/OpenUsd`: `GeneratorServer`, `SiteCompositionServer`
- `samples/Redundancy`: `RedundantClient`, `RedundantServer`,
`RedundantPubSub`
- `samples/Isa95`: `MinimalIsa95Server`
- `samples/MinimalApi`: `MinimalBoilerServer`, `MinimalCalcServer`,
`MinimalClient`
- `samples/DI`: `PumpDeviceIntegrationServer`
- `samples/Lds`: `ConsoleLdsServer`
- `samples/Reference`: `ConsoleReferenceClient`,
`ConsoleReferenceServer`
- `samples/PubSub`: `ConsoleReferencePubSubClient`

`UA.slnx` now exposes the same requested domain groups. Project
references, tests, runtime sample probes, CI workflows, Dockerfiles,
Compose files, helper scripts, and documentation were updated to the new
paths. `samples/Robotics/MinimalRobotServer` remains under `Robotics`.

## Validation

- `dotnet build UA.slnx`
- 3 Redundancy short-haul sample tests
- 70 DI sample tests
- 914 OpenUSD tests
- 1 WotCon model-materialization test
- 137 AOT tests
- `docker compose config` for all 6 moved Redundancy Compose files

## Related Issues

- Fixes #4273

## 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.
- [x] I built the complete `UA.slnx` solution locally.
- [ ] I ran all tests locally against at least .NET Framework and .NET
10.
- [ ] 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>
Co-authored-by: Marc Schier <mschier@microsoft.com>
Co-authored-by: Marc Schier <marcschier@users.noreply.github.com>
Copilot-Session: a2c2838f-74ae-4997-a1d4-50574d9e0636
2026-08-21 04:35:47 +00:00
Marc Schier fac30c45e6 Add MinSupportedSamplingInterval configuration and honor it when revising sampling intervals (#4243)
# Description

PR #4194 removed the unused
`ServerConfiguration.MinMetadataSamplingInterval` and left nothing in
its place. As a result there was no way to populate
`Server.ServerCapabilities.MinSupportedSampleRate` (`i=2272`) —
`ServerInternalData.CreateServerObjectAsync` hardcoded it to `0`.
Separately, the only clamp applied when revising a monitored item's
sampling interval was the per-node
`BaseVariableState.MinimumSamplingInterval`, which defaults to
`MinimumSamplingIntervals.Continuous` (`0`), so reference-server nodes
accepted any requested interval (e.g. 10 ms) unrevised — exactly what
was reported.

This adds a configuration entry **in the exact schema slot #4194
vacated** and enforces it as a lower bound.

## Configuration

- **`ServerConfiguration.MinSupportedSamplingInterval`** — `double`
(milliseconds), default `0`, `[DataTypeField(Order = 17)]`, i.e. between
`MaxNotificationsPerPublish` (16) and `AvailableSamplingRates` (18).
- `ApplicationConfiguration.xsd` — `<xs:element
name="MinSupportedSamplingInterval" type="xs:double" minOccurs="0" />`
in the same position the removed element occupied.
- Fluent API **`SetMinSupportedSamplingInterval(double)`** on
`IApplicationConfigurationBuilderServerOptions`, in the place
`SetMinMetadataSamplingInterval` was removed from.
- The element is added to the six config XML files #4194 touched
(reference / CTT / AOT / GDS use `0`; `test-server-full.xml` uses a
distinctive non-zero value for the round-trip test).

The address-space Property keeps its OPC UA spec name, so
`Server.ServerCapabilities.MinSupportedSampleRate` and
`VariableIds.Server_ServerCapabilities_MinSupportedSampleRate` are
unchanged.

```xml
<MaxNotificationsPerPublish>1000</MaxNotificationsPerPublish>
<MinSupportedSamplingInterval>2000</MinSupportedSamplingInterval>
<AvailableSamplingRates>
```

```csharp
application.Build(applicationUri, productUri)
    .AsServer([endpointUrl])
    .SetMinSupportedSamplingInterval(2000);
```

## Behavior

- `ServerInternalData` now publishes the configured value to
`Server.ServerCapabilities.MinSupportedSampleRate`.
- New `SubscriptionManager.CalculateRevisedSamplingInterval(...)` (two
overloads, alongside the existing `CalculateRevisedQueueSize`)
centralizes the revision rule and replaces the logic that was duplicated
across four call sites — the create and modify paths of
`AsyncCustomNodeManager` and `CustomNodeManager` — which now expose a
`MinSupportedSamplingInterval` property seeded from the configuration
(mirroring how `MaxQueueSize` is initialized).

The rule:

1. A requested interval below zero resolves to the *default sampling
interval*: the **publishing interval of the subscription** on create,
and the item's **current sampling interval** on modify. The modify case
preserves 1.5.378 behavior, so a `ModifyMonitoredItems` call that leaves
the sampling interval unspecified does not silently retune the item.
2. A node declaring `MinimumSamplingIntervals.Continuous` (`0`) for the
`Value` Attribute reports **by exception** and is **not** bound by the
server-wide minimum — the requested interval is returned unchanged.
3. Every other item is raised to `max(nodeMinimumSamplingInterval,
MinSupportedSamplingInterval)`. Attributes other than `Value`, nodes
declaring `MinimumSamplingIntervals.Indeterminate` (`-1`), and
non-Variable nodes are bound only by `MinSupportedSamplingInterval`.
4. `double.MaxValue` is capped to one year (pre-existing behavior, now
in one place).

Event monitored items do not sample and are unaffected.

**Backward compatible:** with the default of `0` the computation is
identical to the previous code for every input, including `Continuous`,
`Indeterminate`, positive node minimums, non-`Value` attributes and
`NaN`. No existing configuration or deployment changes behavior.

Node managers that implement `INodeManager` directly can call the new
public helper to apply the same rule.

## Related Issues

- Fixes #4191

The issue thread also asked whether
`Subscription.MaxNotificationsPerPublish` is revised by the server. It
is (`SubscriptionManager.CalculateMaxNotificationsPerPublish`), but it
was untested and is not observable from the `CreateSubscription`
response, which carries no revised field for that parameter. Four tests
were added that assert it through
`SubscriptionDiagnostics.MaxNotificationsPerPublish` on a privileged
session, covering create and modify, the above-limit, below-limit and
`0` (server default) cases.

## Tests

- **`SamplingIntervalRevisionTests`** (17 unit tests,
`Opc.Ua.Server.Tests`) — the full revision matrix: negative request
resolving to the default interval, the `Continuous` bypass,
`Indeterminate`, node minimum above *and* below the floor, a floor of
`0` being a no-op, non-`Value` attributes, non-Variable and null nodes,
and the `double.MaxValue` cap.
- **`MinSupportedSamplingIntervalTests`** (10 integration tests,
`Opc.Ua.Subscriptions.Tests`) — against a reference server configured
with a 500 ms floor: the capabilities node reflects the configuration; a
node declaring 100 ms is lifted to 500 ms;
`Scalar_Static_Arrays2D_LocalizedText` (1000 ms) wins over the floor;
`CurrentTime` (250 ms) is lifted; a `Continuous` node keeps its 10 ms; a
non-`Value` attribute is floored; `ModifyMonitoredItems` behaves the
same in both directions; event items are unaffected.
- Restored the configuration builder and round-trip test coverage that
#4194 deleted.
- `TestFixture` gained a `protected virtual void
ConfigureServer(ApplicationConfiguration)` hook so a derived fixture can
vary the server configuration before start. Default implementation is a
no-op, so no existing fixture changes behavior.

## Documentation

- `docs/Subscriptions.md` — new **Sampling interval revision** section
with the input table, the four-step rule (including both
negative-interval fallbacks), XML and fluent configuration examples, and
guidance for custom node managers.
- `docs/migrate/2.0.x/configuration.md` — new section documenting that
`MinMetadataSamplingInterval` was removed and
`MinSupportedSamplingInterval` added. Verified against `master378`: the
old setting defaulted to `1000` but was never read, and
`MinSupportedSampleRate` was hardcoded to `0` with no server-wide floor.
The guide therefore stresses that these are **two different settings,
not a rename** — consumers should drop the old element (a default of `0`
reproduces 1.5.378 exactly) and only opt into the new one deliberately,
since reusing the old value would silently introduce a sampling floor.

## Validation

- `dotnet build UA.slnx` — **0 warnings, 0 errors** across all TFMs.
- .NET 10: `Opc.Ua.Server.Tests` 4091 passed,
`Opc.Ua.Subscriptions.Tests` 619 passed (full suite including the new
fixture), `Opc.Ua.InformationModel.Tests` 1063 passed,
`Opc.Ua.Configuration.Tests` 221 passed, `Opc.Ua.Gds.Tests` 119 passed.
- .NET Framework 4.8: new unit tests (200 incl. sampling groups), new
integration tests (17) and configuration round-trip (135) all passed.
- `ConsoleReferenceServer` starts successfully with the updated
configuration file.

## Checklist

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 79f8090c-b2a3-409c-a5bf-56b6d2a35e70
2026-08-12 17:41:29 +00:00
Marc Schier 1cca744b5c Retire the last exposed locks: node, browser, server diagnostics and application properties (#4222)
Closes #4201. Closes #4202. Closes #4203. Closes #4215. Closes #4216.

(#4215 and #4216 are @marcschier's originals from the #4188 review;
#4202 and #4201 are my later duplicates of the same two findings. All
four describe the two changes in section 1 and are closed together.)

**This PR now carries what was #4221 as well.** The two were a stack;
they have been unstacked and combined here, and #4221 is closed.
Everything below the "Read the server diagnostics" section is the work
that was previously reviewed as #4221.

---

## 1. The browse path guards itself, and the last node locks are gone

`AsyncCustomNodeManager` kept two `lock (source)` sites around
`CreateBrowser`, carrying a NOTE that browser creation was not
internally synchronized. `NodeBrowser` still handed its lock to derived
types through `protected Lock DataLock`.

`NodeState.CreateBrowser` now holds a browse lock across
`PopulateBrowser` and `OnPopulateBrowser`, so browser construction on a
node is serialized — which is what the external `lock (source)` was
actually providing, and what a handler that mutates the node during
population (the historian's lazy config installer) depends on. Both
`lock (source)` statements and the NOTE are gone.

**What that lock does not do**, stated plainly in the XML docs and in
`docs/NodeManagers.md` rather than over-promised: it is not an atomic
snapshot across the node's children, notifiers and references. Writers
take those collections' own locks, not the browse lock. The browser is a
point-in-time copy and each collection is read consistently, but the
combination is not a transaction.

No `lock` takes a `NodeState` as its sync root any more. That was the
last thing keeping `NodeState` from being documentably self-guarding, so
the other eleven went with it: `lock (activeNode)`, `lock (target)`,
`lock (source.Node)`, `lock (target.Node)`, `lock (notifier)`, `lock
(handle.Node)` in `AsyncCustomNodeManager`, and `lock (node)` in
`CustomNodeManager2`. Most were a check-then-act pair now served by
`NodeState.AddReferenceIfMissing`, which does the check and the insert
under the node's own lock.

`NodeBrowser.DataLock` is removed. A browser is **single-consumer**: it
belongs to whoever created it, and both server browse paths already
serialize on the owning side. The exposed lock only added an
inheritance-level locking contract that a derived browser could not
reason about.

## 2. Read the server diagnostics under the lock every writer takes

`OnUpdateDiagnostics` locked the diagnostics **payload** rather than the
lock every writer takes through `UpdateServerDiagnostics`, so the
snapshot a client reads from the `ServerDiagnostics` node was never
excluded from a concurrent writer.

Both halves matter, and the second is the one that is easy to miss:

```csharp
lock (m_diagnosticsLock)                                        // was: lock (ServerDiagnostics)
{
    value = Variant.FromStructure(ServerDiagnostics, copy: true);  // was: copy defaulted to false
}
```

`copy: true` is what makes it a snapshot. `FromStructure(value, copy:
false)` does `new ExtensionObject(value, copy)` — it wraps the **live**
structure without copying it, so the caller would read the fields long
after the lock was released and see them change underneath. Swapping the
lock alone would have looked like a fix while still excluding nothing.

## 3. Analyzer numbering

The browser rule is **UA0027**, not UA0025. This branch had
independently taken UA0025 for `NodeBrowser.DataLock` while #4188
published UA0025 for `ILocalNode.DataLock` and UA0026 for
`BaseVariableValue.Lock`. Master's are shipped, so they keep the
numbers:

| | UA0025 | UA0026 | UA0027 |
|---|---|---|---|
| master (#4188) | `ILocalNode.DataLock` | `BaseVariableValue.Lock` | —
|
| **this PR** | `ILocalNode.DataLock` | `BaseVariableValue.Lock` |
**`NodeBrowser.DataLock`** |

Both sides had written a different `UA0025Tests.cs`, which arrived as an
add/add conflict. They are now split: `UA0025Tests.cs` holds master's
`ILocalNode` tests, `UA0027Tests.cs` holds the `NodeBrowser` ones.

UA0024's help text also drops its reference to `ReadServerDiagnostics`,
which #4193 removed as unused, and names `ISession.ReadDiagnostics` /
`ISubscription.ReadDiagnostics` instead — those do still exist.


## 4. Two more locks reachable from outside their owner

**`ApplicationConfiguration.PropertiesLock` is removed.** It returned
the properties dictionary itself, so `lock
(configuration.PropertiesLock)` was `lock (configuration.Properties)` —
the lock *was* the data it guarded, the same shape as #4203.

It had exactly one production consumer
(`OAuth2CredentialCollection.Load`, a get-or-add with `ParseExtension`
called inside the lock), so it could not simply be deleted. `Properties`
is now a `ConcurrentDictionary` — same public type, every operation
atomic on its own — and the one multi-operation need gets a member of
its own:

```csharp
public T GetOrAddProperty<T>(string key, Func<T> valueFactory);
```

It deliberately does **not** invoke the factory under a lock, so
`ParseExtension` now runs outside any critical section. Holding a lock
across a caller-supplied callback is exactly the shape that produced the
attribute/diagnostics deadlock earlier in this work.

The two existing tests asserted only `PropertiesLock is not null`, so
they tested nothing but its presence. They are replaced by tests of the
contract: factory runs once for a missing key, not at all for a present
one, 32 concurrent callers all observe the same instance, null arguments
throw.

`PropertiesLock` ships in 1.5.378, so this is a breaking removal —
migration guide section plus analyzer **UA0028**.

**`SharedDefinitionCache` no longer hands out its lock.** Three sites in
`SharedKeyValueSubscriptionStore` took the nested cache's `Lock` to
operate on the cache's own dictionary. Both are private now, behind
`ReplaceAll` and `CloneWhere`.

`SnapshotCommitLock` stays exposed on purpose: it is a `SemaphoreSlim`
sequencing an async commit across `await` points, which
`System.Threading.Lock` cannot span, so folding it in would change
behaviour rather than hide a lock.
## Verification

Merged `master` (`0edfe237a`, including #4234) with no conflicts.

- `dotnet build UA.slnx` — 0 warnings, 0 errors
- `Opc.Ua.Server.Tests` — 4074 passed
- `Opc.Ua.Types.Tests` — 8501 passed
- `Opc.Ua.MigrationAnalyzer.Tests` — 151 passed on all five TFMs (138 →
151, the new ones being UA0027 and UA0028)
- `Opc.Ua.Core.Tests` — 4249 passed
- `Opc.Ua.Redundancy.Server.Tests` — 573 passed

## Note for the reviewer

@romanett — your approval was given when this PR's diff was the 7
diagnostics files, because it was measured against
`marcschier/node-browse-locks`. Now that the two are combined and the
base is `master`, the diff is 23 files, and the extra 16 are the
node/browser lock work that was #4221. GitHub kept the approval across
the retarget, but it did not cover that code, so please re-review rather
than let it ride.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e9c13dd0-a6e3-4ad2-8ad6-1c3fe6323041
Copilot-Session: b3c0e146-5b9e-4d08-a0f6-db3f940484b1
Copilot-Session: d18f87e9-2484-44ce-98ce-463ba6cf5f63
Copilot-Session: 0ee22c1d-c319-4257-bf78-a5a077a1824a
2026-08-11 22:14:10 +02:00
Marc Schier 466c5cb982 Robot intent follow-ups: multi-controller executors, spec fixes, and an MCP tool library (#4195)
Follow-ups to #4165, plus the work that followed from re-checking this
branch against the specification. Everything started as a defect found
by review or by probing the running code; the specification is unchanged
except where noted.

The TLS certificate lifetime fix (#4196) and the OpenUSD work (#4198)
were both merged into this branch. #4196 has since landed on master, so
it no longer appears in this diff. #4198 was closed in favour of this PR
and its content is here.

The MCP trim/AOT work (#4228), the `DataGenerator` null `NodeId` fix
(#4227) and the NodeSet external-parent import fix (#4229) have since
been merged in as well. All three are closed in favour of this PR and
their content is here, described under *Making the MCP libraries trim
and AOT clean*, *A generator that returned the value you opted out of*
and *A NodeSet parent that lived outside the import batch* below.

## A two-arm cell could not be built

`IntentExecution` carried no controller identity, and there was
effectively one `IIntentExecutor` per server. An executor handed an
execution therefore could not tell **which arm** it was for, so a cell
with two `IntentControllerType` instances — an ordinary configuration —
was not expressible through the builder or DI.

Both halves are addressed:

- `IntentExecution` now carries `ControllerId` (`NodeId`, the OPC UA
identity) and `ControllerName`, so one shared executor can discriminate;
- DI gained per-controller registration —
`AddRobotIntentExecutor<TExecutor>(controllerBrowseName)` and an
instance overload — so two genuinely different executors can coexist.

Resolution order is `WithExecutor` → per-controller DI registration →
the single DI executor → a build-time throw. There is still **no silent
fallback** to a rejecting executor; that was a Critical defect fixed in
#4165 and it stays fixed. `IIntentExecutor` is unchanged, so this is
purely additive.

## Declarations that were quietly untrue

- **`Accepts<T>` silently discarded a redeclaration.** Declaring an
intent type twice — say to narrow `cancelSupported` on the second call —
kept the first, wider declaration and said nothing. That is the same
class of defect as the capability-honesty problems #4165 fixed
(`PauseSupported` declaring a pause the host could not perform;
`Aborting` degrading silently to `Buffered`). Identical redeclarations
remain idempotent; conflicting ones now throw `BadInvalidArgument`.
- **`RI-Interop-40010` assumed Annex B item 3 rather than checking it.**
It now requires exact set equality between the OPC 40010
`TaskProgramName` values and the Robot Intent `ProgramType.ProgramId`
values. Both sides are readable from the address space without
commanding the robot, which is what makes the requirement structural
under §12.1.

## Two things the specification requires that this did not do

Re-checking the implementation against the merged draft revision turned
up two genuine violations. Both are fixed, and the NodeSet is untouched
by either.

- **Every graphed mission reported `Failed` on success.**
`MissionRules.SelectTransition` returned `null` for two different
situations — a graph *terminus*, which is the normal success path, and a
branch point whose conditions all evaluated false — and the caller
collapsed both into `Failed`/`NoTransition`. A three-way result
(`Terminus` / `Selected` / `NoConditionMatched`) separates them, so a
mission that runs to its end now completes. The failure reason surfaces
through the inherited Part 10 result container rather than a new member.
- **Facet URIs were never published.** An upstream `may` became a
`shall` in the revision this branch adopts.
`RobotIntentConformanceUris.Facets` held 31 URIs with **zero**
production references — the set existed but nothing emitted it. The
published set is now derived from `RobotIntentFacetCalculator`, the same
authority that already computed `SupportedFacets`, rather than from a
second hand-maintained list that could drift; a reflection test pins the
two together.

## An easy client mistake

`RequestAuthorityAsync` returns a lease the caller must remember to
inspect. Forgetting makes every later submission fail with
`ControlNotOwned` for reasons that are invisible at the call site.
`RequireAuthorityAsync` throws instead, and names the current owner.
`RequestAuthorityAsync` is untouched — inspecting and branching is
legitimate.

## Letting an agent drive the robot

New `tools/Opc.Ua.Mcp.Robotics` gives a language model the intent API as
MCP tools: 36 across discovery, monitoring, direct control and missions,
behind a `Robotics` tool profile, composable into any MCP host exactly
as the existing OPC UA tool packages are. The `IntentViewerClient`
sample hosts them behind `--mcp` (stdio by default, HTTP when `--view`,
since MCP stdio and the in-process viewer would otherwise share stdout),
and the sample gained a pallet scene so an agent can pick and stack
parts and watch the result in the OpenUSD viewer. All the scene logic is
server-side — no viewer or connector changes.

Four gaps in the client API had to be filled first, since the tools are
a thin layer over it and could not have been written honestly without
them: `ReadStateAsync`, `ListOperationsAsync`/`ListMissionsAsync`,
controller-level
`CancelIntent`/`CancelAll`/`Pause`/`Resume`/`Retry`/`ReleaseControl`,
and `WaitForCompletionAsync`.

The tools refuse rather than paper over: a refusal from the server is
returned with its `IntentFailureEnum` and never retried, and command
authority is never acquired as a side effect of a tool call.

**One defect this surfaced:** `--profile robotics` shipped 36 tools that
could not do anything. Every robotics tool resolves a named OPC UA
session and only the connection tools can open one, so with no `Connect`
in the catalogue the whole profile failed. A test had pinned the defect
in place, asserting the profile did *not* contain `Connect` on the line
below the `diagnostics` assertion requiring it. Each package now
declares its own requirement in its registration extension rather than
the host special-casing it, so an application embedding
`Opc.Ua.Mcp.Robotics` directly gets a usable catalogue without
reproducing the host's pairing. `Full` still takes the connection tools
from the core package, so they are never registered twice. The
replacement test states the invariant — a profile exposing
session-scoped tools must expose the tools that open a session — rather
than the instance.

## Making the MCP libraries trim and AOT clean

*Merged from #4228.*

`Opc.Ua.Mcp.Core` is now `IsAotCompatible` on `net10.0`, and the
reflection the analysers then reported is gone. Turning the analysers on
is the point: until now they were not running on these libraries at all
— including on the tool library this branch adds.

- **`OpcUaJsonHelper.Serialize`** went through `JsonSerializer` (IL2026
/ IL3050). The conversion helpers in that class already reduce every OPC
UA value to a closed set — `null`, the primitive scalars, `string`,
`Dictionary<string, object?>` and lists thereof — so the JSON is now
written directly with a `Utf8JsonWriter`.

Output is unchanged, and that is asserted rather than assumed:
`OpcUaJsonHelperTests.SerializeMatchesTheReflectionBasedSerializer`
compares the new writer byte for byte against `JsonSerializer` for every
shape the helpers emit, including all numeric widths, the
custom-formatted scalars, nested sequences and strings that force the
encoder to escape.

One deliberate contract change: arbitrary object graphs are no longer
accepted. Serializing them needs reflection, so `Serialize` now throws
`NotSupportedException` naming the type instead of silently stringifying
it. No call site in the repository passes anything outside the closed
set.

- **`McpSchemaFilters`** used `JsonSerializer.SerializeToElement` on a
`JsonObject`; it now parses the node's own text, which needs no
reflection.

`Opc.Ua.Mcp.Diagnostics` is deliberately **not** marked. It depends on
SharpPcap and on the reflective service-call dissection in
`Opc.Ua.Core.Diagnostics`, neither of which is trim safe — which is why
that package does not opt in either. Two improvements are kept
regardless: `PacketDecodeTools` serializes `DecodedServiceCall` through
a source-generated `JsonSerializerContext`, and
`PacketCaptureTools.ListInterfaces` now carries the
`RequiresUnreferencedCode` / `RequiresDynamicCode` annotations that
`NetworkInterfaceEnumerator.ListLocalInterfaces` already declares, so
the constraint is visible to callers instead of implicit. Nothing is
suppressed.

Marking an assembly `IsAotCompatible` only proves its own IL is clean;
it says nothing about whether the library still functions once
reflection is gone. So `Opc.Ua.Aot.Tests` references `Opc.Ua.Mcp.Core`
and exercises it with JSON reflection disabled, stating what is and is
not true today:

- tool **results** serialize without reflection — this would fail
outright if the `Utf8JsonWriter` change regressed;
- tool **registration** still requires reflection, because the MCP SDK
builds each tool's input schema by asking `JsonSchemaExporter` for a
`JsonTypeInfo` of every parameter type. Without a source-generated
context covering the tool signatures — and with the injected
`OpcUaSessionManager` being described rather than treated as a service —
that throws `NotSupportedException`.

The second test asserts the throw. It documents the remaining gap and
turns red the moment someone closes it, rather than leaving it to be
found in a published binary. The package README says the same thing in
prose.

**Two pre-existing issues this surfaced, both outside this PR:** pulling
`Opc.Ua.Mcp.Diagnostics` into an AOT publish reports `IL2046` / `IL3051`
on `NicCaptureSource.StartAsync`, which is annotated
`RequiresUnreferencedCode` / `RequiresDynamicCode` while the
`ICaptureSource.StartAsync` it implements is not, and `IL2075` on
`ServiceCallReassembler.GetCollectionCount` / `GetPropertyString`, which
call `Type.GetProperty` on the result of `GetType()`. Happy to file
these separately if useful.

## A generator that returned the value you opted out of

*Merged from #4227. Fixes #4226.*

`GetRandomNodeId(useBoundaryValues: false)` could still return
`NodeId.Null` — one of the boundary values the same method offers when
boundary values *are* requested. A caller that opted out should not
receive one, and `GetRandomExpandedNodeId` builds on it and inherited
the same behaviour.

The `IdType.Opaque` branch is the practical path:
`GetRandomByteString()` draws its length with
`NextInt32(MaxStringLength)`, which is inclusive of zero, and a
zero-length opaque identifier in namespace 0 satisfies `NodeId.IsNull`.
With three namespaces and `MaxStringLength` at 10 that is roughly **one
call in 130**. A numeric identifier of `0` in namespace 0 is null too,
at around one in four billion.

The draw is now repeated while the result is null and boundary values
were not requested. Guarding the result rather than the individual
branches covers every identifier type, including the numeric case,
rather than only the one that happens to be reachable today. The
`useBoundaryValues: true` path is untouched, so boundary values remain
available when they are asked for.

Four tests in `tests/Opc.Ua.Core.Tests/Types/DataGeneratorTests.cs` loop
20,000 draws, because at one call in 130 a single draw proves nothing.
Against the **unmodified** generator they fail on draw 93 and draw 1346.
Two of the four exist to keep the fix honest rather than to test the
bug: `GetRandomNodeIdStillProducesEveryIdentifierType` catches a "fix"
that simply stopped emitting opaque identifiers, which would satisfy the
null assertions while silently narrowing the generator, and
`GetRandomNodeIdWithBoundaryValuesStillYieldsThem` confirms the leak was
closed rather than the feature removed.

**How it was found:** an intermittent downstream test failure. A test
server initialising a static `NodeId`-valued variable via
`TestDataSystem.ReadValue` occasionally stored `NodeId.Null`; the
encoder correctly writes that as JSON `null`, and a consumer asserting a
value was present failed about one run in five — never reproducibly in
isolation. It only surfaced under load because `UnsecureRandom.Shared`
is a fixed-seed, process-global generator shared between `DataGenerator`
and OPC UA transport jitter, so the number of draws preceding
address-space initialisation depends on what else is running in the
process.

## A NodeSet parent that lived outside the import batch

*Merged from #4229.*

`UANodeSetHelpers.LinkParentChildRelationships` builds its lookup table
from **only the nodes in the current import batch**, links a child when
the parent is found — and then cleared `instance.Handle`
**unconditionally**, which is exactly where `Import` had stashed the
authored `ParentNodeId`. A node whose parent lives in another NodeSet,
or is owned by another NodeManager, therefore lost that parent
**silently** at import, and nothing downstream could recover it.
`RuntimeNodeSetNodeManager.CreateAddressSpaceAsync` already has a step
that wires references to external node managers; the parent link never
reached it.

Such a parent is now recorded rather than discarded, and exposed through
`UANodeSet.TryGetUnresolvedParentNodeId`. A parent that resolved
normally inside the batch is **not** recorded, so a caller does not
re-wire references that already exist. The record lives in a
`ConditionalWeakTable`, so it does not keep an imported node alive and
does not widen `NodeState` — whose `Handle` is a general-purpose slot a
caller may legitimately use for its own purposes, and so is not a safe
place to leave the value.

`RuntimeNodeSetNodeManager` consumes it to report the one case that
cannot be repaired automatically. In NodeSet2 the `ParentNodeId`
attribute is a *hint*; the authoritative edge is the entry under
`<References>`, which the reverse-reference pass already turns into an
external reference. A node that names an out-of-manager parent but
carries **no such Reference** materializes with no path from that
parent, and no ReferenceType can be inferred to synthesise one — so it
is reported as an authoring defect rather than guessed at or silently
accepted.

This is deliberately **not** a fix for a specific address-space symptom.
It removes a silent loss of authored information and gives callers the
means to act on it. Whether any particular orphaned-node report is
caused by this path, or by namespace-index translation elsewhere, still
needs separate diagnosis.

**Changed while merging.** As authored in #4229 the accessor was
`GetUnresolvedParentNodeId`, returning `NodeId?`. `NodeId` is a readonly
struct implementing `INullable`, so that was a `System.Nullable<NodeId>`
wrapped around a type that already carries its own null representation —
which this repository excludes in favour of an `IsNull` check, and it
asks for `TryGet` over casting on struct types. The tests
correspondingly asserted `Is.Null` against a struct, which the testing
rules also exclude. It is now `bool
TryGetUnresolvedParentNodeId(NodeState, out NodeId)`, yielding
`NodeId.Null` when there is no unresolved parent. The API is unshipped —
it arrives with this branch — so nothing depended on the previous shape.
Both tests were re-verified by mutation after the rewrite: disabling the
record in `LinkParentChildRelationships` still fails
`ImportKeepsAParentThatIsNotInTheSameBatch`.

## Dependencies

- **OpenUSD to `0.7.0-alpha`.** All 11 issues we filed upstream are
fixed, so the workarounds they justified are gone and the parts of the
pump and generator samples that were blocked on them are implemented.
- **ModelContextProtocol to `2.1.0`** across all five MCP tool libraries
and the executable. No code changes were needed — the surface we use is
unchanged in 2.x — but central package management required a cascade of
pins, each commented where it sits. Both transports were verified
against a running server rather than assumed. MCP 2.x is stateless (no
`initialize`, no `Mcp-Session-Id`); an OPC UA session is application
state addressed by a caller-supplied name, which fits that model but
does assume process affinity, and that assumption is now written down
rather than implied.

## Infrastructure

The `code coverage` gate was measuring nothing on this branch and
reporting success. It is fixed and now enforced, which immediately
exposed real gaps — most usefully `RoboticsIntentJson`, which parses
agent-supplied JSON (untrusted input from a language model) and had no
tests at all. It is now at full line coverage in CI. Patch coverage is
**90.20%** against a 75% floor.

The `netstandard2.1` build leg was also failing. The MCP projects build
as empty shells under a legacy `CustomTestTarget`, and the viewer sample
referenced them on the target framework alone, so it tried to resolve a
net10.0 shell from net8.0. The sample now derives MCP support from
whether those projects are actually being built.

## From review

A review pass over the branch found two real defects, both fixed.
`RoboticsIntentJson` parses JSON written by a language model, so every
malformed shape it can be handed is reachable input; it indexed pose
arrays without checking their length and enumerated properties without
checking their kind, so a short or wrongly-typed array escaped as
`IndexOutOfRangeException` or `InvalidOperationException` rather than
the argument error the tool descriptions promise the agent. Separately,
`IntentOperationHandle` treated a zero `QueuePosition` as "not observed"
and discarded it, so a handle kept reporting the last queued position
after the server had said the operation left the queue.

The pose validation immediately caught a latent test defect: the pallet
scenario interpolated doubles into JSON with the current culture, so
under a comma-decimal culture `[0.12, 0.16, 0.28]` became a six-element
array. It passed only because that submission is expected to be refused
for want of authority, so the wrong coordinates were never used.

A third review finding — that a default-constructed
`RobotIntentControllerState` hands out a null `NodeId` — was checked and
rejected: `NodeId`, `QualifiedName` and `LocalizedText` are readonly
structs here, so `default` is the null value and `IsNull` is `true`. The
tests asserting that invariant are kept.

A second review pass over the merged branch found two more, both fixed
and both about an error surfacing as the wrong kind of failure rather
than about a wrong result:

- **Malformed agent JSON did not arrive as an argument error.**
`RoboticsIntentJson` turns every *validation* failure into an
`ArgumentException` — a wrong root kind, a missing property, a short
pose array — but three `JsonDocument.Parse` calls and the NodeId parse
were unguarded. A syntax error escaped as `JsonException` and an
unparseable NodeId as `ServiceResultException`, which are the two shapes
a language model is most likely to produce. That is the same defect
class as the pose/kind checking above: a caller separating bad input
from a genuine fault had to know to catch three unrelated exception
types. Parsing now goes through one helper that reports a syntax error
as an `ArgumentException` naming the argument, keeping the
`JsonException` as the inner exception so the position is not lost, and
the NodeId goes through `NodeId.TryParse` rather than `Parse`,
collapsing its two failure modes into one.
`MalformedIntentJsonIsRejected` had asserted
`InstanceOf<JsonException>`, pinning the behaviour rather than the
intended contract; it now asserts `ArgumentException` like every
neighbouring test, and three tests cover the newly guarded paths.
- **`ConditionalWeakTable.Add` throws on a duplicate key.** Not
reachable today, but `TryGetUnresolvedParentNodeId` documents `Handle`
as a general-purpose slot a caller may use for its own purposes — and a
caller doing exactly that before re-importing into the same collection
would take a hard throw from inside the import pipeline. Removing first
makes the most recent import win. `AddOrUpdate` would say this more
directly but does not exist on `netstandard2.0`.

## Deliberately not fixed here

`FastenIntentDataType.Joint` is refused when the Server exposes no OPC
40450/40451 model, and a client cannot discover that before calling.
That is **not fixable in this repository**: §5.4.2 makes `Joint` the
defining mechanism while §11.3 says the parameters otherwise stand
alone, and `IntentCapabilityDataType` declares capabilities at
intent-type granularity with no member-level mechanism anywhere in the
model. It is a specification gap, raised as S4 on
[marcschier/opcua-drafts#60](https://github.com/marcschier/opcua-drafts/pull/60)
rather than worked around with a bespoke mechanism the spec does not
define.

## Validation

`dotnet build UA.slnx -c Release` on the merged branch — **0 errors, 0
warnings**, with the AOT analysers now running over `Opc.Ua.Mcp.Core`.

| Suite (net10.0) | Tests |
|---|---|
| `Opc.Ua.Core.Tests` | 4247 (86 skipped) |
| `Opc.Ua.Robotics.Tests` | 540 |
| `Opc.Ua.OpenUsd.Tests` | 909 |
| `Opc.Ua.Tools.Tests` | 419 |
| `Opc.Ua.Server.Tests` — `RuntimeNodeSet` | 94 |
| `Opc.Ua.Robotics.Intent.Tests` | 22 |
| `Opc.Ua.Aot.Tests` — `McpAotTests` | 3 |

0 failures. `Opc.Ua.Tools.Tests` rose from 409 to 419 with the
serializer-equivalence tests from #4228 and the malformed-JSON tests
from the second review pass, and `Opc.Ua.Core.Tests` covers the
`DataGenerator` tests from #4227 and the import tests from #4229.
`UANodeSetHelpers` also passes on **net48** (5/5). The MCP
agent-usability grader scores Core 100 / Full 100.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Copilot-Session: a248a589-9a20-4372-868e-5d347e57001b
Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
Copilot-Session: a0718e8a-a158-45c5-929d-e1d6c0982431
2026-08-11 13:03:36 +02:00
Marc Schier 4cbcec6e23 Update NuGet dependencies to latest and version ASP.NET Core per target framework (#4223)
# Description

Refreshes the centrally-managed NuGet dependencies in
`Directory.Packages.props` to their latest stable versions, and changes
how the ASP.NET Core packages are pinned so their version follows the
target framework.

## ASP.NET Core packages are now versioned per TFM

`Microsoft.AspNetCore.Authentication.Certificate`,
`Microsoft.AspNetCore.Authentication.JwtBearer`,
`Microsoft.AspNetCore.Mvc.Testing` and `Microsoft.AspNetCore.TestHost`
ship one band per .NET major, carry no `netstandard2.0` asset, and do
not roll forward across majors — a `net8.0` project simply cannot
consume the `10.0.x` band. A single central pin therefore had to sit on
the oldest supported band and hold *every* consumer there, which is why
these sat at `8.0.28` while `10.0.10` was current.

They are now selected from `$(TargetFramework)`, mirroring the existing
MQTTnet split:

| TFM | Version |
|---|---|
| `net8.0` | `8.0.29` |
| `net9.0` | `9.0.18` |
| everything else | `10.0.10` |

The last group is the fallback rather than an explicit `net10.0` match,
so a TFM the file does not enumerate — a future `net11.0`, or the
`net10.0` shell that legacy `netstandard2.0`/`netstandard2.1`
`$(CustomTestTarget)` builds fall back to — still resolves a version.
Verified against the generated `project.assets.json` that each TFM of
`Opc.Ua.Bindings.Https` really does resolve its own band.

## Version changes

`Crdt`/`Crdt.Transport` and the `RaftCs` family 1.1.2 · `Dekaf` 1.11.2 ·
`JsonSchema.Net` 9.4.0 · `Microsoft.Bcl.TimeProvider`, the
`Microsoft.Extensions.*` set and
`System.Composition.AttributedModel`/`System.Formats.Asn1`/`System.Text.Json`/`System.Threading.Channels`/`System.Threading.RateLimiting`
10.0.10 · `Microsoft.Extensions.Http.Resilience` and
`Microsoft.Extensions.TimeProvider.Testing` 10.8.0 ·
`Microsoft.NET.Test.Sdk` 18.8.1 · `Microsoft.SourceLink.*` 10.0.301 ·
`Nerdbank.GitVersioning` 3.10.91 · `Serilog` 4.4.0 ·
`System.CommandLine` 2.0.10 · `Avalonia`/`Avalonia.Desktop` 12.1.1 ·
`MQTTnet`/`MQTTnet.Server` 5.2.0.1603 (modern band only) ·
`Roslynator.*` 4.16.0 · `TUnit` 1.64.6.

The two `System.Collections.Immutable` `VersionOverride`s in the schema
test projects move to 10.0.10 to stay ahead of what `JsonSchema.Net`
requires.

## Removed: the DotNext pin

`DotNext` 5.26.3 was pinned centrally but no project references it. The
`docs/migrate/2.0.x/packages.md` row claiming `src/Opc.Ua.Lds.Server`
consumes it was also wrong — that project references only
`Makaretu.Dns.Multicast` and
`Microsoft.Extensions.Hosting.Abstractions`. Both are corrected.

## BouncyCastle held at 2.6.2 — CI caught a real regression

The first push of this branch took `BouncyCastle.Cryptography` to 2.7.0.
CI failed `Opc.Ua.Core.Tests` on **net48 only**
(`VerifyPEMSupportDirectoryStoreAsync` expecting 3 certificates and
getting 0), and the cause turned out to be a genuine behavioural break
rather than a flaky test.

BouncyCastle is referenced **only on net472/net48**, where it backs
`PEMReader`/`PEMWriter` in `Opc.Ua.Security.Certificates`. 2.7.0
tightened `Asn1.X509.TbsCertificateStructure` to throw:

```
certificate issuer is an empty distinguished name
```

`OpenSsl.PemReader.ReadObject()` therefore aborts on the *first* entry
of a chain whose issuer DN is empty. `ImportPublicKeysFromPEM` has no
per-object recovery, so the entire file is discarded and the store
enumerates nothing.

This is a **runtime** behaviour change, not a test artefact: .NET's own
PEM support on net8.0+ still accepts such a certificate, so taking 2.7.0
would let the same trusted-peer store load fine on net8.0+ and come back
**empty** on .NET Framework. Whether the stack *should* reject these
certificates on every TFM is a fair question, but it is a
security-behaviour decision that does not belong in a dependency refresh
— so the pin is documented in place with that context for a follow-up.

Confirmed by bisecting the package version against the net48 tests
(2.6.2 passes, 2.7.0 fails) and by reading the actual PEM asset with
both versions to capture the exact throw site.

## Deliberately left alone

Every existing pin with a documented reason is preserved, and each was
re-checked rather than assumed:

- **Roslyn compat band** (`roslyn.props`) — `Microsoft.CodeAnalysis.*`
stays on the pinned band and
`System.Collections.Immutable`/`System.Reflection.Metadata` stay on
`$(RoslynRuntimeVersion)`. Raising these is what re-breaks the analyzer
`ImmutableArray<T>` identity the file warns about, and it would raise
the minimum compiler that will load the shipped analyzers.
`Microsoft.CodeAnalysis.Analyzers` stays at 4.14.0 for the same reason.
- **`Microsoft.AspNetCore.*` 2.3.11** — last band with a
`netstandard2.0` asset; already latest.
- **`PacketDotNet` / `SharpPcap`** — SDL native-code pins; already
latest stable.
- **`System.Net.Http` 4.3.4 / `System.Text.RegularExpressions` 4.3.1 /
`System.Private.Uri` 4.3.2** — transitive CVE remediation pins; already
latest.
- **`MQTTnet` 4.3.7.1207** for `net472`/`net48`/`netstandard2.1` —
MQTTnet 5.x dropped those TFMs.
- **`BouncyCastle.Cryptography` 2.6.2** — see the section above.
- **`Moq`** — pinned.
- **Prerelease-only upgrades skipped** — `BenchmarkDotNet`
0.16.0-preview, `NUnit` 5.0.0-beta, `NUnit.Console` 4.0.0-beta,
`EmbedIO` 4.0.0-rc1, `Newtonsoft.Json` 13.0.5-beta, `PacketDotNet`
1.4.9-pre, and others.

## Overlap with #4195 — please note

**OpenUSD and ModelContextProtocol are intentionally not touched here.**
#4195 already moves OpenUSD to `0.6.0-alpha` together with the restored
render smoke job and the `OpenUsd.Rendering.Silk.Vulkan` package that
the bump actually depends on, and separately moves MCP to `2.1.0`.
Rev'ing either in this PR would duplicate that work, and would risk
silently downgrading MCP if the two landed in the wrong order. They are
left to #4195.

There is still a small expected overlap in `Directory.Packages.props`:
#4195 also bumps `Microsoft.Extensions.Configuration.Abstractions`,
`Microsoft.Extensions.Hosting.Abstractions` and `System.Text.Json` to
10.0.10 — the same values as here, so whichever merges second resolves
to an identical line.

## The one code change

Roslynator 4.16 adds **RCS1258**, which flags `TypeAttributes.Public |
TypeAttributes.Class` in `ComplexTypeBuilder`. It is correct:
`TypeAttributes.Class` is `0`, so the flag is a no-op, and the
`DefineEnum` call directly above already passes `TypeAttributes.Public`
alone. Dropping it is a verified semantic no-op (`Public | Class ==
Public`).

## Related Issues

_No tracking issue — routine dependency maintenance, in the same spirit
as the weekly Dependabot NuGet updates configured in
`.github/dependabot.yml`._

## 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.<br/>_No new tests: this
changes dependency versions, not behaviour. The existing suite is the
proof, and the one code change is a verified no-op._
- [x] I have added all necessary
documentation.<br/>_`docs/migrate/2.0.x/packages.md` — corrected version
rows, removed the wrong `DotNext` row, added a section explaining the
per-TFM ASP.NET Core scheme._
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.<br/>_Full `UA.slnx` build across all six TFMs
(`net472`, `net48`, `netstandard2.1`, `net8.0`, `net9.0`, `net10.0`):
**0 warnings, 0 errors**. `dotnet restore`: 0 warnings. `dotnet pack`: 0
warnings, 0 errors, package validation passes._
- [x] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.<br/>_See
the verification note below._
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.

### Update after the first CI run

The initial push failed one CI job — `Opc.Ua.Core.Tests` on net48, 2 PEM
tests. That was **not** flakiness: it was a real break from BouncyCastle
2.7.0, root-caused and fixed by holding that package at 2.6.2 (see the
section above). `Opc.Ua.Core.Tests` on net48 now runs **4161 passed / 0
failed** locally, and the full six-TFM build is still 0 warnings / 0
errors.

### Review feedback

Both review threads are addressed in `78bdd0ea3`:

- The **package table's third column was stale** in more rows than the
ones flagged. Every entry is now checked against the actual
`PackageReference` items — `System.Threading.Channels` belongs to
`Opc.Ua.Core`/`Core.Diagnostics`/`PubSub.Diagnostics` (not the LDS
server), the `Microsoft.CodeAnalysis.*` rows come from the
generator/analyzer variant targets under `tools/`, and
`Microsoft.CodeAnalysis.Analyzers` plus `System.Reflection.Metadata`
have no direct reference at all and are labelled as central pins. The
column is renamed **"Referenced by"**, which is what it actually records
and what makes it verifiable.
- The **BouncyCastle pin now references
[#4224](https://github.com/OPCFoundation/UA-.NETStandard/issues/4224)**,
which carries the decision on whether the stack should reject
empty-issuer certificates on every TFM, whether
`ImportPublicKeysFromPEM` should recover per entry, and whether the
expired `Test_chain.pem` asset should be regenerated.

## Verification note on the test run

The full net10.0 run covered ~48,000 tests. 15 failed, and each was
individually shown **not** to be caused by this change:

- **4 in `Opc.Ua.Core.Diagnostics.Tests`** (3×
`ReadChunkDecryptsStackEncryptedSymmetricChunk`,
`PrivateRequestSummaryFormatsKnownRequestKinds`) — reproduced
**identically at `HEAD` with the changes stashed**. Pre-existing.
- **8 across `Client`, `Di`, `WotCon.Samples`, `Server`** — all pass
when their project is run in isolation; they only fail under the
parallelism of a full-solution run. The machine also had ~45 orphaned
test processes holding sockets, which were cleared.
- **3 in `Gds`/`Stress`** — the log shows socket port exhaustion (`Only
one usage of each socket address...`). The `CertRotationLiveTests` pair
passes when run standalone, and that project's net10.0 package closure
contains **none** of the bumped packages (`BouncyCastle` is
`net472`/`net48`-only, so it cannot reach a net10.0 test at all).

The AOT/TUnit suite (`Opc.Ua.Aot.Tests`) was A/B tested: TUnit **1.58**
produced the same intermittent 0–2 failures as 1.64, confirming
pre-existing flakiness rather than a regression. On **1.64.6** it ran
**129/129 twice**.

`net48` was built and exercised via `CustomTestTarget=net48`; the
`MSB3277`/`NU1702` warnings it surfaces in `Opc.Ua.Stress.Tests` are
structural to that mode (the project builds `net8.0` while consuming
`net48` outputs) and pre-date this change — the `net462` asset's
assembly version simply tracks the patch number, so `10.0.0.9` became
`10.0.0.10`.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 2be367ab-e62d-4141-a4df-e5b2ca3cd126
2026-08-10 18:18:27 +00:00
Copilot 404517c6e8 [MigrationAnalyzer] Generate public collection shims to unblock incremental 1.5.378 → 2.0 migration (#4214)
# Description

MigrationAnalyzer-generated `<Type>Collection` shims were emitted as
`internal`, which breaks consumers exposing these types in public APIs
during migration. This change makes generated shims `public` while
preserving `[Obsolete]` + `UA0002` guidance.

- **Generator output access level**
  - `MigrationGenerator` now emits:
    - `public sealed [Obsolete] class <Name>Collection : List<TElement>`
- No behavior changes to element-type resolution, deduplication, or
diagnostics (`MIG01`).

- **Targeted generator test updates**
- Updated assertions in `MigrationGeneratorTests` to validate `public
sealed class ...` output for:
    - well-known overrides,
    - semantic lookup for model types,
    - semantic lookup for built-in and primitive element types.

- **Migration docs alignment**
- Updated migration analyzer docs to reflect public shim generation and
removed outdated internal-only guidance.

```csharp
[global::System.Obsolete("... (UA0002)")]
public sealed class Int32Collection : global::System.Collections.Generic.List<int>
{
    public static implicit operator global::Opc.Ua.ArrayOf<int>(Int32Collection? value)
        => value is null ? default : value.ToArrayOf();
}
```

## Related Issues

- OPCFoundation/UA-.NETStandard#4205

## Checklist

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

- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [ ] 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.
- [ ] 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 #4205

---------

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-08-10 16:28:56 +00:00
Marc Schier 724ae71f8a Make NodeId assignment during a node copy explicit (#4157)
Fixes #4137.

## The problem

`NodeState.Initialize(ISystemContext, NodeState)` copies a node. Every
child it materialises through `CreateChild` gets a per-instance NodeId
from `ISystemContext.NodeIdFactory`, and is then initialised from its
source on the very next statement - which overwrites that NodeId. The
assignment is therefore *always* discarded: it only burns identifiers,
and permanently leaks them for factories that track outstanding
allocations. Booting the Robotics node manager left ten reservations
behind that every later allocation skipped.

#4123 fixed the symptom by hiding the factory for the duration of the
copy, wrapping the context in one that reports `NodeIdFactory` as
`null`. The review objection was fair: a context that lies about one
property is hard to reason about, and it only works because that
property is allowed to be null.

## What this does

States the intent instead of faking the environment. NodeId assignment
is now an argument of the two members that materialise children:

```csharp
public virtual BaseInstanceState? CreateChild(
    ISystemContext context,
    QualifiedName browseName,
    bool assignInstanceNodeIds = true);

protected virtual BaseInstanceState? FindChild(
    ISystemContext context,
    QualifiedName browseName,
    bool createOrReplace,
    BaseInstanceState? replacement,
    bool assignInstanceNodeIds = true);
```

A copy passes `assignInstanceNodeIds: false` and every node type -
generated or hand-written - sees the real context. There is no
capability property, no wrapper, and no second overload to keep in sync.

`MethodState`, `BaseDataVariableState` and the source generator template
each collapse to a single `FindChild` override that threads the argument
into its `CreateOrReplace<Child>` helpers.

## Breaking change

The four argument `FindChild` and the two argument `CreateChild`
virtuals are gone.

- **Call sites are unaffected.** The new parameter defaults to `true`,
so `CreateChild(context, browseName)` and `FindChild(context,
browseName, true, null)` keep compiling and keep the 1.5.378 behaviour.
- **Overrides must add the parameter.** A node manager that overrides
either member gets `CS0115` until it does. The compiler points at every
site; `docs/migrate/2.0.x/node-states.md` shows the before/after.

This is deliberate, per review: the stack ships all base types, and
types generated from a model are regenerated for 2.0 anyway.

## Also fixed

`MethodState.FindChild` returned `OutputArguments` when asked for
`InputArguments` with `createOrReplace: false` - a pre-existing bug in
the method being restructured. Covered by
`FindChildReturnsTheRequestedArgumentsProperty`.

## Validation

- Full `dotnet build UA.slnx`: **0 errors, 0 warnings**.
- `Opc.Ua.Types.Tests`, `Opc.Ua.SourceGeneration.Core.Tests`,
`Opc.Ua.SourceGeneration.Tests`, `Opc.Ua.Di.Tests`,
`Opc.Ua.Robotics.Tests` - all passing.
- Each new regression test was checked to fail without its corresponding
fix.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Copilot-Session: 8cbb8cd0-f0cb-4ab0-bea2-6202fbf69485
Copilot-Session: f45109d9-59cb-4ffd-a4f7-16e1ea449ae4
2026-08-03 20:36:32 +00:00
Marc Schier 76d33cb139 Remove SourceGenerator.Foundations, ship analyzers under Roslyn-versioned folders for VS2022 and VS2026, and validate the packages (#4120)
# Description

The two OPC UA source generators were built on
`SourceGenerator.Foundations` (SGF), whose only job was getting the
generator's dependency closure loaded inside the compiler. It does so
with a **process-wide, first-generator-wins `AppDomain.AssemblyResolve`
hook backed by a non-concurrent `Dictionary` mutated from `AssemblyLoad`
callbacks**. Concurrent compilations in a shared `VBCSCompiler` race it,
which is the root cause of the intermittent clean-build failure:

```
CSC : error CS8784: Generator 'StackSourceGeneratorHoist' failed to initialize. ...
Exception was of type 'FileNotFoundException' with message 'Could not load file or assembly
'SourceGenerator.Foundations.Contracts, Version=2.0.14.0, ...'
```

The repository already solves that problem deterministically — #4041
made the NuGet packages ship the full closure under
`analyzers/dotnet/.../cs`, and `Directory.Build.targets` registers the
same closure as `Analyzer` items for in-repo `ProjectReference`
consumers. SGF was therefore pure liability and is removed.

## What changed

**1. SourceGenerator.Foundations removed.** `ModelSourceGenerator` and
`StackSourceGenerator` are plain `[Generator(LanguageNames.CSharp)]
IIncrementalGenerator` implementations again. SGF's implicit crash
handling is replaced by an explicit `SourceGenerator.Guard` that reports
`MODELGEN003` / `STACKGEN003` and rethrows cancellation. Its logger sink
was already dead code and is dropped.
`Opc.Ua.SourceGeneration.Stack.dll` shrank from **18 MB** (embedded
closure) to **29 KB**.

**2. Analyzers now ship under Roslyn-versioned folders.**
`analyzers/dotnet/roslyn4.14/cs` and `analyzers/dotnet/roslyn5.0/cs`
instead of `analyzers/dotnet/cs`, driven by `roslyn.props` at the
repository root, with new `*.Pack` projects owning the package identity.
The versioned folder matters: a host older than the band **skips** the
analyzer instead of loading it and failing inside generator
initialization.

**3. Dependencies flow automatically.** Both delivery paths discover the
payload by globbing the generator's output directory, so adding a
package to a generator's dependency tree requires **no** `.csproj`,
`.targets`, `.nuspec` or validation-script edit. Verified by temporarily
adding a `PackageReference` and watching it appear in the payload with
no other file touched.

## Two Roslyn bands: VS2022 and VS2026

Analyzers ship twice, once per band. The .NET SDK loads the highest band
its compiler supports and **silently skips** anything above it, so a
single-band package leaves every consumer below that band with no
diagnostics and no generated code.

| Band | Built against | Loaded by |
| --- | --- | --- |
| `roslyn4.14` | `Microsoft.CodeAnalysis.CSharp 4.14.0` | Visual Studio
2022 17.14+ / .NET 9 SDK |
| `roslyn5.0` | `Microsoft.CodeAnalysis.CSharp 5.0.0` | Visual Studio
2026 18.0+ / .NET 10 SDK |

Getting this wrong is invisible, which is most of why it took three
attempts. The rule:

> An analyzer must reference the **lowest**
`System.Collections.Immutable` / `System.Reflection.Metadata` across all
hosts it claims to support, and must never ship them. .NET binds a
reference *upward* but never downward.

Every failure mode is a compiler **warning**:

| Diagnostic | Cause |
| --- | --- |
| `CS9057` | Built against a newer Roslyn than the host, so it is
skipped entirely |
| `CS8784` | Loaded, then `MissingMethodException` - a shipped S.C.I
bound a second `ImmutableArray<T>` |
| `CS8032` | Analyzer instance could not be created |

This PR first shipped a `roslyn4.8` band that **never loaded** -
confirmed against a real Roslyn 4.8 compiler:

```
warning CS8784: Generator 'ModelSourceGenerator' failed to initialize ...
MissingMethodException: Method not found:
'IncrementalValueProvider`1<ImmutableArray`1<!!0>>
 IncrementalValueProviderExtensions.Collect(IncrementalValuesProvider`1<!!0>)'
```

That band was removed, and **this is not a regression**: master had the
same defect, pinned to Roslyn 4.14 while referencing
`System.Collections.Immutable 10.0.0.0`, so no 4.x host ever worked
there either.

The band that landed instead is `4.14`, and it is nearly free:
`Microsoft.CodeAnalysis 4.14` depends on the **same S.C.I 9.0.0 as
5.0.0** and its `csc` runs on `net9.0`. Both bands therefore share
`$(RoslynRuntimeVersion)`, so `Opc.Ua.Types` and
`Opc.Ua.SourceGeneration.Core` are compiled **once** and serve both;
only the five Roslyn-referencing assemblies get a second build, each
from a 12-line project that glob-links the primary sources and overrides
`RoslynApiVersion`. Going lower still - 4.8 wants S.C.I 7.x - would mean
rebuilding that whole closure, `Opc.Ua.Types` included, and remains out
of scope. (`System.Collections.Immutable 7.0.0` also does not resolve in
this repository: `NU1603`, 8.0.0 substituted.)

**Nothing else in the repository exercises the down-level payload**, and
since every failure is a warning, a broken band would ship silently -
exactly as the 4.8 one did. So `validate-source-generator-packages.ps1`
now restores a real `Microsoft.Net.Compilers.Toolset 4.14.0`, runs its
`csc.dll` over the packed `roslyn4.14` payload, and asserts **both**
that none of `CS9057`/`CS8784`/`CS8032`/`CS8034` is emitted **and** that
`/reportanalyzer` lists the generator as having executed - silence alone
would also pass if the generator were never handed to the compiler. The
migration analyzer, whose payload comes from a hand-written nuspec
rather than `SourceGeneratorPack.targets`, gets the same check plus a
band/assembly manifest assertion.

To stop the trap recurring, `Microsoft.CodeAnalysis*`,
`System.Collections.Immutable` and `System.Reflection.Metadata` are
excluded from both the package payload and the in-repo analyzer closure,
and asserted against in the validation script. `roslyn.props` and
`docs/DeveloperGuide.md` document what adding a band requires.
## The shipped package set is unchanged

Evaluating `PackageId`/`IsPackable` across every project in
`preview-pack.slnx` gives the **same 47 package ids** on `master` and on
this branch, and a full Release build plus `dotnet pack --no-build`
produces exactly those 47. Only the owning project changed:

| Package | master | this PR |
| --- | --- | --- |
| `…Opc.Ua.SourceGeneration` | `Opc.Ua.SourceGeneration.csproj` |
`Opc.Ua.SourceGeneration.Pack.csproj` |
| `…Opc.Ua.SourceGeneration.Stack` |
`Opc.Ua.SourceGeneration.Stack.csproj` |
`Opc.Ua.SourceGeneration.Stack.Pack.csproj` |

`.azurepipelines/expected-packages.txt` pins the expected set and the
validation script fails when the packed output drifts.

## Two new package checks

**`build/<PackageId>.props` naming.** NuGet only auto-imports that exact
name, and the file declares every `CompilerVisibleProperty` /
`CompilerVisibleItemMetadata` the model generator relies on. It was
previously packed as
`build/OPCFoundation.Opc.Ua.SourceGeneration.props`, which does **not**
match the package id — so **no NuGet consumer ever imported it**, and
every `ModelSourceGenerator*` property and `AdditionalFiles` metadata
was silently invisible. In-repo projects were unaffected because they
import it by relative path.

**An end-to-end consumer that actually drives the generator.** The
pre-existing clean-consumer check only proved the analyzer *loads*.
`Test-SourceGeneratingConsumer` builds a standalone project that feeds a
real NodeSet2 in as an `AdditionalFile`, pins a
`ModelSourceGeneratorPrefix` that cannot be derived from the model, and
references the emitted types from hand-written code. It deliberately
does **not** import the props by path, so the package's auto-import is
what is under test. Both checks were verified to fail against a package
rebuilt with the misnamed props.

## CI: parallelism re-enabled

`buildandtest.yml` pinned three `dotnet build` invocations to
`/p:UseSharedCompilation=false` and `-maxcpucount:1`. `dotnet build`
defaults to all logical processors, so `-maxcpucount:1` actively
disabled parallelism. Both flags were introduced silently in unrelated
PRs (`f358078b1`, `208c601b0`) and suppress exactly the conditions the
SGF race needed — now structurally gone. Compilation is ~72 % of what
that workflow executes (233 of 323 job-minutes in run `29916768976`), so
the queue-independent `Build` step durations are the baseline:

| Measurement | Baseline |
| --- | --- |
| `Build` step, 26 `test-*` jobs | mean 5.53 min, sum 143.9 min |
| `build-linux-all-tfm` | 56.3 min |
| `build-windows-all-tfm` | 32.5 min |

`codeql-analysis.yml` keeps the flag — the CodeQL tracer requires it.

## Incidental fixes

- `Microsoft.CodeAnalysis.CSharp` declares an exact `[version]` range on
`Microsoft.CodeAnalysis.Common`, so with
`CentralPackageTransitivePinningEnabled` the central pins must move in
lockstep or restore fails with `NU1109`. They now derive from
`$(RoslynApiVersionVS2026)`.
- `MigrationGeneratorTests` created its generator driver without parse
options — silently fine under Roslyn 4.14, `Inconsistent language
versions` under 5.0.
- The migration analyzer props injected `<Analyzer>` items by hand from
`analyzers/dotnet/cs`; NuGet registers analyzers itself and selects the
matching folder, so the injection was obsolete and would now force the
wrong assembly to load.
- Exception diagnostics reported `ex.StackTrace` (null for an exception
that never left its throw site, and carrying neither type nor inner
chain) — now `ex.ToString()` at all three call sites.
- The pack guard tested the flattened assembly list, so it only fired
when *no* variant produced output; it now checks each declared variant
contributed assemblies.
- `GeneratePackageOnBuild` is disabled for the generators; packing
happens in CI or on an explicit `dotnet pack`.

## Note for reviewers

**Building this repository requires a Roslyn 5.x host** (the .NET 10 SDK
or Visual Studio 2026). That is already the documented prerequisite and
every CI leg installs `10.0.x` last, but it is now a hard requirement.

## Related Issues

_No tracking issue: this is a follow-up to the CS8784 clean-build
failure reported against #4041, which fixed the packaged delivery path
but left the in-repo project-reference path relying on the SGF
resolver._

- Follow-up to #4041

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

### Verification detail

| Check | Result |
| --- | --- |
| `dotnet build UA.slnx` (clean, and after merging master) | 0 errors;
18 warnings, all pre-existing `CA1873` in `Opc.Ua.Client` |
| `Opc.Ua.SourceGeneration.Tests` | 81/81 on net10.0 and net48 |
| `Opc.Ua.SourceGeneration.Stack.Tests` | 92/92 on net10.0, 89/89 on
net48 |
| `Opc.Ua.SourceGeneration.Core.Tests` | 3722 passed, 8 skipped |
| `Opc.Ua.MigrationAnalyzer.Tests` | 121/121 on net10.0 and net48 |
| Package id set, master vs branch | identical, 47 packages |
| Release build + `dotnet pack --no-build` | 47 packages, matching the
manifest; both `roslyn4.14` and `roslyn5.0` present; no host-provided
assemblies shipped |
| `validate-source-generator-packages.ps1` | passes, including manifest,
props-naming, end-to-end generator consumer and down-level host checks |
| Down-level band under real csc 4.14.0 | all three analyzer packages
load **and run** |
| Negative tests | misnamed props → both checks fail as designed; hidden
variant output → pack guard fires; 5.0 build placed in `roslyn4.14` →
real `CS9057`; non-running generator name → execution assertion fires;
expecting a `roslyn4.8` band → manifest check fires |

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9ed53262-5cfa-429a-820e-bf6146a00d8b
2026-07-31 15:46:17 +00:00
Marc Schier 0383472ebc Fix PubSub occurrence publishing and JSON DataValue status encoding (#4106)
### Summary

Three related gaps mean no `PublishedDataSet` can produce a message that
is not a key frame or a derived delta frame.
`PubSubDataSetMessageType.Event` and the `ua-event` wire tag of Part 14
§7.2.5.4 are modelled but unreachable.

This PR also corrects the JSON `DataValue` status member to the Part 6
Table 42 name `Status`.

### 1. A dataset cannot declare what its sample is

`PublishedDataSetSnapshot` carries a metadata version, fields and a
timestamp. `WriterGroup.BuildDataSetMessageAsync` therefore decides the
message kind alone: `KeyFrameCount <= 1` forces `KeyFrame`, otherwise it
derives a delta by comparing the snapshot **positionally** against the
previous one.

That derivation assumes successive snapshots describe the same fields in
the same order, which holds only for a source returning its complete
current state every cycle. A source whose samples are *occurrences* — an
event, a condition snapshot, or a delta it has already computed — cannot
satisfy it: consecutive samples carry different fields, so field *i* of
one occurrence is compared against field *i* of an unrelated earlier
one, and `Math.Min` silently truncates when the counts differ.

`PublishedDataSetSnapshot` now carries an optional `MessageType`. When
set, the writer publishes exactly the supplied fields under the declared
type and leaves its key-frame state untouched. When null — the default,
and every existing caller — behaviour is unchanged.

### 2. The event writer was unreachable

`EventDataSetWriter` and `EventPublishedDataSet` implement the Part 14
§6.2.4 event model correctly, but nothing could use them:

- `EventPublishedDataSet` did not implement `IPublishedDataSet`, so
`PubSubApplication` could not register an event dataset;
- nothing in the library constructed `EventDataSetWriter`, and it was
not an `IDataSetWriter`;
- `WriterGroup` appends at most one message per writer per cycle,
whereas an event writer produces one per occurrence.

Now: `EventPublishedDataSet` is an `IPublishedDataSet` whose general
`SampleAsync` drains one occurrence per cycle declared as an event;
`EventDataSetWriter` is an `IDataSetWriter`; `PubSubApplication` builds
an event dataset when the configured `DataSetSource` is a
`PublishedEventsDataType` and the registered source is an
`IEventSampler`; and a writer group drains every pending occurrence in
one publish cycle. Event-writer sequence numbers now use the WriterGroup
checkpoint state, so failover and restart resume above the shared
checkpoint.

The rows-returning `EventPublishedDataSet.SampleAsync` is renamed
`SampleEventsAsync`, both to make room for the interface member and to
match `IEventSampler.SampleEventsAsync`.

### 3. A metadata announcement hides its own payload

`JsonMetaDataMessage` documents that the payload *"is exposed both on
the base `PubSubNetworkMessage.MetaData` property and on
`MetaDataPayload` so callers can use whichever accessor matches their
fluent style."* `MetaDataPublisher` set only `MetaDataPayload`, so the
documented contract was false: any consumer, transform or encoder hook
reading the base property saw an announcement as a message carrying
nothing. Both are now populated. Dataset subscriptions also use a
dispose-safe subscribe/recheck handshake, preventing a concurrent
shutdown from leaving a late handler attached.

### 4. JSON DataValue status used the wrong member name

Part 6 section 5.4.2.18 Table 42 names the `DataValue` status member
`Status`; `StatusCode` is its data type. `JsonEncoder` and `JsonDecoder`
instead shared the member name `StatusCode`, so stack-to-stack
round-trips passed while standards-conformant peers could not exchange
non-Good status values.

Both directions now use `Status`. The encoder regression test also
verifies that `StatusCode` is absent from the emitted `DataValue`
object.

Fixes #4111.

### Compatibility

The snapshot property is optional and defaults to null, so existing
PubSub sources and current-state sampling behavior remain unchanged. The
`EventPublishedDataSet.SampleAsync` rename is source-breaking on a type
that could not previously be reached from the runtime.

The JSON `DataValue` wire member intentionally changes from `StatusCode`
to the specification-defined `Status`. No legacy alias is retained: the
encoder emits `Status`, and the decoder expects `Status`.

### Testing
`Opc.Ua.PubSub.Tests` on `net10.0`: 1341 passed, 1 skipped, 0 failed.

New coverage:
- `WriterGroupDeclaredMessageTypeTests` — a declared type is honoured,
declared samples never become delta frames and are never suppressed as
unchanged, and an undeclared source keeps the existing key-frame
cadence.
-
`EventDataSetWriterTests.EventPublishedDataSet_SamplesOneOccurrencePerSnapshotAsync`
— the general contract drains one occurrence per call, declares it as an
event, and reports nothing when exhausted.
-
`WriterGroupEventWriterHotStandbyContinuesSequenceNumberFromSharedCheckpointAsync`
— event messages share the checkpointed sequence state and continue
monotonically after standby promotion.

Issue #4111 validation:
- `Opc.Ua.Types.Tests` JSON encoder/decoder fixtures on `net10.0`: 274
passed.
- `Opc.Ua.Core.Encoders.Tests` JSON conformance cases on `net10.0`: 604
passed.
- `Opc.Ua.PubSub.Tests` JSON fixtures on `net10.0`: 88 passed.
- Targeted `net48` regression tests: 2 passed; core JSON conformance
cases: 572 passed.
- Review-feedback fixtures on `net48`: 11 passed.
CI-fix validation:
- `Opc.Ua.Core.Tests` on `net10.0`: 4145 passed, 86 skipped.
- `Opc.Ua.Core.Tests` on `net48`: 4070 passed, 92 skipped.
- Patch coverage check passes after adding targeted metadata and event
publishing tests.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1a300bc8-8bde-4a82-8fc3-476ef837bc6a
Copilot-Session: 9f18bf87-795f-40a9-8494-b6e6334c4a97
2026-07-29 22:06:52 +02:00
Marc Schier 275c3800b5 [Server] Add live NodeManager lifecycle support (#4094)
# Description

Adds supported live add, reload, and remove lifecycle operations for
NodeManager instances on a running server.

The change introduces a DI-backed `INodeManagerLifecycle` service with
direct `StandardServer` access, generation-aware registration handles,
copy-on-write routing, transactional prepare/commit/rollback, runtime
NodeSet convenience APIs, append-only namespace updates, model and
semantic change notifications, additive-compatible complex type priming,
and complete teardown behavior.

Lifecycle publication is coordinated with requests, sessions,
subscriptions, monitored items, continuation points, and shutdown so
retired managers cannot remain reachable or be disposed while still in
use. Compatible monitored items transfer to replacement generations
without a transient bad status. Removed or incompatible Nodes publish
`BadNodeIdUnknown` as required by OPC UA Part 4 section 5.8.4.1, retain
their MonitoredItem identity, and automatically recover when a
compatible Node with the same NodeId returns.

Registration is documented end to end in the new
[docs/NodeManagerRegistration.md](docs/NodeManagerRegistration.md),
which covers compile-time, startup, and runtime registration together
with the guarantees for MonitoredItems, Browse continuation points,
namespace indexes, DataTypes, and change notifications.
## Review feedback addressed

Interface surface reduced, as requested:

- `INodeManagerMonitoredItemTracker` folded into
`ISubscriptionMonitoredItemLifecycle`.
- `INodeManagerMutationCoordinator` and the asynchronous MonitoredItem
recovery entry point folded into `IDynamicNodeManagerHost`.
- `ISessionClosingRegistry` folded into `IServerInternal`, then replaced
by `ISession.IsClosing`.
- `ISubscriptionDeletionRegistry` folded into `ISubscriptionManager`,
then removed entirely.
- `INodeManagerContinuationPointTracker` folded into `ISession`.
- The synchronous recovery entry point kept on its own
`ISyncNodeManagerMonitoredItemRecovery`, because `AddPredefinedNode`
cannot await; it still routes through the asynchronous
`INodeManagerMonitoredItemLifecycle` that `AsyncNodeManagerAdapter`
exposes.
- `IMonitoredItemLifecycle` renamed to `IDetachableMonitoredItem`.

Reach into the Subscription area reduced to the minimum the feature
needs:

- The lifecycle feature itself leaves `SubscriptionManager` almost
untouched: two guards that reject a request from a Session that is
closing, on `CreateSubscription` and on `TransferSubscriptions`, because
it would create state that is about to be torn down. The deletion claim
registry, the session closing gates, the closed-session bookkeeping and
the deletion restore paths introduced by earlier revisions are gone.
Transferring a subscription away from a session that is closing stays
allowed, since that is the recovery path the service exists for; the
race that exposed is fixed by serialising source-queue closure with the
transfer rather than by rejecting it.
- Queueing is disabled again for a MonitoredItem with `QueueSize <= 1`,
as before this PR. Without a queue the Client is served the last sampled
value, so the missing-Node notification simply becomes that value;
losing values is expected for a MonitoredItem without queuing.
- What remains of the notification is one `DataValue` plus one bool in
`DataChangeQueueHandler`, mirroring the existing overflow pair, which
stops it being the value discarded when a configured queue is full.
- `MonitoredItem` keeps its original publish path, its original queue
creation and restore paths, its original triggering behaviour, and no
per-epoch bookkeeping.
- `IStoredMonitoredItem` carries `IsDeleted` and `IsDetached`, so a
restored item reports the state it was stored with rather than one
inferred from the last value's StatusCode.

Other feedback:

- Documented `HostedNodeManagerLifecycle`, `IDynamicNodeManagerHost`,
`NodeManagerRoutingTable`, and the `RequestManager` validation and
request scopes.
- Renamed `ValidateMonitoredItemAsync` to `CanAttachMonitoredItemAsync`
and `RestoreMonitoredItemAsync` to `RecoverMonitoredItemAsync`, so the
lifecycle API no longer collides with the durable Subscription
`RestoreMonitoredItem` path used at server startup.
- Removed the `CustomNodeManager2` dependency from the synchronous
MonitoredItem recovery path.
- `DataChangeQueueHandler` is back to the two constructors 1.5.378
shipped, each taking an optional `TimeProvider`.
- Session closing is a one-way latch, and the Session is removed even
when teardown throws.
- Documented what a custom NodeManager has to implement to take part in
live lifecycle operations, and which NodeManager implements
`INodeManagerReloadParticipant` today.

## Fixes found during review

- The guard that stops a NodeManager lifecycle operation being started
from inside a service handler never fired. It was backed by an
`AsyncLocal` written inside `async ValidateRequestAsync`, which is
invisible to the caller that awaited it, so a lifecycle call from a
NodeManager callback waited for its own request and held the lifecycle
semaphore forever. The mark is now applied by the service dispatcher,
and the drain is bounded so no path can wedge the lifecycle permanently.
- `ValidateRequestAsync` is no longer virtual. An override that rejected
a request after calling `base` left it registered forever; subclasses
now use `OnRequestValidatedAsync`, whose failures complete the request.
- A MonitoredItem deleted and disposed while a reload was handing it to
the replacement NodeManager could be attached anyway. The item now
reserves itself for the hand-over and the transition takes it back if it
was disposed meanwhile.
- The Overflow bit is recorded and applied when the value is published,
as the surrounding design does, rather than written into the value at
enqueue time.
- MonitoredItem cleanup for a Subscription being deleted no longer
passes null entries to `DeleteMonitoredItemsAsync`.
- Browse continuation points of a retired NodeManager are persisted and
disposed outside the Session lock.

## Open review discussions

Three threads are open, replied to and awaiting reviewer decisions:
separating service-call dispatch from NodeManager handling in
`MasterNodeManager` (tracked as #4098), the request-scoping shape in
`StandardServer`, and how much of the async MonitoredItem service
implementation should stay in `Subscription`.

Issue #4102 records a specification ambiguity found while implementing
the missing-Node notification: Part 4 §5.8.4.1 makes it mandatory, but
the protected over-capacity queue entry §5.13.1.5 defines applies to
`EventQueueOverflowEventType` only, so the specification does not say
how it survives a full queue.

## Validation

- Full `Opc.Ua.Server.Tests` on net10.0: 3,768 passed, 5 skipped.
- Lifecycle, MonitoredItem, Subscription and continuation point suites:
185 passed on net10.0 and 75 on net48.
- Server builds complete with zero warnings or errors.
- Two failures are pre-existing and unrelated:
`DurableDataValueQueueVerifyReferenceBatchingAsync` (flaky
`BatchPersistor` file contention, passes in isolation on net10.0 and
net48) and
`ConfigureApplicationBuildsSharedClientAndServerConfigurationAsync`
(fails on a clean tree with these changes stashed).

## Related Issues

- Fixes #3993
- Follow-up tracked in #4098
2026-07-28 16:06:28 +02:00
Marc Schier fde50c5700 Enforce ActivateSession transfer and reconnect security (OPC 10000-4 §5.7.3.1) (#4050)
# Description

This draft isolates the Session/ActivateSession, reconnect, and
distributed Session security work split from #4021.

### Failure

- `ActivateSession` could transfer a Session to a new SecureChannel
without consistently enforcing the original client certificate,
MessageSecurityMode, SecurityPolicy, and exact OPC `ClientUserId`
semantics.
- Missing or legacy distributed Session security state did not provide
enough information to validate transfer safely.
- Malformed or reused nonces, concurrent activation, and cancellation
could leave activation state inconsistent.
- Client reconnect could treat recoverable Session-loss/security
responses as fatal, reuse a Session after client-certificate rotation,
or report a managed channel ready before Session recreation completed.

### Fix

- Serialize activation, consume server nonces once, validate application
signatures and user tokens asynchronously, and enforce transfer
continuity against the original channel and authenticated identity.
- Persist versioned transfer-security state for distributed Sessions and
fail closed when certificate, identity, activation, or nonce state is
absent or invalid.
- Validate server signatures/nonces on the client, recreate Sessions for
certificate rotation and recoverable activation failures, and await
participant recreation before transitioning the managed channel to
`Ready`.
- Make the restart recovery integration test use bounded reconnect
cycles instead of a fixed startup delay.

### Specification

- OPC 10000-4 §5.7.2.2 — CreateSession signatures and nonces.
- OPC 10000-4 §5.7.3.1 — ActivateSession identity continuity and
SecureChannel transfer.
- OPC 10000-4 §6.1.8 — application nonce generation, validation, and
reuse requirements.

### Validation

- `git diff --check`
- Release `net10.0`: `Opc.Ua.Redundancy.Server.Tests` 567/567 plus the
targeted server Session, Subscription transfer, ClientUserId, client
reconnect and certificate rotation suites pass.
- Release `net48`: the same redundancy suite (567/567) and the targeted
Session/Subscription suites pass.
- Full `Opc.Ua.Server.Tests` run on `net10.0`: 3633/3634. The single
failure,
`ServerFluentApiHostingTests.ConfigureApplicationBuildsSharedClientAndServerConfigurationAsync`,
reproduces identically on the unmodified commit and is a local
certificate-store path issue rather than a regression.

These were targeted validation runs; no long-soak result is claimed.
2026-07-28 12:07:02 +02:00
Copilot a03bca97cb Remove TraceConfiguration and related APIs (#4092)
# Description

This change removes the remaining `TraceConfiguration`-driven
apply/setter API path and aligns configuration startup with the 2.0
telemetry model (`ITelemetryContext`/`ILoggerFactory`). It also updates
migration docs to make the removal explicit and actionable.

- **Core API removals**
  - Deleted `TraceConfiguration.ApplySettings()` implementation file.
  - Removed runtime apply calls from configuration/server startup paths.
  - Removed fluent builder trace setters:
    - `SetOutputFilePath(...)`
    - `SetDeleteOnLoad(...)`
    - `SetTraceMasks(...)`
  - Simplified builder interface inheritance accordingly.

- **Call-site and test fixture updates**
- Removed usage of deleted trace fluent APIs in affected test
infrastructure and fixtures.
- Updated GDS log readers to tolerate missing `TraceConfiguration`
output path safely.

- **Migration guidance**
- Updated migration docs to explicitly state removal of
`TraceConfiguration` apply APIs and direct users to telemetry-based
logging configuration.

```csharp
// Removed API pattern
app.Build(uri, product)
   .AsClient()
   .SetOutputFilePath("trace.log")
   .SetTraceMasks(Utils.TraceMasks.Error);

// New direction
// Configure logging via ITelemetryContext / ILoggerFactory at composition/startup.
```

## Related Issues

- OPCFoundation/UA-.NETStandard#4091

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

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

- Fixes #4091

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-07-26 00:50:59 +02:00
Marc Schier 5efba99dbd Replace legacy EventSource providers with source-generated logging (#4058)
# Description

Replace the four stack-owned `System.Diagnostics.Tracing.EventSource`
providers with source-generated `ILogger` logging.

- Preserve the legacy provider category names, numeric event IDs, event
names, levels, message structure, and semantic structured fields.
- Remove `ClientTraceFlags.EventLog` and route `ClientTraceFlags.Log`
through the canonical `OPC-UA-Core` compatibility events.
- Keep ChannelManager activities and metrics while moving its diagnostic
events to the `Opc.Ua.ChannelManager` logger category.
- Replace EventListener-based unit and stress-test collection with
structured logger providers.
- Document the ETW removal, retained compatibility identities, and
migration path for existing consumers.

## Validation

- `dotnet build UA.slnx -c Release -p:CustomTestTarget=net10.0
-p:NuGetAudit=false`
- Focused Core, Client, and Server logging tests on `net10.0` and
`net48`
- Stress harness Release build and
`SessionRecoversFromAcceptButStallAsync`
- Direct AOT test application: 115 tests passed
- `dotnet test UA.slnx -c Release -p:CustomTestTarget=net48
-p:NuGetAudit=false`

The full `net10.0` `dotnet test UA.slnx` invocation is currently blocked
by the repository's existing mixed VSTest/Microsoft.Testing.Platform
configuration in `Opc.Ua.Aot.Tests`; the AOT project builds and its test
executable passes directly.

## Related Issues

- Related to #1262

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: b0d90b8a-cde9-4fca-9a75-84f4cecb227d
2026-07-23 21:53:31 +02:00
Marc Schier 2a4bc0fe24 Make reverse connect lifecycle fully asynchronous (#4059)
## Summary

- add cancellation-aware asynchronous start, lazy initialization, stop,
reload, and disposal for `ReverseConnectManager`
- make listener replacement transactional with explicit ownership,
rollback/restoration, stale watcher protection, and deterministic
concurrent start/stop/dispose behavior
- add injectable async configuration providers, remove the former
protected configuration hooks, and retain obsolete synchronous lifecycle
compatibility wrappers
- integrate eager Generic Host startup plus non-host lazy startup,
including custom application-configuration providers
- serialize `ReverseConnectHost` lifecycle and extend raw TCP, Kestrel
TCP, WSS, hosting, concurrency, rollback, and NativeAOT coverage
- document the async lifecycle, hosting behavior, cancellation
guarantees, provider model, and migration path

## Validation

- `Opc.Ua.Client` Release build across all supported target frameworks
- reverse-connect client and core tests on net10.0 and net48
- raw TCP, Kestrel TCP, and WSS integration tests on net10.0; raw TCP on
net48
- NativeAOT publish and 115-test executable run

Closes #4013.

Related: #3985, #4009.
2026-07-23 10:40:32 +02:00
Marc Schier 20d9d65a64 Fix generated configuration namespace resolution (#4019)
# Description

Fix source-generated configuration types whose explicit namespace
references constants emitted by the same generator pass. Roslyn cannot
resolve those constants while `[DataType]` attributes are analyzed, so
the generated XML codec silently fell back to `urn:memorybuffer` /
`urn:testdata` while the shipped configuration uses the model namespace
URIs.

This change:

- adds `MODELGEN021` to fail when an explicitly supplied
`[DataType.Namespace]` expression cannot be resolved;
- uses compile-time-available model namespace URIs for the MemoryBuffer
and TestData configuration types;
- restores production-shaped XML fixtures and exercises the
`UpdateExtension<T>()` / `ParseExtension<T>()` path;
- documents the source-generator namespace requirement and migration
guidance.

Targeted validation passed:

- `TypeGeneratorTests`: 17 tests on `net10.0` and `net48`;
- `ConfigurationRoundTripTests`: 4 tests on `net10.0` and `net48`.

## Related Issues

- Fixes #4014

## 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.
2026-07-18 20:15:49 +02:00
Marc Schier 84fa8e8906 CTT follow-up: monitored items, alarms, codecs, history, and model validation (#4002)
# Description

Follow-up to #3978 based on the latest OPC UA CTT aggregate, matrix,
Historical Access, Attribute, and Node Management runs.

## Aggregate run 16

- Ignore synthetic `BadBoundNotFound` raw-bound markers when feeding
aggregate calculators.
- Add direct and live historian regression coverage.

## Multi-dimensional Variant matrices

- Fix `Variant.Copy()` so rank-2 Variant matrices preserve their payload
and dimensions.
- Enforce valid Variant matrix dimensions consistently across Binary,
JSON, XML stream, and XML parser codecs.
- Encoders return `BadEncodingError`; decoders return `BadDecodingError`
for malformed matrix dimensions.
- Add cross-codec valid/null/empty/malformed tests and a batched
ReferenceServer Read regression.
- Correct the previous CTT issue attribution: the invalid
`Scalar_Static_Arrays2D_Variant` server encoding poisoned the batched
CTT Read.

## Node Management AddNodes

- Return `BadNodeIdRejected` for unsupported requested-NodeId
namespaces.
- Validate hierarchical ReferenceType source/target NodeClass
constraints.
- Document run-18 confirmation that all remaining 15 AddNodes errors are
CTT project/script configuration issues.

## Historical Access and Attribute Services run 18

- Reject whitespace in `NumericRange` syntax with
`BadIndexRangeInvalid`.
- Add HistoryRead tests for operation-level invalid syntax and
per-DataValue `BadIndexRangeNoData`.
- Document CTT defects:
- `012.js` expects `BadIndexRangeNoData` at the operation level instead
of on returned DataValues;
  - `Err-012.js` uses a non-historizing node for an access-denied test;
- generic Attribute array helpers omit NodeId[] and StatusCode[]
conversion paths.

## Base Information model validation run 19

- Run the CTT main session as `sysadmin` over Basic256Sha256
SignAndEncrypt so SecurityAdmin RolePermissions and EncryptionRequired
restrictions are both satisfied without weakening the server model.
- Preserve predefined well-known NodeIds while making generated dynamic
type and optional-child factories allocate compatible per-instance
NodeIds, remap internal references, and update references from the
owning subtree.
- Materialize dynamic RoleType properties through generated helpers and
keep role-child string NodeIds disjoint from numeric RoleManager root
IDs.
- Add net48/net10 regressions for generator output, NodeId
allocation/reference remapping, DI mandatory children, dynamic roles,
and low-privilege versus SecurityAdmin Browse visibility.
- Document CTT defects for permission-unaware mandatory-child
validation, the UA 1.04 reference model used against a UA 1.05 server,
stale ServerDiagnostics cache, and the `ConformanceUnits` scalar/array
mismatch.
## Related Issues

- Follow-up to #3960 and #3978.

## Checklist

- [x] I have signed the CLA and read the CONTRIBUTING document.
- [x] Tests prove the fixes and improve coverage.
- [x] Required CTT documentation is updated.
- [x] Focused builds/tests introduce no warnings.
- [x] Affected tests pass on .NET Framework and .NET 10.
- [ ] CI and CodeQL are green.
- [ ] All PR feedback is addressed.



## Latest CTT full run and A&C run 20

- Fix Monitor Value Change V2 `036.js`: an ambiguous
`DataValue(StatusCode)` call encoded `BadIndexRangeNoData` as a Good
value.
- Prove all 19 configured matrix monitored items notify when the
selected value actually changes; document the independent `042.js`
script defects.
- Rebase dynamic `CertificateExpired` / `TrustListOutOfDate` alarm roots
and descendants, restore standard type declarations and ModellingRules,
and expose the cross-node-manager references.
- Deliver method-triggered events to every authorized Session, fixing
the two-Session Confirm scenario.
- Preserve locale-only `LocalizedText` values consistently across
Binary, JSON, and XML and align Acknowledge/Confirm comment behavior and
tests.
- Document the remaining proven CTT defects for TransactionDiagnostics,
SemanticChange, History `013`, PolicyId scope, A&C, durable
subscriptions, and Subscription Minimum events.

## Base synchronization

Merged `master` through `007cfaf90` (`#4010`, repository layout
consolidation) and retained the matrix regression tests at their new
`tests/` locations.

## Latest validation

- Focused merged-tree tests pass on net10 and net48.
- `Opc.Ua.Server` and `Quickstarts.Servers` Release builds complete with
0 warnings and 0 errors on net10 and net48.
- The existing
`DisableEnableViaTypeAndInstanceMethodNoEventsDuringDisabledAsync`
timing test took its documented inconclusive timeout path after the
layout merge; no test failure was reported.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: romanett <romanett98@gmail.com>
2026-07-18 05:01:03 +02:00
Marc Schier 8538f531e7 Update paths for the consolidated repository layout 2026-07-16 06:26:05 +02:00
Marc Schier 7cec2d614d Move repository files to the consolidated layout 2026-07-16 06:25:54 +02:00