# 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 `.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 `.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 `..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`); 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** ```bash 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: ```csharp [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: ```proto // 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 `.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 `.AckMsg` (Galaxy provider only) after AuthenticateUser as the operator." and append: ```proto // 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`: ```proto // 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 "\". 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`: ```csharp /// /// MXAccess client name the worker's alarm-ack writer registers with. The /// Historian records it as the acknowledgement's OperatorNode. /// public const string AlarmAckClientName = "MXGATEWAY_ALARM_ACK_CLIENT_NAME"; ``` - [ ] **Step 6: Regenerate and copy** ```bash 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** ```bash 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`: ```csharp [Fact] public void ParseSnapshotXml_WithAckElements_PopulatesAckFields() { const string xml = "2D8A9BE88FE04B0F8D996BB4D56620C7" + "2026/10/192400" + "DESKTOP-6JL3KKOGalaxyTestArea" + "TestMachine_001.TestAlarm001DSCtruetrue" + "500ACK_ALM" + "ArchestrAmxa" + "AdministratorTest alarm #1" + "mgack-authwrite 180714"; Dictionary 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`): ```csharp [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`): ```csharp /// Gets or sets the acknowledgement comment (wnwrap ACK_COMMENT). public string AckComment { get; set; } = string.Empty; /// Gets or sets the acknowledging operator's name (wnwrap ACK_OPERATOR_NAME). public string AckOperatorName { get; set; } = string.Empty; /// Gets or sets the acknowledging operator's domain (wnwrap ACK_DOMAIN_NAME). public string AckOperatorDomain { get; set; } = string.Empty; /// Gets or sets the acknowledging node, i.e. the MXAccess client name (wnwrap ACK_OPERATOR_NODE). 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 ```csharp 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`: ```csharp 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: ```csharp 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): ```bash 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`: ```csharp namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; /// /// Operator identity an alarm acknowledgement authenticates as. The password /// is a raw credential: it is passed to MXAccess AuthenticateUser only /// and must never be logged or echoed. /// public sealed class AlarmAckOperator { /// Gets or sets the operator user name. public string UserName { get; set; } = string.Empty; /// Gets or sets the optional domain for OS-authenticated galaxies. public string Domain { get; set; } = string.Empty; /// Gets or sets the operator password (credential-sensitive). public string Password { get; set; } = string.Empty; /// Gets the user string passed to AuthenticateUser: Domain\UserName when a domain is set. public string VerifyUser => string.IsNullOrEmpty(Domain) ? UserName : Domain + "\\" + UserName; /// public override string ToString() => "AlarmAckOperator(" + VerifyUser + ")"; } ``` `AlarmAckOutcome.cs`: ```csharp namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; /// Outcome of an alarm acknowledgement attempt. public enum AlarmAckOutcome { /// The AckMsg write completed with an OK status. Applied, /// The write was sent but no OnWriteComplete arrived before the timeout. Unconfirmed, /// The alarm is not in the consumer's current view. AlarmNotFound, /// The alarm exists but has no MXAccess ack attribute (non-Galaxy provider or no ack subtag). NotAckable, /// AuthenticateUser rejected the operator credentials. AuthenticationFailed, /// The AckMsg write completed with a non-OK status. WriteRejected, } ``` `AlarmAckTarget.cs`: ```csharp namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; /// The MXAccess item an acknowledgement writes, or why none exists. public sealed class AlarmAckTarget { private AlarmAckTarget(string itemAddress, AlarmAckOutcome failure, string reason) { ItemAddress = itemAddress; Failure = failure; Reason = reason; } /// Gets a value indicating whether an ack item was resolved. public bool Found => ItemAddress.Length > 0; /// Gets the ack item address (e.g. TestMachine_001.TestAlarm001.AckMsg), empty when not found. public string ItemAddress { get; } /// Gets the failure outcome when is false. public AlarmAckOutcome Failure { get; } /// Gets a human-readable reason when is false. public string Reason { get; } /// Creates a resolved target. /// The ack item address. /// The target. public static AlarmAckTarget For(string itemAddress) => new AlarmAckTarget(itemAddress ?? string.Empty, AlarmAckOutcome.Applied, string.Empty); /// Creates a not-found result. /// Why the alarm was not found. /// The target. public static AlarmAckTarget NotFound(string reason) => new AlarmAckTarget(string.Empty, AlarmAckOutcome.AlarmNotFound, reason); /// Creates a not-ackable result. /// Why the alarm cannot be acknowledged over MXAccess. /// The target. public static AlarmAckTarget NotAckable(string reason) => new AlarmAckTarget(string.Empty, AlarmAckOutcome.NotAckable, reason); } ``` `AlarmAckResult.cs`: ```csharp using System.Collections.Generic; using ZB.MOM.WW.MxGateway.Contracts.Proto; namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; /// Result of an alarm acknowledgement. public sealed class AlarmAckResult { private static readonly IReadOnlyList NoStatuses = new MxStatusProxy[0]; private AlarmAckResult(AlarmAckOutcome outcome, int nativeStatus, string diagnostic, IReadOnlyList? statuses) { Outcome = outcome; NativeStatus = nativeStatus; Diagnostic = diagnostic; Statuses = statuses ?? NoStatuses; } /// Gets the outcome. public AlarmAckOutcome Outcome { get; } /// Gets the native status: 0 for applied/unconfirmed, else the HRESULT or status detail. public int NativeStatus { get; } /// Gets a diagnostic message. Never contains credentials. public string Diagnostic { get; } /// Gets the OnWriteComplete statuses, empty when none was observed. public IReadOnlyList Statuses { get; } /// Creates a result. /// The outcome. /// The native status. /// A credential-free diagnostic. /// Observed write statuses. /// The result. public static AlarmAckResult Create( AlarmAckOutcome outcome, int nativeStatus, string diagnostic, IReadOnlyList? statuses = null) => new AlarmAckResult(outcome, nativeStatus, diagnostic ?? string.Empty, statuses); /// Creates the result for an unresolved ack target. /// The unresolved target. /// The result. public static AlarmAckResult FromTarget(AlarmAckTarget target) => new AlarmAckResult(target.Failure, -1, target.Reason, null); } ``` `IAlarmAckWriter.cs`: ```csharp using System; namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; /// /// Acknowledges an alarm by writing its MXAccess ack item (AckMsg) /// after authenticating as the operator. Must be called on the worker STA. /// public interface IAlarmAckWriter : IDisposable { /// Authenticates and writes to . /// The ack item, e.g. Obj.Alarm.AckMsg. /// The acknowledgement comment. /// The operator to authenticate as. /// The outcome. 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)`: ```csharp public sealed class AlarmAckWriterTests { private static AlarmAckOperator Alice() => new AlarmAckOperator { UserName = "alice", Domain = "CORP", Password = "pw-secret" }; private static RepeatedField Rows(MxStatusCategory category, int detail) => new RepeatedField { 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`.** ```csharp 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; /// /// Acknowledges alarms the way the AVEVA runtime records operator identity: /// AuthenticateUser as the operator, then write the comment to the alarm's /// AckMsg attribute with that user id. Owns its own /// LMXProxyServerClass 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. /// public sealed class AlarmAckWriter : IAlarmAckWriter { /// The ArchestrA alarm-primitive attribute whose write acknowledges the alarm. public const string AckCommentAttribute = "AckMsg"; /// Default MXAccess client name when none is configured. 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 itemHandles = new Dictionary(StringComparer.OrdinalIgnoreCase); private object? comObject; private LMXProxyServerClass? comEventSource; private IMxAccessServer? server; private int serverHandle; private bool disposed; /// Creates a writer that registers its own MXAccess server on first use. /// Creates the LMXProxyServerClass. /// Drains the STA message queue (OnWriteComplete is delivered by pumping). /// Bounded wait for OnWriteComplete. /// MXAccess client name (Historian OperatorNode). 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(); } /// Test seam: uses an already-constructed server and completion cache. 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); } /// Resolves the configured client name from the launcher environment. /// The client name, or . public static string ResolveClientName() { string? value = Environment.GetEnvironmentVariable(WorkerLaunchEnvironment.AlarmAckClientName); return string.IsNullOrWhiteSpace(value) ? DefaultClientName : value!.Trim(); } /// 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 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); } /// 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 rows = new RepeatedField(); 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 -Mode test`, or the filtered `dotnet test` in the CI clone). Expected: all `AlarmAckWriterTests` pass. - [ ] **Step 5: Commit** ```bash 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: ```csharp /// /// Resolves the MXAccess item whose write acknowledges the alarm with the /// given GUID (e.g. Obj.Alarm.AckMsg). Does not perform the ack. /// /// The alarm GUID as reported by this consumer. /// The resolved target or the reason none exists. AlarmAckTarget ResolveAckTarget(Guid alarmGuid); /// Resolves the ack item for an alarm identified by name, provider and group. /// The tag/alarm name, e.g. TestMachine_001.TestAlarm001. /// The provider (literal Galaxy for ArchestrA). /// The area/group. /// The resolved target or the reason none exists. 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)` under `syncRoot`): ```csharp [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: ```csharp private const string GalaxyProviderName = "Galaxy"; /// 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); } /// 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: ```csharp /// 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); } /// 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: ```csharp /// public AlarmAckTarget ResolveAckTarget(Guid alarmGuid) { if (disposed) throw new ObjectDisposedException(nameof(FailoverAlarmConsumer)); return ActiveChild.ResolveAckTarget(alarmGuid); } /// 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**: ```bash 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 -Mode test` → `SUCCESS mode=test`, Failed: 0 **Steps:** - [ ] **Step 1: Failing dispatcher tests** (replace the four managed tests and `FakeManagedAckClient` in `AlarmDispatcherTests.cs`): ```csharp [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`): ```csharp [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: ```csharp /// Acknowledges an alarm by GUID as . AlarmAckResult Acknowledge(Guid alarmGuid, string comment, AlarmAckOperator ackOperator); /// Acknowledges an alarm by name/provider/group as . 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: ```csharp /// Acknowledges the alarm with the given GUID by writing its ack item as the operator. /// The alarm GUID. /// The acknowledgement comment. /// The operator to authenticate as. /// The outcome. 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); } /// Acknowledges the alarm identified by name/provider/group as the operator. /// The tag/alarm name. /// The provider. /// The group/area. /// The acknowledgement comment. /// The operator to authenticate as. /// The outcome. 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? ackWriterFactory = null`; add `private IAlarmAckWriter? ackWriter;` and ```csharp 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: ```csharp 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: ```csharp 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** ```bash 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 -Mode test' ``` Expected: grep prints nothing; CI log ends `SUCCESS mode=test`, `Failed: 0`. - [ ] **Step 9: Commit** ```bash 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: ```csharp 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`: ```csharp [Fact] public async Task AcknowledgeAlarm_WithMissingOperatorUser_ThrowsInvalidArgument() { MxAccessGatewayService service = CreateService(new FakeSessionManager()); RpcException exception = await Assert.ThrowsAsync( 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: ```csharp 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: ```csharp 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`: ```csharp /// /// 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. /// 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** ```bash 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: ```csharp /// /// Acknowledges an alarm as the operator. The gateway authenticates /// with /// 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. /// public async Task 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 --operator [--operator-domain ] [--operator-password | --operator-password-env ] [--comment ] [--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`: ```python 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`: ```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`: ```rust pub async fn acknowledge_alarm( &self, request: AcknowledgeAlarmRequest, ) -> Result { 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`, `#[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: ```java } 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: ```markdown **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":"","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 = `. - [ ] 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|` ```json:metadata {"userGate": true, "tags": ["user-gate"], "verifyCommand": "sqlcmd -S localhost -E -W -s \"|\" -i C:\\build\\hist-q.sql", "acceptanceCriteria": ["AcknowledgeAlarm RPC returns OK hresult 0", "TestAlarm001.Acked = True and AckMsg = unique comment", "Historian ACK_ALM row Operator=Administrator DomainName=ArchestrA OperatorNode=MxGateway Description=unique comment", "wrong-password ack returns MXACCESS_FAILURE, no ACK row, password absent from logs"]} ``` **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 `), `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 \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: ```bash 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`: ```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).