ADR-002 — driver-vs-virtual dispatch for Phase 7 scripting #176

Merged
dohertj2 merged 1 commits from adr-002-driver-vs-virtual-dispatch into v2 2026-04-20 16:10:32 -04:00
Owner

Locks the architecture decision Phase 7 Stream G was going to have to make anyway — documenting it up front so the stream implementation can reference the chosen shape instead of rediscovering it.

Decision

Option B — single DriverNodeManager, NodeScopeResolver returns a NodeSource enum alongside ScopeId, dispatch branches on source.

public sealed record NodeScope(
    string ScopeId,
    NodeSource Source,        // NEW
    string? DriverInstanceId,
    string? VirtualTagId);

public enum NodeSource { Driver, Virtual }

DriverNodeManager pattern-matches on scope.Source and routes reads / writes / subscribes either through the existing driver dictionary or through IVirtualTagEngine.

Why not the alternatives

  • Option A (separate VirtualTagNodeManager sibling) rejected: shared Equipment folders owning both driver and virtual children force two NodeManagers to fight for ownership on every Equipment node. Common case, not exception.
  • Option C (virtual engine registers as a synthetic IDriver) rejected: DriverInstance shape wrong (no DriverType, no HostAddress, no probe), IDriver lifecycle semantics don't match script compilation, Polly wrappers calibrated for network calls would either passthrough or tune wrong, Admin UI would need special-casing everywhere.

Consequences

  • ADR-001's walker gains the VirtualTag config-DB table as an additional input channel alongside Tag.
  • NodeScopeResolver's ScopeId return stays unchanged → Phase 6.2's ACL trie needs no modification.
  • Phase 6.1 resilience wrapping + Phase 6.2 audit logging apply uniformly to the driver branch; virtual branch has its own per-tag error path (BadInternalError, not through Polly).
  • OPC UA client writes to virtual nodes return BadUserAccessDenied before dispatch — enforces Phase 7 decision #6 (virtual tags writable only from scripts via ctx.SetVirtualTag).
  • Adding a future source kind = one enum case + one dispatch arm + walker extension. Extensible without rewrite.

Test coverage specified for Stream G.4

  • Mixed Equipment folder (driver + virtual children) browses with all children visible
  • Read routes to the correct backend for each source kind
  • Subscribe delivers changes from both kinds on the same subscription
  • OPC UA client write to a virtual node returns BadUserAccessDenied without invoking the engine
  • Script-driven write via ctx.SetVirtualTag updates the value + fires subscription notifications
Locks the architecture decision Phase 7 Stream G was going to have to make anyway — documenting it up front so the stream implementation can reference the chosen shape instead of rediscovering it. ## Decision **Option B — single DriverNodeManager, NodeScopeResolver returns a NodeSource enum alongside ScopeId, dispatch branches on source.** ```csharp public sealed record NodeScope( string ScopeId, NodeSource Source, // NEW string? DriverInstanceId, string? VirtualTagId); public enum NodeSource { Driver, Virtual } ``` DriverNodeManager pattern-matches on `scope.Source` and routes reads / writes / subscribes either through the existing driver dictionary or through `IVirtualTagEngine`. ## Why not the alternatives - **Option A (separate VirtualTagNodeManager sibling)** rejected: shared Equipment folders owning both driver and virtual children force two NodeManagers to fight for ownership on every Equipment node. Common case, not exception. - **Option C (virtual engine registers as a synthetic IDriver)** rejected: DriverInstance shape wrong (no DriverType, no HostAddress, no probe), IDriver lifecycle semantics don't match script compilation, Polly wrappers calibrated for network calls would either passthrough or tune wrong, Admin UI would need special-casing everywhere. ## Consequences - ADR-001's walker gains the VirtualTag config-DB table as an additional input channel alongside Tag. - NodeScopeResolver's ScopeId return stays unchanged → Phase 6.2's ACL trie needs no modification. - Phase 6.1 resilience wrapping + Phase 6.2 audit logging apply uniformly to the driver branch; virtual branch has its own per-tag error path (BadInternalError, not through Polly). - OPC UA client writes to virtual nodes return `BadUserAccessDenied` before dispatch — enforces Phase 7 decision #6 (virtual tags writable only from scripts via `ctx.SetVirtualTag`). - Adding a future source kind = one enum case + one dispatch arm + walker extension. Extensible without rewrite. ## Test coverage specified for Stream G.4 - Mixed Equipment folder (driver + virtual children) browses with all children visible - Read routes to the correct backend for each source kind - Subscribe delivers changes from both kinds on the same subscription - OPC UA client write to a virtual node returns `BadUserAccessDenied` without invoking the engine - Script-driven write via `ctx.SetVirtualTag` updates the value + fires subscription notifications
dohertj2 added 1 commit 2026-04-20 16:10:21 -04:00
ADR-002 — driver-vs-virtual dispatch: DriverNodeManager routes reads/writes/subscriptions across driver tags and virtual (scripted) tags via a single NodeManager with a NodeSource tag on NodeScopeResolver's output. Locks the architecture decision Phase 7 Stream G was going to have to make anyway — documenting it up front so the stream implementation can reference the chosen shape instead of rediscovering it. Option A (separate VirtualTagNodeManager sibling) rejected because shared Equipment folders owning both driver and virtual children would force two NodeManagers to fight for ownership on every Equipment node — the common case, not the exception — defeating the separation. Option C (virtual engine registers as a synthetic IDriver through DriverTypeRegistry) rejected because DriverInstance shape is wrong for scripting config (no DriverType, no HostAddress, no connectivity probe, no NSSM wrapper), IDriver.InitializeAsync semantics don't match script compilation, Polly resilience wrappers calibrated for network calls would either passthrough pointlessly or tune wrong, and Admin UI would need special-casing everywhere to hide fields that don't apply. Option B (single DriverNodeManager, NodeScopeResolver returns NodeSource enum alongside ScopeId, dispatch branches on source) accepted because it preserves one address-space tree with one walker, ACL binding works identically for both kinds, Phase 6.1 resilience + Phase 6.2 audit apply uniformly to the driver branch without needing Roslyn analyzer exemptions, and adding future source kinds is a single-enum-case addition. NodeScopeResolver.Resolve returns NodeScope(ScopeId, NodeSource, DriverInstanceId?, VirtualTagId?); DriverNodeManager pattern-matches on scope.Source and routes to either the driver dictionary or IVirtualTagEngine. OPC UA client writes to a virtual node return BadUserAccessDenied before the dispatch branch because Phase 7 decision #6 restricts virtual-tag writes to scripts via ctx.SetVirtualTag. Dispatch test coverage specified for Stream G.4: mixed Equipment folders browsing correctly, read routing per source kind, subscription fan-out across both kinds, the BadUserAccessDenied guard on virtual writes, and script-driven writes firing subscription notifications. ADR-001's walker gains the VirtualTag config-DB table as an additional input channel alongside Tag; NodeScopeResolver's ScopeId return stays unchanged so Phase 6.2's ACL trie needs no modification. Consequences flagged: whether IVirtualTagEngine lives in Core.Abstractions vs Phase 7's Core.VirtualTags project, and whether future server-side methods on virtual nodes would route through this dispatch, both marked out-of-scope for ADR-002. 2a74daf228
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
dohertj2 merged commit 6ae638a6de into v2 2026-04-20 16:10:32 -04:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: dohertj2/lmxopcua#176