Files
lmxopcua/docs/plans/2026-06-26-otopcua-historian-gateway-integration.md
T

1369 lines
86 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.
# OtOpcUa ↔ HistorianGateway Integration — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
**Goal:** Make HistorianGateway the sole historian read/write backend for the OtOpcUa OPC UA server — replacing the bespoke Wonderware TCP/ArchestrA sidecar — via a new gateway-backed `IHistorianDataSource` + `IAlarmHistorianWriter` + `IHistorianProvisioning`, a continuous-historization recorder, and DI factory swaps.
**Architecture:** A new `ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway` driver project consumes the Gitea-feed `ZB.MOM.WW.HistorianGateway.Client` package (gRPC over `historian_gateway.v1`) behind a thin `IHistorianGatewayClient` seam so every adapter is unit-testable against a fake. Pure, matrix-guarded mappers (design §6) translate OPC UA / driver shapes to gateway proto shapes and back; the read adapter swaps into the existing `AddServerHistorian` factory (the independently-shippable read cutover), the alarm writer swaps into `AddAlarmHistorian` (behind the existing `SqliteStoreAndForwardSink`), and a new Akka recorder taps `DependencyMuxActor` value changes into a crash-safe FasterLog outbox that drains to `WriteLiveValues`. An `EnsureTags` provisioning hook fires non-blocking from `AddressSpaceApplier.Apply()`.
**Tech Stack:** .NET 10, OPC Foundation UA .NET Standard, Akka.NET actors, Microsoft.FASTER.Core (FasterLog outbox), ZB.MOM.WW.HistorianGateway.Client (gRPC), xUnit.
---
## Prerequisites & Constraints
**Cross-plan dependency (hard gate).** This plan DEPENDS ON the sibling gateway-client plan
(`docs/plans/2026-06-26-historian-gateway-client.md`) having already **published
`ZB.MOM.WW.HistorianGateway.Client` and `ZB.MOM.WW.HistorianGateway.Contracts` to the Gitea feed**
(`dohertj2-gitea`). Phase A consumes those packages; do not start until they resolve. Confirm with:
`dotnet package search ZB.MOM.WW.HistorianGateway.Client --source dohertj2-gitea`.
**Gateway deployment prerequisites (no code here — runtime config of the target gateway).**
The gateway OtOpcUa points at MUST run with:
- `RuntimeDb:Enabled=true` — enables the `WriteLiveValues` SQL live path (continuous historization).
- `RuntimeDb:EventReadsEnabled=true` — enables `ReadEvents` from `Runtime.dbo.Events` (alarm-history reads; C2 SQL workaround).
- An API key carrying scopes `historian:read`, `historian:write`, `historian:tags:write`.
The source-name filter on the SQL `ReadEvents` path is delivered by the gateway-client plan (its Task 14 / design §5.3); until it lands, `ReadEventsAsync(sourceName, …)` is time-range-only and filters client-side. T11 notes this.
**Engineering constraints (every task).**
- **Zero new warnings.** `Directory.Build.props` enforces `Nullable=enable`, `Platforms=x64`, `PlatformTarget=x64`, and treats new warnings as build breaks. **Fix, never suppress.**
- **Secrets via environment only.** The gateway API key, any connection strings, and TLS material are supplied via env vars (e.g. `ServerHistorian__ApiKey`). The committed appsettings defaults are blank or dev-only placeholders. Never commit a real key.
- **Redaction.** No hostnames, credentials, API keys, or tag values in error messages or logs by default. Map gateway/gRPC exceptions to safe messages — mirror the existing `WonderwareHistorianClient` posture.
- **Gitea, not GitHub.** `origin` is Gitea; `gh` will not work. Use `git push` to `origin`; open PRs via the Gitea API with `GITEA_TOKEN`.
- **Branch isolation.** OtOpcUa's working tree is currently DIRTY from unrelated in-flight work. This plan runs on its OWN fresh branch off OtOpcUa `main`:
```bash
cd ~/Desktop/OtOpcUa
git stash list # confirm you understand existing state; do NOT touch it
git fetch origin
git switch -c feat/historian-gateway-backend origin/main
```
Do not entangle with the dirty tree. If `origin/main` is unavailable, branch off the local `main` after confirming it is clean of the in-flight work.
- **Commit-message trailer.** Every commit message ends with:
`Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii`
- **Plan relocation.** This file (and its `.tasks.json`) live in the HistorianGateway repo while authored; when execution starts they relocate into `~/Desktop/OtOpcUa/docs/plans/`. All OtOpcUa paths below are absolute under `~/Desktop/OtOpcUa`.
- **Build/test anchors.** Solution is `~/Desktop/OtOpcUa/ZB.MOM.WW.OtOpcUa.slnx`. Build: `dotnet build ZB.MOM.WW.OtOpcUa.slnx`. Test a single class: `dotnet test --filter "FullyQualifiedName~<ClassName>"`.
**Reference implementations to mirror (read before touching the matching task).**
- Quality→OPC-UA-StatusCode: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Internal/QualityMapper.cs` (port the switch verbatim; the gateway `HistorianSample.opc_quality` is the same OPC DA quality byte).
- Aggregate-mode mapping + Total handling + AlignAtTime + null→BadNoData: `…Driver.Historian.Wonderware.Client/WonderwareHistorianClient.cs` (`MapAggregate`, `ReadProcessedAsync`, `AlignAtTimeSnapshots`, `ToAggregateSnapshots`).
- Health-counter discipline (single `_healthLock`, `TotalSuccesses+TotalFailures==TotalQueries`): same file, `GetHealthSnapshot` / `RecordOutcome`.
- FasterLog outbox: `~/Desktop/HistorianGateway/src/ZB.MOM.WW.HistorianGateway.Server/Historian/FasterLogOutboxStore.cs` (PerEntry/Periodic commit, append/peek/remove-truncate, RecoverState).
**The `IHistorianGatewayClient` seam.** All OtOpcUa adapters depend on a thin interface
`IHistorianGatewayClient` (defined in T1, in the Gateway driver project) that exposes exactly the
gateway operations OtOpcUa needs, in proto-typed terms:
```csharp
// uses ZB.MOM.WW.HistorianGateway.Contracts.Grpc types from the Contracts package
public interface IHistorianGatewayClient : IAsyncDisposable
{
IAsyncEnumerable<HistorianSample> ReadRawAsync(string tag, DateTime startUtc, DateTime endUtc, int maxValues, CancellationToken ct);
IAsyncEnumerable<HistorianAggregateSample> ReadAggregateAsync(string tag, DateTime startUtc, DateTime endUtc, RetrievalMode mode, TimeSpan interval, CancellationToken ct);
Task<IReadOnlyList<HistorianSample>> ReadAtTimeAsync(string tag, IReadOnlyList<DateTime> timestampsUtc, CancellationToken ct);
IAsyncEnumerable<HistorianEvent> ReadEventsAsync(string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken ct);
Task<WriteAck> WriteLiveValuesAsync(string tag, IReadOnlyList<HistorianLiveValue> values, CancellationToken ct);
Task<WriteAck> SendEventAsync(HistorianEvent evt, CancellationToken ct);
Task<TagOperationResults> EnsureTagsAsync(IReadOnlyList<HistorianTagDefinition> definitions, CancellationToken ct);
Task<bool> ProbeAsync(CancellationToken ct);
Task<ConnectionStatus> GetConnectionStatusAsync(CancellationToken ct);
}
```
The concrete `HistorianGatewayClientAdapter : IHistorianGatewayClient` (wrapping the package's
`HistorianGatewayClient`) lands in T10 where the factory wires it. Every other adapter/recorder is
TDD'd against an in-memory `FakeHistorianGatewayClient` test double.
---
### Task 1: Consume the gateway packages + scaffold the Gateway driver project
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** none (every later task builds on this project)
**Files:**
- Modify `/Users/dohertj2/Desktop/OtOpcUa/NuGet.config`
- Create `/Users/dohertj2/Desktop/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.csproj`
- Create `/Users/dohertj2/Desktop/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/IHistorianGatewayClient.cs`
- Create `/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests.csproj`
- Create `/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/FakeHistorianGatewayClient.cs`
- Create `/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/ProjectSmokeTests.cs`
- Modify `/Users/dohertj2/Desktop/OtOpcUa/ZB.MOM.WW.OtOpcUa.slnx`
**Step 1: failing test** — `ProjectSmokeTests.cs`:
```csharp
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests;
public sealed class ProjectSmokeTests
{
[Fact]
public void GatewayClientSeam_IsReferenceable()
{
// Compiles only if the project references the Contracts package and the seam exists.
var t = typeof(IHistorianGatewayClient);
Assert.Equal("IHistorianGatewayClient", t.Name);
}
}
```
`FakeHistorianGatewayClient.cs` implements `IHistorianGatewayClient` with settable result fields + recorded-call lists (the reusable double for all later tasks): each method returns from a public `Func`/queue field defaulting to empty, and records its arguments.
**Step 2: run, expect FAIL** — `dotnet test ZB.MOM.WW.OtOpcUa.slnx --filter "FullyQualifiedName~ProjectSmokeTests"` → FAILS to compile/restore (project/package/seam absent).
**Step 3: implement**
1. In `NuGet.config`, add to the `dohertj2-gitea` `<packageSource>` block:
```xml
<package pattern="ZB.MOM.WW.HistorianGateway.Contracts" />
<package pattern="ZB.MOM.WW.HistorianGateway.Client" />
```
2. New `…Driver.Historian.Gateway.csproj` (mirror a sibling driver csproj's TFM/props; do NOT set its own `Nullable`/`Platforms` — `Directory.Build.props` supplies them):
- `<PackageReference Include="ZB.MOM.WW.HistorianGateway.Client" Version="0.1.0" />` (resolve the actual published version with `dotnet package search`; the Client transitively brings `.Contracts`).
- `<ProjectReference>` to `Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions` and `Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian`.
3. `IHistorianGatewayClient.cs` — the interface exactly as in Prerequisites (proto-typed; `using ZB.MOM.WW.HistorianGateway.Contracts.Grpc;`).
4. Test csproj: references the Gateway driver project + `Microsoft.NET.Test.Sdk`, `xunit`, `xunit.runner.visualstudio` (copy versions from a sibling `*.Tests.csproj`).
5. `FakeHistorianGatewayClient` as described.
6. Add both projects to the solution: `dotnet sln ZB.MOM.WW.OtOpcUa.slnx add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.csproj tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests.csproj`.
**Step 4: run, expect PASS** — same filter → PASS; `dotnet build ZB.MOM.WW.OtOpcUa.slnx` clean (0 warnings).
**Step 5: commit**
```bash
git add NuGet.config ZB.MOM.WW.OtOpcUa.slnx src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests
git commit -m "feat(historian-gateway): scaffold Gateway driver project + consume client package
Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii"
```
---
### Task 2: `HistoryAggregateType` → `RetrievalMode` mapper (matrix-guarded)
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 3, Task 4, Task 5, Task 6
**Files:**
- Create `/Users/dohertj2/Desktop/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/AggregateModeMapper.cs`
- Create `/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/AggregateModeMapperTests.cs`
**Step 1: failing test** — exhaustive per-member + matrix guard:
```csharp
using ZB.MOM.WW.OtOpcUa.Core.Abstractions; // HistoryAggregateType
using ZB.MOM.WW.HistorianGateway.Contracts.Grpc; // RetrievalMode
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests.Mapping;
public sealed class AggregateModeMapperTests
{
[Theory]
[InlineData(HistoryAggregateType.Average, RetrievalMode.TimeWeightedAverage)]
[InlineData(HistoryAggregateType.Minimum, RetrievalMode.MinimumWithTime)]
[InlineData(HistoryAggregateType.Maximum, RetrievalMode.MaximumWithTime)]
[InlineData(HistoryAggregateType.Total, RetrievalMode.Integral)]
[InlineData(HistoryAggregateType.Count, RetrievalMode.Counter)]
public void Maps_each_aggregate(HistoryAggregateType a, RetrievalMode expected)
=> Assert.Equal(expected, AggregateModeMapper.ToRetrievalMode(a));
[Fact] // matrix guard: a new HistoryAggregateType member must fail here
public void Every_aggregate_member_is_mapped()
{
foreach (var a in Enum.GetValues<HistoryAggregateType>())
_ = AggregateModeMapper.ToRetrievalMode(a); // must not throw for any defined member
}
}
```
**Step 2: run, expect FAIL** — `dotnet test --filter "FullyQualifiedName~AggregateModeMapperTests"` → FAILS (mapper absent).
**Step 3: implement** — `AggregateModeMapper.ToRetrievalMode(HistoryAggregateType)`: a `switch` expression mapping the five members per the table; the `_ =>` arm throws `ArgumentOutOfRangeException` (so a future enum member fails the matrix guard, not silently mis-maps). Verify against `WonderwareHistorianClient.MapAggregate`: Average/Min/Max line up; the Wonderware path had no native Total (derived Average×interval) and used `ValueCount` for Count — the gateway's native `RetrievalMode.Integral` (Total) and `RetrievalMode.Counter` (Count) replace those client-side workarounds, so the gateway path is a strict improvement. Add an XML doc note recording that Total/Count are now native modes (no client-side scaling).
**Step 4: run, expect PASS.**
**Step 5: commit**
```bash
git commit -am "feat(historian-gateway): HistoryAggregateType->RetrievalMode mapper (matrix-guarded)
Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii"
```
---
### Task 3: `DriverDataType` → `HistorianDataType` mapper with write-gap fallbacks (matrix-guarded)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 2, Task 4, Task 5, Task 6
**Files:**
- Create `/Users/dohertj2/Desktop/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/HistorianTypeMapper.cs`
- Create `/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/HistorianTypeMapperTests.cs`
**Step 1: failing test** — per-member mapping + deferred-throws + matrix guard:
```csharp
using ZB.MOM.WW.OtOpcUa.Core.Abstractions; // DriverDataType
using ZB.MOM.WW.HistorianGateway.Contracts.Grpc; // HistorianDataType
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests.Mapping;
public sealed class HistorianTypeMapperTests
{
[Theory]
[InlineData(DriverDataType.Boolean, HistorianDataType.Int1)]
[InlineData(DriverDataType.Int16, HistorianDataType.Int2)]
[InlineData(DriverDataType.Int32, HistorianDataType.Int4)]
[InlineData(DriverDataType.Int64, HistorianDataType.Int8)]
[InlineData(DriverDataType.UInt16, HistorianDataType.Uint4)] // fallback: UInt2 write deferred upstream
[InlineData(DriverDataType.UInt32, HistorianDataType.Uint4)]
[InlineData(DriverDataType.UInt64, HistorianDataType.Uint8)]
[InlineData(DriverDataType.Float32, HistorianDataType.Float)]
[InlineData(DriverDataType.Float64, HistorianDataType.Double)]
public void Maps_writable_numeric_types(DriverDataType d, HistorianDataType expected)
=> Assert.Equal(expected, HistorianTypeMapper.ToHistorianDataType(d));
[Theory]
[InlineData(DriverDataType.String)]
[InlineData(DriverDataType.DateTime)]
[InlineData(DriverDataType.Reference)]
public void Deferred_types_throw_NotSupported_with_clear_message(DriverDataType d)
{
var ex = Assert.Throws<NotSupportedException>(() => HistorianTypeMapper.ToHistorianDataType(d));
Assert.Contains("not historized in v1", ex.Message); // human-actionable, no tag value leaked
}
[Fact] // matrix guard: a new DriverDataType member must be classified (mapped or explicitly deferred)
public void Every_DriverDataType_member_is_classified()
{
foreach (var d in Enum.GetValues<DriverDataType>())
{
try { _ = HistorianTypeMapper.ToHistorianDataType(d); }
catch (NotSupportedException) { /* explicitly deferred — acceptable */ }
// any OTHER exception (e.g. ArgumentOutOfRangeException from an unhandled new member) fails the test
}
}
}
```
> Note: protobuf C# generates `HistorianDataType.Uint4` / `Uint8` (single capital U). Confirm the generated casing against the resolved Contracts package and align the `InlineData`.
**Step 2: run, expect FAIL** — `dotnet test --filter "FullyQualifiedName~HistorianTypeMapperTests"` → FAILS.
**Step 3: implement** — `HistorianTypeMapper.ToHistorianDataType(DriverDataType)`: `switch` per the §6 table; the three deferred members throw `NotSupportedException` with a message like `$"DriverDataType.{d} is not historized in v1 (string/datetime/reference writes are deferred — gated on the analog SQL write path)."` (no tag name/value in the message). The default arm also throws `NotSupportedException` referencing the unmapped member, so the matrix guard catches a future enum addition. Add a same-file `bool IsHistorizable(DriverDataType)` helper (true for the nine numeric members) — T15's provisioning hook uses it to skip deferred types without catching exceptions.
**Step 4: run, expect PASS.**
**Step 5: commit**
```bash
git commit -am "feat(historian-gateway): DriverDataType->HistorianDataType mapper + write-gap fallbacks (matrix-guarded)
Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii"
```
---
### Task 4: `HistorianSample`/`HistorianAggregateSample` → `DataValueSnapshot` + quality mapper
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 2, Task 3, Task 5, Task 6
**Files:**
- Create `/Users/dohertj2/Desktop/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/GatewayQualityMapper.cs`
- Create `/Users/dohertj2/Desktop/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/SampleMapper.cs`
- Create `/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/GatewayQualityMapperTests.cs`
- Create `/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/SampleMapperTests.cs`
**Step 1: failing test** — quality parity (port the Wonderware table) + sample/aggregate shape:
```csharp
public sealed class GatewayQualityMapperTests
{
[Theory]
[InlineData(192, 0x00000000u)] // Good
[InlineData(216, 0x00D80000u)] // Good_LocalOverride
[InlineData(64, 0x40000000u)] // Uncertain
[InlineData(0, 0x80000000u)] // Bad
[InlineData(8, 0x808A0000u)] // Bad_NotConnected
[InlineData(255, 0x00000000u)] // >=192 bucket
[InlineData(100, 0x40000000u)] // >=64 bucket
[InlineData(1, 0x80000000u)] // bad bucket
public void Maps_opc_quality_byte(byte q, uint expected)
=> Assert.Equal(expected, GatewayQualityMapper.Map(q));
}
public sealed class SampleMapperTests
{
[Fact]
public void Numeric_sample_maps_value_quality_and_timestamps()
{
var s = new HistorianSample { Tag = "T", NumericValue = 12.5,
Quality = 192, OpcQuality = 192, Timestamp = Ts(2026,1,1,0,0,0) };
var snap = SampleMapper.ToSnapshot(s);
Assert.Equal(12.5, Assert.IsType<double>(snap.Value));
Assert.Equal(0x00000000u, snap.StatusCode);
Assert.Equal(DateTimeKind.Utc, snap.SourceTimestampUtc!.Value.Kind);
}
[Fact]
public void String_sample_carries_string_value()
{
var s = new HistorianSample { Tag = "T", StringValue = "abc", OpcQuality = 192, Timestamp = Ts(2026,1,1,0,0,0) };
Assert.Equal("abc", SampleMapper.ToSnapshot(s).Value);
}
[Fact]
public void Aggregate_null_value_is_BadNoData()
{
var a = new HistorianAggregateSample { Tag = "T", /* Value unset */ EndTime = Ts(2026,1,1,0,0,0) };
var snap = SampleMapper.ToAggregateSnapshot(a);
Assert.Equal(0x800E0000u, snap.StatusCode); // BadNoData
Assert.Null(snap.Value);
}
// Ts(...) builds a Google.Protobuf.WellKnownTypes.Timestamp from UTC parts.
}
```
**Step 2: run, expect FAIL** — `dotnet test --filter "FullyQualifiedName~SampleMapperTests|FullyQualifiedName~GatewayQualityMapperTests"` → FAILS.
**Step 3: implement**
- `GatewayQualityMapper.Map(byte)` — **byte-identical port** of `…Wonderware.Client/Internal/QualityMapper.cs` (copy the switch verbatim; add an XML doc cross-reference to that origin so a future quality-table change stays in parity).
- `SampleMapper.ToSnapshot(HistorianSample)` → `DataValueSnapshot(Value, StatusCode, SourceTimestampUtc, ServerTimestampUtc)`:
- `Value`: `s.NumericValue` (when present via proto3 optional `HasNumericValue`) boxed as `double`, else `s.StringValue` when present, else `null`.
- `StatusCode`: `GatewayQualityMapper.Map((byte)s.OpcQuality)` (prefer `opc_quality`; if zero/unset and `quality` carries the OPC-DA byte, fall back to `quality` — match whatever the gateway populates; document the choice).
- `SourceTimestampUtc`: `s.Timestamp.ToDateTime()` (UTC kind).
- `ServerTimestampUtc`: `DateTime.UtcNow`.
- `SampleMapper.ToAggregateSnapshot(HistorianAggregateSample)` → null aggregate value ⇒ `StatusCode 0x800E0000` (BadNoData), non-null ⇒ Good (`0x00000000`) with the value; `SourceTimestampUtc` ← the bucket end/start timestamp (match the Wonderware `ToAggregateSnapshots` convention — it stamps the bucket timestamp). Provide `IReadOnlyList<>` batch helpers too.
**Step 4: run, expect PASS.**
**Step 5: commit**
```bash
git commit -am "feat(historian-gateway): sample/aggregate->DataValueSnapshot + quality mapper (Wonderware parity)
Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii"
```
---
### Task 5: `HistorianEvent` → `HistoricalEvent` mapper (+ severity from properties)
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 2, Task 3, Task 4, Task 6
**Files:**
- Create `/Users/dohertj2/Desktop/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/EventMapper.cs`
- Create `/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/EventMapperTests.cs`
**Step 1: failing test:**
```csharp
public sealed class EventMapperTests
{
[Fact]
public void Maps_core_fields_and_times()
{
var e = new HistorianEvent { Id = "E1", SourceName = "Pump1",
EventTime = Ts(2026,1,1,0,0,0), ReceivedTime = Ts(2026,1,1,0,0,5) };
e.Properties["Message"] = "High temp";
e.Properties["Severity"] = "700";
var h = EventMapper.ToHistoricalEvent(e);
Assert.Equal("E1", h.EventId);
Assert.Equal("Pump1", h.SourceName);
Assert.Equal("High temp", h.Message);
Assert.Equal((ushort)700, h.Severity);
Assert.Equal(DateTimeKind.Utc, h.EventTimeUtc.Kind);
}
[Theory]
[InlineData("Priority", "999", 999)]
[InlineData("Severity", "0", 1)] // clamp to OPC UA min 1
[InlineData("Severity", "5000", 1000)] // clamp to OPC UA max 1000
[InlineData(null, null, 1)] // missing → default min severity
public void Severity_parsed_and_clamped(string? key, string? val, int expected)
{
var e = new HistorianEvent { Id = "E", EventTime = Ts(2026,1,1,0,0,0), ReceivedTime = Ts(2026,1,1,0,0,0) };
if (key is not null) e.Properties[key] = val!;
Assert.Equal((ushort)expected, EventMapper.ToHistoricalEvent(e).Severity);
}
}
```
**Step 2: run, expect FAIL** — `dotnet test --filter "FullyQualifiedName~EventMapperTests"` → FAILS.
**Step 3: implement** — `EventMapper.ToHistoricalEvent(HistorianEvent)`:
- `EventId` ← `e.Id`; `SourceName` ← `e.SourceName`; `EventTimeUtc` ← `e.EventTime.ToDateTime()`; `ReceivedTimeUtc` ← `e.ReceivedTime.ToDateTime()`.
- `Message` ← `Properties["Message"]` if present, else `e.Type` (best-effort render); never null-crash.
- `Severity` ← parse `Properties["Severity"]` else `Properties["Priority"]`; clamp to `[1,1000]`; missing/unparseable ⇒ `1`. Return `(ushort)`.
- Add a batch helper `IReadOnlyList<HistoricalEvent> ToHistoricalEvents(IEnumerable<HistorianEvent>)`.
**Step 4: run, expect PASS.**
**Step 5: commit**
```bash
git commit -am "feat(historian-gateway): HistorianEvent->HistoricalEvent mapper (+ clamped severity)
Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii"
```
---
### Task 6: `AlarmHistorianEvent` → `HistorianEvent` mapper (SendEvent)
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 2, Task 3, Task 4, Task 5
**Files:**
- Create `/Users/dohertj2/Desktop/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/AlarmEventMapper.cs`
- Create `/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/AlarmEventMapperTests.cs`
**Step 1: failing test:**
```csharp
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; // AlarmHistorianEvent
using ZB.MOM.WW.OtOpcUa.Core.Abstractions; // AlarmSeverity
public sealed class AlarmEventMapperTests
{
[Fact]
public void Maps_source_time_type_and_rich_properties()
{
var a = new AlarmHistorianEvent("A1","Area/Line/Pump1","HiHi","LimitAlarm",
AlarmSeverity.High, "Activated", "Temp high", "operator1", "ack note",
new DateTime(2026,1,1,0,0,0,DateTimeKind.Utc));
var e = AlarmEventMapper.ToHistorianEvent(a);
Assert.Equal("Area/Line/Pump1", e.SourceName);
Assert.Equal("LimitAlarm", e.Type);
Assert.Equal(new DateTime(2026,1,1,0,0,0,DateTimeKind.Utc), e.EventTime.ToDateTime());
Assert.Equal("HiHi", e.Properties["AlarmName"]);
Assert.Equal("Activated", e.Properties["EventKind"]);
Assert.Equal("High", e.Properties["Severity"]);
Assert.Equal("operator1", e.Properties["User"]);
Assert.Equal("ack note", e.Properties["Comment"]);
Assert.Equal("Temp high", e.Properties["Message"]);
}
[Fact]
public void Null_comment_is_omitted_not_null()
{
var a = new AlarmHistorianEvent("A","S","N","DiscreteAlarm",AlarmSeverity.Low,"Cleared","m","system",null,
new DateTime(2026,1,1,0,0,0,DateTimeKind.Utc));
Assert.False(AlarmEventMapper.ToHistorianEvent(a).Properties.ContainsKey("Comment"));
}
}
```
**Step 2: run, expect FAIL** — `dotnet test --filter "FullyQualifiedName~AlarmEventMapperTests"` → FAILS.
**Step 3: implement** — `AlarmEventMapper.ToHistorianEvent(AlarmHistorianEvent)`:
- `Id` ← `a.AlarmId` (or `Guid.NewGuid().ToString("N")` if blank); `SourceName` ← `a.EquipmentPath`; `EventTime` ← `Timestamp.FromDateTime(DateTime.SpecifyKind(a.TimestampUtc, DateTimeKind.Utc))`; `ReceivedTime` ← same as event time (server re-stamps on the SQL path); `Type` ← `a.AlarmTypeName`.
- `Properties` map: `AlarmName`, `EventKind`, `Severity` (`a.Severity.ToString()`), `User`, `Message`; add `Comment` only when non-null. Proto `map<string,string>` values must be non-null — never insert a null.
**Step 4: run, expect PASS.**
**Step 5: commit**
```bash
git commit -am "feat(historian-gateway): AlarmHistorianEvent->HistorianEvent mapper (SendEvent properties)
Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii"
```
---
### Task 7: `GatewayHistorianDataSource` — ReadRaw / ReadProcessed / ReadAtTime
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (consumes T2 + T4; gates T8/T10/T11)
**Files:**
- Create `/Users/dohertj2/Desktop/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianDataSource.cs`
- Create `/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayHistorianDataSourceTests.cs`
**Step 1: failing test** — against `FakeHistorianGatewayClient`:
```csharp
public sealed class GatewayHistorianDataSourceTests
{
[Fact]
public async Task ReadRaw_maps_samples_and_passes_args()
{
var fake = new FakeHistorianGatewayClient();
fake.RawSamples = new[] {
new HistorianSample { Tag="T", NumericValue=1.0, OpcQuality=192, Timestamp=Ts(2026,1,1,0,0,0) },
new HistorianSample { Tag="T", NumericValue=2.0, OpcQuality=0, Timestamp=Ts(2026,1,1,0,0,1) },
};
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
var r = await ds.ReadRawAsync("T", DateTime.UtcNow.AddMinutes(-5), DateTime.UtcNow, 100, default);
Assert.Equal(2, r.Samples.Count);
Assert.Equal(0x80000000u, r.Samples[1].StatusCode); // Bad from quality 0
Assert.Equal("T", fake.LastReadRawTag);
Assert.Equal(100, fake.LastReadRawMaxValues);
}
[Fact]
public async Task ReadProcessed_uses_aggregate_mode_mapping()
{
var fake = new FakeHistorianGatewayClient();
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
await ds.ReadProcessedAsync("T", default, default, TimeSpan.FromSeconds(60), HistoryAggregateType.Minimum, default);
Assert.Equal(RetrievalMode.MinimumWithTime, fake.LastAggregateMode);
Assert.Equal(TimeSpan.FromSeconds(60), fake.LastAggregateInterval);
}
[Fact]
public async Task ReadAtTime_aligns_one_snapshot_per_timestamp_with_gaps_Bad()
{
var fake = new FakeHistorianGatewayClient();
var t0 = new DateTime(2026,1,1,0,0,0,DateTimeKind.Utc);
var t1 = t0.AddSeconds(1);
fake.AtTimeSamples = new[] { new HistorianSample { NumericValue=5.0, OpcQuality=192, Timestamp=Timestamp.FromDateTime(t0) } };
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
var r = await ds.ReadAtTimeAsync("T", new[]{ t0, t1 }, default);
Assert.Equal(2, r.Samples.Count); // exactly one per requested ts, in order
Assert.Equal(5.0, r.Samples[0].Value);
Assert.Equal(0x80000000u, r.Samples[1].StatusCode); // gap → Bad at requested ts
}
[Fact]
public async Task Empty_window_is_not_a_fault()
{
var fake = new FakeHistorianGatewayClient { RawSamples = Array.Empty<HistorianSample>() };
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
var r = await ds.ReadRawAsync("T", default, default, 10, default);
Assert.Empty(r.Samples); // GoodNoData-empty, no throw
}
}
```
**Step 2: run, expect FAIL** — `dotnet test --filter "FullyQualifiedName~GatewayHistorianDataSourceTests"` → FAILS.
**Step 3: implement** — `GatewayHistorianDataSource : IHistorianDataSource` (ctor takes `IHistorianGatewayClient` + `ILogger<>`):
- `ReadRawAsync`: clamp `maxValuesPerNode` to `int` (`(int)Math.Min(maxValuesPerNode, int.MaxValue)`); drain `client.ReadRawAsync` `IAsyncEnumerable` → `SampleMapper.ToSnapshot`; return `new HistoryReadResult(list, ContinuationPoint: null)`.
- `ReadProcessedAsync`: `AggregateModeMapper.ToRetrievalMode(aggregate)` → `client.ReadAggregateAsync(...)`; map via `SampleMapper.ToAggregateSnapshot`. (No client-side Total scaling — `Integral` is native; delete the Wonderware workaround.)
- `ReadAtTimeAsync`: call `client.ReadAtTimeAsync`; **align exactly one snapshot per requested timestamp in order**, gaps → Bad (`0x80000000`) stamped at the requested time — **port `WonderwareHistorianClient.AlignAtTimeSnapshots` verbatim** (index returned samples by `Timestamp.ToDateTime().Ticks`).
- Record health counters on every read via a private `RecordOutcome` mirroring the Wonderware single-lock discipline (used by T8). Map a thrown gateway exception to a recorded failure + rethrow (the node manager turns it into a Bad result; never crash the host).
- `Dispose()` disposes the client (bridge the `IAsyncDisposable` like the Wonderware `Dispose`).
**Step 4: run, expect PASS.**
**Step 5: commit**
```bash
git commit -am "feat(historian-gateway): GatewayHistorianDataSource read paths (raw/processed/at-time)
Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii"
```
---
### Task 8: `GetHealthSnapshot` from Probe / GetConnectionStatus
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** none (extends T7's class)
**Files:**
- Modify `/Users/dohertj2/Desktop/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianDataSource.cs`
- Create `/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayHealthSnapshotTests.cs`
**Step 1: failing test:**
```csharp
public sealed class GatewayHealthSnapshotTests
{
[Fact]
public async Task Counters_track_success_and_failure()
{
var fake = new FakeHistorianGatewayClient { RawSamples = Array.Empty<HistorianSample>() };
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
await ds.ReadRawAsync("T", default, default, 1, default);
fake.ThrowOnRead = true;
await Assert.ThrowsAnyAsync<Exception>(() => ds.ReadRawAsync("T", default, default, 1, default));
var h = ds.GetHealthSnapshot();
Assert.Equal(2, h.TotalQueries);
Assert.Equal(1, h.TotalSuccesses);
Assert.Equal(1, h.TotalFailures);
Assert.Equal(1, h.ConsecutiveFailures);
Assert.Equal(h.TotalQueries, h.TotalSuccesses + h.TotalFailures); // invariant
}
[Fact]
public void Connection_state_reflects_GetConnectionStatus_flags()
{
var fake = new FakeHistorianGatewayClient {
ConnectionStatus = new ConnectionStatus { ConnectedToServer = true, ConnectionKind = 0b11 } }; // Process|Event
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
ds.RefreshConnectionStateAsync(default).GetAwaiter().GetResult(); // internal probe used by health hosted-service
var h = ds.GetHealthSnapshot();
Assert.True(h.ProcessConnectionOpen);
Assert.True(h.EventConnectionOpen);
}
}
```
**Step 2: run, expect FAIL** — `dotnet test --filter "FullyQualifiedName~GatewayHealthSnapshotTests"` → FAILS.
**Step 3: implement**
- Add the six health counter fields under one `_healthLock` + `RecordOutcome(bool, string?)` (copy the Wonderware discipline so `TotalSuccesses + TotalFailures == TotalQueries` always holds). Wire `RecordOutcome` into every read method from T7.
- Add a lightweight, non-blocking cached connection state: `RefreshConnectionStateAsync(ct)` calls `client.GetConnectionStatusAsync` (and/or `ProbeAsync`) and caches `ProcessConnectionOpen` ← `ConnectedToServer && (ConnectionKind & 1)!=0`, `EventConnectionOpen` ← `ConnectedToServer && (ConnectionKind & 2)!=0`. `GetHealthSnapshot()` is **pure observation** — returns the cached flags + counters; it never blocks on I/O (per the interface contract). `ActiveProcessNode`/`ActiveEventNode`/`Nodes` are null/empty (the gateway is non-clustered to us, mirroring the Wonderware client's Finding 010 posture).
**Step 4: run, expect PASS.**
**Step 5: commit**
```bash
git commit -am "feat(historian-gateway): GetHealthSnapshot via Probe/GetConnectionStatus (counter discipline)
Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii"
```
---
### Task 9: Reshape `ServerHistorianOptions` to gateway form
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 2Task 8 (different project/area)
**Files:**
- Modify `/Users/dohertj2/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs`
- Create `/Users/dohertj2/Desktop/OtOpcUa/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ServerHistorianOptionsTests.cs` (place in the existing Runtime test project; confirm its path with `find tests -name "*Runtime.Tests.csproj"`)
**Step 1: failing test:**
```csharp
public sealed class ServerHistorianOptionsTests
{
[Fact]
public void Disabled_yields_no_warnings()
=> Assert.Empty(new ServerHistorianOptions { Enabled = false }.Validate());
[Fact]
public void Enabled_without_endpoint_warns()
{
var w = new ServerHistorianOptions { Enabled = true, Endpoint = "", ApiKey = "histgw_x_y" }.Validate();
Assert.Contains(w, m => m.Contains("Endpoint"));
}
[Fact]
public void Enabled_without_apikey_warns()
{
var w = new ServerHistorianOptions { Enabled = true, Endpoint = "https://h:5222", ApiKey = "" }.Validate();
Assert.Contains(w, m => m.Contains("ApiKey"));
}
[Fact]
public void Valid_config_is_clean()
=> Assert.Empty(new ServerHistorianOptions { Enabled = true, Endpoint = "https://h:5222", ApiKey = "histgw_x_y" }.Validate());
}
```
**Step 2: run, expect FAIL** — `dotnet test --filter "FullyQualifiedName~ServerHistorianOptionsTests"` → FAILS (new members absent).
**Step 3: implement** — reshape `ServerHistorianOptions`:
- **Add:** `string Endpoint` (e.g. `https://host:5222`), `string ApiKey = ""` (supplied via env `ServerHistorian__ApiKey`), `bool UseTls = true`, `bool AllowUntrustedServerCertificate = false`, `string? CaCertificatePath`, `TimeSpan CallTimeout = TimeSpan.FromSeconds(30)`.
- **Remove:** `Host`, `Port`, `SharedSecret`, `ServerCertThumbprint` (Wonderware-specific). **Keep** `Enabled` and `MaxTieClusterOverfetch` (still used by the node manager's HistoryRead-Raw tie-cluster paging — unchanged).
- `Validate()`: warn when enabled and `Endpoint` blank; warn when enabled and `ApiKey` blank ("the gateway gRPC surface will reject unauthenticated calls"); keep the `MaxTieClusterOverfetch <= 0` warning. **No secret values in warning text.**
- Update the class XML doc to describe the gateway backend (not the Wonderware sidecar).
> High-risk: this is a config contract change. Any deployment `appsettings` `ServerHistorian` block changes shape (T21 documents the new keys + provides a migration note). The build will surface every stale reference to the removed members — the only in-tree consumer is the Program.cs factory (T10) and the (to-be-retired) Wonderware wiring.
**Step 4: run, expect PASS** — filter passes; `dotnet build` of the Runtime project clean. (Program.cs will not compile until T10 — that is expected and handled there; build the Runtime + Tests projects in isolation for this task: `dotnet build src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ZB.MOM.WW.OtOpcUa.Runtime.csproj`.)
**Step 5: commit**
```bash
git commit -am "feat(historian-gateway): reshape ServerHistorianOptions to gateway form (Endpoint/ApiKey/Tls)
Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii"
```
---
### Task 10: Swap `AddServerHistorian` factory in Program.cs (READ CUTOVER)
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** none (the read cutover gate)
**Files:**
- Create `/Users/dohertj2/Desktop/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/HistorianGatewayClientAdapter.cs`
- Create `/Users/dohertj2/Desktop/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianServiceCollectionExtensions.cs` (a Host-callable factory helper)
- Modify `/Users/dohertj2/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs`
- Create `/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/HistorianGatewayClientAdapterTests.cs`
**Step 1: failing test** — adapter construction + factory shape (no live gateway needed):
```csharp
public sealed class HistorianGatewayClientAdapterTests
{
[Fact]
public void Adapter_constructs_from_options_without_dialing()
{
// Constructing the channel must not perform network I/O (lazy connect).
var opts = new ServerHistorianOptions { Enabled = true, Endpoint = "https://localhost:5222", ApiKey = "histgw_x_y" };
using var adapter = GatewayHistorianClientAdapter.Create(opts, NullLoggerFactory.Instance);
Assert.NotNull(adapter);
}
[Fact]
public void Factory_builds_GatewayHistorianDataSource()
{
var opts = new ServerHistorianOptions { Enabled = true, Endpoint = "https://localhost:5222", ApiKey = "histgw_x_y" };
var ds = GatewayHistorian.CreateDataSource(opts, services: new ServiceCollection().BuildServiceProvider());
Assert.IsType<GatewayHistorianDataSource>(ds);
}
}
```
**Step 2: run, expect FAIL** — `dotnet test --filter "FullyQualifiedName~HistorianGatewayClientAdapterTests"` → FAILS.
**Step 3: implement**
1. `HistorianGatewayClientAdapter : IHistorianGatewayClient` — wraps the package's `HistorianGatewayClient`. `Create(ServerHistorianOptions, ILoggerFactory)` builds the underlying client from `HistorianGatewayClientOptions` (`Endpoint`, `ApiKey`, `UseTls`, `AllowUntrustedServerCertificate`, `CaCertificatePath`, `CallTimeout`) — match the exact options surface the published Client package exposes (the sibling client plan). Each interface method forwards to the matching client wrapper (streaming → `IAsyncEnumerable`, unary → `Task`). Channel construction is **lazy** (no network I/O in the ctor). Map the client's typed exceptions through unchanged (the data source records them as failures).
2. `GatewayHistorian.CreateDataSource(ServerHistorianOptions, IServiceProvider)` static factory: `new GatewayHistorianDataSource(HistorianGatewayClientAdapter.Create(opts, sp.GetService<ILoggerFactory>() ?? NullLoggerFactory.Instance), sp.GetService<ILogger<GatewayHistorianDataSource>>() ?? NullLogger<…>.Instance)`.
3. In `Program.cs` (`hasDriver` block, the `AddServerHistorian` call ~lines 115-122): replace the `WonderwareHistorianClient` factory lambda with `(opts, sp) => GatewayHistorian.CreateDataSource(opts, sp)`. Update the `using` from `…Driver.Historian.Wonderware.Client` to `…Driver.Historian.Gateway` (the `AddAlarmHistorian` Wonderware lambda stays for now — T13 swaps it). Add the Host `<ProjectReference>` to the Gateway driver project in `ZB.MOM.WW.OtOpcUa.Host.csproj`.
**Step 4: run, expect PASS** — adapter tests pass; **`dotnet build ZB.MOM.WW.OtOpcUa.slnx` is clean** (Program.cs compiles against the reshaped options); the existing server-historian / node-manager HistoryRead tests stay green: `dotnet test --filter "FullyQualifiedName~HistoryRead|FullyQualifiedName~ServerHistorian"`.
**Step 5: commit**
```bash
git commit -am "feat(historian-gateway): read cutover — AddServerHistorian builds GatewayHistorianDataSource
Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii"
```
> **This is the read cutover gate.** After this commit, OPC UA HistoryRead (raw/processed/at-time) for Galaxy + non-Galaxy tags flows through the gateway. Use case 1 is shippable here, before any write code.
---
### Task 11: `ReadEventsAsync` (alarm history) on the data source
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** none (extends T7 class; consumes T5)
**Files:**
- Modify `/Users/dohertj2/Desktop/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianDataSource.cs`
- Create `/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayReadEventsTests.cs`
**Step 1: failing test:**
```csharp
public sealed class GatewayReadEventsTests
{
[Fact]
public async Task ReadEvents_maps_and_passes_source_filter()
{
var fake = new FakeHistorianGatewayClient {
Events = new[] { new HistorianEvent { Id="E1", SourceName="Pump1",
EventTime=Ts(2026,1,1,0,0,0), ReceivedTime=Ts(2026,1,1,0,0,0) } } };
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
var r = await ds.ReadEventsAsync("Pump1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, maxEvents: 0, default);
Assert.Single(r.Events);
Assert.Equal("E1", r.Events[0].EventId);
Assert.Equal("Pump1", fake.LastReadEventsSourceName);
}
[Fact]
public async Task ReadEvents_truncation_sets_continuation_point()
{
var fake = new FakeHistorianGatewayClient {
Events = Enumerable.Range(0,5).Select(i => new HistorianEvent { Id=$"E{i}",
EventTime=Ts(2026,1,1,0,0,i), ReceivedTime=Ts(2026,1,1,0,0,i) }).ToArray() };
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
var r = await ds.ReadEventsAsync(null, DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, maxEvents: 3, default);
Assert.Equal(3, r.Events.Count);
Assert.NotNull(r.ContinuationPoint); // cap truncated → non-null per Core.Abstractions-009
}
}
```
**Step 2: run, expect FAIL** — `dotnet test --filter "FullyQualifiedName~GatewayReadEventsTests"` → FAILS.
**Step 3: implement** — `ReadEventsAsync(sourceName, startUtc, endUtc, maxEvents, ct)`:
- Treat `maxEvents <= 0` as the backend-default sentinel (do not pass a cap; let the gateway apply its `EventReadMaxRows`). When `maxEvents > 0`, stop draining at `maxEvents` and set a **non-null `ContinuationPoint`** iff the source produced at least one more event (truncation signal, Core.Abstractions-009); otherwise null.
- Map via `EventMapper.ToHistoricalEvents`. Record health outcome.
- XML doc note: this path depends on the **gateway** being deployed with `RuntimeDb:EventReadsEnabled=true`; the **source-name server filter** is delivered by the gateway-client plan's SQL-ReadEvents enhancement — until it lands, `sourceName` is passed through but the gateway may return the full window and the adapter must **not** assume server-side filtering. (For v1 correctness, when `sourceName` is non-null, defensively filter the mapped events by `SourceName` client-side as well; remove once the server filter is confirmed.)
**Step 4: run, expect PASS.**
**Step 5: commit**
```bash
git commit -am "feat(historian-gateway): ReadEventsAsync alarm-history via gateway ReadEvents (+ truncation signal)
Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii"
```
---
### Task 12: `GatewayAlarmHistorianWriter : IAlarmHistorianWriter`
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 14, Task 16 (independent files; all in Phase D)
**Files:**
- Create `/Users/dohertj2/Desktop/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayAlarmHistorianWriter.cs`
- Create `/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayAlarmHistorianWriterTests.cs`
**Step 1: failing test** — outcome mapping from gRPC status (uses the fake's per-call `SendEvent` behaviour):
```csharp
public sealed class GatewayAlarmHistorianWriterTests
{
private static AlarmHistorianEvent Evt(string id) => new(id,"Area/Pump","N","LimitAlarm",
AlarmSeverity.High,"Activated","m","u",null,new DateTime(2026,1,1,0,0,0,DateTimeKind.Utc));
[Fact]
public async Task All_acked_when_SendEvent_succeeds()
{
var fake = new FakeHistorianGatewayClient { SendEventResult = new WriteAck { Success = true } };
var w = new GatewayAlarmHistorianWriter(fake, NullLogger<GatewayAlarmHistorianWriter>.Instance);
var outcomes = await w.WriteBatchAsync(new[]{ Evt("A"), Evt("B") }, default);
Assert.All(outcomes, o => Assert.Equal(HistorianWriteOutcome.Ack, o));
}
[Fact]
public async Task Unavailable_is_RetryPlease()
{
var fake = new FakeHistorianGatewayClient { SendEventThrows = new RpcException(new Status(StatusCode.Unavailable, "down")) };
var w = new GatewayAlarmHistorianWriter(fake, NullLogger<GatewayAlarmHistorianWriter>.Instance);
var outcomes = await w.WriteBatchAsync(new[]{ Evt("A") }, default);
Assert.Equal(HistorianWriteOutcome.RetryPlease, outcomes[0]);
}
[Fact]
public async Task InvalidArgument_is_PermanentFail()
{
var fake = new FakeHistorianGatewayClient { SendEventThrows = new RpcException(new Status(StatusCode.InvalidArgument, "malformed")) };
var w = new GatewayAlarmHistorianWriter(fake, NullLogger<GatewayAlarmHistorianWriter>.Instance);
var outcomes = await w.WriteBatchAsync(new[]{ Evt("A") }, default);
Assert.Equal(HistorianWriteOutcome.PermanentFail, outcomes[0]);
}
[Fact]
public async Task Empty_batch_returns_empty() =>
Assert.Empty(await new GatewayAlarmHistorianWriter(new FakeHistorianGatewayClient(),
NullLogger<GatewayAlarmHistorianWriter>.Instance).WriteBatchAsync(Array.Empty<AlarmHistorianEvent>(), default));
}
```
> If the published Client wraps `RpcException` in a typed hierarchy (`…UnavailableException`, `…AuthorizationException`), assert on those types instead — align with the client plan's exception design (§9). The fake should be configurable to throw whichever the real client surfaces.
**Step 2: run, expect FAIL** — `dotnet test --filter "FullyQualifiedName~GatewayAlarmHistorianWriterTests"` → FAILS.
**Step 3: implement** — `GatewayAlarmHistorianWriter : IAlarmHistorianWriter`: per event, `AlarmEventMapper.ToHistorianEvent` → `client.SendEventAsync`; map result to `HistorianWriteOutcome`:
- success ack ⇒ `Ack`.
- transient gRPC (`Unavailable`, `DeadlineExceeded`, `ResourceExhausted`, `Aborted`, `Internal`) or the client's `…UnavailableException` ⇒ `RetryPlease`.
- permanent gRPC (`InvalidArgument`, `FailedPrecondition`, `OutOfRange`, `Unimplemented`) ⇒ `PermanentFail` (so the drain worker dead-letters poison events instead of looping to the cap — mirrors the Wonderware `PerEventStatus==2` boundary).
- `Unauthenticated`/`PermissionDenied` ⇒ `RetryPlease` (a key fix re-enables the batch; do not dead-letter on an auth blip).
- Returns one outcome per event, same order. Empty batch ⇒ `[]`. Never throws out of `WriteBatchAsync` — it sits behind `SqliteStoreAndForwardSink`, which expects per-event outcomes.
**Step 4: run, expect PASS.**
**Step 5: commit**
```bash
git commit -am "feat(historian-gateway): GatewayAlarmHistorianWriter — SendEvent + gRPC->outcome mapping
Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii"
```
---
### Task 13: Swap `AddAlarmHistorian` factory in Program.cs
**Classification:** high-risk
**Estimated implement time:** ~3 min
**Parallelizable with:** none
**Files:**
- Modify `/Users/dohertj2/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs`
**Step 1: failing test** — reuse the existing Host integration/boot test (find it: `find tests -name "*Host*Tests*.cs" | head`); assert the resolved `IAlarmHistorianWriter` is the gateway writer when `AlarmHistorian:Enabled=true`. If no such test exists, add a minimal DI-resolution test in the Host test project that builds the service collection with the gateway writer factory and asserts the type. (Keep it offline — the writer ctor must not dial.)
**Step 2: run, expect FAIL** — run that test → FAILS (still wired to `WonderwareHistorianClient`).
**Step 3: implement** — in `Program.cs` `AddAlarmHistorian(...)` call (~lines 101-108): replace the `WonderwareHistorianClient` writer lambda with one that builds `new GatewayAlarmHistorianWriter(HistorianGatewayClientAdapter.Create(<gateway options>, sp.GetService<ILoggerFactory>()...), sp.GetService<ILogger<GatewayAlarmHistorianWriter>>()...)`. The alarm-write path must point at the **same gateway endpoint/key** as the read path — source the connection from `ServerHistorianOptions` (single gateway) rather than the Wonderware-shaped `AlarmHistorianOptions` host/port. Reuse a single `IHistorianGatewayClient`/adapter instance across read + alarm-write where practical (resolve a shared singleton in DI to avoid two channels); if simplest, register `IHistorianGatewayClient` as a singleton built from `ServerHistorianOptions` and have both factories consume it. Remove the now-dead `using …Wonderware.Client`.
**Step 4: run, expect PASS** — the test passes; `dotnet build ZB.MOM.WW.OtOpcUa.slnx` clean.
**Step 5: commit**
```bash
git commit -am "feat(historian-gateway): alarm-write cutover — AddAlarmHistorian drains to GatewayAlarmHistorianWriter
Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii"
```
---
### Task 14: `IHistorianProvisioning` + `GatewayTagProvisioner` (EnsureTags)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 12, Task 16
**Files:**
- Create `/Users/dohertj2/Desktop/OtOpcUa/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianProvisioning.cs`
- Create `/Users/dohertj2/Desktop/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayTagProvisioner.cs`
- Create `/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayTagProvisionerTests.cs`
**Step 1: failing test:**
```csharp
public sealed class GatewayTagProvisionerTests
{
[Fact]
public async Task Ensures_numeric_tags_with_mapped_type()
{
var fake = new FakeHistorianGatewayClient { EnsureTagsResult = new TagOperationResults() };
var p = new GatewayTagProvisioner(fake, NullLogger<GatewayTagProvisioner>.Instance);
var reqs = new[] {
new HistorianTagProvisionRequest("Pump1.Temp", DriverDataType.Float32, "degC", "Temp"),
new HistorianTagProvisionRequest("Pump1.Run", DriverDataType.Boolean, null, null),
};
var result = await p.EnsureTagsAsync(reqs, default);
Assert.Equal(2, fake.LastEnsureDefinitions.Count);
Assert.Equal(HistorianDataType.Float, fake.LastEnsureDefinitions[0].DataType);
Assert.Equal(HistorianDataType.Int1, fake.LastEnsureDefinitions[1].DataType);
Assert.Equal(2, result.Requested);
Assert.Equal(0, result.Skipped);
}
[Fact]
public async Task Deferred_types_are_skipped_not_sent()
{
var fake = new FakeHistorianGatewayClient { EnsureTagsResult = new TagOperationResults() };
var p = new GatewayTagProvisioner(fake, NullLogger<GatewayTagProvisioner>.Instance);
var result = await p.EnsureTagsAsync(new[] {
new HistorianTagProvisionRequest("Pump1.Name", DriverDataType.String, null, null) }, default);
Assert.Empty(fake.LastEnsureDefinitions); // String is deferred → never sent
Assert.Equal(1, result.Skipped);
}
[Fact]
public async Task Gateway_failure_is_swallowed_and_counted_not_thrown()
{
var fake = new FakeHistorianGatewayClient { EnsureTagsThrows = new Exception("boom") };
var p = new GatewayTagProvisioner(fake, NullLogger<GatewayTagProvisioner>.Instance);
var result = await p.EnsureTagsAsync(new[] {
new HistorianTagProvisionRequest("Pump1.Temp", DriverDataType.Float32, null, null) }, default);
Assert.Equal(1, result.Failed); // non-blocking: no throw
}
}
```
**Step 2: run, expect FAIL** — `dotnet test --filter "FullyQualifiedName~GatewayTagProvisionerTests"` → FAILS.
**Step 3: implement**
- `IHistorianProvisioning` (Core.Abstractions, alongside `IHistorianDataSource`): `Task<HistorianProvisionResult> EnsureTagsAsync(IReadOnlyList<HistorianTagProvisionRequest> requests, CancellationToken ct)`. Define `HistorianTagProvisionRequest(string TagName, DriverDataType DataType, string? EngineeringUnit, string? Description)` and `HistorianProvisionResult(int Requested, int Ensured, int Skipped, int Failed)` records here too. Add a `NullHistorianProvisioning` no-op (returns all-zero) so the Applier has a safe default.
- `GatewayTagProvisioner : IHistorianProvisioning` (Gateway driver): for each request, gate on `HistorianTypeMapper.IsHistorizable(DataType)` — non-historizable ⇒ count `Skipped`, log Debug, never build a definition; otherwise build `HistorianTagDefinition { TagName, DataType = HistorianTypeMapper.ToHistorianDataType(...), EngineeringUnit, Description }`. Batch all historizable definitions into one `client.EnsureTagsAsync` call. **Non-blocking semantics**: wrap the call in try/catch — any exception ⇒ count the batch as `Failed`, log a Warning (no tag values), return. Returns the result record. Never throws.
**Step 4: run, expect PASS.**
**Step 5: commit**
```bash
git commit -am "feat(historian-gateway): IHistorianProvisioning + GatewayTagProvisioner (EnsureTags, non-blocking)
Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii"
```
---
### Task 15: Hook provisioning into `AddressSpaceApplier.Apply()`
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Modify `/Users/dohertj2/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj` (add `Core.Abstractions` ProjectReference)
- Modify `/Users/dohertj2/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs`
- Create `/Users/dohertj2/Desktop/OtOpcUa/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierProvisioningTests.cs` (confirm the OpcUaServer test project path with `find tests -name "*OpcUaServer.Tests.csproj"`)
**Step 1: failing test** — synthetic plan with mixed historized/non-historized tags + a capturing `IHistorianProvisioning`:
```csharp
public sealed class AddressSpaceApplierProvisioningTests
{
private sealed class CapturingProvisioner : IHistorianProvisioning
{
public List<HistorianTagProvisionRequest> Seen = new();
public bool Throw;
public Task<HistorianProvisionResult> EnsureTagsAsync(IReadOnlyList<HistorianTagProvisionRequest> r, CancellationToken ct)
{
if (Throw) throw new Exception("boom");
Seen.AddRange(r);
return Task.FromResult(new HistorianProvisionResult(r.Count, r.Count, 0, 0));
}
}
[Fact]
public void Apply_provisions_only_historized_added_tags()
{
var prov = new CapturingProvisioner();
var applier = new AddressSpaceApplier(NullSink, NullLogger, prov);
var plan = PlanWith(
Tag("Pump1.Temp", historized:true, historianName:"Pump1.Temp", type:"Float32"),
Tag("Pump1.Run", historized:false, historianName:null, type:"Boolean"));
applier.Apply(plan);
Assert.Single(prov.Seen);
Assert.Equal("Pump1.Temp", prov.Seen[0].TagName); // resolved historian name
}
[Fact]
public void Provisioner_throw_does_not_block_publish()
{
var applier = new AddressSpaceApplier(NullSink, NullLogger, new CapturingProvisioner { Throw = true });
var outcome = applier.Apply(PlanWith(Tag("Pump1.Temp", historized:true, historianName:"Pump1.Temp", type:"Float32")));
Assert.True(outcome.RebuildCalled); // address-space work still completed
}
}
```
**Step 2: run, expect FAIL** — `dotnet test --filter "FullyQualifiedName~AddressSpaceApplierProvisioningTests"` → FAILS (ctor arity / hook absent).
**Step 3: implement**
- Add `<ProjectReference>` from `ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj` to `Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions` (it is a leaf abstractions project — OpcUaServer already references Commons; no cycle).
- Add an `IHistorianProvisioning` ctor param to `AddressSpaceApplier` **defaulting to `NullHistorianProvisioning.Instance`** (preserves every existing call site — including the one in `ServiceCollectionExtensions.WithOtOpcUaRuntimeActors` that constructs the applier).
- In `Apply(plan)`, after the address-space work (rebuild/surgical passes) completes, iterate `plan.AddedEquipmentTags.Where(t => t.IsHistorized)`:
- resolve the historian name exactly as the materialiser does: `string.IsNullOrWhiteSpace(t.HistorianTagname) ? t.FullName : t.HistorianTagname`.
- parse `t.DataType` (string) → `DriverDataType` via `Enum.TryParse`; an unparseable type ⇒ skip + log Debug.
- build `HistorianTagProvisionRequest(historianName, dataType, EngineeringUnit: null, Description: t.Name)`.
- Fire-and-forget the provisioning **off the apply path** so it NEVER blocks the publish: call `provisioner.EnsureTagsAsync(requests, CancellationToken.None)` and observe the task on a continuation that logs `result.Failed`/`Skipped` (and swallows any escaped exception). The synchronous `Apply` returns its normal `AddressSpaceApplyOutcome` regardless. Wrap the whole hook in try/catch so a hook fault cannot break a deploy.
> High-risk: this runs on the OPC UA publish actor's pinned thread. Keep the hook's synchronous portion to building the request list only; the gateway round-trip must be fully detached (do not `.Wait()`/`.Result`). Tests assert publish completes even when the provisioner throws synchronously.
**Step 4: run, expect PASS** — provisioning tests pass; the existing `AddressSpaceApplier` test suite stays green (`dotnet test --filter "FullyQualifiedName~AddressSpaceApplier"`); `dotnet build` clean.
**Step 5: commit**
```bash
git commit -am "feat(historian-gateway): EnsureTags provisioning hook in AddressSpaceApplier (non-blocking)
Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii"
```
---
### Task 16: FasterLog outbox store for the recorder
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 12, Task 14
**Files:**
- Modify `/Users/dohertj2/Desktop/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.csproj` (add `Microsoft.FASTER.Core` 2.6.5)
- Create `/Users/dohertj2/Desktop/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Recorder/HistorizationOutboxEntry.cs`
- Create `/Users/dohertj2/Desktop/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Recorder/IHistorizationOutbox.cs`
- Create `/Users/dohertj2/Desktop/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Recorder/FasterLogHistorizationOutbox.cs`
- Create `/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Recorder/FasterLogHistorizationOutboxTests.cs`
**Step 1: failing test** — append/peek/remove, restart durability, full-drop:
```csharp
public sealed class FasterLogHistorizationOutboxTests
{
private static HistorizationOutboxEntry E(string tag, double v) =>
new(Guid.NewGuid(), tag, v, 192, new DateTime(2026,1,1,0,0,0,DateTimeKind.Utc));
[Fact]
public async Task Append_then_peek_returns_fifo()
{
var dir = NewTempDir();
using var o = new FasterLogHistorizationOutbox(dir, StoreForwardCommitMode.PerEntry);
await o.AppendAsync(E("A",1), default);
await o.AppendAsync(E("B",2), default);
var batch = await o.PeekBatchAsync(10, default);
Assert.Equal(new[]{"A","B"}, batch.Select(b => b.Tag));
Assert.Equal(2, await o.CountAsync(default));
}
[Fact]
public async Task Remove_truncates_and_survives_restart()
{
var dir = NewTempDir();
Guid keep;
{
using var o = new FasterLogHistorizationOutbox(dir, StoreForwardCommitMode.PerEntry);
var a = E("A",1); var b = E("B",2); keep = b.Id;
await o.AppendAsync(a, default); await o.AppendAsync(b, default);
await o.PeekBatchAsync(10, default);
await o.RemoveAsync(a.Id, default); // ack A
}
using var reopened = new FasterLogHistorizationOutbox(dir, StoreForwardCommitMode.PerEntry);
Assert.Equal(1, await reopened.CountAsync(default)); // only B survives
var batch = await reopened.PeekBatchAsync(10, default);
Assert.Equal(keep, batch[0].Id);
}
[Fact]
public async Task Capacity_full_drops_oldest_and_counts()
{
var dir = NewTempDir();
using var o = new FasterLogHistorizationOutbox(dir, StoreForwardCommitMode.PerEntry, capacity: 2);
await o.AppendAsync(E("A",1), default);
await o.AppendAsync(E("B",2), default);
await o.AppendAsync(E("C",3), default); // overflow → drop oldest (A)
Assert.Equal(2, await o.CountAsync(default));
Assert.Equal(1, o.DroppedCount);
var tags = (await o.PeekBatchAsync(10, default)).Select(b => b.Tag).ToArray();
Assert.DoesNotContain("A", tags);
}
}
```
**Step 2: run, expect FAIL** — `dotnet test --filter "FullyQualifiedName~FasterLogHistorizationOutboxTests"` → FAILS.
**Step 3: implement** — **mirror the gateway's `FasterLogOutboxStore`** (read it first):
- `HistorizationOutboxEntry(Guid Id, string Tag, double NumericValue, ushort Quality, DateTime TimestampUtc)` + a compact binary serializer (BinaryWriter or the same approach the gateway's `OutboxEntrySerializer` uses).
- `IHistorizationOutbox` (Append/PeekBatch/Remove/Count, `DroppedCount`, `IDisposable`).
- `FasterLogHistorizationOutbox`: `ManagedLocalStorageDevice` under `<dir>/hlog.log`; `FasterLog`; PerEntry commits before `AppendAsync` returns, Periodic drives a `PeriodicTimer` commit loop; `RemoveAsync` ⇒ `TruncateUntil(window[id])` + `CommitAsync`; `RecoverState()` in the ctor rebuilds count + head from the committed log (restart durability). **Add the capacity/drop-oldest behavior the gateway store lacks**: a `capacity` ctor arg; when an append would exceed it, advance the head past the oldest entry (truncate one) and increment `DroppedCount` (the recorder surfaces it as a metric in T17/T18). Use `StoreForwardCommitMode` from the gateway's Configuration namespace **or** define a local `enum HistorizationCommitMode { PerEntry, Periodic }` to avoid a cross-package coupling — prefer the local enum (the gateway's type is internal to its Server project).
> High-risk: this is the durable boundary. The restart-durability + truncate-correctness tests are load-bearing; do not skip them.
**Step 4: run, expect PASS.**
**Step 5: commit**
```bash
git commit -am "feat(historian-gateway): FasterLog historization outbox (PerEntry/Periodic, drop-oldest)
Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii"
```
---
### Task 17: `ContinuousHistorizationRecorder` actor
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** none (consumes T16 + the client)
**Files:**
- Create `/Users/dohertj2/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ContinuousHistorizationRecorder.cs`
- Create `/Users/dohertj2/Desktop/OtOpcUa/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ContinuousHistorizationRecorderTests.cs`
> The recorder lives in **Runtime** (it references `DependencyMuxActor` + `DriverInstanceActor.AttributeValuePublished`, both in Runtime), and consumes `IHistorianGatewayClient` + `IHistorizationOutbox` via abstractions so Runtime does not take a hard dependency on the Gateway driver project. Define the two seams the recorder needs (`IHistorianGatewayClient` write subset, `IHistorizationOutbox`) such that Runtime can reference them — either move `IHistorizationOutbox` + a `IHistorianValueWriter` (just `WriteLiveValuesAsync`) into `Core.Abstractions/Historian`, and have the Gateway driver implement them. **Decision:** introduce `IHistorianValueWriter` (single method `Task<bool> WriteLiveValuesAsync(string tag, IReadOnlyList<(DateTime? ts, double value, ushort quality)> values, CancellationToken ct)`) and `IHistorizationOutbox` in `Core.Abstractions/Historian`; `GatewayHistorianValueWriter` (Gateway driver) adapts `IHistorianGatewayClient.WriteLiveValuesAsync`. This keeps Runtime free of the gRPC package.
**Files (revised to honour the layering decision):**
- Create `/Users/dohertj2/Desktop/OtOpcUa/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianValueWriter.cs` (+ move `IHistorizationOutbox` + `HistorizationOutboxEntry` here; the FasterLog impl from T16 stays in the Gateway driver project implementing this interface)
- Create `/Users/dohertj2/Desktop/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Recorder/GatewayHistorianValueWriter.cs`
- Create `/Users/dohertj2/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ContinuousHistorizationRecorder.cs`
- Create the recorder test file above.
**Step 1: failing test** — Akka TestKit with fake mux + fake writer + real/fake outbox:
```csharp
public sealed class ContinuousHistorizationRecorderTests : TestKit
{
[Fact]
public void Registers_interest_for_historized_refs_on_start()
{
var mux = CreateTestProbe();
var writer = new FakeValueWriter();
var outbox = new InMemoryOutbox();
Sys.ActorOf(ContinuousHistorizationRecorder.Props(mux.Ref, writer, outbox,
historizedRefs: new[]{ "Pump1.Temp" }));
var reg = mux.ExpectMsg<DependencyMuxActor.RegisterInterest>();
Assert.Contains("Pump1.Temp", reg.TagRefs);
}
[Fact]
public async Task AttributeValuePublished_appends_to_outbox_then_drains_to_writer()
{
var mux = CreateTestProbe();
var writer = new FakeValueWriter { Succeed = true };
var outbox = new InMemoryOutbox();
var rec = Sys.ActorOf(ContinuousHistorizationRecorder.Props(mux.Ref, writer, outbox, new[]{ "Pump1.Temp" }));
rec.Tell(new DriverInstanceActor.AttributeValuePublished("drv","Pump1.Temp", 42.0, OpcUaQuality.Good, DateTime.UtcNow));
await AwaitAssertAsync(() => Assert.Contains(writer.Written, w => w.Tag=="Pump1.Temp" && w.Value==42.0));
await AwaitAssertAsync(async () => Assert.Equal(0, await outbox.CountAsync(default))); // acked → truncated
}
[Fact]
public async Task Writer_failure_keeps_entry_for_retry()
{
var mux = CreateTestProbe();
var writer = new FakeValueWriter { Succeed = false };
var outbox = new InMemoryOutbox();
var rec = Sys.ActorOf(ContinuousHistorizationRecorder.Props(mux.Ref, writer, outbox, new[]{ "Pump1.Temp" }));
rec.Tell(new DriverInstanceActor.AttributeValuePublished("drv","Pump1.Temp", 7.0, OpcUaQuality.Good, DateTime.UtcNow));
await AwaitAssertAsync(async () => Assert.Equal(1, await outbox.CountAsync(default))); // not acked → retained
}
[Fact]
public void Non_numeric_value_is_dropped_with_metric()
{
var mux = CreateTestProbe(); var writer = new FakeValueWriter(); var outbox = new InMemoryOutbox();
var rec = Sys.ActorOf(ContinuousHistorizationRecorder.Props(mux.Ref, writer, outbox, new[]{ "Pump1.Name" }));
rec.Tell(new DriverInstanceActor.AttributeValuePublished("drv","Pump1.Name", "text", OpcUaQuality.Good, DateTime.UtcNow));
// string value: SQL analog write path can't carry it → dropped, not appended
AwaitAssert(() => Assert.Empty(writer.Written));
}
}
```
**Step 2: run, expect FAIL** — `dotnet test --filter "FullyQualifiedName~ContinuousHistorizationRecorderTests"` → FAILS.
**Step 3: implement** — `ContinuousHistorizationRecorder : ReceiveActor`:
- `Props(IActorRef dependencyMux, IHistorianValueWriter writer, IHistorizationOutbox outbox, IReadOnlyList<string> historizedRefs, ...options)`.
- `PreStart`: `dependencyMux.Tell(new DependencyMuxActor.RegisterInterest(historizedRefs, Self))`. (The mux fans `AttributeValuePublished` for those refs back as `DependencyValueChanged`; **or** wire the recorder to receive `AttributeValuePublished` directly from `DriverHostActor`'s forward — match the actual fan-out. Per the mux contract, register interest and handle `VirtualTagActor.DependencyValueChanged`; if the recorder must see ALL historized refs regardless of vtag interest, instead have `DriverHostActor` forward `AttributeValuePublished` to the recorder. **Choose the `RegisterInterest`→`DependencyValueChanged` path** to reuse the existing mux without touching `DriverHostActor`; the test asserts the register message.)
- On a value-change message for a historized ref: coerce the value to numeric (`double`); a non-numeric/non-coercible value (string/null) is **dropped + metered** (the SQL analog `WriteLiveValues` path is numeric-only — design §6/§7, §11 risk 2). Append a `HistorizationOutboxEntry` to the outbox (the durable boundary). On outbox-full, the store drops oldest + increments `DroppedCount`.
- A background drain (self-scheduled tick via `Context.System.Scheduler` / `Timers`): `PeekBatchAsync(batchSize)` → group by tag → `writer.WriteLiveValuesAsync(tag, values)`; on success `RemoveAsync` each acked id; on failure leave queued and **back off** (exponential, capped) before the next tick. Never let a drain exception kill the actor (supervised try/catch; log Warning, back off).
- Surface counters (queued depth, dropped, last drain success) for T18's meter/health.
> High-risk: actor + durable recorder + data contract. The append-before-ack ordering, the numeric-only gate, and the failure-retains-entry behavior are the load-bearing invariants. Use `Akka.TestKit.Xunit2`.
**Step 4: run, expect PASS.**
**Step 5: commit**
```bash
git commit -am "feat(historian-gateway): ContinuousHistorizationRecorder actor (outbox->WriteLiveValues, backoff)
Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii"
```
---
### Task 18: Wire the recorder into DI + hosted lifecycle
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Create `/Users/dohertj2/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ContinuousHistorizationOptions.cs`
- Modify `/Users/dohertj2/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs` (spawn the recorder in `WithOtOpcUaRuntimeActors`, gated on options)
- Modify `/Users/dohertj2/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs` (bind options + register `IHistorianValueWriter` + `IHistorizationOutbox` from the gateway, gated)
- Create `/Users/dohertj2/Desktop/OtOpcUa/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ContinuousHistorizationOptionsTests.cs`
**Step 1: failing test** — options validation + actor-spawn gating:
```csharp
public sealed class ContinuousHistorizationOptionsTests
{
[Fact] public void Disabled_no_warnings() => Assert.Empty(new ContinuousHistorizationOptions{Enabled=false}.Validate());
[Fact] public void Enabled_requires_outbox_path()
=> Assert.Contains(new ContinuousHistorizationOptions{Enabled=true, OutboxPath=""}.Validate(), m => m.Contains("OutboxPath"));
[Fact] public void Periodic_requires_positive_interval()
=> Assert.Contains(new ContinuousHistorizationOptions{Enabled=true, OutboxPath="x", CommitMode="Periodic", CommitIntervalMs=0}.Validate(), m => m.Contains("CommitIntervalMs"));
}
```
Plus a Runtime spawn test (mirror existing `WithOtOpcUaRuntimeActors` tests): when `ContinuousHistorization:Enabled=false`, the recorder actor is NOT spawned; when enabled with a fake writer/outbox registered, it IS (`registry.Get<ContinuousHistorizationRecorderKey>()` resolves).
**Step 2: run, expect FAIL** — `dotnet test --filter "FullyQualifiedName~ContinuousHistorizationOptions"` → FAILS.
**Step 3: implement**
- `ContinuousHistorizationOptions` (`SectionName="ContinuousHistorization"`): `Enabled`, `OutboxPath` (directory; required when enabled — production absolute path), `CommitMode` (`PerEntry`/`Periodic`), `CommitIntervalMs`, `DrainBatchSize` (default 64), `DrainIntervalSeconds`, `Capacity`, `RetryBackoff`. `Validate()` warns on the gated cases above.
- In `WithOtOpcUaRuntimeActors`: resolve `IHistorianValueWriter` + `IHistorizationOutbox` (+ the historized-ref set — source it from the deployed composition; for v1, the recorder can register interest dynamically as tags deploy, but the minimal wiring registers the actor and lets a later `SetHistorizedRefs` message feed it. **Keep T18 scope to: bind options, build the outbox from `OutboxPath`, spawn the recorder when enabled, register its key.**). Gate the spawn on `ContinuousHistorizationOptions.Enabled`.
- In `Program.cs` (`hasDriver` block): bind+validate the options; when enabled, register `IHistorizationOutbox` ⇒ `FasterLogHistorizationOutbox(opts.OutboxPath, …)` and `IHistorianValueWriter` ⇒ `GatewayHistorianValueWriter` over the shared `IHistorianGatewayClient` (the singleton from T13). Add a meter/observable-gauge for outbox depth + dropped count (mirror the existing observability registration), and feed the recorder's health into `/healthz` if a historian health hook exists.
**Step 4: run, expect PASS** — option + spawn tests pass; `dotnet build ZB.MOM.WW.OtOpcUa.slnx` clean; full offline suite green: `dotnet test ZB.MOM.WW.OtOpcUa.slnx`.
**Step 5: commit**
```bash
git commit -am "feat(historian-gateway): wire ContinuousHistorizationRecorder into DI + hosted lifecycle + meters
Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii"
```
---
### Task 19: Retire the Wonderware historian projects
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** none (must be last code task)
**Files (remove from solution + delete):**
- `/Users/dohertj2/Desktop/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware`
- `/Users/dohertj2/Desktop/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client`
- `/Users/dohertj2/Desktop/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Contracts`
- `/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests`
- `/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests`
- Modify `/Users/dohertj2/Desktop/OtOpcUa/ZB.MOM.WW.OtOpcUa.slnx`
- Grep-and-remove any remaining `using …Wonderware…` / `ProjectReference …Wonderware…` (notably `Host.csproj`).
> Leave `/Users/dohertj2/Desktop/OtOpcUa/code-reviews/Driver.Historian.Wonderware*` untouched — those are review artifacts, not projects.
**Step 1: failing test** — a guard test asserting the Wonderware types are gone (compiles only after removal):
```csharp
public sealed class WonderwareRetirementTests
{
[Fact]
public void No_Wonderware_historian_assembly_is_loaded()
{
var loaded = AppDomain.CurrentDomain.GetAssemblies().Select(a => a.GetName().Name);
Assert.DoesNotContain(loaded, n => n is not null && n.Contains("Wonderware", StringComparison.OrdinalIgnoreCase));
}
}
```
(Place in the Gateway driver test project. Before removal this passes trivially only if nothing loaded it — instead make the gate the **build**: after deleting, `dotnet build` must still be clean with no dangling references. The test documents intent.)
**Step 2: run, expect FAIL** — first run `dotnet build ZB.MOM.WW.OtOpcUa.slnx` BEFORE deletion to confirm it is currently green with both backends, then delete; the meaningful failure is a **dangling reference** build break if any `using`/`ProjectReference` survives.
**Step 3: implement**
1. `dotnet sln ZB.MOM.WW.OtOpcUa.slnx remove <each of the 5 project paths>`.
2. `git rm -r <each of the 5 directories>`.
3. `grep -rn "Wonderware" src/ tests/ --include=*.cs --include=*.csproj` and remove every remaining reference (Host.csproj `ProjectReference`, any stray `using`). `AlarmHistorianOptions` Wonderware-shaped fields (Host/Port/SharedSecret) — if now unused by the gateway alarm-write wiring (T13 sources connection from `ServerHistorian`), prune them; otherwise leave and note in T21.
**Step 4: run, expect PASS** — `dotnet build ZB.MOM.WW.OtOpcUa.slnx` clean (0 warnings, no dangling refs); full suite green: `dotnet test ZB.MOM.WW.OtOpcUa.slnx`.
**Step 5: commit**
```bash
git rm -r src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Contracts tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests
git commit -am "refactor(historian-gateway): retire Wonderware historian projects (gateway is sole backend)
Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii"
```
---
### Task 20: Env-gated live validation vs `wonder-sql-vd03`
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Create `/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Live/GatewayLiveIntegrationTests.cs`
- Create `/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Live/GatewayLiveFixture.cs`
**Step 1: failing test** — `[Trait("Category","LiveIntegration")]` tests that **skip** when env vars are absent:
```csharp
public sealed class GatewayLiveIntegrationTests
{
// Reads HISTGW_GATEWAY_ENDPOINT + HISTGW_GATEWAY_APIKEY + HISTGW_TEST_TAG (Galaxy tag),
// HISTGW_WRITE_SANDBOX_TAG (HistGW.LiveTest.*), HISTGW_ALARM_SOURCE. Skips if endpoint/key absent.
[SkippableFact, Trait("Category","LiveIntegration")]
public async Task Galaxy_tag_read_round_trip() { Skip.If(Fixture.NotConfigured); /* ReadRaw last 1h, assert >=0 samples, no throw */ }
[SkippableFact, Trait("Category","LiveIntegration")]
public async Task Write_then_read_on_sandbox_tag() { /* EnsureTags(Float) → WriteLiveValues → ReadRaw → sample present */ }
[SkippableFact, Trait("Category","LiveIntegration")]
public async Task Alarm_SendEvent_then_ReadEvents() { /* SendEvent → ReadEvents(source) → event present (needs RuntimeDb:EventReadsEnabled) */ }
}
```
**Step 2: run, expect FAIL/SKIP** — `dotnet test --filter "Category=LiveIntegration"` with no env → all SKIP (green). With env set + VPN up → FAIL until the live path works.
**Step 3: implement** — the fixture builds a real `HistorianGatewayClientAdapter` from env, the three round-trips exercise the read/write/alarm paths against the live gateway → `wonder-sql-vd03`. Mirror the HistorianGateway repo's `GatewayIntegrationFixture` env-gating convention (skip+log when unset). **VPN-gated**: `wonder-sql-vd03` is reachable only on VPN — if the endpoint is configured but unreachable, prompt the user to connect VPN (do not hang).
**Step 4: run, expect PASS** — offline: all SKIP. On VPN with env + the gateway running `RuntimeDb:Enabled=true` + `RuntimeDb:EventReadsEnabled=true`: the three round-trips PASS (this is the real-world validation gate before T19's retirement is trusted).
**Step 5: commit**
```bash
git commit -am "test(historian-gateway): env-gated live validation vs wonder-sql-vd03 (read/write/alarm round-trips)
Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii"
```
---
### Task 21: Documentation
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Modify `/Users/dohertj2/Desktop/OtOpcUa/CLAUDE.md`
- Modify `/Users/dohertj2/Desktop/OtOpcUa/README.md` (if present)
- Modify the relevant `appsettings*.json` (the `ServerHistorian` + new `ContinuousHistorization` blocks) under `src/Server/ZB.MOM.WW.OtOpcUa.Host/`
**Step 1: failing test** — none (docs). Verification is review + a `grep` guard: the docs must not reference the retired Wonderware backend as current, and must document the new config keys.
**Step 2: run, expect FAIL** — `grep -n "Wonderware" CLAUDE.md` shows stale references (the backend section still describes the sidecar).
**Step 3: implement**
- `CLAUDE.md`: historian backend is now **HistorianGateway** (gRPC client package); document the `ServerHistorian` keys (`Endpoint`/`ApiKey`/`UseTls`/`AllowUntrustedServerCertificate`/`CaCertificatePath`/`CallTimeout`), the `ContinuousHistorization` section, the `IHistorianProvisioning` EnsureTags hook, the alarm SendEvent path + `ReadEvents` dependency on gateway `RuntimeDb:EventReadsEnabled=true`, and that the Wonderware projects were retired. Note the gateway-side prerequisites (RuntimeDb flags + API-key scopes).
- `appsettings`: a commented `ServerHistorian` example (blank `ApiKey` — env-supplied) + a `ContinuousHistorization` example (disabled by default, absolute `OutboxPath` note for production).
- Migration note: deployments must rename their old `ServerHistorian` keys (`Host`/`Port`/`SharedSecret`/`ServerCertThumbprint` → `Endpoint`/`ApiKey`/`UseTls`/`CaCertificatePath`) and supply `ServerHistorian__ApiKey` via env.
**Step 4: run, expect PASS** — `grep -n "Wonderware" CLAUDE.md` returns only retirement-history mentions; the new keys are documented.
**Step 5: commit**
```bash
git commit -am "docs(historian-gateway): document gateway backend, config keys, EnsureTags hook, Wonderware retirement
Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii"
```
---
## Execution order & parallelism
- **Phase A (T1)** runs first — every later task depends on the project + consumed package.
- **Phase B mappers (T2T6)** are all pure/no-I/O and fully **parallelizable** with each other once T1 lands.
- **Phase C (T7→T8→T9→T10→T11)** follows B. T7 consumes T2+T4; T9 (options reshape) can proceed in parallel with B/T7 (different area). **T10 is the read cutover gate** — it depends on T7 (data source) + T9 (options) and makes use case 1 shippable on its own. T8 and T11 extend T7's class (sequential with it).
- **Phase D (T12T18)** follows the read cutover (T10). T12 (alarm writer), T14 (provisioner), T16 (outbox) are mutually **parallelizable**; T13 depends on T12; T15 depends on T14; T17 depends on T16 (+ client write seam); T18 depends on T17.
- **Phase E:** **T20 (live validation)** runs after the gateway path is fully wired (read cutover + alarm + recorder). **T19 (Wonderware retirement) lands LAST — only after D is green AND T20's live round-trips pass** (do not delete the proven-out backend until the replacement is live-validated). T21 (docs) follows the retirement so it documents the final state.
- The whole live-validation phase is gated on VPN reachability of `wonder-sql-vd03` and on the gateway being deployed with `RuntimeDb:Enabled=true` + `RuntimeDb:EventReadsEnabled=true`.
## Live/VPN + verify-live risks (design §11)
Settle these during T10/T11/T17/T20 against `wonder-sql-vd03` (prompt the user to connect VPN if unreachable):
1. **Galaxy-tag → historian-tag identity.** Does OtOpcUa's resolved `historianTagname` (`FullName` or the `HistorianTagname` override, typically `tag_name.Attribute`) match the AVEVA historian's stored tag name? Confirm early in T20's read round-trip — a mismatch surfaces as empty reads, not errors.
2. **UInt16 / String / DateTime write gaps.** Continuous historization is **numeric-analog only** in v1. `UInt16→UInt4` is a documented fallback; `String`/`DateTime`/`Reference` are deferred and **throw `NotSupported` (provisioning skips them, the recorder drops non-numeric values + meters)** — never silent. T3/T14/T17 encode this; T21 documents it.
3. **Alarm-history reads depend on the gateway's SQL event path.** `ReadEventsAsync` requires the gateway deployed with `RuntimeDb:EventReadsEnabled=true`; the **source-name server filter** is delivered by the gateway-client plan (§5.3) — until it lands, T11 filters client-side and must not assume server filtering.
4. **`WriteLiveValues` requires gateway `RuntimeDb:Enabled=true` AND an `EnsureTags`-provisioned tag.** The provisioning hook (T15) must have run for a tag before the recorder's writes (T17) land — provisioning is fire-and-forget, so the first few values for a brand-new historized tag may be rejected until `EnsureTags` completes; the outbox retains + retries them (no loss). Validate the ordering in T20's write→read round-trip.
5. **`received_time` UTC semantics.** On the SQL event/value paths, inherit whatever the gateway's `feat/sql-readevents` work establishes for local-vs-UTC and `EventTimeUTCOffsetMins`. Stamp all OtOpcUa-side timestamps UTC (`DateTimeKind.Utc`) and assert kind in the mappers (T4/T5/T6).