v2 mxgw migration — Phase 1+2+3.1 wiring (7 PRs)

Foundational PRs from lmx_mxgw_impl.md, all green. Bodies only — DI/wiring
deferred to PR 1+2.W (combined wire-up) and PR 3.W.

PR 1.1 — IHistorianDataSource lifted to Core.Abstractions/Historian/
  Reuses existing DataValueSnapshot + HistoricalEvent shapes; sidecar (PR
  3.4) translates byte-quality → uint StatusCode internally.

PR 1.2 — IHistoryRouter + HistoryRouter on the server
  Longest-prefix-match resolution, case-insensitive, ObjectDisposed-guarded,
  swallow-on-shutdown disposal of misbehaving sources.

PR 1.3 — DriverNodeManager.HistoryRead* dispatch through IHistoryRouter
  Per-tag resolution with LegacyDriverHistoryAdapter wrapping
  `_driver as IHistoryProvider` so existing tests + drivers keep working
  until PR 7.2 retires the fallback.

PR 2.1 — AlarmConditionInfo extended with five sub-attribute refs
  InAlarmRef / PriorityRef / DescAttrNameRef / AckedRef / AckMsgWriteRef.
  Optional defaulted parameters preserve all existing 3-arg call sites.

PR 2.2 — AlarmConditionService state machine in Server/Alarms/
  Driver-agnostic port of GalaxyAlarmTracker. Sub-attribute refs come from
  AlarmConditionInfo, values arrive as DataValueSnapshot, ack writes route
  through IAlarmAcknowledger. State machine preserves Active/Acknowledged/
  Inactive transitions, Acked-on-active reset, post-disposal silence.

PR 2.3 — DriverNodeManager wires AlarmConditionService
  MarkAsAlarmCondition registers each alarm-bearing variable with the
  service; DriverWritableAcknowledger routes ack-message writes through
  the driver's IWritable + CapabilityInvoker. Service-raised transitions
  route via OnAlarmServiceTransition → matching ConditionSink. Legacy
  IAlarmSource path unchanged for null service.

PR 3.1 — Driver.Historian.Wonderware shell project (net48 x86)
  Console host shell + smoke test; SDK references + code lift come in
  PR 3.2.

Tests: 9 (PR 1.1) + 5 (PR 2.1) + 10 (PR 1.2) + 19 (PR 2.2) + 1 (PR 3.1)
all pass. Existing AlarmSubscribeIntegrationTests + HistoryReadIntegrationTests
unchanged.

Plan + audit docs (lmx_backend.md, lmx_mxgw.md, lmx_mxgw_impl.md)
included so parallel subagent worktrees can read them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-04-29 14:03:36 -04:00
parent 012c42a846
commit ef22a61c39
21 changed files with 3553 additions and 70 deletions

View File

@@ -5,6 +5,8 @@ using Opc.Ua.Server;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
using ZB.MOM.WW.OtOpcUa.Server.Alarms;
using ZB.MOM.WW.OtOpcUa.Server.History;
using ZB.MOM.WW.OtOpcUa.Server.Security;
using DriverWriteRequest = ZB.MOM.WW.OtOpcUa.Core.Abstractions.WriteRequest;
// Core.Abstractions defines a type-named HistoryReadResult (driver-side samples + continuation
@@ -85,10 +87,31 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
private readonly IReadable? _virtualReadable;
private readonly IReadable? _scriptedAlarmReadable;
// PR 1.3 — server-level history routing. When non-null + a source is registered for
// the requested full reference, the four HistoryRead* overrides dispatch through the
// router. Otherwise we fall back to the legacy `_driver as IHistoryProvider` path
// wrapped in a thin adapter, so existing tests and drivers that still implement
// IHistoryProvider directly keep working until PR 1.W flips DI to register the
// legacy path inside the router.
private readonly IHistoryRouter? _historyRouter;
private LegacyDriverHistoryAdapter? _legacyHistoryAdapter;
// PR 2.3 — server-level alarm-condition state machine. When non-null, every
// MarkAsAlarmCondition call also registers the condition with the service so the
// server runs the Active/Acknowledged/Inactive transitions itself instead of
// relying on the driver's own tracker. _conditionSinks maps conditionId →
// ConditionSink so service-raised transitions reach the right OPC UA AlarmCondition
// sibling. Legacy IAlarmSource path keeps working in parallel until PR 7.2.
private readonly AlarmConditionService? _alarmService;
private readonly Dictionary<string, ConditionSink> _conditionSinks = new(StringComparer.OrdinalIgnoreCase);
private EventHandler<AlarmConditionTransition>? _alarmTransitionHandler;
public DriverNodeManager(IServerInternal server, ApplicationConfiguration configuration,
IDriver driver, CapabilityInvoker invoker, ILogger<DriverNodeManager> logger,
AuthorizationGate? authzGate = null, NodeScopeResolver? scopeResolver = null,
IReadable? virtualReadable = null, IReadable? scriptedAlarmReadable = null)
IReadable? virtualReadable = null, IReadable? scriptedAlarmReadable = null,
IHistoryRouter? historyRouter = null,
AlarmConditionService? alarmService = null)
: base(server, configuration, namespaceUris: $"urn:OtOpcUa:{driver.DriverInstanceId}")
{
_driver = driver;
@@ -100,7 +123,117 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
_scopeResolver = scopeResolver;
_virtualReadable = virtualReadable;
_scriptedAlarmReadable = scriptedAlarmReadable;
_historyRouter = historyRouter;
_alarmService = alarmService;
_logger = logger;
if (_alarmService is not null)
{
_alarmTransitionHandler = OnAlarmServiceTransition;
_alarmService.TransitionRaised += _alarmTransitionHandler;
}
}
/// <summary>
/// Routes <see cref="AlarmConditionService.TransitionRaised"/> to the matching
/// <see cref="ConditionSink"/> registered during <c>MarkAsAlarmCondition</c>. Translates
/// <see cref="AlarmConditionTransition"/> into the legacy <see cref="AlarmEventArgs"/>
/// shape the existing sink consumes — the sink's switch on <c>AlarmType</c> string
/// ("Active" / "Acknowledged" / "Inactive") is preserved so PR 2.3 doesn't perturb the
/// OPC UA Part 9 state mapping. Stale transitions for an untracked condition are
/// silently dropped.
/// </summary>
private void OnAlarmServiceTransition(object? sender, AlarmConditionTransition t)
{
ConditionSink? sink;
lock (Lock)
{
_conditionSinks.TryGetValue(t.ConditionId, out sink);
}
if (sink is null) return;
var transitionName = t.Transition switch
{
AlarmStateTransition.Active => "Active",
AlarmStateTransition.Acknowledged => "Acknowledged",
AlarmStateTransition.Inactive => "Inactive",
_ => "Unknown",
};
sink.OnTransition(new AlarmEventArgs(
SubscriptionHandle: null!,
SourceNodeId: t.ConditionId,
ConditionId: t.ConditionId,
AlarmType: transitionName,
Message: t.Description ?? t.ConditionId,
Severity: MapPriorityToSeverity(t.Priority),
SourceTimestampUtc: t.AtUtc));
}
/// <summary>
/// Maps the integer priority Galaxy carries on <c>.Priority</c> (typically 1-1000) to
/// the four-bucket <see cref="AlarmSeverity"/> the OPC UA condition sibling consumes.
/// Mirrors the legacy <c>GalaxyProxyDriver.MapSeverity</c> bucketing.
/// </summary>
private static AlarmSeverity MapPriorityToSeverity(int priority) => priority switch
{
<= 250 => AlarmSeverity.Low,
<= 500 => AlarmSeverity.Medium,
<= 800 => AlarmSeverity.High,
_ => AlarmSeverity.Critical,
};
/// <summary>
/// Default <see cref="IAlarmAcknowledger"/> bound to a driver's <see cref="IWritable"/>.
/// Writes the operator comment to the alarm's <c>.AckMsg</c> sub-attribute via the same
/// dispatcher OnWriteValue uses so the resilience pipeline gates the call. Returns
/// false when the driver doesn't implement <see cref="IWritable"/> — alarms whose
/// drivers can't write are tracked but cannot be acknowledged through this path.
/// </summary>
private sealed class DriverWritableAcknowledger(
IWritable? writable, CapabilityInvoker invoker, string driverInstanceId) : IAlarmAcknowledger
{
public async Task<bool> WriteAckMessageAsync(
string ackMsgWriteRef, string comment, CancellationToken cancellationToken)
{
if (writable is null || string.IsNullOrEmpty(ackMsgWriteRef)) return false;
var request = new DriverWriteRequest(
FullReference: ackMsgWriteRef,
Value: comment ?? string.Empty);
try
{
// Ack writes are not idempotent — repeating an ack would re-trigger the
// driver-side acknowledgement state change. False matches the OnWriteValue
// default path for non-Idempotent attributes.
var results = await invoker.ExecuteWriteAsync(
driverInstanceId,
isIdempotent: false,
async ct => await writable.WriteAsync(new[] { request }, ct).ConfigureAwait(false),
cancellationToken).ConfigureAwait(false);
return results.Count > 0 && results[0].StatusCode == 0;
}
catch
{
return false;
}
}
}
/// <summary>
/// Detach from the alarm service before the base disposes. The service is shared across
/// drivers, so leaking the handler keeps a dead DriverNodeManager pinned in memory and
/// dispatches transitions to a sink that's no longer wired to any OPC UA node.
/// </summary>
protected override void Dispose(bool disposing)
{
if (disposing && _alarmService is not null && _alarmTransitionHandler is not null)
{
_alarmService.TransitionRaised -= _alarmTransitionHandler;
_alarmTransitionHandler = null;
}
base.Dispose(disposing);
}
protected override NodeStateCollection LoadPredefinedNodes(ISystemContext context) => new();
@@ -644,7 +777,22 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
// Without this the Report fires but has no subscribers to deliver to.
_owner.AddRootNotifier(alarm);
return new ConditionSink(_owner, alarm);
var sink = new ConditionSink(_owner, alarm);
// PR 2.3 — when the server-level alarm-condition service is wired, register
// this condition with it so the state machine runs server-side. The sink-map
// entry routes future TransitionRaised events back to this OPC UA node.
// Conditions whose info lacks an InAlarmRef can't be observed without driver
// help — those still rely on the legacy IAlarmSource path until PR 7.2.
if (_owner._alarmService is not null && !string.IsNullOrEmpty(info.InAlarmRef))
{
_owner._conditionSinks[FullReference] = sink;
var acker = new DriverWritableAcknowledger(
_owner._writable, _owner._invoker, _owner._driver.DriverInstanceId);
_owner._alarmService.Track(FullReference, info, acker);
}
return sink;
}
}
@@ -808,29 +956,97 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
internal bool TryGetVariable(string fullRef, out BaseDataVariableState? v)
=> _variablesByFullRef.TryGetValue(fullRef, out v!);
// ===================== HistoryRead service handlers (LMX #1, PR 38) =====================
// ===================== HistoryRead service handlers (LMX #1, PR 38; PR 1.3 routing) =====================
//
// Wires the driver's IHistoryProvider capability (PR 35 added ReadAtTimeAsync / ReadEventsAsync
// alongside the PR 19 ReadRawAsync / ReadProcessedAsync) to the OPC UA HistoryRead service.
// CustomNodeManager2 has four protected per-kind hooks; the base dispatches to the right one
// based on the concrete HistoryReadDetails subtype. Each hook is sync-returning-void — the
// per-driver async calls are bridged via GetAwaiter().GetResult(), matching the pattern
// OnReadValue / OnWriteValue already use in this class so HistoryRead doesn't introduce a
// different sync-over-async convention.
// Wires HistoryRead to the server-level IHistoryRouter (PR 1.2). For each tag:
// (1) the router resolves the longest-matching IHistorianDataSource registration —
// when a server-registered source covers the namespace it wins; (2) when the router
// doesn't match (or no router is configured), we fall back to the driver's own
// IHistoryProvider capability via a thin adapter, preserving the legacy behavior tests
// rely on. PR 1.W will register the legacy adapter inside the router as well, at
// which point this fallback can be deleted.
//
// Per-node routing: every HistoryReadValueId in nodesToRead has a NodeHandle in
// nodesToProcess; the NodeHandle's NodeId.Identifier is the driver-side full reference
// (set during Variable() registration) so we can dispatch straight to IHistoryProvider
// without a second lookup. Nodes without IHistoryProvider backing (drivers that don't
// implement the capability) surface BadHistoryOperationUnsupported per slot and the
// rest of the batch continues — same failure-isolation pattern as OnWriteValue.
//
// Continuation-point handling is pass-through only in this PR: the driver returns null
// from its ContinuationPoint field today so the outer result's ContinuationPoint stays
// empty. Full Session.SaveHistoryContinuationPoint plumbing is a follow-up when a driver
// actually needs paging — the dispatch shape doesn't change, only the result-population.
// Continuation-point handling is pass-through only: the source returns null from its
// ContinuationPoint today so the outer result's ContinuationPoint stays empty. Proper
// Session.SaveHistoryContinuationPoint plumbing is a follow-up when a source actually
// needs paging — the dispatch shape doesn't change, only the result-population.
private IHistoryProvider? History => _driver as IHistoryProvider;
/// <summary>
/// Resolves the historian data source for a given driver full reference. Returns
/// null when neither the router nor the legacy IHistoryProvider path can serve it.
/// </summary>
/// <param name="fullRef">
/// Full reference, or null for driver-root event-history queries (event reads can
/// target a notifier rather than a specific variable). Null fullRef skips router
/// lookup and goes straight to the legacy fallback so today's "all events in the
/// driver namespace" path keeps working.
/// </param>
private IHistorianDataSource? ResolveHistory(string? fullRef)
{
if (fullRef is not null
&& _historyRouter?.Resolve(fullRef) is { } routed)
{
return routed;
}
if (_driver is IHistoryProvider legacy)
{
return _legacyHistoryAdapter ??= new LegacyDriverHistoryAdapter(legacy);
}
return null;
}
/// <summary>
/// Wraps a driver's <see cref="IHistoryProvider"/> as an
/// <see cref="IHistorianDataSource"/> so the four HistoryRead* methods can dispatch
/// through one interface regardless of resolution path. PR 1.W's legacy
/// auto-registration uses the same adapter; PR 7.2 deletes both once
/// IHistoryProvider stops being a driver capability.
/// </summary>
// OTOPCUA0001 (UnwrappedCapabilityCallAnalyzer) flags every direct IHistoryProvider call
// that isn't lexically inside a CapabilityInvoker.ExecuteAsync lambda. The adapter's
// pass-throughs are direct calls — but the four HistoryRead* call sites that own the
// adapter ARE inside ExecuteAsync lambdas, so the wrapping is preserved at runtime.
// Suppress here rather than at every call site.
#pragma warning disable OTOPCUA0001
private sealed class LegacyDriverHistoryAdapter(IHistoryProvider provider) : IHistorianDataSource
{
// HistoryReadResult is unqualified-ambiguous in this file (Core.Abstractions vs.
// Opc.Ua); fully qualify on the adapter signatures so the file's existing var-based
// dispatch sites stay readable.
public Task<Core.Abstractions.HistoryReadResult> ReadRawAsync(
string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode,
CancellationToken cancellationToken)
=> provider.ReadRawAsync(fullReference, startUtc, endUtc, maxValuesPerNode, cancellationToken);
public Task<Core.Abstractions.HistoryReadResult> ReadProcessedAsync(
string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
HistoryAggregateType aggregate, CancellationToken cancellationToken)
=> provider.ReadProcessedAsync(fullReference, startUtc, endUtc, interval, aggregate, cancellationToken);
public Task<Core.Abstractions.HistoryReadResult> ReadAtTimeAsync(
string fullReference, IReadOnlyList<DateTime> timestampsUtc, CancellationToken cancellationToken)
=> provider.ReadAtTimeAsync(fullReference, timestampsUtc, cancellationToken);
public Task<HistoricalEventsResult> ReadEventsAsync(
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents,
CancellationToken cancellationToken)
=> provider.ReadEventsAsync(sourceName, startUtc, endUtc, maxEvents, cancellationToken);
// Legacy IHistoryProvider has no health surface. Return an "unknown but reachable"
// snapshot so dashboards don't show the data source as broken.
public HistorianHealthSnapshot GetHealthSnapshot()
=> new(0, 0, 0, 0, null, null, null,
ProcessConnectionOpen: true, EventConnectionOpen: true,
ActiveProcessNode: null, ActiveEventNode: null,
Nodes: []);
// Legacy lifecycle is the driver's responsibility — disposing the adapter must
// not dispose the driver out from under DriverNodeManager.
public void Dispose() { }
}
#pragma warning restore OTOPCUA0001
protected override void HistoryReadRawModified(
ServerSystemContext context, ReadRawModifiedDetails details, TimestampsToReturn timestamps,
@@ -838,12 +1054,6 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
IList<ServiceResult> errors, List<NodeHandle> nodesToProcess,
IDictionary<NodeId, NodeState> cache)
{
if (History is null)
{
MarkAllUnsupported(nodesToProcess, results, errors);
return;
}
// IsReadModified=true requests a "modifications" history (who changed the data, when
// it was re-written). The driver side has no modifications store — surface that
// explicitly rather than silently returning raw data, which would mislead the client.
@@ -868,6 +1078,13 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
continue;
}
var source = ResolveHistory(fullRef);
if (source is null)
{
WriteUnsupported(results, errors, i);
continue;
}
if (_authzGate is not null && _scopeResolver is not null)
{
var historyScope = _scopeResolver.Resolve(fullRef);
@@ -883,7 +1100,7 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
var driverResult = _invoker.ExecuteAsync(
DriverCapability.HistoryRead,
ResolveHostFor(fullRef),
async ct => await History.ReadRawAsync(
async ct => await source.ReadRawAsync(
fullRef,
details.StartTime,
details.EndTime,
@@ -912,12 +1129,6 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
IList<ServiceResult> errors, List<NodeHandle> nodesToProcess,
IDictionary<NodeId, NodeState> cache)
{
if (History is null)
{
MarkAllUnsupported(nodesToProcess, results, errors);
return;
}
// AggregateType is one NodeId shared across every item in the batch — map once.
var aggregate = MapAggregate(details.AggregateType?.FirstOrDefault());
if (aggregate is null)
@@ -930,10 +1141,6 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
for (var n = 0; n < nodesToProcess.Count; n++)
{
var handle = nodesToProcess[n];
// NodeHandle.Index points back to the slot in the outer results/errors/nodesToRead
// arrays. nodesToProcess is the filtered subset (just the nodes this manager
// claimed), so writing to results[n] lands in the wrong slot when N > 1 and nodes
// are interleaved across multiple node managers.
var i = handle.Index;
var fullRef = ResolveFullRef(handle);
if (fullRef is null)
@@ -942,6 +1149,13 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
continue;
}
var source = ResolveHistory(fullRef);
if (source is null)
{
WriteUnsupported(results, errors, i);
continue;
}
if (_authzGate is not null && _scopeResolver is not null)
{
var historyScope = _scopeResolver.Resolve(fullRef);
@@ -957,7 +1171,7 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
var driverResult = _invoker.ExecuteAsync(
DriverCapability.HistoryRead,
ResolveHostFor(fullRef),
async ct => await History.ReadProcessedAsync(
async ct => await source.ReadProcessedAsync(
fullRef,
details.StartTime,
details.EndTime,
@@ -987,20 +1201,10 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
IList<ServiceResult> errors, List<NodeHandle> nodesToProcess,
IDictionary<NodeId, NodeState> cache)
{
if (History is null)
{
MarkAllUnsupported(nodesToProcess, results, errors);
return;
}
var requestedTimes = (IReadOnlyList<DateTime>)(details.ReqTimes?.ToArray() ?? Array.Empty<DateTime>());
for (var n = 0; n < nodesToProcess.Count; n++)
{
var handle = nodesToProcess[n];
// NodeHandle.Index points back to the slot in the outer results/errors/nodesToRead
// arrays. nodesToProcess is the filtered subset (just the nodes this manager
// claimed), so writing to results[n] lands in the wrong slot when N > 1 and nodes
// are interleaved across multiple node managers.
var i = handle.Index;
var fullRef = ResolveFullRef(handle);
if (fullRef is null)
@@ -1009,6 +1213,13 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
continue;
}
var source = ResolveHistory(fullRef);
if (source is null)
{
WriteUnsupported(results, errors, i);
continue;
}
if (_authzGate is not null && _scopeResolver is not null)
{
var historyScope = _scopeResolver.Resolve(fullRef);
@@ -1024,7 +1235,7 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
var driverResult = _invoker.ExecuteAsync(
DriverCapability.HistoryRead,
ResolveHostFor(fullRef),
async ct => await History.ReadAtTimeAsync(fullRef, requestedTimes, ct).ConfigureAwait(false),
async ct => await source.ReadAtTimeAsync(fullRef, requestedTimes, ct).ConfigureAwait(false),
CancellationToken.None).AsTask().GetAwaiter().GetResult();
WriteResult(results, errors, i, StatusCodes.Good,
@@ -1048,34 +1259,30 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
IList<ServiceResult> errors, List<NodeHandle> nodesToProcess,
IDictionary<NodeId, NodeState> cache)
{
if (History is null)
{
MarkAllUnsupported(nodesToProcess, results, errors);
return;
}
// SourceName filter extraction is deferred — EventFilter SelectClauses + WhereClause
// handling is a dedicated concern (proper per-select-clause Variant population + where
// filter evaluation). This PR treats the event query as "all events in range for the
// node's source" and populates only the standard BaseEventType fields. Richer filter
// handling is a follow-up; clients issuing empty/default filters get the right answer
// today which covers the common alarm-history browse case.
// handling is a dedicated concern. This PR treats the event query as "all events in
// range for the node's source" and populates only the standard BaseEventType fields.
var maxEvents = (int)details.NumValuesPerNode;
if (maxEvents <= 0) maxEvents = 1000;
for (var n = 0; n < nodesToProcess.Count; n++)
{
var handle = nodesToProcess[n];
// NodeHandle.Index points back to the slot in the outer results/errors/nodesToRead
// arrays. nodesToProcess is the filtered subset (just the nodes this manager
// claimed), so writing to results[n] lands in the wrong slot when N > 1 and nodes
// are interleaved across multiple node managers.
var i = handle.Index;
// Event history queries may target a notifier object (e.g. the driver-root folder)
// rather than a specific variable — in that case we pass sourceName=null to mean
// "all sources in the driver's namespace" per the IHistoryProvider contract.
// rather than a specific variable — in that case fullRef is null and we pass
// sourceName=null to the source meaning "all sources in this source's namespace."
var fullRef = ResolveFullRef(handle);
// ResolveHistory tolerates null fullRef — for notifier queries the router is
// skipped and the legacy fallback handles "all sources" reads.
var source = ResolveHistory(fullRef);
if (source is null)
{
WriteUnsupported(results, errors, i);
continue;
}
// fullRef is null for event-history queries that target a notifier (driver root).
// Those are cluster-wide reads + need a different scope shape; skip the gate here
// and let the driver-level authz handle them. Non-null path gets per-node gating.
@@ -1094,7 +1301,7 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
var driverResult = _invoker.ExecuteAsync(
DriverCapability.HistoryRead,
fullRef is null ? _driver.DriverInstanceId : ResolveHostFor(fullRef),
async ct => await History.ReadEventsAsync(
async ct => await source.ReadEventsAsync(
sourceName: fullRef,
startUtc: details.StartTime,
endUtc: details.EndTime,