# OpcUaClient Driver — Implementation Plan

> Source of gap analysis: [featuregaps.md → OpcUaClient](../featuregaps.md#opcuaclient-opc-ua-aggregation-client)
>
> Covers Build = Yes items only. Numbering matches the featuregaps Recommendations table.

## Summary

The OpcUaClient driver already ships 8/8 capability interfaces and a working
end-to-end Session/Subscription/MonitoredItem/HistoryRead pipeline backed by
the OPC Foundation `OPCFoundation.NetStandard.Opc.Ua.Client` SDK. Most of the
14 Build = Yes gaps are operability or curation knobs — config surface +
plumbing into existing SDK calls — rather than new protocol implementation.
A small number need genuinely new SDK plumbing (Reverse Connect,
ModelChangeEvent subscribe) and one (`ReadEventsAsync`) needs a coordinated
cross-driver interface change.

The plan groups the work into five phases, ordered to deliver per-tag /
per-subscription operability first (highest-frequency operator pain), then
curation, then change tracking, then connectivity, then historical+HA. Each
PR sticks to one feature-gap row so reviews stay narrow.

## Phased delivery

| Phase | Theme | Gaps | PRs | Notes |
| :---: | --- | --- | :---: | --- |
| 1 | Operability knobs | #5, #6, #15, #17, #20 | 5 | Pure SDK config surface; no new wire flows |
| 2 | Discovery & curation | #2, #7, #8, #9 | 4 | Touches `ITagDiscovery` + adds method invoke |
| 3 | Change tracking | #10 | 1 | New session-level subscription on `Server` node |
| 4 | Connectivity | #1 | 1 | Reverse Connect — new listener path |
| 5 | Historical & redundancy | #12, #13, #14 | 3 | Includes the cross-driver `IHistoryProvider` change |

**Total: 14 PRs across 5 phases.** Phases 1-3 land independently against
the existing single-session model. Phase 4 ships in parallel with phases 2-3
since it doesn't touch `OpcUaClientDriver` proper. Phase 5's first PR is a
prerequisite for the `ReadEventsAsync` work in every other history-capable
driver and must coordinate with them.

## Per-PR detail

### Phase 1 — Operability knobs

#### PR-1: Per-subscription tuning (gap #6)

**Goal**: lift the hard-coded `KeepAliveCount=10`, `LifetimeCount=1000`,
`MaxNotificationsPerPublish=0`, `Priority=0`, `PublishingInterval` floor of
50 ms into `OpcUaClientDriverOptions` so high-event-rate servers can be
defended against (`MaxNotificationsPerPublish=0` is unlimited — the
documented DoS surface) and high-tag-count deployments can split by
priority.

**SDK API**:
- `Subscription.SetPublishingMode(bool, ct)` for runtime enable/disable
- `SubscriptionOptions.PublishingInterval / KeepAliveCount / LifetimeCount /
  MaxNotificationsPerPublish / Priority` set at create-time
- New options class `OpcUaSubscriptionDefaults` (publish interval floor,
  keep-alive count, lifetime count, max notifications, priority)

**Files**:
- `src/.../OpcUaClient/OpcUaClientDriverOptions.cs` — add `Subscriptions`
  sub-section
- `src/.../OpcUaClient/OpcUaClientDriver.cs` — `SubscribeAsync` reads from
  options
- `src/.../OpcUaClient/OpcUaClientDriver.cs` — `SubscribeAlarmsAsync` reuses
  same defaults but with `Priority=1` higher than data subscriptions so
  alarms aren't starved during data bursts

**Tests**: `OpcUaClientSubscribeAndProbeTests` — assert options propagate;
add a stress unit test (mocked `Subscription`) that asserts custom
`MaxNotificationsPerPublish` is forwarded so a value > 0 actually reaches
the SDK.

**Risks**: Setting `LifetimeCount` too low against a server with publish-
throttling can drop subscriptions; doc the formula (`LifetimeCount >=
3 * KeepAliveCount`).

**Docs / fixture / e2e**: new "Subscription tuning" subsection in
`docs/drivers/OpcUaClient.md` (create if missing) documenting the
`Subscriptions` options block with the `LifetimeCount >= 3 *
KeepAliveCount` formula; cross-link from the "Advanced options" section
of `docs/Client.CLI.md` so CLI users discover the knobs. Fixture: opc-plc
already publishes fast tickers (`FastUInt1` @ 100 ms) sufficient for
coverage — no fixture-side change. Integration test in
`tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/` asserting
custom `KeepAliveCount` / `Priority` reach the wire (capture via
`OpcPlcFixture` keepalive count). E2E: extend
`scripts/e2e/test-opcuaclient.ps1` with a stage that sets a non-default
publish interval and confirms the local subscription honours it.

---

#### PR-2: Per-tag advanced subscription tuning incl. deadband (gap #5)

**Goal**: surface `SamplingInterval`, `QueueSize`, `DiscardOldest`,
`MonitoringMode`, and `DataChangeFilter` (DeadbandType=Absolute/Percent +
Trigger=Status/StatusValue/StatusValueTimestamp) per-tag. Deadband is the
baseline analog noise filter every commercial UA aggregator ships and the
single feature most likely to cut bandwidth on busy plants.

**SDK API**:
- `MonitoredItem.Filter = new DataChangeFilter { Trigger =
  DataChangeTrigger.StatusValue, DeadbandType = (uint)DeadbandType.Absolute,
  DeadbandValue = 0.5 }`
- `MonitoredItemOptions.QueueSize / DiscardOldest / SamplingInterval /
  MonitoringMode`
- Per-tag override structure: extend the `SubscribeAsync` parameter shape
  (or add an overload accepting a `IReadOnlyList<MonitoredTagSpec>`) — note
  this requires coordinating with `ISubscribable` so the per-tag carrier
  reaches the driver.

**Files**:
- `src/.../Core.Abstractions/ISubscribable.cs` — add overload
  `SubscribeAsync(IReadOnlyList<MonitoredTagSpec>, ...)` keeping old API
  for source compat
- `src/.../OpcUaClient/OpcUaClientDriver.cs` — translate spec → SDK filter

**Tests**: assert `DataChangeFilter` lands on the `MonitoredItem.Filter` for
each kind of trigger; assert PercentDeadband requires server-side
EURange (server returns `BadFilterNotAllowed` if not configured) — capture
the StatusCode and surface as a usable error.

**Risks**: cross-cutting `ISubscribable` change. Mitigation: ship the
overload as additive — existing single-arg path still exists.

**Docs / fixture / e2e**: new "Per-tag deadband and monitoring filters"
section in `docs/drivers/OpcUaClient.md` (create if missing) with worked
examples of Absolute vs Percent deadband + the EURange prerequisite;
update `docs/Client.CLI.md` `subscribe` command page with the new tag-
config syntax for `--deadband` / `--queue-size` / `--discard-oldest`;
update `docs/Client.UI.md` Subscriptions tab section to mirror. Fixture:
`OpcPlcFixture` / `OpcPlcProfile` seeds an analog (`StepUp` already
oscillates) and confirms `EURange` is published — extend the profile to
flag noisy nodes. Integration test in
`tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/` asserts
publish suppression below the deadband threshold. E2E: add a
`-DeadbandValue` stage to `scripts/e2e/test-opcuaclient.ps1` (and a
`deadband` knob to `scripts/e2e/e2e-config.sample.json`) that subscribes,
asserts no spurious updates within the band.

---

#### PR-3: Honor server `OperationLimits` (gap #15)

**Goal**: read `Server.ServerCapabilities.OperationLimits.MaxNodesPerRead /
Write / Browse / HistoryReadData` once after Session activation, cache,
and chunk batch operations to those caps client-side. Today the SDK chunks
on its internal default; against an undersized embedded UA server this
results in `BadTooManyOperations`.

**SDK API**:
- After session open: `Session.ReadAsync` of
  `VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerRead`
  + sibling NodeIds. The SDK exposes `Session.OperationLimits` after
  `FetchOperationLimits` is called — prefer that path.
- `Session.FetchOperationLimitsAsync(ct)` (1.5+); fallback: explicit Read.

**Files**:
- `src/.../OpcUaClient/OpcUaClientDriver.cs` — call
  `FetchOperationLimitsAsync` post-`OpenSessionOnEndpointAsync`; honour
  caps in `ReadAsync`, `WriteAsync`, `BrowseRecursiveAsync`,
  `EnrichAndRegisterVariablesAsync`, `ExecuteHistoryReadAsync`.

**Tests**: mock `Session.OperationLimits` to a value below the test batch
size and assert the driver issues N wire calls instead of one.

**Risks**: a zero on the server means "no limit" per Part 5 — don't divide
by zero.

**Docs / fixture / e2e**: new "Server OperationLimits handling"
subsection in `docs/drivers/OpcUaClient.md` documenting the auto-fetch
behaviour, the zero-means-unlimited semantics, and how to override via
options if the server reports an under-truthful value. Fixture: opc-plc
publishes the standard ServerCapabilities tree out of the box — no
container-side change; the `OpcPlcFixture` seed validates the IDs at
collection init. Integration test asserts batch reads chunk to the
fetched cap. No e2e change needed (the script's batch sizes are already
small).

---

#### PR-4: Diagnostics counters (gap #17)

**Goal**: expose per-driver counters on `DriverHealth` (or a sibling
`DriverDiagnostics` surface): publish-request count, notifications-per-
second EWMA, missing-publish-request count, dropped-notification rate,
session resets count. Operators currently see only `LastSuccessfulRead`
+ last error.

**SDK API**:
- `Subscription.Notification` event fires per published notification — bump
  a counter
- `Subscription.PublishStateChanged` event for missed-publish detection
- `Session.PublishError` event for channel-level errors
- `Session.SessionClosing`/`SessionConfigurationChanged` for session-reset
  attribution

**Files**:
- `src/.../OpcUaClient/OpcUaClientDriver.cs` — instrument hooks; expose via
  `IDriver.GetDiagnostics()` or extend `DriverHealth`
- `src/.../Core.Abstractions/IDriver.cs` — confirm where the counter shape
  lives; if `DriverHealth` is too rigid, add `IDriverDiagnostics` (mirrors
  the Modbus `driver-diagnostics` RPC pattern from #154)

**Tests**: synthetic notification fan-out → assert counters increment;
session close → assert reset count bumps.

**Risks**: counters need to be lock-free hot-path safe; use
`Interlocked.Increment` and a single sliding-window clock per counter.

**Docs / fixture / e2e**: new "Driver diagnostics" section in
`docs/drivers/OpcUaClient.md` enumerating each counter and the event
that bumps it; cross-link to the `driver-diagnostics` Admin RPC
documented for Modbus (#154 pattern). Fixture: no opc-plc change
required. Integration test exercises `IDriverDiagnostics` after
forcing a session close. E2E: extend
`scripts/e2e/test-opcuaclient.ps1` with a "diagnostics snapshot" stage
that asserts publish/notification counters are non-zero after the
subscribe stage.

---

#### PR-5: CRL / revocation handling (gap #20)

**Goal**: explicit revoked-cert handling in `CertificateValidator` plus a
`RejectSHA1SignedCertificates` knob. Today the validator hooks
`BadCertificateUntrusted` only — a revoked cert silently fails as
"untrusted" with no operator-visible distinction.

**SDK API**:
- `CertificateValidator.CertificateValidation` event — inspect
  `e.Error.StatusCode` for `BadCertificateRevoked`,
  `BadCertificateRevocationUnknown`,
  `BadCertificateIssuerRevocationUnknown`,
  `BadCertificatePolicyCheckFailed`
- `SecurityConfiguration.RejectSHA1SignedCertificates`,
  `SecurityConfiguration.RejectUnknownRevocationStatus`,
  `SecurityConfiguration.MinimumCertificateKeySize` — direct config
  bool/int knobs already on the SDK type
- `CertificateTrustList.AddCRL` / per-store CRL directories under
  `%LocalAppData%\OtOpcUa\pki\{trusted,issuers}\crl\`

**Files**:
- `src/.../OpcUaClient/OpcUaClientDriver.cs` — `BuildApplicationConfigurationAsync`
  honours new options, validator handler distinguishes revoked vs untrusted
  in the surfaced error message
- `src/.../OpcUaClient/OpcUaClientDriverOptions.cs` — add
  `RejectSHA1SignedCertificates`, `RejectUnknownRevocationStatus`,
  `MinimumCertificateKeySize`

**Tests**: feed a SHA1-signed test cert and a revoked cert through the
validator with the new knobs on/off.

**Risks**: PKI directory layout changes — existing deployments need a
migration note.

**Docs / fixture / e2e**: new "Certificate revocation and SHA1 rejection"
subsection in `docs/drivers/OpcUaClient.md` documenting the CRL
directory layout under `%LocalAppData%\OtOpcUa\pki\{trusted,issuers}\crl\`
and the new options (with a migration note for existing PKI stores);
cross-link from `docs/security.md`. Fixture: extend
`OpcPlcFixture` / `Docker/docker-compose.yml` with an optional secured
endpoint variant and a SHA1-signed test cert checked into the test
project's resources for the validator unit test. Integration test
exercises a revoked cert via a local CRL drop. E2E: add a
`-Insecure:$false` smoke stage to `scripts/e2e/test-opcuaclient.ps1`
that asserts a revoked cert produces a distinguishable error message.

---

### Phase 2 — Discovery & curation

#### PR-6: Discovery URL `FindServers` (gap #2)

**Goal**: accept a discovery URL (`opc.tcp://host:4840` pointing at the
LDS or the server's own discovery endpoint) and surface advertised servers
+ endpoints to the operator without manual policy/mode tuple copy.

**SDK API**:
- `DiscoveryClient.CreateAsync(appConfig, new Uri(url), DiagnosticsMasks.None, ct)`
- `DiscoveryClient.FindServersAsync(null, ct)` → `ApplicationDescription[]`
- `DiscoveryClient.GetEndpointsAsync(null, ct)` per advertised `DiscoveryUrl`

**Files**:
- `src/.../OpcUaClient/OpcUaClientDriver.cs` — new internal
  `DiscoverServersAsync` helper; extend the Admin-side discovery RPC to
  invoke it (driver-diagnostics pattern from #154)
- `src/.../OpcUaClient/OpcUaClientDriverOptions.cs` — add
  `DiscoveryUrl` knob (alternative to explicit `EndpointUrls` — when set
  the driver runs `FindServers` at init and feeds the result into the
  failover candidate list)

**Tests**: mock `DiscoveryClient` returning two advertised servers each
with three endpoints; assert the candidate list reflects the policy/mode
filter applied client-side.

**Risks**: `FindServers` itself usually requires `SecurityMode=None` —
spec out in the doc that the discovery channel is unsecured even when
the data channel will be encrypted.

**Docs / fixture / e2e**: new "Discovery URL (`FindServers`)" section in
`docs/drivers/OpcUaClient.md` with the unsecured-discovery-vs-secured-
data caveat called out; cross-link from `docs/Client.CLI.md` if a
`discover` CLI command surfaces. Fixture: opc-plc already responds to
`FindServers` on the same endpoint — `OpcPlcFixture` adds a discovery
probe at collection init. Integration test exercises the helper against
the live opc-plc container and asserts at least one
`ApplicationDescription` returned. E2E: replace the hard-coded
`-RemoteUrl` stage in `scripts/e2e/test-opcuaclient.ps1` with an
optional `-DiscoveryUrl` mode that picks the first advertised endpoint.

---

#### PR-7: Selective import / namespace remap (gap #7)

**Goal**: per-branch include/exclude rules, namespace-URI remapping, and
re-keyed BrowseNames — the curation surface every commercial aggregator
ships.

**Approach**: extend `OpcUaClientDriverOptions` with a `Curation` section:
- `IncludePaths: string[]` — glob or NodeId-rooted prefix list; only paths
  matching are imported
- `ExcludePaths: string[]` — wins over Include (Include is allow-list,
  Exclude is block-list)
- `NamespaceRemap: Dictionary<string,string>` — upstream NS URI →
  local-side alias for BrowseName generation
- `RootAlias: string` — default `"Remote"`; replaces the hardcoded folder
  name today

**SDK API** — none new; this is pure local filtering inside
`BrowseRecursiveAsync` and `EnrichAndRegisterVariablesAsync`.

**Files**:
- `src/.../OpcUaClient/OpcUaClientDriverOptions.cs`
- `src/.../OpcUaClient/OpcUaClientDriver.cs` —
  `BrowseRecursiveAsync` consults the rule set; helper
  `MapNamespaceForBrowseName` handles NS remap

**Tests**: synthetic browse tree, exercise include/exclude/remap each
independently and combined; verify the cap accounting in
`MaxDiscoveredNodes` excludes filtered nodes.

**Risks**: glob semantics — pin to a small subset (`*`, `?` only — no
character classes or `**`) to keep the doc + behaviour simple.

**Docs / fixture / e2e**: new "Curation: include/exclude and namespace
remap" section in `docs/drivers/OpcUaClient.md` with worked examples of
each rule kind and the supported glob subset; update
`docs/drivers/OpcUaClient-Test-Fixture.md` "Coverage map" with the new
filtering rows. Fixture: extend `OpcPlcProfile` to enumerate which
upstream namespaces are exercised so curation tests can target them.
Integration test seeds an Include + Exclude + Remap rule and asserts
the local tree reflects the filter. E2E: add a
`-IncludePath` / `-NamespaceRemap` set of params to
`scripts/e2e/test-opcuaclient.ps1` that asserts the local browse depth
matches the rule.

---

#### PR-8: Type definition mirroring (gap #8)

**Goal**: walk the upstream `Types` folder (`ObjectTypes`,
`VariableTypes`, `DataTypes`, `ReferenceTypes`) and project them into the
local address space so downstream UI clients keep type-aware rendering and
structured DataTypes decode correctly.

**SDK API**:
- `Session.NodeCache.FetchNode(typeNodeId)` for type metadata
- `Session.LoadDataTypeSystem` — for structured DataType encoding
- `Session.FetchTypeTree(NodeIdCollection)` — populates the session's
  type cache from the server

**Files**:
- `src/.../OpcUaClient/OpcUaClientDriver.cs` — new pass-3 in `DiscoverAsync`
  that walks `i=86` (Types folder) under the curation rules, registers a
  parallel type subtree, and links variables to their TypeDefinition via
  HasTypeDefinition references on the address-space builder
- `src/.../Core.Abstractions/IAddressSpaceBuilder.cs` — confirm whether
  the builder accepts type nodes; if not, extend it (this likely is a
  prerequisite — if so, it gets its own preceding PR-8a)

**Tests**: mock browse returning `BaseObjectType -> DerivedThing`;
assert local builder receives the type node + the HasTypeDefinition link.

**Risks**: significant. Type mirroring touches `IAddressSpaceBuilder`
which is a cross-cutting interface every driver depends on. If
`IAddressSpaceBuilder` already supports type nodes (Galaxy has type-like
templates), reuse that surface; otherwise this PR splits.

**Docs / fixture / e2e**: new "Type mirroring" section in
`docs/drivers/OpcUaClient.md` documenting which type nodes get walked
and how downstream UA clients see the HasTypeDefinition references; also
note in `docs/Client.UI.md` that the Browse tree now shows mirrored
types. Fixture: opc-plc already exposes the standard `Types` folder;
extend `OpcPlcProfile` to assert at least one custom ObjectType is
present. Integration test browses the local Types folder post-discovery
and asserts the upstream type chain landed. No e2e change needed beyond
extending the existing browse stage to walk under `Types`.

---

#### PR-9: Method node mirroring + `Call` passthrough (gap #9)

**Goal**: discover `NodeClass.Method` nodes in the browse pass, expose
them on the local address space, and forward `Call` invocations as
`Session.CallAsync` against the upstream node. The driver already calls
`AcknowledgeableConditionType.Acknowledge` for A&C — generalize that path.

**SDK API**:
- `Session.CallAsync(requestHeader, methodsToCall: CallMethodRequestCollection, ct)`
  returning `CallMethodResultCollection`
- Browse already covers Method nodes by lifting the `NodeClassMask`; need
  to additionally browse `HasProperty` to discover `InputArguments` /
  `OutputArguments` for argument translation

**Files**:
- `src/.../Core.Abstractions/IDriver.cs` — add `IMethodInvoker` capability
  interface (this is a NEW capability, not a tweak to an existing one)
- `src/.../OpcUaClient/OpcUaClientDriver.cs` — implement
  `IMethodInvoker.InvokeAsync(string objectId, string methodId,
  IReadOnlyList<object?> inputs, ct)`; refactor `AcknowledgeAsync` to
  reuse the common path
- `src/.../Server/...` node-manager — wire `IMethodInvoker` to the OPC UA
  server's `MethodNode.OnCallMethod` hook so downstream Call requests
  reach the driver

**Tests**: mock `Session.CallAsync` returning Good + an output collection;
assert pass-through fidelity. Also assert per-argument `BadInvalidArgument`
codes pass through.

**Risks**: high — adds a new capability interface. Other drivers that
*could* support methods (Galaxy via `OnExecute` scripts, FOCAS via FOCAS
commands) gain a clean extension point but each is its own follow-up.

**Docs / fixture / e2e**: new "Method nodes and Call passthrough"
section in `docs/drivers/OpcUaClient.md` explaining how method calls
flow through the aggregator (input/output argument translation, error-
code passthrough); add a `call` command page to `docs/Client.CLI.md`
covering the new path; mirror in `docs/Client.UI.md` if a UI surface
ships. Fixture: opc-plc already exposes the standard
`Server.GetMonitoredItems` method — `OpcPlcFixture` registers it as the
canonical method-call target. Integration test in
`tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/` invokes
`Server.GetMonitoredItems` through the aggregator. E2E: add a
`-MethodNodeId` stage to `scripts/e2e/test-opcuaclient.ps1` that calls
the method through the local server and asserts the output matches the
direct upstream call.

---

### Phase 3 — Change tracking

#### PR-10: Auto re-import on `ModelChangeEvent` (gap #10)

**Goal**: subscribe to `BaseModelChangeEventType` /
`GeneralModelChangeEventType` on the upstream server's `i=2253` Server
node so when the upstream topology changes (new tag added, type modified)
the driver triggers a `ReinitializeAsync`-style re-import without
operator action.

**SDK API**:
- A second `Subscription` on the Session, monitoring `Server` node
  (`ObjectIds.Server`) with an `EventFilter` whose SelectClauses reference
  `BaseModelChangeEventType` and (optionally) `GeneralModelChangeEventType`
  Changes property
- On notification: enqueue a debounced re-discover (don't react to every
  event during a bulk topology edit — coalesce 2-5s window)

**Files**:
- `src/.../OpcUaClient/OpcUaClientDriver.cs` — add `_modelChangeSubscription`
  field; new `SubscribeModelChangesAsync` invoked at the end of
  `InitializeAsync`; debounce timer that calls `ReinitializeAsync` on the
  driver host
- `src/.../OpcUaClient/OpcUaClientDriverOptions.cs` — add
  `WatchModelChanges: bool` (default true) +
  `ModelChangeDebounce: TimeSpan` (default 5s)

**Tests**: synthetic event injection on the mock Session's notification
stream; assert one debounced re-import call regardless of N events
arriving in the window.

**Risks**: re-import while a downstream client is mid-browse — needs
serialization on `_gate` like the rest of the driver; document that
clients see a brief gap in the address space during reload.

**Docs / fixture / e2e**: new "Auto re-import on ModelChangeEvent"
section in `docs/drivers/OpcUaClient.md` documenting the debounce window,
the `_gate` serialization, and the brief browse-gap during reload.
Fixture: opc-plc supports runtime topology mutation via the
`addnode`/`addtag` HTTP control endpoint — extend `OpcPlcFixture` with
a helper that triggers a model change. Integration test asserts a
single re-import call after a burst of synthetic model change events.
E2E: add a "topology change" stage to
`scripts/e2e/test-opcuaclient.ps1` that calls the opc-plc control
endpoint, then asserts the local server reflects the new node within
the debounce window.

---

### Phase 4 — Connectivity

#### PR-11: Reverse Connect (gap #1)

**Goal**: support server-initiated client connect for OT-DMZ outbound-only
firewalls. The upstream server connects *to* us on a TCP listener; we
respond as the client. Hard requirement for many regulated plant networks.

**SDK API**:
- `Opc.Ua.Client.ReverseConnectManager` — manages a TCP listener on the
  configured port and dispatches incoming reverse-connect requests
- `ReverseConnectManager.AddEndpoint(Uri reverseEndpoint)` — listener URI
  e.g. `opc.tcp://0.0.0.0:4844`
- `ReverseConnectManager.WaitForConnection(serverUri, serverUri, ct)` —
  blocks until the configured server initiates a reverse connect
- `Session.Create(appConfig, reverseConnection, endpoint, ...)` —
  alternative session-create overload accepting the
  `ITransportWaitingConnection` returned by the manager

**Files**:
- `src/.../OpcUaClient/OpcUaClientDriverOptions.cs` — add
  `ReverseConnect: { Enabled, ListenerUrl, ExpectedServerUri }` section
- `src/.../OpcUaClient/OpcUaClientDriver.cs` — when reverse-connect is
  enabled, replace the failover sweep with `WaitForConnection` and fall
  through into the same session-create path
- New helper `ReverseConnectListener` — owns the manager lifecycle, one
  listener per driver-host process (singleton across instances if multiple
  reverse-connect drivers are configured)

**Tests**: spin up a `ReverseConnectClient` test against an opc-plc
container started with `--rc opc.tcp://host:4844` to verify end-to-end.
Unit tests mock `ITransportWaitingConnection`.

**Risks**: highest of the plan. Reverse Connect changes the
listen-vs-dial direction; if multiple OpcUaClient driver instances both
listen on the same port the manager must multiplex. opc-plc supports
reverse connect (`--rc` flag) so the integration test pattern from
`docs/drivers/OpcUaClient-Test-Fixture.md` extends cleanly.

**Docs / fixture / e2e**: new "Reverse Connect" section in
`docs/drivers/OpcUaClient.md` (create if missing) documenting the
listener URL config, the OT-DMZ outbound-only use case, and the shared-
listener singleton model; update `docs/drivers/OpcUaClient-Test-Fixture.md`
with the new "Reverse Connect coverage" row. Fixture: extend
`Docker/docker-compose.yml` with an `opc-plc-rc` service variant that
adds `--rc opc.tcp://host.docker.internal:4844`; `OpcPlcFixture` gains
a `[CollectionDefinition]` that wires up the reverse-connect listener
on the test side. Integration test asserts a session opens via the
reverse path. E2E: add a `-ReverseConnect` switch to
`scripts/e2e/test-opcuaclient.ps1` that flips the driver to listener
mode and verifies the bridge stage still passes.

---

### Phase 5 — Historical & redundancy

#### PR-12: `IHistoryProvider.ReadEventsAsync` interface fix + driver impl (gap #12)

**Goal**: extend `IHistoryProvider.ReadEventsAsync` to carry an
`EventFilter SelectClauses` parameter so HistoryRead Events can return
the right field projection, and implement the OPC UA Client passthrough.

**This is a cross-driver concern.** `IHistoryProvider` lives in
`Core.Abstractions` and every driver that opts into history (Galaxy,
OpcUaClient, plus any future historian-backed Tier-A driver) inherits the
default. Changing the signature is source-breaking — coordinate as one PR
that:
1. Adds the `IReadOnlyList<EventFieldProjection>` (or equivalent
   abstract `EventFilterSpec`) parameter
2. Updates Galaxy's existing override (currently the only override) to
   honour the projection (best-effort — the Galaxy A&E log has a fixed
   field set so most projections degrade to the default columns)
3. Lands the OpcUaClient passthrough using `Session.HistoryReadAsync` with
   `ReadEventDetails`

**SDK API**:
- `ReadEventDetails { StartTime, EndTime, NumValuesPerNode, Filter }`
- `Session.HistoryReadAsync` is already the call we use for Raw — pass
  `new ExtensionObject(new ReadEventDetails { ... })` for events
- `HistoryEvent.Events: HistoryEventFieldList[]` — unwrap into
  `HistoricalEvent` records

**Files**:
- `src/.../Core.Abstractions/IHistoryProvider.cs` — interface change
- `src/.../Driver.Galaxy.../*HistoryProvider*.cs` — adjust signature
- `src/.../OpcUaClient/OpcUaClientDriver.cs` — implement
  `ReadEventsAsync`; reuse `ExecuteHistoryReadAsync` shape
- Server-side history facade — propagate the new parameter

**Tests**: integration test against opc-plc with
`--alm` (alarm sim already enabled per the fixture doc) — verify the
SelectClause projection comes back correctly.

**Risks**: the cross-driver interface change is the riskiest single
ergonomic call in this plan. If we can't fit the new parameter without
breaking every driver's `IHistoryProvider` impl, fall back to a sibling
`IEventHistoryProvider` interface and only the OPC UA Client + Galaxy
implement it. **Decide this in the PR review.**

**Docs / fixture / e2e**: new "HistoryRead Events" section in
`docs/drivers/OpcUaClient.md` documenting the `EventFilter`-aware
passthrough; update `docs/Client.CLI.md` `historyread` page to cover
event-mode reads. **Cross-driver doc updates** (this PR adds an
"`IHistoryProvider.ReadEventsAsync` signature change — see
`docs/plans/opcuaclient-plan.md` PR-12" note to every other driver
plan that has a history surface): `docs/plans/abcip-plan.md`,
`docs/plans/ablegacy-plan.md`, `docs/plans/focas-plan.md`,
`docs/plans/s7-plan.md`, `docs/plans/twincat-plan.md`, the Galaxy plan
family (`docs/plans/galaxy-*.md` if/when present, and the LMX equivalent
if it lands), and any Modbus plan. Galaxy is the only existing
implementor and gets a real signature update in this PR; the others
get a heads-up note so future work tracks the new shape. Fixture: opc-
plc runs with `--alm` already (per existing fixture doc) — no compose
change. Integration test issues a HistoryRead Events with a non-default
SelectClause and asserts the projected fields. E2E: extend
`scripts/e2e/test-opcuaclient.ps1` with a "history events" stage
gated on the `--alm` simulator producing at least one event.

---

#### PR-13: Full Aggregate function set (gap #13)

**Goal**: extend `HistoryAggregateType` from the 5 enum values today
(Average/Minimum/Maximum/Total/Count) to the OPC UA Part 13 standard
catalog of 30+ aggregates that historian-class clients expect.

**SDK API**: `ObjectIds.AggregateFunction_*` constants — one per
aggregate. The SDK already exposes them; this is pure mapping work.

Aggregates to add (Part 13 §5):
- `TimeAverage`, `TimeAverage2`
- `Interpolative`
- `MinimumActualTime`, `MaximumActualTime`, `Range`, `Range2`
- `AnnotationCount`, `DurationGood`, `DurationBad`,
  `PercentGood`, `PercentBad`
- `WorstQuality`, `WorstQuality2`
- `StandardDeviationSample`, `StandardDeviationPopulation`,
  `VarianceSample`, `VariancePopulation`
- `NumberOfTransitions`
- `Start`, `End`, `Delta`, `StartBound`, `EndBound`
- `DurationInStateZero`, `DurationInStateNonZero`

**Files**:
- `src/.../Core.Abstractions/IHistoryProvider.cs` — extend
  `HistoryAggregateType` enum (additive — existing values keep their
  ordinal)
- `src/.../OpcUaClient/OpcUaClientDriver.cs` —
  `MapAggregateToNodeId` switch grows; default arm rejects `out of range`

**Tests**: parametrized unit test sweeping every enum value — assert
each maps to a non-null `NodeId` in the SDK's well-known set.

**Risks**: low — this is mapping work. Drivers without a real historian
(everything except Galaxy + OpcUaClient) keep throwing `NotSupported`.

**Docs / fixture / e2e**: extend the "HistoryRead aggregates" section in
`docs/drivers/OpcUaClient.md` with the full Part 13 catalog and which
aggregates require server-side support; update
`docs/Client.CLI.md` `historyread` page enumerating the new
`--aggregate` values. Fixture: opc-plc historian support is limited —
flag in `docs/drivers/OpcUaClient-Test-Fixture.md` that the new
aggregates are unit-tested via the SDK's well-known NodeId set, not
exercised wire-side. Integration test sweeps every enum value and
asserts the mapping; gated-skip for aggregates the live opc-plc image
doesn't honour. No e2e change.

---

#### PR-14: `ServerUriArray` redundant failover (gap #14)

**Goal**: read upstream `Server.ServerArray` /
`ServerStatus.ServerArray` and `ServerRedundancyType.RedundancySupport` at
session activation; when the upstream server advertises non-`None`
redundancy, fail over mid-session on `ServiceLevel` drop without losing
client subscriptions. Today our `EndpointUrls` is a one-shot connect-
attempt list, not a live redundancy group.

**SDK API**:
- `Session.ReadValueAsync(VariableIds.Server_ServerStatus_ServerArray, ct)`
  → URI list
- `Session.ReadValueAsync(VariableIds.Server_ServiceLevel, ct)` polled or
  subscribed via MonitoredItem
- Subscribe `Server_ServiceLevel` on the existing alarm subscription so
  drops propagate via the publish channel
- On low-`ServiceLevel`: open a parallel session against the next URI in
  `ServerArray`, `Session.TransferSubscriptionsAsync(otherSession, ...)`
  the live subscriptions, swap `Session` reference

**Files**:
- `src/.../OpcUaClient/OpcUaClientDriver.cs` — new
  `MonitorServerRedundancyAsync` method; integrate with the existing
  `OnKeepAlive` / `SessionReconnectHandler` machinery so reconnect and
  redundancy-failover share the subscription-transfer code path
- `src/.../OpcUaClient/OpcUaClientDriverOptions.cs` — add
  `Redundancy: { Enabled, ServiceLevelThreshold (default 200) }`

**Tests**: with two opc-plc containers behind the driver,
artificially drop ServiceLevel on the active one and assert the
secondary takes over; assert subscription handles stay valid.

**Risks**: redundancy is the second-riskiest item after Reverse Connect.
The SDK's `TransferSubscriptions` has known edge cases when the
secondary's `SecureChannel` rejects the source-channel's authentication
token; doc that the secondary must trust the same client cert as the
primary.

**Docs / fixture / e2e**: new "Upstream redundancy (`ServerArray`)"
section in `docs/drivers/OpcUaClient.md` with the ServiceLevel
threshold, the shared-cert prerequisite for `TransferSubscriptions`,
and the ops runbook for forcing a failover; cross-link from
`docs/Redundancy.md` (which today covers OUR server's redundancy —
add a "vs upstream-side redundancy" note). Fixture: extend
`Docker/docker-compose.yml` with a second `opc-plc-secondary` service
on a different port; `OpcPlcFixture` gains a multi-endpoint variant.
Integration test drops the active server's ServiceLevel and asserts
the secondary takes over with subscription handles intact. E2E: add a
`-PrimaryUrl` / `-SecondaryUrl` pair to
`scripts/e2e/test-opcuaclient.ps1` (and matching keys to
`scripts/e2e/e2e-config.sample.json`) that scripts a primary stop +
asserts the bridge stage continues to pass.

---

## Documentation, fixture, and e2e impact

Consolidated index of every doc page, fixture asset, and e2e script touched
by the plan above. Authoritative for review — if a PR's `Docs / fixture /
e2e` line references a path not listed here, that's a checklist gap.

### Driver user docs

- `docs/drivers/OpcUaClient.md` — **create on first PR that needs it
  (PR-1)** if not present, then extend with one section per PR-1 through
  PR-14 covering: subscription tuning, per-tag deadband, OperationLimits
  handling, diagnostics counters, CRL/SHA1, FindServers, curation,
  type mirroring, methods, ModelChangeEvent, Reverse Connect, history
  events, aggregates, upstream redundancy.
- `docs/drivers/OpcUaClient-Test-Fixture.md` — coverage map updated for
  curation (PR-7), Reverse Connect (PR-11), aggregates note (PR-13),
  redundancy multi-endpoint variant (PR-14).
- `docs/Client.CLI.md` — extended for subscribe deadband syntax (PR-2),
  any `discover` command (PR-6), `call` command (PR-9), `historyread`
  event mode (PR-12), `--aggregate` enum expansion (PR-13).
- `docs/Client.UI.md` — extended for Subscriptions tab deadband fields
  (PR-2), Browse-tree type rendering note (PR-8), Method-call surface
  (PR-9) if it ships.
- `docs/security.md` — cross-link from PR-5 (CRL/SHA1 knobs).
- `docs/Redundancy.md` — cross-link from PR-14 (note distinguishing
  server-side redundancy from upstream-side redundancy).

### Fixture assets

- `tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/Docker/docker-compose.yml`
  — add `opc-plc-rc` (PR-11) and `opc-plc-secondary` (PR-14) service
  variants; optional secured endpoint (PR-5).
- `tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcPlcFixture.cs`
  — discovery probe at collection init (PR-6), reverse-connect listener
  (PR-11), multi-endpoint variant (PR-14), model-change helper (PR-10).
- `tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcPlcProfile.cs`
  — flag noisy analogs for deadband (PR-2), enumerate exercised
  namespaces for curation (PR-7), record at least one custom ObjectType
  (PR-8).
- New integration tests added per PR; all live under the existing
  `tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/`
  collection.
- Test certs (PR-5): SHA1-signed + revoked test fixtures checked into
  the unit-test project's resources.

### E2E scripts

- `scripts/e2e/test-opcuaclient.ps1` — new stages added per PR (subscription
  tuning PR-1, deadband PR-2, diagnostics PR-4, CRL PR-5, discovery
  PR-6, curation PR-7, method call PR-9, topology change PR-10,
  reverse connect PR-11, history events PR-12, redundancy failover
  PR-14). The script is the single integration point for every
  driver-level e2e — keep the stages ordered top-down by phase.
- `scripts/e2e/e2e-config.sample.json` — new keys: `deadband`,
  `discoveryUrl`, `includePath`, `namespaceRemap`, `methodNodeId`,
  `reverseConnect`, `primaryUrl`, `secondaryUrl`.
- `scripts/e2e/test-all.ps1` — no structural change; the existing
  `opcuaclient` block forwards new params after wiring them through
  `e2e-config.sample.json`.

### Cross-driver impact (PR-12 — `IHistoryProvider.ReadEventsAsync`)

PR-12 changes the `IHistoryProvider.ReadEventsAsync` signature in
`Core.Abstractions` (or introduces a sibling `IEventHistoryProvider`
— pinned in PR-12 review per Open Question 2). That decision is
source-breaking for every driver that opts into history. PR-12 must
add an explicit "interface change — adopt new signature when this
driver implements `ReadEventsAsync`" note to:

- `docs/plans/abcip-plan.md`
- `docs/plans/ablegacy-plan.md`
- `docs/plans/focas-plan.md`
- `docs/plans/s7-plan.md`
- `docs/plans/twincat-plan.md`
- The Galaxy plan family — `docs/plans/galaxy-*.md` if/when those
  pages exist; Galaxy is the only current implementor and gets the
  real signature update in PR-12, not just a note.
- The LMX plan — `docs/plans/lmx-*.md` if/when it lands (current state:
  the LMX driver's history surface is implicit through Galaxy; revisit
  during PR-12 review).
- A Modbus plan page if/when one exists; Modbus does not implement
  history today but the heads-up note tracks the cross-driver shape.

The cross-driver note text should be a one-paragraph "Heads up: the
`IHistoryProvider.ReadEventsAsync` interface gained an
`EventFilterSpec` parameter in OpcUaClient PR-12 (`docs/plans/opcuaclient-plan.md`).
If/when this driver implements event-history, adopt the new signature."
This pattern keeps each driver plan stable while the cross-cutting
breakage is owned by one PR.

---

## Skip-rated items (for context)

These featuregaps rows are **Build = No** and intentionally omitted from
the plan above:

| # | Gap | Why we're skipping |
| :---: | --- | --- |
| 3 | Multicast / LDS-ME registration | Server-side responsibility, not aggregator's. |
| 4 | GDS push management (Part 12) | Significant infra; rare for our deployment scale. |
| 11 | HistoryUpdate / Modified / Annotation passthrough | MES backfill scope; defer. |
| 16 | Connection / session pooling for multi-instance scale-out | Premature; current per-instance model is simple and adequate. |
| 18 | Kerberos / OAuth2 / JWT identity | Significant security work; defer until AD integration drives it (separate workstream). |
| 19 | Write attribute scope beyond `Value` | Niche; rarely used in OPC UA practice. |

If any of these get prioritized later they slot cleanly between the phases
above — none have prerequisites among the Build = Yes items.

## Open questions

1. **`ISubscribable` overload vs new method (PR-2)**: per-tag spec
   carrier is needed for deadband; do we extend the existing
   `SubscribeAsync` overload or add `SubscribeWithSpecsAsync`? The
   former is source-breaking but cleaner; the latter is additive but
   leaves two parallel paths.
2. **`IHistoryProvider.ReadEventsAsync` shape (PR-12)**: does the
   `EventFilterSpec` parameter live on `IHistoryProvider` (one interface,
   every driver gets it) or on a sibling `IEventHistoryProvider` (two
   interfaces, only event-history drivers implement)? Memory entry
   suggests the former; preference depends on whether non-OPC-UA drivers
   ever expect to project arbitrary event fields. **Pin this in PR-12
   review.**
3. **`IMethodInvoker` capability (PR-9)**: does this become the 9th
   capability interface (currently 8/8) or is it folded into
   `IWritable` as a method-invoke variant? Adding a 9th interface is
   the cleaner model and matches the spec layering.
4. **Type mirroring address-space surface (PR-8)**: does
   `IAddressSpaceBuilder` already accept type nodes? If yes, PR-8 is
   straightforward; if no, it splits into a prerequisite PR-8a that
   extends the builder, then PR-8b for the OPC UA Client wire-up. The
   answer determines whether PR-8 ships in Phase 2 or slips to a later
   phase.
5. **Reverse Connect listener ownership (PR-11)**: one listener per
   driver instance (port collision when multiple reverse-connect
   drivers run in the same process) vs one shared listener with a
   `expectedServerUri` dispatcher. Shared is the right answer; pin
   the singleton lifetime to the driver-host.
6. **Phase 1 ship order**: PR-1, PR-3, PR-4, PR-5 are independent and can
   land in parallel. PR-2 depends on the `ISubscribable` interface
   decision (Q1) — recommend landing PR-1 first to validate the
   `OpcUaSubscriptionDefaults` shape, then PR-2.
