diff --git a/docs/superpowers/plans/2026-10-19-ackmsg-as-operator.md b/docs/superpowers/plans/2026-10-19-ackmsg-as-operator.md new file mode 100644 index 0000000..fb511b1 --- /dev/null +++ b/docs/superpowers/plans/2026-10-19-ackmsg-as-operator.md @@ -0,0 +1,1867 @@ +# 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). diff --git a/docs/superpowers/plans/2026-10-19-ackmsg-as-operator.md.tasks.json b/docs/superpowers/plans/2026-10-19-ackmsg-as-operator.md.tasks.json new file mode 100644 index 0000000..a59bb8c --- /dev/null +++ b/docs/superpowers/plans/2026-10-19-ackmsg-as-operator.md.tasks.json @@ -0,0 +1,130 @@ +{ + "planPath": "docs/superpowers/plans/2026-10-19-ackmsg-as-operator.md", + "tasks": [ + { + "id": 0, + "subject": "Task 1: Contract fields and regenerated bindings", + "status": "pending", + "description": "**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.\n\n**Files:**\n- Modify: `src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto` (`AcknowledgeAlarmCommand` ~394-406, `AcknowledgeAlarmByNameCommand` ~415-434, `AcknowledgeAlarmReplyPayload` ~726-737, `AcknowledgeAlarmRequest` ~977-990)\n- Modify: `src/ZB.MOM.WW.MxGateway.Contracts/WorkerLaunchEnvironment.cs`\n- 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)\n- Test: `src/ZB.MOM.WW.MxGateway.Tests/Contracts/ProtobufContractRoundTripTests.cs`\n\n**Acceptance Criteria:**\n- [ ] `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.\n- [ ] `WorkerLaunchEnvironment.AlarmAckClientName == \"MXGATEWAY_ALARM_ACK_CLIENT_NAME\"`.\n- [ ] Round-trip tests cover the new fields and pass.\n- [ ] `pwsh -File scripts/publish-client-proto-inputs.ps1 -Check` passes and `ClientProtoInputTests` passes.\n- [ ] Rust vendored proto is byte-identical to the Contracts proto.\n\n**Verify:** `dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter \"FullyQualifiedName~ProtobufContractRoundTripTests|FullyQualifiedName~ClientProtoInputTests\"` \u2192 all pass\n\nFull steps and code: docs/superpowers/plans/2026-10-19-ackmsg-as-operator.md (Task 1: Contract fields and regenerated bindings).\n\n```json:metadata\n{\"files\": [\"src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto\", \"src/ZB.MOM.WW.MxGateway.Contracts/WorkerLaunchEnvironment.cs\", \"src/ZB.MOM.WW.MxGateway.Contracts/Generated/MxaccessGateway.cs\", \"MxaccessGatewayGrpc.cs\", \"clients/proto/descriptors/mxaccessgw-client-v1.protoset\", \"clients/rust/protos/mxaccess_gateway.proto\", \"src/ZB.MOM.WW.MxGateway.Tests/Contracts/ProtobufContractRoundTripTests.cs\"], \"verifyCommand\": \"dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter \\\"FullyQualifiedName~ProtobufContractRoundTripTests|FullyQualifiedName~ClientProtoInputTests\\\"\", \"acceptanceCriteria\": [\"`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.\"], \"modelTier\": \"standard\"}\n```" + }, + { + "id": 1, + "subject": "Task 2: Parse wnwrap ACK_* fields and attribute ack trans...", + "status": "pending", + "description": "**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.\n\n**Files:**\n- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAlarmSnapshot.cs`\n- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs` (`ParseSnapshotXml` ~739-849)\n- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs` (`OnTransition` ~235-260, `MapToSnapshot` ~273-274)\n- Test: `src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/WnWrapAlarmConsumerXmlTests.cs`, `AlarmDispatcherTests.cs`\n\n**Acceptance Criteria:**\n- [ ] `ParseSnapshotXml` fills `AckComment`, `AckOperatorName`, `AckOperatorDomain`, `AckOperatorNode` from the live XML sample below.\n- [ ] An `UnackAlm \u2192 AckAlm` transition whose record has `AckOperatorName=\"alice\"`, `AckComment=\"checked\"` enqueues an `OnAlarmTransitionEvent` with `OperatorUser == \"alice\"` and `OperatorComment == \"checked\"`.\n- [ ] Non-ack transitions keep `OperatorUser = OperatorName`, `OperatorComment = AlarmComment`.\n\n**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) \u2192 pass\n\nFull steps and code: docs/superpowers/plans/2026-10-19-ackmsg-as-operator.md (Task 2: Parse wnwrap ACK_* fields and attribute ack transitions).\n\n```json:metadata\n{\"files\": [\"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/WnWrapAlarmConsumerXmlTests.cs\", \"AlarmDispatcherTests.cs\"], \"verifyCommand\": \"dotnet test src/ZB.MOM.WW.MxGateway.Worker.Tests/ZB.MOM.WW.MxGateway.Worker.Tests.csproj -p:Platform=x86 --filter \\\"FullyQualifiedName~WnWrapAlarmConsumerXmlTests|FullyQualifiedName~AlarmDispatcherTests\\\"\", \"acceptanceCriteria\": [\"`ParseSnapshotXml` fills `AckComment`, `AckOperatorName`, `AckOperatorDomain`, `AckOperatorNode` from the live XML sample below.\", \"An `UnackAlm \\u2192 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`.\"], \"modelTier\": \"standard\"}\n```", + "blockedBy": [ + 0 + ] + }, + { + "id": 2, + "subject": "Task 3: AlarmAckWriter (authenticated AckMsg write)", + "status": "pending", + "description": "**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`.\n\n**Files:**\n- Create: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckOperator.cs`, `AlarmAckOutcome.cs`, `AlarmAckTarget.cs`, `AlarmAckResult.cs`, `IAlarmAckWriter.cs`, `AlarmAckWriter.cs`\n- Test: `src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmAckWriterTests.cs`\n\n**Acceptance Criteria:**\n- [ ] 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`.\n- [ ] Completion with category `SecurityError`, detail 1008 \u2192 `WriteRejected`, `NativeStatus == 1008`, statuses returned.\n- [ ] No completion before the timeout \u2192 `Unconfirmed`, `NativeStatus == 0`.\n- [ ] `AuthenticateUser` throwing `COMException(hr)` or returning `0` \u2192 `AuthenticationFailed`, no write; the password never appears in `Diagnostic` or in the fake's operation log.\n- [ ] A second ack to the same item reuses the item handle (one `AddItem`, one `Advise`).\n- [ ] `AlarmAckOperator.ToString()` does not contain the password.\n\n**Verify:** `dotnet test src/ZB.MOM.WW.MxGateway.Worker.Tests/ZB.MOM.WW.MxGateway.Worker.Tests.csproj -p:Platform=x86 --filter \"FullyQualifiedName~AlarmAckWriterTests\"` (windev) \u2192 pass\n\nFull steps and code: docs/superpowers/plans/2026-10-19-ackmsg-as-operator.md (Task 3: AlarmAckWriter (authenticated AckMsg write)).\n\n```json:metadata\n{\"files\": [\"src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckOperator.cs\", \"AlarmAckOutcome.cs\", \"AlarmAckTarget.cs\", \"AlarmAckResult.cs\", \"IAlarmAckWriter.cs\", \"AlarmAckWriter.cs\", \"src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmAckWriterTests.cs\"], \"verifyCommand\": \"dotnet test src/ZB.MOM.WW.MxGateway.Worker.Tests/ZB.MOM.WW.MxGateway.Worker.Tests.csproj -p:Platform=x86 --filter \\\"FullyQualifiedName~AlarmAckWriterTests\\\"\", \"acceptanceCriteria\": [\"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 \\u2192 `WriteRejected`, `NativeStatus == 1008`, statuses returned.\", \"No completion before the timeout \\u2192 `Unconfirmed`, `NativeStatus == 0`.\", \"`AuthenticateUser` throwing `COMException(hr)` or returning `0` \\u2192 `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.\"], \"modelTier\": \"frontier\"}\n```", + "blockedBy": [ + 0 + ] + }, + { + "id": 3, + "subject": "Task 4: Consumers resolve the ack item instead of acking", + "status": "pending", + "description": "**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.\n\n**Files:**\n- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IMxAccessAlarmConsumer.cs`, `WnWrapAlarmConsumer.cs`, `SubtagAlarmConsumer.cs`, `FailoverAlarmConsumer.cs`, `ISubtagAlarmSource.cs`, `LmxSubtagAlarmSource.cs`\n- Test: `src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/{WnWrapAlarmConsumerXmlTests,SubtagAlarmConsumerTests,FailoverAlarmConsumerTests,LmxSubtagAlarmSourceTests}.cs` and every test fake implementing the changed interfaces\n\n**Acceptance Criteria:**\n- [ ] `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`.\n- [ ] `WnWrapAlarmConsumer.ResolveAckTargetByName(\"TestMachine_001.TestAlarm001\", \"Galaxy\", \"TestArea\")` returns `TestMachine_001.TestAlarm001.AckMsg` without needing a snapshot; non-Galaxy provider \u2192 `NotAckable`.\n- [ ] `SubtagAlarmConsumer` resolves synthetic GUIDs and names to the target's `AckCommentSubtag`; empty subtag \u2192 `NotAckable`; unknown \u2192 `NotFound`.\n- [ ] `FailoverAlarmConsumer` delegates both to `ActiveChild`.\n- [ ] wnwrap no longer creates the second `ackClient` consumer; `ISubtagAlarmSource.Write` and `LmxSubtagAlarmSource.Write` are gone; the solution builds with no warnings.\n\n**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) \u2192 pass\n\nFull steps and code: docs/superpowers/plans/2026-10-19-ackmsg-as-operator.md (Task 4: Consumers resolve the ack item instead of acking).\n\n```json:metadata\n{\"files\": [\"src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IMxAccessAlarmConsumer.cs\", \"WnWrapAlarmConsumer.cs\", \"SubtagAlarmConsumer.cs\", \"FailoverAlarmConsumer.cs\", \"ISubtagAlarmSource.cs\", \"LmxSubtagAlarmSource.cs\", \"src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/{WnWrapAlarmConsumerXmlTests,SubtagAlarmConsumerTests,FailoverAlarmConsumerTests,LmxSubtagAlarmSourceTests}.cs\"], \"verifyCommand\": \"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\\\"\", \"acceptanceCriteria\": [\"`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 \\u2192 `NotAckable`.\", \"`SubtagAlarmConsumer` resolves synthetic GUIDs and names to the target's `AckCommentSubtag`; empty subtag \\u2192 `NotAckable`; unknown \\u2192 `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.\"], \"modelTier\": \"standard\"}\n```", + "blockedBy": [ + 0 + ] + }, + { + "id": 4, + "subject": "Task 5: Route acks through the writer and remove the mana...", + "status": "pending", + "description": "**Goal:** Wire `AlarmAckWriter` through `AlarmCommandHandler` \u2192 `AlarmDispatcher` \u2192 `MxAccessCommandExecutor` with an `AlarmAckOperator` built from the command, map `AlarmAckResult` to the reply, and delete the dead managed/WNAL ack path.\n\n**Files:**\n- 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)\n- Delete: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AaManagedAlarmAckConsumer.cs`, `IManagedAlarmAckClient.cs`, `WnalNativeAckConsumer.cs`, `src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmManagedGuidAckLiveSmokeTests.cs`\n- Test: `AlarmDispatcherTests.cs`, `AlarmCommandHandlerTests.cs`, `AlarmCommandExecutorTests.cs`, `MxAccessCommandExecutorTests.cs`\n\n**Acceptance Criteria:**\n- [ ] `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.\n- [ ] `ExecuteAcknowledgeAlarm` builds `AlarmAckOperator{UserName=operator_user, Domain=operator_domain, Password=operator_password}`; maps `Applied`/`Unconfirmed` \u2192 `Ok`, `AlarmNotFound`/`NotAckable` \u2192 `InvalidRequest`, `AuthenticationFailed`/`WriteRejected` \u2192 `MxaccessFailure`; sets `Hresult` and `AcknowledgeAlarm.NativeStatus` to `result.NativeStatus`; copies `result.Statuses` into `reply.Statuses`; `DiagnosticMessage = result.Diagnostic`.\n- [ ] A test with `operator_password = \"pw-secret\"` asserts the secret is absent from `DiagnosticMessage`, `ProtocolStatus.Message`, for every outcome.\n- [ ] No references remain to `AaManagedAlarmAckConsumer`, `IManagedAlarmAckClient`, `WnalNativeAckConsumer`, `MXGATEWAY_USE_MANAGED_ALARM_ACK`, `IsSubtagActive` (`grep -rn` returns nothing under `src/`).\n- [ ] Production wiring creates one `AlarmAckWriter(comFactory, () => new StaMessagePump().PumpPendingMessages(), MxAccessStaSession.ResolveWriteCompletionTimeout(), AlarmAckWriter.ResolveClientName())` per `AlarmCommandHandler`, disposed with the handler.\n- [ ] x86 worker builds with 0 warnings; all `Worker.Tests` pass on windev.\n\n**Verify:** on windev: `powershell -NoProfile -ExecutionPolicy Bypass -File C:\\build\\mxaccessgw-ci\\scripts\\ci\\windev-worker-ci.ps1 -Sha -Mode test` \u2192 `SUCCESS mode=test`, Failed: 0\n\nFull steps and code: docs/superpowers/plans/2026-10-19-ackmsg-as-operator.md (Task 5: Route acks through the writer and remove the managed path).\n\n```json:metadata\n{\"files\": [\"src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs\", \"AlarmCommandHandler.cs\", \"IAlarmCommandHandler.cs\", \"MxAccessCommandExecutor.cs\", \"src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerPipeSession.cs\", \"src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessStaSession.cs\", \"src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AaManagedAlarmAckConsumer.cs\", \"IManagedAlarmAckClient.cs\", \"WnalNativeAckConsumer.cs\", \"src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmManagedGuidAckLiveSmokeTests.cs\", \"AlarmDispatcherTests.cs\", \"AlarmCommandHandlerTests.cs\", \"AlarmCommandExecutorTests.cs\", \"MxAccessCommandExecutorTests.cs\"], \"verifyCommand\": \"powershell -NoProfile -ExecutionPolicy Bypass -File C:\\\\build\\\\mxaccessgw-ci\\\\scripts\\\\ci\\\\windev-worker-ci.ps1 -Sha -Mode test\", \"acceptanceCriteria\": [\"`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` \\u2192 `Ok`, `AlarmNotFound`/`NotAckable` \\u2192 `InvalidRequest`, `AuthenticationFailed`/`WriteRejected` \\u2192 `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.\"], \"modelTier\": \"frontier\"}\n```", + "blockedBy": [ + 1, + 2, + 3 + ] + }, + { + "id": 5, + "subject": "Task 6: Gateway forwards operator credentials and the ack...", + "status": "pending", + "description": "**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.\n\n**Files:**\n- Modify: `src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs` (`BuildAcknowledgeCommand` ~1172-1222)\n- Modify: `src/ZB.MOM.WW.MxGateway.Server/Grpc/MxAccessGatewayService.cs` (`AcknowledgeAlarm` ~187-207)\n- Modify: `src/ZB.MOM.WW.MxGateway.Server/Configuration/AlarmsOptions.cs`, `GatewayOptionsValidator.cs` (`ValidateAlarms` ~473-515), `Workers/WorkerProcessLauncher.cs` (~214-224)\n- Modify: `src/ZB.MOM.WW.MxGateway.Server/Diagnostics/GatewayLogRedactor.cs` (:11-16), `GatewayLogRedactorSeam.cs`\n- 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`\n\n**Acceptance Criteria:**\n- [ ] 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.\n- [ ] The gRPC handler returns `InvalidArgument` \"operator_user is required.\" when `operator_user` is empty.\n- [ ] `GatewayLogRedactor.IsCredentialBearingCommand(\"AcknowledgeAlarm\")` and `(\"AcknowledgeAlarmByName\")` are true; the seam redacts any property whose name contains `password` (case-insensitive).\n- [ ] `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.\n- [ ] Gateway builds with 0 warnings; the filtered tests pass.\n\n**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\"` \u2192 pass\n\nFull steps and code: docs/superpowers/plans/2026-10-19-ackmsg-as-operator.md (Task 6: Gateway forwards operator credentials and the ack client name).\n\n```json:metadata\n{\"files\": [\"src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs\", \"src/ZB.MOM.WW.MxGateway.Server/Grpc/MxAccessGatewayService.cs\", \"src/ZB.MOM.WW.MxGateway.Server/Configuration/AlarmsOptions.cs\", \"GatewayOptionsValidator.cs\", \"Workers/WorkerProcessLauncher.cs\", \"src/ZB.MOM.WW.MxGateway.Server/Diagnostics/GatewayLogRedactor.cs\", \"GatewayLogRedactorSeam.cs\", \"src/ZB.MOM.WW.MxGateway.Tests/Alarms/GatewayAlarmMonitorAcknowledgeTests.cs\", \"Gateway/Grpc/MxAccessGatewayServiceTests.cs\", \"Configuration/GatewayOptionsValidatorTests.cs\", \"Gateway/Workers/WorkerProcessLauncherTests.cs\", \"Diagnostics/GatewayLogRedactorTests.cs\", \"Diagnostics/GatewayLogRedactorSeamTests.cs\"], \"verifyCommand\": \"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\\\"\", \"acceptanceCriteria\": [\"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.\"], \"modelTier\": \"standard\"}\n```", + "blockedBy": [ + 0 + ] + }, + { + "id": 6, + "subject": "Task 7: .NET client and CLI", + "status": "pending", + "description": "**Goal:** Expose the new fields in the .NET client CLI with safe password input and make failures never echo the password.\n\n**Files:**\n- 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)\n- Test: `clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/MxGatewayClientAlarmsTests.cs`, `MxGatewayClientCliTests.cs`\n\n**Acceptance Criteria:**\n- [ ] `AcknowledgeAlarmAsync` doc mentions operator credentials; on failure the thrown exception is passed through `MxGatewaySecretRedaction.Redacted(ex, request.OperatorPassword)`.\n- [ ] 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.\n- [ ] Tests assert the request fields and that a failing call's message does not contain the password.\n\n**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\"` \u2192 pass\n\nFull steps and code: docs/superpowers/plans/2026-10-19-ackmsg-as-operator.md (Task 7: .NET client and CLI).\n\n```json:metadata\n{\"files\": [\"clients/dotnet/ZB.MOM.WW.MxGateway.Client/MxGatewayClient.cs\", \"clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/MxGatewayClientCli.cs\", \"clients/dotnet/README.md\", \"clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/MxGatewayClientAlarmsTests.cs\", \"MxGatewayClientCliTests.cs\"], \"verifyCommand\": \"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\\\"\", \"acceptanceCriteria\": [\"`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.\"], \"modelTier\": \"standard\"}\n```", + "blockedBy": [ + 0 + ] + }, + { + "id": 7, + "subject": "Task 8: Python client and CLI", + "status": "pending", + "description": "**Goal:** Regenerate Python bindings and add operator credential options to the CLI with redaction.\n\n**Files:**\n- Regenerate: `clients/python/src/zb_mom_ww_mxgateway/generated/*_pb2*.py` via `clients/python/generate-proto.ps1` (grpcio-tools **1.80.0**)\n- 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)\n- Test: `clients/python/tests/test_alarms.py`, `tests/test_review_findings_022_to_026.py`\n\n**Acceptance Criteria:**\n- [ ] Regenerated `_pb2.py` keeps `# Protobuf Python Version: 6.31.1` and only contains the real descriptor delta.\n- [ ] `acknowledge_alarm` redacts `request.operator_password` from raised errors (`redact_secret`).\n- [ ] CLI accepts `--operator-domain`, `--operator-password`, `--operator-password-env` (default `MXGATEWAY_OPERATOR_PASSWORD`); password added to `secrets=`.\n- [ ] README example still parses in `test_review_findings_022_to_026.py`.\n\n**Verify:** `cd clients/python && python -m pytest tests/test_alarms.py tests/test_review_findings_022_to_026.py tests/test_cli.py -q` \u2192 pass\n\nFull steps and code: docs/superpowers/plans/2026-10-19-ackmsg-as-operator.md (Task 8: Python client and CLI).\n\n```json:metadata\n{\"files\": [\"clients/python/src/zb_mom_ww_mxgateway/generated/*_pb2*.py\", \"clients/python/generate-proto.ps1\", \"clients/python/src/zb_mom_ww_mxgateway/client.py\", \"src/zb_mom_ww_mxgateway_cli/commands.py\", \"clients/python/README.md\", \"clients/python/tests/test_alarms.py\", \"tests/test_review_findings_022_to_026.py\"], \"verifyCommand\": \"cd clients/python && python -m pytest tests/test_alarms.py tests/test_review_findings_022_to_026.py tests/test_cli.py -q\", \"acceptanceCriteria\": [\"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`.\"], \"modelTier\": \"standard\"}\n```", + "blockedBy": [ + 0 + ] + }, + { + "id": 8, + "subject": "Task 9: Go client and CLI", + "status": "pending", + "description": "**Goal:** Regenerate Go bindings and add operator credential flags to `mxgw-go acknowledge-alarm` with error redaction.\n\n**Files:**\n- Regenerate: `clients/go/internal/generated/mxaccess_gateway.pb.go`, `mxaccess_gateway_grpc.pb.go` via `clients/go/generate-proto.ps1`\n- 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)\n- Test: `clients/go/mxgateway/alarms_test.go`\n\n**Acceptance Criteria:**\n- [ ] `AcknowledgeAlarm` wraps errors with `redactSecrets(err, req.GetOperatorPassword())` when the password is non-empty.\n- [ ] CLI flags `-operator-domain`, `-operator-password`, `-operator-password-env` (default `MXGATEWAY_OPERATOR_PASSWORD`) populate the request.\n- [ ] `gofmt -l .` prints nothing; tests pass.\n\n**Verify:** `cd clients/go && gofmt -l . && go build ./... && go test ./...` \u2192 no gofmt output, ok\n\nFull steps and code: docs/superpowers/plans/2026-10-19-ackmsg-as-operator.md (Task 9: Go client and CLI).\n\n```json:metadata\n{\"files\": [\"clients/go/internal/generated/mxaccess_gateway.pb.go\", \"mxaccess_gateway_grpc.pb.go\", \"clients/go/generate-proto.ps1\", \"clients/go/mxgateway/alarms.go\", \"clients/go/cmd/mxgw-go/main.go\", \"clients/go/README.md\", \"clients/go/mxgateway/alarms_test.go\"], \"verifyCommand\": \"cd clients/go && gofmt -l . && go build ./... && go test ./...\", \"acceptanceCriteria\": [\"`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.\"], \"modelTier\": \"standard\"}\n```", + "blockedBy": [ + 0 + ] + }, + { + "id": 9, + "subject": "Task 10: Rust client and CLI", + "status": "pending", + "description": "**Goal:** Make the Rust workspace compile against the new fields, add CLI credential options, and keep the password out of `Debug` output and errors.\n\n**Files:**\n- 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)\n- (Vendored proto already updated in Task 1.)\n\n**Acceptance Criteria:**\n- [ ] All `AcknowledgeAlarmRequest { \u2026 }` literals include `operator_password`/`operator_domain` (or `..Default::default()`).\n- [ ] `acknowledge_alarm` attaches the password via `MxAccessError::with_secrets` (or equivalent) so `{:?}` of a returned error lacks it.\n- [ ] CLI `--operator-domain`, `--operator-password`, `--operator-password-env` (default `MXGATEWAY_OPERATOR_PASSWORD`).\n- [ ] Stale README example (`--session-id`, `--alarm-reference`) fixed to `--reference \u2026 --operator \u2026`.\n- [ ] fmt/check/test/clippy clean.\n\n**Verify:** `cd clients/rust && cargo fmt --all --check && cargo check --workspace && cargo test --workspace && cargo clippy --workspace --all-targets -- -D warnings` \u2192 all succeed\n\nFull steps and code: docs/superpowers/plans/2026-10-19-ackmsg-as-operator.md (Task 10: Rust client and CLI).\n\n```json:metadata\n{\"files\": [\"clients/rust/src/client.rs\", \"clients/rust/crates/mxgw-cli/src/main.rs\", \"clients/rust/tests/client_behavior.rs\", \"clients/rust/README.md\", \"clients/rust/RustClientDesign.md\"], \"verifyCommand\": \"cd clients/rust && cargo fmt --all --check && cargo check --workspace && cargo test --workspace && cargo clippy --workspace --all-targets -- -D warnings\", \"acceptanceCriteria\": [\"All `AcknowledgeAlarmRequest { \\u2026 }` 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 \\u2026 --operator \\u2026`.\", \"fmt/check/test/clippy clean.\"], \"modelTier\": \"standard\"}\n```", + "blockedBy": [ + 0 + ] + }, + { + "id": 10, + "subject": "Task 11: Java client and CLI", + "status": "pending", + "description": "**Goal:** Regenerate Java bindings and add operator credential options to the Java CLI with redaction.\n\n**Files:**\n- Regenerate: `clients/java/src/main/generated/main/java/mxaccess_gateway/v1/MxaccessGateway.java` (`gradle :zb-mom-ww-mxgateway-client:generateProto`) \u2014 **commit it** (real proto change)\n- 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)\n- Test: `clients/java/zb-mom-ww-mxgateway-cli/src/test/java/com/zb/mom/ww/mxgateway/cli/MxGatewayCliTests.java` (:706, :758)\n\n**Acceptance Criteria:**\n- [ ] `acknowledgeAlarm` rethrows failures with `MxGatewaySecrets.redactExact(message, request.getOperatorPassword())` applied when the password is non-empty.\n- [ ] CLI `--operator-domain`, `--operator-password`, `--operator-password-env` (default `MXGATEWAY_OPERATOR_PASSWORD`, picocli `arity`/`interactive` not required).\n- [ ] `readmeDocumentedAcknowledgeAlarmExampleParsesCleanly` updated to the new README tokens and passes; `checkGeneratedClean` passes after commit.\n\n**Verify:** `cd clients/java && JAVA_HOME=/opt/homebrew/opt/openjdk@17 gradle test :zb-mom-ww-mxgateway-client:checkGeneratedClean` \u2192 BUILD SUCCESSFUL\n\nFull steps and code: docs/superpowers/plans/2026-10-19-ackmsg-as-operator.md (Task 11: Java client and CLI).\n\n```json:metadata\n{\"files\": [\"clients/java/src/main/generated/main/java/mxaccess_gateway/v1/MxaccessGateway.java\", \"clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/MxGatewayClient.java\", \"clients/java/zb-mom-ww-mxgateway-cli/src/main/java/com/zb/mom/ww/mxgateway/cli/MxGatewayCli.java\", \"clients/java/README.md\", \"clients/java/zb-mom-ww-mxgateway-cli/src/test/java/com/zb/mom/ww/mxgateway/cli/MxGatewayCliTests.java\"], \"verifyCommand\": \"cd clients/java && JAVA_HOME=/opt/homebrew/opt/openjdk@17 gradle test :zb-mom-ww-mxgateway-client:checkGeneratedClean\", \"acceptanceCriteria\": [\"`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.\"], \"modelTier\": \"standard\"}\n```", + "blockedBy": [ + 0 + ] + }, + { + "id": 11, + "subject": "Task 12: Documentation", + "status": "pending", + "description": "**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.\n\n**Files:**\n- 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` (\u00a72-\u00a74 :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)\n\n**Acceptance Criteria:**\n- [ ] 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).\n- [ ] 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.\n- [ ] GatewayConfiguration.md documents `MxGateway:Alarms:AckClientName` (default `MxGateway`, = Historian OperatorNode).\n- [ ] 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).\n- [ ] 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).\n\n**Verify:** `grep -rn \"MXGATEWAY_USE_MANAGED_ALARM_ACK\\|AaManagedAlarmAckConsumer\" docs gateway.md CLAUDE.md` \u2192 only lines inside dated historical sections; manual read of the edited sections.\n\nFull steps and code: docs/superpowers/plans/2026-10-19-ackmsg-as-operator.md (Task 12: Documentation).\n\n```json:metadata\n{\"files\": [\"gateway.md\", \"docs/Grpc.md\", \"docs/Contracts.md\", \"docs/DesignDecisions.md\", \"docs/AlarmClientDiscovery.md\", \"docs/AlarmProbeFindings.md\", \"docs/GatewayConfiguration.md\", \"docs/Diagnostics.md\", \"docs/Authorization.md\", \"CLAUDE.md\"], \"verifyCommand\": \"grep -rn \\\"MXGATEWAY_USE_MANAGED_ALARM_ACK\\\\|AaManagedAlarmAckConsumer\\\" docs gateway.md CLAUDE.md\", \"acceptanceCriteria\": [\"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).\"], \"modelTier\": \"standard\"}\n```", + "blockedBy": [ + 4, + 5 + ] + }, + { + "id": 12, + "subject": "Task 13: Full verification and live Historian check on wi...", + "status": "pending", + "description": "**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.\n\n> **USER-ORDERED GATE \u2014 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.\n\n**Files:**\n- No source changes (deploy only). Uses windev `C:\\build\\mxaccessgw-ci`, `C:\\publish\\mxaccessgw\\Server-YYYYMMDD`, `C:\\publish\\mxaccessgw\\Worker`, NSSM service `MxAccessGw`.\n\n**Acceptance Criteria:**\n- [ ] 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.\n- [ ] 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`.\n- [ ] With `TestMachine_001.TestAlarm001` raised (UNACK_ALM), `grpcurl \u2026 AcknowledgeAlarm {\"alarm_full_reference\":\"Galaxy!TestArea.TestMachine_001.TestAlarm001\",\"comment\":\"\",\"operator_user\":\"Administrator\",\"operator_password\":\"\"}` returns `PROTOCOL_STATUS_CODE_OK`, `hresult` 0.\n- [ ] `mxa read TestMachine_001.TestAlarm001.Acked` \u2192 `True`; `.AckMsg` = the unique comment.\n- [ ] `Runtime.dbo.v_AlarmEventHistory2` newest `ACK_ALM` row for `TestMachine_001.TestAlarm001` has `Operator = Administrator`, `DomainName = ArchestrA`, `OperatorNode = MxGateway`, `Description = `.\n- [ ] 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.\n- [ ] Rig restored: alarm cleared (`mxa write \u2026 false --secured -u Administrator`), scratch files removed.\n\n**Verify:** windev: `sqlcmd -S localhost -E -W -s \"|\" -i C:\\build\\hist-q.sql` (query below) \u2192 newest ACK_ALM row shows `Administrator|ArchestrA|\u2026|MxGateway|`\n\nFull steps and code: docs/superpowers/plans/2026-10-19-ackmsg-as-operator.md (Task 13: Full verification and live Historian check on windev).\n\n```json:metadata\n{\"files\": [], \"verifyCommand\": \"sqlcmd -S localhost -E -W -s \\\"|\\\" -i C:\\\\build\\\\hist-q.sql\", \"acceptanceCriteria\": [\"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 \\u2026 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` \\u2192 `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 \\u2026 false --secured -u Administrator`), scratch files removed.\"], \"modelTier\": \"frontier\", \"userGate\": true, \"tags\": [\"user-gate\"]}\n```", + "blockedBy": [ + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11 + ] + } + ], + "lastUpdated": "2026-10-19T20:43:58.922975Z" +} \ No newline at end of file