Files
mxaccessgw/docs/plans/2026-08-09-write-completion-correlation.md
T

30 KiB

WriteSecured Completion Correlation Implementation Plan

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

Goal: Populate MxCommandReply.statuses[0] on unary WriteSecured/WriteSecured2 replies with the correlated MXAccess OnWriteComplete outcome, bounded by a configurable wait (default 1.5 s), falling back to today's empty-statuses shape on timeout.

Architecture: Worker-side only (plus one gateway config option). A versioned per-(serverHandle, itemHandle) completion cache (mirror of MxAccessValueCache) is populated by the event sink's OnWriteComplete post-publish hook; the STA command executor captures a version baseline before the COM call, then pump-waits (ReadBulk precedent) until a newer completion lands or the deadline passes. Design: docs/plans/2026-08-09-write-completion-correlation-design.md.

Tech Stack: .NET Framework 4.8 x86 worker (no init-only props/positional records!), .NET 10 gateway, protobuf via Grpc.Tools regen. Worker builds/tests run ONLY on windev (10.100.0.48) — local macOS verification covers the gateway + contracts.


Task 0: Create feature branch

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

cd /Users/dohertj2/Desktop/MxAccessGateway && git checkout -b feat/write-completion-correlation

Task 1: Proto contract comments + regen

Classification: small Estimated implement time: ~4 min Parallelizable with: none (everything builds on the regenerated contracts)

Files:

  • Modify: src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto (~line 528 statuses, ~line 259 WriteSecuredCommand, ~line 269 WriteSecured2Command)
  • Regenerate: src/ZB.MOM.WW.MxGateway.Contracts/Generated/*.cs

Step 1: On repeated MxStatusProxy statuses = 7; in MxCommandReply, add above the field:

  // Correlated per-item outcome rows. For WRITE_SECURED / WRITE_SECURED2
  // replies the worker holds the reply for a bounded window (default 1.5 s,
  // MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for the matching
  // MXAccess OnWriteComplete callback and copies its status rows here, so
  // statuses[0] carries the real MXAccess commit outcome (success OR failure)
  // while protocol_status/hresult still describe command acceptance only.
  // Empty statuses on a write reply means the completion did not arrive
  // within the window — the write is unconfirmed, not failed. Correlation is
  // best-effort per (server_handle, item_handle): MXAccess's callback carries
  // no transaction id, so concurrent writes to the same item within the
  // window can swap rows. The OnWriteComplete event still flows on the event
  // stream unchanged. Other command kinds leave this field as before.

Step 2: On message WriteSecuredCommand and message WriteSecured2Command, append to the existing leading comment (or add one): // The unary reply's statuses field carries the correlated OnWriteComplete outcome when it arrives within the worker's bounded wait — see MxCommandReply.statuses.

Step 3: Regenerate + verify wire-identical build:

rm src/ZB.MOM.WW.MxGateway.Contracts/Generated/*.cs
dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj

Expected: build succeeds, git diff --stat shows only comment-churn in Generated.

Step 4: Commit: git add src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto src/ZB.MOM.WW.MxGateway.Contracts/Generated && git commit -m "docs(proto): document the correlated write-completion statuses contract" (Comment-only proto change is wire-identical; other clients' generated code is intentionally not regenerated — no functional delta.)

Task 2: MxAccessWriteCompletionCache + tests

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

Files:

  • Create: src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessWriteCompletionCache.cs
  • Create: src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessWriteCompletionCacheTests.cs

Step 1: Create the cache — mirror MxAccessValueCache's shape, locking, and net48 constraints (no init-only, plain struct/class):

using System;
using System.Collections.Generic;
using System.Threading;
using Google.Protobuf.Collections;
using ZB.MOM.WW.MxGateway.Contracts.Proto;

namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;

/// <summary>
///     Per-session cache of the most recent <c>OnWriteComplete</c> status rows
///     for each (server handle, item handle) pair. Written by the MXAccess
///     event sink as completion callbacks arrive; read by the write command
///     executor so a WriteSecured/WriteSecured2 reply can carry the correlated
///     MXAccess outcome instead of proving command acceptance only.
/// </summary>
/// <remarks>
///     Same threading posture as <see cref="MxAccessValueCache"/>: writers and
///     readers run on the worker's STA thread (COM dispatches events on the
///     apartment thread; commands also execute on the STA), so no internal
///     locking is required. A single sync root keeps it nominally thread-safe
///     for tests that drive it from a non-STA thread.
/// </remarks>
public sealed class MxAccessWriteCompletionCache
{
    private readonly Dictionary<long, CompletionEntry> entries = new();
    private readonly object syncRoot = new();

    /// <summary>Records the status rows of a fresh OnWriteComplete callback for the given handle pair.</summary>
    /// <param name="serverHandle">MXAccess server handle.</param>
    /// <param name="itemHandle">MXAccess item handle.</param>
    /// <param name="statuses">Status rows from the mapped OnWriteComplete event; cloned before storing.</param>
    public void Record(
        int serverHandle,
        int itemHandle,
        RepeatedField<MxStatusProxy> statuses)
    {
        if (statuses is null)
        {
            throw new ArgumentNullException(nameof(statuses));
        }

        lock (syncRoot)
        {
            long key = CreateItemKey(serverHandle, itemHandle);
            ulong version = entries.TryGetValue(key, out CompletionEntry existing)
                ? existing.Version + 1
                : 1UL;
            entries[key] = new CompletionEntry(version, statuses.Clone());
        }
    }

    /// <summary>Returns the current completion version for a handle pair, or 0 if none was recorded.</summary>
    /// <param name="serverHandle">MXAccess server handle.</param>
    /// <param name="itemHandle">MXAccess item handle.</param>
    /// <returns>The current completion version, or 0 if no completion was recorded.</returns>
    public ulong CurrentVersion(
        int serverHandle,
        int itemHandle)
    {
        lock (syncRoot)
        {
            return entries.TryGetValue(CreateItemKey(serverHandle, itemHandle), out CompletionEntry existing)
                ? existing.Version
                : 0UL;
        }
    }

    /// <summary>
    ///     Polls for a completion newer than <paramref name="sinceVersion"/> until it
    ///     arrives or the deadline elapses, calling <paramref name="pumpStep"/> on every
    ///     poll iteration so the worker's STA can dispatch the inbound MXAccess
    ///     OnWriteComplete message. Same loop shape as
    ///     <see cref="MxAccessValueCache.TryWaitForUpdate"/>.
    /// </summary>
    /// <param name="serverHandle">MXAccess server handle.</param>
    /// <param name="itemHandle">MXAccess item handle.</param>
    /// <param name="sinceVersion">Version snapshot captured before the write COM call.</param>
    /// <param name="deadlineUtc">Absolute UTC deadline.</param>
    /// <param name="pumpStep">Action that pumps any pending Windows messages.</param>
    /// <param name="statuses">The recorded status rows if a completion arrived before the deadline.</param>
    /// <param name="pollIntervalMs">How long to sleep between pump cycles. Default 5 ms.</param>
    /// <returns><see langword="true"/> if a completion newer than <paramref name="sinceVersion"/> arrived before the deadline; otherwise <see langword="false"/>.</returns>
    public bool TryWaitForCompletion(
        int serverHandle,
        int itemHandle,
        ulong sinceVersion,
        DateTime deadlineUtc,
        Action pumpStep,
        out RepeatedField<MxStatusProxy> statuses,
        int pollIntervalMs = 5)
    {
        if (pumpStep is null)
        {
            throw new ArgumentNullException(nameof(pumpStep));
        }

        while (true)
        {
            pumpStep();

            lock (syncRoot)
            {
                if (entries.TryGetValue(CreateItemKey(serverHandle, itemHandle), out CompletionEntry entry)
                    && entry.Version > sinceVersion)
                {
                    statuses = entry.Statuses;
                    return true;
                }
            }

            if (DateTime.UtcNow >= deadlineUtc)
            {
                statuses = new RepeatedField<MxStatusProxy>();
                return false;
            }

            Thread.Sleep(pollIntervalMs);
        }
    }

    private static long CreateItemKey(
        int serverHandle,
        int itemHandle)
    {
        return ((long)serverHandle << 32) | (uint)itemHandle;
    }

    /// <summary>
    ///     Snapshot of the most recent OnWriteComplete status rows for a handle
    ///     pair. <see cref="Version"/> increments by one on every
    ///     <see cref="Record"/> call so the write executor can detect "a new
    ///     completion arrived since I captured my baseline".
    /// </summary>
    /// <remarks>
    ///     Plain readonly struct (not a record) so this compiles under the
    ///     worker's net48 target, which lacks <c>IsExternalInit</c>.
    /// </remarks>
    private readonly struct CompletionEntry
    {
        public CompletionEntry(
            ulong version,
            RepeatedField<MxStatusProxy> statuses)
        {
            Version = version;
            Statuses = statuses;
        }

        public ulong Version { get; }

        public RepeatedField<MxStatusProxy> Statuses { get; }
    }
}

Step 2: Tests (mirror MxAccessValueCacheTests style; build MxStatusProxy rows inline):

  • Record_IncrementsVersionPerKey — two Record calls on the same pair → CurrentVersion 1 then 2; a different pair stays independent.
  • TryWaitForCompletion_WhenCompletionNewerThanBaseline_ReturnsStatuses — record once, wait with sinceVersion: 0, deadline in the future → true, statuses round-trip (assert an MxStatusProxy field value survives the clone).
  • TryWaitForCompletion_WhenOnlyStaleCompletion_TimesOut — record once, wait with sinceVersion: CurrentVersion(...) and a deadline ~50 ms out → false, out statuses empty.
  • TryWaitForCompletion_InvokesPumpStepEachIteration — pumpStep increments a counter; on the counter's second call, Record the completion (this proves the pump loop is what lets the callback land); assert true and counter >= 2.
  • Record_ClonesStatuses — mutate the caller's RepeatedField after Record; waited-out statuses unaffected.

Step 3: Cannot compile locally (worker is Windows-only) — defer build/test to Task 9 (windev). Commit: git add src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessWriteCompletionCache.cs src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessWriteCompletionCacheTests.cs && git commit -m "feat(worker): versioned OnWriteComplete completion cache"

Task 3: Sink records completions (+ provider seam)

Classification: standard Estimated implement time: ~4 min Parallelizable with: Task 7

Files:

  • Create: src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IWriteCompletionCacheProvider.cs
  • Modify: src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessBaseEventSink.cs
  • Test: src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessBaseEventSinkTests.cs

Step 1: New seam interface:

namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;

/// <summary>
///     Exposes the per-session <see cref="MxAccessWriteCompletionCache"/> an
///     event sink populates from OnWriteComplete callbacks, so
///     <see cref="MxAccessSession.Create"/> can share one instance between the
///     sink (writer) and the write command executor (reader). Implemented by
///     <see cref="MxAccessBaseEventSink"/> and by test sinks that cannot
///     attach to a live MXAccess COM object.
/// </summary>
public interface IWriteCompletionCacheProvider
{
    /// <summary>The completion cache bound to this sink.</summary>
    MxAccessWriteCompletionCache WriteCompletionCache { get; }
}

Step 2: MxAccessBaseEventSink — declare IWriteCompletionCacheProvider on the class; add a private readonly MxAccessWriteCompletionCache writeCompletionCache; initialized in the widest ctor (add a new optional-most ctor overload following the existing chain pattern: the 3-arg (eventQueue, eventMapper, valueCache) ctor chains to a new 4-arg (eventQueue, eventMapper, valueCache, writeCompletionCache) with a fresh cache); expose public MxAccessWriteCompletionCache WriteCompletionCache => writeCompletionCache;. Change OnWriteComplete to use the post-publish hook (same pattern as OnDataChange's value-cache publish — post-publish only runs after the event cleared the queue, and a queue overflow faults the session anyway):

        MXSTATUS_PROXY[] statuses = pVars;
        EnqueueEvent(
            () => eventMapper.CreateOnWriteComplete(
                sessionId,
                hLMXServerHandle,
                phItemHandle,
                statuses),
            mxEvent => writeCompletionCache.Record(hLMXServerHandle, phItemHandle, mxEvent.Statuses));

Step 3: Tests in MxAccessBaseEventSinkTests (mirror OnDataChange_ComCallback_PopulatesValueCache and ValueCache_ReturnsTheInstanceBoundAtConstruction):

  • OnWriteComplete_ComCallback_RecordsCompletionAndStillEnqueuesEvent — drive sink.OnWriteComplete(7, 21, ref proxies); assert the queue got the OnWriteComplete event AND cache.CurrentVersion(7, 21) == 1.
  • WriteCompletionCache_ReturnsTheInstanceBoundAtConstruction.

Step 4: Commit: git commit -m "feat(worker): event sink records OnWriteComplete rows into the completion cache" (explicit paths).

Task 4: MxAccessSession plumbing

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

Files:

  • Modify: src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessSession.cs (private ctor ~line 18, Create ~line 145)

Step 1: Add private readonly MxAccessWriteCompletionCache writeCompletionCache; + ctor param (after valueCache) + null guard; add property mirroring ValueCache:

    /// <summary>
    ///     Per-session OnWriteComplete completion cache populated by the event
    ///     sink. The write command executor consults it after a
    ///     WriteSecured/WriteSecured2 COM call so the unary reply can carry
    ///     the correlated completion outcome.
    /// </summary>
    public MxAccessWriteCompletionCache WriteCompletionCache => writeCompletionCache;

Step 2: In Create, next to the value-cache sharing block:

            // Share the sink's completion cache the same way (production sink
            // and completion-aware test sinks implement the provider seam);
            // fall back to a fresh cache for other fakes — the write executor
            // then simply never observes a completion and replies unconfirmed.
            MxAccessWriteCompletionCache writeCompletionCache = eventSink is IWriteCompletionCacheProvider provider
                ? provider.WriteCompletionCache
                : new MxAccessWriteCompletionCache();

Pass it to the ctor.

Step 3: Commit: git commit -m "feat(worker): share the completion cache between sink and session".

Task 5: Executor bounded wait + StaSession env plumbing

Classification: high-risk (STA/pump semantics) Estimated implement time: ~5 min Parallelizable with: none (touches the same files as 6's tests exercise)

Files:

  • Modify: src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs (~lines 14-85 ctors, 447-497 write methods)
  • Modify: src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessStaSession.cs (~line 205 executor construction)

Step 1: Executor — next to DefaultReadBulkTimeout:

    /// <summary>
    ///     Default bounded wait for the OnWriteComplete callback after a
    ///     WriteSecured/WriteSecured2 COM call. 1.5 s keeps the unary reply
    ///     inside the OtOpcUa driver's 2 s Tier A write-resilience budget (a
    ///     longer gateway wait must raise that consumer timeout in step) while
    ///     covering the common fast-commit case; on expiry the reply returns
    ///     with empty statuses — unconfirmed, not failed.
    /// </summary>
    internal static readonly TimeSpan DefaultWriteCompletionTimeout = TimeSpan.FromMilliseconds(1500);

    private readonly TimeSpan writeCompletionTimeout;

Add TimeSpan? writeCompletionTimeout = null as a trailing optional parameter on the widest (4-arg) ctor; this.writeCompletionTimeout = writeCompletionTimeout ?? DefaultWriteCompletionTimeout;.

Step 2: In ExecuteWriteSecured, replace the tail (session.WriteSecured(...); return CreateOkReply(command);) with:

        MxAccessWriteCompletionCache completionCache = session.WriteCompletionCache;
        // Baseline BEFORE the COM call: a completion that dispatches during or
        // immediately after WriteSecured bumps the version past this snapshot,
        // so a fast commit still correlates (no missed-callback window).
        ulong completionBaseline = completionCache.CurrentVersion(
            writeSecuredCommand.ServerHandle,
            writeSecuredCommand.ItemHandle);

        session.WriteSecured(
            writeSecuredCommand.ServerHandle,
            writeSecuredCommand.ItemHandle,
            writeSecuredCommand.CurrentUserId,
            writeSecuredCommand.VerifierUserId,
            variantConverter.ConvertToComValue(writeSecuredCommand.Value));

        MxCommandReply reply = CreateOkReply(command);
        AwaitWriteCompletion(
            reply,
            completionCache,
            writeSecuredCommand.ServerHandle,
            writeSecuredCommand.ItemHandle,
            completionBaseline);
        return reply;

Mirror in ExecuteWriteSecured2. Shared private helper:

    /// <summary>
    ///     Bounded pump-wait for the OnWriteComplete row matching a
    ///     WriteSecured/WriteSecured2 call, copied onto the reply when it
    ///     arrives in time. The executor holds the STA thread but pumps
    ///     Windows messages each poll (ReadBulk precedent) so the COM callback
    ///     can dispatch re-entrantly; on expiry the reply keeps its empty
    ///     statuses — the consumer's unconfirmed path, never a synthesized
    ///     failure. Protocol status/hresult stay acceptance-only either way.
    /// </summary>
    private void AwaitWriteCompletion(
        MxCommandReply reply,
        MxAccessWriteCompletionCache completionCache,
        int serverHandle,
        int itemHandle,
        ulong completionBaseline)
    {
        if (writeCompletionTimeout <= TimeSpan.Zero)
        {
            return;
        }

        if (completionCache.TryWaitForCompletion(
                serverHandle,
                itemHandle,
                completionBaseline,
                DateTime.UtcNow + writeCompletionTimeout,
                pumpStep,
                out Google.Protobuf.Collections.RepeatedField<MxStatusProxy> statuses))
        {
            reply.Statuses.Add(statuses);
        }
    }

Step 3: MxAccessStaSession — add:

    /// <summary>
    ///     Environment variable the gateway's WorkerProcessLauncher sets from
    ///     MxGateway:Worker:WriteCompletionWaitMilliseconds. 0 disables the
    ///     write-completion wait (pure fire-and-forget replies).
    /// </summary>
    internal const string WriteCompletionWaitEnvironmentVariableName =
        "MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS";

    /// <summary>
    ///     Bounded WriteSecured/WriteSecured2 completion wait handed to the
    ///     command executor at StartAsync. Internal-settable as a test seam so
    ///     Worker.Tests can shorten it without env-var plumbing.
    /// </summary>
    internal TimeSpan WriteCompletionTimeout { get; set; } = ResolveWriteCompletionTimeout();

    internal static TimeSpan ResolveWriteCompletionTimeout()
    {
        string value = Environment.GetEnvironmentVariable(WriteCompletionWaitEnvironmentVariableName);
        return int.TryParse(value, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out int milliseconds)
            && milliseconds >= 0
                ? TimeSpan.FromMilliseconds(milliseconds)
                : MxAccessCommandExecutor.DefaultWriteCompletionTimeout;
    }

and pass writeCompletionTimeout: WriteCompletionTimeout when constructing MxAccessCommandExecutor in StartAsync. (net48: Environment.GetEnvironmentVariable returns string — keep nullable annotations consistent with the file.)

Step 4: Commit: git commit -m "feat(worker): bounded pump-wait correlates OnWriteComplete onto secured-write replies".

Task 6: Executor tests

Classification: standard Estimated implement time: ~5 min Parallelizable with: none (depends on Tasks 2-5)

Files:

  • Test: src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessCommandExecutorTests.cs

Step 1: Test support inside the test class file:

  • New sink: private sealed class CompletionCacheEventSink : IMxAccessEventSink, IWriteCompletionCacheProvider { public MxAccessWriteCompletionCache WriteCompletionCache { get; } = new MxAccessWriteCompletionCache(); public void Attach(object mxAccessComObject, string sessionId) { } public void Detach() { } } (match the exact IMxAccessEventSink member list — read the interface first).
  • FakeMxAccessComObject: add public Action OnWriteSecuredCallback { get; set; } (nullable per file convention) invoked at the end of WriteSecured and WriteSecured2.

Step 2: Tests (all through MxAccessStaSession.DispatchAsync, constructing the session with the completion sink; set session.WriteCompletionTimeout before StartAsync):

  • DispatchAsync_WriteSecured_WhenCompletionArrivesDuringComCall_ReturnsStatuses (fast-completion edge): wire OnWriteSecuredCallback = () => sink.WriteCompletionCache.Record(82, 821, StatusRows(1)) (helper building a RepeatedField<MxStatusProxy> with a recognizable value); dispatch; assert ProtocolStatus.Code == Ok, reply.Statuses.Count == 1, row round-trips.
  • DispatchAsync_WriteSecured_WhenCompletionArrivesWhileWaiting_ReturnsStatuses: no fake callback; WriteCompletionTimeout = TimeSpan.FromSeconds(10); start Task<MxCommandReply> pending = session.DispatchAsync(...), then sink.WriteCompletionCache.Record(...) from the test thread after a short Task.Delay(50); await; assert statuses present. (No .ConfigureAwait(false) in [Fact] bodies — xUnit1030 fails the Windows build.)
  • DispatchAsync_WriteSecured_WhenNoCompletion_TimesOutWithEmptyStatusesAndOkProtocol: WriteCompletionTimeout = TimeSpan.FromMilliseconds(100); assert Ok + reply.Statuses.Count == 0.
  • DispatchAsync_WriteSecured2_WhenCompletionArrivesDuringComCall_ReturnsStatuses (mirror of the fast test).
  • DispatchAsync_Write_DoesNotWaitForCompletion: WriteCompletionTimeout = TimeSpan.FromSeconds(30), plain Write command, no completion recorded; assert await completes within a 5 s guard (Task.WhenAny with Task.Delay) — proving plain writes never enter the wait.
  • Baseline test DispatchAsync_WriteSecured_IgnoresStaleCompletionFromBeforeTheCall: Record once BEFORE dispatch, WriteCompletionTimeout = 100 ms, no new completion → empty statuses (stale row not misattributed).

Step 2b: Confirm the two existing WriteSecured tests (DispatchAsync_WriteSecured_ForwardsUserIds, ..._WriteSecured2_...) still pass unmodified — they use NoopEventSink, so the session falls back to a fresh cache, no completion ever arrives, and the default 1.5 s wait adds latency only; if that latency bothers the suite, switch them to the completion sink with WriteCompletionTimeout = TimeSpan.Zero.

Step 3: Commit: git commit -m "test(worker): write-completion correlation executor coverage".

Task 7: Gateway config option + launcher env var

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 2, Task 3, Task 4

Files:

  • Modify: src/ZB.MOM.WW.MxGateway.Server/Configuration/WorkerOptions.cs
  • Modify: src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs (~line 224 block)
  • Modify: src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerProcessLauncher.cs (~lines 18-22 consts, ~line 175 env block)
  • Test: src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/WorkerProcessLauncherTests.cs, the GatewayOptionsValidator test file (find via grep -rl GatewayOptionsValidatorTests src/ZB.MOM.WW.MxGateway.Tests)

Step 1: WorkerOptions:

    /// <summary>
    ///     Bounded wait, in milliseconds, the worker holds a WriteSecured/WriteSecured2
    ///     reply for the matching MXAccess OnWriteComplete callback so the reply's
    ///     statuses carry the real commit outcome. 0 disables the wait. Deployments
    ///     raising this above consumer write-timeout budgets (e.g. OtOpcUa's 2 s Tier A
    ///     write resilience timeout) must raise those in step.
    /// </summary>
    public int WriteCompletionWaitMilliseconds { get; init; } = 1500;

Step 2: Validator (>= 0, not the positive helper):

        if (options.WriteCompletionWaitMilliseconds < 0)
        {
            builder.Add("MxGateway:Worker:WriteCompletionWaitMilliseconds must be greater than or equal to zero.");
        }

Step 3: Launcher — const public const string WorkerWriteCompletionWaitEnvironmentVariableName = "MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS"; and in CreateStartInfo next to the pipe-connect env line:

        startInfo.Environment[WorkerWriteCompletionWaitEnvironmentVariableName] =
            _workerOptions.WriteCompletionWaitMilliseconds.ToString(System.Globalization.CultureInfo.InvariantCulture);

Step 4: Tests — mirror the existing pipe-connect-timeout launcher env assertion and an existing validator negative test; add: launcher exports MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS=1500 by default / custom value when configured; validator rejects -1, accepts 0.

Step 5: Run locally:

dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter "FullyQualifiedName~WorkerProcessLauncherTests|FullyQualifiedName~GatewayOptionsValidator"

Expected: PASS.

Step 6: Commit: git commit -m "feat(gateway): configurable worker write-completion wait (MxGateway:Worker:WriteCompletionWaitMilliseconds)".

Task 8: Docs

Classification: small Estimated implement time: ~4 min Parallelizable with: none (write after code settles)

Files:

  • Modify: docs/GatewayConfiguration.md (Worker options table, ~line 113 area)
  • Modify: gateway.md (command surface / write semantics section)
  • Modify: docs/DesignDecisions.md (new decision entry)

Content: the option row (default 1500, 0 disables, consumer-budget pairing rule with OtOpcUa's Write resilience timeout); gateway.md note that WriteSecured/WriteSecured2 unary replies now carry correlated completion statuses (bounded wait, empty = unconfirmed, event stream unchanged); DesignDecisions entry summarizing the design doc (link it) including the best-effort per-(hItem) correlation caveat and why plain Write/Write2/bulk stay fire-and-forget. Follow docs/style-guides/StyleGuide.md (present tense, why not what).

Commit: git commit -m "docs: write-completion correlation configuration and semantics".

Task 9: Local + windev verification

Classification: standard Estimated implement time: ~10 min (mostly remote build time) Parallelizable with: none

Step 1 (local): dotnet build src/ZB.MOM.WW.MxGateway.NonWindows.slnx → succeeds; re-run the Task 7 filtered gateway tests.

Step 2 (push): git push -u origin feat/write-completion-correlation.

Step 3 (windev): Use the isolated C:\build worktree (NOT the Desktop checkout — dirty feature branch). Remote PS via base64 -EncodedCommand; no $ErrorActionPreference='Stop' around git. Sequence:

git fetch origin && git checkout feat/write-completion-correlation && git pull
dotnet build src/ZB.MOM.WW.MxGateway.Worker/ZB.MOM.WW.MxGateway.Worker.csproj -p:Platform=x86
dotnet test src/ZB.MOM.WW.MxGateway.Worker.Tests/ZB.MOM.WW.MxGateway.Worker.Tests.csproj -p:Platform=x86 --filter "FullyQualifiedName~MxAccessWriteCompletionCacheTests|FullyQualifiedName~MxAccessBaseEventSinkTests|FullyQualifiedName~MxAccessCommandExecutorTests"

Expected: build clean (TreatWarningsAsErrors — watch xUnit1030), all filtered tests PASS. Fix-and-push iterations happen from the Mac; windev only builds/tests.

Step 4: Full worker test suite once green on the filter (dotnet test ... -p:Platform=x86, no filter) — one full pass before merge.

Task 10: Review, merge, notify

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

  • Run a code review over the full branch diff (code-reviewer agent or /code-review) and address findings.
  • Merge: git checkout main && git merge --no-ff feat/write-completion-correlation && git push origin main.
  • Notify the OtOpcUa session (SendMessage) that the feature is on main and built/tested on windev; flag that the live ArchestrA leg needs the user's Windows deployment (wonder-app-vd03 / 10.100.0.48 redeploy is a separate user-approved step).
  • Surface to the user: deployed services (MxAccessGw on 10.100.0.48, wonder-app-vd03) do NOT pick this up until redeployed; OtOpcUa's end-to-end verification against a live gateway needs that redeploy.