Files
lmxopcua/docs/plans/2026-06-18-opcuaclient-read-events.md
T
Joseph Doherty 767bc56d97 docs(plan): OpcUaClient ReadEventsAsync implementation plan + tasks
3 serial tasks (T1 pure cores → T2 wire-in → T3 smoke+docs+finish), all
driver-internal. No interface/Commons/proto/EF change; no bUnit.
2026-06-18 06:00:01 -04:00

23 KiB

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.csReadEventsAsync(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.csusing 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)

/// <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):

// 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 usingd 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

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:

/// <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):

/// <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

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:

/// <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

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:

# 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.