Files
UA-.NETStandard/docs/NodeManagers.md
Marc Schier fa8d45f2cf Fix server/core validation, lifecycle and protocol regressions (#4477)
# Description

**Server/core audit fixes with regression tests. Latest review-fix
validation, merge validation and earlier acceptance results are
distinguished below.**

This addresses a 95-finding server/core report. Every finding was
reviewed with
the OPC UA specification knowledge base or, where the standard does not
prescribe a C# implementation, the corresponding ownership/configuration
contract. Incorrect or withdrawn subclaims remain qualified rather than
being
presented as additional fixes.

The original checkpoint covers session identity/certificate validation
and
revocation; certificate/key ownership and transactional updates; TCP
reconnect,
receive/admission and buffer lifetime; HTTP quotas; subscription
admission,
durability and pooled payloads; file-operation lifecycle; historian
aggregates;
fluent/state-machine behavior; and related utilities. It includes
targeted
regressions and directly related documentation.

The approved acceptance cleanup is separate in the commit history:

| Cleanup | Verified result |
| --- | --- |
| Invalid identity-policy and KeyCredential fixture inputs | Fixtures
explicitly advertise Username and use its generated policy ID.
Credential persistence uses valid clear mode inside the existing
encrypted admin context. Original permission, lockout and negative
encryption assertions remain. |
| Native GDS lifetime and concurrent connection failures | Retain
ApplicationInstance until client teardown. A deterministic native
reproducer proved the test-only channel cap of 10 was exhausted;
removing that override restores the existing bounded default of 1000. No
global serialization or production quota change. |
| Stress certificate ownership and close accounting | Balance two helper
references and the fake transport's server-certificate ownership.
Replace the invalid event-count bound with exactly-once physical
disposal, opens = closes, zero active entries and one logical entry
lifetime. |
| Participant deadlines | Use the injected TimeProvider with the
existing cross-framework WaitAsync overload. Tests pin one tick before,
exact expiry, terminal notification, recovery and timely completion. |
| Reproducibility and platform assertions | Preserve canonical WoT
bytes/hashes, explicit display-culture contracts and exact
numeric-vector assertions. Accept Apple's CryptographicException subtype
without weakening disposal balance. |
| Missing user-signature proof | Reject null/empty proofs before OS
crypto. Return BadUserSignatureInvalid per Part 4, 5.7.3.3. None-policy
and valid-signature controls remain. A CI-only test nullable-context
error was corrected without changing production behavior. |
| Durable sample restore and shared test storage | Dequeue waits for
actual batch residency instead of treating failed restore completion as
success, and restored files close before deletion. Five deterministic
regressions failed before the fix. The two fixtures that clear the
shared batch directory are isolated; all original 3,000-item assertions
remain unchanged. |

## Latest review follow-up

Commit `828856850219518970e8e2f580b8f94b3c1fc748` fixes the deferred
robotics
disposal reported in the latest review.
`RobotIntentNodeManager.DisposeAsync`
now joins a shared controller-host drain and then explicitly awaits base
node-manager disposal. A controller's bounded shutdown wait may defer
its
cleanup, but no longer lets the node-manager override finish early.
Concurrent
callers, including an asynchronous call after synchronous `Dispose`,
cannot
bypass the drain after the host list has been detached.

Synchronous disposal remains nonblocking for deferred work. Deferred
initialization failures are logged and retained, and base cleanup
failures
propagate to asynchronous callers. The existing controller-level bounded
disposal contract is unchanged; the node-manager contract is documented
in
`docs/Robotics.md`.

| Regression requirement | Evidence |
| --- | --- |
| Wait for deferred hosts; preserve synchronous compatibility and
concurrent joins | `NodeManagerAsyncDisposalWaitsForDeferredHostsAsync`:
four cases covering both entry-point orders and zero/positive host
shutdown waits. All four failed before the fix. |
| Wait through base cleanup and observe failures |
`NodeManagerDisposalWaitsForBaseCleanupAndReportsItsFailureAsync`: four
cases hold the actual base-resource semaphore, verify pending callers
after the host finishes, then check success or the same propagated
exception and exactly-once cleanup. |

The focused `IntentHostingCoverageTests` and
`IntentControllerHostTests.Dispose`
selection passed **30/30 on net10.0 and 30/30 on net48**, with no
failures or
skips. The final net10 solution build passed with zero errors and only
the four
previously documented CA2249 warnings in unchanged fuzz-test code. No
new
warnings, analyzer suppressions, dependencies, or weakened assertions
were added.
The review thread received a commit-specific reply and was explicitly
resolved.

This verification is scoped to the robotics feedback; the earlier full
Subscriptions-run limitation remains recorded in the merge section
below.


## Documentation review follow-up (2026-09-14)

Commit `f9a9686fcc5d3cfd19cbbc8c2d826e20f5c7e72f` addresses the six
follow-up
threads on documentation and implementation rationale. Each has an
in-thread
reply citing the commit and an explicit resolution.

Multiline XML documentation now covers the declarations introduced or
modified
throughout the entire PR, including internal contracts, samples, tests,
and
public members of related private helpers. A syntax-aware inventory
checked
**2,031 declarations across all 256 PR C# paths with zero documentation
gaps**.
A supplementary check also covers **75 previously unchanged public
members**
of private helpers involved in the changed code. Token comparison across
the
conditional-compilation branches proves this follow-up changes **no
executable
C# or test assertions**. Project-file changes are rationale comments
only.

The implementation questions are documented at their source:

| Question | Resolution |
| --- | --- |
| Certificate assembly friendship | Server Push configuration reuses the
internal explicit-subject CSR helper with the existing key; no public
crypto API was added. |
| Core.Types assembly friendship | Server builders use internal
transition callbacks/revision guards; Server.Tests checks the revision
deterministically without creating a public compatibility contract. |
| Always-single-reader notifications | One dequeuer preserves event
order; optional concurrency fans out within one event and joins before
the next event. |
| Asynchronous disposal completion | `DisposeAsync` already joins the
completion published after admitted operations and all cleanup attempts.
The compatibility `Dispose` remains nonblocking to avoid deadlocking an
admitted callback against its own drain. |

The requested detailed review disposition follows. It distinguishes
actual
fixes from confirmed policies and incorrect subclaims rather than
counting every
comment as a new runtime change.


### Detailed disposition of review 5191795284

The [SDK
review](https://github.com/OPCFoundation/UA-.NETStandard/pull/4477#pullrequestreview-5191795284)
was submitted against `1c4a9df6c`, before the subsequent merge and
feedback fixes.
Its **15 inline threads** have each received an action-specific reply
and an
explicit resolution. A fresh GraphQL query verified those replies and
resolution
states; the fixes in `b8a59990f` remain ancestors of the current branch.

| Review concern | Action and disposition | Concrete regression evidence
|
| --- | --- | --- |
| Injected certificate-store providers bypassed | **Fixed.**
Registration, configuration snapshots, and validation use the injected
providers. Explicit custom types remain authoritative; the built-in
Directory catch-all is deferred so native X509 paths and later custom
providers are not captured. Failed store opens dispose the created
store. | `InjectedProviderResolvesRegisteredAndConfiguredTrustStores`
covers default-provider ordering and both registration paths; provider
validation/open-failure cases and the unchanged
`TrustedCertificateInjectedViaIndependentX509StoreIsObservedAsync` cover
real use and independent-store visibility. |
| Unguarded timer-thread leadership callbacks | **Fixed.** Each
subscriber's exception is logged independently and cannot suppress
subsequent subscribers or fail lease operations. `IsLeader` reads the
deadline without invoking application callbacks. |
`ThrowingLeadershipSubscriberDoesNotFailAcquireOrDisposeAsync`,
`ExpiryNotifiesOtherSubscribersWhenOneThrowsAsync`, and
`IsLeaderDoesNotDispatchExpiryNotificationsAsync` were all demonstrated
failing before the fix. |
| Legacy pending-key provider accepts an unfinishable regenerated CSR |
**Fixed by explicit capability validation.** A provider implementing
only `IPendingCertificateKeyStore` receives `BadNotSupported` before key
generation or persistence when regeneration is requested. Existing-key
CSR/update remains supported. Regeneration requires matching-only
consumption; no unsafe destructive probing was introduced. |
`LegacyPendingProviderRejectsRegenerationBeforeCreatingOrSavingAKeyAsync`
checks no key or store mutation;
`LegacyPendingProviderCompletesExistingKeySigningAndUpdateAsync` proves
the supported flow completes. |
| Shutdown waits behind user deactivation | **Fixed.** Accepted
deactivations drain before the master's lifecycle gate is taken.
Configuration retains its binding until the shared drain completes,
including concurrent shutdown callers and direct master disposal. | Four
cases of `ShutdownDrainsDeactivationOutsideTheSessionClosingGateAsync`
exercise real configuration/binding/ServerInternalData/master reentry,
retained address space, exact session/subscription closure, and no
production cancellation shortcut. |
| Subclass cleanup runs under the operation-lifetime lock | **Fixed.**
Cleanup starts outside that lock. A pre-published completion joins all
disposers, and only the active asynchronous teardown context may use
guarded helpers. Captured contexts lose that privilege when the callback
exits. |
`SynchronousOwnedCleanupLetsSamplingWorkerReachClosedAdmissionAsync`,
`DisposeCoreCanUseGuardedMembersWithoutReopeningCallerAdmissionAsync`,
captured-context rejection, and exact cleanup-failure/ownership
assertions cover both synchronous and suspended cleanup. |
| Discovery requestId-zero chunk read after pool return | **Fixed by the
earlier merge.** Discovery validation precedes `SaveIntermediateChunk`;
no read occurs after that path can return the buffer. The ordering is
retained. | `DiscoveryChannelReadsTheFirstChunkBeforeItIsReturnedAsync`
uses a poisoning pool and the requestId-zero path. |
| T1.8 OPN cleanup misses post-decryption throws | **Fixed by the
earlier merge and pinned with new regressions.** Both sequence rejection
and certificate mismatch release the decrypted body and input ownership.
| `OpenSecureChannelRejectionAfterDecryptionReturnsEveryRentalAsync`
checks the exact audit status and pool conservation. Omitting either
corresponding release in an isolated snapshot made its case fail; no
mutation remains. |
| T1.2 mount-root protection depends on the provider | **Fixed at the
host boundary.** Separator-only aliases resolve to the protected root.
Traversal, empty/whitespace segments, backslashes, and drive/stream
qualifiers are rejected before arbitrary providers are invoked. Valid
nested paths and root destinations remain supported. | A deliberately
permissive provider is used by
`NonCanonicalObjectPathsCannotDeleteMoveOrCopyAsync` and invalid-target
cases; exact zero-call checks are paired with legitimate
nested-path/root-destination controls. |
| Inline trusted/issuer entries now face CRL checks | **Intentional
strict behavior, documented and tested.** A configured backing store
with strict unknown-status rejection requires current revocation
information. Explicit-only lists without a store retain their no-store
behavior. Actual revocation cannot be suppressed; a matching store
entry's policy takes precedence. |
`InlineIssuerWithStoreHonorsStrictUnknownRevocationPolicyAsync`,
`InlineIssuerRevocationIsNeverSuppressedAsync`, store-precedence cases,
and explicit-only controls preserve the missing-information versus
actual-revocation distinction in Part 4 6.1.3. |
| RSA minimum applied to unspecified ECC identifiers | **Fixed at the
application-certificate check.** The RSA minimum is compared only with
an actual RSA public key. An unspecified ECC identity is not replaced
simply because its key size is below an RSA threshold. |
`SilentCertificateCheckPreservesUnspecifiedEccIdentityAsync` checks DER,
thumbprint, store contents, and private-key retention; explicit RSA and
HTTPS minimum controls remain. |
| Failed directory refresh wedges corrective mutations | **Fixed.**
Recoverable automatic pre-refresh failures are logged and leave
reconciliation pending, but do not block corrective provider mutations.
Explicit refresh errors, capacity admission, caller cancellation, and
actual provider denials remain enforced. |
`CorrectiveDeleteRecoversAfterPersistentOverLimitRefreshFailureAsync`
converges an externally over-limit directory back to its exact allowed
contents. Capacity, cancellation/token, and six refresh-error controls
check negative paths. |
| Timed-transition rejection logs at 10 Hz | **Fixed.** Repeated
`(state, revision, status)` warnings are deduplicated without changing
the retry cadence. A different status or state entry permits another
warning. | `RepeatedTimedRejectionLogsOnceAndRecoversAsync` checks one
warning over eleven rejections and success on the next attempt.
Status-change and same/different-state reentry controls pin reset
behavior. |
| Bad unsubscribe strands retired-generation bookkeeping | **Fixed.**
Retired all-event bookkeeping completes even when unsubscribe reports
Bad; the first Bad is still returned to the caller. | Good and
BadCommunicationError cases of
`RetiredAllEventsDeletionCompletesBookkeepingAndReleasesGenerationAsync`
check event/snapshot removal, generation release, and exactly one
unsubscribe without finalization replay. |
| CSR subject parsing and fallback | **Fixed for existing and
regenerated keys.** Omitted subjects fall back to the actual stored
certificate. Regeneration resolves the public certificate's subject
before creating a key. Malformed or unresolvable subjects return
`BadInvalidArgument` before pending-key mutation. | Store-only fallback,
real-generator null/empty cases, and missing-subject/malformed-DN
controls verify the signed CSR, matching fresh pending key, unchanged
active identity, and no destructive side effects. |
| Committed RemoveUser change reported as failed | **Fixed for
RemoveUser and ModifyUser.** Accepted cleanup is still awaited and close
failures remain logged, but cannot replace the committed mutation
result. Precommit failures retain their original result and side
effects. |
`CommittedUserChangeKeepsResultAndPublishedUsersWhenCloseFailsAsync`
checks published Users, Good result, logging, and continued closes.
`FailedUserChangeKeepsPublishedUsersAndDoesNotCloseSessionsAsync` checks
BadNotFound with no publication or close. |
| Alleged pooled decoded-request aliasing | **No runtime redesign
needed; reviewer rebuttal confirmed.** The decoder copies the relevant
input data. Misleading ownership comments were corrected rather than
introducing a new dispatch model. |
`DecodedRequestRetainsValuesAfterInputBuffersAreReturnedAsync` checks
the exact decoded NodeId and ByteString after returned arrays are
poisoned. |

The current documentation-only follow-up rechecked **104 Core, 473
Server, and
4 Configuration cases** covering these concerns: **581 passed, zero
failed or
skipped**. The final net10 solution build also passed with **zero
warnings and
errors**. The complete earlier Core/Server/Configuration and net48
results remain
listed separately rather than being presented as rerun current-head
acceptance.

## Earlier review follow-up

Commit `b8a59990fe2e8e967a12ffd5d9158beed7831143` addresses all 19
review
threads. Each thread has an in-thread reply naming this commit and was
explicitly
resolved after verification; the final paginated query found 19 resolved
and
zero unresolved threads.

The follow-up fixes injected certificate-store selection (including
default
provider ordering and native X509 paths), actual-RSA minimum
enforcement, and
CSR capability/subject validation before key mutation. Node-manager
teardown
uses simpler scoped ownership and drains accepted user revocation
outside the
master gate; committed user results and retired-event cleanup remain
correct
when cleanup reports failure. File mutations can recover after failed
refresh
without bypassing capacity or cancellation, provider paths protect mount
roots,
and repeated timed rejections no longer flood logs. Leadership
subscriber
failures are isolated without callbacks from `IsLeader`.

Regressions also pin the already-merged OPN/discovery buffer fixes and
prove
decoded request values do not borrow returned input buffers. The strict
inline
issuer revocation policy is documented and tested, not weakened.
Existing tests
were changed only for the reviewed behavior: the incorrect
committed-user result
was corrected, and inline-issuer/root-alias controls were expanded.

### Review-fix validation

| Suite | Scope | Framework | Passed | Existing TRX NotExecuted |
| --- | --- | --- | ---: | ---: |
| Core | Complete project | net10.0 | 4,970 | 88 |
| Server | Complete project | net10.0 | 6,414 | 69 |
| Configuration | Complete project | net10.0 | 231 | 0 |
| Core | Certificate, lease and buffer compatibility slice | net48 | 153
| 0 |
| Server | Reviewed lifecycle, user, file, timer and CSR compatibility
slice | net48 | 692 | 0 |
| Configuration | Complete project | net48 | 231 | 0 |
| CSR provider regressions | Final portable-mock follow-up | net10.0 |
11 | 0 |

All runs exited successfully with normal concurrency and leak detection.
The Server console reports 5 skips; its TRX additionally records
existing
assumption/explicit cases, hence the 69-record count above. The
Framework build
caught two modern-only `ValueTask.CompletedTask` usages in new mock
setup; only
those expressions changed to the equivalent portable
`default(ValueTask)`.
The complete modern suites precede that test-only correction; all
production
files remained byte-identical, and the corrected fixture passed on both
targets.

The final net10 solution build succeeded with 0 errors and the same 4
CA2249
warnings in unchanged fuzz-test code. Diff checks and changed-line
whitespace
checks are clean; whole-file formatting still identifies 10 unchanged
baseline
lines, which were not reformatted outside the feedback scope. The
committed
35-file tree was hash-verified against the validation inputs.

This is the review-feedback verification scope, not a rerun or sign-off
of the
historical complete platform/native/soak acceptance campaign below.


## Latest upstream merge

Merge `9d9bf679f317164c5b2b90924cd3cdc27a86d451` integrates `master`
through `162fc5ebb` (#4486),
including the AddNodes child-name index, shared certificate-error
reporting,
CTT sample-data corrections, and the accompanying regressions and
documentation.

The CreateSession conflict preserves this branch's certificate
ownership,
required certificate, application-URI, and channel-certificate checks.
The
validation result now retains nested errors through `ThrowIfInvalid`,
allowing
the upstream shared reporting helper to mask trust, policy, and
revocation
failures consistently without losing audit detail.

The new indexed AddNodes regression initially attempted to reuse a
deterministic
NodeId after renaming its existing node. Renaming frees the BrowseName,
not that
NodeId. `IndexedBrowseNameReusePreservesExistingNodeIdentityAsync` now
proves
both contracts: the derived-ID collision leaves the original node
intact, then
a fresh explicitly requested ID can reuse the old name while the new
name
remains protected against duplicates. No production identity rule was
relaxed.
The incoming seeded random-index test uses the existing `UnsecureRandom`
wrapper
with the same seed and sequence; no new analyzer suppression was added.

| Local merge validation | Framework | Passed | Existing exclusions |
| --- | --- | ---: | ---: |
| Core, complete project | net10.0 | 5,081 | 88 |
| NodeState / BaseInstanceState | net10.0 | 579 | 0 |
| Server node management, certificate and session-security selection |
net10.0 | 112 | 0 |
| InformationModel node-management services | net10.0 | 29 | 1 |
| NodeState / BaseInstanceState | net48 | 578 | 0 |
| Core certificate validation, error reporting and TCP buffers | net48 |
111 | 0 |
| Server node management, certificate and session-security selection |
net48 | 112 | 0 |

All selected runs exited successfully at normal concurrency: **6,602
passing
test executions**. Modified test assemblies were explicitly rebuilt, and
the
combined name/identity regression was verified by name in both net10 and
net48
results. No skips or weakened assertions were introduced.

The final `dotnet build UA.slnx -c Release --no-restore -m:1` with
`CustomTestTarget=net10.0` succeeded with **zero warnings and zero
errors**.
The resolved source and adjusted tests pass whitespace verification, and
their
source hashes match the validation inputs.

This is merge-specific verification. Earlier review results and the
previously
reported incomplete full Subscriptions runs below remain scoped to their
own
commits; they were not reopened or relabeled as passing by this
operation.

## Previous upstream merge (2026-09-15 morning)

Merge `c4694e20ab089c555af233642042133ff3a03ac8` integrates `master`
through `484d6c887`, retaining
#4481's shared Like parser/GDS query fixes, #4488's RequestHandle
recovery in
ServiceFaults, and #4487's expired-session and durable-transfer
corrections.

Three conflict resolutions preserve both sides:

- Alias searches now use the shared `LikePattern` implementation,
including
upstream malformed-list validation. A timeout overload retains this
branch's
100 ms per-alias deadline and `BadTimeout` reporting without keeping a
second
wildcard translator. The shared matcher's original unlimited overload
remains
unchanged for existing GDS/ContentFilter callers. New regressions cover
bounded prefix/list scans, reuse after timeout, and the alias service
result.
- Expired activation closes outside the session-manager gate using
upstream's
once-only timeout claim, audit, and diagnostic accounting. This branch's
  original-channel and security-policy checks remain in place.
- TCP buffer tests retain this branch's ownership, poisoning-pool and
post-decryption checks alongside the upstream RequestHandle/fault
overload
  tests; neither set was discarded.

The upstream HTTPS binary behavior is intentionally retained: a decoder
`ServiceResultException` produces HTTP 200 with an OPC UA ServiceFault
carrying
the decoder status, response timestamp, and recoverable RequestHandle,
rather
than the previous HTTP 500 text response. Other exceptions retain their
existing HTTP error handling.

| Local validation | Framework | Passed | TRX NotExecuted |
| --- | --- | ---: | ---: |
| Core, complete project | net10.0 | 5,054 | 88 |
| Sessions, complete project | net10.0 | 780 | 610 |
| Server aliases, expiry/security and subscriptions | net10.0 | 288 | 0
|
| GDS database, paging and live directory calls | net10.0 | 263 | 0 |
| Shared Like patterns and bounded evaluation | net10.0 | 109 | 0 |
| Subscriptions transfer/durability/service selection | net10.0 | 23 | 1
|
| HTTPS WebApi, complete project | net10.0 | 346 | 0 |
| InformationModel aliases | net10.0 | 54 | 5 |
| Shared Like patterns and bounded evaluation | net48 | 109 | 0 |
| Core fault handles, TCP/HTTPS and filters | net48 | 268 | 0 |
| Server aliases, expiry/security and subscriptions | net48 | 288 | 0 |

These selected runs exited successfully with normal test concurrency:
**7,582 passing test executions**. Sessions' console reports 379 skips;
the
TRX also contains conditional/assumption exclusions, so the table uses
the
actual 610 NotExecuted records. No skips or weakened assertions were
added.
The new `EvaluationTimeoutBoundsWorkAndPreservesPatternReuse` and
`SharedPatternTimeoutRemainsAnAliasServiceError` tests passed on both
targets.

**Validation limitation:** two unfiltered Subscriptions runs did not
produce
a final result and were stopped, including a retry with a hang guard.
They
are not counted as passing. The isolated failover fixture passed 2/2,
and the
transfer/durability/service selection above passed. The stall's cause
was not
established; no unrelated code or test was changed to conceal it.

The final `dotnet build UA.slnx -c Release --no-restore -m:1` with
`CustomTestTarget=net10.0` succeeded with **0 warnings and 0 errors**.
Whitespace verification passed for all hand-reconciled files, and staged
source hashes match the validation inputs.

These are merge-specific checks, not a new full platform/native/soak
acceptance
sign-off. Earlier review and acceptance evidence below remains dated to
its
own commit.

## Previous upstream merge (2026-09-14)

Merge `c3e09553e4d1509502d4ba751960d58491bea2cf` integrates `master`
through `6313e38a0` (#4478),
including the CTT-driven Part 13 aggregate corrections and FindAlias
argument
validation. The upstream tests and CTT documentation are retained.

The single conflict in `AliasNameWildcardMatcher` is reconciled through
the
branch's shared regex compiler: upstream malformed-pattern rejection and
`IsValidPattern` coexist with OPC UA character escaping,
whole-string/newline
matching, the 100 ms timeout, and service-level error reporting. The
upstream
ReferenceTypeFilter checks and chronological aggregate behavior are
preserved.

Local merge verification:

| Suite / selection | Framework | Passed | Existing skips |
| --- | --- | ---: | ---: |
| Server aliases, aggregates and historian | net10.0 | 1,011 | 0 |
| Server aliases, aggregates and historian | net48 | 1,011 | 0 |
| InformationModel aliases | net10.0 | 54 | 5 |
| Features aliases | net10.0 | 11 | 0 |
| Client aliases | net10.0 | 28 | 0 |
| History aggregate/historical-access consumers | net10.0 | 265 | 0 |

All selected tests passed at normal concurrency. The final net10
solution
build passed with 0 errors and 0 warnings.
The resolved file's whitespace check and the staged diff check passed.
These
are merge-specific checks, not a new full platform/native/soak
acceptance claim.

## Previous upstream merges

Integrates `master` through `8e874d073`, including the Client, Types,
Core and
source-generator audit fixes in #4471, #4473, #4474 and #4475, plus the
event queue-size revision from #4480. The merge commits are `150ea0439`
(26 conflict resolutions) and `a0e17c07c` (the base update that arrived
during
validation).

The overlapping fixes are reconciled rather than replacing either
branch:

- **Rejected-certificate retention follows this branch, as explicitly
requested:**
zero means unlimited history; negative values disable new storage while
preserving existing backend trimming behavior (Directory clears prior
history;
shared key/value leaves it intact). The processor and manager policy are
not
  redefined, and documentation/tests describe the distinction.
- Preserve owned certificate snapshots and borrowed validation-core
lifetimes
  alongside upstream cache invalidation, store-first CRL checks and
  `BadCertificateRevocationUnknown` for unsupported revocation lookup.
- Combine exactly-once chunk ownership, receive-loop cancellation,
stale-epoch
rejection and detach-before-adoption with upstream
signature-before-padding
  verification and secret cleanup.
- Preserve pooled `HttpClient` per-request configuration together with
bounded
response reads and deadlines that remain active through body
consumption.
Regression cases exercise already-used clients and both timeout
precedence cases.
- Retain the binary-compatible channel-key constructor and normalize
omitted
profiles; preserve exact profile filtering and upstream binary-first
HTTPS/WSS
  endpoint ordering.
- Preserve monotonic lease expiry and stale-confirmation fencing while
adding
ordered, non-reentrant leadership notifications outside the state lock.

## Validation

Local validation of the conflict-resolution merge:

| Suite | Framework | Passed | Existing skips |
| --- | --- | ---: | ---: |
| Core | net10.0 | 4,940 | 88 |
| Client | net10.0 | 2,278 | 0 |
| Server | net10.0 | 6,088 | 5 |
| Types | net10.0 | 10,394 | 1 |
| SourceGeneration.Core | net10.0 | 3,940 | 8 |
| SourceGeneration | net10.0 | 185 | 0 |
| Core | net48 | 4,855 | 104 |

After the subsequent four-file #4480 update, the related Server slice
passed
811 tests with 5 existing skips, the real durable queue-size cases
passed 6/6,
and EventManager's 21 cases passed again after analyzer-only
expected-array
cleanup. The final `dotnet build UA.slnx -c Release` with
`CustomTestTarget=net10.0`
succeeded: 0 errors and 4 pre-existing CA2249 warnings in unchanged
fuzz-test
code. Whitespace verification passed for the resolved files and the
follow-up.

Merge-specific proof includes
`RequestTimeoutRemainsActiveWhileReadingTheOpenApiBodyAsync` on
already-used
shared clients with either the request or HttpClient timeout shorter,
the
existing ownership/expiry regressions from both branches, and both
branches'
retention tests adjusted only to the explicitly chosen zero/negative
contract.

Earlier acceptance at `1c4a9df6c` passed hosted Linux/macOS CI, CodeQL,
image
builds and the canonical coverage gate (87.15% line, 77.27% branch,
92.14% patch).
Both Framework project matrices, all three Windows native suites (183
cases),
and the unfiltered net10 stress project (230 cases, including full
60-minute
chaos and 30-minute memory soaks) also passed. Those are **pre-merge
results**,
not a claim that the full framework/platform/native/soak matrix has been
rerun
on this merge. The previous acceptance workflow remains separate from
this
review-feedback operation.

Security checks, meaningful assertions, leak detection, default native
concurrency, full soak durations and coverage thresholds remain intact.
The user-set ready-for-review state is unchanged. No architectural or
complete
merged-head acceptance sign-off is claimed.

## Related Issues

- Fixes #4476

## Checklist

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

134 KiB

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

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

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.

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:

// 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:
- Manages RootNotifiers.
- Propagates events via SubscribeToAllEvents.
- Implements ConditionRefresh.
- 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. (The modify case preserves the behaviour of 1.5.378 and earlier, so a ModifyMonitoredItems call that leaves the sampling interval unspecified 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:

<ServerConfiguration>
  <!-- ... -->
  <MaxNotificationsPerPublish>1000</MaxNotificationsPerPublish>
  <MinSupportedSamplingInterval>2000</MinSupportedSamplingInterval>
  <!-- ... -->
</ServerConfiguration>

or with the fluent configuration builder:

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

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.

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, runtime NodeSets, and CoreNodeManager vs CustomNodeManager2.

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 in INodeManagerLifecycle.Registrations. The built-in diagnostics, configuration, and core NodeManagers are not exposed there.

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:

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

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:

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

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:

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.

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

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 awaits base.CreateAddressSpaceAsync, then builds a fluent INodeManagerBuilder, awaits ConfigureAsync(builder, ct) (the awaitable wiring seam — see below), invokes Configure(builder), awaits 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 awaits 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.
  • 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:

sequenceDiagram
    autonumber
    participant M as Generated NodeManager
    participant U as Your partial or override
    participant B as NodeManagerBuilder

    M->>M: await base.CreateAddressSpaceAsync
    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:

Annotate the user-authored partial class that should host the generated manager:

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:

[NodeManager(NamespaceUri = "http://opcfoundation.org/UA/Boiler/")]

or by file stem:

[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:

[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:

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.

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

<!-- 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:

<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:

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:

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:

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. The stack caches the result 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:

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.

Manager-level asynchronous batch hooks receive only successful items and run after the monitored-item manager has applied its changes:

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:

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.

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

Hand-written managers that drive CreateFluentBuilder themselves 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 ServiceResultExceptions.

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

[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:

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

// 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

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

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.

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.

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:

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:

<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:

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

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.

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:

// "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:

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.

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

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.

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

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:

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:

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:

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.

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.

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:

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:

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:

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:

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:

<!-- 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>
<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:

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.

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
// 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 NodeStateActivators 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:

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.

Breaking change in 2.0. The four argument FindChild and the two argument CreateChild are gone. An override written against 1.5.378 fails to compile until the parameter is added; see the migration guide.

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 and Historical Access.
  • 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 <200 lines.
  • samples/MinimalApi/MinimalCalcServer/ — a calculator server that exercises the typed methods-with-arguments 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.