# OpcUaClient `ReadEventsAsync` event-history passthrough — Implementation Plan

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

**Goal:** Implement the optional `IHistoryProvider.ReadEventsAsync` on `OpcUaClientDriver` so it forwards OPC UA HistoryReadEvents to its upstream server, completing the driver's history-provider surface.

**Architecture:** Driver-internal only. The driver builds a fixed canonical 6-clause `EventFilter` and maps the upstream `HistoryEvent` onto the existing `HistoricalEvent` record (mirrors `WonderwareHistorianClient.ReadEventsAsync`). The pure logic (`BuildBaseEventFilter`, `MapHistoryEvents`) is `internal static` and unit-tested offline; the thin `Session.HistoryReadAsync` wire glue is covered by a without-init unit test + a skip-gated opc-plc integration smoke. NO `IHistoryProvider`/Core.Abstractions/Commons/proto/EF change. NO bUnit.

**Tech Stack:** C#/.NET 10, OPC Foundation UA-.NETStandard SDK, xUnit + Shouldly.

**Design doc:** `docs/plans/2026-06-18-opcuaclient-read-events-design.md` (committed `400bef47`).

**Branch:** `feat/opcuaclient-read-events` (off master `bd791e79`).

**Hard rules:** stage by explicit path (never `git add .`); never stage `sql_login.txt`, `src/Server/.../pki/`, `pending.md`, `stillpending.md`, `docker-dev/docker-compose.yml`; no `--no-verify`; no force-push; NO EF migration / Commons / proto / interface change; NO bUnit. Run all build/test/rig commands with `dangerouslyDisableSandbox: true`.

---

## Reference: exact existing code

**`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs`**
- Class decl `:31`: `public sealed class OpcUaClientDriver : … IHistoryProvider, …` (already implements the interface).
- Raw/Processed/AtTime passthrough `:1546-1664`, shared wire path `ExecuteHistoryReadAsync` `:1615-1664` — the pattern to mirror (`RequireSession()`, `TryParseNodeId(session, ref, out nodeId)`, `_gate.WaitAsync`, `session.HistoryReadAsync(requestHeader: null, historyReadDetails: …, timestampsToReturn: TimestampsToReturn.Both, releaseContinuationPoints: false, nodesToRead: …, ct: …)`, unwrap `r.HistoryData?.Body`, `r.ContinuationPoint is { Length: > 0 }`).
- `:1668-1676`: `internal static NodeId MapAggregateToNodeId(...)` — the `internal static` pure-helper precedent the new helpers follow.
- `:1678-1682`: the stale "ReadEventsAsync stays at the interface default … out of scope for this PR" comment — **delete in T2.**
- Existing methods return `Core.Abstractions.HistoryReadResult` (fully qualified) — mirror with `Core.Abstractions.HistoricalEventsResult` / `Core.Abstractions.HistoricalEvent`.

**`src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IHistoryProvider.cs`** — `ReadEventsAsync(string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken)` returns `HistoricalEventsResult`; `HistoricalEvent(string EventId, string? SourceName, DateTime EventTimeUtc, DateTime ReceivedTimeUtc, string? Message, ushort Severity)`. `maxEvents <= 0` = "backend default cap" sentinel.

**`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientHistoryTests.cs`** — `using Opc.Ua; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions;`. Has the current `ReadEventsAsync_throws_NotSupportedException_as_documented` test (`:83-98`) — **replace in T2.** Direct driver-capability calls (e.g. `drv.ReadRawAsync`) already build, so the project already tolerates the `UnwrappedCapabilityCallAnalyzer` rule — follow that (no new suppression).

**`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcUaClientSmokeTests.cs`** — `[Collection(OpcPlcCollection.Name)]`, ctor `(OpcPlcFixture sim)`, `if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);`, `OpcPlcProfile.BuildOptions(sim.EndpointUrl)`, `await drv.InitializeAsync("{}", ct)`. opc-plc runs with `--alm` (live alarm conditions, not a historian).

---

### Task 1: Pure cores — `BuildBaseEventFilter` + `MapHistoryEvents` + offline unit tests

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (T2 edits the same driver file)

**Files:**
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs` (add the two `internal static` helpers + private coercion helpers near the existing `MapAggregateToNodeId` at `:1666`; do NOT add `ReadEventsAsync` yet)
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientHistoryTests.cs` (add new tests; leave the existing NotSupported test untouched in this task)

**Step 1: Write the failing tests** (append to `OpcUaClientHistoryTests.cs`)

```csharp
/// <summary>BuildBaseEventFilter emits the six canonical BaseEventType select clauses in order.</summary>
[Fact]
public void BuildBaseEventFilter_has_six_canonical_BaseEventType_value_clauses()
{
    var filter = OpcUaClientDriver.BuildBaseEventFilter();

    filter.SelectClauses.Count.ShouldBe(6);
    string[] expected = ["EventId", "SourceName", "Time", "ReceiveTime", "Message", "Severity"];
    for (var i = 0; i < expected.Length; i++)
    {
        var clause = filter.SelectClauses[i];
        clause.TypeDefinitionId.ShouldBe(ObjectTypeIds.BaseEventType);
        clause.AttributeId.ShouldBe(Attributes.Value);
        clause.BrowsePath.Count.ShouldBe(1);
        clause.BrowsePath[0].Name.ShouldBe(expected[i]);
    }
}

/// <summary>MapHistoryEvents maps every field by its canonical select-clause index.</summary>
[Fact]
public void MapHistoryEvents_maps_all_six_fields_by_canonical_index()
{
    var eventTime = new DateTime(2026, 6, 18, 10, 0, 0, DateTimeKind.Utc);
    var recvTime = eventTime.AddSeconds(1);
    var he = new HistoryEvent();
    var fields = new HistoryEventFieldList();
    fields.EventFields.Add(new Variant(new byte[] { 1, 2, 3 }));            // 0 EventId
    fields.EventFields.Add(new Variant("Pump17"));                          // 1 SourceName
    fields.EventFields.Add(new Variant(eventTime));                         // 2 Time
    fields.EventFields.Add(new Variant(recvTime));                          // 3 ReceiveTime
    fields.EventFields.Add(new Variant(new LocalizedText("High temp")));    // 4 Message
    fields.EventFields.Add(new Variant((ushort)700));                       // 5 Severity
    he.Events.Add(fields);

    var mapped = OpcUaClientDriver.MapHistoryEvents(he);

    mapped.Count.ShouldBe(1);
    var e = mapped[0];
    e.EventId.ShouldBe(Convert.ToBase64String(new byte[] { 1, 2, 3 }));
    e.SourceName.ShouldBe("Pump17");
    e.EventTimeUtc.ShouldBe(eventTime);
    e.ReceivedTimeUtc.ShouldBe(recvTime);
    e.Message.ShouldBe("High temp");
    e.Severity.ShouldBe((ushort)700);
}

/// <summary>MapHistoryEvents returns empty for a HistoryEvent with no rows.</summary>
[Fact]
public void MapHistoryEvents_with_no_events_returns_empty()
{
    OpcUaClientDriver.MapHistoryEvents(new HistoryEvent()).ShouldBeEmpty();
}

/// <summary>MapHistoryEvents tolerates a field list shorter than six without throwing.</summary>
[Fact]
public void MapHistoryEvents_tolerates_short_field_list_without_throwing()
{
    var he = new HistoryEvent();
    var fields = new HistoryEventFieldList();
    fields.EventFields.Add(new Variant(new byte[] { 9 })); // only EventId present
    he.Events.Add(fields);

    var mapped = OpcUaClientDriver.MapHistoryEvents(he);

    mapped.Count.ShouldBe(1);
    mapped[0].EventId.ShouldBe(Convert.ToBase64String(new byte[] { 9 }));
    mapped[0].SourceName.ShouldBeNull();
    mapped[0].EventTimeUtc.ShouldBe(DateTime.MinValue);
    mapped[0].Severity.ShouldBe((ushort)0);
}
```

**Step 2: Run to verify they fail to compile** (`BuildBaseEventFilter`/`MapHistoryEvents` don't exist yet)

Run: `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests --filter "FullyQualifiedName~OpcUaClientHistoryTests"` (`dangerouslyDisableSandbox: true`)
Expected: BUILD FAILS — `OpcUaClientDriver` has no `BuildBaseEventFilter` / `MapHistoryEvents`.

**Step 3: Implement the helpers** in `OpcUaClientDriver.cs`, immediately after `MapAggregateToNodeId` (around `:1676`, before the `:1678` comment):

```csharp
// Canonical BaseEventType select-clause order — MapHistoryEvents maps by these indices.
private static readonly string[] EventFieldBrowseNames =
[
    BrowseNames.EventId,      // 0
    BrowseNames.SourceName,   // 1
    BrowseNames.Time,         // 2
    BrowseNames.ReceiveTime,  // 3
    BrowseNames.Message,      // 4
    BrowseNames.Severity,     // 5
];

/// <summary>
///     Builds the fixed canonical EventFilter the driver sends upstream for HistoryReadEvents —
///     the six BaseEventType fields the OtOpcUa server projects (<see cref="HistoricalEvent"/>).
///     The clause order is load-bearing: <see cref="MapHistoryEvents"/> reads results by index.
/// </summary>
/// <returns>An EventFilter with six SimpleAttributeOperand value clauses.</returns>
internal static EventFilter BuildBaseEventFilter()
{
    var filter = new EventFilter();
    foreach (var browseName in EventFieldBrowseNames)
    {
        filter.SelectClauses.Add(new SimpleAttributeOperand
        {
            TypeDefinitionId = ObjectTypeIds.BaseEventType,
            BrowsePath = new QualifiedNameCollection { new QualifiedName(browseName) },
            AttributeId = Attributes.Value,
        });
    }
    return filter;
}

/// <summary>
///     Maps an upstream <see cref="HistoryEvent"/> (field arrays ordered to match
///     <see cref="BuildBaseEventFilter"/>) onto <see cref="HistoricalEvent"/> records. Defensive:
///     short / null / wrong-typed fields degrade to null/default rather than throwing.
/// </summary>
/// <param name="historyEvent">The upstream history-event payload.</param>
/// <returns>The mapped historical events in upstream order.</returns>
internal static IReadOnlyList<Core.Abstractions.HistoricalEvent> MapHistoryEvents(HistoryEvent historyEvent)
{
    if (historyEvent?.Events is not { Count: > 0 } rows) return [];
    var result = new List<Core.Abstractions.HistoricalEvent>(rows.Count);
    foreach (var row in rows)
    {
        var fields = row?.EventFields;
        result.Add(new Core.Abstractions.HistoricalEvent(
            EventId: CoerceEventId(FieldAt(fields, 0)),
            SourceName: CoerceString(FieldAt(fields, 1)),
            EventTimeUtc: CoerceDateTime(FieldAt(fields, 2)),
            ReceivedTimeUtc: CoerceDateTime(FieldAt(fields, 3)),
            Message: CoerceString(FieldAt(fields, 4)),
            Severity: CoerceSeverity(FieldAt(fields, 5))));
    }
    return result;
}

private static object? FieldAt(VariantCollection? fields, int index)
    => fields is not null && index < fields.Count ? fields[index].Value : null;

private static string CoerceEventId(object? value) => value switch
{
    byte[] bytes => Convert.ToBase64String(bytes),
    string s => s,
    null => string.Empty,
    _ => value.ToString() ?? string.Empty,
};

private static string? CoerceString(object? value) => value switch
{
    LocalizedText lt => lt.Text,
    string s => s,
    null => null,
    _ => value.ToString(),
};

private static DateTime CoerceDateTime(object? value)
    => value is DateTime dt ? dt : DateTime.MinValue;

private static ushort CoerceSeverity(object? value)
{
    try { return value is null ? (ushort)0 : Convert.ToUInt16(value); }
    catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException) { return 0; }
}
```

Notes for the implementer:
- These types are all in `Opc.Ua` (already `using`d by the driver): `EventFilter`, `SimpleAttributeOperand`, `ObjectTypeIds`, `Attributes`, `BrowseNames`, `QualifiedName`, `QualifiedNameCollection`, `HistoryEvent`, `HistoryEventFieldList`, `VariantCollection`, `Variant`, `LocalizedText`.
- Fully-qualify `Core.Abstractions.HistoricalEvent` to match the existing `Core.Abstractions.HistoryReadResult` style and avoid any SDK name collision.

**Step 4: Run the tests** — Run the same `dotnet test --filter` command. Expected: the 4 new tests PASS (the existing `ReadEventsAsync_throws_NotSupportedException_as_documented` still passes too — untouched).

**Step 5: Commit**

```bash
git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs \
        tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientHistoryTests.cs
git commit -m "feat(opcuaclient): add BuildBaseEventFilter + MapHistoryEvents pure cores"
```

---

### Task 2: `ReadEventsAsync` wire-in + remove stale comment + flip contract test

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (depends on T1; edits the same driver + test files)

**Files:**
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs` (add the public `ReadEventsAsync`; delete the `:1678-1682` stale comment)
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientHistoryTests.cs` (replace the NotSupported test)

**Step 1: Replace the contract test.** Delete `ReadEventsAsync_throws_NotSupportedException_as_documented` (`:83-98`) and add in its place:

```csharp
/// <summary>ReadEventsAsync requires an initialized session like the sibling history reads.</summary>
[Fact]
public async Task ReadEventsAsync_without_initialize_throws_InvalidOperationException()
{
    using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-events-uninit");
    await Should.ThrowAsync<InvalidOperationException>(async () =>
        await drv.ReadEventsAsync(
            sourceName: null,
            startUtc: DateTime.UtcNow.AddMinutes(-5),
            endUtc: DateTime.UtcNow,
            maxEvents: 100,
            cancellationToken: TestContext.Current.CancellationToken));
}
```

**Step 2: Run to verify it fails** — Run: `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests --filter "FullyQualifiedName~ReadEventsAsync_without_initialize"` (`dangerouslyDisableSandbox: true`). Expected: FAIL — `ReadEventsAsync` still inherits the throwing default, so it throws `NotSupportedException`, not `InvalidOperationException` (RequireSession runs first only once the override exists).

**Step 3: Implement `ReadEventsAsync`.** Delete the stale comment block at `:1678-1682` and add the method in its place (after the helpers from T1):

```csharp
/// <summary>
///     Forwards OPC UA HistoryReadEvents to the upstream server. Sends the fixed canonical
///     <see cref="BuildBaseEventFilter"/> and maps the result onto <see cref="HistoricalEvent"/>
///     (the OtOpcUa server projects only those six BaseEventType fields, so a richer client
///     filter would be discarded server-side — the driver supplies the canonical set itself).
/// </summary>
/// <param name="sourceName">
///     Upstream event-notifier NodeId to read from (mirrors how <c>fullReference</c> is the
///     upstream NodeId for raw reads). Null/empty → the upstream Server object (<c>i=2253</c>),
///     the standard server-wide event notifier. An unparseable id short-circuits to an empty
///     result, matching the raw path's malformed-NodeId behavior.
/// </param>
/// <param name="startUtc">Inclusive lower bound on event time (UTC).</param>
/// <param name="endUtc">Exclusive upper bound on event time (UTC).</param>
/// <param name="maxEvents">Upper cap; <c>&lt;= 0</c> means "no cap" (NumValuesPerNode = 0).</param>
/// <param name="cancellationToken">Request cancellation.</param>
/// <returns>The historical events plus the upstream continuation point (null when complete).</returns>
public async Task<Core.Abstractions.HistoricalEventsResult> ReadEventsAsync(
    string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents,
    CancellationToken cancellationToken)
{
    var session = RequireSession();

    NodeId notifierNodeId;
    if (string.IsNullOrEmpty(sourceName))
    {
        notifierNodeId = ObjectIds.Server;
    }
    else if (!TryParseNodeId(session, sourceName, out var parsed))
    {
        return new Core.Abstractions.HistoricalEventsResult([], null);
    }
    else
    {
        notifierNodeId = parsed;
    }

    var details = new ReadEventDetails
    {
        StartTime = startUtc,
        EndTime = endUtc,
        NumValuesPerNode = maxEvents <= 0 ? 0u : (uint)maxEvents,
        Filter = BuildBaseEventFilter(),
    };

    var nodesToRead = new HistoryReadValueIdCollection
    {
        new HistoryReadValueId { NodeId = notifierNodeId },
    };

    await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
    try
    {
        var resp = await session.HistoryReadAsync(
            requestHeader: null,
            historyReadDetails: new ExtensionObject(details),
            timestampsToReturn: TimestampsToReturn.Both,
            releaseContinuationPoints: false,
            nodesToRead: nodesToRead,
            ct: cancellationToken).ConfigureAwait(false);

        if (resp.Results.Count == 0) return new Core.Abstractions.HistoricalEventsResult([], null);
        var r = resp.Results[0];

        var events = r.HistoryData?.Body is HistoryEvent he
            ? MapHistoryEvents(he)
            : [];

        var contPt = r.ContinuationPoint is { Length: > 0 } ? r.ContinuationPoint : null;
        return new Core.Abstractions.HistoricalEventsResult(events, contPt);
    }
    finally { _gate.Release(); }
}
```

Notes:
- `ReadEventDetails`, `HistoryReadValueIdCollection`, `HistoryReadValueId`, `ObjectIds`, `ExtensionObject`, `TimestampsToReturn` are all `Opc.Ua` (already in scope, used by the raw path).
- `TryParseNodeId(ISession, string, out NodeId)` and `RequireSession()` are the existing private members (see `ExecuteHistoryReadAsync`); `_gate` is the existing `SemaphoreSlim`.
- Verify `notifierNodeId` definite-assignment compiles (assigned in both reachable branches; the `else if` early-returns).

**Step 4: Run the tests**

Run: `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests --filter "FullyQualifiedName~OpcUaClientHistoryTests"` (`dangerouslyDisableSandbox: true`)
Expected: ALL pass — including the new `ReadEventsAsync_without_initialize_throws_InvalidOperationException`.

**Step 5: Commit**

```bash
git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs \
        tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientHistoryTests.cs
git commit -m "feat(opcuaclient): implement IHistoryProvider.ReadEventsAsync passthrough"
```

---

### Task 3: Integration smoke + docs + build + live `/run` + finish

**Classification:** small
**Estimated implement time:** ~4 min (implement); live-verify + finish run in the controller
**Parallelizable with:** none (depends on T2)

**Files:**
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcUaClientSmokeTests.cs` (add one skip-gated smoke)
- Modify: `docs/Historian.md` (add a short driver-passthrough note)

**Step 1: Add the integration smoke** to `OpcUaClientSmokeTests.cs`:

```csharp
/// <summary>
///     Verifies HistoryReadEvents passthrough issues a well-formed request and returns a
///     result without throwing. opc-plc exposes live alarm conditions (--alm) but is NOT a
///     historian, so the upstream may return zero historical events or reject the service —
///     either way the driver must produce a HistoricalEventsResult, never throw. This proves
///     the wire request + unwrap path; a non-empty event list is infra-gated on an upstream
///     that historizes events.
/// </summary>
[Fact]
public async Task Client_reads_events_returns_result_without_throwing()
{
    if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);

    var options = OpcPlcProfile.BuildOptions(sim.EndpointUrl);
    await using var drv = new OpcUaClientDriver(options, driverInstanceId: "opcua-smoke-events");
    await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);

    var result = await drv.ReadEventsAsync(
        sourceName: null,                       // null → upstream Server object (i=2253)
        startUtc: DateTime.UtcNow.AddHours(-1),
        endUtc: DateTime.UtcNow,
        maxEvents: 100,
        cancellationToken: TestContext.Current.CancellationToken);

    result.ShouldNotBeNull();
    result.Events.ShouldNotBeNull();
}
```

**Step 2: Build the integration test project**

Run: `dotnet build tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests` (`dangerouslyDisableSandbox: true`)
Expected: 0 errors. (The test is skip-gated; running it without the fixture skips.)

**Step 3: Doc note.** In `docs/Historian.md`, find the section describing `IHistorianDataSource` vs the driver `IHistoryProvider` (or the HistoryRead overview) and add a short paragraph: the OpcUaClient driver's `IHistoryProvider` now forwards **all four** history reads (Raw / Processed / AtTime / **Events**) to its upstream server; for events it sends a fixed canonical BaseEventType `EventFilter` and maps the upstream `HistoryEvent` onto `HistoricalEvent` (the same six fields the server projects). Keep it to ~3 sentences. Distinguish it from the server-side single `IHistorianDataSource` backend. If `docs/Historian.md` has no natural spot, add the note where the OpcUaClient driver history passthrough is first mentioned; if it isn't mentioned at all, add a one-line bullet under the HistoryRead section.

**Step 4: Commit**

```bash
git add tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcUaClientSmokeTests.cs \
        docs/Historian.md
git commit -m "test(opcuaclient): event-history smoke + docs(historian): driver event passthrough"
```

**Step 5: Full driver-test build + run (controller).**

Run: `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests` (`dangerouslyDisableSandbox: true`)
Expected: green (existing + 5 new unit tests).

**Step 6: Live `/run` (controller).** Bring up opc-plc and run the skip-gated integration smoke against it:

```bash
# opc-plc reference sim — either the local integration-test compose or the shared 10.100.0.35:50000.
docker compose -f tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/Docker/docker-compose.yml up -d
# Point the fixture at the running sim; run the event smoke (and the existing smokes as a sanity check).
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests \
  --filter "FullyQualifiedName~Client_reads_events_returns_result_without_throwing" \
  # dangerouslyDisableSandbox: true (the rig is outside the Bash sandbox)
```
Expected: the event smoke PASSES (returns a non-null result without throwing) — proving the wire path. Honest caveat to record: a non-empty event list is infra-gated (opc-plc is not a historian). If the fixture self-skips because the sim env var isn't wired, fall back to the shared `OPCUA_SIM_ENDPOINT=opc.tcp://10.100.0.35:50000` and document the outcome.

**Step 7: Finish — REQUIRED SUB-SKILL `superpowers-extended-cc:finishing-a-development-branch`.** Verify tests green, then **merge to master + push** (the standing finish choice). Stage only by path. After merge, update memory + the never-staged `stillpending.md` §A backlog line (item 2 struck through SHIPPED) — these are local bookkeeping, NOT committed.

---

## Execution notes

- T1 → T2 → T3 strictly serial (T1/T2 edit the same driver file; T2 depends on T1; T3 depends on T2). No parallel dispatch.
- After all three, a final integration code-review over `git diff 400bef47..HEAD` before finishing.
