Files
lmxopcua/docs/plans/2026-06-12-historian-tcp-transport.md
T

27 KiB
Raw Blame History

Wonderware Historian Sidecar — TCP Transport Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.

Goal: Replace the Wonderware Historian sidecar's local Windows named-pipe IPC with a TCP transport (plaintext dev / optional TLS prod, shared-secret Hello auth) so a remote off-VM OtOpcUa host can reach the net48 sidecar.

Architecture: Everything above the socket is reused verbatim (Hello/HelloAck, length-prefixed MessageKind framing, both Contracts.cs MessagePack DTOs, FrameReader/FrameWriter — they operate on Stream, and NetworkStream/SslStream are Streams — HistorianFrameHandler dispatch, the AVEVA SDK backends). The work is: swap the server's PipeServer for a TcpFrameServer (TcpListener + optional SslStream), swap the client's pipe connect-factory for a TCP one, thread host/port/TLS through options + config, and update deploy scripts + docs. Single active connection, serial accept (mirrors today's pipe maxNumberOfServerInstances:1).

Tech Stack: C# .NET 10 (client/host) + .NET Framework 4.8 x64 (sidecar — forced by AVEVA aahClientManaged), MessagePack, System.Net.Sockets/System.Net.Security, xUnit + Shouldly. Design doc: docs/plans/2026-06-12-historian-tcp-transport-design.md (master 3d3f8a47).

Green-at-each-step strategy: Because the named pipe is fully replaced, several files change shape. To keep the build green per commit we go additive then cleanup: add the TCP path alongside the pipe path, switch the defaults, then delete the dead pipe code in one final cleanup task. The client side (Tasks 14, 7-cleanup) and the sidecar side (Tasks 56) touch disjoint projects and can proceed in parallel.

net48 caveats (sidecar only — bake into Task 5):

  • TcpListener.AcceptTcpClientAsync(CancellationToken) does not exist on net48. Use the no-arg AcceptTcpClientAsync() and tie cancellation to the listener: ct.Register(() => _listener.Stop()) (a Stop() makes the pending accept throw, which the loop treats as cancellation).
  • SslStream.AuthenticateAsServerAsync on net48: use SslProtocols.Tls12 (Tls13 isn't available). checkCertificateRevocation: false for self-signed dev certs.

Hard rules: stage by path (never git add .); never stage sql_login.txt / src/Server/ZB.MOM.WW.OtOpcUa.Host/pki/ / pending.md; never echo the gateway API key or the historian SharedSecret into a tracked file; no force-push / no --no-verify; NO Configuration entity / EF migration change.


Task 0: Create the feature branch

Classification: trivial Estimated implement time: ~1 min Parallelizable with: none

Files: none (git only)

Step 1: From master (HEAD 3d3f8a47, design doc already committed):

git checkout master && git pull --ff-only
git checkout -b feat/historian-tcp-transport

Step 2: Confirm git status clean (except untracked pending.md, which is left alone) and git rev-parse --abbrev-ref HEADfeat/historian-tcp-transport.


Task 1: Add TCP/TLS fields to the client options

Classification: small Estimated implement time: ~3 min Parallelizable with: Task 5

Files:

  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Contracts/WonderwareHistorianClientOptions.cs
  • Test: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/WonderwareHistorianClientOptionsTests.cs (new)

Additive — keep PipeName for now (removed in Task 7) so the build stays green. Add init-only properties:

/// <summary>Sidecar TCP host (DNS name or IP). Required for the TCP transport.</summary>
public string? Host { get; init; }

/// <summary>Sidecar TCP port (matches the sidecar's OTOPCUA_HISTORIAN_TCP_PORT).</summary>
public int Port { get; init; }

/// <summary>When true, the client wraps the TCP stream in TLS before the Hello handshake.</summary>
public bool UseTls { get; init; }

/// <summary>
///     Optional SHA-1 thumbprint (hex, no spaces) the client pins the sidecar's TLS
///     server cert against. When null/empty and <see cref="UseTls"/> is true, the client
///     validates the cert chain normally (CA-issued cert).
/// </summary>
public string? ServerCertThumbprint { get; init; }

Step 1 (test, TDD): assert an options instance carries the new fields and that defaults are Port=0, UseTls=false, Host/ServerCertThumbprint=null. Step 2: run dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests --filter WonderwareHistorianClientOptionsTests → fail (no fields). Step 3: add the properties. Step 4: test passes. dotnet build on the Contracts project is green. Step 5: commit (git add the two files by path) — feat(historian-client): add TCP/TLS options fields.


Task 2: Client TCP connect factory + FrameChannel rename

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 5

Files:

  • Rename: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Internal/PipeChannel.csInternal/FrameChannel.cs (rename the class PipeChannelFrameChannel; it is already transport-agnostic — it only touches Stream/FrameReader/FrameWriter). Update the one reference in WonderwareHistorianClient.cs (private readonly PipeChannel _channel; and new PipeChannel(...)).
  • Modify (same file): keep DefaultNamedPipeConnectFactory for now (deleted in Task 7); add DefaultTcpConnectFactory.
  • Test: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/TcpConnectFactoryTests.cs (new)

Add to FrameChannel (needs using System.Net.Sockets; using System.Net.Security; using System.Security.Authentication;):

/// <summary>
///     Default TCP factory: connects to the sidecar over TCP, optionally wrapping the
///     stream in TLS (server-auth; pinned-thumbprint or CA-chain validation). The Hello
///     handshake + shared secret still authenticate the caller on top of this.
/// </summary>
public static Func<WonderwareHistorianClientOptions, CancellationToken, Task<Stream>> DefaultTcpConnectFactory =
    async (opts, ct) =>
    {
        if (string.IsNullOrWhiteSpace(opts.Host))
            throw new InvalidOperationException("WonderwareHistorianClientOptions.Host is required for the TCP transport.");

        var tcp = new TcpClient();
        using (var connectCts = CancellationTokenSource.CreateLinkedTokenSource(ct))
        {
            connectCts.CancelAfter(opts.EffectiveConnectTimeout);
            await tcp.ConnectAsync(opts.Host!, opts.Port, connectCts.Token).ConfigureAwait(false);
        }
        tcp.NoDelay = true;

        Stream stream = tcp.GetStream();
        if (!opts.UseTls) return stream;

        var ssl = new SslStream(stream, leaveInnerStreamOpen: false, (_, cert, _, errors) =>
        {
            if (!string.IsNullOrEmpty(opts.ServerCertThumbprint))
                return string.Equals(cert?.GetCertHashString(), opts.ServerCertThumbprint, StringComparison.OrdinalIgnoreCase);
            return errors == SslPolicyErrors.None;
        });
        await ssl.AuthenticateAsClientAsync(opts.Host!).ConfigureAwait(false);
        return ssl;
    };

Step 1 (test, TDD): stand up a loopback TcpListener on 127.0.0.1:0, accept in a background task and (a) for plaintext: read the first byte to prove a stream arrived; (b) for TLS: AuthenticateAsServerAsync a self-signed cert (build one with CertificateRequest / X509Certificate2.CreateSelfSigned-style helper, EKU serverAuth) and assert the client factory with the matching ServerCertThumbprint connects, and with a wrong thumbprint throws AuthenticationException. Step 2: run the new test → fail (no DefaultTcpConnectFactory). Step 3: add the factory + do the rename. Step 4: dotnet test …Client.Tests --filter TcpConnectFactory → pass; whole Client project builds. Step 5: commit by path — feat(historian-client): TCP connect factory + FrameChannel rename.


Task 3: Switch the client default ctor to TCP

Classification: small Estimated implement time: ~2 min Parallelizable with: none (needs Task 2)

Files:

  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/WonderwareHistorianClient.cs:42
  • Test: tests/.../Client.Tests/WonderwareHistorianClientTests.cs (extend)

Change the public ctor delegation from the pipe factory to TCP:

public WonderwareHistorianClient(WonderwareHistorianClientOptions options, ILogger<WonderwareHistorianClient>? logger = null)
    : this(options, ct => FrameChannel.DefaultTcpConnectFactory(options, ct), logger)
{
}

Step 1: add a test that constructs the client with Host=127.0.0.1, Port=<loopback> against a loopback TcpListener running the real Hello/HelloAck exchange (reuse/extend FakeSidecarServer to accept a Stream from a TCP socket) and assert a ReadRawAsync round-trips. Step 2: fail. Step 3: flip the ctor. Step 4: pass. Step 5: commit by path — feat(historian-client): default ctor dials TCP.


Task 4: Host config binding (Host/Port/TLS)

Classification: small Estimated implement time: ~3 min Parallelizable with: Task 5

Files:

  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/AlarmHistorianOptions.cs:29 (add fields next to PipeName)
  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs:96-97
  • Test: tests/Server/…Runtime.Tests/… (extend an options-binding test if present; else add a small bind test)

AlarmHistorianOptions: add

public string Host { get; init; } = "localhost";
public int Port { get; init; } = 32569;
public bool UseTls { get; init; }
public string? ServerCertThumbprint { get; init; }

Host/Program.cs:97 — pass them through:

new WonderwareHistorianClientOptions(opts.PipeName, opts.SharedSecret)
{
    Host = opts.Host, Port = opts.Port, UseTls = opts.UseTls, ServerCertThumbprint = opts.ServerCertThumbprint,
}

(PipeName still passes through positionally until Task 7 reshapes the record; harmless.) Add the keys to the Host appsettings.json Historian:Wonderware section (Host/Port/UseTls/ServerCertThumbprint). Steps: TDD the binding (config → options carries Host/Port/UseTls) → green → commit by path — feat(historian-host): bind TCP host/port/tls config.


Task 5: Sidecar TcpFrameServer

Classification: high-risk (transport + security + concurrency, net48) Estimated implement time: ~5 min Parallelizable with: Task 1, Task 2, Task 4 (disjoint project)

Files:

  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/TcpFrameServer.cs
  • Test: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Ipc/TcpRoundTripTests.cs (new; model on the existing Ipc/PipeRoundTripTests.cs)

Mirror PipeServer's shape (same IFrameHandler, same Hello verify minus SID, same RunAsync backoff + MaxConsecutiveFailures=20→throw so Program.Main's exit-2/NSSM semantics are identical). Keep PipeServer.cs for now (deleted Task 7).

using System;
using System.IO;
using System.Net;
using System.Net.Security;
using System.Net.Sockets;
using System.Security.Authentication;
using System.Security.Cryptography.X509Certificates;
using System.Threading;
using System.Threading.Tasks;
using MessagePack;
using Serilog;

namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;

/// <summary>
///     Accepts one TCP client at a time, optionally over TLS, verifies the shared-secret
///     Hello, then dispatches frames to <see cref="IFrameHandler"/>. The TCP replacement for
///     <c>PipeServer</c>; the Windows-SID ACL is replaced by TLS + the shared secret.
/// </summary>
public sealed class TcpFrameServer : IDisposable
{
    private readonly IPAddress _bind;
    private readonly int _port;
    private readonly string _sharedSecret;
    private readonly X509Certificate2? _tlsCert; // null = plaintext
    private readonly ILogger _logger;
    private readonly CancellationTokenSource _cts = new();
    private TcpListener? _listener;

    public TcpFrameServer(IPAddress bind, int port, string sharedSecret, X509Certificate2? tlsCert, ILogger logger)
    {
        _bind = bind ?? throw new ArgumentNullException(nameof(bind));
        _port = port;
        _sharedSecret = sharedSecret ?? throw new ArgumentNullException(nameof(sharedSecret));
        _tlsCert = tlsCert;
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
    }

    /// <summary>The port the listener actually bound (useful when constructed with port 0 in tests).</summary>
    public int BoundPort => ((IPEndPoint)_listener!.LocalEndpoint).Port;

    private void EnsureListening()
    {
        if (_listener is not null) return;
        _listener = new TcpListener(_bind, _port);
        _listener.Start();
    }

    public async Task RunOneConnectionAsync(IFrameHandler handler, CancellationToken ct)
    {
        using var linked = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token, ct);
        EnsureListening();

        // net48 has no AcceptTcpClientAsync(CancellationToken); Stop() unblocks a pending accept.
        using var reg = linked.Token.Register(() => { try { _listener!.Stop(); } catch { /* ignore */ } });
        TcpClient client;
        try { client = await _listener!.AcceptTcpClientAsync().ConfigureAwait(false); }
        catch (ObjectDisposedException) when (linked.Token.IsCancellationRequested) { throw new OperationCanceledException(linked.Token); }
        catch (InvalidOperationException) when (linked.Token.IsCancellationRequested) { throw new OperationCanceledException(linked.Token); }

        using (client)
        {
            client.NoDelay = true;
            Stream stream = client.GetStream();
            SslStream? ssl = null;
            try
            {
                if (_tlsCert is not null)
                {
                    ssl = new SslStream(stream, leaveInnerStreamOpen: false);
                    await ssl.AuthenticateAsServerAsync(_tlsCert, clientCertificateRequired: false,
                        enabledSslProtocols: SslProtocols.Tls12, checkCertificateRevocation: false).ConfigureAwait(false);
                    stream = ssl;
                }

                using var reader = new FrameReader(stream, leaveOpen: true);
                using var writer = new FrameWriter(stream, leaveOpen: true);

                var first = await reader.ReadFrameAsync(linked.Token).ConfigureAwait(false);
                if (first is null || first.Value.Kind != MessageKind.Hello)
                {
                    _logger.Warning("Sidecar TCP first frame was not Hello; dropping");
                    return;
                }
                var hello = MessagePackSerializer.Deserialize<Hello>(first.Value.Body);
                if (!string.Equals(hello.SharedSecret, _sharedSecret, StringComparison.Ordinal))
                {
                    await writer.WriteAsync(MessageKind.HelloAck,
                        new HelloAck { Accepted = false, RejectReason = "shared-secret-mismatch" }, linked.Token).ConfigureAwait(false);
                    _logger.Warning("Sidecar TCP Hello rejected: shared-secret-mismatch");
                    return;
                }
                if (hello.ProtocolMajor != Hello.CurrentMajor)
                {
                    await writer.WriteAsync(MessageKind.HelloAck,
                        new HelloAck { Accepted = false, RejectReason = $"major-version-mismatch-peer={hello.ProtocolMajor}-server={Hello.CurrentMajor}" },
                        linked.Token).ConfigureAwait(false);
                    return;
                }
                await writer.WriteAsync(MessageKind.HelloAck,
                    new HelloAck { Accepted = true, HostName = Environment.MachineName }, linked.Token).ConfigureAwait(false);

                while (!linked.Token.IsCancellationRequested)
                {
                    var frame = await reader.ReadFrameAsync(linked.Token).ConfigureAwait(false);
                    if (frame is null) break;
                    await handler.HandleAsync(frame.Value.Kind, frame.Value.Body, writer, linked.Token).ConfigureAwait(false);
                }
            }
            finally { ssl?.Dispose(); }
        }
    }

    // ---- identical backoff/give-up policy to PipeServer (copy verbatim) ----
    private static readonly TimeSpan[] BackoffSteps =
    {
        TimeSpan.FromMilliseconds(250), TimeSpan.FromMilliseconds(500), TimeSpan.FromSeconds(1),
        TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(4), TimeSpan.FromSeconds(8),
    };
    private const int MaxConsecutiveFailures = 20;

    public async Task RunAsync(IFrameHandler handler, CancellationToken ct)
    {
        var consecutiveFailures = 0;
        while (!ct.IsCancellationRequested)
        {
            try { await RunOneConnectionAsync(handler, ct).ConfigureAwait(false); consecutiveFailures = 0; }
            catch (OperationCanceledException) { break; }
            catch (Exception ex)
            {
                consecutiveFailures++;
                if (consecutiveFailures >= MaxConsecutiveFailures)
                {
                    _logger.Fatal(ex, "Sidecar TCP connection loop failed {Count} consecutive times — giving up so supervisor can restart", consecutiveFailures);
                    throw;
                }
                var delay = BackoffSteps[Math.Min(consecutiveFailures - 1, BackoffSteps.Length - 1)];
                _logger.Error(ex, "Sidecar TCP connection loop error (consecutive failure {Count}/{Max}) — retrying in {Delay}", consecutiveFailures, MaxConsecutiveFailures, delay);
                try { await Task.Delay(delay, ct).ConfigureAwait(false); } catch (OperationCanceledException) { break; }
            }
        }
    }

    public void Dispose() { _cts.Cancel(); try { _listener?.Stop(); } catch { } _cts.Dispose(); }
}

Tests (TcpRoundTripTests): with a fake IFrameHandler that echoes a known ReadRawReply:

  1. plaintext round-trip — server on 127.0.0.1:0, a raw TcpClient does Hello (good secret) → HelloAck.Accepted, then a ReadRaw → echoed reply.
  2. TLS round-trip — construct with a self-signed cert; client SslStream (pin thumbprint) → same exchange.
  3. bad secret — Hello with wrong secret → HelloAck.Accepted == false, RejectReason == "shared-secret-mismatch".
  4. single-active serial accept — while one client is connected, a second connect does not get served until the first disconnects (assert the second's Hello completes only after the first closes).

Run dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests --filter TcpRoundTrip. Commit by path — feat(historian-sidecar): TcpFrameServer (TCP + optional TLS).


Task 6: Sidecar Program.cs — TCP bootstrap + env

Classification: high-risk (process entry, env contract) Estimated implement time: ~4 min Parallelizable with: none (needs Task 5)

Files:

  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Program.cs
  • Modify: tests/.../Driver.Historian.Wonderware.Tests/ProgramSmokeTests.cs

Changes:

  • Remove the OTOPCUA_ALLOWED_SID read + new SecurityIdentifier(...) + the using System.Security.Principal;.
  • Read new env: OTOPCUA_HISTORIAN_TCP_PORT (int, default 32569), OTOPCUA_HISTORIAN_BIND (IPAddress.Parse, default IPAddress.Any), OTOPCUA_HISTORIAN_TLS_ENABLED (== "true"), and when TLS on, load the cert from OTOPCUA_HISTORIAN_TLS_CERT (pfx path or store thumbprint) + OTOPCUA_HISTORIAN_TLS_CERT_PASSWORD into an X509Certificate2? (null when TLS off). Keep OTOPCUA_HISTORIAN_SECRET/OTOPCUA_HISTORIAN_ENABLED + the SDK vars.
  • Preserve the ENABLED!=true idle branch (now log "tcp-only idle" wording; still WaitOne() → return 0).
  • Swap line ~62/64-65:
using var server = new TcpFrameServer(bind, tcpPort, sharedSecret, tlsCert, Log.Logger);
Log.Information("Wonderware historian sidecar serving — bind={Bind} port={Port} tls={Tls}", bind, tcpPort, tlsCert is not null);
try { server.RunAsync(handler, cts.Token).GetAwaiter().GetResult(); }
catch (OperationCanceledException) { }
  • Update the class doc comment (drop "named-pipe"/"allowed-SID"; say "hosts a TCP server").

Update ProgramSmokeTests to the new env contract (no OTOPCUA_ALLOWED_SID; set OTOPCUA_HISTORIAN_TCP_PORT). Build the sidecar project → green. Commit by path — feat(historian-sidecar): TCP bootstrap + env, drop allowed-SID.


Task 7: Remove dead pipe code + finalize options shape

Classification: standard (deletion-heavy; must keep build green) Estimated implement time: ~4 min Parallelizable with: none (needs Tasks 3, 4, 6)

Files:

  • Delete: src/Drivers/…Driver.Historian.Wonderware/Ipc/PipeServer.cs, …/Ipc/PipeAcl.cs
  • Delete: tests/.../Driver.Historian.Wonderware.Tests/Ipc/PipeServerSidRejectTests.cs, Ipc/PipeRoundTripTests.cs (superseded by TcpRoundTripTests)
  • Modify: …Client/Internal/FrameChannel.cs — delete DefaultNamedPipeConnectFactory + using System.IO.Pipes;
  • Modify: WonderwareHistorianClientOptions.cs — reshape record to (string Host, int Port, string SharedSecret, string PeerName="OtOpcUa", TimeSpan? ConnectTimeout=null, TimeSpan? CallTimeout=null); drop PipeName; keep UseTls/ServerCertThumbprint/ProbeTimeoutSeconds as init props.
  • Modify call sites that passed PipeName: Host/Program.cs:97 (new WonderwareHistorianClientOptions(opts.Host, opts.Port, opts.SharedSecret) { UseTls = …, ServerCertThumbprint = … }), AlarmHistorianOptions.cs (drop PipeName), and any test constructing options.

Step — grep gate: grep -rn "PipeName\|PipeServer\|PipeAcl\|System.IO.Pipes\|DefaultNamedPipeConnectFactory\|OTOPCUA_ALLOWED_SID" src tests must return zero non-comment hits before commit. Run the full two suites (Client.Tests + Driver.Historian.Wonderware.Tests) green. Commit by path — refactor(historian): remove named-pipe transport.


Task 8: Deploy scripts — env block + firewall + cert

Classification: standard Estimated implement time: ~4 min Parallelizable with: Task 9, Task 10

Files:

  • Modify: scripts/install/Install-Services.ps1 (the $historianEnv array, lines ~100-108)

  • Modify: scripts/install/Refresh-Services.ps1 (the Step-5 env patch; the Step 4b assertion is unchanged)

  • $historianEnv: drop OTOPCUA_ALLOWED_SID=$sid; add OTOPCUA_HISTORIAN_TCP_PORT=$HistorianTcpPort, OTOPCUA_HISTORIAN_BIND=$HistorianBind, OTOPCUA_HISTORIAN_TLS_ENABLED=$($HistorianUseTls.ToString().ToLower()), and (when TLS) OTOPCUA_HISTORIAN_TLS_CERT=…. Add params [int]$HistorianTcpPort = 32569, [string]$HistorianBind='0.0.0.0', [switch]$HistorianUseTls, [string]$HistorianTlsCertThumbprint.

  • Add a Windows Firewall rule for the port: New-NetFirewallRule -DisplayName "OtOpcUa Wonderware Historian (TCP $HistorianTcpPort)" -Direction Inbound -Action Allow -Protocol TCP -LocalPort $HistorianTcpPort (idempotent: remove-then-add or check Get-NetFirewallRule).

  • A comment block on cert provisioning for prod (store thumbprint or pfx in C:\ProgramData\OtOpcUa\pki). The SharedSecret handling is unchanged (still generated/printed, never written to a tracked file).

No automated test (PowerShell). Validate with pwsh -NoProfile -Command "[Parser]::ParseFile(...)" parse-check on both scripts (like the Step 4b commit). Commit by path — feat(install): historian TCP env + firewall rule.


Task 9: AdminUI Test-Connect probe → host/port/TLS

Classification: small Estimated implement time: ~3 min Parallelizable with: Task 8, Task 10

Files:

  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/HistorianWonderwareAddressBuilder.cs
  • Modify: the probe path that builds WonderwareHistorianClientOptions for the Test-Connect (find via grep -rn "WonderwareHistorianClientOptions\|ProbeTimeoutSeconds" src/Server/ZB.MOM.WW.OtOpcUa.AdminUI).

Replace pipe-name input with Host/Port/UseTls/(optional)Thumbprint fields; build the probe options with those. No bUnit (per repo convention — verified live in Task 11). Build AdminUI green. Commit by path — feat(adminui): historian probe uses TCP host/port/tls.


Task 10: Docs

Classification: small Estimated implement time: ~4 min Parallelizable with: Task 8, Task 9

Files:

  • Modify: docs/drivers/Historian.Wonderware.md (rewrite the Architecture ASCII diagram + "named pipe / shared secret + allowed-SID" text → TCP + optional TLS + shared secret; note the remote-host capability)
  • Modify: docs/ServiceHosting.md, docs/AlarmHistorian.md (any named-pipe references → TCP)
  • Optionally: a short note in CLAUDE.md if it gains a historian transport line (it currently has none — skip unless adding).

Grep gate: grep -rni "named pipe\|allowed-sid\|OTOPCUA_ALLOWED_SID\|PipeName" docs/drivers/Historian.Wonderware.md docs/ServiceHosting.md docs/AlarmHistorian.md → no stale references. Commit by path — docs(historian): TCP transport.


Task 11: Verification (build + test + live)

Classification: verification Estimated implement time: ~3 min agent + user-driven live Parallelizable with: none (needs all)

Agent:

  • dotnet build ZB.MOM.WW.OtOpcUa.slnx → 0 errors.
  • dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests → green; plus …Runtime.Tests if Task 4 touched a bound options test.
  • Final grep gate (Task 7) clean.

Live (user-driven — agent does NOT sign in):

  1. Publish the sidecar to the VM (the now-master checkout) with TCP env: OTOPCUA_HISTORIAN_TCP_PORT=32569, TLS off; open the firewall port (Task 8 rule). Restart OtOpcUaWonderwareHistorian; confirm log "serving — bind=… port=32569 tls=False".
  2. Point a (MacBook-docker) OtOpcUa Historian:Wonderware config at Host=<VM-ip> Port=32569 UseTls=false + the matching SharedSecret; do a ReadRaw (live samples) and a WriteAlarmEvents round-trip.
  3. Flip OTOPCUA_HISTORIAN_TLS_ENABLED=true (provision a cert) + client UseTls=true (+ thumbprint pin); re-verify the read.

Done = build clean + dotnet test green + live read/write pass (plaintext, then TLS).

Then run superpowers-extended-cc:finishing-a-development-branch.


Dependency graph

T0 ─┬─ T1 ─ T2 ─ T3 ─┐
    │   └── T4 ───────┤
    └─ T5 ─ T6 ───────┤
                      T7 ─┬─ T8 ─┐
                          ├─ T9 ─┤
                          └─ T10 ┤
                                 T11

T1∥T5, T2∥T5, T4∥T5 (disjoint projects). T8∥T9∥T10. T3 needs T2; T4 needs T1; T6 needs T5; T7 needs T3+T4+T6; T8/T9/T10 need T7; T11 needs all.