Files
mxaccessgw/docs/superpowers/plans/2026-10-19-ackmsg-as-operator.md
T

112 KiB

AckMsg-as-Operator Alarm Acknowledgement Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development (recommended) or superpowers-extended-cc:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Make AcknowledgeAlarm acknowledge alarms by writing the comment to <TAGNAME>.AckMsg over MXAccess after authenticating as the requesting operator, so the AVEVA Historian events table records the real operator name, domain and full name (the way InTouch does).

Architecture: The public AcknowledgeAlarmRequest gains operator_password (credential) and operator_domain; the gateway forwards them on the worker ack commands. In the x86 worker a new AlarmAckWriter owns its own registered LMXProxyServer, authenticates the operator (AuthenticateUser), advises <item>.AckMsg, writes the comment with that user id and waits (bounded, pumped) for OnWriteComplete. Alarm consumers stop acking themselves and only resolve the ack item address (alarm-manager GUID → snapshot TAGNAME; by-name → tag; subtag → AckCommentSubtag). The dead wnwrap/managed alarm-client ack paths are removed.

Tech Stack: .NET 10 gateway, .NET Framework 4.8 x86 worker (no init-only props / positional records), protobuf/gRPC (Grpc.Tools 2.80.0), ArchestrA MXAccess COM (LMXProxyServerClass), xUnit; clients in .NET, Python, Go, Rust, Java.

Spec: this document's Design section (below) plus the live evidence in memory project_managed_alarm_ack_live_result.md and docs/AlarmClientDiscovery.md §4.

Global Constraints

  • Field numbers (exact): AcknowledgeAlarmRequest.operator_password = 6, AcknowledgeAlarmRequest.operator_domain = 7, AcknowledgeAlarmCommand.operator_password = 7, AcknowledgeAlarmByNameCommand.operator_password = 9. Additive only; GatewayProtocolVersion stays 3.
  • Credential handling: operator_password must never reach logs, metrics labels, command lines, DiagnosticMessage, ProtocolStatus.Message, exception messages, or ToString()/Debug output that is logged. Mirror the existing AuthenticateUserCommand.verify_user_password comment and tests (DispatchAsync_AuthenticateUser_DoesNotLeakPassword).
  • Ack item address: "{TagName}.AckMsg" for Galaxy-provider alarm-manager alarms (constant AlarmAckWriter.AckCommentAttribute = "AckMsg"); subtag mode uses the target's AckCommentSubtag verbatim. Alarms whose provider is not Galaxy (case-insensitive) are rejected as not ackable.
  • Verify user: operator_domain empty → AuthenticateUser(operator_user, operator_password); non-empty → AuthenticateUser($"{operator_domain}\\{operator_user}", operator_password).
  • operator_user is required on AcknowledgeAlarmRequest (gateway returns InvalidArgument when empty) — the requirement is that the real operator is recorded; an empty password IS allowed (Galaxy accounts may have empty passwords).
  • Historian OperatorNode = the ack writer's MXAccess Register() client name: gateway option MxGateway:Alarms:AckClientName, default "MxGateway", conveyed to workers as env MXGATEWAY_ALARM_ACK_CLIENT_NAME.
  • Reply semantics: Hresult/AcknowledgeAlarm.NativeStatus = 0 on applied or unconfirmed-but-sent; non-zero = AuthenticateUser HRESULT or the write status detail. reply.Statuses carries the OnWriteComplete MxStatusProxy rows when observed.
  • net48 worker: no init accessors, no positional records; TreatWarningsAsErrors=true; xUnit1030 forbids .ConfigureAwait(false) in [Fact] bodies (only surfaces on Windows).
  • Generated code is committed: src/ZB.MOM.WW.MxGateway.Contracts/Generated/*.cs, clients/proto/descriptors/mxaccessgw-client-v1.protoset, clients/rust/protos/mxaccess_gateway.proto, Go clients/go/internal/generated/*.pb.go, Python clients/python/src/zb_mom_ww_mxgateway/generated/*_pb2*.py (regenerate with grpcio-tools 1.80.0 only), Java clients/java/src/main/generated/**.
  • Docs change in the same commit as behaviour (CLAUDE.md). Commits end with Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT.
  • Work on branch feat/ackmsg-as-operator off main. Run targeted tests per task (--filter); full gateway suite at most once at the end.

User decisions (already made):

  • "build the AckMsg-as-operator route" — ack = authenticated AckMsg write as the operator (not a service account, not the alarm-client string ack).
  • "we need to capture the operator name in the historian in the events table just as intouch does" — success is a Historian ACK_ALM row whose Operator is the requesting operator.
  • "yes operators have galaxy accounts" — authenticating each operator with their own Galaxy credentials is acceptable.
  • "dont forget to verify the historian is logging the event with the username captured" — live Historian verification is mandatory.
  • Earlier (2026-10-18): remove the dead managed alarm-ack path from PR #160 was recommended; this plan removes it because the new path supersedes it.

Design

Why this route. Live tests (2026-10-18/19, windev rig) showed every alarm-client ack (wnwrap AlarmAckByName/AlarmAckByGUID/AlarmAck/AlarmAckSelected, managed AlarmClient.*, native WNAL UserRequest) returns 0 and is silently dropped for a headless consumer, regardless of security classification, operator name or consumer identity. Writing <Object>.<Alarm>.AckMsg over MXAccess does acknowledge, and when the write is authenticated (AuthenticateUser + Write(value, userId)) the Historian Runtime.dbo.v_AlarmEventHistory2 ACK_ALM row carries Operator, DomainName, UserFullName from the authenticated account and OperatorNode = the MXAccess client name.

Data flow.

Client AcknowledgeAlarmRequest{alarm_full_reference, comment, operator_user, operator_password, operator_domain}
  → MxAccessGatewayService.AcknowledgeAlarm (validates reference + operator_user)
  → GatewayAlarmMonitor.AcknowledgeAsync → BuildAcknowledgeCommand
      GUID ref  → AcknowledgeAlarmCommand{alarm_guid, comment, operator_user, operator_domain, operator_password}
      P!G.T ref → AcknowledgeAlarmByNameCommand{alarm_name, provider_name, group_name, comment, operator_user, operator_domain, operator_password}
  → worker MxAccessCommandExecutor.ExecuteAcknowledgeAlarm[ByName]
  → AlarmCommandHandler.Acknowledge[ByName](…, AlarmAckOperator) → AlarmDispatcher
      → consumer.ResolveAckTarget(guid) / ResolveAckTargetByName(name, provider, group) → AlarmAckTarget{ItemAddress}
      → AlarmAckWriter.Acknowledge(itemAddress, comment, operator) → AlarmAckResult
  → executor maps AlarmAckResult → MxCommandReply (status, hresult, native_status, statuses, diagnostic)

New worker types (namespace ZB.MOM.WW.MxGateway.Worker.MxAccess):

  • AlarmAckOperator — UserName, Domain, Password (get/set; ToString() overridden to omit the password); VerifyUser computed.
  • AlarmAckTarget — Found, ItemAddress, Outcome (AlarmAckOutcome.AlarmNotFound/NotAckable when not found), Reason; static factories For(item), NotFound(reason), NotAckable(reason).
  • AlarmAckOutcome enum — Applied, Unconfirmed, AlarmNotFound, NotAckable, AuthenticationFailed, WriteRejected.
  • AlarmAckResult — Outcome, NativeStatus, Diagnostic, Statuses (IReadOnlyList<MxStatusProxy>); static factories.
  • IAlarmAckWriter : IDisposable — AlarmAckResult Acknowledge(string itemAddress, string comment, AlarmAckOperator ackOperator).
  • AlarmAckWriter — production implementation over its own LMXProxyServerClass.

Removed: AaManagedAlarmAckConsumer, IManagedAlarmAckClient, WnalNativeAckConsumer, AlarmManagedGuidAckLiveSmokeTests, MXGATEWAY_USE_MANAGED_ALARM_ACK, AlarmDispatcher.IsSubtagActive, IMxAccessAlarmConsumer.AcknowledgeByGuid/AcknowledgeByName and their implementations, wnwrap's second ackClient consumer, ISubtagAlarmSource.Write/LmxSubtagAlarmSource.Write.

Ack transitions. wnwrap's XML carries ACK_COMMENT, ACK_OPERATOR_NAME, ACK_DOMAIN_NAME, ACK_OPERATOR_NODE once acked. MxAlarmSnapshotRecord gains AckComment, AckOperatorName, AckOperatorDomain, AckOperatorNode; AlarmDispatcher emits Acknowledge transitions (and acked snapshot rows) with operator_user = AckOperatorName and operator_comment = AckComment when those are present.


File Structure

File Change Responsibility
src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto Modify new fields + comments
src/ZB.MOM.WW.MxGateway.Contracts/Generated/*.cs Regenerate committed C# bindings
src/ZB.MOM.WW.MxGateway.Contracts/WorkerLaunchEnvironment.cs Modify AlarmAckClientName env name
clients/proto/descriptors/mxaccessgw-client-v1.protoset Regenerate client descriptor
src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAck*.cs (AlarmAckOperator, AlarmAckTarget, AlarmAckOutcome, AlarmAckResult, IAlarmAckWriter, AlarmAckWriter) Create ack value types + writer
src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAlarmSnapshot.cs Modify Ack* fields
src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs Modify parse ACK_*, ResolveAckTarget*, drop ack consumer
src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmConsumer.cs, FailoverAlarmConsumer.cs, IMxAccessAlarmConsumer.cs, ISubtagAlarmSource.cs, LmxSubtagAlarmSource.cs Modify resolve-only ack contract
src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs, AlarmCommandHandler.cs, IAlarmCommandHandler.cs, MxAccessCommandExecutor.cs Modify route acks to writer, map result
src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerPipeSession.cs Modify drop managed factory
src/ZB.MOM.WW.MxGateway.Worker/MxAccess/{AaManagedAlarmAckConsumer,IManagedAlarmAckClient,WnalNativeAckConsumer}.cs, src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmManagedGuidAckLiveSmokeTests.cs Delete dead path
src/ZB.MOM.WW.MxGateway.Server/Configuration/AlarmsOptions.cs, GatewayOptionsValidator.cs, Workers/WorkerProcessLauncher.cs Modify AckClientName option + env
src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs, Grpc/MxAccessGatewayService.cs Modify forward credentials, require operator_user
src/ZB.MOM.WW.MxGateway.Server/Diagnostics/GatewayLogRedactor.cs, GatewayLogRedactorSeam.cs Modify ack methods credential-bearing; name-based password redaction
clients/{dotnet,python,go,rust,java}/** Modify regenerate, CLI flags, redaction, tests, README
gateway.md, docs/{Grpc,Contracts,DesignDecisions,AlarmClientDiscovery,AlarmProbeFindings,GatewayConfiguration,Diagnostics,Authorization}.md, CLAUDE.md Modify describe new behaviour

Task 1: Contract fields and regenerated bindings

Goal: Add the credential/domain fields to the public request and the worker ack commands, plus the worker env-var name, and commit regenerated C# bindings and the client descriptor.

Files:

  • Modify: src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto (AcknowledgeAlarmCommand ~394-406, AcknowledgeAlarmByNameCommand ~415-434, AcknowledgeAlarmReplyPayload ~726-737, AcknowledgeAlarmRequest ~977-990)
  • Modify: src/ZB.MOM.WW.MxGateway.Contracts/WorkerLaunchEnvironment.cs
  • Regenerate: src/ZB.MOM.WW.MxGateway.Contracts/Generated/MxaccessGateway.cs, MxaccessGatewayGrpc.cs; clients/proto/descriptors/mxaccessgw-client-v1.protoset; clients/rust/protos/mxaccess_gateway.proto (byte copy)
  • Test: src/ZB.MOM.WW.MxGateway.Tests/Contracts/ProtobufContractRoundTripTests.cs

Acceptance Criteria:

  • AcknowledgeAlarmRequest has operator_password = 6 and operator_domain = 7; AcknowledgeAlarmCommand has operator_password = 7; AcknowledgeAlarmByNameCommand has operator_password = 9; each password field carries the credential comment.
  • WorkerLaunchEnvironment.AlarmAckClientName == "MXGATEWAY_ALARM_ACK_CLIENT_NAME".
  • Round-trip tests cover the new fields and pass.
  • pwsh -File scripts/publish-client-proto-inputs.ps1 -Check passes and ClientProtoInputTests passes.
  • Rust vendored proto is byte-identical to the Contracts proto.

Verify: dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter "FullyQualifiedName~ProtobufContractRoundTripTests|FullyQualifiedName~ClientProtoInputTests" → all pass

Steps:

  • Step 1: Create the branch
git checkout main && git pull --ff-only origin main && git checkout -b feat/ackmsg-as-operator
  • Step 2: Write failing round-trip tests — in ProtobufContractRoundTripTests.cs, extend AcknowledgeAlarmRequest_RoundTripsAllFields and add command round-trips:
    [Fact]
    public void AcknowledgeAlarmRequest_RoundTripsAllFields()
    {
        AcknowledgeAlarmRequest original = new()
        {
            ClientCorrelationId = "corr-1",
            AlarmFullReference = "Galaxy!TestArea.TestMachine_001.TestAlarm001",
            Comment = "investigating",
            OperatorUser = "alice",
            OperatorPassword = "s3cret",
            OperatorDomain = "CORP",
        };

        AcknowledgeAlarmRequest parsed = AcknowledgeAlarmRequest.Parser.ParseFrom(original.ToByteArray());

        Assert.Equal(original, parsed);
        Assert.Equal("s3cret", parsed.OperatorPassword);
        Assert.Equal("CORP", parsed.OperatorDomain);
    }

    [Fact]
    public void AcknowledgeAlarmCommands_RoundTripOperatorPassword()
    {
        MxCommand byGuid = new()
        {
            Kind = MxCommandKind.AcknowledgeAlarm,
            AcknowledgeAlarmCommand = new AcknowledgeAlarmCommand
            {
                AlarmGuid = "BCC47053-9542-4D65-BDAA-BCDEA6A32A73",
                OperatorUser = "alice",
                OperatorDomain = "CORP",
                OperatorPassword = "s3cret",
            },
        };
        MxCommand byName = new()
        {
            Kind = MxCommandKind.AcknowledgeAlarmByName,
            AcknowledgeAlarmByNameCommand = new AcknowledgeAlarmByNameCommand
            {
                AlarmName = "TestMachine_001.TestAlarm001",
                ProviderName = "Galaxy",
                GroupName = "TestArea",
                OperatorUser = "alice",
                OperatorPassword = "s3cret",
            },
        };

        Assert.Equal("s3cret", MxCommand.Parser.ParseFrom(byGuid.ToByteArray()).AcknowledgeAlarmCommand.OperatorPassword);
        Assert.Equal("s3cret", MxCommand.Parser.ParseFrom(byName.ToByteArray()).AcknowledgeAlarmByNameCommand.OperatorPassword);
    }
  • Step 3: Run to verify they fail to compile — dotnet build src/ZB.MOM.WW.MxGateway.Tests → CS1061 OperatorPassword not found.

  • Step 4: Edit the proto. In AcknowledgeAlarmCommand replace the leading comment and append field 7:

// Acknowledge a single alarm by its GUID. The worker resolves the GUID to the
// alarm's Galaxy reference from its active-alarm snapshot and acknowledges it
// by writing `comment` to `<TAGNAME>.AckMsg` over MXAccess after
// AuthenticateUser as the operator, so the alarm history records the real
// operator. The reply's hresult / native_status is 0 when the write was
// applied (or sent but unconfirmed), otherwise the AuthenticateUser HRESULT or
// the OnWriteComplete status detail.
message AcknowledgeAlarmCommand {
  // Canonical 8-4-4-4-12 GUID string (e.g. "BCC47053-9542-4D65-BDAA-BCDEA6A32A73").
  string alarm_guid = 1;
  string comment = 2;
  string operator_user = 3;
  string operator_node = 4;
  string operator_domain = 5;
  string operator_full_name = 6;
  // Operator credential used for AuthenticateUser before the AckMsg write.
  // Implementations must keep this field out of logs, metrics labels, command
  // lines, and diagnostics.
  string operator_password = 7;
}

In AcknowledgeAlarmByNameCommand replace the leading comment's last two lines ("The worker invokes wwAlarmConsumerClass.AlarmAckByName which reaches the same alarm history path as AlarmAckByGUID.") with "The worker acknowledges by writing comment to <alarm_name>.AckMsg (Galaxy provider only) after AuthenticateUser as the operator." and append:

  // Operator credential used for AuthenticateUser before the AckMsg write.
  // Implementations must keep this field out of logs, metrics labels, command
  // lines, and diagnostics.
  string operator_password = 9;

In the AcknowledgeAlarmReplyPayload comment replace "Surfaces AVEVA's native ack return code (AlarmAckByGUID for the GUID arm, AlarmAckByName for the by-name arm); 0 means success." with "Surfaces the ack outcome: 0 when the AckMsg write was applied or sent but unconfirmed; otherwise the AuthenticateUser HRESULT or the OnWriteComplete status detail." and replace "the gateway-side WorkerAlarmRpcDispatcher" with "GatewayAlarmMonitor".

In AcknowledgeAlarmRequest append after operator_user = 5:

  // Operator's Galaxy (or OS) password. The gateway authenticates as
  // operator_user with it before writing the acknowledgement so the alarm
  // history records the real operator. May be empty for accounts without a
  // password. Credential-sensitive: implementations must keep this field out
  // of logs, metrics labels, command lines, and diagnostics.
  string operator_password = 6;
  // Optional domain for OS-authenticated galaxies. When set the gateway
  // authenticates as "<operator_domain>\<operator_user>".
  string operator_domain = 7;

Also update the AcknowledgeAlarmReply.hresult comment: replace "= AlarmAckByName / AlarmAckByGUID return code; 0 = success); the gateway's WorkerAlarmRpcDispatcher copies" with "0 = applied or sent; otherwise the AuthenticateUser HRESULT or OnWriteComplete detail); GatewayAlarmMonitor copies".

  • Step 5: Add the env name in WorkerLaunchEnvironment.cs after MaxAlarmsPerFetch:
    /// <summary>
    ///     MXAccess client name the worker's alarm-ack writer registers with. The
    ///     Historian records it as the acknowledgement's OperatorNode.
    /// </summary>
    public const string AlarmAckClientName = "MXGATEWAY_ALARM_ACK_CLIENT_NAME";
  • Step 6: Regenerate and copy
rm src/ZB.MOM.WW.MxGateway.Contracts/Generated/*.cs
dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj
pwsh -File scripts/publish-client-proto-inputs.ps1
cp src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto clients/rust/protos/mxaccess_gateway.proto
pwsh -File scripts/publish-client-proto-inputs.ps1 -Check
  • Step 7: Run tests — the Verify command → PASS.

  • Step 8: Commit

git add src/ZB.MOM.WW.MxGateway.Contracts clients/proto/descriptors clients/rust/protos src/ZB.MOM.WW.MxGateway.Tests/Contracts/ProtobufContractRoundTripTests.cs
git commit -m "feat(contracts): carry operator credentials on alarm acknowledgement" -m "Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT"

Task 2: Parse wnwrap ACK_* fields and attribute ack transitions

Goal: Capture the ACK_COMMENT/ACK_OPERATOR_NAME/ACK_DOMAIN_NAME/ACK_OPERATOR_NODE elements on snapshot records and use them for Acknowledge transitions and acked snapshot rows.

Files:

  • Modify: src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAlarmSnapshot.cs
  • Modify: src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs (ParseSnapshotXml ~739-849)
  • Modify: src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs (OnTransition ~235-260, MapToSnapshot ~273-274)
  • Test: src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/WnWrapAlarmConsumerXmlTests.cs, AlarmDispatcherTests.cs

Acceptance Criteria:

  • ParseSnapshotXml fills AckComment, AckOperatorName, AckOperatorDomain, AckOperatorNode from the live XML sample below.
  • An UnackAlm → AckAlm transition whose record has AckOperatorName="alice", AckComment="checked" enqueues an OnAlarmTransitionEvent with OperatorUser == "alice" and OperatorComment == "checked".
  • Non-ack transitions keep OperatorUser = OperatorName, OperatorComment = AlarmComment.

Verify: dotnet test src/ZB.MOM.WW.MxGateway.Worker.Tests/ZB.MOM.WW.MxGateway.Worker.Tests.csproj -p:Platform=x86 --filter "FullyQualifiedName~WnWrapAlarmConsumerXmlTests|FullyQualifiedName~AlarmDispatcherTests" (windev) → pass

Steps:

  • Step 1: Failing parser test in WnWrapAlarmConsumerXmlTests.cs:
    [Fact]
    public void ParseSnapshotXml_WithAckElements_PopulatesAckFields()
    {
        const string xml =
            "<?xml version=\"1.0\"?><ALARM_RECORDS COUNT=\"1\"><ALARM><GUID>2D8A9BE88FE04B0F8D996BB4D56620C7</GUID>" +
            "<DATE>2026/10/19</DATE><TIME>18:2:53.812</TIME><GMTOFFSET>240</GMTOFFSET><DSTADJUST>0</DSTADJUST>" +
            "<PROVIDER_NODE>DESKTOP-6JL3KKO</PROVIDER_NODE><PROVIDER_NAME>Galaxy</PROVIDER_NAME><GROUP>TestArea</GROUP>" +
            "<TAGNAME>TestMachine_001.TestAlarm001</TAGNAME><TYPE>DSC</TYPE><VALUE>true</VALUE><LIMIT>true</LIMIT>" +
            "<PRIORITY>500</PRIORITY><STATE>ACK_ALM</STATE><OPERATOR_NODE></OPERATOR_NODE><OPERATOR_NAME></OPERATOR_NAME>" +
            "<ACK_DOMAIN_NAME>ArchestrA</ACK_DOMAIN_NAME><ACK_OPERATOR_NODE>mxa</ACK_OPERATOR_NODE>" +
            "<ACK_OPERATOR_NAME>Administrator</ACK_OPERATOR_NAME><ALARM_COMMENT>Test alarm #1</ALARM_COMMENT>" +
            "<ACK_COMMENT>mgack-authwrite 180714</ACK_COMMENT></ALARM></ALARM_RECORDS>";

        Dictionary<Guid, MxAlarmSnapshotRecord> records = WnWrapAlarmConsumer.ParseSnapshotXml(xml);

        MxAlarmSnapshotRecord record = Assert.Single(records.Values);
        Assert.Equal("Administrator", record.AckOperatorName);
        Assert.Equal("ArchestrA", record.AckOperatorDomain);
        Assert.Equal("mxa", record.AckOperatorNode);
        Assert.Equal("mgack-authwrite 180714", record.AckComment);
        Assert.Equal("Test alarm #1", record.AlarmComment);
    }
  • Step 2: Failing dispatcher test in AlarmDispatcherTests.cs (reuses the file's FakeAlarmConsumer):
    [Fact]
    public void OnTransition_WhenAcknowledged_UsesAckOperatorAndComment()
    {
        FakeAlarmConsumer consumer = new FakeAlarmConsumer();
        MxAccessEventQueue queue = new MxAccessEventQueue();
        using AlarmDispatcher dispatcher = new AlarmDispatcher(
            consumer, new MxAccessAlarmEventSink(queue, new MxAccessEventMapper()), SessionId, new FakeAckWriter());

        consumer.RaiseTransition(new MxAlarmTransitionEvent
        {
            PreviousState = MxAlarmStateKind.UnackAlm,
            Record = new MxAlarmSnapshotRecord
            {
                AlarmGuid = Guid.NewGuid(),
                ProviderName = "Galaxy",
                Group = "TestArea",
                TagName = "TestMachine_001.TestAlarm001",
                State = MxAlarmStateKind.AckAlm,
                AlarmComment = "Test alarm #1",
                AckOperatorName = "alice",
                AckComment = "checked",
            },
        });

        Assert.True(queue.TryDequeue(out WorkerEvent? evt));
        OnAlarmTransitionEvent body = evt!.Event.OnAlarmTransition;
        Assert.Equal(AlarmTransitionKind.Acknowledge, body.TransitionKind);
        Assert.Equal("alice", body.OperatorUser);
        Assert.Equal("checked", body.OperatorComment);
    }

(FakeAckWriter and the 4-arg AlarmDispatcher constructor arrive in Task 5; until then write this test against the current 3-arg constructor — drop the 4th argument — and update it in Task 5.)

  • Step 3: Add fields to MxAlarmSnapshotRecord (after AlarmComment):
    /// <summary>Gets or sets the acknowledgement comment (wnwrap <c>ACK_COMMENT</c>).</summary>
    public string AckComment { get; set; } = string.Empty;

    /// <summary>Gets or sets the acknowledging operator's name (wnwrap <c>ACK_OPERATOR_NAME</c>).</summary>
    public string AckOperatorName { get; set; } = string.Empty;

    /// <summary>Gets or sets the acknowledging operator's domain (wnwrap <c>ACK_DOMAIN_NAME</c>).</summary>
    public string AckOperatorDomain { get; set; } = string.Empty;

    /// <summary>Gets or sets the acknowledging node, i.e. the MXAccess client name (wnwrap <c>ACK_OPERATOR_NODE</c>).</summary>
    public string AckOperatorNode { get; set; } = string.Empty;
  • Step 4: Parse them in ParseSnapshotXml: declare string? ackComment = null; string? ackOperatorName = null; string? ackOperatorDomain = null; string? ackOperatorNode = null; next to alarmComment; add switch arms
                    case "ACK_COMMENT": ackComment ??= child.InnerText; break;
                    case "ACK_OPERATOR_NAME": ackOperatorName ??= child.InnerText; break;
                    case "ACK_DOMAIN_NAME": ackOperatorDomain ??= child.InnerText; break;
                    case "ACK_OPERATOR_NODE": ackOperatorNode ??= child.InnerText; break;

and initializers AckComment = ackComment ?? string.Empty, AckOperatorName = ackOperatorName ?? string.Empty, AckOperatorDomain = ackOperatorDomain ?? string.Empty, AckOperatorNode = ackOperatorNode ?? string.Empty,.

  • Step 5: Use them in AlarmDispatcher. Add helpers and use them in OnTransition and MapToSnapshot:
    private static bool HasAckAttribution(MxAlarmSnapshotRecord record) =>
        !string.IsNullOrEmpty(record.AckOperatorName) || !string.IsNullOrEmpty(record.AckComment);

    private static bool IsAcked(MxAlarmStateKind state) =>
        state == MxAlarmStateKind.AckAlm || state == MxAlarmStateKind.AckRtn;

In OnTransition replace operatorUser: record.OperatorName, operatorComment: record.AlarmComment, with:

        bool useAck = kind == AlarmTransitionKind.Acknowledge && HasAckAttribution(record);
        ...
            operatorUser: useAck ? record.AckOperatorName : record.OperatorName,
            operatorComment: useAck ? record.AckComment : record.AlarmComment,

In MapToSnapshot use IsAcked(record.State) && HasAckAttribution(record) the same way. (Check MxAlarmStateKind member names in AlarmRecordTransitionMapper.ParseStateKind; use the exact acked members it defines.)

  • Step 6: Commit after the Verify command passes on windev (or after Task 5 when verifying the worker together):
git add src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAlarmSnapshot.cs src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess
git commit -m "feat(worker): attribute alarm ack transitions from wnwrap ACK fields" -m "Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT"

Task 3: AlarmAckWriter (authenticated AckMsg write)

Goal: Add the ack value types and AlarmAckWriter, which authenticates the operator on its own registered MXAccess server, writes the comment to the ack item, and waits for OnWriteComplete.

Files:

  • Create: src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckOperator.cs, AlarmAckOutcome.cs, AlarmAckTarget.cs, AlarmAckResult.cs, IAlarmAckWriter.cs, AlarmAckWriter.cs
  • Test: src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmAckWriterTests.cs

Acceptance Criteria:

  • With a fake IMxAccessServer returning user id 5: Acknowledge("T.A.AckMsg", "c", {alice, CORP, pw}) calls Register(clientName) once, AuthenticateUser(h, "CORP\\alice", "pw"), AddItem("T.A.AckMsg"), Advise, Write(h, item, "c", 5) in that order; a recorded OnWriteComplete with category Ok yields Outcome == Applied, NativeStatus == 0.
  • Completion with category SecurityError, detail 1008 → WriteRejected, NativeStatus == 1008, statuses returned.
  • No completion before the timeout → Unconfirmed, NativeStatus == 0.
  • AuthenticateUser throwing COMException(hr) or returning 0 → AuthenticationFailed, no write; the password never appears in Diagnostic or in the fake's operation log.
  • A second ack to the same item reuses the item handle (one AddItem, one Advise).
  • AlarmAckOperator.ToString() does not contain the password.

Verify: dotnet test src/ZB.MOM.WW.MxGateway.Worker.Tests/ZB.MOM.WW.MxGateway.Worker.Tests.csproj -p:Platform=x86 --filter "FullyQualifiedName~AlarmAckWriterTests" (windev) → pass

Steps:

  • Step 1: Value types.

AlarmAckOperator.cs:

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

/// <summary>
///     Operator identity an alarm acknowledgement authenticates as. The password
///     is a raw credential: it is passed to MXAccess <c>AuthenticateUser</c> only
///     and must never be logged or echoed.
/// </summary>
public sealed class AlarmAckOperator
{
    /// <summary>Gets or sets the operator user name.</summary>
    public string UserName { get; set; } = string.Empty;

    /// <summary>Gets or sets the optional domain for OS-authenticated galaxies.</summary>
    public string Domain { get; set; } = string.Empty;

    /// <summary>Gets or sets the operator password (credential-sensitive).</summary>
    public string Password { get; set; } = string.Empty;

    /// <summary>Gets the user string passed to <c>AuthenticateUser</c>: <c>Domain\UserName</c> when a domain is set.</summary>
    public string VerifyUser => string.IsNullOrEmpty(Domain) ? UserName : Domain + "\\" + UserName;

    /// <inheritdoc />
    public override string ToString() => "AlarmAckOperator(" + VerifyUser + ")";
}

AlarmAckOutcome.cs:

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

/// <summary>Outcome of an alarm acknowledgement attempt.</summary>
public enum AlarmAckOutcome
{
    /// <summary>The AckMsg write completed with an OK status.</summary>
    Applied,
    /// <summary>The write was sent but no OnWriteComplete arrived before the timeout.</summary>
    Unconfirmed,
    /// <summary>The alarm is not in the consumer's current view.</summary>
    AlarmNotFound,
    /// <summary>The alarm exists but has no MXAccess ack attribute (non-Galaxy provider or no ack subtag).</summary>
    NotAckable,
    /// <summary>AuthenticateUser rejected the operator credentials.</summary>
    AuthenticationFailed,
    /// <summary>The AckMsg write completed with a non-OK status.</summary>
    WriteRejected,
}

AlarmAckTarget.cs:

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

/// <summary>The MXAccess item an acknowledgement writes, or why none exists.</summary>
public sealed class AlarmAckTarget
{
    private AlarmAckTarget(string itemAddress, AlarmAckOutcome failure, string reason)
    {
        ItemAddress = itemAddress;
        Failure = failure;
        Reason = reason;
    }

    /// <summary>Gets a value indicating whether an ack item was resolved.</summary>
    public bool Found => ItemAddress.Length > 0;

    /// <summary>Gets the ack item address (e.g. <c>TestMachine_001.TestAlarm001.AckMsg</c>), empty when not found.</summary>
    public string ItemAddress { get; }

    /// <summary>Gets the failure outcome when <see cref="Found"/> is false.</summary>
    public AlarmAckOutcome Failure { get; }

    /// <summary>Gets a human-readable reason when <see cref="Found"/> is false.</summary>
    public string Reason { get; }

    /// <summary>Creates a resolved target.</summary>
    /// <param name="itemAddress">The ack item address.</param>
    /// <returns>The target.</returns>
    public static AlarmAckTarget For(string itemAddress) =>
        new AlarmAckTarget(itemAddress ?? string.Empty, AlarmAckOutcome.Applied, string.Empty);

    /// <summary>Creates a not-found result.</summary>
    /// <param name="reason">Why the alarm was not found.</param>
    /// <returns>The target.</returns>
    public static AlarmAckTarget NotFound(string reason) =>
        new AlarmAckTarget(string.Empty, AlarmAckOutcome.AlarmNotFound, reason);

    /// <summary>Creates a not-ackable result.</summary>
    /// <param name="reason">Why the alarm cannot be acknowledged over MXAccess.</param>
    /// <returns>The target.</returns>
    public static AlarmAckTarget NotAckable(string reason) =>
        new AlarmAckTarget(string.Empty, AlarmAckOutcome.NotAckable, reason);
}

AlarmAckResult.cs:

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

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

/// <summary>Result of an alarm acknowledgement.</summary>
public sealed class AlarmAckResult
{
    private static readonly IReadOnlyList<MxStatusProxy> NoStatuses = new MxStatusProxy[0];

    private AlarmAckResult(AlarmAckOutcome outcome, int nativeStatus, string diagnostic, IReadOnlyList<MxStatusProxy>? statuses)
    {
        Outcome = outcome;
        NativeStatus = nativeStatus;
        Diagnostic = diagnostic;
        Statuses = statuses ?? NoStatuses;
    }

    /// <summary>Gets the outcome.</summary>
    public AlarmAckOutcome Outcome { get; }

    /// <summary>Gets the native status: 0 for applied/unconfirmed, else the HRESULT or status detail.</summary>
    public int NativeStatus { get; }

    /// <summary>Gets a diagnostic message. Never contains credentials.</summary>
    public string Diagnostic { get; }

    /// <summary>Gets the OnWriteComplete statuses, empty when none was observed.</summary>
    public IReadOnlyList<MxStatusProxy> Statuses { get; }

    /// <summary>Creates a result.</summary>
    /// <param name="outcome">The outcome.</param>
    /// <param name="nativeStatus">The native status.</param>
    /// <param name="diagnostic">A credential-free diagnostic.</param>
    /// <param name="statuses">Observed write statuses.</param>
    /// <returns>The result.</returns>
    public static AlarmAckResult Create(
        AlarmAckOutcome outcome,
        int nativeStatus,
        string diagnostic,
        IReadOnlyList<MxStatusProxy>? statuses = null) =>
        new AlarmAckResult(outcome, nativeStatus, diagnostic ?? string.Empty, statuses);

    /// <summary>Creates the result for an unresolved ack target.</summary>
    /// <param name="target">The unresolved target.</param>
    /// <returns>The result.</returns>
    public static AlarmAckResult FromTarget(AlarmAckTarget target) =>
        new AlarmAckResult(target.Failure, -1, target.Reason, null);
}

IAlarmAckWriter.cs:

using System;

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

/// <summary>
///     Acknowledges an alarm by writing its MXAccess ack item (<c>AckMsg</c>)
///     after authenticating as the operator. Must be called on the worker STA.
/// </summary>
public interface IAlarmAckWriter : IDisposable
{
    /// <summary>Authenticates <paramref name="ackOperator"/> and writes <paramref name="comment"/> to <paramref name="itemAddress"/>.</summary>
    /// <param name="itemAddress">The ack item, e.g. <c>Obj.Alarm.AckMsg</c>.</param>
    /// <param name="comment">The acknowledgement comment.</param>
    /// <param name="ackOperator">The operator to authenticate as.</param>
    /// <returns>The outcome.</returns>
    AlarmAckResult Acknowledge(string itemAddress, string comment, AlarmAckOperator ackOperator);
}
  • Step 2: Failing tests AlarmAckWriterTests.cs. Build a RecordingAckServer : IMxAccessServer modelled on LmxSubtagAlarmSourceTests.RecordingMxAccessServer (copy that class and change: Register records the client name and returns 7; AuthenticateUser records verifyUser (NOT the password) into Operations, stores the password in LastPassword, returns AuthenticateResult or throws AuthenticateException; Write records (itemHandle, value, userId) and invokes OnWrite). Tests construct the writer through the internal seam new AlarmAckWriter(server, completionCache, pumpStep: () => { }, timeout):
public sealed class AlarmAckWriterTests
{
    private static AlarmAckOperator Alice() =>
        new AlarmAckOperator { UserName = "alice", Domain = "CORP", Password = "pw-secret" };

    private static RepeatedField<MxStatusProxy> Rows(MxStatusCategory category, int detail) =>
        new RepeatedField<MxStatusProxy> { new MxStatusProxy { Success = category == MxStatusCategory.Ok ? 1 : 0, Category = category, Detail = detail } };

    [Fact]
    public void Acknowledge_AuthenticatesThenWritesCommentWithOperatorUserId()
    {
        RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 };
        MxAccessWriteCompletionCache cache = new MxAccessWriteCompletionCache();
        server.OnWrite = (handle, item) => cache.Record(handle, item, Rows(MxStatusCategory.Ok, 0));
        using AlarmAckWriter writer = new AlarmAckWriter(server, cache, () => { }, TimeSpan.FromSeconds(1), "MxGateway");

        AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "checked", Alice());

        Assert.Equal(AlarmAckOutcome.Applied, result.Outcome);
        Assert.Equal(0, result.NativeStatus);
        Assert.Equal(new[] { "Register:MxGateway", "AuthenticateUser:CORP\\alice", "AddItem:T.A.AckMsg", "Advise", "Write:checked:5" }, server.Operations);
        Assert.Equal("pw-secret", server.LastPassword);
    }

    [Fact]
    public void Acknowledge_WhenWriteRejected_ReturnsDetailAndStatuses()
    {
        RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 };
        MxAccessWriteCompletionCache cache = new MxAccessWriteCompletionCache();
        server.OnWrite = (handle, item) => cache.Record(handle, item, Rows(MxStatusCategory.SecurityError, 1008));
        using AlarmAckWriter writer = new AlarmAckWriter(server, cache, () => { }, TimeSpan.FromSeconds(1), "MxGateway");

        AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "checked", Alice());

        Assert.Equal(AlarmAckOutcome.WriteRejected, result.Outcome);
        Assert.Equal(1008, result.NativeStatus);
        Assert.Single(result.Statuses);
    }

    [Fact]
    public void Acknowledge_WhenNoCompletion_ReturnsUnconfirmed()
    {
        RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 };
        using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(50), "MxGateway");

        AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "checked", Alice());

        Assert.Equal(AlarmAckOutcome.Unconfirmed, result.Outcome);
        Assert.Equal(0, result.NativeStatus);
    }

    [Theory]
    [InlineData(true)]
    [InlineData(false)]
    public void Acknowledge_WhenAuthenticationFails_DoesNotWriteOrLeakPassword(bool throwComException)
    {
        RecordingAckServer server = new RecordingAckServer
        {
            AuthenticateResult = 0,
            AuthenticateException = throwComException ? new COMException("bad credentials", unchecked((int)0x80070005)) : null,
        };
        using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(50), "MxGateway");

        AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "checked", Alice());

        Assert.Equal(AlarmAckOutcome.AuthenticationFailed, result.Outcome);
        Assert.NotEqual(0, result.NativeStatus);
        Assert.DoesNotContain(server.Operations, op => op.StartsWith("Write", StringComparison.Ordinal));
        Assert.DoesNotContain("pw-secret", result.Diagnostic, StringComparison.Ordinal);
        Assert.DoesNotContain(server.Operations, op => op.Contains("pw-secret"));
    }

    [Fact]
    public void Acknowledge_Twice_ReusesItemHandle()
    {
        RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 };
        using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(10), "MxGateway");

        writer.Acknowledge("T.A.AckMsg", "one", Alice());
        writer.Acknowledge("T.A.AckMsg", "two", Alice());

        Assert.Equal(1, server.Operations.Count(op => op.StartsWith("AddItem", StringComparison.Ordinal)));
        Assert.Equal(1, server.Operations.Count(op => op == "Advise"));
    }

    [Fact]
    public void AlarmAckOperator_ToString_OmitsPassword()
    {
        Assert.DoesNotContain("pw-secret", Alice().ToString(), StringComparison.Ordinal);
    }
}
  • Step 3: Implement AlarmAckWriter.cs.
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using ArchestrA.MxAccess;
using Google.Protobuf.Collections;
using ZB.MOM.WW.MxGateway.Contracts;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Worker.Conversion;

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

/// <summary>
///     Acknowledges alarms the way the AVEVA runtime records operator identity:
///     AuthenticateUser as the operator, then write the comment to the alarm's
///     <c>AckMsg</c> attribute with that user id. Owns its own
///     <c>LMXProxyServerClass</c> registered under the configured client name,
///     which the Historian records as the acknowledgement's OperatorNode.
///     Apartment-threaded: every call must run on the worker STA. The password
///     is passed to MXAccess only; it never reaches diagnostics.
/// </summary>
public sealed class AlarmAckWriter : IAlarmAckWriter
{
    /// <summary>The ArchestrA alarm-primitive attribute whose write acknowledges the alarm.</summary>
    public const string AckCommentAttribute = "AckMsg";

    /// <summary>Default MXAccess client name when none is configured.</summary>
    public const string DefaultClientName = "MxGateway";

    private readonly IMxAccessComObjectFactory? factory;
    private readonly MxAccessWriteCompletionCache completionCache;
    private readonly Action pumpStep;
    private readonly TimeSpan completionTimeout;
    private readonly string clientName;
    private readonly MxStatusProxyConverter statusConverter = new MxStatusProxyConverter();
    private readonly Dictionary<string, int> itemHandles = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
    private object? comObject;
    private LMXProxyServerClass? comEventSource;
    private IMxAccessServer? server;
    private int serverHandle;
    private bool disposed;

    /// <summary>Creates a writer that registers its own MXAccess server on first use.</summary>
    /// <param name="factory">Creates the <c>LMXProxyServerClass</c>.</param>
    /// <param name="pumpStep">Drains the STA message queue (OnWriteComplete is delivered by pumping).</param>
    /// <param name="completionTimeout">Bounded wait for OnWriteComplete.</param>
    /// <param name="clientName">MXAccess client name (Historian OperatorNode).</param>
    public AlarmAckWriter(
        IMxAccessComObjectFactory factory,
        Action pumpStep,
        TimeSpan completionTimeout,
        string? clientName)
    {
        this.factory = factory ?? throw new ArgumentNullException(nameof(factory));
        this.pumpStep = pumpStep ?? throw new ArgumentNullException(nameof(pumpStep));
        this.completionTimeout = completionTimeout;
        this.clientName = string.IsNullOrWhiteSpace(clientName) ? DefaultClientName : clientName!;
        completionCache = new MxAccessWriteCompletionCache();
    }

    /// <summary>Test seam: uses an already-constructed server and completion cache.</summary>
    internal AlarmAckWriter(
        IMxAccessServer server,
        MxAccessWriteCompletionCache completionCache,
        Action pumpStep,
        TimeSpan completionTimeout,
        string clientName)
    {
        this.server = server ?? throw new ArgumentNullException(nameof(server));
        this.completionCache = completionCache ?? throw new ArgumentNullException(nameof(completionCache));
        this.pumpStep = pumpStep ?? throw new ArgumentNullException(nameof(pumpStep));
        this.completionTimeout = completionTimeout;
        this.clientName = clientName;
        serverHandle = server.Register(clientName);
    }

    /// <summary>Resolves the configured client name from the launcher environment.</summary>
    /// <returns>The client name, or <see cref="DefaultClientName"/>.</returns>
    public static string ResolveClientName()
    {
        string? value = Environment.GetEnvironmentVariable(WorkerLaunchEnvironment.AlarmAckClientName);
        return string.IsNullOrWhiteSpace(value) ? DefaultClientName : value!.Trim();
    }

    /// <inheritdoc />
    public AlarmAckResult Acknowledge(string itemAddress, string comment, AlarmAckOperator ackOperator)
    {
        if (disposed) throw new ObjectDisposedException(nameof(AlarmAckWriter));
        if (string.IsNullOrWhiteSpace(itemAddress)) throw new ArgumentException("Ack item address is required.", nameof(itemAddress));
        if (ackOperator is null) throw new ArgumentNullException(nameof(ackOperator));

        IMxAccessServer mx = EnsureRegistered();

        int userId;
        try
        {
            userId = mx.AuthenticateUser(serverHandle, ackOperator.VerifyUser, ackOperator.Password ?? string.Empty);
        }
        catch (COMException ex)
        {
            return AlarmAckResult.Create(
                AlarmAckOutcome.AuthenticationFailed,
                ex.HResult,
                $"Operator authentication failed for '{ackOperator.VerifyUser}' (HRESULT 0x{ex.HResult:X8}).");
        }

        if (userId == 0)
        {
            return AlarmAckResult.Create(
                AlarmAckOutcome.AuthenticationFailed,
                -1,
                $"Operator authentication failed for '{ackOperator.VerifyUser}' (no user id returned).");
        }

        if (!itemHandles.TryGetValue(itemAddress, out int itemHandle))
        {
            itemHandle = mx.AddItem(serverHandle, itemAddress);
            // MXAccess rejects writes to added-but-unadvised items with E_INVALIDARG.
            mx.Advise(serverHandle, itemHandle);
            itemHandles[itemAddress] = itemHandle;
        }

        pumpStep();
        ulong baseline = completionCache.CurrentVersion(serverHandle, itemHandle);
        mx.Write(serverHandle, itemHandle, comment ?? string.Empty, userId);

        if (!completionCache.TryWaitForCompletion(
                serverHandle,
                itemHandle,
                baseline,
                DateTime.UtcNow + completionTimeout,
                pumpStep,
                out RepeatedField<MxStatusProxy> statuses))
        {
            return AlarmAckResult.Create(
                AlarmAckOutcome.Unconfirmed,
                0,
                $"AckMsg write to '{itemAddress}' sent; OnWriteComplete not observed within {completionTimeout.TotalMilliseconds:F0} ms.");
        }

        foreach (MxStatusProxy status in statuses)
        {
            if (status.Category != MxStatusCategory.Ok)
            {
                return AlarmAckResult.Create(
                    AlarmAckOutcome.WriteRejected,
                    status.Detail != 0 ? status.Detail : -1,
                    $"AckMsg write to '{itemAddress}' rejected: category {status.Category}, detail {status.Detail}.",
                    statuses);
            }
        }

        return AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, string.Empty, statuses);
    }

    /// <inheritdoc />
    public void Dispose()
    {
        if (disposed) return;
        disposed = true;

        if (comEventSource is not null)
        {
            try { comEventSource.OnWriteComplete -= OnWriteComplete; } catch { /* best effort */ }
        }

        IMxAccessServer? mx = server;
        if (mx is not null)
        {
            foreach (int itemHandle in itemHandles.Values)
            {
                try { mx.UnAdvise(serverHandle, itemHandle); } catch { /* best effort */ }
                try { mx.RemoveItem(serverHandle, itemHandle); } catch { /* best effort */ }
            }

            try { mx.Unregister(serverHandle); } catch { /* best effort */ }
        }

        itemHandles.Clear();
        server = null;
        comEventSource = null;
        object? toRelease = comObject;
        comObject = null;
        if (toRelease is not null && Marshal.IsComObject(toRelease))
        {
            try { Marshal.FinalReleaseComObject(toRelease); } catch { /* best effort */ }
        }
    }

    private IMxAccessServer EnsureRegistered()
    {
        if (server is not null) return server;

        object created = factory!.Create() ?? throw new InvalidOperationException("MXAccess COM factory returned null.");
        try
        {
            comObject = created;
            if (created is LMXProxyServerClass comServer)
            {
                comEventSource = comServer;
                comServer.OnWriteComplete += OnWriteComplete;
            }

            IMxAccessServer mx = new MxAccessComServer(created);
            serverHandle = mx.Register(clientName);
            server = mx;
            return mx;
        }
        catch
        {
            if (comEventSource is not null)
            {
                try { comEventSource.OnWriteComplete -= OnWriteComplete; } catch { /* best effort */ }
                comEventSource = null;
            }

            if (Marshal.IsComObject(created))
            {
                try { Marshal.FinalReleaseComObject(created); } catch { /* best effort */ }
            }

            comObject = null;
            throw;
        }
    }

    private void OnWriteComplete(int hLMXServerHandle, int phItemHandle, ref MXSTATUS_PROXY[] pVars)
    {
        RepeatedField<MxStatusProxy> rows = new RepeatedField<MxStatusProxy>();
        rows.AddRange(statusConverter.ConvertMany(pVars));
        completionCache.Record(hLMXServerHandle, phItemHandle, rows);
    }
}

(Confirm MxStatusProxyConverter's namespace and that ConvertMany(Array?) accepts MXSTATUS_PROXY[]; adjust the using. MxAccessComObjectFactory already returns LMXProxyServerClass.)

  • Step 4: Build and run the Verify command on windev (push branch, windev-worker-ci.ps1 -Sha <sha> -Mode test, or the filtered dotnet test in the CI clone). Expected: all AlarmAckWriterTests pass.

  • Step 5: Commit

git add src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAck*.cs src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IAlarmAckWriter.cs src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmAckWriterTests.cs
git commit -m "feat(worker): acknowledge alarms via authenticated AckMsg write" -m "Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT"

Task 4: Consumers resolve the ack item instead of acking

Goal: Replace IMxAccessAlarmConsumer.AcknowledgeByGuid/AcknowledgeByName with ResolveAckTarget/ResolveAckTargetByName, implement them in the wnwrap, subtag and failover consumers, and delete the inert wnwrap ack consumer and the subtag write path.

Files:

  • Modify: src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IMxAccessAlarmConsumer.cs, WnWrapAlarmConsumer.cs, SubtagAlarmConsumer.cs, FailoverAlarmConsumer.cs, ISubtagAlarmSource.cs, LmxSubtagAlarmSource.cs
  • Test: src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/{WnWrapAlarmConsumerXmlTests,SubtagAlarmConsumerTests,FailoverAlarmConsumerTests,LmxSubtagAlarmSourceTests}.cs and every test fake implementing the changed interfaces

Acceptance Criteria:

  • WnWrapAlarmConsumer.ResolveAckTarget(guid) returns TestMachine_001.TestAlarm001.AckMsg for a snapshot record with provider Galaxy and that tag; NotFound for an unknown GUID; NotAckable for provider InTouch.
  • WnWrapAlarmConsumer.ResolveAckTargetByName("TestMachine_001.TestAlarm001", "Galaxy", "TestArea") returns TestMachine_001.TestAlarm001.AckMsg without needing a snapshot; non-Galaxy provider → NotAckable.
  • SubtagAlarmConsumer resolves synthetic GUIDs and names to the target's AckCommentSubtag; empty subtag → NotAckable; unknown → NotFound.
  • FailoverAlarmConsumer delegates both to ActiveChild.
  • wnwrap no longer creates the second ackClient consumer; ISubtagAlarmSource.Write and LmxSubtagAlarmSource.Write are gone; the solution builds with no warnings.

Verify: dotnet test src/ZB.MOM.WW.MxGateway.Worker.Tests/ZB.MOM.WW.MxGateway.Worker.Tests.csproj -p:Platform=x86 --filter "FullyQualifiedName~WnWrapAlarmConsumerXmlTests|FullyQualifiedName~SubtagAlarmConsumerTests|FullyQualifiedName~FailoverAlarmConsumerTests|FullyQualifiedName~LmxSubtagAlarmSourceTests" (windev) → pass

Steps:

  • Step 1: Interface. In IMxAccessAlarmConsumer.cs delete AcknowledgeByGuid and AcknowledgeByName (and their docs) and add:
    /// <summary>
    ///     Resolves the MXAccess item whose write acknowledges the alarm with the
    ///     given GUID (e.g. <c>Obj.Alarm.AckMsg</c>). Does not perform the ack.
    /// </summary>
    /// <param name="alarmGuid">The alarm GUID as reported by this consumer.</param>
    /// <returns>The resolved target or the reason none exists.</returns>
    AlarmAckTarget ResolveAckTarget(Guid alarmGuid);

    /// <summary>Resolves the ack item for an alarm identified by name, provider and group.</summary>
    /// <param name="alarmName">The tag/alarm name, e.g. <c>TestMachine_001.TestAlarm001</c>.</param>
    /// <param name="providerName">The provider (literal <c>Galaxy</c> for ArchestrA).</param>
    /// <param name="groupName">The area/group.</param>
    /// <returns>The resolved target or the reason none exists.</returns>
    AlarmAckTarget ResolveAckTargetByName(string alarmName, string providerName, string groupName);
  • Step 2: Failing tests. wnwrap (in WnWrapAlarmConsumerXmlTests.cs, using whatever seam the file already uses to load a snapshot — e.g. FoldFetch/ApplySnapshotUpdate via a fake client — or add internal void ReplaceSnapshotForTests(IEnumerable<MxAlarmSnapshotRecord>) under syncRoot):
    [Fact]
    public void ResolveAckTarget_GalaxyRecord_ReturnsAckMsgItem()
    {
        Guid guid = Guid.NewGuid();
        using WnWrapAlarmConsumer consumer = CreateConsumerWithSnapshot(new MxAlarmSnapshotRecord
        {
            AlarmGuid = guid, ProviderName = "Galaxy", Group = "TestArea", TagName = "TestMachine_001.TestAlarm001", State = MxAlarmStateKind.UnackAlm,
        });

        AlarmAckTarget target = consumer.ResolveAckTarget(guid);

        Assert.True(target.Found);
        Assert.Equal("TestMachine_001.TestAlarm001.AckMsg", target.ItemAddress);
        Assert.Equal(AlarmAckOutcome.AlarmNotFound, consumer.ResolveAckTarget(Guid.NewGuid()).Failure);
    }

    [Fact]
    public void ResolveAckTarget_NonGalaxyProvider_IsNotAckable()
    {
        Guid guid = Guid.NewGuid();
        using WnWrapAlarmConsumer consumer = CreateConsumerWithSnapshot(new MxAlarmSnapshotRecord
        {
            AlarmGuid = guid, ProviderName = "InTouch", Group = "G", TagName = "Tag1", State = MxAlarmStateKind.UnackAlm,
        });

        Assert.Equal(AlarmAckOutcome.NotAckable, consumer.ResolveAckTarget(guid).Failure);
    }

    [Fact]
    public void ResolveAckTargetByName_Galaxy_ReturnsAckMsgItem()
    {
        using WnWrapAlarmConsumer consumer = CreateConsumerWithSnapshot();

        Assert.Equal("TestMachine_001.TestAlarm001.AckMsg",
            consumer.ResolveAckTargetByName("TestMachine_001.TestAlarm001", "Galaxy", "TestArea").ItemAddress);
        Assert.Equal(AlarmAckOutcome.NotAckable, consumer.ResolveAckTargetByName("Tag1", "InTouch", "G").Failure);
    }

Subtag (replace the existing AcknowledgeBy*_WritesCommentToAckCommentSubtag / _UnknownGuid_ReturnsNonZero / _UnknownAlarm_ReturnsNonZero / _PrefixNameDoesNotFalseMatch tests with resolve equivalents asserting ItemAddress == target.AckCommentSubtag, Failure == AlarmNotFound for unknown, NotAckable for empty AckCommentSubtag, and that a prefix name does not false-match). Failover: replace Acknowledge_DelegatesToActiveChild with ResolveAckTarget_DelegatesToActiveChild (primary before failover, standby after).

  • Step 3: wnwrap implementation. Delete AcknowledgeByGuid/AcknowledgeByName, the ackClient field and its provisioning block in Subscribe (the "Provision a parallel COM consumer for ack calls" block) and its teardown in Dispose; update the class remarks that describe the two-consumer split. Add:
    private const string GalaxyProviderName = "Galaxy";

    /// <inheritdoc />
    public AlarmAckTarget ResolveAckTarget(Guid alarmGuid)
    {
        if (disposed) throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));
        MxAlarmSnapshotRecord? record;
        lock (syncRoot)
        {
            latestSnapshot.TryGetValue(alarmGuid, out record);
        }

        if (record is null)
        {
            return AlarmAckTarget.NotFound($"Alarm {alarmGuid} is not in the current alarm snapshot.");
        }

        return ResolveGalaxyAckTarget(record.ProviderName, record.TagName);
    }

    /// <inheritdoc />
    public AlarmAckTarget ResolveAckTargetByName(string alarmName, string providerName, string groupName)
    {
        if (disposed) throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));
        return ResolveGalaxyAckTarget(providerName, alarmName);
    }

    private static AlarmAckTarget ResolveGalaxyAckTarget(string? providerName, string? tagName)
    {
        if (!string.Equals(providerName, GalaxyProviderName, StringComparison.OrdinalIgnoreCase))
        {
            return AlarmAckTarget.NotAckable(
                $"Alarm provider '{providerName}' is not a Galaxy provider; only Galaxy alarms can be acknowledged over MXAccess.");
        }

        if (string.IsNullOrWhiteSpace(tagName))
        {
            return AlarmAckTarget.NotFound("Alarm record has no tag name.");
        }

        return AlarmAckTarget.For(tagName + "." + AlarmAckWriter.AckCommentAttribute);
    }
  • Step 4: Subtag implementation. Replace AcknowledgeByGuid/AcknowledgeByName/WriteAckComment with:
    /// <inheritdoc />
    public AlarmAckTarget ResolveAckTarget(Guid alarmGuid)
    {
        if (disposed) throw new ObjectDisposedException(nameof(SubtagAlarmConsumer));
        if (!referencesBySyntheticGuid.TryGetValue(alarmGuid, out string reference) ||
            !targetsByReference.TryGetValue(reference, out AlarmSubtagTarget target))
        {
            return AlarmAckTarget.NotFound($"Alarm {alarmGuid} is not on the subtag watch list.");
        }

        return ToAckTarget(target);
    }

    /// <inheritdoc />
    public AlarmAckTarget ResolveAckTargetByName(string alarmName, string providerName, string groupName)
    {
        if (disposed) throw new ObjectDisposedException(nameof(SubtagAlarmConsumer));
        AlarmSubtagTarget? target = ResolveTargetByName(alarmName, providerName, groupName);
        return target is null
            ? AlarmAckTarget.NotFound($"Alarm '{providerName}!{groupName}.{alarmName}' is not on the subtag watch list.")
            : ToAckTarget(target);
    }

    private static AlarmAckTarget ToAckTarget(AlarmSubtagTarget target)
    {
        string ackItem = target.AckCommentSubtag ?? string.Empty;
        return ackItem.Length == 0
            ? AlarmAckTarget.NotAckable($"Alarm '{target.AlarmFullReference}' has no ack-comment subtag configured.")
            : AlarmAckTarget.For(ackItem);
    }

Keep Subscribe advising the ack-comment subtag only if other code reads it; otherwise drop it from the advise list (the ack writer advises on its own server). Remove Write from ISubtagAlarmSource and LmxSubtagAlarmSource (and LmxSubtagAlarmSourceTests.Write_* tests and the subtag fake's Write).

  • Step 5: Failover — replace the two ack methods:
    /// <inheritdoc />
    public AlarmAckTarget ResolveAckTarget(Guid alarmGuid)
    {
        if (disposed) throw new ObjectDisposedException(nameof(FailoverAlarmConsumer));
        return ActiveChild.ResolveAckTarget(alarmGuid);
    }

    /// <inheritdoc />
    public AlarmAckTarget ResolveAckTargetByName(string alarmName, string providerName, string groupName)
    {
        if (disposed) throw new ObjectDisposedException(nameof(FailoverAlarmConsumer));
        return ActiveChild.ResolveAckTargetByName(alarmName, providerName, groupName);
    }
  • Step 6: Update every fake implementing IMxAccessAlarmConsumer (grep -rn ": IMxAccessAlarmConsumer" src/ZB.MOM.WW.MxGateway.Worker.Tests) — replace the two ack members with ResolveAckTarget/ResolveAckTargetByName that record their arguments and return a configurable AlarmAckTarget (default AlarmAckTarget.For("Fake.Alarm.AckMsg")).

  • Step 7: Verify (Task 5 must land before the worker project compiles end to end, because AlarmDispatcher still calls the removed members — do Tasks 4 and 5 in one windev build) and commit:

git add src/ZB.MOM.WW.MxGateway.Worker src/ZB.MOM.WW.MxGateway.Worker.Tests
git commit -m "refactor(worker): alarm consumers resolve the AckMsg item instead of acking" -m "Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT"

Task 5: Route acks through the writer and remove the managed path

Goal: Wire AlarmAckWriter through AlarmCommandHandler → AlarmDispatcher → MxAccessCommandExecutor with an AlarmAckOperator built from the command, map AlarmAckResult to the reply, and delete the dead managed/WNAL ack path.

Files:

  • Modify: src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs, AlarmCommandHandler.cs, IAlarmCommandHandler.cs, MxAccessCommandExecutor.cs (~894-990), src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerPipeSession.cs (:79, :101, :1588), src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessStaSession.cs (handler factory call ~288)
  • Delete: src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AaManagedAlarmAckConsumer.cs, IManagedAlarmAckClient.cs, WnalNativeAckConsumer.cs, src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmManagedGuidAckLiveSmokeTests.cs
  • Test: AlarmDispatcherTests.cs, AlarmCommandHandlerTests.cs, AlarmCommandExecutorTests.cs, MxAccessCommandExecutorTests.cs

Acceptance Criteria:

  • AlarmDispatcher.Acknowledge(guid, comment, op) resolves via the consumer and calls the writer with the resolved item; an unresolved target returns AlarmAckResult.FromTarget without calling the writer.
  • ExecuteAcknowledgeAlarm builds AlarmAckOperator{UserName=operator_user, Domain=operator_domain, Password=operator_password}; maps Applied/Unconfirmed → Ok, AlarmNotFound/NotAckable → InvalidRequest, AuthenticationFailed/WriteRejected → MxaccessFailure; sets Hresult and AcknowledgeAlarm.NativeStatus to result.NativeStatus; copies result.Statuses into reply.Statuses; DiagnosticMessage = result.Diagnostic.
  • A test with operator_password = "pw-secret" asserts the secret is absent from DiagnosticMessage, ProtocolStatus.Message, for every outcome.
  • No references remain to AaManagedAlarmAckConsumer, IManagedAlarmAckClient, WnalNativeAckConsumer, MXGATEWAY_USE_MANAGED_ALARM_ACK, IsSubtagActive (grep -rn returns nothing under src/).
  • Production wiring creates one AlarmAckWriter(comFactory, () => new StaMessagePump().PumpPendingMessages(), MxAccessStaSession.ResolveWriteCompletionTimeout(), AlarmAckWriter.ResolveClientName()) per AlarmCommandHandler, disposed with the handler.
  • x86 worker builds with 0 warnings; all Worker.Tests pass on windev.

Verify: on windev: powershell -NoProfile -ExecutionPolicy Bypass -File C:\build\mxaccessgw-ci\scripts\ci\windev-worker-ci.ps1 -Sha <pushed sha> -Mode test → SUCCESS mode=test, Failed: 0

Steps:

  • Step 1: Failing dispatcher tests (replace the four managed tests and FakeManagedAckClient in AlarmDispatcherTests.cs):
    [Fact]
    public void Acknowledge_ResolvesTargetThenWritesWithOperator()
    {
        FakeAlarmConsumer consumer = new FakeAlarmConsumer { AckTarget = AlarmAckTarget.For("T.A.AckMsg") };
        FakeAckWriter writer = new FakeAckWriter();
        using AlarmDispatcher dispatcher = new AlarmDispatcher(
            consumer, new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), SessionId, writer);
        Guid guid = Guid.NewGuid();
        AlarmAckOperator op = new AlarmAckOperator { UserName = "alice", Password = "pw" };

        AlarmAckResult result = dispatcher.Acknowledge(guid, "checked", op);

        Assert.Equal(AlarmAckOutcome.Applied, result.Outcome);
        Assert.Equal(guid, consumer.LastResolvedGuid);
        Assert.Equal("T.A.AckMsg", writer.LastItemAddress);
        Assert.Equal("checked", writer.LastComment);
        Assert.Same(op, writer.LastOperator);
    }

    [Fact]
    public void Acknowledge_WhenTargetNotFound_DoesNotWrite()
    {
        FakeAlarmConsumer consumer = new FakeAlarmConsumer { AckTarget = AlarmAckTarget.NotFound("gone") };
        FakeAckWriter writer = new FakeAckWriter();
        using AlarmDispatcher dispatcher = new AlarmDispatcher(
            consumer, new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), SessionId, writer);

        AlarmAckResult result = dispatcher.Acknowledge(Guid.NewGuid(), "c", new AlarmAckOperator { UserName = "alice" });

        Assert.Equal(AlarmAckOutcome.AlarmNotFound, result.Outcome);
        Assert.Null(writer.LastItemAddress);
    }

    private sealed class FakeAckWriter : IAlarmAckWriter
    {
        public string? LastItemAddress { get; private set; }
        public string? LastComment { get; private set; }
        public AlarmAckOperator? LastOperator { get; private set; }
        public AlarmAckResult Result { get; set; } = AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, string.Empty);
        public bool Disposed { get; private set; }

        public AlarmAckResult Acknowledge(string itemAddress, string comment, AlarmAckOperator ackOperator)
        {
            LastItemAddress = itemAddress;
            LastComment = comment;
            LastOperator = ackOperator;
            return Result;
        }

        public void Dispose() => Disposed = true;
    }

Add the matching AcknowledgeByName test (consumer.LastResolvedName == ("TestMachine_001.TestAlarm001","Galaxy","TestArea")).

  • Step 2: Failing executor tests in AlarmCommandExecutorTests.cs (extend the existing fake IAlarmCommandHandler so Acknowledge returns a configurable AlarmAckResult and records the AlarmAckOperator):
    [Theory]
    [InlineData(AlarmAckOutcome.Applied, ProtocolStatusCode.Ok)]
    [InlineData(AlarmAckOutcome.Unconfirmed, ProtocolStatusCode.Ok)]
    [InlineData(AlarmAckOutcome.AlarmNotFound, ProtocolStatusCode.InvalidRequest)]
    [InlineData(AlarmAckOutcome.NotAckable, ProtocolStatusCode.InvalidRequest)]
    [InlineData(AlarmAckOutcome.AuthenticationFailed, ProtocolStatusCode.MxaccessFailure)]
    [InlineData(AlarmAckOutcome.WriteRejected, ProtocolStatusCode.MxaccessFailure)]
    public async Task AcknowledgeAlarm_MapsOutcomeWithoutLeakingPassword(AlarmAckOutcome outcome, ProtocolStatusCode expected)
    {
        int native = outcome is AlarmAckOutcome.Applied or AlarmAckOutcome.Unconfirmed ? 0 : 1008;
        FakeAlarmCommandHandler handler = new FakeAlarmCommandHandler
        {
            AckResult = AlarmAckResult.Create(outcome, native, "diagnostic for alice"),
        };
        MxCommandReply reply = await DispatchAsync(handler, new MxCommand
        {
            Kind = MxCommandKind.AcknowledgeAlarm,
            AcknowledgeAlarmCommand = new AcknowledgeAlarmCommand
            {
                AlarmGuid = Guid.NewGuid().ToString(),
                Comment = "checked",
                OperatorUser = "alice",
                OperatorDomain = "CORP",
                OperatorPassword = "pw-secret",
            },
        });

        Assert.Equal(expected, reply.ProtocolStatus.Code);
        Assert.Equal(native, reply.Hresult);
        Assert.Equal(native, reply.AcknowledgeAlarm.NativeStatus);
        Assert.Equal("alice", handler.LastOperator!.UserName);
        Assert.Equal("CORP", handler.LastOperator.Domain);
        Assert.Equal("pw-secret", handler.LastOperator.Password);
        Assert.DoesNotContain("pw-secret", reply.DiagnosticMessage ?? string.Empty, StringComparison.Ordinal);
        Assert.DoesNotContain("pw-secret", reply.ProtocolStatus.Message ?? string.Empty, StringComparison.Ordinal);
    }

(Use the file's existing dispatch helper name; xUnit1030: no .ConfigureAwait(false) in the test body.) Add the same theory for AcknowledgeAlarmByName.

  • Step 3: IAlarmCommandHandler — change the two ack members to:
    /// <summary>Acknowledges an alarm by GUID as <paramref name="ackOperator"/>.</summary>
    AlarmAckResult Acknowledge(Guid alarmGuid, string comment, AlarmAckOperator ackOperator);

    /// <summary>Acknowledges an alarm by name/provider/group as <paramref name="ackOperator"/>.</summary>
    AlarmAckResult AcknowledgeByName(string alarmName, string providerName, string groupName, string comment, AlarmAckOperator ackOperator);
  • Step 4: AlarmDispatcher — remove managedAck, the managed block in Subscribe, IsSubtagActive, the managed dispose; add private readonly IAlarmAckWriter ackWriter; and make the constructor AlarmDispatcher(IMxAccessAlarmConsumer consumer, MxAccessAlarmEventSink sink, string sessionId, IAlarmAckWriter ackWriter) (null → ArgumentNullException). The dispatcher does not dispose the writer (the handler owns it). Replace the ack methods:
    /// <summary>Acknowledges the alarm with the given GUID by writing its ack item as the operator.</summary>
    /// <param name="alarmGuid">The alarm GUID.</param>
    /// <param name="ackComment">The acknowledgement comment.</param>
    /// <param name="ackOperator">The operator to authenticate as.</param>
    /// <returns>The outcome.</returns>
    public AlarmAckResult Acknowledge(Guid alarmGuid, string ackComment, AlarmAckOperator ackOperator)
    {
        if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher));
        AlarmAckTarget target = consumer.ResolveAckTarget(alarmGuid);
        return target.Found
            ? ackWriter.Acknowledge(target.ItemAddress, ackComment, ackOperator)
            : AlarmAckResult.FromTarget(target);
    }

    /// <summary>Acknowledges the alarm identified by name/provider/group as the operator.</summary>
    /// <param name="alarmName">The tag/alarm name.</param>
    /// <param name="providerName">The provider.</param>
    /// <param name="groupName">The group/area.</param>
    /// <param name="ackComment">The acknowledgement comment.</param>
    /// <param name="ackOperator">The operator to authenticate as.</param>
    /// <returns>The outcome.</returns>
    public AlarmAckResult AcknowledgeByName(
        string alarmName, string providerName, string groupName, string ackComment, AlarmAckOperator ackOperator)
    {
        if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher));
        AlarmAckTarget target = consumer.ResolveAckTargetByName(alarmName, providerName, groupName);
        return target.Found
            ? ackWriter.Acknowledge(target.ItemAddress, ackComment, ackOperator)
            : AlarmAckResult.FromTarget(target);
    }
  • Step 5: AlarmCommandHandler — replace the managedAckFactory field/ctor param/doc with Func<IAlarmAckWriter>? ackWriterFactory = null; add private IAlarmAckWriter? ackWriter; and
    private IAlarmAckWriter GetOrCreateAckWriter()
    {
        if (ackWriter is not null) return ackWriter;
        if (ackWriterFactory is not null)
        {
            ackWriter = ackWriterFactory() ?? throw new InvalidOperationException("Alarm ack writer factory returned null.");
            return ackWriter;
        }

        if (comFactory is null)
        {
            throw new InvalidOperationException(
                "Alarm acknowledgement requires an IMxAccessComObjectFactory; the alarm command handler was constructed without one.");
        }

        ackWriter = new AlarmAckWriter(
            comFactory,
            () => new StaMessagePump().PumpPendingMessages(),
            MxAccessStaSession.ResolveWriteCompletionTimeout(),
            AlarmAckWriter.ResolveClientName());
        return ackWriter;
    }

In Subscribe pass GetOrCreateAckWriter() as the dispatcher's 4th argument (delete the managed block). Change Acknowledge/AcknowledgeByName to the new signatures (null comment → empty, null operator → ArgumentNullException). In Dispose dispose ackWriter after the dispatcher (try { ackWriter?.Dispose(); } catch { }). (StaMessagePump.PumpPendingMessages is PeekMessage on the calling thread, i.e. the STA when called from an ack.)

  • Step 6: Executor — rewrite the try-blocks of ExecuteAcknowledgeAlarm and ExecuteAcknowledgeAlarmByName to:
        try
        {
            AlarmAckResult result = alarmCommandHandler.Acknowledge(
                alarmGuid,
                payload.Comment,
                new AlarmAckOperator
                {
                    UserName = payload.OperatorUser,
                    Domain = payload.OperatorDomain,
                    Password = payload.OperatorPassword,
                });
            return CreateAcknowledgeReply(command, result);
        }
        catch (Exception ex)
        {
            return CreateAlarmFailureReply(command, ex);
        }

(by-name passes payload.AlarmName, payload.ProviderName, payload.GroupName, payload.Comment, new AlarmAckOperator { … }) and add:

    private MxCommandReply CreateAcknowledgeReply(StaCommand command, AlarmAckResult result)
    {
        MxCommandReply reply = CreateOkReply(command);
        reply.Hresult = result.NativeStatus;
        reply.AcknowledgeAlarm = new AcknowledgeAlarmReplyPayload { NativeStatus = result.NativeStatus };
        reply.Statuses.Add(result.Statuses);
        reply.DiagnosticMessage = result.Diagnostic;
        switch (result.Outcome)
        {
            case AlarmAckOutcome.AlarmNotFound:
            case AlarmAckOutcome.NotAckable:
                reply.ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.InvalidRequest, Message = result.Diagnostic };
                break;
            case AlarmAckOutcome.AuthenticationFailed:
            case AlarmAckOutcome.WriteRejected:
                reply.ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.MxaccessFailure, Message = result.Diagnostic };
                break;
        }

        return reply;
    }

(Statuses must be the repeated field on MxCommandReply; if AddRange is required on RepeatedField, use reply.Statuses.AddRange(result.Statuses).) The only operator-derived text in diagnostics is VerifyUser; the password never enters AlarmAckResult.

  • Step 7: Delete the managed path — git rm the four files; in WorkerPipeSession.cs remove , managedAckFactory: AaManagedAlarmAckConsumer.EnvGatedFactory from the three handler constructions; fix AlarmCommandHandlerTests constructions that passed managedAckFactory.

  • Step 8: Grep, push, verify on windev

grep -rn "AaManagedAlarmAckConsumer\|IManagedAlarmAckClient\|WnalNativeAckConsumer\|MXGATEWAY_USE_MANAGED_ALARM_ACK\|IsSubtagActive\|AcknowledgeByGuid" src/ --include=*.cs
git push -u origin feat/ackmsg-as-operator
ssh windev 'powershell -NoProfile -ExecutionPolicy Bypass -File C:\build\mxaccessgw-ci\scripts\ci\windev-worker-ci.ps1 -Sha <sha> -Mode test'

Expected: grep prints nothing; CI log ends SUCCESS mode=test, Failed: 0.

  • Step 9: Commit
git add -A src/ZB.MOM.WW.MxGateway.Worker src/ZB.MOM.WW.MxGateway.Worker.Tests
git commit -m "feat(worker): route alarm acks through the operator-authenticated AckMsg writer" -m "Removes the inert managed/WNAL alarm-client ack path." -m "Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT"

Task 6: Gateway forwards operator credentials and the ack client name

Goal: Forward operator_password/operator_domain from AcknowledgeAlarmRequest onto the worker ack commands, require operator_user, treat ack commands as credential-bearing in redaction, and add the AckClientName option conveyed to workers.

Files:

  • Modify: src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs (BuildAcknowledgeCommand ~1172-1222)
  • Modify: src/ZB.MOM.WW.MxGateway.Server/Grpc/MxAccessGatewayService.cs (AcknowledgeAlarm ~187-207)
  • Modify: src/ZB.MOM.WW.MxGateway.Server/Configuration/AlarmsOptions.cs, GatewayOptionsValidator.cs (ValidateAlarms ~473-515), Workers/WorkerProcessLauncher.cs (~214-224)
  • Modify: src/ZB.MOM.WW.MxGateway.Server/Diagnostics/GatewayLogRedactor.cs (:11-16), GatewayLogRedactorSeam.cs
  • Test: src/ZB.MOM.WW.MxGateway.Tests/Alarms/GatewayAlarmMonitorAcknowledgeTests.cs (create), Gateway/Grpc/MxAccessGatewayServiceTests.cs, Configuration/GatewayOptionsValidatorTests.cs, Gateway/Workers/WorkerProcessLauncherTests.cs, Diagnostics/GatewayLogRedactorTests.cs, Diagnostics/GatewayLogRedactorSeamTests.cs

Acceptance Criteria:

  • A GUID-reference ack produces AcknowledgeAlarmCommand{OperatorUser="alice", OperatorDomain="CORP", OperatorPassword="pw-secret"}; a Galaxy!TestArea.T.A ack produces the by-name command with the same three fields.
  • The gRPC handler returns InvalidArgument "operator_user is required." when operator_user is empty.
  • GatewayLogRedactor.IsCredentialBearingCommand("AcknowledgeAlarm") and ("AcknowledgeAlarmByName") are true; the seam redacts any property whose name contains password (case-insensitive).
  • MxGateway:Alarms:AckClientName defaults to "MxGateway", rejects values over 64 chars or containing control characters, and the launcher sets MXGATEWAY_ALARM_ACK_CLIENT_NAME to it.
  • Gateway builds with 0 warnings; the filtered tests pass.

Verify: dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter "FullyQualifiedName~GatewayAlarmMonitorAcknowledgeTests|FullyQualifiedName~MxAccessGatewayServiceTests|FullyQualifiedName~GatewayOptionsValidatorTests|FullyQualifiedName~WorkerProcessLauncherTests|FullyQualifiedName~GatewayLogRedactor" → pass

Steps:

  • Step 1: Failing monitor test. BuildAcknowledgeCommand is private; make it internal static (the test project already has InternalsVisibleTo for Server — confirm with grep -rn InternalsVisibleTo src/ZB.MOM.WW.MxGateway.Server) and test it directly:
namespace ZB.MOM.WW.MxGateway.Tests.Alarms;

public sealed class GatewayAlarmMonitorAcknowledgeTests
{
    [Fact]
    public void BuildAcknowledgeCommand_GuidReference_ForwardsOperatorCredentials()
    {
        MxCommand? command = GatewayAlarmMonitor.BuildAcknowledgeCommand(new AcknowledgeAlarmRequest
        {
            AlarmFullReference = "BCC47053-9542-4D65-BDAA-BCDEA6A32A73",
            Comment = "checked",
            OperatorUser = "alice",
            OperatorDomain = "CORP",
            OperatorPassword = "pw-secret",
        }, out string? error);

        Assert.Null(error);
        Assert.Equal(MxCommandKind.AcknowledgeAlarm, command!.Kind);
        Assert.Equal("alice", command.AcknowledgeAlarmCommand.OperatorUser);
        Assert.Equal("CORP", command.AcknowledgeAlarmCommand.OperatorDomain);
        Assert.Equal("pw-secret", command.AcknowledgeAlarmCommand.OperatorPassword);
    }

    [Fact]
    public void BuildAcknowledgeCommand_NameReference_ForwardsOperatorCredentials()
    {
        MxCommand? command = GatewayAlarmMonitor.BuildAcknowledgeCommand(new AcknowledgeAlarmRequest
        {
            AlarmFullReference = "Galaxy!TestArea.TestMachine_001.TestAlarm001",
            OperatorUser = "alice",
            OperatorPassword = "pw-secret",
        }, out _);

        Assert.Equal(MxCommandKind.AcknowledgeAlarmByName, command!.Kind);
        Assert.Equal("TestMachine_001.TestAlarm001", command.AcknowledgeAlarmByNameCommand.AlarmName);
        Assert.Equal("alice", command.AcknowledgeAlarmByNameCommand.OperatorUser);
        Assert.Equal("pw-secret", command.AcknowledgeAlarmByNameCommand.OperatorPassword);
    }
}
  • Step 2: Implement — in BuildAcknowledgeCommand add string operatorPassword = request.OperatorPassword ?? string.Empty; string operatorDomain = request.OperatorDomain ?? string.Empty; and in both command initializers set OperatorDomain = operatorDomain, OperatorPassword = operatorPassword, (replacing OperatorDomain = string.Empty). Keep OperatorNode/OperatorFullName empty (the Historian takes them from the authenticated account and the ack client name).

  • Step 3: Handler validation — failing test in MxAccessGatewayServiceTests.cs:

    [Fact]
    public async Task AcknowledgeAlarm_WithMissingOperatorUser_ThrowsInvalidArgument()
    {
        MxAccessGatewayService service = CreateService(new FakeSessionManager());
        RpcException exception = await Assert.ThrowsAsync<RpcException>(
            async () => await service.AcknowledgeAlarm(
                new AcknowledgeAlarmRequest { AlarmFullReference = "Galaxy!Area.Tag" },
                new TestServerCallContext()));
        Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
        Assert.Contains("operator_user", exception.Status.Detail, StringComparison.Ordinal);
    }

then add after the reference check in the handler:

            if (string.IsNullOrWhiteSpace(request.OperatorUser))
            {
                throw new RpcException(new Status(StatusCode.InvalidArgument, "operator_user is required."));
            }

Update existing ack tests that omit OperatorUser (e.g. AcknowledgeAlarm_WithMissingAlarmReference_ThrowsInvalidArgument already sets it).

  • Step 4: Redaction — add "AcknowledgeAlarm" and "AcknowledgeAlarmByName" to SensitiveCommandMethods with a comment ("carry operator_password"); in GatewayLogRedactorSeam.Redact add name-based redaction:
        foreach (string key in properties.Keys.ToList())
        {
            if (key.IndexOf("password", StringComparison.OrdinalIgnoreCase) >= 0 && properties[key] is not null)
            {
                properties[key] = GatewayLogRedactor.RedactedValue;
            }
        }

(use the existing redacted-value constant name from GatewayLogRedactor). Tests: IsCredentialBearingCommand_AckCommands_ReturnsTrue and Redact_PasswordNamedProperty_IsRedacted ({"OperatorPassword": "pw-secret"} → [redacted]).

  • Step 5: AckClientName option — in AlarmsOptions.cs:
    /// <summary>
    ///     MXAccess client name the alarm worker's ack writer registers with. The
    ///     AVEVA Historian records it as each acknowledgement's OperatorNode.
    ///     Conveyed to workers via MXGATEWAY_ALARM_ACK_CLIENT_NAME.
    /// </summary>
    public string AckClientName { get; init; } = "MxGateway";

validator (in ValidateAlarms): fail when empty/whitespace, longer than 64, or AckClientName.Any(char.IsControl) with message "MxGateway:Alarms:AckClientName must be 1-64 printable characters."; launcher: startInfo.Environment[WorkerLaunchEnvironment.AlarmAckClientName] = _alarmsOptions.AckClientName;. Tests: validator rejects "" and a 65-char name, accepts default; launcher test asserts the env var equals the configured name (follow the existing MXGATEWAY_ALARM_POLL_INTERVAL_MS launcher test).

  • Step 6: Build + Verify — dotnet build src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj (0 warnings) then the Verify command → pass.

  • Step 7: Commit

git add src/ZB.MOM.WW.MxGateway.Server src/ZB.MOM.WW.MxGateway.Tests
git commit -m "feat(gateway): forward operator credentials on alarm acknowledgement" -m "Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT"

Task 7: .NET client and CLI

Goal: Expose the new fields in the .NET client CLI with safe password input and make failures never echo the password.

Files:

  • Modify: clients/dotnet/ZB.MOM.WW.MxGateway.Client/MxGatewayClient.cs (:191-204), clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/MxGatewayClientCli.cs (:176-188, :1526-1545, :2468), clients/dotnet/README.md (:144-149, :294)
  • Test: clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/MxGatewayClientAlarmsTests.cs, MxGatewayClientCliTests.cs

Acceptance Criteria:

  • AcknowledgeAlarmAsync doc mentions operator credentials; on failure the thrown exception is passed through MxGatewaySecretRedaction.Redacted(ex, request.OperatorPassword).
  • CLI acknowledge-alarm accepts --operator, --operator-domain, --operator-password, --operator-password-env (default env MXGATEWAY_OPERATOR_PASSWORD); the request carries them; the password is never printed and is added to the CLI error-redaction list.
  • Tests assert the request fields and that a failing call's message does not contain the password.

Verify: dotnet build clients/dotnet/ZB.MOM.WW.MxGateway.Client.slnx && dotnet test clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests --filter "FullyQualifiedName~MxGatewayClientAlarmsTests|FullyQualifiedName~MxGatewayClientCliTests" → pass

Steps:

  • Step 1: Failing tests — extend AcknowledgeAlarmAsync_RecordsRequestShapeAndReturnsReply to set OperatorPassword = "pw-secret", OperatorDomain = "CORP" and assert they reach call.Request; add a test where the fake transport throws RpcException(new Status(StatusCode.Internal, "boom pw-secret")) and assert the resulting exception's Message lacks pw-secret. CLI: extend RunAsync_AcknowledgeAlarm_BuildsRequestAndPrintsJsonReply with --operator-domain CORP --operator-password-env MXGW_TEST_OPPW (set the env var in the test) and assert request.OperatorPassword and that stdout lacks the password.
  • Step 2: Implement — client:
    /// <summary>
    ///     Acknowledges an alarm as the operator. The gateway authenticates
    ///     <see cref="AcknowledgeAlarmRequest.OperatorUser"/> with
    ///     <see cref="AcknowledgeAlarmRequest.OperatorPassword"/> and writes the
    ///     comment to the alarm's AckMsg attribute, so the alarm history records
    ///     the operator. The password is redacted from any surfaced error.
    /// </summary>
    public async Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
        AcknowledgeAlarmRequest request,
        CancellationToken cancellationToken = default)
    {
        ArgumentNullException.ThrowIfNull(request);
        ThrowIfDisposed();
        try
        {
            return await ExecuteSafeUnaryAsync(
                token => _transport.AcknowledgeAlarmAsync(request, CreateCallOptions(token)),
                cancellationToken).ConfigureAwait(false);
        }
        catch (MxGatewayException ex) when (request.OperatorPassword.Length > 0)
        {
            throw MxGatewaySecretRedaction.Redacted(ex, request.OperatorPassword);
        }
    }

CLI builder: OperatorDomain = arguments.GetOptional("operator-domain") ?? string.Empty, OperatorPassword = ResolveOperatorPassword(arguments), where ResolveOperatorPassword mirrors TryResolveVerifyUserPassword with option operator-password, env option operator-password-env, default env MXGATEWAY_OPERATOR_PASSWORD; add it to the MxGatewayCliSecretRedactor.Redact(...) list; update usage string to mxgw-dotnet acknowledge-alarm --reference <ref> --operator <user> [--operator-domain <dom>] [--operator-password <pw> | --operator-password-env <VAR>] [--comment <text>] [--json].

  • Step 3: README — replace the alarms sentence with "…and AcknowledgeAlarmAsync (acknowledges as the operator: the gateway authenticates OperatorUser/OperatorPassword and writes the comment to the alarm's AckMsg, so the Historian records the operator)"; update the CLI example to --operator operator1 --operator-password-env MXGATEWAY_OPERATOR_PASSWORD and note the password is never echoed.
  • Step 4: Verify → pass. Step 5: Commit git add clients/dotnet && git commit -m "feat(dotnet-client): operator credentials on acknowledge-alarm" -m "Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT"

Task 8: Python client and CLI

Goal: Regenerate Python bindings and add operator credential options to the CLI with redaction.

Files:

  • Regenerate: clients/python/src/zb_mom_ww_mxgateway/generated/*_pb2*.py via clients/python/generate-proto.ps1 (grpcio-tools 1.80.0)
  • Modify: clients/python/src/zb_mom_ww_mxgateway/client.py (:147-161), src/zb_mom_ww_mxgateway_cli/commands.py (:527-539, :1156-1166), clients/python/README.md (:111-116, :460)
  • Test: clients/python/tests/test_alarms.py, tests/test_review_findings_022_to_026.py

Acceptance Criteria:

  • Regenerated _pb2.py keeps # Protobuf Python Version: 6.31.1 and only contains the real descriptor delta.
  • acknowledge_alarm redacts request.operator_password from raised errors (redact_secret).
  • CLI accepts --operator-domain, --operator-password, --operator-password-env (default MXGATEWAY_OPERATOR_PASSWORD); password added to secrets=.
  • README example still parses in test_review_findings_022_to_026.py.

Verify: cd clients/python && python -m pytest tests/test_alarms.py tests/test_review_findings_022_to_026.py tests/test_cli.py -q → pass

Steps:

  • Step 1: Regenerate — cd clients/python && python -m pip install "grpcio-tools==1.80.0" && pwsh -File generate-proto.ps1; git diff --stat src/zb_mom_ww_mxgateway/generated shows only mxaccess_gateway_pb2.py/.pyi changes.
  • Step 2: Failing tests — in test_alarms.py set operator_password="pw-secret", operator_domain="CORP" on the request and assert captured_request.operator_password == "pw-secret"; add test_acknowledge_alarm_error_redacts_operator_password where the fake stub raises an RpcError whose details contain pw-secret and assert "pw-secret" not in str(excinfo.value). CLI: extend test_cli_acknowledge_alarm_happy_path with --operator-domain CORP --operator-password-env MXGW_TEST_OPPW (monkeypatch env) and assert the request field and that output lacks the secret.
  • Step 3: Implement — in client.py:
    async def acknowledge_alarm(
        self,
        request: pb.AcknowledgeAlarmRequest,
    ) -> pb.AcknowledgeAlarmReply:
        """Acknowledge an alarm as the operator.

        The gateway authenticates ``operator_user`` with ``operator_password`` and
        writes the comment to the alarm's ``AckMsg``, so the alarm history records
        the operator. The password is redacted from any raised error.
        """
        try:
            reply = await self._unary("acknowledge alarm", self.raw_stub.AcknowledgeAlarm, request)
            ensure_protocol_success("acknowledge alarm", reply.protocol_status, reply)
            return reply
        except MxGatewayError as error:
            if request.operator_password:
                raise type(error)(redact_secret(str(error), [request.operator_password])) from None
            raise

(Use the module's actual base error class name and import redact_secret from .auth; if error types take structured args, mirror the session's _invoke_redacted approach instead.) CLI: add @click.option("--operator-domain", default="", ...), @click.option("--operator-password", default=None, ...), @click.option("--operator-password-env", default="MXGATEWAY_OPERATOR_PASSWORD", ...); resolve with a helper modelled on _resolve_password; set operator_domain=, operator_password= in the request; pass secrets=_secrets(kwargs) + [password].

  • Step 4: README — same wording as Task 7; CLI example adds --operator operator1 --operator-password-env MXGATEWAY_OPERATOR_PASSWORD.
  • Step 5: Verify → pass. Step 6: Commit git add clients/python && git commit -m "feat(python-client): operator credentials on acknowledge-alarm" -m "Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT"

Task 9: Go client and CLI

Goal: Regenerate Go bindings and add operator credential flags to mxgw-go acknowledge-alarm with error redaction.

Files:

  • Regenerate: clients/go/internal/generated/mxaccess_gateway.pb.go, mxaccess_gateway_grpc.pb.go via clients/go/generate-proto.ps1
  • Modify: clients/go/mxgateway/alarms.go (:15-32), clients/go/cmd/mxgw-go/main.go (:1122-1162, usage :1434), clients/go/README.md (:141-146, :427)
  • Test: clients/go/mxgateway/alarms_test.go

Acceptance Criteria:

  • AcknowledgeAlarm wraps errors with redactSecrets(err, req.GetOperatorPassword()) when the password is non-empty.
  • CLI flags -operator-domain, -operator-password, -operator-password-env (default MXGATEWAY_OPERATOR_PASSWORD) populate the request.
  • gofmt -l . prints nothing; tests pass.

Verify: cd clients/go && gofmt -l . && go build ./... && go test ./... → no gofmt output, ok

Steps:

  • Step 1: Regenerate — cd clients/go && pwsh -File generate-proto.ps1 (needs protoc-gen-go v1.36.11, protoc-gen-go-grpc 1.6.2).
  • Step 2: Failing test in alarms_test.go: send OperatorPassword: "pw-secret", OperatorDomain: "CORP" and assert fake.acknowledgeRequest.GetOperatorPassword() == "pw-secret"; add TestAcknowledgeAlarmRedactsPasswordFromError where the fake returns status.Error(codes.Internal, "boom pw-secret") and assert !strings.Contains(err.Error(), "pw-secret").
  • Step 3: Implement in alarms.go:
	reply, err := c.raw.AcknowledgeAlarm(callCtx, req)
	if err != nil {
		return nil, redactSecrets(&GatewayError{Op: "acknowledge alarm", Err: err}, req.GetOperatorPassword())
	}
	if err := EnsureProtocolSuccess("acknowledge alarm", reply.GetProtocolStatus(), nil); err != nil {
		return reply, redactSecrets(err, req.GetOperatorPassword())
	}

(Confirm redactSecrets returns the error unchanged for empty secrets.) CLI: add the three flags; resolve password like the authenticate-user -password/-password-env code at main.go:449-503; set OperatorDomain, OperatorPassword; update usage.

  • Step 4: README — wording as Task 7; CLI table row "Acknowledge an alarm as an operator (-operator, -operator-password-env)".
  • Step 5: Verify → pass. Step 6: Commit git add clients/go && git commit -m "feat(go-client): operator credentials on acknowledge-alarm" -m "Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT"

Task 10: Rust client and CLI

Goal: Make the Rust workspace compile against the new fields, add CLI credential options, and keep the password out of Debug output and errors.

Files:

  • Modify: clients/rust/src/client.rs (:335-346), clients/rust/crates/mxgw-cli/src/main.rs (:370-381, :1079-1095), clients/rust/tests/client_behavior.rs (:219-235, ~:1041-1079, :1423-1440), clients/rust/README.md (:81, :123-126), clients/rust/RustClientDesign.md (:160, :349)
  • (Vendored proto already updated in Task 1.)

Acceptance Criteria:

  • All AcknowledgeAlarmRequest { … } literals include operator_password/operator_domain (or ..Default::default()).
  • acknowledge_alarm attaches the password via MxAccessError::with_secrets (or equivalent) so {:?} of a returned error lacks it.
  • CLI --operator-domain, --operator-password, --operator-password-env (default MXGATEWAY_OPERATOR_PASSWORD).
  • Stale README example (--session-id, --alarm-reference) fixed to --reference … --operator ….
  • fmt/check/test/clippy clean.

Verify: cd clients/rust && cargo fmt --all --check && cargo check --workspace && cargo test --workspace && cargo clippy --workspace --all-targets -- -D warnings → all succeed

Steps:

  • Step 1: Fix literals — add operator_password: String::new(), operator_domain: String::new(), to the two test literals and the CLI literal (CLI sets real values).
  • Step 2: Failing test in client_behavior.rs: send operator_password: "pw-secret".to_owned() with a fake that returns Status::internal("boom pw-secret"); assert !format!("{err:?}").contains("pw-secret") and !err.to_string().contains("pw-secret").
  • Step 3: Implement in client.rs:
    pub async fn acknowledge_alarm(
        &self,
        request: AcknowledgeAlarmRequest,
    ) -> Result<AcknowledgeAlarmReply, Error> {
        let secret = request.operator_password.clone();
        let mut client = self.inner.clone();
        let result = async {
            let response = client.acknowledge_alarm(self.unary_request(request)).await?;
            let reply = response.into_inner();
            ensure_protocol_success("acknowledge alarm", reply.protocol_status.as_ref())?;
            Ok(reply)
        }
        .await;
        result.map_err(|error: Error| if secret.is_empty() { error } else { error.with_secrets(&[secret.as_str()]) })
    }

(Use the crate's actual secret-attachment API name from src/error.rs; if with_secrets lives on MxAccessError only, add an Error::redact_secrets(self, &[&str]) -> Error helper that scrubs Display/Debug text, with a unit test.) CLI: add clap args #[arg(long, default_value = "")] operator_domain, #[arg(long)] operator_password: Option<String>, #[arg(long, default_value = "MXGATEWAY_OPERATOR_PASSWORD")] operator_password_env: String; resolve like resolve_verify_user_password.

  • Step 4: Docs — README/RustClientDesign wording as Task 7 and the corrected CLI example.
  • Step 5: Verify → pass. Step 6: Commit git add clients/rust && git commit -m "feat(rust-client): operator credentials on acknowledge-alarm" -m "Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT"

Task 11: Java client and CLI

Goal: Regenerate Java bindings and add operator credential options to the Java CLI with redaction.

Files:

  • Regenerate: clients/java/src/main/generated/main/java/mxaccess_gateway/v1/MxaccessGateway.java (gradle :zb-mom-ww-mxgateway-client:generateProto) — commit it (real proto change)
  • Modify: clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/MxGatewayClient.java (:280-306), clients/java/zb-mom-ww-mxgateway-cli/src/main/java/com/zb/mom/ww/mxgateway/cli/MxGatewayCli.java (:1814-1846), clients/java/README.md (:114-118, :420)
  • Test: clients/java/zb-mom-ww-mxgateway-cli/src/test/java/com/zb/mom/ww/mxgateway/cli/MxGatewayCliTests.java (:706, :758)

Acceptance Criteria:

  • acknowledgeAlarm rethrows failures with MxGatewaySecrets.redactExact(message, request.getOperatorPassword()) applied when the password is non-empty.
  • CLI --operator-domain, --operator-password, --operator-password-env (default MXGATEWAY_OPERATOR_PASSWORD, picocli arity/interactive not required).
  • readmeDocumentedAcknowledgeAlarmExampleParsesCleanly updated to the new README tokens and passes; checkGeneratedClean passes after commit.

Verify: cd clients/java && JAVA_HOME=/opt/homebrew/opt/openjdk@17 gradle test :zb-mom-ww-mxgateway-client:checkGeneratedClean → BUILD SUCCESSFUL

Steps:

  • Step 1: Regenerate — cd clients/java && JAVA_HOME=/opt/homebrew/opt/openjdk@17 gradle :zb-mom-ww-mxgateway-client:generateProto; git diff --stat shows MxaccessGateway.java with the new fields.
  • Step 2: Failing CLI test — extend acknowledgeAlarmCommandForwardsOptionsAndPrintsReply with "--operator-domain", "CORP", "--operator-password", "pw-secret" and assert factory.client.lastAcknowledgeAlarmRequest.getOperatorPassword() equals it and run.output() does not contain pw-secret.
  • Step 3: Implement — CLI options and .setOperatorDomain(operatorDomain).setOperatorPassword(resolveOperatorPassword()) where resolveOperatorPassword() returns the option value or System.getenv(operatorPasswordEnv) or ""; client acknowledgeAlarm catch block:
        } catch (RuntimeException error) {
            MxGatewayException mapped = error instanceof MxGatewayException gatewayError
                    ? gatewayError
                    : MxGatewayErrors.fromGrpc("acknowledge alarm", error);
            String secret = request.getOperatorPassword();
            if (secret.isEmpty()) {
                throw mapped;
            }
            throw MxGatewayErrors.redacted(mapped, secret);
        }

(If no MxGatewayErrors.redacted exists, add one that rebuilds the exception with MxGatewaySecrets.redactExact(message, secret) — mirror MxGatewaySession.rebuildRedacted.)

  • Step 4: README — wording as Task 7; CLI example adds --operator operator1 --operator-password-env MXGATEWAY_OPERATOR_PASSWORD; update the README-pinned test tokens.
  • Step 5: Verify → BUILD SUCCESSFUL. Step 6: Commit git add clients/java && git commit -m "feat(java-client): operator credentials on acknowledge-alarm" -m "Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT"

Task 12: Documentation

Goal: Describe the operator-authenticated AckMsg ack everywhere the old alarm-client ack is described, add the credential to the never-log rules, and record the decision.

Files:

  • Modify: gateway.md (:143-144, :224-237, :1031-1032), docs/Grpc.md (:99-101, validation table :219), docs/Contracts.md (add ack/credential paragraph near :69-71), docs/DesignDecisions.md (:704-713 + new decision section), docs/AlarmClientDiscovery.md (§2-§4 :738-843, :997-1019), docs/AlarmProbeFindings.md (new "Fourth attempt (2026-10-19)" section; update Outcome table), docs/GatewayConfiguration.md (:449-455 replace; add AckClientName row in the Alarm Options table), docs/Diagnostics.md (:7, :66-83), docs/Authorization.md (:128 note), CLAUDE.md (:129)

Acceptance Criteria:

  • No doc says acks use AlarmAckByName/AlarmAckByGUID in current tense or mentions MXGATEWAY_USE_MANAGED_ALARM_ACK / AaManagedAlarmAckConsumer (grep -rn over docs gateway.md CLAUDE.md returns only historical, dated finding sections).
  • Grpc.md documents operator_user required, operator_password/operator_domain, the status mapping (Ok / InvalidRequest / MxaccessFailure) and that Historian records the operator; validation table has the operator_user row.
  • GatewayConfiguration.md documents MxGateway:Alarms:AckClientName (default MxGateway, = Historian OperatorNode).
  • Diagnostics.md and CLAUDE.md list AcknowledgeAlarm operator_password among never-log credentials; DesignDecisions.md has "Alarm acknowledgement writes AckMsg as the authenticated operator" with the why (alarm-client acks inert headless; AckMsg write historizes the authenticated identity).
  • AlarmProbeFindings.md's new section records: authenticated AckMsg write acks and logs Operator/Domain/FullName/Node; alarm-client acks inert in every variant (secured and free-access, operator names, consumer names, pump).

Verify: grep -rn "MXGATEWAY_USE_MANAGED_ALARM_ACK\|AaManagedAlarmAckConsumer" docs gateway.md CLAUDE.md → only lines inside dated historical sections; manual read of the edited sections.

Steps:

  • Step 1: gateway.md — replace :224-237 with:
**Acknowledge:** the gateway acknowledges an alarm by writing the operator's
comment to the alarm attribute's `AckMsg` (subtag mode: the configured
ack-comment subtag) over MXAccess, after `AuthenticateUser` as the requesting
operator (`operator_user`, `operator_password`, optional `operator_domain`).
The AVEVA runtime treats that write as the acknowledgement and historizes it
with the authenticated account's name, domain and full name; the Historian's
OperatorNode is the ack writer's MXAccess client name
(`MxGateway:Alarms:AckClientName`). The worker waits a bounded time for
`OnWriteComplete`: an OK status is reported as success, a non-OK status as
`MxaccessFailure` with the status detail, and no completion as success with a
diagnostic noting the write was sent but unconfirmed. Alarm-client ack calls
(`AlarmAckByName`/`AlarmAckByGUID`, the managed `AlarmClient`, WNAL) are not
used: live tests showed a headless consumer's acks are accepted and silently
dropped (see `docs/AlarmProbeFindings.md`).

and add "AcknowledgeAlarm's operator_password" to the credential sentence at :1031-1032.

  • Step 2: Grpc.md — rewrite :99-101 with the request fields, required operator_user, mechanism (same as Step 1, shorter), outcome mapping table (Applied/Unconfirmed → OK + hresult 0; alarm not found / not ackable → INVALID_REQUEST; authentication failed / write rejected → MXACCESS_FAILURE + hresult), and add validation row | AcknowledgeAlarm | operator_user must be non-empty. | InvalidArgument |.
  • Step 3: DesignDecisions.md — replace :704-713 ("Acknowledge via ack-comment write") so it states both modes write AckMsg as the operator and fix the stale "empty by default" (default is AckMsg); add the new decision section with rationale and the credential-handling rule.
  • Step 4: AlarmClientDiscovery.md — in §2-§4 mark the two-consumer split and by-name ack as historical ("Superseded 2026-10-19: acks now write AckMsg as the operator; see AlarmProbeFindings.md"), delete the "Opt-in managed GUID ack" flag instructions but keep its findings table under a dated heading; update "Acknowledge in subtag mode" (:997-1019) to say the write is authenticated and goes through the ack writer.
  • Step 5: AlarmProbeFindings.md — add "## Fourth attempt (2026-10-19): authenticated AckMsg write" with the Historian rows from memory project_managed_alarm_ack_live_result.md (Administrator/ArchestrA/mxa, NULL-operator unauthenticated row) and the inert-variants list; update the Outcome table's acknowledge row to "Answered — AckMsg write as the operator".
  • Step 6: GatewayConfiguration.md — replace :449-455 with the AckClientName explanation and add the table row | MxGateway:Alarms:AckClientName | MxGateway | MXAccess client name the alarm worker's ack writer registers with; the Historian records it as each acknowledgement's OperatorNode. 1-64 printable characters. Conveyed via MXGATEWAY_ALARM_ACK_CLIENT_NAME. |.
  • Step 7: Diagnostics.md / CLAUDE.md / Contracts.md / Authorization.md — add AcknowledgeAlarm/AcknowledgeAlarmByName to the documented SensitiveCommandMethods set and the name-based password seam rule; CLAUDE.md :129 append "and AcknowledgeAlarm operator_password"; Contracts.md paragraph on the ack credential fields and their never-log rule; Authorization.md note that AcknowledgeAlarm stays invoke:write because the operator's own credential authorizes the write.
  • Step 8: Verify grep; Commit git add gateway.md docs CLAUDE.md && git commit -m "docs: alarm acknowledgement writes AckMsg as the authenticated operator" -m "Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT"

Task 13: Full verification and live Historian check on windev

Goal: Prove end to end on the windev rig that an AcknowledgeAlarm RPC with operator credentials acknowledges the alarm and the Historian events table records that operator.

USER-ORDERED GATE — NON-SKIPPABLE. This task was requested by the user in the current conversation. It MUST NOT be closed by walking around it, by declaring it "verified inline", or by substituting a cheaper check. Close only after every item in acceptanceCriteria has been re-validated independently, with output captured.

Files:

  • No source changes (deploy only). Uses windev C:\build\mxaccessgw-ci, C:\publish\mxaccessgw\Server-YYYYMMDD, C:\publish\mxaccessgw\Worker, NSSM service MxAccessGw.

Acceptance Criteria:

  • Gateway full suite passes once on macOS (dotnet test src/ZB.MOM.WW.MxGateway.Tests) and windev worker CI SUCCESS mode=test with Failed: 0 for the final SHA.
  • Server + Worker for the final SHA deployed to windev (Server to a dated Server-YYYYMMDD dir with previous appsettings*.json copied; Worker swapped with Worker.bak-YYYYMMDD backup); /health/ready 200; mxgateway_alarms_provider_mode 1.
  • With TestMachine_001.TestAlarm001 raised (UNACK_ALM), grpcurl … AcknowledgeAlarm {"alarm_full_reference":"Galaxy!TestArea.TestMachine_001.TestAlarm001","comment":"<unique>","operator_user":"Administrator","operator_password":""} returns PROTOCOL_STATUS_CODE_OK, hresult 0.
  • mxa read TestMachine_001.TestAlarm001.Acked → True; .AckMsg = the unique comment.
  • Runtime.dbo.v_AlarmEventHistory2 newest ACK_ALM row for TestMachine_001.TestAlarm001 has Operator = Administrator, DomainName = ArchestrA, OperatorNode = MxGateway, Description = <unique comment>.
  • Negative: same RPC with operator_password = a wrong value for a password-protected account (or operator_user = a non-existent user) returns PROTOCOL_STATUS_CODE_MXACCESS_FAILURE, the alarm stays unacked, no new ACK_ALM row, and the gateway/worker logs (Server-*/logs/mxgateway-*.log, stderr.log tail) do not contain the password string.
  • Rig restored: alarm cleared (mxa write … false --secured -u Administrator), scratch files removed.

Verify: windev: sqlcmd -S localhost -E -W -s "|" -i C:\build\hist-q.sql (query below) → newest ACK_ALM row shows Administrator|ArchestrA|…|MxGateway|<unique comment>

Steps:

  • Step 1: Full suites — dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj (once); push; windev CI -Mode test for the final SHA.
  • Step 2: Deploy per memory project_deploy_mechanics.md: build in a detached worktree off the CI clone at the final SHA (git -C C:\build\mxaccessgw-ci worktree add C:\build\mgack-deploy <sha>), dotnet publish Server (net10, framework-dependent) to C:\publish\mxaccessgw\Server-YYYYMMDD and copy appsettings.json/appsettings.Development.json from the current Server dir; publish Worker x86 to a temp dir; Stop-Service MxAccessGw -Force; rename Worker → Worker.bak-YYYYMMDD; copy the new worker; nssm set MxAccessGw Application <new>\ZB.MOM.WW.MxGateway.Server.exe and AppDirectory; Start-Service; check health + metrics. Use base64 -EncodedCommand or scp'd .ps1 files; read logs by content (not file size).
  • Step 3: Raise — mxa write TestMachine_001.TestAlarm001 true --type bool --secured -u Administrator; wait until QueryActiveAlarms shows the alarm ALARM_CONDITION_STATE_ACTIVE.
  • Step 4: Ack via RPC from the Mac:
K=$(ssh windev 'type C:\publish\mxaccessgw\.apikey' | tr -d '\r\n'); C="ackmsg-op $(date -u +%H%M%S)"
grpcurl -plaintext -import-path src/ZB.MOM.WW.MxGateway.Contracts/Protos -proto mxaccess_gateway.proto \
  -H "authorization: Bearer $K" \
  -d "{\"client_correlation_id\":\"ackmsg-live\",\"alarm_full_reference\":\"Galaxy!TestArea.TestMachine_001.TestAlarm001\",\"comment\":\"$C\",\"operator_user\":\"Administrator\",\"operator_password\":\"\"}" \
  10.100.0.48:5120 mxaccess_gateway.v1.MxAccessGateway/AcknowledgeAlarm
  • Step 5: Check mxa read TestMachine_001.TestAlarm001.Acked TestMachine_001.TestAlarm001.AckMsg, then the Historian with C:\build\hist-q.sql:
set nocount on;
select top 6 convert(varchar(23),EventStampUTC,121) as utc, AlarmState, TagName, Operator, DomainName, UserFullName, OperatorNode, left(Description,40) as Description
from Runtime.dbo.v_AlarmEventHistory2
where TagName like 'TestMachine_001.TestAlarm001%' and EventStampUTC > dateadd(hour,-3,getutcdate())
order by EventStampUTC desc;
  • Step 6: Negative case — clear and re-raise, send the ack with a bad credential (e.g. operator_user":"NoSuchOperator"), confirm MXACCESS_FAILURE, Acked=False, no ACK row; Select-String the password-like value you sent in C:\publish\mxaccessgw\Server-*\logs\*.log and C:\publish\mxaccessgw\stderr.log → no matches.
  • Step 7: Restore — clear the alarm, remove C:\build\mgack-*, git worktree prune. Record results (Historian rows) in docs/AlarmProbeFindings.md's new section if they differ from Task 12's text, commit, push, open the PR to main with a body ending https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT.

Self-Review

  • Spec coverage: request/command fields (T1), ack transitions attribution (T2), authenticated write + completion (T3), resolve-only consumers + non-Galaxy rejection (T4), routing/reply mapping + managed removal (T5), gateway forwarding/validation/redaction/client name (T6), five clients (T7-T11), docs + never-log rules (T12), live Historian proof incl. negative + no-leak (T13). No gaps found.
  • Placeholders: none; the few "confirm the exact name" notes point at a specific file to read, not deferred design.
  • Type consistency: AlarmAckOperator{UserName,Domain,Password,VerifyUser}, AlarmAckTarget{Found,ItemAddress,Failure,Reason; For/NotFound/NotAckable}, AlarmAckResult{Outcome,NativeStatus,Diagnostic,Statuses; Create/FromTarget}, IAlarmAckWriter.Acknowledge(string,string,AlarmAckOperator), IMxAccessAlarmConsumer.ResolveAckTarget(Guid)/ResolveAckTargetByName(string,string,string), AlarmDispatcher(consumer, sink, sessionId, IAlarmAckWriter), IAlarmCommandHandler.Acknowledge(Guid,string,AlarmAckOperator)/AcknowledgeByName(string,string,string,string,AlarmAckOperator) — used consistently across T2-T5.
  • Ordering note: T4 and T5 change interfaces together; verify the worker build after T5 (T2's dispatcher test is adjusted in T5).