30 Commits

Author SHA1 Message Date
Marc Schier 15d0ce7bd5 Implement OPC UA Part 10 Programs (#4789)
# Description

Implement OPC UA Part 10 Programs across `Opc.Ua.Core`/`Core.Types`,
`Opc.Ua.Server`, and `Opc.Ua.Client`, reusing the existing
state-machine, source-generation, node-management, auditing, and
streaming-subscription infrastructure.

- Add asynchronous provider-backed execution, standard controls,
internal/autonomous transitions, generation checks, and nested-state
activation/resumption.
- Add checked creation/adoption, vendor Create adapters, deletion,
recycling and instance limits, auto-delete, DI, and direct-construction
paths.
- Maintain ProgramDiagnostic2 and retained final results; publish
captured intermediate results and concrete transition/audit events with
access controls.
- Add generated SDK models/proxies, typed client discovery and reads,
event streams, and subscribe-before-call readiness handling.
- Add unit, connected client/server, generated Annex A-style model, and
NativeAOT coverage. Document usage and migration in `docs/Programs.md`
and related guides.

### Compatibility and design decisions

The linked Part 10 document is v1.05.00; namespace-zero definitions
remain aligned with 1.05.07. Documentation distinguishes normative
requirements from conflicting specification text and SDK conventions.

- Correct state numbers to Halted=11, Ready=12, Running=13,
Suspended=14. Generated subtypes retain inherited namespace-zero
state/transition identifiers.
- Managed Reset is Halted-to-Ready only. Preserve the existing low-level
Reset-from-Suspended behavior for compatibility.
- Preserve UInt32 limit types; use `UInt32.MaxValue` as a documented SDK
unlimited convention. RecycleCount overflow reports BadOutOfRange
without stopping unlimited execution.
- In-memory default with injectable providers. Durable recovery,
distributed execution/failover, and a production Annex A download engine
are excluded.

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

Tests and focused coverage evidence are above; the combined
tests/coverage checkbox remains open because a whole-repository coverage
increase has not been established. CLA status, CI/CodeQL results,
tracking-issue approval, and PR feedback are not asserted.
2026-10-15 09:19:25 +02:00
romanett 0dff94bd37 Server quick wins from the o6 benchmark gap analysis: admission sizing, sampling permission cache, routed Read dispatch (#4743)
The o6-automation benchmark gap analysis listed several server quick
wins that need no wire or public API change. This PR implements three of
them. The 2.0 server now passes the benchmark's 10 clients × 1000
outstanding Reads test with the benchmark's own queue sizing. Its 100 Hz
sampling capacity grows about 7–10× on the same machine, and a
single-value Read allocates 8 % less.

## Results

Paired A/B against `master` (071660354): alternating builds, 3 rounds (5
for Read), medians, 24-core machine shared with other sessions.
Allocated bytes and CPU per operation are the reliable signal here.
Throughput varied up to 3× between rounds of the same build, so it is
listed only where the result is pass/fail.

| Scenario | Server alloc / op | Server CPU / op | Result |
|---|---|---|---|
| o6 `sub_client`, 2048 counters, 10 ms sampling, 1 s publishing |
**2309 → 111 B / sample (−95 %)** | **3.75 → 1.14 µs / sample (−70 %)**
| PASS on both |
| Harness `counters`, 2048 items, 10 ms / 1 s | 232 → 11.2 KB /
notification (−95 %) | −74 % | GC pause 644 → 24 ms in 12 s |
| **o6 sampling capacity** (largest count with every item ≥ 99 % of
samples, doubling from 1024, then bisect) | | | **2176–2304 →
16384–22528 items** (2 searches each) |
| o6 `client`, 10 × 1000 outstanding Reads, `MaxQueuedRequestCount =
10000` | | | **master fails 3/3 (BadServerTooBusy), this PR passes 3/3**
(31–36k req/s) |
| o6 `client`, 10 × 100 outstanding Reads | 7823 → 7556 B (−3 %) | ±0 |
|
| Read, 1 value (20 sessions) | 6534 → 6020 B (**−8 %**) | −1 % | |
| Read, 100 values | −1 % | ±0 | |
| Write, 100 values | −1 % | −5 % | |

For reference, open62541 (single thread, same Windows machine and native
client) sustained 5632 items in the gap analysis.

## Changes

### 1. `MaxQueuedRequestCount` admits that many established requests
(Balanced / TrustedReservations)
- `ServerResourceIsolationOptions.CreateRuntimePlan` carved the
bootstrap, reconnect, control and trusted floors out of the configured
queue, so established sessions could queue only `N − 3`. The default
totals of the decoded-request admission stages (RequestQueue,
RequestQueueBytes, ParkedRequest) now add the floors on top; the shared
capacity equals the configured value.
- `FairRequestQueue` takes its physical bound from the provider's stage
capacity (internal `IResourceIsolationCapacitySource`), so the protected
entries stay reachable when the shared part is full.
- RequestExecution keeps its floors inside the worker limit, so an idle
worker remains for protected requests. Explicit `Stages[].Capacity`
overrides keep their meaning as absolute totals including the floors.

### 2. Sampling without a role permission check per sample
- Since #4503 every sampling cycle called `ValidateRolePermissionsAsync`
for every item. For a `CustomNodeManager2` node that built full node
metadata (13-attribute read, namespace defaults via sync-over-async,
three nested node manager operations) only to return Good: about 80 % of
the 2.3 KB per sample.
- Each sampling loop now keeps per-item state between cycles:
ReadValueIds, result buffers and the last permission verdict. A verdict
is reused while the item's Session, effective identity and node handle
are unchanged and no permission change was signalled:
- `NodeState` bumps an internal version counter when `RolePermissions`
or `UserRolePermissions` of any node change (setter or Write);
- `IConfigurationNodeManager.DefaultPermissionsChanged` (namespace
defaults);
- a new `EffectiveIdentity` instance (ActivateSession with another user,
or roles refreshed after `RoleConfigurationChanged`).
- Independently, every item is revalidated once per second, spread over
the cycles, which bounds the delay for permissions that a node manager
computes without a signal (`OnReadRolePermissions`, custom
`ValidateRolePermissionsAsync`).
- The loop reuses its item snapshot until items are added or removed,
reads all items of one owner with one context instead of a LINQ
`GroupBy` (items moved to another session by TransferSubscriptions still
use the per-owner path), marks disabled items processed instead of
rebuilding the list, and checks for a detached loop without taking the
group lock.
- `Opc.Ua.Types` grants `InternalsVisibleTo` to `Opc.Ua.Server` for the
version counter.

### 3. Read and Write call only the node managers routed for the request
- `NodeManagerServiceDispatcher.ReadAsync`/`WriteAsync` called every
node manager. Every `AsyncCustomNodeManager` (configuration,
diagnostics, core, …) then copied the system context and allocated a
dictionary and a list, even with none of the requested nodes in its
namespaces.
- Validation now collects the namespace routes of the valid operations
and the dispatcher skips node managers outside them. Every manager of a
requested namespace is still called in dispatch order, so overlapping
managers keep their precedence. A namespace without a route still calls
every node manager, which keeps `BadNodeIdUnknown` and custom routing
unchanged.
- Not done: reusing the handle resolved during validation inside
`ReadAsync` needs a new node manager overload (public API), so nodes are
still resolved twice. HistoryRead and Call keep the old dispatch: Call
routes by object and method, and HistoryRead releases continuation
points per manager.

## Behaviour notes
- **`MaxQueuedRequestCount`** keeps its 1.5.x meaning again: established
sessions can have that many decoded requests waiting. Balanced and
TrustedReservations size the RequestQueue stage at
`MaxQueuedRequestCount + floors` (for example 10,003 for 10,000). A
deployment that relied on the smaller queue lowers
`MaxQueuedRequestCount` or sets the RequestQueue `Capacity`.
`docs/MigrationGuide.md`, `docs/ResourceIsolation.md` and
`docs/ServerScalability.md` are updated.
- **Permission changes for sampled items** still apply within one or two
sampling cycles when they come through
`NodeState.RolePermissions`/`UserRolePermissions`, namespace default
permissions, or a new effective identity. Permissions that a node
manager computes without changing those (for example
`OnReadRolePermissions` or a custom `ValidateRolePermissionsAsync`)
apply within one second instead of on the next sample.
`docs/NodeManagers.md` describes it.
- **Custom node managers** that process Reads or Writes of nodes in
namespaces they did not register receive those calls only while some
valid node of the request is in an unrouted namespace. Such a manager
registers its namespaces with `RegisterNamespaceManager` (documented in
`docs/NodeManagers.md`).

## Testing
- New unit tests:
- `RuntimeResourceIsolationTests`: plan sizing per mode, explicit
capacities, and a fair queue that admits exactly `MaxQueuedRequestCount`
requests from four owners, rejects the next and still admits bootstrap
and reconnect.
- `SamplingGroupPermissionCacheTests`: verdict reuse, revocation through
node RolePermissions in the same cycle, namespace default change,
identity change, periodic revalidation, disabled items. Each change is
injected inside the Read of a known cycle, so the tests do not depend on
timing.
- `NodeManagerDispatchRoutingTests`: non-owning managers are skipped,
several owners keep result order, an unrouted namespace still calls
everyone, two managers of one namespace keep their precedence, Write.
- net10.0: `Opc.Ua.Server.Tests` 8462 passed,
`Opc.Ua.Subscriptions.Tests` 643 passed, `Opc.Ua.Sessions.Tests` 851
passed / 379 skipped, `Opc.Ua.Core.Tests` (Stack.Server) 1858 passed,
`Opc.Ua.Stress.Tests` Contract|Integration 45 passed; 0 failures.
- net48 build of `Opc.Ua.Server.Tests` clean; `dotnet format whitespace`
clean.
- Measured with `perf/ServerLoadHarness` and the o6 scenarios (`o6read`,
`o6counters`) from the gap-analysis harness, which is not part of this
PR.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 08:52:09 +00:00
Cristi Pogacean 9344354aaa Fix empty LocalizedText binary field encoding (#4709)
# Description

Omit empty `Locale` and `Text` fields from the OPC UA Binary
DataEncoding for `LocalizedText`, as required by OPC 10000-6, Section
5.2.2.14, Table 24. Add wire-format tests for empty and non-empty
fields, including translation-backed localized text.

Validation:

- `git diff --check` passed before commit.
- `dotnet build tests\Opc.Ua.Types.Tests\Opc.Ua.Types.Tests.csproj
--framework net10.0 --no-restore --verbosity minimal
-p:NuGetAudit=false` could not complete because access to
`https://api.nuget.org/v3/index.json` failed with `NU1301` and a TLS
handshake error.

## Related Issues

No related issue.

## Checklist

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-10 08:05:11 +02:00
romanett 28f1401324 Server: cut request-path lock contention and allocation; add load harness (#4707)
Profiling the reference server under load showed most request-path CPU
spent spinning on contended locks, plus several avoidable allocations
per request or per node. This PR removes them, switches the reference
server to Server GC, and adds the load harness used to measure it.

## Results

Paired A/B against `master` (3 alternating rounds, medians, 20 sessions,
workstation GC on both sides, 24-core machine):

| Scenario | Throughput | p50 | p90 | Server CPU / op | Server alloc /
op | Lock contention |
|---|---|---|---|---|---|---|
| Read, 1 value | **+45%** (94k → 137k req/s) | −20% | −46% | −37% |
−39% | −94% |
| Read, 100 values | **+40%** | −26% | −17% | −24% | −25% | +130% ¹ |
| Read, 100 values, SignAndEncrypt | **+48%** | −36% | −20% | −30% |
−26% | +106% ¹ |
| Browse (Server object) | **+33%** | −31% | −12% | −13% | −14% | −40% |
| Write, 100 values | +13% | −12% | −4% | −10% | −19% | −23% |
| Data-change subscriptions | ±0 ² | ≈ | ≈ | −14% | −17% | −29% |

¹ The faster server spends more time in the remaining per-node locks.
² Throughput is bounded by the writer rate in this scenario.

Separately, Server GC instead of workstation GC doubles Read-100
throughput (21k → 44k req/s) and cuts p90 from 2.1 ms to 0.6 ms, because
workstation GC paused the server for about half the wall time under
load. The working set grows from ~210 MB to ~430 MB.

## Changes

- **`ObjectPool<T>`**: lock-free slot array. `ConcurrentBag.Count` took
every per-thread lock on each `Return` of the pooled response value-task
source, once per request.
- **`FairRequestQueue`**:
- drops redundant gate entries: the fail-fast pre-checks and the stopped
check read volatile fields and are re-checked under the gate; the owner
count is folded into selection;
- coalesces wake pulses with a pending flag, so the bounded signal
channel's lock is only taken when no token is outstanding. Wake-count
semantics in the tests are unchanged.
- **`DefaultServerResourceIsolationProvider`**:
  - compares peer addresses without `GetAddressBytes()` allocations;
- builds the unsecured verification context only when a classifier is
configured.
- **TCP listener / channel**: caches the certificate DER copies per
channel, keyed on the certificate instance. `X509Certificate2.RawData`
returns a fresh copy on every access, and the listener read it for every
request.
- **`EndpointBase`**: resolves the request `ActivitySource` once instead
of `Assembly.GetCallingAssembly()` per request.
- **Node managers**:
- one `ServerSystemContext` copy per request (cached on the
`OperationContext`) for per-node callbacks: permission metadata, node
metadata, view membership, method lookup;
  - removes a discarded copy in `GetReferenceDescriptionAsync`;
- per-request operation caches are plain `Dictionary` instead of a
`ConcurrentDictionary` with 24 lock objects;
  - the validation cache is built in one pass;
  - fixes zero-capacity error lists.
- **`ResourceManager`**: translation lookups read an immutable snapshot
that is rebuilt after writes. Browse serialised on the global lock for
every display name.
- **`MonitoredItem`**: shares the default `DataChangeFilter` instead of
allocating one per sampled value.
- **Reference server**: `ServerGarbageCollection=true`; new "Garbage
collector mode" section in `docs/ServerScalability.md`.
- **`perf/`**: `ServerLoadHarness` (out-of-process server plus load
driver, server-side CPU/GC/alloc/contention deltas, optional
`dotnet-trace` capture), `TraceAnalyzer` (TraceEvent-based
CPU/allocation/contention aggregation), and paired A/B scripts. Not part
of `UA.slnx`.

### Behaviour notes for custom node managers
- The `IDictionary<NodeId, NodeState>` operation cache handed to
overrides is now a `Dictionary`. It was a `ConcurrentDictionary`, but
only one request flow uses it.
- Within one request, per-node callbacks of a node manager share one
`ServerSystemContext` copy.

### Later commits on this PR
- **Permission checks without `NodeMetadata`** (`9068684c3`): the
role-permission and access-restriction validators run on an internal
`PermissionMetadata` struct; `AsyncCustomNodeManager` fills it on the
stack from the same attributes and namespace defaults. Browse's
reference filter reads 3 attributes per target instead of 13. Read 100
values: 120.6 → 95.7 KB/request, ~21k → 26k req/s; Browse 67.6 → 51.8
KB, 26.7k → 29.8k req/s.
- **Read-only channel evidence** (`936b28d76`, `dc638ab9f`):
`SecureChannelContext.ClientChannelCertificate`,
`ServerChannelCertificate` and `ChannelThumbprint` are `ByteString`, so
the cached per-channel certificate buffer is shared without a copy and
cannot be altered by handlers or classifiers. **Public API change**, see
the new section in `docs/MigrationGuide.md`. The isolation provider's
revalidation snapshot still copies.
- **Session lookup** (`936b28d76`):
`SessionManager.TryGetSessionContext` takes the candidate snapshot
without the bindings lock; the validating check under the lock is
unchanged (2 instead of 4 global lock acquisitions per request).
- Unit tests for `ObjectPool`, the per-request system context cache, the
translation snapshot, `SecureChannelContext` null semantics and a
stopped `FairRequestQueue`; `perf/` follows the repository conventions.

## Testing
- `Opc.Ua.Server.Tests` (net10.0): 8386 passed before the `ByteString`
change. After it, 13 secure-session and isolation tests failed; both
causes are fixed in `dc638ab9f` and those tests (25 with their variants)
pass. A full rerun of the Server, Sessions, Core, Redundancy, LDS and
GDS suites on the final commit is in progress.
- Full solution builds for net10.0 and net48; `dotnet format whitespace`
clean.

### o6-automation opcua-benchmarks (sampling, deep pipelines)
Replicated both tests of the [o6-automation
benchmark](https://github.com/o6-automation/opcua-benchmarks) in the
harness (`--scenario counters`, and `read` with 10 clients × N
outstanding), paired against `origin/master`:

| Test | master | this PR |
|---|---|---|
| Subscription sampling, 100 Hz, every item ≥ 99 % of samples, 1 item |
FAIL (75–94 %) | PASS (100 %) |
| same, 1024 items | FAIL (64–73 %) | PASS (99.9 %) |
| same, capacity | — | between 4096 and 6144 items (~410–520k samples/s)
|
| Read, 10 clients × 100 outstanding, None | stalls (0–1.7k req/s) |
121–131k req/s |
| Read, 10 clients × 100 outstanding, SignAndEncrypt | stalls (0 req/s)
| 89–91k req/s |
| Read, 10 clients × 1000 outstanding | channels faulted
(`BadTcpNotEnoughResources`) | 0 errors, ~45–54k req/s |

- **Fixed-rate sampling** (`30b769d13`): the sampling loop waited one
interval after each sample, adding timer overshoot and sampling time to
every cycle; samples now have absolute deadlines.
- **Async response send** (`cdac5c5ca`): `SendResponse` blocked
thread-pool workers on the channel gate; with many requests in flight
the receive loop that was handed the gate waited for a pool thread and
the channel stalled. The TCP, Kestrel TCP and WebSocket listeners now
use `SendResponseAsync`.
- **Backpressure instead of channel fault** (`191cd2ba7`): server
channels stop reading while more than 100 responses are queued and
resume at 50, instead of faulting the channel. Client channels keep
reading. At 10,000 outstanding requests throughput is ~50k req/s with or
without the limit engaged, so the remaining gap to the 100-per-client
case is not the backpressure.

## Not changed
- `SentMessageQueue` deep-clones every `NotificationMessage`. Retained
messages return their pooled payloads on acknowledgement while the
response copy may still be queued for send, and a client can acknowledge
a sequence number before receiving it; the clone keeps a recycled
payload from reaching another session's response. Removing it needs a
send-completion hook from the transport.
- Generated responses allocate a default `ResponseHeader` that is
replaced (~2-3 % of per-request allocation). Fixing it means making
structure fields of every generated type lazily initialized, a
generator-wide change to default values, equality and encoding.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:00:49 +02:00
romanett 7212607f16 OPC UA Part 3 compliance: permissions, Write semantics, NodeManagement, NodeSet DataTypes, locales (#4675)
# Description

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

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

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

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

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

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

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

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

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

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

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

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

## Related Issues

- Part 3 compliance audit (no tracking issue)

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:43:59 +00:00
Marc Schier c4e0721a29 Add a documentation learning path and fix issues found while verifying it (#4555)
## Summary

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

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

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

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

Review and validation artifacts are maintained outside the repository.

---------

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

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

Changes:

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

## Related Issues

- Fixes #4556

## Checklist

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

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

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

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

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

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

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

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

## Related Issues

Builds on merged #4522. Fixes #4554.

## Review and CI corrections

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

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

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

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

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

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

## Validation

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

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

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

## Checklist

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

---------

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

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

## Recovery and Publish ownership

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

## Audit and review corrections

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

## Latest review-feedback corrections

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

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

## CI integration contracts

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

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

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

## Related Issues

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

## Validation

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

### Session-test diagnostic memory

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

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

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

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

## Checklist

- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [ ] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [ ] All checks on the latest review-follow-up head have completed
successfully; prior-head CI passed as recorded above.
- [x] I have addressed **all** PR feedback received.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 68f26810-22b8-476f-abcd-0a8c82d6e208
2026-09-25 14:59:34 +00:00
Marc Schier 57c0a32ed4 Fix incomplete-message buffering and channel cleanup (#4522)
# Description

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

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

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

## Related Issues

Fixes #4521.

## Validation

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

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

## Checklist

- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [ ] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.
2026-09-24 20:12:55 +02:00
Marc Schier 138086863b Fix audit findings and review regressions across the OPC UA stack (#4503)
## Scope

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

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

## Review-batch verification at `ab686ccae`

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

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

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

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

## CI follow-up at `e1bc3ac3b`

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

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

Fresh local evidence after integration includes:

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

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

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

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

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

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

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

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

## Documentation follow-up at `57ed02bea`

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## Review and CI status at `5d4d13d63`

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

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

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

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

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 68f26810-22b8-476f-abcd-0a8c82d6e208
2026-09-22 09:10:58 +02:00
Marc Schier 4633aa7a34 Harden HA address-space hydration and replica-consistent NodeIds (#4445)
# Description

Follow-up to #4390 for distributed address-space ownership,
authoritative
hydration, and replica-consistent wire NodeIds. Tracks #4444.

Identity implementation: `a78d59067` (Server policy/lifecycle/adapter
seam) and
`07faf5cfd` (replica identity module, runtime wiring, examples and
proofs).

## Replica identity

- `UseReplicaNodeIdentity` / `ReplicaNodeIdFactory` reserve fixed shared
namespace
indexes before node-manager construction and reuse
`DefaultNodeIdFactory`.
  ApplicationUri and built-in diagnostics/configuration remain local.
- Replicated startup requires an explicit identity contract. Namespace
layout,
  deterministic mode, replica-set identity and writer policy must agree.
Standard factory views, replacement and registration preserve the
policy.
- Active/passive writer-assigned identities are replicated unchanged.
Independent
creation requires stable keys or explicit IDs. Live IDs and retained
tombstones
are reserved before writer allocation; no network/storage call runs in
`New`.
- Protected store descriptors use strong routing, including customized
hybrid
prefixes. Unknown legacy state and empty eventual scans cannot authorize
  automatic adoption. Verified-new hybrid provisioning is explicit.
- Active/active frames compare the descriptor before CRDT merge.
Rejected frames
are not reported as applied; incompatible admission remains at
ServiceLevel 0
  through the standard service-level wiring.
- Shared metadata validation covers type definitions, data types,
references,
permissions, NodeId/ExpandedNodeId/QualifiedName values,
arrays/matrices,
  DataValues and encodeable structures/arguments, without reflection.
- Runtime preparation hydrates retained IDs before replacement routes
publish.
Rebinding keeps the existing store/election or gossip transport/map and
attaches
to the new node-manager sources. Normal node-manager mutations,
including
  AddNodes, reach the cached local-address-space adapter.
- WoT materialization retains the stable configured factory. Samples
expose
genuinely factory-created nodes and a client `--identity` failover
workflow.

## Preserved HA repairs

The published `d99995003` and `79da74f14` fixes remain in place:

- Shared linearizable sequence/lease coordination; no pretend CAS on
CRDT and no
  private counter substituted for shared coordination.
- Strict authoritative record decoding, protected pending reservations,
safe
  snapshot publication and required delta retention.
- Accepted topology state controls authoritative membership; stale
deletion
  cannot remove a newer incarnation.
- Independent subtree attachment tracking and correct disposal of the
final
  snapshot task.
- Values wait for their topology, and parent tombstone horizons prevent
older
  descendant values crossing incarnations.

A new deterministic regression also covers writer hydration preparation:
temporary reconciliation removals must remain inbound application, not
be
recaptured as new shared deletion records.

## Validation

### Review feedback fixes

`3a682155b` addresses the active-node notification, WoT namespace-index
bounds,
and retired-manager XML documentation feedback. The address-space
adapter now
notifies listeners with the registered active node, and both WoT
managers reject
missing/out-of-range namespace indexes before ownership is established.

Targeted feedback validation passes on net10.0 and net48: 8
address-space adapter
cases and 74 WoT cases per framework, plus 17
hydration/gossip/live-client
integration cases on net10.0. The wrong-instance notification and
missing
namespace regressions were reproduced before the fixes.

### Identity implementation evidence

| Selected scope | net10.0 | net48 |
| --- | --- | --- |
| HA repair, identity, metadata, peer admission and real-client
identity/reload fixtures | 215 passed | 215 passed |
| Factory, fluent authoring/import, NodeManagement and address-space
adapter fixtures | 213 passed | 213 passed |
| WoT node-manager fixtures | 68 passed | 68 passed |

- Real UA-TCP clients compare full NodeIds/reference targets across
independent
servers in all four deterministic modes, with different manager/creation
order
and local allocations. Saved IDs work after endpoint failover and
restart.
- Real AddNodes-created IDs remain readable through successive live
manager
replacements in both replication modes, including nodes created after a
reload.
- Two process-level cases start three sample replicas, kill the serving
process,
verify a different serving ServerArray URI, and reuse cached
factory-generated
  IDs for Read and CreateMonitoredItems without remapping or rebrowsing.
- The identity/hydration scenario runs from the actual published win-x64
NativeAOT
  executable in the existing historian companion host.
- The focused writer-recapture regression was observed failing without
its fix,
  then passing. The real reload scenario was additionally repeated while
  diagnosing that race.
- Changed-file formatting/style/analyzers were applied. Existing CA1508,
CA1823,
CA1861 and CA1850 warnings in untouched upstream files were not
suppressed.

The selected checks above do not replace full-solution CI, coverage
gates or
maintainer review, and do not claim that the entire solution or every
native
platform was run. #4444 remains the tracking issue.

## Contract and consistency limits

Fixed identity does not make eventual payload reads linearizable. Hybrid
views
remain non-destructive, use explicit tombstones, and do not infer
empty-store
bootstrap or partition completion from absence. Compacted snapshots
require
authoritative storage. Failed/uncertain reservations require
reconciliation.

There is no automatic namespace-slot allocation, persistent logical-key
mapping
database, live renumbering, legacy-store wipe/adoption, or new hash
algorithm.
Opaque shared structures require a registered codec so their
namespace-bearing
fields can be validated. Application callbacks are still
application-owned.

## Related issues

- Tracks #4444.
- Follow-up to #4390.
- Builds on the merged NodeId factory (#4433), configure/seal (#4442)
and
  lifecycle/import (#4427) integration at `aedd3f738`.

## Checklist

- [ ] I have signed the CLA and read CONTRIBUTING.
- [x] Added targeted regression and integration tests.
- [x] Updated directly related API, HA, migration and sample
documentation.
- [x] Ran scoped formatting and the targeted framework checks listed
above.
- [ ] Full UA.slnx matrix and coverage gates completed.
- [ ] All CI and maintainer review completed.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9dbd9527-d818-47a0-984c-fbb1472d3d6b
2026-09-09 13:26:45 +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
Marc Schier 7411654c8d Complete OPC UA Part 11 Historical Access (#4390)
# Description

Completes the OPC UA Part 11 Historical Access work requested by #4387
and
the historian framework integration requested by #4400.

The conformance/evidence catalog covers all 37 released UACore 1.05
Historical Access profiles: 15 Server facets and 22 Client facets,
together
with the standard aggregate functions and their conformance units.
Server
facets are advertised only when the resolved providers expose their
required
interfaces and capabilities.

## Server and provider support

- Completes raw, modified, at-time, processed, annotation, structured,
and event history.
- Dispatches best-effort, bulk, and transactional updates with aligned
statuses, old values, diagnostics, and rollback state.
- Supports mixed history-update detail types and batched
structured/annotation operations.
- Uses composite identities and exclusive cursors for same-timestamp
structured, modified, and event values.
- Captures live data and events, installs historical configuration
objects, and emits typed audit payloads.
- Derives aggregate monitoring filters from per-node historian
capabilities and rejects unsupported processed aggregates before
provider dispatch.
- Supports tri-state `Historizing`: set, clear, or preserve
provider-owned state.

## Client support

- Exposes modified values together with `ModificationInfo`.
- Adds event-history reads and event insert/replace/update/delete
operations.
- Adds structured and annotation batching with strict response
validation.
- Shares continuation handling and release-on-abandon across read
families.
- Accepts inherited standard event fields rooted at event subtypes.

## Review hardening

- Registers DI-selected historians before node-manager reconciliation
and capability publication, while preserving provider ownership.
- Uses committed Raft read barriers for linearizable `TryGetAsync` and
`ScanAsync`.
- Keeps claimed continuation state immutable until a fresh successor is
saved, restoring the original after transient failures.
- Uses exact-incarnation compare-and-delete so delayed cleanup cannot
remove a restored same-ID continuation.
- Cleans up indeterminate initial and replacement saves, and propagates
shutdown cancellation through cleanup resolution.
- Preserves legacy continuation envelopes/cursors and distinct
annotation parent/property identities.
- Deletes every structured entry at a requested timestamp and uses the
complete modified-history ordering tuple.
- Shares native/fallback at-time and AnnotationCount calculations,
including reverse endpoints and cross-framework fractional intervals.
- Primes modified aggregate items before buffered live delivery and
deduplicates history/live overlap using provider-specific identities.
- Keeps committed Modify results successful while reporting priming
failures
  through the monitored item's notification queue.
- Keeps protected-notification state live-only for queues larger than
one.
Queue size one retains the newest notification without pinning errors or
transferring priority during resizing. Durable restore retains ordinary
definitions, last values/errors, and raw queues without recreating
priority.
- Isolates aggregate calculation, modification preparation, and
historical/live
handoff in `AggregationFilterHandler`, retaining existing
provider/factory
wiring and supported past-start behavior. The larger-queue protection
policy
  is unchanged pending the open review discussion.
- Uses one `IMonitoringFilter` owner for protocol filters or the
aggregation
handler, eliminating parallel effective-filter storage and retaining the
current definition during preparation. The original client filter stays
distinct.
- Integrates current master, retaining custom monitored-item creation
decisions and per-node history callbacks.

## Redundant-server alignment and samples

- Adds a protected shared historian with immutable segments, manifest
CAS publication, writer fencing, and restart recovery.
- Persists portable continuation state before exposing it to clients and
transfers ownership during mirrored-session takeover.
- Rejects eventual-consistency and active/active multi-writer historian
configurations rather than silently degrading.
- ReferenceServer and ConsoleReferenceClient demonstrate discovery,
paging, raw/modified/at-time/processed reads, annotation/structured
CRUD, event capture, and event CRUD.
- RedundantServer and RedundantClient demonstrate active/passive
history, continuation recovery after replica termination, and
post-promotion writes.
- Updates Historical Access, High Availability, profile, migration, and
sample documentation.

## Compatibility

- New 2.0 collection boundaries use `ArrayOf<T>` and public byte
payloads use `ByteString`.
- Removes the legacy public virtual
`SamplingGroupManager.CreateMonitoredItem` /
`ModifyMonitoredItem` entry points and protected creation factory, as
agreed
  in review. Migration guidance points to the monitored-item manager and
  construction-decision APIs.
- Retains established persisted formats and current write layouts.
Retired interim
notification-state formats (sample v2 / shared v4) are rejected, as
agreed in review.
- Keeps the historian integration compatible with .NET Framework and
NativeAOT.

## Validation

### Latest CI source repair

`73c619435` forwards the existing cancellation token at both
predefined-node
creation calls in the PR-added History event fixture. The merged
lifecycle
overload exposed these two CA2016 errors, which blocked History jobs,
the
solution build matrix, and CodeQL on GitHub and Azure. The exact
compiler
failure was reproduced locally; the repaired fixture passes 4/4
integration
cases on net10.0 and 4/4 on net48 with clean scoped diagnostics. No
assertions,
tests, coverage thresholds, or CI configuration were weakened.

The repaired History jobs on Linux/macOS and all four Azure solution
builds
now pass. The Windows all-TFM GitHub job instead lost its hosted
runner's
connection; no build log was recoverable, and GitHub recorded a
runner-loss
annotation rather than a compiler/test diagnostic. One unchanged retry
was
requested. Remaining checks are monitored with
`gh pr checks --watch --fail-fast --interval 30`; CI is not yet reported
as green.

### Latest base integration

`bd52e5fea` merges upstream `master` at `da7412c42`. The lifecycle
conflict
retains upstream's shared cancellation-aware registration helper and the
historian behavior. The hosted-historian fixture forwards its existing
token
to the new lifecycle overload. GDS project renames and all three AOT
test
hosts are retained, with their project references resolving after the
merge.

| Integrated scope | net10.0 | net48 |
| --- | --- | --- |
| Server lifecycle, fluent authoring/import, historian, monitored-item
and hosting regressions | 732 passed | 732 passed |
| NodeState lifecycle regressions | 216 passed | 216 passed |
| Automatically merged Raft client fixtures | 28 passed | 28 passed |

No selected tests failed or were skipped. The manually resolved files
pass
scoped formatting/analyzer checks. One CA1861 warning remains in the
incoming
`NodeSetImportIntegrationTests.cs`; that file is byte-identical to
upstream
master and was not rewritten or suppressed during integration.

### Latest feedback follow-up

`102954eaa` replaces split filter references with the shared
`IMonitoringFilter`
contract implemented by protocol filters and `AggregationFilterHandler`.
Targeted server regressions pass **103/103 on net10.0 and 103/103 on
net48**;
scoped formatting/analyzers are clean, and the updated XML-documentation
inventory has zero gaps across 188 non-generated PR C# files.

`93c5b5db8` renames `AggregationFilterHandler`, makes it the owner of
the
server-revised aggregate filter while preserving the original client
filter,
and removes the retired interim notification-state readers. Server
regressions
pass **101/101** and shared-store regressions **64/64**, on both net10.0
and
net48, with no failures or skips. Runtime-file formatting/analyzers are
clean.

`5ccf45630` completes the public/internal XML-documentation sweep. The
Roslyn
inventory finds zero undocumented eligible declarations across all 186
non-generated PR C# files; the 110-file documentation slice changes only
XML
comments and spacing. `f17765475` applies the three requested Markdown
edits.

### Earlier feedback follow-ups

`a53950737` removes single-slot notification pinning and extracts
aggregate
implementation from MonitoredItem. Targeted sampling, aggregate
modification,
history/live handoff, queue/lifecycle, restore, and serialization cases
pass
**101/101 on net10.0 and 101/101 on net48**, without failures or skips.
Scoped formatting/analyzers are clean; the focused review found no
significant
issues. The final formatted monitored-item fixtures also pass 45/45 on
net48.

The preceding `b21555568` removes the legacy sampling API bridge and
durable
notification-priority state. Its targeted Server cases passed 97/97 and
shared
subscription-store cases passed 64/64 on each framework. The broader
evidence
below remains pinned to its original tested commit, not attributed to
these
follow-ups.

### Earlier integrated-implementation validation

Validated implementation commit: **`3efae597f`**. The following commit,
`f6dbefb1c`, only adds two explanatory comment lines for the final
standards
review; it changes no executable code.

| Scope | net10.0 | net48 |
| --- | --- | --- |
| Server | 5,187 passed; 56 skipped | Affected surface: 2,160 passed; 1
reproduced baseline failure; 56 skipped |
| Client | 2,202 passed; 2 skipped | 2,204 passed; 5 skipped |
| History integration | 519 passed; 21 skipped | 519 passed; 21 skipped
|
| Redundancy server | 641 passed | 641 passed |
| Redundancy client | 124 passed | 124 passed |
| Core shared-store regression suite | 23 passed | 23 passed |

All six affected samples passed non-incremental Release/net10.0 builds
with
zero warnings and errors: ConsoleReferenceClient,
ConsoleReferenceServer,
RedundantServer, RedundantClient, MinimalBoilerServer, and
PumpDeviceIntegrationServer.

The published self-contained win-x64 NativeAOT executable is 116,924,928
bytes and has no CLR header or managed runtime dependency. Direct
execution
passed **HistoryAotTests 3/3** and **RaftAotTests 2/2**.

Final standards and specification reviews found no confirmed outstanding
high/medium functional gaps or hard standards violations. The remaining
constructor-purpose clarification is addressed in `f6dbefb1c`.

### Reproduced baseline exceptions

The net48 hosted-startup timeout also reproduces in a pristine worktree
at
the previous PR head, `24cf1416d`, in

`RegisterPostStartRegistriesWiresHistorianAndAliasStoresOnPlainServerAsync`,
with the same subsequent certificate-leak assertion. The corresponding
renamed case fails before startup in the reviewed branch as well. This
is
reported separately from the net48 affected-surface results, not counted
as
a passing full Server suite. The net48 matrix selected the historian,
aggregate, monitored-item, fluent, and continuation surfaces and
excluded
the Hosting namespace.

`DurableDataValueQueueVerifyReferenceBatchingAsync` failed at item 1000
in
the net48 affected-surface run and passed its single isolated rerun. The
same
failure at the same assertion reproduces on pristine `849e16acc`; the
test,
sample queue, queue factory, and batch persistor are unchanged. The
original
failure remains in the table rather than being erased by the rerun.

### CI source repairs

- `4be7eb8f7` keeps diagnostics/configuration managers out of
distributed
address-space replication. Those managers inherit the source interface,
but their live server state and redundancy metadata must remain
replica-local.
  The ownership regression passes on net10.0 and net48, and all nine
  redundancy sample CI tests pass locally.
- `d17f467b3` completes the sample startup repair: core managers are
also
replica-local; the startup banner no longer advertises readiness before
initialization; the producer starts after distributed services; and
native
Raft startup no longer forces disruptive extra elections. The full
sample
suite passed 9/9, with initialized `RedundancySupport=Hot`, and focused
  Raft/address-space regressions passed 8/8 and 3/3.
- `81b0b72be` moves the unchanged distributed-historian NativeAOT
scenario
into a smaller, always-run companion executable to bound Apple's linker
input. All 137 AOT tests remain present; the moved test and helpers are
  unchanged. GitHub and Azure publish/run both native executables, with
separate result directories where they share a job. The companion passed
actual Windows NativeAOT execution. Linux and Intel macOS CI passed both
  executables, but this split alone did not fix ARM64 macOS linking.
The remaining historian graph is rooted by ReferenceServer startup, not
just the relocated test. No test exclusion or speculative linker setting
  was added to conceal it.
- `8a5842913` isolates the independent MCP test graph into an always-run
native host. The existing MCP source and assembly leak hooks are linked
  unchanged, and evaluated compiler inputs prove exactly one owner for
  every test source: 133 main + 3 MCP + 1 historian = 137 declarations.
All three hosts run on every AOT platform. The main Windows native
object
is 9.35% smaller; both changed publishes had zero diagnostics. MCP
passed
  3/3; the identical main executable passed 133/133 on one unfiltered
followup after two initial endpoint-discovery connection failures, which
  remain recorded in the local evidence.
- The previous Intel macOS job successfully published and executed its
native
tests, then failed on artifact-upload DNS (`ENOTFOUND`). No upload gate,
  shared environment setting, or cache configuration was disabled.

The first repair round also encountered the unrelated
`MetricsAreEmittedForChannelLifetimeAsync` timing failure on macOS Core.
Neither that test nor channel-manager code is changed by this PR, and
its
assertions remain intact. It passed unchanged on the final repair head.

On `d17f467b3`, the Linux and macOS redundancy sample jobs pass; the
remaining
failure was ARM64 Apple's `too many large addends` assertion. The
verified
upstream relocation-anchor fix (dotnet/runtime#124721, backport
dotnet/runtime#132171) is not present in the shipping ILCompiler
10.0.11.
An experimental multi-module probe failed and was rejected, not added to
CI.
On `8a5842913`,

[`aot-macos-latest`](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/34102158680/job/101678934057)
successfully publishes and executes all three native hosts on ARM64:
136 main runtime cases, 1 historian case, and 3 MCP cases, with no
skips.
The previous Apple linker blocker is resolved. Linux NativeAOT also
passes.
No assertion, test, shared environment setting, cache, or required gate
was
weakened.

The same CI run reports a separate macOS Sessions teardown failure: all
662 executed tests pass, but the global leak assertion detects one
undisposed certificate. The MCP partition changes no runtime, Sessions,
or
test-fixture code. A single unchanged diagnostic rerun is queued once
GitHub
permits rerunning that job; the failure is not suppressed or counted as
green.
Other CI checks are still pending.

## Related issues

- Fixes #4387
- Fixes #4400

## Checklist

- [ ] I have signed the CLA and read the contributing guide.
- [x] I have added regression tests and directly related documentation.
- [x] I have retained working reference and redundant-server sample
wiring.
- [x] Final validation and review evidence is complete, including the
baseline exceptions above.
- [ ] All required CI checks pass; the fail-fast rerun for `73c619435`
is pending.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9dbd9527-d818-47a0-984c-fbb1472d3d6b
2026-09-08 11:32:23 +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
Marc Schier aad8350bc1 Pluggable symmetric cryptography, an awaitable asymmetric path, registrable security policies, and PubSub crypto offboarding (#4206, #4207, #4208, #4210) (#4232)
# Description

Closes the three crypto-offboarding follow-ups split out of #4192. Each
was recorded there as knowingly *not* done, with the reason and what
would justify revisiting it. Those conditions are met, and where one of
them turned out to be unachievable that is written down rather than
worked around.

The safety property throughout: **every seam is opt-in and null-checked,
and in the default software configuration the added code is observably
inert.** No interface dispatch on the per-message path, and the
asynchronous paths complete synchronously — so channel locking order,
buffer ownership and error sequencing are unchanged for a deployment
that configures nothing.

## #4207 — symmetric, key-derivation and RNG provider seams

`ISymmetricCryptoProvider`, `IKeyDerivationProvider` and
`ISecureRandomSource`, added on the terms #4207 set: only once a
concrete requirement arrived (a validated module that must perform
*every* operation, not just the asymmetric ones), behind a fast path,
with `SymmetricChannelCryptoBenchmarks` as the gate.

They are **facets discovered by type test**, not members of
`ICryptoProvider`, so providers written against the shipped interface
still compile. `PlatformCryptoProvider` implements all three, which
answers the "an interface nothing implements" objection recorded in the
issue.

`CryptoProviderFacets` returns `null` both when no registry is
configured *and* when resolution lands on the platform, because the
platform facets are the inline code. Resolution happens once, in
`CalculateSymmetricKeySizes`, and is held for the life of the channel.

`CryptoCompliance.GetUnservedOperationPurposes` closes the failure the
seam would otherwise introduce: a provider bound to `ChannelSymmetric`
without implementing the facet is silently replaced by the platform, so
a deployment could believe its validated module performed the
per-message cryptography while the platform did. Under `FipsOnly` that
now refuses to start.

## #4208 — the asymmetric path is awaitable

`RSA` and `ECDsa` are .NET's contracts and cannot be replaced without
making every ready-made hardware and cloud implementation unusable.
Instead an implementation may **also** declare `IAsyncRsaKey` /
`IAsyncEcdsaKey`, which the stack finds by type test.

Now asynchronous: the secure channel open and renew path, user identity
token signing and decryption, and session activation. A software key
declares neither facet, so those paths complete synchronously and
nothing about their ordering changes.

`ReadAsymmetricMessage` is refactored into shared helpers rather than
duplicated — adding an `await` was not worth two hundred cloned lines of
signature, padding and endpoint-selection logic. `AsymmetricMessage` /
`AsymmetricWriteResult` exist because an asynchronous method cannot have
`out` parameters.

The channel no longer serialises its state on a monitor. A monitor
cannot be held across an `await`, and a plain `SemaphoreSlim` was not an
option at the time: the code relied on re-entrancy in eight places
(`HandleIncomingMessage` holds the lock and calls `ForceChannelFault`,
which takes it again; `ForceReconnect` and `Shutdown` do the same).
`ChannelGate` replaces it at 32 sites and `DataLock` is `[Obsolete]`.
That reliance has since been removed as well — see [Removing the gate's
re-entrancy](#removing-the-gates-re-entrancy) below.

### Defects this surfaced, all fixed and regression-tested

**Detached work started inline.** Writes were started with `_ =
WriteBuffersAsync(...)`. An async method runs its prologue on the
caller's stack, so `LeaveInheritedContext` — which exists to stop
*detached* work inheriting the right to re-enter the gate — was
stripping the **caller's own** entitlement; when the send then completed
synchronously, the completion blocked on a gate that very thread was
holding. The client sent its OpenSecureChannel request and never
processed the response. Writes are now queued.

**A synchronous gate handle held across an `await`.** `Enter()` records
the acquiring thread so an inline completion callback can re-enter —
`ChannelAsyncOperation` invokes its callback both inline and detached.
That record is only sound while the holder is synchronously on that
thread; the token renewal path held it across an await, so a reused pool
thread was recognised as the holder.

**`SignAsync` rejected the policy that signs nothing.** It validated the
certificate before the algorithm, so `SecurityPolicies.None` — which
carries no certificate — threw where the synchronous `Sign` returns
`null`.

**`EnterAsync` lost the holder when contended** *(found in review)*. The
`AsyncLocal` recording ownership was written inside the awaiting method,
and .NET restores the caller's execution context when an async method
completes, so the write was discarded. The caller returned owning the
semaphore while `IsHeldByCurrentContext` reported `false` — and any
nested `Enter()` then blocked forever on a gate its own context held.
Reachable remotely: `HandleIncomingMessageAsync` → error path →
`ForceChannelFault`. The holder is now published from the caller's frame
before the wait, and only its depth is raised in the continuation.

**Queued writes lost their order** *(found in review)*. Sequence numbers
are assigned under the gate, but independently queued work items reach
the transport's FIFO send lock in arbitrary order, so a peer could
reject a chunk with `BadSequenceNumberInvalid`. Writes are now appended
to a chain: still off the caller's stack, but a write cannot start
before its predecessor finishes.

### Four more this surfaced in CI, on paths only the integration suites
reach

Queuing the writes to fix the deadlock above turned out to have moved
three implicit guarantees, and the gate had one more liveness bug. Each
was reproduced locally against a clean baseline before it was changed.

**A faulting channel's error message never reached the peer.** A
listener channel sends a UASC `Error` describing why it faulted and
closes the transport as its next statement. That worked only because the
write started on the caller's stack, so the bytes reached the socket
first; queued, the close discarded them and the client saw
`BadConnectionClosed` instead of the certificate status it was told to
expect. Nine `SecurityCertValidationTests` cases assert exactly that
status. The terminal error is written inline again, on a path that
touches the gate only to disclaim what it inherited and reports nothing
— a channel that is already faulted is owed nothing beyond its buffer.

**The certificate of a rejected sender leaked.** An asynchronous method
cannot have `out` parameters, so the asymmetric read returns a record
instead. That silently dropped an ownership guarantee: an `out`
parameter reaches the caller even when the method goes on to throw, so
the caller's `catch` disposed the sender certificate and reported it to
the audit. A return value does not — nine leaked certificates, one per
rejection case, which the leak detector fails the run on. The parsed
certificate is now handed to the caller before anything that can reject
the message, restoring both the disposal and the audit.

**The gate could strand its own permit.** It released only when the
holder's depth returned to zero, but that depth lives in an `AsyncLocal`
and is therefore shared with every context forked from the owner's — a
timer callback whose timer was created inside the region, for instance.
Such a context entering the gate raised the shared depth, so the owner's
own exit saw a non-zero depth and returned without releasing, and
nothing afterwards could: the nested exit is not the owner. Every later
entry on that channel blocked forever. `Opc.Ua.Features.Tests` hung
after its tests had finished, with a receive loop waiting on the gate
and the server unable to stop. The owner now releases when it leaves;
depth still gates re-entrancy, but liveness no longer depends on a count
another context can move.

**Write completion throttled the writer.** Reporting a completed write
sat inside the ordering chain, and the client channel implements that
callback by entering the gate — so every write queued behind a gate the
receive loop contends for on each incoming chunk. A session with fifty
subscriptions publishing continuously had its publish requests throttled
enough that the client kept topping them up past its own limit, which
`Subscriptions.Classic` asserts against. Completion is now reported off
the chain, which covers only what ordering actually requires.

### Still synchronous, and why

Certificate, certificate request and revocation list signing cannot be
made asynchronous at all: `X509SignatureGenerator.SignData` is invoked
by .NET's own builders. Service faults and the `Reconnect` override are
reached from synchronous call sites (`SendServiceFault` alone has eight)
and are left as they are behind scoped, commented suppressions. All
three are documented rather than left to be rediscovered.

## #4210 — PubSub

This one produced a finding rather than the API the issue asked for, and
the finding is the point:

> **With a standard Security Key Service the key necessarily exists in
process memory.** `GetSecurityKeys` (Part 14 §8.3.2) returns raw key
bytes over the wire, so the "key never leaves the device" property #4192
achieved for client and server **cannot** be achieved for PubSub through
the SKS pull profile. That is a property of the specification, not of
this stack.

A wrapped-key envelope would change what is on the wire and break
interoperability with third-party key services and publishers, so it was
rejected rather than built.

What is achievable is delivered: the per-message AES-CTR and HMAC route
through `ISymmetricCryptoProvider`, so a validated module performs them.
`IPubSubSecurityPolicy` is **not** modified — new members would break
every implementation of it; the provider is supplied through the policy
constructor. `PlatformSymmetricCryptoProvider` gained AES counter mode,
which the seam should have covered anyway since the algorithm enum names
it.

Key lifetime is also tightened: unpacking an SKS response copied key
material into intermediates that `ByteString.Create` then copied again,
leaving plain keys in the heap for the lifetime of the process. Those
are now zeroed.

### Two wiring gaps this depended on, both found in review

The registrations above were **inert**, so this section originally
claimed more than the code did:

- `CryptoProviderConfiguration` was applied nowhere outside tests, so
`AddCryptoProvider(configure)` never reached the registry in a real
host. The registry now applies every registered configuration when it is
first resolved — which also affects #4207's DI story.
- `PubSubSecurityWrapperResolver` selected
`PubSubAes256CtrPolicy.Instance`, the provider-less static singleton,
ignoring the provider-backed bundles registered for it. A `FipsOnly`
deployment therefore certified while the platform did the work. The
resolver now selects from the registered policies; the container and
`PubSubApplicationBuilder` both supply theirs.

A third, smaller one: `CryptoProviderBuilder.Use` published a
**policy-scoped** random source as the process-wide nonce source, so
`.For(purpose, policyUri).Use(hsm)` redirected nonce generation for
every other policy too. Only an unscoped binding does that now.

## #4206 — a provider can contribute a security policy

Added in review: the policy set was fixed at compile time, so a provider
could contribute key custody but not a *profile*. It is now table-driven
and open, and the blockers #4206 listed are gone.

```csharp
using IDisposable registration = SecurityPolicies.Register(
    new SecurityPolicyInfo("urn:vendor:SecurityPolicy#CustomProfile", "CustomProfile")
    {
        PlatformSupport = () => true,
        SupportedCertificateTypes = [ObjectTypeIds.RsaSha256ApplicationCertificateType],
        IsDefault = true
    });

services.AddOpcUa()
    .AddCryptoProvider(crypto => crypto
        .For(CryptoPurpose.ChannelSymmetric, "urn:vendor:SecurityPolicy#CustomProfile").Use(module))
    .AddSecurityPolicy(customPolicy);
```

- **The reflection is gone.** Four lookup tables were built by
reflecting over the fields of `SecurityPolicies` and
`SecurityPolicyInfo`. They are one explicit table now, which removes the
**last reflection in `Security/Constants`** — so this is a trimming and
Native AOT improvement as much as a feature.
- **`SecurityPolicyInfo` is constructible from another assembly** and
carries the metadata the helpers need, so `IsPlatformSupportedName`,
`GetDefaultUris`, `MapSecurityPolicyToCertificateTypes` and
`GetCurveFromCertificateTypeId` read that table instead of each being
hand-written. Adding a policy is one entry rather than five edits.
- **The dead `#if CURVE25519` conditional is deleted** from
`Security/Constants`.
- Registration is copy-on-write, so the read path stays lock-free.
Shadowing a built-in policy requires `replaceExisting: true` and is
reversible by disposing the registration.

`RegisterLightsUpCurvePoliciesFromOutsideCore` is the acceptance test
the issue asked for: it registers `ECC_curve25519` and `ECC_curve448`
from the test project and asserts they become discoverable through the
public API.

**Registering a policy makes it advertised and resolvable; it does not
supply the cryptography behind it.** For those two curve profiles the
in-tree key agreement is still behind a compile symbol no project
defines plus a BouncyCastle dependency, so a deployment lighting them up
provides the operations through a provider. That division is the point:
the policy set says *what* is offered, the provider says *who* performs
it.

## Removing the gate's re-entrancy

Folded in from #4242, which was merged into this branch.

`ChannelGate` was re-entrant, and that forced the design behind most of
the defects above. A holder that awaits releases its thread, so
ownership could not be tracked by thread identity — it had to live in an
`AsyncLocal<Holder?>`. A logical context is *inherited*, so anything
started while the gate was held inherited the right to re-enter and ran
inside the guarded region alongside whatever started it, unless it opted
out by hand via `LeaveInheritedContext()` at each of eight sites,
enforced by review alone. Five of the defects listed above trace to that
mechanism, including both CI hangs.

So the *requirement* was removed rather than the mechanism, and the
`AsyncLocal` then deleted itself. Every path that re-entered now calls a
lock-free `Core` method (`ForceChannelFaultCore`, `ShutdownCore`,
`ForceReconnectCore`, `CompleteConnectCore`), and three regions stopped
running foreign code under the lock: the `RequestReceived` dispatch is
deferred until the gate is released, `OnTransportError` no longer wraps
the `HandleSocketError` virtual, and the callback setters and
`EndpointDescription` publish a single reference with a volatile write
instead of locking — the last is load-bearing, because
`AsyncResultBase.OperationCompleted` invokes the reverse-hello callback
inline while the gate is held.

`ChannelGate` is now a `SemaphoreSlim(1,1)` behind a `Releaser`;
`Holder`, the thread-identity fallback, `LeaveInheritedContext`,
`IsHeldByCurrentContext` and all eight disclaimer calls are gone, with
the call shape at all 32 sites unchanged. `SaveIntermediateChunk`,
`GetSavedChunks` and `DoMessageLimitsExceeded` gained a `gateHeld`
argument, because the client reaches them both with and without the gate
and must pick the matching teardown — without it an oversized
`OpenSecureChannel` response deadlocked the receive loop against itself.

The remaining paths were not found by reading the code, since that is
what missed them originally. A temporary strict mode made every
re-entrant acquisition throw with its stack, the suite was run under it
until clean, and it was deleted with the re-entrancy it measured. It
caught two sites review had missed: the `EndpointDescription` **property
getter** taking the gate, and the `RequestReceived` dispatch to
application code happening inside the guarded region.

Two deliberate residuals are documented for reviewers: `Dispose` no
longer takes the gate (unavoidable — it is reached from `ChannelFaulted`
with the gate held, where the old re-entrant acquisition excluded
nothing anyway), and the deferred request dispatch builds
`SecureChannelContext` just after gate release rather than under it.

## Testing

New: the three seams and their platform implementations, facet discovery
and resolution precedence, `FipsOnly` refusing a provider that cannot
serve what it was bound to, AES counter mode (including a
non-block-aligned round trip and a check that the counter carries, since
one that did not would silently reuse key stream), `ChannelGate`
(exclusion, re-entrancy across an await, re-entrancy on a reused thread,
and the deadlocks above), the asynchronous channel paths, and the PubSub
policy provider.

Each review finding got a test that fails without its fix:

- `ContendedEntryStillRecordsTheHolderAsync` — established contention
deterministically, then re-entered; failed on the pre-fix gate. Removed
with the re-entrancy it covered; `ChannelGateTests` now asserts the
non-re-entrant contract instead.
- `SecureClientAndTcpListenerKeepChunkOrderUnderConcurrentRequestsAsync`
— 60 concurrent multi-chunk `SignAndEncrypt` requests over a real
loopback listener; failed 3 runs out of 3 with the previous dispatch,
passes 3 out of 3 with the chain.
-
`DependencyInjectionRoutesPubSubCryptoThroughTheRegisteredProviderAsync`
— builds a container, registers a counting provider, wraps a message and
asserts the provider actually performed the encryption and the
signature. This is the assertion the PubSub claim above now rests on.
- `PolicyScopedRandomBindingDoesNotEscapeItsPolicy`.

Two further assertions are the ones that would catch a silent defect: a
provider and the platform must produce **byte-identical** output — a
validated module that disagreed could not interoperate — and with a
software key the returned `ValueTask` must report
`IsCompletedSuccessfully`, which is what makes "ordering is unchanged"
checkable rather than merely asserted.

`SymmetricChannelCryptoBenchmarks` gains a provider-registered variant
beside the existing baseline, so the cost of the indirection is measured
rather than assumed.

Verified locally, every suite at or above its recorded baseline: `dotnet
build UA.slnx -c Release` **0 warnings, 0 errors on all six target
frameworks**; Core **4413** (net10.0); Core.Security **617**, with its
certificate-leak detector clean; PubSub **1350** plus PubSub.Diagnostics
**54** and PubSub.Udp **233**; Features **156**; Subscriptions.Classic
**32**; Server integration **4053**; Client integration **2123**;
MigrationAnalyzer **151** and MigrationAnalyzer.Core **13**. Zero
failures in each.

Core moved from 4388 to 4381 when the gate change landed — eight
re-entrancy tests were removed along with the behaviour they covered,
and one deadlock regression test added — and has since grown to 4413
with the registry isolation, provider-output and security-review tests.
`ChannelGateTests` was rewritten around the new contract — exclusion
across both entry modes, an uncontended asynchronous entry not
suspending, re-entry blocking by contract, and the property that used to
be the hazard: work started while the gate is held is excluded without
having to opt out.

Every CI failure this PR produced was reproduced locally, diagnosed
against a clean `origin/master` baseline in a second worktree, and fixed
at the source. Two hangs were found from `--blame-hang-timeout` dumps
read with `dotnet-dump`. One failure that looked like a candidate was
confirmed **not** to be: an intermittent `Opc.Ua.Aot.Tests` connect
failure, pre-existing on `origin/master` (5 of 8 baseline runs failed,
versus 3 of 14 here).

### The publish request overshoot

`Subscriptions.Classic.PublishRequestCountAsync` failed on the Windows
net10.0 leg with **207** outstanding publish requests against a cap of
`max(maxServerPublishRequest, subscriptions)` = 50. It failed in 2 of 6
pipeline builds on this branch, never on `master` in 13 sampled builds,
and never locally in 12 attempts including under deliberate CPU
contention.

The mechanism is a genuine race.
`ClassicSubscriptionEngine.QueueBeginPublish` was a **check-then-act**:
it read `GoodPublishRequestCount`, compared it against
`GetDesiredPublishRequestCount(false)`, and issued a publish if it was
below. It runs on every publish completion, so *n* completions that
overlap each read the same value, each conclude they are below the cap,
and each issue — overshooting by up to *n* per round. The read also
*lags*: a request only becomes visible to `GoodPublishRequestCount` once
`AsyncRequestStarted` has recorded it, which happens after the request
has already been sent.

That code is pre-existing and was untouched by this PR. What this PR
changed is how hard the race is hit: the server used to hold the channel
gate across request processing, which serialised every request on a
channel, so publish completions arrived one at a time. Dispatching
`RequestReceived` outside the gate — an intended improvement, since a
channel should not serialise on application request-processing time —
lets them complete concurrently.

Rather than change the publish pipeline blind, the race was first made
reproducible.
`ConcurrentPublishReEvaluationDoesNotExceedDesiredRequestCount` drives
64 barrier-released completions against a mocked context whose count
lags the send exactly the way the session's does. It issued **64
requests where 5 were desired** — the same defect the CI leg hit, now
deterministic and running in milliseconds.

The engine now tracks the requests it has in flight and reserves a slot
with a compare-exchange before sending, so the decision and the send are
one atomic step. The reservation is released when the request completes,
when the send is declined, or when it throws. The repro test then issues
at most the desired count.

`StartPublishing` is deliberately left **uncapped**. It is the recovery
valve that refills a pipeline whose requests are outstanding but no
longer expected to return, so gating it on the in-flight count would
remove the only path out of a stalled pipeline. An earlier revision of
this fix did gate it, and that is the one change here that could not be
justified on the evidence — it was reverted.

The assertion was **not** loosened, retried away, or marked flaky: it
caught a real throttling regression earlier in this PR and is worth
keeping sharp.

Validated: the repro test red before the fix and green after;
`Subscriptions.Classic` 32/32 **five** consecutive runs on net10.0 and
once on net48; `Opc.Ua.Client.Tests` 2124 on net10.0 across four
consecutive runs and 2127 on net48; `Opc.Ua.Subscriptions.Tests` 604
passed with this fix in place.

### The SSH.NET advisory that turned CI red repo-wide

Partway through this work every build job began failing with `NU1903:
Package 'SSH.NET' 2025.1.0 has a known high severity vulnerability`
([GHSA-q939-rpr3-3284](https://github.com/advisories/GHSA-q939-rpr3-3284),
`<= 2025.1.0` affected, first patched `2026.0.0`).

Nothing in the dependency graph changed — the advisory was published.
Build 17026 failed on it where build 17000, two hours earlier on an
identical graph, had passed every build job. SSH.NET is not referenced
anywhere in the repository; it arrives transitively through
`Testcontainers.Kafka`.

`master` landed the remediation independently while this was being
investigated (a central transitive pin to `2026.0.0`, which
`CentralPackageTransitivePinningEnabled` exists for). This branch
briefly carried its own identical pin, which made the merge commit hold
**two** `PackageVersion` entries for SSH.NET and fail restore with
`NU1506`. The duplicate is removed and `master` is merged, so the pin
now comes from one place only.

The audit was **not** suppressed: `NuGetAudit` stays enabled and
`NU1903` stays an error. Verified after the merge — `dotnet restore
UA.slnx` reports neither NU1903 nor NU1506, and `dotnet build UA.slnx -c
Release` is **0 warnings, 0 errors** with the audit live.

The remaining red check in build 17026 was not from this change either.
`aot-ubuntu-latest` is the intermittent `Opc.Ua.Aot.Tests` failure
already noted above — `ReconnectSessionAsync` timed out at 30s with 135
of 136 passing. It remains below the `origin/master` baseline rate (4 of
15 sampled here against 5 of 8 on the baseline), and the managed
reconnect paths are green throughout: `Opc.Ua.Client.Tests` 2124 across
four consecutive runs and `Opc.Ua.Subscriptions.Tests` 604.


## Documentation

`docs/CryptoProvider.md` gains sections on substituting the symmetric
primitives, using a key served over a network, and PubSub — including
what cannot be claimed and why, the scope rule for a random-source
binding, and how to supply a provider-backed policy outside the
container. `docs/MigrationGuide.md` covers the `[Obsolete]` channel
virtuals and `DataLock`, and the non-re-entrant gate's contract for
channel subclasses. `docs/WhatsNewIn2.0.md` is updated.
`plans/cryptooffboard.md` is deleted now that the work it planned has
landed.

## Note for reviewers

This touches the secure channel handshake, which is the most
security-critical code in the stack. Nine distinct concurrency and
lifetime defects were found and fixed in it during this work — three
during development, two in review, four from CI — so `ChannelGate`, the
write path and the certificate ownership across the new suspension
points deserve the closest reading.

Five of those nine traced to the gate's `AsyncLocal`, which is why the
re-entrancy was subsequently removed and the `AsyncLocal` deleted. The
wrapper/`Core` split and the two documented residuals in that section
are the parts most worth a second reading.

The #4206 work removes the last reflection from `Security/Constants` and
rewrites the policy lookup tables, so
`SecurityConfiguration.SupportedSecurityPolicies` and the endpoint set
it feeds are worth a second look: an earlier revision of it changed that
set and broke six server tests, which is now covered by
`SecurityConfigurationBuildsExpectedSupportedPolicySet`.

## Related Issues

- Fixes #4206
- Fixes #4207
- Fixes #4208
- Fixes #4210
- Follow-up #4250 — this PR makes the security policy set injectable,
but nothing in the stack resolves `ISecurityPolicyRegistry` from DI yet,
so every production path still lands on the `Default` fallback. #4250
tracks threading it through `Session` and `UaSCBinaryChannel`, which is
what makes the injection points already added here load-bearing.

## 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>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: marcschier <11168470+marcschier@users.noreply.github.com>
Copilot-Session: 7a68ca72-83e8-496c-b2f5-3df4c718320b
Copilot-Session: 3f5ab76a-c0ba-4f47-87ac-54d1f4182c6b
2026-08-15 05:23:20 -07: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 5bc8a9d705 Reduce ISession: type the continuation points, delete the obsolete validate overload (#4199)
## Stacked on #4197

Base is `marcschier/server-bind-phase`. The full stack is **#4188 →
#4193 → #4197 → this**.
GitHub retargets each base automatically as the stack merges. **Review
the earlier three
first.**

**#4217 has been merged into this branch** (squash `53dd4b618`), so the
last remaining
item from the issue — `ISession.SessionDiagnostics` — is now part of
this PR.

Closes #4187.

Refs #4203 — **does not close it.** #4217 swapped `OnUpdateDiagnostics`
onto
`m_diagnosticsLock`, but it still calls
`Variant.FromStructure(ServerDiagnostics)` with the
default `copy: false`, which wraps the *live* structure in the
`ExtensionObject`. The caller
reads its fields after the lock is released, so the lock currently
excludes nothing. #4222
adds `copy: true` and is what actually closes #4203.

## The finding that changed the approach

`ISession` has **exactly one production adapter**:
`src/Opc.Ua.Server/Session/Session.cs`.
The other `ISession` implementations in the tree are the unrelated
**client** interface
(`src/Opc.Ua.Client/Session/Session.cs`, `RedundantClientSession`) and a
migration-analyzer test stub.

One adapter means the seam is hypothetical; two means it is real. The
issue proposed
splitting `ISession` into five concern-interfaces — that would turn one
hypothetical seam
into five. The interface earns its keep for testability and because node
managers receive
it through `OperationContext`, but that argues for making it
**smaller**, not for
multiplying it.

**So: reduce, don't split.** Reasoning posted to the issue as
[comment
5226712936](https://github.com/OPCFoundation/UA-.NETStandard/issues/4187#issuecomment-5226712936)
so the five-way framing does not get re-proposed later.

## Measurement

38 members, 368 references. External production consumers are **4 files
using 5 members**:

| Consumer | Members used |
|---|---|
| `Opc.Ua.Redundancy.Server/Sessions/DistributedSessionManager.cs` |
`Id`, `InitializeAsync`, `Dispose` |
| `Opc.Ua.Di.Server/Locking/DefaultLockService.cs` | `Id` |
| `samples/ConsoleReferenceServer/UAServer.cs` | `Id`, `Identity`,
`ReadDiagnostics` |
| `samples/Quickstarts.Servers/.../DataChangeMonitoredItem.cs` | type
reference only |

Every production caller of the five continuation-point members is inside
`Opc.Ua.Server`
(`MasterNodeManager`, `NodeManagerLifecycle`, `HistorianDispatcher`).

**`ISession` is 38 → 32 members.**

## Commits

### `882b2937` Type the history continuation point

`ISession` leaked `object` twice, against the repository rule:

```csharp
void SaveHistoryContinuationPoint(Guid id, object continuationPoint);
object? RestoreHistoryContinuationPoint(ByteString continuationPoint);
```

The erasure was **not** hiding a genuinely opaque plugin token.
`HistorianDispatcher` is the
only production caller, it always passes a `HistorianContinuationState`,
and it casts
straight back to that type on restore. The `object` was a round trip
through the type system
that bought nothing.

`HistorianContinuationState` is `internal`, so it cannot appear on a
public interface.
`IHistoryContinuationPoint` names the concept instead: it carries an
`Id` and extends
`IDisposable`.

Two latent bugs fell out of typing it:

- The session disposed only those points that **happened** to implement
`IDisposable` and
**silently leaked the rest**. Every point is now disposable by
construction, and three
  `as IDisposable` probes are gone.
- Carrying the `Id` on the point removes the separate `Guid` parameter,
which could disagree
with the payload it was filed under. The mock stores in the historian
tests had the same
  latent inconsistency.

### `0d020b77` Delete the obsolete synchronous `ValidateBeforeActivate`

**Zero production callers** — the nine references were all tests written
for it. `[Obsolete]`
since 1.5.378, which the repository rules allow removing in master.

It is also the divergent-signature problem the runtime analysis flagged:
the async form
returns the validated handler and policy as a tuple, while the sync one
took the same two
values as trailing `out` parameters, so a caller had to learn two shapes
for one operation.

The overload could not verify a user token requiring decryption, so on a
secure endpoint it
failed closed and told the caller to use the async path anyway. The one
test asserting that
fail-closed behaviour goes with the method it described; the path it
pointed at is already
covered by `ActivationValidationDecodesBinaryEncodedUserIdentityToken`.

The other eight tests are **converted, not deleted** — the channel,
signature,
security-policy and token-policy validation they cover is still
asserted, now against the
path the server actually uses.

### `821e8302` Route the continuation points behind one member

Five `ISession` members were one-line pass-throughs to
`SessionContinuationPoints`, a
419-line module that already owns the behaviour and already has its own
`IContinuationPointStore` seam. The interface restated the module's
surface without adding
anything.

```csharp
// was
session.SaveContinuationPoint(cp);
session.RestoreHistoryContinuationPoint(bytes);

// now
session.ContinuationPoints.SaveBrowse(cp);
session.ContinuationPoints.RestoreHistory(bytes);
```

The holder is `internal sealed`, so `ISessionContinuationPoints` is what
makes it
addressable.

**This is composition, not the service locator `IServerContext` refused
to be.** A subsystem
with an independent lifetime should be injected; continuation points
belong to the session
and cannot exist without it.

`InvalidateContinuationPoints` called `RemoveBrowseForManager` and
`RemoveHistoryForManager`
in sequence, so the pair becomes `RemoveForManager` — the caller's
intent is that a node
manager is going away, not that two collections need visiting.

## Tests

Three harnesses had hand-rolled a `Dictionary<Guid, object>` to stand in
for the holder. They
now use the **real** one, so the dispatcher and `BrowseNext` meet the
eviction and disposal
rules a live session actually applies.

**One hazard worth recording for future interface work.** A Moq member
that is never stubbed
returns `null`. Where the old code called a method straight on
`ISession`, that `null` was
harmless. Reaching *through* a property means an unstubbed
`ContinuationPoints` null-derefs
instead. Three tests failed exactly that way; they are wired to a real
holder, not restubbed.

| Suite | net10.0 | net48 |
|---|---|---|
| `Opc.Ua.Server.Tests` | 4032 passed, 1 failed | builds clean |
| Touched areas (continuation points, historian, master node manager,
session) | 898 passed | — |

The single failure is
`ConfigureApplicationBuildsSharedClientAndServerConfigurationAsync`, a
local certificate-store problem verified to reproduce unchanged at HEAD
with these changes
stashed. Not caused by this PR.

`Opc.Ua.Redundancy.Server`, `Opc.Ua.Di.Server`, `Quickstarts.Servers`
and
`ConsoleReferenceServer` all build clean.

## Docs

`docs/MigrationGuide.md` gains a "Removed members on ISession" section
covering the
`ValidateBeforeActivate` removal with the async replacement, and the
`IHistoryContinuationPoint` change with before/after.

## The SessionDiagnostics removal (folded in from #4217)

`ISession.SessionDiagnostics` handed out the whole mutable
`SessionDiagnosticsDataType` —
the structure the session's diagnostics lock protects — so a caller
could read a field while
the owner was writing it. Every server-side reader wanted exactly one
value out of it, so the
interface now exposes those two values (`SessionName`,
`ClientApplicationUri`) directly and
the structure stays behind the lock.

That work landed here as #4217 rather than as a separate PR, and it also
fixes #4203:
`OnUpdateDiagnostics` now takes `m_diagnosticsLock` instead of locking
the diagnostics
payload it was handed.

## Reviewer questions

- `ISessionContinuationPoints` exposes `MaxBrowse` as read-only while
the concrete holder
keeps a setter (the test framework adjusts it to exercise limits).
Should the setter be on
the interface, or is test-only mutation better left to the concrete
type?
- `RemoveForManager` collapses two removal methods. The concrete
`RemoveBrowseForManager`/`RemoveHistoryForManager` remain public on the
holder — worth
  making them private now that nothing outside calls them individually?

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e9c13dd0-a6e3-4ad2-8ad6-1c3fe6323041
2026-08-11 06:57:40 +02:00
Marc Schier 9375a2431b Retire the IServerInternal bind mutators and freeze the bind phase (#4197)
## Stacked on #4193

Base is `marcschier/server-context`, which is itself based on #4188.
**Review #4188 and
#4193 first.** GitHub retargets each base automatically as the stack
merges.

Closes #4184.

## What this does

`IServerInternal` published twelve `Set*` binding methods plus
`CreateServerObjectAsync`,
so any holder of the interface could rewire a running server. This takes
them off the
interface and makes a late bind fail loudly.

## The audit changed the shape of the work

The plan this came from said "design a frozen bind phase". **One already
exists.**
`StandardServer` has a seventeen-method `Create*` factory seam, and
`DependencyInjectionStandardServer` overrides seven of those to resolve
from the container:

```csharp
protected override IRoleManager CreateRoleManager(IServerInternal server, ApplicationConfiguration configuration)
    => m_services.GetService<IRoleManager>() ?? base.CreateRoleManager(server, configuration);
```

`StandardServer` then carries each factory result into the datastore:

```csharp
ServerInternal.SetRoleManager(CreateRoleManager(m_serverInternal, configuration));
```

So an integrator already had two supported routes — override the
virtual, or register in
DI. `Set*` on the interface was a **third route that bypassed the
ordering**, and the only
one that let a caller rebind a running server.

The job was therefore to stop publishing plumbing, not to build a phase.

## Measurement

Every call site of the twelve mutators plus `CreateServerObjectAsync`:

| Area | Count | Where |
|---|---:|---|
| `Opc.Ua.Server` | **11** | `StandardServer.cs:3729–3844`, one
contiguous block |
| samples | **1** | `ReferenceServer.cs:219`, inside a
`CreateMasterNodeManager` override |
| tests | 20 | `ServerInternalDataTests` (18), `SubscriptionTests` (2) —
**all on the concrete `ServerInternalData`** |

`SetIdentityRegistry` had **zero** call sites anywhere.

The earlier estimate for this work was 268 files. That number counted
files *referencing*
`IServerInternal`, which this change never touches: the interface
survives and only the
mutators move off it.

## Commits

### `e719a88a` Add the missing `CreateUserManagement` factory seam

The one gap, and the reason this had to land first. User management was
the only subsystem
with **no factory seam** — the only way to populate
`IServerInternal.UserManagement` was
`SetUserManagement`, which is what `ReferenceServer` called from inside
a
`CreateMasterNodeManager` override. Removing the mutator without a
replacement would have
broken that scenario with no migration path.

The DI path at `OpcUaServerBuilderExtensions.cs:1178` builds a
`UserNamePasswordAuthenticator` from a registered `IUserManagement` — a
different thing
entirely that never reaches the Part 18 §5 model on the address space.

Purely additive. `ReferenceServer` moves onto the override.

The test covers the **override** seam rather than the DI seam
deliberately: registering an
`IUserManagement` in the container also switches on the
username/password authenticator, so
a DI-based test exercises two unrelated paths and fails for reasons
unrelated to binding.

### `cbcfda47` Remove `SetIdentityRegistry`

Zero call sites, despite a doc comment advertising it as the integrator
seam. The supported
route has always been
`ServerIdentityRegistryExtensions.RegisterDefaultAuthenticators`,
which *adds* authenticators to the default registry rather than
replacing it.
`IdentityRegistry` is read five times outside `Opc.Ua.Server`, so the
default is what every
consumer already uses.

**The analyzer confirmed the reading**: with the setter gone, `RCS1170`
fired on the
property — it is provably never reassigned after construction, so it is
now read-only.

### `ea0937a2` Take the bind mutators off `IServerInternal`

The mutators stay `public` on `ServerInternalData`, which owns the
state. Only the interface
loses them. The eleven call sites switch from the `ServerInternal`
property to the
`m_serverInternal` field, already typed `ServerInternalData?` — the same
one-token change
`GetStatus` and `EnterState` took in #4193.

**No test changed.** All twenty test call sites already used the
concrete type, which is the
clearest evidence these were never interface-level API.

`CS1574` found six doc comments across `IRoleManager`, `RoleManager`,
`IUserManagement`,
`UserManagement`, `IServerInternal` and `SessionManager` that pointed
integrators at the
mutators. They now point at the factory seam.

### `f3c4b9d3` Refuse to bind after the server has started

Each bind goes through a guard; `StandardServer` closes the phase once
the session manager
is in place. A late `Set*` throws
`ServiceResultException(BadInvalidState)` **naming the
operation** and pointing at the factory seam — otherwise the failure is
indistinguishable
from a configuration problem.

Rebinding *during* startup stays legal, which is what let both audit
hazards resolve with no
behaviour change:

- **`SetNodeManager` also binds three more managers**
(`DiagnosticsNodeManager`,
`ConfigurationNodeManager`, `CoreNodeManager`). Intentional — they are
reached through the
master node manager and have no independent lifetime — and now
documented rather than
  silent.
- **`SetSessionManager` unhooks the previous manager's handlers**, which
reads as
written-for-repeat-calls. Nothing rebinds it, but the freeze does not
make the path dead
either, since binding twice during startup is still allowed. Left as-is;
removing it is a
  separate question.

## Tests

Four new tests on the freeze: the refusal itself across four
representative mutators, that
the message names the operation, that rebinding stays legal until the
phase closes, and that
closing twice is harmless. Plus one hosted-server test for the
`CreateUserManagement` seam.

| Suite | net10.0 | net48 |
|---|---|---|
| `Opc.Ua.Server.Tests` | 4033 passed, 1 failed | 230 passed, 1 failed
(filtered) |
| `Opc.Ua.Redundancy.Server.Tests` | 573 passed | — |
| `Opc.Ua.Redundancy.Kubernetes.Tests` | 97 passed | n/a (net8/net10
only) |

The single failure is
`ConfigureApplicationBuildsSharedClientAndServerConfigurationAsync`, a
local certificate-store problem verified to reproduce unchanged at HEAD
with these changes
stashed. Not caused by this PR.

## Docs

`docs/MigrationGuide.md` gains a table mapping every removed mutator to
the `Create*`
override and the DI registration that replace it, plus the
`SetIdentityRegistry` removal
with its rationale.

## Net effect across #4193 and this PR

`IServerInternal` goes from ~460 lines to **~200**. It no longer
publishes locks, dead
members, or the ability to rewire a running server.

## Reviewer questions

- `CompleteBindPhase()` is public on `ServerInternalData` but
deliberately **not** on
`IServerInternal` — only the server owning the startup sequence should
decide when binding
  is over. Reasonable, or should it be `internal`?
- `SetSessionManager`'s unhook path is now unreachable in practice but
still legal during
startup. Leave it, or remove it and assert single-bind for that one
subsystem?

---------

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
2026-08-11 00:27:45 +02:00
Marc Schier 4e0b82af5f Add IServerContext and move server startup tasks onto it (#4193)
## Stacked on #4188

Base is `marcschier/server-seam-analysis`, not `master`.
`IServerInternal.cs` and
`ServerInternalData.cs` both diverge from master on that branch (chunk
4c removed the two
`object` locks), so branching off master would conflict on both files
*and* design
`IServerContext` against a stale interface. GitHub retargets the base to
`master`
automatically when #4188 merges. **Review #4188 first.**

Closes part of #4184.

## What this does

`IServerInternal` is a service locator: 56 members, 30-odd of which hand
out one of 20+
subsystems. A component that only needs to observe the server and act on
it has to take the
whole thing, and no signature tells a reader which parts a component
actually touches.

This adds `IServerContext` — the ambient view of a running server — and
moves
`IServerStartupTask` onto it.

```csharp
public interface IServerContext
{
    ServerState CurrentState { get; }
    ServerObjectState ServerObject { get; }
    IServiceMessageContext MessageContext { get; }
    ServerSystemContext DefaultSystemContext { get; }

    ServerSystemContext CreateSystemContext(ISession session);
    T? FindPredefinedNode<T>(NodeId nodeId) where T : NodeState;
    IEnumerable<T> FindNodeManagers<T>() where T : class;

    ValueTask ReportEventAsync(IFilterTarget e, CancellationToken ct = default);
    ValueTask CloseSessionAsync(OperationContext context, NodeId sessionId,
        bool deleteSubscriptions, CancellationToken ct = default);
    ValueTask DeleteSubscriptionAsync(uint subscriptionId, CancellationToken ct = default);

    void UpdateServerDiagnostics(Action<ServerDiagnosticsSummaryDataType> update);
}
```

Nothing on it returns a subsystem. A component that needs one takes it
as a constructor
dependency, which states the requirement instead of hiding it behind an
ambient handle.

`IServerInternal : IServerContext`, so every existing handle already
satisfies the new seam
and consumers migrate one at a time.

## The member set was measured, not guessed

Every member of `IServerInternal` was counted by read site across `src`,
`samples` and
`tests`, split by area. `Ext` is non-test production consumers, which is
what decides
whether something belongs on an ambient interface.

| Member | InServer | OtherSrc | Sample | Test | **Ext** | On
`ISystemContext`? |
|---|---:|---:|---:|---:|---:|---|
| `NamespaceUris` | 63 | 50 | 34 | 131 | **84** | yes |
| `Telemetry` | 58 | 23 | 35 | 5 | **58** | yes |
| `MessageContext` | 2 | 8 | 7 | 11 | **15** | no |
| `TypeTree` | 42 | 3 | 11 | 10 | **14** | yes, as `TypeTable` |
| `NodeManager` | 66 | 7 | 6 | 74 | **13** | no |
| `DefaultSystemContext` | 21 | 4 | 1 | 7 | **5** | no |
| `IdentityRegistry` | 1 | 4 | 1 | 1 | **5** | no |
| `SubscriptionManager` | 23 | 0 | 0 | 17 | **0** | no |
| `SessionManager` | 21 | 0 | 0 | 15 | **0** | no |
| `RequestManager` | 10 | 0 | 0 | 23 | **0** | no |
| `AggregateManager` | 12 | 0 | 0 | 2 | **0** | no |
| `ModellingRulesManager`, `ConformanceUnitsManager`,
`DiagnosticsEnabled` | 0 | 0 | 0 | 0 | **0** | no |

Two things fall out. The highest-traffic members are already carried by
`ISystemContext`,
which `ServerSystemContext` copies off the server at construction — so
most of those 278
`NamespaceUris` reads are callers reaching through the server for
something they already
hold. And the subsystems with zero external demand are exactly the ones
that should be
injected rather than published.

The check that matters: the eight `IServerStartupTask` implementations
in
`Opc.Ua.Redundancy.Server` are the heaviest real consumer of the ambient
handle, and
between them they touch **eight distinct members**. That is the shape
the interface is cut
to.

## Commits

### `e4a912eb` Remove seven unused members from `IServerInternal`

The deletion test, applied before anything is designed against the
interface.

| Removed | Evidence |
|---|---|
| `CloseSession(OperationContext, NodeId, bool)` | zero call sites;
sync-over-async; already obsolete |
| `ReadServerDiagnostics<TResult>` | zero production sites — **added one
commit earlier in #4188 for symmetry and never called** |
| `ModellingRulesManager` | property never read; `ServerInternalData`
owns the lifetime |
| `ConformanceUnitsManager` | same |
| `DiagnosticsEnabled` | façade over
`IDiagnosticsNodeManager.DiagnosticsEnabled`, which is what callers use
|
| `Status` | one site, itself inside an obsolete method |
| `ServerDiagnostics` | only reachable through the diagnostics methods
that replaced it |

`EndpointAddresses` was kept — `Opc.Ua.Di.Tests` uses it to resolve an
endpoint URL. Thin,
but real.

The only build fallout was three lines in one test assembly.
`Opc.Ua.Redundancy.Server`,
`Opc.Ua.Redundancy.Kubernetes`, `Opc.Ua.Gds.Server.Common`,
`Opc.Ua.WotCon.Server`,
`Opc.Ua.PubSub.Server`, `Opc.Ua.Di.Server`, `ConsoleReferenceServer` and
`Quickstarts.Servers` all built untouched. That is the evidence the
members were dead.

### `ffcc80b9` Add `IServerContext`

Additive. `ServerInternalData` implements it; no caller changes.

### `e8c4e15f` Hand startup tasks `IServerContext`

Nine implementations, `OpcUaServerHostedService`, and the test call
sites.

### `1b4de97a` Drop the `IServerStartupTask` migration-guide section

Review feedback. `IServerStartupTask` does not exist in `master378`, so
it is a 2.0-only
concept and nobody migrating from 1.5.378 can own an implementation of
it. The **Removed
members on `IServerInternal`** section stays - those members *are* in
master378 - and is now
self-contained.

### `2ab7d689` Merge `marcschier/server-seam-analysis`

Picks up the base branch including its merge of master. Textually clean,
no conflicts.

### `6a650f12` Migrate `RobotIntentHostStartupTask` to `IServerContext`

The merge was textually clean but **semantically broken**: master's
Robot Intent feature
(#4165) added a tenth `IServerStartupTask` implementation written
against the old
`IServerInternal` signature, so it no longer satisfied the interface.
Its body walked
`server.NodeManager.AsyncNodeManagers` and type-tested each entry for
`RobotIntentNodeManager` - exactly the pattern `FindNodeManagers<T>()`
replaces - so it
migrated the same way as the other nine. `FindNodeManagers` also
searches the synchronous
registry and de-dupes by reference, so a manager registered on both
faces still starts its
intent controller hosts exactly once.

## Three findings from migrating real consumers

**`MessageContext` is not derivable.** The design had callers use
`DefaultSystemContext.AsMessageContext()`, on the reasoning that a
message context can be
built from a system context. It can't: that conversion carries the
namespace tables and the
factory but **not** `MaxStringLength`, `MaxArrayLength` or
`MaxByteStringLength`. Four
replication paths in `Opc.Ua.Redundancy.Server` would have silently
fallen back to default
decoding limits — a component quietly accepting more than the server is
configured to. It
is a real member, and the interface documents why.

**`new`-shadowed members split the mock surface.** `IServerInternal`
first redeclared
`DefaultSystemContext` with a narrower type and `CurrentState` with a
setter. That
compiles, but a shadowed member is a *second interface slot*: production
code calling
through `IServerContext` read a slot that every existing
`Mock<IServerInternal>` leaves
unstubbed. Seventeen tests went from passing to `NullReferenceException`
**while still
compiling**. The narrower typing bought no encapsulation anyway — the
object really is a
`ServerSystemContext` and any caller can cast — so both shadows are
gone. `CurrentState` is
read-only on the ambient interface and the one site that assigns it uses
the concrete type,
as `GetStatus` already does.

**`IServiceProvider` is deliberately *not* on `OnServerStartedAsync`.**
The plan sketched
it. All nine implementations already receive their dependencies through
their constructors
from the builder extensions, so a container on the callback would have
replaced one service
locator with another.

## `FindNodeManagers<T>`

Two tasks walked `server.NodeManager.NodeManagers` and type-tested each
entry for
`ILocalAddressSpaceSource`; two more reached for a specific manager to
cast it to a
capability. Asking for the capability says what the caller wants; asking
for the registry
says how to find it.

```csharp
// before
foreach (INodeManager nodeManager in server.NodeManager.NodeManagers)
{
    if (nodeManager is not ILocalAddressSpaceSource source) { continue; }
    ...
}

// after
foreach (ILocalAddressSpaceSource source in server.FindNodeManagers<ILocalAddressSpaceSource>())
{
    ...
}
```

It searches both the asynchronous and synchronous registries:
`ToSyncNodeManager()` returns
`this` when a manager already implements `INodeManager`, so the two
lists overlap and a
capability may sit on either face.

## Tests

Mock harnesses are **wired, not stubbed** — they return the node
managers and contexts the
tests already build, so the tests keep asserting against real behaviour
rather than against
a mock that returns whatever is convenient. Four new tests cover
`CreateSystemContext`
(identity/locale propagation, null guard), the explicit
`DefaultSystemContext` identity,
and `FindPredefinedNode` before the address space is bound.

| Suite | net10.0 | net48 |
|---|---|---|
| `Opc.Ua.Redundancy.Server.Tests` | 573 passed | 573 passed |
| `Opc.Ua.Redundancy.Kubernetes.Tests` | 97 passed | n/a (net8/net10
only) |
| `Opc.Ua.Server.Tests` | 4028 passed, 1 failed | 225 passed, 1 failed
(filtered) |

The single failure is
`ConfigureApplicationBuildsSharedClientAndServerConfigurationAsync`,
a local certificate-store problem. Verified it reproduces unchanged at
HEAD with these
changes stashed — it is not caused by this PR.

All twelve dependent assemblies and samples build clean on both TFMs,
zero warnings.

### Re-verified after the base merge (`6a650f12`)

`Opc.Ua.Server`, `Redundancy.Server`, `Redundancy.Kubernetes`,
`Robotics.Server`,
`WotCon.Server`, `Di.Server`, `PubSub.Server`, `Gds.Server.Common` and
`XRegistry.Server` all build clean on net10.0. `Opc.Ua.Robotics.Tests`
491 passed,
`Opc.Ua.Redundancy.Server.Tests` 573 passed,
`Opc.Ua.Redundancy.Kubernetes.Tests` 97 passed.

### CI status: 119 passed, 3 failed - none caused by this PR

| Failing job | Why it is not this PR |
|---|---|
| `SourceGeneration_Core_Tests_windows` (net48) |
`GenerateAndCompileDesignFileAsync(..., CSharp11, ...)` fails with
`CS9058: Feature 'Lock object' is not available in C# 11.0` because
generated `Opc.Ua.NodeStates.g.cs` emits `System.Threading.Lock`.
**Identical failure on base #4188 (build 16737)**, which contains none
of these commits. This PR touches no source-generation file. |
| `SourceGeneration_Core_Tests_windows` (net10.0) | Same root cause;
also failing on base #4188. |
| `PubSub_Eth_Tests_windows` (net48) | Agent DNS outage:
`NuGetToolInstaller` died with `ERR:getaddrinfo ENOTFOUND
dist.nuget.org`. The job aborted before restore/build, so **the test
step never executed**. Infra flake; needs a re-run, not a code change. |

Both `SourceGeneration.Core` failures belong to the base branch and
should be fixed there
rather than papered over here.

## Correction to the recorded plan

Chunk 7 (the frozen bind phase) was recorded as the largest mechanical
footprint remaining
and the one most likely to stall. It isn't: **each of the 12 `Set*`
mutators has exactly one
call site**, in the `StandardServer` wiring, plus one sample. It is a
~13 call-site change.
The 268-file figure that produced that assessment counted files
*referencing*
`IServerInternal`, which chunk 7 never has to touch, because the
interface survives and only
the mutators move off it.

## Docs

`docs/MigrationGuide.md` gains one section: the removed
`IServerInternal` members with their
replacements, plus the note that `MessageContext`,
`DefaultSystemContext`, `CurrentState`,
`ServerObject`, `ReportEventAsync`, `CloseSessionAsync`,
`DeleteSubscriptionAsync` and
`UpdateServerDiagnostics` moved *down* to `IServerContext` and stay
reachable through
`IServerInternal` unchanged, so no call site has to move.

An earlier revision also documented the `IServerStartupTask` signature
change there. That
was dropped after review (1b4de97a4): `IServerStartupTask` does not
exist in `master378`, so
it is a 2.0-only concept and nobody migrating from 1.5.378 can own an
implementation of it.
The rationale for the ambient view - including the `AsMessageContext()`
decoding-limit trap -
lives on the `IServerContext` and `IServerStartupTask` XML docs instead,
which is where a
2.0-only concept belongs.

## Reviewer questions

- `IServerContext` is 11 members, not the 9 the design targeted.
`MessageContext` and
`FindNodeManagers<T>` were added because migrating real consumers showed
the alternatives
were wrong (silent decoding-limit loss) or a pass-through
(`NodeManager.NodeManagers` plus
  a type test at every call site). Reasonable?
- `IServerInternal : IServerContext` keeps the fat interface alive on
purpose — it is what
makes this a signature change rather than a 268-file rewrite. It only
shrinks once the DI
migration lands. The 6a deletions are unconditional, so even a full
stall leaves it seven
  members smaller.

---------

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
2026-08-10 21:14:09 +02:00
Marc Schier e05a820c91 Removal of the exposed diagnostics and node locks (#4188)
## What this is

Design analysis for `Opc.Ua.Server`, the two rounds of design
exploration behind it, and the first two implementation chunks it
recommends.

Draft: the analyses are ready to read, but the *conclusions* deserve
discussion before the larger phases proceed. Later chunks stack onto
this PR as they land.

| File | Kind | Lines |
|---|---|---:|
| `plans/NodeManagerAnalysis.md` | Evidence — the node-manager plugin
surface | 488 |
| `plans/ServerRuntimeAnalysis.md` | Evidence — the server runtime
cluster | 576 |
| `plans/ServerDesignExploration.md` | Design round 2 — delivery
strategy | 1,394 |
| `plans/ServerDesignExplorationAlt.md` | Design round 1 — four
interface designs | 2,119 |
| `src/Opc.Ua.Types/State/NodeState.cs` | **Code** — node self-guards
attributes | |
| `src/Opc.Ua.Types/State/BaseVariableState.cs` | **Code** — value
accessors take the lock | |
| `src/Opc.Ua.Server/NodeManager/AsyncCustomNodeManager.cs` | **Code** —
6 external locks removed | |
| `tests/…/NodeStateAttributeConcurrencyTests.cs` | **Code** —
concurrency safety net | 449 |

`docs/` is untouched — it holds shipped feature documentation, and none
of this is shipped API.

## The analyses

Counted mechanically over `src`, `samples` and `tests`; **every figure
is reproducible** from commands printed in each document.

**Node manager surface.** `AsyncCustomNodeManager` declares **93
`virtual`/`abstract` members** (80 distinct names). Across the
repository: **112 override sites, 25 distinct names ever overridden, 55
of 80 never**. Six carry nearly all real use. For scale,
`ReferenceNodeManager` is 6,373 lines and overrides **8**.

**Server runtime.** Seven findings, **two of which recommend no
change**. Findings 1–5 are filed as #4183–#4187. The headline is an
`object`-typed sync root published on three public interfaces and taken
in **88 `lock` statements across 7 files**, including a shipped sample.

**What is deliberately not flagged.** `IStandardServer` — 9 members over
5,575 lines, 16 of 37 virtuals used — is identified as the reference
shape to preserve. `IApplicationInstance` and the already-`[Obsolete]`
`Stop()` are examined and left alone.

## The design exploration

**Round 1** explored four alternative interfaces in parallel under the
current back-compat constraint. Its value is the convergence: four
designers who never saw each other's work independently arrived at a 6–9
member `IServerContext` replacing the 57-member locator, removal of the
`object` node-manager handle, elimination of the `Processed` protocol,
and `IHistorianProvider` as the pattern to copy.

**Round 2** re-runs it with back-compat lifted and asks how to deliver:
full rewrite versus side-by-side fork. **Both option designers
independently declined to recommend their own assignment.** Three
structural facts were verified:

1. **The fork line already sits at `IAsyncNodeManager`** — old and new
node managers can coexist with zero runtime changes, so a fork
duplicates ~10% of the assembly, not all of it.
2. **`[OpcUaShim]` cannot shim an inheritance surface** — honest
auto-fix ceiling for the authoring surface is **~10%**, against ~70% for
the shipped analyzers.
3. **A second lock leak exists** — `NodeState` takes `lock (this)` under
a `CA2002` suppression whose own comment documents the contract.

Recommendation: **neither pure option**, but a staged path reaching the
rewrite end state incrementally, gated on migrating the five
framework-internal node managers that today make it impossible for the
old base to ever freeze.

## Implementation: NodeState now guards its own attributes

This removes the prose-only contract in which `NodeState` took
`lock(this)` and every external caller was required to take
`lock(source)` on the node instance. The code already carried a TODO
describing exactly this fix and noting it required updating every
external site.

- The node self-guards rather than relying on callers.
`BaseVariableState` gains a **fully private** `System.Threading.Lock`
covering its value fields; `NodeState`'s other collections keep their
existing private locks.
- All **6** `lock(this)` sites and both `CA2002`/`RCS1059` suppressions
are gone.
- `Value`, `Timestamp` and `StatusCode` on `BaseVariableState` take the
lock too — without that, a direct property write raced an attribute read
once the external lock was gone.
- **6 of 9** external sites removed. Five were already redundant
(`GetReferences`, `GetChildren`, `ReferenceExists` and
`SetAreEventsMonitored` guard themselves; `FindMethod` reaches its state
via `GetChildren`); the sixth was an attribute read now covered by the
node's own lock.
- Attribute read/write deliberately take **no** node-wide lock: both
dispatch to caller-supplied handlers (`OnReadValue` and siblings) that
may take locks of their own, and holding a lock on the node across one
gives the node no fixed place in the lock order.
- **3 deliberately retained and annotated in code**: two `CreateBrowser`
calls, which are not internally synchronized (tracked in #4216), and one
in `CustomNodeManager`, whose class is slated for deletion so
refactoring its lock would be wasted work.

### No lock is exposed anywhere, to anyone

An earlier revision put the attribute lock on `NodeState` as `private
protected`, which still published a lock-sharing contract on the
inheritance surface. Review pushed back and it is gone:
`BaseVariableState` was the only user, so the lock moved there as a
plain `private readonly Lock`. There is no member for a derived type to
call, in this assembly or any other.

`BaseVariableValue` was the last public leak — it handed out the lock
guarding its value through a `public Lock` property, so any caller could
hold it across a call back into the stack. That property is removed:

- Derived value classes (what the source generator emits for every
structure variable) synchronize through a `protected EnterLock()` /
`ExitLock()` pair. Callback helpers were the first choice, but the
generated bodies pass `ref Variant` / `ref StatusCode` / `ref
DateTimeUtc`, and `ref` arguments cannot be captured by a lambda. The
lock *object* stays private either way.
- `NodeStateTemplates` emits the new form.
- `ServerInternalData`, the one cross-assembly caller, was reading the
lock back off a value it had itself constructed with that lock. It now
takes `m_diagnosticsLock` directly.

`NodeBrowser.DataLock` remains `protected` and is tracked in #4215: a
subclass extending `Next()` legitimately needs its enumeration state
atomic with the base browser's, so the fix needs a base-owned
acquisition rather than a deletion.

### Migration coverage for the removed lock members

None of the removed lock members get an `[Obsolete]` shim, and the
migration guide now says why: a shim would have to be `object`-typed to
keep old call sites compiling, and on .NET 9+ `lock (o)` on a
statically-`object` reference is `Monitor.Enter`, which does **not**
interlock with `Lock.Enter()`. It would compile, run, and silently
synchronize nothing — strictly worse than a compile error. Re-adding a
member to a downstream-implemented interface is also a break in its own
right on `net48` / `netstandard2.0`, where there are no default
interface methods.

Instead, every removed member has a named replacement on the migration
path:

| Rule | Covers | Replacement it names |
|---|---|---|
| `UA0024` | `IServerInternal` / `ISession` / `ISubscription`
`DiagnosticsLock`, `DiagnosticsWriteLock` |
`UpdateServerDiagnostics(...)` / `UpdateDiagnostics(...)`,
`ReadServerDiagnostics` / `ReadDiagnostics` |
| `UA0025` | `ILocalNode.DataLock`, `Node.DataLock` | a lock the caller
owns; the node guards its own state |
| `UA0026` | `BaseVariableValue.Lock` | a lock passed to the
`BaseVariableValue` constructor |

Each rule fires on sources still written against 1.5.378, where the
compiler only says a member is missing. `docs/MigrationGuide.md` gains a
section per removal with before/after.

### The tests earned their keep

The four concurrency tests were landed **first**, against the unmodified
code, because removing a synchronization contract cannot be verified by
the compiler and the existing `NodeStateHandlerConcurrencyTests` covers
a different concern (racing a handler assignment against a write, not
reader/writer exclusion). They now run with **no external locking**,
which is what demonstrates the node guards itself.

They caught two real problems, and one of them was mine:

- The `Value`/`StatusCode`/`Timestamp` setters bypassed the new lock — a
genuine race.
- One test asserted an invariant the design does not offer. Two property
setters are two lock acquisitions, so that pair was **never** atomic; it
only passed originally because the test held a lock across both. It now
uses a single attribute write carrying a whole `DataValue`, which
genuinely is atomic and is the path the server uses.

A third issue was found and fixed before it shipped: the reader loops
gated on `!writer.IsCompleted`, so a reader scheduled after the writer
finished performed zero reads — failing two runs in three on `net48`.
Converted to `do…while`; now five consecutive green runs.

**Verified:** `Stack.State` 116 passed on `net10.0` *and* `net48`; the
concurrency category green 5/5 consecutive on `net48`;
`Opc.Ua.Types.Tests` 8,456 passed; the server node-manager suite 1,205
passed, including `NodeManagerLifecycleTests` and
`AsyncCustomNodeManagerTests` which exercise the changed paths.

## Two corrections to my own published figures

Both were found while starting implementation, and both are recorded on
#4183.

**`ForceDiagnosticsScan()` was overstated.** The analysis claimed the
`DiagnosticsWriteLock` getter *"triggers a full address-space
diagnostics rescan"*. It is in fact a single flag assignment
(`DiagnosticsNodeManager.cs:700-703`) consumed by the read paths and the
periodic timer. The defect stands — a side-effecting property getter
with a racy write on a synchronisation path — but its cost was
misstated.

**The contract was 15 sites, not 19.** A naive search matched ten
comment and `#pragma` lines that *mention* `lock(source)` while
explaining it. The documents now carry a counting command that excludes
comment matches.

A third finding expanded scope the other way: the `object`-typed lock
leak is **repo-wide**, with ten such members across `Opc.Ua.Core`,
`Opc.Ua.Types` and `Opc.Ua.Server` — including `Node.DataLock => this`
and `ApplicationConfiguration.PropertiesLock => m_properties`, the same
defect class as `Subscription.DiagnosticsLock`.

## Caveats stated in the documents

- **Nothing in the designs is implemented or benchmarked.** Every
performance claim across all seven designs is a design intention, not a
measurement.
- The larger phases are a **major undertaking against a live plugin
API**; the documents say so rather than letting the neatness of the
proposals imply otherwise.
- The recommended sequencing puts low-risk, self-contained steps first
and notes they are **worth doing regardless** of which strategy is
eventually chosen.
- Round 2 notes the two option designers' cost estimates are **not
independent of their assignments**.

## Review guidance

The two analyses make factual claims checkable by running the included
commands. The design explorations are exploratory and need not be agreed
with to merge. The code change is small, test-backed, and independent of
which delivery strategy is eventually chosen.

Reasonable outcomes include merging as-is, merging only the analyses and
the lock fix, or disagreeing with the recommendation while keeping the
evidence.

---

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e9c13dd0-a6e3-4ad2-8ad6-1c3fe6323041
Copilot-Session: d18f87e9-2484-44ce-98ce-463ba6cf5f63
2026-08-10 06:47:50 +02:00
Marc Schier 0612a43fe4 Pluggable cryptography and hardware-held private keys (#4192)
Makes the stack's cryptographic operations replaceable - by another library, an offboard service, or hardware (TPM 2.0 / HSM / PKCS#11 token / cloud KMS) - with no performance cost in the default configuration, and extends this to certificates so a private key need never exist in process memory.

Also fixes three pre-existing defects found while validating: STACKGEN001 erroring for every compilation the stack generator was merely loaded into, certificates failing to load from paths past MAX_PATH on Windows, and the MonitoredNode2 tests starving the thread pool.

Fixes #4190
2026-08-09 12:40:26 +02:00
Marc Schier fb80bfdc82 [WotCon] Track WoT Connectivity/Binding 1.1 and add projections (Section 12) (#4182)
Follow-up to #4128, which merged the WoT Connectivity 1.1 work against
**draft2** of the companion specifications. Both drafts have moved
since, and this branch tracks them through to **draft3**, adds the
projection feature they introduced, and closes the gaps a bidirectional
re-audit found.

The specifications are the private
[`OPCF-Members/spec-drafts`](https://github.com/OPCF-Members/spec-drafts)
drafts of *OPC UA — WoT Connectivity 1.1* and *OPC UA — WoT Binding
1.1*. They are still drafts, which is why this is opened as a draft PR —
see [Status](#status) at the bottom.

## What changed in the specifications

Two revisions landed upstream while #4128 was in flight.

**draft2** replaced the `eventGroup` / `actionGroup` grouping constructs
with a single **projection / view** concept, added fifteen Section 6
vocabulary terms, and made two identifier rules normative. **draft3**
([spec PR #8](https://github.com/OPCF-Members/spec-drafts/pull/8))
un-deprecated the incorporated OPC 10100-1 v1.02 surface, restructured
the conformance clause into three profiles, and tightened two security
obligations.

The draft3 NodeSet is **structurally identical** to draft2 — I diffed
all 286 nodes node-by-node. Every change is metadata: 96
`ReleaseStatus="Deprecated"` attributes removed, `PublicationDate`
advanced, and `WOTC-ProjectionMaterialization` claimed by three Type
Nodes that previously implemented it in name only.

## Projections (WoT Binding Section 12)

The substantial addition. A projection document selects affordances from
other Thing Descriptions or Thing Models and resolves to a document of
its own; materialized, it becomes an OPC UA `View` that **creates no
affordance Node** and only `Organizes` what its sources already
materialized.

| Piece | Where |
|---|---|
| Document model and validation | `Opc.Ua.Types/Wot/WotProjection.cs`,
`WotProjectionModel.cs` |
| Four-stage resolution engine |
`Opc.Ua.Types/Wot/WotProjectionResolver.cs` |
| Materialization plan |
`Opc.Ua.WotCon.Server/Materialization/WotViewProjectionPlan.cs`,
`WotProjectionViewBuilder.cs` |
| Live address-space host | `WotProjectionViewNodeManager.cs`,
`LifecycleWotViewProjectionHost.cs`, `IWotViewProjectionHost.cs` |
| Node lookup seam | `IWotMaterializedNodeIndex.cs` |

Groups reached by `ua:Organizes` become organizational Objects rather
than nested Views, only the outermost materialization is a `View`, and a
member whose source is served by another server is omitted and reported
rather than recreated — the federation case the clause calls out.

### How this is validated

The specification publishes twenty worked examples, two of which are a
**golden pair**: a projection document and the resolved view it is
defined to produce. `WotSpecExampleTests` embeds all twenty and runs the
pair through the resolver, asserting against *the specification's own
expected output* rather than against my reading of the prose. That
single document exercises all three selection forms, the bulk naming
rule, the security closure naming and the provenance term.

This is the strongest evidence in the PR, and it is worth more than the
unit tests around it: it is the same check the upstream
`validate_local.py` performs.

## Other spec work

- **Section 6 vocabulary** — fifteen missing model and platform terms
with their validation rules (`WotNodeSetConverter.ModelVocabulary.cs`).
- **Portable identifiers** — the two rules release 1.1 made normative,
with `WotNodeSetConverterOptions.AllowNonPortableIdentifiers` as a
documented downgrade-to-warning switch for documents authored against
v1.00. Defaults to `false`.
- **xRegistry §6.9 identifier construction** (`XRegistryIdentifier.cs`)
— reverse-authority symbolic identifiers; all seven published worked
examples are test cases.

## Gaps found by re-auditing against draft3

I audited in both directions — every normative requirement to code, and
every `uav:` term in code back to a spec that defines it. Three
findings, all fixed:

1. **`Wot-Con 1.02` accepted anything well-formed.** draft3 makes format
validation part of the unit, because *WoT-Con Minimal* is one unit wide
and materialization projects a *valid* document. The 1.02 upload path
only deserialized into the `ThingDescription` POCO, which happily
accepts JSON that is not a WoT document at all — every member of that
type is optional, so `{}` deserialized and was materialized. It now
parses via `WotDocument.Parse` and requires the document to identify
itself by a non-empty `name` or `title`, returning `Bad_DecodingError`
and materializing nothing otherwise. The rule lives in
`ThingDescriptionFormatValidator` and is shared with
`CreateAssetForEndpoint`, whose auto-generated Thing Descriptions §11
also treats as untrusted input.
2. **Two `WoTFile` operations were ungated.** draft3 §11 names `Write`
and `CloseAndUpdate` alongside the five management methods; both reached
the materializer without an access check. `EnforceManagementAccess` is
now wired into `WotAssetFileManager`.
3. **Two invented `uav:` terms.** `uav:NodeSet2Preservation` was
declared and never used — removed. `uav:eventFields` is read by
`OpcUaBindingPlanner` and defined in *neither* spec, while a comment
claimed it came from OPC 10101. It still works for compatibility but is
now documented as a non-standard extension at both sites rather than
misattributed.

The SSRF prohibition is satisfied by construction: there is no HTTP
client anywhere in these assemblies, and all resolution goes through the
injected `IWotThingResolver`.

## Two ambiguities fixed in the specifications themselves

The audit turned up two places where a conforming implementer had to
invent an answer. Rather than pick silently, I took both upstream — they
are pushed to [spec-drafts PR
#8](https://github.com/OPCF-Members/spec-drafts/pull/8) (all checks
green) and are now normative in *Binding* §12.6 and *Connectivity*
§7.13:

- **`ViewVersion`** had one sentence saying only *that* it changes. It
now changes iff the resolved membership changes, is updated at commit
time alongside the `NodeVersion` stamping, and is a deterministic
function of the membership alone in a canonical order — so two servers
that resolved the same membership agree, which a per-server counter
would not across a redundant pair. Explicitly **not monotonic**: compare
for inequality only.
- **"Type-level view"** was used once in §12.2 and defined nowhere,
while every other statement of materialization was universal. §12.6 now
*defines* it rather than contradicting §12.2: the NodeClass is `View` in
both cases.

Both were already what this code did, so neither changed behaviour — but
the order-insensitivity the clause added was **not** covered by any
test, and `ViewVersionIsUnchangedWhenOnlyTheOrderOfTheMembershipChanges`
now holds it. It is mutation-verified: removing the ordinal sort turns
it red.

`docs/WotBindings.md` no longer lists these as points "the specification
leaves open".

## Validation

| Suite | Result |
|---|---|
| `Opc.Ua.Types.Tests` | 8525 ✅ (8518 on net48) |
| `Opc.Ua.WotCon.Tests` | 1057 ✅ (net10.0 and net48) |
| `Opc.Ua.WotCon.Bindings.Tests` | 559 ✅ |
| `Opc.Ua.XRegistry.Tests` | 196 ✅ |
| `Opc.Ua.Server.Tests` | 4027 ✅ |
| `Opc.Ua.SourceGeneration.Core.Tests` | 3771 ✅ |
| `Opc.Ua.WotCon.Samples.Tests` | 9 ✅ |

The full `UA.slnx` builds with **0 errors and 0 warnings attributable to
this PR** on all 6 TFMs (`TreatWarningsAsErrors` is on).
`Opc.Ua.Server.Tests` and the source-generation suites are listed
because this PR now also touches `MasterNodeManager` and the WoT-Con
NodeSet.

## Compatibility

No public API is removed or broken. Everything added is new surface
using `ArrayOf<T>` / `ByteString` per the repo conventions, wired for DI
with a direct-construction fallback, and the one behavioural tightening
(`Wot-Con 1.02` format validation) rejects input that was never valid to
begin with.

One pre-existing issue is **not** addressed here because it came in with
#4128 and is out of scope: `WotAssetFileManager.OnCloseAndUpdate` uses
`.GetAwaiter().GetResult()`, which the repo rules ban. Worth a
follow-up.

## Landed since this PR was opened

- **DI upgraded to 1.05.0.** `ConnectsTo` was declared a subtype of
`HierarchicalReferences`, contradicting OPC 10000-100 §5.5 Table 48.
That is an upstream error in every official DI NodeSet up to 1.04,
corrected by the OPC Foundation in 1.05.0, so the fixture is replaced
with the verbatim 1.05.0 file rather than hand-patched. A guard test
pins the corrected form. This also closed a second gap: six DI types the
WoT model was missing are 1.05 additions.
- **The sample Pump has the type definition OPC 40223 declares for it.**
`Pump1.Identification` carried DI's `FunctionalGroupType` instead of
`PumpIdentificationType`. Every test, the sample documents and the
README agreed with each other about it, which is why it survived.
- **`WotPumpAddressSpaceComparisonTests`** compares this server against
`PumpDeviceIntegrationServer`, which builds the same OPC 40223 Pump from
generated NodeSets and hand-written wiring. An independent oracle rather
than a restatement of the same assumptions - it is what found the defect
above.
- **Error containment in `MaterializeProjectionViewsAsync`.** An
exception mid-loop discarded the applied View handles before they were
published, leaving View Nodes in the address space that nothing could
remove.
- **`HasWoTProjection`** is now created; it was documented and plumbed
but never wired.
- **The default in-memory View host** no longer deletes the View it has
just applied.
- **An undeclared NodeSet alias now says so.** It previously surfaced as
`IdentifierMissing`, naming neither the value nor the fact that an alias
was expected.
- **`ViewVersion` follows the algorithm the specification now states.**
My earlier spec text was rejected on review, correctly: it named neither
the function nor the canonical order it demanded, ignored that OPC
10000-3 §5.4 requires a value greater than zero, and its "when, and only
when" is unsatisfiable for a 32-bit value. §12.6 now specifies the
algorithm outright and this implementation follows it exactly, pinned by
an independently computed test vector. Implementing it surfaced a
further defect in the clause - joining members on U+000A is not
injective, because a NodeId string identifier may itself contain U+000A
- which is fixed in the specification by length-prefixing each member.
- **An auto-generated Thing Description is treated as untrusted input.**
§11 requires it; `CreateAssetForEndpoint` validated the endpoint but
handed whatever the discovery provider returned straight to the
materializer. The `Wot-Con 1.02` format rule now gates both paths from
one place.
- **Document bytes moved out of the registry snapshot** into a
content-addressed store, so a commit no longer rewrites the whole corpus
to edit one document. Writes stage and are promoted during the commit,
which preserves both durability-first ordering and the fail-closed rule
that a blob directory without a manifest means operator recovery.
Restoring streaming digest verification along the way closed a real gap:
content integrity had degenerated to a length comparison.
- **External references now reach a NodeManager registered later.** A
reference to a Node that does not exist yet is dropped rather than
queued, and the dynamic-registration path had no second phase to apply
them in, so registering a NodeSet before the one it points into lost the
forward edge permanently. Review of that change then found a state leak
in its rollback path, fixed with a red-to-green regression test.
- **`WOTC-Legacy` is renamed `Wot-Con 1.02`** in the specification and
here. Editorial: the NodeSet diff is `<Category>` values only.

## Status

Open for review. Two things may still move:

1. **The specifications are unpublished drafts.** Anything here can
change if the working group decides differently. The `ViewVersion`
definition has since been settled - §12.6 specifies the algorithm and
this follows it - but the same could happen to any other clause tracked
here.
2. **`uav:eventFields`** is kept as a documented non-standard extension.
That is a judgement call the WG may want to reverse, either by adopting
the term or removing it.

Two known items are recorded rather than fixed:

- `Pump1` has no hierarchical parent, so it is reachable by NodeId but
not by browsing down from `Objects`. The cross-NodeSet ordering defect
this was originally attributed to is fixed above, and cross-manager
references are now symmetric in both registration orders. The Pump
itself is still blocked on a different cause found while verifying that
fix: with the reference added, `DeviceSet` exists but the Pump has
**zero** inverse references, so the edge is dropped at *import* - before
any external-reference machinery runs. That is a separate defect in the
materialization path, not the one that was fixed, and it is left
recorded rather than guessed at.
- `WotAssetFileManager.OnCloseAndUpdate` uses
`.GetAwaiter().GetResult()`, which the repo rules ban. Pre-existing from
#4128 and out of scope here.

Review of the projection resolver and the materialization host is the
most valuable thing to look at; the vocabulary and identifier commits
are mechanical by comparison.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
2026-08-08 09:08:23 +02:00
Marc Schier 416d619c41 Split the CI matrix across both systems, split the required test gate from the advisory coverage check, and give background work an owner (#4158)
# Description

Two changes to how CI is routed and how it reports.

## 1. Split the CI matrix back across both systems

Flips both halves of the CI backend switch from `ado` to `actions`, so
the workload is shared instead of concentrated on the managed pool. The
switch is checked into source in **two places that must always be
flipped together**:

| File | Setting | Before | After |
| --- | --- | --- | --- |
| `azure-pipelines.yml` | `parameters.ciBuildBackend` | `ado` |
`actions` |
| `.github/workflows/buildandtest.yml` | `env.CI_BUILD_BACKEND` | `ado`
| `actions` |

| Work | GitHub Actions | Azure Pipelines (managed pool) |
| --- | --- | --- |
| All-TFM solution builds | `build-all-tfm-windows` + `-linux` | two-TFM
PR gate (`net10.0`, `net48`) |
| Test matrix | ubuntu, every test project | Windows `net48` + `net10.0`
fast PR legs |
| Native AoT | ubuntu | Schedule/Manual only |
| macOS | always (no managed-pool macOS image) | — |

## 2. Split the required gate from the advisory coverage check

The coverage gate job was appended to *every* test stage, so `Fast PR
test` emitted three sibling checks whose display names collided —
`Coverage Release (net10.0)` appeared twice, once for the Windows leg
and once for macOS, with nothing to tell them apart:

```text
✓ Coverage Release (net48)      55s
● Coverage Release (net10.0)
○ Coverage Release (net10.0)
```

That job also fused two unrelated concerns: whether the tests passed
(which must block a merge) and whether coverage met its thresholds
(which should not — the `net10.0` leg had `enforceCoverage: true`, so a
coverage dip blocked merges).

Both concerns now get a stage of their own, mirrored in both CI systems:

| Concern | Azure Pipelines | GitHub Actions | In the branch ruleset? |
| --- | --- | --- | --- |
| Every test passed | **`Tests passed`** stage | **`build-and-test
summary`** job | **Yes — required** |
| Coverage meets thresholds | **`Code coverage`** stage | **`code
coverage`** job | **No — advisory** |

The coverage check reports a clean failure when the thresholds are
missed, so the miss is visible, but it never blocks the merge.

Coverage is now merged **once** across every leg of the run instead of
once per leg, which also removes the `enforceCoverage` /
`publishCoverage` "exactly one invocation may set this" parameters.
Neither check re-runs any tests — the previous design re-ran the whole
suite in one job with the collector attached, which serialised a suite
that is deliberately fanned out and always exceeded the stage timeout.

### Coverage checks now exist in GitHub Actions

`build-and-test` already ran with `--collect:"XPlat Code Coverage"` and
uploaded the fragments, but nothing ever merged or evaluated them. It
now runs the same `check-coverage.ps1` against the same
`coverage-thresholds.json` as Azure. Since `CI_BUILD_BACKEND` defaults
to `actions`, that ubuntu matrix — which runs *every* test project — is
the fullest coverage signal in the repository.

### Reporting the numbers

`check-coverage.ps1` gained `-SummaryPath`, rendering a markdown report
that both systems surface:

- **GitHub Actions** — appended to the job summary and upserted as a
single **sticky pull-request comment**, updated in place on each run.
Threshold misses also appear as `::error::` annotations.
- **Azure Pipelines** — attached to the build summary via
`##vso[task.uploadsummary]`.

The report is a pass/fail table of the project floor, patch coverage and
baseline delta, with the uncovered changed lines listed by file in a
collapsible block. Rendered example:

> ## Code coverage
>
> ❌ **Coverage gate failed.** This check is advisory and does not
block the merge.
>
> | Check | Result | Threshold |
> | --- | --- | --- |
> | ✅ Project line rate | **72.10%** (40 731/56 496
lines) | >= 70.00% |
> | ❌ Patch coverage | **0.00%** (0/1 changed lines) | >= 75.00% |

The comment is fork-safe: a `pull_request` from a fork gets a read-only
token regardless of the `permissions:` block, so it degrades to the job
summary alone. The sticky comment uses `actions/github-script` rather
than a community action, keeping this workflow on first-party actions
only.

## Notable correctness details

These were found and fixed during review, and are the parts most worth a
second pair of eyes:

- **`dependencies.<stage>.result` resolves at *stage* scope only.** At
job scope `dependencies` refers to other jobs of the same stage, so
reading stage results from a jobs template would have silently returned
empty and the gate would have passed everything. `gate.yml` and
`coverage.yml` are therefore **stage** templates that derive their own
`dependsOn` from the same stage list they evaluate, so the two can never
drift apart. An unresolved result is a hard error.
- **Both rollups run on `not(canceled())` / `always()`**, not on
success. A check that reports **skipped** is treated by GitHub as
**satisfied**, so a required check that skips when its dependency fails
would wave a red build straight through.
- **`get-matrix.ps1` used to emit an empty matrix on a discovery miss.**
The downstream job is then skipped, a skipped job rolls up as a
*succeeded* stage, and the gate would have approved a run that executed
no tests. It now fails, with an `-AllowEmpty` opt-out.
- **Zero coverage reports used to be a green advisory check**, which
would hide a broken collector indefinitely. Both systems now distinguish
"the tests never ran" (warning) from "the tests ran and published
nothing" (error).
- The gate reads stage-level results rather than enumerating
matrix-generated job names, which change whenever a test project or
agent is added.

## Test-host hang and the reconnect defect behind it — both fixed

Flipping `CI_BUILD_BACKEND` to `actions` started exercising the ubuntu
matrix, which
`master` never runs, and it immediately surfaced two real defects.

**1. The test host hung for ten minutes.** `Assert.ThrowsAsync` blocks
the calling thread
with no timeout, and these tests drive a *fake clock* from that same
thread — so once the
reconnect parked on a fake-clock timer, nothing could ever advance it.
NUnit sat in
`WaitForCompletion` until `--blame-hang-timeout` killed the host,
aborting the run at 3902
of 4238 tests and taking every other test in the assembly down with it.
A hang dump named
the culprit exactly. Replaced with an awaited, bounded
`AssertThrowsAsync` helper (the
repo's own rules forbid sync over async). The run now completes in
**~1m29s** with all
tests executed.

**2. A budget-aware reconnect never returned.** With the hang gone, two
tests failed with
`Expected ServiceResultException but got TimeoutException`. `dumpasync`
on a hang dump gave
the whole chain:

```
ReconnectAsyncWithBudgetShrinksDelayToFitRemainingAsync
  → AssertThrowsAsync
  → ClientChannelManager.ReconnectAsync
  → ClientChannelManager.ReconnectLeaseAsync
  → ClientChannelManager.SwapFaultedEntryAsync   ← parked here
```

`master` (via #4128) added `IsTerminalReconnectRace` so a reconnect that
loses a race
against a concurrent close recovers on a freshly swapped entry. The
predicate only asks
whether the entry ended up `Closed`/`Faulted` with
`BadSecureChannelClosed` — which is
*also* exactly how a reconnect ends when the caller's retry budget or
the reconnect policy
is deliberately exhausted. So a budget-exhausted reconnect was misread
as a race: the
manager swapped the entry and started a **second, unbudgeted** cycle,
waiting out the swap
back-off first. `ReconnectAsync(channel, budget)` therefore could not
return within its
budget, and under a fake clock it never returned at all.

The fix requires the budget to have room left before treating a terminal
failure as a race:

```csharp
return !ct.IsCancellationRequested &&
    !budget.IsExhausted &&
    sre.StatusCode == StatusCodes.BadSecureChannelClosed &&
    entry.State is ChannelState.Closed or ChannelState.Faulted;
```

The parameterless `ReconnectAsync(channel, ct)` overload passes an
unlimited budget, so
genuine race recovery is unchanged. Verified locally: Core 4152, Client
2122 and Sessions
776 tests all pass, and the full GitHub Actions PR run is green across
56 jobs.

Two smaller fixes came along the way, neither weakening an assertion:

- `ObservableFakeTimeProvider` waiters made **relative** ("N more timers
from now") rather
than absolute ("the Nth timer of the run"). Absolute numbering let an
earlier reconnect
consume the slots, so a waiter completed before the awaited timer
existed and `Advance()`
  fired nothing.
- The raw worker thread in `NodeStateHandlerConcurrencyTests` was a
**foreground** thread
whose stop flag was read without a barrier and set only on the success
path — a failed
assertion leaked a spinning thread that pins the process at exit. Now
background,
  `Volatile`, stopped in a `finally`, joined with a bound.
## Background work now has an owner

The reconnect hang above was one symptom of a pattern the codebase had
**26 times** across seven assemblies: a bare `_ = Task.Run(...)`. Each
one handed work to the thread pool and forgot it — nothing observed the
exception if it threw, nothing bounded how many ran at once, and
disposal raced them, so a component could finish tearing itself down
while work it started was still touching its fields. Two of those sites
already carried a standing `// TODO: Await the task completion in
shutdown`.

`BackgroundTaskScope` (in `Opc.Ua.Types`) gives them an owner: schedule
with `Run`, drain with `DisposeAsync`, or signal-only with `Dispose`
where the owner's teardown is synchronous and awaiting would be sync
over async. `Run` never blocks and never throws — which matters, because
most of these call sites went to the thread pool precisely to escape a
lock they were holding. 11 unit tests cover drain, cancellation,
exception observation, the concurrency cap, post-shutdown rejection and
idempotent disposal.

Two of the fixes were more than re-wiring:

- **`SessionManager` / `SubscriptionManager` loops were structurally
unawaitable.** Both return `ValueTask`, so `Task.Factory.StartNew(() =>
...)` handed back a `Task<ValueTask>` that completes at the first yield.
Storing it would not have helped — awaiting it would only have awaited
the *scheduling*. Both now go through `AsTask().Unwrap()`, matching the
neighbouring `StartConditionRefreshWorker`.
- **`ChannelAsyncOperation` was swallowing subscriber exceptions.** Its
`doNotBlock` branch had no try/catch while the inline branch beside it
did. It is per-operation with no owner to hang a drain on, so it gained
the missing handler rather than a scope.

The Kubernetes readiness endpoint keeps a purpose-built semaphore
instead: an unauthenticated endpoint needs a hard cap on concurrent
handlers, not just an owner.

### ⚠️ Breaking: `ISessionManager.Shutdown()` is removed

Replaced by `ShutdownAsync(CancellationToken)`. There is no correct
synchronous way to wait for the session monitor loop, so leaving the
overload would only have preserved the race. This mirrors
`ISubscriptionManager`, which already had `ShutdownAsync`. Migration
notes are in `docs/MigrationGuide.md`; only one in-repo implementer
existed.

Also converted the injected `object partitionLock` shared by
`LogicalSubscription` and `CompositeMonitoredItemCollection` to
`System.Threading.Lock`, per the repo rule.

## Patch coverage now scales with the size of the patch

A flat floor over changed lines punishes small changes for arithmetic:
two uncovered lines in a four-line fix reads as 50 % and failed an 80/5
floor — which is exactly how this branch earned a red coverage check for
a four-line predicate change. A check that fires on changes nobody
considers undertested is one people learn to ignore.

| Coverable changed lines | Floor | Below it |
| --- | --- | --- |
| 1 – 10 | 50 % | warning, still passes |
| 11 – 100 | 60 % | warning, still passes |
| more than 100 | 75 % | **failure** |

Only changes past the last band can fail. Below it the author still gets
a warning naming the uncovered lines. Bands live in `patch.bands` in
`coverage-thresholds.json` and each carries its own `enforced` flag.
Verified end-to-end against synthetic repositories at every boundary.

## Codecov is back, as reporting

Dropped in #4122 when the in-pipeline gate replaced it. Keeping the gate
was right, but dropping Codecov also lost the PR comment, the
file-by-file diff view and the trend. It returns with both statuses
`informational: true` — two gates with two sets of thresholds would
eventually disagree, and `coverage-thresholds.json` stays the single
source of truth.

The upload is switchable and can never fail a build: `enableCodecov`
(Azure) and `ENABLE_CODECOV` (Actions), both defaulting on, both skipped
when `CODECOV_TOKEN` is absent as on fork PRs. Each system uploads the
single merged report it already builds, under its own flag. The old
`codecov.io/bash` uploader was sunset, so Azure uses the current CLI.

## Follow-up actions for maintainers

1. **Update the branch ruleset.** The per-leg `Coverage Release (…)`
checks are gone. Azure reports checks as `<pipeline> (<stage> <job>)`,
so the exact names are:

   | Check | Require? |
   | --- | --- |
| `OPCFoundation.UA-.NETStandard (Tests passed Verify stage results)` |
**yes** |
   | `build-and-test summary` | **yes** (already required) |
| `OPCFoundation.UA-.NETStandard (Code coverage Merge and evaluate)` |
no |
   | `code coverage` | no |
2. **Re-calibrate the thresholds.** `coverage-thresholds.json` is still
seeded from the last Codecov reading (73.61 %). The two systems now
report *different* numbers by design — Actions merges every project on
ubuntu, Azure only the Windows fast-PR legs, and scheduled runs read
higher still because the Debug / .NET 8 / .NET 9 / netstandard stages
also contribute. Re-seed from the GitHub Actions figure.
3. **Note:** a failed advisory coverage stage still turns the whole
Azure run red, which the README build badge reflects on `master`. That
follows from wanting a visible red X on a threshold miss; say the word
if you would rather it were a warning.
4. **The Azure agent pool is the slowest thing here.** Several runs on
this PR sat queued for hours, and one build failed with `Insufficient
system resources` while writing an XML doc file — an agent exhaustion
error, not a code error. It passed on re-run. Worth raising against the
`netstandard` Managed DevOps Pool capacity separately.
## Related Issues

_No tracking issue; this is CI infrastructure work requested directly.
It builds on the switch introduced in #4122._

## 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.
- No product code changed, so there are no unit tests to add. The new
logic was verified with purpose-built harnesses instead:
- `check-coverage.ps1` run for real against a merged Cobertura report on
all three paths — pass, threshold failure, and patch coverage with
uncovered changed lines (a scratch commit adding an uncovered method was
correctly resolved to `DataTypeException.cs:193`, reported 0 % patch
coverage and exited 1).
- The gate's accept/reject logic dry-run over `Succeeded` /
`SucceededWithIssues` / `Skipped` / empty / `Failed` / `Canceled` — 6/6
correct.
- `get-matrix.ps1` exit codes verified in clean processes: empty → 1,
normal → 0, `-AllowEmpty` → 0.
- All five pipeline/workflow files parse; both PowerShell scripts parse.
- Action versions checked against the GitHub API and pinned to current
majors.
- [x] I have added all necessary documentation.
- Rewrote the coverage section of `docs/DeveloperGuide.md` as *Required
checks and coverage*, covering which checks belong in the ruleset and
which must not, how coverage is measured, where the numbers appear, and
why the two systems disagree. Corrected the stale "BLOCKS" wording in
`coverage-thresholds.json`.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- CI YAML, two PowerShell scripts and documentation only; no compilation
involved.
- [x] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- Not applicable — no product code changed. `Opc.Ua.Core.Schema.Tests`
was run repeatedly on net10.0 (119 passed) to generate the real
Cobertura fragments used to validate the gate.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [x] I have addressed **all** PR feedback received.
- Feedback on the path-relevance diff (now a merge-base diff) and the
target-framework numbering in `docs/DeveloperGuide.md` is fixed in
`15b8e38df`.
- A review pass over the gate/coverage split produced the four
correctness fixes listed above, in `863eefc4d`.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 26aa55a2-5731-4a4c-b61c-df2f7a785eca
2026-08-07 06:56:05 +02: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 8a10139b8c Fix OPC UA address-space compliance in the device integration stack (#4117)
# Description

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

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

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

## Findings and fixes

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

## Relationship to #4116

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

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

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

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

## Breaking change

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

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

## Verification

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

Post-merge on net10.0:

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

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

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

## Documentation

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

## Related Issues

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

- Fixes #

## Checklist

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

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0d50c3fc-58b1-4c99-85a4-1e32c88666d9
2026-07-30 16:04:18 +02:00
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 7cec2d614d Move repository files to the consolidated layout 2026-07-16 06:25:54 +02:00