Commit Graph

5 Commits

Author SHA1 Message Date
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
romanett a05a360550 Adopt the fluent builder's node-creation surface in the remaining hand-rolled node managers (#4460)
Closes #4429.

PR #4428 added the node-creation surface to `INodeManagerBuilder`. This
picks up the remaining candidates that needed a base-class change first,
and reports on the two that turn out not to be candidates at all.

## Converted

**`samples/WotCon/FlatTagServer/FlatTagNodeManager.cs`** — now derives
from `FluentNodeManagerBase`. The private `CreateObject` /
`CreateVariable` / `CreateMethod` helpers are gone, along with every
`pumpNodeId + ".Operational.Measurements.MassFlow"` concatenation; nodes
are staged with `AddObject` / `AddVariable<T>` / `AddMethod` and the
hand-maintained `externalReferences[ObjectsFolder]` entry is replaced by
`CompleteConfigureAsync`.

The `EventNotifier` bits and `AddRootNotifierAsync(pump)` are kept
verbatim — the pump is the notifier an aggregating server subscribes to,
and OPC 10000-3 only delivers the supervision conditions to a client
that can reach one.

`SupervisionSignal` loses its `namespaceIndex` and `tagPath` parameters
and derives both from the supervision Object it hangs off, so the signal
follows wherever that Object was staged.

**`samples/Redundancy/RedundantServer/HaSampleNodeManager.cs`** — same
shape. The `Counter` still reaches `EnableDistributedValueParticipation`
through `INodeBuilder.Node`. The variables keep their `Organizes`
reference type, which the fluent default would otherwise have made
`HasComponent`.

**`samples/DI/PumpDeviceIntegrationServer/OpenUsdComposition.cs`** —
both `AddPredefinedNodeAsync` sites stage on the builder now, threaded
through `ConfigureAsync`; `DiNodeManager.CreateAddressSpaceAsync`
already calls `RegisterAuthoredNodesAsync` between `ConfigureAsync` and
the reverse-reference pass, so no new plumbing was needed. Both methods
became synchronous. The NodeIds are unchanged because the hand
`SystemContext.NodeIdFactory.New(...)` calls and
`AssignInstanceChildNodeIds` were already doing exactly what staging
does.

`CreateRepresentedComponent` is left alone: it sits on
`CreatePumpAsync`, a public runtime API, and so is out of scope for the
same reason as the DI device builders.

## NodeIds are part of two samples' contracts

The issue expected the staged path to mint through the manager's
`INodeIdFactory`, and it does — but for two of these samples the
identifiers are a published contract, not an implementation detail:

- the aggregating client's checked-in Thing Descriptions under
`samples/WotCon/AggregationClient/Documents/` spell out
`nsu=…SourceA;s=Pump1.Identification.Manufacturer`;
- `samples/Redundancy/RedundantClient/Program.cs` addresses `new
NodeId("Counter", ns)` directly.

Both managers therefore override `New` for those nodes instead of taking
the default factory's derived identifier, which satisfies the acceptance
criterion that each manager keeps its current address-space shape.
FlatTag mints the full dotted browse path; the HA sample mints the bare
browse name, but only for the sample folder and its direct children.

Two things are worth knowing for anyone writing such an override.

**It has to keep an identifier the caller already chose.**
`DefaultNodeIdFactory.New` returns `node.NodeId` unchanged when it is
non-null and in the manager's own namespace. Without that same guard,
`NodeState.Create(context, nodeId, …, assignNodeIds: true)` sets the
explicit id and then `AssignNodeIds` overwrites it from the factory —
which silently moved `…SupervisionProcessFluid.Cavitation.Alarm` to
`…SupervisionProcessFluid.CavitationAlarm` and broke five alarm tests
until the guard was added.

**It has to be narrow.** A browse name is unique only among its
siblings. The HA override originally named every node the manager
minted, and the historian hangs an `HA Configuration` object off each
node it historizes — so the Counter's and the HistoryEvents' one both
landed on `ns=2;s=HA Configuration` and the strong active/passive
replicas refused to start. It is now limited to the nodes the redundant
client actually spells out.

## Merge with master

Merged `origin/master` (`0e600c0f0`). The one conflict was in
`HaSampleNodeManager.CreateAddressSpaceAsync`, where #4445 added
`AddFactoryAssignedNodes` next to the `HistoryEvents` object this branch
had converted. Both are kept, and the factory-assigned subtree stays
hand-built and unstaged on purpose: it exists to show what an identity
minted straight through `NodeIdFactory` looks like beside the sample's
named ones. It is attached once the folder has been staged, so those
identifiers survive untouched and the subtree is still registered as
part of the folder's. Its parent's identifier is `s=HighAvailability` on
both sides, so the ids it mints are unchanged from master.

## Not candidates

**`src/Opc.Ua.PubSub.Server/PubSubNodeManager.cs`** is out for the
issue's own DI-device-builder reasoning. All three of its build paths
are reachable from runtime Method calls — `OnAddDataSetFolder` and
`OnRemoveDataSetFolder` → `RebuildConfigurationAddressSpaceAsync`,
`OnAddPushTarget` / `OnRemovePushTarget` →
`RebuildKeyPushTargetAddressSpaceAsync`, `OnGetSecurityGroup` /
`OnAddSecurityGroup` / `OnRemoveSecurityGroup` →
`RebuildSecurityGroupAddressSpaceAsync` — plus `ConfigurationChanged`.
Each removes its previous roots and rebuilds them, and `Add*` throws
`BadInvalidState` once `RegisterAuthoredNodesAsync` has run.
Independently of that, its parents (`PublishSubscribe`,
`PublishedDataSets`, `SecurityGroups`, `KeyPushTargets`) are owned by
the diagnostics node manager, so `AttachToParent` would reject them as
`BadNodeIdUnknown`.


**`src/Opc.Ua.OpenUsd.Server/Scene/UsdSceneMaterializer.Properties.cs`**
— the issue asked whether a builder is reachable at those call sites. It
is not. `MaterializeUsdStage` is a public, documented `ISystemContext`
extension (`docs/OpenUsd.md`, the package README) whose only input is
the context; it returns a detached subtree for the caller to register.
It already mints through `context.RequireNodeIdFactory()`, which is the
same factory the staged path uses, so there is nothing to gain and a
public signature to break.

## Verification

Run against the merged tree:

- `Opc.Ua.Di.Tests` — 425/425 pass (covers `PumpOpenUsdE2eTests`,
`PumpHostedReferenceTests`).
- `Opc.Ua.Redundancy.Samples.Tests` — 14/14 pass, including both
variants of master's new
`FactoryAssignedNodeIdsSurviveActiveReplicaFailureAsync` and
`StrongHistorianContinuationsSurviveActiveReplicaFailureAsync`.
- `Opc.Ua.WotCon.Samples.Tests` — 25 pass, 4 fail. The same 4 fail on a
clean baseline worktree
(`CompanionTypeDefinitionsMatchNativePumpServerAsync`,
`WotPumpInstanceMatchesNativePumpSubsetAsync`,
`RealSamplesAggregateSubscribeAndReplaceGenerationAsync`,
`RealSamplesRouteManagementAndConditionActionsToEachSourceAsync` — all
`BadConfigurationError` out of the aggregation client). Pre-existing,
unrelated to this change.
- All three sample projects build with 0 warnings and 0 errors.

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

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 12:11:55 +00:00
romanett 105508bcbd Mint runtime NodeIds through a single DefaultNodeIdFactory (#4433)
# Description

Nodes created at runtime now get their NodeIds from a single
`DefaultNodeIdFactory` rather than from a `New` override per
NodeManager.
The factory derives a deterministic identifier from a canonical,
length-prefixed browse path, which is injective where the classic
`{parent}_{child}` concatenation was not — `A_B` + `C` and `A` + `B_C`
both produced `A_B_C` — and it also records the parent's identifier type
and qualifies cross-namespace segments by URI, so identifiers no longer
shift with namespace-table ordering.

A NodeManager selects its style declaratively instead of writing
assignment code:

```csharp
NodeIdFactory = NodeIdFactory.WithMode(NodeIdAssignmentMode.Counter);
```

`NodeIdAssignmentMode` offers `String` (the canonical path verbatim, the
default), `Numeric` / `Guid` / `Opaque` (that path through SHA-256),
`Counter` (sequential, for nodes whose browse paths repeat — per-session
diagnostics, inference jobs, rediscovered assets) and `None`. The
counter
starts above `0x40000000`, out of reach of any authored model, so a
long-running server cannot walk a runtime instance onto a type node.

`New` overrides are gone from every NodeManager the stack ships except
two whose identifiers are a domain scheme rather than an assignment
policy: `FileSystemNodeManager`, whose NodeIds encode the file path they
resolve back to, and `RoboticsNodeManager`, whose build coordinator
reserves identifiers across managers with ownership tracking.

**Namespace rule.** Every identifier is minted into the NodeManager's
own
namespace. Neither the parent's namespace nor the browse name's is
consulted: a parent can belong to a companion-specification model whose
NodeIds are fixed by its NodeSet (`DeviceSet` in the DI namespace), and
a
browse name only names the type that declared the child. NodeManagers
whose instance namespace is not their first one rebase with
`WithDefaultNamespaceIndex`.

**Types vs instances.** An authored NodeId on a node that stands on its
own is kept. A node hanging off a parent is re-minted, because it
reached
the factory through `AssignNodeIds` walking a subtree copied from a type
declaration. Keeping those would alias every instance onto the type's
own
nodes, and the predefined-node index takes the last writer, so the type
would quietly become an instance rather than the clash being reported.

New `ISystemContext.CreateInstance(...)` builds an instance of a
generated
type and rebases its subtree through `AssignInstanceNodeId` — the path
the
generated `CreateInstanceOf<Type>` helpers already took. The AI
NodeManager was the one place calling `NodeState.Create` directly, so
its
roots kept the NodeId their state object is born with (the type's own);
it now uses the shared path.

## Breaking changes

- `IAsyncNodeManager` extends `INodeIdFactory` and gains `AddNode` and
  `AddRootNotifier`. This lets the fluent surface mint and register
  through the interface instead of type-testing for
  `AsyncCustomNodeManager` and silently falling back to a concatenated
  identifier when the test failed — that fallback was itself a source of
  divergent NodeIds.
- `AsyncNodeManagerAdapter` delegates `New` to the wrapped NodeManager,
so
**`CustomNodeManager2` behaves exactly as before**: it mints nothing and
  a node keeps whatever NodeId it already has. Servers built on it are
  unaffected.

## Notes for reviewers

- `docs/NodeIdAssignment.md` is new and describes the whole mechanism:
the
  factory contract, identifier formats, type declarations vs instances,
  the generated helpers and their parameters, per-NodeManager behaviour,
  and an inventory of every member involved.
- The three commits are independently revertible.
- Two pieces that look redundant are deliberately kept, with the
reasoning
  recorded in the doc: `assignInstanceNodeIds` (it *suppresses* minting,
  where the rebase rule *forces* it) and the generator's
  `NodeId.Equals(TypeNodeIdConstant)` guard (it distinguishes a
  caller-assigned NodeId from a declaration's).
- Fluent builder tests previously passed only because of the removed
  fallback; they now run against a real `DefaultNodeIdFactory` through a
  shared `FluentTestNodeManager` helper.

## Related Issues

_No tracking issue yet._

## Checklist

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

> Test runs were on **net10.0 only** — .NET Framework was not exercised
> locally. Suites run green on an idle machine: Server 4950/4955, GDS
> 1116/1170, WotCon 1225/1225, Vision 484/485, DI 424/424, AI 174/174,
> ISA95 136/136, Positioning 69/69, RobotIntent 22/22. One unrelated
> pre-existing failure remains in `Opc.Ua.Types.Tests`
> (`EveryEmbeddedExampleMatchesItsRecordedHash`) — a WoT fixture checked
> out with CRLF, fixed by
> `git add --renormalize tests/Opc.Ua.Types.Tests/Wot/Assets`.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:37:19 +00:00
romanett 668310f21f Make the fluent node manager's configure/seal path genuinely async (#4442)
# Description

The fluent node-manager pipeline had two synchronous chokepoints, and
two independent efforts have now hit both of them.

`Configure` is a `partial void`, so it cannot `await`. A manager whose
wiring depends on asynchronous setup had to invent a second hook outside
the pipeline, create its own builder there, and hand-roll
`CreateFluentBuilder(...).Configure(...).Seal()` inside it.

`Seal()` was synchronous, so nothing a registration staged during
`Configure` could be completed by awaiting. Registrations that needed to
await either blocked — `EventSourceRegistry` did
`.GetAwaiter().GetResult()` on the manager's root-notifier registration
— or could not be expressed at all.

## What changed

**`FluentNodeManagerBase.ConfigureAsync(INodeManagerBuilder,
CancellationToken)`** is the awaitable wiring seam. It runs once per
activation, with the manager's attached builder, immediately *before*
the synchronous `Configure` partial(s) — so instances it materialises
are in the address space by the time `Configure` wires callbacks against
them. The default implementation is a no-op.

It is a `virtual` method rather than a second `partial` declaration
because a partial method can be optional (`partial void`, no return
value) or awaitable (an extended partial method, which must be
implemented) — not both, and the generator would otherwise have to
detect which one the user declared. `partial void
Configure(INodeManagerBuilder)` keeps working unchanged; a manager uses
either hook or both.

**`NodeManagerBuilder.Seal()` becomes `SealAsync(CancellationToken)`.**
Sealing is now the single point where registrations that could not
finish inside `Configure` get their turn to await. Every seal site
awaits it, so a later change can move registry *setup* onto the same
seam.

**`DiNodeManager` now runs the standard pipeline itself** — create and
attach the builder, `await ConfigureAsync`,
`RegisterAuthoredNodesAsync`, `CompleteConfigureAsync`, the post-setup
runner, then `await builder.SealAsync` — instead of handing subclasses a
bare callback and leaving them to build and seal on their own. DI
subclasses gain node registration and the reverse-reference pass, which
they previously skipped.

Sealing deliberately comes **after** the post-setup runner. The fluent
registries are owned by the manager, not by a single builder, and
sealing starts them — so sealing first locks post-setup configurators
out of registering simulation loops of their own, which is exactly what
`AddRobotics`' configurators do. Sealing before the runner makes
`MinimalRobotServer` fail to start with *"Cannot add a simulation loop
after the registry has started"*. Worth knowing for follow-up work: with
several builders attached to one manager, the first seal freezes the
shared registry for all of them.

## Workarounds removed

- **`DiNodeManager.OnAddressSpaceReadyAsync`** — deleted. Its three
overriders (`RoboticsNodeManager`, and the `PumpNodeManager` /
`GeneratorNodeManager` samples) now override `ConfigureAsync` and no
longer hand-roll `CreateFluentBuilder(...).Configure(Configure).Seal()`.
- **`EventSourceRegistry`'s `.GetAwaiter().GetResult()`** on
`AddRootNotifierAsync` — gone. `Publish(..., RegisterAsRootNotifier:
true)` now stages the registration and `SealAsync` drains it, so the
wait happens on an awaited path. A source whose root-notifier
registration fails is rolled out of the registry before the failure
surfaces, and a registry disposed before its builder sealed drops what
it staged.
- **`Isa95NodeManager.ConfigureStatusEvents()`** — the synchronous
second builder pass is now
`ConfigureStatusEventsAsync(CancellationToken)`, awaited from
`CreateAddressSpaceAsync`. (`ConfigureCatalogChanges()` stays
synchronous: it starts a background task and never seals a builder, so
there was nothing to convert.)
- **`IRoboticsBuildContext.Seal()`** — now
`SealAsync(CancellationToken)`. These really were
`NodeManagerBuilder.Seal()` calls behind a same-named method. The state
transition still happens under the context lock; the builder's
asynchronous completion runs outside it, so no `await` is taken while
the lock is held.
- The Pump / Generator incremental registration callbacks
(`onRegistered`) changed from `Action<T>` to `Func<T, CancellationToken,
ValueTask>` so their per-instance builder pass can await its own seal.

No sync-over-async was introduced anywhere. `Dispose(bool)` is unchanged
and stays non-blocking; teardown remains in `DeleteAddressSpaceAsync`.

`NodeManagerBuilder.Seal()` and `IRoboticsBuildContext.Seal()` are
removed rather than kept as synchronous overloads. A synchronous seal
that silently skipped the asynchronous completion would reintroduce
exactly the failure mode this change exists to remove.

## Generator

`NodeManagerTemplates` emits, in order: predefined nodes load → `await
ConfigureAsync(__m_builder, cancellationToken)` →
`Configure(__m_builder)` and the typed `Configure` →
`RegisterAuthoredNodesAsync` → `CompleteConfigureAsync` → `await
SealConfigurationAsync(__m_builder, cancellationToken)`.

`NodeManagerGeneratorTests` asserts that order literally, and that
neither `__m_builder.Seal()` nor the synchronous
`SealConfiguration(__m_builder)` is emitted.

### Merging with #4432

#4432 landed on `master` while this was in review and reworked the same
seam from the other side: it split `Seal()` into `SealGraphAuthoring()`
+ `StartSimulations()` so a manager can replay `NotifyNodeAdded`
*between* the two halves — sealing first stops a lifecycle handler
authoring nodes nothing would register, starting the simulations last
stops a simulated value change preceding its own node''s `OnNodeAdded` —
and added `FluentNodeManagerBase.SealConfiguration(builder)` as the one
call the generator emits.

Both intents are kept. That split is precisely why the asynchronous
completion could not simply live inside `SealAsync`: a manager reaching
the seam through the split path would silently skip it, which is the
failure mode this PR exists to remove. So the asynchronous half became
its own step:

- `NodeManagerBuilder.CompleteSealAsync(ct)` drains the staged
root-notifier registrations, then starts the simulations.
`SealAsync(ct)` is `SealGraphAuthoring()` followed by it.
- #4432''s synchronous `StartSimulations()` is gone, folded into
`CompleteSealAsync`. It had exactly one production caller after the
merge, and leaving it standing would have kept a second, synchronous way
to activate a builder that skips the staged registrations — the very
hazard this PR removes. Activation is now one step with one entry point;
the *split* itself stays, because the replay has to sit between the
halves.
- `SealConfiguration` becomes `SealConfigurationAsync(builder, ct)`:
seal the graph, replay `NotifyNodeAdded`, then `CompleteSealAsync`.
Activation still happens last, so #4432''s ordering guarantee holds
unchanged.

## Consumers unblocked

- **#4434 ("Reimplement the GDS ApplicationsNodeManager as a generated
fluent node manager")** adds `OnAddressSpaceReadyAsync` to
`FluentNodeManagerBase` purely because, in its own words, *"`Configure`
is a `partial void` and cannot await"*. That hook is unnecessary once
`ConfigureAsync` exists — the GDS manager overrides `ConfigureAsync`
instead, and the ordering it wants (predefined nodes → async setup →
`Configure`) is what the generator now emits. #4434 has not merged yet,
so it can drop its addition rather than have it simplified afterwards.
- **Branch `romanett/fluent-node-behaviors`** moved
`SimulationRegistry`, `EventSourceRegistry`, `MonitoredSourceRegistry`
and the alarm wiring onto `AttachToType`/`Attach` for *release* only,
because activation ran from `CompleteConfigureAsync` and most managers
only called the synchronous `Seal()`. Every seal site now reaches
`SealAsync`, so registry and alarm *setup* can move onto behavior
activation too — the remaining half of that work.

## Tests

- New `DiNodeManagerConfigureAsyncTests` drives the real
`DiNodeManager.CreateAddressSpaceAsync` and asserts the hook runs once
with an attached builder, that nodes it stages are registered, that
their references to externally owned nodes are published, and that the
builder is sealed before `CreateAddressSpaceAsync` returns.
- `PublishTests` now pins the root-notifier deferral contract:
registration is staged during `Configure`, lands only on the seal, and
draining twice does not double-register.
- Every `Seal()` call site in the test suites became `await
...SealAsync()`, including the three #4432 added in
`NodeSetImportBuilderTests` and `SimulationBuilderExtensionsTests`.

## Related Issues

No tracking issue was opened for this; it is the shared prerequisite
extracted from #4434 and branch `romanett/fluent-node-behaviors`, both
of which had independently worked around the same synchrony. Happy to
open one if maintainers want the ADR trail.

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

### What was actually run locally

`UA.slnx` builds clean. Post-merge on **net10.0**: `Opc.Ua.Server.Tests`
5020/0 and `Opc.Ua.SourceGeneration.Core.Tests` 3813/0. Pre-merge the
same run plus `Opc.Ua.Di.Tests` 425/0, `Opc.Ua.Robotics.Tests` 627/0,
`Opc.Ua.ISA95.Tests` 136/0, `Opc.Ua.Positioning.Tests` and
`Opc.Ua.Robotics.Intent.Tests` were all green; `MinimalRobotServer` was
also started directly to confirm the hosted robotics path comes up.

Three caveats, so the unchecked box above is not a mystery: the full
suite was **not** run against .NET Framework locally;
`Opc.Ua.OpenUsd.Tests` has one pre-existing failure on this machine
(`StructuredCartesianCoordinatesAreAccepted` expects `"1.5"` and gets
`"1,5"` — a German-locale decimal separator, unrelated to this change);
and one pre-existing `CA1861` warning comes from
`NodeSetImportIntegrationTests.cs`, which is byte-identical to `master`
on this branch.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 12:01:56 +00:00
Copilot d525bc3f28 Bound reconnect cleanup and organize samples by domain (#4276)
# Description

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

## Reconnect cleanup

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

## Sample organization

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

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

## Validation

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

## Related Issues

- Fixes #4273

## Checklist

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: marcschier <11168470+marcschier@users.noreply.github.com>
Co-authored-by: Marc Schier <mschier@microsoft.com>
Co-authored-by: Marc Schier <marcschier@users.noreply.github.com>
Copilot-Session: a2c2838f-74ae-4997-a1d4-50574d9e0636
2026-08-21 04:35:47 +00:00