docs(plans): OPC UA SDK 2.0 upgrade spike catalogue (lmxopcua#782)
v2-ci / unit-tests (push) Waiting to run

Catalogue of what breaks moving from 1.5.378.106 to 2.0.1: restore
break (MEL 10.0.12), 42 -> 103 compile errors in the first two waves,
root-cause groups with API mappings, PeerAddress replacement for the
reflection-based remote endpoint resolver, effort and risks.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 20:27:30 -05:00
parent c5df43d594
commit 1b9c36f38d
@@ -0,0 +1,107 @@
# OPC Foundation SDK 1.5.378.106 -> 2.0.x upgrade spike (lmxopcua#782)
Date: 2026-11-09. Branch: `spike/sdk2` off `origin/master`. Catalogue only; no code was ported and nothing was pushed.
## 1. Versions
| | |
|---|---|
| Current pin | `1.5.378.106` (Core, Server, Client, Configuration) |
| Latest stable 2.0.x | **`2.0.1`** (GitHub release 2026-10-12; `2.0.0` 2026-10-07; previews `2.0.0-preview.2`..`.6` before). 1.5.378 line is still maintained (`1.5.378.182` on 2026-10-04) |
| Packages we reference | Core, Server, Client, Configuration - all four still exist at 2.0.1, same ids. No package disappeared |
| New transitive packages | `Opc.Ua.Core.Types` (built-in types + node-state contracts, split out of Core), `Opc.Ua.Security.Certificates` (wrapper `Certificate` type), `Opc.Ua.Types`. Come in transitively; no csproj change needed |
| GDS packages | renamed (drop `.Common`) - we do not use them |
| Companion tooling | `OPCFoundation.NetStandard.Opc.Ua.MigrationAnalyzer` (26 rules `UA0002`..`UA0030`, with code fixers) and an `opcua-v20-migration` agent skill. Not tried in this spike; use it for the bulk mechanical work |
Primary docs: `docs/MigrationGuide.md` and `docs/migrate/2.0.x/*.md` (types, node-states, identity, certificates, configuration, sessions-subscriptions, telemetry, packages) at tag `2.0.1` of OPCFoundation/UA-.NETStandard; `docs/WhatsNewIn2.0.md`.
### Restore-level break (first thing you hit)
2.0.1 requires `Microsoft.Extensions.*` (Logging, Logging.Abstractions, Options, Hosting.Abstractions chain...) **>= 10.0.12**. Our `Directory.Packages.props` pins `Microsoft.Extensions.Logging(.Abstractions)` at 10.0.7, so every project that references the SDK fails with `NU1605` (package downgrade, warnings-as-errors; 8 errors). Bumping those two pins to 10.0.12 fixes restore. The props file also carries a comment that 10.0.13 was tried and rejected (SignalR.Client.Core floor), so 10.0.12 is the likely ceiling: keep Logging/Abstractions at exactly 10.0.12 and verify the rest of the Microsoft.Extensions.* family does not need to move with it (Options, Hosting, DI are still 10.0.7 and restored fine).
## 2. Error counts (Release build, after the MEL bump)
The build is staged by project dependencies: a failing project blocks everything that references it, so the first pass is only the leaves. The numbers below are what the compiler reported; the real total is much larger (see section 5).
| Pass | Errors | Where |
|---|---|---|
| 0: restore | 8 | NU1605 (MEL 10.0.7 vs 10.0.12) |
| 1: first compile wave | **42** distinct | Client.Shared 4, OpcUaClient.Session 8, **OpcUaServer 17**, Historian.Conformance 10, Historian.Sql.Common.Tests 3 |
| 2: after 3 trivial fixes in Client.Shared/Session (`ReferenceDescriptionCollection` -> `ArrayOf<ReferenceDescription>`, `NodeId.IsNull`, `IdentifierAsString`) | **103** distinct | Client.Shared **71** (it now actually compiles far enough to report), Session 2, OpcUaServer 15, Conformance 12, Sql.Common.Tests 3 |
Pass 2 grew 42 -> 103 from fixing 4 errors: fixing a project exposes the rest of its files. Still blocked (never compiled in either pass): OpcUaClient driver (4,056 loc of Opc.Ua code), Client.CLI, Client.UI, Browser, Host and every test project. Verdict for step 5 of the task: **far over the 40-error threshold; stopped at the catalogue.**
Surface that touches `using Opc.Ua` (files / loc): src - OpcUaServer 13 / 10.3k, OpcUaClient driver 3 / 4.1k, Client.Shared 21 / 2.8k, Client.CLI 14 / 1.8k, Client.UI 5 / 1.0k, Browser 2 / 0.5k, Session 3 / 0.4k; tests - OpcUaServer.Tests 48 / 17.6k, OpcUaServer.IntegrationTests 25 / 8.0k, OpcUaClient.Tests 20 / 4.5k, Client.Shared.Tests 14 / 3.2k, Client.CLI.Tests 14 / 2.3k, Client.UI.Tests 5 / 1.7k, others ~1.5k. About 20k loc of production and 40k of test code. Client.Shared produced ~71 errors per 2.8k loc once reachable; a linear extrapolation gives roughly **600-1,200 compile errors** over the whole solution (estimate, not measured).
## 3. Root-cause groups and API mappings
### A. Built-in types are now immutable value types (largest group, ~60% of errors)
Cause: 2.0 "immutable built in types" - `NodeId`, `Variant`, `DataValue`, `LocalizedText`, `QualifiedName`, `StatusCode`, `ByteString`, `ExtensionObject` are readonly structs; `object` replaced by `Variant` in APIs. Analyzers `UA0002`-`UA0008`, `UA0014`, `UA0019` cover much of this; doc `migrate/2.0.x/types.md`.
| Symptom (example from our build) | 2.0 replacement |
|---|---|
| `ReferenceDescriptionCollection`, `HistoryReadValueIdCollection`, `WriteValueCollection`, `CallMethodRequestCollection`, `ExtensionObjectCollection`, `SimpleAttributeOperandCollection` not found | `ArrayOf<T>` (readonly; no `.Add`, build with collection expression / `ArrayOf<T>.From`). `IList<T>` parameters on node-manager overrides become `ArrayOf<T>` |
| `ArrayOf<string>` cannot be null (`DefaultEndpointDiscovery:22`, `DefaultSessionFactory:33`) | use `default` / `[]` |
| `byte[]` -> `ByteString` (continuation points, `ChannelThumbprint`, certificates) | `ByteString` with `.IsNull`, `.Span`, `.Memory` |
| `NodeId.IsNull(x)` static -> property `x.IsNull`; `NodeId?` operator invalid (struct) | property; `default(NodeId)` |
| `NodeId.Identifier` obsolete (CS0618, 5 hits in NamespaceMap) | `TryGetValue<T>(out T)` or `IdentifierAsString` |
| `DataValue.Value` obsolete; `DataValue.StatusCode/SourceTimestamp/ServerTimestamp/WrappedValue` read-only (`Historian.Conformance/SdkAggregateOracle`) | construct with the ctor/`with`-style init; read `WrappedValue` |
| `Variant.Value` obsolete (~20 hits in `OpcUaClientService.cs` ~1010-1031); `new Variant(object)` obsolete | `Variant.TryGet<T>`, `AsBoxedObject()`, `Variant.From(...)` |
| `LocalizedText?.` invalid; `Text` returns null for empty (behaviour change) | null-aware checks; audit `Text == ""` comparisons |
| `ExtensionObject.Body` obsolete (3 hits) | `TryGetAsXXX` |
| `DateTimeUtc` replaces `DateTime` in several APIs (`HistoryRead` times) | convert explicitly |
| `StatusCode` is a struct, no implicit `uint` (`Sql.Common.Tests/StatusCodeOracleTests`); `StatusCodes.GetBrowseName` gone; string passed where `StatusCode`/`QualifiedName` expected | `.Code` for uint; find replacement name lookup in `StatusCode`/`StatusCodes` |
| `new QualifiedName`/`SimpleAttributeOperand` collection-expression initialisers | `QualifiedName` is not constructible from a collection expression; `ArrayOf<QualifiedName>` where a browse path is wanted (`IFilterTarget.GetAttributeValue` now takes `ArrayOf<QualifiedName>`) |
### B. Server node-manager override signatures (OpcUaServer, 17 errors, structural)
`OtOpcUaNodeManager : CustomNodeManager2` - `CustomNodeManager2` **still exists** in 2.0.1 (good: no rewrite to async needed), but every override changed `IList<X>` -> `ArrayOf<X>`:
`Read(OperationContext, double, ArrayOf<ReadValueId>, IList<DataValue>, IList<ServiceResult>)`, `CreateMonitoredItems(..., ArrayOf<MonitoredItemCreateRequest>, ...)`, `HistoryRead`, `HistoryReadRawModified/Processed/AtTime/Events`, `HistoryReleaseContinuationPoints`, `HistoryUpdateData`, `HistoryDeleteRawModified/AtTime` (11 overrides, lines 4089-7482). Output lists stay `IList<>`. `IFilterTarget.GetAttributeValue(IFilterContext, NodeId, ArrayOf<QualifiedName>, uint, NumericRange)` (HistoricalEventFilterTarget, line 6513). An `AsyncCustomNodeManager` with `*Async(..., CancellationToken)` variants is the new preferred base; moving to it is optional and a separate design decision (we do synchronous in-memory work, so not required).
`OtOpcUaSdkServer.CreateAggregateManager(IServerInternal, ApplicationConfiguration)` -> **`CreateAggregateManagerAsync(IServerInternal, ApplicationConfiguration, CancellationToken)`** (`StandardServer`); our `SyncAdvertisedAggregates()` hook must move into the async override.
`IAggregateCalculator.GetProcessedValue` -> `TryGetProcessedValue(bool returnPartial, out DataValue)` (Conformance oracle).
### C. Identity / certificates (OpcUaClient.Session, Host)
- `new UserIdentity(X509Certificate2)` gone (`OpcUaUserIdentityFactory:40`). Replace with `await UserIdentity.CreateAsync(CertificateIdentifier, ICertificatePasswordProvider, configuration.CertificateManager.CertificateProvider, ct)` - makes `FromCertificate` async and moves the cert loading (`LoadCertificate`, caller-owns-disposal contract) onto a `CertificateIdentifier`. `Certificate` is now a ref-counted wrapper type (`Opc.Ua.Security.Certificates`), so every `X509Certificate2` exposure (server cert, app cert, CLI) needs a look. Doc `identity.md`, `certificates.md`.
- Server-side user auth: `SessionManager.ImpersonateUser` is legacy; the registry path is `IServerIdentityRegistry` + `IUserTokenAuthenticator.AuthenticateAsync(AuthenticationContext, ct)` returning `Accepted/Rejected/NotHandled`. `OpcUaApplicationHost` (lines 226-305, 631-667) subscribes to `ImpersonateUser`; per the 2.0 docs the event still works as fallback but it is the deprecated path. Not yet compile-checked (OpcUaApplicationHost produced no error in pass 2, so the event still exists on `ISessionManager`).
### D. Configuration / security setup
`ApplicationConfiguration.CertificateValidator` removed (`DefaultApplicationConfigurationFactory:64`) -> `configuration.CertificateManager` (ICertificateProvider / CertificateValidator on the manager); also `MinMetadataSamplingInterval` -> `MinSupportedSamplingInterval`, Data-Contract serializer removed, trace config setters removed. Doc `configuration.md`, `certificates.md`. Hidden in the not-yet-compiled Host/Client projects: expect the same pattern wherever `ApplicationConfiguration`/`ApplicationInstance` are built.
### E. Client session API (not yet reached; predicted)
`ISession` members removed (`SessionDiagnostics` -> `ClientApplicationUri`, `SessionName`); 1.5.378 sync/APM APIs deleted (the repo already ported to the async API in the 1.5.378 port, so little residual); `Session` -> `ManagedSession` is optional. Subscription/`MonitoredItem` V2 engine optional. Doc `sessions-subscriptions.md`. The OpcUaClient driver (4k loc) and Client.CLI/UI have not compiled yet, so their share is unmeasured.
### F. Nullable annotations
All SDK projects are now `nullable enable`: we see CS8625/CS8604/CS8602 and `CA2264` (`ThrowIfNull` on a non-nullable value) because we build with warnings-as-errors. ~15 of the 103; they disappear as API usage is corrected, but expect more across the 60k loc.
### G. Logging
`Utils.Trace*/SetLogger` static helpers are removed; everything via `ITelemetryContext`. Grep shows none in the first-wave errors, so presumably already on `ITelemetryContext` from the earlier port; not confirmed for the unbuilt projects.
## 4. `SecureChannelContext.PeerAddress` (replaces `SecureChannelRemoteEndpoint.cs`)
Confirmed in Opc.Ua.Core 2.0.1 XML docs / assembly: `Opc.Ua.SecureChannelContext.PeerAddress` - "The observed network address of the peer, when the transport exposes one." The constructor gained a trailing `System.Net.IPAddress peerAddress` parameter (`(string secureChannelId, EndpointDescription, RequestEncoding, byte[] clientCert, byte[] serverCert, byte[] thumbprint, IPAddress peerAddress)`). Type is **`IPAddress`, not `IPEndPoint`** - the port is lost (we only need the IP for the #751 per-client budget, but `SecureChannelRemoteEndpoint.Resolve` currently returns an `IPEndPoint`; callers must adapt). It may be null when "the transport exposes none" - same fail-soft semantics we already have. Not verified at runtime that the TCP binding fills it in (no over-the-wire test was run); `RemoteEndpointResolutionTests` is the gate for that.
How a server reaches the current channel context during activation:
1. `StandardServer.ActivateSessionAsync(SecureChannelContext secureChannelContext, RequestHeader, SignatureData, ArrayOf<SignedSoftwareCertificate>, ArrayOf<string>, ExtensionObject userIdentityToken, SignatureData, RequestLifetime)` receives the context as its first argument. This is the place to override: read `secureChannelContext.PeerAddress`, stash it in an `AsyncLocal<IPAddress?>` (or a small per-`SecureChannelId` map), then call base; the authenticator reads the AsyncLocal. (Confirm `ActivateSessionAsync` is `virtual` in the dll before committing to this; the docs show it as an `<inheritdoc/>` member of `StandardServer`, which is how overridable service methods are documented.)
2. `OperationContext.ChannelContext` (type `SecureChannelContext`) is available to node managers on every request after activation, so post-activation attribution needs no reflection either.
3. `ISession.SecureChannelId` still exists, so a `SecureChannelId -> PeerAddress` map populated in (1) also works for sessions looked up later.
4. The new-style `AuthenticationContext` passed to `IUserTokenAuthenticator` does **not** carry the channel context or peer address (fields: `TokenHandler`, `UserTokenPolicy`, `EndpointDescription`, `MessageContext`, `ChannelCertificate`, `ChannelApplicationUri`), and `ImpersonateEventArgs` does not either, so option 1's AsyncLocal bridge is required whichever auth hook is used. The `ImpersonateUser` handler runs inline on the activation call in the existing design, so the AsyncLocal set around `base.ActivateSessionAsync` is visible to it; with the registry authenticators (`AuthenticateAsync` is async) AsyncLocal still flows because it is awaited from the same logical call.
Net: `SecureChannelRemoteEndpoint.cs` (reflection over `TcpTransportListener.m_channels`) and its `TransportListeners` plumbing in `OtOpcUaSdkServer` can be deleted; this is a net simplification and removes the "fails on SDK upgrade" test coupling.
## 5. Effort estimate and sequencing
Honest size: this is **larger than the 1.5.378 async port**. That port was mostly sync->async method renames; 2.0 changes the type system under every value the code touches (`Variant`/`DataValue`/`NodeId`/collections) plus certificates/identity and node-manager signatures. Mechanical, but wide: ~20k production + ~40k test loc import `Opc.Ua`.
- Estimated compile errors: 600-1,200 (extrapolated). The MigrationAnalyzer should auto-fix a large share (types group A).
- Effort: **about 6-10 engineer-days** for compile-clean plus green unit tests; **+3-5 days** for server integration tests, wire/live verification against ScadaBridge and the OpcUaClient driver wire tests, and fixing behaviour changes found there. Roughly 2 calendar weeks, one person, dominated by tests (60% of the surface is test code).
- Sequencing: (1) land the MEL 10.0.12 pin bump on its own first (independent, low risk, checks the other family libs); (2) bump packages + install MigrationAnalyzer, run its codefixers solution-wide in one commit; (3) Client.Shared (+ Client.CLI/UI) - smallest standalone vertical, 71+ known errors; (4) OpcUaClient.Session + driver + Browser (identity factory async change, `ApplicationConfiguration` setup); (5) OpcUaServer: node-manager override signatures, `CreateAggregateManagerAsync`, `PeerAddress` replacing the reflection class, then Host auth; (6) tests last, in dependency order, with `AKKA_TEST_TIMEFACTOR=3` for OpcUaServer.Tests / IntegrationTests; (7) live gate.
- Do the 2.0 jump only after the `1.5.378.182` maintenance line has been considered: `.106 -> .182` is a cheap, low-risk interim that picks up months of fixes and may make the 2.0 diff smaller to review. (Not tested here.)
## 6. Risks
- **Wire behaviour toward ScadaBridge's OPC UA client (DCL adapter, still on 1.5.x):** 2.0.1 tightens security: secure channels never answer with an expired token; anonymous session-less requests must use the channel security of `CreateSession`; client lockout is new; UADP metadata refused on secured connections. Anonymous is a normal grantable principal in OtOpcUa (`OpcUa:AllowAnonymous` default true) - the "anonymous must match CreateSession security" rule could reject a client that creates a session on a secured channel and activates anonymously on another. Must be live-tested against ScadaBridge's current adapter, plus the two ServiceLevel-based redundancy readers. Address space, NodeIds (`s=<RawPath>`), Part 9 alarms and HistoryRead semantics should be unchanged on the wire but this was not verified; note also 2.0 aligns Write `Value` semantics, ContentFilter NULL semantics, address-space permission checks and locale handling "to the spec" (migration guide sections), each of which can change which requests succeed. Coordinate with ScadaBridge (which independently uses the same SDK) before deploying.
- Empty `LocalizedText` is now null: DisplayName/Description round-tripping and any equality on `Text`.
- Aggregates: 2.0.1 fixed aggregate edge cases (TreatUncertainAsBad on bounding values, DurationGood/Bad); our `Historian.Conformance` oracle compares against SDK aggregate output, so expected values may legitimately move.
- Certificate handling rework (ref-counted wrapper, `CertificateManager`) touches PKI paths on the wonder boxes (trust lists, `CertificateValidator` removal). Requires a deploy-time cert test, and the wonder boxes have no .NET 10 runtime (self-contained deploy already).
- Version coupling: SDK needs MEL >= 10.0.12; other family libs / apps pin 10.0.7 - check Auth, Secrets, Telemetry pins for downgrade conflicts.
- Preview/major-version maturity: 2.0.0 is 5 weeks old, 2.0.1 four weeks; nullable annotations flipped everywhere; first bug-fix cycle is still under way.
- Test-suite cost: the 17.6k-loc OpcUaServer.Tests and 8k-loc integration project use the raw SDK type surface heavily; expect most of the calendar time here.
- Not attempted in this spike: any runtime test, the OpcUaClient driver/Client.CLI/Client.UI/Host compile, `ImpersonateUser` fallback behaviour, whether `ActivateSessionAsync` is overridable.