Skip to content

Reimplement the GDS ApplicationsNodeManager as a generated fluent node manager - #4434

Merged
marcschier merged 17 commits into
masterfrom
romanett/applications-node-manager-fluent-8c0aa7
Sep 9, 2026
Merged

marcschier merged 17 commits into
masterfrom
romanett/applications-node-manager-fluent-8c0aa7

Conversation

@romanett

@romanett romanett commented Sep 6, 2026 •

Copy link
Copy Markdown
Contributor

Description

Builds on #4428, which is now merged; this branch is rebased onto master.

The GDS ApplicationsNodeManager becomes a source-generated [NodeManager]: the companion model, its loader and the fluent plumbing are emitted, and what stays hand-written is the behaviour. Getting there needed three things from the shared surface, which are the first two commits.

Commits

1. Add the fluent hooks a hand-written manager needs at startup

  • OnReadRolePermissions / OnReadUserRolePermissions on the node builder. Every other NodeState On* hook was already fluent; these were not, and a server that grants access on something the static model cannot express has to compute the permission set per request.
  • Node<TState>(TState) returns a builder over a node the caller already holds, for nodes materialised after Configure resolved its graph.
  • OnAddressSpaceReadyAsync on FluentNodeManagerBase — the one seam between the address space existing and the wiring being applied, for managers whose wiring depends on asynchronous setup. Configure is a partial void and cannot await. DiNodeManager had already invented the hook privately, so its declaration becomes an override.

2. Let a [NodeManager] bind to a model a referenced assembly supplies

A model belongs to the assembly that emits its types, which leaves a node manager for it homeless whenever that assembly cannot reference Opc.Ua.Server — a model-only package shared with clients, for instance. Binding was rejected twice over: the design was skipped before the binding was matched (MODELGEN010), and fluent-accessors-only mode refused to run alongside manager generation (MODELGEN014). The binding is now matched before the skip decision, and a matched binding puts that design into accessors-only mode. A design nothing binds to is still skipped, so this cannot start duplicating types by accident.

The emitted manager also gains what a real manager needs: DefaultNamespaceUris() plus a protected constructor taking a replacement set; GenerateDefaultConstructor=false to suppress the two-argument form; its own logger category; and the call to OnAddressSpaceReadyAsync.

Generated fluent accessors also stop re-enabling nullable warnings — a bare #nullable enable undid the shared header's disable warnings, so any model with a structure-typed method argument reported CS8600 on every generated out T in a project that builds warnings as errors.

3. Reimplement the GDS ApplicationsNodeManager as a generated node manager

The design stays owned by Opc.Ua.Gds.Common; the server assembly only binds a manager to it.

NodeIds do not move. NamespaceIndexes[0] must stay the application record namespace — it is the one the application database, the certificate request store and the base allocator mint ids in — so the constructor passes the namespace order explicitly rather than adopting the generated model-first default. A test pins both ends against a live server.

Four overrides go, leaving OnAddressSpaceReadyAsync and Dispose(bool) — no address-space overrides at all:

Removed Why it was unnecessary
New The base allocator already mints into NamespaceIndexes[0], with a thread-safe counter. The override existed only because Create(assignNodeIds: true) asks the factory with the declaration id still set; AssignInstanceNodeId nulls it and re-asks, documented as being there for exactly this allocator.
AddBehaviourToPredefinedNodeAsync Its AuthorizationServiceType branch re-wired a node that was already wired, and its KeyCredentialServiceType branch had nothing to wire — the model ships that folder empty.
GetManagerHandleAsync, ValidateNodeAsync Verbatim reimplementations of the base that also blocked the fluent base's virtual-node support.
DeleteAddressSpaceAsync A // TBD passthrough.

Everything else — all sixteen directory methods with their self-administration permissions, the certificate groups' concrete types and trust-list flags, and the authorization service (created through this PR's Add surface and wired in the same place) — is one Configure pass.

4. Bind the certificate groups from the Configure pass

Acquiring a certificate group and binding it to its nodes are different kinds of work: the first is I/O (InitAsync opens certificate stores and creates CA certificates), the second only touches the address space. Each now runs in the phase it belongs to, which puts the whole of the wiring in one place.

Ownership no longer rides on the binding. A group joins m_ownedCertificateGroups the moment it is constructed, before InitAsync can throw, so a group that fails half way through is still disposed — which the previous code could not manage, keying ownership off a dictionary it filled only after initialization succeeded.

Commit 3's message says this move is impossible and blames PushTest. That was a wrong diagnosis, and this commit supersedes it. Two real defects were hiding behind those failures:

  • Create(..., NodeId.Null, ..., assignNodeIds: false) does not null the NodeId. NodeState.Create overrides it only when handed a non-null one, so a custom group node kept the namespace-0 id of its type declaration. AssignInstanceNodeId hid this by re-asking the factory with the id nulled; the builder validates instead, and rejected it. The node is now created with an id in the manager's own namespace, as the Default authorization service already was.
  • The custom certificate group node was not browseable. It answered GetCertificateGroups and ReadNode, but a type-filtered recursive browse from the Objects folder never returned it — which OPC 10000-12 §7.8.2 requires of a certificate group. Staging the node through the builder gives it the reference it was missing.

Two PushTest methods change — please review these closely

The second defect had been masking what those tests actually did. Selecting the group positionally, groups[3] resolved to the server's own ServerConfiguration group while the custom one stayed invisible, so UpdateTrustListOfCustomGroupAsync and AddRemoveCertOfCustomGroupAsync were exercising a transactional trust list under a custom-group name.

Browse results, before and after:

# Before (baseline) After
0-2 GDS Directory groups (ns=3) GDS Directory groups (ns=3)
3 DefaultApplicationGroup i=14156 — the server's own MyCustomGroup ns=2
4 — DefaultApplicationGroup i=14156

They now resolve the group by browse name — which asserts it is browseable at all — and expect the immediate-apply semantics the GDS actually gives the groups of its own Directory: CloseAndUpdate writes the stores at once and reports applyChangesRequired == false, so the ApplyChanges calls go away (they would return Bad_NothingToDo). The read-back assertions are what prove the writes landed, and they are unchanged.

This is a genuine loss of the transactional trust-list coverage those two methods were accidentally providing — the server's own group is still covered by the other PushTest methods, which target it deliberately.

5. Select GetCertificates results by type instead of by position

GetCertificatesAsync checked entry zero of a parallel-array result. Which entry that is depends on the order the server reports its certificate types in, an empty result throws IndexOutOfRange rather than failing an assertion, and a second type going missing or unparseable would not be noticed. Every returned pair is now checked, and a failure names the certificate type.

6. Drop the .Common suffix from the three GDS projects

Every other companion specification ships as <Family> / <Family>.Client / <Family>.Server — Di, ISA95, Positioning, Robotics, Vision, WotCon, XRegistry. GDS was the exception, with a .Common suffix that named nothing.

Before After
src/Opc.Ua.Gds.Common src/Opc.Ua.Gds
src/Opc.Ua.Gds.Client.Common src/Opc.Ua.Gds.Client
src/Opc.Ua.Gds.Server.Common src/Opc.Ua.Gds.Server

No source changes were needed. RootNamespace was already the final name in all three projects — the namespaces have always been Opc.Ua.Gds, Opc.Ua.Gds.Client and Opc.Ua.Gds.Server, and only the assembly and package ids disagreed. Nothing compiling against these assemblies changes a using.

The package ids do change, which is consumer-visible, so docs/migrate/2.0.x/packages.md gains a renamed-packages section — it is the document that undertakes to cover NuGet renames — and the migration skill's package table gains the two rows a 1.5.378 consumer needs.

Build infrastructure that names the projects or their output moves with them: both solution files, expected-packages.txt (re-sorted — it is maintained alphabetically), both signing manifests, the reference-server Dockerfile, the metapackage nuspecs, and the analyzer's package dependencies. preview-pack.slnx is derived from UA.slnx and needs no edit. The migration analyzer's shim tree mirrors the source layout by its own documented convention, so Gds.Client.Common/ becomes Gds.Client/.

Prose naming past work is left alone — plan 23 still records that its deferral happened during the "GDS Client.Common modernization", which is the name that effort had.

Validation

net10.0, on master + these four commits, from a clean GDS PKI:

  • UA.slnx: 0 errors, 0 warnings in changed files (the solution's 30 pre-existing warning lines are CA1861/CA1850/CA1307 in XRegistry, Types.Tests and WotCon.Tests — same rules and projects as the base).
  • Opc.Ua.Gds.Tests: 1,117 passed, 0 failed, 54 skipped — identical to the base branch.
  • Opc.Ua.Server.Tests: 4,961 passed, 0 failed, 5 skipped (measured before commit 4, which touches only the GDS assembly and its tests).
  • Opc.Ua.SourceGeneration.Tests: 169 passed.
  • Opc.Ua.SourceGeneration.Core.Tests, NodeManager/Generators/Fluent: 84 passed; GenerateCode: 21 passed, 4 skipped.
  • Opc.Ua.MigrationAnalyzer.Core.Tests: 10 passed, 3 skipped.
  • After the rename: assemblies build as Opc.Ua.Gds.dll / .Client.dll / .Server.dll, with no *.Common.dll produced anywhere.
  • Commits 1-3 build standalone.
  • Opc.Ua.Gds.Server.Common also builds for net48 and netstandard2.1 (checked before the rebase onto Add node creation to the fluent node manager builder #4428).

Not yet run: the full Opc.Ua.SourceGeneration.Core.Tests suite (~3,800), and the older target frameworks since the rebase.

Checklist

  • Existing tests pass
  • New tests cover the new surface (role permissions, resolved-node builder, referenced-model binding, constructor/namespace control, GDS namespace invariant, custom-group node namespace)
  • Full Core.Tests sweep and net48/netstandard2.1 re-run after the rebase

🤖 Generated with Claude Code

Base automatically changed from romanett/fluent-node-creation to master September 7, 2026 05:53
@romanett

romanett commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

This needs the NodeID Factory work, to select the right namespace without adding the custom constructor logic

romanett and others added 4 commits September 7, 2026 13:26
Three gaps the GDS conversion ran into, each usable on its own:

- OnReadRolePermissions / OnReadUserRolePermissions on the node builder.
  Every other NodeState On* hook was already fluent; these were not, and
  a server that grants access on something the static model cannot
  express has to compute the permission set per request.
- Node<TState>(TState) returns a builder over a node the caller already
  holds, for nodes materialised after Configure resolved its graph.
- OnAddressSpaceReadyAsync on FluentNodeManagerBase: the one seam
  between the address space existing and the wiring being applied, for
  managers whose wiring depends on asynchronous setup. Configure is a
  partial void and cannot await it. DiNodeManager had already invented
  the hook privately, so its declaration becomes an override.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A model belongs to the assembly that emits its types, which leaves a
node manager for it homeless whenever that assembly cannot reference
Opc.Ua.Server — a model-only package shared with clients, for instance.
Binding was rejected twice over: the design was skipped before the
binding was matched (MODELGEN010), and fluent-accessors-only mode
refused to run alongside manager generation (MODELGEN014).

The binding is now matched before the skip decision, and a matched
binding puts that design into accessors-only mode: the manager and its
fluent surface are emitted locally while the model types keep coming
from the reference. A design nothing binds to is still skipped, so this
cannot start duplicating types by accident.

The project-wide ModelSourceGeneratorGenerateNodeManager switch stays
incompatible with accessors-only mode: it would emit conventionally
named managers into the referenced model's own C# namespace for every
design. Its message now names the attribute as the supported route.

The emitted manager also gains what a real manager needs:

- DefaultNamespaceUris() plus a protected constructor taking a
  replacement set, so a manager can take collaborators the generated
  signature does not carry, or change which namespaces it owns and in
  what order. Entry 0 becomes the manager's NamespaceIndex and is baked
  into every NodeId it mints, so the order is part of its contract.
- GenerateDefaultConstructor=false to suppress the two-argument form,
  so a manager with dependencies cannot be built half-initialized.
- Its own logger category rather than the shared base class one.
- A call to OnAddressSpaceReadyAsync between loading the model and
  Configure.

Generated fluent accessors also stop re-enabling nullable warnings: a
bare '#nullable enable' undid the shared header's 'disable warnings', so
any model with a structure-typed method argument reported CS8600 on
every generated 'out T' in a project that builds warnings as errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The companion model, its loader and the fluent plumbing now come from a
[NodeManager] attribute. The design stays owned by Opc.Ua.Gds.Common,
which emits the model types; this assembly only binds a manager to it.
What is written by hand is the behaviour.

Node ids do not move. NamespaceIndexes[0] must stay the application
record namespace — it is the one the application database, the
certificate request store and the base class's allocator mint ids in —
so the constructor passes the namespace order explicitly rather than
adopting the generated model-first default. A test pins both ends
against a live server.

Four overrides go:

- New: the base allocator already mints into NamespaceIndexes[0], with
  a thread-safe counter. The override only existed because
  Create(assignNodeIds: true) asks the factory with the declaration id
  still set; AssignInstanceNodeId nulls it and re-asks, which is
  documented as being there for exactly this allocator.
- AddBehaviourToPredefinedNodeAsync: its AuthorizationServiceType branch
  re-wired a node CreateDefaultAuthorizationService had already wired,
  and its KeyCredentialServiceType branch had nothing to wire — the
  model ships that folder empty. Host-contributed services now call
  ConfigureAuthorizationService / ConfigureKeyCredentialService.
- GetManagerHandleAsync and ValidateNodeAsync: verbatim reimplementations
  of the base that also blocked the fluent base's virtual-node support.
- DeleteAddressSpaceAsync: a TBD passthrough.

What remains is OnAddressSpaceReadyAsync, the fluent async seam, and
Dispose(bool). Everything else — all sixteen directory methods with
their self-administration permissions, the certificate groups' concrete
types and trust-list flags, and the authorization service — is one
Configure pass.

Certificate group acquisition stays in the async hook. Moving the
group-to-node binding into Configure split a group's acquisition from
its ownership: InitAsync opens certificate stores, so a server failing
between the phases leaked them, and PushTest failed deterministically.
The call site records this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Acquiring a certificate group and binding it to its nodes are different
kinds of work. The first is I/O: InitAsync opens certificate stores and
creates CA certificates. The second only touches the address space. Each
now happens in the phase it belongs to, so OnAddressSpaceReadyAsync
brings the authorities up and Configure binds them, and the whole of the
wiring is finally in one place.

Ownership no longer rides on the binding. A group joins
m_ownedCertificateGroups the moment it is constructed, before InitAsync
can throw, so a group that fails half way through is still disposed --
which the previous code could not manage, keying ownership off a
dictionary it filled only after initialization succeeded.
m_certificateGroups still indexes the groups by node id, filled during
binding because that is the earliest point a group knows the id it is
addressed by.

An earlier attempt at this move read the resulting PushTest failures as
proof that acquisition, binding and ownership cannot be separated. That
was a guess, and it was wrong. Two real defects were behind it:

- Create(..., NodeId.Null, ..., assignNodeIds: false) does not null the
  NodeId. NodeState.Create overrides it only when handed a non-null one,
  so a custom group node kept the namespace-0 id of its type
  declaration. AssignInstanceNodeId hid this by re-asking the factory
  with the id nulled; the builder validates instead, and rejected it.
  The node is now created with an id in the manager's own namespace,
  as the Default authorization service already was.

- The custom group node was not browseable. It answered
  GetCertificateGroups and ReadNode, but a type-filtered recursive
  browse from the Objects folder never returned it, which OPC 10000-12
  7.8.2 requires of a certificate group. Staging the node through the
  builder gives it the reference it was missing.

That second defect is why two PushTests change here. Picking the group
positionally, groups[3] resolved to the server's own ServerConfiguration
group while the custom one stayed invisible, so
UpdateTrustListOfCustomGroupAsync and AddRemoveCertOfCustomGroupAsync
were exercising a transactional trust list under a custom-group name.
They now resolve the group by browse name -- which asserts it is
browseable at all -- and expect the immediate-apply semantics the GDS
actually gives the groups of its own Directory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@romanett
romanett force-pushed the romanett/applications-node-manager-fluent-8c0aa7 branch from 84b019e to de97409 Compare September 7, 2026 11:27
@github-actions

github-actions Bot commented Sep 7, 2026 •

Copy link
Copy Markdown

Code coverage

✅ Coverage gate passed.

Check Result Threshold
✅ Project line rate 86.82% (257538/296634 lines) >= 70.00%
✅ Project branch rate 76.77% >= 60.00%
✅ Patch coverage 95.41% (395/414 changed lines) >= 75.00% (> 100 changed lines)
ℹ️ Baseline delta (advisory) +13.22 pp 73.60% recorded
Uncovered changed lines
  • src/Opc.Ua.Gds.Server/ApplicationsNodeManager.cs: 251, 438, 439, 440, 441, 442, 443, 447, 448, 449, 450, 451, 452, 534, 575, 589, 614
  • tools/Opc.Ua.SourceGeneration.Core/Generators.cs: 625, 634

Coverage is above the recorded baseline - consider ratcheting coverage-thresholds.json.

Thresholds live in coverage-thresholds.json. Whole report before exclusions: line 85.97%, branch 75.96%.

GetCertificates returns parallel arrays -- one certificate per
certificate type the group is configured for (OPC 10000-12 7.8.4) --
and the test checked entry zero. Which entry that is depends on the
order the server reports its types in, an empty result throws
IndexOutOfRange instead of failing an assertion, and a second type
going missing or unparseable would not be noticed at all.

Every returned pair is now checked, and a failure names the certificate
type rather than an index.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@romanett
romanett force-pushed the romanett/applications-node-manager-fluent-8c0aa7 branch from c05b7f9 to f50c11b Compare September 7, 2026 12:48
@codecov

codecov Bot commented Sep 7, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.88889% with 46 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.88%. Comparing base (556c9cb) to head (cd1b3f4).

Files with missing lines Patch % Lines
src/Opc.Ua.Gds.Server/ApplicationsNodeManager.cs 84.68% 17 Missing and 15 partials ⚠️
tools/Opc.Ua.SourceGeneration.Core/Generators.cs 85.33% 2 Missing and 9 partials ⚠️
....Ua.Server/Fluent/ResolvedNodeBuilderExtensions.cs 85.71% 0 Missing and 1 partial ⚠️
...ceGeneration.Core/Generators/NodeStateGenerator.cs 75.00% 0 Missing and 1 partial ⚠️
....SourceGeneration/NodeManagerAttributeDiscovery.cs 90.00% 0 Missing and 1 partial ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##           master    #4434      +/-   ##
==========================================
+ Coverage   80.83%   80.88%   +0.04%     
==========================================
  Files        2032     2034       +2     
  Lines      296559   296634      +75     
  Branches    51186    51176      -10     
==========================================
+ Hits       239738   239920     +182     
+ Misses      39048    38941     -107     
  Partials    17773    17773              
Flag Coverage Δ
actions 80.88% <88.88%> (+0.04%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
...a.Server/Fluent/RolePermissionBuilderExtensions.cs 100.00% <100.00%> (ø)
tools/Opc.Ua.SourceGeneration.Core/DesignFile.cs 92.04% <ø> (ø)
...neration.Core/Generators/FluentBuilderTemplates.cs 100.00% <100.00%> (ø)
...Generation.Core/Generators/NodeManagerGenerator.cs 98.61% <100.00%> (+0.08%) ⬆️
...Generation.Core/Generators/NodeManagerTemplates.cs 100.00% <100.00%> (ø)
.../Opc.Ua.SourceGeneration.Core/Templating/Tokens.cs 99.53% <100.00%> (+<0.01%) ⬆️
tools/Opc.Ua.SourceGeneration/ModelCompilation.cs 89.96% <100.00%> (+0.07%) ⬆️
....Ua.Server/Fluent/ResolvedNodeBuilderExtensions.cs 85.71% <85.71%> (ø)
...ceGeneration.Core/Generators/NodeStateGenerator.cs 87.72% <75.00%> (-0.02%) ⬇️
....SourceGeneration/NodeManagerAttributeDiscovery.cs 86.60% <90.00%> (+1.56%) ⬆️
... and 2 more

... and 34 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

romanett and others added 2 commits September 7, 2026 20:41
Merging the .Common rename (#4438) left this project's AdditionalFiles
entries naming ..\Opc.Ua.Gds.Common\Design, a directory the rename had
just removed. Git could not see the conflict: those lines are additions
from this branch, so there was nothing for it to mark, and the merge
resolved cleanly onto a broken path.

The design now resolves at ..\Opc.Ua.Gds\Design, and the three comments
that still named the old project follow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three changes landed on master that meet this branch. Two needed more
than a textual resolution.

NodeManagerGeneratorTests: #4432 rewrote the ordering assertions this
branch had extended, and both sides declared idxConfigure. The chain now
asserts base -> OnAddressSpaceReadyAsync -> Configure ->
RegisterAuthoredNodes -> CompleteConfigure -> SealConfiguration, so
master's new guarantees and this branch's async-seam guarantee both
still hold.

The generated manager now reaches for the model's NodeSet import factory
provider (#4432). Normally NodeStateGenerator emits that beside the
manager, but a manager bound to a model a referenced assembly supplies
lives in a different assembly from the model -- and the model-only
assembly cannot emit the provider at all, because it implements an
Opc.Ua.Server contract such an assembly deliberately does not reference.
The manager was left naming a type that could never exist.
NodeStateGenerator gains ImportSupportOnly so the provider is emitted
into the assembly that does reference Opc.Ua.Server, under the model's
namespace, which keeps the import feature working for these managers
rather than switching it off for them.

#4432 also added a sealed node manager, and the namespace-taking
constructor this branch emits then trips CS0628 -- an error under
warnings as errors. The generator cannot see whether the user's partial
is sealed, so the emitted file suppresses it: in a sealed manager that
constructor is unreachable, not wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
marcschier pushed a commit that referenced this pull request Sep 8, 2026
…4442)

# Description

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

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

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

## What changed

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

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

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

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

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

## Workarounds removed

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

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

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

## Generator

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

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

### Merging with #4432

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

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

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

## Consumers unblocked

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

## Tests

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

## Related Issues

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

## Checklist

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

### What was actually run locally

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

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

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
romanett and others added 6 commits September 8, 2026 14:39
#4442 adds ConfigureAsync(INodeManagerBuilder, CancellationToken) to
FluentNodeManagerBase -- the awaitable wiring seam this branch had
introduced as OnAddressSpaceReadyAsync, but with the builder handed over
as well, and reasoned from the same constraint: a partial method can be
optional or awaitable, not both. DiNodeManager, which had invented the
hook privately and which this branch had turned into an override, has
already moved onto it upstream.

Shipping both would leave two overlapping async seams on one base class,
so OnAddressSpaceReadyAsync goes: from the base class, the generated
CreateAddressSpace sequence, the generator's ordering test, the GDS
manager (now an override of ConfigureAsync, which ignores the builder
because its work is I/O rather than address space), and docs, where
master's account is fuller than the section this branch added.

#4442 also replaces Seal() with SealAsync(cancellationToken).
ConfigureKeyCredentialService is host-facing and had to follow, so it
becomes ConfigureKeyCredentialServiceAsync; its caller and cref move
with it, along with one test and a doc example.

The net effect is a smaller branch: it no longer adds an API the stack
now provides.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ConfigureAsync can await and carries the builder, so the two-pass shape
this manager had is no longer forced by anything.

The partial void Configure -> OnConfigure indirection existed only
because a partial method cannot be virtual and cannot await; both hooks
collapse into the ConfigureAsync override, which was already protected
virtual. Subclasses override that instead, the same seam the rest of the
stack uses.

Acquiring a certificate group and binding it to its nodes were split
across the two passes, so the groups were walked twice with
m_ownedCertificateGroups carrying the hand-off. They are now acquired
and bound in one step per group, which is what gives m_certificateGroups
its key before anything looks a group up, and leaves
ConfigureCertificateGroups doing only what its name says. The 60-line
certificate type dictionary moves out of the startup path into
CreateCertificateTypeMap.

An earlier attempt at joining those phases failed and was read as proof
that they could not be joined. That was wrong twice over: the failure
was the namespace-0 declaration id, and the phases were only ever
separated by the sync/async boundary that has now gone.

Also fixes the fluent example in StateMachines.md, which had
`protected override void OnConfigure(INodeManagerBuilder)` on a
FluentNodeManagerBase subclass. No base class has ever declared
OnConfigure, so that example did not compile; it now shows the
[NodeManager] partial with `partial void Configure`, as NodeManagers.md
and HistoricalAccess.md do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#4433 routes runtime NodeId minting through DefaultNodeIdFactory and
drops SystemContext.NodeIdFactory = this from generated managers, so a
manager now selects a mode instead of overriding New.

It also edited the ApplicationsNodeManager this branch has rewritten, so
git produced four conflict regions. Three are this branch's rewrite
against nothing, or against the very overrides it removed --
DeleteAddressSpaceAsync and GetManagerHandleAsync, kept out rather than
resurrected by a reflexive "keep both sides".

The fourth carries real intent and is taken: the GDS mints Counter
identifiers. Applications, certificate groups and trust lists are
registered and unregistered under repeating names, and Counter is the
only mode that stays unique when a browse path repeats; every other mode
derives the identifier from the path and would collide across a remove
and re-add.

Master's companion NamespaceUris assignment is not taken. That is how
the hand-written manager declared its namespaces; the generated one
carries the same order through its constructor chain already.

The namespace half of the NodeId contract still holds: NodeIdFactory
rebases an assigned factory onto the manager's own namespace, so ids
still land in the application record namespace, which the invariant test
pins against a live server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The manager reordered its own namespaces so that NamespaceIndexes[0]
was the application-record one, because that is where the old
CustomNodeManager2.New() minted. #4433 moved the minting namespace onto
the factory, so the reordering and the two accessors that worked around
it can go:

- the constructor takes the generated namespace order (the companion
  model first) and points the factory at the application-record
  namespace instead, as DiNodeManager already does for its instance
  namespace;
- GdsNamespaceIndex is gone, since the model namespace is the manager's
  own again; ApplicationsNamespaceIndex names the other one and is
  resolved through the namespace table rather than a fixed position;
- the custom certificate group root goes in with a null NodeId again.
  It only carried a hand-built one because
  PrepareAuthoredNodeIdsForRegistration skipped the root, which #4433
  fixed with HasStandardDeclarationNodeId.

The two GDS node manager factories now advertise
ApplicationsNodeManager.DefaultNamespaceUris() so their namespace list
cannot drift from the manager's.

The two namespaces swap index in the server's namespace table. Nothing
in the stack keys off the numbers - the assertions resolve URIs and
browse names - but a client that cached indexes across an upgrade sees
it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The audit that prompted this found nothing dead - every fluent and
generator addition on this branch still has a live consumer - but three
places still told readers to reorder a manager's namespaces so that
NamespaceIndexes[0] is where its ids are minted. #4433 moved that
decision onto the NodeId factory, and this branch's own manager stopped
doing it, so the guidance now teaches a workaround that its only example
no longer follows.

DefaultNamespaceUris() is emitted into every generated manager, so its
summary is the widest-read of the three: it now says the order picks the
namespace an unqualified browse path resolves in, and says the factory
owns the minting namespace. The attribute's remarks and the
NodeManagers.md section carry the same correction, the latter with a
worked NodeIdFactory.WithDefaultNamespaceIndex call in place of the
reordered array and a pointer to NodeIdAssignment.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@romanett
romanett marked this pull request as ready for review September 8, 2026 19:46
Copilot AI lite review requested due to automatic review settings September 8, 2026 19:46

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

docs/NodeManagers.md shows builder.Seal() even though the API is SealAsync, which is misleading and should be corrected.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR advances the move to source-generated fluent node managers by extending the generator/binding surface (including referenced-model bindings and constructor control) and then applying it to reimplement the GDS ApplicationsNodeManager with behavior remaining hand-written but wiring shifting to a generated fluent pipeline.

Changes:

  • Extend node-manager source generation to support referenced-model bindings, accessors-only composition, and optional emission of the public default constructor.
  • Add fluent wiring entry points for role-permissions read hooks and for wrapping already-materialized NodeState instances.
  • Rework GDS tests and fixtures to align with the new GDS node-manager wiring and more robust certificate-group/certificate assertions.
File summaries
File Description
tools/Opc.Ua.SourceGeneration/NodeManagerAttributeDiscovery.cs Adds attribute binding support for GenerateDefaultConstructor and refactors bool-flag parsing.
tools/Opc.Ua.SourceGeneration/ModelCompilation.cs Improves diagnostics guidance around accessors-only + node-manager generation.
tools/Opc.Ua.SourceGeneration.Core/Templating/Tokens.cs Adds token for conditional default-constructor emission.
tools/Opc.Ua.SourceGeneration.Core/NodeManagerAttributeBinding.cs Introduces GenerateDefaultConstructor binding option.
tools/Opc.Ua.SourceGeneration.Core/Generators/NodeStateGenerator.cs Adds “import-support-only” emission mode for referenced-model scenarios.
tools/Opc.Ua.SourceGeneration.Core/Generators/NodeManagerTemplates.cs Updates generated manager template: namespace-set API, protected constructor, logger category, conditional default constructor.
tools/Opc.Ua.SourceGeneration.Core/Generators/NodeManagerGenerator.cs Plumbs EmitDefaultConstructor into node-manager generation.
tools/Opc.Ua.SourceGeneration.Core/Generators/FluentBuilderTemplates.cs Adjusts nullable directive in generated fluent builders to avoid re-enabling warnings.
tools/Opc.Ua.SourceGeneration.Core/Generators.cs Enables binding a manager to a referenced model by switching to accessors-only emission for that design.
tools/Opc.Ua.SourceGeneration.Core/DesignFile.cs Adds per-design option to suppress the generated public default constructor.
tests/Opc.Ua.SourceGeneration.Tests/ModelGeneratorTests.cs Updates assertions for new constructor/namespace plumbing and adds coverage for suppressing default constructor.
tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/NodeManagerGeneratorTests.cs Adds tests for new namespace-set API, logging category, and default-constructor suppression.
tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/NodeManagerBindingToReferencedModelTests.cs New tests covering node-manager binding to models provided by referenced assemblies.
tests/Opc.Ua.Server.Tests/Fluent/RolePermissionBuilderExtensionsTests.cs New tests for fluent role-permission read-hook wiring.
tests/Opc.Ua.Server.Tests/Fluent/ResolvedNodeBuilderExtensionsTests.cs New tests for fluent wiring over already-resolved nodes (Node(TState node)).
tests/Opc.Ua.Gds.Tests/PushTest.cs Fixes custom-group selection by browse name, removes invalid ApplyChanges calls for non-transactional groups, improves certificate assertions.
tests/Opc.Ua.Gds.Tests/CustomCertificateGroupIntegrationTest.cs Pins custom certificate group node namespace invariant.
tests/Opc.Ua.Gds.Tests/AuthorizationService/RefreshTokenTests.cs Uses manager-provided namespace URI list to avoid drift.
tests/Opc.Ua.Gds.Tests/ApplicationsNodeManagerKeyCredentialTests.cs Updates namespace-index usage and key-credential service wiring flow to match new manager behavior.
tests/Opc.Ua.Gds.Tests/ApplicationsNodeManagerCoverageTests.cs Adds a test pinning the application vs model namespace mapping.
src/Opc.Ua.Server/Fluent/RolePermissionBuilderExtensions.cs Adds fluent extensions for OnReadRolePermissions / OnReadUserRolePermissions.
src/Opc.Ua.Server/Fluent/ResolvedNodeBuilderExtensions.cs Adds fluent Node<TState>(TState node) overload for runtime-materialized nodes.
src/Opc.Ua.Server/Fluent/NodeManagerAttribute.cs Adds GenerateDefaultConstructor to the [NodeManager] attribute surface.
src/Opc.Ua.Gds.Server/Opc.Ua.Gds.Server.csproj Enables model source-generation inputs for binding to the design from a referenced assembly.
src/Opc.Ua.Gds.Server/ApplicationsNodeManager.cs Converts GDS ApplicationsNodeManager to a generated [NodeManager] + fluent wiring approach; behavior remains hand-written.
samples/Reference/ConsoleReferenceServer/GdsNodeManagerFactory.cs Uses manager namespace list to avoid drift between factory and manager.
docs/StateMachines.md Updates example to match generated node-manager pattern ([NodeManager] + partial void Configure).
docs/NodeManagers.md Documents constructor/namespace override patterns, referenced-model binding, and new addressing/wiring hooks.
Review details
  • Files reviewed: 28/28 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread docs/NodeManagers.md Outdated
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
@romanett romanett added the ready Ready to merge once CI Passes label Sep 9, 2026
@marcschier
marcschier merged commit ececea1 into master Sep 9, 2026
270 checks passed
marcschier pushed a commit that referenced this pull request Sep 9, 2026
…4441)

# Description

The fluent builder resolves exactly **one node per call**.
`NodeFromTypeId` is exact-type-only and throws `BadBrowseNameDuplicated`
the moment two instances match, and handlers land in single-occupant
`NodeState` slots — a second handler for an occupied slot is an error by
design.

So the fluent surface cannot express four things:

- **fan-out** over the instances of a type,
- **subtype matching**, so a registration for `AnalogItemType` also
covers a user's derived type,
- **composition** of two behaviors on one node,
- **teardown** for a behavior that *owns* something — a semaphore, a
file handle, a store, a timer.

Three hand-rolled registries already work around exactly this —
`EventSourceRegistry` (730 lines), `MonitoredSourceRegistry` (998),
`SimulationRegistry` (328) — each with its own register → activate →
reconcile → deactivate → dispose loop and its own failure policy,
written three times because the slot model had no lifecycle to hang them
on.

This PR adds that lifecycle and moves the registries onto it.

## The new surface

Ten public members. A registration returns an `IAsyncDisposable` that
the node manager owns:

```csharp
builder.AttachToType<AlarmConditionState>(
    ObjectTypeIds.AlarmConditionType,
    async (alarm, ctx, ct) =>
    {
        MySubscription sub = await Source.SubscribeAsync(alarm, ctx.Lifetime).ConfigureAwait(false);
        return sub;                       // released on teardown, in reverse order
    });
```

- `AttachToType<TState>(NodeId, attach, NodeAttachOptions?)` — one
behavior per matching instance, subtypes included by default.
- `Attach<TState>(attach)` on a node builder — one behavior on an
already-resolved node.
- `Attach(attach)` on the manager builder — manager-scoped, owns no
node.
- `AcquireWhileMonitored<TValue>(acquire)` — acquire on the first
subscriber, release on the last **and at shutdown**.
- `INodeAttachContext`, `NodeAttachOptions`, and `protected
ActivateNodeBehaviorsAsync` on `FluentNodeManagerBase`.

**Guarantees.** Behaviors activate deepest node first, and on one node
from the base-registered behavior to the derived-registered one;
manager-scoped behaviors run last, so they can drive what the node
behaviors prepared. Any failure — including cancellation — unwinds every
lease recorded so far in **exact reverse order**, and aggregates cleanup
failures *with* the original rather than replacing it.

**The engine stays internal.** Its vocabulary is not worth freezing into
public API, and `INodeAttachContext` + `IAsyncDisposable` is the whole
contract a caller needs.

Two deliberate decisions worth a reviewer's attention:

- **Method nodes are excluded from type matching.** `MethodState`
aliases `MethodDeclarationId` onto `TypeDefinitionId`, so without an
explicit filter every method node would match a registration for its
declaration — a surprise nobody asked for. Method behavior stays on the
call handler. Pinned by a test.
- **A CLR type mismatch throws `BadTypeMismatch`**, naming node,
expected and actual type, rather than silently skipping the instance. A
whole set of alarms quietly getting no behavior is worse than a startup
failure.

`Dispose(bool)` is **signal-only**. Real release belongs in
`DeleteAddressSpaceAsync`, which `MasterNodeManager.ShutdownAsync`
invokes before it disposes managers on every production path — including
the synchronous one, where `StandardServer.Dispose` blocks on
`DisposeAsyncCore` → `StopAsync` → `ShutdownAsync` first. Blocking in
`Dispose(bool)` would sit under a sync-over-async dispose and
`MasterNodeManager`'s semaphore `Wait`.

## The registry migrations

**`SimulationRegistry` — release moved, plus two latent bugs.**
`Dispose` cancelled the loops then blocked up to five seconds draining
them, from a synchronous dispose. Release now runs on the async teardown
path and `Dispose` only trips the token. Loops are driven from the
server `TimeProvider` instead of `PeriodicTimer`/`Stopwatch`, so they
are testable on a fake clock rather than with sleeps. Fixed alongside:
the running loop enumerated the handler list **by reference** while a
late `OnTick` could still mutate it (handlers are now snapshotted, and a
late `OnTick` is rejected rather than silently dropped); and a handler
throwing `OperationCanceledException` for its own reasons killed the
whole loop in silence.

**`EventSourceRegistry` — ownership only; the reconcile loop stays.** It
cannot collapse: `OnSubscribeToEventsAsync` runs while the
monitored-item semaphore is held and `AddRootNotifierAsync` re-enters
`SubscribeToEventsAsync`, so awaiting anything lease-shaped from that
hook would self-deadlock. What moves is ownership of the two side
effects nothing reversed — the `EventNotifier` bit ORed into the
notifier, and the root-notifier registration. A node that *already*
carried the flag is not ours to clear, so only what registration
actually changed is recorded and undone.

**`MonitoredSourceRegistry` — ownership only, and the real leak.**
Rerouting teardown to `DisposeAsync` turned out to be necessary but
**not sufficient**: that method only clears the item map, cancels the
worker and disposes the lock, while the last-subscriber handler runs
*solely* from the reconcile path's `Deactivate` arm — which server
shutdown never reaches, because subscriptions dispose their items
locally without deleting them. Anything acquired on the first subscriber
therefore outlived the server. `ReleaseAsync` now runs those handlers
for still-active sources, and `AcquireWhileMonitored` is the demand verb
over the pair, with a test that proves release at shutdown with a live
subscription.

**Alarms.** Attaching an alarm enabled the condition, promoted the whole
notifier chain and registered a root notifier, with no inverse anywhere.
`RegisterAlarmEventSource` now reports what it changed and an
`AlarmRelease` behavior undoes it. While wiring that up: the parent's
`EventNotifier` was being promoted **twice** — once directly and once
inside `RegisterAlarmEventSource` — which left the reversible path with
nothing recorded. The redundant promotion is gone.

Alarms still work on managers that never opted into the fluent surface;
those simply get no automatic release, exactly as before, rather than a
hard failure.

## What this deliberately does not do

**Registry and alarm *setup* stays where it is.** Only release moved.
`NodeManagerBuilder.Seal()` is synchronous and is called from sites that
never call `CompleteConfigureAsync` — `Isa95NodeManager.cs:1046` seals
from inside `private void ConfigureStatusEvents()` — so making behavior
activation the sole setup path would silently disable simulations, event
sources and alarms in ISA-95, Pump, Generator and RuntimeNodeSet.

That same synchrony is what forced the `OnAddressSpaceReadyAsync` hook
in #4434 (*"`Configure` is a `partial void` and cannot await"*), which
`Opc.Ua.Di.Server/DiNodeManager.cs` had already invented privately. **A
separate PR making the fluent configure path async is planned to land
first**; after it, setup can move onto activation here and both copies
of that workaround can go.

Also out of scope: incremental attach on runtime node add and release on
node removal (the ledger needs real synchronisation first), and the
Quickstart `AlarmNodeManager` port that would give the alarm work
end-to-end coverage.

## Related Issues

No tracking issue yet — this came out of a design review of an earlier
behavior-activation prototype, which was rejected for being unreachable
from any application and hooked into a loading path rather than the node
manager. Happy to open one and link it if maintainers want the ADR on
file before review.

## 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. *(XML doc comments on
every new public member, including why method nodes are excluded and why
`Dispose` is signal-only. No `docs/` page — say the word if one is
wanted.)*
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed. *(net10.0
only — see Validation. Not run against .NET Framework locally.)*
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.

## Validation

net10.0:

- `UA.slnx` builds clean, 0 errors.
- `Opc.Ua.Server.Tests`: **4,984 passed, 0 failed**, 5 skipped.
- 23 new tests:
- **17 on the behavior surface** — child-first ordering,
base-before-derived on one node, exact-reverse unwind, fan-out over
three instances, subtype vs exact-type matching, zero-match diagnostics,
declined instances, mid-activation rollback, lifetime-token ordering,
second-pass unwind order, drained registrations, method-node exclusion,
alarm release, `Publish` release, `Dispose` tripping an activated
behavior's lifetime, and the two aggregation contracts (several release
failures all surface and do not stop the remaining releases; a rollback
that itself fails does not hide the activation failure that triggered
it).
- **4 on the simulation lifecycle** — fake-clock ticking, teardown
awaiting an in-flight tick, non-blocking `Dispose`, late-handler
rejection.
- **2 on `AcquireWhileMonitored`** — release on the last subscriber, and
release at shutdown with a live subscription.
- No new analyzer warnings.

Not run locally: .NET Framework targets, and suites outside
`Opc.Ua.Server.Tests`.

**On the advisory coverage gate.** The first report put patch coverage
at 47.65%, listing whole files as uncovered that these tests exercise
directly — `NodeBehaviorRegistry.cs` end to end, for instance. That
report is assembled from whichever legs published Cobertura fragments,
and the `Server` leg had not. It did point at three paths that genuinely
had no test, all now covered: `EventSourceRegistry.ReleaseAsync`,
`SignalShutdown` (reachable only via `Dispose` on a manager whose
behaviors actually activated), and the two failure-aggregation paths.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@romanett
romanett deleted the romanett/applications-node-manager-fluent-8c0aa7 branch September 9, 2026 08:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready Ready to merge once CI Passes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants