Files
UA-.NETStandard/docs/NodeManagers.md
Marc SchierandCopilot 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

2732 lines
140 KiB
Markdown

# Node Managers
Event delivery for a node follows enqueue order. The Server node and
nodes opting into parallel event consumers deliver each event concurrently
to independent monitored items, then complete that event before advancing
the shared queue. A slow permission check cannot let a later event overtake
an earlier one; the bounded queue still applies backpressure.
Monitored-item deletion and mode changes isolate failures by owner.
Already completed results are retained, remaining items for a failed
owner receive an explicit error, and other owners still run. Event
unsubscribe failures likewise do not skip later owners or monitored items.
Request cancellation continues to stop the operation rather than being
converted into an ordinary per-item failure.
Fluent node builders reject new handler registration once graph authoring
is sealed, including callbacks wired directly to node slots. History,
removal, condition-refresh, and monitored-item callbacks on ad-hoc child
and instance builders use the same dispatcher as regular node builders.
A manager without that dispatcher rejects those registrations explicitly.
Monitored-source lifecycle ownership is rechecked after a worker stops.
A superseded deactivate/reactivate cycle keeps its live source rather than
reacquiring it and releasing the replacement. Callback invocation is
coordinated without holding a gate across its asynchronous completion, so
first-subscriber callbacks can still reenter monitoring operations.
Virtual monitored sources register the same shutdown behavior as concrete
sources; every live materialized instance is released at teardown.
## Table of contents
- [Overview](#overview)
- [Built-in node managers](#built-in-node-managers)
- [Master node manager](#master-node-manager)
- [Core node manager](#core-node-manager)
- [Diagnostics and configuration node manager](#diagnostics-and-configuration-node-manager)
- [Managers supplied by server features](#managers-supplied-by-server-features)
- [Runtime lifecycle provider](#runtime-lifecycle-provider)
- [Core vs custom node managers](#core-vs-custom-node-managers)
- [Storage and data structures](#storage-and-data-structures)
- [Extensibility](#extensibility)
- [Operational behaviour](#operational-behaviour)
- [Reading & Writing](#reading--writing)
- [Method Calls](#method-calls)
- [Runtime subtype replacement (IPredefinedNodeSubtypeReplacer)](#runtime-subtype-replacement-ipredefinednodesubtypereplacer)
- [Monitoring and subscriptions](#monitoring-and-subscriptions)
- [Sampling interval revision](#sampling-interval-revision)
- [History](#history)
- [Security](#security)
- [Threading contract for nodes and browsers](#threading-contract-for-nodes-and-browsers)
- [Registering node managers](#registering-node-managers)
- [Startup registration](#startup-registration)
- [Runtime registration](#runtime-registration)
- [Node manager lifecycle impact on clients](#node-manager-lifecycle-impact-on-clients)
- [MonitoredItems](#monitoreditems)
- [Continuation points](#continuation-points)
- [Namespaces](#namespaces)
- [DataTypes](#datatypes)
- [Change notifications](#change-notifications)
- [How to make a node manager reloadable](#how-to-make-a-node-manager-reloadable)
- [Related documentation](#related-documentation)
- [Server address-space metadata](#server-address-space-metadata)
- [NamespaceMetadata for every namespace](#namespacemetadata-for-every-namespace)
- [Node-manager authoring note](#node-manager-authoring-note)
- [Historical-access reconciliation](#historical-access-reconciliation)
- [See also](#see-also)
- [Source-generated node managers](#source-generated-node-managers)
- [What the generator produces](#what-the-generator-produces)
- [Opting in](#opting-in)
- [Per-class opt-in via [NodeManager] (recommended)](#per-class-opt-in-via-nodemanager-recommended)
- [Taking over the constructor and the namespace order](#taking-over-the-constructor-and-the-namespace-order)
- [Asynchronous startup before Configure](#asynchronous-startup-before-configure)
- [Binding to a model a referenced assembly supplies](#binding-to-a-model-a-referenced-assembly-supplies)
- [Project-wide opt-in via MSBuild property (legacy)](#project-wide-opt-in-via-msbuild-property-legacy)
- [Wiring callbacks: the Configure partial](#wiring-callbacks-the-configure-partial)
- [Addressing modes](#addressing-modes)
- [On-demand virtual node families](#on-demand-virtual-node-families)
- [Monitored-item creation and lifecycle](#monitored-item-creation-and-lifecycle)
- [Creating nodes under other managers' nodes (Objects folder)](#creating-nodes-under-other-managers-nodes-objects-folder)
- [Creating nodes from scratch — the Add* surface](#creating-nodes-from-scratch--the-add-surface)
- [Typed model-traversal — the Configure(I{Manager}NodeManagerBuilder) partial](#typed-model-traversal--the-configureimanagernodemanagerbuilder-partial)
- [What the generator emits per model](#what-the-generator-emits-per-model)
- [Methods with arguments — typed OnCall overloads](#methods-with-arguments--typed-oncall-overloads)
- [Event sources — typed Publish&lt;TEvent&gt; on notifier wrappers](#event-sources--typed-publishtevent-on-notifier-wrappers)
- [Where the typed overload appears](#where-the-typed-overload-appears)
- [Two registration shapes](#two-registration-shapes)
- [Tuning lifecycle with EventPublishOptions](#tuning-lifecycle-with-eventpublishoptions)
- [Hand-written node managers](#hand-written-node-managers)
- [Single-file Program.cs — what it looks like](#single-file-programcs--what-it-looks-like)
- [Multi-namespace and manager-swap subclassing](#multi-namespace-and-manager-swap-subclassing)
- [NativeAOT publishing](#nativeaot-publishing)
- [Runtime NodeSet alternative](#runtime-nodeset-alternative)
- [Building richer node managers — the fluent extension surface](#building-richer-node-managers--the-fluent-extension-surface)
- [Engineering units & EU range](#engineering-units--eu-range)
- [Bulk property initialisation](#bulk-property-initialisation)
- [References & dynamic child objects](#references--dynamic-child-objects)
- [Creating instances of model types](#creating-instances-of-model-types)
- [Alarm setup (MVP)](#alarm-setup-mvp)
- [Boolean supervision → alarm activation (NAMUR pattern)](#boolean-supervision--alarm-activation-namur-pattern)
- [Simulation timers](#simulation-timers)
- [Pushing runtime value changes to subscribers](#pushing-runtime-value-changes-to-subscribers)
- [Subscription-gated sources](#subscription-gated-sources)
- [Multi-model composition](#multi-model-composition)
- [Mixing ModelDesign and NodeSet2 in one project](#mixing-modeldesign-and-nodeset2-in-one-project)
- [Importing a NodeSet2 overlay at runtime — `builder.Import`](#importing-a-nodeset2-overlay-at-runtime--builderimport)
- [NodeSet2 access-level bitmasks](#nodeset2-access-level-bitmasks)
- [Materialising instances at runtime — NodeId assignment](#materialising-instances-at-runtime--nodeid-assignment)
- [Current limitations](#current-limitations)
- [Sample](#sample)
## Overview
A node manager is the server-side component that owns a portion of the server address space and implements the service behavior for the nodes in that portion. In this stack a node manager is an `IAsyncNodeManager` (or the older synchronous `INodeManager` adapted to it) that can create nodes during address-space startup, return manager handles for `NodeId`s it owns, browse and translate references, read and write attributes, dispatch methods, validate monitored items, and participate in history, events, and node-management services.
`StandardServer` creates a `MasterNodeManager` while the server starts. The master node manager is the server's routing layer: OPC UA service implementations call it, and it dispatches each operation to the node manager that owns the requested node. Ownership is resolved primarily from the `NodeId.NamespaceIndex`, then confirmed by asking the candidate manager for a handle. This keeps application models, runtime NodeSets, diagnostics, configuration, and namespace 0 infrastructure independent while presenting one coherent address space to clients.
Developers need to care about node managers when they expose application data, methods, events, alarms, file-system objects, alias names, runtime NodeSets, or companion-spec models from a server. A node manager is also where model-specific behavior is attached: read and write callbacks, method callbacks, historian providers, event notifiers, permissions, model-change notifications, and cross-manager references to nodes owned elsewhere. For simple generated models, the source generator and fluent builder hide much of the plumbing; for dynamic or backed-by-service models, a custom manager is the boundary between the OPC UA services and the application's data source.
The server builds the initial set of node managers before accepting connections. Additional managers can be registered by hosting extensions such as `AddNodeManager` and `AddRuntimeNodeSet`, and the lifecycle API can add, reload, or remove lifecycle-managed managers while the server is running. Regardless of how a manager is supplied, it must cooperate with the master node manager's routing and reference-merging rules so clients can browse, monitor, and call nodes consistently across namespace and manager boundaries.
## Built-in node managers
Replicated application managers require a fixed
[replica NodeId identity configuration](ReplicaNodeIdentity.md). It runs before
their constructors, so namespace indexes and callback keys are already final.
The shared registration policy survives factory rebasing/replacement and runs
before predefined-node indexing, including during import and hydration. Shared
model namespaces must be declared up front; loading a new shared namespace at
runtime requires a coordinated layout revision. Built-in core, diagnostics and
configuration namespaces retain their replica-local role.
Every `StandardServer` creates a `MasterNodeManager` and asks the server's `IMainNodeManagerFactory` for the main managers that are always present. The default `MainNodeManagerFactory` creates one `ConfigurationNodeManager` and one `CoreNodeManager`; application-provided managers from `AddNodeManager` or derived-server overrides are appended after those built-ins.
### Master node manager
`MasterNodeManager` is not an address-space model in the same sense as an application manager. It is the coordinator that the server stores as `IServerInternal.NodeManager` and exposes through `IMasterNodeManager`. Service implementations route through it for reads, writes, browsing, TranslateBrowsePaths, method calls, node management, monitored-item setup, history, and event-related operations.
Internally, service-call dispatch and lifecycle coordination are separated. The session-service implementations live in the internal `NodeManagerServiceDispatcher`, which depends only on the lock-free routing-table snapshot and never acquires lifecycle semaphores; `MasterNodeManager` keeps the public service surface (every virtual entry point and protected helper delegates to the dispatcher, so derived classes are unaffected) together with NodeManager lifecycle coordination. The node-management services (AddNodes, DeleteNodes, AddReferences, DeleteReferences) sit between the two: they dispatch per item like other services but serialize address-space mutation with runtime NodeManager lifecycle operations.
`AddNodes` rejects colliding identifiers whether supplied by the client,
derived from a parent and browse name, or returned by a custom allocator.
Registration atomically admits the new root without replacing an existing
node. This add-only rule does not change the explicit runtime
replacement/re-registration APIs.
Variable admission also checks the supplied value against its datatype, rank
and array dimensions. Each nonzero `ArrayDimensions` entry is a maximum length:
shorter values are valid, while exceeding any dimension returns
`BadNodeAttributesInvalid` without registering the node. Zero denotes an
unknown maximum; null and empty arrays remain valid for compatible array types.
The master node manager builds a routing table keyed by namespace index. During construction it ensures the configured dynamic namespace URI is present, registers the configuration/diagnostics manager first, registers the core node manager second, and then registers application managers. For a service request, `GetManagerHandleAsync` uses the `NodeId.NamespaceIndex` to find the candidate manager list and asks each candidate for a handle until one claims the node. If no explicit route exists for the namespace, it falls back to the core node manager. This means a namespace route is a candidate list, not a single-owner map.
Multiple managers can serve the same namespace. `RegisterNamespaceManager(string namespaceUri, IAsyncNodeManager nodeManager)` appends a manager to the namespace route instead of replacing the existing route; the routing table also preserves manager order during lifecycle replacement. This is important for namespace 0 and for generated or runtime models that add nodes in namespaces already used by another manager.
The master node manager also merges references across managers. Each manager receives the shared `externalReferences` table while `CreateAddressSpaceAsync` runs. After all managers have created their nodes and historical-access advertisement has been reconciled, the master calls `AddReferencesAsync(externalReferences, ...)` on every manager. `MasterNodeManager.CreateExternalReference` is the helper most custom managers use to put a cross-manager reference in that table. The target manager then materialises the reference on the node it owns, so a node owned by one manager can be browseable from a parent, folder, metadata object, or notifier owned by another manager. Attaching a child only in the source manager is not enough when the source and target owners differ.
At runtime, the same reference handling is used for lifecycle-managed managers. A prepared manager builds its address space while hidden from client routing, the master adds its external references during commit, and rollback/removal removes the cross-manager references that were added for that generation.
### Core node manager
`CoreNodeManager` is the always-present manager for the core address-space infrastructure. The default master-node-manager constructor registers it for namespace index 0 and for the built-in server namespace route, and it also uses it as the fallback when a namespace has no explicit route. `CoreNodeManager` derives from `AsyncCustomNodeManager`, implements `ICoreNodeManager`, and uses sampling groups for monitored items.
`AsyncCustomNodeManager.Dispose()` stops admission to gate-backed operations and
starts cleanup without blocking on outstanding work. `DisposeAsync()` also waits
for admitted operations and semaphore owners to finish before releasing the
address space, monitored-item manager and synchronization resources. Diagnostics
mutations participate in the same lifetime. `MasterNodeManager` and the server's
asynchronous teardown await this cleanup; prefer `await using` when directly
owning a manager. Subclasses release deferred resources in `DisposeAsyncCore`,
which runs after admitted operations drain, and await its base implementation.
Cleanup starts outside the admission lock, including when all operations have
already completed. Guarded helpers remain available within the active
`DisposeAsyncCore` async context, but new caller operations remain rejected.
Subclasses must await their teardown operations before the callback returns.
Operation lifetime extends through post-processing callbacks even after their
semaphore scope has ended.
`CustomNodeManager2` also closes admission before releasing its monitored-item
manager. Its synchronous service and lifecycle calls, and its optional
asynchronous method callbacks, retain operation leases until they return.
Cleanup waits for those leases without holding the node lock or the admission
lock, so a sampling worker can observe closed admission and finish. New calls
after admission closes throw `ObjectDisposedException`, except `Find` and both
`FindPredefinedNode` overloads, which return null. Nested calls belonging to an
operation that is still admitted can finish; a captured context cannot grant
access after that operation returns. `Dispose()` initiates
this shutdown; `DisposeAsync()` awaits the shared completion and propagates
cleanup failures even when a legacy `Dispose(bool)` override omits its base call.
`AsyncNodeManagerAdapter` invokes the wrapped asynchronous disposal directly,
without first invoking synchronous disposal, so
the master and server also await cleanup for adapted synchronous managers.
An admitted callback may initiate shutdown with `Dispose()`, but must return
before its caller awaits `DisposeAsync()`.
Before serializing address-space deletion or disposal, the master drains
configuration work that can call back into the server. Session-closing
notifications and the address space remain available until accepted user
deactivations and deferred configuration effects complete.
The core manager owns and imports built-in nodes that other server components need to expose as part of the standard server address space. It is also the target for nodes loaded by the diagnostics/configuration manager from generated model output: `DiagnosticsNodeManager.CreateAddressSpaceAsync` loads predefined diagnostics/configuration nodes and then imports them into the core manager with `ImportNodesAsync(..., isInternal: true)`. When application nodes are imported with `isInternal: false`, the core manager updates the diagnostics manager so diagnostics metadata stays in sync.
### Diagnostics and configuration node manager
The default configuration and diagnostics manager is a single object. `MainNodeManagerFactory.CreateConfigurationNodeManager` creates a `ConfigurationNodeManager`; `ConfigurationNodeManager` derives from `DiagnosticsNodeManager` and implements `IConfigurationNodeManager`. `ServerInternalData.SetNodeManager` assigns `DiagnosticsNodeManager`, `ConfigurationNodeManager`, and `CoreNodeManager` from the master node manager, and the master exposes both diagnostics and configuration properties from index 0 of its manager list. In the default server, therefore, `ServerInternal.DiagnosticsNodeManager` and `ServerInternal.ConfigurationNodeManager` refer to the same `ConfigurationNodeManager` instance through different interfaces.
As a diagnostics manager, it loads the standard diagnostics and server-support nodes generated for the stack, manages session and subscription diagnostics, diagnostics enable/disable state, aggregate functions, event notifier updates, and the well-known OPC UA Part 17 alias-name methods that dispatch through the server-wide alias-name registry. It registers namespace URIs for the OPC UA namespace and the diagnostics namespace.
As a configuration manager, the same instance exposes push certificate-management and server-configuration functionality from OPC UA Part 12. It owns the server-configuration methods and state that interact with trust lists, certificate groups, transaction coordination, pending regenerated keys, endpoint and listener registries, and post-`ApplyChanges` effects. The class is a partial split by concern (`ConfigurationNodeManager.PushMethods.cs`, `.PushValidation.cs`, `.CertificateSlots.cs`, `.ApplyChanges.cs`, `.TrustMaterial.cs`, `.CertificateAlarms.cs`, `.NamespaceMetadata.cs`) over one core file, and delegates namespace-metadata tracking and alarm scheduling to the internal `NamespaceMetadataRegistry` and `CertificateAlarmScheduler` collaborators.
### Managers supplied by server features
The default server does not create file-system, alias-name, or runtime-NodeSet managers unless the application opts in. Hosting extensions register their factories as normal startup node-manager factories: for example, file-system support uses `FileSystemNodeManagerFactory`, runtime NodeSet loading uses `RuntimeNodeSetNodeManagerFactory`, and alias-name support can use `AliasNameNodeManager`. Once registered, these managers are routed by the same master-node-manager table and follow the same cross-reference and lifecycle rules as hand-written or source-generated managers.
### Runtime lifecycle provider
`StandardServer` also creates a `NodeManagerLifecycle` provider. It is not itself a node manager; it is the host control-plane object behind `INodeManagerLifecycle`. Hosted servers expose it through dependency injection, and direct `StandardServer` users can access `StandardServer.NodeManagerLifecycle`. The lifecycle provider prepares, commits, reloads, removes, and drains lifecycle-managed managers through the master node manager.
## Core vs custom node managers
This document outlines the key differences in behavior and implementation between `CoreNodeManager` and `CustomNodeManager2` within the OPC UA .NET Standard Stack.
`CoreNodeManager` is typically used for managing the internal nodes of the Server (Namespace 0) or simple static node sets. `CustomNodeManager2` is designed as a base class for developers implementing custom node managers with specific business logic, dynamic behavior, or backing stores.
### Storage and data structures
| Feature | CoreNodeManager | CustomNodeManager2 |
| :--- | :--- | :--- |
| **Node Storage** | Uses a `NodeTable` (`m_nodes`) internally. | Uses a `NodeIdDictionary<NodeState>` (`PredefinedNodes`). |
| **Node Type** | Manages `ILocalNode` interface objects. | Manages `NodeState` objects (and subclasses). |
| **Handle Type** | `GetManagerHandle` returns the `ILocalNode` instance directly. | `GetManagerHandle` returns a `NodeHandle` wrapper containing the `NodeState` and validation status. |
| **Locking** | Uses `DataLock` (object). | Uses `Lock` (object). |
| **Namespace** | Typically manages dynamic nodes in specific indexes or internal server nodes. | Designed to manage specific namespaces passed in the constructor. Uses `IsNodeIdInNamespace` checks. |
### Extensibility
| Feature | CoreNodeManager | CustomNodeManager2 |
| :--- | :--- | :--- |
| **Design Intent** | Sealed-like behavior. Not primarily designed for inheritance or overriding behavior. | Highly extensible. Most methods (`Read`, `Write`, `Browse`, `Call`) are `virtual` to allow custom overrides. |
| **Node Factory** | Does not implement `INodeIdFactory`. | Implements `INodeIdFactory` to generate new NodeIds for the system context. |
| **Address Space** | `CreateAddressSpace` is often empty (`ImportNodes` is used instead). | `CreateAddressSpace` invokes `LoadPredefinedNodes` to load nodes from resources/assemblies. |
### Operational behaviour
#### Reading & Writing
* **CoreNodeManager**:
* **Read**: Directly invokes `ILocalNode.Read`.
* **Write**: Performs basic type checking (expected data type/value rank) and invokes `ILocalNode.Write`.
* **CustomNodeManager2**:
* **Read**: Validates the node handle, supports operation caching, and invokes `NodeState.ReadAttribute`. Handles timestamp synchronization (e.g., matching ServerTimestamp to SourceTimestamp for Value attributes).
* **Write**:
* Performs **Range Checks** for `AnalogItemState` (InstrumentRange).
* Generates **Audit Events** (`Server.ReportAuditWriteUpdateEvent`).
* Detects **Semantic Changes** (e.g., changes to `EURange`, `EnumStrings`) and updates monitored items accordingly.
#### Method Calls
`CustomNodeManager2.Call` completes synchronously through its synchronous
`Call` override and `MethodState.Call`. It does not launch asynchronous
overrides or discard their results. An unhandled dispatch exception is
returned to the caller as an exception, while errors translated by
`MethodState` retain their normal `ServiceResult` status.
Use `CallAsync` and the asynchronous overrides for work that can suspend.
The awaitable path completes only after that work finishes. Both paths
share node ownership, method resolution, role-permission checks, and
argument-result handling.
* **CoreNodeManager**:
* **Browse**: Iterates over references stored in `ILocalNode`. Basic masking and filtering.
* **Translate**: Basic search through internal references.
* **CustomNodeManager2**:
* **Browse**: Uses `NodeState.CreateBrowser`. Explicitly validates `PermissionType.Browse`. Supports Views (`IsNodeInView`).
* **Translate**: Uses `CreateBrowser` to navigate path. Supports resolving targets in other node managers via `unresolvedTargetIds`.
Browse continuation ownership transfers explicitly between the dispatcher,
node manager, and session store. Permission rejection, metadata/fetch
failure, and cancellation release the claimed point. If a batch is
canceled, any earlier retained pages that were not returned to the client
are released too. Successful continuation pages remain owned by the
session until resumed, released, or expired.
#### Runtime subtype replacement (`IPredefinedNodeSubtypeReplacer`)
`AsyncCustomNodeManager` implements the `IPredefinedNodeSubtypeReplacer` capability interface. It swaps an already-registered predefined instance node for a **differently-typed instance** (typically a generated subtype) at runtime, while preserving the node's identity in the address space:
* the replacement inherits the existing node's `NodeId`, `BrowseName`, `SymbolicName`, `DisplayName` and `ReferenceTypeId`;
* children shared by both types (matched by `BrowseName` at any depth) keep the existing child's `NodeId` and value, so well-known instance NodeIds survive the swap;
* children that only exist on the replacement take their `NodeId` from a caller-supplied `BrowseName → NodeId` map, or a freshly minted one;
* the old subtree is removed and the new one registered in the manager's `PredefinedNodes` index, and a `ModelChange` is emitted (subject to `ModelChangeEmissionEnabled`) so live clients observe the new type definition and members.
**When to use it.** Reach for this capability when a well-known instance node's concrete type is a *runtime* decision — for example modelling `Server.ServerRedundancy` as `TransparentRedundancyType` vs `NonTransparentRedundancyType` from configuration, and changing that mode live (see `Opc.Ua.Redundancy.Server.ServerRedundancyController`). It is the right tool whenever you would otherwise mutate a node's `TypeDefinitionId` in place and hand-build the subtype-specific children.
**When not to use it.** If you only need to re-index an already-reparented replacement of the *same* type (e.g. promoting a passive nodeset node to a typed proxy), the lighter `ReplacePredefinedNode(nodeId, node)` index-only swap is sufficient — this is what `RoleStateBinding` and the `ConfigurationNodeManager` passive→typed promotion do today. If you are *creating* a new node subtree, use `AddNodeAsync` / `AddPredefinedNodeAsync` or the fluent `CreateInstance<TState>(...)` builder instead.
Create the replacement with the generated `CreateInstanceOf<Type>` factory, then hand it to the capability:
```csharp
// server.DiagnosticsNodeManager (or any AsyncCustomNodeManager) exposes the capability.
if (server.DiagnosticsNodeManager is IPredefinedNodeSubtypeReplacer replacer)
{
ISystemContext context = server.DefaultSystemContext;
ServerObjectState serverObject = server.ServerObject;
var existing = serverObject.ServerRedundancy;
// Build the target subtype instance (typed, generated).
NonTransparentRedundancyState subtype = context.CreateInstanceOfNonTransparentRedundancyType();
await replacer.ReplacePredefinedInstanceSubtypeAsync(
context,
existing,
subtype,
// well-known NodeIds for members that only exist on the subtype
newChildNodeIds: new Dictionary<QualifiedName, NodeId>
{
[new QualifiedName(BrowseNames.ServerUriArray, 0)]
= VariableIds.Server_ServerRedundancy_ServerUriArray
},
// keep the parent's typed backing slot in sync (setters don't reparent)
onReplaced: node => serverObject.ServerRedundancy = (ServerRedundancyState)node,
cancellationToken);
}
```
The operation is deliberately exposed as a capability interface method rather than a construction-time fluent builder: the fluent `INodeBuilder` surface models building a node *before* it is registered, whereas subtype replacement mutates a node that is already live in the address space. Callers that already hold a fluent builder can still create the replacement instance with `CreateInstance<TState>(...)` and then pass the built node to the capability.
### Monitoring and subscriptions
| Feature | CoreNodeManager | CustomNodeManager2 |
| :--- | :--- | :--- |
| **Manager** | Uses `SamplingGroupManager` directly. | Uses `IMonitoredItemManager` abstraction (defaults to `SamplingGroupMonitoredItemManager` or `MonitoredNodeMonitoredItemManager`). |
| **Filter Validation** | Validates `DataChangeFilter` specifically (deadband, EU Range). | Delegates validation to `ValidateMonitoringFilter`, supports `AggregateFilter` (if supported by server) and `DataChangeFilter`. |
| **Events** | Basic event subscription support (`SubscribeToEvents` checks `EventNotifier` bit). | **Full Event Support**: <br/>- Manages `RootNotifiers`. <br/>- Propagates events via `SubscribeToAllEvents`. <br/>- Implements `ConditionRefresh`. <br/>- Validates `PermissionType.ReceiveEvents`. |
#### Sampling interval revision
When a client creates or modifies a monitored item the node manager revises the
requested `samplingInterval` before the server returns it in
`revisedSamplingInterval`. Three inputs take part:
| Input | Where it comes from |
| :--- | :--- |
| Requested sampling interval | `MonitoringParameters.SamplingInterval`; a negative value means "use the default sampling interval" (see below) |
| Node minimum | `BaseVariableState.MinimumSamplingInterval` of the monitored node, and only for the `Value` Attribute |
| Server minimum | `ServerConfiguration.MinSupportedSamplingInterval`, published in `Server.ServerCapabilities.MinSupportedSampleRate` |
The rule applied by `SubscriptionManager.CalculateRevisedSamplingInterval` is:
1. A requested interval below zero is resolved to the default sampling interval:
the **publishing interval of the subscription** when the item is created, and
the item's **current sampling interval** when it is modified. A
`ModifyMonitoredItems` call that leaves the sampling interval unspecified
therefore does not silently retune the item.
2. If the node declares `MinimumSamplingIntervals.Continuous` (`0`) for the
`Value` Attribute, it reports by exception and **no** lower bound is applied —
the requested interval is returned unchanged.
3. Otherwise the interval is raised to the larger of the node minimum and
`MinSupportedSamplingInterval`. Attributes other than `Value`, nodes that
declare `MinimumSamplingIntervals.Indeterminate` (`-1`), and nodes that are
not Variables are only bound by `MinSupportedSamplingInterval`.
4. `double.MaxValue` is capped to one year.
Event monitored items do not sample and are not affected.
`MinSupportedSamplingInterval` defaults to `0`, which means the server does not
impose a server-wide lower bound. Configure it in XML:
```xml
<ServerConfiguration>
<!-- ... -->
<MaxNotificationsPerPublish>1000</MaxNotificationsPerPublish>
<MinSupportedSamplingInterval>2000</MinSupportedSamplingInterval>
<!-- ... -->
</ServerConfiguration>
```
or with the fluent configuration builder:
```csharp
application.Build(applicationUri, productUri)
.AsServer([endpointUrl])
.SetMinSupportedSamplingInterval(2000);
```
With `MinSupportedSamplingInterval` set to 2000 ms, a client that requests a
10 ms sampling interval on a node that declares a minimum of 100 ms is revised
to 2000 ms; a node that declares 5000 ms still wins and is revised to 5000 ms.
`CustomNodeManager2` and `AsyncCustomNodeManager` pick the configured value up
automatically through their `MinSupportedSamplingInterval` property. Node
managers that implement `INodeManager` directly can call
`SubscriptionManager.CalculateRevisedSamplingInterval` to apply the same rule.
### History
* **CoreNodeManager**:
* `HistoryRead` / `HistoryUpdate`: Iterates nodes and returns `BadNotReadable` / `BadNotWritable` (or `BadHistoryOperationUnsupported` implicit). No infrastructure for history.
* **CustomNodeManager2**:
* Provides scaffold methods (`HistoryReadRawModified`, `HistoryReadProcessed`, `HistoryUpdateData`, etc.).
* Checks `AccessLevels.HistoryRead/Write` and `EventNotifier.HistoryRead/Write`.
* Default implementation returns `BadHistoryOperationUnsupported`, but is structured for easy overriding in derived classes.
### Security
* **CoreNodeManager**:
* Checks `AccessLevel`, `UserAccessLevel`, `WriteMask` in `Write`.
* Loads Role Permissions into metadata.
* **CustomNodeManager2**:
* Explicitly calls `MasterNodeManager.ValidateRolePermissions` during `Browse`, `Call`, and Event processing.
* Reads and caches validation attributes (`AccessRestrictions`, `RolePermissions`) for optimized access.
### Threading contract for nodes and browsers
A `NodeState` synchronizes itself. Its attributes, children, notifiers and references each sit
behind a private lock inside the node, and `NodeState.CreateBrowser` holds a browse lock while it
assembles the browser. **No caller — inside or outside the stack — may take a lock on a node
instance.** A `lock (node)` is a lock on a monitor that guards nothing: every path that touches the
node's data takes the node's own locks instead, so the two never interlock. This is enforced by
convention rather than by the compiler, so the rule is: if you feel the need to lock a node, the
operation you want is missing from `NodeState` — add it there.
| You want | Use |
| --- | --- |
| A consistent read of one attribute | `ReadAttribute` / `ReadAttributeAsync` — already guarded |
| A consistent read of several attributes | `ReadAttributes` — each attribute is guarded; there is deliberately no cross-attribute transaction |
| A consistent snapshot of the references | `GetReferences`, `GetChildren` — already guarded |
| Add a reference only if it is absent | `AddReferenceIfMissing` — the check and the insert are one critical section |
| Everything browsable, without locking the node | `CreateBrowser` — the node guards the build |
**What `CreateBrowser` does and does not promise.** Browser construction on a node is
serialized, so two concurrent browses do not interleave their `PopulateBrowser` /
`OnPopulateBrowser` work — a handler that mutates the node during population depends on that.
The browser is a point-in-time copy: later changes to the node do not appear in it. It is **not**
an atomic snapshot across the node's children, notifiers and references. Writers take those
collections' own locks, not the browse lock, so a browser built while a writer runs can pair
children from before a change with references from after it. Each collection is read
consistently; the combination is not a transaction. Making it one would mean funnelling every
child, notifier and reference write through the browse lock, which is a far larger contract than
browsing needs.
An **`INodeBrowser` is single-consumer.** It performs no synchronization of its own and belongs
to whoever created it. Where a browser outlives a single service call — the instance parked in a
continuation point for `BrowseNext` — its owner serializes access to it: `AsyncCustomNodeManager`
does so through the continuation point's `BrowserContext`, `CustomNodeManager2` with a lock
around the iteration. A derived browser must not add locking of its own; `NodeBrowser` no longer
exposes one to inherit (see [migration](migrate/2.0.x/node-states.md)).
An **`INodeBrowser` is iterated through `NextAsync`** by the async server paths. `Next()` and
`NextAsync(CancellationToken)` drain the same sequence, and the default `NextAsync` simply wraps
`Next()`, so a browser that only overrides `Next()` works unchanged. `AsyncCustomNodeManager`
drives both `BrowseAsync` and `TranslateBrowsePathAsync` through `NextAsync`, which gives a
browser whose references come from I/O — an aggregating server that browses another server, a
device, a file system — a place to `await` instead of holding a request worker on a blocking
call. Creation stays synchronous: `OnCreateBrowser` and `CreateBrowser` build the browser under
the node's browse lock, so the fetch happens lazily on the first `NextAsync`, not in the
constructor.
```csharp
public sealed class RemoteBrowser : NodeBrowser
{
public override async ValueTask<IReference?> NextAsync(CancellationToken cancellationToken)
{
// In-memory references first, exactly as the base class hands them out.
IReference? reference = base.Next();
if (reference != null)
{
return reference;
}
m_remote ??= await m_session.BrowseAsync(..., cancellationToken).ConfigureAwait(false);
return m_remote.Count > 0 ? m_remote.Dequeue() : null;
}
// Synchronous consumers still exist (the nodeset exporter, CustomNodeManager2);
// bridge them rather than duplicating the fetch.
public override IReference? Next()
{
return NextAsync(CancellationToken.None).AsTask().GetAwaiter().GetResult();
}
}
```
Three rules follow for node types that customise browsing:
* An override of `PopulateBrowser` runs with the node's browse lock held. Keep it to in-memory
work — the lock is held for its duration, so blocking on I/O there stalls every other browse of
that node. A browser that has to reach an underlying system does that lazily in its own
`NextAsync`, outside every node lock, as `DirectoryBrowser` does for the file-system provider.
* A browser that overrides `NextAsync` also overrides `Next()`, because the base `Next()` only
sees the in-memory references. Bridging `Next()` to `NextAsync` is the usual shape; the async
server never calls it, so the blocking only ever happens on a synchronous caller's own thread.
* An override of `CreateBrowser` that builds its own browser instead of delegating to
`base.CreateBrowser` must fill it through `PopulateBrowserSynchronized`. Calling
`PopulateBrowser` directly leaves construction unserialized against other browses of the same
node and skips `OnPopulateBrowser` altogether.
## Registering node managers
A NodeManager owns a part of the server address space. This section explains the three points at
which a NodeManager can be registered with a server, and what the server guarantees when
registrations change while the server is running.
For how to author a NodeManager, see [source-generated NodeManagers](#source-generated-node-managers),
[runtime NodeSets](RuntimeNodeSets.md), and
[CoreNodeManager vs CustomNodeManager2](#core-vs-custom-node-managers).
There are several ways a NodeManager originates and is added to a server, shown in the following
table.
| Registration point | API | When the address space is built |
| --- | --- | --- |
| Compile time | A source-generated or hand-written `AsyncCustomNodeManager` / `CustomNodeManager2` type | When the server creates its address space |
| Startup | `IOpcUaServerBuilder.AddNodeManager(...)`, `IOpcUaServerBuilder.AddRuntimeNodeSet(...)` | During `CreateAddressSpaceAsync`, before the server accepts connections |
| Runtime | `INodeManagerLifecycle.AddAsync` / `ReloadAsync` / `RemoveAsync` | While the server is running and serving Clients |
Compile-time and startup registration are the normal path. Use runtime registration only when the
set of models genuinely has to change without restarting the server.
### Startup registration
`AddNodeManager` and `AddRuntimeNodeSet` register a factory on `IOpcUaServerBuilder`. The factory is
created before the server starts, and the server builds its address space from all registered
factories while it starts. Once startup succeeds, each application NodeManager appears as a
[generation-1 entry](#registration-generations) in `INodeManagerLifecycle.Registrations`. The built-in diagnostics,
configuration, and core NodeManagers are not exposed there.
```csharp
services.AddOpcUa()
.AddServer(o => { /* … */ })
.AddNodeManager(sp => new MyNodeManager(sp.GetRequiredService<ITelemetryContext>()));
```
For a one-shot fluent node manager, the callback creates and places the
complete contributed graph. The hosting API does not add an implicit root:
```csharp
const string namespaceUri = "urn:example:line";
services.AddOpcUa()
.AddServer(o => { /* … */ })
.AddNodeManager(namespaceUri, builder =>
{
ushort namespaceIndex =
(ushort)builder.Context.NamespaceUris.GetIndex(namespaceUri);
builder.CreateInstance(
new QualifiedName("Line", namespaceIndex),
parent => new FolderState(parent))
.Configure(node => node.UnderObjectsFolder());
});
```
### Registration generations
A **generation** identifies one published NodeManager instance within a logical registration.
`NodeManagerRegistration.Id` stays the same across reloads; `Generation` numbers the successive
instances. Startup adoption and `AddAsync` both create the first instance with `Generation = 1`,
hence **generation 1**. The first committed reload creates **generation 2**, the next creates
**generation 3**, and so on. All [reload modes](#reload-modes) use this numbering; it is independent
of the information model's version and namespace index.
After a reload, use the newly returned registration handle for further lifecycle operations.
The previous handle is stale even if a shadow reload keeps its old NodeManager alive for existing
Clients. Adding a new registration starts at generation 1 with a new `Id`, rather than continuing
the numbering of a removed registration.
### Runtime registration
A running server exposes `INodeManagerLifecycle`. Resolve it from dependency injection in a hosted
server, or use `StandardServer.NodeManagerLifecycle` when constructing the server directly.
```csharp
public sealed class ModelLoader(INodeManagerLifecycle lifecycle)
{
private NodeManagerRegistration? m_registration;
public async ValueTask LoadAsync(IAsyncNodeManagerFactory factory, CancellationToken ct)
{
m_registration = await lifecycle.AddAsync(factory, callerContext: null, ct);
}
public async ValueTask ReloadAsync(IAsyncNodeManagerFactory replacement, CancellationToken ct)
{
m_registration = await lifecycle.ReloadAsync(m_registration!, replacement, callerContext: null, ct);
}
public ValueTask RemoveAsync(CancellationToken ct)
{
return lifecycle.RemoveAsync(m_registration!, callerContext: null, ct);
}
}
```
Each add returns an immutable `NodeManagerRegistration`. Reload returns the next generation and
invalidates the previous handle. `Registrations` also contains application NodeManagers that were
composed before startup, so a control-plane component can discover their generation-1 handles and
remove them or, when the current manager implements `INodeManagerReloadParticipant`, reload them.
Diagnostics, configuration, and core NodeManagers remain protected and absent from the collection.
`INodeManagerLifecycle` is a host control-plane API. Do not invoke reload or removal from inside an
OPC UA service or Method callback: teardown waits for the requests that already captured the retired
routing generation to complete before disposing it, so a lifecycle call made from within such a
request would wait for itself.
Every lifecycle method takes the operation the caller is running under. Pass it from a NodeManager
or Method callback and the server rejects the call with an `InvalidOperationException` instead of
deadlocking:
```csharp
private async ValueTask<ServiceResult> OnReloadModelAsync(
ISystemContext context,
MethodState method,
IList<Variant> inputArguments,
IList<Variant> outputArguments,
CancellationToken ct)
{
// Throws InvalidOperationException: the call is serving a Client request.
await m_lifecycle.ReloadAsync(
m_registration,
replacement,
context.GetOperationContext(),
ct);
return ServiceResult.Good;
}
```
A control-plane caller — a hosted service, or anything resolved from dependency injection — is not
serving a request and passes `null`.
The guard is an identity check against the requests the server is currently executing, not ambient
state, so an internal operation that was never enrolled as a Client request is allowed through, and
a context whose request has already completed no longer blocks anything. A caller that is inside a
request but passes no operation is not detected; for that case the wait is bounded instead: it lasts
at most as long as the longest deadline still outstanding plus `RequestManager.RequestDrainTimeout`,
after which the lifecycle operation fails with a `TimeoutException` instead of blocking
indefinitely.
A server that rejects requests of its own by overriding `StandardServer.OnRequestValidatedAsync`
does not interfere with this: a rejected request is completed before the exception leaves the
server, so it never holds a lifecycle operation up.
A lifecycle operation is transactional. The replacement address space is built and validated before
anything becomes visible to Clients, and any failure is rolled back, so Clients never observe a
partially applied model.
### Node manager lifecycle impact on clients
#### Reload modes
All reload modes build and validate the replacement generation before Clients can see it. The
commit then switches new service requests to the replacement generation atomically. Namespace URI
indexes are append-only server state, so reloads can add namespace URIs but do not renumber
existing indexes. Requests that already captured the retired generation are allowed to complete
before the server detaches and disposes it.
The modes differ in the client contract for work already attached to the retired generation:
| Mode | Existing MonitoredItems | Browse continuation points and in-flight requests | When to choose it | Cost |
| --- | --- | --- | --- | --- |
| Normal reload, `ReloadAsync` | The server detaches items from the retired generation before the routing switch, attaches compatible items to the replacement after commit, and reports `BadNodeIdUnknown` once for removed or incompatible items. Subscriptions and compatible MonitoredItem ids are preserved. | Existing requests complete on the generation they captured. Continuation points that captured the retired generation are invalidated after that request drain because no old MonitoredItems remain to keep the generation alive. | The default for compatible model updates where Clients should keep subscriptions and receive a clear status only for removed nodes. | Requires the replacement to support monitored-item attachment and may fail the reload if an unexpected item incompatibility is detected. |
| Shadow reload, `ShadowReloadAsync` | Existing items stay on the retired generation and continue sampling there. New MonitoredItems are created on the replacement. The retired generation is disposed only after those old items are deleted, their Sessions close, or they otherwise drain. | Requests and continuation points that already captured the retired generation keep using it while it remains shadow-retired. Cleanup invalidates remaining continuation points only once no old MonitoredItems are active. | Use when existing subscriptions must keep exactly the old model semantics while new Clients move to the replacement, for example during long migrations or when compatible hand-over is not desirable. | Runs two generations at once, including the old sampling/event fan-out, so memory and model resources remain allocated until Clients drain. |
| Immediate reload, `ImmediateReloadAsync` | The server detaches every item owned by the retired generation and reports `BadNodeIdUnknown`; it does not try to attach compatible items to the replacement. Clients may recreate items against the new generation. | Existing requests complete on the generation they captured. Continuation points that captured the retired generation are invalidated after the request drain, and the old generation can then be detached promptly. | Use for destructive or security-sensitive changes where serving or migrating old items is worse than forcing Clients to resubscribe. | Causes deliberate subscription churn and one bad status per old data MonitoredItem. |
All three modes are intentional: normal reload preserves compatible subscriptions, shadow reload
preserves old subscriptions without migration, and immediate reload fails old subscriptions fast.
#### MonitoredItems
Active MonitoredItems survive reload and removal. A compatible NodeId in a replacement generation
keeps the same MonitoredItem and Subscription without a transient bad status. A removed or
incompatible NodeId is detached and publishes one `BadNodeIdUnknown` data-change notification, as
required by OPC UA Part 4 §5.8.4.1; adding a compatible Node with the same NodeId later revalidates
and reattaches the item automatically. Event MonitoredItems detach and recover their source binding
without synthesizing a data-change status.
That notification is queued in its natural position, because Part 4 §5.13.1.5 requires a Server to
return notifications in the order they are in the queue. It occupies an ordinary queue slot, but it
is the one value that is never discarded: once the queue is full, an incoming value is dropped
instead of the notification, so a full queue cannot swallow it.
This applies only when queuing is enabled. At the default `queueSize` of 1 the MonitoredItem has no
queue at all — the last sampled value is what the Client is served — so the notification simply
becomes that value, and a value sampled after the deletion replaces it in the usual way. Losing
values is the accepted behaviour of a MonitoredItem without queuing. Issue
[#4102](https://github.com/OPCFoundation/UA-.NETStandard/issues/4102) records the underlying
specification ambiguity: the protected, over-capacity slot the specification defines applies to
`EventQueueOverflowEventType` only, so it says nothing about how a mandatory data-change
notification survives a full queue. Only one such notification is pending at a time, and a pending
one is not preserved across a durable subscription restart.
The built-in NodeManager and Subscription implementations support these transitions. A custom
implementation that the server cannot migrate safely fails with `NotSupportedException` before any
routing changes, so the operation is rejected rather than half applied.
To make a custom NodeManager participate, derive from `CustomNodeManager2` or
`AsyncCustomNodeManager`, which already implement the MonitoredItem transition contract, or
implement `INodeManagerMonitoredItemLifecycle` directly. That interface needs four operations: report
whether an existing MonitoredItem could attach, detach one without disposing it, attach a detached
one to the matching Node, and give a detached one back when a lifecycle operation is rolled back. A
custom Subscription implementation needs the equivalent snapshots from
`ISubscriptionMonitoredItemLifecycle`.
#### Continuation points
Reload and removal invalidate saved Browse continuation points owned by the retired NodeManager. A
later `BrowseNext` with one of those tokens returns `BadContinuationPointInvalid` instead of
invoking a disposed generation.
#### Namespaces
Namespace indexes are append-only for the lifetime of a running server. Removing a model removes its
Nodes and routing but leaves its namespace URI in `NamespaceArray`, and a later reload or add reuses
the same index. When a live add appends a URI, the server updates `NamespaceArray` and `UrisVersion`.
A Client with [model change tracking](ModelChangeTracking.md) enabled re-reads its namespace table
when it observes the resulting model-change notification, so NodeIds from the newly added namespace
resolve without any application code. See
[Namespace table refresh](ModelChangeTracking.md#namespace-table-refresh). A Client that does not
track model changes keeps the namespace table it fetched while the Session was opened and has to
call `ISession.FetchNamespaceTablesAsync` itself.
#### DataTypes
Runtime DataType registrations are additive. Reload accepts an existing DataType only when its
definition is structurally compatible, rejects incompatible changes, and retains removed stand-in
encodeables so existing Sessions and in-flight values remain decodable.
#### Change notifications
Every committed lifecycle transaction emits one compressed model-change notification. Reload also
emits a semantic-change notification when values of Properties marked with the `SemanticChange`
access-level bit changed.
### How to make a node manager reloadable
A NodeManager can be added and removed through the lifecycle provider without extra work. Reload
needs more, because the references other NodeManagers hold into the retired address space have to be
carried over to the replacement. A NodeManager can only be reloaded when it implements
`INodeManagerReloadParticipant`; reloading one that does not fails with `NotSupportedException`
before anything changes.
The contract is a single method:
```csharp
public interface INodeManagerReloadParticipant
{
ValueTask<ArrayOf<LocalReference>> PrepareReloadAsync(
IAsyncNodeManager replacement,
CancellationToken ct = default);
}
```
The server calls it on the outgoing generation, handing it the already-built replacement, before any
routing changes. The implementation has two jobs:
1. **Re-add the references your NodeManager contributed to Nodes it does not own.** These are the
cross-manager references you registered while building the address space — for example a
`Organizes` reference from the ns=0 `Objects` folder to your root. Track them as you add them, and
in `PrepareReloadAsync` push the same set into the replacement so the foreign Nodes keep pointing
at the new generation.
2. **Return the inbound references the replacement can no longer satisfy.** For every reference whose
target NodeId the replacement does not contain, return a `LocalReference` describing the
*counterpart* edge so the server can delete it from the foreign Node. `LocalReference` is
`(NodeId sourceId, NodeId referenceTypeId, bool isInverse, NodeId targetId)`, so the counterpart is
the reference with source and target swapped and `isInverse` negated.
A minimal implementation looks like this:
```csharp
public async ValueTask<ArrayOf<LocalReference>> PrepareReloadAsync(
IAsyncNodeManager replacement,
CancellationToken ct = default)
{
if (replacement is not MyNodeManager target)
{
throw new NotSupportedException(
"This NodeManager can only be reloaded with another instance of the same type.");
}
// 1. hand the references we added to foreign Nodes to the replacement.
Dictionary<NodeId, IList<IReference>> addedReferences = GetAddedReferences();
await target.AddReferencesAsync(addedReferences, ct).ConfigureAwait(false);
// 2. report the counterparts whose target the replacement no longer has.
var dropped = new List<LocalReference>();
foreach (KeyValuePair<NodeId, IList<IReference>> entry in addedReferences)
{
if (target.ContainsNode(entry.Key))
{
continue;
}
foreach (IReference reference in entry.Value)
{
if (!reference.TargetId.IsAbsolute)
{
dropped.Add(new LocalReference(
(NodeId)reference.TargetId,
reference.ReferenceTypeId,
!reference.IsInverse,
entry.Key));
}
}
}
return new ArrayOf<LocalReference>(dropped.ToArray());
}
```
Practical guidance:
* **Keep a record of cross-manager references as you create them.** The base classes do not track
them for you. `AddExternalReference` populates the `externalReferences` dictionary handed to
`CreateAddressSpaceAsync`; keep that dictionary (or an equivalent map) in a field so
`PrepareReloadAsync` can replay it.
* **Reject an incompatible replacement.** Throwing `NotSupportedException` when the replacement is
not the type you expect is safer than silently skipping the reference transfer, and the server
fails the reload cleanly.
* **Do not mutate your own Nodes.** By the time this runs, the replacement generation owns the
address space; the retired generation is about to be disposed.
* **Reload is transactional.** If your implementation throws, the whole operation is rolled back and
Clients never see a partially applied model, so it is safe to fail fast.
`RuntimeNodeSetNodeManager` is the only built-in NodeManager that implements the contract, so runtime
NodeSets are reloadable out of the box — see
`src/Opc.Ua.Server/RuntimeNodeSet/RuntimeNodeSetNodeManager.cs` for the reference implementation. A
NodeManager derived from `CustomNodeManager2` or `AsyncCustomNodeManager` can be added and removed
live without any of this, and becomes reloadable once it implements the interface.
### Related documentation
* [Runtime NodeSets](RuntimeNodeSets.md) — loading NodeSet2 XML without source generation.
* [Source-generated NodeManagers](#source-generated-node-managers) — compile-time models.
* [Dependency Injection](DependencyInjection.md) — the `services.AddOpcUa()` hosting surface.
* [Model Change Tracking](ModelChangeTracking.md) — how Clients observe address-space changes.
## Server address-space metadata
This guide covers two server-startup behaviours that keep the published
address space consistent with what the server can actually serve:
- namespace metadata objects under `Server/Namespaces`;
- historical-access advertisement on variables.
### NamespaceMetadata for every namespace
OPC UA Part 5 requires the `Server/Namespaces` object to describe the
namespaces exposed by a server. Companion specifications repeat the
same requirement in their namespace-metadata clauses so clients can
compare `NamespaceVersion` and `NamespacePublicationDate` against cached
models.
`StandardServer` calls the overridable
`PublishNamespaceMetadataAsync(IServerInternal, CancellationToken)` seam
during startup, after conformance units are published and before the
server accepts sessions. The default implementation uses
`NamespaceMetadataPublisher` to walk `NamespaceArray` and ensure every
namespace URI has a `NamespaceMetadataType` object under
`Server/Namespaces`.
For source-generated models, the publisher fills
`NamespaceVersion` and `NamespacePublicationDate` from the
`ModelDependencyAttribute` stamped on model assemblies. Existing
metadata objects and already-populated values are preserved.
#### Node-manager authoring note
Attaching a child to an object owned by another node manager is not
enough to make it browseable through the master node manager. This is
common for namespace metadata because `Server/Namespaces` is a namespace
0 object owned by the configuration node manager, while the metadata
object may be created by another manager. Register the link as a
cross-manager reference with `AddReferencesAsync` when the owner differs.
`NamespaceMetadataPublisher` does this check automatically for metadata
objects it creates.
Servers that publish namespace metadata themselves can override
`StandardServer.PublishNamespaceMetadataAsync` and either add custom
metadata or return without doing work.
### Historical-access reconciliation
Official companion NodeSets often declare `Historizing="true"` or set
`AccessLevel` bits such as `HistoryRead` on variables whose type is
capable of history. A concrete server still needs a historian provider
before it can serve `HistoryRead` or `HistoryUpdate` for those variables.
During master-node-manager startup, every `AsyncCustomNodeManager`
reconciles this advertisement before external references are applied.
For each variable that advertises historical access, the server checks
whether an `IHistorianProvider` resolves through:
1. the node manager's `GetHistorianProvider(NodeState)` override;
2. the server-wide historian registry (`RegisterForNode`,
`RegisterForNamespace`, then `RegisterDefault`).
If no provider resolves, the server clears `Historizing` and masks
`HistoryRead` / `HistoryWrite` from `AccessLevel`,
`UserAccessLevel`, and the corresponding attribute read callbacks. This
keeps direct reads of the attributes consistent with the values stored
on the node.
Variables with a historian keep their NodeSet-declared history surface.
Use `builder.UseHistorian()` and `.Historize()` from the fluent server
API, or override `GetHistorianProvider`, when a NodeSet variable should
continue advertising historical access.
### See also
- [Historical Access](HistoricalAccess.md) — historian provider model
and fluent `.Historize()` wiring.
- [Source-generated NodeManagers](#source-generated-node-managers) —
NodeSet2 import, fluent node creation, and runtime instance NodeId
assignment.
## Source-generated node managers
This guide explains how to use the OPC UA stack source generator to emit a
ready-to-host `AsyncCustomNodeManager` for an information model design XML, and
how to wire callbacks (read/write/method/lifecycle) using the fluent
`INodeManagerBuilder` API. The combination is designed for **single-file,
NativeAOT-friendly** servers — see
`samples/MinimalApi/MinimalBoilerServer` for the canonical sample.
### What the generator produces
The base source generator already emits, for each model design:
- `Add{Ns}(NodeStateCollection, ISystemContext)` — populates a node
collection.
- `Add{Ns}(INodeStateFactoryBuilder)` — registers strongly-typed activators.
- `Add{Ns}DataTypes(IEncodeableFactoryBuilder)` — registers encodeables.
When `ModelSourceGeneratorGenerateNodeManager=true` is set **or** a
class is annotated with `[Opc.Ua.Server.Fluent.NodeManagerAttribute]`,
the generator **additionally** emits, in either the `{ModelNamespace}`
namespace (legacy MSBuild mode) or the user class's namespace
(attribute mode):
- `public partial class {Ns}NodeManager : AsyncCustomNodeManager` (legacy)
or `public partial class {UserClass} : AsyncCustomNodeManager` (attribute)
- Constructor `(IServerInternal, ApplicationConfiguration)`.
- Pre-registers the model namespace URI.
- `LoadPredefinedNodesAsync` returns
`new NodeStateCollection().Add{Ns}(context)` wrapped in a
`ValueTask<NodeStateCollection>`.
- `CreateAddressSpaceAsync` `await`s `LoadPredefinedNodesAsync`,
then builds a fluent `INodeManagerBuilder`, `await`s
`ConfigureAsync(builder, ct)` (the awaitable wiring seam — see
below), invokes `Configure(builder)`, `await`s
`CompleteConfigureAsync` (re-running the reverse-reference pass so
nodes created inside `Configure` publish their references to nodes
owned by other managers — e.g. an inverse `Organizes` to the ns=0
`Objects` folder — into the `externalReferences` dictionary), and
`await`s `SealConfigurationAsync(builder, ct)` — which seals the
builder, replays `NotifyNodeAdded` for every predefined node so
per-node lifecycle hooks fire deterministically, and only then
completes the registrations `Configure` could not await and starts
the simulations.
- `AddPredefinedNodeAsync` / `RemovePredefinedNodeAsync` overrides
forward to base and then dispatch the lifecycle notification.
- `OnMonitoredItemCreated` (still synchronous on the base) dispatches
the per-node hook.
- Declares `partial void Configure(INodeManagerBuilder builder);` for
synchronous user wiring, and inherits
`protected virtual ValueTask ConfigureAsync(INodeManagerBuilder builder, CancellationToken cancellationToken)`
from `FluentNodeManagerBase` for wiring that has to `await`
(materialising instances, reading a store). `ConfigureAsync` runs
first, so the nodes it creates exist by the time `Configure` wires
callbacks against them. The hook is a `virtual` method rather than a
second `partial` because a partial method can be optional
(`partial void`) or awaitable (extended partial, must be
implemented) — not both. Use either or both.
- Implements `INodeSetImportFactoryProvider` by delegating to the
model's generated `{Ns}NodeSetImportFactoryProvider`, so a NodeSet2
document imported through `builder.Import` materialises this model's
nodes as their generated `*State` subclasses. A
`partial void AddNodeSetImportFactories(List<INodeSetImportFactory>)`
hook lets a dependency model contribute its own factories. See
[Importing a NodeSet2 overlay at runtime](#importing-a-nodeset2-overlay-at-runtime--builderimport).
- `public sealed class {Ns}NodeSetImportFactoryProvider`
- One `INodeSetImportFactory` per model type/declaration, each calling
a concrete constructor for an empty state.
- `public class {Ns}NodeManagerFactory : IAsyncNodeManagerFactory`
- Returns the namespace URI in `NamespacesUris`.
- `CreateAsync(IServerInternal, ApplicationConfiguration, CancellationToken)`
returns a `ValueTask<IAsyncNodeManager>` containing a new manager
instance.
- Both members are `virtual` so consumers can subclass to add a second
namespace or swap in a manager subclass.
`AddNodeManager` on `StandardServer` has overloads for both
`INodeManagerFactory` and `IAsyncNodeManagerFactory`; the generated
async factory binds to the latter automatically.
#### The generated activation pipeline
The order the generated `CreateAddressSpaceAsync` runs in is part of the
contract — the ordering test in `NodeManagerGeneratorTests` pins it — so
it is worth seeing whole:
```mermaid
sequenceDiagram
autonumber
participant M as Generated NodeManager
participant U as Your partial or override
participant B as NodeManagerBuilder
M->>M: await LoadPredefinedNodesAsync
Note over M: LoadPredefinedNodesAsync has<br/>populated PredefinedNodes
M->>B: construct, then AttachToBuilder
M->>U: await ConfigureAsync(builder, ct)
Note over U: the awaitable seam — materialise<br/>instances, read a store, await I/O
M->>U: Configure(builder), Configure(typedBuilder)
Note over U: synchronous wiring, against nodes<br/>ConfigureAsync has already created
M->>M: await RegisterAuthoredNodesAsync(builder, ct)
Note over M: staged nodes reach the address space
M->>M: await CompleteConfigureAsync(externalReferences, ct)
Note over M: reverse-reference pass, so authored nodes<br/>publish edges to other managers
Note over M,B: await SealConfigurationAsync(builder, ct)
M->>B: SealGraphAuthoring
Note over B: no further authoring, nothing activated yet
M->>U: replay NotifyNodeAdded for every predefined node
M->>B: CompleteSealAsync
Note over B: drain staged root notifiers,<br/>then start the simulations
```
Both ends of the seal matter. Sealing before the replay stops an
`OnNodeAdded` handler from authoring nodes nothing would register any
more; activating after it keeps a simulated value change from preceding
the `OnNodeAdded` handler of its own node.
### Opting in
Add the generator analyzer to your project (this is what
`OPCFoundation.Opc.Ua.SourceGeneration.props` is for) and choose **one**
of the two opt-in modes:
#### Per-class opt-in via `[NodeManager]` (recommended)
Annotate the user-authored partial class that should host the generated
manager:
```csharp
using Opc.Ua.Server.Fluent;
namespace MyCompany.MyServer;
[NodeManager]
public partial class MyDeviceNodeManager
{
partial void Configure(INodeManagerBuilder builder)
{
// wire your callbacks here
}
}
```
The generator emits a sibling `partial class MyDeviceNodeManager :
AsyncCustomNodeManager` and a `MyDeviceNodeManagerFactory` (implementing
`IAsyncNodeManagerFactory`) in the same namespace as the user class. No
MSBuild flag is required.
When a project carries multiple model designs, disambiguate which
design the attribute targets via either:
```csharp
[NodeManager(NamespaceUri = "http://opcfoundation.org/UA/Boiler/")]
```
or by file stem:
```csharp
[NodeManager(Design = "BoilerDesign")]
```
Set `GenerateFactory = false` to suppress factory emission when you want
to ship a hand-written `IAsyncNodeManagerFactory`.
When the manager also owns namespaces beyond the model's own — most
commonly a separate *instance* namespace for nodes created at runtime —
declare them on the attribute:
```csharp
[NodeManager(
NamespaceUri = "http://opcfoundation.org/UA/Boiler/",
AdditionalNamespaceUris = new[]
{
"http://opcfoundation.org/UA/Boiler/Instance"
})]
```
The generated constructor passes them to the base manager together with
the model namespace and the generated factory advertises them in
`NamespacesUris`, so the master node manager routes requests for those
namespaces to this manager from the moment it is built. Calling
`SetNamespaces` later is not sufficient — the master builds its
namespace routing from what the manager reported when it was
constructed.
The URI expressions must be available to Roslyn before this generator
runs: use string literals, `const` values declared in ordinary source,
or constants from a referenced assembly. A constant emitted by another
generator in the **same compilation** is not available. Such an
expression reports `MODELGEN035` at the offending argument instead of
silently dropping the namespace from the generated manager and factory.
##### Taking over the constructor and the namespace order
The generator emits two constructors:
```csharp
public MyDeviceNodeManager(IServerInternal server, ApplicationConfiguration configuration)
: this(server, configuration, null) { }
protected MyDeviceNodeManager(
IServerInternal server,
ApplicationConfiguration configuration,
string[]? namespaceUris)
: base(server, configuration, namespaceUris ?? DefaultNamespaceUris()) { … }
```
Chain to the protected one from a constructor of your own when the
manager needs collaborators the generated signature does not carry, or
when it owns a different set of namespaces. `DefaultNamespaceUris()`
returns the attribute's set, model namespace first.
The order decides which namespace becomes `NamespaceIndexes[0]` and
therefore the manager's own `NamespaceIndex` — the one a browse path
without an explicit `ns=` prefix resolves in, and the one
`CreateFluentBuilder` defaults to. It does **not** decide where runtime
NodeIds are minted: that is the NodeId factory's
`DefaultNamespaceIndex`, so a manager whose instances belong in a
namespace of its own points the factory at that namespace and leaves the
order alone. See
[Which namespace](NodeIdAssignment.md#which-namespace).
```csharp
[NodeManager(
NamespaceUri = Namespaces.MyModel,
AdditionalNamespaceUris = ["urn:my:instances"],
GenerateDefaultConstructor = false,
GenerateFactory = false)]
public partial class MyDeviceNodeManager
{
private readonly IMyDatabase m_database;
public MyDeviceNodeManager(
IServerInternal server,
ApplicationConfiguration configuration,
IMyDatabase database)
// null adopts DefaultNamespaceUris(): the model namespace first
: this(server, configuration, null)
{
m_database = database;
// the records this manager creates are its own instances, so they
// are minted next to the model rather than into it
NodeIdFactory = NodeIdFactory.WithDefaultNamespaceIndex(
(ushort)server.NamespaceUris.GetIndex("urn:my:instances"));
}
}
```
`GenerateDefaultConstructor = false` suppresses the public two-argument
form so callers cannot build the manager without its collaborators. The
generated factory constructs the manager through exactly that form, so a
manager that suppresses it — and does not declare a replacement with the
same signature — must set `GenerateFactory = false` too.
##### Binding to a model a referenced assembly supplies
A `[NodeManager]` normally lives in the project that emits the model.
That is impossible when the model belongs to a package which cannot
reference `Opc.Ua.Server` — a model-only assembly shared with clients,
for example. Point the attribute at the model anyway: add its design as
`AdditionalFiles` in the server-side project, and the generator emits
the manager and its fluent surface there while the model types keep
coming from the reference.
```xml
<!-- MyServer.csproj — the model itself lives in MyModel.csproj -->
<ItemGroup>
<ProjectReference Include="..\MyModel\MyModel.csproj" />
<AdditionalFiles Include="..\MyModel\Design\MyModel.xml" />
<AdditionalFiles Include="..\MyModel\Design\MyModel.csv" />
</ItemGroup>
```
Nothing else is needed — no `ModelSourceGeneratorFluentAccessorsOnly`.
Without a binding, a design that a reference already supplies stays
skipped, exactly as before, so this cannot start duplicating types by
accident.
Two conditions apply, both reported as `MODELGEN014` when unmet: the
referenced assembly must publish the model under the same C# prefix, and
it must not already contain generated fluent accessors (a model-only
assembly built with `ModelSourceGeneratorOmitFluentApi` does not). The
project-wide `ModelSourceGeneratorGenerateNodeManager` switch is not a
substitute here and is still rejected in accessors-only builds: it would
emit conventionally named managers into the referenced model's own C#
namespace for every design in the project.
#### Project-wide opt-in via MSBuild property (legacy)
If you prefer a generator-derived class identity (`{Prefix}NodeManager` /
`{Prefix}NodeManagerFactory`) without authoring a stub partial, set the
opt-in property:
```xml
<PropertyGroup>
<ModelSourceGeneratorGenerateNodeManager>true</ModelSourceGeneratorGenerateNodeManager>
</PropertyGroup>
<ItemGroup>
<AdditionalFiles Include="Generated\MyModelDesign.xml" />
<AdditionalFiles Include="Generated\MyModelDesign.csv" />
</ItemGroup>
```
This emits `{Prefix}NodeManager` + `{Prefix}NodeManagerFactory` for
every design in the project. Wire callbacks by adding a sibling
`partial class {Prefix}NodeManager` that implements `Configure`.
Without either opt-in, only the existing `Add{Ns}*` extensions are
emitted — hand-written `AsyncCustomNodeManager` (or legacy
`CustomNodeManager2`) subclasses keep working unchanged.
### Wiring callbacks: the `Configure` partial
Author a sibling partial that fills in `Configure`:
```csharp
namespace MyModel;
public partial class MyModelNodeManager
{
// The source-generated constructor retains the exact startup
// configuration on FluentNodeManagerBase. Use the protected property
// from any user-authored partial; no custom factory is required.
private MyModelConfiguration? Settings =>
Configuration?.ParseExtension<MyModelConfiguration>();
partial void Configure(INodeManagerBuilder builder)
{
builder
.Node("Boilers/Boiler #1/Drum1001/LevelIndicator/Output")
.OnRead(MyReadHandler);
// Resolve a singleton instance by its TypeDefinitionId — stable
// across deployments and independent of where the instance sits
// in the tree. Ideal for well-known types like
// HistoryServerCapabilities or a single BoilerType instance.
builder
.NodeFromTypeId(ExpandedNodeId.ToNodeId(
MyModel.ObjectTypeIds.BoilerType, Server.NamespaceUris))
.OnNodeAdded((ctx, node) => /* ... */);
// For multi-instance types, disambiguate with a BrowseName:
builder
.NodeFromTypeId(
ExpandedNodeId.ToNodeId(MyModel.ObjectTypeIds.BoilerType, Server.NamespaceUris),
new QualifiedName("Boiler #2", nsIndex))
.OnRead(MyReadHandler);
}
}
```
Path syntax is `/`-separated **BrowseNames**, rooted at the model
namespace's predefined nodes. Optional `ns=N;` prefix lets you target a
different namespace.
#### Addressing modes
| Method | Resolves by | Use when |
|--------|-------------|----------|
| `Node(string path)` | BrowseName path | Deterministic tree layout, multiple siblings |
| `Node(NodeId id)` / `Node<TState>(NodeId id)` | Absolute NodeId | You own the id (e.g. generated `Variables.*`) |
| `NodeFromTypeId(NodeId typeId)` / `NodeFromTypeId<TState>(NodeId typeId)` | `BaseInstanceState.TypeDefinitionId` | Singleton instance of a well-known type |
| `NodeFromTypeId(NodeId typeId, QualifiedName browseName)` | TypeDefinitionId + BrowseName | Multi-instance types — pick one |
| `Node<TState>(TState node)` | Nothing — takes the node as given | You already hold the `NodeState` (see below) |
`NodeFromTypeId` walks every predefined node owned by this manager
(and their sub-trees) at Configure-time. Error matrix:
* `BadNodeIdInvalid` — `typeId` is null or `IsNull`.
* `BadNodeIdUnknown` — no instance carries that `TypeDefinitionId`, or
the optional `browseName` disambiguator finds no match.
* `BadBrowseNameDuplicated` — more than one candidate and no
disambiguator was supplied (or multiple candidates share the same
`browseName`).
* `BadTypeMismatch` — typed overload's `TState` cast fails.
`Node<TState>(TState node)` is the one addressing mode that does not
consult the predefined-node graph. Use it for nodes that appear *after*
`Configure` has run — a service object a host contributes at runtime, a
subtree replaced in `AddBehaviourToPredefinedNodeAsync`, a device
discovered while the server is up — so the same fluent wiring recipe
applies to statically declared and runtime-created nodes alike:
```csharp
private async Task ConfigureServiceAsync(MyServiceState service)
{
NodeManagerBuilder builder = CreateFluentBuilder(namespaceIndex);
builder.Node(service.DoSomething!)
.OnReadRolePermissions(GrantSelfAdmin);
await builder.SealAsync().ConfigureAwait(false);
}
```
The builder exposes:
| Method | Wires |
|--------|-------|
| `OnRead` / `OnReadAsync` | `BaseVariableState.OnReadValue` |
| `OnWrite` / `OnWriteAsync` | `BaseVariableState.OnWriteValue` |
| `OnCall` / `OnCallAsync` | `MethodState.OnCallMethod*` |
| `OnReadRolePermissions` / `OnReadUserRolePermissions` | `NodeState.OnRead*RolePermissions` — compute the permission set per request (e.g. grant a caller a role on its own record) |
| `OnNodeAdded` / `OnNodeRemoved` | Lifecycle dispatch from `NotifyNodeAdded` |
| `OnEvent`, `OnConditionRefresh`, `OnHistoryRead`, `OnHistoryUpdate` | Node or manager-level dispatch keyed by `NodeId` |
| `OnCreateMonitoredItem`, `OnMonitoredItemCreated`, `OnMonitoredItemModified`, `OnMonitoredItemDeleted`, `OnMonitoringModeChanged` | Data-change monitored-item creation and lifecycle |
`INodeManagerBuilder.NodeManager` is typed as `IAsyncNodeManager`. Use
`builder.NodeManager.SyncNodeManager` to obtain the synchronous
`INodeManager` facade for legacy interop, or cast it to your concrete
manager type if you need direct access.
Ordinary `Node(...)` / `Variable(...)` resolution happens **once**
during `CreateAddressSpaceAsync`, against the in-memory predefined-node
tree. Virtual node families are registered during the same phase but
materialize individual nodes per service operation as described below.
There is no reflection, no `Activator.CreateInstance`, no
`Expression.Compile` — the whole pipeline is NativeAOT-safe.
#### On-demand virtual node families
Use `ResolveNodes` when the manager owns a potentially large or external
address space that must not be copied into `PredefinedNodes`. The first
delegate is a cheap ownership test and must not perform I/O. The second
delegate materializes the requested `NodeState` asynchronously:
```csharp
partial void Configure(INodeManagerBuilder builder)
{
builder.ResolveNodes(
nodeId => TryParseRegisterId(nodeId, out _),
async (context, nodeId, ct) =>
{
RegisterAddress address = ParseRegisterId(nodeId);
RegisterMetadata? metadata =
await m_device.DescribeAsync(address, ct);
if (metadata is null)
{
return null;
}
return new BaseDataVariableState(parent: null)
{
NodeId = nodeId,
BrowseName = new QualifiedName(metadata.Name, nodeId.NamespaceIndex),
DisplayName = metadata.Name,
DataType = metadata.DataType,
ValueRank = ValueRanks.Scalar
};
})
.OnRead(ReadRegister)
.OnWrite(WriteRegister)
.OnCreateBrowser(CreateRegisterBrowser)
.OnMonitoredItemCreated(StartPushSource);
}
```
Predefined nodes always win. On a predefined-node miss,
`FluentNodeManagerBase` selects exactly one matching virtual family,
creates an unvalidated `NodeHandle`, and invokes the resolver during
normal node validation. Overlapping predicates fail with
`BadConfigurationError` rather than depending on registration order.
The resolver may return `null` for a syntactically valid id whose backing
object does not exist. A returned node with `NodeId.Null` receives the
requested id; a conflicting non-null id is rejected with `BadNodeIdInvalid`.
Resolver `ServiceResultException` failures and callback-template validation
errors retain their exact status and are not cached as missing nodes.
Batched operations report these errors only for the affected item and
continue processing other items. During stored monitored-item restoration,
a validation failure is logged and that item is skipped without aborting
the remaining items. Request cancellation still stops the operation.
Browse and TranslateBrowsePaths isolate resolver failures per target reference:
the failed reference is logged and skipped without discarding healthy siblings,
including branches in a multi-element path. A failure resolving the starting
node still fails that browse or path.
The stack caches results only in its existing per-operation and monitored-component caches:
virtual nodes are never inserted into `PredefinedNodes`.
The returned `IVirtualNodeBuilder` applies one callback template to every
materialized member of the family. It supports read/write/call,
condition/event, history, browser, monitored-item creation, and
monitored-item lifecycle hooks. `OnCreateBrowser` uses the ordinary
`NodeState.CreateBrowser` contract, so custom browsers still participate
in browse filtering, continuation points, and translate-path handling.
#### Monitored-item creation and lifecycle
`OnCreateMonitoredItem` runs before the default sampled item is
allocated. It can keep the default path, reject the request with an exact
status, or supply a factory for a custom
`ISampledDataChangeMonitoredItem`:
```csharp
builder.Node("Buffers/UInt32")
.OnCreateMonitoredItem((request, ct) =>
{
if (!request.Request.RequestedParameters.Filter.IsNull)
{
return new ValueTask<MonitoredItemCreateDecision>(
MonitoredItemCreateDecision.Refuse(
StatusCodes.BadFilterNotAllowed));
}
if (!request.Request.ItemToMonitor.ParsedIndexRange.IsNull)
{
return new ValueTask<MonitoredItemCreateDecision>(
MonitoredItemCreateDecision.Refuse(
StatusCodes.BadIndexRangeInvalid));
}
return new ValueTask<MonitoredItemCreateDecision>(
MonitoredItemCreateDecision.Use(
factory => new BufferMonitoredItem(factory)));
})
.OnMonitoredItemCreated(OnCreated)
.OnMonitoredItemModified(OnModifiedAsync)
.OnMonitoringModeChanged(OnModeChangedAsync)
.OnMonitoredItemDeleted(OnDeletedAsync);
```
The stack allocates the id and owns registration for a custom item. It
supplies the validated filter/range, revised sampling interval and queue
size, manager handle, subscription information, and durability setting
through `MonitoredItemFactoryContext`. The returned item must preserve
that identity and ownership. Both built-in monitored-item managers then
handle modify, monitoring-mode, delete, and manager-lifecycle operations
normally. `Use(factory, queueInitialValue: true)` additionally performs
the standard initial attribute read; push-style items omit it by default.
An initial read that rejects the attribute or data encoding removes the
unaccepted monitored item from registration, sampling, and any newly
acquired component-cache entry. Existing items on that node remain valid.
Recoverable bad data values do not prevent creating the monitored item.
`AsyncCustomNodeManager` acquires current values through
`NodeState.ReadAttributeAsync`, including initial creation, aggregate-filter
validation and current-value fallbacks, lifecycle compatibility checks,
reattachment/recovery, and re-enabling monitoring. Async `OnReadValueAsync`
bindings therefore supply the first sample rather than the node's stored
placeholder (OPC UA Part 4 [5.13.1.3](https://reference.opcfoundation.org/Core/Part4/v105/docs/5.13.1.3)).
Provider awaits run outside the monitored-item registry semaphore; registration
and cleanup remain serialized, and the operation lifetime prevents disposal
from releasing owned resources before the read completes. Request cancellation
removes unaccepted registrations instead of publishing a cancelled read as a
successful initial sample. Override `ReadInitialValueAsync` for custom initial
acquisition.
Manager-level asynchronous batch hooks receive only successful items and
run after the monitored-item manager has applied its changes:
```csharp
builder
.OnMonitoredItemsCreated(SubscribeRegisterSlicesAsync)
.OnMonitoredItemsDeleted(UnsubscribeRegisterSlicesAsync);
```
The existing synchronous `OnCreateMonitoredItemsComplete` override remains
supported and runs before the new async create-complete hook.
#### Creating nodes under other managers' nodes (Objects folder)
Anything a NodeSet can declare, `Configure` can declare identically —
including references whose target is owned by **another** node manager,
such as the ns=0 `Objects` folder managed by the `CoreNodeManager`.
Write the inverse reference on your node; after the `Configure`
partials return, the manager re-runs its reverse-reference pass
(`FluentNodeManagerBase.CompleteConfigureAsync`) and publishes the
matching forward edge into the `externalReferences` dictionary that the
master node manager distributes once every manager's address space is
built.
Two helpers make the common placement declarative, and a manager-level
`CreateInstance<TState>` creates root-level (parentless) instances,
materializing the subtree from the type model and minting NodeIds
through the manager's `INodeIdFactory`:
```csharp
partial void Configure(INodeManagerBuilder builder)
{
// Instantiate a second boiler at runtime and place it under the
// ns=0 Objects folder — no LoadPredefinedNodesAsync override, no
// manual externalReferences bookkeeping.
builder.CreateInstance(
new QualifiedName("Boiler #2", NamespaceIndexes[1]),
p => new BoilerState(p))
.Configure(n => n.UnderObjectsFolder());
// Arbitrary parents work the same way:
// n.OrganizedBy(parentNodeId)
// adds the inverse Organizes reference; the forward edge reaches
// the owning manager automatically.
}
```
Inverse `HasNotifier` references to external notifiers get root-notifier
registration through the same pass. This covers **startup-time**
configuration only — for nodes created after startup use
`IMasterNodeManager.AddReferencesAsync`, which dispatches to the live
owning manager.
#### Creating nodes from scratch — the `Add*` surface
`Configure` can also *create* nodes, not just wire callbacks on nodes a
NodeSet or ModelDesign already declared. `INodeManagerBuilder` carries a
small creation surface for filling a namespace when there is no model to
generate from:
| Member | Creates |
| --- | --- |
| `AddFolder(name, parentId)` | a `FolderState` (`Organizes`) |
| `AddObject(name, parentId, typeDefinitionId)` | a `BaseObjectState` |
| `AddVariable<TValue>(name, parentId)` | a `BaseDataVariableState` whose `DataType`/`ValueRank` come from `TValue` |
| `AddMethod(name, parentId)` | an executable `MethodState` |
| `Add<TState>(node, parentId)` | an already-constructed state of any `NodeState` subclass |
| `Add<TState>(factory, parentId)` | a state built by a factory that receives the resolved parent |
| `AddRoot<TState>(node)` | a root, with its existing references left alone |
| `TryGetNode(nodeId, out node)` | lookup across created-but-not-yet-registered nodes and predefined nodes |
Each `Add*` takes the browse name as a `string` — qualified with the
manager's default namespace — or as a `QualifiedName` carrying an
explicit nonzero namespace index. `parentId` defaults to the ns=0
`Objects` folder.
```csharp
partial void Configure(INodeManagerBuilder builder)
{
INodeBuilder<FolderState> machines = builder.AddFolder("Machines");
builder.AddVariable<double>("Pressure", machines.Node.NodeId)
.OnRead(() => m_sensor.Pressure);
builder.AddMethod("Reset", machines.Node.NodeId)
.OnCall(ResetAsync);
}
```
Three properties make this usable straight from `Configure`:
- **NodeIds are final before the builder comes back.** Every `Add*`
runs the node — and its whole subtree — through the manager's
`INodeIdFactory` before returning, so `OnRead`/`OnWrite`
registrations, which key off `NodeState.NodeId`, stay valid once the
node is registered. `machines.Node.NodeId` above is the id clients
will browse. This includes a subtree materialised from a type model
with `NodeState.Create(…, assignNodeIds: false)`, whose children still
carry their declaration ids: those are rebased at `Add` time rather
than at registration, so they too are final when the builder returns.
- **Creation is staged, not immediate.** Created nodes are held until
the manager calls `RegisterAuthoredNodesAsync`, which the generated
`CreateAddressSpaceAsync` emits after the `Configure` partials and
before `CompleteConfigureAsync`. That ordering is what lets a node
name a sibling created moments earlier, and what gets references to
externally owned nodes (the `Objects` folder above) mirrored into
`externalReferences` by the same reverse-reference pass described in
[Creating nodes under other managers' nodes](#creating-nodes-under-other-managers-nodes-objects-folder).
- **Custom state types stay typed.** `Add<TState>` returns
`INodeBuilder<TState>`, so a hand-written `NodeState` subclass keeps
its type through the fluent chain. The factory overload exists for
the case where a custom `INodeIdFactory` derives a child id from its
parent: it resolves the parent first and hands it to the factory,
substituting an identity-only proxy when the parent belongs to
another node manager.
Creation is rejected outside that window. Calling an `Add*` after the
builder is sealed, or after the staged graph has been registered, throws
`BadInvalidState`. A NodeId in a namespace the manager does not own
throws `BadNodeIdInvalid`, and a parent in one of the manager's *own*
namespaces that was never created throws `BadNodeIdUnknown`.
A hand-written manager deriving from `FluentNodeManagerBase` gets the
whole pipeline without writing it: the base `CreateAddressSpaceAsync`
loads the predefined nodes, builds a builder for the manager's
`NamespaceIndex`, awaits `ConfigureAsync(builder, ct)`, registers the
authored nodes, runs `CompleteConfigureAsync` and seals the builder. The
manager only overrides `ConfigureAsync`:
```csharp
public sealed class MyNodeManager : FluentNodeManagerBase
{
public MyNodeManager(IServerInternal server, ApplicationConfiguration configuration)
: base(server, configuration, "urn:my:namespace")
{
}
protected override ValueTask ConfigureAsync(
INodeManagerBuilder builder,
CancellationToken cancellationToken)
{
FolderState devices = builder.AddFolder("Devices").Node;
builder.AddVariable<int>("Speed", devices.NodeId).Writable();
return default;
}
}
```
Hand-written managers that need a different order (for example work
between completing and sealing, as `DiNodeManager` does) override
`CreateAddressSpaceAsync` *without* calling the base implementation,
load their predefined nodes through `LoadPredefinedNodesAsync`, drive
`CreateFluentBuilder` themselves and get the same surface by calling
`FluentNodeManagerBase.RegisterAuthoredNodesAsync(builder)` in the same
position — after the configuration delegate, before
`CompleteConfigureAsync`. A builder that created nothing registers
nothing, so the call is safe to make unconditionally.
### Typed model-traversal — the `Configure(I{Manager}NodeManagerBuilder)` partial
Alongside the string/NodeId/TypeId addressing surface above, the
generator emits a **second** `Configure` partial whose builder parameter
exposes one IntelliSense-aware accessor per predefined instance, child,
variable and method in the model. Every wiring site becomes a chain of
properties — typos are compile-time errors, not startup-time
`ServiceResultException`s.
```csharp
public partial class BoilerNodeManager
{
// Untyped Configure remains available for nodes outside the model
// (e.g. dynamic instances, foreign-namespace nodes, or just to keep
// hand-written wiring side-by-side with typed wiring).
partial void Configure(INodeManagerBuilder builder)
{
builder
.Node("Boilers/Boiler #1/DrumX001/LIX001/Output")
.OnRead(GenerateDrumLevel);
}
// Typed Configure: every accessor below is a generated property
// resolved against the model. The compiler enforces both the path
// shape AND the value type of every leaf.
partial void Configure(IBoilerNodeManagerBuilder builder)
{
// Variable: typed Func<double> handler — the generator removed
// the ref-Variant boilerplate.
builder.Boilers.Boiler__1.LCX001.Measurement
.OnRead(GenerateLevelMeasurement);
// Variable, async: routes through BaseVariableState.ReadAttributeAsync
// outside the lock so the lambda may freely await.
builder.Boilers.Boiler__1.PipeX002.FTX002.Output
.OnRead(GenerateOutputFlowAsync);
// Method, async: typed OnCall(Func<CancellationToken,ValueTask>)
// overload. Bind sync Action variants the same way.
builder.Boilers.Boiler__1.Simulation.Halt
.OnCall(HaltSimulationAsync);
}
}
```
Both partials are optional and both run; wiring the same node from
both is illegal and throws at startup. Choose whichever shape best fits
each call site — typed for everything declared in the model, untyped
for everything else.
#### What the generator emits per model
For a model with `N` ObjectTypes and `M` predefined instances/children
the generator emits, into a single `{Manager}.FluentBuilders.g.cs`:
- `internal interface I{Manager}NodeManagerBuilder : INodeManagerBuilder`
— one accessor per top-level predefined instance.
- `internal sealed class {Manager}NodeManagerTypedBuilder` — proxy that
forwards `INodeManagerBuilder` members to the runtime builder while
surfacing the typed accessors.
- One `internal sealed class` per instance node — whose properties map
to typed `IVariableBuilder<TValue>`, child wrapper instances, and
method wrappers.
- One `internal sealed class` per method — exposing typed
`OnCall(...)` overloads bound to the method's declared arguments
(the generator handles `Variant.TryGetValue` unpacking and
`Variant.From<T>` boxing — see [Methods with arguments](#methods-with-arguments--typed-oncall-overloads)).
A method with inputs but no output binds to `OnCall(Action<TIn…>)`;
a method with neither inputs nor outputs keeps the argument-less
`OnCall(Action)` / `OnCall(Func<CancellationToken, ValueTask>)`
overloads.
All emitted types are `internal sealed` because `Configure` is a
private partial — the surface never escapes the assembly. Child
accessors resolve namespace indices lazily through
`ISystemContext.NamespaceUris.GetIndexOrAppend(...)` so the wrappers
work regardless of the namespace-table order at runtime. Object wrappers
use the generated concrete `*State` type when the model declares one,
and manager-level extensions such as `Simulation(...)` work through the
typed proxy just as they do through the untyped builder.
#### Methods with arguments — typed `OnCall` overloads
When a model method declares input or output arguments the generator
emits **typed `OnCall` overloads** that bind directly to the user
handler's parameters and return value. Inputs are unboxed via
`Variant.TryGetValue<T>(out T)`, the boxed result is written back
through `Variant.From<T>(value)`, and `BadInvalidArgument` /
`BadArgumentsMissing` is returned when the wire shape does not match
the declared signature — none of which the user has to spell out.
Two overloads are emitted per method, shaped by the declared arguments:
- **Inputs and outputs** →
`OnCall(Func<TIn1, …, TResult> handler)` (synchronous dispatch through
`MethodState.OnCallMethod2`) and
`OnCall(Func<TIn1, …, CancellationToken, ValueTask<TResult>> handler)`
(async dispatch through `MethodState.OnCallMethod2Async`, awaited inside
`AsyncCustomNodeManager.CallAsync` so the lambda may freely `await`).
- **Inputs but no output** (a `void`-returning action) →
`OnCall(Action<TIn1, …> handler)` and
`OnCall(Func<TIn1, …, CancellationToken, ValueTask> handler)`. The inputs
are still unpacked via `Variant.TryGetValue<T>`, so
`builder.X.SetOutputVal.OnCall((float v) => …)` binds directly to the
argument.
- **No inputs and no output** → the argument-less `OnCall(Action)` /
`OnCall(Func<CancellationToken, ValueTask>)` overloads.
Methods with multiple output arguments are bound to a `ValueTuple`
return — slot `i` is written from `__r.Item{i+1}`.
The declared arguments are resolved from the method itself and, when the
method carries none of its own, from its method declaration / method type.
This means **instance methods imported from a NodeSet2** (whose
`InputArguments`/`OutputArguments` live on the referenced declaration) get
the same typed `OnCall` overloads as methods authored in a ModelDesign.
```csharp
[NodeManager(NamespaceUri = "http://opcfoundation.org/UA/Calc/")]
public partial class CalcNodeManager
{
partial void Configure(ICalcNodeManagerBuilder builder)
{
// Sync int+int → int. The generator unpacks each Variant
// through Variant.TryGetValue<int> and boxes the result back
// through Variant.From<int>.
builder.Calculator.Add
.OnCall((int a, int b) => a + b);
// Async double+double → double. The CancellationToken is
// forwarded by AsyncCustomNodeManager.CallAsync so the
// handler may freely await and honour cancellation.
builder.Calculator.Multiply
.OnCall(async (double x, double y, CancellationToken ct) =>
{
await Task.Yield();
ct.ThrowIfCancellationRequested();
return x * y;
});
// Sync string+string → string. Reference-typed inputs and
// return values use the same Variant.TryGetValue / Variant.From
// path; the handler can null-coalesce safely because a missing
// input is reported as BadInvalidArgument before the lambda
// ever runs.
builder.Calculator.Concat
.OnCall((string left, string right) =>
(left ?? string.Empty) + (right ?? string.Empty));
}
}
```
The end-to-end sample lives in
`samples/MinimalApi/MinimalCalcServer/` (model in `Model/Calc.xml`, wiring
in `CalcNodeManager.Configure.cs`). The companion AOT round-trip tests
in `tests/Opc.Ua.Aot.Tests/CalculatorNodeManagerAotTests.cs` exercise
each shape over a real `Session.CallAsync(...)`.
### Event sources — typed `Publish<TEvent>` on notifier wrappers
Beyond reads, writes and method calls, the fluent API lets callers
register an `IAsyncEnumerable<TEvent>` against any notifier object so
events flow into the standard `NodeState.ReportEvent` path
automatically. The runtime owns the entire lifecycle: it starts the
iterator the first time a client subscribes to events on the notifier
(or any ancestor that walks via inverse `HasNotifier` /
`HasEventSource` references), cancels it when the last interested
monitored item disappears, and disposes it on manager teardown.
Generated managers derive from `Opc.Ua.Server.Fluent.FluentNodeManagerBase`
out of the box, so wiring is one call:
```csharp
partial void Configure(IBoilerNodeManagerBuilder builder)
{
// The DrumX001 wrapper exposes Publish<TEvent> because the model
// declares EventNotifier=SubscribeToEvents on the node. Lazy by
// default — the iterator only runs while a client is monitoring.
builder.Boilers.Boiler__1.DrumX001
.Publish<BaseEventState>(GenerateDrumHeartbeatAsync);
}
private async IAsyncEnumerable<BaseEventState> GenerateDrumHeartbeatAsync(
BaseObjectState notifier,
ISystemContext context,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
try
{
await Task.Delay(TimeSpan.FromMilliseconds(500), cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException) { yield break; }
var ev = new BaseEventState(parent: notifier);
ev.Severity = PropertyState<ushort>.With<VariantBuilder>(
ev, (ushort)EventSeverity.Medium);
ev.Message = PropertyState<LocalizedText>.With<VariantBuilder>(
ev, new LocalizedText("Drum heartbeat"));
yield return ev;
}
}
```
The runtime auto-populates `EventId`, `EventType`, `SourceNode`,
`SourceName` (browse name of the notifier), `Time`, `ReceiveTime`,
`Severity` (Medium when 0) and `Message` (empty `LocalizedText` when
unset) on the way out, so the iterator only sets the user-meaningful
fields.
#### Where the typed overload appears
The generator emits `Publish<TEvent>` on a wrapper **only** when the
underlying node qualifies as an event source:
- `ObjectDesign.SupportsEvents == true` (i.e. the model declares
`EventNotifier=SubscribeToEvents`, `HasNotifier`, or
`HasEventSource`), or
- The node has a forward `GeneratesEvent` / `AlwaysGeneratesEvent`
reference.
`TEvent` is constrained to `BaseEventState` — pass any subtype that
fits the model's event hierarchy. For nodes outside the model, or
hand-written managers, the same `Publish<TNotifier, TEvent>` extension
is available directly on `INodeBuilder<TNotifier>` where
`TNotifier : BaseObjectState`.
#### Two registration shapes
```csharp
// Direct stream — registry uses the same instance for every activation.
builder.Boilers.Boiler__1.DrumX001
.Publish<BaseEventState>(channel.Reader.ReadAllAsync(default));
// Factory — registry calls the factory each time a client subscribes,
// so the iterator can capture the live notifier / context / token.
builder.Boilers.Boiler__1.DrumX001
.Publish<BaseEventState>(
(notifier, context, ct) => GenerateAsync(notifier, context, ct));
```
#### Tuning lifecycle with `EventPublishOptions`
```csharp
builder.Boilers.Boiler__1.DrumX001
.Publish<BaseEventState>(GenerateDrumHeartbeatAsync,
new EventPublishOptions
{
// Keep iterator running even with no monitored items.
AlwaysOn = false,
// Skip default population of EventId / EventType / Time /
// ReceiveTime / SourceNode / SourceName / Severity / Message.
SkipDefaultPopulation = false,
// Register the notifier as a server-wide root notifier so
// clients can monitor events on the Server object itself.
RegisterAsRootNotifier = true,
// Bound how long the registry waits for the iterator to
// honour cancellation on deactivation.
CancellationTimeout = TimeSpan.FromSeconds(5),
// Optional fault-handler invoked when the iterator throws.
OnError = (notifier, exception, context) => { /* log */ }
});
```
#### Hand-written node managers
The `Server` Object (`i=2253`) is the aggregate event subscription point
(OPC UA Part 5 [8.3.2](https://reference.opcfoundation.org/Core/Part5/v105/docs/8.3.2)).
Server-wide fan-out treats an individual root or manager's `BadNotSupported` as
non-participation, not as a failure of every other source. Root registration
does not silently set `EventNotifier.SubscribeToEvents`; declare the capability
on actual notifiers as required by Part 3
[7.18](https://reference.opcfoundation.org/Core/Part3/v105/docs/7.18).
A direct subscription to an unsupported root still fails with `BadNotSupported`.
Legacy `null` success results are normalized to `Good` before per-root or
per-manager status inspection.
Other startup errors fail the item and roll back attempted registrations.
Unsubscription continues across independent roots and managers while reporting
genuine cleanup errors; unsupported participants do not turn successful deletion
into an error.
An event stream that connects an asynchronous upstream producer can also
implement `IEventSourceReadiness`. The registry enumerates the stream while
awaiting `WaitUntilReadyAsync`; creation of a corresponding event monitored
item completes only after the producer can deliver notifications. Readiness
must not wait for the first event. Failures are returned to the subscribing
client, reported through `OnError`, and stop that activation. For reactivatable
producers, return a new readiness-aware stream from the `Publish` factory on
each activation.
Lifecycle attachment, recovery and compensating reattachment await the same
readiness contract without holding admission for unrelated monitored-item
services. Callback failure compensates only that operation's binding and
notifier count, preserving a newer binding. Cleanup remains available after
request cancellation, and compensation errors are reported with the original
failure. Compensation uses a fresh five-second deadline on the server's injected
clock, even when ordinary source readiness has an infinite timeout.
Readiness belongs to each activation: completion of the previous iterator's
drain does not make its replacement ready. A producer's readiness signal is
independent of iterator entry and the first event.
Failed factories, iterators, and readiness checks are reported to the caller
and `OnError`. While a source is still wanted, retries use an exponential delay
from one second up to thirty seconds rather than an immediate restart loop.
Only the current activation can request a retry; a late failure from a cancelled
activation cannot retire its replacement. A failed event subscription rolls back
its monitored-node registration and notifier count before returning the error.
Managers that don't use the source generator can opt in by deriving
from `Opc.Ua.Server.Fluent.FluentNodeManagerBase` and calling
`AttachToBuilder(builder)` from inside their address-space-build
callback. Once attached, all `Publish` extensions resolve against the
manager's registry exactly as for generated managers.
The end-to-end sample lives in
`samples/MinimalApi/MinimalBoilerServer/BoilerNodeManager.Configure.cs`
(wiring `GenerateDrumHeartbeatAsync` on the drum). The companion AOT
round-trip test in
`tests/Opc.Ua.Aot.Tests/PublishedEventsAotTests.cs` subscribes a
real client `MonitoredItem` with an `EventFilter` and asserts the
heartbeats arrive end-to-end under NativeAOT constraints (no JIT, no
reflection).
### Single-file `Program.cs` — what it looks like
The shipping `services.AddOpcUa().AddServer(...)` extension wires the
server into the .NET Generic Host: configuration, certificate check,
`ApplicationInstance` lifetime and Ctrl+C/SIGTERM handling are all owned
by the host. User code stays at ~12 lines.
```csharp
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
builder.Logging.AddConsole();
builder.Services
.AddOpcUa()
.AddServer(o =>
{
o.ApplicationName = "MyServer";
o.ApplicationUri = "urn:localhost:MyServer";
o.ProductUri = "uri:opcfoundation.org:MyServer";
o.AutoAcceptUntrustedCertificates = true;
o.EndpointUrls.Add("opc.tcp://localhost:51210/MyServer");
})
.AddNodeManager<MyModel.MyModelNodeManagerFactory>();
await builder.Build().RunAsync();
```
`AddOpcUa()` registers a `ServiceProviderTelemetryContext` that adapts
the host's `ILoggerFactory` to `ITelemetryContext` — no separate logging
pipeline is required. `IOpcUaServerBuilder.AddNodeManager<T>()` registers
an `IAsyncNodeManagerFactory`; use `AddSyncNodeManager<T>()` for the
legacy `INodeManagerFactory`. For advanced configuration (custom security
policies, additional builder calls), set `OpcUaServerOptions.ConfigureBuilder`.
That's the whole server. The Boiler version is in
`samples/MinimalApi/MinimalBoilerServer/Program.cs`.
### Multi-namespace and manager-swap subclassing
Because the generated factory members are `virtual`, you can extend
without forking:
```csharp
public sealed class MyExtendedFactory : MyModel.MyModelNodeManagerFactory
{
public override ArrayOf<string> NamespacesUris
{
get
{
var ns = base.NamespacesUris;
ns.Add("urn:my:second:namespace");
return ns;
}
}
public override ValueTask<IAsyncNodeManager> CreateAsync(
IServerInternal server,
ApplicationConfiguration cfg,
CancellationToken cancellationToken = default)
=> new(new MyExtendedNodeManager(server, cfg));
}
```
The `tests/Opc.Ua.Server.Tests/Fluent/GeneratedManagerHybridTests.cs`
suite verifies these subclassing scenarios.
### NativeAOT publishing
The project that hosts the generated manager only needs the standard AOT
settings:
```xml
<PropertyGroup>
<PublishAot>true</PublishAot>
<InvariantGlobalization>true</InvariantGlobalization>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
```
Use `Microsoft.Extensions.Logging.Console` for AOT-friendly logging
(Serilog providers vary in AOT compatibility). Validate with:
```cmd
dotnet publish -c Release -r win-x64
```
`samples/MinimalApi/MinimalBoilerServer` publishes cleanly with **zero AOT/trim
warnings** (~29 MB self-contained EXE).
### Runtime NodeSet alternative
When you want to host a NodeSet2 document without any source generation — for example a companion-spec XML received from a vendor, or a model that changes more frequently than you rebuild — use [AddRuntimeNodeSet](RuntimeNodeSets.md) instead. The runtime path loads a file or stream, imports nodes in topological dependency order, and exposes them through the same untyped `INodeManagerBuilder` surface as the `Configure` partial above. Use `AddRuntimeNodeSet` for startup registration or `INodeManagerLifecycle` to add, reload, and remove a model while the server runs. See [RuntimeNodeSets.md](RuntimeNodeSets.md) for a side-by-side comparison of the two paths.
### Building richer node managers — the fluent extension surface
The Configure callback wires read/write/method/event hooks against
already-loaded predefined nodes, but real-world servers also need to
materialise dynamic instances, attach engineering units to measurements,
build alarms, run simulation loops, populate identification properties,
and compose multiple companion-spec models into a single address space.
The extensions below cover those workflows. All are AOT/trim safe and
follow the same return-the-same-builder chaining contract as the core
`INodeBuilder` API.
#### Engineering units & EU range
`IVariableBuilder<TValue>.WithEngineeringUnits` and `.WithEURange`
attach the standard `EngineeringUnits` and `EURange` property children
on a `BaseAnalogState` variable. The extensions create the property
child on demand (matching the runtime's `AddEngineeringUnits` /
`AddEURange` helpers) and then set the Value attribute.
```csharp
builder.Variable<double>("Pumps/Pump_1/Operational/Measurements/FluidTemperature")
.OnRead(SimulateTemperature)
.WithEngineeringUnits(
new EUInformation("K", "Kelvin",
"http://www.opcfoundation.org/UA/units/un/cefact"))
.WithEURange(min: 263.15, max: 393.15);
// Convenience: set both at once.
builder.Variable<double>("Pumps/Pump_1/Operational/Measurements/DifferentialPressure")
.OnRead(SimulatePressure)
.WithUnits(EUInformations.Pascal, min: 0, max: 400_000);
```
Fail-fast behaviour: calling these on a non-`BaseAnalogState` variable
throws `ServiceResultException` with
`StatusCodes.BadTypeMismatch` — analog-only properties don't apply to
plain `BaseDataVariableState` nodes.
#### Bulk property initialisation
`INodeBuilder.WithProperty` writes the Value attribute of a property
child, **creating the property first when it does not already exist**.
Typed overloads exist for every built-in OPC UA scalar (`string`,
`int`, `uint`, `double`, `bool`, `DateTimeUtc`, `NodeId`,
`LocalizedText`, `QualifiedName`, etc.) plus a generic `Variant` escape
hatch.
```csharp
builder.Node("Pumps/Pump_1/Identification")
.WithProperty("Manufacturer", new LocalizedText("SimPump Corp"))
.WithProperty("Model", new LocalizedText("PumpX-2000"))
.WithProperty("SerialNumber", "SN-001")
.WithProperty("DeviceClass", "Pump")
.WithProperty("ProductInstanceUri",
"urn:simdevice:SimPump:PumpX-2000:SN-001");
```
Pass the CLR type the model declares for the property — `LocalizedText`
for `Manufacturer` / `Model` / `ComponentName`, `ushort` for
`YearOfConstruction`, `byte` for `MonthOfConstruction`, and so on. The
typed overloads make the choice explicit at the call site.
Reference resolution is by browse-name only (case-sensitive,
namespace-agnostic), matching the AOT-safe constraint of the rest of
the fluent surface. When the child exists it is updated; when it exists
but isn't a variable the call throws `BadTypeMismatch`.
When the child is **missing**, `WithProperty` materialises a new
read-only `PropertyState` (data type inferred from the value) under the
current node and registers it with the owning node manager. This makes
the helper usable on freshly built nodes such as custom DI functional
groups — not just on properties that come from a loaded model:
```csharp
// "Diagnostics" is a custom functional group with no model-defined
// properties; WithProperty creates each one on the fly.
node.WithProperty("LastError", string.Empty)
.WithProperty("ErrorCount", 0)
.WithProperty("LastSelfTest", (DateTimeUtc)DateTime.UtcNow);
```
Auto-created properties are read-only by default. Grant write access
with the fluent `Writable()` helper — either standalone on a resolved
variable, or inline via the `WithProperty(name, value, configure)`
overload that positions a builder on the new property:
```csharp
node.WithProperty("LastError", Variant.From(string.Empty), p => p.Writable())
.WithProperty("ErrorCount", 0);
// or, on an existing variable:
builder.Node("Pumps/Pump #1/Operational/SetPoint").Writable();
```
#### References & dynamic child objects
`INodeBuilder.Organizes`, `.HasComponent`, `.HasProperty` and the
generic `.AddReference(typeId, isInverse, target)` add forward /
inverse references on the current node. They're the foundation for
OPC UA Device Integration (DI)'s FunctionalGroup pattern — group
unrelated variables under a shared object via `Organizes`.
```csharp
// Wire existing measurement variables into a custom FunctionalGroup.
builder.Node("Pumps/Pump #1/Operational/MyGroup")
.Organizes(temperatureNodeId)
.Organizes(pressureNodeId)
.HasProperty(metadataNodeId);
```
`INodeBuilder.OrganizedBy(parentId)` and `.UnderObjectsFolder()` add
the *inverse* `Organizes` reference, placing the current node below an
organizing parent. The parent may be owned by another node manager
(e.g. the ns=0 `Objects` folder) — the forward edge is published
automatically when `Configure` completes; see
[Creating nodes under other managers' nodes](#creating-nodes-under-other-managers-nodes-objects-folder).
`INodeBuilder.AddObject(browseName, typeDefinitionId)` synthesises a
new `BaseObjectState` child under the current node and returns a typed
builder for the new object. NodeIds follow the
`{parentIdentifier}_{childName}` pattern used by the source generator's
default factory. The helper registers the created node with the owning
`AsyncCustomNodeManager`, so the object is immediately browseable and
addressable by NodeId.
```csharp
// Create a custom FunctionalGroup, then attach measurements.
builder.Node("Pumps/Pump #1")
.AddObject(new QualifiedName("CustomMetrics", pumpsNs))
.Organizes(t1).Organizes(t2);
```
Newly created objects are reachable through navigation from the parent
and through direct NodeId lookup immediately. Callers do not need to
index nodes created by `AddObject` themselves.
#### Creating instances of model types
`INodeBuilder.CreateInstance<TState>(name, factory)` materialises a
new `BaseInstanceState` subtype using a user-supplied factory delegate
— typically a generated `Create<TypeName>` method from the source
generator output. The returned `IInstanceBuilder<TState>` exposes
`.Configure(builder => …)` for inline child wiring, `.AsNode()` for a
typed `INodeBuilder<TState>` view, and `.Done()` to return to the
parent builder.
```csharp
builder.Node("Pumps")
.CreateInstance(
new QualifiedName("Pump #2", pumpsNs),
pumpTypeId,
parent => context.CreatePumpType(parent))
.Configure(p2 =>
p2.AsNode()
.WithProperty("Manufacturer", "Vendor B")
.WithProperty("SerialNumber", "SN-002"));
```
The factory pattern keeps the API reflection-free and AOT safe — the
generator already emits the per-type `Create<Type>` extension methods
that the factory delegate calls into.
Like `AddObject`, `CreateInstance<TState>` registers the materialised
subtree with the owning node manager. The same registration behaviour
is used by the fluent state-machine creators, so generated instances
created from a builder can be browsed, read, and monitored without a
separate `AddPredefinedNodeAsync` call.
For **root-level** instances (no parent node in this manager),
`INodeManagerBuilder.CreateInstance<TState>(name, factory)` accepts a
plain constructor-style factory (`p => new BoilerState(p)`, invoked
with a `null` parent), fully materialises the instance from its type
model, and mints per-instance NodeIds for the whole subtree through
the manager's `INodeIdFactory`. Combine with `.UnderObjectsFolder()`
(or `.OrganizedBy(parentId)`) to place the instance below a node owned
by another manager:
```csharp
builder.CreateInstance(
new QualifiedName("Boiler #2", NamespaceIndexes[1]),
p => new BoilerState(p))
.Configure(n => n.UnderObjectsFolder());
```
#### Alarm setup (MVP)
`INodeBuilder.CreateLimitAlarm`, `.CreateExclusiveLimitAlarm` and
`.CreateOffNormalAlarm` attach a fresh alarm condition under the
current node and return an `IAlarmBuilder<TState>` for further
configuration. The helpers register the condition, add the
`HasCondition` reference, initialise `SourceNode`, `SourceName`,
`ConditionName`, and `InputNode`, and promote the source object and its
ancestors with `EventNotifiers.SubscribeToEvents`. The source is also
registered as a root notifier so clients subscribing to the `Server`
object receive condition events:
```csharp
builder.Node("Pumps/Pump #1/Events")
.CreateLimitAlarm(new QualifiedName("OverTempAlarm", pumpsNs))
.WithLimits(highHigh: 380, high: 370, low: 273, lowLow: 263)
.MonitorVariable(temperatureNode)
.OnAcknowledge((ctx, condition, eventId, comment) => ServiceResult.Good)
.OnConfirm((ctx, condition, eventId, comment) => ServiceResult.Good);
```
For full state access (severity tables, retain flag, branches), use
the `.ConfigureAlarm(Action<TState>)` escape hatch:
```csharp
builder.Node("Events")
.CreateLimitAlarm(new QualifiedName("Custom", ns))
.WithLimits(high: 100)
.ConfigureAlarm(alarm =>
{
alarm.Retain!.Value = true;
// any state-class mutation goes here
});
```
#### Boolean supervision → alarm activation (NAMUR pattern)
`IVariableBuilder<bool>.OnRisingEdge` / `.OnFallingEdge` register
callbacks that fire when the variable's value transitions. The
`.ActivatesAlarm(alarmBuilder)` extension wires the bool variable to
an `AlarmConditionState`'s ActiveState so it flips in lockstep with
the supervision flag, updates `Retain`, adjusts severity, and reports a
condition event — exactly the OPC UA DI / NAMUR NE 107 pattern.
```csharp
IAlarmBuilder<NonExclusiveLimitAlarmState> cavitationAlarm =
builder.Node("Events").CreateLimitAlarm(name)
.ConfigureAlarm(a => a.Severity!.Value = (ushort)EventSeverity.Medium);
builder.Variable<bool>("Pump #1/Events/Supervision/ProcessFluid/Cavitation")
.ActivatesAlarm(cavitationAlarm);
```
Detection is value-change based: transitions only fire when something
else (an `OnWrite` handler, a simulation tick, a client write) actually
mutates the variable.
#### Simulation timers
`INodeManagerBuilder.Simulation(interval).OnTick(...)` registers a
periodic background loop owned by the `FluentNodeManagerBase`. Each
tick fires on a `PeriodicTimer` and is cancelled when the manager is
disposed; exceptions inside handlers are logged and do not kill the
loop.
```csharp
partial void Configure(INodeManagerBuilder builder)
{
builder.Simulation(TimeSpan.FromMilliseconds(250))
.OnTick((ctx, elapsed) =>
{
m_temperature = 313.15 + 5 * Math.Sin(m_t * 0.01);
m_pressure = 200000 + 50000 * Math.Sin(m_t * 0.03);
m_t++;
});
}
```
Async tick handlers receive a `CancellationToken` honouring manager
disposal — use it for any awaitable work inside the loop. Multiple
`.OnTick` calls on the same `Simulation()` builder all fire on every
tick.
The simulation registry **requires** the manager to derive from
`FluentNodeManagerBase` (the source generator-emitted manager already
does); calling `.Simulation()` on a plain `CustomNodeManager2` throws
`StatusCodes.BadConfigurationError`.
#### Pushing runtime value changes to subscribers
`OnRead` getters are invoked on the **Attribute (Read) service**, but a
value that only lives behind a getter — or in a backing field mutated by
an `OnCall` handler — will **not** reach subscribed MonitoredItems on its
own. In previous implementations the fix was to mutate `Node.Value` and call
`Node.ClearChangeMasks(...)`, but that node handle is deliberately unavailable
through the fluent surface once `Configure` returns (the builder is sealed).
Two fluent mechanisms close that gap.
**1. `Bind(out IValueUpdater<TValue>)` — explicit push.** Capture a runtime
handle during `Configure` and store it on the manager; the handle survives
sealing. `SetValue` assigns the value, timestamp and status and flushes the
change mask in one serialized call, so both reads *and* subscriptions see
the update:
```csharp
private IValueUpdater<float> m_ao01 = null!;
partial void Configure(IMyNodeManagerBuilder builder)
{
builder.MyEquipment03.AO01.Builder.AsVariable<float>()
.Bind(out m_ao01);
builder.MyEquipment03.SetOutputVal
.OnCall((float value) => m_ao01.SetValue(value));
}
```
`IValueUpdater<TValue>` also exposes `SetValue(value, statusCode)`,
`SetValue(value, statusCode, sourceTimestamp)`, and `NotifyChange()` (flush
a notification after an in-place mutation without changing the value).
**2. `PollEvery(interval, getter)` — opt-in auto-sampling.** Register a
periodic loop that reads the getter and pushes a change only when the value
actually differs, so subscriptions update automatically with no
change-notification code. An initial sample is applied immediately:
```csharp
builder.MyEquipment03.AO01
.PollEvery(TimeSpan.FromMilliseconds(250), () => m_ao01Value);
```
Like `Simulation`, `PollEvery` reuses the manager-owned loop
infrastructure and therefore **requires** the manager to derive from
`FluentNodeManagerBase`; calling it on a plain `CustomNodeManager2` throws
`StatusCodes.BadConfigurationError`.
#### Subscription-gated sources
Use `PollWhileMonitored` when sampling an external source should consume
resources only while a client is interested. A disabled monitored item
does not keep the source active; `Sampling` and `Reporting` items do:
```csharp
builder.Variable<double>("Dynamic/Temperature")
.OnFirstSubscriber((context, node, ct) =>
m_device.StartMonitoringAsync(node.NodeId, ct))
.OnLastSubscriber((context, node, ct) =>
m_device.StopMonitoringAsync(node.NodeId, ct))
.PollWhileMonitored(
TimeSpan.FromMilliseconds(100),
(context, ct) => m_device.ReadTemperatureAsync(ct));
```
The zero-to-one transition invokes `OnFirstSubscriber`, samples
immediately, and starts the worker. The one-to-zero transition cancels
the worker and invokes `OnLastSubscriber`. While active, the effective
period is the fastest revised sampling interval among active items,
bounded by the minimum period passed to `PollWhileMonitored`. Create,
modify, mode-change, and delete operations reconcile that period without
overlapping samples. The worker uses the server `TimeProvider`, pushes
only changed values through `IValueUpdater<TValue>`, and is cancelled when
the manager is disposed.
The same `OnFirstSubscriber`, `OnLastSubscriber`, and
`PollWhileMonitored` extensions are available on an
`IVirtualNodeBuilder`; the current materialized node is retained only for
the monitored-item lifetime.
#### Multi-model composition
The only supported mode for combining models is **source-generated
library references**. Each companion spec is built once into its
own model library (a `src/Opc.Ua.{Spec}/` project that
consumes the ModelDesign XML and emits an `AddOpcUa{Spec}`
extension method); the consumer adds project references and calls
the generated extensions directly in dependency order:
```csharp
protected override ValueTask<NodeStateCollection> LoadPredefinedNodesAsync(
ISystemContext context, CancellationToken ct = default)
{
var nodes = new NodeStateCollection();
nodes.AddOpcUaDi(context);
nodes.AddOpcUaMachinery(context);
nodes.AddOpcUaPumps(context);
return new ValueTask<NodeStateCollection>(nodes);
}
```
Source-generated models are AOT-friendly, deterministic, and
produce typed `*State` / `*Client` proxies. **Every application-
owned model must ship as source-generated content** — companion
specs ship as project references; locally-owned NodeSet2 XMLs are
wired through `<AdditionalFiles>` so the source generator emits
the same typed surface inside the consuming assembly. Each
`AddOpcUa{Spec}(context)` extension is idempotent and re-entrant,
so direct chaining in dependency order is the recommended pattern.
#### Mixing ModelDesign and NodeSet2 in one project
A `ModelDesign` XML and a `NodeSet2` XML can be combined in the same
project, and a node in one may reference a type defined in the other.
A common split is to author the reusable **object types** as a
NodeSet2 (e.g. exported from a modelling tool such as SiOME) and the
concrete **instances** as a ModelDesign whose `TypeDefinition`
points at those NodeSet2 types:
```xml
<!-- Instances.ModelDesign.xml -->
<opc:ModelDesign
xmlns:opc="http://opcfoundation.org/UA/ModelDesign.xsd"
xmlns:et="http://example.org/EquipmentTypes"
xmlns="http://example.org/EquipmentInstances"
TargetNamespace="http://example.org/EquipmentInstances">
<opc:Namespaces>
<opc:Namespace Name="EquipmentInstances"
>http://example.org/EquipmentInstances</opc:Namespace>
<!-- Bind the same URI to the "et" XML prefix used below. -->
<opc:Namespace Name="EquipmentTypes" XmlPrefix="et"
>http://example.org/EquipmentTypes</opc:Namespace>
</opc:Namespaces>
<!-- "et:" resolves to the NodeSet2 namespace declared via xmlns:et. -->
<opc:Object SymbolicName="Equipment01" TypeDefinition="et:SimpleEquipmentType" />
</opc:ModelDesign>
```
```xml
<Project>
<ItemGroup>
<AdditionalFiles Include="Model\EquipmentTypes.NodeSet2.xml">
<ModelSourceGeneratorModelUri>http://example.org/EquipmentTypes</ModelSourceGeneratorModelUri>
</AdditionalFiles>
<AdditionalFiles Include="Model\Instances.ModelDesign.xml">
<ModelSourceGeneratorModelUri>http://example.org/EquipmentInstances</ModelSourceGeneratorModelUri>
</AdditionalFiles>
</ItemGroup>
</Project>
```
The generator resolves the cross-model reference automatically — every
input is supplied to the others as a resolution dependency (both
`ModelDesign → NodeSet2` and `ModelDesign → ModelDesign`).
> **Binding a `[NodeManager]` in a mixed project.** A `[NodeManager]`
> may target the namespace of *either* input — the NodeSet2 type model
> or the ModelDesign instance model — by setting its `NamespaceUri` to
> that model's URI. Binding is resolved across both the NodeSet2 and the
> ModelDesign generation passes, so a manager bound to the NodeSet2 types
> is **not** reported as unmatched (`MODELGEN010`) just because the
> project also contains a ModelDesign — and vice-versa. The generated
> node-manager class name and namespace come from the annotated partial
> class itself, **not** from `ModelSourceGeneratorPrefix`/`Name` (those
> control the generated `*State`/type class names — see the note below).
> **C# namespace of a NodeSet2 model.** The generated C# namespace for
> a NodeSet2 input is derived from its `ModelUri` unless you set
> `ModelSourceGeneratorPrefix` (C# namespace / prefix) and
> `ModelSourceGeneratorName` (the `Namespaces` class identifier) on that
> `AdditionalFiles` entry. A `Prefix`/`Name` declared *inside* a
> referencing ModelDesign's `<opc:Namespaces>` does **not** rename the
> NodeSet2's generated types — set the per-file MSBuild metadata on the
> NodeSet2 entry to control it.
NodeSet2 `ParentNodeId` identifies a node's ownership relationship, not every
hierarchical reference to it. The generator preserves additional hierarchical
links, such as `HasComponent`, `HasOrderedComponent`, or `HasAddIn` to a node
owned by another parent, without moving that node. Only the exact reference
represented by the parent/child relationship is implicit. Address-space
registration completes reverse references, and generation deduplicates links
by their resolved reference type, direction, and target NodeId. Rebuild the
consuming project to regenerate its address-space code.
#### Importing a NodeSet2 overlay at runtime — `builder.Import`
The models above are compiled into the assembly. A NodeSet2 document
that is only known at runtime — an overlay shipped by an integrator, a
document downloaded from a device — can be imported into the *same*
manager from inside `Configure`:
```csharp
partial void Configure(INodeManagerBuilder builder)
{
using var stream = File.OpenRead("overlay.NodeSet2.xml");
builder.Import(UANodeSet.Read(stream));
// Imported nodes resolve immediately, so they can be wired in the
// same pass.
builder.Node(new NodeId(3300u, NamespaceIndex))
.As<BaseDataVariableState>()
.OnSimpleRead(ReadOverlayValue);
}
```
What the import guarantees:
- **One batch per `Configure` pass.** Every document imported during
one pass is linked exactly once, after the pass returns. A node may
therefore declare a `ParentNodeId` that lives in another document of
the same batch, or a node the manager already owns — which is how an
overlay extends the generated model.
- **Typed states without reflection.** The generated node manager
implements `INodeSetImportFactoryProvider`. Its factories are matched
by TypeDefinition (Object, Variable), by MethodDeclaration (Method)
and by NodeId (declarations), so an imported node of a generated type
materialises as its generated `*State` subclass. Each factory calls a
concrete constructor — nothing is looked up at runtime, which keeps
the path NativeAOT-safe. Pass an
`INodeSetImportFactoryProvider` to `Import` to supply factories from
somewhere else, or implement
`partial void AddNodeSetImportFactories(List<INodeSetImportFactory>)`
on the manager to add the factories of a dependency model.
- **Empty typed states.** A factory returns a state without children:
the document, not the model, decides which children the imported node
has.
- **Placeholder replacement.** When an imported child lands in a slot
that the generated parent declares (same BrowseName), it replaces the
generated placeholder, and the displaced node and every descendant it
does not carry over are removed from the address space. References to
the displaced node are retargeted at the replacement. Wiring a node in
`Configure` *before* an import displaces it is rejected with
`BadInvalidState` rather than silently dropping the wiring — import
first, then wire.
The three contracts (`INodeSetImportFactoryProvider`,
`INodeSetImportFactory`, `NodeSetImportDiscriminator`) live in
`Opc.Ua.Server.Nodes`. For a manager built entirely from NodeSet2
documents with no compiled model at all, see
[Runtime NodeSets](RuntimeNodeSets.md).
#### NodeSet2 access-level bitmasks
NodeSet2 imports preserve the verbatim `AccessLevel` bitmask. This
matters for values such as `AccessLevel="5"` (`CurrentRead |
HistoryRead`): the legacy ModelDesign enum can describe the individual
named values but is not a `[Flags]` enum. The importer stores the raw
mask on `VariableDesign.RawAccessLevel`, and code generation emits the
corresponding `Opc.Ua.AccessLevels` constants instead of collapsing the
value to `Read`. `UserAccessLevel` intentionally mirrors
`AccessLevel`, matching the runtime NodeSet2 importer.
### Materialising instances at runtime — NodeId assignment
Every model gets three families of instance helpers. They differ only in
**who owns the NodeIds** of the nodes they produce:
| Helper | Produces | NodeIds |
| --- | --- | --- |
| `context.CreateInstanceOf<Type>(parent, browseName)` | A full typed subtree | Rebased onto per-instance NodeIds minted by `ISystemContext.NodeIdFactory` whenever a `browseName` is supplied |
| `owner.Add<Child>(context, nodeId = default)` | One optional child (+ its declared subtree) | Per-instance NodeIds, or the explicit `nodeId` you pass |
| `owner.CreateOrReplace<Child>(context, replacement)` | One child slot — also the plumbing behind `NodeState.CreateChild` / `ReplaceChild` | Per-instance NodeIds for a child that carries no NodeId yet or still carries the type-level one |
```csharp
// Two instances of the same type never collide: the factory rebases the
// mandatory children, and every subsequent child materialisation mints
// its own NodeId from the parent chain.
PumpState pump = SystemContext.CreateInstanceOfPumpType(deviceSet, pumpBrowseName);
deviceSet.AddChild(pump);
pump.AddOperational(SystemContext); // optional child
pump.CreateChild(SystemContext, someBrowseName); // CreateOrReplace<Child>
await AddPredefinedNodeAsync(SystemContext, pump, cancellationToken);
```
The generated factory intentionally returns a graph whose create lifecycle
has not completed. Node manager registration completes
`OnBeforeCreate`/`OnAfterCreate` and clears change masks before the graph is
indexed. Asynchronous predefined-node registration repairs typed instance
subtrees which still carry null, foreign-namespace, or
type-declaration-colliding NodeIds first, so lifecycle callbacks see the
identifiers which enter the address space. NodeIds explicitly assigned in a
namespace owned by the manager are preserved. Synchronous registration keeps
the caller's identifiers; fluent helpers which materialise typed subtrees,
such as the state-machine creators, assign their instance child NodeIds before
registration. The behavior hook then receives a created node.
`NodeState.IsCreated` reports whether an individual node has completed that
lifecycle.
Most callers should configure the graph and then register it as shown above.
If code must read state established by `OnAfterCreate`, or write state or
handlers which an `OnAfterCreate` override would replace, call
`CreateAsPredefinedNode` after assembling the subtree and before that
ordering-sensitive code. The call is idempotent per node and still completes
children added since an earlier call. It does not re-run `OnAfterCreate` on
ancestors which were already created; assemble parent-wired children before
the parent's first completion, or wire those late children explicitly.
Notes:
* An explicit `browseName` is what marks a *dynamically materialised
instance*. `CreateInstanceOf<Type>()` without one (as used by the
generated `NodeStateActivator`s and when replacing a well-known
singleton) keeps the declaration NodeIds.
* A NodeId **you** assigned is never overwritten — pass a fully
configured child as the `replacement`, or use `Add<Child>(context,
nodeId)`, to keep control.
* The generated type factories opt out through
`assignInstanceNodeIds: false`: they build declaration subtrees whose
NodeIds `CreateInstanceOf<Type>` rebases in a single pass afterwards.
The same parameter is available to you if you need that behaviour.
* `CreateInstanceOf<Type>` assigns identity but does not call
`CreateAsPredefinedNode`. `AddPredefinedNodeAsync`, `AddNodeAsync`, and the
synchronous predefined-node registration paths complete the lifecycle
automatically.
* Assignment only happens when the context carries an
`ISystemContext.NodeIdFactory`. `AsyncCustomNodeManager` supplies one
that allocates null IDs in the manager's default namespace. Registration
selectively retries descendants when an asynchronously registered,
manager-owned instance subtree still carries foreign declaration IDs, while
preserving explicitly assigned IDs in an owned namespace and well-known
namespace-zero roots. Synchronous creators must assign typed child IDs before
registration. Override `New` only when the address space needs a different
stable naming strategy, such as deriving IDs from the parent chain.
* **A node copy never assigns.** `NodeState.Create(context, source)`
initialises each child from its source right after creating it, which
overwrites any NodeId minted along the way — so minting one would only
consume identifiers, and leak them for factories that track outstanding
allocations. The copy therefore calls
`CreateChild(context, browseName, assignInstanceNodeIds: false)`.
#### Custom node types and assignment control
`NodeState.FindChild` and `NodeState.CreateChild` carry
`assignInstanceNodeIds` as their last parameter. It defaults to `true`, so
callers that state no intent keep materialising children with per-instance
NodeIds. A type that declares children overrides `FindChild`, resolves the
ones it declares, and passes the request on — both to its
`CreateOrReplace<Child>` helpers and to the base:
```csharp
protected override BaseInstanceState? FindChild(
ISystemContext context,
QualifiedName browseName,
bool createOrReplace,
BaseInstanceState? replacement,
bool assignInstanceNodeIds = true)
{
if (browseName.Name == BrowseNames.EnumStrings)
{
return !createOrReplace
? EnumStrings
: CreateOrReplaceEnumStrings(context, replacement, assignInstanceNodeIds);
}
return base.FindChild(
context, browseName, createOrReplace, replacement, assignInstanceNodeIds);
}
```
Source generated types emit exactly this shape. Because the request is an
argument, every type — generated or hand-written — sees the real
`ISystemContext` during a copy; nothing wraps the context to hide the
`NodeIdFactory`.
`NodeState.FindChild` takes the request as an argument and `CreateChild` takes
the state NodeId; migrating an override written against 1.5.378 is covered by the
[migration guide](migrate/2.0.x/node-states.md#nodestate-findchild-and-createchild-state-nodeid-assignment).
### Current limitations
- **Browse-path wildcards** (`*`, `**`) are not supported. Wire each
path explicitly or resolve by NodeId / TypeDefinitionId.
- **Historical access advertisement.** Servers reconcile
`Historizing` and `HistoryRead` / `HistoryWrite` access-level bits at
startup. Variables that do not resolve to an `IHistorianProvider`
have those bits masked before the server accepts clients; variables
wired with `Historize()` or another historian keep their history
surface. See [Server address-space metadata](#server-address-space-metadata)
and [Historical Access](HistoricalAccess.md).
- **Reserved child names.** A component/property whose BrowseName
matches a built-in `NodeState` attribute member (for example
`Description` or `DisplayName`) shadows that member on the generated
`*State` class and produces code that does not compile. Rename such
children (the OPC UA `Description`/`DisplayName` *attributes* are
always available without a dedicated child).
### Sample
- `samples/MinimalApi/MinimalBoilerServer/` — a fully self-contained,
NativeAOT single-file Boiler server. Read it top-to-bottom in
&lt;200 lines.
- `samples/MinimalApi/MinimalCalcServer/` — a calculator server that
exercises the typed
[methods-with-arguments OnCall overloads](#methods-with-arguments--typed-oncall-overloads)
end-to-end (sync `int+int → int`, async `double+double → double`,
sync `string+string → string`).
- `samples/DI/PumpDeviceIntegrationServer/` — the full OPC 40223
Pumps companion server. Exercises every fluent extension above
(engineering units, identification properties, FunctionalGroup
wiring, instance creation, limit alarm with NAMUR-style boolean
supervision, periodic simulation tick, and multi-model loader for
DI + Machinery + Pumps), and additionally attaches the OPC
10000-100 software-update facet to a second declarative pump
device.