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

478 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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* `Stream`s — `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):
```bash
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 HEAD``feat/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:
```csharp
/// <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.cs``Internal/FrameChannel.cs` (rename the class `PipeChannel``FrameChannel`; 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;`):
```csharp
/// <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:
```csharp
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
```csharp
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:
```csharp
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).
```csharp
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:
```csharp
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.