Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
112 KiB
AckMsg-as-Operator Alarm Acknowledgement Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development (recommended) or superpowers-extended-cc:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Make AcknowledgeAlarm acknowledge alarms by writing the comment to <TAGNAME>.AckMsg over MXAccess after authenticating as the requesting operator, so the AVEVA Historian events table records the real operator name, domain and full name (the way InTouch does).
Architecture: The public AcknowledgeAlarmRequest gains operator_password (credential) and operator_domain; the gateway forwards them on the worker ack commands. In the x86 worker a new AlarmAckWriter owns its own registered LMXProxyServer, authenticates the operator (AuthenticateUser), advises <item>.AckMsg, writes the comment with that user id and waits (bounded, pumped) for OnWriteComplete. Alarm consumers stop acking themselves and only resolve the ack item address (alarm-manager GUID → snapshot TAGNAME; by-name → tag; subtag → AckCommentSubtag). The dead wnwrap/managed alarm-client ack paths are removed.
Tech Stack: .NET 10 gateway, .NET Framework 4.8 x86 worker (no init-only props / positional records), protobuf/gRPC (Grpc.Tools 2.80.0), ArchestrA MXAccess COM (LMXProxyServerClass), xUnit; clients in .NET, Python, Go, Rust, Java.
Spec: this document's Design section (below) plus the live evidence in memory project_managed_alarm_ack_live_result.md and docs/AlarmClientDiscovery.md §4.
Global Constraints
- Field numbers (exact):
AcknowledgeAlarmRequest.operator_password = 6,AcknowledgeAlarmRequest.operator_domain = 7,AcknowledgeAlarmCommand.operator_password = 7,AcknowledgeAlarmByNameCommand.operator_password = 9. Additive only;GatewayProtocolVersionstays3. - Credential handling:
operator_passwordmust never reach logs, metrics labels, command lines,DiagnosticMessage,ProtocolStatus.Message, exception messages, orToString()/Debugoutput that is logged. Mirror the existingAuthenticateUserCommand.verify_user_passwordcomment and tests (DispatchAsync_AuthenticateUser_DoesNotLeakPassword). - Ack item address:
"{TagName}.AckMsg"for Galaxy-provider alarm-manager alarms (constantAlarmAckWriter.AckCommentAttribute = "AckMsg"); subtag mode uses the target'sAckCommentSubtagverbatim. Alarms whose provider is notGalaxy(case-insensitive) are rejected as not ackable. - Verify user:
operator_domainempty →AuthenticateUser(operator_user, operator_password); non-empty →AuthenticateUser($"{operator_domain}\\{operator_user}", operator_password). - operator_user is required on
AcknowledgeAlarmRequest(gateway returnsInvalidArgumentwhen 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 optionMxGateway:Alarms:AckClientName, default"MxGateway", conveyed to workers as envMXGATEWAY_ALARM_ACK_CLIENT_NAME. - Reply semantics:
Hresult/AcknowledgeAlarm.NativeStatus= 0 on applied or unconfirmed-but-sent; non-zero =AuthenticateUserHRESULT or the write statusdetail.reply.Statusescarries theOnWriteCompleteMxStatusProxyrows when observed. - net48 worker: no
initaccessors, 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, Goclients/go/internal/generated/*.pb.go, Pythonclients/python/src/zb_mom_ww_mxgateway/generated/*_pb2*.py(regenerate with grpcio-tools 1.80.0 only), Javaclients/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-operatoroffmain. 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
AckMsgwrite 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_ALMrow whoseOperatoris the requesting operator. - "yes operators have galaxy accounts" — authenticating each operator with their own Galaxy credentials is acceptable.
- "dont forget to verify the historian is logging the event with the username captured" — live Historian verification is mandatory.
- Earlier (2026-10-18): remove the dead managed alarm-ack path from PR #160 was recommended; this plan removes it because the new path supersedes it.
Design
Why this route. Live tests (2026-10-18/19, windev rig) showed every alarm-client ack (wnwrap AlarmAckByName/AlarmAckByGUID/AlarmAck/AlarmAckSelected, managed AlarmClient.*, native WNAL UserRequest) returns 0 and is silently dropped for a headless consumer, regardless of security classification, operator name or consumer identity. Writing <Object>.<Alarm>.AckMsg over MXAccess does acknowledge, and when the write is authenticated (AuthenticateUser + Write(value, userId)) the Historian Runtime.dbo.v_AlarmEventHistory2 ACK_ALM row carries Operator, DomainName, UserFullName from the authenticated account and OperatorNode = the MXAccess client name.
Data flow.
Client AcknowledgeAlarmRequest{alarm_full_reference, comment, operator_user, operator_password, operator_domain}
→ MxAccessGatewayService.AcknowledgeAlarm (validates reference + operator_user)
→ GatewayAlarmMonitor.AcknowledgeAsync → BuildAcknowledgeCommand
GUID ref → AcknowledgeAlarmCommand{alarm_guid, comment, operator_user, operator_domain, operator_password}
P!G.T ref → AcknowledgeAlarmByNameCommand{alarm_name, provider_name, group_name, comment, operator_user, operator_domain, operator_password}
→ worker MxAccessCommandExecutor.ExecuteAcknowledgeAlarm[ByName]
→ AlarmCommandHandler.Acknowledge[ByName](…, AlarmAckOperator) → AlarmDispatcher
→ consumer.ResolveAckTarget(guid) / ResolveAckTargetByName(name, provider, group) → AlarmAckTarget{ItemAddress}
→ AlarmAckWriter.Acknowledge(itemAddress, comment, operator) → AlarmAckResult
→ executor maps AlarmAckResult → MxCommandReply (status, hresult, native_status, statuses, diagnostic)
New worker types (namespace ZB.MOM.WW.MxGateway.Worker.MxAccess):
AlarmAckOperator—UserName,Domain,Password(get/set;ToString()overridden to omit the password);VerifyUsercomputed.AlarmAckTarget—Found,ItemAddress,Outcome(AlarmAckOutcome.AlarmNotFound/NotAckablewhen not found),Reason; static factoriesFor(item),NotFound(reason),NotAckable(reason).AlarmAckOutcomeenum —Applied, Unconfirmed, AlarmNotFound, NotAckable, AuthenticationFailed, WriteRejected.AlarmAckResult—Outcome,NativeStatus,Diagnostic,Statuses(IReadOnlyList<MxStatusProxy>); static factories.IAlarmAckWriter : IDisposable—AlarmAckResult Acknowledge(string itemAddress, string comment, AlarmAckOperator ackOperator).AlarmAckWriter— production implementation over its ownLMXProxyServerClass.
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:
AcknowledgeAlarmRequesthasoperator_password = 6andoperator_domain = 7;AcknowledgeAlarmCommandhasoperator_password = 7;AcknowledgeAlarmByNameCommandhasoperator_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 -Checkpasses andClientProtoInputTestspasses.- Rust vendored proto is byte-identical to the Contracts proto.
Verify: dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter "FullyQualifiedName~ProtobufContractRoundTripTests|FullyQualifiedName~ClientProtoInputTests" → all pass
Steps:
- Step 1: Create the branch
git checkout main && git pull --ff-only origin main && git checkout -b feat/ackmsg-as-operator
- Step 2: Write failing round-trip tests — in
ProtobufContractRoundTripTests.cs, extendAcknowledgeAlarmRequest_RoundTripsAllFieldsand add command round-trips:
[Fact]
public void AcknowledgeAlarmRequest_RoundTripsAllFields()
{
AcknowledgeAlarmRequest original = new()
{
ClientCorrelationId = "corr-1",
AlarmFullReference = "Galaxy!TestArea.TestMachine_001.TestAlarm001",
Comment = "investigating",
OperatorUser = "alice",
OperatorPassword = "s3cret",
OperatorDomain = "CORP",
};
AcknowledgeAlarmRequest parsed = AcknowledgeAlarmRequest.Parser.ParseFrom(original.ToByteArray());
Assert.Equal(original, parsed);
Assert.Equal("s3cret", parsed.OperatorPassword);
Assert.Equal("CORP", parsed.OperatorDomain);
}
[Fact]
public void AcknowledgeAlarmCommands_RoundTripOperatorPassword()
{
MxCommand byGuid = new()
{
Kind = MxCommandKind.AcknowledgeAlarm,
AcknowledgeAlarmCommand = new AcknowledgeAlarmCommand
{
AlarmGuid = "BCC47053-9542-4D65-BDAA-BCDEA6A32A73",
OperatorUser = "alice",
OperatorDomain = "CORP",
OperatorPassword = "s3cret",
},
};
MxCommand byName = new()
{
Kind = MxCommandKind.AcknowledgeAlarmByName,
AcknowledgeAlarmByNameCommand = new AcknowledgeAlarmByNameCommand
{
AlarmName = "TestMachine_001.TestAlarm001",
ProviderName = "Galaxy",
GroupName = "TestArea",
OperatorUser = "alice",
OperatorPassword = "s3cret",
},
};
Assert.Equal("s3cret", MxCommand.Parser.ParseFrom(byGuid.ToByteArray()).AcknowledgeAlarmCommand.OperatorPassword);
Assert.Equal("s3cret", MxCommand.Parser.ParseFrom(byName.ToByteArray()).AcknowledgeAlarmByNameCommand.OperatorPassword);
}
-
Step 3: Run to verify they fail to compile —
dotnet build src/ZB.MOM.WW.MxGateway.Tests→ CS1061OperatorPasswordnot found. -
Step 4: Edit the proto. In
AcknowledgeAlarmCommandreplace the leading comment and append field 7:
// Acknowledge a single alarm by its GUID. The worker resolves the GUID to the
// alarm's Galaxy reference from its active-alarm snapshot and acknowledges it
// by writing `comment` to `<TAGNAME>.AckMsg` over MXAccess after
// AuthenticateUser as the operator, so the alarm history records the real
// operator. The reply's hresult / native_status is 0 when the write was
// applied (or sent but unconfirmed), otherwise the AuthenticateUser HRESULT or
// the OnWriteComplete status detail.
message AcknowledgeAlarmCommand {
// Canonical 8-4-4-4-12 GUID string (e.g. "BCC47053-9542-4D65-BDAA-BCDEA6A32A73").
string alarm_guid = 1;
string comment = 2;
string operator_user = 3;
string operator_node = 4;
string operator_domain = 5;
string operator_full_name = 6;
// Operator credential used for AuthenticateUser before the AckMsg write.
// Implementations must keep this field out of logs, metrics labels, command
// lines, and diagnostics.
string operator_password = 7;
}
In AcknowledgeAlarmByNameCommand replace the leading comment's last two lines ("The worker invokes wwAlarmConsumerClass.AlarmAckByName which reaches the same alarm history path as AlarmAckByGUID.") with "The worker acknowledges by writing comment to <alarm_name>.AckMsg (Galaxy provider only) after AuthenticateUser as the operator." and append:
// Operator credential used for AuthenticateUser before the AckMsg write.
// Implementations must keep this field out of logs, metrics labels, command
// lines, and diagnostics.
string operator_password = 9;
In the AcknowledgeAlarmReplyPayload comment replace "Surfaces AVEVA's native ack return code (AlarmAckByGUID for the GUID arm, AlarmAckByName for the by-name arm); 0 means success." with "Surfaces the ack outcome: 0 when the AckMsg write was applied or sent but unconfirmed; otherwise the AuthenticateUser HRESULT or the OnWriteComplete status detail." and replace "the gateway-side WorkerAlarmRpcDispatcher" with "GatewayAlarmMonitor".
In AcknowledgeAlarmRequest append after operator_user = 5:
// Operator's Galaxy (or OS) password. The gateway authenticates as
// operator_user with it before writing the acknowledgement so the alarm
// history records the real operator. May be empty for accounts without a
// password. Credential-sensitive: implementations must keep this field out
// of logs, metrics labels, command lines, and diagnostics.
string operator_password = 6;
// Optional domain for OS-authenticated galaxies. When set the gateway
// authenticates as "<operator_domain>\<operator_user>".
string operator_domain = 7;
Also update the AcknowledgeAlarmReply.hresult comment: replace "= AlarmAckByName / AlarmAckByGUID return code; 0 = success); the gateway's WorkerAlarmRpcDispatcher copies" with "0 = applied or sent; otherwise the AuthenticateUser HRESULT or OnWriteComplete detail); GatewayAlarmMonitor copies".
- Step 5: Add the env name in
WorkerLaunchEnvironment.csafterMaxAlarmsPerFetch:
/// <summary>
/// MXAccess client name the worker's alarm-ack writer registers with. The
/// Historian records it as the acknowledgement's OperatorNode.
/// </summary>
public const string AlarmAckClientName = "MXGATEWAY_ALARM_ACK_CLIENT_NAME";
- Step 6: Regenerate and copy
rm src/ZB.MOM.WW.MxGateway.Contracts/Generated/*.cs
dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj
pwsh -File scripts/publish-client-proto-inputs.ps1
cp src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto clients/rust/protos/mxaccess_gateway.proto
pwsh -File scripts/publish-client-proto-inputs.ps1 -Check
-
Step 7: Run tests — the Verify command → PASS.
-
Step 8: Commit
git add src/ZB.MOM.WW.MxGateway.Contracts clients/proto/descriptors clients/rust/protos src/ZB.MOM.WW.MxGateway.Tests/Contracts/ProtobufContractRoundTripTests.cs
git commit -m "feat(contracts): carry operator credentials on alarm acknowledgement" -m "Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT"
Task 2: Parse wnwrap ACK_* fields and attribute ack transitions
Goal: Capture the ACK_COMMENT/ACK_OPERATOR_NAME/ACK_DOMAIN_NAME/ACK_OPERATOR_NODE elements on snapshot records and use them for Acknowledge transitions and acked snapshot rows.
Files:
- Modify:
src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAlarmSnapshot.cs - Modify:
src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs(ParseSnapshotXml~739-849) - Modify:
src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs(OnTransition~235-260,MapToSnapshot~273-274) - Test:
src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/WnWrapAlarmConsumerXmlTests.cs,AlarmDispatcherTests.cs
Acceptance Criteria:
ParseSnapshotXmlfillsAckComment,AckOperatorName,AckOperatorDomain,AckOperatorNodefrom the live XML sample below.- An
UnackAlm → AckAlmtransition whose record hasAckOperatorName="alice",AckComment="checked"enqueues anOnAlarmTransitionEventwithOperatorUser == "alice"andOperatorComment == "checked". - Non-ack transitions keep
OperatorUser = OperatorName,OperatorComment = AlarmComment.
Verify: dotnet test src/ZB.MOM.WW.MxGateway.Worker.Tests/ZB.MOM.WW.MxGateway.Worker.Tests.csproj -p:Platform=x86 --filter "FullyQualifiedName~WnWrapAlarmConsumerXmlTests|FullyQualifiedName~AlarmDispatcherTests" (windev) → pass
Steps:
- Step 1: Failing parser test in
WnWrapAlarmConsumerXmlTests.cs:
[Fact]
public void ParseSnapshotXml_WithAckElements_PopulatesAckFields()
{
const string xml =
"<?xml version=\"1.0\"?><ALARM_RECORDS COUNT=\"1\"><ALARM><GUID>2D8A9BE88FE04B0F8D996BB4D56620C7</GUID>" +
"<DATE>2026/10/19</DATE><TIME>18:2:53.812</TIME><GMTOFFSET>240</GMTOFFSET><DSTADJUST>0</DSTADJUST>" +
"<PROVIDER_NODE>DESKTOP-6JL3KKO</PROVIDER_NODE><PROVIDER_NAME>Galaxy</PROVIDER_NAME><GROUP>TestArea</GROUP>" +
"<TAGNAME>TestMachine_001.TestAlarm001</TAGNAME><TYPE>DSC</TYPE><VALUE>true</VALUE><LIMIT>true</LIMIT>" +
"<PRIORITY>500</PRIORITY><STATE>ACK_ALM</STATE><OPERATOR_NODE></OPERATOR_NODE><OPERATOR_NAME></OPERATOR_NAME>" +
"<ACK_DOMAIN_NAME>ArchestrA</ACK_DOMAIN_NAME><ACK_OPERATOR_NODE>mxa</ACK_OPERATOR_NODE>" +
"<ACK_OPERATOR_NAME>Administrator</ACK_OPERATOR_NAME><ALARM_COMMENT>Test alarm #1</ALARM_COMMENT>" +
"<ACK_COMMENT>mgack-authwrite 180714</ACK_COMMENT></ALARM></ALARM_RECORDS>";
Dictionary<Guid, MxAlarmSnapshotRecord> records = WnWrapAlarmConsumer.ParseSnapshotXml(xml);
MxAlarmSnapshotRecord record = Assert.Single(records.Values);
Assert.Equal("Administrator", record.AckOperatorName);
Assert.Equal("ArchestrA", record.AckOperatorDomain);
Assert.Equal("mxa", record.AckOperatorNode);
Assert.Equal("mgack-authwrite 180714", record.AckComment);
Assert.Equal("Test alarm #1", record.AlarmComment);
}
- Step 2: Failing dispatcher test in
AlarmDispatcherTests.cs(reuses the file'sFakeAlarmConsumer):
[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(afterAlarmComment):
/// <summary>Gets or sets the acknowledgement comment (wnwrap <c>ACK_COMMENT</c>).</summary>
public string AckComment { get; set; } = string.Empty;
/// <summary>Gets or sets the acknowledging operator's name (wnwrap <c>ACK_OPERATOR_NAME</c>).</summary>
public string AckOperatorName { get; set; } = string.Empty;
/// <summary>Gets or sets the acknowledging operator's domain (wnwrap <c>ACK_DOMAIN_NAME</c>).</summary>
public string AckOperatorDomain { get; set; } = string.Empty;
/// <summary>Gets or sets the acknowledging node, i.e. the MXAccess client name (wnwrap <c>ACK_OPERATOR_NODE</c>).</summary>
public string AckOperatorNode { get; set; } = string.Empty;
- Step 4: Parse them in
ParseSnapshotXml: declarestring? ackComment = null; string? ackOperatorName = null; string? ackOperatorDomain = null; string? ackOperatorNode = null;next toalarmComment; add switch arms
case "ACK_COMMENT": ackComment ??= child.InnerText; break;
case "ACK_OPERATOR_NAME": ackOperatorName ??= child.InnerText; break;
case "ACK_DOMAIN_NAME": ackOperatorDomain ??= child.InnerText; break;
case "ACK_OPERATOR_NODE": ackOperatorNode ??= child.InnerText; break;
and initializers AckComment = ackComment ?? string.Empty, AckOperatorName = ackOperatorName ?? string.Empty, AckOperatorDomain = ackOperatorDomain ?? string.Empty, AckOperatorNode = ackOperatorNode ?? string.Empty,.
- Step 5: Use them in
AlarmDispatcher. Add helpers and use them inOnTransitionandMapToSnapshot:
private static bool HasAckAttribution(MxAlarmSnapshotRecord record) =>
!string.IsNullOrEmpty(record.AckOperatorName) || !string.IsNullOrEmpty(record.AckComment);
private static bool IsAcked(MxAlarmStateKind state) =>
state == MxAlarmStateKind.AckAlm || state == MxAlarmStateKind.AckRtn;
In OnTransition replace operatorUser: record.OperatorName, operatorComment: record.AlarmComment, with:
bool useAck = kind == AlarmTransitionKind.Acknowledge && HasAckAttribution(record);
...
operatorUser: useAck ? record.AckOperatorName : record.OperatorName,
operatorComment: useAck ? record.AckComment : record.AlarmComment,
In MapToSnapshot use IsAcked(record.State) && HasAckAttribution(record) the same way. (Check MxAlarmStateKind member names in AlarmRecordTransitionMapper.ParseStateKind; use the exact acked members it defines.)
- Step 6: Commit after the Verify command passes on windev (or after Task 5 when verifying the worker together):
git add src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAlarmSnapshot.cs src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess
git commit -m "feat(worker): attribute alarm ack transitions from wnwrap ACK fields" -m "Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT"
Task 3: AlarmAckWriter (authenticated AckMsg write)
Goal: Add the ack value types and AlarmAckWriter, which authenticates the operator on its own registered MXAccess server, writes the comment to the ack item, and waits for OnWriteComplete.
Files:
- Create:
src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckOperator.cs,AlarmAckOutcome.cs,AlarmAckTarget.cs,AlarmAckResult.cs,IAlarmAckWriter.cs,AlarmAckWriter.cs - Test:
src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmAckWriterTests.cs
Acceptance Criteria:
- With a fake
IMxAccessServerreturning user id 5:Acknowledge("T.A.AckMsg", "c", {alice, CORP, pw})callsRegister(clientName)once,AuthenticateUser(h, "CORP\\alice", "pw"),AddItem("T.A.AckMsg"),Advise,Write(h, item, "c", 5)in that order; a recordedOnWriteCompletewith category Ok yieldsOutcome == Applied,NativeStatus == 0. - Completion with category
SecurityError, detail 1008 →WriteRejected,NativeStatus == 1008, statuses returned. - No completion before the timeout →
Unconfirmed,NativeStatus == 0. AuthenticateUserthrowingCOMException(hr)or returning0→AuthenticationFailed, no write; the password never appears inDiagnosticor in the fake's operation log.- A second ack to the same item reuses the item handle (one
AddItem, oneAdvise). AlarmAckOperator.ToString()does not contain the password.
Verify: dotnet test src/ZB.MOM.WW.MxGateway.Worker.Tests/ZB.MOM.WW.MxGateway.Worker.Tests.csproj -p:Platform=x86 --filter "FullyQualifiedName~AlarmAckWriterTests" (windev) → pass
Steps:
- Step 1: Value types.
AlarmAckOperator.cs:
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>
/// Operator identity an alarm acknowledgement authenticates as. The password
/// is a raw credential: it is passed to MXAccess <c>AuthenticateUser</c> only
/// and must never be logged or echoed.
/// </summary>
public sealed class AlarmAckOperator
{
/// <summary>Gets or sets the operator user name.</summary>
public string UserName { get; set; } = string.Empty;
/// <summary>Gets or sets the optional domain for OS-authenticated galaxies.</summary>
public string Domain { get; set; } = string.Empty;
/// <summary>Gets or sets the operator password (credential-sensitive).</summary>
public string Password { get; set; } = string.Empty;
/// <summary>Gets the user string passed to <c>AuthenticateUser</c>: <c>Domain\UserName</c> when a domain is set.</summary>
public string VerifyUser => string.IsNullOrEmpty(Domain) ? UserName : Domain + "\\" + UserName;
/// <inheritdoc />
public override string ToString() => "AlarmAckOperator(" + VerifyUser + ")";
}
AlarmAckOutcome.cs:
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>Outcome of an alarm acknowledgement attempt.</summary>
public enum AlarmAckOutcome
{
/// <summary>The AckMsg write completed with an OK status.</summary>
Applied,
/// <summary>The write was sent but no OnWriteComplete arrived before the timeout.</summary>
Unconfirmed,
/// <summary>The alarm is not in the consumer's current view.</summary>
AlarmNotFound,
/// <summary>The alarm exists but has no MXAccess ack attribute (non-Galaxy provider or no ack subtag).</summary>
NotAckable,
/// <summary>AuthenticateUser rejected the operator credentials.</summary>
AuthenticationFailed,
/// <summary>The AckMsg write completed with a non-OK status.</summary>
WriteRejected,
}
AlarmAckTarget.cs:
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>The MXAccess item an acknowledgement writes, or why none exists.</summary>
public sealed class AlarmAckTarget
{
private AlarmAckTarget(string itemAddress, AlarmAckOutcome failure, string reason)
{
ItemAddress = itemAddress;
Failure = failure;
Reason = reason;
}
/// <summary>Gets a value indicating whether an ack item was resolved.</summary>
public bool Found => ItemAddress.Length > 0;
/// <summary>Gets the ack item address (e.g. <c>TestMachine_001.TestAlarm001.AckMsg</c>), empty when not found.</summary>
public string ItemAddress { get; }
/// <summary>Gets the failure outcome when <see cref="Found"/> is false.</summary>
public AlarmAckOutcome Failure { get; }
/// <summary>Gets a human-readable reason when <see cref="Found"/> is false.</summary>
public string Reason { get; }
/// <summary>Creates a resolved target.</summary>
/// <param name="itemAddress">The ack item address.</param>
/// <returns>The target.</returns>
public static AlarmAckTarget For(string itemAddress) =>
new AlarmAckTarget(itemAddress ?? string.Empty, AlarmAckOutcome.Applied, string.Empty);
/// <summary>Creates a not-found result.</summary>
/// <param name="reason">Why the alarm was not found.</param>
/// <returns>The target.</returns>
public static AlarmAckTarget NotFound(string reason) =>
new AlarmAckTarget(string.Empty, AlarmAckOutcome.AlarmNotFound, reason);
/// <summary>Creates a not-ackable result.</summary>
/// <param name="reason">Why the alarm cannot be acknowledged over MXAccess.</param>
/// <returns>The target.</returns>
public static AlarmAckTarget NotAckable(string reason) =>
new AlarmAckTarget(string.Empty, AlarmAckOutcome.NotAckable, reason);
}
AlarmAckResult.cs:
using System.Collections.Generic;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>Result of an alarm acknowledgement.</summary>
public sealed class AlarmAckResult
{
private static readonly IReadOnlyList<MxStatusProxy> NoStatuses = new MxStatusProxy[0];
private AlarmAckResult(AlarmAckOutcome outcome, int nativeStatus, string diagnostic, IReadOnlyList<MxStatusProxy>? statuses)
{
Outcome = outcome;
NativeStatus = nativeStatus;
Diagnostic = diagnostic;
Statuses = statuses ?? NoStatuses;
}
/// <summary>Gets the outcome.</summary>
public AlarmAckOutcome Outcome { get; }
/// <summary>Gets the native status: 0 for applied/unconfirmed, else the HRESULT or status detail.</summary>
public int NativeStatus { get; }
/// <summary>Gets a diagnostic message. Never contains credentials.</summary>
public string Diagnostic { get; }
/// <summary>Gets the OnWriteComplete statuses, empty when none was observed.</summary>
public IReadOnlyList<MxStatusProxy> Statuses { get; }
/// <summary>Creates a result.</summary>
/// <param name="outcome">The outcome.</param>
/// <param name="nativeStatus">The native status.</param>
/// <param name="diagnostic">A credential-free diagnostic.</param>
/// <param name="statuses">Observed write statuses.</param>
/// <returns>The result.</returns>
public static AlarmAckResult Create(
AlarmAckOutcome outcome,
int nativeStatus,
string diagnostic,
IReadOnlyList<MxStatusProxy>? statuses = null) =>
new AlarmAckResult(outcome, nativeStatus, diagnostic ?? string.Empty, statuses);
/// <summary>Creates the result for an unresolved ack target.</summary>
/// <param name="target">The unresolved target.</param>
/// <returns>The result.</returns>
public static AlarmAckResult FromTarget(AlarmAckTarget target) =>
new AlarmAckResult(target.Failure, -1, target.Reason, null);
}
IAlarmAckWriter.cs:
using System;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>
/// Acknowledges an alarm by writing its MXAccess ack item (<c>AckMsg</c>)
/// after authenticating as the operator. Must be called on the worker STA.
/// </summary>
public interface IAlarmAckWriter : IDisposable
{
/// <summary>Authenticates <paramref name="ackOperator"/> and writes <paramref name="comment"/> to <paramref name="itemAddress"/>.</summary>
/// <param name="itemAddress">The ack item, e.g. <c>Obj.Alarm.AckMsg</c>.</param>
/// <param name="comment">The acknowledgement comment.</param>
/// <param name="ackOperator">The operator to authenticate as.</param>
/// <returns>The outcome.</returns>
AlarmAckResult Acknowledge(string itemAddress, string comment, AlarmAckOperator ackOperator);
}
- Step 2: Failing tests
AlarmAckWriterTests.cs. Build aRecordingAckServer : IMxAccessServermodelled onLmxSubtagAlarmSourceTests.RecordingMxAccessServer(copy that class and change:Registerrecords the client name and returns 7;AuthenticateUserrecordsverifyUser(NOT the password) intoOperations, stores the password inLastPassword, returnsAuthenticateResultor throwsAuthenticateException;Writerecords(itemHandle, value, userId)and invokesOnWrite). Tests construct the writer through the internal seamnew AlarmAckWriter(server, completionCache, pumpStep: () => { }, timeout):
public sealed class AlarmAckWriterTests
{
private static AlarmAckOperator Alice() =>
new AlarmAckOperator { UserName = "alice", Domain = "CORP", Password = "pw-secret" };
private static RepeatedField<MxStatusProxy> Rows(MxStatusCategory category, int detail) =>
new RepeatedField<MxStatusProxy> { new MxStatusProxy { Success = category == MxStatusCategory.Ok ? 1 : 0, Category = category, Detail = detail } };
[Fact]
public void Acknowledge_AuthenticatesThenWritesCommentWithOperatorUserId()
{
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 };
MxAccessWriteCompletionCache cache = new MxAccessWriteCompletionCache();
server.OnWrite = (handle, item) => cache.Record(handle, item, Rows(MxStatusCategory.Ok, 0));
using AlarmAckWriter writer = new AlarmAckWriter(server, cache, () => { }, TimeSpan.FromSeconds(1), "MxGateway");
AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "checked", Alice());
Assert.Equal(AlarmAckOutcome.Applied, result.Outcome);
Assert.Equal(0, result.NativeStatus);
Assert.Equal(new[] { "Register:MxGateway", "AuthenticateUser:CORP\\alice", "AddItem:T.A.AckMsg", "Advise", "Write:checked:5" }, server.Operations);
Assert.Equal("pw-secret", server.LastPassword);
}
[Fact]
public void Acknowledge_WhenWriteRejected_ReturnsDetailAndStatuses()
{
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 };
MxAccessWriteCompletionCache cache = new MxAccessWriteCompletionCache();
server.OnWrite = (handle, item) => cache.Record(handle, item, Rows(MxStatusCategory.SecurityError, 1008));
using AlarmAckWriter writer = new AlarmAckWriter(server, cache, () => { }, TimeSpan.FromSeconds(1), "MxGateway");
AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "checked", Alice());
Assert.Equal(AlarmAckOutcome.WriteRejected, result.Outcome);
Assert.Equal(1008, result.NativeStatus);
Assert.Single(result.Statuses);
}
[Fact]
public void Acknowledge_WhenNoCompletion_ReturnsUnconfirmed()
{
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 };
using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(50), "MxGateway");
AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "checked", Alice());
Assert.Equal(AlarmAckOutcome.Unconfirmed, result.Outcome);
Assert.Equal(0, result.NativeStatus);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public void Acknowledge_WhenAuthenticationFails_DoesNotWriteOrLeakPassword(bool throwComException)
{
RecordingAckServer server = new RecordingAckServer
{
AuthenticateResult = 0,
AuthenticateException = throwComException ? new COMException("bad credentials", unchecked((int)0x80070005)) : null,
};
using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(50), "MxGateway");
AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "checked", Alice());
Assert.Equal(AlarmAckOutcome.AuthenticationFailed, result.Outcome);
Assert.NotEqual(0, result.NativeStatus);
Assert.DoesNotContain(server.Operations, op => op.StartsWith("Write", StringComparison.Ordinal));
Assert.DoesNotContain("pw-secret", result.Diagnostic, StringComparison.Ordinal);
Assert.DoesNotContain(server.Operations, op => op.Contains("pw-secret"));
}
[Fact]
public void Acknowledge_Twice_ReusesItemHandle()
{
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 };
using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(10), "MxGateway");
writer.Acknowledge("T.A.AckMsg", "one", Alice());
writer.Acknowledge("T.A.AckMsg", "two", Alice());
Assert.Equal(1, server.Operations.Count(op => op.StartsWith("AddItem", StringComparison.Ordinal)));
Assert.Equal(1, server.Operations.Count(op => op == "Advise"));
}
[Fact]
public void AlarmAckOperator_ToString_OmitsPassword()
{
Assert.DoesNotContain("pw-secret", Alice().ToString(), StringComparison.Ordinal);
}
}
- Step 3: Implement
AlarmAckWriter.cs.
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using ArchestrA.MxAccess;
using Google.Protobuf.Collections;
using ZB.MOM.WW.MxGateway.Contracts;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Worker.Conversion;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>
/// Acknowledges alarms the way the AVEVA runtime records operator identity:
/// AuthenticateUser as the operator, then write the comment to the alarm's
/// <c>AckMsg</c> attribute with that user id. Owns its own
/// <c>LMXProxyServerClass</c> registered under the configured client name,
/// which the Historian records as the acknowledgement's OperatorNode.
/// Apartment-threaded: every call must run on the worker STA. The password
/// is passed to MXAccess only; it never reaches diagnostics.
/// </summary>
public sealed class AlarmAckWriter : IAlarmAckWriter
{
/// <summary>The ArchestrA alarm-primitive attribute whose write acknowledges the alarm.</summary>
public const string AckCommentAttribute = "AckMsg";
/// <summary>Default MXAccess client name when none is configured.</summary>
public const string DefaultClientName = "MxGateway";
private readonly IMxAccessComObjectFactory? factory;
private readonly MxAccessWriteCompletionCache completionCache;
private readonly Action pumpStep;
private readonly TimeSpan completionTimeout;
private readonly string clientName;
private readonly MxStatusProxyConverter statusConverter = new MxStatusProxyConverter();
private readonly Dictionary<string, int> itemHandles = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
private object? comObject;
private LMXProxyServerClass? comEventSource;
private IMxAccessServer? server;
private int serverHandle;
private bool disposed;
/// <summary>Creates a writer that registers its own MXAccess server on first use.</summary>
/// <param name="factory">Creates the <c>LMXProxyServerClass</c>.</param>
/// <param name="pumpStep">Drains the STA message queue (OnWriteComplete is delivered by pumping).</param>
/// <param name="completionTimeout">Bounded wait for OnWriteComplete.</param>
/// <param name="clientName">MXAccess client name (Historian OperatorNode).</param>
public AlarmAckWriter(
IMxAccessComObjectFactory factory,
Action pumpStep,
TimeSpan completionTimeout,
string? clientName)
{
this.factory = factory ?? throw new ArgumentNullException(nameof(factory));
this.pumpStep = pumpStep ?? throw new ArgumentNullException(nameof(pumpStep));
this.completionTimeout = completionTimeout;
this.clientName = string.IsNullOrWhiteSpace(clientName) ? DefaultClientName : clientName!;
completionCache = new MxAccessWriteCompletionCache();
}
/// <summary>Test seam: uses an already-constructed server and completion cache.</summary>
internal AlarmAckWriter(
IMxAccessServer server,
MxAccessWriteCompletionCache completionCache,
Action pumpStep,
TimeSpan completionTimeout,
string clientName)
{
this.server = server ?? throw new ArgumentNullException(nameof(server));
this.completionCache = completionCache ?? throw new ArgumentNullException(nameof(completionCache));
this.pumpStep = pumpStep ?? throw new ArgumentNullException(nameof(pumpStep));
this.completionTimeout = completionTimeout;
this.clientName = clientName;
serverHandle = server.Register(clientName);
}
/// <summary>Resolves the configured client name from the launcher environment.</summary>
/// <returns>The client name, or <see cref="DefaultClientName"/>.</returns>
public static string ResolveClientName()
{
string? value = Environment.GetEnvironmentVariable(WorkerLaunchEnvironment.AlarmAckClientName);
return string.IsNullOrWhiteSpace(value) ? DefaultClientName : value!.Trim();
}
/// <inheritdoc />
public AlarmAckResult Acknowledge(string itemAddress, string comment, AlarmAckOperator ackOperator)
{
if (disposed) throw new ObjectDisposedException(nameof(AlarmAckWriter));
if (string.IsNullOrWhiteSpace(itemAddress)) throw new ArgumentException("Ack item address is required.", nameof(itemAddress));
if (ackOperator is null) throw new ArgumentNullException(nameof(ackOperator));
IMxAccessServer mx = EnsureRegistered();
int userId;
try
{
userId = mx.AuthenticateUser(serverHandle, ackOperator.VerifyUser, ackOperator.Password ?? string.Empty);
}
catch (COMException ex)
{
return AlarmAckResult.Create(
AlarmAckOutcome.AuthenticationFailed,
ex.HResult,
$"Operator authentication failed for '{ackOperator.VerifyUser}' (HRESULT 0x{ex.HResult:X8}).");
}
if (userId == 0)
{
return AlarmAckResult.Create(
AlarmAckOutcome.AuthenticationFailed,
-1,
$"Operator authentication failed for '{ackOperator.VerifyUser}' (no user id returned).");
}
if (!itemHandles.TryGetValue(itemAddress, out int itemHandle))
{
itemHandle = mx.AddItem(serverHandle, itemAddress);
// MXAccess rejects writes to added-but-unadvised items with E_INVALIDARG.
mx.Advise(serverHandle, itemHandle);
itemHandles[itemAddress] = itemHandle;
}
pumpStep();
ulong baseline = completionCache.CurrentVersion(serverHandle, itemHandle);
mx.Write(serverHandle, itemHandle, comment ?? string.Empty, userId);
if (!completionCache.TryWaitForCompletion(
serverHandle,
itemHandle,
baseline,
DateTime.UtcNow + completionTimeout,
pumpStep,
out RepeatedField<MxStatusProxy> statuses))
{
return AlarmAckResult.Create(
AlarmAckOutcome.Unconfirmed,
0,
$"AckMsg write to '{itemAddress}' sent; OnWriteComplete not observed within {completionTimeout.TotalMilliseconds:F0} ms.");
}
foreach (MxStatusProxy status in statuses)
{
if (status.Category != MxStatusCategory.Ok)
{
return AlarmAckResult.Create(
AlarmAckOutcome.WriteRejected,
status.Detail != 0 ? status.Detail : -1,
$"AckMsg write to '{itemAddress}' rejected: category {status.Category}, detail {status.Detail}.",
statuses);
}
}
return AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, string.Empty, statuses);
}
/// <inheritdoc />
public void Dispose()
{
if (disposed) return;
disposed = true;
if (comEventSource is not null)
{
try { comEventSource.OnWriteComplete -= OnWriteComplete; } catch { /* best effort */ }
}
IMxAccessServer? mx = server;
if (mx is not null)
{
foreach (int itemHandle in itemHandles.Values)
{
try { mx.UnAdvise(serverHandle, itemHandle); } catch { /* best effort */ }
try { mx.RemoveItem(serverHandle, itemHandle); } catch { /* best effort */ }
}
try { mx.Unregister(serverHandle); } catch { /* best effort */ }
}
itemHandles.Clear();
server = null;
comEventSource = null;
object? toRelease = comObject;
comObject = null;
if (toRelease is not null && Marshal.IsComObject(toRelease))
{
try { Marshal.FinalReleaseComObject(toRelease); } catch { /* best effort */ }
}
}
private IMxAccessServer EnsureRegistered()
{
if (server is not null) return server;
object created = factory!.Create() ?? throw new InvalidOperationException("MXAccess COM factory returned null.");
try
{
comObject = created;
if (created is LMXProxyServerClass comServer)
{
comEventSource = comServer;
comServer.OnWriteComplete += OnWriteComplete;
}
IMxAccessServer mx = new MxAccessComServer(created);
serverHandle = mx.Register(clientName);
server = mx;
return mx;
}
catch
{
if (comEventSource is not null)
{
try { comEventSource.OnWriteComplete -= OnWriteComplete; } catch { /* best effort */ }
comEventSource = null;
}
if (Marshal.IsComObject(created))
{
try { Marshal.FinalReleaseComObject(created); } catch { /* best effort */ }
}
comObject = null;
throw;
}
}
private void OnWriteComplete(int hLMXServerHandle, int phItemHandle, ref MXSTATUS_PROXY[] pVars)
{
RepeatedField<MxStatusProxy> rows = new RepeatedField<MxStatusProxy>();
rows.AddRange(statusConverter.ConvertMany(pVars));
completionCache.Record(hLMXServerHandle, phItemHandle, rows);
}
}
(Confirm MxStatusProxyConverter's namespace and that ConvertMany(Array?) accepts MXSTATUS_PROXY[]; adjust the using. MxAccessComObjectFactory already returns LMXProxyServerClass.)
-
Step 4: Build and run the Verify command on windev (push branch,
windev-worker-ci.ps1 -Sha <sha> -Mode test, or the filtereddotnet testin the CI clone). Expected: allAlarmAckWriterTestspass. -
Step 5: Commit
git add src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAck*.cs src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IAlarmAckWriter.cs src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmAckWriterTests.cs
git commit -m "feat(worker): acknowledge alarms via authenticated AckMsg write" -m "Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT"
Task 4: Consumers resolve the ack item instead of acking
Goal: Replace IMxAccessAlarmConsumer.AcknowledgeByGuid/AcknowledgeByName with ResolveAckTarget/ResolveAckTargetByName, implement them in the wnwrap, subtag and failover consumers, and delete the inert wnwrap ack consumer and the subtag write path.
Files:
- Modify:
src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IMxAccessAlarmConsumer.cs,WnWrapAlarmConsumer.cs,SubtagAlarmConsumer.cs,FailoverAlarmConsumer.cs,ISubtagAlarmSource.cs,LmxSubtagAlarmSource.cs - Test:
src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/{WnWrapAlarmConsumerXmlTests,SubtagAlarmConsumerTests,FailoverAlarmConsumerTests,LmxSubtagAlarmSourceTests}.csand every test fake implementing the changed interfaces
Acceptance Criteria:
WnWrapAlarmConsumer.ResolveAckTarget(guid)returnsTestMachine_001.TestAlarm001.AckMsgfor a snapshot record with providerGalaxyand that tag;NotFoundfor an unknown GUID;NotAckablefor providerInTouch.WnWrapAlarmConsumer.ResolveAckTargetByName("TestMachine_001.TestAlarm001", "Galaxy", "TestArea")returnsTestMachine_001.TestAlarm001.AckMsgwithout needing a snapshot; non-Galaxy provider →NotAckable.SubtagAlarmConsumerresolves synthetic GUIDs and names to the target'sAckCommentSubtag; empty subtag →NotAckable; unknown →NotFound.FailoverAlarmConsumerdelegates both toActiveChild.- wnwrap no longer creates the second
ackClientconsumer;ISubtagAlarmSource.WriteandLmxSubtagAlarmSource.Writeare 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.csdeleteAcknowledgeByGuidandAcknowledgeByName(and their docs) and add:
/// <summary>
/// Resolves the MXAccess item whose write acknowledges the alarm with the
/// given GUID (e.g. <c>Obj.Alarm.AckMsg</c>). Does not perform the ack.
/// </summary>
/// <param name="alarmGuid">The alarm GUID as reported by this consumer.</param>
/// <returns>The resolved target or the reason none exists.</returns>
AlarmAckTarget ResolveAckTarget(Guid alarmGuid);
/// <summary>Resolves the ack item for an alarm identified by name, provider and group.</summary>
/// <param name="alarmName">The tag/alarm name, e.g. <c>TestMachine_001.TestAlarm001</c>.</param>
/// <param name="providerName">The provider (literal <c>Galaxy</c> for ArchestrA).</param>
/// <param name="groupName">The area/group.</param>
/// <returns>The resolved target or the reason none exists.</returns>
AlarmAckTarget ResolveAckTargetByName(string alarmName, string providerName, string groupName);
- Step 2: Failing tests. wnwrap (in
WnWrapAlarmConsumerXmlTests.cs, using whatever seam the file already uses to load a snapshot — e.g.FoldFetch/ApplySnapshotUpdatevia a fake client — or addinternal void ReplaceSnapshotForTests(IEnumerable<MxAlarmSnapshotRecord>)undersyncRoot):
[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, theackClientfield and its provisioning block inSubscribe(the "Provision a parallel COM consumer for ack calls" block) and its teardown inDispose; update the class remarks that describe the two-consumer split. Add:
private const string GalaxyProviderName = "Galaxy";
/// <inheritdoc />
public AlarmAckTarget ResolveAckTarget(Guid alarmGuid)
{
if (disposed) throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));
MxAlarmSnapshotRecord? record;
lock (syncRoot)
{
latestSnapshot.TryGetValue(alarmGuid, out record);
}
if (record is null)
{
return AlarmAckTarget.NotFound($"Alarm {alarmGuid} is not in the current alarm snapshot.");
}
return ResolveGalaxyAckTarget(record.ProviderName, record.TagName);
}
/// <inheritdoc />
public AlarmAckTarget ResolveAckTargetByName(string alarmName, string providerName, string groupName)
{
if (disposed) throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));
return ResolveGalaxyAckTarget(providerName, alarmName);
}
private static AlarmAckTarget ResolveGalaxyAckTarget(string? providerName, string? tagName)
{
if (!string.Equals(providerName, GalaxyProviderName, StringComparison.OrdinalIgnoreCase))
{
return AlarmAckTarget.NotAckable(
$"Alarm provider '{providerName}' is not a Galaxy provider; only Galaxy alarms can be acknowledged over MXAccess.");
}
if (string.IsNullOrWhiteSpace(tagName))
{
return AlarmAckTarget.NotFound("Alarm record has no tag name.");
}
return AlarmAckTarget.For(tagName + "." + AlarmAckWriter.AckCommentAttribute);
}
- Step 4: Subtag implementation. Replace
AcknowledgeByGuid/AcknowledgeByName/WriteAckCommentwith:
/// <inheritdoc />
public AlarmAckTarget ResolveAckTarget(Guid alarmGuid)
{
if (disposed) throw new ObjectDisposedException(nameof(SubtagAlarmConsumer));
if (!referencesBySyntheticGuid.TryGetValue(alarmGuid, out string reference) ||
!targetsByReference.TryGetValue(reference, out AlarmSubtagTarget target))
{
return AlarmAckTarget.NotFound($"Alarm {alarmGuid} is not on the subtag watch list.");
}
return ToAckTarget(target);
}
/// <inheritdoc />
public AlarmAckTarget ResolveAckTargetByName(string alarmName, string providerName, string groupName)
{
if (disposed) throw new ObjectDisposedException(nameof(SubtagAlarmConsumer));
AlarmSubtagTarget? target = ResolveTargetByName(alarmName, providerName, groupName);
return target is null
? AlarmAckTarget.NotFound($"Alarm '{providerName}!{groupName}.{alarmName}' is not on the subtag watch list.")
: ToAckTarget(target);
}
private static AlarmAckTarget ToAckTarget(AlarmSubtagTarget target)
{
string ackItem = target.AckCommentSubtag ?? string.Empty;
return ackItem.Length == 0
? AlarmAckTarget.NotAckable($"Alarm '{target.AlarmFullReference}' has no ack-comment subtag configured.")
: AlarmAckTarget.For(ackItem);
}
Keep Subscribe advising the ack-comment subtag only if other code reads it; otherwise drop it from the advise list (the ack writer advises on its own server). Remove Write from ISubtagAlarmSource and LmxSubtagAlarmSource (and LmxSubtagAlarmSourceTests.Write_* tests and the subtag fake's Write).
- Step 5: Failover — replace the two ack methods:
/// <inheritdoc />
public AlarmAckTarget ResolveAckTarget(Guid alarmGuid)
{
if (disposed) throw new ObjectDisposedException(nameof(FailoverAlarmConsumer));
return ActiveChild.ResolveAckTarget(alarmGuid);
}
/// <inheritdoc />
public AlarmAckTarget ResolveAckTargetByName(string alarmName, string providerName, string groupName)
{
if (disposed) throw new ObjectDisposedException(nameof(FailoverAlarmConsumer));
return ActiveChild.ResolveAckTargetByName(alarmName, providerName, groupName);
}
-
Step 6: Update every fake implementing
IMxAccessAlarmConsumer(grep -rn ": IMxAccessAlarmConsumer" src/ZB.MOM.WW.MxGateway.Worker.Tests) — replace the two ack members withResolveAckTarget/ResolveAckTargetByNamethat record their arguments and return a configurableAlarmAckTarget(defaultAlarmAckTarget.For("Fake.Alarm.AckMsg")). -
Step 7: Verify (Task 5 must land before the worker project compiles end to end, because
AlarmDispatcherstill calls the removed members — do Tasks 4 and 5 in one windev build) and commit:
git add src/ZB.MOM.WW.MxGateway.Worker src/ZB.MOM.WW.MxGateway.Worker.Tests
git commit -m "refactor(worker): alarm consumers resolve the AckMsg item instead of acking" -m "Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT"
Task 5: Route acks through the writer and remove the managed path
Goal: Wire AlarmAckWriter through AlarmCommandHandler → AlarmDispatcher → MxAccessCommandExecutor with an AlarmAckOperator built from the command, map AlarmAckResult to the reply, and delete the dead managed/WNAL ack path.
Files:
- Modify:
src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs,AlarmCommandHandler.cs,IAlarmCommandHandler.cs,MxAccessCommandExecutor.cs(~894-990),src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerPipeSession.cs(:79, :101, :1588),src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessStaSession.cs(handler factory call ~288) - Delete:
src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AaManagedAlarmAckConsumer.cs,IManagedAlarmAckClient.cs,WnalNativeAckConsumer.cs,src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmManagedGuidAckLiveSmokeTests.cs - Test:
AlarmDispatcherTests.cs,AlarmCommandHandlerTests.cs,AlarmCommandExecutorTests.cs,MxAccessCommandExecutorTests.cs
Acceptance Criteria:
AlarmDispatcher.Acknowledge(guid, comment, op)resolves via the consumer and calls the writer with the resolved item; an unresolved target returnsAlarmAckResult.FromTargetwithout calling the writer.ExecuteAcknowledgeAlarmbuildsAlarmAckOperator{UserName=operator_user, Domain=operator_domain, Password=operator_password}; mapsApplied/Unconfirmed→Ok,AlarmNotFound/NotAckable→InvalidRequest,AuthenticationFailed/WriteRejected→MxaccessFailure; setsHresultandAcknowledgeAlarm.NativeStatustoresult.NativeStatus; copiesresult.Statusesintoreply.Statuses;DiagnosticMessage = result.Diagnostic.- A test with
operator_password = "pw-secret"asserts the secret is absent fromDiagnosticMessage,ProtocolStatus.Message, for every outcome. - No references remain to
AaManagedAlarmAckConsumer,IManagedAlarmAckClient,WnalNativeAckConsumer,MXGATEWAY_USE_MANAGED_ALARM_ACK,IsSubtagActive(grep -rnreturns nothing undersrc/). - Production wiring creates one
AlarmAckWriter(comFactory, () => new StaMessagePump().PumpPendingMessages(), MxAccessStaSession.ResolveWriteCompletionTimeout(), AlarmAckWriter.ResolveClientName())perAlarmCommandHandler, disposed with the handler. - x86 worker builds with 0 warnings; all
Worker.Testspass on windev.
Verify: on windev: powershell -NoProfile -ExecutionPolicy Bypass -File C:\build\mxaccessgw-ci\scripts\ci\windev-worker-ci.ps1 -Sha <pushed sha> -Mode test → SUCCESS mode=test, Failed: 0
Steps:
- Step 1: Failing dispatcher tests (replace the four managed tests and
FakeManagedAckClientinAlarmDispatcherTests.cs):
[Fact]
public void Acknowledge_ResolvesTargetThenWritesWithOperator()
{
FakeAlarmConsumer consumer = new FakeAlarmConsumer { AckTarget = AlarmAckTarget.For("T.A.AckMsg") };
FakeAckWriter writer = new FakeAckWriter();
using AlarmDispatcher dispatcher = new AlarmDispatcher(
consumer, new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), SessionId, writer);
Guid guid = Guid.NewGuid();
AlarmAckOperator op = new AlarmAckOperator { UserName = "alice", Password = "pw" };
AlarmAckResult result = dispatcher.Acknowledge(guid, "checked", op);
Assert.Equal(AlarmAckOutcome.Applied, result.Outcome);
Assert.Equal(guid, consumer.LastResolvedGuid);
Assert.Equal("T.A.AckMsg", writer.LastItemAddress);
Assert.Equal("checked", writer.LastComment);
Assert.Same(op, writer.LastOperator);
}
[Fact]
public void Acknowledge_WhenTargetNotFound_DoesNotWrite()
{
FakeAlarmConsumer consumer = new FakeAlarmConsumer { AckTarget = AlarmAckTarget.NotFound("gone") };
FakeAckWriter writer = new FakeAckWriter();
using AlarmDispatcher dispatcher = new AlarmDispatcher(
consumer, new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), SessionId, writer);
AlarmAckResult result = dispatcher.Acknowledge(Guid.NewGuid(), "c", new AlarmAckOperator { UserName = "alice" });
Assert.Equal(AlarmAckOutcome.AlarmNotFound, result.Outcome);
Assert.Null(writer.LastItemAddress);
}
private sealed class FakeAckWriter : IAlarmAckWriter
{
public string? LastItemAddress { get; private set; }
public string? LastComment { get; private set; }
public AlarmAckOperator? LastOperator { get; private set; }
public AlarmAckResult Result { get; set; } = AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, string.Empty);
public bool Disposed { get; private set; }
public AlarmAckResult Acknowledge(string itemAddress, string comment, AlarmAckOperator ackOperator)
{
LastItemAddress = itemAddress;
LastComment = comment;
LastOperator = ackOperator;
return Result;
}
public void Dispose() => Disposed = true;
}
Add the matching AcknowledgeByName test (consumer.LastResolvedName == ("TestMachine_001.TestAlarm001","Galaxy","TestArea")).
- Step 2: Failing executor tests in
AlarmCommandExecutorTests.cs(extend the existing fakeIAlarmCommandHandlersoAcknowledgereturns a configurableAlarmAckResultand records theAlarmAckOperator):
[Theory]
[InlineData(AlarmAckOutcome.Applied, ProtocolStatusCode.Ok)]
[InlineData(AlarmAckOutcome.Unconfirmed, ProtocolStatusCode.Ok)]
[InlineData(AlarmAckOutcome.AlarmNotFound, ProtocolStatusCode.InvalidRequest)]
[InlineData(AlarmAckOutcome.NotAckable, ProtocolStatusCode.InvalidRequest)]
[InlineData(AlarmAckOutcome.AuthenticationFailed, ProtocolStatusCode.MxaccessFailure)]
[InlineData(AlarmAckOutcome.WriteRejected, ProtocolStatusCode.MxaccessFailure)]
public async Task AcknowledgeAlarm_MapsOutcomeWithoutLeakingPassword(AlarmAckOutcome outcome, ProtocolStatusCode expected)
{
int native = outcome is AlarmAckOutcome.Applied or AlarmAckOutcome.Unconfirmed ? 0 : 1008;
FakeAlarmCommandHandler handler = new FakeAlarmCommandHandler
{
AckResult = AlarmAckResult.Create(outcome, native, "diagnostic for alice"),
};
MxCommandReply reply = await DispatchAsync(handler, new MxCommand
{
Kind = MxCommandKind.AcknowledgeAlarm,
AcknowledgeAlarmCommand = new AcknowledgeAlarmCommand
{
AlarmGuid = Guid.NewGuid().ToString(),
Comment = "checked",
OperatorUser = "alice",
OperatorDomain = "CORP",
OperatorPassword = "pw-secret",
},
});
Assert.Equal(expected, reply.ProtocolStatus.Code);
Assert.Equal(native, reply.Hresult);
Assert.Equal(native, reply.AcknowledgeAlarm.NativeStatus);
Assert.Equal("alice", handler.LastOperator!.UserName);
Assert.Equal("CORP", handler.LastOperator.Domain);
Assert.Equal("pw-secret", handler.LastOperator.Password);
Assert.DoesNotContain("pw-secret", reply.DiagnosticMessage ?? string.Empty, StringComparison.Ordinal);
Assert.DoesNotContain("pw-secret", reply.ProtocolStatus.Message ?? string.Empty, StringComparison.Ordinal);
}
(Use the file's existing dispatch helper name; xUnit1030: no .ConfigureAwait(false) in the test body.) Add the same theory for AcknowledgeAlarmByName.
- Step 3:
IAlarmCommandHandler— change the two ack members to:
/// <summary>Acknowledges an alarm by GUID as <paramref name="ackOperator"/>.</summary>
AlarmAckResult Acknowledge(Guid alarmGuid, string comment, AlarmAckOperator ackOperator);
/// <summary>Acknowledges an alarm by name/provider/group as <paramref name="ackOperator"/>.</summary>
AlarmAckResult AcknowledgeByName(string alarmName, string providerName, string groupName, string comment, AlarmAckOperator ackOperator);
- Step 4:
AlarmDispatcher— removemanagedAck, the managed block inSubscribe,IsSubtagActive, the managed dispose; addprivate readonly IAlarmAckWriter ackWriter;and make the constructorAlarmDispatcher(IMxAccessAlarmConsumer consumer, MxAccessAlarmEventSink sink, string sessionId, IAlarmAckWriter ackWriter)(null →ArgumentNullException). The dispatcher does not dispose the writer (the handler owns it). Replace the ack methods:
/// <summary>Acknowledges the alarm with the given GUID by writing its ack item as the operator.</summary>
/// <param name="alarmGuid">The alarm GUID.</param>
/// <param name="ackComment">The acknowledgement comment.</param>
/// <param name="ackOperator">The operator to authenticate as.</param>
/// <returns>The outcome.</returns>
public AlarmAckResult Acknowledge(Guid alarmGuid, string ackComment, AlarmAckOperator ackOperator)
{
if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher));
AlarmAckTarget target = consumer.ResolveAckTarget(alarmGuid);
return target.Found
? ackWriter.Acknowledge(target.ItemAddress, ackComment, ackOperator)
: AlarmAckResult.FromTarget(target);
}
/// <summary>Acknowledges the alarm identified by name/provider/group as the operator.</summary>
/// <param name="alarmName">The tag/alarm name.</param>
/// <param name="providerName">The provider.</param>
/// <param name="groupName">The group/area.</param>
/// <param name="ackComment">The acknowledgement comment.</param>
/// <param name="ackOperator">The operator to authenticate as.</param>
/// <returns>The outcome.</returns>
public AlarmAckResult AcknowledgeByName(
string alarmName, string providerName, string groupName, string ackComment, AlarmAckOperator ackOperator)
{
if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher));
AlarmAckTarget target = consumer.ResolveAckTargetByName(alarmName, providerName, groupName);
return target.Found
? ackWriter.Acknowledge(target.ItemAddress, ackComment, ackOperator)
: AlarmAckResult.FromTarget(target);
}
- Step 5:
AlarmCommandHandler— replace themanagedAckFactoryfield/ctor param/doc withFunc<IAlarmAckWriter>? ackWriterFactory = null; addprivate IAlarmAckWriter? ackWriter;and
private IAlarmAckWriter GetOrCreateAckWriter()
{
if (ackWriter is not null) return ackWriter;
if (ackWriterFactory is not null)
{
ackWriter = ackWriterFactory() ?? throw new InvalidOperationException("Alarm ack writer factory returned null.");
return ackWriter;
}
if (comFactory is null)
{
throw new InvalidOperationException(
"Alarm acknowledgement requires an IMxAccessComObjectFactory; the alarm command handler was constructed without one.");
}
ackWriter = new AlarmAckWriter(
comFactory,
() => new StaMessagePump().PumpPendingMessages(),
MxAccessStaSession.ResolveWriteCompletionTimeout(),
AlarmAckWriter.ResolveClientName());
return ackWriter;
}
In Subscribe pass GetOrCreateAckWriter() as the dispatcher's 4th argument (delete the managed block). Change Acknowledge/AcknowledgeByName to the new signatures (null comment → empty, null operator → ArgumentNullException). In Dispose dispose ackWriter after the dispatcher (try { ackWriter?.Dispose(); } catch { }). (StaMessagePump.PumpPendingMessages is PeekMessage on the calling thread, i.e. the STA when called from an ack.)
- Step 6: Executor — rewrite the try-blocks of
ExecuteAcknowledgeAlarmandExecuteAcknowledgeAlarmByNameto:
try
{
AlarmAckResult result = alarmCommandHandler.Acknowledge(
alarmGuid,
payload.Comment,
new AlarmAckOperator
{
UserName = payload.OperatorUser,
Domain = payload.OperatorDomain,
Password = payload.OperatorPassword,
});
return CreateAcknowledgeReply(command, result);
}
catch (Exception ex)
{
return CreateAlarmFailureReply(command, ex);
}
(by-name passes payload.AlarmName, payload.ProviderName, payload.GroupName, payload.Comment, new AlarmAckOperator { … }) and add:
private MxCommandReply CreateAcknowledgeReply(StaCommand command, AlarmAckResult result)
{
MxCommandReply reply = CreateOkReply(command);
reply.Hresult = result.NativeStatus;
reply.AcknowledgeAlarm = new AcknowledgeAlarmReplyPayload { NativeStatus = result.NativeStatus };
reply.Statuses.Add(result.Statuses);
reply.DiagnosticMessage = result.Diagnostic;
switch (result.Outcome)
{
case AlarmAckOutcome.AlarmNotFound:
case AlarmAckOutcome.NotAckable:
reply.ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.InvalidRequest, Message = result.Diagnostic };
break;
case AlarmAckOutcome.AuthenticationFailed:
case AlarmAckOutcome.WriteRejected:
reply.ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.MxaccessFailure, Message = result.Diagnostic };
break;
}
return reply;
}
(Statuses must be the repeated field on MxCommandReply; if AddRange is required on RepeatedField, use reply.Statuses.AddRange(result.Statuses).) The only operator-derived text in diagnostics is VerifyUser; the password never enters AlarmAckResult.
-
Step 7: Delete the managed path —
git rmthe four files; inWorkerPipeSession.csremove, managedAckFactory: AaManagedAlarmAckConsumer.EnvGatedFactoryfrom the three handler constructions; fixAlarmCommandHandlerTestsconstructions that passedmanagedAckFactory. -
Step 8: Grep, push, verify on windev
grep -rn "AaManagedAlarmAckConsumer\|IManagedAlarmAckClient\|WnalNativeAckConsumer\|MXGATEWAY_USE_MANAGED_ALARM_ACK\|IsSubtagActive\|AcknowledgeByGuid" src/ --include=*.cs
git push -u origin feat/ackmsg-as-operator
ssh windev 'powershell -NoProfile -ExecutionPolicy Bypass -File C:\build\mxaccessgw-ci\scripts\ci\windev-worker-ci.ps1 -Sha <sha> -Mode test'
Expected: grep prints nothing; CI log ends SUCCESS mode=test, Failed: 0.
- Step 9: Commit
git add -A src/ZB.MOM.WW.MxGateway.Worker src/ZB.MOM.WW.MxGateway.Worker.Tests
git commit -m "feat(worker): route alarm acks through the operator-authenticated AckMsg writer" -m "Removes the inert managed/WNAL alarm-client ack path." -m "Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT"
Task 6: Gateway forwards operator credentials and the ack client name
Goal: Forward operator_password/operator_domain from AcknowledgeAlarmRequest onto the worker ack commands, require operator_user, treat ack commands as credential-bearing in redaction, and add the AckClientName option conveyed to workers.
Files:
- Modify:
src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs(BuildAcknowledgeCommand~1172-1222) - Modify:
src/ZB.MOM.WW.MxGateway.Server/Grpc/MxAccessGatewayService.cs(AcknowledgeAlarm~187-207) - Modify:
src/ZB.MOM.WW.MxGateway.Server/Configuration/AlarmsOptions.cs,GatewayOptionsValidator.cs(ValidateAlarms~473-515),Workers/WorkerProcessLauncher.cs(~214-224) - Modify:
src/ZB.MOM.WW.MxGateway.Server/Diagnostics/GatewayLogRedactor.cs(:11-16),GatewayLogRedactorSeam.cs - Test:
src/ZB.MOM.WW.MxGateway.Tests/Alarms/GatewayAlarmMonitorAcknowledgeTests.cs(create),Gateway/Grpc/MxAccessGatewayServiceTests.cs,Configuration/GatewayOptionsValidatorTests.cs,Gateway/Workers/WorkerProcessLauncherTests.cs,Diagnostics/GatewayLogRedactorTests.cs,Diagnostics/GatewayLogRedactorSeamTests.cs
Acceptance Criteria:
- A GUID-reference ack produces
AcknowledgeAlarmCommand{OperatorUser="alice", OperatorDomain="CORP", OperatorPassword="pw-secret"}; aGalaxy!TestArea.T.Aack produces the by-name command with the same three fields. - The gRPC handler returns
InvalidArgument"operator_user is required." whenoperator_useris empty. GatewayLogRedactor.IsCredentialBearingCommand("AcknowledgeAlarm")and("AcknowledgeAlarmByName")are true; the seam redacts any property whose name containspassword(case-insensitive).MxGateway:Alarms:AckClientNamedefaults to"MxGateway", rejects values over 64 chars or containing control characters, and the launcher setsMXGATEWAY_ALARM_ACK_CLIENT_NAMEto 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.
BuildAcknowledgeCommandis private; make itinternal static(the test project already hasInternalsVisibleTofor Server — confirm withgrep -rn InternalsVisibleTo src/ZB.MOM.WW.MxGateway.Server) and test it directly:
namespace ZB.MOM.WW.MxGateway.Tests.Alarms;
public sealed class GatewayAlarmMonitorAcknowledgeTests
{
[Fact]
public void BuildAcknowledgeCommand_GuidReference_ForwardsOperatorCredentials()
{
MxCommand? command = GatewayAlarmMonitor.BuildAcknowledgeCommand(new AcknowledgeAlarmRequest
{
AlarmFullReference = "BCC47053-9542-4D65-BDAA-BCDEA6A32A73",
Comment = "checked",
OperatorUser = "alice",
OperatorDomain = "CORP",
OperatorPassword = "pw-secret",
}, out string? error);
Assert.Null(error);
Assert.Equal(MxCommandKind.AcknowledgeAlarm, command!.Kind);
Assert.Equal("alice", command.AcknowledgeAlarmCommand.OperatorUser);
Assert.Equal("CORP", command.AcknowledgeAlarmCommand.OperatorDomain);
Assert.Equal("pw-secret", command.AcknowledgeAlarmCommand.OperatorPassword);
}
[Fact]
public void BuildAcknowledgeCommand_NameReference_ForwardsOperatorCredentials()
{
MxCommand? command = GatewayAlarmMonitor.BuildAcknowledgeCommand(new AcknowledgeAlarmRequest
{
AlarmFullReference = "Galaxy!TestArea.TestMachine_001.TestAlarm001",
OperatorUser = "alice",
OperatorPassword = "pw-secret",
}, out _);
Assert.Equal(MxCommandKind.AcknowledgeAlarmByName, command!.Kind);
Assert.Equal("TestMachine_001.TestAlarm001", command.AcknowledgeAlarmByNameCommand.AlarmName);
Assert.Equal("alice", command.AcknowledgeAlarmByNameCommand.OperatorUser);
Assert.Equal("pw-secret", command.AcknowledgeAlarmByNameCommand.OperatorPassword);
}
}
-
Step 2: Implement — in
BuildAcknowledgeCommandaddstring operatorPassword = request.OperatorPassword ?? string.Empty; string operatorDomain = request.OperatorDomain ?? string.Empty;and in both command initializers setOperatorDomain = operatorDomain, OperatorPassword = operatorPassword,(replacingOperatorDomain = string.Empty). KeepOperatorNode/OperatorFullNameempty (the Historian takes them from the authenticated account and the ack client name). -
Step 3: Handler validation — failing test in
MxAccessGatewayServiceTests.cs:
[Fact]
public async Task AcknowledgeAlarm_WithMissingOperatorUser_ThrowsInvalidArgument()
{
MxAccessGatewayService service = CreateService(new FakeSessionManager());
RpcException exception = await Assert.ThrowsAsync<RpcException>(
async () => await service.AcknowledgeAlarm(
new AcknowledgeAlarmRequest { AlarmFullReference = "Galaxy!Area.Tag" },
new TestServerCallContext()));
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
Assert.Contains("operator_user", exception.Status.Detail, StringComparison.Ordinal);
}
then add after the reference check in the handler:
if (string.IsNullOrWhiteSpace(request.OperatorUser))
{
throw new RpcException(new Status(StatusCode.InvalidArgument, "operator_user is required."));
}
Update existing ack tests that omit OperatorUser (e.g. AcknowledgeAlarm_WithMissingAlarmReference_ThrowsInvalidArgument already sets it).
- Step 4: Redaction — add
"AcknowledgeAlarm"and"AcknowledgeAlarmByName"toSensitiveCommandMethodswith a comment ("carry operator_password"); inGatewayLogRedactorSeam.Redactadd name-based redaction:
foreach (string key in properties.Keys.ToList())
{
if (key.IndexOf("password", StringComparison.OrdinalIgnoreCase) >= 0 && properties[key] is not null)
{
properties[key] = GatewayLogRedactor.RedactedValue;
}
}
(use the existing redacted-value constant name from GatewayLogRedactor). Tests: IsCredentialBearingCommand_AckCommands_ReturnsTrue and Redact_PasswordNamedProperty_IsRedacted ({"OperatorPassword": "pw-secret"} → [redacted]).
- Step 5: AckClientName option — in
AlarmsOptions.cs:
/// <summary>
/// MXAccess client name the alarm worker's ack writer registers with. The
/// AVEVA Historian records it as each acknowledgement's OperatorNode.
/// Conveyed to workers via MXGATEWAY_ALARM_ACK_CLIENT_NAME.
/// </summary>
public string AckClientName { get; init; } = "MxGateway";
validator (in ValidateAlarms): fail when empty/whitespace, longer than 64, or AckClientName.Any(char.IsControl) with message "MxGateway:Alarms:AckClientName must be 1-64 printable characters."; launcher: startInfo.Environment[WorkerLaunchEnvironment.AlarmAckClientName] = _alarmsOptions.AckClientName;. Tests: validator rejects "" and a 65-char name, accepts default; launcher test asserts the env var equals the configured name (follow the existing MXGATEWAY_ALARM_POLL_INTERVAL_MS launcher test).
-
Step 6: Build + Verify —
dotnet build src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj(0 warnings) then the Verify command → pass. -
Step 7: Commit
git add src/ZB.MOM.WW.MxGateway.Server src/ZB.MOM.WW.MxGateway.Tests
git commit -m "feat(gateway): forward operator credentials on alarm acknowledgement" -m "Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT"
Task 7: .NET client and CLI
Goal: Expose the new fields in the .NET client CLI with safe password input and make failures never echo the password.
Files:
- Modify:
clients/dotnet/ZB.MOM.WW.MxGateway.Client/MxGatewayClient.cs(:191-204),clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/MxGatewayClientCli.cs(:176-188, :1526-1545, :2468),clients/dotnet/README.md(:144-149, :294) - Test:
clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/MxGatewayClientAlarmsTests.cs,MxGatewayClientCliTests.cs
Acceptance Criteria:
AcknowledgeAlarmAsyncdoc mentions operator credentials; on failure the thrown exception is passed throughMxGatewaySecretRedaction.Redacted(ex, request.OperatorPassword).- CLI
acknowledge-alarmaccepts--operator,--operator-domain,--operator-password,--operator-password-env(default envMXGATEWAY_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_RecordsRequestShapeAndReturnsReplyto setOperatorPassword = "pw-secret", OperatorDomain = "CORP"and assert they reachcall.Request; add a test where the fake transport throwsRpcException(new Status(StatusCode.Internal, "boom pw-secret"))and assert the resulting exception'sMessagelackspw-secret. CLI: extendRunAsync_AcknowledgeAlarm_BuildsRequestAndPrintsJsonReplywith--operator-domain CORP --operator-password-env MXGW_TEST_OPPW(set the env var in the test) and assertrequest.OperatorPasswordand that stdout lacks the password. - Step 2: Implement — client:
/// <summary>
/// Acknowledges an alarm as the operator. The gateway authenticates
/// <see cref="AcknowledgeAlarmRequest.OperatorUser"/> with
/// <see cref="AcknowledgeAlarmRequest.OperatorPassword"/> and writes the
/// comment to the alarm's AckMsg attribute, so the alarm history records
/// the operator. The password is redacted from any surfaced error.
/// </summary>
public async Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
AcknowledgeAlarmRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ThrowIfDisposed();
try
{
return await ExecuteSafeUnaryAsync(
token => _transport.AcknowledgeAlarmAsync(request, CreateCallOptions(token)),
cancellationToken).ConfigureAwait(false);
}
catch (MxGatewayException ex) when (request.OperatorPassword.Length > 0)
{
throw MxGatewaySecretRedaction.Redacted(ex, request.OperatorPassword);
}
}
CLI builder: OperatorDomain = arguments.GetOptional("operator-domain") ?? string.Empty, OperatorPassword = ResolveOperatorPassword(arguments), where ResolveOperatorPassword mirrors TryResolveVerifyUserPassword with option operator-password, env option operator-password-env, default env MXGATEWAY_OPERATOR_PASSWORD; add it to the MxGatewayCliSecretRedactor.Redact(...) list; update usage string to mxgw-dotnet acknowledge-alarm --reference <ref> --operator <user> [--operator-domain <dom>] [--operator-password <pw> | --operator-password-env <VAR>] [--comment <text>] [--json].
- Step 3: README — replace the alarms sentence with "…and
AcknowledgeAlarmAsync(acknowledges as the operator: the gateway authenticatesOperatorUser/OperatorPasswordand writes the comment to the alarm'sAckMsg, so the Historian records the operator)"; update the CLI example to--operator operator1 --operator-password-env MXGATEWAY_OPERATOR_PASSWORDand 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*.pyviaclients/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.pykeeps# Protobuf Python Version: 6.31.1and only contains the real descriptor delta. acknowledge_alarmredactsrequest.operator_passwordfrom raised errors (redact_secret).- CLI accepts
--operator-domain,--operator-password,--operator-password-env(defaultMXGATEWAY_OPERATOR_PASSWORD); password added tosecrets=. - 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/generatedshows onlymxaccess_gateway_pb2.py/.pyichanges. - Step 2: Failing tests — in
test_alarms.pysetoperator_password="pw-secret", operator_domain="CORP"on the request and assertcaptured_request.operator_password == "pw-secret"; addtest_acknowledge_alarm_error_redacts_operator_passwordwhere the fake stub raises anRpcErrorwhose details containpw-secretand assert"pw-secret" not in str(excinfo.value). CLI: extendtest_cli_acknowledge_alarm_happy_pathwith--operator-domain CORP --operator-password-env MXGW_TEST_OPPW(monkeypatch env) and assert the request field and that output lacks the secret. - Step 3: Implement — in
client.py:
async def acknowledge_alarm(
self,
request: pb.AcknowledgeAlarmRequest,
) -> pb.AcknowledgeAlarmReply:
"""Acknowledge an alarm as the operator.
The gateway authenticates ``operator_user`` with ``operator_password`` and
writes the comment to the alarm's ``AckMsg``, so the alarm history records
the operator. The password is redacted from any raised error.
"""
try:
reply = await self._unary("acknowledge alarm", self.raw_stub.AcknowledgeAlarm, request)
ensure_protocol_success("acknowledge alarm", reply.protocol_status, reply)
return reply
except MxGatewayError as error:
if request.operator_password:
raise type(error)(redact_secret(str(error), [request.operator_password])) from None
raise
(Use the module's actual base error class name and import redact_secret from .auth; if error types take structured args, mirror the session's _invoke_redacted approach instead.) CLI: add @click.option("--operator-domain", default="", ...), @click.option("--operator-password", default=None, ...), @click.option("--operator-password-env", default="MXGATEWAY_OPERATOR_PASSWORD", ...); resolve with a helper modelled on _resolve_password; set operator_domain=, operator_password= in the request; pass secrets=_secrets(kwargs) + [password].
- Step 4: README — same wording as Task 7; CLI example adds
--operator operator1 --operator-password-env MXGATEWAY_OPERATOR_PASSWORD. - Step 5: Verify → pass. Step 6: Commit
git add clients/python && git commit -m "feat(python-client): operator credentials on acknowledge-alarm" -m "Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT"
Task 9: Go client and CLI
Goal: Regenerate Go bindings and add operator credential flags to mxgw-go acknowledge-alarm with error redaction.
Files:
- Regenerate:
clients/go/internal/generated/mxaccess_gateway.pb.go,mxaccess_gateway_grpc.pb.goviaclients/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:
AcknowledgeAlarmwraps errors withredactSecrets(err, req.GetOperatorPassword())when the password is non-empty.- CLI flags
-operator-domain,-operator-password,-operator-password-env(defaultMXGATEWAY_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(needsprotoc-gen-go v1.36.11,protoc-gen-go-grpc 1.6.2). - Step 2: Failing test in
alarms_test.go: sendOperatorPassword: "pw-secret", OperatorDomain: "CORP"and assertfake.acknowledgeRequest.GetOperatorPassword() == "pw-secret"; addTestAcknowledgeAlarmRedactsPasswordFromErrorwhere the fake returnsstatus.Error(codes.Internal, "boom pw-secret")and assert!strings.Contains(err.Error(), "pw-secret"). - Step 3: Implement in
alarms.go:
reply, err := c.raw.AcknowledgeAlarm(callCtx, req)
if err != nil {
return nil, redactSecrets(&GatewayError{Op: "acknowledge alarm", Err: err}, req.GetOperatorPassword())
}
if err := EnsureProtocolSuccess("acknowledge alarm", reply.GetProtocolStatus(), nil); err != nil {
return reply, redactSecrets(err, req.GetOperatorPassword())
}
(Confirm redactSecrets returns the error unchanged for empty secrets.) CLI: add the three flags; resolve password like the authenticate-user -password/-password-env code at main.go:449-503; set OperatorDomain, OperatorPassword; update usage.
- Step 4: README — wording as Task 7; CLI table row "Acknowledge an alarm as an operator (
-operator,-operator-password-env)". - Step 5: Verify → pass. Step 6: Commit
git add clients/go && git commit -m "feat(go-client): operator credentials on acknowledge-alarm" -m "Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT"
Task 10: Rust client and CLI
Goal: Make the Rust workspace compile against the new fields, add CLI credential options, and keep the password out of Debug output and errors.
Files:
- Modify:
clients/rust/src/client.rs(:335-346),clients/rust/crates/mxgw-cli/src/main.rs(:370-381, :1079-1095),clients/rust/tests/client_behavior.rs(:219-235, ~:1041-1079, :1423-1440),clients/rust/README.md(:81, :123-126),clients/rust/RustClientDesign.md(:160, :349) - (Vendored proto already updated in Task 1.)
Acceptance Criteria:
- All
AcknowledgeAlarmRequest { … }literals includeoperator_password/operator_domain(or..Default::default()). acknowledge_alarmattaches the password viaMxAccessError::with_secrets(or equivalent) so{:?}of a returned error lacks it.- CLI
--operator-domain,--operator-password,--operator-password-env(defaultMXGATEWAY_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: sendoperator_password: "pw-secret".to_owned()with a fake that returnsStatus::internal("boom pw-secret"); assert!format!("{err:?}").contains("pw-secret")and!err.to_string().contains("pw-secret"). - Step 3: Implement in
client.rs:
pub async fn acknowledge_alarm(
&self,
request: AcknowledgeAlarmRequest,
) -> Result<AcknowledgeAlarmReply, Error> {
let secret = request.operator_password.clone();
let mut client = self.inner.clone();
let result = async {
let response = client.acknowledge_alarm(self.unary_request(request)).await?;
let reply = response.into_inner();
ensure_protocol_success("acknowledge alarm", reply.protocol_status.as_ref())?;
Ok(reply)
}
.await;
result.map_err(|error: Error| if secret.is_empty() { error } else { error.with_secrets(&[secret.as_str()]) })
}
(Use the crate's actual secret-attachment API name from src/error.rs; if with_secrets lives on MxAccessError only, add an Error::redact_secrets(self, &[&str]) -> Error helper that scrubs Display/Debug text, with a unit test.) CLI: add clap args #[arg(long, default_value = "")] operator_domain, #[arg(long)] operator_password: Option<String>, #[arg(long, default_value = "MXGATEWAY_OPERATOR_PASSWORD")] operator_password_env: String; resolve like resolve_verify_user_password.
- Step 4: Docs — README/RustClientDesign wording as Task 7 and the corrected CLI example.
- Step 5: Verify → pass. Step 6: Commit
git add clients/rust && git commit -m "feat(rust-client): operator credentials on acknowledge-alarm" -m "Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT"
Task 11: Java client and CLI
Goal: Regenerate Java bindings and add operator credential options to the Java CLI with redaction.
Files:
- Regenerate:
clients/java/src/main/generated/main/java/mxaccess_gateway/v1/MxaccessGateway.java(gradle :zb-mom-ww-mxgateway-client:generateProto) — commit it (real proto change) - Modify:
clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/MxGatewayClient.java(:280-306),clients/java/zb-mom-ww-mxgateway-cli/src/main/java/com/zb/mom/ww/mxgateway/cli/MxGatewayCli.java(:1814-1846),clients/java/README.md(:114-118, :420) - Test:
clients/java/zb-mom-ww-mxgateway-cli/src/test/java/com/zb/mom/ww/mxgateway/cli/MxGatewayCliTests.java(:706, :758)
Acceptance Criteria:
acknowledgeAlarmrethrows failures withMxGatewaySecrets.redactExact(message, request.getOperatorPassword())applied when the password is non-empty.- CLI
--operator-domain,--operator-password,--operator-password-env(defaultMXGATEWAY_OPERATOR_PASSWORD, picocliarity/interactivenot required). readmeDocumentedAcknowledgeAlarmExampleParsesCleanlyupdated to the new README tokens and passes;checkGeneratedCleanpasses 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 --statshowsMxaccessGateway.javawith the new fields. - Step 2: Failing CLI test — extend
acknowledgeAlarmCommandForwardsOptionsAndPrintsReplywith"--operator-domain", "CORP", "--operator-password", "pw-secret"and assertfactory.client.lastAcknowledgeAlarmRequest.getOperatorPassword()equals it andrun.output()does not containpw-secret. - Step 3: Implement — CLI options and
.setOperatorDomain(operatorDomain).setOperatorPassword(resolveOperatorPassword())whereresolveOperatorPassword()returns the option value orSystem.getenv(operatorPasswordEnv)or""; clientacknowledgeAlarmcatch block:
} catch (RuntimeException error) {
MxGatewayException mapped = error instanceof MxGatewayException gatewayError
? gatewayError
: MxGatewayErrors.fromGrpc("acknowledge alarm", error);
String secret = request.getOperatorPassword();
if (secret.isEmpty()) {
throw mapped;
}
throw MxGatewayErrors.redacted(mapped, secret);
}
(If no MxGatewayErrors.redacted exists, add one that rebuilds the exception with MxGatewaySecrets.redactExact(message, secret) — mirror MxGatewaySession.rebuildRedacted.)
- Step 4: README — wording as Task 7; CLI example adds
--operator operator1 --operator-password-env MXGATEWAY_OPERATOR_PASSWORD; update the README-pinned test tokens. - Step 5: Verify → BUILD SUCCESSFUL. Step 6: Commit
git add clients/java && git commit -m "feat(java-client): operator credentials on acknowledge-alarm" -m "Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT"
Task 12: Documentation
Goal: Describe the operator-authenticated AckMsg ack everywhere the old alarm-client ack is described, add the credential to the never-log rules, and record the decision.
Files:
- Modify:
gateway.md(:143-144, :224-237, :1031-1032),docs/Grpc.md(:99-101, validation table :219),docs/Contracts.md(add ack/credential paragraph near :69-71),docs/DesignDecisions.md(:704-713 + new decision section),docs/AlarmClientDiscovery.md(§2-§4 :738-843, :997-1019),docs/AlarmProbeFindings.md(new "Fourth attempt (2026-10-19)" section; update Outcome table),docs/GatewayConfiguration.md(:449-455 replace; addAckClientNamerow 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/AlarmAckByGUIDin current tense or mentionsMXGATEWAY_USE_MANAGED_ALARM_ACK/AaManagedAlarmAckConsumer(grep -rnoverdocs gateway.md CLAUDE.mdreturns only historical, dated finding sections). - Grpc.md documents
operator_userrequired,operator_password/operator_domain, the status mapping (Ok / InvalidRequest / MxaccessFailure) and that Historian records the operator; validation table has theoperator_userrow. - GatewayConfiguration.md documents
MxGateway:Alarms:AckClientName(defaultMxGateway, = Historian OperatorNode). - Diagnostics.md and CLAUDE.md list
AcknowledgeAlarmoperator_passwordamong never-log credentials; DesignDecisions.md has "Alarm acknowledgement writes AckMsg as the authenticated operator" with the why (alarm-client acks inert headless; AckMsg write historizes the authenticated identity). - AlarmProbeFindings.md's new section records: authenticated AckMsg write acks and logs Operator/Domain/FullName/Node; alarm-client acks inert in every variant (secured and free-access, operator names, consumer names, pump).
Verify: grep -rn "MXGATEWAY_USE_MANAGED_ALARM_ACK\|AaManagedAlarmAckConsumer" docs gateway.md CLAUDE.md → only lines inside dated historical sections; manual read of the edited sections.
Steps:
- Step 1: gateway.md — replace :224-237 with:
**Acknowledge:** the gateway acknowledges an alarm by writing the operator's
comment to the alarm attribute's `AckMsg` (subtag mode: the configured
ack-comment subtag) over MXAccess, after `AuthenticateUser` as the requesting
operator (`operator_user`, `operator_password`, optional `operator_domain`).
The AVEVA runtime treats that write as the acknowledgement and historizes it
with the authenticated account's name, domain and full name; the Historian's
OperatorNode is the ack writer's MXAccess client name
(`MxGateway:Alarms:AckClientName`). The worker waits a bounded time for
`OnWriteComplete`: an OK status is reported as success, a non-OK status as
`MxaccessFailure` with the status detail, and no completion as success with a
diagnostic noting the write was sent but unconfirmed. Alarm-client ack calls
(`AlarmAckByName`/`AlarmAckByGUID`, the managed `AlarmClient`, WNAL) are not
used: live tests showed a headless consumer's acks are accepted and silently
dropped (see `docs/AlarmProbeFindings.md`).
and add "AcknowledgeAlarm's operator_password" to the credential sentence at :1031-1032.
- Step 2: Grpc.md — rewrite :99-101 with the request fields, required
operator_user, mechanism (same as Step 1, shorter), outcome mapping table (Applied/Unconfirmed→OK+hresult 0; alarm not found / not ackable →INVALID_REQUEST; authentication failed / write rejected →MXACCESS_FAILURE+hresult), and add validation row| AcknowledgeAlarm | operator_user must be non-empty. | InvalidArgument |. - Step 3: DesignDecisions.md — replace :704-713 ("Acknowledge via ack-comment write") so it states both modes write AckMsg as the operator and fix the stale "empty by default" (default is
AckMsg); add the new decision section with rationale and the credential-handling rule. - Step 4: AlarmClientDiscovery.md — in §2-§4 mark the two-consumer split and by-name ack as historical ("Superseded 2026-10-19: acks now write AckMsg as the operator; see AlarmProbeFindings.md"), delete the "Opt-in managed GUID ack" flag instructions but keep its findings table under a dated heading; update "Acknowledge in subtag mode" (:997-1019) to say the write is authenticated and goes through the ack writer.
- Step 5: AlarmProbeFindings.md — add "## Fourth attempt (2026-10-19): authenticated AckMsg write" with the Historian rows from memory
project_managed_alarm_ack_live_result.md(Administrator/ArchestrA/mxa, NULL-operator unauthenticated row) and the inert-variants list; update the Outcome table's acknowledge row to "Answered — AckMsg write as the operator". - Step 6: GatewayConfiguration.md — replace :449-455 with the
AckClientNameexplanation 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/AcknowledgeAlarmByNameto the documentedSensitiveCommandMethodsset and the name-basedpasswordseam rule; CLAUDE.md :129 append "andAcknowledgeAlarmoperator_password"; Contracts.md paragraph on the ack credential fields and their never-log rule; Authorization.md note thatAcknowledgeAlarmstaysinvoke:writebecause 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
acceptanceCriteriahas 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 serviceMxAccessGw.
Acceptance Criteria:
- Gateway full suite passes once on macOS (
dotnet test src/ZB.MOM.WW.MxGateway.Tests) and windev worker CISUCCESS mode=testwithFailed: 0for the final SHA. - Server + Worker for the final SHA deployed to windev (Server to a dated
Server-YYYYMMDDdir with previousappsettings*.jsoncopied; Worker swapped withWorker.bak-YYYYMMDDbackup);/health/ready200;mxgateway_alarms_provider_mode 1. - With
TestMachine_001.TestAlarm001raised (UNACK_ALM),grpcurl … AcknowledgeAlarm {"alarm_full_reference":"Galaxy!TestArea.TestMachine_001.TestAlarm001","comment":"<unique>","operator_user":"Administrator","operator_password":""}returnsPROTOCOL_STATUS_CODE_OK,hresult0. mxa read TestMachine_001.TestAlarm001.Acked→True;.AckMsg= the unique comment.Runtime.dbo.v_AlarmEventHistory2newestACK_ALMrow forTestMachine_001.TestAlarm001hasOperator = Administrator,DomainName = ArchestrA,OperatorNode = MxGateway,Description = <unique comment>.- Negative: same RPC with
operator_password= a wrong value for a password-protected account (oroperator_user= a non-existent user) returnsPROTOCOL_STATUS_CODE_MXACCESS_FAILURE, the alarm stays unacked, no new ACK_ALM row, and the gateway/worker logs (Server-*/logs/mxgateway-*.log,stderr.logtail) do not contain the password string. - Rig restored: alarm cleared (
mxa write … false --secured -u Administrator), scratch files removed.
Verify: windev: sqlcmd -S localhost -E -W -s "|" -i C:\build\hist-q.sql (query below) → newest ACK_ALM row shows Administrator|ArchestrA|…|MxGateway|<unique comment>
{"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 testfor the final SHA. - Step 2: Deploy per memory
project_deploy_mechanics.md: build in a detached worktree off the CI clone at the final SHA (git -C C:\build\mxaccessgw-ci worktree add C:\build\mgack-deploy <sha>),dotnet publishServer (net10, framework-dependent) toC:\publish\mxaccessgw\Server-YYYYMMDDand copyappsettings.json/appsettings.Development.jsonfrom the current Server dir; publish Worker x86 to a temp dir;Stop-Service MxAccessGw -Force; renameWorker→Worker.bak-YYYYMMDD; copy the new worker;nssm set MxAccessGw Application <new>\ZB.MOM.WW.MxGateway.Server.exeandAppDirectory;Start-Service; check health + metrics. Use base64-EncodedCommandor scp'd.ps1files; read logs by content (not file size). - Step 3: Raise —
mxa write TestMachine_001.TestAlarm001 true --type bool --secured -u Administrator; wait untilQueryActiveAlarmsshows the alarmALARM_CONDITION_STATE_ACTIVE. - Step 4: Ack via RPC from the Mac:
K=$(ssh windev 'type C:\publish\mxaccessgw\.apikey' | tr -d '\r\n'); C="ackmsg-op $(date -u +%H%M%S)"
grpcurl -plaintext -import-path src/ZB.MOM.WW.MxGateway.Contracts/Protos -proto mxaccess_gateway.proto \
-H "authorization: Bearer $K" \
-d "{\"client_correlation_id\":\"ackmsg-live\",\"alarm_full_reference\":\"Galaxy!TestArea.TestMachine_001.TestAlarm001\",\"comment\":\"$C\",\"operator_user\":\"Administrator\",\"operator_password\":\"\"}" \
10.100.0.48:5120 mxaccess_gateway.v1.MxAccessGateway/AcknowledgeAlarm
- Step 5: Check
mxa read TestMachine_001.TestAlarm001.Acked TestMachine_001.TestAlarm001.AckMsg, then the Historian withC:\build\hist-q.sql:
set nocount on;
select top 6 convert(varchar(23),EventStampUTC,121) as utc, AlarmState, TagName, Operator, DomainName, UserFullName, OperatorNode, left(Description,40) as Description
from Runtime.dbo.v_AlarmEventHistory2
where TagName like 'TestMachine_001.TestAlarm001%' and EventStampUTC > dateadd(hour,-3,getutcdate())
order by EventStampUTC desc;
- Step 6: Negative case — clear and re-raise, send the ack with a bad credential (e.g.
operator_user":"NoSuchOperator"), confirmMXACCESS_FAILURE,Acked=False, no ACK row;Select-Stringthe password-like value you sent inC:\publish\mxaccessgw\Server-*\logs\*.logandC:\publish\mxaccessgw\stderr.log→ no matches. - Step 7: Restore — clear the alarm, remove
C:\build\mgack-*,git worktree prune. Record results (Historian rows) indocs/AlarmProbeFindings.md's new section if they differ from Task 12's text, commit, push, open the PR tomainwith a body endinghttps://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).