From bf4b9c6c091865fd0f6854e2c72e93bd4f0b5a3f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 19 Oct 2026 19:15:20 -0400 Subject: [PATCH 01/25] docs(plan): AckMsg-as-operator alarm acknowledgement plan Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT --- .../plans/2026-10-19-ackmsg-as-operator.md | 1867 +++++++++++++++++ ...026-10-19-ackmsg-as-operator.md.tasks.json | 130 ++ 2 files changed, 1997 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-19-ackmsg-as-operator.md create mode 100644 docs/superpowers/plans/2026-10-19-ackmsg-as-operator.md.tasks.json diff --git a/docs/superpowers/plans/2026-10-19-ackmsg-as-operator.md b/docs/superpowers/plans/2026-10-19-ackmsg-as-operator.md new file mode 100644 index 0000000..fb511b1 --- /dev/null +++ b/docs/superpowers/plans/2026-10-19-ackmsg-as-operator.md @@ -0,0 +1,1867 @@ +# AckMsg-as-Operator Alarm Acknowledgement Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development (recommended) or superpowers-extended-cc:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `AcknowledgeAlarm` acknowledge alarms by writing the comment to `.AckMsg` over MXAccess after authenticating as the requesting operator, so the AVEVA Historian events table records the real operator name, domain and full name (the way InTouch does). + +**Architecture:** The public `AcknowledgeAlarmRequest` gains `operator_password` (credential) and `operator_domain`; the gateway forwards them on the worker ack commands. In the x86 worker a new `AlarmAckWriter` owns its own registered `LMXProxyServer`, authenticates the operator (`AuthenticateUser`), advises `.AckMsg`, writes the comment with that user id and waits (bounded, pumped) for `OnWriteComplete`. Alarm consumers stop acking themselves and only resolve the ack item address (alarm-manager GUID → snapshot `TAGNAME`; by-name → tag; subtag → `AckCommentSubtag`). The dead wnwrap/managed alarm-client ack paths are removed. + +**Tech Stack:** .NET 10 gateway, .NET Framework 4.8 x86 worker (no init-only props / positional records), protobuf/gRPC (Grpc.Tools 2.80.0), ArchestrA MXAccess COM (`LMXProxyServerClass`), xUnit; clients in .NET, Python, Go, Rust, Java. + +**Spec:** this document's **Design** section (below) plus the live evidence in memory `project_managed_alarm_ack_live_result.md` and `docs/AlarmClientDiscovery.md` §4. + +## Global Constraints + +- **Field numbers (exact):** `AcknowledgeAlarmRequest.operator_password = 6`, `AcknowledgeAlarmRequest.operator_domain = 7`, `AcknowledgeAlarmCommand.operator_password = 7`, `AcknowledgeAlarmByNameCommand.operator_password = 9`. Additive only; `GatewayProtocolVersion` stays `3`. +- **Credential handling:** `operator_password` must never reach logs, metrics labels, command lines, `DiagnosticMessage`, `ProtocolStatus.Message`, exception messages, or `ToString()`/`Debug` output that is logged. Mirror the existing `AuthenticateUserCommand.verify_user_password` comment and tests (`DispatchAsync_AuthenticateUser_DoesNotLeakPassword`). +- **Ack item address:** `"{TagName}.AckMsg"` for Galaxy-provider alarm-manager alarms (constant `AlarmAckWriter.AckCommentAttribute = "AckMsg"`); subtag mode uses the target's `AckCommentSubtag` verbatim. Alarms whose provider is not `Galaxy` (case-insensitive) are rejected as not ackable. +- **Verify user:** `operator_domain` empty → `AuthenticateUser(operator_user, operator_password)`; non-empty → `AuthenticateUser($"{operator_domain}\\{operator_user}", operator_password)`. +- **operator_user is required** on `AcknowledgeAlarmRequest` (gateway returns `InvalidArgument` when empty) — the requirement is that the real operator is recorded; an empty password IS allowed (Galaxy accounts may have empty passwords). +- **Historian OperatorNode** = the ack writer's MXAccess `Register()` client name: gateway option `MxGateway:Alarms:AckClientName`, default `"MxGateway"`, conveyed to workers as env `MXGATEWAY_ALARM_ACK_CLIENT_NAME`. +- **Reply semantics:** `Hresult`/`AcknowledgeAlarm.NativeStatus` = 0 on applied or unconfirmed-but-sent; non-zero = `AuthenticateUser` HRESULT or the write status `detail`. `reply.Statuses` carries the `OnWriteComplete` `MxStatusProxy` rows when observed. +- **net48 worker:** no `init` accessors, no positional records; `TreatWarningsAsErrors=true`; xUnit1030 forbids `.ConfigureAwait(false)` in `[Fact]` bodies (only surfaces on Windows). +- **Generated code is committed:** `src/ZB.MOM.WW.MxGateway.Contracts/Generated/*.cs`, `clients/proto/descriptors/mxaccessgw-client-v1.protoset`, `clients/rust/protos/mxaccess_gateway.proto`, Go `clients/go/internal/generated/*.pb.go`, Python `clients/python/src/zb_mom_ww_mxgateway/generated/*_pb2*.py` (regenerate with grpcio-tools **1.80.0** only), Java `clients/java/src/main/generated/**`. +- **Docs change in the same commit as behaviour** (CLAUDE.md). Commits end with `Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT`. +- Work on branch `feat/ackmsg-as-operator` off `main`. Run targeted tests per task (`--filter`); full gateway suite at most once at the end. + +**User decisions (already made):** +- "build the AckMsg-as-operator route" — ack = authenticated `AckMsg` write as the operator (not a service account, not the alarm-client string ack). +- "we need to capture the operator name in the historian in the events table just as intouch does" — success is a Historian `ACK_ALM` row whose `Operator` is the requesting operator. +- "yes operators have galaxy accounts" — authenticating each operator with their own Galaxy credentials is acceptable. +- "dont forget to verify the historian is logging the event with the username captured" — live Historian verification is mandatory. +- Earlier (2026-10-18): remove the dead managed alarm-ack path from PR #160 was recommended; this plan removes it because the new path supersedes it. + +--- + +## Design + +**Why this route.** Live tests (2026-10-18/19, windev rig) showed every alarm-client ack (wnwrap `AlarmAckByName`/`AlarmAckByGUID`/`AlarmAck`/`AlarmAckSelected`, managed `AlarmClient.*`, native WNAL `UserRequest`) returns 0 and is silently dropped for a headless consumer, regardless of security classification, operator name or consumer identity. Writing `..AckMsg` over MXAccess **does** acknowledge, and when the write is authenticated (`AuthenticateUser` + `Write(value, userId)`) the Historian `Runtime.dbo.v_AlarmEventHistory2` `ACK_ALM` row carries `Operator`, `DomainName`, `UserFullName` from the authenticated account and `OperatorNode` = the MXAccess client name. + +**Data flow.** +``` +Client AcknowledgeAlarmRequest{alarm_full_reference, comment, operator_user, operator_password, operator_domain} + → MxAccessGatewayService.AcknowledgeAlarm (validates reference + operator_user) + → GatewayAlarmMonitor.AcknowledgeAsync → BuildAcknowledgeCommand + GUID ref → AcknowledgeAlarmCommand{alarm_guid, comment, operator_user, operator_domain, operator_password} + P!G.T ref → AcknowledgeAlarmByNameCommand{alarm_name, provider_name, group_name, comment, operator_user, operator_domain, operator_password} + → worker MxAccessCommandExecutor.ExecuteAcknowledgeAlarm[ByName] + → AlarmCommandHandler.Acknowledge[ByName](…, AlarmAckOperator) → AlarmDispatcher + → consumer.ResolveAckTarget(guid) / ResolveAckTargetByName(name, provider, group) → AlarmAckTarget{ItemAddress} + → AlarmAckWriter.Acknowledge(itemAddress, comment, operator) → AlarmAckResult + → executor maps AlarmAckResult → MxCommandReply (status, hresult, native_status, statuses, diagnostic) +``` + +**New worker types** (namespace `ZB.MOM.WW.MxGateway.Worker.MxAccess`): +- `AlarmAckOperator` — `UserName`, `Domain`, `Password` (get/set; `ToString()` overridden to omit the password); `VerifyUser` computed. +- `AlarmAckTarget` — `Found`, `ItemAddress`, `Outcome` (`AlarmAckOutcome.AlarmNotFound`/`NotAckable` when not found), `Reason`; static factories `For(item)`, `NotFound(reason)`, `NotAckable(reason)`. +- `AlarmAckOutcome` enum — `Applied, Unconfirmed, AlarmNotFound, NotAckable, AuthenticationFailed, WriteRejected`. +- `AlarmAckResult` — `Outcome`, `NativeStatus`, `Diagnostic`, `Statuses` (`IReadOnlyList`); static factories. +- `IAlarmAckWriter : IDisposable` — `AlarmAckResult Acknowledge(string itemAddress, string comment, AlarmAckOperator ackOperator)`. +- `AlarmAckWriter` — production implementation over its own `LMXProxyServerClass`. + +**Removed:** `AaManagedAlarmAckConsumer`, `IManagedAlarmAckClient`, `WnalNativeAckConsumer`, `AlarmManagedGuidAckLiveSmokeTests`, `MXGATEWAY_USE_MANAGED_ALARM_ACK`, `AlarmDispatcher.IsSubtagActive`, `IMxAccessAlarmConsumer.AcknowledgeByGuid/AcknowledgeByName` and their implementations, wnwrap's second `ackClient` consumer, `ISubtagAlarmSource.Write`/`LmxSubtagAlarmSource.Write`. + +**Ack transitions.** wnwrap's XML carries `ACK_COMMENT`, `ACK_OPERATOR_NAME`, `ACK_DOMAIN_NAME`, `ACK_OPERATOR_NODE` once acked. `MxAlarmSnapshotRecord` gains `AckComment`, `AckOperatorName`, `AckOperatorDomain`, `AckOperatorNode`; `AlarmDispatcher` emits Acknowledge transitions (and acked snapshot rows) with `operator_user = AckOperatorName` and `operator_comment = AckComment` when those are present. + +--- + +## File Structure + +| File | Change | Responsibility | +|---|---|---| +| `src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto` | Modify | new fields + comments | +| `src/ZB.MOM.WW.MxGateway.Contracts/Generated/*.cs` | Regenerate | committed C# bindings | +| `src/ZB.MOM.WW.MxGateway.Contracts/WorkerLaunchEnvironment.cs` | Modify | `AlarmAckClientName` env name | +| `clients/proto/descriptors/mxaccessgw-client-v1.protoset` | Regenerate | client descriptor | +| `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAck*.cs` (`AlarmAckOperator`, `AlarmAckTarget`, `AlarmAckOutcome`, `AlarmAckResult`, `IAlarmAckWriter`, `AlarmAckWriter`) | Create | ack value types + writer | +| `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAlarmSnapshot.cs` | Modify | `Ack*` fields | +| `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs` | Modify | parse `ACK_*`, `ResolveAckTarget*`, drop ack consumer | +| `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmConsumer.cs`, `FailoverAlarmConsumer.cs`, `IMxAccessAlarmConsumer.cs`, `ISubtagAlarmSource.cs`, `LmxSubtagAlarmSource.cs` | Modify | resolve-only ack contract | +| `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs`, `AlarmCommandHandler.cs`, `IAlarmCommandHandler.cs`, `MxAccessCommandExecutor.cs` | Modify | route acks to writer, map result | +| `src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerPipeSession.cs` | Modify | drop managed factory | +| `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/{AaManagedAlarmAckConsumer,IManagedAlarmAckClient,WnalNativeAckConsumer}.cs`, `src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmManagedGuidAckLiveSmokeTests.cs` | Delete | dead path | +| `src/ZB.MOM.WW.MxGateway.Server/Configuration/AlarmsOptions.cs`, `GatewayOptionsValidator.cs`, `Workers/WorkerProcessLauncher.cs` | Modify | `AckClientName` option + env | +| `src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs`, `Grpc/MxAccessGatewayService.cs` | Modify | forward credentials, require operator_user | +| `src/ZB.MOM.WW.MxGateway.Server/Diagnostics/GatewayLogRedactor.cs`, `GatewayLogRedactorSeam.cs` | Modify | ack methods credential-bearing; name-based `password` redaction | +| `clients/{dotnet,python,go,rust,java}/**` | Modify | regenerate, CLI flags, redaction, tests, README | +| `gateway.md`, `docs/{Grpc,Contracts,DesignDecisions,AlarmClientDiscovery,AlarmProbeFindings,GatewayConfiguration,Diagnostics,Authorization}.md`, `CLAUDE.md` | Modify | describe new behaviour | + +--- + +### Task 1: Contract fields and regenerated bindings + +**Goal:** Add the credential/domain fields to the public request and the worker ack commands, plus the worker env-var name, and commit regenerated C# bindings and the client descriptor. + +**Files:** +- Modify: `src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto` (`AcknowledgeAlarmCommand` ~394-406, `AcknowledgeAlarmByNameCommand` ~415-434, `AcknowledgeAlarmReplyPayload` ~726-737, `AcknowledgeAlarmRequest` ~977-990) +- Modify: `src/ZB.MOM.WW.MxGateway.Contracts/WorkerLaunchEnvironment.cs` +- Regenerate: `src/ZB.MOM.WW.MxGateway.Contracts/Generated/MxaccessGateway.cs`, `MxaccessGatewayGrpc.cs`; `clients/proto/descriptors/mxaccessgw-client-v1.protoset`; `clients/rust/protos/mxaccess_gateway.proto` (byte copy) +- Test: `src/ZB.MOM.WW.MxGateway.Tests/Contracts/ProtobufContractRoundTripTests.cs` + +**Acceptance Criteria:** +- [ ] `AcknowledgeAlarmRequest` has `operator_password = 6` and `operator_domain = 7`; `AcknowledgeAlarmCommand` has `operator_password = 7`; `AcknowledgeAlarmByNameCommand` has `operator_password = 9`; each password field carries the credential comment. +- [ ] `WorkerLaunchEnvironment.AlarmAckClientName == "MXGATEWAY_ALARM_ACK_CLIENT_NAME"`. +- [ ] Round-trip tests cover the new fields and pass. +- [ ] `pwsh -File scripts/publish-client-proto-inputs.ps1 -Check` passes and `ClientProtoInputTests` passes. +- [ ] Rust vendored proto is byte-identical to the Contracts proto. + +**Verify:** `dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter "FullyQualifiedName~ProtobufContractRoundTripTests|FullyQualifiedName~ClientProtoInputTests"` → all pass + +**Steps:** + +- [ ] **Step 1: Create the branch** + +```bash +git checkout main && git pull --ff-only origin main && git checkout -b feat/ackmsg-as-operator +``` + +- [ ] **Step 2: Write failing round-trip tests** — in `ProtobufContractRoundTripTests.cs`, extend `AcknowledgeAlarmRequest_RoundTripsAllFields` and add command round-trips: + +```csharp + [Fact] + public void AcknowledgeAlarmRequest_RoundTripsAllFields() + { + AcknowledgeAlarmRequest original = new() + { + ClientCorrelationId = "corr-1", + AlarmFullReference = "Galaxy!TestArea.TestMachine_001.TestAlarm001", + Comment = "investigating", + OperatorUser = "alice", + OperatorPassword = "s3cret", + OperatorDomain = "CORP", + }; + + AcknowledgeAlarmRequest parsed = AcknowledgeAlarmRequest.Parser.ParseFrom(original.ToByteArray()); + + Assert.Equal(original, parsed); + Assert.Equal("s3cret", parsed.OperatorPassword); + Assert.Equal("CORP", parsed.OperatorDomain); + } + + [Fact] + public void AcknowledgeAlarmCommands_RoundTripOperatorPassword() + { + MxCommand byGuid = new() + { + Kind = MxCommandKind.AcknowledgeAlarm, + AcknowledgeAlarmCommand = new AcknowledgeAlarmCommand + { + AlarmGuid = "BCC47053-9542-4D65-BDAA-BCDEA6A32A73", + OperatorUser = "alice", + OperatorDomain = "CORP", + OperatorPassword = "s3cret", + }, + }; + MxCommand byName = new() + { + Kind = MxCommandKind.AcknowledgeAlarmByName, + AcknowledgeAlarmByNameCommand = new AcknowledgeAlarmByNameCommand + { + AlarmName = "TestMachine_001.TestAlarm001", + ProviderName = "Galaxy", + GroupName = "TestArea", + OperatorUser = "alice", + OperatorPassword = "s3cret", + }, + }; + + Assert.Equal("s3cret", MxCommand.Parser.ParseFrom(byGuid.ToByteArray()).AcknowledgeAlarmCommand.OperatorPassword); + Assert.Equal("s3cret", MxCommand.Parser.ParseFrom(byName.ToByteArray()).AcknowledgeAlarmByNameCommand.OperatorPassword); + } +``` + +- [ ] **Step 3: Run to verify they fail to compile** — `dotnet build src/ZB.MOM.WW.MxGateway.Tests` → CS1061 `OperatorPassword` not found. + +- [ ] **Step 4: Edit the proto.** In `AcknowledgeAlarmCommand` replace the leading comment and append field 7: + +```proto +// Acknowledge a single alarm by its GUID. The worker resolves the GUID to the +// alarm's Galaxy reference from its active-alarm snapshot and acknowledges it +// by writing `comment` to `.AckMsg` over MXAccess after +// AuthenticateUser as the operator, so the alarm history records the real +// operator. The reply's hresult / native_status is 0 when the write was +// applied (or sent but unconfirmed), otherwise the AuthenticateUser HRESULT or +// the OnWriteComplete status detail. +message AcknowledgeAlarmCommand { + // Canonical 8-4-4-4-12 GUID string (e.g. "BCC47053-9542-4D65-BDAA-BCDEA6A32A73"). + string alarm_guid = 1; + string comment = 2; + string operator_user = 3; + string operator_node = 4; + string operator_domain = 5; + string operator_full_name = 6; + // Operator credential used for AuthenticateUser before the AckMsg write. + // Implementations must keep this field out of logs, metrics labels, command + // lines, and diagnostics. + string operator_password = 7; +} +``` + +In `AcknowledgeAlarmByNameCommand` replace the leading comment's last two lines ("The worker invokes wwAlarmConsumerClass.AlarmAckByName which reaches the same alarm history path as AlarmAckByGUID.") with "The worker acknowledges by writing `comment` to `.AckMsg` (Galaxy provider only) after AuthenticateUser as the operator." and append: + +```proto + // Operator credential used for AuthenticateUser before the AckMsg write. + // Implementations must keep this field out of logs, metrics labels, command + // lines, and diagnostics. + string operator_password = 9; +``` + +In the `AcknowledgeAlarmReplyPayload` comment replace "Surfaces AVEVA's native ack return code (AlarmAckByGUID for the GUID arm, AlarmAckByName for the by-name arm); 0 means success." with "Surfaces the ack outcome: 0 when the AckMsg write was applied or sent but unconfirmed; otherwise the AuthenticateUser HRESULT or the OnWriteComplete status detail." and replace "the gateway-side WorkerAlarmRpcDispatcher" with "GatewayAlarmMonitor". + +In `AcknowledgeAlarmRequest` append after `operator_user = 5`: + +```proto + // Operator's Galaxy (or OS) password. The gateway authenticates as + // operator_user with it before writing the acknowledgement so the alarm + // history records the real operator. May be empty for accounts without a + // password. Credential-sensitive: implementations must keep this field out + // of logs, metrics labels, command lines, and diagnostics. + string operator_password = 6; + // Optional domain for OS-authenticated galaxies. When set the gateway + // authenticates as "\". + string operator_domain = 7; +``` + +Also update the `AcknowledgeAlarmReply.hresult` comment: replace "= AlarmAckByName / AlarmAckByGUID return code; 0 = success); the gateway's WorkerAlarmRpcDispatcher copies" with "0 = applied or sent; otherwise the AuthenticateUser HRESULT or OnWriteComplete detail); GatewayAlarmMonitor copies". + +- [ ] **Step 5: Add the env name** in `WorkerLaunchEnvironment.cs` after `MaxAlarmsPerFetch`: + +```csharp + /// + /// MXAccess client name the worker's alarm-ack writer registers with. The + /// Historian records it as the acknowledgement's OperatorNode. + /// + public const string AlarmAckClientName = "MXGATEWAY_ALARM_ACK_CLIENT_NAME"; +``` + +- [ ] **Step 6: Regenerate and copy** + +```bash +rm src/ZB.MOM.WW.MxGateway.Contracts/Generated/*.cs +dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj +pwsh -File scripts/publish-client-proto-inputs.ps1 +cp src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto clients/rust/protos/mxaccess_gateway.proto +pwsh -File scripts/publish-client-proto-inputs.ps1 -Check +``` + +- [ ] **Step 7: Run tests** — the Verify command → PASS. + +- [ ] **Step 8: Commit** + +```bash +git add src/ZB.MOM.WW.MxGateway.Contracts clients/proto/descriptors clients/rust/protos src/ZB.MOM.WW.MxGateway.Tests/Contracts/ProtobufContractRoundTripTests.cs +git commit -m "feat(contracts): carry operator credentials on alarm acknowledgement" -m "Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT" +``` + +--- + +### Task 2: Parse wnwrap ACK_* fields and attribute ack transitions + +**Goal:** Capture the `ACK_COMMENT`/`ACK_OPERATOR_NAME`/`ACK_DOMAIN_NAME`/`ACK_OPERATOR_NODE` elements on snapshot records and use them for Acknowledge transitions and acked snapshot rows. + +**Files:** +- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAlarmSnapshot.cs` +- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs` (`ParseSnapshotXml` ~739-849) +- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs` (`OnTransition` ~235-260, `MapToSnapshot` ~273-274) +- Test: `src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/WnWrapAlarmConsumerXmlTests.cs`, `AlarmDispatcherTests.cs` + +**Acceptance Criteria:** +- [ ] `ParseSnapshotXml` fills `AckComment`, `AckOperatorName`, `AckOperatorDomain`, `AckOperatorNode` from the live XML sample below. +- [ ] An `UnackAlm → AckAlm` transition whose record has `AckOperatorName="alice"`, `AckComment="checked"` enqueues an `OnAlarmTransitionEvent` with `OperatorUser == "alice"` and `OperatorComment == "checked"`. +- [ ] Non-ack transitions keep `OperatorUser = OperatorName`, `OperatorComment = AlarmComment`. + +**Verify:** `dotnet test src/ZB.MOM.WW.MxGateway.Worker.Tests/ZB.MOM.WW.MxGateway.Worker.Tests.csproj -p:Platform=x86 --filter "FullyQualifiedName~WnWrapAlarmConsumerXmlTests|FullyQualifiedName~AlarmDispatcherTests"` (windev) → pass + +**Steps:** + +- [ ] **Step 1: Failing parser test** in `WnWrapAlarmConsumerXmlTests.cs`: + +```csharp + [Fact] + public void ParseSnapshotXml_WithAckElements_PopulatesAckFields() + { + const string xml = + "2D8A9BE88FE04B0F8D996BB4D56620C7" + + "2026/10/192400" + + "DESKTOP-6JL3KKOGalaxyTestArea" + + "TestMachine_001.TestAlarm001DSCtruetrue" + + "500ACK_ALM" + + "ArchestrAmxa" + + "AdministratorTest alarm #1" + + "mgack-authwrite 180714"; + + Dictionary records = WnWrapAlarmConsumer.ParseSnapshotXml(xml); + + MxAlarmSnapshotRecord record = Assert.Single(records.Values); + Assert.Equal("Administrator", record.AckOperatorName); + Assert.Equal("ArchestrA", record.AckOperatorDomain); + Assert.Equal("mxa", record.AckOperatorNode); + Assert.Equal("mgack-authwrite 180714", record.AckComment); + Assert.Equal("Test alarm #1", record.AlarmComment); + } +``` + +- [ ] **Step 2: Failing dispatcher test** in `AlarmDispatcherTests.cs` (reuses the file's `FakeAlarmConsumer`): + +```csharp + [Fact] + public void OnTransition_WhenAcknowledged_UsesAckOperatorAndComment() + { + FakeAlarmConsumer consumer = new FakeAlarmConsumer(); + MxAccessEventQueue queue = new MxAccessEventQueue(); + using AlarmDispatcher dispatcher = new AlarmDispatcher( + consumer, new MxAccessAlarmEventSink(queue, new MxAccessEventMapper()), SessionId, new FakeAckWriter()); + + consumer.RaiseTransition(new MxAlarmTransitionEvent + { + PreviousState = MxAlarmStateKind.UnackAlm, + Record = new MxAlarmSnapshotRecord + { + AlarmGuid = Guid.NewGuid(), + ProviderName = "Galaxy", + Group = "TestArea", + TagName = "TestMachine_001.TestAlarm001", + State = MxAlarmStateKind.AckAlm, + AlarmComment = "Test alarm #1", + AckOperatorName = "alice", + AckComment = "checked", + }, + }); + + Assert.True(queue.TryDequeue(out WorkerEvent? evt)); + OnAlarmTransitionEvent body = evt!.Event.OnAlarmTransition; + Assert.Equal(AlarmTransitionKind.Acknowledge, body.TransitionKind); + Assert.Equal("alice", body.OperatorUser); + Assert.Equal("checked", body.OperatorComment); + } +``` + +(`FakeAckWriter` and the 4-arg `AlarmDispatcher` constructor arrive in Task 5; until then write this test against the current 3-arg constructor — drop the 4th argument — and update it in Task 5.) + +- [ ] **Step 3: Add fields** to `MxAlarmSnapshotRecord` (after `AlarmComment`): + +```csharp + /// Gets or sets the acknowledgement comment (wnwrap ACK_COMMENT). + public string AckComment { get; set; } = string.Empty; + + /// Gets or sets the acknowledging operator's name (wnwrap ACK_OPERATOR_NAME). + public string AckOperatorName { get; set; } = string.Empty; + + /// Gets or sets the acknowledging operator's domain (wnwrap ACK_DOMAIN_NAME). + public string AckOperatorDomain { get; set; } = string.Empty; + + /// Gets or sets the acknowledging node, i.e. the MXAccess client name (wnwrap ACK_OPERATOR_NODE). + public string AckOperatorNode { get; set; } = string.Empty; +``` + +- [ ] **Step 4: Parse them** in `ParseSnapshotXml`: declare `string? ackComment = null; string? ackOperatorName = null; string? ackOperatorDomain = null; string? ackOperatorNode = null;` next to `alarmComment`; add switch arms + +```csharp + case "ACK_COMMENT": ackComment ??= child.InnerText; break; + case "ACK_OPERATOR_NAME": ackOperatorName ??= child.InnerText; break; + case "ACK_DOMAIN_NAME": ackOperatorDomain ??= child.InnerText; break; + case "ACK_OPERATOR_NODE": ackOperatorNode ??= child.InnerText; break; +``` + +and initializers `AckComment = ackComment ?? string.Empty, AckOperatorName = ackOperatorName ?? string.Empty, AckOperatorDomain = ackOperatorDomain ?? string.Empty, AckOperatorNode = ackOperatorNode ?? string.Empty,`. + +- [ ] **Step 5: Use them in `AlarmDispatcher`.** Add helpers and use them in `OnTransition` and `MapToSnapshot`: + +```csharp + private static bool HasAckAttribution(MxAlarmSnapshotRecord record) => + !string.IsNullOrEmpty(record.AckOperatorName) || !string.IsNullOrEmpty(record.AckComment); + + private static bool IsAcked(MxAlarmStateKind state) => + state == MxAlarmStateKind.AckAlm || state == MxAlarmStateKind.AckRtn; +``` + +In `OnTransition` replace `operatorUser: record.OperatorName, operatorComment: record.AlarmComment,` with: + +```csharp + bool useAck = kind == AlarmTransitionKind.Acknowledge && HasAckAttribution(record); + ... + operatorUser: useAck ? record.AckOperatorName : record.OperatorName, + operatorComment: useAck ? record.AckComment : record.AlarmComment, +``` + +In `MapToSnapshot` use `IsAcked(record.State) && HasAckAttribution(record)` the same way. (Check `MxAlarmStateKind` member names in `AlarmRecordTransitionMapper.ParseStateKind`; use the exact acked members it defines.) + +- [ ] **Step 6: Commit** after the Verify command passes on windev (or after Task 5 when verifying the worker together): + +```bash +git add src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAlarmSnapshot.cs src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess +git commit -m "feat(worker): attribute alarm ack transitions from wnwrap ACK fields" -m "Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT" +``` + +--- + +### Task 3: AlarmAckWriter (authenticated AckMsg write) + +**Goal:** Add the ack value types and `AlarmAckWriter`, which authenticates the operator on its own registered MXAccess server, writes the comment to the ack item, and waits for `OnWriteComplete`. + +**Files:** +- Create: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckOperator.cs`, `AlarmAckOutcome.cs`, `AlarmAckTarget.cs`, `AlarmAckResult.cs`, `IAlarmAckWriter.cs`, `AlarmAckWriter.cs` +- Test: `src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmAckWriterTests.cs` + +**Acceptance Criteria:** +- [ ] With a fake `IMxAccessServer` returning user id 5: `Acknowledge("T.A.AckMsg", "c", {alice, CORP, pw})` calls `Register(clientName)` once, `AuthenticateUser(h, "CORP\\alice", "pw")`, `AddItem("T.A.AckMsg")`, `Advise`, `Write(h, item, "c", 5)` in that order; a recorded `OnWriteComplete` with category Ok yields `Outcome == Applied`, `NativeStatus == 0`. +- [ ] Completion with category `SecurityError`, detail 1008 → `WriteRejected`, `NativeStatus == 1008`, statuses returned. +- [ ] No completion before the timeout → `Unconfirmed`, `NativeStatus == 0`. +- [ ] `AuthenticateUser` throwing `COMException(hr)` or returning `0` → `AuthenticationFailed`, no write; the password never appears in `Diagnostic` or in the fake's operation log. +- [ ] A second ack to the same item reuses the item handle (one `AddItem`, one `Advise`). +- [ ] `AlarmAckOperator.ToString()` does not contain the password. + +**Verify:** `dotnet test src/ZB.MOM.WW.MxGateway.Worker.Tests/ZB.MOM.WW.MxGateway.Worker.Tests.csproj -p:Platform=x86 --filter "FullyQualifiedName~AlarmAckWriterTests"` (windev) → pass + +**Steps:** + +- [ ] **Step 1: Value types.** + +`AlarmAckOperator.cs`: +```csharp +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; + +/// +/// Operator identity an alarm acknowledgement authenticates as. The password +/// is a raw credential: it is passed to MXAccess AuthenticateUser only +/// and must never be logged or echoed. +/// +public sealed class AlarmAckOperator +{ + /// Gets or sets the operator user name. + public string UserName { get; set; } = string.Empty; + + /// Gets or sets the optional domain for OS-authenticated galaxies. + public string Domain { get; set; } = string.Empty; + + /// Gets or sets the operator password (credential-sensitive). + public string Password { get; set; } = string.Empty; + + /// Gets the user string passed to AuthenticateUser: Domain\UserName when a domain is set. + public string VerifyUser => string.IsNullOrEmpty(Domain) ? UserName : Domain + "\\" + UserName; + + /// + public override string ToString() => "AlarmAckOperator(" + VerifyUser + ")"; +} +``` + +`AlarmAckOutcome.cs`: +```csharp +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; + +/// Outcome of an alarm acknowledgement attempt. +public enum AlarmAckOutcome +{ + /// The AckMsg write completed with an OK status. + Applied, + /// The write was sent but no OnWriteComplete arrived before the timeout. + Unconfirmed, + /// The alarm is not in the consumer's current view. + AlarmNotFound, + /// The alarm exists but has no MXAccess ack attribute (non-Galaxy provider or no ack subtag). + NotAckable, + /// AuthenticateUser rejected the operator credentials. + AuthenticationFailed, + /// The AckMsg write completed with a non-OK status. + WriteRejected, +} +``` + +`AlarmAckTarget.cs`: +```csharp +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; + +/// The MXAccess item an acknowledgement writes, or why none exists. +public sealed class AlarmAckTarget +{ + private AlarmAckTarget(string itemAddress, AlarmAckOutcome failure, string reason) + { + ItemAddress = itemAddress; + Failure = failure; + Reason = reason; + } + + /// Gets a value indicating whether an ack item was resolved. + public bool Found => ItemAddress.Length > 0; + + /// Gets the ack item address (e.g. TestMachine_001.TestAlarm001.AckMsg), empty when not found. + public string ItemAddress { get; } + + /// Gets the failure outcome when is false. + public AlarmAckOutcome Failure { get; } + + /// Gets a human-readable reason when is false. + public string Reason { get; } + + /// Creates a resolved target. + /// The ack item address. + /// The target. + public static AlarmAckTarget For(string itemAddress) => + new AlarmAckTarget(itemAddress ?? string.Empty, AlarmAckOutcome.Applied, string.Empty); + + /// Creates a not-found result. + /// Why the alarm was not found. + /// The target. + public static AlarmAckTarget NotFound(string reason) => + new AlarmAckTarget(string.Empty, AlarmAckOutcome.AlarmNotFound, reason); + + /// Creates a not-ackable result. + /// Why the alarm cannot be acknowledged over MXAccess. + /// The target. + public static AlarmAckTarget NotAckable(string reason) => + new AlarmAckTarget(string.Empty, AlarmAckOutcome.NotAckable, reason); +} +``` + +`AlarmAckResult.cs`: +```csharp +using System.Collections.Generic; +using ZB.MOM.WW.MxGateway.Contracts.Proto; + +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; + +/// Result of an alarm acknowledgement. +public sealed class AlarmAckResult +{ + private static readonly IReadOnlyList NoStatuses = new MxStatusProxy[0]; + + private AlarmAckResult(AlarmAckOutcome outcome, int nativeStatus, string diagnostic, IReadOnlyList? statuses) + { + Outcome = outcome; + NativeStatus = nativeStatus; + Diagnostic = diagnostic; + Statuses = statuses ?? NoStatuses; + } + + /// Gets the outcome. + public AlarmAckOutcome Outcome { get; } + + /// Gets the native status: 0 for applied/unconfirmed, else the HRESULT or status detail. + public int NativeStatus { get; } + + /// Gets a diagnostic message. Never contains credentials. + public string Diagnostic { get; } + + /// Gets the OnWriteComplete statuses, empty when none was observed. + public IReadOnlyList Statuses { get; } + + /// Creates a result. + /// The outcome. + /// The native status. + /// A credential-free diagnostic. + /// Observed write statuses. + /// The result. + public static AlarmAckResult Create( + AlarmAckOutcome outcome, + int nativeStatus, + string diagnostic, + IReadOnlyList? statuses = null) => + new AlarmAckResult(outcome, nativeStatus, diagnostic ?? string.Empty, statuses); + + /// Creates the result for an unresolved ack target. + /// The unresolved target. + /// The result. + public static AlarmAckResult FromTarget(AlarmAckTarget target) => + new AlarmAckResult(target.Failure, -1, target.Reason, null); +} +``` + +`IAlarmAckWriter.cs`: +```csharp +using System; + +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; + +/// +/// Acknowledges an alarm by writing its MXAccess ack item (AckMsg) +/// after authenticating as the operator. Must be called on the worker STA. +/// +public interface IAlarmAckWriter : IDisposable +{ + /// Authenticates and writes to . + /// The ack item, e.g. Obj.Alarm.AckMsg. + /// The acknowledgement comment. + /// The operator to authenticate as. + /// The outcome. + AlarmAckResult Acknowledge(string itemAddress, string comment, AlarmAckOperator ackOperator); +} +``` + +- [ ] **Step 2: Failing tests** `AlarmAckWriterTests.cs`. Build a `RecordingAckServer : IMxAccessServer` modelled on `LmxSubtagAlarmSourceTests.RecordingMxAccessServer` (copy that class and change: `Register` records the client name and returns 7; `AuthenticateUser` records `verifyUser` (NOT the password) into `Operations`, stores the password in `LastPassword`, returns `AuthenticateResult` or throws `AuthenticateException`; `Write` records `(itemHandle, value, userId)` and invokes `OnWrite`). Tests construct the writer through the internal seam `new AlarmAckWriter(server, completionCache, pumpStep: () => { }, timeout)`: + +```csharp +public sealed class AlarmAckWriterTests +{ + private static AlarmAckOperator Alice() => + new AlarmAckOperator { UserName = "alice", Domain = "CORP", Password = "pw-secret" }; + + private static RepeatedField Rows(MxStatusCategory category, int detail) => + new RepeatedField { new MxStatusProxy { Success = category == MxStatusCategory.Ok ? 1 : 0, Category = category, Detail = detail } }; + + [Fact] + public void Acknowledge_AuthenticatesThenWritesCommentWithOperatorUserId() + { + RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 }; + MxAccessWriteCompletionCache cache = new MxAccessWriteCompletionCache(); + server.OnWrite = (handle, item) => cache.Record(handle, item, Rows(MxStatusCategory.Ok, 0)); + using AlarmAckWriter writer = new AlarmAckWriter(server, cache, () => { }, TimeSpan.FromSeconds(1), "MxGateway"); + + AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "checked", Alice()); + + Assert.Equal(AlarmAckOutcome.Applied, result.Outcome); + Assert.Equal(0, result.NativeStatus); + Assert.Equal(new[] { "Register:MxGateway", "AuthenticateUser:CORP\\alice", "AddItem:T.A.AckMsg", "Advise", "Write:checked:5" }, server.Operations); + Assert.Equal("pw-secret", server.LastPassword); + } + + [Fact] + public void Acknowledge_WhenWriteRejected_ReturnsDetailAndStatuses() + { + RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 }; + MxAccessWriteCompletionCache cache = new MxAccessWriteCompletionCache(); + server.OnWrite = (handle, item) => cache.Record(handle, item, Rows(MxStatusCategory.SecurityError, 1008)); + using AlarmAckWriter writer = new AlarmAckWriter(server, cache, () => { }, TimeSpan.FromSeconds(1), "MxGateway"); + + AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "checked", Alice()); + + Assert.Equal(AlarmAckOutcome.WriteRejected, result.Outcome); + Assert.Equal(1008, result.NativeStatus); + Assert.Single(result.Statuses); + } + + [Fact] + public void Acknowledge_WhenNoCompletion_ReturnsUnconfirmed() + { + RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 }; + using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(50), "MxGateway"); + + AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "checked", Alice()); + + Assert.Equal(AlarmAckOutcome.Unconfirmed, result.Outcome); + Assert.Equal(0, result.NativeStatus); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Acknowledge_WhenAuthenticationFails_DoesNotWriteOrLeakPassword(bool throwComException) + { + RecordingAckServer server = new RecordingAckServer + { + AuthenticateResult = 0, + AuthenticateException = throwComException ? new COMException("bad credentials", unchecked((int)0x80070005)) : null, + }; + using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(50), "MxGateway"); + + AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "checked", Alice()); + + Assert.Equal(AlarmAckOutcome.AuthenticationFailed, result.Outcome); + Assert.NotEqual(0, result.NativeStatus); + Assert.DoesNotContain(server.Operations, op => op.StartsWith("Write", StringComparison.Ordinal)); + Assert.DoesNotContain("pw-secret", result.Diagnostic, StringComparison.Ordinal); + Assert.DoesNotContain(server.Operations, op => op.Contains("pw-secret")); + } + + [Fact] + public void Acknowledge_Twice_ReusesItemHandle() + { + RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 }; + using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(10), "MxGateway"); + + writer.Acknowledge("T.A.AckMsg", "one", Alice()); + writer.Acknowledge("T.A.AckMsg", "two", Alice()); + + Assert.Equal(1, server.Operations.Count(op => op.StartsWith("AddItem", StringComparison.Ordinal))); + Assert.Equal(1, server.Operations.Count(op => op == "Advise")); + } + + [Fact] + public void AlarmAckOperator_ToString_OmitsPassword() + { + Assert.DoesNotContain("pw-secret", Alice().ToString(), StringComparison.Ordinal); + } +} +``` + +- [ ] **Step 3: Implement `AlarmAckWriter.cs`.** + +```csharp +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using ArchestrA.MxAccess; +using Google.Protobuf.Collections; +using ZB.MOM.WW.MxGateway.Contracts; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Worker.Conversion; + +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; + +/// +/// Acknowledges alarms the way the AVEVA runtime records operator identity: +/// AuthenticateUser as the operator, then write the comment to the alarm's +/// AckMsg attribute with that user id. Owns its own +/// LMXProxyServerClass registered under the configured client name, +/// which the Historian records as the acknowledgement's OperatorNode. +/// Apartment-threaded: every call must run on the worker STA. The password +/// is passed to MXAccess only; it never reaches diagnostics. +/// +public sealed class AlarmAckWriter : IAlarmAckWriter +{ + /// The ArchestrA alarm-primitive attribute whose write acknowledges the alarm. + public const string AckCommentAttribute = "AckMsg"; + + /// Default MXAccess client name when none is configured. + public const string DefaultClientName = "MxGateway"; + + private readonly IMxAccessComObjectFactory? factory; + private readonly MxAccessWriteCompletionCache completionCache; + private readonly Action pumpStep; + private readonly TimeSpan completionTimeout; + private readonly string clientName; + private readonly MxStatusProxyConverter statusConverter = new MxStatusProxyConverter(); + private readonly Dictionary itemHandles = new Dictionary(StringComparer.OrdinalIgnoreCase); + private object? comObject; + private LMXProxyServerClass? comEventSource; + private IMxAccessServer? server; + private int serverHandle; + private bool disposed; + + /// Creates a writer that registers its own MXAccess server on first use. + /// Creates the LMXProxyServerClass. + /// Drains the STA message queue (OnWriteComplete is delivered by pumping). + /// Bounded wait for OnWriteComplete. + /// MXAccess client name (Historian OperatorNode). + public AlarmAckWriter( + IMxAccessComObjectFactory factory, + Action pumpStep, + TimeSpan completionTimeout, + string? clientName) + { + this.factory = factory ?? throw new ArgumentNullException(nameof(factory)); + this.pumpStep = pumpStep ?? throw new ArgumentNullException(nameof(pumpStep)); + this.completionTimeout = completionTimeout; + this.clientName = string.IsNullOrWhiteSpace(clientName) ? DefaultClientName : clientName!; + completionCache = new MxAccessWriteCompletionCache(); + } + + /// Test seam: uses an already-constructed server and completion cache. + internal AlarmAckWriter( + IMxAccessServer server, + MxAccessWriteCompletionCache completionCache, + Action pumpStep, + TimeSpan completionTimeout, + string clientName) + { + this.server = server ?? throw new ArgumentNullException(nameof(server)); + this.completionCache = completionCache ?? throw new ArgumentNullException(nameof(completionCache)); + this.pumpStep = pumpStep ?? throw new ArgumentNullException(nameof(pumpStep)); + this.completionTimeout = completionTimeout; + this.clientName = clientName; + serverHandle = server.Register(clientName); + } + + /// Resolves the configured client name from the launcher environment. + /// The client name, or . + public static string ResolveClientName() + { + string? value = Environment.GetEnvironmentVariable(WorkerLaunchEnvironment.AlarmAckClientName); + return string.IsNullOrWhiteSpace(value) ? DefaultClientName : value!.Trim(); + } + + /// + public AlarmAckResult Acknowledge(string itemAddress, string comment, AlarmAckOperator ackOperator) + { + if (disposed) throw new ObjectDisposedException(nameof(AlarmAckWriter)); + if (string.IsNullOrWhiteSpace(itemAddress)) throw new ArgumentException("Ack item address is required.", nameof(itemAddress)); + if (ackOperator is null) throw new ArgumentNullException(nameof(ackOperator)); + + IMxAccessServer mx = EnsureRegistered(); + + int userId; + try + { + userId = mx.AuthenticateUser(serverHandle, ackOperator.VerifyUser, ackOperator.Password ?? string.Empty); + } + catch (COMException ex) + { + return AlarmAckResult.Create( + AlarmAckOutcome.AuthenticationFailed, + ex.HResult, + $"Operator authentication failed for '{ackOperator.VerifyUser}' (HRESULT 0x{ex.HResult:X8})."); + } + + if (userId == 0) + { + return AlarmAckResult.Create( + AlarmAckOutcome.AuthenticationFailed, + -1, + $"Operator authentication failed for '{ackOperator.VerifyUser}' (no user id returned)."); + } + + if (!itemHandles.TryGetValue(itemAddress, out int itemHandle)) + { + itemHandle = mx.AddItem(serverHandle, itemAddress); + // MXAccess rejects writes to added-but-unadvised items with E_INVALIDARG. + mx.Advise(serverHandle, itemHandle); + itemHandles[itemAddress] = itemHandle; + } + + pumpStep(); + ulong baseline = completionCache.CurrentVersion(serverHandle, itemHandle); + mx.Write(serverHandle, itemHandle, comment ?? string.Empty, userId); + + if (!completionCache.TryWaitForCompletion( + serverHandle, + itemHandle, + baseline, + DateTime.UtcNow + completionTimeout, + pumpStep, + out RepeatedField statuses)) + { + return AlarmAckResult.Create( + AlarmAckOutcome.Unconfirmed, + 0, + $"AckMsg write to '{itemAddress}' sent; OnWriteComplete not observed within {completionTimeout.TotalMilliseconds:F0} ms."); + } + + foreach (MxStatusProxy status in statuses) + { + if (status.Category != MxStatusCategory.Ok) + { + return AlarmAckResult.Create( + AlarmAckOutcome.WriteRejected, + status.Detail != 0 ? status.Detail : -1, + $"AckMsg write to '{itemAddress}' rejected: category {status.Category}, detail {status.Detail}.", + statuses); + } + } + + return AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, string.Empty, statuses); + } + + /// + public void Dispose() + { + if (disposed) return; + disposed = true; + + if (comEventSource is not null) + { + try { comEventSource.OnWriteComplete -= OnWriteComplete; } catch { /* best effort */ } + } + + IMxAccessServer? mx = server; + if (mx is not null) + { + foreach (int itemHandle in itemHandles.Values) + { + try { mx.UnAdvise(serverHandle, itemHandle); } catch { /* best effort */ } + try { mx.RemoveItem(serverHandle, itemHandle); } catch { /* best effort */ } + } + + try { mx.Unregister(serverHandle); } catch { /* best effort */ } + } + + itemHandles.Clear(); + server = null; + comEventSource = null; + object? toRelease = comObject; + comObject = null; + if (toRelease is not null && Marshal.IsComObject(toRelease)) + { + try { Marshal.FinalReleaseComObject(toRelease); } catch { /* best effort */ } + } + } + + private IMxAccessServer EnsureRegistered() + { + if (server is not null) return server; + + object created = factory!.Create() ?? throw new InvalidOperationException("MXAccess COM factory returned null."); + try + { + comObject = created; + if (created is LMXProxyServerClass comServer) + { + comEventSource = comServer; + comServer.OnWriteComplete += OnWriteComplete; + } + + IMxAccessServer mx = new MxAccessComServer(created); + serverHandle = mx.Register(clientName); + server = mx; + return mx; + } + catch + { + if (comEventSource is not null) + { + try { comEventSource.OnWriteComplete -= OnWriteComplete; } catch { /* best effort */ } + comEventSource = null; + } + + if (Marshal.IsComObject(created)) + { + try { Marshal.FinalReleaseComObject(created); } catch { /* best effort */ } + } + + comObject = null; + throw; + } + } + + private void OnWriteComplete(int hLMXServerHandle, int phItemHandle, ref MXSTATUS_PROXY[] pVars) + { + RepeatedField rows = new RepeatedField(); + rows.AddRange(statusConverter.ConvertMany(pVars)); + completionCache.Record(hLMXServerHandle, phItemHandle, rows); + } +} +``` + +(Confirm `MxStatusProxyConverter`'s namespace and that `ConvertMany(Array?)` accepts `MXSTATUS_PROXY[]`; adjust the `using`. `MxAccessComObjectFactory` already returns `LMXProxyServerClass`.) + +- [ ] **Step 4: Build and run** the Verify command on windev (push branch, `windev-worker-ci.ps1 -Sha -Mode test`, or the filtered `dotnet test` in the CI clone). Expected: all `AlarmAckWriterTests` pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAck*.cs src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IAlarmAckWriter.cs src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmAckWriterTests.cs +git commit -m "feat(worker): acknowledge alarms via authenticated AckMsg write" -m "Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT" +``` + +--- + +### Task 4: Consumers resolve the ack item instead of acking + +**Goal:** Replace `IMxAccessAlarmConsumer.AcknowledgeByGuid/AcknowledgeByName` with `ResolveAckTarget/ResolveAckTargetByName`, implement them in the wnwrap, subtag and failover consumers, and delete the inert wnwrap ack consumer and the subtag write path. + +**Files:** +- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IMxAccessAlarmConsumer.cs`, `WnWrapAlarmConsumer.cs`, `SubtagAlarmConsumer.cs`, `FailoverAlarmConsumer.cs`, `ISubtagAlarmSource.cs`, `LmxSubtagAlarmSource.cs` +- Test: `src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/{WnWrapAlarmConsumerXmlTests,SubtagAlarmConsumerTests,FailoverAlarmConsumerTests,LmxSubtagAlarmSourceTests}.cs` and every test fake implementing the changed interfaces + +**Acceptance Criteria:** +- [ ] `WnWrapAlarmConsumer.ResolveAckTarget(guid)` returns `TestMachine_001.TestAlarm001.AckMsg` for a snapshot record with provider `Galaxy` and that tag; `NotFound` for an unknown GUID; `NotAckable` for provider `InTouch`. +- [ ] `WnWrapAlarmConsumer.ResolveAckTargetByName("TestMachine_001.TestAlarm001", "Galaxy", "TestArea")` returns `TestMachine_001.TestAlarm001.AckMsg` without needing a snapshot; non-Galaxy provider → `NotAckable`. +- [ ] `SubtagAlarmConsumer` resolves synthetic GUIDs and names to the target's `AckCommentSubtag`; empty subtag → `NotAckable`; unknown → `NotFound`. +- [ ] `FailoverAlarmConsumer` delegates both to `ActiveChild`. +- [ ] wnwrap no longer creates the second `ackClient` consumer; `ISubtagAlarmSource.Write` and `LmxSubtagAlarmSource.Write` are gone; the solution builds with no warnings. + +**Verify:** `dotnet test src/ZB.MOM.WW.MxGateway.Worker.Tests/ZB.MOM.WW.MxGateway.Worker.Tests.csproj -p:Platform=x86 --filter "FullyQualifiedName~WnWrapAlarmConsumerXmlTests|FullyQualifiedName~SubtagAlarmConsumerTests|FullyQualifiedName~FailoverAlarmConsumerTests|FullyQualifiedName~LmxSubtagAlarmSourceTests"` (windev) → pass + +**Steps:** + +- [ ] **Step 1: Interface.** In `IMxAccessAlarmConsumer.cs` delete `AcknowledgeByGuid` and `AcknowledgeByName` (and their docs) and add: + +```csharp + /// + /// Resolves the MXAccess item whose write acknowledges the alarm with the + /// given GUID (e.g. Obj.Alarm.AckMsg). Does not perform the ack. + /// + /// The alarm GUID as reported by this consumer. + /// The resolved target or the reason none exists. + AlarmAckTarget ResolveAckTarget(Guid alarmGuid); + + /// Resolves the ack item for an alarm identified by name, provider and group. + /// The tag/alarm name, e.g. TestMachine_001.TestAlarm001. + /// The provider (literal Galaxy for ArchestrA). + /// The area/group. + /// The resolved target or the reason none exists. + AlarmAckTarget ResolveAckTargetByName(string alarmName, string providerName, string groupName); +``` + +- [ ] **Step 2: Failing tests.** wnwrap (in `WnWrapAlarmConsumerXmlTests.cs`, using whatever seam the file already uses to load a snapshot — e.g. `FoldFetch`/`ApplySnapshotUpdate` via a fake client — or add `internal void ReplaceSnapshotForTests(IEnumerable)` under `syncRoot`): + +```csharp + [Fact] + public void ResolveAckTarget_GalaxyRecord_ReturnsAckMsgItem() + { + Guid guid = Guid.NewGuid(); + using WnWrapAlarmConsumer consumer = CreateConsumerWithSnapshot(new MxAlarmSnapshotRecord + { + AlarmGuid = guid, ProviderName = "Galaxy", Group = "TestArea", TagName = "TestMachine_001.TestAlarm001", State = MxAlarmStateKind.UnackAlm, + }); + + AlarmAckTarget target = consumer.ResolveAckTarget(guid); + + Assert.True(target.Found); + Assert.Equal("TestMachine_001.TestAlarm001.AckMsg", target.ItemAddress); + Assert.Equal(AlarmAckOutcome.AlarmNotFound, consumer.ResolveAckTarget(Guid.NewGuid()).Failure); + } + + [Fact] + public void ResolveAckTarget_NonGalaxyProvider_IsNotAckable() + { + Guid guid = Guid.NewGuid(); + using WnWrapAlarmConsumer consumer = CreateConsumerWithSnapshot(new MxAlarmSnapshotRecord + { + AlarmGuid = guid, ProviderName = "InTouch", Group = "G", TagName = "Tag1", State = MxAlarmStateKind.UnackAlm, + }); + + Assert.Equal(AlarmAckOutcome.NotAckable, consumer.ResolveAckTarget(guid).Failure); + } + + [Fact] + public void ResolveAckTargetByName_Galaxy_ReturnsAckMsgItem() + { + using WnWrapAlarmConsumer consumer = CreateConsumerWithSnapshot(); + + Assert.Equal("TestMachine_001.TestAlarm001.AckMsg", + consumer.ResolveAckTargetByName("TestMachine_001.TestAlarm001", "Galaxy", "TestArea").ItemAddress); + Assert.Equal(AlarmAckOutcome.NotAckable, consumer.ResolveAckTargetByName("Tag1", "InTouch", "G").Failure); + } +``` + +Subtag (replace the existing `AcknowledgeBy*_WritesCommentToAckCommentSubtag` / `_UnknownGuid_ReturnsNonZero` / `_UnknownAlarm_ReturnsNonZero` / `_PrefixNameDoesNotFalseMatch` tests with resolve equivalents asserting `ItemAddress == target.AckCommentSubtag`, `Failure == AlarmNotFound` for unknown, `NotAckable` for empty `AckCommentSubtag`, and that a prefix name does not false-match). Failover: replace `Acknowledge_DelegatesToActiveChild` with `ResolveAckTarget_DelegatesToActiveChild` (primary before failover, standby after). + +- [ ] **Step 3: wnwrap implementation.** Delete `AcknowledgeByGuid`/`AcknowledgeByName`, the `ackClient` field and its provisioning block in `Subscribe` (the "Provision a parallel COM consumer for ack calls" block) and its teardown in `Dispose`; update the class remarks that describe the two-consumer split. Add: + +```csharp + private const string GalaxyProviderName = "Galaxy"; + + /// + public AlarmAckTarget ResolveAckTarget(Guid alarmGuid) + { + if (disposed) throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer)); + MxAlarmSnapshotRecord? record; + lock (syncRoot) + { + latestSnapshot.TryGetValue(alarmGuid, out record); + } + + if (record is null) + { + return AlarmAckTarget.NotFound($"Alarm {alarmGuid} is not in the current alarm snapshot."); + } + + return ResolveGalaxyAckTarget(record.ProviderName, record.TagName); + } + + /// + public AlarmAckTarget ResolveAckTargetByName(string alarmName, string providerName, string groupName) + { + if (disposed) throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer)); + return ResolveGalaxyAckTarget(providerName, alarmName); + } + + private static AlarmAckTarget ResolveGalaxyAckTarget(string? providerName, string? tagName) + { + if (!string.Equals(providerName, GalaxyProviderName, StringComparison.OrdinalIgnoreCase)) + { + return AlarmAckTarget.NotAckable( + $"Alarm provider '{providerName}' is not a Galaxy provider; only Galaxy alarms can be acknowledged over MXAccess."); + } + + if (string.IsNullOrWhiteSpace(tagName)) + { + return AlarmAckTarget.NotFound("Alarm record has no tag name."); + } + + return AlarmAckTarget.For(tagName + "." + AlarmAckWriter.AckCommentAttribute); + } +``` + +- [ ] **Step 4: Subtag implementation.** Replace `AcknowledgeByGuid`/`AcknowledgeByName`/`WriteAckComment` with: + +```csharp + /// + public AlarmAckTarget ResolveAckTarget(Guid alarmGuid) + { + if (disposed) throw new ObjectDisposedException(nameof(SubtagAlarmConsumer)); + if (!referencesBySyntheticGuid.TryGetValue(alarmGuid, out string reference) || + !targetsByReference.TryGetValue(reference, out AlarmSubtagTarget target)) + { + return AlarmAckTarget.NotFound($"Alarm {alarmGuid} is not on the subtag watch list."); + } + + return ToAckTarget(target); + } + + /// + public AlarmAckTarget ResolveAckTargetByName(string alarmName, string providerName, string groupName) + { + if (disposed) throw new ObjectDisposedException(nameof(SubtagAlarmConsumer)); + AlarmSubtagTarget? target = ResolveTargetByName(alarmName, providerName, groupName); + return target is null + ? AlarmAckTarget.NotFound($"Alarm '{providerName}!{groupName}.{alarmName}' is not on the subtag watch list.") + : ToAckTarget(target); + } + + private static AlarmAckTarget ToAckTarget(AlarmSubtagTarget target) + { + string ackItem = target.AckCommentSubtag ?? string.Empty; + return ackItem.Length == 0 + ? AlarmAckTarget.NotAckable($"Alarm '{target.AlarmFullReference}' has no ack-comment subtag configured.") + : AlarmAckTarget.For(ackItem); + } +``` + +Keep `Subscribe` advising the ack-comment subtag only if other code reads it; otherwise drop it from the advise list (the ack writer advises on its own server). Remove `Write` from `ISubtagAlarmSource` and `LmxSubtagAlarmSource` (and `LmxSubtagAlarmSourceTests.Write_*` tests and the subtag fake's `Write`). + +- [ ] **Step 5: Failover** — replace the two ack methods: + +```csharp + /// + public AlarmAckTarget ResolveAckTarget(Guid alarmGuid) + { + if (disposed) throw new ObjectDisposedException(nameof(FailoverAlarmConsumer)); + return ActiveChild.ResolveAckTarget(alarmGuid); + } + + /// + public AlarmAckTarget ResolveAckTargetByName(string alarmName, string providerName, string groupName) + { + if (disposed) throw new ObjectDisposedException(nameof(FailoverAlarmConsumer)); + return ActiveChild.ResolveAckTargetByName(alarmName, providerName, groupName); + } +``` + +- [ ] **Step 6: Update every fake** implementing `IMxAccessAlarmConsumer` (`grep -rn ": IMxAccessAlarmConsumer" src/ZB.MOM.WW.MxGateway.Worker.Tests`) — replace the two ack members with `ResolveAckTarget`/`ResolveAckTargetByName` that record their arguments and return a configurable `AlarmAckTarget` (default `AlarmAckTarget.For("Fake.Alarm.AckMsg")`). + +- [ ] **Step 7: Verify** (Task 5 must land before the worker project compiles end to end, because `AlarmDispatcher` still calls the removed members — do Tasks 4 and 5 in one windev build) and **commit**: + +```bash +git add src/ZB.MOM.WW.MxGateway.Worker src/ZB.MOM.WW.MxGateway.Worker.Tests +git commit -m "refactor(worker): alarm consumers resolve the AckMsg item instead of acking" -m "Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT" +``` + +--- + +### Task 5: Route acks through the writer and remove the managed path + +**Goal:** Wire `AlarmAckWriter` through `AlarmCommandHandler` → `AlarmDispatcher` → `MxAccessCommandExecutor` with an `AlarmAckOperator` built from the command, map `AlarmAckResult` to the reply, and delete the dead managed/WNAL ack path. + +**Files:** +- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs`, `AlarmCommandHandler.cs`, `IAlarmCommandHandler.cs`, `MxAccessCommandExecutor.cs` (~894-990), `src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerPipeSession.cs` (:79, :101, :1588), `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessStaSession.cs` (handler factory call ~288) +- Delete: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AaManagedAlarmAckConsumer.cs`, `IManagedAlarmAckClient.cs`, `WnalNativeAckConsumer.cs`, `src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmManagedGuidAckLiveSmokeTests.cs` +- Test: `AlarmDispatcherTests.cs`, `AlarmCommandHandlerTests.cs`, `AlarmCommandExecutorTests.cs`, `MxAccessCommandExecutorTests.cs` + +**Acceptance Criteria:** +- [ ] `AlarmDispatcher.Acknowledge(guid, comment, op)` resolves via the consumer and calls the writer with the resolved item; an unresolved target returns `AlarmAckResult.FromTarget` without calling the writer. +- [ ] `ExecuteAcknowledgeAlarm` builds `AlarmAckOperator{UserName=operator_user, Domain=operator_domain, Password=operator_password}`; maps `Applied`/`Unconfirmed` → `Ok`, `AlarmNotFound`/`NotAckable` → `InvalidRequest`, `AuthenticationFailed`/`WriteRejected` → `MxaccessFailure`; sets `Hresult` and `AcknowledgeAlarm.NativeStatus` to `result.NativeStatus`; copies `result.Statuses` into `reply.Statuses`; `DiagnosticMessage = result.Diagnostic`. +- [ ] A test with `operator_password = "pw-secret"` asserts the secret is absent from `DiagnosticMessage`, `ProtocolStatus.Message`, for every outcome. +- [ ] No references remain to `AaManagedAlarmAckConsumer`, `IManagedAlarmAckClient`, `WnalNativeAckConsumer`, `MXGATEWAY_USE_MANAGED_ALARM_ACK`, `IsSubtagActive` (`grep -rn` returns nothing under `src/`). +- [ ] Production wiring creates one `AlarmAckWriter(comFactory, () => new StaMessagePump().PumpPendingMessages(), MxAccessStaSession.ResolveWriteCompletionTimeout(), AlarmAckWriter.ResolveClientName())` per `AlarmCommandHandler`, disposed with the handler. +- [ ] x86 worker builds with 0 warnings; all `Worker.Tests` pass on windev. + +**Verify:** on windev: `powershell -NoProfile -ExecutionPolicy Bypass -File C:\build\mxaccessgw-ci\scripts\ci\windev-worker-ci.ps1 -Sha -Mode test` → `SUCCESS mode=test`, Failed: 0 + +**Steps:** + +- [ ] **Step 1: Failing dispatcher tests** (replace the four managed tests and `FakeManagedAckClient` in `AlarmDispatcherTests.cs`): + +```csharp + [Fact] + public void Acknowledge_ResolvesTargetThenWritesWithOperator() + { + FakeAlarmConsumer consumer = new FakeAlarmConsumer { AckTarget = AlarmAckTarget.For("T.A.AckMsg") }; + FakeAckWriter writer = new FakeAckWriter(); + using AlarmDispatcher dispatcher = new AlarmDispatcher( + consumer, new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), SessionId, writer); + Guid guid = Guid.NewGuid(); + AlarmAckOperator op = new AlarmAckOperator { UserName = "alice", Password = "pw" }; + + AlarmAckResult result = dispatcher.Acknowledge(guid, "checked", op); + + Assert.Equal(AlarmAckOutcome.Applied, result.Outcome); + Assert.Equal(guid, consumer.LastResolvedGuid); + Assert.Equal("T.A.AckMsg", writer.LastItemAddress); + Assert.Equal("checked", writer.LastComment); + Assert.Same(op, writer.LastOperator); + } + + [Fact] + public void Acknowledge_WhenTargetNotFound_DoesNotWrite() + { + FakeAlarmConsumer consumer = new FakeAlarmConsumer { AckTarget = AlarmAckTarget.NotFound("gone") }; + FakeAckWriter writer = new FakeAckWriter(); + using AlarmDispatcher dispatcher = new AlarmDispatcher( + consumer, new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), SessionId, writer); + + AlarmAckResult result = dispatcher.Acknowledge(Guid.NewGuid(), "c", new AlarmAckOperator { UserName = "alice" }); + + Assert.Equal(AlarmAckOutcome.AlarmNotFound, result.Outcome); + Assert.Null(writer.LastItemAddress); + } + + private sealed class FakeAckWriter : IAlarmAckWriter + { + public string? LastItemAddress { get; private set; } + public string? LastComment { get; private set; } + public AlarmAckOperator? LastOperator { get; private set; } + public AlarmAckResult Result { get; set; } = AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, string.Empty); + public bool Disposed { get; private set; } + + public AlarmAckResult Acknowledge(string itemAddress, string comment, AlarmAckOperator ackOperator) + { + LastItemAddress = itemAddress; + LastComment = comment; + LastOperator = ackOperator; + return Result; + } + + public void Dispose() => Disposed = true; + } +``` + +Add the matching `AcknowledgeByName` test (`consumer.LastResolvedName == ("TestMachine_001.TestAlarm001","Galaxy","TestArea")`). + +- [ ] **Step 2: Failing executor tests** in `AlarmCommandExecutorTests.cs` (extend the existing fake `IAlarmCommandHandler` so `Acknowledge` returns a configurable `AlarmAckResult` and records the `AlarmAckOperator`): + +```csharp + [Theory] + [InlineData(AlarmAckOutcome.Applied, ProtocolStatusCode.Ok)] + [InlineData(AlarmAckOutcome.Unconfirmed, ProtocolStatusCode.Ok)] + [InlineData(AlarmAckOutcome.AlarmNotFound, ProtocolStatusCode.InvalidRequest)] + [InlineData(AlarmAckOutcome.NotAckable, ProtocolStatusCode.InvalidRequest)] + [InlineData(AlarmAckOutcome.AuthenticationFailed, ProtocolStatusCode.MxaccessFailure)] + [InlineData(AlarmAckOutcome.WriteRejected, ProtocolStatusCode.MxaccessFailure)] + public async Task AcknowledgeAlarm_MapsOutcomeWithoutLeakingPassword(AlarmAckOutcome outcome, ProtocolStatusCode expected) + { + int native = outcome is AlarmAckOutcome.Applied or AlarmAckOutcome.Unconfirmed ? 0 : 1008; + FakeAlarmCommandHandler handler = new FakeAlarmCommandHandler + { + AckResult = AlarmAckResult.Create(outcome, native, "diagnostic for alice"), + }; + MxCommandReply reply = await DispatchAsync(handler, new MxCommand + { + Kind = MxCommandKind.AcknowledgeAlarm, + AcknowledgeAlarmCommand = new AcknowledgeAlarmCommand + { + AlarmGuid = Guid.NewGuid().ToString(), + Comment = "checked", + OperatorUser = "alice", + OperatorDomain = "CORP", + OperatorPassword = "pw-secret", + }, + }); + + Assert.Equal(expected, reply.ProtocolStatus.Code); + Assert.Equal(native, reply.Hresult); + Assert.Equal(native, reply.AcknowledgeAlarm.NativeStatus); + Assert.Equal("alice", handler.LastOperator!.UserName); + Assert.Equal("CORP", handler.LastOperator.Domain); + Assert.Equal("pw-secret", handler.LastOperator.Password); + Assert.DoesNotContain("pw-secret", reply.DiagnosticMessage ?? string.Empty, StringComparison.Ordinal); + Assert.DoesNotContain("pw-secret", reply.ProtocolStatus.Message ?? string.Empty, StringComparison.Ordinal); + } +``` + +(Use the file's existing dispatch helper name; xUnit1030: no `.ConfigureAwait(false)` in the test body.) Add the same theory for `AcknowledgeAlarmByName`. + +- [ ] **Step 3: `IAlarmCommandHandler`** — change the two ack members to: + +```csharp + /// Acknowledges an alarm by GUID as . + AlarmAckResult Acknowledge(Guid alarmGuid, string comment, AlarmAckOperator ackOperator); + + /// Acknowledges an alarm by name/provider/group as . + AlarmAckResult AcknowledgeByName(string alarmName, string providerName, string groupName, string comment, AlarmAckOperator ackOperator); +``` + +- [ ] **Step 4: `AlarmDispatcher`** — remove `managedAck`, the managed block in `Subscribe`, `IsSubtagActive`, the managed dispose; add `private readonly IAlarmAckWriter ackWriter;` and make the constructor `AlarmDispatcher(IMxAccessAlarmConsumer consumer, MxAccessAlarmEventSink sink, string sessionId, IAlarmAckWriter ackWriter)` (null → `ArgumentNullException`). The dispatcher does **not** dispose the writer (the handler owns it). Replace the ack methods: + +```csharp + /// Acknowledges the alarm with the given GUID by writing its ack item as the operator. + /// The alarm GUID. + /// The acknowledgement comment. + /// The operator to authenticate as. + /// The outcome. + public AlarmAckResult Acknowledge(Guid alarmGuid, string ackComment, AlarmAckOperator ackOperator) + { + if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher)); + AlarmAckTarget target = consumer.ResolveAckTarget(alarmGuid); + return target.Found + ? ackWriter.Acknowledge(target.ItemAddress, ackComment, ackOperator) + : AlarmAckResult.FromTarget(target); + } + + /// Acknowledges the alarm identified by name/provider/group as the operator. + /// The tag/alarm name. + /// The provider. + /// The group/area. + /// The acknowledgement comment. + /// The operator to authenticate as. + /// The outcome. + public AlarmAckResult AcknowledgeByName( + string alarmName, string providerName, string groupName, string ackComment, AlarmAckOperator ackOperator) + { + if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher)); + AlarmAckTarget target = consumer.ResolveAckTargetByName(alarmName, providerName, groupName); + return target.Found + ? ackWriter.Acknowledge(target.ItemAddress, ackComment, ackOperator) + : AlarmAckResult.FromTarget(target); + } +``` + +- [ ] **Step 5: `AlarmCommandHandler`** — replace the `managedAckFactory` field/ctor param/doc with `Func? ackWriterFactory = null`; add `private IAlarmAckWriter? ackWriter;` and + +```csharp + private IAlarmAckWriter GetOrCreateAckWriter() + { + if (ackWriter is not null) return ackWriter; + if (ackWriterFactory is not null) + { + ackWriter = ackWriterFactory() ?? throw new InvalidOperationException("Alarm ack writer factory returned null."); + return ackWriter; + } + + if (comFactory is null) + { + throw new InvalidOperationException( + "Alarm acknowledgement requires an IMxAccessComObjectFactory; the alarm command handler was constructed without one."); + } + + ackWriter = new AlarmAckWriter( + comFactory, + () => new StaMessagePump().PumpPendingMessages(), + MxAccessStaSession.ResolveWriteCompletionTimeout(), + AlarmAckWriter.ResolveClientName()); + return ackWriter; + } +``` + +In `Subscribe` pass `GetOrCreateAckWriter()` as the dispatcher's 4th argument (delete the managed block). Change `Acknowledge`/`AcknowledgeByName` to the new signatures (null comment → empty, null operator → `ArgumentNullException`). In `Dispose` dispose `ackWriter` after the dispatcher (`try { ackWriter?.Dispose(); } catch { }`). (`StaMessagePump.PumpPendingMessages` is `PeekMessage` on the calling thread, i.e. the STA when called from an ack.) + +- [ ] **Step 6: Executor** — rewrite the try-blocks of `ExecuteAcknowledgeAlarm` and `ExecuteAcknowledgeAlarmByName` to: + +```csharp + try + { + AlarmAckResult result = alarmCommandHandler.Acknowledge( + alarmGuid, + payload.Comment, + new AlarmAckOperator + { + UserName = payload.OperatorUser, + Domain = payload.OperatorDomain, + Password = payload.OperatorPassword, + }); + return CreateAcknowledgeReply(command, result); + } + catch (Exception ex) + { + return CreateAlarmFailureReply(command, ex); + } +``` + +(by-name passes `payload.AlarmName, payload.ProviderName, payload.GroupName, payload.Comment, new AlarmAckOperator { … }`) and add: + +```csharp + private MxCommandReply CreateAcknowledgeReply(StaCommand command, AlarmAckResult result) + { + MxCommandReply reply = CreateOkReply(command); + reply.Hresult = result.NativeStatus; + reply.AcknowledgeAlarm = new AcknowledgeAlarmReplyPayload { NativeStatus = result.NativeStatus }; + reply.Statuses.Add(result.Statuses); + reply.DiagnosticMessage = result.Diagnostic; + switch (result.Outcome) + { + case AlarmAckOutcome.AlarmNotFound: + case AlarmAckOutcome.NotAckable: + reply.ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.InvalidRequest, Message = result.Diagnostic }; + break; + case AlarmAckOutcome.AuthenticationFailed: + case AlarmAckOutcome.WriteRejected: + reply.ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.MxaccessFailure, Message = result.Diagnostic }; + break; + } + + return reply; + } +``` + +(`Statuses` must be the repeated field on `MxCommandReply`; if `AddRange` is required on `RepeatedField`, use `reply.Statuses.AddRange(result.Statuses)`.) The only operator-derived text in diagnostics is `VerifyUser`; the password never enters `AlarmAckResult`. + +- [ ] **Step 7: Delete the managed path** — `git rm` the four files; in `WorkerPipeSession.cs` remove `, managedAckFactory: AaManagedAlarmAckConsumer.EnvGatedFactory` from the three handler constructions; fix `AlarmCommandHandlerTests` constructions that passed `managedAckFactory`. + +- [ ] **Step 8: Grep, push, verify on windev** + +```bash +grep -rn "AaManagedAlarmAckConsumer\|IManagedAlarmAckClient\|WnalNativeAckConsumer\|MXGATEWAY_USE_MANAGED_ALARM_ACK\|IsSubtagActive\|AcknowledgeByGuid" src/ --include=*.cs +git push -u origin feat/ackmsg-as-operator +ssh windev 'powershell -NoProfile -ExecutionPolicy Bypass -File C:\build\mxaccessgw-ci\scripts\ci\windev-worker-ci.ps1 -Sha -Mode test' +``` + +Expected: grep prints nothing; CI log ends `SUCCESS mode=test`, `Failed: 0`. + +- [ ] **Step 9: Commit** + +```bash +git add -A src/ZB.MOM.WW.MxGateway.Worker src/ZB.MOM.WW.MxGateway.Worker.Tests +git commit -m "feat(worker): route alarm acks through the operator-authenticated AckMsg writer" -m "Removes the inert managed/WNAL alarm-client ack path." -m "Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT" +``` + +--- + +### Task 6: Gateway forwards operator credentials and the ack client name + +**Goal:** Forward `operator_password`/`operator_domain` from `AcknowledgeAlarmRequest` onto the worker ack commands, require `operator_user`, treat ack commands as credential-bearing in redaction, and add the `AckClientName` option conveyed to workers. + +**Files:** +- Modify: `src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs` (`BuildAcknowledgeCommand` ~1172-1222) +- Modify: `src/ZB.MOM.WW.MxGateway.Server/Grpc/MxAccessGatewayService.cs` (`AcknowledgeAlarm` ~187-207) +- Modify: `src/ZB.MOM.WW.MxGateway.Server/Configuration/AlarmsOptions.cs`, `GatewayOptionsValidator.cs` (`ValidateAlarms` ~473-515), `Workers/WorkerProcessLauncher.cs` (~214-224) +- Modify: `src/ZB.MOM.WW.MxGateway.Server/Diagnostics/GatewayLogRedactor.cs` (:11-16), `GatewayLogRedactorSeam.cs` +- Test: `src/ZB.MOM.WW.MxGateway.Tests/Alarms/GatewayAlarmMonitorAcknowledgeTests.cs` (create), `Gateway/Grpc/MxAccessGatewayServiceTests.cs`, `Configuration/GatewayOptionsValidatorTests.cs`, `Gateway/Workers/WorkerProcessLauncherTests.cs`, `Diagnostics/GatewayLogRedactorTests.cs`, `Diagnostics/GatewayLogRedactorSeamTests.cs` + +**Acceptance Criteria:** +- [ ] A GUID-reference ack produces `AcknowledgeAlarmCommand{OperatorUser="alice", OperatorDomain="CORP", OperatorPassword="pw-secret"}`; a `Galaxy!TestArea.T.A` ack produces the by-name command with the same three fields. +- [ ] The gRPC handler returns `InvalidArgument` "operator_user is required." when `operator_user` is empty. +- [ ] `GatewayLogRedactor.IsCredentialBearingCommand("AcknowledgeAlarm")` and `("AcknowledgeAlarmByName")` are true; the seam redacts any property whose name contains `password` (case-insensitive). +- [ ] `MxGateway:Alarms:AckClientName` defaults to `"MxGateway"`, rejects values over 64 chars or containing control characters, and the launcher sets `MXGATEWAY_ALARM_ACK_CLIENT_NAME` to it. +- [ ] Gateway builds with 0 warnings; the filtered tests pass. + +**Verify:** `dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter "FullyQualifiedName~GatewayAlarmMonitorAcknowledgeTests|FullyQualifiedName~MxAccessGatewayServiceTests|FullyQualifiedName~GatewayOptionsValidatorTests|FullyQualifiedName~WorkerProcessLauncherTests|FullyQualifiedName~GatewayLogRedactor"` → pass + +**Steps:** + +- [ ] **Step 1: Failing monitor test.** `BuildAcknowledgeCommand` is private; make it `internal static` (the test project already has `InternalsVisibleTo` for Server — confirm with `grep -rn InternalsVisibleTo src/ZB.MOM.WW.MxGateway.Server`) and test it directly: + +```csharp +namespace ZB.MOM.WW.MxGateway.Tests.Alarms; + +public sealed class GatewayAlarmMonitorAcknowledgeTests +{ + [Fact] + public void BuildAcknowledgeCommand_GuidReference_ForwardsOperatorCredentials() + { + MxCommand? command = GatewayAlarmMonitor.BuildAcknowledgeCommand(new AcknowledgeAlarmRequest + { + AlarmFullReference = "BCC47053-9542-4D65-BDAA-BCDEA6A32A73", + Comment = "checked", + OperatorUser = "alice", + OperatorDomain = "CORP", + OperatorPassword = "pw-secret", + }, out string? error); + + Assert.Null(error); + Assert.Equal(MxCommandKind.AcknowledgeAlarm, command!.Kind); + Assert.Equal("alice", command.AcknowledgeAlarmCommand.OperatorUser); + Assert.Equal("CORP", command.AcknowledgeAlarmCommand.OperatorDomain); + Assert.Equal("pw-secret", command.AcknowledgeAlarmCommand.OperatorPassword); + } + + [Fact] + public void BuildAcknowledgeCommand_NameReference_ForwardsOperatorCredentials() + { + MxCommand? command = GatewayAlarmMonitor.BuildAcknowledgeCommand(new AcknowledgeAlarmRequest + { + AlarmFullReference = "Galaxy!TestArea.TestMachine_001.TestAlarm001", + OperatorUser = "alice", + OperatorPassword = "pw-secret", + }, out _); + + Assert.Equal(MxCommandKind.AcknowledgeAlarmByName, command!.Kind); + Assert.Equal("TestMachine_001.TestAlarm001", command.AcknowledgeAlarmByNameCommand.AlarmName); + Assert.Equal("alice", command.AcknowledgeAlarmByNameCommand.OperatorUser); + Assert.Equal("pw-secret", command.AcknowledgeAlarmByNameCommand.OperatorPassword); + } +} +``` + +- [ ] **Step 2: Implement** — in `BuildAcknowledgeCommand` add `string operatorPassword = request.OperatorPassword ?? string.Empty; string operatorDomain = request.OperatorDomain ?? string.Empty;` and in both command initializers set `OperatorDomain = operatorDomain, OperatorPassword = operatorPassword,` (replacing `OperatorDomain = string.Empty`). Keep `OperatorNode`/`OperatorFullName` empty (the Historian takes them from the authenticated account and the ack client name). + +- [ ] **Step 3: Handler validation** — failing test in `MxAccessGatewayServiceTests.cs`: + +```csharp + [Fact] + public async Task AcknowledgeAlarm_WithMissingOperatorUser_ThrowsInvalidArgument() + { + MxAccessGatewayService service = CreateService(new FakeSessionManager()); + RpcException exception = await Assert.ThrowsAsync( + async () => await service.AcknowledgeAlarm( + new AcknowledgeAlarmRequest { AlarmFullReference = "Galaxy!Area.Tag" }, + new TestServerCallContext())); + Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); + Assert.Contains("operator_user", exception.Status.Detail, StringComparison.Ordinal); + } +``` + +then add after the reference check in the handler: + +```csharp + if (string.IsNullOrWhiteSpace(request.OperatorUser)) + { + throw new RpcException(new Status(StatusCode.InvalidArgument, "operator_user is required.")); + } +``` + +Update existing ack tests that omit `OperatorUser` (e.g. `AcknowledgeAlarm_WithMissingAlarmReference_ThrowsInvalidArgument` already sets it). + +- [ ] **Step 4: Redaction** — add `"AcknowledgeAlarm"` and `"AcknowledgeAlarmByName"` to `SensitiveCommandMethods` with a comment ("carry operator_password"); in `GatewayLogRedactorSeam.Redact` add name-based redaction: + +```csharp + foreach (string key in properties.Keys.ToList()) + { + if (key.IndexOf("password", StringComparison.OrdinalIgnoreCase) >= 0 && properties[key] is not null) + { + properties[key] = GatewayLogRedactor.RedactedValue; + } + } +``` + +(use the existing redacted-value constant name from `GatewayLogRedactor`). Tests: `IsCredentialBearingCommand_AckCommands_ReturnsTrue` and `Redact_PasswordNamedProperty_IsRedacted` (`{"OperatorPassword": "pw-secret"}` → `[redacted]`). + +- [ ] **Step 5: AckClientName option** — in `AlarmsOptions.cs`: + +```csharp + /// + /// MXAccess client name the alarm worker's ack writer registers with. The + /// AVEVA Historian records it as each acknowledgement's OperatorNode. + /// Conveyed to workers via MXGATEWAY_ALARM_ACK_CLIENT_NAME. + /// + public string AckClientName { get; init; } = "MxGateway"; +``` + +validator (in `ValidateAlarms`): fail when empty/whitespace, longer than 64, or `AckClientName.Any(char.IsControl)` with message `"MxGateway:Alarms:AckClientName must be 1-64 printable characters."`; launcher: `startInfo.Environment[WorkerLaunchEnvironment.AlarmAckClientName] = _alarmsOptions.AckClientName;`. Tests: validator rejects `""` and a 65-char name, accepts default; launcher test asserts the env var equals the configured name (follow the existing `MXGATEWAY_ALARM_POLL_INTERVAL_MS` launcher test). + +- [ ] **Step 6: Build + Verify** — `dotnet build src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj` (0 warnings) then the Verify command → pass. + +- [ ] **Step 7: Commit** + +```bash +git add src/ZB.MOM.WW.MxGateway.Server src/ZB.MOM.WW.MxGateway.Tests +git commit -m "feat(gateway): forward operator credentials on alarm acknowledgement" -m "Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT" +``` + +--- + +### Task 7: .NET client and CLI + +**Goal:** Expose the new fields in the .NET client CLI with safe password input and make failures never echo the password. + +**Files:** +- Modify: `clients/dotnet/ZB.MOM.WW.MxGateway.Client/MxGatewayClient.cs` (:191-204), `clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/MxGatewayClientCli.cs` (:176-188, :1526-1545, :2468), `clients/dotnet/README.md` (:144-149, :294) +- Test: `clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/MxGatewayClientAlarmsTests.cs`, `MxGatewayClientCliTests.cs` + +**Acceptance Criteria:** +- [ ] `AcknowledgeAlarmAsync` doc mentions operator credentials; on failure the thrown exception is passed through `MxGatewaySecretRedaction.Redacted(ex, request.OperatorPassword)`. +- [ ] CLI `acknowledge-alarm` accepts `--operator`, `--operator-domain`, `--operator-password`, `--operator-password-env` (default env `MXGATEWAY_OPERATOR_PASSWORD`); the request carries them; the password is never printed and is added to the CLI error-redaction list. +- [ ] Tests assert the request fields and that a failing call's message does not contain the password. + +**Verify:** `dotnet build clients/dotnet/ZB.MOM.WW.MxGateway.Client.slnx && dotnet test clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests --filter "FullyQualifiedName~MxGatewayClientAlarmsTests|FullyQualifiedName~MxGatewayClientCliTests"` → pass + +**Steps:** + +- [ ] **Step 1: Failing tests** — extend `AcknowledgeAlarmAsync_RecordsRequestShapeAndReturnsReply` to set `OperatorPassword = "pw-secret", OperatorDomain = "CORP"` and assert they reach `call.Request`; add a test where the fake transport throws `RpcException(new Status(StatusCode.Internal, "boom pw-secret"))` and assert the resulting exception's `Message` lacks `pw-secret`. CLI: extend `RunAsync_AcknowledgeAlarm_BuildsRequestAndPrintsJsonReply` with `--operator-domain CORP --operator-password-env MXGW_TEST_OPPW` (set the env var in the test) and assert `request.OperatorPassword` and that stdout lacks the password. +- [ ] **Step 2: Implement** — client: + +```csharp + /// + /// Acknowledges an alarm as the operator. The gateway authenticates + /// with + /// and writes the + /// comment to the alarm's AckMsg attribute, so the alarm history records + /// the operator. The password is redacted from any surfaced error. + /// + public async Task AcknowledgeAlarmAsync( + AcknowledgeAlarmRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + ThrowIfDisposed(); + try + { + return await ExecuteSafeUnaryAsync( + token => _transport.AcknowledgeAlarmAsync(request, CreateCallOptions(token)), + cancellationToken).ConfigureAwait(false); + } + catch (MxGatewayException ex) when (request.OperatorPassword.Length > 0) + { + throw MxGatewaySecretRedaction.Redacted(ex, request.OperatorPassword); + } + } +``` + +CLI builder: `OperatorDomain = arguments.GetOptional("operator-domain") ?? string.Empty, OperatorPassword = ResolveOperatorPassword(arguments),` where `ResolveOperatorPassword` mirrors `TryResolveVerifyUserPassword` with option `operator-password`, env option `operator-password-env`, default env `MXGATEWAY_OPERATOR_PASSWORD`; add it to the `MxGatewayCliSecretRedactor.Redact(...)` list; update usage string to `mxgw-dotnet acknowledge-alarm --reference --operator [--operator-domain ] [--operator-password | --operator-password-env ] [--comment ] [--json]`. +- [ ] **Step 3: README** — replace the alarms sentence with "…and `AcknowledgeAlarmAsync` (acknowledges as the operator: the gateway authenticates `OperatorUser`/`OperatorPassword` and writes the comment to the alarm's `AckMsg`, so the Historian records the operator)"; update the CLI example to `--operator operator1 --operator-password-env MXGATEWAY_OPERATOR_PASSWORD` and note the password is never echoed. +- [ ] **Step 4: Verify** → pass. **Step 5: Commit** `git add clients/dotnet && git commit -m "feat(dotnet-client): operator credentials on acknowledge-alarm" -m "Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT"` + +--- + +### Task 8: Python client and CLI + +**Goal:** Regenerate Python bindings and add operator credential options to the CLI with redaction. + +**Files:** +- Regenerate: `clients/python/src/zb_mom_ww_mxgateway/generated/*_pb2*.py` via `clients/python/generate-proto.ps1` (grpcio-tools **1.80.0**) +- Modify: `clients/python/src/zb_mom_ww_mxgateway/client.py` (:147-161), `src/zb_mom_ww_mxgateway_cli/commands.py` (:527-539, :1156-1166), `clients/python/README.md` (:111-116, :460) +- Test: `clients/python/tests/test_alarms.py`, `tests/test_review_findings_022_to_026.py` + +**Acceptance Criteria:** +- [ ] Regenerated `_pb2.py` keeps `# Protobuf Python Version: 6.31.1` and only contains the real descriptor delta. +- [ ] `acknowledge_alarm` redacts `request.operator_password` from raised errors (`redact_secret`). +- [ ] CLI accepts `--operator-domain`, `--operator-password`, `--operator-password-env` (default `MXGATEWAY_OPERATOR_PASSWORD`); password added to `secrets=`. +- [ ] README example still parses in `test_review_findings_022_to_026.py`. + +**Verify:** `cd clients/python && python -m pytest tests/test_alarms.py tests/test_review_findings_022_to_026.py tests/test_cli.py -q` → pass + +**Steps:** + +- [ ] **Step 1: Regenerate** — `cd clients/python && python -m pip install "grpcio-tools==1.80.0" && pwsh -File generate-proto.ps1`; `git diff --stat src/zb_mom_ww_mxgateway/generated` shows only `mxaccess_gateway_pb2.py`/`.pyi` changes. +- [ ] **Step 2: Failing tests** — in `test_alarms.py` set `operator_password="pw-secret", operator_domain="CORP"` on the request and assert `captured_request.operator_password == "pw-secret"`; add `test_acknowledge_alarm_error_redacts_operator_password` where the fake stub raises an `RpcError` whose details contain `pw-secret` and assert `"pw-secret" not in str(excinfo.value)`. CLI: extend `test_cli_acknowledge_alarm_happy_path` with `--operator-domain CORP --operator-password-env MXGW_TEST_OPPW` (monkeypatch env) and assert the request field and that output lacks the secret. +- [ ] **Step 3: Implement** — in `client.py`: + +```python + async def acknowledge_alarm( + self, + request: pb.AcknowledgeAlarmRequest, + ) -> pb.AcknowledgeAlarmReply: + """Acknowledge an alarm as the operator. + + The gateway authenticates ``operator_user`` with ``operator_password`` and + writes the comment to the alarm's ``AckMsg``, so the alarm history records + the operator. The password is redacted from any raised error. + """ + try: + reply = await self._unary("acknowledge alarm", self.raw_stub.AcknowledgeAlarm, request) + ensure_protocol_success("acknowledge alarm", reply.protocol_status, reply) + return reply + except MxGatewayError as error: + if request.operator_password: + raise type(error)(redact_secret(str(error), [request.operator_password])) from None + raise +``` + +(Use the module's actual base error class name and import `redact_secret` from `.auth`; if error types take structured args, mirror the session's `_invoke_redacted` approach instead.) CLI: add `@click.option("--operator-domain", default="", ...)`, `@click.option("--operator-password", default=None, ...)`, `@click.option("--operator-password-env", default="MXGATEWAY_OPERATOR_PASSWORD", ...)`; resolve with a helper modelled on `_resolve_password`; set `operator_domain=`, `operator_password=` in the request; pass `secrets=_secrets(kwargs) + [password]`. +- [ ] **Step 4: README** — same wording as Task 7; CLI example adds `--operator operator1 --operator-password-env MXGATEWAY_OPERATOR_PASSWORD`. +- [ ] **Step 5: Verify** → pass. **Step 6: Commit** `git add clients/python && git commit -m "feat(python-client): operator credentials on acknowledge-alarm" -m "Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT"` + +--- + +### Task 9: Go client and CLI + +**Goal:** Regenerate Go bindings and add operator credential flags to `mxgw-go acknowledge-alarm` with error redaction. + +**Files:** +- Regenerate: `clients/go/internal/generated/mxaccess_gateway.pb.go`, `mxaccess_gateway_grpc.pb.go` via `clients/go/generate-proto.ps1` +- Modify: `clients/go/mxgateway/alarms.go` (:15-32), `clients/go/cmd/mxgw-go/main.go` (:1122-1162, usage :1434), `clients/go/README.md` (:141-146, :427) +- Test: `clients/go/mxgateway/alarms_test.go` + +**Acceptance Criteria:** +- [ ] `AcknowledgeAlarm` wraps errors with `redactSecrets(err, req.GetOperatorPassword())` when the password is non-empty. +- [ ] CLI flags `-operator-domain`, `-operator-password`, `-operator-password-env` (default `MXGATEWAY_OPERATOR_PASSWORD`) populate the request. +- [ ] `gofmt -l .` prints nothing; tests pass. + +**Verify:** `cd clients/go && gofmt -l . && go build ./... && go test ./...` → no gofmt output, ok + +**Steps:** + +- [ ] **Step 1: Regenerate** — `cd clients/go && pwsh -File generate-proto.ps1` (needs `protoc-gen-go v1.36.11`, `protoc-gen-go-grpc 1.6.2`). +- [ ] **Step 2: Failing test** in `alarms_test.go`: send `OperatorPassword: "pw-secret", OperatorDomain: "CORP"` and assert `fake.acknowledgeRequest.GetOperatorPassword() == "pw-secret"`; add `TestAcknowledgeAlarmRedactsPasswordFromError` where the fake returns `status.Error(codes.Internal, "boom pw-secret")` and assert `!strings.Contains(err.Error(), "pw-secret")`. +- [ ] **Step 3: Implement** in `alarms.go`: + +```go + reply, err := c.raw.AcknowledgeAlarm(callCtx, req) + if err != nil { + return nil, redactSecrets(&GatewayError{Op: "acknowledge alarm", Err: err}, req.GetOperatorPassword()) + } + if err := EnsureProtocolSuccess("acknowledge alarm", reply.GetProtocolStatus(), nil); err != nil { + return reply, redactSecrets(err, req.GetOperatorPassword()) + } +``` + +(Confirm `redactSecrets` returns the error unchanged for empty secrets.) CLI: add the three flags; resolve password like the `authenticate-user` `-password/-password-env` code at main.go:449-503; set `OperatorDomain`, `OperatorPassword`; update usage. +- [ ] **Step 4: README** — wording as Task 7; CLI table row "Acknowledge an alarm as an operator (`-operator`, `-operator-password-env`)". +- [ ] **Step 5: Verify** → pass. **Step 6: Commit** `git add clients/go && git commit -m "feat(go-client): operator credentials on acknowledge-alarm" -m "Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT"` + +--- + +### Task 10: Rust client and CLI + +**Goal:** Make the Rust workspace compile against the new fields, add CLI credential options, and keep the password out of `Debug` output and errors. + +**Files:** +- Modify: `clients/rust/src/client.rs` (:335-346), `clients/rust/crates/mxgw-cli/src/main.rs` (:370-381, :1079-1095), `clients/rust/tests/client_behavior.rs` (:219-235, ~:1041-1079, :1423-1440), `clients/rust/README.md` (:81, :123-126), `clients/rust/RustClientDesign.md` (:160, :349) +- (Vendored proto already updated in Task 1.) + +**Acceptance Criteria:** +- [ ] All `AcknowledgeAlarmRequest { … }` literals include `operator_password`/`operator_domain` (or `..Default::default()`). +- [ ] `acknowledge_alarm` attaches the password via `MxAccessError::with_secrets` (or equivalent) so `{:?}` of a returned error lacks it. +- [ ] CLI `--operator-domain`, `--operator-password`, `--operator-password-env` (default `MXGATEWAY_OPERATOR_PASSWORD`). +- [ ] Stale README example (`--session-id`, `--alarm-reference`) fixed to `--reference … --operator …`. +- [ ] fmt/check/test/clippy clean. + +**Verify:** `cd clients/rust && cargo fmt --all --check && cargo check --workspace && cargo test --workspace && cargo clippy --workspace --all-targets -- -D warnings` → all succeed + +**Steps:** + +- [ ] **Step 1: Fix literals** — add `operator_password: String::new(), operator_domain: String::new(),` to the two test literals and the CLI literal (CLI sets real values). +- [ ] **Step 2: Failing test** in `client_behavior.rs`: send `operator_password: "pw-secret".to_owned()` with a fake that returns `Status::internal("boom pw-secret")`; assert `!format!("{err:?}").contains("pw-secret")` and `!err.to_string().contains("pw-secret")`. +- [ ] **Step 3: Implement** in `client.rs`: + +```rust + pub async fn acknowledge_alarm( + &self, + request: AcknowledgeAlarmRequest, + ) -> Result { + let secret = request.operator_password.clone(); + let mut client = self.inner.clone(); + let result = async { + let response = client.acknowledge_alarm(self.unary_request(request)).await?; + let reply = response.into_inner(); + ensure_protocol_success("acknowledge alarm", reply.protocol_status.as_ref())?; + Ok(reply) + } + .await; + result.map_err(|error: Error| if secret.is_empty() { error } else { error.with_secrets(&[secret.as_str()]) }) + } +``` + +(Use the crate's actual secret-attachment API name from `src/error.rs`; if `with_secrets` lives on `MxAccessError` only, add an `Error::redact_secrets(self, &[&str]) -> Error` helper that scrubs `Display`/`Debug` text, with a unit test.) CLI: add clap args `#[arg(long, default_value = "")] operator_domain`, `#[arg(long)] operator_password: Option`, `#[arg(long, default_value = "MXGATEWAY_OPERATOR_PASSWORD")] operator_password_env: String`; resolve like `resolve_verify_user_password`. +- [ ] **Step 4: Docs** — README/RustClientDesign wording as Task 7 and the corrected CLI example. +- [ ] **Step 5: Verify** → pass. **Step 6: Commit** `git add clients/rust && git commit -m "feat(rust-client): operator credentials on acknowledge-alarm" -m "Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT"` + +--- + +### Task 11: Java client and CLI + +**Goal:** Regenerate Java bindings and add operator credential options to the Java CLI with redaction. + +**Files:** +- Regenerate: `clients/java/src/main/generated/main/java/mxaccess_gateway/v1/MxaccessGateway.java` (`gradle :zb-mom-ww-mxgateway-client:generateProto`) — **commit it** (real proto change) +- Modify: `clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/MxGatewayClient.java` (:280-306), `clients/java/zb-mom-ww-mxgateway-cli/src/main/java/com/zb/mom/ww/mxgateway/cli/MxGatewayCli.java` (:1814-1846), `clients/java/README.md` (:114-118, :420) +- Test: `clients/java/zb-mom-ww-mxgateway-cli/src/test/java/com/zb/mom/ww/mxgateway/cli/MxGatewayCliTests.java` (:706, :758) + +**Acceptance Criteria:** +- [ ] `acknowledgeAlarm` rethrows failures with `MxGatewaySecrets.redactExact(message, request.getOperatorPassword())` applied when the password is non-empty. +- [ ] CLI `--operator-domain`, `--operator-password`, `--operator-password-env` (default `MXGATEWAY_OPERATOR_PASSWORD`, picocli `arity`/`interactive` not required). +- [ ] `readmeDocumentedAcknowledgeAlarmExampleParsesCleanly` updated to the new README tokens and passes; `checkGeneratedClean` passes after commit. + +**Verify:** `cd clients/java && JAVA_HOME=/opt/homebrew/opt/openjdk@17 gradle test :zb-mom-ww-mxgateway-client:checkGeneratedClean` → BUILD SUCCESSFUL + +**Steps:** + +- [ ] **Step 1: Regenerate** — `cd clients/java && JAVA_HOME=/opt/homebrew/opt/openjdk@17 gradle :zb-mom-ww-mxgateway-client:generateProto`; `git diff --stat` shows `MxaccessGateway.java` with the new fields. +- [ ] **Step 2: Failing CLI test** — extend `acknowledgeAlarmCommandForwardsOptionsAndPrintsReply` with `"--operator-domain", "CORP", "--operator-password", "pw-secret"` and assert `factory.client.lastAcknowledgeAlarmRequest.getOperatorPassword()` equals it and `run.output()` does not contain `pw-secret`. +- [ ] **Step 3: Implement** — CLI options and `.setOperatorDomain(operatorDomain).setOperatorPassword(resolveOperatorPassword())` where `resolveOperatorPassword()` returns the option value or `System.getenv(operatorPasswordEnv)` or `""`; client `acknowledgeAlarm` catch block: + +```java + } catch (RuntimeException error) { + MxGatewayException mapped = error instanceof MxGatewayException gatewayError + ? gatewayError + : MxGatewayErrors.fromGrpc("acknowledge alarm", error); + String secret = request.getOperatorPassword(); + if (secret.isEmpty()) { + throw mapped; + } + throw MxGatewayErrors.redacted(mapped, secret); + } +``` + +(If no `MxGatewayErrors.redacted` exists, add one that rebuilds the exception with `MxGatewaySecrets.redactExact(message, secret)` — mirror `MxGatewaySession.rebuildRedacted`.) +- [ ] **Step 4: README** — wording as Task 7; CLI example adds `--operator operator1 --operator-password-env MXGATEWAY_OPERATOR_PASSWORD`; update the README-pinned test tokens. +- [ ] **Step 5: Verify** → BUILD SUCCESSFUL. **Step 6: Commit** `git add clients/java && git commit -m "feat(java-client): operator credentials on acknowledge-alarm" -m "Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT"` + +--- + +### Task 12: Documentation + +**Goal:** Describe the operator-authenticated AckMsg ack everywhere the old alarm-client ack is described, add the credential to the never-log rules, and record the decision. + +**Files:** +- Modify: `gateway.md` (:143-144, :224-237, :1031-1032), `docs/Grpc.md` (:99-101, validation table :219), `docs/Contracts.md` (add ack/credential paragraph near :69-71), `docs/DesignDecisions.md` (:704-713 + new decision section), `docs/AlarmClientDiscovery.md` (§2-§4 :738-843, :997-1019), `docs/AlarmProbeFindings.md` (new "Fourth attempt (2026-10-19)" section; update Outcome table), `docs/GatewayConfiguration.md` (:449-455 replace; add `AckClientName` row in the Alarm Options table), `docs/Diagnostics.md` (:7, :66-83), `docs/Authorization.md` (:128 note), `CLAUDE.md` (:129) + +**Acceptance Criteria:** +- [ ] No doc says acks use `AlarmAckByName`/`AlarmAckByGUID` in current tense or mentions `MXGATEWAY_USE_MANAGED_ALARM_ACK` / `AaManagedAlarmAckConsumer` (`grep -rn` over `docs gateway.md CLAUDE.md` returns only historical, dated finding sections). +- [ ] Grpc.md documents `operator_user` required, `operator_password`/`operator_domain`, the status mapping (Ok / InvalidRequest / MxaccessFailure) and that Historian records the operator; validation table has the `operator_user` row. +- [ ] GatewayConfiguration.md documents `MxGateway:Alarms:AckClientName` (default `MxGateway`, = Historian OperatorNode). +- [ ] Diagnostics.md and CLAUDE.md list `AcknowledgeAlarm` `operator_password` among never-log credentials; DesignDecisions.md has "Alarm acknowledgement writes AckMsg as the authenticated operator" with the why (alarm-client acks inert headless; AckMsg write historizes the authenticated identity). +- [ ] AlarmProbeFindings.md's new section records: authenticated AckMsg write acks and logs Operator/Domain/FullName/Node; alarm-client acks inert in every variant (secured and free-access, operator names, consumer names, pump). + +**Verify:** `grep -rn "MXGATEWAY_USE_MANAGED_ALARM_ACK\|AaManagedAlarmAckConsumer" docs gateway.md CLAUDE.md` → only lines inside dated historical sections; manual read of the edited sections. + +**Steps:** + +- [ ] **Step 1: gateway.md** — replace :224-237 with: + +```markdown +**Acknowledge:** the gateway acknowledges an alarm by writing the operator's +comment to the alarm attribute's `AckMsg` (subtag mode: the configured +ack-comment subtag) over MXAccess, after `AuthenticateUser` as the requesting +operator (`operator_user`, `operator_password`, optional `operator_domain`). +The AVEVA runtime treats that write as the acknowledgement and historizes it +with the authenticated account's name, domain and full name; the Historian's +OperatorNode is the ack writer's MXAccess client name +(`MxGateway:Alarms:AckClientName`). The worker waits a bounded time for +`OnWriteComplete`: an OK status is reported as success, a non-OK status as +`MxaccessFailure` with the status detail, and no completion as success with a +diagnostic noting the write was sent but unconfirmed. Alarm-client ack calls +(`AlarmAckByName`/`AlarmAckByGUID`, the managed `AlarmClient`, WNAL) are not +used: live tests showed a headless consumer's acks are accepted and silently +dropped (see `docs/AlarmProbeFindings.md`). +``` + +and add "`AcknowledgeAlarm`'s `operator_password`" to the credential sentence at :1031-1032. +- [ ] **Step 2: Grpc.md** — rewrite :99-101 with the request fields, required `operator_user`, mechanism (same as Step 1, shorter), outcome mapping table (`Applied`/`Unconfirmed` → `OK` + `hresult 0`; alarm not found / not ackable → `INVALID_REQUEST`; authentication failed / write rejected → `MXACCESS_FAILURE` + `hresult`), and add validation row `| AcknowledgeAlarm | operator_user must be non-empty. | InvalidArgument |`. +- [ ] **Step 3: DesignDecisions.md** — replace :704-713 ("Acknowledge via ack-comment write") so it states both modes write AckMsg as the operator and fix the stale "empty by default" (default is `AckMsg`); add the new decision section with rationale and the credential-handling rule. +- [ ] **Step 4: AlarmClientDiscovery.md** — in §2-§4 mark the two-consumer split and by-name ack as historical ("Superseded 2026-10-19: acks now write AckMsg as the operator; see AlarmProbeFindings.md"), delete the "Opt-in managed GUID ack" flag instructions but keep its findings table under a dated heading; update "Acknowledge in subtag mode" (:997-1019) to say the write is authenticated and goes through the ack writer. +- [ ] **Step 5: AlarmProbeFindings.md** — add "## Fourth attempt (2026-10-19): authenticated AckMsg write" with the Historian rows from memory `project_managed_alarm_ack_live_result.md` (Administrator/ArchestrA/mxa, NULL-operator unauthenticated row) and the inert-variants list; update the Outcome table's acknowledge row to "Answered — AckMsg write as the operator". +- [ ] **Step 6: GatewayConfiguration.md** — replace :449-455 with the `AckClientName` explanation and add the table row `| MxGateway:Alarms:AckClientName | MxGateway | MXAccess client name the alarm worker's ack writer registers with; the Historian records it as each acknowledgement's OperatorNode. 1-64 printable characters. Conveyed via MXGATEWAY_ALARM_ACK_CLIENT_NAME. |`. +- [ ] **Step 7: Diagnostics.md / CLAUDE.md / Contracts.md / Authorization.md** — add `AcknowledgeAlarm`/`AcknowledgeAlarmByName` to the documented `SensitiveCommandMethods` set and the name-based `password` seam rule; CLAUDE.md :129 append "and `AcknowledgeAlarm` `operator_password`"; Contracts.md paragraph on the ack credential fields and their never-log rule; Authorization.md note that `AcknowledgeAlarm` stays `invoke:write` because the operator's own credential authorizes the write. +- [ ] **Step 8: Verify** grep; **Commit** `git add gateway.md docs CLAUDE.md && git commit -m "docs: alarm acknowledgement writes AckMsg as the authenticated operator" -m "Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT"` + +--- + +### Task 13: Full verification and live Historian check on windev + +**Goal:** Prove end to end on the windev rig that an `AcknowledgeAlarm` RPC with operator credentials acknowledges the alarm and the Historian events table records that operator. + +> **USER-ORDERED GATE — NON-SKIPPABLE.** This task was requested by the user in the current conversation. It MUST NOT be closed by walking around it, by declaring it "verified inline", or by substituting a cheaper check. Close only after every item in `acceptanceCriteria` has been re-validated independently, with output captured. + +**Files:** +- No source changes (deploy only). Uses windev `C:\build\mxaccessgw-ci`, `C:\publish\mxaccessgw\Server-YYYYMMDD`, `C:\publish\mxaccessgw\Worker`, NSSM service `MxAccessGw`. + +**Acceptance Criteria:** +- [ ] Gateway full suite passes once on macOS (`dotnet test src/ZB.MOM.WW.MxGateway.Tests`) and windev worker CI `SUCCESS mode=test` with `Failed: 0` for the final SHA. +- [ ] Server + Worker for the final SHA deployed to windev (Server to a dated `Server-YYYYMMDD` dir with previous `appsettings*.json` copied; Worker swapped with `Worker.bak-YYYYMMDD` backup); `/health/ready` 200; `mxgateway_alarms_provider_mode 1`. +- [ ] With `TestMachine_001.TestAlarm001` raised (UNACK_ALM), `grpcurl … AcknowledgeAlarm {"alarm_full_reference":"Galaxy!TestArea.TestMachine_001.TestAlarm001","comment":"","operator_user":"Administrator","operator_password":""}` returns `PROTOCOL_STATUS_CODE_OK`, `hresult` 0. +- [ ] `mxa read TestMachine_001.TestAlarm001.Acked` → `True`; `.AckMsg` = the unique comment. +- [ ] `Runtime.dbo.v_AlarmEventHistory2` newest `ACK_ALM` row for `TestMachine_001.TestAlarm001` has `Operator = Administrator`, `DomainName = ArchestrA`, `OperatorNode = MxGateway`, `Description = `. +- [ ] Negative: same RPC with `operator_password` = a wrong value for a password-protected account (or `operator_user` = a non-existent user) returns `PROTOCOL_STATUS_CODE_MXACCESS_FAILURE`, the alarm stays unacked, no new ACK_ALM row, and the gateway/worker logs (`Server-*/logs/mxgateway-*.log`, `stderr.log` tail) do not contain the password string. +- [ ] Rig restored: alarm cleared (`mxa write … false --secured -u Administrator`), scratch files removed. + +**Verify:** windev: `sqlcmd -S localhost -E -W -s "|" -i C:\build\hist-q.sql` (query below) → newest ACK_ALM row shows `Administrator|ArchestrA|…|MxGateway|` + +```json:metadata +{"userGate": true, "tags": ["user-gate"], "verifyCommand": "sqlcmd -S localhost -E -W -s \"|\" -i C:\\build\\hist-q.sql", "acceptanceCriteria": ["AcknowledgeAlarm RPC returns OK hresult 0", "TestAlarm001.Acked = True and AckMsg = unique comment", "Historian ACK_ALM row Operator=Administrator DomainName=ArchestrA OperatorNode=MxGateway Description=unique comment", "wrong-password ack returns MXACCESS_FAILURE, no ACK row, password absent from logs"]} +``` + +**Steps:** + +- [ ] **Step 1: Full suites** — `dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj` (once); push; windev CI `-Mode test` for the final SHA. +- [ ] **Step 2: Deploy** per memory `project_deploy_mechanics.md`: build in a detached worktree off the CI clone at the final SHA (`git -C C:\build\mxaccessgw-ci worktree add C:\build\mgack-deploy `), `dotnet publish` Server (net10, framework-dependent) to `C:\publish\mxaccessgw\Server-YYYYMMDD` and copy `appsettings.json`/`appsettings.Development.json` from the current Server dir; publish Worker x86 to a temp dir; `Stop-Service MxAccessGw -Force`; rename `Worker` → `Worker.bak-YYYYMMDD`; copy the new worker; `nssm set MxAccessGw Application \ZB.MOM.WW.MxGateway.Server.exe` and `AppDirectory`; `Start-Service`; check health + metrics. Use base64 `-EncodedCommand` or scp'd `.ps1` files; read logs by content (not file size). +- [ ] **Step 3: Raise** — `mxa write TestMachine_001.TestAlarm001 true --type bool --secured -u Administrator`; wait until `QueryActiveAlarms` shows the alarm `ALARM_CONDITION_STATE_ACTIVE`. +- [ ] **Step 4: Ack via RPC** from the Mac: + +```bash +K=$(ssh windev 'type C:\publish\mxaccessgw\.apikey' | tr -d '\r\n'); C="ackmsg-op $(date -u +%H%M%S)" +grpcurl -plaintext -import-path src/ZB.MOM.WW.MxGateway.Contracts/Protos -proto mxaccess_gateway.proto \ + -H "authorization: Bearer $K" \ + -d "{\"client_correlation_id\":\"ackmsg-live\",\"alarm_full_reference\":\"Galaxy!TestArea.TestMachine_001.TestAlarm001\",\"comment\":\"$C\",\"operator_user\":\"Administrator\",\"operator_password\":\"\"}" \ + 10.100.0.48:5120 mxaccess_gateway.v1.MxAccessGateway/AcknowledgeAlarm +``` + +- [ ] **Step 5: Check** `mxa read TestMachine_001.TestAlarm001.Acked TestMachine_001.TestAlarm001.AckMsg`, then the Historian with `C:\build\hist-q.sql`: + +```sql +set nocount on; +select top 6 convert(varchar(23),EventStampUTC,121) as utc, AlarmState, TagName, Operator, DomainName, UserFullName, OperatorNode, left(Description,40) as Description +from Runtime.dbo.v_AlarmEventHistory2 +where TagName like 'TestMachine_001.TestAlarm001%' and EventStampUTC > dateadd(hour,-3,getutcdate()) +order by EventStampUTC desc; +``` + +- [ ] **Step 6: Negative case** — clear and re-raise, send the ack with a bad credential (e.g. `operator_user":"NoSuchOperator"`), confirm `MXACCESS_FAILURE`, `Acked=False`, no ACK row; `Select-String` the password-like value you sent in `C:\publish\mxaccessgw\Server-*\logs\*.log` and `C:\publish\mxaccessgw\stderr.log` → no matches. +- [ ] **Step 7: Restore** — clear the alarm, remove `C:\build\mgack-*`, `git worktree prune`. Record results (Historian rows) in `docs/AlarmProbeFindings.md`'s new section if they differ from Task 12's text, commit, push, open the PR to `main` with a body ending `https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT`. + +--- + +## Self-Review + +- **Spec coverage:** request/command fields (T1), ack transitions attribution (T2), authenticated write + completion (T3), resolve-only consumers + non-Galaxy rejection (T4), routing/reply mapping + managed removal (T5), gateway forwarding/validation/redaction/client name (T6), five clients (T7-T11), docs + never-log rules (T12), live Historian proof incl. negative + no-leak (T13). No gaps found. +- **Placeholders:** none; the few "confirm the exact name" notes point at a specific file to read, not deferred design. +- **Type consistency:** `AlarmAckOperator{UserName,Domain,Password,VerifyUser}`, `AlarmAckTarget{Found,ItemAddress,Failure,Reason; For/NotFound/NotAckable}`, `AlarmAckResult{Outcome,NativeStatus,Diagnostic,Statuses; Create/FromTarget}`, `IAlarmAckWriter.Acknowledge(string,string,AlarmAckOperator)`, `IMxAccessAlarmConsumer.ResolveAckTarget(Guid)/ResolveAckTargetByName(string,string,string)`, `AlarmDispatcher(consumer, sink, sessionId, IAlarmAckWriter)`, `IAlarmCommandHandler.Acknowledge(Guid,string,AlarmAckOperator)/AcknowledgeByName(string,string,string,string,AlarmAckOperator)` — used consistently across T2-T5. +- **Ordering note:** T4 and T5 change interfaces together; verify the worker build after T5 (T2's dispatcher test is adjusted in T5). diff --git a/docs/superpowers/plans/2026-10-19-ackmsg-as-operator.md.tasks.json b/docs/superpowers/plans/2026-10-19-ackmsg-as-operator.md.tasks.json new file mode 100644 index 0000000..a59bb8c --- /dev/null +++ b/docs/superpowers/plans/2026-10-19-ackmsg-as-operator.md.tasks.json @@ -0,0 +1,130 @@ +{ + "planPath": "docs/superpowers/plans/2026-10-19-ackmsg-as-operator.md", + "tasks": [ + { + "id": 0, + "subject": "Task 1: Contract fields and regenerated bindings", + "status": "pending", + "description": "**Goal:** Add the credential/domain fields to the public request and the worker ack commands, plus the worker env-var name, and commit regenerated C# bindings and the client descriptor.\n\n**Files:**\n- Modify: `src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto` (`AcknowledgeAlarmCommand` ~394-406, `AcknowledgeAlarmByNameCommand` ~415-434, `AcknowledgeAlarmReplyPayload` ~726-737, `AcknowledgeAlarmRequest` ~977-990)\n- Modify: `src/ZB.MOM.WW.MxGateway.Contracts/WorkerLaunchEnvironment.cs`\n- Regenerate: `src/ZB.MOM.WW.MxGateway.Contracts/Generated/MxaccessGateway.cs`, `MxaccessGatewayGrpc.cs`; `clients/proto/descriptors/mxaccessgw-client-v1.protoset`; `clients/rust/protos/mxaccess_gateway.proto` (byte copy)\n- Test: `src/ZB.MOM.WW.MxGateway.Tests/Contracts/ProtobufContractRoundTripTests.cs`\n\n**Acceptance Criteria:**\n- [ ] `AcknowledgeAlarmRequest` has `operator_password = 6` and `operator_domain = 7`; `AcknowledgeAlarmCommand` has `operator_password = 7`; `AcknowledgeAlarmByNameCommand` has `operator_password = 9`; each password field carries the credential comment.\n- [ ] `WorkerLaunchEnvironment.AlarmAckClientName == \"MXGATEWAY_ALARM_ACK_CLIENT_NAME\"`.\n- [ ] Round-trip tests cover the new fields and pass.\n- [ ] `pwsh -File scripts/publish-client-proto-inputs.ps1 -Check` passes and `ClientProtoInputTests` passes.\n- [ ] Rust vendored proto is byte-identical to the Contracts proto.\n\n**Verify:** `dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter \"FullyQualifiedName~ProtobufContractRoundTripTests|FullyQualifiedName~ClientProtoInputTests\"` \u2192 all pass\n\nFull steps and code: docs/superpowers/plans/2026-10-19-ackmsg-as-operator.md (Task 1: Contract fields and regenerated bindings).\n\n```json:metadata\n{\"files\": [\"src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto\", \"src/ZB.MOM.WW.MxGateway.Contracts/WorkerLaunchEnvironment.cs\", \"src/ZB.MOM.WW.MxGateway.Contracts/Generated/MxaccessGateway.cs\", \"MxaccessGatewayGrpc.cs\", \"clients/proto/descriptors/mxaccessgw-client-v1.protoset\", \"clients/rust/protos/mxaccess_gateway.proto\", \"src/ZB.MOM.WW.MxGateway.Tests/Contracts/ProtobufContractRoundTripTests.cs\"], \"verifyCommand\": \"dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter \\\"FullyQualifiedName~ProtobufContractRoundTripTests|FullyQualifiedName~ClientProtoInputTests\\\"\", \"acceptanceCriteria\": [\"`AcknowledgeAlarmRequest` has `operator_password = 6` and `operator_domain = 7`; `AcknowledgeAlarmCommand` has `operator_password = 7`; `AcknowledgeAlarmByNameCommand` has `operator_password = 9`; each password field carries the credential comment.\", \"`WorkerLaunchEnvironment.AlarmAckClientName == \\\"MXGATEWAY_ALARM_ACK_CLIENT_NAME\\\"`.\", \"Round-trip tests cover the new fields and pass.\", \"`pwsh -File scripts/publish-client-proto-inputs.ps1 -Check` passes and `ClientProtoInputTests` passes.\", \"Rust vendored proto is byte-identical to the Contracts proto.\"], \"modelTier\": \"standard\"}\n```" + }, + { + "id": 1, + "subject": "Task 2: Parse wnwrap ACK_* fields and attribute ack trans...", + "status": "pending", + "description": "**Goal:** Capture the `ACK_COMMENT`/`ACK_OPERATOR_NAME`/`ACK_DOMAIN_NAME`/`ACK_OPERATOR_NODE` elements on snapshot records and use them for Acknowledge transitions and acked snapshot rows.\n\n**Files:**\n- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAlarmSnapshot.cs`\n- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs` (`ParseSnapshotXml` ~739-849)\n- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs` (`OnTransition` ~235-260, `MapToSnapshot` ~273-274)\n- Test: `src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/WnWrapAlarmConsumerXmlTests.cs`, `AlarmDispatcherTests.cs`\n\n**Acceptance Criteria:**\n- [ ] `ParseSnapshotXml` fills `AckComment`, `AckOperatorName`, `AckOperatorDomain`, `AckOperatorNode` from the live XML sample below.\n- [ ] An `UnackAlm \u2192 AckAlm` transition whose record has `AckOperatorName=\"alice\"`, `AckComment=\"checked\"` enqueues an `OnAlarmTransitionEvent` with `OperatorUser == \"alice\"` and `OperatorComment == \"checked\"`.\n- [ ] Non-ack transitions keep `OperatorUser = OperatorName`, `OperatorComment = AlarmComment`.\n\n**Verify:** `dotnet test src/ZB.MOM.WW.MxGateway.Worker.Tests/ZB.MOM.WW.MxGateway.Worker.Tests.csproj -p:Platform=x86 --filter \"FullyQualifiedName~WnWrapAlarmConsumerXmlTests|FullyQualifiedName~AlarmDispatcherTests\"` (windev) \u2192 pass\n\nFull steps and code: docs/superpowers/plans/2026-10-19-ackmsg-as-operator.md (Task 2: Parse wnwrap ACK_* fields and attribute ack transitions).\n\n```json:metadata\n{\"files\": [\"src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAlarmSnapshot.cs\", \"src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs\", \"src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs\", \"src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/WnWrapAlarmConsumerXmlTests.cs\", \"AlarmDispatcherTests.cs\"], \"verifyCommand\": \"dotnet test src/ZB.MOM.WW.MxGateway.Worker.Tests/ZB.MOM.WW.MxGateway.Worker.Tests.csproj -p:Platform=x86 --filter \\\"FullyQualifiedName~WnWrapAlarmConsumerXmlTests|FullyQualifiedName~AlarmDispatcherTests\\\"\", \"acceptanceCriteria\": [\"`ParseSnapshotXml` fills `AckComment`, `AckOperatorName`, `AckOperatorDomain`, `AckOperatorNode` from the live XML sample below.\", \"An `UnackAlm \\u2192 AckAlm` transition whose record has `AckOperatorName=\\\"alice\\\"`, `AckComment=\\\"checked\\\"` enqueues an `OnAlarmTransitionEvent` with `OperatorUser == \\\"alice\\\"` and `OperatorComment == \\\"checked\\\"`.\", \"Non-ack transitions keep `OperatorUser = OperatorName`, `OperatorComment = AlarmComment`.\"], \"modelTier\": \"standard\"}\n```", + "blockedBy": [ + 0 + ] + }, + { + "id": 2, + "subject": "Task 3: AlarmAckWriter (authenticated AckMsg write)", + "status": "pending", + "description": "**Goal:** Add the ack value types and `AlarmAckWriter`, which authenticates the operator on its own registered MXAccess server, writes the comment to the ack item, and waits for `OnWriteComplete`.\n\n**Files:**\n- Create: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckOperator.cs`, `AlarmAckOutcome.cs`, `AlarmAckTarget.cs`, `AlarmAckResult.cs`, `IAlarmAckWriter.cs`, `AlarmAckWriter.cs`\n- Test: `src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmAckWriterTests.cs`\n\n**Acceptance Criteria:**\n- [ ] With a fake `IMxAccessServer` returning user id 5: `Acknowledge(\"T.A.AckMsg\", \"c\", {alice, CORP, pw})` calls `Register(clientName)` once, `AuthenticateUser(h, \"CORP\\\\alice\", \"pw\")`, `AddItem(\"T.A.AckMsg\")`, `Advise`, `Write(h, item, \"c\", 5)` in that order; a recorded `OnWriteComplete` with category Ok yields `Outcome == Applied`, `NativeStatus == 0`.\n- [ ] Completion with category `SecurityError`, detail 1008 \u2192 `WriteRejected`, `NativeStatus == 1008`, statuses returned.\n- [ ] No completion before the timeout \u2192 `Unconfirmed`, `NativeStatus == 0`.\n- [ ] `AuthenticateUser` throwing `COMException(hr)` or returning `0` \u2192 `AuthenticationFailed`, no write; the password never appears in `Diagnostic` or in the fake's operation log.\n- [ ] A second ack to the same item reuses the item handle (one `AddItem`, one `Advise`).\n- [ ] `AlarmAckOperator.ToString()` does not contain the password.\n\n**Verify:** `dotnet test src/ZB.MOM.WW.MxGateway.Worker.Tests/ZB.MOM.WW.MxGateway.Worker.Tests.csproj -p:Platform=x86 --filter \"FullyQualifiedName~AlarmAckWriterTests\"` (windev) \u2192 pass\n\nFull steps and code: docs/superpowers/plans/2026-10-19-ackmsg-as-operator.md (Task 3: AlarmAckWriter (authenticated AckMsg write)).\n\n```json:metadata\n{\"files\": [\"src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckOperator.cs\", \"AlarmAckOutcome.cs\", \"AlarmAckTarget.cs\", \"AlarmAckResult.cs\", \"IAlarmAckWriter.cs\", \"AlarmAckWriter.cs\", \"src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmAckWriterTests.cs\"], \"verifyCommand\": \"dotnet test src/ZB.MOM.WW.MxGateway.Worker.Tests/ZB.MOM.WW.MxGateway.Worker.Tests.csproj -p:Platform=x86 --filter \\\"FullyQualifiedName~AlarmAckWriterTests\\\"\", \"acceptanceCriteria\": [\"With a fake `IMxAccessServer` returning user id 5: `Acknowledge(\\\"T.A.AckMsg\\\", \\\"c\\\", {alice, CORP, pw})` calls `Register(clientName)` once, `AuthenticateUser(h, \\\"CORP\\\\\\\\alice\\\", \\\"pw\\\")`, `AddItem(\\\"T.A.AckMsg\\\")`, `Advise`, `Write(h, item, \\\"c\\\", 5)` in that order; a recorded `OnWriteComplete` with category Ok yields `Outcome == Applied`, `NativeStatus == 0`.\", \"Completion with category `SecurityError`, detail 1008 \\u2192 `WriteRejected`, `NativeStatus == 1008`, statuses returned.\", \"No completion before the timeout \\u2192 `Unconfirmed`, `NativeStatus == 0`.\", \"`AuthenticateUser` throwing `COMException(hr)` or returning `0` \\u2192 `AuthenticationFailed`, no write; the password never appears in `Diagnostic` or in the fake's operation log.\", \"A second ack to the same item reuses the item handle (one `AddItem`, one `Advise`).\", \"`AlarmAckOperator.ToString()` does not contain the password.\"], \"modelTier\": \"frontier\"}\n```", + "blockedBy": [ + 0 + ] + }, + { + "id": 3, + "subject": "Task 4: Consumers resolve the ack item instead of acking", + "status": "pending", + "description": "**Goal:** Replace `IMxAccessAlarmConsumer.AcknowledgeByGuid/AcknowledgeByName` with `ResolveAckTarget/ResolveAckTargetByName`, implement them in the wnwrap, subtag and failover consumers, and delete the inert wnwrap ack consumer and the subtag write path.\n\n**Files:**\n- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IMxAccessAlarmConsumer.cs`, `WnWrapAlarmConsumer.cs`, `SubtagAlarmConsumer.cs`, `FailoverAlarmConsumer.cs`, `ISubtagAlarmSource.cs`, `LmxSubtagAlarmSource.cs`\n- Test: `src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/{WnWrapAlarmConsumerXmlTests,SubtagAlarmConsumerTests,FailoverAlarmConsumerTests,LmxSubtagAlarmSourceTests}.cs` and every test fake implementing the changed interfaces\n\n**Acceptance Criteria:**\n- [ ] `WnWrapAlarmConsumer.ResolveAckTarget(guid)` returns `TestMachine_001.TestAlarm001.AckMsg` for a snapshot record with provider `Galaxy` and that tag; `NotFound` for an unknown GUID; `NotAckable` for provider `InTouch`.\n- [ ] `WnWrapAlarmConsumer.ResolveAckTargetByName(\"TestMachine_001.TestAlarm001\", \"Galaxy\", \"TestArea\")` returns `TestMachine_001.TestAlarm001.AckMsg` without needing a snapshot; non-Galaxy provider \u2192 `NotAckable`.\n- [ ] `SubtagAlarmConsumer` resolves synthetic GUIDs and names to the target's `AckCommentSubtag`; empty subtag \u2192 `NotAckable`; unknown \u2192 `NotFound`.\n- [ ] `FailoverAlarmConsumer` delegates both to `ActiveChild`.\n- [ ] wnwrap no longer creates the second `ackClient` consumer; `ISubtagAlarmSource.Write` and `LmxSubtagAlarmSource.Write` are gone; the solution builds with no warnings.\n\n**Verify:** `dotnet test src/ZB.MOM.WW.MxGateway.Worker.Tests/ZB.MOM.WW.MxGateway.Worker.Tests.csproj -p:Platform=x86 --filter \"FullyQualifiedName~WnWrapAlarmConsumerXmlTests|FullyQualifiedName~SubtagAlarmConsumerTests|FullyQualifiedName~FailoverAlarmConsumerTests|FullyQualifiedName~LmxSubtagAlarmSourceTests\"` (windev) \u2192 pass\n\nFull steps and code: docs/superpowers/plans/2026-10-19-ackmsg-as-operator.md (Task 4: Consumers resolve the ack item instead of acking).\n\n```json:metadata\n{\"files\": [\"src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IMxAccessAlarmConsumer.cs\", \"WnWrapAlarmConsumer.cs\", \"SubtagAlarmConsumer.cs\", \"FailoverAlarmConsumer.cs\", \"ISubtagAlarmSource.cs\", \"LmxSubtagAlarmSource.cs\", \"src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/{WnWrapAlarmConsumerXmlTests,SubtagAlarmConsumerTests,FailoverAlarmConsumerTests,LmxSubtagAlarmSourceTests}.cs\"], \"verifyCommand\": \"dotnet test src/ZB.MOM.WW.MxGateway.Worker.Tests/ZB.MOM.WW.MxGateway.Worker.Tests.csproj -p:Platform=x86 --filter \\\"FullyQualifiedName~WnWrapAlarmConsumerXmlTests|FullyQualifiedName~SubtagAlarmConsumerTests|FullyQualifiedName~FailoverAlarmConsumerTests|FullyQualifiedName~LmxSubtagAlarmSourceTests\\\"\", \"acceptanceCriteria\": [\"`WnWrapAlarmConsumer.ResolveAckTarget(guid)` returns `TestMachine_001.TestAlarm001.AckMsg` for a snapshot record with provider `Galaxy` and that tag; `NotFound` for an unknown GUID; `NotAckable` for provider `InTouch`.\", \"`WnWrapAlarmConsumer.ResolveAckTargetByName(\\\"TestMachine_001.TestAlarm001\\\", \\\"Galaxy\\\", \\\"TestArea\\\")` returns `TestMachine_001.TestAlarm001.AckMsg` without needing a snapshot; non-Galaxy provider \\u2192 `NotAckable`.\", \"`SubtagAlarmConsumer` resolves synthetic GUIDs and names to the target's `AckCommentSubtag`; empty subtag \\u2192 `NotAckable`; unknown \\u2192 `NotFound`.\", \"`FailoverAlarmConsumer` delegates both to `ActiveChild`.\", \"wnwrap no longer creates the second `ackClient` consumer; `ISubtagAlarmSource.Write` and `LmxSubtagAlarmSource.Write` are gone; the solution builds with no warnings.\"], \"modelTier\": \"standard\"}\n```", + "blockedBy": [ + 0 + ] + }, + { + "id": 4, + "subject": "Task 5: Route acks through the writer and remove the mana...", + "status": "pending", + "description": "**Goal:** Wire `AlarmAckWriter` through `AlarmCommandHandler` \u2192 `AlarmDispatcher` \u2192 `MxAccessCommandExecutor` with an `AlarmAckOperator` built from the command, map `AlarmAckResult` to the reply, and delete the dead managed/WNAL ack path.\n\n**Files:**\n- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs`, `AlarmCommandHandler.cs`, `IAlarmCommandHandler.cs`, `MxAccessCommandExecutor.cs` (~894-990), `src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerPipeSession.cs` (:79, :101, :1588), `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessStaSession.cs` (handler factory call ~288)\n- Delete: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AaManagedAlarmAckConsumer.cs`, `IManagedAlarmAckClient.cs`, `WnalNativeAckConsumer.cs`, `src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmManagedGuidAckLiveSmokeTests.cs`\n- Test: `AlarmDispatcherTests.cs`, `AlarmCommandHandlerTests.cs`, `AlarmCommandExecutorTests.cs`, `MxAccessCommandExecutorTests.cs`\n\n**Acceptance Criteria:**\n- [ ] `AlarmDispatcher.Acknowledge(guid, comment, op)` resolves via the consumer and calls the writer with the resolved item; an unresolved target returns `AlarmAckResult.FromTarget` without calling the writer.\n- [ ] `ExecuteAcknowledgeAlarm` builds `AlarmAckOperator{UserName=operator_user, Domain=operator_domain, Password=operator_password}`; maps `Applied`/`Unconfirmed` \u2192 `Ok`, `AlarmNotFound`/`NotAckable` \u2192 `InvalidRequest`, `AuthenticationFailed`/`WriteRejected` \u2192 `MxaccessFailure`; sets `Hresult` and `AcknowledgeAlarm.NativeStatus` to `result.NativeStatus`; copies `result.Statuses` into `reply.Statuses`; `DiagnosticMessage = result.Diagnostic`.\n- [ ] A test with `operator_password = \"pw-secret\"` asserts the secret is absent from `DiagnosticMessage`, `ProtocolStatus.Message`, for every outcome.\n- [ ] No references remain to `AaManagedAlarmAckConsumer`, `IManagedAlarmAckClient`, `WnalNativeAckConsumer`, `MXGATEWAY_USE_MANAGED_ALARM_ACK`, `IsSubtagActive` (`grep -rn` returns nothing under `src/`).\n- [ ] Production wiring creates one `AlarmAckWriter(comFactory, () => new StaMessagePump().PumpPendingMessages(), MxAccessStaSession.ResolveWriteCompletionTimeout(), AlarmAckWriter.ResolveClientName())` per `AlarmCommandHandler`, disposed with the handler.\n- [ ] x86 worker builds with 0 warnings; all `Worker.Tests` pass on windev.\n\n**Verify:** on windev: `powershell -NoProfile -ExecutionPolicy Bypass -File C:\\build\\mxaccessgw-ci\\scripts\\ci\\windev-worker-ci.ps1 -Sha -Mode test` \u2192 `SUCCESS mode=test`, Failed: 0\n\nFull steps and code: docs/superpowers/plans/2026-10-19-ackmsg-as-operator.md (Task 5: Route acks through the writer and remove the managed path).\n\n```json:metadata\n{\"files\": [\"src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs\", \"AlarmCommandHandler.cs\", \"IAlarmCommandHandler.cs\", \"MxAccessCommandExecutor.cs\", \"src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerPipeSession.cs\", \"src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessStaSession.cs\", \"src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AaManagedAlarmAckConsumer.cs\", \"IManagedAlarmAckClient.cs\", \"WnalNativeAckConsumer.cs\", \"src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmManagedGuidAckLiveSmokeTests.cs\", \"AlarmDispatcherTests.cs\", \"AlarmCommandHandlerTests.cs\", \"AlarmCommandExecutorTests.cs\", \"MxAccessCommandExecutorTests.cs\"], \"verifyCommand\": \"powershell -NoProfile -ExecutionPolicy Bypass -File C:\\\\build\\\\mxaccessgw-ci\\\\scripts\\\\ci\\\\windev-worker-ci.ps1 -Sha -Mode test\", \"acceptanceCriteria\": [\"`AlarmDispatcher.Acknowledge(guid, comment, op)` resolves via the consumer and calls the writer with the resolved item; an unresolved target returns `AlarmAckResult.FromTarget` without calling the writer.\", \"`ExecuteAcknowledgeAlarm` builds `AlarmAckOperator{UserName=operator_user, Domain=operator_domain, Password=operator_password}`; maps `Applied`/`Unconfirmed` \\u2192 `Ok`, `AlarmNotFound`/`NotAckable` \\u2192 `InvalidRequest`, `AuthenticationFailed`/`WriteRejected` \\u2192 `MxaccessFailure`; sets `Hresult` and `AcknowledgeAlarm.NativeStatus` to `result.NativeStatus`; copies `result.Statuses` into `reply.Statuses`; `DiagnosticMessage = result.Diagnostic`.\", \"A test with `operator_password = \\\"pw-secret\\\"` asserts the secret is absent from `DiagnosticMessage`, `ProtocolStatus.Message`, for every outcome.\", \"No references remain to `AaManagedAlarmAckConsumer`, `IManagedAlarmAckClient`, `WnalNativeAckConsumer`, `MXGATEWAY_USE_MANAGED_ALARM_ACK`, `IsSubtagActive` (`grep -rn` returns nothing under `src/`).\", \"Production wiring creates one `AlarmAckWriter(comFactory, () => new StaMessagePump().PumpPendingMessages(), MxAccessStaSession.ResolveWriteCompletionTimeout(), AlarmAckWriter.ResolveClientName())` per `AlarmCommandHandler`, disposed with the handler.\", \"x86 worker builds with 0 warnings; all `Worker.Tests` pass on windev.\"], \"modelTier\": \"frontier\"}\n```", + "blockedBy": [ + 1, + 2, + 3 + ] + }, + { + "id": 5, + "subject": "Task 6: Gateway forwards operator credentials and the ack...", + "status": "pending", + "description": "**Goal:** Forward `operator_password`/`operator_domain` from `AcknowledgeAlarmRequest` onto the worker ack commands, require `operator_user`, treat ack commands as credential-bearing in redaction, and add the `AckClientName` option conveyed to workers.\n\n**Files:**\n- Modify: `src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs` (`BuildAcknowledgeCommand` ~1172-1222)\n- Modify: `src/ZB.MOM.WW.MxGateway.Server/Grpc/MxAccessGatewayService.cs` (`AcknowledgeAlarm` ~187-207)\n- Modify: `src/ZB.MOM.WW.MxGateway.Server/Configuration/AlarmsOptions.cs`, `GatewayOptionsValidator.cs` (`ValidateAlarms` ~473-515), `Workers/WorkerProcessLauncher.cs` (~214-224)\n- Modify: `src/ZB.MOM.WW.MxGateway.Server/Diagnostics/GatewayLogRedactor.cs` (:11-16), `GatewayLogRedactorSeam.cs`\n- Test: `src/ZB.MOM.WW.MxGateway.Tests/Alarms/GatewayAlarmMonitorAcknowledgeTests.cs` (create), `Gateway/Grpc/MxAccessGatewayServiceTests.cs`, `Configuration/GatewayOptionsValidatorTests.cs`, `Gateway/Workers/WorkerProcessLauncherTests.cs`, `Diagnostics/GatewayLogRedactorTests.cs`, `Diagnostics/GatewayLogRedactorSeamTests.cs`\n\n**Acceptance Criteria:**\n- [ ] A GUID-reference ack produces `AcknowledgeAlarmCommand{OperatorUser=\"alice\", OperatorDomain=\"CORP\", OperatorPassword=\"pw-secret\"}`; a `Galaxy!TestArea.T.A` ack produces the by-name command with the same three fields.\n- [ ] The gRPC handler returns `InvalidArgument` \"operator_user is required.\" when `operator_user` is empty.\n- [ ] `GatewayLogRedactor.IsCredentialBearingCommand(\"AcknowledgeAlarm\")` and `(\"AcknowledgeAlarmByName\")` are true; the seam redacts any property whose name contains `password` (case-insensitive).\n- [ ] `MxGateway:Alarms:AckClientName` defaults to `\"MxGateway\"`, rejects values over 64 chars or containing control characters, and the launcher sets `MXGATEWAY_ALARM_ACK_CLIENT_NAME` to it.\n- [ ] Gateway builds with 0 warnings; the filtered tests pass.\n\n**Verify:** `dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter \"FullyQualifiedName~GatewayAlarmMonitorAcknowledgeTests|FullyQualifiedName~MxAccessGatewayServiceTests|FullyQualifiedName~GatewayOptionsValidatorTests|FullyQualifiedName~WorkerProcessLauncherTests|FullyQualifiedName~GatewayLogRedactor\"` \u2192 pass\n\nFull steps and code: docs/superpowers/plans/2026-10-19-ackmsg-as-operator.md (Task 6: Gateway forwards operator credentials and the ack client name).\n\n```json:metadata\n{\"files\": [\"src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs\", \"src/ZB.MOM.WW.MxGateway.Server/Grpc/MxAccessGatewayService.cs\", \"src/ZB.MOM.WW.MxGateway.Server/Configuration/AlarmsOptions.cs\", \"GatewayOptionsValidator.cs\", \"Workers/WorkerProcessLauncher.cs\", \"src/ZB.MOM.WW.MxGateway.Server/Diagnostics/GatewayLogRedactor.cs\", \"GatewayLogRedactorSeam.cs\", \"src/ZB.MOM.WW.MxGateway.Tests/Alarms/GatewayAlarmMonitorAcknowledgeTests.cs\", \"Gateway/Grpc/MxAccessGatewayServiceTests.cs\", \"Configuration/GatewayOptionsValidatorTests.cs\", \"Gateway/Workers/WorkerProcessLauncherTests.cs\", \"Diagnostics/GatewayLogRedactorTests.cs\", \"Diagnostics/GatewayLogRedactorSeamTests.cs\"], \"verifyCommand\": \"dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter \\\"FullyQualifiedName~GatewayAlarmMonitorAcknowledgeTests|FullyQualifiedName~MxAccessGatewayServiceTests|FullyQualifiedName~GatewayOptionsValidatorTests|FullyQualifiedName~WorkerProcessLauncherTests|FullyQualifiedName~GatewayLogRedactor\\\"\", \"acceptanceCriteria\": [\"A GUID-reference ack produces `AcknowledgeAlarmCommand{OperatorUser=\\\"alice\\\", OperatorDomain=\\\"CORP\\\", OperatorPassword=\\\"pw-secret\\\"}`; a `Galaxy!TestArea.T.A` ack produces the by-name command with the same three fields.\", \"The gRPC handler returns `InvalidArgument` \\\"operator_user is required.\\\" when `operator_user` is empty.\", \"`GatewayLogRedactor.IsCredentialBearingCommand(\\\"AcknowledgeAlarm\\\")` and `(\\\"AcknowledgeAlarmByName\\\")` are true; the seam redacts any property whose name contains `password` (case-insensitive).\", \"`MxGateway:Alarms:AckClientName` defaults to `\\\"MxGateway\\\"`, rejects values over 64 chars or containing control characters, and the launcher sets `MXGATEWAY_ALARM_ACK_CLIENT_NAME` to it.\", \"Gateway builds with 0 warnings; the filtered tests pass.\"], \"modelTier\": \"standard\"}\n```", + "blockedBy": [ + 0 + ] + }, + { + "id": 6, + "subject": "Task 7: .NET client and CLI", + "status": "pending", + "description": "**Goal:** Expose the new fields in the .NET client CLI with safe password input and make failures never echo the password.\n\n**Files:**\n- Modify: `clients/dotnet/ZB.MOM.WW.MxGateway.Client/MxGatewayClient.cs` (:191-204), `clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/MxGatewayClientCli.cs` (:176-188, :1526-1545, :2468), `clients/dotnet/README.md` (:144-149, :294)\n- Test: `clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/MxGatewayClientAlarmsTests.cs`, `MxGatewayClientCliTests.cs`\n\n**Acceptance Criteria:**\n- [ ] `AcknowledgeAlarmAsync` doc mentions operator credentials; on failure the thrown exception is passed through `MxGatewaySecretRedaction.Redacted(ex, request.OperatorPassword)`.\n- [ ] CLI `acknowledge-alarm` accepts `--operator`, `--operator-domain`, `--operator-password`, `--operator-password-env` (default env `MXGATEWAY_OPERATOR_PASSWORD`); the request carries them; the password is never printed and is added to the CLI error-redaction list.\n- [ ] Tests assert the request fields and that a failing call's message does not contain the password.\n\n**Verify:** `dotnet build clients/dotnet/ZB.MOM.WW.MxGateway.Client.slnx && dotnet test clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests --filter \"FullyQualifiedName~MxGatewayClientAlarmsTests|FullyQualifiedName~MxGatewayClientCliTests\"` \u2192 pass\n\nFull steps and code: docs/superpowers/plans/2026-10-19-ackmsg-as-operator.md (Task 7: .NET client and CLI).\n\n```json:metadata\n{\"files\": [\"clients/dotnet/ZB.MOM.WW.MxGateway.Client/MxGatewayClient.cs\", \"clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/MxGatewayClientCli.cs\", \"clients/dotnet/README.md\", \"clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/MxGatewayClientAlarmsTests.cs\", \"MxGatewayClientCliTests.cs\"], \"verifyCommand\": \"dotnet build clients/dotnet/ZB.MOM.WW.MxGateway.Client.slnx && dotnet test clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests --filter \\\"FullyQualifiedName~MxGatewayClientAlarmsTests|FullyQualifiedName~MxGatewayClientCliTests\\\"\", \"acceptanceCriteria\": [\"`AcknowledgeAlarmAsync` doc mentions operator credentials; on failure the thrown exception is passed through `MxGatewaySecretRedaction.Redacted(ex, request.OperatorPassword)`.\", \"CLI `acknowledge-alarm` accepts `--operator`, `--operator-domain`, `--operator-password`, `--operator-password-env` (default env `MXGATEWAY_OPERATOR_PASSWORD`); the request carries them; the password is never printed and is added to the CLI error-redaction list.\", \"Tests assert the request fields and that a failing call's message does not contain the password.\"], \"modelTier\": \"standard\"}\n```", + "blockedBy": [ + 0 + ] + }, + { + "id": 7, + "subject": "Task 8: Python client and CLI", + "status": "pending", + "description": "**Goal:** Regenerate Python bindings and add operator credential options to the CLI with redaction.\n\n**Files:**\n- Regenerate: `clients/python/src/zb_mom_ww_mxgateway/generated/*_pb2*.py` via `clients/python/generate-proto.ps1` (grpcio-tools **1.80.0**)\n- Modify: `clients/python/src/zb_mom_ww_mxgateway/client.py` (:147-161), `src/zb_mom_ww_mxgateway_cli/commands.py` (:527-539, :1156-1166), `clients/python/README.md` (:111-116, :460)\n- Test: `clients/python/tests/test_alarms.py`, `tests/test_review_findings_022_to_026.py`\n\n**Acceptance Criteria:**\n- [ ] Regenerated `_pb2.py` keeps `# Protobuf Python Version: 6.31.1` and only contains the real descriptor delta.\n- [ ] `acknowledge_alarm` redacts `request.operator_password` from raised errors (`redact_secret`).\n- [ ] CLI accepts `--operator-domain`, `--operator-password`, `--operator-password-env` (default `MXGATEWAY_OPERATOR_PASSWORD`); password added to `secrets=`.\n- [ ] README example still parses in `test_review_findings_022_to_026.py`.\n\n**Verify:** `cd clients/python && python -m pytest tests/test_alarms.py tests/test_review_findings_022_to_026.py tests/test_cli.py -q` \u2192 pass\n\nFull steps and code: docs/superpowers/plans/2026-10-19-ackmsg-as-operator.md (Task 8: Python client and CLI).\n\n```json:metadata\n{\"files\": [\"clients/python/src/zb_mom_ww_mxgateway/generated/*_pb2*.py\", \"clients/python/generate-proto.ps1\", \"clients/python/src/zb_mom_ww_mxgateway/client.py\", \"src/zb_mom_ww_mxgateway_cli/commands.py\", \"clients/python/README.md\", \"clients/python/tests/test_alarms.py\", \"tests/test_review_findings_022_to_026.py\"], \"verifyCommand\": \"cd clients/python && python -m pytest tests/test_alarms.py tests/test_review_findings_022_to_026.py tests/test_cli.py -q\", \"acceptanceCriteria\": [\"Regenerated `_pb2.py` keeps `# Protobuf Python Version: 6.31.1` and only contains the real descriptor delta.\", \"`acknowledge_alarm` redacts `request.operator_password` from raised errors (`redact_secret`).\", \"CLI accepts `--operator-domain`, `--operator-password`, `--operator-password-env` (default `MXGATEWAY_OPERATOR_PASSWORD`); password added to `secrets=`.\", \"README example still parses in `test_review_findings_022_to_026.py`.\"], \"modelTier\": \"standard\"}\n```", + "blockedBy": [ + 0 + ] + }, + { + "id": 8, + "subject": "Task 9: Go client and CLI", + "status": "pending", + "description": "**Goal:** Regenerate Go bindings and add operator credential flags to `mxgw-go acknowledge-alarm` with error redaction.\n\n**Files:**\n- Regenerate: `clients/go/internal/generated/mxaccess_gateway.pb.go`, `mxaccess_gateway_grpc.pb.go` via `clients/go/generate-proto.ps1`\n- Modify: `clients/go/mxgateway/alarms.go` (:15-32), `clients/go/cmd/mxgw-go/main.go` (:1122-1162, usage :1434), `clients/go/README.md` (:141-146, :427)\n- Test: `clients/go/mxgateway/alarms_test.go`\n\n**Acceptance Criteria:**\n- [ ] `AcknowledgeAlarm` wraps errors with `redactSecrets(err, req.GetOperatorPassword())` when the password is non-empty.\n- [ ] CLI flags `-operator-domain`, `-operator-password`, `-operator-password-env` (default `MXGATEWAY_OPERATOR_PASSWORD`) populate the request.\n- [ ] `gofmt -l .` prints nothing; tests pass.\n\n**Verify:** `cd clients/go && gofmt -l . && go build ./... && go test ./...` \u2192 no gofmt output, ok\n\nFull steps and code: docs/superpowers/plans/2026-10-19-ackmsg-as-operator.md (Task 9: Go client and CLI).\n\n```json:metadata\n{\"files\": [\"clients/go/internal/generated/mxaccess_gateway.pb.go\", \"mxaccess_gateway_grpc.pb.go\", \"clients/go/generate-proto.ps1\", \"clients/go/mxgateway/alarms.go\", \"clients/go/cmd/mxgw-go/main.go\", \"clients/go/README.md\", \"clients/go/mxgateway/alarms_test.go\"], \"verifyCommand\": \"cd clients/go && gofmt -l . && go build ./... && go test ./...\", \"acceptanceCriteria\": [\"`AcknowledgeAlarm` wraps errors with `redactSecrets(err, req.GetOperatorPassword())` when the password is non-empty.\", \"CLI flags `-operator-domain`, `-operator-password`, `-operator-password-env` (default `MXGATEWAY_OPERATOR_PASSWORD`) populate the request.\", \"`gofmt -l .` prints nothing; tests pass.\"], \"modelTier\": \"standard\"}\n```", + "blockedBy": [ + 0 + ] + }, + { + "id": 9, + "subject": "Task 10: Rust client and CLI", + "status": "pending", + "description": "**Goal:** Make the Rust workspace compile against the new fields, add CLI credential options, and keep the password out of `Debug` output and errors.\n\n**Files:**\n- Modify: `clients/rust/src/client.rs` (:335-346), `clients/rust/crates/mxgw-cli/src/main.rs` (:370-381, :1079-1095), `clients/rust/tests/client_behavior.rs` (:219-235, ~:1041-1079, :1423-1440), `clients/rust/README.md` (:81, :123-126), `clients/rust/RustClientDesign.md` (:160, :349)\n- (Vendored proto already updated in Task 1.)\n\n**Acceptance Criteria:**\n- [ ] All `AcknowledgeAlarmRequest { \u2026 }` literals include `operator_password`/`operator_domain` (or `..Default::default()`).\n- [ ] `acknowledge_alarm` attaches the password via `MxAccessError::with_secrets` (or equivalent) so `{:?}` of a returned error lacks it.\n- [ ] CLI `--operator-domain`, `--operator-password`, `--operator-password-env` (default `MXGATEWAY_OPERATOR_PASSWORD`).\n- [ ] Stale README example (`--session-id`, `--alarm-reference`) fixed to `--reference \u2026 --operator \u2026`.\n- [ ] fmt/check/test/clippy clean.\n\n**Verify:** `cd clients/rust && cargo fmt --all --check && cargo check --workspace && cargo test --workspace && cargo clippy --workspace --all-targets -- -D warnings` \u2192 all succeed\n\nFull steps and code: docs/superpowers/plans/2026-10-19-ackmsg-as-operator.md (Task 10: Rust client and CLI).\n\n```json:metadata\n{\"files\": [\"clients/rust/src/client.rs\", \"clients/rust/crates/mxgw-cli/src/main.rs\", \"clients/rust/tests/client_behavior.rs\", \"clients/rust/README.md\", \"clients/rust/RustClientDesign.md\"], \"verifyCommand\": \"cd clients/rust && cargo fmt --all --check && cargo check --workspace && cargo test --workspace && cargo clippy --workspace --all-targets -- -D warnings\", \"acceptanceCriteria\": [\"All `AcknowledgeAlarmRequest { \\u2026 }` literals include `operator_password`/`operator_domain` (or `..Default::default()`).\", \"`acknowledge_alarm` attaches the password via `MxAccessError::with_secrets` (or equivalent) so `{:?}` of a returned error lacks it.\", \"CLI `--operator-domain`, `--operator-password`, `--operator-password-env` (default `MXGATEWAY_OPERATOR_PASSWORD`).\", \"Stale README example (`--session-id`, `--alarm-reference`) fixed to `--reference \\u2026 --operator \\u2026`.\", \"fmt/check/test/clippy clean.\"], \"modelTier\": \"standard\"}\n```", + "blockedBy": [ + 0 + ] + }, + { + "id": 10, + "subject": "Task 11: Java client and CLI", + "status": "pending", + "description": "**Goal:** Regenerate Java bindings and add operator credential options to the Java CLI with redaction.\n\n**Files:**\n- Regenerate: `clients/java/src/main/generated/main/java/mxaccess_gateway/v1/MxaccessGateway.java` (`gradle :zb-mom-ww-mxgateway-client:generateProto`) \u2014 **commit it** (real proto change)\n- Modify: `clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/MxGatewayClient.java` (:280-306), `clients/java/zb-mom-ww-mxgateway-cli/src/main/java/com/zb/mom/ww/mxgateway/cli/MxGatewayCli.java` (:1814-1846), `clients/java/README.md` (:114-118, :420)\n- Test: `clients/java/zb-mom-ww-mxgateway-cli/src/test/java/com/zb/mom/ww/mxgateway/cli/MxGatewayCliTests.java` (:706, :758)\n\n**Acceptance Criteria:**\n- [ ] `acknowledgeAlarm` rethrows failures with `MxGatewaySecrets.redactExact(message, request.getOperatorPassword())` applied when the password is non-empty.\n- [ ] CLI `--operator-domain`, `--operator-password`, `--operator-password-env` (default `MXGATEWAY_OPERATOR_PASSWORD`, picocli `arity`/`interactive` not required).\n- [ ] `readmeDocumentedAcknowledgeAlarmExampleParsesCleanly` updated to the new README tokens and passes; `checkGeneratedClean` passes after commit.\n\n**Verify:** `cd clients/java && JAVA_HOME=/opt/homebrew/opt/openjdk@17 gradle test :zb-mom-ww-mxgateway-client:checkGeneratedClean` \u2192 BUILD SUCCESSFUL\n\nFull steps and code: docs/superpowers/plans/2026-10-19-ackmsg-as-operator.md (Task 11: Java client and CLI).\n\n```json:metadata\n{\"files\": [\"clients/java/src/main/generated/main/java/mxaccess_gateway/v1/MxaccessGateway.java\", \"clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/MxGatewayClient.java\", \"clients/java/zb-mom-ww-mxgateway-cli/src/main/java/com/zb/mom/ww/mxgateway/cli/MxGatewayCli.java\", \"clients/java/README.md\", \"clients/java/zb-mom-ww-mxgateway-cli/src/test/java/com/zb/mom/ww/mxgateway/cli/MxGatewayCliTests.java\"], \"verifyCommand\": \"cd clients/java && JAVA_HOME=/opt/homebrew/opt/openjdk@17 gradle test :zb-mom-ww-mxgateway-client:checkGeneratedClean\", \"acceptanceCriteria\": [\"`acknowledgeAlarm` rethrows failures with `MxGatewaySecrets.redactExact(message, request.getOperatorPassword())` applied when the password is non-empty.\", \"CLI `--operator-domain`, `--operator-password`, `--operator-password-env` (default `MXGATEWAY_OPERATOR_PASSWORD`, picocli `arity`/`interactive` not required).\", \"`readmeDocumentedAcknowledgeAlarmExampleParsesCleanly` updated to the new README tokens and passes; `checkGeneratedClean` passes after commit.\"], \"modelTier\": \"standard\"}\n```", + "blockedBy": [ + 0 + ] + }, + { + "id": 11, + "subject": "Task 12: Documentation", + "status": "pending", + "description": "**Goal:** Describe the operator-authenticated AckMsg ack everywhere the old alarm-client ack is described, add the credential to the never-log rules, and record the decision.\n\n**Files:**\n- Modify: `gateway.md` (:143-144, :224-237, :1031-1032), `docs/Grpc.md` (:99-101, validation table :219), `docs/Contracts.md` (add ack/credential paragraph near :69-71), `docs/DesignDecisions.md` (:704-713 + new decision section), `docs/AlarmClientDiscovery.md` (\u00a72-\u00a74 :738-843, :997-1019), `docs/AlarmProbeFindings.md` (new \"Fourth attempt (2026-10-19)\" section; update Outcome table), `docs/GatewayConfiguration.md` (:449-455 replace; add `AckClientName` row in the Alarm Options table), `docs/Diagnostics.md` (:7, :66-83), `docs/Authorization.md` (:128 note), `CLAUDE.md` (:129)\n\n**Acceptance Criteria:**\n- [ ] No doc says acks use `AlarmAckByName`/`AlarmAckByGUID` in current tense or mentions `MXGATEWAY_USE_MANAGED_ALARM_ACK` / `AaManagedAlarmAckConsumer` (`grep -rn` over `docs gateway.md CLAUDE.md` returns only historical, dated finding sections).\n- [ ] Grpc.md documents `operator_user` required, `operator_password`/`operator_domain`, the status mapping (Ok / InvalidRequest / MxaccessFailure) and that Historian records the operator; validation table has the `operator_user` row.\n- [ ] GatewayConfiguration.md documents `MxGateway:Alarms:AckClientName` (default `MxGateway`, = Historian OperatorNode).\n- [ ] Diagnostics.md and CLAUDE.md list `AcknowledgeAlarm` `operator_password` among never-log credentials; DesignDecisions.md has \"Alarm acknowledgement writes AckMsg as the authenticated operator\" with the why (alarm-client acks inert headless; AckMsg write historizes the authenticated identity).\n- [ ] AlarmProbeFindings.md's new section records: authenticated AckMsg write acks and logs Operator/Domain/FullName/Node; alarm-client acks inert in every variant (secured and free-access, operator names, consumer names, pump).\n\n**Verify:** `grep -rn \"MXGATEWAY_USE_MANAGED_ALARM_ACK\\|AaManagedAlarmAckConsumer\" docs gateway.md CLAUDE.md` \u2192 only lines inside dated historical sections; manual read of the edited sections.\n\nFull steps and code: docs/superpowers/plans/2026-10-19-ackmsg-as-operator.md (Task 12: Documentation).\n\n```json:metadata\n{\"files\": [\"gateway.md\", \"docs/Grpc.md\", \"docs/Contracts.md\", \"docs/DesignDecisions.md\", \"docs/AlarmClientDiscovery.md\", \"docs/AlarmProbeFindings.md\", \"docs/GatewayConfiguration.md\", \"docs/Diagnostics.md\", \"docs/Authorization.md\", \"CLAUDE.md\"], \"verifyCommand\": \"grep -rn \\\"MXGATEWAY_USE_MANAGED_ALARM_ACK\\\\|AaManagedAlarmAckConsumer\\\" docs gateway.md CLAUDE.md\", \"acceptanceCriteria\": [\"No doc says acks use `AlarmAckByName`/`AlarmAckByGUID` in current tense or mentions `MXGATEWAY_USE_MANAGED_ALARM_ACK` / `AaManagedAlarmAckConsumer` (`grep -rn` over `docs gateway.md CLAUDE.md` returns only historical, dated finding sections).\", \"Grpc.md documents `operator_user` required, `operator_password`/`operator_domain`, the status mapping (Ok / InvalidRequest / MxaccessFailure) and that Historian records the operator; validation table has the `operator_user` row.\", \"GatewayConfiguration.md documents `MxGateway:Alarms:AckClientName` (default `MxGateway`, = Historian OperatorNode).\", \"Diagnostics.md and CLAUDE.md list `AcknowledgeAlarm` `operator_password` among never-log credentials; DesignDecisions.md has \\\"Alarm acknowledgement writes AckMsg as the authenticated operator\\\" with the why (alarm-client acks inert headless; AckMsg write historizes the authenticated identity).\", \"AlarmProbeFindings.md's new section records: authenticated AckMsg write acks and logs Operator/Domain/FullName/Node; alarm-client acks inert in every variant (secured and free-access, operator names, consumer names, pump).\"], \"modelTier\": \"standard\"}\n```", + "blockedBy": [ + 4, + 5 + ] + }, + { + "id": 12, + "subject": "Task 13: Full verification and live Historian check on wi...", + "status": "pending", + "description": "**Goal:** Prove end to end on the windev rig that an `AcknowledgeAlarm` RPC with operator credentials acknowledges the alarm and the Historian events table records that operator.\n\n> **USER-ORDERED GATE \u2014 NON-SKIPPABLE.** This task was requested by the user in the current conversation. It MUST NOT be closed by walking around it, by declaring it \"verified inline\", or by substituting a cheaper check. Close only after every item in `acceptanceCriteria` has been re-validated independently, with output captured.\n\n**Files:**\n- No source changes (deploy only). Uses windev `C:\\build\\mxaccessgw-ci`, `C:\\publish\\mxaccessgw\\Server-YYYYMMDD`, `C:\\publish\\mxaccessgw\\Worker`, NSSM service `MxAccessGw`.\n\n**Acceptance Criteria:**\n- [ ] Gateway full suite passes once on macOS (`dotnet test src/ZB.MOM.WW.MxGateway.Tests`) and windev worker CI `SUCCESS mode=test` with `Failed: 0` for the final SHA.\n- [ ] Server + Worker for the final SHA deployed to windev (Server to a dated `Server-YYYYMMDD` dir with previous `appsettings*.json` copied; Worker swapped with `Worker.bak-YYYYMMDD` backup); `/health/ready` 200; `mxgateway_alarms_provider_mode 1`.\n- [ ] With `TestMachine_001.TestAlarm001` raised (UNACK_ALM), `grpcurl \u2026 AcknowledgeAlarm {\"alarm_full_reference\":\"Galaxy!TestArea.TestMachine_001.TestAlarm001\",\"comment\":\"\",\"operator_user\":\"Administrator\",\"operator_password\":\"\"}` returns `PROTOCOL_STATUS_CODE_OK`, `hresult` 0.\n- [ ] `mxa read TestMachine_001.TestAlarm001.Acked` \u2192 `True`; `.AckMsg` = the unique comment.\n- [ ] `Runtime.dbo.v_AlarmEventHistory2` newest `ACK_ALM` row for `TestMachine_001.TestAlarm001` has `Operator = Administrator`, `DomainName = ArchestrA`, `OperatorNode = MxGateway`, `Description = `.\n- [ ] Negative: same RPC with `operator_password` = a wrong value for a password-protected account (or `operator_user` = a non-existent user) returns `PROTOCOL_STATUS_CODE_MXACCESS_FAILURE`, the alarm stays unacked, no new ACK_ALM row, and the gateway/worker logs (`Server-*/logs/mxgateway-*.log`, `stderr.log` tail) do not contain the password string.\n- [ ] Rig restored: alarm cleared (`mxa write \u2026 false --secured -u Administrator`), scratch files removed.\n\n**Verify:** windev: `sqlcmd -S localhost -E -W -s \"|\" -i C:\\build\\hist-q.sql` (query below) \u2192 newest ACK_ALM row shows `Administrator|ArchestrA|\u2026|MxGateway|`\n\nFull steps and code: docs/superpowers/plans/2026-10-19-ackmsg-as-operator.md (Task 13: Full verification and live Historian check on windev).\n\n```json:metadata\n{\"files\": [], \"verifyCommand\": \"sqlcmd -S localhost -E -W -s \\\"|\\\" -i C:\\\\build\\\\hist-q.sql\", \"acceptanceCriteria\": [\"Gateway full suite passes once on macOS (`dotnet test src/ZB.MOM.WW.MxGateway.Tests`) and windev worker CI `SUCCESS mode=test` with `Failed: 0` for the final SHA.\", \"Server + Worker for the final SHA deployed to windev (Server to a dated `Server-YYYYMMDD` dir with previous `appsettings*.json` copied; Worker swapped with `Worker.bak-YYYYMMDD` backup); `/health/ready` 200; `mxgateway_alarms_provider_mode 1`.\", \"With `TestMachine_001.TestAlarm001` raised (UNACK_ALM), `grpcurl \\u2026 AcknowledgeAlarm {\\\"alarm_full_reference\\\":\\\"Galaxy!TestArea.TestMachine_001.TestAlarm001\\\",\\\"comment\\\":\\\"\\\",\\\"operator_user\\\":\\\"Administrator\\\",\\\"operator_password\\\":\\\"\\\"}` returns `PROTOCOL_STATUS_CODE_OK`, `hresult` 0.\", \"`mxa read TestMachine_001.TestAlarm001.Acked` \\u2192 `True`; `.AckMsg` = the unique comment.\", \"`Runtime.dbo.v_AlarmEventHistory2` newest `ACK_ALM` row for `TestMachine_001.TestAlarm001` has `Operator = Administrator`, `DomainName = ArchestrA`, `OperatorNode = MxGateway`, `Description = `.\", \"Negative: same RPC with `operator_password` = a wrong value for a password-protected account (or `operator_user` = a non-existent user) returns `PROTOCOL_STATUS_CODE_MXACCESS_FAILURE`, the alarm stays unacked, no new ACK_ALM row, and the gateway/worker logs (`Server-*/logs/mxgateway-*.log`, `stderr.log` tail) do not contain the password string.\", \"Rig restored: alarm cleared (`mxa write \\u2026 false --secured -u Administrator`), scratch files removed.\"], \"modelTier\": \"frontier\", \"userGate\": true, \"tags\": [\"user-gate\"]}\n```", + "blockedBy": [ + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11 + ] + } + ], + "lastUpdated": "2026-10-19T20:43:58.922975Z" +} \ No newline at end of file From a94b502ce6df7ba711361678e6157f3371bf743c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 19 Oct 2026 19:16:39 -0400 Subject: [PATCH 02/25] feat(contracts): carry operator credentials on alarm acknowledgement Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT --- .../descriptors/mxaccessgw-client-v1.protoset | Bin 124000 -> 125524 bytes clients/rust/protos/mxaccess_gateway.proto | 43 +- .../Generated/MxaccessGateway.cs | 926 +++++++++++------- .../Protos/mxaccess_gateway.proto | 43 +- .../WorkerLaunchEnvironment.cs | 6 + .../ProtobufContractRoundTripTests.cs | 36 + 6 files changed, 651 insertions(+), 403 deletions(-) diff --git a/clients/proto/descriptors/mxaccessgw-client-v1.protoset b/clients/proto/descriptors/mxaccessgw-client-v1.protoset index 3e1541ca98d8ec254986d3bb813d716655707dcd..002fe83cd1a8cf8ae26e0172f2dd713308abeacf 100644 GIT binary patch delta 22721 zcmchfcYsyZ*{{#pXYF|gEU5Q+S7Do6bMJrmKb~3d?_K>}Z67wg60Tky z&hFFOyY-$q2lhU(i$C2fw(HyYBTjR5*?mK75nXh*9?)MtI{^6O5pLZ7gytBsX1A5Nc^oJ2$iZ4E2 zD29oj%k7vmXv(2|TYg+9dTPHUm{iE!S;%)>S7`j>hJDkM&pP#l%F^JAYfIGiceP_n zV{7Z{M*Y6FzSO|qB(>B~C;u*%YDU$J9o@aYbTa?=!(|wbs2y87W>{@$#MrvgrI8Kw zrJ7+4BY$68p$$v*V`?s`zqqcUR5NBcDZd+2cj>6w;TPd~WJ9rZ;bo;u$Bt}BM~=Cu zbiuH?(W7g}G+ZEAU2yc^z9*m2_w*Bwsq8!Kcc<51bU~@^_mu1OU-ca}thSy^Ml{rp zEtdL@qflcSMh>fKs6DG5gBnw0*G;`k^}5`q*rkg{*3+p{>S6S$F&$f5(|lC1S z-PU(aN7k1PC|!zc-2hTXnx>|n>R)onsFAh9OFinwmg>p5bm6#$(zr3h>c)&1Id*jI z@SeR(brfmrr6cQWwf6rg;VA=89CX$%2bbz7zO-)tG3SW;es!ZS8C6R*^$j%*`;MzG z4XwTe5B>VUV9gx&g+&65;djZ?cH%^zSQ4Ffzq(CwZmnY)r=~Q ztEcxz(EHM{-Tr0lU0BQCZLjM=s|R;&WwG(`MF({#jjnANJ91ckX;jUHwWI2Lmt?#% zD~gRf7wvWEeqKN+$=H=Lpp;VzaYtK+fKpB=?73HQQ=SRjH3{x65n)c_e{YzZ{>x0Y zv-D>ssh;EKkEt15o6XZ6HZoi?w(j>MhchMX#*DhGrLxRAPNtR74Z3V)bkE78GP>tvQU>=EZc=sALk@IO8FEkT znp7mCeZozuZeEzEzq?5VkuB)7A=zRdc35_avBC$UR%g>ww!G%Xx)Jl9cK$<8OY!Gm{a*D;Vmj1Y$eC5Sic# z;&pzZeTU-NMoa251+R-W&eQW?(IKv$DKs;ox4S1J^qE3S6ZJq`JyR&{Q+!G$X^$j$ zsgQeR&hZ}{o=;yYMD2CqdPw13c7=oR2%5ryczoFv4#eZjQaCAF4;kJI3tl%}7>Kfg zrfeXiWnrON2bZdiJDTv4l8mc`h5fqf*6W!Biwe1=w)J{qx~LGf)w%SP0xm9iCRRff z5Htk@v0Yq<%(?~Qb+MabJ*9xJ6};BE2oNO#<6?_pta-3bF7ED<(Y{tlTG?!w&`UC{ zOdHT;E1s7WA}5u(yhNmfXz2ZuU_~Lfx^eAC1Jf0S$TZgeQjL|aogw^z#vh2km9CvZ z{H=8DyuW0&s^E3k<%DQw(6lp1tyUG9nb8Gm<4$J4q%f{l6}ooQ?c6H~-YDeO+IH@R z=^KT}Eb_gifN#2XhA1Ft3J7BRrfX*quW!0`?j;3$%e56mi9p&prJZ4&Y}*+``<83x zULNiIcEQ_AR}Ml8qSaFI{C1&e1`(-9-!63CQ+H7>p*2~y5CUVVWaCM2$!I73F302>m?A68(lB;mZEMdcon+j z5WNJNUIMAtrb08bsQ|U{K0UN?3gc>1p;vF+OBG4*VIlX4?WGD#KP*J1&J|L?k6bT7 z6c9881hM_d^%98Jk6bTRNC7t&JhOyDNI|4B8(^KxXpoi%(QYnu?4;YJLV9V73;hsU z&}A#0w@5@WQt6j168UeH<+nm`YvX}iZ>!wuI+nj7*;=q;0u&n=6Mv>}DwE*zLhg&k z9b5mDe(v@sl~VJ6x~_pJ0%(c=;_siXYe4+{({)Xyly7^%tJLL#=o-*;4M^8)FElfe z3Dm}2dYZ0*akahByFzzOpCtIIko&=QO&?6Za$&xY6mW;@8i)ddrhp)}J6zX*c-`T; zrjHcxYu7apQqXn{tdnimfM~yVUDHRp<{Q^F5L(b>E1th`T|+9;Z+3M}AHkh;&CMzQ zq#1U)4gwMGbR7hWrJJ;UWCQZOo3s#8&`eqo>Gy8Zf=IuYN&AUU=N^y*H>liSRlegp ze(CseqkdN!SX(oE&WE3__tG1b4Mqn@Ti&Sbk_k~M&{PT}gBz7yGC?x9QQ1}L0IBg! z%G*!(A4E|>)7c=NZc@!!x>RkP>}@)mWL({(y6me*{Q=UnlayV0Ahe*_4uEJUDSKoB zkxo+j$mFR5lb})MCM&ZYI1tN5#Udp!=0K_2Ey|vzAiRQjm1qfLe2cO>KM=RKD82LZ z)Imv5R=L}(+k>zyE4|C`)WJz`hssT{Ne7d3hl=DxsRDqNRHgyCszRT4Sq%e26Iv&a~LgntYZV$!sZe_#Nq2l%)<@MI>3Nb)Hvqpi~ z-lIBocd2;2NA2G$)upXUg8NkN1M9X5)BBX&3si~Q`(1}X6c#jv1+l%~6&A$p{id)= zRY`9>pltMmkb+JsGwgv>#U}cZjP?O#qhFPbg9nvoRw0PmfM)Rr(LSj5FuM#8>4U1B z*u(aqi5MeYTx~ePf6sgl4i- zN0r_4fw*{7wKTC8B)>-ct5n$vv8FqJ}P8dZ6LPHg6KmA(J7s)Grkq~hsWrT2DH3%33Xls!*FlnHb#FI)cw z%ATi5MY=$7p59H$5ap8KdF8#Y3bbG9%O5a*Ua|3#sZ=E=gBO&t?dL_53N(|OL~LJB zi5Ybuu3u1lnw7rFixc&tQfB5uNI};zG13=ROV{Hu(icmykeqWQp#1gh!%9& z7SX<>5_3aW&@!)+J&l<*)~*pEfV!g)=({wg093ZkiL>NR12i9WDT`jqFyCz z6;oN=tGwON!mep{L;I@g*vl4qH?*&+$^&fw?9NVJsY7kz?npt`A-f}8q&oF+sYn;8 zgATFMJ=n=B<(guTAW4PYVBG`lV%5$>`6_P@w2M{uo>tqEojf}a>$oM^f==2J>1(Q; zi9S_cOQf%<{blPVBPwAhpGgy>pq8eRigrn+P0%jMw5<}gH0#?|XhD~4E2K+RJ6EAr zNSE&FTd#GZmMP_0u{BcANn0abrhe7VMUmEMm#H56+ak9~)bgyzZP0>F+Xn6Oti)}Q zE|(I2rF3=MvS*%i+>d|o(ly!eXp3fz+UFqKer?gLQHLI8vul^AwMrdj6Wfs;=;lH@ zq-#|d*W>Mwu2qL0X{GHGwN9yht+YK-FwvM+dF|1zQ?1PL6-2vEmAY7MheWOC_R?xQ zpaqS#%Iko1y=vvAMhB$p)jnn!ukt!3>Yc1{J0b<0v?J1YRIAiAZb!85WR2S?QSWjv zu-SG(3p#BlwC}1`<^){jbwc{C>e zgQLXl*KUD_=rGXiOh9bER(qIG0OIv)S>UC^j!uH_RPKB0_2_(>sP9~l9W8EmI=2vR zLE{$0?M~+w#O+S!_7_R;qsm?3n~m=;u>8?&3V$J9FZbljZRlLX`a+|}0YF}NM?+v~Ms#O+niEreUpxCODj%6SFxdX@8fY!Y1K=Wftm z(}Fq{(`$Tt1UXjRUhCUED}-CnxCODj*0)Cx5VzO*_6TyU>?P^ZRb zR)I+W;}lZM>_*ivGXew>!mS^(GT^MV3wG^}cGaM-<~dx zlQx{>+nXzh{VZrUks!lrl5cOWy1{5C`S$7xB$r7(H&#M52A-QI&*+bK0qRej}v zFwM6&u#iFLN{;2ze7i{}6<5=IyGaLeHO;r1^uAJo>8}4FgQovMq|;shgR)pncLU(~ zB)Hel-Dd~D@tEH0+XLJ2(mONU0DveUXbK2Yz!`1;fJkS!{s+lqhV=h}nEvmV1W)<7 z7yW$4$Kz5{-{$$JRQi<5r=R3A*L4R(@j=s-Adcs{Q2^q2uIr9|(pOKr$qFF_P0c}_ zY9}j*^l9H7?$UlT3g)>H1fd08w&Ho78$qO^o##eSKcV?qiKW{?Gk{1$I^PW-P#fFH z+E04@8Q*K0>X{9qg`HOV`58Yn8x2y?KI6AGYjHm*;=-`*%a?uGvPj52K?dJKS3-yqf@Y9|6mX$$-$6jM z3w`?z0+P!@Q$SChm;{Ub+-o+M6R}+6+xLSLC6mQ&m_m3>;oWqL!Wb|1cQ=a)h}*?x zoOy~iJE z=QVyav*8EPtnpi!1_ANDMjGUM#UMF_J+PnKLWtT{zPxRIeJUSl@D^UG8`r>deeIDy z`6{GXGGC^cM}GM_WY@NCGr@8Xhzc17xrrn-*#W%+c6i-o7^LAb*YO@D!ic9;R|sp=&7 zmK{Z4wyM=Q_*TM~l&o4x_MHn*5Jdsa1{lQlJKyfBLA-uvRzs1>wAkqug=#%5Kr=o; zooa85L9{#FqEIc<;(OQF5RDC*#s-mo?;0CK`n_Ams)c^gxym&%h!%EQnHE2|wTM)0 zZ8wh9vY`CvB0EHFL9-};NPl#T0*LfS7uc(%h?fU;PY;19?6Q>wviK%`d$c2NM4UJ=+up<3<~uMF(Q2ciO?896D8t1AP$^8s;nWngzc zAg-&f`Qh9Xjtjt^`os+RTSs=zx)4@rnz!PM127L=>9 z8pwijRnX@^JHog_ygKmAHA=N7cZi^q${pg>L1%M~LMqa$1AC29Eq90$0vod-Di4~< zgDEwb5ZE0gjH?NO-9du5nh@A~!D_iuye6O2H9z@M;vvC`FM_{i7tMz3hXbv?Xp6>|S$|Vt-0~ptL1om36 z+T%8IN?`9^AxZ_BRR%;mC9rp|Akry;y?d?pxQ)CsPzT$d<~CB$Tx61n^vpF;a@IO6 z2_6b^PdEK{*Zivd?hge~%i>si{4|M*Gt1|7&o@-gbfbhn;%a6Pn`Z}8`9ba_6PSI8dh_|bO{(R4QiNv$ zPwRw81a)Hy5Bz%i@^% z_d?+9ZyihY1YHGa7_f)F=fjo`eKGL#z!Ab0)DH`S*uEGXESKq?`ehO<405lecB%U% zrV9hTvGmmG>^XznVw-e2Ns9veZg@r#ED3T;ZPFPeEeY&vWB(*r9^_uPN&QJ$9z?p? zRe$NC6)u!PloM=j-Zx>4R|GB0r4&e8t_bv9fNC9*AxL%_0V(T^-oF9#9*z$+=rT zBpHwdYlGaaP2(!_rTiXirGeg%{p0{?hIh;N^~t|g@va;9{1IR82CZLvL8xA z9j7Jaxuv}R!2I!*TLSwGJwR7-OVHNbf`eitW9{#zGK}JF<Rs-~k|D6o7TTIDV z@_%Fj?Ra&*yzP{Hr}6`b=EwEnFJ|EzpihxM2C>;Tf)w({fNlN+BWKwkAC^z6%3o7C zKHMde<$in^n{)R7eSRDtwr;DxC>6H#Ic7?K3IRZDFM2sX9}p%I%@ufs5Xlsg5c!cncZOzJ5Tc}@*|mX4?+ke*mYoZzjrWlUEE(s) zv@my{slP9l(?Vb0`T7Mfr2(dgcJV3b8iQu>0V(J7&@Mg&Dc{|pT>&A3W(5SXy*sok zU_oklPv%#opz~`|#V?OPl!D^kn>m&ug3ht5YWHT2i3c-6v;RgZiyEl60D{=g2-yN$ zCC9*l>?Xq8@-W}=_oYGZ2OV`IN;RcnT2G zA_-_Z6(qNZLmNXu%Kfk{8O9_&650pYM_1)D(y zqMa4mfC-|V6%sIIJPwp1K4v>u2q|dhIf(SJ(7qFbNFTG~2xDw#hn`u7A^*3UU2P@k z&UVp^WVEwGdy6smWzbjrqG7?fjZnagSDiMMk@8`rU+WOHS{nzkg|KArWxe3}884a7hlcAs`(7exBBgk2*g>dwn* zBVT5KM%s0tC*gOV3%?-Rc@loF^mI`Pzt1(*_06k%<#R6n4%G4YITwFHM9)e5onh)n z0Dh@__wo7rD_?SarDT$>a=`^65j4RCWG=08K}*TpS)C>7tvIan^hw2d zb*Lky0-@JKyG4f3f@XUS>QuYERxgD#yd#yb6-SkY)dj^~sg>6725{Kr%B zeJl8bQ%eZj;G*9k9l|z9^pm~aAZeV9C%x67xM$ZlKn-L{C-Y;J!EfF-ERQp*M9%< z`@G_R`hni4T^UrCPOrHPzopvImo!|ae~48xY*^j6G5qk5Tw7kuO>L>Bm=(P7uP?VL z99bIKIA>A!A_W~$8dY~ueecHi7geP+0{u!WxSo;e@o{K>bVVvn_HoD&W1?whjJFY9 zI{ptW)-bYe3_k}OUN^dCB|Y%}PvJ8Hg4GO&LLQ{xY;T`ykExWoRE1274!x`juPv2kVm~XpSbJPPHFef@r^z zxbTcz9uJmTvs1d}vGO^mJ|Y_5%L2n5OI+K3SNC#rRp zRQkH8+|)n+=RVhE8tKF9BKs3WjL=*c@e{;}^7e9e5=@SA_csk6kdM8}$q~;w@&)PH za-Dc<n-bZ%4CyhZic5_~cID;hto-tHMy8Q2 zosnszOJ|5iPG#pL!GlrmQ9HNK!SX@ZgXc)wJQUee8N{XrG^a8U+lL~1Dg*KQP{hdW zP2tbco7RWRL(k4vRy-WpBjq`|ksglhkrG7naKzEdXlSHI%Kr<=Zy#};evWRWMuNzRo}_F804VnRqkQ|uH*`&!h}{00a_`o|8?b678MRBf#yk(J%`jA1Vm6d5(WPkfY zD$-?Wgc8+rO0W|VRY1iM+kp~okVYong}qh*pg2CmCI z$@vd7hX)dIye_gg9=t@dx6W*3gA6*`7~A!kE!&HCvRviB2b!*<{fijC6YZ;ib;@Bm zcrVKR!wvzd+k26(LyI5C5ZLH4fk*_+c?HDHMp>_;G6Xh9p1!c9NYYGTAKk4Wwwt4a^rI8041q0~tqcLs*`|yESQ~q4 zND^#~azEOrIRw+K5)UO34UyLW#6@z5B7o*p24ee3WM2?KTIrL>Zf}N2gKmrLRX#*b zK^+oO982eHi(+#_0;1g(*$=CRNb7&<0w9DIG~ZQ$NI#82697S^pGGzS4iWmyPC2rX z`ybFmA`t0kQEK3R>&$A*+@B~c&BoXcBvYyCF0V4feN-W!(A+o*M9(h05 z?G23e_Q<}L4v`vs5!v5r3`uotg7t9wP7W-(|*faRfHAMU;D^ z@1o|e#By)+U6w7^Mmu-)t=EDQgRY_EjeTcSG`qDS`u1pNn)NNWN8d;G6@7?)MF-8x z1&H?hC^6e#kZiw?^dq{bh9(^awl z!6)qxey~YAhe(>h0NlsV>^jS)y8%b50z{u#O7L_Y-AFH zW)g!)C!{gQW*K}i+6ghYHZqHc$}GM%_Wl~1Lkf)fwK01wnNCBc3V(_1Pt_pW6>MQ$ z$t3a;S8ug|nHi?7eL%;Fnj`=P>6{ZS2QHp(E<8{$1& zJIW-!A-2B}8Y+|c#!M>T)PPP}l=uG|Gp)?x8#67l_@-F(w5=+$7&LqCp?VhI6t~iQ z4mw08@lCOQ*^>TY5>JZljE9hdZrsZxo)ouW5_i;HFSB@3Ox&3u&&TKSEEebPZ2Dnj z-p{AE#l(5(^Ydgb-R|ZRgip};1o3&hn@S)qZ+BDaJQ?A4xTyp&dO$OXL7i%+5{UE; zH2bF#&}vBo52F&c3R9j+m?3uwd6TH_F8v|O7+9!KM*oy z1QY*2ZD+q^25~Yy*5^h~{U!*j@sGXdjGQnq>eamj`1VcRY1L5*bM8~a zlAtNhJ!LZ)hGmn>WLPS>Jn3>7rgH&JE+Dx)>2d+lKIw7+$>m9x%kU(4I?m0vxeUkh z>Dc}zVz^{7FE(H5D2S?nk_ihG%s`$OH#4g*h<0Aw)%-FPB$s({rTiq=Q?*I(Y@B<} z=2DC0voVj15XkU{<u7=k!{ zH8!^xAl3$T5LY#1z9_n#P+xgVGHWzT@tZf5}TVMki~MTU15Z@|%d=LK^$&CfZjSBp*c>vjWfQw1HlG0dVX7ZJVRMH-D#j~f z^J{X9*|n~W?TrG2W6+%KL2OsX`e-lXhqumES?%PF6Lgi^9otn|<@mUBb7hOikj9eSrkAoqG zC1`HmKy25>ZOmp7Wag}m?GHLe=r7mT*+HvD=$!*-b`Bugb+LU#1ktXG%@wF0CBeIK z?jJTr$QXS$_Vr_^A4!QexF&)~1kD$mAQjsX>n}f5iZQA0+2RWsG))F#{GKf@snA9@ zSRiacbKwN)RD0hPNsQR!x(&h>?4i32#CB8MD>eNV$+qYH%vgd1=#1qq<^8yqvn57+ zz>|aZBryWi+ek_%G2(;Re%{Gd0=F=3P(s*(=28jtI3Rs!w{d6*5+B9(*&D(bG=mbv z@kenN{R?+&i4mK#LP-0At`HI6&eB`auTu`ivv#)orqJrp7V&sjGNw zu3cQkL7i$V9!s}=CR10YAB1Djgai=V&tiL18%wW#o+V1Jg08^QtLf)iiRspVx;~F} z=m*W6BZ%=o%~n@hf^OaJq9KH1(1d;v+wCrzb0Xx9#V*SzrB^|{?)vd>bhmzC7G7~o qw|f$?2QXfr?%xJ($CoPu#d6@tUz~CE*%zF1CV5S` za@^R;$SX#*)R)UOC{3RXyb3CDpRe2f3Wqdkb+LCkhTWt#a&!7+SZ_3cbhH7 z(*nO@6GMYe8={>Sq^?3C(rH1DL(1D^R2-f~GlSCYty|uFYskzXsc46XOM}k}{6n=v z2#26?2;y*7kal;eIGhz6QYp{(ao8)1W(TEt46}P$+izQxj~@H0D}K>JpI>#A?)5=e zj2(aFrdW(sBVs{K`&i{Id1HL)L@Q`o#7&p9{y|V z$=ffi`)i?)A@f()cbK5~t91L(TEUQ++q!J~vH9FWA#UatdH5*iy7Add=zcdoAsQGo z4Gbc^Kd|Ey)W&vv_VO7he|KXHLJAsb4x{~hQBZ~U?{0kdlEM5yp_PFGx@={jJWxnw zpgd4Wqf9;M#%C`-LJB%*g!I86&CU2E8SR5^eD?C=Og-erCxjMs+8FIaZhVr8^dT9a z`!Sk&XVLti^jP5OsL&hB`9ab{57OS!vyTLRtsaIDUUN8hjPWBuVnP7PxP2t(($#87 zd^GS6v$h^>3YHz>>d~Oa#E0I#^!=kjdowjbJUtpz4lOT~@!ltkmIS588Sj7dEAuzL zd$gA?36d^4n?6#+rLKq&enC@05Wh=Z2|@fWbtUX0!)ICG_tHgzC?RM{2+|wNf|{-_ zRU6lu`I?iAt7SpY!*v_>&7voR(lfRV`(pZJkaW@o>?;La9{8r)APNYY0)p5s4-yk% zLA)+^L%6RL@TtJ>sEYtmA}}qt8%cPEb#nOtmyGtQAnRbWWx_mNXk}P}E?e>ZbdWfy z%$27_I*jH%B8#34O3$^9*)=qOHb_i&9wF6O<=Pp-A87o6_*>=L8N}Zz*Um>sW~&3g zw=O3{JABuM2SG!Ij5aroow3~MEeid&i#Dac}?K=(3OMG zf@rl=Jg*7LW)P8zbWPCxVBJOigw__>N`wMki6uf+*1A5zSXrUXLF;!{PC1r;(+=BQ|AHvD1$JnF+LS)}4P8{9MehcsU9C5K@az0tx9-1Bh2to?l z9)fkU?I95D`>uxuNDqDBdI&-bx@^Vs2d;-mMf$jZ4Nbeq*Mc1m*4a%$C99GV_a?CF(LtC0h z&A)2zCO^Mc*`Rc!H0E{6E}#�!^(zGPq9J^%Eq6>y%wTkCZB3ul%0610jkEn%)NS zbiJx+?^3mKc8KY1l5ustI_x`o^dBj0J5kvs2to^*%>jsZqO!Yu5a~pvclo|LDvKtm z(v8Y&4vxZdl47xvSaX!r?T^YH03f`Ac$KILWBf;DcUT~9|ETm1%U4Hd(aowf*}6R% z%bS(nbNK3*ESjQ9Q*F{QBu!C?Y{GqYY!*#Zr5QHqSdylx#2gEbmBLK7Rm>GgX<$=F z5aa2tjv$4Z?&^447Tu~!Gp*a>u)I~-aCMxxy-oT3b-O|g5YVhuAhx%uZoOS9UT;%J z^viW=8?xwjReHm^ZNT()W%mRP;`UFjLm&zZn!c><#OWPHzt+&f7&-v z<&c?14Su8(Yh13f$9_z8H8GS_JUyoL9#3k)7Q0Q^BOpYXK-coJ#cosf2uLc@HpLcu zeOfpCQU{wA zzrjy4wOA=L^C6_5>zEYjV%6UDc#3p!uDYA;eS=@l)Dop!b<1c$mu(sC5|x=lEw*Tv zs2=9{+Tho)lUC|TJ05C~g03BFkSVWi_Z~NBon5h*? zxmN6m6m-&#NLQ$fJG&^-5$y`q=LlQmgEFULu9 z+_u*D{>#tT7UQE6qP6PKqiq9rLbO&Lcf8H7bEeiQb+S$DOn#u744sj#Q-`@O?~HVv zI^iTM?UJdNl=_a9c0mee8uJFf3)+`d2XmSQ(Y~ZAhgogcOs(g}&1$=%1&y}B?}~K2 z>fq)^SETFJp=KR#@M|;ma?!fANI@s9Mf$Spkh|8cMf-Bmy4^ChffItwwi{Z|X}h7_ zpgNeNaD(3s=?2xepDl6sOubUHOn0Q9n{?fgzEXsV?m605ia2p_rd}wm1m+j-R+$=jZnN-Kivc1JE{}3wx zXm)cTwp&z^o65ns-D0+LzB(a`-cqHvt=khYeM=?gI`jl_yUpzeAl!n+Er{(l*>p+R z0`a=dZCy^xqIXp3UF-EkEZn`UN!YgRJf*#>*mval_c9(N|au)4Yr4OvzlX1J-wZX~a_IVZRw;;A(IIkdHzi?hp%c8GU>Do}X>3N_|!}M!+202aK ze&a?hgj>+K1+o3cok2j{e&fy{r^%M^x1rrILP$ZgehoUn9j+gml>M_xW9v`58(lEqY4X^4V@ zroBL1P7Uo*5ya)x&{le&G-j)7Y6vOlq|!~TuBk~y+UlA*A1K{4t;klo2{dg@BHC%L ztwFTYifp0jp?yyo=m#_kXc`5?)AZ2p??F6G5AFUQ)Y@*jd0rVPCxjWHy^4hlI#+To zpAp(!I;pst5!zikh^rZ)-K7td3RGSHLk3O%gGj5c|3O)-s%`+Bkwr7Z((QHtoPp`g z(4N@NklvZ)1^`3>K~q4G0?u*+07N>=^*=}^v!wr@Na_DUS@cj?S{!<{^U_M&x&yq0 zDu2l3Gf488=eh%;_@L=Z5XbY}C;)Lh&vnNj>8pp`WQCA|rskkdwUZS@`fzAZcljV0 z1@qkqg3y94Tk$;KjUZCd&UYhdkkBJViKW{?Gk{1$`iL7qpf0_b2mjjVL7TQ~%L2~YGE2L3K z$bemKI08xLW82`5-y;Eeh==07P4WuB+wHx5y20QjsnSyUIfg9wOrS!f?bKyq1T3h1k| zvgpaM^pwrzEG(Z4?L)#@lF4#6Od-7H@cz0*VT_lD2be_##O-o3PJJ~vi=GKftF7C? zSU%&z=U{QWBDDAL5KRJ_J_WH|Aw`fS8N};~(B8uj_H$BKy5=0LXAWqZ6V$2pN(Dr_ zGPGAJgJt48>tY~8wxE;B#CbMM&9RtNq|b)-Qe&{tDxK?4T_~A3u+z%SSrvYpt<0QN zCR<;fokh=wrFFI;&c^cjkn8Kux9b z@*q#k`qKj*mizzPL$ZE_wg%Rgf->}2a>^#@Q^-w=z(OgBkb3|{6e5zk-EEW{~q2yynMF{ zedp*gw>ykY_kf6YoAK(a#w_}f4Mb$_WE*ktp+qYwS)-I}kBdhTMFGuD6vTE+FN1}?OwMAG|DXa$TcxU6N9FSL8KqKCI*pyPuv;+BK^e0@kS})r-f9u z2|4VtmG$FOw+3L0_R~U3KA*WYpi$o;f@Tc>k$&dZ01)YCZVhOZJH*djgoUU8Xhu#B z~3yNNJaW(Xm3p#<<9Uc z7os6551Pt@IW_poZ5Ux(edRWcAg;a&?LAnSC5_0cHwtCrE-#7k>q;u=BUQ3Md)yQb99ioasY9A zb97iweZ|`-SG>1Gel?OSUIwzh;k_kF_Os2#?cU4cO>&`#3eScdZbm0?ncVqMaJqyH*hC)X3hoHu~H?wnpk0+tb`W3Yv>c5|Oq> z-OT+Wh_p42jy&2HncK%{k-Z;l^yT&uG?!W++G$Y_*VA(QI4%0Nr@4Kc9;st(wsQLj zx@zLBhR^Nej7WXYYUTD3G*?88zT7^}h~oe+tjZcIVq=c5tdyBJ#%A7g+@9ZB>gFvI$FKp5l4A|6+aG1M0MeXOzM z3hL&_rJgH-Kgl1JOPg z**gPJ8?&|fvwT1@B#YKXrJLG19_>}U1J_9dZIJEb5NU=D)iaOrUai~U#yx+;*M=xH zvmeCQ1{tvbPfJLAt$NO}-mmLkDOEg{dX)&D-uJEMMUWS^af=t^#lI+;svP>f`(J!&e$DBfQE=W$+J z{dU(dL-bhQ9wp|*2}H9!;%R!b(a?%Jt7{v)t#v!ya2TRblslvDCZvIAc1FjaQ2uXS zG&*f}b@TDwZ|Zgz5*gaNqweO7Y>3_o?3STDMoZWU?5Y0Y1aIuIdx{2-6@O1;@27_7 z6@QNmvT3GdEcu^AevjJgyy{KodflpnPxP)Fz+cS5H$)#AKZ#PaZ3HRgClR~)>y4ac ze_vGHd7?M5eqSMy<$hn3nq&45eO%lZb?l_S)EpvDgkMF~J5Ta1sQ;?a$U6U3WN#BN zBBQUO&RxsXO-3yA*HjNX*{jxH6WgIYL@(&q#AWj|4WhXwuI=vX#)^Jjb;rrxjQZ;e zjjZI?#bvWj4)J9rzb@`lTYk;t!%9A>I`_NY8+DUndu=d8FVmCa*lc=1G?U`2WBCN# z?5x8#Rv$UVJ1M_0_RXdG5WW817}hZa4K0#d1a*>O0;r@N*iV8r#(;&@~3l>H|{FYHU}ZK+1P(Y?naD zpjiSzY;TS25*SDgZ!7$Y6m)(~s`wqrvKC0)W)_a6h@f*U%i7GsF#%y#Y%Us6%BlwH zJ%Av#vtsrD*UIT{DBFp+w6c2s>E7@2yW+%rOEy$G{m(W#1raVl({Ugk{~X&u2~xH{ z+uSiG@$T3xj6z63CzTetJGKFnRHS#uW}R30P^t86H`XAupwo)y*|CkBq@tbe#@bM! zdtw_hA*7&LCP1Y3#5Nj&+Sq;yJyecZ_s0H-=86tv;J{|!fN1ZHZBzu&-WwAYWy}qg zBHm{^Q3xq$<}!%%zSzF~fk^MOV+CVu=fu8QY$3U#gI#SUvd)QZ$RZi-oY>wB4wW<3 zUt;@c0Z|IjNhQMmCAN`^RHT22xd&w=ku3psZtR~_dqL%*%UaA&E-t@f{DjK5>Y6jW zA$6p=n#%!eZfs9F2=OyFKH?&a3Vi#8{hGXe7|ii)}>5hHU1^L^TaeoPD?$67mrSXtoYRb-aDp#aj^R!xC?e zlyE!0sEvGs0UGJyLw$+6^IhBp(ax8+`#WD3mAJd0ZU3`8<<&26fp@45ybE051raTf zzgD#yeyS(5l6m^>sJVfwHH+zkMRzz z)}HGPtm6+(EphD?7ZQi*xb}*KMA_91lg8Otz2RJTbsGzbwBW|remsZ`8E%Y;NmUt2 z!vx=`es->R+u3gv8fnQl-1NdmjAFe(_to$$+7g%c+C_XgmRn-J@w=E-8BROCS?x8% zYs%kr?KoVwMAVu63 z+pFZ^zTA_)?N*23`Vaw{T`H(k?KgHH+P7t(w8=fB=?izumOTPs#p|d)9H>{r+;a{t;30gvzg)$6VP`xvXVei`?2) ze>&8=>r@J4u2Lu-4Y4b>uYn+y*%jN@K#bl0et;5(xIh_VYYak?x84JnsV5`3th>leqLnTy1xO zH|+bLxP|@#>B~>uoPh8M8jm0zKXr2g#N($jCw@&0E|B`|i>q@l@P0F3U!jpc+!x!Q zbzy{NU(C;gH8@(S$ zvXpXSb@IjDd36&LWp>y65u1rg7xR@7NKO+ILeOutgv3eJnoGRj)=f&3snCUbfK5tL zbJ7HnOiDOueospnUpH14T;es>-cjR?*1*{Gu$XCZ*dG zvp>HG%W7h`Y8Oe{+?v?^Im9Xln*BM5?X8L3pM!Y4HDM6;r|=i)t<7!K^M2&j*WH%b z!^K6qk#0-u;Q~Z+Tf!#WXlSIF)xROhXC`)_T%;RmW@5MUAd;CPktlqT(5%Ehd_$BF zG@A^N63$Axn=2|%8?!xKC|{sooJDhz(%%z)78O}cA>iP@+NAq7pba~SR7q`moV2#9uZ!o-p`zC>t=>vo8F44UmP zh;&JknD<9e8@Jcjc1RhHOB1`7gph(xD#LPVlH}%sm}Im|6Z@!siG;jmiCqdHgH9{k z-(`vYF$AedmnC*7xJ0V=xEr?+QqW9P5b5KI-Rpz$5yRs$aOJobX3_Gb^rGpsP;yzG zg!+A#TRi&gsl?vPK_r4^>jBclPbKzVQD0v^Z5te=bPlK+SPIPEU6@_DIe$Y9V{m%;LIF#mJS=dT@gU(h?Yb&vp@9hP1v=Xl+vf{g_8c!1xyrEsG+jsgmoa`J z`HucIBIm4VO;Xxuhk(>=O%m#O6Gk!w*1Aj}5<#=81#z=BvAfzxx^Z1%ZYEL65CHZ1 zAc*ZcSs&z!OBj5~6%fK0GzV)?rym8g61Li$?b*~*48$PHOcJVS`=A=Cujm7iDH`; zg;Z`A-YuluF1(l6x5Fl1ZWloF0sta?FXjtofM%eAI@Qiv5a}K_Ye&ee-D{&4j$}50rt3gF?M?I#0BL_1 zS9_Dp?0`UA?R7h#5keoiSqz~CT`6Q1f0WosOsY1vlX!$=`*C7!ZOKL^F=!?+i1gz; z;V3PG4@UcO!r5MC@d%m4pC`#{<+7)bPUCAW=EZN_@ zWsr*J&k}p{HA2?!&l9C1yUIbsCWe7%KTi%Ya~wqbd18OMG(v*;KBt9{f=(*2eP1D! zNxZL+GKs$^r1Hrgm^-b^;x7uV%;GN!EwlK`#6Dz?&>vlZW}^%u{W3YwwWCbpFBAKd zkrDb4rjW`v7he_9vb+y}RcQ5NOrd2Kf1RklwpC>ogJ#b?LeJu_lMZ^%L5IjB{yNbQ zAJRWe;&0p}hLD18+{+~XCTYhcuGL*Hv-lgC#n;R8;*YXuVp{rR+ny`D(90*L#ChrS zAIV(0AvK#EA$)?yCy37*QahDET;7nH?Fhys{voxGoe-l3G=mt_sdg%XNdJ)91u*}S z%%w@G+2G+x1~q8r5=bc~rS|)65Lc7ZZl+N{Tun;#rp;GB&Z3*r(k(Vl{20rdQu_t> zkEL!mr}lUWQ9zIa_NIYgjBid;Ggv^}-kh?|cA{N=EO}0@b|2?;$tS1w9+p4KUsL-T zD~M!rs*jGo`bic|O-s{kNq&Om)RYe%q?LanC239VF%qJ>ps6lM!CKQAGxR{Dt*O0L z`3X-t}< zQ~QhQpGz*YQ+pi&kqKxr0m)=`T9cdQ3Pw9S&CK&5NG`KebIMaAv*^CG^cS1UNG$Jj zxr~%t=D1uSasf>)Ai2y*?GML6q;p&*V8Cf+j>~0K7X2+P-EVUlh3Vf?`$7CD$z^V8 zzS~d`xqv1YkQp^Mtuae2h;(jxxcONiNLA*h_40dAU$tb>18M0Yn@NkTrVpgNM#(^K zkz5{hoez-ernG- zajyNq=Ac6=v15K}KDodc=_7V(2^rK6Rf5<)V&;wlp+^f_k%G?Fq?-Lbwv_fUv6Y{v z3Sta9V>uK(Ryd{|+6u?A3WLs84n=K+EseAwHCGt;m1PywR~CZUE=bLl1xUB$3yZ4D zItuDNx};*f&<0hIb#zf-D{TroTR9UgDr{Le7pLaOKqzIQ1obXX5ZlG6`KmMIG{s7} zB(;ZI2xCw$<`~Plcu8vRg+TVVOYM3hge_>^enD)Pre+x>m8Ec5;a7IBpzG!uY?q}6 zA7XA3Vp;qiFPcqOJJ6MQKa3ww&5;7fY+0X3&5r_vum#P90f_Arsre}}sccc7Ozrgn zge_=C5>+hhE6%8zS}F(30iQ+UcS2KBxSPZ_q)q-I|R^6}n^ zqMzk!Jy7p9BoW&c=|S@377#DpPWGYQN@(7?+^AX9F=_ zogQSih9FaCb!xxnzf@+~b9Nl!NVW-}*(QKG)jlkNNS{m1eP@_t(Tiy*-)Mt`4AK|V zP(P4`iInIcu8klPLGwKyNX`C{>hAy*wj{2xr57@2S`5T?jV&&z&{{WGAc=plxn6>G zvb|kOBuK1t{RZI}?4$b)#CBcU&vaWNdz_aFTZs{%vy~X}QejIYt>@7}9OaZrf&{EL zje;1jPwmHkTpV!c;szyzEoiQgKx|*Odp4xRhz+TI35Bo)&7cIa-H;xpf8C5NG2)ff z&vhvTF@{|sBuKnc6oMG>YHI#t4s0bxfMx`P*uI*Y2ZAuoqSsUYw?=)m3&ryFG}IfV zFqK|?!%aVk#2hxU2FA}DF4m;dt()BRgAD5R8Bfx!o6O3oKxnh8ID{=|Dh}#YTk%wS zb&H$1X>M*+ib6@ZZb|LEY%1NlwMdk11zmxqTelVkrd!{1eV*!G1Qs zSKpFosMjA5$FK?KAjWUGaGpxHZnMiWQt4Ju@9hM!-DVbEaZInL1>9;aEBgbdIH4x3``0Yp?U={~uWYE3yCp diff --git a/clients/rust/protos/mxaccess_gateway.proto b/clients/rust/protos/mxaccess_gateway.proto index 6b00da9..915b1e8 100644 --- a/clients/rust/protos/mxaccess_gateway.proto +++ b/clients/rust/protos/mxaccess_gateway.proto @@ -391,10 +391,13 @@ message AlarmFailoverConfig { int32 failback_stable_probes = 3; // clean probes before switching back (>=1) } -// Acknowledge a single alarm by its GUID. Operator identity fields are -// recorded atomically with the ack transition in the alarm-history log. -// The reply's hresult / native_status surfaces AVEVA's -// AlarmAckByGUID return code. +// Acknowledge a single alarm by its GUID. The worker resolves the GUID to the +// alarm's Galaxy reference from its active-alarm snapshot and acknowledges it +// by writing `comment` to `.AckMsg` over MXAccess after +// AuthenticateUser as the operator, so the alarm history records the real +// operator. The reply's hresult / native_status is 0 when the write was +// applied (or sent but unconfirmed), otherwise the AuthenticateUser HRESULT or +// the OnWriteComplete status detail. message AcknowledgeAlarmCommand { // Canonical 8-4-4-4-12 GUID string (e.g. "BCC47053-9542-4D65-BDAA-BCDEA6A32A73"). string alarm_guid = 1; @@ -403,6 +406,10 @@ message AcknowledgeAlarmCommand { 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; } // Snapshot the currently-active alarm set. Optional filter prefix scopes @@ -414,9 +421,7 @@ message QueryActiveAlarmsCommand { // Acknowledge a single alarm by its (name, provider, group) tuple. Used // when the public RPC's AlarmFullReference (Provider!Group.Tag) cannot -// be resolved to a GUID directly. The worker invokes -// wwAlarmConsumerClass.AlarmAckByName which reaches the same alarm -// history path as AlarmAckByGUID. +// be resolved to a GUID directly. The worker acknowledges by writing `comment` to `.AckMsg` (Galaxy provider only) after AuthenticateUser as the operator. message AcknowledgeAlarmByNameCommand { // Tag/alarm name (e.g. "TestMachine_001.TestAlarm001"). Tag itself // may contain dots; the gateway-side parser splits on the first dot @@ -431,6 +436,10 @@ message AcknowledgeAlarmByNameCommand { string operator_node = 6; string operator_domain = 7; string operator_full_name = 8; + // 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; } message UnsubscribeBulkCommand { @@ -725,11 +734,11 @@ message DrainEventsReply { // Reply payload for AcknowledgeAlarmCommand AND // AcknowledgeAlarmByNameCommand — both ack command kinds reuse this // payload case (`MxCommandReply.acknowledge_alarm`); there is no -// dedicated by-name reply case. Surfaces AVEVA's native ack return -// code (AlarmAckByGUID for the GUID arm, AlarmAckByName for the -// by-name arm); 0 means success. The MxCommandReply's hresult field +// dedicated by-name reply case. Surfaces the ack outcome: 0 when the +// AckMsg write was applied or sent but unconfirmed; otherwise the +// AuthenticateUser HRESULT or the OnWriteComplete status detail. The MxCommandReply's hresult field // carries the same value and is preferred for protocol consumers — -// this payload exists so the gateway-side WorkerAlarmRpcDispatcher +// this payload exists so GatewayAlarmMonitor // can echo native_status into AcknowledgeAlarmReply.hresult without // unpacking the outer envelope. message AcknowledgeAlarmReplyPayload { @@ -987,6 +996,15 @@ message AcknowledgeAlarmRequest { // Operator principal performing the acknowledgement. The lmxopcua side // resolves this from the OPC UA session prior to invoking the RPC. string 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 "\". + string operator_domain = 7; } message AcknowledgeAlarmReply { @@ -997,8 +1015,7 @@ message AcknowledgeAlarmReply { ProtocolStatus protocol_status = 3; // Native ack return code echoed from the worker. The worker carries the // ack outcome as a single int32 (AcknowledgeAlarmReplyPayload.native_status, - // = AlarmAckByName / AlarmAckByGUID return code; 0 = success); the gateway's - // WorkerAlarmRpcDispatcher copies that value here. This is the authoritative + // 0 = applied or sent; otherwise the AuthenticateUser HRESULT or OnWriteComplete detail); GatewayAlarmMonitor copies that value here. This is the authoritative // ack-outcome field for the public RPC. Absent only when the worker reply // omitted the value entirely (a protocol violation). optional int32 hresult = 4; diff --git a/src/ZB.MOM.WW.MxGateway.Contracts/Generated/MxaccessGateway.cs b/src/ZB.MOM.WW.MxGateway.Contracts/Generated/MxaccessGateway.cs index 8075357..9e1229e 100644 --- a/src/ZB.MOM.WW.MxGateway.Contracts/Generated/MxaccessGateway.cs +++ b/src/ZB.MOM.WW.MxGateway.Contracts/Generated/MxaccessGateway.cs @@ -172,370 +172,373 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { "cmlvcml0eV9zdWJ0YWcYBiABKAkihQEKE0FsYXJtRmFpbG92ZXJDb25maWcS", "JQodY29uc2VjdXRpdmVfZmFpbHVyZV90aHJlc2hvbGQYASABKAUSJwofZmFp", "bGJhY2tfcHJvYmVfaW50ZXJ2YWxfc2Vjb25kcxgCIAEoBRIeChZmYWlsYmFj", - "a19zdGFibGVfcHJvYmVzGAMgASgFIqEBChdBY2tub3dsZWRnZUFsYXJtQ29t", + "a19zdGFibGVfcHJvYmVzGAMgASgFIrwBChdBY2tub3dsZWRnZUFsYXJtQ29t", "bWFuZBISCgphbGFybV9ndWlkGAEgASgJEg8KB2NvbW1lbnQYAiABKAkSFQoN", "b3BlcmF0b3JfdXNlchgDIAEoCRIVCg1vcGVyYXRvcl9ub2RlGAQgASgJEhcK", "D29wZXJhdG9yX2RvbWFpbhgFIAEoCRIaChJvcGVyYXRvcl9mdWxsX25hbWUY", - "BiABKAkiNwoYUXVlcnlBY3RpdmVBbGFybXNDb21tYW5kEhsKE2FsYXJtX2Zp", - "bHRlcl9wcmVmaXgYASABKAki0gEKHUFja25vd2xlZGdlQWxhcm1CeU5hbWVD", - "b21tYW5kEhIKCmFsYXJtX25hbWUYASABKAkSFQoNcHJvdmlkZXJfbmFtZRgC", - "IAEoCRISCgpncm91cF9uYW1lGAMgASgJEg8KB2NvbW1lbnQYBCABKAkSFQoN", - "b3BlcmF0b3JfdXNlchgFIAEoCRIVCg1vcGVyYXRvcl9ub2RlGAYgASgJEhcK", - "D29wZXJhdG9yX2RvbWFpbhgHIAEoCRIaChJvcGVyYXRvcl9mdWxsX25hbWUY", - "CCABKAkiRQoWVW5zdWJzY3JpYmVCdWxrQ29tbWFuZBIVCg1zZXJ2ZXJfaGFu", - "ZGxlGAEgASgFEhQKDGl0ZW1faGFuZGxlcxgCIAMoBSJfChBXcml0ZUJ1bGtD", - "b21tYW5kEhUKDXNlcnZlcl9oYW5kbGUYASABKAUSNAoHZW50cmllcxgCIAMo", - "CzIjLm14YWNjZXNzX2dhdGV3YXkudjEuV3JpdGVCdWxrRW50cnkiYwoOV3Jp", - "dGVCdWxrRW50cnkSEwoLaXRlbV9oYW5kbGUYASABKAUSKwoFdmFsdWUYAiAB", - "KAsyHC5teGFjY2Vzc19nYXRld2F5LnYxLk14VmFsdWUSDwoHdXNlcl9pZBgD", - "IAEoBSJhChFXcml0ZTJCdWxrQ29tbWFuZBIVCg1zZXJ2ZXJfaGFuZGxlGAEg", - "ASgFEjUKB2VudHJpZXMYAiADKAsyJC5teGFjY2Vzc19nYXRld2F5LnYxLldy", - "aXRlMkJ1bGtFbnRyeSKbAQoPV3JpdGUyQnVsa0VudHJ5EhMKC2l0ZW1faGFu", - "ZGxlGAEgASgFEisKBXZhbHVlGAIgASgLMhwubXhhY2Nlc3NfZ2F0ZXdheS52", - "MS5NeFZhbHVlEjUKD3RpbWVzdGFtcF92YWx1ZRgDIAEoCzIcLm14YWNjZXNz", - "X2dhdGV3YXkudjEuTXhWYWx1ZRIPCgd1c2VyX2lkGAQgASgFIm0KF1dyaXRl", - "U2VjdXJlZEJ1bGtDb21tYW5kEhUKDXNlcnZlcl9oYW5kbGUYASABKAUSOwoH", - "ZW50cmllcxgCIAMoCzIqLm14YWNjZXNzX2dhdGV3YXkudjEuV3JpdGVTZWN1", - "cmVkQnVsa0VudHJ5IowBChVXcml0ZVNlY3VyZWRCdWxrRW50cnkSEwoLaXRl", - "bV9oYW5kbGUYASABKAUSFwoPY3VycmVudF91c2VyX2lkGAIgASgFEhgKEHZl", - "cmlmaWVyX3VzZXJfaWQYAyABKAUSKwoFdmFsdWUYBCABKAsyHC5teGFjY2Vz", - "c19nYXRld2F5LnYxLk14VmFsdWUibwoYV3JpdGVTZWN1cmVkMkJ1bGtDb21t", - "YW5kEhUKDXNlcnZlcl9oYW5kbGUYASABKAUSPAoHZW50cmllcxgCIAMoCzIr", - "Lm14YWNjZXNzX2dhdGV3YXkudjEuV3JpdGVTZWN1cmVkMkJ1bGtFbnRyeSLE", - "AQoWV3JpdGVTZWN1cmVkMkJ1bGtFbnRyeRITCgtpdGVtX2hhbmRsZRgBIAEo", - "BRIXCg9jdXJyZW50X3VzZXJfaWQYAiABKAUSGAoQdmVyaWZpZXJfdXNlcl9p", - "ZBgDIAEoBRIrCgV2YWx1ZRgEIAEoCzIcLm14YWNjZXNzX2dhdGV3YXkudjEu", - "TXhWYWx1ZRI1Cg90aW1lc3RhbXBfdmFsdWUYBSABKAsyHC5teGFjY2Vzc19n", - "YXRld2F5LnYxLk14VmFsdWUiUwoPUmVhZEJ1bGtDb21tYW5kEhUKDXNlcnZl", - "cl9oYW5kbGUYASABKAUSFQoNdGFnX2FkZHJlc3NlcxgCIAMoCRISCgp0aW1l", - "b3V0X21zGAMgASgNIh4KC1BpbmdDb21tYW5kEg8KB21lc3NhZ2UYASABKAki", - "GAoWR2V0U2Vzc2lvblN0YXRlQ29tbWFuZCIWChRHZXRXb3JrZXJJbmZvQ29t", - "bWFuZCIoChJEcmFpbkV2ZW50c0NvbW1hbmQSEgoKbWF4X2V2ZW50cxgBIAEo", - "DSJIChVTaHV0ZG93bldvcmtlckNvbW1hbmQSLwoMZ3JhY2VfcGVyaW9kGAEg", - "ASgLMhkuZ29vZ2xlLnByb3RvYnVmLkR1cmF0aW9uIoYPCg5NeENvbW1hbmRS", - "ZXBseRISCgpzZXNzaW9uX2lkGAEgASgJEhYKDmNvcnJlbGF0aW9uX2lkGAIg", - "ASgJEjAKBGtpbmQYAyABKA4yIi5teGFjY2Vzc19nYXRld2F5LnYxLk14Q29t", - "bWFuZEtpbmQSPAoPcHJvdG9jb2xfc3RhdHVzGAQgASgLMiMubXhhY2Nlc3Nf", - "Z2F0ZXdheS52MS5Qcm90b2NvbFN0YXR1cxIUCgdocmVzdWx0GAUgASgFSAGI", - "AQESMgoMcmV0dXJuX3ZhbHVlGAYgASgLMhwubXhhY2Nlc3NfZ2F0ZXdheS52", - "MS5NeFZhbHVlEjQKCHN0YXR1c2VzGAcgAygLMiIubXhhY2Nlc3NfZ2F0ZXdh", - "eS52MS5NeFN0YXR1c1Byb3h5EhoKEmRpYWdub3N0aWNfbWVzc2FnZRgIIAEo", - "CRI2CghyZWdpc3RlchgUIAEoCzIiLm14YWNjZXNzX2dhdGV3YXkudjEuUmVn", - "aXN0ZXJSZXBseUgAEjUKCGFkZF9pdGVtGBUgASgLMiEubXhhY2Nlc3NfZ2F0", - "ZXdheS52MS5BZGRJdGVtUmVwbHlIABI3CglhZGRfaXRlbTIYFiABKAsyIi5t", - "eGFjY2Vzc19nYXRld2F5LnYxLkFkZEl0ZW0yUmVwbHlIABJGChFhZGRfYnVm", - "ZmVyZWRfaXRlbRgXIAEoCzIpLm14YWNjZXNzX2dhdGV3YXkudjEuQWRkQnVm", - "ZmVyZWRJdGVtUmVwbHlIABI0CgdzdXNwZW5kGBggASgLMiEubXhhY2Nlc3Nf", - "Z2F0ZXdheS52MS5TdXNwZW5kUmVwbHlIABI2CghhY3RpdmF0ZRgZIAEoCzIi", - "Lm14YWNjZXNzX2dhdGV3YXkudjEuQWN0aXZhdGVSZXBseUgAEkcKEWF1dGhl", - "bnRpY2F0ZV91c2VyGBogASgLMioubXhhY2Nlc3NfZ2F0ZXdheS52MS5BdXRo", - "ZW50aWNhdGVVc2VyUmVwbHlIABJLChRhcmNoZXN0cmFfdXNlcl90b19pZBgb", - "IAEoCzIrLm14YWNjZXNzX2dhdGV3YXkudjEuQXJjaGVzdHJBVXNlclRvSWRS", - "ZXBseUgAEkAKDWFkZF9pdGVtX2J1bGsYHCABKAsyJy5teGFjY2Vzc19nYXRl", - "d2F5LnYxLkJ1bGtTdWJzY3JpYmVSZXBseUgAEkMKEGFkdmlzZV9pdGVtX2J1", - "bGsYHSABKAsyJy5teGFjY2Vzc19nYXRld2F5LnYxLkJ1bGtTdWJzY3JpYmVS", - "ZXBseUgAEkMKEHJlbW92ZV9pdGVtX2J1bGsYHiABKAsyJy5teGFjY2Vzc19n", - "YXRld2F5LnYxLkJ1bGtTdWJzY3JpYmVSZXBseUgAEkYKE3VuX2FkdmlzZV9p", - "dGVtX2J1bGsYHyABKAsyJy5teGFjY2Vzc19nYXRld2F5LnYxLkJ1bGtTdWJz", - "Y3JpYmVSZXBseUgAEkEKDnN1YnNjcmliZV9idWxrGCAgASgLMicubXhhY2Nl", - "c3NfZ2F0ZXdheS52MS5CdWxrU3Vic2NyaWJlUmVwbHlIABJDChB1bnN1YnNj", - "cmliZV9idWxrGCEgASgLMicubXhhY2Nlc3NfZ2F0ZXdheS52MS5CdWxrU3Vi", - "c2NyaWJlUmVwbHlIABJOChFhY2tub3dsZWRnZV9hbGFybRgiIAEoCzIxLm14", - "YWNjZXNzX2dhdGV3YXkudjEuQWNrbm93bGVkZ2VBbGFybVJlcGx5UGF5bG9h", - "ZEgAElEKE3F1ZXJ5X2FjdGl2ZV9hbGFybXMYIyABKAsyMi5teGFjY2Vzc19n", - "YXRld2F5LnYxLlF1ZXJ5QWN0aXZlQWxhcm1zUmVwbHlQYXlsb2FkSAASOQoK", - "d3JpdGVfYnVsaxgkIAEoCzIjLm14YWNjZXNzX2dhdGV3YXkudjEuQnVsa1dy", - "aXRlUmVwbHlIABI6Cgt3cml0ZTJfYnVsaxglIAEoCzIjLm14YWNjZXNzX2dh", - "dGV3YXkudjEuQnVsa1dyaXRlUmVwbHlIABJBChJ3cml0ZV9zZWN1cmVkX2J1", - "bGsYJiABKAsyIy5teGFjY2Vzc19nYXRld2F5LnYxLkJ1bGtXcml0ZVJlcGx5", - "SAASQgoTd3JpdGVfc2VjdXJlZDJfYnVsaxgnIAEoCzIjLm14YWNjZXNzX2dh", - "dGV3YXkudjEuQnVsa1dyaXRlUmVwbHlIABI3CglyZWFkX2J1bGsYKCABKAsy", - "Ii5teGFjY2Vzc19nYXRld2F5LnYxLkJ1bGtSZWFkUmVwbHlIABI/Cg1zZXNz", - "aW9uX3N0YXRlGGQgASgLMiYubXhhY2Nlc3NfZ2F0ZXdheS52MS5TZXNzaW9u", - "U3RhdGVSZXBseUgAEjsKC3dvcmtlcl9pbmZvGGUgASgLMiQubXhhY2Nlc3Nf", - "Z2F0ZXdheS52MS5Xb3JrZXJJbmZvUmVwbHlIABI9CgxkcmFpbl9ldmVudHMY", - "ZiABKAsyJS5teGFjY2Vzc19nYXRld2F5LnYxLkRyYWluRXZlbnRzUmVwbHlI", - "AEIJCgdwYXlsb2FkQgoKCF9ocmVzdWx0IiYKDVJlZ2lzdGVyUmVwbHkSFQoN", - "c2VydmVyX2hhbmRsZRgBIAEoBSIjCgxBZGRJdGVtUmVwbHkSEwoLaXRlbV9o", - "YW5kbGUYASABKAUiJAoNQWRkSXRlbTJSZXBseRITCgtpdGVtX2hhbmRsZRgB", - "IAEoBSIrChRBZGRCdWZmZXJlZEl0ZW1SZXBseRITCgtpdGVtX2hhbmRsZRgB", - "IAEoBSJCCgxTdXNwZW5kUmVwbHkSMgoGc3RhdHVzGAEgASgLMiIubXhhY2Nl", - "c3NfZ2F0ZXdheS52MS5NeFN0YXR1c1Byb3h5IkMKDUFjdGl2YXRlUmVwbHkS", - "MgoGc3RhdHVzGAEgASgLMiIubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeFN0YXR1", - "c1Byb3h5IigKFUF1dGhlbnRpY2F0ZVVzZXJSZXBseRIPCgd1c2VyX2lkGAEg", - "ASgFIikKFkFyY2hlc3RyQVVzZXJUb0lkUmVwbHkSDwoHdXNlcl9pZBgBIAEo", - "BSKBAQoPU3Vic2NyaWJlUmVzdWx0EhUKDXNlcnZlcl9oYW5kbGUYASABKAUS", - "EwoLdGFnX2FkZHJlc3MYAiABKAkSEwoLaXRlbV9oYW5kbGUYAyABKAUSFgoO", - "d2FzX3N1Y2Nlc3NmdWwYBCABKAgSFQoNZXJyb3JfbWVzc2FnZRgFIAEoCSJL", - "ChJCdWxrU3Vic2NyaWJlUmVwbHkSNQoHcmVzdWx0cxgBIAMoCzIkLm14YWNj", - "ZXNzX2dhdGV3YXkudjEuU3Vic2NyaWJlUmVzdWx0IsQBCg9CdWxrV3JpdGVS", - "ZXN1bHQSFQoNc2VydmVyX2hhbmRsZRgBIAEoBRITCgtpdGVtX2hhbmRsZRgC", - "IAEoBRIWCg53YXNfc3VjY2Vzc2Z1bBgDIAEoCBIUCgdocmVzdWx0GAQgASgF", - "SACIAQESNAoIc3RhdHVzZXMYBSADKAsyIi5teGFjY2Vzc19nYXRld2F5LnYx", - "Lk14U3RhdHVzUHJveHkSFQoNZXJyb3JfbWVzc2FnZRgGIAEoCUIKCghfaHJl", - "c3VsdCJHCg5CdWxrV3JpdGVSZXBseRI1CgdyZXN1bHRzGAEgAygLMiQubXhh", - "Y2Nlc3NfZ2F0ZXdheS52MS5CdWxrV3JpdGVSZXN1bHQivgIKDkJ1bGtSZWFk", - "UmVzdWx0EhUKDXNlcnZlcl9oYW5kbGUYASABKAUSEwoLdGFnX2FkZHJlc3MY", - "AiABKAkSEwoLaXRlbV9oYW5kbGUYAyABKAUSFgoOd2FzX3N1Y2Nlc3NmdWwY", - "BCABKAgSEgoKd2FzX2NhY2hlZBgFIAEoCBIrCgV2YWx1ZRgGIAEoCzIcLm14", - "YWNjZXNzX2dhdGV3YXkudjEuTXhWYWx1ZRIPCgdxdWFsaXR5GAcgASgFEjQK", - "EHNvdXJjZV90aW1lc3RhbXAYCCABKAsyGi5nb29nbGUucHJvdG9idWYuVGlt", - "ZXN0YW1wEjQKCHN0YXR1c2VzGAkgAygLMiIubXhhY2Nlc3NfZ2F0ZXdheS52", - "MS5NeFN0YXR1c1Byb3h5EhUKDWVycm9yX21lc3NhZ2UYCiABKAkiRQoNQnVs", - "a1JlYWRSZXBseRI0CgdyZXN1bHRzGAEgAygLMiMubXhhY2Nlc3NfZ2F0ZXdh", - "eS52MS5CdWxrUmVhZFJlc3VsdCJFChFTZXNzaW9uU3RhdGVSZXBseRIwCgVz", - "dGF0ZRgBIAEoDjIhLm14YWNjZXNzX2dhdGV3YXkudjEuU2Vzc2lvblN0YXRl", - "InUKD1dvcmtlckluZm9SZXBseRIZChF3b3JrZXJfcHJvY2Vzc19pZBgBIAEo", - "BRIWCg53b3JrZXJfdmVyc2lvbhgCIAEoCRIXCg9teGFjY2Vzc19wcm9naWQY", - "AyABKAkSFgoObXhhY2Nlc3NfY2xzaWQYBCABKAkiQAoQRHJhaW5FdmVudHNS", - "ZXBseRIsCgZldmVudHMYASADKAsyHC5teGFjY2Vzc19nYXRld2F5LnYxLk14", - "RXZlbnQiNQocQWNrbm93bGVkZ2VBbGFybVJlcGx5UGF5bG9hZBIVCg1uYXRp", - "dmVfc3RhdHVzGAEgASgFIngKHVF1ZXJ5QWN0aXZlQWxhcm1zUmVwbHlQYXls", - "b2FkEjsKCXNuYXBzaG90cxgBIAMoCzIoLm14YWNjZXNzX2dhdGV3YXkudjEu", - "QWN0aXZlQWxhcm1TbmFwc2hvdBIaChJzbmFwc2hvdF90cnVuY2F0ZWQYAiAB", - "KAgijwgKB014RXZlbnQSMgoGZmFtaWx5GAEgASgOMiIubXhhY2Nlc3NfZ2F0", - "ZXdheS52MS5NeEV2ZW50RmFtaWx5EhIKCnNlc3Npb25faWQYAiABKAkSFQoN", - "c2VydmVyX2hhbmRsZRgDIAEoBRITCgtpdGVtX2hhbmRsZRgEIAEoBRIrCgV2", - "YWx1ZRgFIAEoCzIcLm14YWNjZXNzX2dhdGV3YXkudjEuTXhWYWx1ZRIPCgdx", - "dWFsaXR5GAYgASgFEjQKEHNvdXJjZV90aW1lc3RhbXAYByABKAsyGi5nb29n", - "bGUucHJvdG9idWYuVGltZXN0YW1wEjQKCHN0YXR1c2VzGAggAygLMiIubXhh", - "Y2Nlc3NfZ2F0ZXdheS52MS5NeFN0YXR1c1Byb3h5EhcKD3dvcmtlcl9zZXF1", - "ZW5jZRgJIAEoBBI0ChB3b3JrZXJfdGltZXN0YW1wGAogASgLMhouZ29vZ2xl", - "LnByb3RvYnVmLlRpbWVzdGFtcBI9ChlnYXRld2F5X3JlY2VpdmVfdGltZXN0", - "YW1wGAsgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIUCgdocmVz", - "dWx0GAwgASgFSAGIAQESEgoKcmF3X3N0YXR1cxgNIAEoCRI3CgpyZXBsYXlf", - "Z2FwGA4gASgLMh4ubXhhY2Nlc3NfZ2F0ZXdheS52MS5SZXBsYXlHYXBIAogB", - "ARJACg5vbl9kYXRhX2NoYW5nZRgUIAEoCzImLm14YWNjZXNzX2dhdGV3YXku", - "djEuT25EYXRhQ2hhbmdlRXZlbnRIABJGChFvbl93cml0ZV9jb21wbGV0ZRgV", - "IAEoCzIpLm14YWNjZXNzX2dhdGV3YXkudjEuT25Xcml0ZUNvbXBsZXRlRXZl", - "bnRIABJJChJvcGVyYXRpb25fY29tcGxldGUYFiABKAsyKy5teGFjY2Vzc19n", - "YXRld2F5LnYxLk9wZXJhdGlvbkNvbXBsZXRlRXZlbnRIABJRChdvbl9idWZm", - "ZXJlZF9kYXRhX2NoYW5nZRgXIAEoCzIuLm14YWNjZXNzX2dhdGV3YXkudjEu", - "T25CdWZmZXJlZERhdGFDaGFuZ2VFdmVudEgAEkoKE29uX2FsYXJtX3RyYW5z", - "aXRpb24YGCABKAsyKy5teGFjY2Vzc19nYXRld2F5LnYxLk9uQWxhcm1UcmFu", - "c2l0aW9uRXZlbnRIABJeCh5vbl9hbGFybV9wcm92aWRlcl9tb2RlX2NoYW5n", - "ZWQYGSABKAsyNC5teGFjY2Vzc19nYXRld2F5LnYxLk9uQWxhcm1Qcm92aWRl", - "ck1vZGVDaGFuZ2VkRXZlbnRIAEIGCgRib2R5QgoKCF9ocmVzdWx0Qg0KC19y", - "ZXBsYXlfZ2FwIlAKCVJlcGxheUdhcBIgChhyZXF1ZXN0ZWRfYWZ0ZXJfc2Vx", - "dWVuY2UYASABKAQSIQoZb2xkZXN0X2F2YWlsYWJsZV9zZXF1ZW5jZRgCIAEo", - "BCITChFPbkRhdGFDaGFuZ2VFdmVudCIWChRPbldyaXRlQ29tcGxldGVFdmVu", - "dCIYChZPcGVyYXRpb25Db21wbGV0ZUV2ZW50ItQBChlPbkJ1ZmZlcmVkRGF0", - "YUNoYW5nZUV2ZW50EjIKCWRhdGFfdHlwZRgBIAEoDjIfLm14YWNjZXNzX2dh", - "dGV3YXkudjEuTXhEYXRhVHlwZRI0Cg5xdWFsaXR5X3ZhbHVlcxgCIAEoCzIc", - "Lm14YWNjZXNzX2dhdGV3YXkudjEuTXhBcnJheRI2ChB0aW1lc3RhbXBfdmFs", - "dWVzGAMgASgLMhwubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeEFycmF5EhUKDXJh", - "d19kYXRhX3R5cGUYBCABKAUi0AQKFk9uQWxhcm1UcmFuc2l0aW9uRXZlbnQS", - "HAoUYWxhcm1fZnVsbF9yZWZlcmVuY2UYASABKAkSHwoXc291cmNlX29iamVj", - "dF9yZWZlcmVuY2UYAiABKAkSFwoPYWxhcm1fdHlwZV9uYW1lGAMgASgJEkEK", - "D3RyYW5zaXRpb25fa2luZBgEIAEoDjIoLm14YWNjZXNzX2dhdGV3YXkudjEu", - "QWxhcm1UcmFuc2l0aW9uS2luZBIQCghzZXZlcml0eRgFIAEoBRI8Chhvcmln", - "aW5hbF9yYWlzZV90aW1lc3RhbXAYBiABKAsyGi5nb29nbGUucHJvdG9idWYu", - "VGltZXN0YW1wEjgKFHRyYW5zaXRpb25fdGltZXN0YW1wGAcgASgLMhouZ29v", - "Z2xlLnByb3RvYnVmLlRpbWVzdGFtcBIVCg1vcGVyYXRvcl91c2VyGAggASgJ", - "EhgKEG9wZXJhdG9yX2NvbW1lbnQYCSABKAkSEAoIY2F0ZWdvcnkYCiABKAkS", - "EwoLZGVzY3JpcHRpb24YCyABKAkSMwoNY3VycmVudF92YWx1ZRgMIAEoCzIc", - "Lm14YWNjZXNzX2dhdGV3YXkudjEuTXhWYWx1ZRIxCgtsaW1pdF92YWx1ZRgN", - "IAEoCzIcLm14YWNjZXNzX2dhdGV3YXkudjEuTXhWYWx1ZRIQCghkZWdyYWRl", - "ZBgOIAEoCBI/Cg9zb3VyY2VfcHJvdmlkZXIYDyABKA4yJi5teGFjY2Vzc19n", - "YXRld2F5LnYxLkFsYXJtUHJvdmlkZXJNb2RlIqABCh9PbkFsYXJtUHJvdmlk", - "ZXJNb2RlQ2hhbmdlZEV2ZW50EjQKBG1vZGUYASABKA4yJi5teGFjY2Vzc19n", - "YXRld2F5LnYxLkFsYXJtUHJvdmlkZXJNb2RlEg4KBnJlYXNvbhgCIAEoCRIP", - "CgdocmVzdWx0GAMgASgFEiYKAmF0GAQgASgLMhouZ29vZ2xlLnByb3RvYnVm", - "LlRpbWVzdGFtcCLxBAoTQWN0aXZlQWxhcm1TbmFwc2hvdBIcChRhbGFybV9m", - "dWxsX3JlZmVyZW5jZRgBIAEoCRIfChdzb3VyY2Vfb2JqZWN0X3JlZmVyZW5j", - "ZRgCIAEoCRIXCg9hbGFybV90eXBlX25hbWUYAyABKAkSEAoIc2V2ZXJpdHkY", - "BCABKAUSPAoYb3JpZ2luYWxfcmFpc2VfdGltZXN0YW1wGAUgASgLMhouZ29v", - "Z2xlLnByb3RvYnVmLlRpbWVzdGFtcBI/Cg1jdXJyZW50X3N0YXRlGAYgASgO", - "MigubXhhY2Nlc3NfZ2F0ZXdheS52MS5BbGFybUNvbmRpdGlvblN0YXRlEhAK", - "CGNhdGVnb3J5GAcgASgJEhMKC2Rlc2NyaXB0aW9uGAggASgJEj0KGWxhc3Rf", - "dHJhbnNpdGlvbl90aW1lc3RhbXAYCSABKAsyGi5nb29nbGUucHJvdG9idWYu", - "VGltZXN0YW1wEhUKDW9wZXJhdG9yX3VzZXIYCiABKAkSGAoQb3BlcmF0b3Jf", - "Y29tbWVudBgLIAEoCRIzCg1jdXJyZW50X3ZhbHVlGAwgASgLMhwubXhhY2Nl", - "c3NfZ2F0ZXdheS52MS5NeFZhbHVlEjEKC2xpbWl0X3ZhbHVlGA0gASgLMhwu", - "bXhhY2Nlc3NfZ2F0ZXdheS52MS5NeFZhbHVlEhAKCGRlZ3JhZGVkGA4gASgI", - "Ej8KD3NvdXJjZV9wcm92aWRlchgPIAEoDjImLm14YWNjZXNzX2dhdGV3YXku", - "djEuQWxhcm1Qcm92aWRlck1vZGUSHwoXZnJvbV90cnVuY2F0ZWRfc25hcHNo", - "b3QYECABKAgikAEKF0Fja25vd2xlZGdlQWxhcm1SZXF1ZXN0Eh0KFWNsaWVu", - "dF9jb3JyZWxhdGlvbl9pZBgCIAEoCRIcChRhbGFybV9mdWxsX3JlZmVyZW5j", - "ZRgDIAEoCRIPCgdjb21tZW50GAQgASgJEhUKDW9wZXJhdG9yX3VzZXIYBSAB", - "KAlKBAgBEAJSCnNlc3Npb25faWQi8QEKFUFja25vd2xlZGdlQWxhcm1SZXBs", - "eRIWCg5jb3JyZWxhdGlvbl9pZBgCIAEoCRI8Cg9wcm90b2NvbF9zdGF0dXMY", - "AyABKAsyIy5teGFjY2Vzc19nYXRld2F5LnYxLlByb3RvY29sU3RhdHVzEhQK", - "B2hyZXN1bHQYBCABKAVIAIgBARIyCgZzdGF0dXMYBSABKAsyIi5teGFjY2Vz", - "c19nYXRld2F5LnYxLk14U3RhdHVzUHJveHkSGgoSZGlhZ25vc3RpY19tZXNz", - "YWdlGAYgASgJQgoKCF9ocmVzdWx0SgQIARACUgpzZXNzaW9uX2lkIlEKE1N0", - "cmVhbUFsYXJtc1JlcXVlc3QSHQoVY2xpZW50X2NvcnJlbGF0aW9uX2lkGAEg", - "ASgJEhsKE2FsYXJtX2ZpbHRlcl9wcmVmaXgYAiABKAkiyQIKEEFsYXJtRmVl", - "ZE1lc3NhZ2USQAoMYWN0aXZlX2FsYXJtGAEgASgLMigubXhhY2Nlc3NfZ2F0", - "ZXdheS52MS5BY3RpdmVBbGFybVNuYXBzaG90SAASGwoRc25hcHNob3RfY29t", - "cGxldGUYAiABKAhIABJBCgp0cmFuc2l0aW9uGAMgASgLMisubXhhY2Nlc3Nf", - "Z2F0ZXdheS52MS5PbkFsYXJtVHJhbnNpdGlvbkV2ZW50SAASQwoPcHJvdmlk", - "ZXJfc3RhdHVzGAQgASgLMigubXhhY2Nlc3NfZ2F0ZXdheS52MS5BbGFybVBy", - "b3ZpZGVyU3RhdHVzSAASQwoPc25hcHNob3Rfc3RhdHVzGAUgASgLMigubXhh", - "Y2Nlc3NfZ2F0ZXdheS52MS5BbGFybVNuYXBzaG90U3RhdHVzSABCCQoHcGF5", - "bG9hZCKYAQoTQWxhcm1Qcm92aWRlclN0YXR1cxI0CgRtb2RlGAEgASgOMiYu", - "bXhhY2Nlc3NfZ2F0ZXdheS52MS5BbGFybVByb3ZpZGVyTW9kZRIQCghkZWdy", - "YWRlZBgCIAEoCBIOCgZyZWFzb24YAyABKAkSKQoFc2luY2UYBCABKAsyGi5n", - "b29nbGUucHJvdG9idWYuVGltZXN0YW1wIigKE0FsYXJtU25hcHNob3RTdGF0", - "dXMSEQoJdHJ1bmNhdGVkGAEgASgIIusBCg1NeFN0YXR1c1Byb3h5Eg8KB3N1", - "Y2Nlc3MYASABKAUSNwoIY2F0ZWdvcnkYAiABKA4yJS5teGFjY2Vzc19nYXRl", - "d2F5LnYxLk14U3RhdHVzQ2F0ZWdvcnkSOAoLZGV0ZWN0ZWRfYnkYAyABKA4y", - "Iy5teGFjY2Vzc19nYXRld2F5LnYxLk14U3RhdHVzU291cmNlEg4KBmRldGFp", - "bBgEIAEoBRIUCgxyYXdfY2F0ZWdvcnkYBSABKAUSFwoPcmF3X2RldGVjdGVk", - "X2J5GAYgASgFEhcKD2RpYWdub3N0aWNfdGV4dBgHIAEoCSLpAwoHTXhWYWx1", - "ZRIyCglkYXRhX3R5cGUYASABKA4yHy5teGFjY2Vzc19nYXRld2F5LnYxLk14", - "RGF0YVR5cGUSFAoMdmFyaWFudF90eXBlGAIgASgJEg8KB2lzX251bGwYAyAB", - "KAgSFgoOcmF3X2RpYWdub3N0aWMYBCABKAkSFQoNcmF3X2RhdGFfdHlwZRgF", - "IAEoBRIUCgpib29sX3ZhbHVlGAogASgISAASFQoLaW50MzJfdmFsdWUYCyAB", - "KAVIABIVCgtpbnQ2NF92YWx1ZRgMIAEoA0gAEhUKC2Zsb2F0X3ZhbHVlGA0g", - "ASgCSAASFgoMZG91YmxlX3ZhbHVlGA4gASgBSAASFgoMc3RyaW5nX3ZhbHVl", - "GA8gASgJSAASNQoPdGltZXN0YW1wX3ZhbHVlGBAgASgLMhouZ29vZ2xlLnBy", - "b3RvYnVmLlRpbWVzdGFtcEgAEjMKC2FycmF5X3ZhbHVlGBEgASgLMhwubXhh", - "Y2Nlc3NfZ2F0ZXdheS52MS5NeEFycmF5SAASEwoJcmF3X3ZhbHVlGBIgASgM", - "SAASQAoSc3BhcnNlX2FycmF5X3ZhbHVlGBMgASgLMiIubXhhY2Nlc3NfZ2F0", - "ZXdheS52MS5NeFNwYXJzZUFycmF5SABCBgoEa2luZCL+BAoHTXhBcnJheRI6", - "ChFlbGVtZW50X2RhdGFfdHlwZRgBIAEoDjIfLm14YWNjZXNzX2dhdGV3YXku", - "djEuTXhEYXRhVHlwZRIUCgx2YXJpYW50X3R5cGUYAiABKAkSEgoKZGltZW5z", - "aW9ucxgDIAMoDRIWCg5yYXdfZGlhZ25vc3RpYxgEIAEoCRIdChVyYXdfZWxl", - "bWVudF9kYXRhX3R5cGUYBSABKAUSNQoLYm9vbF92YWx1ZXMYCiABKAsyHi5t", - "eGFjY2Vzc19nYXRld2F5LnYxLkJvb2xBcnJheUgAEjcKDGludDMyX3ZhbHVl", - "cxgLIAEoCzIfLm14YWNjZXNzX2dhdGV3YXkudjEuSW50MzJBcnJheUgAEjcK", - "DGludDY0X3ZhbHVlcxgMIAEoCzIfLm14YWNjZXNzX2dhdGV3YXkudjEuSW50", - "NjRBcnJheUgAEjcKDGZsb2F0X3ZhbHVlcxgNIAEoCzIfLm14YWNjZXNzX2dh", - "dGV3YXkudjEuRmxvYXRBcnJheUgAEjkKDWRvdWJsZV92YWx1ZXMYDiABKAsy", - "IC5teGFjY2Vzc19nYXRld2F5LnYxLkRvdWJsZUFycmF5SAASOQoNc3RyaW5n", - "X3ZhbHVlcxgPIAEoCzIgLm14YWNjZXNzX2dhdGV3YXkudjEuU3RyaW5nQXJy", - "YXlIABI/ChB0aW1lc3RhbXBfdmFsdWVzGBAgASgLMiMubXhhY2Nlc3NfZ2F0", - "ZXdheS52MS5UaW1lc3RhbXBBcnJheUgAEjMKCnJhd192YWx1ZXMYESABKAsy", - "HS5teGFjY2Vzc19nYXRld2F5LnYxLlJhd0FycmF5SABCCAoGdmFsdWVzIpkB", - "Cg1NeFNwYXJzZUFycmF5EjoKEWVsZW1lbnRfZGF0YV90eXBlGAEgASgOMh8u", - "bXhhY2Nlc3NfZ2F0ZXdheS52MS5NeERhdGFUeXBlEhQKDHRvdGFsX2xlbmd0", - "aBgCIAEoDRI2CghlbGVtZW50cxgDIAMoCzIkLm14YWNjZXNzX2dhdGV3YXku", - "djEuTXhTcGFyc2VFbGVtZW50Ik0KD014U3BhcnNlRWxlbWVudBINCgVpbmRl", - "eBgBIAEoDRIrCgV2YWx1ZRgCIAEoCzIcLm14YWNjZXNzX2dhdGV3YXkudjEu", - "TXhWYWx1ZSIbCglCb29sQXJyYXkSDgoGdmFsdWVzGAEgAygIIhwKCkludDMy", - "QXJyYXkSDgoGdmFsdWVzGAEgAygFIhwKCkludDY0QXJyYXkSDgoGdmFsdWVz", - "GAEgAygDIhwKCkZsb2F0QXJyYXkSDgoGdmFsdWVzGAEgAygCIh0KC0RvdWJs", - "ZUFycmF5Eg4KBnZhbHVlcxgBIAMoASIdCgtTdHJpbmdBcnJheRIOCgZ2YWx1", - "ZXMYASADKAkiPAoOVGltZXN0YW1wQXJyYXkSKgoGdmFsdWVzGAEgAygLMhou", - "Z29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCIaCghSYXdBcnJheRIOCgZ2YWx1", - "ZXMYASADKAwiWAoOUHJvdG9jb2xTdGF0dXMSNQoEY29kZRgBIAEoDjInLm14", - "YWNjZXNzX2dhdGV3YXkudjEuUHJvdG9jb2xTdGF0dXNDb2RlEg8KB21lc3Nh", - "Z2UYAiABKAkqzAsKDU14Q29tbWFuZEtpbmQSHwobTVhfQ09NTUFORF9LSU5E", - "X1VOU1BFQ0lGSUVEEAASHAoYTVhfQ09NTUFORF9LSU5EX1JFR0lTVEVSEAES", - "HgoaTVhfQ09NTUFORF9LSU5EX1VOUkVHSVNURVIQAhIcChhNWF9DT01NQU5E", - "X0tJTkRfQUREX0lURU0QAxIdChlNWF9DT01NQU5EX0tJTkRfQUREX0lURU0y", - "EAQSHwobTVhfQ09NTUFORF9LSU5EX1JFTU9WRV9JVEVNEAUSGgoWTVhfQ09N", - "TUFORF9LSU5EX0FEVklTRRAGEh0KGU1YX0NPTU1BTkRfS0lORF9VTl9BRFZJ", - "U0UQBxImCiJNWF9DT01NQU5EX0tJTkRfQURWSVNFX1NVUEVSVklTT1JZEAgS", - "JQohTVhfQ09NTUFORF9LSU5EX0FERF9CVUZGRVJFRF9JVEVNEAkSMAosTVhf", - "Q09NTUFORF9LSU5EX1NFVF9CVUZGRVJFRF9VUERBVEVfSU5URVJWQUwQChIb", - "ChdNWF9DT01NQU5EX0tJTkRfU1VTUEVORBALEhwKGE1YX0NPTU1BTkRfS0lO", - "RF9BQ1RJVkFURRAMEhkKFU1YX0NPTU1BTkRfS0lORF9XUklURRANEhoKFk1Y", - "X0NPTU1BTkRfS0lORF9XUklURTIQDhIhCh1NWF9DT01NQU5EX0tJTkRfV1JJ", - "VEVfU0VDVVJFRBAPEiIKHk1YX0NPTU1BTkRfS0lORF9XUklURV9TRUNVUkVE", - "MhAQEiUKIU1YX0NPTU1BTkRfS0lORF9BVVRIRU5USUNBVEVfVVNFUhAREigK", - "JE1YX0NPTU1BTkRfS0lORF9BUkNIRVNUUkFfVVNFUl9UT19JRBASEiEKHU1Y", - "X0NPTU1BTkRfS0lORF9BRERfSVRFTV9CVUxLEBMSJAogTVhfQ09NTUFORF9L", - "SU5EX0FEVklTRV9JVEVNX0JVTEsQFBIkCiBNWF9DT01NQU5EX0tJTkRfUkVN", - "T1ZFX0lURU1fQlVMSxAVEicKI01YX0NPTU1BTkRfS0lORF9VTl9BRFZJU0Vf", - "SVRFTV9CVUxLEBYSIgoeTVhfQ09NTUFORF9LSU5EX1NVQlNDUklCRV9CVUxL", - "EBcSJAogTVhfQ09NTUFORF9LSU5EX1VOU1VCU0NSSUJFX0JVTEsQGBIkCiBN", - "WF9DT01NQU5EX0tJTkRfU1VCU0NSSUJFX0FMQVJNUxAZEiYKIk1YX0NPTU1B", - "TkRfS0lORF9VTlNVQlNDUklCRV9BTEFSTVMQGhIlCiFNWF9DT01NQU5EX0tJ", - "TkRfQUNLTk9XTEVER0VfQUxBUk0QGxInCiNNWF9DT01NQU5EX0tJTkRfUVVF", - "UllfQUNUSVZFX0FMQVJNUxAcEi0KKU1YX0NPTU1BTkRfS0lORF9BQ0tOT1dM", - "RURHRV9BTEFSTV9CWV9OQU1FEB0SHgoaTVhfQ09NTUFORF9LSU5EX1dSSVRF", - "X0JVTEsQHhIfChtNWF9DT01NQU5EX0tJTkRfV1JJVEUyX0JVTEsQHxImCiJN", - "WF9DT01NQU5EX0tJTkRfV1JJVEVfU0VDVVJFRF9CVUxLECASJwojTVhfQ09N", - "TUFORF9LSU5EX1dSSVRFX1NFQ1VSRUQyX0JVTEsQIRIdChlNWF9DT01NQU5E", - "X0tJTkRfUkVBRF9CVUxLECISKwonTVhfQ09NTUFORF9LSU5EX1VQREFURV9B", - "TEFSTV9XQVRDSF9MSVNUECMSGAoUTVhfQ09NTUFORF9LSU5EX1BJTkcQZBIl", - "CiFNWF9DT01NQU5EX0tJTkRfR0VUX1NFU1NJT05fU1RBVEUQZRIjCh9NWF9D", - "T01NQU5EX0tJTkRfR0VUX1dPUktFUl9JTkZPEGYSIAocTVhfQ09NTUFORF9L", - "SU5EX0RSQUlOX0VWRU5UUxBnEiMKH01YX0NPTU1BTkRfS0lORF9TSFVURE9X", - "Tl9XT1JLRVIQaCp6ChFBbGFybVByb3ZpZGVyTW9kZRIjCh9BTEFSTV9QUk9W", - "SURFUl9NT0RFX1VOU1BFQ0lGSUVEEAASIAocQUxBUk1fUFJPVklERVJfTU9E", - "RV9BTEFSTU1HUhABEh4KGkFMQVJNX1BST1ZJREVSX01PREVfU1VCVEFHEAIq", - "rQIKDU14RXZlbnRGYW1pbHkSHwobTVhfRVZFTlRfRkFNSUxZX1VOU1BFQ0lG", - "SUVEEAASIgoeTVhfRVZFTlRfRkFNSUxZX09OX0RBVEFfQ0hBTkdFEAESJQoh", - "TVhfRVZFTlRfRkFNSUxZX09OX1dSSVRFX0NPTVBMRVRFEAISJgoiTVhfRVZF", - "TlRfRkFNSUxZX09QRVJBVElPTl9DT01QTEVURRADEisKJ01YX0VWRU5UX0ZB", - "TUlMWV9PTl9CVUZGRVJFRF9EQVRBX0NIQU5HRRAEEicKI01YX0VWRU5UX0ZB", - "TUlMWV9PTl9BTEFSTV9UUkFOU0lUSU9OEAUSMgouTVhfRVZFTlRfRkFNSUxZ", - "X09OX0FMQVJNX1BST1ZJREVSX01PREVfQ0hBTkdFRBAGKsoBChNBbGFybVRy", - "YW5zaXRpb25LaW5kEiUKIUFMQVJNX1RSQU5TSVRJT05fS0lORF9VTlNQRUNJ", - "RklFRBAAEh8KG0FMQVJNX1RSQU5TSVRJT05fS0lORF9SQUlTRRABEiUKIUFM", - "QVJNX1RSQU5TSVRJT05fS0lORF9BQ0tOT1dMRURHRRACEh8KG0FMQVJNX1RS", - "QU5TSVRJT05fS0lORF9DTEVBUhADEiMKH0FMQVJNX1RSQU5TSVRJT05fS0lO", - "RF9SRVRSSUdHRVIQBCqqAQoTQWxhcm1Db25kaXRpb25TdGF0ZRIlCiFBTEFS", - "TV9DT05ESVRJT05fU1RBVEVfVU5TUEVDSUZJRUQQABIgChxBTEFSTV9DT05E", - "SVRJT05fU1RBVEVfQUNUSVZFEAESJgoiQUxBUk1fQ09ORElUSU9OX1NUQVRF", - "X0FDVElWRV9BQ0tFRBACEiIKHkFMQVJNX0NPTkRJVElPTl9TVEFURV9JTkFD", - "VElWRRADKqUDChBNeFN0YXR1c0NhdGVnb3J5EiIKHk1YX1NUQVRVU19DQVRF", - "R09SWV9VTlNQRUNJRklFRBAAEh4KGk1YX1NUQVRVU19DQVRFR09SWV9VTktO", - "T1dOEAESGQoVTVhfU1RBVFVTX0NBVEVHT1JZX09LEAISHgoaTVhfU1RBVFVT", - "X0NBVEVHT1JZX1BFTkRJTkcQAxIeChpNWF9TVEFUVVNfQ0FURUdPUllfV0FS", - "TklORxAEEioKJk1YX1NUQVRVU19DQVRFR09SWV9DT01NVU5JQ0FUSU9OX0VS", - "Uk9SEAUSKgomTVhfU1RBVFVTX0NBVEVHT1JZX0NPTkZJR1VSQVRJT05fRVJS", - "T1IQBhIoCiRNWF9TVEFUVVNfQ0FURUdPUllfT1BFUkFUSU9OQUxfRVJST1IQ", - "BxIlCiFNWF9TVEFUVVNfQ0FURUdPUllfU0VDVVJJVFlfRVJST1IQCBIlCiFN", - "WF9TVEFUVVNfQ0FURUdPUllfU09GVFdBUkVfRVJST1IQCRIiCh5NWF9TVEFU", - "VVNfQ0FURUdPUllfT1RIRVJfRVJST1IQCirKAgoOTXhTdGF0dXNTb3VyY2US", - "IAocTVhfU1RBVFVTX1NPVVJDRV9VTlNQRUNJRklFRBAAEhwKGE1YX1NUQVRV", - "U19TT1VSQ0VfVU5LTk9XThABEiMKH01YX1NUQVRVU19TT1VSQ0VfUkVRVUVT", - "VElOR19MTVgQAhIjCh9NWF9TVEFUVVNfU09VUkNFX1JFU1BPTkRJTkdfTE1Y", - "EAMSIwofTVhfU1RBVFVTX1NPVVJDRV9SRVFVRVNUSU5HX05NWBAEEiMKH01Y", - "X1NUQVRVU19TT1VSQ0VfUkVTUE9ORElOR19OTVgQBRIxCi1NWF9TVEFUVVNf", - "U09VUkNFX1JFUVVFU1RJTkdfQVVUT01BVElPTl9PQkpFQ1QQBhIxCi1NWF9T", - "VEFUVVNfU09VUkNFX1JFU1BPTkRJTkdfQVVUT01BVElPTl9PQkpFQ1QQByrd", - "BAoKTXhEYXRhVHlwZRIcChhNWF9EQVRBX1RZUEVfVU5TUEVDSUZJRUQQABIY", - "ChRNWF9EQVRBX1RZUEVfVU5LTk9XThABEhgKFE1YX0RBVEFfVFlQRV9OT19E", - "QVRBEAISGAoUTVhfREFUQV9UWVBFX0JPT0xFQU4QAxIYChRNWF9EQVRBX1RZ", - "UEVfSU5URUdFUhAEEhYKEk1YX0RBVEFfVFlQRV9GTE9BVBAFEhcKE01YX0RB", - "VEFfVFlQRV9ET1VCTEUQBhIXChNNWF9EQVRBX1RZUEVfU1RSSU5HEAcSFQoR", - "TVhfREFUQV9UWVBFX1RJTUUQCBIdChlNWF9EQVRBX1RZUEVfRUxBUFNFRF9U", - "SU1FEAkSHwobTVhfREFUQV9UWVBFX1JFRkVSRU5DRV9UWVBFEAoSHAoYTVhf", - "REFUQV9UWVBFX1NUQVRVU19UWVBFEAsSFQoRTVhfREFUQV9UWVBFX0VOVU0Q", - "DBItCilNWF9EQVRBX1RZUEVfU0VDVVJJVFlfQ0xBU1NJRklDQVRJT05fRU5V", - "TRANEiIKHk1YX0RBVEFfVFlQRV9EQVRBX1FVQUxJVFlfVFlQRRAOEh8KG01Y", - "X0RBVEFfVFlQRV9RVUFMSUZJRURfRU5VTRAPEiEKHU1YX0RBVEFfVFlQRV9R", - "VUFMSUZJRURfU1RSVUNUEBASKQolTVhfREFUQV9UWVBFX0lOVEVSTkFUSU9O", - "QUxJWkVEX1NUUklORxAREhsKF01YX0RBVEFfVFlQRV9CSUdfU1RSSU5HEBIS", - "FAoQTVhfREFUQV9UWVBFX0VORBATKqMDChJQcm90b2NvbFN0YXR1c0NvZGUS", - "JAogUFJPVE9DT0xfU1RBVFVTX0NPREVfVU5TUEVDSUZJRUQQABIbChdQUk9U", - "T0NPTF9TVEFUVVNfQ09ERV9PSxABEigKJFBST1RPQ09MX1NUQVRVU19DT0RF", - "X0lOVkFMSURfUkVRVUVTVBACEioKJlBST1RPQ09MX1NUQVRVU19DT0RFX1NF", - "U1NJT05fTk9UX0ZPVU5EEAMSKgomUFJPVE9DT0xfU1RBVFVTX0NPREVfU0VT", - "U0lPTl9OT1RfUkVBRFkQBBIrCidQUk9UT0NPTF9TVEFUVVNfQ09ERV9XT1JL", - "RVJfVU5BVkFJTEFCTEUQBRIgChxQUk9UT0NPTF9TVEFUVVNfQ09ERV9USU1F", - "T1VUEAYSIQodUFJPVE9DT0xfU1RBVFVTX0NPREVfQ0FOQ0VMRUQQBxIrCidQ", - "Uk9UT0NPTF9TVEFUVVNfQ09ERV9QUk9UT0NPTF9WSU9MQVRJT04QCBIpCiVQ", - "Uk9UT0NPTF9TVEFUVVNfQ09ERV9NWEFDQ0VTU19GQUlMVVJFEAkqvwIKDFNl", - "c3Npb25TdGF0ZRIdChlTRVNTSU9OX1NUQVRFX1VOU1BFQ0lGSUVEEAASGgoW", - "U0VTU0lPTl9TVEFURV9DUkVBVElORxABEiEKHVNFU1NJT05fU1RBVEVfU1RB", - "UlRJTkdfV09SS0VSEAISIgoeU0VTU0lPTl9TVEFURV9XQUlUSU5HX0ZPUl9Q", - "SVBFEAMSHQoZU0VTU0lPTl9TVEFURV9IQU5EU0hBS0lORxAEEiUKIVNFU1NJ", - "T05fU1RBVEVfSU5JVElBTElaSU5HX1dPUktFUhAFEhcKE1NFU1NJT05fU1RB", - "VEVfUkVBRFkQBhIZChVTRVNTSU9OX1NUQVRFX0NMT1NJTkcQBxIYChRTRVNT", - "SU9OX1NUQVRFX0NMT1NFRBAIEhkKFVNFU1NJT05fU1RBVEVfRkFVTFRFRBAJ", - "MsMFCg9NeEFjY2Vzc0dhdGV3YXkSXQoLT3BlblNlc3Npb24SJy5teGFjY2Vz", - "c19nYXRld2F5LnYxLk9wZW5TZXNzaW9uUmVxdWVzdBolLm14YWNjZXNzX2dh", - "dGV3YXkudjEuT3BlblNlc3Npb25SZXBseRJgCgxDbG9zZVNlc3Npb24SKC5t", - "eGFjY2Vzc19nYXRld2F5LnYxLkNsb3NlU2Vzc2lvblJlcXVlc3QaJi5teGFj", - "Y2Vzc19nYXRld2F5LnYxLkNsb3NlU2Vzc2lvblJlcGx5ElQKBkludm9rZRIl", - "Lm14YWNjZXNzX2dhdGV3YXkudjEuTXhDb21tYW5kUmVxdWVzdBojLm14YWNj", - "ZXNzX2dhdGV3YXkudjEuTXhDb21tYW5kUmVwbHkSWAoMU3RyZWFtRXZlbnRz", - "EigubXhhY2Nlc3NfZ2F0ZXdheS52MS5TdHJlYW1FdmVudHNSZXF1ZXN0Ghwu", - "bXhhY2Nlc3NfZ2F0ZXdheS52MS5NeEV2ZW50MAESbAoQQWNrbm93bGVkZ2VB", - "bGFybRIsLm14YWNjZXNzX2dhdGV3YXkudjEuQWNrbm93bGVkZ2VBbGFybVJl", - "cXVlc3QaKi5teGFjY2Vzc19nYXRld2F5LnYxLkFja25vd2xlZGdlQWxhcm1S", - "ZXBseRJhCgxTdHJlYW1BbGFybXMSKC5teGFjY2Vzc19nYXRld2F5LnYxLlN0", - "cmVhbUFsYXJtc1JlcXVlc3QaJS5teGFjY2Vzc19nYXRld2F5LnYxLkFsYXJt", - "RmVlZE1lc3NhZ2UwARJuChFRdWVyeUFjdGl2ZUFsYXJtcxItLm14YWNjZXNz", - "X2dhdGV3YXkudjEuUXVlcnlBY3RpdmVBbGFybXNSZXF1ZXN0GigubXhhY2Nl", - "c3NfZ2F0ZXdheS52MS5BY3RpdmVBbGFybVNuYXBzaG90MAFCJqoCI1pCLk1P", - "TS5XVy5NeEdhdGV3YXkuQ29udHJhY3RzLlByb3RvYgZwcm90bzM=")); + "BiABKAkSGQoRb3BlcmF0b3JfcGFzc3dvcmQYByABKAkiNwoYUXVlcnlBY3Rp", + "dmVBbGFybXNDb21tYW5kEhsKE2FsYXJtX2ZpbHRlcl9wcmVmaXgYASABKAki", + "7QEKHUFja25vd2xlZGdlQWxhcm1CeU5hbWVDb21tYW5kEhIKCmFsYXJtX25h", + "bWUYASABKAkSFQoNcHJvdmlkZXJfbmFtZRgCIAEoCRISCgpncm91cF9uYW1l", + "GAMgASgJEg8KB2NvbW1lbnQYBCABKAkSFQoNb3BlcmF0b3JfdXNlchgFIAEo", + "CRIVCg1vcGVyYXRvcl9ub2RlGAYgASgJEhcKD29wZXJhdG9yX2RvbWFpbhgH", + "IAEoCRIaChJvcGVyYXRvcl9mdWxsX25hbWUYCCABKAkSGQoRb3BlcmF0b3Jf", + "cGFzc3dvcmQYCSABKAkiRQoWVW5zdWJzY3JpYmVCdWxrQ29tbWFuZBIVCg1z", + "ZXJ2ZXJfaGFuZGxlGAEgASgFEhQKDGl0ZW1faGFuZGxlcxgCIAMoBSJfChBX", + "cml0ZUJ1bGtDb21tYW5kEhUKDXNlcnZlcl9oYW5kbGUYASABKAUSNAoHZW50", + "cmllcxgCIAMoCzIjLm14YWNjZXNzX2dhdGV3YXkudjEuV3JpdGVCdWxrRW50", + "cnkiYwoOV3JpdGVCdWxrRW50cnkSEwoLaXRlbV9oYW5kbGUYASABKAUSKwoF", + "dmFsdWUYAiABKAsyHC5teGFjY2Vzc19nYXRld2F5LnYxLk14VmFsdWUSDwoH", + "dXNlcl9pZBgDIAEoBSJhChFXcml0ZTJCdWxrQ29tbWFuZBIVCg1zZXJ2ZXJf", + "aGFuZGxlGAEgASgFEjUKB2VudHJpZXMYAiADKAsyJC5teGFjY2Vzc19nYXRl", + "d2F5LnYxLldyaXRlMkJ1bGtFbnRyeSKbAQoPV3JpdGUyQnVsa0VudHJ5EhMK", + "C2l0ZW1faGFuZGxlGAEgASgFEisKBXZhbHVlGAIgASgLMhwubXhhY2Nlc3Nf", + "Z2F0ZXdheS52MS5NeFZhbHVlEjUKD3RpbWVzdGFtcF92YWx1ZRgDIAEoCzIc", + "Lm14YWNjZXNzX2dhdGV3YXkudjEuTXhWYWx1ZRIPCgd1c2VyX2lkGAQgASgF", + "Im0KF1dyaXRlU2VjdXJlZEJ1bGtDb21tYW5kEhUKDXNlcnZlcl9oYW5kbGUY", + "ASABKAUSOwoHZW50cmllcxgCIAMoCzIqLm14YWNjZXNzX2dhdGV3YXkudjEu", + "V3JpdGVTZWN1cmVkQnVsa0VudHJ5IowBChVXcml0ZVNlY3VyZWRCdWxrRW50", + "cnkSEwoLaXRlbV9oYW5kbGUYASABKAUSFwoPY3VycmVudF91c2VyX2lkGAIg", + "ASgFEhgKEHZlcmlmaWVyX3VzZXJfaWQYAyABKAUSKwoFdmFsdWUYBCABKAsy", + "HC5teGFjY2Vzc19nYXRld2F5LnYxLk14VmFsdWUibwoYV3JpdGVTZWN1cmVk", + "MkJ1bGtDb21tYW5kEhUKDXNlcnZlcl9oYW5kbGUYASABKAUSPAoHZW50cmll", + "cxgCIAMoCzIrLm14YWNjZXNzX2dhdGV3YXkudjEuV3JpdGVTZWN1cmVkMkJ1", + "bGtFbnRyeSLEAQoWV3JpdGVTZWN1cmVkMkJ1bGtFbnRyeRITCgtpdGVtX2hh", + "bmRsZRgBIAEoBRIXCg9jdXJyZW50X3VzZXJfaWQYAiABKAUSGAoQdmVyaWZp", + "ZXJfdXNlcl9pZBgDIAEoBRIrCgV2YWx1ZRgEIAEoCzIcLm14YWNjZXNzX2dh", + "dGV3YXkudjEuTXhWYWx1ZRI1Cg90aW1lc3RhbXBfdmFsdWUYBSABKAsyHC5t", + "eGFjY2Vzc19nYXRld2F5LnYxLk14VmFsdWUiUwoPUmVhZEJ1bGtDb21tYW5k", + "EhUKDXNlcnZlcl9oYW5kbGUYASABKAUSFQoNdGFnX2FkZHJlc3NlcxgCIAMo", + "CRISCgp0aW1lb3V0X21zGAMgASgNIh4KC1BpbmdDb21tYW5kEg8KB21lc3Nh", + "Z2UYASABKAkiGAoWR2V0U2Vzc2lvblN0YXRlQ29tbWFuZCIWChRHZXRXb3Jr", + "ZXJJbmZvQ29tbWFuZCIoChJEcmFpbkV2ZW50c0NvbW1hbmQSEgoKbWF4X2V2", + "ZW50cxgBIAEoDSJIChVTaHV0ZG93bldvcmtlckNvbW1hbmQSLwoMZ3JhY2Vf", + "cGVyaW9kGAEgASgLMhkuZ29vZ2xlLnByb3RvYnVmLkR1cmF0aW9uIoYPCg5N", + "eENvbW1hbmRSZXBseRISCgpzZXNzaW9uX2lkGAEgASgJEhYKDmNvcnJlbGF0", + "aW9uX2lkGAIgASgJEjAKBGtpbmQYAyABKA4yIi5teGFjY2Vzc19nYXRld2F5", + "LnYxLk14Q29tbWFuZEtpbmQSPAoPcHJvdG9jb2xfc3RhdHVzGAQgASgLMiMu", + "bXhhY2Nlc3NfZ2F0ZXdheS52MS5Qcm90b2NvbFN0YXR1cxIUCgdocmVzdWx0", + "GAUgASgFSAGIAQESMgoMcmV0dXJuX3ZhbHVlGAYgASgLMhwubXhhY2Nlc3Nf", + "Z2F0ZXdheS52MS5NeFZhbHVlEjQKCHN0YXR1c2VzGAcgAygLMiIubXhhY2Nl", + "c3NfZ2F0ZXdheS52MS5NeFN0YXR1c1Byb3h5EhoKEmRpYWdub3N0aWNfbWVz", + "c2FnZRgIIAEoCRI2CghyZWdpc3RlchgUIAEoCzIiLm14YWNjZXNzX2dhdGV3", + "YXkudjEuUmVnaXN0ZXJSZXBseUgAEjUKCGFkZF9pdGVtGBUgASgLMiEubXhh", + "Y2Nlc3NfZ2F0ZXdheS52MS5BZGRJdGVtUmVwbHlIABI3CglhZGRfaXRlbTIY", + "FiABKAsyIi5teGFjY2Vzc19nYXRld2F5LnYxLkFkZEl0ZW0yUmVwbHlIABJG", + "ChFhZGRfYnVmZmVyZWRfaXRlbRgXIAEoCzIpLm14YWNjZXNzX2dhdGV3YXku", + "djEuQWRkQnVmZmVyZWRJdGVtUmVwbHlIABI0CgdzdXNwZW5kGBggASgLMiEu", + "bXhhY2Nlc3NfZ2F0ZXdheS52MS5TdXNwZW5kUmVwbHlIABI2CghhY3RpdmF0", + "ZRgZIAEoCzIiLm14YWNjZXNzX2dhdGV3YXkudjEuQWN0aXZhdGVSZXBseUgA", + "EkcKEWF1dGhlbnRpY2F0ZV91c2VyGBogASgLMioubXhhY2Nlc3NfZ2F0ZXdh", + "eS52MS5BdXRoZW50aWNhdGVVc2VyUmVwbHlIABJLChRhcmNoZXN0cmFfdXNl", + "cl90b19pZBgbIAEoCzIrLm14YWNjZXNzX2dhdGV3YXkudjEuQXJjaGVzdHJB", + "VXNlclRvSWRSZXBseUgAEkAKDWFkZF9pdGVtX2J1bGsYHCABKAsyJy5teGFj", + "Y2Vzc19nYXRld2F5LnYxLkJ1bGtTdWJzY3JpYmVSZXBseUgAEkMKEGFkdmlz", + "ZV9pdGVtX2J1bGsYHSABKAsyJy5teGFjY2Vzc19nYXRld2F5LnYxLkJ1bGtT", + "dWJzY3JpYmVSZXBseUgAEkMKEHJlbW92ZV9pdGVtX2J1bGsYHiABKAsyJy5t", + "eGFjY2Vzc19nYXRld2F5LnYxLkJ1bGtTdWJzY3JpYmVSZXBseUgAEkYKE3Vu", + "X2FkdmlzZV9pdGVtX2J1bGsYHyABKAsyJy5teGFjY2Vzc19nYXRld2F5LnYx", + "LkJ1bGtTdWJzY3JpYmVSZXBseUgAEkEKDnN1YnNjcmliZV9idWxrGCAgASgL", + "MicubXhhY2Nlc3NfZ2F0ZXdheS52MS5CdWxrU3Vic2NyaWJlUmVwbHlIABJD", + "ChB1bnN1YnNjcmliZV9idWxrGCEgASgLMicubXhhY2Nlc3NfZ2F0ZXdheS52", + "MS5CdWxrU3Vic2NyaWJlUmVwbHlIABJOChFhY2tub3dsZWRnZV9hbGFybRgi", + "IAEoCzIxLm14YWNjZXNzX2dhdGV3YXkudjEuQWNrbm93bGVkZ2VBbGFybVJl", + "cGx5UGF5bG9hZEgAElEKE3F1ZXJ5X2FjdGl2ZV9hbGFybXMYIyABKAsyMi5t", + "eGFjY2Vzc19nYXRld2F5LnYxLlF1ZXJ5QWN0aXZlQWxhcm1zUmVwbHlQYXls", + "b2FkSAASOQoKd3JpdGVfYnVsaxgkIAEoCzIjLm14YWNjZXNzX2dhdGV3YXku", + "djEuQnVsa1dyaXRlUmVwbHlIABI6Cgt3cml0ZTJfYnVsaxglIAEoCzIjLm14", + "YWNjZXNzX2dhdGV3YXkudjEuQnVsa1dyaXRlUmVwbHlIABJBChJ3cml0ZV9z", + "ZWN1cmVkX2J1bGsYJiABKAsyIy5teGFjY2Vzc19nYXRld2F5LnYxLkJ1bGtX", + "cml0ZVJlcGx5SAASQgoTd3JpdGVfc2VjdXJlZDJfYnVsaxgnIAEoCzIjLm14", + "YWNjZXNzX2dhdGV3YXkudjEuQnVsa1dyaXRlUmVwbHlIABI3CglyZWFkX2J1", + "bGsYKCABKAsyIi5teGFjY2Vzc19nYXRld2F5LnYxLkJ1bGtSZWFkUmVwbHlI", + "ABI/Cg1zZXNzaW9uX3N0YXRlGGQgASgLMiYubXhhY2Nlc3NfZ2F0ZXdheS52", + "MS5TZXNzaW9uU3RhdGVSZXBseUgAEjsKC3dvcmtlcl9pbmZvGGUgASgLMiQu", + "bXhhY2Nlc3NfZ2F0ZXdheS52MS5Xb3JrZXJJbmZvUmVwbHlIABI9CgxkcmFp", + "bl9ldmVudHMYZiABKAsyJS5teGFjY2Vzc19nYXRld2F5LnYxLkRyYWluRXZl", + "bnRzUmVwbHlIAEIJCgdwYXlsb2FkQgoKCF9ocmVzdWx0IiYKDVJlZ2lzdGVy", + "UmVwbHkSFQoNc2VydmVyX2hhbmRsZRgBIAEoBSIjCgxBZGRJdGVtUmVwbHkS", + "EwoLaXRlbV9oYW5kbGUYASABKAUiJAoNQWRkSXRlbTJSZXBseRITCgtpdGVt", + "X2hhbmRsZRgBIAEoBSIrChRBZGRCdWZmZXJlZEl0ZW1SZXBseRITCgtpdGVt", + "X2hhbmRsZRgBIAEoBSJCCgxTdXNwZW5kUmVwbHkSMgoGc3RhdHVzGAEgASgL", + "MiIubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeFN0YXR1c1Byb3h5IkMKDUFjdGl2", + "YXRlUmVwbHkSMgoGc3RhdHVzGAEgASgLMiIubXhhY2Nlc3NfZ2F0ZXdheS52", + "MS5NeFN0YXR1c1Byb3h5IigKFUF1dGhlbnRpY2F0ZVVzZXJSZXBseRIPCgd1", + "c2VyX2lkGAEgASgFIikKFkFyY2hlc3RyQVVzZXJUb0lkUmVwbHkSDwoHdXNl", + "cl9pZBgBIAEoBSKBAQoPU3Vic2NyaWJlUmVzdWx0EhUKDXNlcnZlcl9oYW5k", + "bGUYASABKAUSEwoLdGFnX2FkZHJlc3MYAiABKAkSEwoLaXRlbV9oYW5kbGUY", + "AyABKAUSFgoOd2FzX3N1Y2Nlc3NmdWwYBCABKAgSFQoNZXJyb3JfbWVzc2Fn", + "ZRgFIAEoCSJLChJCdWxrU3Vic2NyaWJlUmVwbHkSNQoHcmVzdWx0cxgBIAMo", + "CzIkLm14YWNjZXNzX2dhdGV3YXkudjEuU3Vic2NyaWJlUmVzdWx0IsQBCg9C", + "dWxrV3JpdGVSZXN1bHQSFQoNc2VydmVyX2hhbmRsZRgBIAEoBRITCgtpdGVt", + "X2hhbmRsZRgCIAEoBRIWCg53YXNfc3VjY2Vzc2Z1bBgDIAEoCBIUCgdocmVz", + "dWx0GAQgASgFSACIAQESNAoIc3RhdHVzZXMYBSADKAsyIi5teGFjY2Vzc19n", + "YXRld2F5LnYxLk14U3RhdHVzUHJveHkSFQoNZXJyb3JfbWVzc2FnZRgGIAEo", + "CUIKCghfaHJlc3VsdCJHCg5CdWxrV3JpdGVSZXBseRI1CgdyZXN1bHRzGAEg", + "AygLMiQubXhhY2Nlc3NfZ2F0ZXdheS52MS5CdWxrV3JpdGVSZXN1bHQivgIK", + "DkJ1bGtSZWFkUmVzdWx0EhUKDXNlcnZlcl9oYW5kbGUYASABKAUSEwoLdGFn", + "X2FkZHJlc3MYAiABKAkSEwoLaXRlbV9oYW5kbGUYAyABKAUSFgoOd2FzX3N1", + "Y2Nlc3NmdWwYBCABKAgSEgoKd2FzX2NhY2hlZBgFIAEoCBIrCgV2YWx1ZRgG", + "IAEoCzIcLm14YWNjZXNzX2dhdGV3YXkudjEuTXhWYWx1ZRIPCgdxdWFsaXR5", + "GAcgASgFEjQKEHNvdXJjZV90aW1lc3RhbXAYCCABKAsyGi5nb29nbGUucHJv", + "dG9idWYuVGltZXN0YW1wEjQKCHN0YXR1c2VzGAkgAygLMiIubXhhY2Nlc3Nf", + "Z2F0ZXdheS52MS5NeFN0YXR1c1Byb3h5EhUKDWVycm9yX21lc3NhZ2UYCiAB", + "KAkiRQoNQnVsa1JlYWRSZXBseRI0CgdyZXN1bHRzGAEgAygLMiMubXhhY2Nl", + "c3NfZ2F0ZXdheS52MS5CdWxrUmVhZFJlc3VsdCJFChFTZXNzaW9uU3RhdGVS", + "ZXBseRIwCgVzdGF0ZRgBIAEoDjIhLm14YWNjZXNzX2dhdGV3YXkudjEuU2Vz", + "c2lvblN0YXRlInUKD1dvcmtlckluZm9SZXBseRIZChF3b3JrZXJfcHJvY2Vz", + "c19pZBgBIAEoBRIWCg53b3JrZXJfdmVyc2lvbhgCIAEoCRIXCg9teGFjY2Vz", + "c19wcm9naWQYAyABKAkSFgoObXhhY2Nlc3NfY2xzaWQYBCABKAkiQAoQRHJh", + "aW5FdmVudHNSZXBseRIsCgZldmVudHMYASADKAsyHC5teGFjY2Vzc19nYXRl", + "d2F5LnYxLk14RXZlbnQiNQocQWNrbm93bGVkZ2VBbGFybVJlcGx5UGF5bG9h", + "ZBIVCg1uYXRpdmVfc3RhdHVzGAEgASgFIngKHVF1ZXJ5QWN0aXZlQWxhcm1z", + "UmVwbHlQYXlsb2FkEjsKCXNuYXBzaG90cxgBIAMoCzIoLm14YWNjZXNzX2dh", + "dGV3YXkudjEuQWN0aXZlQWxhcm1TbmFwc2hvdBIaChJzbmFwc2hvdF90cnVu", + "Y2F0ZWQYAiABKAgijwgKB014RXZlbnQSMgoGZmFtaWx5GAEgASgOMiIubXhh", + "Y2Nlc3NfZ2F0ZXdheS52MS5NeEV2ZW50RmFtaWx5EhIKCnNlc3Npb25faWQY", + "AiABKAkSFQoNc2VydmVyX2hhbmRsZRgDIAEoBRITCgtpdGVtX2hhbmRsZRgE", + "IAEoBRIrCgV2YWx1ZRgFIAEoCzIcLm14YWNjZXNzX2dhdGV3YXkudjEuTXhW", + "YWx1ZRIPCgdxdWFsaXR5GAYgASgFEjQKEHNvdXJjZV90aW1lc3RhbXAYByAB", + "KAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEjQKCHN0YXR1c2VzGAgg", + "AygLMiIubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeFN0YXR1c1Byb3h5EhcKD3dv", + "cmtlcl9zZXF1ZW5jZRgJIAEoBBI0ChB3b3JrZXJfdGltZXN0YW1wGAogASgL", + "MhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBI9ChlnYXRld2F5X3JlY2Vp", + "dmVfdGltZXN0YW1wGAsgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFt", + "cBIUCgdocmVzdWx0GAwgASgFSAGIAQESEgoKcmF3X3N0YXR1cxgNIAEoCRI3", + "CgpyZXBsYXlfZ2FwGA4gASgLMh4ubXhhY2Nlc3NfZ2F0ZXdheS52MS5SZXBs", + "YXlHYXBIAogBARJACg5vbl9kYXRhX2NoYW5nZRgUIAEoCzImLm14YWNjZXNz", + "X2dhdGV3YXkudjEuT25EYXRhQ2hhbmdlRXZlbnRIABJGChFvbl93cml0ZV9j", + "b21wbGV0ZRgVIAEoCzIpLm14YWNjZXNzX2dhdGV3YXkudjEuT25Xcml0ZUNv", + "bXBsZXRlRXZlbnRIABJJChJvcGVyYXRpb25fY29tcGxldGUYFiABKAsyKy5t", + "eGFjY2Vzc19nYXRld2F5LnYxLk9wZXJhdGlvbkNvbXBsZXRlRXZlbnRIABJR", + "Chdvbl9idWZmZXJlZF9kYXRhX2NoYW5nZRgXIAEoCzIuLm14YWNjZXNzX2dh", + "dGV3YXkudjEuT25CdWZmZXJlZERhdGFDaGFuZ2VFdmVudEgAEkoKE29uX2Fs", + "YXJtX3RyYW5zaXRpb24YGCABKAsyKy5teGFjY2Vzc19nYXRld2F5LnYxLk9u", + "QWxhcm1UcmFuc2l0aW9uRXZlbnRIABJeCh5vbl9hbGFybV9wcm92aWRlcl9t", + "b2RlX2NoYW5nZWQYGSABKAsyNC5teGFjY2Vzc19nYXRld2F5LnYxLk9uQWxh", + "cm1Qcm92aWRlck1vZGVDaGFuZ2VkRXZlbnRIAEIGCgRib2R5QgoKCF9ocmVz", + "dWx0Qg0KC19yZXBsYXlfZ2FwIlAKCVJlcGxheUdhcBIgChhyZXF1ZXN0ZWRf", + "YWZ0ZXJfc2VxdWVuY2UYASABKAQSIQoZb2xkZXN0X2F2YWlsYWJsZV9zZXF1", + "ZW5jZRgCIAEoBCITChFPbkRhdGFDaGFuZ2VFdmVudCIWChRPbldyaXRlQ29t", + "cGxldGVFdmVudCIYChZPcGVyYXRpb25Db21wbGV0ZUV2ZW50ItQBChlPbkJ1", + "ZmZlcmVkRGF0YUNoYW5nZUV2ZW50EjIKCWRhdGFfdHlwZRgBIAEoDjIfLm14", + "YWNjZXNzX2dhdGV3YXkudjEuTXhEYXRhVHlwZRI0Cg5xdWFsaXR5X3ZhbHVl", + "cxgCIAEoCzIcLm14YWNjZXNzX2dhdGV3YXkudjEuTXhBcnJheRI2ChB0aW1l", + "c3RhbXBfdmFsdWVzGAMgASgLMhwubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeEFy", + "cmF5EhUKDXJhd19kYXRhX3R5cGUYBCABKAUi0AQKFk9uQWxhcm1UcmFuc2l0", + "aW9uRXZlbnQSHAoUYWxhcm1fZnVsbF9yZWZlcmVuY2UYASABKAkSHwoXc291", + "cmNlX29iamVjdF9yZWZlcmVuY2UYAiABKAkSFwoPYWxhcm1fdHlwZV9uYW1l", + "GAMgASgJEkEKD3RyYW5zaXRpb25fa2luZBgEIAEoDjIoLm14YWNjZXNzX2dh", + "dGV3YXkudjEuQWxhcm1UcmFuc2l0aW9uS2luZBIQCghzZXZlcml0eRgFIAEo", + "BRI8ChhvcmlnaW5hbF9yYWlzZV90aW1lc3RhbXAYBiABKAsyGi5nb29nbGUu", + "cHJvdG9idWYuVGltZXN0YW1wEjgKFHRyYW5zaXRpb25fdGltZXN0YW1wGAcg", + "ASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIVCg1vcGVyYXRvcl91", + "c2VyGAggASgJEhgKEG9wZXJhdG9yX2NvbW1lbnQYCSABKAkSEAoIY2F0ZWdv", + "cnkYCiABKAkSEwoLZGVzY3JpcHRpb24YCyABKAkSMwoNY3VycmVudF92YWx1", + "ZRgMIAEoCzIcLm14YWNjZXNzX2dhdGV3YXkudjEuTXhWYWx1ZRIxCgtsaW1p", + "dF92YWx1ZRgNIAEoCzIcLm14YWNjZXNzX2dhdGV3YXkudjEuTXhWYWx1ZRIQ", + "CghkZWdyYWRlZBgOIAEoCBI/Cg9zb3VyY2VfcHJvdmlkZXIYDyABKA4yJi5t", + "eGFjY2Vzc19nYXRld2F5LnYxLkFsYXJtUHJvdmlkZXJNb2RlIqABCh9PbkFs", + "YXJtUHJvdmlkZXJNb2RlQ2hhbmdlZEV2ZW50EjQKBG1vZGUYASABKA4yJi5t", + "eGFjY2Vzc19nYXRld2F5LnYxLkFsYXJtUHJvdmlkZXJNb2RlEg4KBnJlYXNv", + "bhgCIAEoCRIPCgdocmVzdWx0GAMgASgFEiYKAmF0GAQgASgLMhouZ29vZ2xl", + "LnByb3RvYnVmLlRpbWVzdGFtcCLxBAoTQWN0aXZlQWxhcm1TbmFwc2hvdBIc", + "ChRhbGFybV9mdWxsX3JlZmVyZW5jZRgBIAEoCRIfChdzb3VyY2Vfb2JqZWN0", + "X3JlZmVyZW5jZRgCIAEoCRIXCg9hbGFybV90eXBlX25hbWUYAyABKAkSEAoI", + "c2V2ZXJpdHkYBCABKAUSPAoYb3JpZ2luYWxfcmFpc2VfdGltZXN0YW1wGAUg", + "ASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBI/Cg1jdXJyZW50X3N0", + "YXRlGAYgASgOMigubXhhY2Nlc3NfZ2F0ZXdheS52MS5BbGFybUNvbmRpdGlv", + "blN0YXRlEhAKCGNhdGVnb3J5GAcgASgJEhMKC2Rlc2NyaXB0aW9uGAggASgJ", + "Ej0KGWxhc3RfdHJhbnNpdGlvbl90aW1lc3RhbXAYCSABKAsyGi5nb29nbGUu", + "cHJvdG9idWYuVGltZXN0YW1wEhUKDW9wZXJhdG9yX3VzZXIYCiABKAkSGAoQ", + "b3BlcmF0b3JfY29tbWVudBgLIAEoCRIzCg1jdXJyZW50X3ZhbHVlGAwgASgL", + "MhwubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeFZhbHVlEjEKC2xpbWl0X3ZhbHVl", + "GA0gASgLMhwubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeFZhbHVlEhAKCGRlZ3Jh", + "ZGVkGA4gASgIEj8KD3NvdXJjZV9wcm92aWRlchgPIAEoDjImLm14YWNjZXNz", + "X2dhdGV3YXkudjEuQWxhcm1Qcm92aWRlck1vZGUSHwoXZnJvbV90cnVuY2F0", + "ZWRfc25hcHNob3QYECABKAgixAEKF0Fja25vd2xlZGdlQWxhcm1SZXF1ZXN0", + "Eh0KFWNsaWVudF9jb3JyZWxhdGlvbl9pZBgCIAEoCRIcChRhbGFybV9mdWxs", + "X3JlZmVyZW5jZRgDIAEoCRIPCgdjb21tZW50GAQgASgJEhUKDW9wZXJhdG9y", + "X3VzZXIYBSABKAkSGQoRb3BlcmF0b3JfcGFzc3dvcmQYBiABKAkSFwoPb3Bl", + "cmF0b3JfZG9tYWluGAcgASgJSgQIARACUgpzZXNzaW9uX2lkIvEBChVBY2tu", + "b3dsZWRnZUFsYXJtUmVwbHkSFgoOY29ycmVsYXRpb25faWQYAiABKAkSPAoP", + "cHJvdG9jb2xfc3RhdHVzGAMgASgLMiMubXhhY2Nlc3NfZ2F0ZXdheS52MS5Q", + "cm90b2NvbFN0YXR1cxIUCgdocmVzdWx0GAQgASgFSACIAQESMgoGc3RhdHVz", + "GAUgASgLMiIubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeFN0YXR1c1Byb3h5EhoK", + "EmRpYWdub3N0aWNfbWVzc2FnZRgGIAEoCUIKCghfaHJlc3VsdEoECAEQAlIK", + "c2Vzc2lvbl9pZCJRChNTdHJlYW1BbGFybXNSZXF1ZXN0Eh0KFWNsaWVudF9j", + "b3JyZWxhdGlvbl9pZBgBIAEoCRIbChNhbGFybV9maWx0ZXJfcHJlZml4GAIg", + "ASgJIskCChBBbGFybUZlZWRNZXNzYWdlEkAKDGFjdGl2ZV9hbGFybRgBIAEo", + "CzIoLm14YWNjZXNzX2dhdGV3YXkudjEuQWN0aXZlQWxhcm1TbmFwc2hvdEgA", + "EhsKEXNuYXBzaG90X2NvbXBsZXRlGAIgASgISAASQQoKdHJhbnNpdGlvbhgD", + "IAEoCzIrLm14YWNjZXNzX2dhdGV3YXkudjEuT25BbGFybVRyYW5zaXRpb25F", + "dmVudEgAEkMKD3Byb3ZpZGVyX3N0YXR1cxgEIAEoCzIoLm14YWNjZXNzX2dh", + "dGV3YXkudjEuQWxhcm1Qcm92aWRlclN0YXR1c0gAEkMKD3NuYXBzaG90X3N0", + "YXR1cxgFIAEoCzIoLm14YWNjZXNzX2dhdGV3YXkudjEuQWxhcm1TbmFwc2hv", + "dFN0YXR1c0gAQgkKB3BheWxvYWQimAEKE0FsYXJtUHJvdmlkZXJTdGF0dXMS", + "NAoEbW9kZRgBIAEoDjImLm14YWNjZXNzX2dhdGV3YXkudjEuQWxhcm1Qcm92", + "aWRlck1vZGUSEAoIZGVncmFkZWQYAiABKAgSDgoGcmVhc29uGAMgASgJEikK", + "BXNpbmNlGAQgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCIoChNB", + "bGFybVNuYXBzaG90U3RhdHVzEhEKCXRydW5jYXRlZBgBIAEoCCLrAQoNTXhT", + "dGF0dXNQcm94eRIPCgdzdWNjZXNzGAEgASgFEjcKCGNhdGVnb3J5GAIgASgO", + "MiUubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeFN0YXR1c0NhdGVnb3J5EjgKC2Rl", + "dGVjdGVkX2J5GAMgASgOMiMubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeFN0YXR1", + "c1NvdXJjZRIOCgZkZXRhaWwYBCABKAUSFAoMcmF3X2NhdGVnb3J5GAUgASgF", + "EhcKD3Jhd19kZXRlY3RlZF9ieRgGIAEoBRIXCg9kaWFnbm9zdGljX3RleHQY", + "ByABKAki6QMKB014VmFsdWUSMgoJZGF0YV90eXBlGAEgASgOMh8ubXhhY2Nl", + "c3NfZ2F0ZXdheS52MS5NeERhdGFUeXBlEhQKDHZhcmlhbnRfdHlwZRgCIAEo", + "CRIPCgdpc19udWxsGAMgASgIEhYKDnJhd19kaWFnbm9zdGljGAQgASgJEhUK", + "DXJhd19kYXRhX3R5cGUYBSABKAUSFAoKYm9vbF92YWx1ZRgKIAEoCEgAEhUK", + "C2ludDMyX3ZhbHVlGAsgASgFSAASFQoLaW50NjRfdmFsdWUYDCABKANIABIV", + "CgtmbG9hdF92YWx1ZRgNIAEoAkgAEhYKDGRvdWJsZV92YWx1ZRgOIAEoAUgA", + "EhYKDHN0cmluZ192YWx1ZRgPIAEoCUgAEjUKD3RpbWVzdGFtcF92YWx1ZRgQ", + "IAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBIABIzCgthcnJheV92", + "YWx1ZRgRIAEoCzIcLm14YWNjZXNzX2dhdGV3YXkudjEuTXhBcnJheUgAEhMK", + "CXJhd192YWx1ZRgSIAEoDEgAEkAKEnNwYXJzZV9hcnJheV92YWx1ZRgTIAEo", + "CzIiLm14YWNjZXNzX2dhdGV3YXkudjEuTXhTcGFyc2VBcnJheUgAQgYKBGtp", + "bmQi/gQKB014QXJyYXkSOgoRZWxlbWVudF9kYXRhX3R5cGUYASABKA4yHy5t", + "eGFjY2Vzc19nYXRld2F5LnYxLk14RGF0YVR5cGUSFAoMdmFyaWFudF90eXBl", + "GAIgASgJEhIKCmRpbWVuc2lvbnMYAyADKA0SFgoOcmF3X2RpYWdub3N0aWMY", + "BCABKAkSHQoVcmF3X2VsZW1lbnRfZGF0YV90eXBlGAUgASgFEjUKC2Jvb2xf", + "dmFsdWVzGAogASgLMh4ubXhhY2Nlc3NfZ2F0ZXdheS52MS5Cb29sQXJyYXlI", + "ABI3CgxpbnQzMl92YWx1ZXMYCyABKAsyHy5teGFjY2Vzc19nYXRld2F5LnYx", + "LkludDMyQXJyYXlIABI3CgxpbnQ2NF92YWx1ZXMYDCABKAsyHy5teGFjY2Vz", + "c19nYXRld2F5LnYxLkludDY0QXJyYXlIABI3CgxmbG9hdF92YWx1ZXMYDSAB", + "KAsyHy5teGFjY2Vzc19nYXRld2F5LnYxLkZsb2F0QXJyYXlIABI5Cg1kb3Vi", + "bGVfdmFsdWVzGA4gASgLMiAubXhhY2Nlc3NfZ2F0ZXdheS52MS5Eb3VibGVB", + "cnJheUgAEjkKDXN0cmluZ192YWx1ZXMYDyABKAsyIC5teGFjY2Vzc19nYXRl", + "d2F5LnYxLlN0cmluZ0FycmF5SAASPwoQdGltZXN0YW1wX3ZhbHVlcxgQIAEo", + "CzIjLm14YWNjZXNzX2dhdGV3YXkudjEuVGltZXN0YW1wQXJyYXlIABIzCgpy", + "YXdfdmFsdWVzGBEgASgLMh0ubXhhY2Nlc3NfZ2F0ZXdheS52MS5SYXdBcnJh", + "eUgAQggKBnZhbHVlcyKZAQoNTXhTcGFyc2VBcnJheRI6ChFlbGVtZW50X2Rh", + "dGFfdHlwZRgBIAEoDjIfLm14YWNjZXNzX2dhdGV3YXkudjEuTXhEYXRhVHlw", + "ZRIUCgx0b3RhbF9sZW5ndGgYAiABKA0SNgoIZWxlbWVudHMYAyADKAsyJC5t", + "eGFjY2Vzc19nYXRld2F5LnYxLk14U3BhcnNlRWxlbWVudCJNCg9NeFNwYXJz", + "ZUVsZW1lbnQSDQoFaW5kZXgYASABKA0SKwoFdmFsdWUYAiABKAsyHC5teGFj", + "Y2Vzc19nYXRld2F5LnYxLk14VmFsdWUiGwoJQm9vbEFycmF5Eg4KBnZhbHVl", + "cxgBIAMoCCIcCgpJbnQzMkFycmF5Eg4KBnZhbHVlcxgBIAMoBSIcCgpJbnQ2", + "NEFycmF5Eg4KBnZhbHVlcxgBIAMoAyIcCgpGbG9hdEFycmF5Eg4KBnZhbHVl", + "cxgBIAMoAiIdCgtEb3VibGVBcnJheRIOCgZ2YWx1ZXMYASADKAEiHQoLU3Ry", + "aW5nQXJyYXkSDgoGdmFsdWVzGAEgAygJIjwKDlRpbWVzdGFtcEFycmF5EioK", + "BnZhbHVlcxgBIAMoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAiGgoI", + "UmF3QXJyYXkSDgoGdmFsdWVzGAEgAygMIlgKDlByb3RvY29sU3RhdHVzEjUK", + "BGNvZGUYASABKA4yJy5teGFjY2Vzc19nYXRld2F5LnYxLlByb3RvY29sU3Rh", + "dHVzQ29kZRIPCgdtZXNzYWdlGAIgASgJKswLCg1NeENvbW1hbmRLaW5kEh8K", + "G01YX0NPTU1BTkRfS0lORF9VTlNQRUNJRklFRBAAEhwKGE1YX0NPTU1BTkRf", + "S0lORF9SRUdJU1RFUhABEh4KGk1YX0NPTU1BTkRfS0lORF9VTlJFR0lTVEVS", + "EAISHAoYTVhfQ09NTUFORF9LSU5EX0FERF9JVEVNEAMSHQoZTVhfQ09NTUFO", + "RF9LSU5EX0FERF9JVEVNMhAEEh8KG01YX0NPTU1BTkRfS0lORF9SRU1PVkVf", + "SVRFTRAFEhoKFk1YX0NPTU1BTkRfS0lORF9BRFZJU0UQBhIdChlNWF9DT01N", + "QU5EX0tJTkRfVU5fQURWSVNFEAcSJgoiTVhfQ09NTUFORF9LSU5EX0FEVklT", + "RV9TVVBFUlZJU09SWRAIEiUKIU1YX0NPTU1BTkRfS0lORF9BRERfQlVGRkVS", + "RURfSVRFTRAJEjAKLE1YX0NPTU1BTkRfS0lORF9TRVRfQlVGRkVSRURfVVBE", + "QVRFX0lOVEVSVkFMEAoSGwoXTVhfQ09NTUFORF9LSU5EX1NVU1BFTkQQCxIc", + "ChhNWF9DT01NQU5EX0tJTkRfQUNUSVZBVEUQDBIZChVNWF9DT01NQU5EX0tJ", + "TkRfV1JJVEUQDRIaChZNWF9DT01NQU5EX0tJTkRfV1JJVEUyEA4SIQodTVhf", + "Q09NTUFORF9LSU5EX1dSSVRFX1NFQ1VSRUQQDxIiCh5NWF9DT01NQU5EX0tJ", + "TkRfV1JJVEVfU0VDVVJFRDIQEBIlCiFNWF9DT01NQU5EX0tJTkRfQVVUSEVO", + "VElDQVRFX1VTRVIQERIoCiRNWF9DT01NQU5EX0tJTkRfQVJDSEVTVFJBX1VT", + "RVJfVE9fSUQQEhIhCh1NWF9DT01NQU5EX0tJTkRfQUREX0lURU1fQlVMSxAT", + "EiQKIE1YX0NPTU1BTkRfS0lORF9BRFZJU0VfSVRFTV9CVUxLEBQSJAogTVhf", + "Q09NTUFORF9LSU5EX1JFTU9WRV9JVEVNX0JVTEsQFRInCiNNWF9DT01NQU5E", + "X0tJTkRfVU5fQURWSVNFX0lURU1fQlVMSxAWEiIKHk1YX0NPTU1BTkRfS0lO", + "RF9TVUJTQ1JJQkVfQlVMSxAXEiQKIE1YX0NPTU1BTkRfS0lORF9VTlNVQlND", + "UklCRV9CVUxLEBgSJAogTVhfQ09NTUFORF9LSU5EX1NVQlNDUklCRV9BTEFS", + "TVMQGRImCiJNWF9DT01NQU5EX0tJTkRfVU5TVUJTQ1JJQkVfQUxBUk1TEBoS", + "JQohTVhfQ09NTUFORF9LSU5EX0FDS05PV0xFREdFX0FMQVJNEBsSJwojTVhf", + "Q09NTUFORF9LSU5EX1FVRVJZX0FDVElWRV9BTEFSTVMQHBItCilNWF9DT01N", + "QU5EX0tJTkRfQUNLTk9XTEVER0VfQUxBUk1fQllfTkFNRRAdEh4KGk1YX0NP", + "TU1BTkRfS0lORF9XUklURV9CVUxLEB4SHwobTVhfQ09NTUFORF9LSU5EX1dS", + "SVRFMl9CVUxLEB8SJgoiTVhfQ09NTUFORF9LSU5EX1dSSVRFX1NFQ1VSRURf", + "QlVMSxAgEicKI01YX0NPTU1BTkRfS0lORF9XUklURV9TRUNVUkVEMl9CVUxL", + "ECESHQoZTVhfQ09NTUFORF9LSU5EX1JFQURfQlVMSxAiEisKJ01YX0NPTU1B", + "TkRfS0lORF9VUERBVEVfQUxBUk1fV0FUQ0hfTElTVBAjEhgKFE1YX0NPTU1B", + "TkRfS0lORF9QSU5HEGQSJQohTVhfQ09NTUFORF9LSU5EX0dFVF9TRVNTSU9O", + "X1NUQVRFEGUSIwofTVhfQ09NTUFORF9LSU5EX0dFVF9XT1JLRVJfSU5GTxBm", + "EiAKHE1YX0NPTU1BTkRfS0lORF9EUkFJTl9FVkVOVFMQZxIjCh9NWF9DT01N", + "QU5EX0tJTkRfU0hVVERPV05fV09SS0VSEGgqegoRQWxhcm1Qcm92aWRlck1v", + "ZGUSIwofQUxBUk1fUFJPVklERVJfTU9ERV9VTlNQRUNJRklFRBAAEiAKHEFM", + "QVJNX1BST1ZJREVSX01PREVfQUxBUk1NR1IQARIeChpBTEFSTV9QUk9WSURF", + "Ul9NT0RFX1NVQlRBRxACKq0CCg1NeEV2ZW50RmFtaWx5Eh8KG01YX0VWRU5U", + "X0ZBTUlMWV9VTlNQRUNJRklFRBAAEiIKHk1YX0VWRU5UX0ZBTUlMWV9PTl9E", + "QVRBX0NIQU5HRRABEiUKIU1YX0VWRU5UX0ZBTUlMWV9PTl9XUklURV9DT01Q", + "TEVURRACEiYKIk1YX0VWRU5UX0ZBTUlMWV9PUEVSQVRJT05fQ09NUExFVEUQ", + "AxIrCidNWF9FVkVOVF9GQU1JTFlfT05fQlVGRkVSRURfREFUQV9DSEFOR0UQ", + "BBInCiNNWF9FVkVOVF9GQU1JTFlfT05fQUxBUk1fVFJBTlNJVElPThAFEjIK", + "Lk1YX0VWRU5UX0ZBTUlMWV9PTl9BTEFSTV9QUk9WSURFUl9NT0RFX0NIQU5H", + "RUQQBirKAQoTQWxhcm1UcmFuc2l0aW9uS2luZBIlCiFBTEFSTV9UUkFOU0lU", + "SU9OX0tJTkRfVU5TUEVDSUZJRUQQABIfChtBTEFSTV9UUkFOU0lUSU9OX0tJ", + "TkRfUkFJU0UQARIlCiFBTEFSTV9UUkFOU0lUSU9OX0tJTkRfQUNLTk9XTEVE", + "R0UQAhIfChtBTEFSTV9UUkFOU0lUSU9OX0tJTkRfQ0xFQVIQAxIjCh9BTEFS", + "TV9UUkFOU0lUSU9OX0tJTkRfUkVUUklHR0VSEAQqqgEKE0FsYXJtQ29uZGl0", + "aW9uU3RhdGUSJQohQUxBUk1fQ09ORElUSU9OX1NUQVRFX1VOU1BFQ0lGSUVE", + "EAASIAocQUxBUk1fQ09ORElUSU9OX1NUQVRFX0FDVElWRRABEiYKIkFMQVJN", + "X0NPTkRJVElPTl9TVEFURV9BQ1RJVkVfQUNLRUQQAhIiCh5BTEFSTV9DT05E", + "SVRJT05fU1RBVEVfSU5BQ1RJVkUQAyqlAwoQTXhTdGF0dXNDYXRlZ29yeRIi", + "Ch5NWF9TVEFUVVNfQ0FURUdPUllfVU5TUEVDSUZJRUQQABIeChpNWF9TVEFU", + "VVNfQ0FURUdPUllfVU5LTk9XThABEhkKFU1YX1NUQVRVU19DQVRFR09SWV9P", + "SxACEh4KGk1YX1NUQVRVU19DQVRFR09SWV9QRU5ESU5HEAMSHgoaTVhfU1RB", + "VFVTX0NBVEVHT1JZX1dBUk5JTkcQBBIqCiZNWF9TVEFUVVNfQ0FURUdPUllf", + "Q09NTVVOSUNBVElPTl9FUlJPUhAFEioKJk1YX1NUQVRVU19DQVRFR09SWV9D", + "T05GSUdVUkFUSU9OX0VSUk9SEAYSKAokTVhfU1RBVFVTX0NBVEVHT1JZX09Q", + "RVJBVElPTkFMX0VSUk9SEAcSJQohTVhfU1RBVFVTX0NBVEVHT1JZX1NFQ1VS", + "SVRZX0VSUk9SEAgSJQohTVhfU1RBVFVTX0NBVEVHT1JZX1NPRlRXQVJFX0VS", + "Uk9SEAkSIgoeTVhfU1RBVFVTX0NBVEVHT1JZX09USEVSX0VSUk9SEAoqygIK", + "Dk14U3RhdHVzU291cmNlEiAKHE1YX1NUQVRVU19TT1VSQ0VfVU5TUEVDSUZJ", + "RUQQABIcChhNWF9TVEFUVVNfU09VUkNFX1VOS05PV04QARIjCh9NWF9TVEFU", + "VVNfU09VUkNFX1JFUVVFU1RJTkdfTE1YEAISIwofTVhfU1RBVFVTX1NPVVJD", + "RV9SRVNQT05ESU5HX0xNWBADEiMKH01YX1NUQVRVU19TT1VSQ0VfUkVRVUVT", + "VElOR19OTVgQBBIjCh9NWF9TVEFUVVNfU09VUkNFX1JFU1BPTkRJTkdfTk1Y", + "EAUSMQotTVhfU1RBVFVTX1NPVVJDRV9SRVFVRVNUSU5HX0FVVE9NQVRJT05f", + "T0JKRUNUEAYSMQotTVhfU1RBVFVTX1NPVVJDRV9SRVNQT05ESU5HX0FVVE9N", + "QVRJT05fT0JKRUNUEAcq3QQKCk14RGF0YVR5cGUSHAoYTVhfREFUQV9UWVBF", + "X1VOU1BFQ0lGSUVEEAASGAoUTVhfREFUQV9UWVBFX1VOS05PV04QARIYChRN", + "WF9EQVRBX1RZUEVfTk9fREFUQRACEhgKFE1YX0RBVEFfVFlQRV9CT09MRUFO", + "EAMSGAoUTVhfREFUQV9UWVBFX0lOVEVHRVIQBBIWChJNWF9EQVRBX1RZUEVf", + "RkxPQVQQBRIXChNNWF9EQVRBX1RZUEVfRE9VQkxFEAYSFwoTTVhfREFUQV9U", + "WVBFX1NUUklORxAHEhUKEU1YX0RBVEFfVFlQRV9USU1FEAgSHQoZTVhfREFU", + "QV9UWVBFX0VMQVBTRURfVElNRRAJEh8KG01YX0RBVEFfVFlQRV9SRUZFUkVO", + "Q0VfVFlQRRAKEhwKGE1YX0RBVEFfVFlQRV9TVEFUVVNfVFlQRRALEhUKEU1Y", + "X0RBVEFfVFlQRV9FTlVNEAwSLQopTVhfREFUQV9UWVBFX1NFQ1VSSVRZX0NM", + "QVNTSUZJQ0FUSU9OX0VOVU0QDRIiCh5NWF9EQVRBX1RZUEVfREFUQV9RVUFM", + "SVRZX1RZUEUQDhIfChtNWF9EQVRBX1RZUEVfUVVBTElGSUVEX0VOVU0QDxIh", + "Ch1NWF9EQVRBX1RZUEVfUVVBTElGSUVEX1NUUlVDVBAQEikKJU1YX0RBVEFf", + "VFlQRV9JTlRFUk5BVElPTkFMSVpFRF9TVFJJTkcQERIbChdNWF9EQVRBX1RZ", + "UEVfQklHX1NUUklORxASEhQKEE1YX0RBVEFfVFlQRV9FTkQQEyqjAwoSUHJv", + "dG9jb2xTdGF0dXNDb2RlEiQKIFBST1RPQ09MX1NUQVRVU19DT0RFX1VOU1BF", + "Q0lGSUVEEAASGwoXUFJPVE9DT0xfU1RBVFVTX0NPREVfT0sQARIoCiRQUk9U", + "T0NPTF9TVEFUVVNfQ09ERV9JTlZBTElEX1JFUVVFU1QQAhIqCiZQUk9UT0NP", + "TF9TVEFUVVNfQ09ERV9TRVNTSU9OX05PVF9GT1VORBADEioKJlBST1RPQ09M", + "X1NUQVRVU19DT0RFX1NFU1NJT05fTk9UX1JFQURZEAQSKwonUFJPVE9DT0xf", + "U1RBVFVTX0NPREVfV09SS0VSX1VOQVZBSUxBQkxFEAUSIAocUFJPVE9DT0xf", + "U1RBVFVTX0NPREVfVElNRU9VVBAGEiEKHVBST1RPQ09MX1NUQVRVU19DT0RF", + "X0NBTkNFTEVEEAcSKwonUFJPVE9DT0xfU1RBVFVTX0NPREVfUFJPVE9DT0xf", + "VklPTEFUSU9OEAgSKQolUFJPVE9DT0xfU1RBVFVTX0NPREVfTVhBQ0NFU1Nf", + "RkFJTFVSRRAJKr8CCgxTZXNzaW9uU3RhdGUSHQoZU0VTU0lPTl9TVEFURV9V", + "TlNQRUNJRklFRBAAEhoKFlNFU1NJT05fU1RBVEVfQ1JFQVRJTkcQARIhCh1T", + "RVNTSU9OX1NUQVRFX1NUQVJUSU5HX1dPUktFUhACEiIKHlNFU1NJT05fU1RB", + "VEVfV0FJVElOR19GT1JfUElQRRADEh0KGVNFU1NJT05fU1RBVEVfSEFORFNI", + "QUtJTkcQBBIlCiFTRVNTSU9OX1NUQVRFX0lOSVRJQUxJWklOR19XT1JLRVIQ", + "BRIXChNTRVNTSU9OX1NUQVRFX1JFQURZEAYSGQoVU0VTU0lPTl9TVEFURV9D", + "TE9TSU5HEAcSGAoUU0VTU0lPTl9TVEFURV9DTE9TRUQQCBIZChVTRVNTSU9O", + "X1NUQVRFX0ZBVUxURUQQCTLDBQoPTXhBY2Nlc3NHYXRld2F5El0KC09wZW5T", + "ZXNzaW9uEicubXhhY2Nlc3NfZ2F0ZXdheS52MS5PcGVuU2Vzc2lvblJlcXVl", + "c3QaJS5teGFjY2Vzc19nYXRld2F5LnYxLk9wZW5TZXNzaW9uUmVwbHkSYAoM", + "Q2xvc2VTZXNzaW9uEigubXhhY2Nlc3NfZ2F0ZXdheS52MS5DbG9zZVNlc3Np", + "b25SZXF1ZXN0GiYubXhhY2Nlc3NfZ2F0ZXdheS52MS5DbG9zZVNlc3Npb25S", + "ZXBseRJUCgZJbnZva2USJS5teGFjY2Vzc19nYXRld2F5LnYxLk14Q29tbWFu", + "ZFJlcXVlc3QaIy5teGFjY2Vzc19nYXRld2F5LnYxLk14Q29tbWFuZFJlcGx5", + "ElgKDFN0cmVhbUV2ZW50cxIoLm14YWNjZXNzX2dhdGV3YXkudjEuU3RyZWFt", + "RXZlbnRzUmVxdWVzdBocLm14YWNjZXNzX2dhdGV3YXkudjEuTXhFdmVudDAB", + "EmwKEEFja25vd2xlZGdlQWxhcm0SLC5teGFjY2Vzc19nYXRld2F5LnYxLkFj", + "a25vd2xlZGdlQWxhcm1SZXF1ZXN0GioubXhhY2Nlc3NfZ2F0ZXdheS52MS5B", + "Y2tub3dsZWRnZUFsYXJtUmVwbHkSYQoMU3RyZWFtQWxhcm1zEigubXhhY2Nl", + "c3NfZ2F0ZXdheS52MS5TdHJlYW1BbGFybXNSZXF1ZXN0GiUubXhhY2Nlc3Nf", + "Z2F0ZXdheS52MS5BbGFybUZlZWRNZXNzYWdlMAESbgoRUXVlcnlBY3RpdmVB", + "bGFybXMSLS5teGFjY2Vzc19nYXRld2F5LnYxLlF1ZXJ5QWN0aXZlQWxhcm1z", + "UmVxdWVzdBooLm14YWNjZXNzX2dhdGV3YXkudjEuQWN0aXZlQWxhcm1TbmFw", + "c2hvdDABQiaqAiNaQi5NT00uV1cuTXhHYXRld2F5LkNvbnRyYWN0cy5Qcm90", + "b2IGcHJvdG8z")); descriptor = pbr::FileDescriptor.FromGeneratedCode(descriptorData, new pbr::FileDescriptor[] { global::Google.Protobuf.WellKnownTypes.DurationReflection.Descriptor, global::Google.Protobuf.WellKnownTypes.TimestampReflection.Descriptor, }, new pbr::GeneratedClrTypeInfo(new[] {typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxCommandKind), typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmProviderMode), typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxEventFamily), typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmTransitionKind), typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmConditionState), typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusCategory), typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusSource), typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxDataType), typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.ProtocolStatusCode), typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.SessionState), }, null, new pbr::GeneratedClrTypeInfo[] { @@ -575,9 +578,9 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.UnsubscribeAlarmsCommand), global::ZB.MOM.WW.MxGateway.Contracts.Proto.UnsubscribeAlarmsCommand.Parser, null, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmSubtagTarget), global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmSubtagTarget.Parser, new[]{ "AlarmFullReference", "SourceObjectReference", "ActiveSubtag", "AckedSubtag", "AckCommentSubtag", "PrioritySubtag" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmFailoverConfig), global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmFailoverConfig.Parser, new[]{ "ConsecutiveFailureThreshold", "FailbackProbeIntervalSeconds", "FailbackStableProbes" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmCommand), global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmCommand.Parser, new[]{ "AlarmGuid", "Comment", "OperatorUser", "OperatorNode", "OperatorDomain", "OperatorFullName" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmCommand), global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmCommand.Parser, new[]{ "AlarmGuid", "Comment", "OperatorUser", "OperatorNode", "OperatorDomain", "OperatorFullName", "OperatorPassword" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.QueryActiveAlarmsCommand), global::ZB.MOM.WW.MxGateway.Contracts.Proto.QueryActiveAlarmsCommand.Parser, new[]{ "AlarmFilterPrefix" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmByNameCommand), global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmByNameCommand.Parser, new[]{ "AlarmName", "ProviderName", "GroupName", "Comment", "OperatorUser", "OperatorNode", "OperatorDomain", "OperatorFullName" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmByNameCommand), global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmByNameCommand.Parser, new[]{ "AlarmName", "ProviderName", "GroupName", "Comment", "OperatorUser", "OperatorNode", "OperatorDomain", "OperatorFullName", "OperatorPassword" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.UnsubscribeBulkCommand), global::ZB.MOM.WW.MxGateway.Contracts.Proto.UnsubscribeBulkCommand.Parser, new[]{ "ServerHandle", "ItemHandles" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteBulkCommand), global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteBulkCommand.Parser, new[]{ "ServerHandle", "Entries" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteBulkEntry), global::ZB.MOM.WW.MxGateway.Contracts.Proto.WriteBulkEntry.Parser, new[]{ "ItemHandle", "Value", "UserId" }, null, null, null, null), @@ -622,7 +625,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnAlarmTransitionEvent), global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnAlarmTransitionEvent.Parser, new[]{ "AlarmFullReference", "SourceObjectReference", "AlarmTypeName", "TransitionKind", "Severity", "OriginalRaiseTimestamp", "TransitionTimestamp", "OperatorUser", "OperatorComment", "Category", "Description", "CurrentValue", "LimitValue", "Degraded", "SourceProvider" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnAlarmProviderModeChangedEvent), global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnAlarmProviderModeChangedEvent.Parser, new[]{ "Mode", "Reason", "Hresult", "At" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.ActiveAlarmSnapshot), global::ZB.MOM.WW.MxGateway.Contracts.Proto.ActiveAlarmSnapshot.Parser, new[]{ "AlarmFullReference", "SourceObjectReference", "AlarmTypeName", "Severity", "OriginalRaiseTimestamp", "CurrentState", "Category", "Description", "LastTransitionTimestamp", "OperatorUser", "OperatorComment", "CurrentValue", "LimitValue", "Degraded", "SourceProvider", "FromTruncatedSnapshot" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmRequest), global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmRequest.Parser, new[]{ "ClientCorrelationId", "AlarmFullReference", "Comment", "OperatorUser" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmRequest), global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmRequest.Parser, new[]{ "ClientCorrelationId", "AlarmFullReference", "Comment", "OperatorUser", "OperatorPassword", "OperatorDomain" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmReply), global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmReply.Parser, new[]{ "CorrelationId", "ProtocolStatus", "Hresult", "Status", "DiagnosticMessage" }, new[]{ "Hresult" }, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.StreamAlarmsRequest), global::ZB.MOM.WW.MxGateway.Contracts.Proto.StreamAlarmsRequest.Parser, new[]{ "ClientCorrelationId", "AlarmFilterPrefix" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmFeedMessage), global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmFeedMessage.Parser, new[]{ "ActiveAlarm", "SnapshotComplete", "Transition", "ProviderStatus", "SnapshotStatus" }, new[]{ "Payload" }, null, null, null), @@ -12586,10 +12589,13 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { } /// - /// Acknowledge a single alarm by its GUID. Operator identity fields are - /// recorded atomically with the ack transition in the alarm-history log. - /// The reply's hresult / native_status surfaces AVEVA's - /// AlarmAckByGUID return code. + /// 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. /// [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] public sealed partial class AcknowledgeAlarmCommand : pb::IMessage @@ -12632,6 +12638,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { operatorNode_ = other.operatorNode_; operatorDomain_ = other.operatorDomain_; operatorFullName_ = other.operatorFullName_; + operatorPassword_ = other.operatorPassword_; _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); } @@ -12716,6 +12723,23 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { } } + /// Field number for the "operator_password" field. + public const int OperatorPasswordFieldNumber = 7; + private string operatorPassword_ = ""; + /// + /// Operator credential used for AuthenticateUser before the AckMsg write. + /// Implementations must keep this field out of logs, metrics labels, command + /// lines, and diagnostics. + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string OperatorPassword { + get { return operatorPassword_; } + set { + operatorPassword_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public override bool Equals(object other) { @@ -12737,6 +12761,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { if (OperatorNode != other.OperatorNode) return false; if (OperatorDomain != other.OperatorDomain) return false; if (OperatorFullName != other.OperatorFullName) return false; + if (OperatorPassword != other.OperatorPassword) return false; return Equals(_unknownFields, other._unknownFields); } @@ -12750,6 +12775,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { if (OperatorNode.Length != 0) hash ^= OperatorNode.GetHashCode(); if (OperatorDomain.Length != 0) hash ^= OperatorDomain.GetHashCode(); if (OperatorFullName.Length != 0) hash ^= OperatorFullName.GetHashCode(); + if (OperatorPassword.Length != 0) hash ^= OperatorPassword.GetHashCode(); if (_unknownFields != null) { hash ^= _unknownFields.GetHashCode(); } @@ -12792,6 +12818,10 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { output.WriteRawTag(50); output.WriteString(OperatorFullName); } + if (OperatorPassword.Length != 0) { + output.WriteRawTag(58); + output.WriteString(OperatorPassword); + } if (_unknownFields != null) { _unknownFields.WriteTo(output); } @@ -12826,6 +12856,10 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { output.WriteRawTag(50); output.WriteString(OperatorFullName); } + if (OperatorPassword.Length != 0) { + output.WriteRawTag(58); + output.WriteString(OperatorPassword); + } if (_unknownFields != null) { _unknownFields.WriteTo(ref output); } @@ -12854,6 +12888,9 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { if (OperatorFullName.Length != 0) { size += 1 + pb::CodedOutputStream.ComputeStringSize(OperatorFullName); } + if (OperatorPassword.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(OperatorPassword); + } if (_unknownFields != null) { size += _unknownFields.CalculateSize(); } @@ -12884,6 +12921,9 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { if (other.OperatorFullName.Length != 0) { OperatorFullName = other.OperatorFullName; } + if (other.OperatorPassword.Length != 0) { + OperatorPassword = other.OperatorPassword; + } _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); } @@ -12927,6 +12967,10 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { OperatorFullName = input.ReadString(); break; } + case 58: { + OperatorPassword = input.ReadString(); + break; + } } } #endif @@ -12970,6 +13014,10 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { OperatorFullName = input.ReadString(); break; } + case 58: { + OperatorPassword = input.ReadString(); + break; + } } } } @@ -13183,9 +13231,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { /// /// Acknowledge a single alarm by its (name, provider, group) tuple. Used /// when the public RPC's AlarmFullReference (Provider!Group.Tag) cannot - /// be resolved to a GUID directly. The worker invokes - /// wwAlarmConsumerClass.AlarmAckByName which reaches the same alarm - /// history path as AlarmAckByGUID. + /// be resolved to a GUID directly. The worker acknowledges by writing `comment` to `<alarm_name>.AckMsg` (Galaxy provider only) after AuthenticateUser as the operator. /// [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] public sealed partial class AcknowledgeAlarmByNameCommand : pb::IMessage @@ -13230,6 +13276,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { operatorNode_ = other.operatorNode_; operatorDomain_ = other.operatorDomain_; operatorFullName_ = other.operatorFullName_; + operatorPassword_ = other.operatorPassword_; _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); } @@ -13346,6 +13393,23 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { } } + /// Field number for the "operator_password" field. + public const int OperatorPasswordFieldNumber = 9; + private string operatorPassword_ = ""; + /// + /// Operator credential used for AuthenticateUser before the AckMsg write. + /// Implementations must keep this field out of logs, metrics labels, command + /// lines, and diagnostics. + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string OperatorPassword { + get { return operatorPassword_; } + set { + operatorPassword_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public override bool Equals(object other) { @@ -13369,6 +13433,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { if (OperatorNode != other.OperatorNode) return false; if (OperatorDomain != other.OperatorDomain) return false; if (OperatorFullName != other.OperatorFullName) return false; + if (OperatorPassword != other.OperatorPassword) return false; return Equals(_unknownFields, other._unknownFields); } @@ -13384,6 +13449,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { if (OperatorNode.Length != 0) hash ^= OperatorNode.GetHashCode(); if (OperatorDomain.Length != 0) hash ^= OperatorDomain.GetHashCode(); if (OperatorFullName.Length != 0) hash ^= OperatorFullName.GetHashCode(); + if (OperatorPassword.Length != 0) hash ^= OperatorPassword.GetHashCode(); if (_unknownFields != null) { hash ^= _unknownFields.GetHashCode(); } @@ -13434,6 +13500,10 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { output.WriteRawTag(66); output.WriteString(OperatorFullName); } + if (OperatorPassword.Length != 0) { + output.WriteRawTag(74); + output.WriteString(OperatorPassword); + } if (_unknownFields != null) { _unknownFields.WriteTo(output); } @@ -13476,6 +13546,10 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { output.WriteRawTag(66); output.WriteString(OperatorFullName); } + if (OperatorPassword.Length != 0) { + output.WriteRawTag(74); + output.WriteString(OperatorPassword); + } if (_unknownFields != null) { _unknownFields.WriteTo(ref output); } @@ -13510,6 +13584,9 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { if (OperatorFullName.Length != 0) { size += 1 + pb::CodedOutputStream.ComputeStringSize(OperatorFullName); } + if (OperatorPassword.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(OperatorPassword); + } if (_unknownFields != null) { size += _unknownFields.CalculateSize(); } @@ -13546,6 +13623,9 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { if (other.OperatorFullName.Length != 0) { OperatorFullName = other.OperatorFullName; } + if (other.OperatorPassword.Length != 0) { + OperatorPassword = other.OperatorPassword; + } _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); } @@ -13597,6 +13677,10 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { OperatorFullName = input.ReadString(); break; } + case 74: { + OperatorPassword = input.ReadString(); + break; + } } } #endif @@ -13648,6 +13732,10 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { OperatorFullName = input.ReadString(); break; } + case 74: { + OperatorPassword = input.ReadString(); + break; + } } } } @@ -23278,11 +23366,11 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { /// Reply payload for AcknowledgeAlarmCommand AND /// AcknowledgeAlarmByNameCommand — both ack command kinds reuse this /// payload case (`MxCommandReply.acknowledge_alarm`); there is no - /// dedicated by-name reply case. Surfaces AVEVA's native ack return - /// code (AlarmAckByGUID for the GUID arm, AlarmAckByName for the - /// by-name arm); 0 means success. The MxCommandReply's hresult field + /// dedicated by-name reply case. Surfaces the ack outcome: 0 when the + /// AckMsg write was applied or sent but unconfirmed; otherwise the + /// AuthenticateUser HRESULT or the OnWriteComplete status detail. The MxCommandReply's hresult field /// carries the same value and is preferred for protocol consumers — - /// this payload exists so the gateway-side WorkerAlarmRpcDispatcher + /// this payload exists so GatewayAlarmMonitor /// can echo native_status into AcknowledgeAlarmReply.hresult without /// unpacking the outer envelope. /// @@ -27891,6 +27979,8 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { alarmFullReference_ = other.alarmFullReference_; comment_ = other.comment_; operatorUser_ = other.operatorUser_; + operatorPassword_ = other.operatorPassword_; + operatorDomain_ = other.operatorDomain_; _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); } @@ -27958,6 +28048,41 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { } } + /// Field number for the "operator_password" field. + public const int OperatorPasswordFieldNumber = 6; + private string operatorPassword_ = ""; + /// + /// 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. + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string OperatorPassword { + get { return operatorPassword_; } + set { + operatorPassword_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + /// Field number for the "operator_domain" field. + public const int OperatorDomainFieldNumber = 7; + private string operatorDomain_ = ""; + /// + /// Optional domain for OS-authenticated galaxies. When set the gateway + /// authenticates as "<operator_domain>\<operator_user>". + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string OperatorDomain { + get { return operatorDomain_; } + set { + operatorDomain_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public override bool Equals(object other) { @@ -27977,6 +28102,8 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { if (AlarmFullReference != other.AlarmFullReference) return false; if (Comment != other.Comment) return false; if (OperatorUser != other.OperatorUser) return false; + if (OperatorPassword != other.OperatorPassword) return false; + if (OperatorDomain != other.OperatorDomain) return false; return Equals(_unknownFields, other._unknownFields); } @@ -27988,6 +28115,8 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { if (AlarmFullReference.Length != 0) hash ^= AlarmFullReference.GetHashCode(); if (Comment.Length != 0) hash ^= Comment.GetHashCode(); if (OperatorUser.Length != 0) hash ^= OperatorUser.GetHashCode(); + if (OperatorPassword.Length != 0) hash ^= OperatorPassword.GetHashCode(); + if (OperatorDomain.Length != 0) hash ^= OperatorDomain.GetHashCode(); if (_unknownFields != null) { hash ^= _unknownFields.GetHashCode(); } @@ -28022,6 +28151,14 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { output.WriteRawTag(42); output.WriteString(OperatorUser); } + if (OperatorPassword.Length != 0) { + output.WriteRawTag(50); + output.WriteString(OperatorPassword); + } + if (OperatorDomain.Length != 0) { + output.WriteRawTag(58); + output.WriteString(OperatorDomain); + } if (_unknownFields != null) { _unknownFields.WriteTo(output); } @@ -28048,6 +28185,14 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { output.WriteRawTag(42); output.WriteString(OperatorUser); } + if (OperatorPassword.Length != 0) { + output.WriteRawTag(50); + output.WriteString(OperatorPassword); + } + if (OperatorDomain.Length != 0) { + output.WriteRawTag(58); + output.WriteString(OperatorDomain); + } if (_unknownFields != null) { _unknownFields.WriteTo(ref output); } @@ -28070,6 +28215,12 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { if (OperatorUser.Length != 0) { size += 1 + pb::CodedOutputStream.ComputeStringSize(OperatorUser); } + if (OperatorPassword.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(OperatorPassword); + } + if (OperatorDomain.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(OperatorDomain); + } if (_unknownFields != null) { size += _unknownFields.CalculateSize(); } @@ -28094,6 +28245,12 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { if (other.OperatorUser.Length != 0) { OperatorUser = other.OperatorUser; } + if (other.OperatorPassword.Length != 0) { + OperatorPassword = other.OperatorPassword; + } + if (other.OperatorDomain.Length != 0) { + OperatorDomain = other.OperatorDomain; + } _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); } @@ -28129,6 +28286,14 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { OperatorUser = input.ReadString(); break; } + case 50: { + OperatorPassword = input.ReadString(); + break; + } + case 58: { + OperatorDomain = input.ReadString(); + break; + } } } #endif @@ -28164,6 +28329,14 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { OperatorUser = input.ReadString(); break; } + case 50: { + OperatorPassword = input.ReadString(); + break; + } + case 58: { + OperatorDomain = input.ReadString(); + break; + } } } } @@ -28254,8 +28427,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { /// /// Native ack return code echoed from the worker. The worker carries the /// ack outcome as a single int32 (AcknowledgeAlarmReplyPayload.native_status, - /// = AlarmAckByName / AlarmAckByGUID return code; 0 = success); the gateway's - /// WorkerAlarmRpcDispatcher copies that value here. This is the authoritative + /// 0 = applied or sent; otherwise the AuthenticateUser HRESULT or OnWriteComplete detail); GatewayAlarmMonitor copies that value here. This is the authoritative /// ack-outcome field for the public RPC. Absent only when the worker reply /// omitted the value entirely (a protocol violation). /// diff --git a/src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto b/src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto index 6b00da9..915b1e8 100644 --- a/src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto +++ b/src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto @@ -391,10 +391,13 @@ message AlarmFailoverConfig { int32 failback_stable_probes = 3; // clean probes before switching back (>=1) } -// Acknowledge a single alarm by its GUID. Operator identity fields are -// recorded atomically with the ack transition in the alarm-history log. -// The reply's hresult / native_status surfaces AVEVA's -// AlarmAckByGUID return code. +// Acknowledge a single alarm by its GUID. The worker resolves the GUID to the +// alarm's Galaxy reference from its active-alarm snapshot and acknowledges it +// by writing `comment` to `.AckMsg` over MXAccess after +// AuthenticateUser as the operator, so the alarm history records the real +// operator. The reply's hresult / native_status is 0 when the write was +// applied (or sent but unconfirmed), otherwise the AuthenticateUser HRESULT or +// the OnWriteComplete status detail. message AcknowledgeAlarmCommand { // Canonical 8-4-4-4-12 GUID string (e.g. "BCC47053-9542-4D65-BDAA-BCDEA6A32A73"). string alarm_guid = 1; @@ -403,6 +406,10 @@ message AcknowledgeAlarmCommand { 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; } // Snapshot the currently-active alarm set. Optional filter prefix scopes @@ -414,9 +421,7 @@ message QueryActiveAlarmsCommand { // Acknowledge a single alarm by its (name, provider, group) tuple. Used // when the public RPC's AlarmFullReference (Provider!Group.Tag) cannot -// be resolved to a GUID directly. The worker invokes -// wwAlarmConsumerClass.AlarmAckByName which reaches the same alarm -// history path as AlarmAckByGUID. +// be resolved to a GUID directly. The worker acknowledges by writing `comment` to `.AckMsg` (Galaxy provider only) after AuthenticateUser as the operator. message AcknowledgeAlarmByNameCommand { // Tag/alarm name (e.g. "TestMachine_001.TestAlarm001"). Tag itself // may contain dots; the gateway-side parser splits on the first dot @@ -431,6 +436,10 @@ message AcknowledgeAlarmByNameCommand { string operator_node = 6; string operator_domain = 7; string operator_full_name = 8; + // 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; } message UnsubscribeBulkCommand { @@ -725,11 +734,11 @@ message DrainEventsReply { // Reply payload for AcknowledgeAlarmCommand AND // AcknowledgeAlarmByNameCommand — both ack command kinds reuse this // payload case (`MxCommandReply.acknowledge_alarm`); there is no -// dedicated by-name reply case. Surfaces AVEVA's native ack return -// code (AlarmAckByGUID for the GUID arm, AlarmAckByName for the -// by-name arm); 0 means success. The MxCommandReply's hresult field +// dedicated by-name reply case. Surfaces the ack outcome: 0 when the +// AckMsg write was applied or sent but unconfirmed; otherwise the +// AuthenticateUser HRESULT or the OnWriteComplete status detail. The MxCommandReply's hresult field // carries the same value and is preferred for protocol consumers — -// this payload exists so the gateway-side WorkerAlarmRpcDispatcher +// this payload exists so GatewayAlarmMonitor // can echo native_status into AcknowledgeAlarmReply.hresult without // unpacking the outer envelope. message AcknowledgeAlarmReplyPayload { @@ -987,6 +996,15 @@ message AcknowledgeAlarmRequest { // Operator principal performing the acknowledgement. The lmxopcua side // resolves this from the OPC UA session prior to invoking the RPC. string 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 "\". + string operator_domain = 7; } message AcknowledgeAlarmReply { @@ -997,8 +1015,7 @@ message AcknowledgeAlarmReply { ProtocolStatus protocol_status = 3; // Native ack return code echoed from the worker. The worker carries the // ack outcome as a single int32 (AcknowledgeAlarmReplyPayload.native_status, - // = AlarmAckByName / AlarmAckByGUID return code; 0 = success); the gateway's - // WorkerAlarmRpcDispatcher copies that value here. This is the authoritative + // 0 = applied or sent; otherwise the AuthenticateUser HRESULT or OnWriteComplete detail); GatewayAlarmMonitor copies that value here. This is the authoritative // ack-outcome field for the public RPC. Absent only when the worker reply // omitted the value entirely (a protocol violation). optional int32 hresult = 4; diff --git a/src/ZB.MOM.WW.MxGateway.Contracts/WorkerLaunchEnvironment.cs b/src/ZB.MOM.WW.MxGateway.Contracts/WorkerLaunchEnvironment.cs index 47a84e1..0ea7988 100644 --- a/src/ZB.MOM.WW.MxGateway.Contracts/WorkerLaunchEnvironment.cs +++ b/src/ZB.MOM.WW.MxGateway.Contracts/WorkerLaunchEnvironment.cs @@ -22,4 +22,10 @@ public static class WorkerLaunchEnvironment /// Maximum alarm records requested in one current-alarm fetch. public const string MaxAlarmsPerFetch = "MXGATEWAY_ALARM_MAX_ALARMS_PER_FETCH"; + + /// + /// MXAccess client name the worker's alarm-ack writer registers with. The + /// Historian records it as the acknowledgement's OperatorNode. + /// + public const string AlarmAckClientName = "MXGATEWAY_ALARM_ACK_CLIENT_NAME"; } diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Contracts/ProtobufContractRoundTripTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Contracts/ProtobufContractRoundTripTests.cs index 238ca35..f774476 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Contracts/ProtobufContractRoundTripTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Contracts/ProtobufContractRoundTripTests.cs @@ -310,11 +310,47 @@ public sealed class ProtobufContractRoundTripTests AlarmFullReference = "Tank01.Level.HiHi", Comment = "shift handover", OperatorUser = "operator2", + OperatorPassword = "s3cret", + OperatorDomain = "CORP", }; var parsed = AcknowledgeAlarmRequest.Parser.ParseFrom(original.ToByteArray()); Assert.Equal(original, parsed); + Assert.Equal("s3cret", parsed.OperatorPassword); + Assert.Equal("CORP", parsed.OperatorDomain); + } + + /// Verifies that the worker ack commands round-trip the operator password. + [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); } /// Verifies that AcknowledgeAlarmReply round-trips with status, hresult, and diagnostics. From 64dc1215fd324005f4464abc57aaa37d792a2a0b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 19 Oct 2026 19:18:24 -0400 Subject: [PATCH 03/25] feat(worker): attribute alarm ack transitions from wnwrap ACK fields Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT --- .../MxAccess/AlarmDispatcherTests.cs | 32 +++++++++++++++++++ .../MxAccess/WnWrapAlarmConsumerXmlTests.cs | 23 +++++++++++++ .../MxAccess/AlarmDispatcher.cs | 16 +++++++--- .../MxAccess/MxAlarmSnapshot.cs | 12 +++++++ .../MxAccess/WnWrapAlarmConsumer.cs | 12 +++++++ 5 files changed, 91 insertions(+), 4 deletions(-) diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs index 7c85796..677dc37 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs @@ -17,6 +17,38 @@ public sealed class AlarmDispatcherTests { private const string SessionId = "session-001"; + /// Verifies an Acknowledge transition is attributed to the ACK_* operator and comment. + [Fact] + public void OnTransition_WhenAcknowledged_UsesAckOperatorAndComment() + { + FakeAlarmConsumer consumer = new FakeAlarmConsumer(); + MxAccessEventQueue queue = new MxAccessEventQueue(); + MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper()); + using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId); + + 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); + } + /// Verifies that alarm transitions land in the queue with correctly mapped fields. [Fact] public void OnTransition_WhenAlarmTransitionRaised_LandsInQueueWithMappedFields() diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/WnWrapAlarmConsumerXmlTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/WnWrapAlarmConsumerXmlTests.cs index afef255..3941a21 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/WnWrapAlarmConsumerXmlTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/WnWrapAlarmConsumerXmlTests.cs @@ -801,4 +801,27 @@ public sealed class WnWrapAlarmConsumerXmlTests ProviderName = "Galaxy", }; } + + [Fact] + public void ParseSnapshotXml_WithAckElements_PopulatesAckFields() + { + const string xml = + "2D8A9BE88FE04B0F8D996BB4D56620C7" + + "2026/10/192400" + + "DESKTOP-6JL3KKOGalaxyTestArea" + + "TestMachine_001.TestAlarm001DSCtruetrue" + + "500ACK_ALM" + + "ArchestrAmxa" + + "AdministratorTest alarm #1" + + "mgack-authwrite 180714"; + + Dictionary records = WnWrapAlarmConsumer.ParseSnapshotXml(xml); + + MxAlarmSnapshotRecord record = Assert.Single(records.Values); + Assert.Equal("Administrator", record.AckOperatorName); + Assert.Equal("ArchestrA", record.AckOperatorDomain); + Assert.Equal("mxa", record.AckOperatorNode); + Assert.Equal("mgack-authwrite 180714", record.AckComment); + Assert.Equal("Test alarm #1", record.AlarmComment); + } } diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs index 388266e..722db2e 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs @@ -244,6 +244,7 @@ public sealed class AlarmDispatcher : IDisposable string fullReference = AlarmRecordTransitionMapper.ComposeFullReference( record.ProviderName, record.Group, record.TagName); + bool useAck = kind == AlarmTransitionKind.Acknowledge && HasAckAttribution(record); sink.EnqueueTransition( alarmFullReference: fullReference, @@ -253,15 +254,22 @@ public sealed class AlarmDispatcher : IDisposable severity: record.Priority, originalRaiseTimestampUtc: null, transitionTimestampUtc: record.TransitionTimestampUtc, - operatorUser: record.OperatorName, - operatorComment: record.AlarmComment, + operatorUser: useAck ? record.AckOperatorName : record.OperatorName, + operatorComment: useAck ? record.AckComment : record.AlarmComment, category: record.Group, description: string.Empty, degraded: record.Degraded); } + 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; + private static ActiveAlarmSnapshot MapToSnapshot(MxAlarmSnapshotRecord record, bool truncated) { + bool useAck = IsAcked(record.State) && HasAckAttribution(record); ActiveAlarmSnapshot snapshot = new ActiveAlarmSnapshot { AlarmFullReference = AlarmRecordTransitionMapper.ComposeFullReference( @@ -270,8 +278,8 @@ public sealed class AlarmDispatcher : IDisposable AlarmTypeName = record.Type, CurrentState = MapConditionState(record.State), Severity = record.Priority, - OperatorUser = record.OperatorName, - OperatorComment = record.AlarmComment, + OperatorUser = useAck ? record.AckOperatorName : record.OperatorName, + OperatorComment = useAck ? record.AckComment : record.AlarmComment, Category = record.Group, Description = string.Empty, Degraded = record.Degraded, diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAlarmSnapshot.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAlarmSnapshot.cs index b8556d2..c431728 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAlarmSnapshot.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAlarmSnapshot.cs @@ -39,6 +39,18 @@ public sealed class MxAlarmSnapshotRecord public string OperatorName { get; set; } = string.Empty; /// Gets or sets the alarm comment. public string AlarmComment { get; set; } = string.Empty; + + /// Gets or sets the acknowledgement comment (wnwrap ACK_COMMENT). + public string AckComment { get; set; } = string.Empty; + + /// Gets or sets the acknowledging operator's name (wnwrap ACK_OPERATOR_NAME). + public string AckOperatorName { get; set; } = string.Empty; + + /// Gets or sets the acknowledging operator's domain (wnwrap ACK_DOMAIN_NAME). + public string AckOperatorDomain { get; set; } = string.Empty; + + /// Gets or sets the acknowledging node, i.e. the MXAccess client name (wnwrap ACK_OPERATOR_NODE). + public string AckOperatorNode { get; set; } = string.Empty; /// /// Gets or sets a value indicating whether this record was synthesized /// by the subtag-provider fallback rather than emitted by the native diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs index 5f3a7f4..f109dc3 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs @@ -792,6 +792,10 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer string? operatorNode = null; string? operatorName = null; string? alarmComment = null; + string? ackComment = null; + string? ackOperatorName = null; + string? ackOperatorDomain = null; + string? ackOperatorNode = null; foreach (XmlNode child in alarmNode.ChildNodes) { @@ -816,6 +820,10 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer case "OPERATOR_NODE": operatorNode ??= child.InnerText; break; case "OPERATOR_NAME": operatorName ??= child.InnerText; break; case "ALARM_COMMENT": alarmComment ??= child.InnerText; break; + 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; } } @@ -843,6 +851,10 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer OperatorNode = operatorNode ?? string.Empty, OperatorName = operatorName ?? string.Empty, AlarmComment = alarmComment ?? string.Empty, + AckComment = ackComment ?? string.Empty, + AckOperatorName = ackOperatorName ?? string.Empty, + AckOperatorDomain = ackOperatorDomain ?? string.Empty, + AckOperatorNode = ackOperatorNode ?? string.Empty, }; } return records; From 76db822df358641e9d7a0bb21fac1df704bbe49d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 19 Oct 2026 19:21:55 -0400 Subject: [PATCH 04/25] feat(worker): acknowledge alarms via authenticated AckMsg write Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT --- .../MxAccess/AlarmAckWriterTests.cs | 184 ++++++++++++++ .../MxAccess/AlarmAckOperator.cs | 24 ++ .../MxAccess/AlarmAckOutcome.cs | 18 ++ .../MxAccess/AlarmAckResult.cs | 49 ++++ .../MxAccess/AlarmAckTarget.cs | 42 ++++ .../MxAccess/AlarmAckWriter.cs | 232 ++++++++++++++++++ .../MxAccess/IAlarmAckWriter.cs | 17 ++ 7 files changed, 566 insertions(+) create mode 100644 src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmAckWriterTests.cs create mode 100644 src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckOperator.cs create mode 100644 src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckOutcome.cs create mode 100644 src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckResult.cs create mode 100644 src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckTarget.cs create mode 100644 src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckWriter.cs create mode 100644 src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IAlarmAckWriter.cs diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmAckWriterTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmAckWriterTests.cs new file mode 100644 index 0000000..ce400c4 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmAckWriterTests.cs @@ -0,0 +1,184 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using Google.Protobuf.Collections; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Worker.MxAccess; + +namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess; + +public sealed class AlarmAckWriterTests +{ + private static AlarmAckOperator Alice() => + new AlarmAckOperator { UserName = "alice", Domain = "CORP", Password = "pw-secret" }; + + private static RepeatedField Rows(MxStatusCategory category, int detail) => + new RepeatedField { new MxStatusProxy { Success = category == MxStatusCategory.Ok ? 1 : 0, Category = category, Detail = detail } }; + + [Fact] + public void Acknowledge_AuthenticatesThenWritesCommentWithOperatorUserId() + { + RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 }; + MxAccessWriteCompletionCache cache = new MxAccessWriteCompletionCache(); + server.OnWrite = (handle, item) => cache.Record(handle, item, Rows(MxStatusCategory.Ok, 0)); + using AlarmAckWriter writer = new AlarmAckWriter(server, cache, () => { }, TimeSpan.FromSeconds(1), "MxGateway"); + + AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "checked", Alice()); + + Assert.Equal(AlarmAckOutcome.Applied, result.Outcome); + Assert.Equal(0, result.NativeStatus); + Assert.Equal(new[] { "Register:MxGateway", "AuthenticateUser:CORP\\alice", "AddItem:T.A.AckMsg", "Advise", "Write:checked:5" }, server.Operations); + Assert.Equal("pw-secret", server.LastPassword); + } + + [Fact] + public void Acknowledge_WhenWriteRejected_ReturnsDetailAndStatuses() + { + RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 }; + MxAccessWriteCompletionCache cache = new MxAccessWriteCompletionCache(); + server.OnWrite = (handle, item) => cache.Record(handle, item, Rows(MxStatusCategory.SecurityError, 1008)); + using AlarmAckWriter writer = new AlarmAckWriter(server, cache, () => { }, TimeSpan.FromSeconds(1), "MxGateway"); + + AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "checked", Alice()); + + Assert.Equal(AlarmAckOutcome.WriteRejected, result.Outcome); + Assert.Equal(1008, result.NativeStatus); + Assert.Single(result.Statuses); + } + + [Fact] + public void Acknowledge_WhenNoCompletion_ReturnsUnconfirmed() + { + RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 }; + using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(50), "MxGateway"); + + AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "checked", Alice()); + + Assert.Equal(AlarmAckOutcome.Unconfirmed, result.Outcome); + Assert.Equal(0, result.NativeStatus); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Acknowledge_WhenAuthenticationFails_DoesNotWriteOrLeakPassword(bool throwComException) + { + RecordingAckServer server = new RecordingAckServer + { + AuthenticateResult = 0, + AuthenticateException = throwComException ? new COMException("bad credentials", unchecked((int)0x80070005)) : null, + }; + using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(50), "MxGateway"); + + AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "checked", Alice()); + + Assert.Equal(AlarmAckOutcome.AuthenticationFailed, result.Outcome); + Assert.NotEqual(0, result.NativeStatus); + Assert.DoesNotContain(server.Operations, op => op.StartsWith("Write", StringComparison.Ordinal)); + Assert.DoesNotContain("pw-secret", result.Diagnostic, StringComparison.Ordinal); + Assert.DoesNotContain(server.Operations, op => op.Contains("pw-secret")); + } + + [Fact] + public void Acknowledge_Twice_ReusesItemHandle() + { + RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 }; + using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(10), "MxGateway"); + + writer.Acknowledge("T.A.AckMsg", "one", Alice()); + writer.Acknowledge("T.A.AckMsg", "two", Alice()); + + Assert.Equal(1, server.Operations.Count(op => op.StartsWith("AddItem", StringComparison.Ordinal))); + Assert.Equal(1, server.Operations.Count(op => op == "Advise")); + } + + [Fact] + public void AlarmAckOperator_ToString_OmitsPassword() + { + Assert.DoesNotContain("pw-secret", Alice().ToString(), StringComparison.Ordinal); + } +} + +internal sealed class RecordingAckServer : IMxAccessServer +{ + private int nextItemHandle = 100; + + public List Operations { get; } = new(); + + public int AuthenticateResult { get; set; } + + public Exception? AuthenticateException { get; set; } + + public string? LastPassword { get; private set; } + + public Action? OnWrite { get; set; } + + public int Register(string clientName) + { + Operations.Add("Register:" + clientName); + return 7; + } + + public void Unregister(int serverHandle) => Operations.Add("Unregister"); + + public int AddItem(int serverHandle, string itemDefinition) + { + Operations.Add("AddItem:" + itemDefinition); + return nextItemHandle++; + } + + public int AddItem2(int serverHandle, string itemDefinition, string itemContext) => AddItem(serverHandle, itemDefinition); + + public void RemoveItem(int serverHandle, int itemHandle) => Operations.Add("RemoveItem"); + + public void Advise(int serverHandle, int itemHandle) => Operations.Add("Advise"); + + public void UnAdvise(int serverHandle, int itemHandle) => Operations.Add("UnAdvise"); + + public void AdviseSupervisory(int serverHandle, int itemHandle) + { + } + + public void Write(int serverHandle, int itemHandle, object? value, int userId) + { + Operations.Add("Write:" + value + ":" + userId); + OnWrite?.Invoke(serverHandle, itemHandle); + } + + public void Write2(int serverHandle, int itemHandle, object? value, object? timestamp, int userId) + { + } + + public void WriteSecured(int serverHandle, int itemHandle, int currentUserId, int verifierUserId, object? value) + { + } + + public void WriteSecured2(int serverHandle, int itemHandle, int currentUserId, int verifierUserId, object? value, object? timestamp) + { + } + + public object Suspend(int serverHandle, int itemHandle) => new object(); + + public object Activate(int serverHandle, int itemHandle) => new object(); + + public int AuthenticateUser(int serverHandle, string verifyUser, string verifyUserPassword) + { + Operations.Add("AuthenticateUser:" + verifyUser); + LastPassword = verifyUserPassword; + if (AuthenticateException is not null) + { + throw AuthenticateException; + } + + return AuthenticateResult; + } + + public int ArchestrAUserToId(int serverHandle, string userIdGuid) => 0; + + public int AddBufferedItem(int serverHandle, string itemDefinition, string itemContext) => AddItem(serverHandle, itemDefinition); + + public void SetBufferedUpdateInterval(int serverHandle, int updateIntervalMilliseconds) + { + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckOperator.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckOperator.cs new file mode 100644 index 0000000..5caee25 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckOperator.cs @@ -0,0 +1,24 @@ +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; + +/// +/// Operator identity an alarm acknowledgement authenticates as. The password +/// is a raw credential: it is passed to MXAccess AuthenticateUser only +/// and must never be logged or echoed. +/// +public sealed class AlarmAckOperator +{ + /// Gets or sets the operator user name. + public string UserName { get; set; } = string.Empty; + + /// Gets or sets the optional domain for OS-authenticated galaxies. + public string Domain { get; set; } = string.Empty; + + /// Gets or sets the operator password (credential-sensitive). + public string Password { get; set; } = string.Empty; + + /// Gets the user string passed to AuthenticateUser: Domain\UserName when a domain is set. + public string VerifyUser => string.IsNullOrEmpty(Domain) ? UserName : Domain + "\\" + UserName; + + /// + public override string ToString() => "AlarmAckOperator(" + VerifyUser + ")"; +} diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckOutcome.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckOutcome.cs new file mode 100644 index 0000000..9f4309a --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckOutcome.cs @@ -0,0 +1,18 @@ +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; + +/// Outcome of an alarm acknowledgement attempt. +public enum AlarmAckOutcome +{ + /// The AckMsg write completed with an OK status. + Applied, + /// The write was sent but no OnWriteComplete arrived before the timeout. + Unconfirmed, + /// The alarm is not in the consumer's current view. + AlarmNotFound, + /// The alarm exists but has no MXAccess ack attribute (non-Galaxy provider or no ack subtag). + NotAckable, + /// AuthenticateUser rejected the operator credentials. + AuthenticationFailed, + /// The AckMsg write completed with a non-OK status. + WriteRejected, +} diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckResult.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckResult.cs new file mode 100644 index 0000000..8f27c82 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckResult.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; +using ZB.MOM.WW.MxGateway.Contracts.Proto; + +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; + +/// Result of an alarm acknowledgement. +public sealed class AlarmAckResult +{ + private static readonly IReadOnlyList NoStatuses = new MxStatusProxy[0]; + + private AlarmAckResult(AlarmAckOutcome outcome, int nativeStatus, string diagnostic, IReadOnlyList? statuses) + { + Outcome = outcome; + NativeStatus = nativeStatus; + Diagnostic = diagnostic; + Statuses = statuses ?? NoStatuses; + } + + /// Gets the outcome. + public AlarmAckOutcome Outcome { get; } + + /// Gets the native status: 0 for applied/unconfirmed, else the HRESULT or status detail. + public int NativeStatus { get; } + + /// Gets a diagnostic message. Never contains credentials. + public string Diagnostic { get; } + + /// Gets the OnWriteComplete statuses, empty when none was observed. + public IReadOnlyList Statuses { get; } + + /// Creates a result. + /// The outcome. + /// The native status. + /// A credential-free diagnostic. + /// Observed write statuses. + /// The result. + public static AlarmAckResult Create( + AlarmAckOutcome outcome, + int nativeStatus, + string diagnostic, + IReadOnlyList? statuses = null) => + new AlarmAckResult(outcome, nativeStatus, diagnostic ?? string.Empty, statuses); + + /// Creates the result for an unresolved ack target. + /// The unresolved target. + /// The result. + public static AlarmAckResult FromTarget(AlarmAckTarget target) => + new AlarmAckResult(target.Failure, -1, target.Reason, null); +} diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckTarget.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckTarget.cs new file mode 100644 index 0000000..89d28f5 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckTarget.cs @@ -0,0 +1,42 @@ +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; + +/// The MXAccess item an acknowledgement writes, or why none exists. +public sealed class AlarmAckTarget +{ + private AlarmAckTarget(string itemAddress, AlarmAckOutcome failure, string reason) + { + ItemAddress = itemAddress; + Failure = failure; + Reason = reason; + } + + /// Gets a value indicating whether an ack item was resolved. + public bool Found => ItemAddress.Length > 0; + + /// Gets the ack item address (e.g. TestMachine_001.TestAlarm001.AckMsg), empty when not found. + public string ItemAddress { get; } + + /// Gets the failure outcome when is false. + public AlarmAckOutcome Failure { get; } + + /// Gets a human-readable reason when is false. + public string Reason { get; } + + /// Creates a resolved target. + /// The ack item address. + /// The target. + public static AlarmAckTarget For(string itemAddress) => + new AlarmAckTarget(itemAddress ?? string.Empty, AlarmAckOutcome.Applied, string.Empty); + + /// Creates a not-found result. + /// Why the alarm was not found. + /// The target. + public static AlarmAckTarget NotFound(string reason) => + new AlarmAckTarget(string.Empty, AlarmAckOutcome.AlarmNotFound, reason); + + /// Creates a not-ackable result. + /// Why the alarm cannot be acknowledged over MXAccess. + /// The target. + public static AlarmAckTarget NotAckable(string reason) => + new AlarmAckTarget(string.Empty, AlarmAckOutcome.NotAckable, reason); +} diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckWriter.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckWriter.cs new file mode 100644 index 0000000..87a20b5 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckWriter.cs @@ -0,0 +1,232 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using ArchestrA.MxAccess; +using Google.Protobuf.Collections; +using ZB.MOM.WW.MxGateway.Contracts; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Worker.Conversion; + +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; + +/// +/// Acknowledges alarms the way the AVEVA runtime records operator identity: +/// AuthenticateUser as the operator, then write the comment to the alarm's +/// AckMsg attribute with that user id. Owns its own +/// LMXProxyServerClass registered under the configured client name, +/// which the Historian records as the acknowledgement's OperatorNode. +/// Apartment-threaded: every call must run on the worker STA. The password +/// is passed to MXAccess only; it never reaches diagnostics. +/// +public sealed class AlarmAckWriter : IAlarmAckWriter +{ + /// The ArchestrA alarm-primitive attribute whose write acknowledges the alarm. + public const string AckCommentAttribute = "AckMsg"; + + /// Default MXAccess client name when none is configured. + public const string DefaultClientName = "MxGateway"; + + private readonly IMxAccessComObjectFactory? factory; + private readonly MxAccessWriteCompletionCache completionCache; + private readonly Action pumpStep; + private readonly TimeSpan completionTimeout; + private readonly string clientName; + private readonly MxStatusProxyConverter statusConverter = new MxStatusProxyConverter(); + private readonly Dictionary itemHandles = new Dictionary(StringComparer.OrdinalIgnoreCase); + private object? comObject; + private LMXProxyServerClass? comEventSource; + private IMxAccessServer? server; + private int serverHandle; + private bool disposed; + + /// Creates a writer that registers its own MXAccess server on first use. + /// Creates the LMXProxyServerClass. + /// Drains the STA message queue (OnWriteComplete is delivered by pumping). + /// Bounded wait for OnWriteComplete. + /// MXAccess client name (Historian OperatorNode). + public AlarmAckWriter( + IMxAccessComObjectFactory factory, + Action pumpStep, + TimeSpan completionTimeout, + string? clientName) + { + this.factory = factory ?? throw new ArgumentNullException(nameof(factory)); + this.pumpStep = pumpStep ?? throw new ArgumentNullException(nameof(pumpStep)); + this.completionTimeout = completionTimeout; + this.clientName = string.IsNullOrWhiteSpace(clientName) ? DefaultClientName : clientName!; + completionCache = new MxAccessWriteCompletionCache(); + } + + /// Test seam: uses an already-constructed server and completion cache. + internal AlarmAckWriter( + IMxAccessServer server, + MxAccessWriteCompletionCache completionCache, + Action pumpStep, + TimeSpan completionTimeout, + string clientName) + { + this.server = server ?? throw new ArgumentNullException(nameof(server)); + this.completionCache = completionCache ?? throw new ArgumentNullException(nameof(completionCache)); + this.pumpStep = pumpStep ?? throw new ArgumentNullException(nameof(pumpStep)); + this.completionTimeout = completionTimeout; + this.clientName = clientName; + serverHandle = server.Register(clientName); + } + + /// Resolves the configured client name from the launcher environment. + /// The client name, or . + public static string ResolveClientName() + { + string? value = Environment.GetEnvironmentVariable(WorkerLaunchEnvironment.AlarmAckClientName); + return string.IsNullOrWhiteSpace(value) ? DefaultClientName : value!.Trim(); + } + + /// + public AlarmAckResult Acknowledge(string itemAddress, string comment, AlarmAckOperator ackOperator) + { + if (disposed) throw new ObjectDisposedException(nameof(AlarmAckWriter)); + if (string.IsNullOrWhiteSpace(itemAddress)) throw new ArgumentException("Ack item address is required.", nameof(itemAddress)); + if (ackOperator is null) throw new ArgumentNullException(nameof(ackOperator)); + + IMxAccessServer mx = EnsureRegistered(); + + int userId; + try + { + userId = mx.AuthenticateUser(serverHandle, ackOperator.VerifyUser, ackOperator.Password ?? string.Empty); + } + catch (COMException ex) + { + return AlarmAckResult.Create( + AlarmAckOutcome.AuthenticationFailed, + ex.HResult, + $"Operator authentication failed for '{ackOperator.VerifyUser}' (HRESULT 0x{ex.HResult:X8})."); + } + + if (userId == 0) + { + return AlarmAckResult.Create( + AlarmAckOutcome.AuthenticationFailed, + -1, + $"Operator authentication failed for '{ackOperator.VerifyUser}' (no user id returned)."); + } + + if (!itemHandles.TryGetValue(itemAddress, out int itemHandle)) + { + itemHandle = mx.AddItem(serverHandle, itemAddress); + // MXAccess rejects writes to added-but-unadvised items with E_INVALIDARG. + mx.Advise(serverHandle, itemHandle); + itemHandles[itemAddress] = itemHandle; + } + + pumpStep(); + ulong baseline = completionCache.CurrentVersion(serverHandle, itemHandle); + mx.Write(serverHandle, itemHandle, comment ?? string.Empty, userId); + + if (!completionCache.TryWaitForCompletion( + serverHandle, + itemHandle, + baseline, + DateTime.UtcNow + completionTimeout, + pumpStep, + out RepeatedField statuses)) + { + return AlarmAckResult.Create( + AlarmAckOutcome.Unconfirmed, + 0, + $"AckMsg write to '{itemAddress}' sent; OnWriteComplete not observed within {completionTimeout.TotalMilliseconds:F0} ms."); + } + + foreach (MxStatusProxy status in statuses) + { + if (status.Category != MxStatusCategory.Ok) + { + return AlarmAckResult.Create( + AlarmAckOutcome.WriteRejected, + status.Detail != 0 ? status.Detail : -1, + $"AckMsg write to '{itemAddress}' rejected: category {status.Category}, detail {status.Detail}.", + statuses); + } + } + + return AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, string.Empty, statuses); + } + + /// + public void Dispose() + { + if (disposed) return; + disposed = true; + + if (comEventSource is not null) + { + try { comEventSource.OnWriteComplete -= OnWriteComplete; } catch { /* best effort */ } + } + + IMxAccessServer? mx = server; + if (mx is not null) + { + foreach (int itemHandle in itemHandles.Values) + { + try { mx.UnAdvise(serverHandle, itemHandle); } catch { /* best effort */ } + try { mx.RemoveItem(serverHandle, itemHandle); } catch { /* best effort */ } + } + + try { mx.Unregister(serverHandle); } catch { /* best effort */ } + } + + itemHandles.Clear(); + server = null; + comEventSource = null; + object? toRelease = comObject; + comObject = null; + if (toRelease is not null && Marshal.IsComObject(toRelease)) + { + try { Marshal.FinalReleaseComObject(toRelease); } catch { /* best effort */ } + } + } + + private IMxAccessServer EnsureRegistered() + { + if (server is not null) return server; + + object created = factory!.Create() ?? throw new InvalidOperationException("MXAccess COM factory returned null."); + try + { + comObject = created; + if (created is LMXProxyServerClass comServer) + { + comEventSource = comServer; + comServer.OnWriteComplete += OnWriteComplete; + } + + IMxAccessServer mx = new MxAccessComServer(created); + serverHandle = mx.Register(clientName); + server = mx; + return mx; + } + catch + { + if (comEventSource is not null) + { + try { comEventSource.OnWriteComplete -= OnWriteComplete; } catch { /* best effort */ } + comEventSource = null; + } + + if (Marshal.IsComObject(created)) + { + try { Marshal.FinalReleaseComObject(created); } catch { /* best effort */ } + } + + comObject = null; + throw; + } + } + + private void OnWriteComplete(int hLMXServerHandle, int phItemHandle, ref MXSTATUS_PROXY[] pVars) + { + RepeatedField rows = new RepeatedField(); + rows.AddRange(statusConverter.ConvertMany(pVars)); + completionCache.Record(hLMXServerHandle, phItemHandle, rows); + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IAlarmAckWriter.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IAlarmAckWriter.cs new file mode 100644 index 0000000..b5877be --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IAlarmAckWriter.cs @@ -0,0 +1,17 @@ +using System; + +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; + +/// +/// Acknowledges an alarm by writing its MXAccess ack item (AckMsg) +/// after authenticating as the operator. Must be called on the worker STA. +/// +public interface IAlarmAckWriter : IDisposable +{ + /// Authenticates and writes to . + /// The ack item, e.g. Obj.Alarm.AckMsg. + /// The acknowledgement comment. + /// The operator to authenticate as. + /// The outcome. + AlarmAckResult Acknowledge(string itemAddress, string comment, AlarmAckOperator ackOperator); +} From 7900df3dce924fc6e16e3fa03a2dafbce2c101ba Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 19 Oct 2026 19:22:27 -0400 Subject: [PATCH 05/25] fix(worker): disambiguate MxStatusCategory in AlarmAckWriter Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT --- src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckWriter.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckWriter.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckWriter.cs index 87a20b5..6f61402 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckWriter.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckWriter.cs @@ -139,7 +139,7 @@ public sealed class AlarmAckWriter : IAlarmAckWriter foreach (MxStatusProxy status in statuses) { - if (status.Category != MxStatusCategory.Ok) + if (status.Category != Contracts.Proto.MxStatusCategory.Ok) { return AlarmAckResult.Create( AlarmAckOutcome.WriteRejected, From 819955373440cc948d0de62232ecb1e32b037fea Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 19 Oct 2026 19:25:43 -0400 Subject: [PATCH 06/25] fix(worker): handle AckMsg add/advise/write COM failures and server death Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT --- .../MxAccess/AlarmAckWriterTests.cs | 80 ++++++++++++++++++- .../MxAccess/AlarmAckWriter.cs | 65 ++++++++++++--- 2 files changed, 135 insertions(+), 10 deletions(-) diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmAckWriterTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmAckWriterTests.cs index ce400c4..fc1630b 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmAckWriterTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmAckWriterTests.cs @@ -93,6 +93,68 @@ public sealed class AlarmAckWriterTests Assert.Equal(1, server.Operations.Count(op => op == "Advise")); } + [Fact] + public void Acknowledge_WhenAdviseThrows_RejectsCleansUpAndReAddsNextTime() + { + RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5, AdviseException = new COMException("x", unchecked((int)0x80004005)) }; + using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(10), "MxGateway"); + + AlarmAckResult first = writer.Acknowledge("T.A.AckMsg", "one", Alice()); + server.AdviseException = null; + writer.Acknowledge("T.A.AckMsg", "two", Alice()); + + Assert.Equal(AlarmAckOutcome.WriteRejected, first.Outcome); + Assert.Equal(unchecked((int)0x80004005), first.NativeStatus); + Assert.DoesNotContain("pw-secret", first.Diagnostic, StringComparison.Ordinal); + Assert.Contains("RemoveItem", server.Operations); + Assert.Equal(2, server.Operations.Count(op => op.StartsWith("AddItem", StringComparison.Ordinal))); + } + + [Fact] + public void Acknowledge_WhenCachedWriteThrows_RejectsAndReAddsNextTime() + { + 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()); + server.WriteException = new COMException("x", unchecked((int)0x80004005)); + + AlarmAckResult failed = writer.Acknowledge("T.A.AckMsg", "two", Alice()); + server.WriteException = null; + writer.Acknowledge("T.A.AckMsg", "three", Alice()); + + Assert.Equal(AlarmAckOutcome.WriteRejected, failed.Outcome); + Assert.Equal(2, server.Operations.Count(op => op.StartsWith("AddItem", StringComparison.Ordinal))); + } + + [Fact] + public void Acknowledge_WhenWriteDisconnected_TearsDownServer() + { + // The test seam has no COM factory, so re-registration itself is not + // exercised here; this verifies the teardown that makes it possible. + RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5, WriteException = new COMException("x", unchecked((int)0x80010108)) }; + using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(10), "MxGateway"); + + AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "one", Alice()); + + Assert.Equal(AlarmAckOutcome.WriteRejected, result.Outcome); + Assert.Equal(unchecked((int)0x80010108), result.NativeStatus); + Assert.Contains("Unregister", server.Operations); + } + + [Fact] + public void Acknowledge_WhenNonOkCompletionHasNoDetail_ReturnsMinusOne() + { + RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 }; + MxAccessWriteCompletionCache cache = new MxAccessWriteCompletionCache(); + server.OnWrite = (handle, item) => cache.Record(handle, item, Rows(MxStatusCategory.SecurityError, 0)); + 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(-1, result.NativeStatus); + } + [Fact] public void AlarmAckOperator_ToString_OmitsPassword() { @@ -110,6 +172,10 @@ internal sealed class RecordingAckServer : IMxAccessServer public Exception? AuthenticateException { get; set; } + public Exception? AdviseException { get; set; } + + public Exception? WriteException { get; set; } + public string? LastPassword { get; private set; } public Action? OnWrite { get; set; } @@ -132,7 +198,14 @@ internal sealed class RecordingAckServer : IMxAccessServer public void RemoveItem(int serverHandle, int itemHandle) => Operations.Add("RemoveItem"); - public void Advise(int serverHandle, int itemHandle) => Operations.Add("Advise"); + public void Advise(int serverHandle, int itemHandle) + { + Operations.Add("Advise"); + if (AdviseException is not null) + { + throw AdviseException; + } + } public void UnAdvise(int serverHandle, int itemHandle) => Operations.Add("UnAdvise"); @@ -143,6 +216,11 @@ internal sealed class RecordingAckServer : IMxAccessServer public void Write(int serverHandle, int itemHandle, object? value, int userId) { Operations.Add("Write:" + value + ":" + userId); + if (WriteException is not null) + { + throw WriteException; + } + OnWrite?.Invoke(serverHandle, itemHandle); } diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckWriter.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckWriter.cs index 6f61402..ce9bb39 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckWriter.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckWriter.cs @@ -111,17 +111,48 @@ public sealed class AlarmAckWriter : IAlarmAckWriter $"Operator authentication failed for '{ackOperator.VerifyUser}' (no user id returned)."); } - if (!itemHandles.TryGetValue(itemAddress, out int itemHandle)) + ulong baselineVersion = 0; + int itemHandle = 0; + bool cached = itemHandles.TryGetValue(itemAddress, out itemHandle); + string step = "AddItem"; + try { - itemHandle = mx.AddItem(serverHandle, itemAddress); - // MXAccess rejects writes to added-but-unadvised items with E_INVALIDARG. - mx.Advise(serverHandle, itemHandle); - itemHandles[itemAddress] = itemHandle; + if (!cached) + { + itemHandle = mx.AddItem(serverHandle, itemAddress); + itemHandles[itemAddress] = itemHandle; + step = "Advise"; + // MXAccess rejects writes to added-but-unadvised items with E_INVALIDARG. + mx.Advise(serverHandle, itemHandle); + } + + pumpStep(); + step = "Write"; + baselineVersion = completionCache.CurrentVersion(serverHandle, itemHandle); + mx.Write(serverHandle, itemHandle, comment ?? string.Empty, userId); + } + catch (COMException ex) + { + int hresult = ex.HResult; + if (itemHandles.Remove(itemAddress)) + { + try { mx.UnAdvise(serverHandle, itemHandle); } catch { /* best effort */ } + try { mx.RemoveItem(serverHandle, itemHandle); } catch { /* best effort */ } + } + + if (IsServerGone(hresult)) + { + TearDownServer(); + } + + // Diagnostic names the step and HRESULT only: exception text can echo the item or operator. + return AlarmAckResult.Create( + AlarmAckOutcome.WriteRejected, + hresult, + $"AckMsg {step} failed (HRESULT 0x{hresult:X8})."); } - pumpStep(); - ulong baseline = completionCache.CurrentVersion(serverHandle, itemHandle); - mx.Write(serverHandle, itemHandle, comment ?? string.Empty, userId); + ulong baseline = baselineVersion; if (!completionCache.TryWaitForCompletion( serverHandle, @@ -158,6 +189,17 @@ public sealed class AlarmAckWriter : IAlarmAckWriter if (disposed) return; disposed = true; + TearDownServer(); + } + + private static bool IsServerGone(int hresult) => + hresult == unchecked((int)0x80010108) // RPC_E_DISCONNECTED + || hresult == unchecked((int)0x800706BA) // RPC_S_SERVER_UNAVAILABLE + || hresult == unchecked((int)0x80010105); // RPC_E_SERVERFAULT + + /// Releases the MXAccess registration so the next call re-registers (also used by Dispose). + private void TearDownServer() + { if (comEventSource is not null) { try { comEventSource.OnWriteComplete -= OnWriteComplete; } catch { /* best effort */ } @@ -190,7 +232,12 @@ public sealed class AlarmAckWriter : IAlarmAckWriter { if (server is not null) return server; - object created = factory!.Create() ?? throw new InvalidOperationException("MXAccess COM factory returned null."); + if (factory is null) + { + throw new InvalidOperationException("MXAccess server is gone and no COM factory is available to re-register."); + } + + object created = factory.Create() ?? throw new InvalidOperationException("MXAccess COM factory returned null."); try { comObject = created; From 0d4b2aea2d0d34d7cfc3d73c653b2d4b6254d9f6 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 19 Oct 2026 19:35:49 -0400 Subject: [PATCH 07/25] refactor(worker): alarm consumers resolve the AckMsg item instead of acking IMxAccessAlarmConsumer.ResolveAckTarget/ResolveAckTargetByName replace AcknowledgeByGuid/AcknowledgeByName. wnwrap maps Galaxy-provider alarms to .AckMsg and drops its inert parallel ack consumer; subtag resolves to the target's AckCommentSubtag and no longer advises or writes it; ISubtagAlarmSource.Write is removed. Does not build alone: AlarmDispatcher moves to the new API in the next commit. Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT --- .../MxAccess/FailoverAlarmConsumerTests.cs | 24 +-- .../MxAccess/LmxSubtagAlarmSourceTests.cs | 58 +------ .../MxAccess/SubtagAlarmConsumerTests.cs | 151 ++++++----------- .../MxAccess/WnWrapAlarmConsumerXmlTests.cs | 54 ++++++ .../Probes/AlarmSubtagLiveSmokeTests.cs | 62 ++++--- .../MxAccess/FailoverAlarmConsumer.cs | 27 +-- .../MxAccess/IMxAccessAlarmConsumer.cs | 54 ++---- .../MxAccess/ISubtagAlarmSource.cs | 5 - .../MxAccess/LmxSubtagAlarmSource.cs | 39 +---- .../MxAccess/SubtagAlarmConsumer.cs | 56 ++---- .../MxAccess/WnWrapAlarmConsumer.cs | 160 +++++------------- 11 files changed, 238 insertions(+), 452 deletions(-) diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/FailoverAlarmConsumerTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/FailoverAlarmConsumerTests.cs index f95beca..5bc4e4b 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/FailoverAlarmConsumerTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/FailoverAlarmConsumerTests.cs @@ -72,10 +72,10 @@ public sealed class FailoverAlarmConsumerTests } /// - public int AcknowledgeByGuid(Guid g, string c, string a, string b, string d, string e) => 11; + public AlarmAckTarget ResolveAckTarget(Guid g) => AlarmAckTarget.For("primary.guid.AckMsg"); /// - public int AcknowledgeByName(string n, string p, string gr, string c, string a, string b, string d, string e) => 11; + public AlarmAckTarget ResolveAckTargetByName(string n, string p, string gr) => AlarmAckTarget.For("primary.name.AckMsg"); /// public IReadOnlyList SnapshotActiveAlarms(out bool truncated) @@ -122,10 +122,10 @@ public sealed class FailoverAlarmConsumerTests public void PollOnce() { } /// - public int AcknowledgeByGuid(Guid g, string c, string a, string b, string d, string e) => 22; + public AlarmAckTarget ResolveAckTarget(Guid g) => AlarmAckTarget.For("standby.guid.AckMsg"); /// - public int AcknowledgeByName(string n, string p, string gr, string c, string a, string b, string d, string e) => 22; + public AlarmAckTarget ResolveAckTargetByName(string n, string p, string gr) => AlarmAckTarget.For("standby.name.AckMsg"); /// public IReadOnlyList SnapshotActiveAlarms(out bool truncated) @@ -305,9 +305,9 @@ public sealed class FailoverAlarmConsumerTests Assert.Equal(subscribeCountAfterSetup, primary.SubscribeCount); } - /// Proves that acknowledgment calls delegate to whichever child (primary or standby) is currently active. + /// Proves that ack-target resolution delegates to whichever child (primary or standby) is currently active. [Fact] - public void Acknowledge_DelegatesToActiveChild() + public void ResolveAckTarget_DelegatesToActiveChild() { FlakyPrimary primary = new FlakyPrimary { ThrowOnPoll = false }; StubStandby standby = new StubStandby(); @@ -316,18 +316,18 @@ public sealed class FailoverAlarmConsumerTests sut.Subscribe(@"\\HOST\Galaxy!Area"); - // Active = Primary → primary's sentinel value (11). - Assert.Equal(11, sut.AcknowledgeByGuid(Guid.NewGuid(), "c", "n", "node", "dom", "full")); - Assert.Equal(11, sut.AcknowledgeByName("a", "p", "g", "c", "n", "node", "dom", "full")); + // Active = Primary → primary's sentinel targets. + Assert.Equal("primary.guid.AckMsg", sut.ResolveAckTarget(Guid.NewGuid()).ItemAddress); + Assert.Equal("primary.name.AckMsg", sut.ResolveAckTargetByName("a", "p", "g").ItemAddress); // Force a failover by failing the primary past threshold. primary.ThrowOnPoll = true; sut.PollOnce(); // threshold=1 → switch to Standby Assert.Equal(AlarmProviderMode.Subtag, sut.Mode); - // Active = Standby → standby's sentinel value (22). - Assert.Equal(22, sut.AcknowledgeByGuid(Guid.NewGuid(), "c", "n", "node", "dom", "full")); - Assert.Equal(22, sut.AcknowledgeByName("a", "p", "g", "c", "n", "node", "dom", "full")); + // Active = Standby → standby's sentinel targets. + Assert.Equal("standby.guid.AckMsg", sut.ResolveAckTarget(Guid.NewGuid()).ItemAddress); + Assert.Equal("standby.name.AckMsg", sut.ResolveAckTargetByName("a", "p", "g").ItemAddress); } /// diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/LmxSubtagAlarmSourceTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/LmxSubtagAlarmSourceTests.cs index 2b5b7fb..ab74bd9 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/LmxSubtagAlarmSourceTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/LmxSubtagAlarmSourceTests.cs @@ -94,76 +94,28 @@ public sealed class LmxSubtagAlarmSourceTests } /// - /// Verifies adds the item when - /// it was not previously advised and writes with user id 0. + /// Verifies UnAdvises and + /// removes every advised handle and unregisters the server exactly once. /// [Fact] - public void Write_AddsItemWhenUnknownAndWrites() - { - var server = new RecordingMxAccessServer(); - using var source = new LmxSubtagAlarmSource(server, FakeServerHandle); - - source.Write("Tank1.Alarm.AckComment", "acknowledged"); - - Assert.Contains("Tank1.Alarm.AckComment", server.AddedItems); - Assert.Single(server.Writes); - RecordingMxAccessServer.WriteRecord write = server.Writes[0]; - Assert.Equal(FakeServerHandle, write.ServerHandle); - Assert.Equal("acknowledged", write.Value); - Assert.Equal(0, write.UserId); - } - - /// - /// Verifies reuses an existing - /// item handle (no duplicate AddItem) when the address was already - /// advised. - /// - [Fact] - public void Write_ReusesHandleForAdvisedAddress() - { - var server = new RecordingMxAccessServer(); - using var source = new LmxSubtagAlarmSource(server, FakeServerHandle); - - source.Advise(new[] { "Tank1.Alarm.Subtag" }); - int adviseAddCount = server.AddedItems.Count; - - source.Write("Tank1.Alarm.Subtag", true); - - // No second AddItem for the already-advised address. - Assert.Equal(adviseAddCount, server.AddedItems.Count); - Assert.Single(server.Writes); - } - - /// - /// Verifies UnAdvises only the - /// handles that were actually advised — a write-only item (added by - /// but never advised) is removed - /// but not unadvised — and unregisters the server exactly once. - /// - [Fact] - public void Dispose_UnAdvisesOnlyAdvisedHandles_RemovesAll_AndUnregistersOnce() + public void Dispose_UnAdvisesAndRemovesAdvisedHandles_AndUnregistersOnce() { var server = new RecordingMxAccessServer(); var source = new LmxSubtagAlarmSource(server, FakeServerHandle); source.Advise(new[] { "Tank1.Alarm.Subtag", "Tank2.Alarm.Subtag" }); - // A write-only subtag: added by Write, never advised. - source.Write("Tank1.Alarm.AckComment", "acknowledged"); int advised1 = server.LastItemHandleFor("Tank1.Alarm.Subtag"); int advised2 = server.LastItemHandleFor("Tank2.Alarm.Subtag"); - int writeOnly = server.LastItemHandleFor("Tank1.Alarm.AckComment"); source.Dispose(); - // Only the two advised handles are unadvised — never the write-only one. Assert.Equal(new[] { advised1, advised2 }, server.UnAdvisedItemHandles); - Assert.DoesNotContain(writeOnly, server.UnAdvisedItemHandles); - // Every added item (advised + write-only) is removed. Assert.Equal( - new[] { advised1, advised2, writeOnly }.OrderBy(h => h), + new[] { advised1, advised2 }.OrderBy(h => h), server.RemovedItemHandles.OrderBy(h => h)); Assert.Equal(1, server.UnregisterCount); + Assert.Empty(server.Writes); } /// diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SubtagAlarmConsumerTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SubtagAlarmConsumerTests.cs index 093a3b9..76b1be2 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SubtagAlarmConsumerTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SubtagAlarmConsumerTests.cs @@ -9,7 +9,7 @@ namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess; /// /// Unit tests for : prove that the subtag /// fallback advises the observable subtags, synthesizes degraded -/// transitions with stable synthetic GUIDs, routes acknowledgments to the +/// transitions with stable synthetic GUIDs, resolves acknowledgments to the /// ack-comment subtag, and stamps snapshots. A /// stands in for the live MXAccess subtag source so this needs no AVEVA /// install. @@ -40,9 +40,13 @@ public sealed class SubtagAlarmConsumerTests return new SubtagAlarmConsumer(source, new[] { BuildTarget() }); } - /// Verifies Subscribe advises the active, acked, priority, and ack-comment subtags (the ack-comment is advised so it is an active, writable MXAccess item). + /// + /// Verifies Subscribe advises the observable active, acked and priority + /// subtags but not the ack-comment subtag: the ack is written by + /// , which adds and advises it on its own server. + /// [Fact] - public void Subscribe_AdvisesAllSubtagsIncludingAckComment() + public void Subscribe_AdvisesObservableSubtagsButNotAckComment() { FakeSource source = new FakeSource(); using SubtagAlarmConsumer consumer = BuildConsumer(source); @@ -52,7 +56,7 @@ public sealed class SubtagAlarmConsumerTests Assert.Contains(ActiveSubtag, source.Advised); Assert.Contains(AckedSubtag, source.Advised); Assert.Contains(PrioritySubtag, source.Advised); - Assert.Contains(AckCommentSubtag, source.Advised); + Assert.DoesNotContain(AckCommentSubtag, source.Advised); } /// Verifies an active=true value change raises a degraded, GUID-stamped UNACK_ALM transition. @@ -74,50 +78,49 @@ public sealed class SubtagAlarmConsumerTests Assert.NotEqual(Guid.Empty, emitted.Record.AlarmGuid); } - /// Verifies AcknowledgeByName writes the comment to the ack-comment subtag and returns success. + /// Verifies ResolveAckTargetByName resolves to the target's ack-comment subtag. [Fact] - public void AcknowledgeByName_WritesCommentToAckCommentSubtag() + public void ResolveAckTargetByName_ReturnsAckCommentSubtag() { FakeSource source = new FakeSource(); using SubtagAlarmConsumer consumer = BuildConsumer(source); consumer.Subscribe(@"\\HOST\Galaxy!TestArea"); - int rc = consumer.AcknowledgeByName( - alarmName: "Tank01.Level.HiHi", - providerName: "Galaxy", - groupName: "TestArea", - ackComment: "operator ack", - ackOperatorName: "alice", - ackOperatorNode: "WS01", - ackOperatorDomain: "CORP", - ackOperatorFullName: "Alice Smith"); + AlarmAckTarget target = consumer.ResolveAckTargetByName("Tank01.Level.HiHi", "Galaxy", "TestArea"); - Assert.Equal(0, rc); - Assert.NotNull(source.LastWrite); - Assert.Equal(AckCommentSubtag, source.LastWrite!.Value.Address); - Assert.Equal("operator ack", source.LastWrite!.Value.Value); + Assert.True(target.Found); + Assert.Equal(AckCommentSubtag, target.ItemAddress); } - /// Verifies AcknowledgeByName returns non-zero when no target matches the supplied name. + /// Verifies ResolveAckTargetByName reports AlarmNotFound when no target matches the supplied name. [Fact] - public void AcknowledgeByName_UnknownAlarm_ReturnsNonZero() + public void ResolveAckTargetByName_UnknownAlarm_IsNotFound() { FakeSource source = new FakeSource(); using SubtagAlarmConsumer consumer = BuildConsumer(source); consumer.Subscribe(@"\\HOST\Galaxy!TestArea"); - int rc = consumer.AcknowledgeByName( - alarmName: "DoesNotExist.NoSuchAlarm", - providerName: "Galaxy", - groupName: "TestArea", - ackComment: "operator ack", - ackOperatorName: "alice", - ackOperatorNode: "WS01", - ackOperatorDomain: "CORP", - ackOperatorFullName: "Alice Smith"); + AlarmAckTarget target = consumer.ResolveAckTargetByName("DoesNotExist.NoSuchAlarm", "Galaxy", "TestArea"); - Assert.NotEqual(0, rc); - Assert.Null(source.LastWrite); + Assert.False(target.Found); + Assert.Equal(AlarmAckOutcome.AlarmNotFound, target.Failure); + } + + /// Verifies a target with no ack-comment subtag resolves as NotAckable, by name and by GUID. + [Fact] + public void ResolveAckTarget_EmptyAckCommentSubtag_IsNotAckable() + { + AlarmSubtagTarget noAck = BuildTarget(); + noAck.AckCommentSubtag = string.Empty; + FakeSource source = new FakeSource(); + using SubtagAlarmConsumer consumer = new SubtagAlarmConsumer(source, new[] { noAck }); + + Assert.Equal( + AlarmAckOutcome.NotAckable, + consumer.ResolveAckTargetByName("Tank01.Level.HiHi", "Galaxy", "TestArea").Failure); + Assert.Equal( + AlarmAckOutcome.NotAckable, + consumer.ResolveAckTarget(SyntheticAlarmGuid.ForReference(Reference)).Failure); } /// Verifies a snapshot of an active alarm stamps Degraded and a non-empty synthetic GUID. @@ -160,11 +163,11 @@ public sealed class SubtagAlarmConsumerTests /// /// Verifies that when two alarm targets share a prefix (e.g. Level.Hi vs Level.HiHi), - /// AcknowledgeByName routes each ack to its own ack-comment subtag and never + /// ResolveAckTargetByName resolves each to its own ack-comment subtag and never /// conflates the shorter name with the longer one. /// [Fact] - public void AcknowledgeByName_PrefixNameDoesNotFalseMatch() + public void ResolveAckTargetByName_PrefixNameDoesNotFalseMatch() { const string ReferenceHi = "Galaxy!Area.Tank01.Level.Hi"; const string ReferenceHiHi = "Galaxy!Area.Tank01.Level.HiHi"; @@ -195,42 +198,19 @@ public sealed class SubtagAlarmConsumerTests source, new[] { targetHi, targetHiHi }); consumer.Subscribe(@"\\HOST\Galaxy!Area"); - // Ack the shorter name — must write to the shorter target's subtag only. - int rcHi = consumer.AcknowledgeByName( - alarmName: "Tank01.Level.Hi", - providerName: "Galaxy", - groupName: "Area", - ackComment: "ack hi", - ackOperatorName: "op", ackOperatorNode: "WS01", - ackOperatorDomain: "CORP", ackOperatorFullName: "Operator"); - - Assert.Equal(0, rcHi); - Assert.NotNull(source.LastWrite); - Assert.Equal(AckCommentHi, source.LastWrite!.Value.Address); - Assert.Equal("ack hi", source.LastWrite.Value.Value); - - // Ack the longer name — must write to the longer target's subtag. - int rcHiHi = consumer.AcknowledgeByName( - alarmName: "Tank01.Level.HiHi", - providerName: "Galaxy", - groupName: "Area", - ackComment: "ack hihi", - ackOperatorName: "op", ackOperatorNode: "WS01", - ackOperatorDomain: "CORP", ackOperatorFullName: "Operator"); - - Assert.Equal(0, rcHiHi); - Assert.NotNull(source.LastWrite); - Assert.Equal(AckCommentHiHi, source.LastWrite!.Value.Address); - Assert.Equal("ack hihi", source.LastWrite.Value.Value); + Assert.Equal(AckCommentHi, consumer.ResolveAckTargetByName("Tank01.Level.Hi", "Galaxy", "Area").ItemAddress); + Assert.Equal(AckCommentHiHi, consumer.ResolveAckTargetByName("Tank01.Level.HiHi", "Galaxy", "Area").ItemAddress); + Assert.Equal( + AlarmAckOutcome.AlarmNotFound, + consumer.ResolveAckTargetByName("Tank01.Level.H", "Galaxy", "Area").Failure); } /// - /// Verifies AcknowledgeByGuid resolves the synthetic GUID (computed from - /// the alarm's full reference) to the correct target and writes the comment - /// to that target's ack-comment subtag. + /// Verifies ResolveAckTarget resolves the synthetic GUID (computed from + /// the alarm's full reference) to that target's ack-comment subtag. /// [Fact] - public void AcknowledgeByGuid_WritesCommentToAckCommentSubtag() + public void ResolveAckTarget_SyntheticGuid_ReturnsAckCommentSubtag() { FakeSource source = new FakeSource(); using SubtagAlarmConsumer consumer = BuildConsumer(source); @@ -246,41 +226,21 @@ public sealed class SubtagAlarmConsumerTests Guid syntheticGuid = emitted!.Record.AlarmGuid; Assert.NotEqual(Guid.Empty, syntheticGuid); - int rc = consumer.AcknowledgeByGuid( - alarmGuid: syntheticGuid, - ackComment: "guid ack", - ackOperatorName: "op", - ackOperatorNode: "WS01", - ackOperatorDomain: "CORP", - ackOperatorFullName: "Operator"); + AlarmAckTarget target = consumer.ResolveAckTarget(syntheticGuid); - Assert.Equal(0, rc); - Assert.NotNull(source.LastWrite); - Assert.Equal(AckCommentSubtag, source.LastWrite!.Value.Address); - Assert.Equal("guid ack", source.LastWrite.Value.Value); + Assert.True(target.Found); + Assert.Equal(AckCommentSubtag, target.ItemAddress); } - /// - /// Verifies AcknowledgeByGuid returns non-zero and performs no write when - /// the supplied GUID is not known to the consumer. - /// + /// Verifies ResolveAckTarget reports AlarmNotFound for a GUID unknown to the consumer. [Fact] - public void AcknowledgeByGuid_UnknownGuid_ReturnsNonZero() + public void ResolveAckTarget_UnknownGuid_IsNotFound() { FakeSource source = new FakeSource(); using SubtagAlarmConsumer consumer = BuildConsumer(source); consumer.Subscribe(@"\\HOST\Galaxy!TestArea"); - int rc = consumer.AcknowledgeByGuid( - alarmGuid: Guid.NewGuid(), - ackComment: "should not write", - ackOperatorName: "op", - ackOperatorNode: "WS01", - ackOperatorDomain: "CORP", - ackOperatorFullName: "Operator"); - - Assert.NotEqual(0, rc); - Assert.Null(source.LastWrite); + Assert.Equal(AlarmAckOutcome.AlarmNotFound, consumer.ResolveAckTarget(Guid.NewGuid()).Failure); } private sealed class FakeSource : ISubtagAlarmSource @@ -291,21 +251,12 @@ public sealed class SubtagAlarmConsumerTests /// Gets the subtag addresses passed to . public List Advised { get; } = new List(); - /// Gets the most recent (address, value) pair passed to . - public (string Address, object? Value)? LastWrite { get; private set; } - /// public void Advise(IReadOnlyCollection itemAddresses) { Advised.AddRange(itemAddresses); } - /// - public void Write(string itemAddress, object? value) - { - LastWrite = (itemAddress, value); - } - /// Raises a for the given subtag. /// The subtag address whose value changed. /// The new value. diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/WnWrapAlarmConsumerXmlTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/WnWrapAlarmConsumerXmlTests.cs index 3941a21..1d51f0e 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/WnWrapAlarmConsumerXmlTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/WnWrapAlarmConsumerXmlTests.cs @@ -824,4 +824,58 @@ public sealed class WnWrapAlarmConsumerXmlTests Assert.Equal("mgack-authwrite 180714", record.AckComment); Assert.Equal("Test alarm #1", record.AlarmComment); } + + /// A Galaxy-provider record resolves to its <tag>.AckMsg item; an unknown GUID is not found. + [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); + } + + /// A non-Galaxy provider has no MXAccess ack attribute, so it is not ackable. + [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); + } + + /// By-name resolution needs no snapshot: a Galaxy name maps straight to its AckMsg item. + [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); + } + + private static WnWrapAlarmConsumer CreateConsumerWithSnapshot(params MxAlarmSnapshotRecord[] records) + { + WnWrapAlarmConsumer consumer = new WnWrapAlarmConsumer(1024); + Dictionary next = new Dictionary(); + foreach (MxAlarmSnapshotRecord record in records) + { + next[record.AlarmGuid] = record; + } + + consumer.FoldFetch(next, truncated: false, out _); + return consumer; + } } diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmSubtagLiveSmokeTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmSubtagLiveSmokeTests.cs index 9fb9254..57ab552 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmSubtagLiveSmokeTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmSubtagLiveSmokeTests.cs @@ -2,7 +2,7 @@ // // Validates the subtag-fallback pipeline against a real Galaxy + MXAccess install: // LmxSubtagAlarmSource (own LMXProxyServerClass) -> -// SubtagAlarmConsumer (state machine + AcknowledgeByName write) -> +// SubtagAlarmConsumer (state machine + ResolveAckTargetByName) + AlarmAckWriter (AckMsg write) -> // synthesized MxAlarmTransitionEvent (Raise / Clear, Degraded=true, SyntheticGuid) // // FIELD NAMES CONFIRMED: InAlarm/Acked/AckMsg/Priority are the confirmed AVEVA @@ -49,8 +49,9 @@ namespace ZB.MOM.WW.MxGateway.Worker.Tests.Probes; /// synthesize records from Galaxy /// alarm subtags using the confirmed AVEVA AlarmExtension field /// names (InAlarm/Acked/AckMsg/Priority), and that -/// writes the -/// ack-comment subtag (AckMsg) successfully. +/// resolves the +/// ack-comment subtag (AckMsg) that then +/// writes successfully. /// /// Skip-gated; flip Skip=null on the dev rig with an alarm being /// driven (flip script or a manual operator/IDE toggle of the alarm @@ -119,7 +120,7 @@ public sealed class AlarmSubtagLiveSmokeTests /// real LMXProxyServerClass, collects a Raise then a Clear /// transition synthesized by , confirms /// the Degraded flag and synthetic GUID are stamped, then - /// AcknowledgeByName and verifies the ack-comment write returns 0. + /// acknowledges through the resolved ack-comment subtag and verifies the write succeeds. /// [Fact(Skip = "Live dev-rig smoke test — flip Skip=null with AVEVA + an alarm being driven (flip script or manual operator/IDE toggle of the alarm attribute). Subtag fallback path. Field names confirmed (InAlarm/Acked/AckMsg/Priority); live-validate runtime path resolves without intermediate alarm-condition segment.")] public void SubtagFallback_FullPipelineRoundTrip_SynthesizesRaiseAndAcknowledges() @@ -257,17 +258,9 @@ public sealed class AlarmSubtagLiveSmokeTests Log("InAlarm subtag delivered a value: " + inAlarmResolved); // Decisive write: AckMsg. Returns 0 only if the address resolves and is writable. - Log("AcknowledgeByName (writes AckMsg subtag) ..."); - int rc = consumer.AcknowledgeByName( - alarmName: AlarmTagName, - providerName: Provider, - groupName: Group, - ackComment: "subtag-resolution-probe", - ackOperatorName: Environment.UserName, - ackOperatorNode: Environment.MachineName, - ackOperatorDomain: Environment.UserDomainName ?? string.Empty, - ackOperatorFullName: Environment.UserName); - Log(string.Format("AcknowledgeByName(AckMsg write) -> rc={0}", rc)); + Log("Acknowledge (writes AckMsg subtag via AlarmAckWriter) ..."); + int rc = AckViaWriter(consumer, "subtag-resolution-probe").NativeStatus; + Log(string.Format("Ack (AckMsg write) -> rc={0}", rc)); DateTime settle = DateTime.UtcNow + TimeSpan.FromSeconds(2); while (DateTime.UtcNow < settle) @@ -392,21 +385,13 @@ public sealed class AlarmSubtagLiveSmokeTests Assert.True(foundInSnapshot, string.Format("Active alarm snapshot must contain '{0}' after a Raise.", AlarmFullReference)); - // 3. AcknowledgeByName — writes the ack-comment subtag. + // 3. Resolve the ack-comment subtag and write it as the operator. // The dispatcher derives (alarmName, provider, group) via the same // TryParseReference logic as AlarmsLiveSmokeTests. - Log("Calling AcknowledgeByName ..."); - int rc = consumer.AcknowledgeByName( - alarmName: AlarmTagName, - providerName: Provider, - groupName: Group, - ackComment: "subtag-fallback-smoke ack", - ackOperatorName: Environment.UserName, - ackOperatorNode: Environment.MachineName, - ackOperatorDomain: Environment.UserDomainName ?? string.Empty, - ackOperatorFullName: Environment.UserName); + Log("Acknowledging via AlarmAckWriter ..."); + int rc = AckViaWriter(consumer, "subtag-fallback-smoke ack").NativeStatus; - Log(string.Format("AcknowledgeByName -> rc={0}", rc)); + Log(string.Format("Ack -> rc={0}", rc)); Assert.Equal(0, rc); // 4. Wait for a Clear or Acknowledge transition to confirm the state @@ -501,6 +486,29 @@ public sealed class AlarmSubtagLiveSmokeTests /// Mirrors the pump pattern documented in /// docs/MxAccessWorkerInstanceDesign.md. /// + /// + /// Resolves the probe alarm's ack item through the consumer and writes it + /// with as the operator named by + /// MXGATEWAY_PROBE_ACK_USER/MXGATEWAY_PROBE_ACK_PASSWORD + /// (default: current user, empty password). + /// + private static AlarmAckResult AckViaWriter(SubtagAlarmConsumer consumer, string comment) + { + AlarmAckTarget target = consumer.ResolveAckTargetByName(AlarmTagName, Provider, Group); + if (!target.Found) + { + return AlarmAckResult.FromTarget(target); + } + + using AlarmAckWriter writer = new AlarmAckWriter( + new MxAccessComObjectFactory(), PumpMessages, TimeSpan.FromSeconds(5), AlarmAckWriter.DefaultClientName); + return writer.Acknowledge(target.ItemAddress, comment, new AlarmAckOperator + { + UserName = Environment.GetEnvironmentVariable("MXGATEWAY_PROBE_ACK_USER") ?? Environment.UserName, + Password = Environment.GetEnvironmentVariable("MXGATEWAY_PROBE_ACK_PASSWORD") ?? string.Empty, + }); + } + private static void PumpMessages() { NativeMethods.MSG msg; diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/FailoverAlarmConsumer.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/FailoverAlarmConsumer.cs index 0b94d55..1b8f765 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/FailoverAlarmConsumer.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/FailoverAlarmConsumer.cs @@ -41,7 +41,7 @@ namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; /// Threading. Like its children, this type is driven /// entirely on the worker's STA: , /// , , and the -/// AcknowledgeBy* calls are all invoked from the apartment that +/// ResolveAckTarget* calls are all invoked from the apartment that /// owns the underlying COM objects. It owns no locks of its own and no /// internal timer; the worker drives on a timer. /// @@ -259,34 +259,17 @@ public sealed class FailoverAlarmConsumer : IMxAccessAlarmConsumer } /// - public int AcknowledgeByGuid( - Guid alarmGuid, - string ackComment, - string ackOperatorName, - string ackOperatorNode, - string ackOperatorDomain, - string ackOperatorFullName) + public AlarmAckTarget ResolveAckTarget(Guid alarmGuid) { if (disposed) throw new ObjectDisposedException(nameof(FailoverAlarmConsumer)); - return ActiveChild.AcknowledgeByGuid( - alarmGuid, ackComment, ackOperatorName, ackOperatorNode, ackOperatorDomain, ackOperatorFullName); + return ActiveChild.ResolveAckTarget(alarmGuid); } /// - public int AcknowledgeByName( - string alarmName, - string providerName, - string groupName, - string ackComment, - string ackOperatorName, - string ackOperatorNode, - string ackOperatorDomain, - string ackOperatorFullName) + public AlarmAckTarget ResolveAckTargetByName(string alarmName, string providerName, string groupName) { if (disposed) throw new ObjectDisposedException(nameof(FailoverAlarmConsumer)); - return ActiveChild.AcknowledgeByName( - alarmName, providerName, groupName, ackComment, - ackOperatorName, ackOperatorNode, ackOperatorDomain, ackOperatorFullName); + return ActiveChild.ResolveAckTargetByName(alarmName, providerName, groupName); } /// diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IMxAccessAlarmConsumer.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IMxAccessAlarmConsumer.cs index d5b2b14..703d164 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IMxAccessAlarmConsumer.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IMxAccessAlarmConsumer.cs @@ -49,51 +49,19 @@ public interface IMxAccessAlarmConsumer : IDisposable void Subscribe(string subscription); /// - /// Acknowledges a single alarm with full operator-identity fidelity. - /// Reaches AVEVA's native AlarmAckByGUID; operator - /// user / node / domain / full-name and the comment land atomically - /// with the ack transition in the alarm-history log. + /// Resolves the MXAccess item whose write acknowledges the alarm with the + /// given GUID (e.g. Obj.Alarm.AckMsg). Does not perform the ack. /// - /// The alarm GUID. - /// The acknowledgment comment. - /// The operator name. - /// The operator node. - /// The operator domain. - /// The operator full name. - /// The AVEVA-native status code. - int AcknowledgeByGuid( - Guid alarmGuid, - string ackComment, - string ackOperatorName, - string ackOperatorNode, - string ackOperatorDomain, - string ackOperatorFullName); + /// The alarm GUID as reported by this consumer. + /// The resolved target or the reason none exists. + AlarmAckTarget ResolveAckTarget(Guid alarmGuid); - /// - /// Acknowledge a single alarm by its (name, provider, group) tuple. - /// Reaches AVEVA's AlarmAckByName on - /// wwAlarmConsumerClass; same alarm-history outcome as - /// , used when the caller has the - /// human-readable reference but not the canonical GUID. - /// - /// The alarm name. - /// The provider name. - /// The group name. - /// The acknowledgment comment. - /// The operator name. - /// The operator node. - /// The operator domain. - /// The operator full name. - /// The AVEVA-native status code. - int AcknowledgeByName( - string alarmName, - string providerName, - string groupName, - string ackComment, - string ackOperatorName, - string ackOperatorNode, - string ackOperatorDomain, - string ackOperatorFullName); + /// Resolves the ack item for an alarm identified by name, provider and group. + /// The tag/alarm name, e.g. TestMachine_001.TestAlarm001. + /// The provider (literal Galaxy for ArchestrA). + /// The area/group. + /// The resolved target or the reason none exists. + AlarmAckTarget ResolveAckTargetByName(string alarmName, string providerName, string groupName); /// /// Returns the consumer's most recently parsed snapshot of currently diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/ISubtagAlarmSource.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/ISubtagAlarmSource.cs index 75b4873..ef160c2 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/ISubtagAlarmSource.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/ISubtagAlarmSource.cs @@ -16,9 +16,4 @@ public interface ISubtagAlarmSource : IDisposable /// Begins advising the supplied MXAccess subtag item addresses. /// The subtag references to advise. void Advise(IReadOnlyCollection itemAddresses); - - /// Writes a value to the named MXAccess subtag (e.g. an ack request). - /// The subtag reference to write. - /// The value to write. - void Write(string itemAddress, object? value); } diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/LmxSubtagAlarmSource.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/LmxSubtagAlarmSource.cs index 9fa0cd8..64a3fa8 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/LmxSubtagAlarmSource.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/LmxSubtagAlarmSource.cs @@ -9,8 +9,9 @@ namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; /// Production backed by its own MXAccess /// LMXProxyServerClass COM object. It advises a set of alarm subtag /// item addresses, normalizes each OnDataChange COM callback into a -/// , and supports writing a value (e.g. an -/// ack-comment) to a subtag. +/// . It is observe-only: alarm +/// acknowledgement goes through , which +/// authenticates the operator on its own server. /// /// /// @@ -25,7 +26,7 @@ namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; /// Threading. The LMXProxyServerClass CLSID is /// apartment-threaded and delivers OnDataChange via the STA /// message pump. Every method on this type — the lazy -/// creation, , and — must be +/// creation and — must be /// invoked on the worker's STA that created the source, exactly as /// and /// require. The OnDataChange COM event also arrives on that STA. @@ -47,9 +48,7 @@ public sealed class LmxSubtagAlarmSource : ISubtagAlarmSource new Dictionary(); // Handles that were actually Advise()d, tracked separately from the added - // set so Dispose only UnAdvises advised items. Write() can AddItem a - // write-only subtag (e.g. an ack-comment that was never advised); calling - // UnAdvise on such a handle would be an unbalanced teardown. + // set so Dispose only UnAdvises items this source advised. private readonly HashSet advisedItemHandles = new HashSet(); private object? mxAccessComObject; @@ -85,7 +84,7 @@ public sealed class LmxSubtagAlarmSource : ISubtagAlarmSource /// with a pre-registered server handle, /// bypassing the live COM factory. Exposed via /// InternalsVisibleTo("ZB.MOM.WW.MxGateway.Worker.Tests") so tests can - /// exercise / and + /// exercise and /// without a live LMXProxyServerClass. /// No COM event source is wired in this mode — tests drive /// directly to simulate an @@ -140,29 +139,6 @@ public sealed class LmxSubtagAlarmSource : ISubtagAlarmSource } } - /// - public void Write(string itemAddress, object? value) - { - // Writes with MXAccess user id 0 — always an unsecured Write, never WriteSecured semantics. - if (itemAddress is null) - { - throw new ArgumentNullException(nameof(itemAddress)); - } - - ThrowIfDisposed(); - EnsureRegistered(); - - IMxAccessServer mxServer = server!; - if (!itemHandlesByAddress.TryGetValue(itemAddress, out int itemHandle)) - { - itemHandle = mxServer.AddItem(serverHandle, itemAddress); - itemHandlesByAddress[itemAddress] = itemHandle; - addressesByItemHandle[itemHandle] = itemAddress; - } - - mxServer.Write(serverHandle, itemHandle, value, userId: 0); - } - /// /// Normalizes an MXAccess OnDataChange callback into a /// and raises @@ -223,8 +199,7 @@ public sealed class LmxSubtagAlarmSource : ISubtagAlarmSource { foreach (KeyValuePair entry in addressesByItemHandle) { - // Only UnAdvise handles that were actually advised; a write-only - // item (added by Write but never Advise'd) was never advised. + // Only UnAdvise handles that were actually advised. if (advisedItemHandles.Contains(entry.Key)) { try { mxServer.UnAdvise(serverHandle, entry.Key); } catch { /* swallow — best effort */ } diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmConsumer.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmConsumer.cs index cca921d..4d9257b 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmConsumer.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmConsumer.cs @@ -27,9 +27,11 @@ namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; /// /// The subtag path is event-driven, so is a /// no-op — value changes arrive through the source's advise stream -/// rather than an explicit poll. Acknowledgment in subtag mode writes -/// the comment to the target's writable ack-comment subtag rather than -/// calling a native AlarmAckByGUID / AlarmAckByName. +/// rather than an explicit poll. Acknowledgment in subtag mode resolves +/// to the target's writable ack-comment subtag +/// (); +/// performs the operator-authenticated write on its own MXAccess +/// server, so this consumer neither advises nor writes that subtag. /// /// public sealed class SubtagAlarmConsumer : IMxAccessAlarmConsumer @@ -49,7 +51,7 @@ public sealed class SubtagAlarmConsumer : IMxAccessAlarmConsumer /// Initializes the consumer over a subtag source and a watch list of /// alarm targets. /// - /// The subtag value source to advise and write through. + /// The subtag value source to advise. /// The alarm subtag targets to observe. /// /// Thrown when or @@ -91,7 +93,6 @@ public sealed class SubtagAlarmConsumer : IMxAccessAlarmConsumer AddIfNotEmpty(addresses, target.ActiveSubtag); AddIfNotEmpty(addresses, target.AckedSubtag); AddIfNotEmpty(addresses, target.PrioritySubtag); - AddIfNotEmpty(addresses, target.AckCommentSubtag); } source.Advise(addresses); @@ -104,13 +105,7 @@ public sealed class SubtagAlarmConsumer : IMxAccessAlarmConsumer } /// - public int AcknowledgeByGuid( - Guid alarmGuid, - string ackComment, - string ackOperatorName, - string ackOperatorNode, - string ackOperatorDomain, - string ackOperatorFullName) + public AlarmAckTarget ResolveAckTarget(Guid alarmGuid) { if (disposed) { @@ -120,22 +115,14 @@ public sealed class SubtagAlarmConsumer : IMxAccessAlarmConsumer if (!referencesBySyntheticGuid.TryGetValue(alarmGuid, out string reference) || !targetsByReference.TryGetValue(reference, out AlarmSubtagTarget target)) { - return -1; + return AlarmAckTarget.NotFound($"Alarm {alarmGuid} is not on the subtag watch list."); } - return WriteAckComment(target, ackComment); + return ToAckTarget(target); } /// - public int AcknowledgeByName( - string alarmName, - string providerName, - string groupName, - string ackComment, - string ackOperatorName, - string ackOperatorNode, - string ackOperatorDomain, - string ackOperatorFullName) + public AlarmAckTarget ResolveAckTargetByName(string alarmName, string providerName, string groupName) { if (disposed) { @@ -143,12 +130,9 @@ public sealed class SubtagAlarmConsumer : IMxAccessAlarmConsumer } AlarmSubtagTarget? target = ResolveTargetByName(alarmName, providerName, groupName); - if (target is null) - { - return -1; - } - - return WriteAckComment(target, ackComment); + return target is null + ? AlarmAckTarget.NotFound($"Alarm '{providerName}!{groupName}.{alarmName}' is not on the subtag watch list.") + : ToAckTarget(target); } /// @@ -260,16 +244,12 @@ public sealed class SubtagAlarmConsumer : IMxAccessAlarmConsumer return target; } - private int WriteAckComment(AlarmSubtagTarget target, string ackComment) + private static AlarmAckTarget ToAckTarget(AlarmSubtagTarget target) { - string ackCommentSubtag = target.AckCommentSubtag ?? string.Empty; - if (string.IsNullOrEmpty(ackCommentSubtag)) - { - return -1; - } - - source.Write(ackCommentSubtag, ackComment); - return 0; + string ackItem = target.AckCommentSubtag ?? string.Empty; + return ackItem.Length == 0 + ? AlarmAckTarget.NotAckable($"Alarm '{target.AlarmFullReference}' has no ack-comment subtag configured.") + : AlarmAckTarget.For(ackItem); } private static void AddIfNotEmpty(List addresses, string? address) diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs index f109dc3..85089ea 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs @@ -34,7 +34,7 @@ namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; /// and operated from an STA thread; the worker's /// runs an STA pump that hosts it. /// The consumer owns no internal timer: every COM call -/// (Subscribe, PollOnce, AcknowledgeBy*) must +/// (Subscribe, PollOnce) must /// be invoked on the STA that created the consumer. Polling cadence /// is driven externally by the worker's STA via /// StaRuntime.InvokeAsync(() => consumer.PollOnce()), which @@ -43,6 +43,15 @@ namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; /// off the owning STA and can deadlock on cross-apartment marshaling /// when the STA is not pumping messages, so no such timer exists. /// +/// +/// Acknowledgement. This consumer only receives +/// alarms. The wnwrap AlarmAckBy* calls were accepted and then +/// inert on the dev rig (see docs/AlarmProbeFindings.md), so +/// acknowledgement no longer goes through wnwrap: the consumer resolves +/// a Galaxy alarm to its <tag>.AckMsg item +/// () and +/// writes that item over MXAccess as the authenticated operator. +/// /// public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer { @@ -50,6 +59,7 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer private const string DefaultApplicationName = "OtOpcUa.ZB.MOM.WW.MxGateway.Worker"; private const string DefaultVersion = "1.0"; private const int DefaultMaxAlarmsPerFetch = 1024; + private const string GalaxyProviderName = "Galaxy"; /// /// Floor on the resolved per-fetch cap. Mirrors the gateway-side @@ -93,7 +103,6 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer private long truncatedFetchCount; private bool lastSnapshotTruncated; private wwAlarmConsumerClass? client; - private wwAlarmConsumerClass? ackClient; private bool subscribed; private bool disposed; @@ -200,7 +209,7 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer // WIN-911-style code uses against the same wnwrap library. // Note rc=0 means the call was // accepted, not that an acknowledgement was applied — see - // AcknowledgeByName below and docs/AlarmProbeFindings.md. + // docs/AlarmProbeFindings.md. Acks now go through AlarmAckWriter. int init = com.IwwAlarmConsumer_InitializeConsumer(DefaultApplicationName); if (init != 0) { @@ -241,10 +250,7 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer // SetXmlAlarmQuery is mangled (see docs/AlarmClientDiscovery.md), // calling it is necessary for subsequent GetXmlCurrentAlarms2 // calls to succeed. Without it, GetXmlCurrentAlarms2 returns - // E_FAIL (HRESULT 0x80004005) on the first poll. SetXmlAlarmQuery - // also breaks AlarmAckByName on the same consumer (rejects with - // -55), so a separate ack-only consumer is provisioned below - // that gets only Initialize/Register/Subscribe (no SetXmlAlarmQuery). + // E_FAIL (HRESULT 0x80004005) on the first poll. // // The wnwrap interop signature is `void SetXmlAlarmQuery(string)` // — there is no integer return code to gate on like the other v1 @@ -268,116 +274,49 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer ex); } - // Provision a parallel COM consumer for ack calls. It runs the - // v1 lifecycle (Initialize/Register/Subscribe) only; without - // SetXmlAlarmQuery, AlarmAckByName succeeds. State is read-only - // — we never poll this consumer. - ackClient = new wwAlarmConsumerClass(); - int ackInit = ackClient.IwwAlarmConsumer_InitializeConsumer(DefaultApplicationName + ".ack"); - int ackReg = ackClient.IwwAlarmConsumer_RegisterConsumer( - hWnd: 0, - szProductName: DefaultProductName, - szApplicationName: DefaultApplicationName + ".ack", - szVersion: DefaultVersion); - int ackSub = ackClient.IwwAlarmConsumer_Subscribe( - szSubscription: subscription, - wFromPri: 1, - wToPri: 999, - QueryType: eQueryType.qtSummary, - SortFlags: eSortFlags.sfReturnNewestFirst, - FilterMask: eAlarmFilterState.asAlarmActiveNow, - FilterSpecification: eAlarmFilterState.asAlarmActiveNow); - if (ackInit != 0 || ackReg != 0 || ackSub != 0) - { - throw new InvalidOperationException( - $"Ack consumer setup returned non-zero status: " + - $"Initialize={ackInit}, Register={ackReg}, Subscribe={ackSub}."); - } - subscribed = true; } } /// - public int AcknowledgeByGuid( - Guid alarmGuid, - string ackComment, - string ackOperatorName, - string ackOperatorNode, - string ackOperatorDomain, - string ackOperatorFullName) + public AlarmAckTarget ResolveAckTarget(Guid alarmGuid) { if (disposed) throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer)); + MxAlarmSnapshotRecord? record; + lock (syncRoot) + { + latestSnapshot.TryGetValue(alarmGuid, out record); + } - wwAlarmConsumerClass com = client - ?? throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer)); + if (record is null) + { + return AlarmAckTarget.NotFound($"Alarm {alarmGuid} is not in the current alarm snapshot."); + } - // VBGUID is wnwrap's GUID interop struct (same memory layout as - // System.Guid: int32 + 2x int16 + 8x byte). Convert via a single - // unmanaged-blittable round-trip. - VBGUID vb = ToVbGuid(alarmGuid); - return com.AlarmAckByGUID( - AlmGUID: vb, - szComment: ackComment ?? string.Empty, - szOprName: ackOperatorName ?? string.Empty, - szNode: ackOperatorNode ?? string.Empty, - szDomainName: ackOperatorDomain ?? string.Empty, - szOprFullName: ackOperatorFullName ?? string.Empty); + return ResolveGalaxyAckTarget(record.ProviderName, record.TagName); } /// - public int AcknowledgeByName( - string alarmName, - string providerName, - string groupName, - string ackComment, - string ackOperatorName, - string ackOperatorNode, - string ackOperatorDomain, - string ackOperatorFullName) + public AlarmAckTarget ResolveAckTargetByName(string alarmName, string providerName, string groupName) { if (disposed) throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer)); + return ResolveGalaxyAckTarget(providerName, alarmName); + } - // Use the parallel ack-only consumer (no SetXmlAlarmQuery applied) - // — see docs/AlarmClientDiscovery.md "Option A — captured" for the - // empirical justification. - wwAlarmConsumerClass com = ackClient - ?? throw new InvalidOperationException( - "Cannot acknowledge: WnWrapAlarmConsumer was disposed or has not been subscribed yet."); + 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."); + } - // The original observation (live dev-rig 2026-05-01) was narrower than - // it read: the IwwAlarmConsumer2 8-arg AlarmAckByName returned -55 - // "and looked like a stub" while the legacy 6-arg - // IwwAlarmConsumer.AlarmAckByName returned 0, which is how the 6-arg - // overload came to be the one called here. The 2026-08-18 probe - // corrected both halves. The -55 tracks the *consumer*, not the - // overload: both overloads return -55 against a - // SetXmlAlarmQuery-applied consumer and 0 against the ack-only one - // used above. And a zero return is NOT evidence the acknowledgement - // was applied — the probe acked a real, freshly raised alarm six ways - // and watched the snapshot stay UNACK_ALM, OPERATOR_NAME stay empty, - // and the alarm extension's own .Acked attribute stay False for 16 s - // after each call. On that rig the ack is accepted and then inert, so - // whether any wnwrap ack reaches the alarm-history path is - // unverified; see docs/AlarmProbeFindings.md and the 2026-08-18 - // correction in docs/AlarmClientDiscovery.md. The 6-arg call site - // below therefore stays as-is for MXAccess parity — the choice is no - // longer justified by the rc, and rc semantics are per the probe. - // - // Operator-domain and operator-full-name fields are accepted by the - // proto contract for forward-compat but are not propagated to AVEVA - // today — wrapped in the 6-arg call so domain/full-name go to the - // alarm-history operator-name field via the szOprName parameter. - // Suppress unused-warning explicitly: - _ = ackOperatorDomain; - _ = ackOperatorFullName; - return com.AlarmAckByName( - szAlarmName: alarmName ?? string.Empty, - szProviderName: providerName ?? string.Empty, - szGroupName: groupName ?? string.Empty, - szComment: ackComment ?? string.Empty, - szOprName: ackOperatorName ?? string.Empty, - szNode: ackOperatorNode ?? string.Empty); + if (string.IsNullOrWhiteSpace(tagName)) + { + return AlarmAckTarget.NotFound("Alarm record has no tag name."); + } + + return AlarmAckTarget.For(tagName + "." + AlarmAckWriter.AckCommentAttribute); } /// @@ -944,37 +883,18 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer return sb.ToString(); } - private static VBGUID ToVbGuid(Guid g) - { - byte[] bytes = g.ToByteArray(); - // Guid byte layout: int32-LE + int16-LE + int16-LE + 8 bytes (Data4). - VBGUID vb = new VBGUID - { - Data1 = BitConverter.ToInt32(bytes, 0), - Data2 = BitConverter.ToInt16(bytes, 4), - Data3 = BitConverter.ToInt16(bytes, 6), - Data4 = new byte[8], - }; - Array.Copy(bytes, 8, vb.Data4, 0, 8); - return vb; - } - /// public void Dispose() { wwAlarmConsumerClass? clientToDispose; - wwAlarmConsumerClass? ackClientToDispose; lock (syncRoot) { if (disposed) return; disposed = true; clientToDispose = client; client = null; - ackClientToDispose = ackClient; - ackClient = null; } ReleaseConsumerCom(clientToDispose); - ReleaseConsumerCom(ackClientToDispose); } private static void ReleaseConsumerCom(wwAlarmConsumerClass? consumer) From d6db0ff40d08e3d45e8dbb03aa52070e41ca6ebd Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 19 Oct 2026 19:35:55 -0400 Subject: [PATCH 08/25] feat(worker): route alarm acks through the operator-authenticated AckMsg writer AlarmCommandHandler -> AlarmDispatcher -> IAlarmAckWriter: the dispatcher resolves the ack item through the consumer and the writer authenticates the operator and writes the comment. The executor builds AlarmAckOperator from operator_user/domain/password, maps AlarmAckOutcome to the reply (Applied/Unconfirmed Ok, AlarmNotFound/NotAckable InvalidRequest, AuthenticationFailed/WriteRejected MxaccessFailure) and reports escaping exceptions by type and HRESULT only, so operator_password cannot leak. Removes the inert managed/WNAL alarm-client ack path. Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT --- .../MxAccess/AlarmCommandExecutorTests.cs | 216 +++++++++++-- .../MxAccess/AlarmCommandHandlerTests.cs | 211 +++++++++---- .../MxAccess/AlarmDispatcherTests.cs | 286 +++++++----------- .../MxAccess/MxAccessStaSessionTests.cs | 12 +- .../AlarmManagedGuidAckLiveSmokeTests.cs | 229 -------------- .../Probes/AlarmsLiveSmokeTests.cs | 40 +-- .../Ipc/WorkerPipeSession.cs | 6 +- .../MxAccess/AaManagedAlarmAckConsumer.cs | 231 -------------- .../MxAccess/AlarmCommandHandler.cs | 131 +++++--- .../MxAccess/AlarmDispatcher.cs | 144 +++------ .../MxAccess/IAlarmCommandHandler.cs | 45 +-- .../MxAccess/IManagedAlarmAckClient.cs | 44 --- .../MxAccess/MxAccessCommandExecutor.cs | 109 ++++--- .../MxAccess/WnalNativeAckConsumer.cs | 273 ----------------- 14 files changed, 695 insertions(+), 1282 deletions(-) delete mode 100644 src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmManagedGuidAckLiveSmokeTests.cs delete mode 100644 src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AaManagedAlarmAckConsumer.cs delete mode 100644 src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IManagedAlarmAckClient.cs delete mode 100644 src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnalNativeAckConsumer.cs diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandExecutorTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandExecutorTests.cs index 4b82047..1ce7860 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandExecutorTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandExecutorTests.cs @@ -116,7 +116,7 @@ public sealed class AlarmCommandExecutorTests [Fact] public void AcknowledgeAlarm_WithHandler_RoutesNativeStatusIntoHresultAndPayload() { - FakeAlarmHandler handler = new FakeAlarmHandler { AcknowledgeReturn = 0 }; + FakeAlarmHandler handler = new FakeAlarmHandler(); MxAccessCommandExecutor executor = NewExecutor(handler); Guid g = Guid.NewGuid(); @@ -143,7 +143,10 @@ public sealed class AlarmCommandExecutorTests Assert.NotNull(reply.AcknowledgeAlarm); Assert.Equal(0, reply.AcknowledgeAlarm.NativeStatus); Assert.Equal(g, handler.LastAckGuid); - Assert.Equal("alice", handler.LastAckOperatorName); + Assert.Equal("ack", handler.LastComment); + Assert.Equal("alice", handler.LastOperator!.UserName); + Assert.Equal("CORP", handler.LastOperator.Domain); + Assert.Equal(string.Empty, handler.LastOperator.Password); } /// Verifies that invalid alarm GUID returns invalid request. @@ -168,11 +171,14 @@ public sealed class AlarmCommandExecutorTests Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code); } - /// Verifies that nonzero native status carries a diagnostic message. + /// Verifies that a rejected write carries its native status and diagnostic. [Fact] public void AcknowledgeAlarm_WithNonzeroNativeStatus_CarriesDiagnostic() { - FakeAlarmHandler handler = new FakeAlarmHandler { AcknowledgeReturn = -123 }; + FakeAlarmHandler handler = new FakeAlarmHandler + { + AckResult = AlarmAckResult.Create(AlarmAckOutcome.WriteRejected, -123, "AckMsg write rejected: detail -123."), + }; MxAccessCommandExecutor executor = NewExecutor(handler); StaCommand command = new StaCommand( @@ -188,7 +194,9 @@ public sealed class AlarmCommandExecutorTests MxCommandReply reply = executor.Execute(command); + Assert.Equal(ProtocolStatusCode.MxaccessFailure, reply.ProtocolStatus.Code); Assert.Equal(-123, reply.Hresult); + Assert.Equal(-123, reply.AcknowledgeAlarm.NativeStatus); Assert.Contains("-123", reply.DiagnosticMessage); } @@ -196,7 +204,7 @@ public sealed class AlarmCommandExecutorTests [Fact] public void AcknowledgeAlarmByName_WithHandler_RoutesTupleToHandler() { - FakeAlarmHandler handler = new FakeAlarmHandler { AcknowledgeReturn = 0 }; + FakeAlarmHandler handler = new FakeAlarmHandler(); MxAccessCommandExecutor executor = NewExecutor(handler); StaCommand command = new StaCommand( @@ -223,7 +231,133 @@ public sealed class AlarmCommandExecutorTests Assert.Equal("TestMachine_001.TestAlarm001", handler.LastAckByNameTuple!.Value.Name); Assert.Equal("Galaxy", handler.LastAckByNameTuple!.Value.Provider); Assert.Equal("TestArea", handler.LastAckByNameTuple!.Value.Group); - Assert.Equal("alice", handler.LastAckOperatorName); + Assert.Equal("ack", handler.LastComment); + Assert.Equal("alice", handler.LastOperator!.UserName); + } + + /// + /// Every ack outcome maps to its protocol status, carries the native + /// status, and never echoes the operator password. + /// + /// The writer outcome. + /// The expected protocol status. + [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 void AcknowledgeAlarm_MapsOutcomeWithoutLeakingPassword(AlarmAckOutcome outcome, ProtocolStatusCode expected) + { + int native = outcome is AlarmAckOutcome.Applied or AlarmAckOutcome.Unconfirmed ? 0 : 1008; + FakeAlarmHandler handler = new FakeAlarmHandler + { + AckResult = AlarmAckResult.Create(outcome, native, "diagnostic for alice"), + }; + + MxCommandReply reply = Execute(handler, new MxCommand + { + Kind = MxCommandKind.AcknowledgeAlarm, + AcknowledgeAlarmCommand = new AcknowledgeAlarmCommand + { + AlarmGuid = Guid.NewGuid().ToString(), + Comment = "checked", + OperatorUser = "alice", + OperatorDomain = "CORP", + OperatorPassword = "pw-secret", + }, + }); + + AssertAckReply(reply, handler, expected, native); + } + + /// The by-name ack maps outcomes identically and never echoes the operator password. + /// The writer outcome. + /// The expected protocol status. + [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 void AcknowledgeAlarmByName_MapsOutcomeWithoutLeakingPassword(AlarmAckOutcome outcome, ProtocolStatusCode expected) + { + int native = outcome is AlarmAckOutcome.Applied or AlarmAckOutcome.Unconfirmed ? 0 : 1008; + FakeAlarmHandler handler = new FakeAlarmHandler + { + AckResult = AlarmAckResult.Create(outcome, native, "diagnostic for alice"), + }; + + MxCommandReply reply = Execute(handler, new MxCommand + { + Kind = MxCommandKind.AcknowledgeAlarmByName, + AcknowledgeAlarmByNameCommand = new AcknowledgeAlarmByNameCommand + { + AlarmName = "TestMachine_001.TestAlarm001", + ProviderName = "Galaxy", + GroupName = "TestArea", + Comment = "checked", + OperatorUser = "alice", + OperatorDomain = "CORP", + OperatorPassword = "pw-secret", + }, + }); + + AssertAckReply(reply, handler, expected, native); + } + + /// The writer's OnWriteComplete statuses are copied onto the reply. + [Fact] + public void AcknowledgeAlarm_CopiesWriteStatusesIntoReply() + { + MxStatusProxy status = new MxStatusProxy { Category = MxStatusCategory.Ok, Success = 1 }; + FakeAlarmHandler handler = new FakeAlarmHandler + { + AckResult = AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, string.Empty, new[] { status }), + }; + + MxCommandReply reply = Execute(handler, new MxCommand + { + Kind = MxCommandKind.AcknowledgeAlarm, + AcknowledgeAlarmCommand = new AcknowledgeAlarmCommand { AlarmGuid = Guid.NewGuid().ToString(), OperatorUser = "alice" }, + }); + + Assert.Equal(status, Assert.Single(reply.Statuses)); + } + + /// + /// An exception escaping the ack path (e.g. the writer's MXAccess + /// Register failing) maps to MxaccessFailure with only the exception type + /// and HRESULT — never the exception text, which is not under our control. + /// + [Fact] + public void AcknowledgeAlarmByName_WhenHandlerThrows_ReportsTypeAndHresultOnly() + { + FakeAlarmHandler handler = new FakeAlarmHandler + { + AcknowledgeException = new System.Runtime.InteropServices.COMException("echo pw-secret", unchecked((int)0x80004005)), + }; + + MxCommandReply reply = Execute(handler, new MxCommand + { + Kind = MxCommandKind.AcknowledgeAlarmByName, + AcknowledgeAlarmByNameCommand = new AcknowledgeAlarmByNameCommand + { + AlarmName = "TestMachine_001.TestAlarm001", + ProviderName = "Galaxy", + OperatorUser = "alice", + OperatorPassword = "pw-secret", + }, + }); + + Assert.Equal(ProtocolStatusCode.MxaccessFailure, reply.ProtocolStatus.Code); + Assert.Equal(unchecked((int)0x80004005), reply.Hresult); + Assert.Contains("COMException", reply.DiagnosticMessage, StringComparison.Ordinal); + Assert.Contains("0x80004005", reply.DiagnosticMessage, StringComparison.Ordinal); + Assert.DoesNotContain("pw-secret", reply.DiagnosticMessage, StringComparison.Ordinal); + Assert.DoesNotContain("pw-secret", reply.ProtocolStatus.Message, StringComparison.Ordinal); } /// Verifies that empty alarm name returns invalid request. @@ -393,7 +527,10 @@ public sealed class AlarmCommandExecutorTests [Fact] public void AcknowledgeAlarm_WhenHandlerThrows_ReturnsMxaccessFailure() { - FakeAlarmHandler handler = new FakeAlarmHandler { AcknowledgeThrow = true }; + FakeAlarmHandler handler = new FakeAlarmHandler + { + AcknowledgeException = new InvalidOperationException("simulated alarm-handler failure"), + }; MxAccessCommandExecutor executor = NewExecutor(handler); StaCommand command = new StaCommand( @@ -410,7 +547,25 @@ public sealed class AlarmCommandExecutorTests MxCommandReply reply = executor.Execute(command); Assert.Equal(ProtocolStatusCode.MxaccessFailure, reply.ProtocolStatus.Code); - Assert.Contains("simulated", reply.DiagnosticMessage); + Assert.Contains("InvalidOperationException", reply.DiagnosticMessage, StringComparison.Ordinal); + Assert.DoesNotContain("simulated", reply.DiagnosticMessage, StringComparison.Ordinal); + } + + private static MxCommandReply Execute(IAlarmCommandHandler handler, MxCommand command) => + NewExecutor(handler).Execute(new StaCommand(SessionId, CorrelationId, command)); + + private static void AssertAckReply(MxCommandReply reply, FakeAlarmHandler handler, ProtocolStatusCode expected, int native) + { + Assert.Equal(expected, reply.ProtocolStatus.Code); + Assert.Equal(native, reply.Hresult); + Assert.Equal(native, reply.AcknowledgeAlarm.NativeStatus); + Assert.Equal("diagnostic for alice", reply.DiagnosticMessage); + Assert.Equal("checked", handler.LastComment); + 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); } private static MxAccessCommandExecutor NewExecutor(IAlarmCommandHandler? alarmHandler) @@ -442,14 +597,17 @@ public sealed class AlarmCommandExecutorTests /// Gets the last acknowledge alarm GUID. public Guid LastAckGuid { get; private set; } - /// Gets the last acknowledge operator name. - public string? LastAckOperatorName { get; private set; } + /// Gets the last acknowledge comment. + public string? LastComment { get; private set; } - /// Gets or sets the value returned by acknowledge. - public int AcknowledgeReturn { get; set; } + /// Gets the last acknowledge operator. + public AlarmAckOperator? LastOperator { get; private set; } - /// Gets or sets a value indicating whether acknowledge throws. - public bool AcknowledgeThrow { get; set; } + /// Gets or sets the result returned by both acknowledge methods. + public AlarmAckResult AckResult { get; set; } = AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, string.Empty); + + /// Gets or sets an exception both acknowledge methods throw. + public Exception? AcknowledgeException { get; set; } /// Gets or sets the query result snapshots. public IReadOnlyList QueryResult { get; set; } = @@ -484,28 +642,32 @@ public sealed class AlarmCommandExecutorTests } /// - public int Acknowledge( - Guid alarmGuid, string comment, string operatorUser, - string operatorNode, string operatorDomain, string operatorFullName) + public AlarmAckResult Acknowledge(Guid alarmGuid, string comment, AlarmAckOperator ackOperator) { LastAckGuid = alarmGuid; - LastAckOperatorName = operatorUser; - if (AcknowledgeThrow) + LastComment = comment; + LastOperator = ackOperator; + if (AcknowledgeException is not null) { - throw new InvalidOperationException("simulated alarm-handler failure"); + throw AcknowledgeException; } - return AcknowledgeReturn; + + return AckResult; } /// - public int AcknowledgeByName( - string alarmName, string providerName, string groupName, - string comment, string operatorUser, string operatorNode, - string operatorDomain, string operatorFullName) + public AlarmAckResult AcknowledgeByName( + string alarmName, string providerName, string groupName, string comment, AlarmAckOperator ackOperator) { LastAckByNameTuple = (alarmName, providerName, groupName); - LastAckOperatorName = operatorUser; - return AcknowledgeReturn; + LastComment = comment; + LastOperator = ackOperator; + if (AcknowledgeException is not null) + { + throw AcknowledgeException; + } + + return AckResult; } /// Gets the last acknowledge by name tuple. diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandHandlerTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandHandlerTests.cs index c41807a..92c2d2c 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandHandlerTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandHandlerTests.cs @@ -96,22 +96,100 @@ public sealed class AlarmCommandHandlerTests Assert.False(handler.IsSubscribed); } - /// Verifies that acknowledge forwards to consumer with full operator identity when subscribed. + /// Verifies that acknowledge resolves via the consumer and writes through the injected writer as the operator. [Fact] - public void Acknowledge_WhenSubscribed_ForwardsToConsumerWithFullOperatorIdentity() + public void Acknowledge_WhenSubscribed_ResolvesTargetAndWritesAsOperator() { - FakeConsumer consumer = new FakeConsumer { AcknowledgeReturn = 0 }; - AlarmCommandHandler handler = new AlarmCommandHandler( - new MxAccessEventQueue(), - () => consumer); + FakeConsumer consumer = new FakeConsumer { AckTarget = AlarmAckTarget.For("T.A.AckMsg") }; + RecordingAckWriter writer = new RecordingAckWriter(); + AlarmCommandHandler handler = NewHandlerWithWriter(() => consumer, writer); handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1"); Guid g = Guid.NewGuid(); - int rc = handler.Acknowledge(g, "c", "u", "n", "d", "F"); + AlarmAckOperator op = new AlarmAckOperator { UserName = "u", Domain = "d", Password = "p" }; + AlarmAckResult result = handler.Acknowledge(g, "c", op); - Assert.Equal(0, rc); - Assert.Equal(g, consumer.LastAckGuid); - Assert.Equal("u", consumer.LastAckOperatorName); + Assert.Equal(AlarmAckOutcome.Applied, result.Outcome); + Assert.Equal(g, consumer.LastResolvedGuid); + Assert.Equal("T.A.AckMsg", writer.LastItemAddress); + Assert.Equal("c", writer.LastComment); + Assert.Same(op, writer.LastOperator); + } + + /// Verifies acknowledge-by-name forwards the tuple and a null comment becomes empty. + [Fact] + public void AcknowledgeByName_WhenSubscribed_ForwardsTupleAndNormalizesComment() + { + FakeConsumer consumer = new FakeConsumer(); + RecordingAckWriter writer = new RecordingAckWriter(); + AlarmCommandHandler handler = NewHandlerWithWriter(() => consumer, writer); + handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1"); + + handler.AcknowledgeByName("Tag1", "Galaxy", "Area", null!, new AlarmAckOperator { UserName = "u" }); + + Assert.Equal(("Tag1", "Galaxy", "Area"), consumer.LastResolvedName); + Assert.Equal(string.Empty, writer.LastComment); + } + + /// Verifies a null operator is rejected. + [Fact] + public void Acknowledge_NullOperator_Throws() + { + AlarmCommandHandler handler = NewHandlerWithWriter(() => new FakeConsumer(), new RecordingAckWriter()); + handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1"); + + Assert.Throws(() => handler.Acknowledge(Guid.NewGuid(), "c", null!)); + } + + /// + /// The ack writer is created once, on first acknowledgement, survives an + /// unsubscribe/resubscribe cycle, and is disposed with the handler. + /// + [Fact] + public void AckWriter_CreatedLazilyOnce_AndDisposedWithHandler() + { + RecordingAckWriter writer = new RecordingAckWriter(); + int created = 0; + AlarmCommandHandler handler = new AlarmCommandHandler( + new MxAccessEventQueue(), + () => new FakeConsumer(), + threadAffinityCheck: null, + comFactory: null, + standbyFactory: null, + ackWriterFactory: () => + { + created++; + return writer; + }); + + handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1"); + Assert.Equal(0, created); + + handler.Acknowledge(Guid.NewGuid(), "c", new AlarmAckOperator()); + handler.Unsubscribe(); + Assert.False(writer.Disposed); + + handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1"); + handler.AcknowledgeByName("a", "Galaxy", "g", "c", new AlarmAckOperator()); + Assert.Equal(1, created); + + handler.Dispose(); + Assert.True(writer.Disposed); + } + + /// + /// Without a COM factory or writer factory, subscribing still works and + /// the acknowledgement fails with a diagnosable InvalidOperationException. + /// + [Fact] + public void Acknowledge_WithoutComFactoryOrWriterFactory_ThrowsInvalidOperation() + { + AlarmCommandHandler handler = new AlarmCommandHandler(new MxAccessEventQueue(), () => new FakeConsumer()); + handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1"); + + InvalidOperationException ex = Assert.Throws( + () => handler.Acknowledge(Guid.NewGuid(), "c", new AlarmAckOperator())); + Assert.Contains("IMxAccessComObjectFactory", ex.Message); } /// Verifies that acknowledge throws invalid operation when called before subscribe. @@ -123,7 +201,7 @@ public sealed class AlarmCommandHandlerTests () => new FakeConsumer()); Assert.Throws( - () => handler.Acknowledge(Guid.Empty, "", "", "", "", "")); + () => handler.Acknowledge(Guid.Empty, "", new AlarmAckOperator())); } /// Verifies that query active returns mapped proto snapshots when consumer has alarms. @@ -266,7 +344,10 @@ public sealed class AlarmCommandHandlerTests AlarmCommandHandler handler = new AlarmCommandHandler( new MxAccessEventQueue(), () => consumer, - () => guardInvocations++); + () => guardInvocations++, + comFactory: null, + standbyFactory: null, + ackWriterFactory: () => new RecordingAckWriter()); // Subscribe is the first call — guard must run before the consumer // factory is invoked. We tally invocation counts after each call so @@ -275,10 +356,10 @@ public sealed class AlarmCommandHandlerTests handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1"); Assert.Equal(1, guardInvocations); - handler.Acknowledge(Guid.NewGuid(), "c", "u", "n", "d", "F"); + handler.Acknowledge(Guid.NewGuid(), "c", new AlarmAckOperator { UserName = "u" }); Assert.Equal(2, guardInvocations); - handler.AcknowledgeByName("a", "p", "g", "c", "u", "n", "d", "F"); + handler.AcknowledgeByName("a", "p", "g", "c", new AlarmAckOperator { UserName = "u" }); Assert.Equal(3, guardInvocations); _ = handler.QueryActive(null, out _); @@ -323,9 +404,9 @@ public sealed class AlarmCommandHandlerTests // assert each independently with a fresh handler. Below we reuse // the same throwing handler for the not-subscribed-yet entries: Assert.Throws( - () => handler.Acknowledge(Guid.Empty, "", "", "", "", "")); + () => handler.Acknowledge(Guid.Empty, "", new AlarmAckOperator())); Assert.Throws( - () => handler.AcknowledgeByName("", "", "", "", "", "", "", "")); + () => handler.AcknowledgeByName("", "", "", "", new AlarmAckOperator())); Assert.Throws(() => handler.QueryActive(null, out _)); Assert.Throws(() => handler.PollOnce()); Assert.Throws( @@ -422,16 +503,17 @@ public sealed class AlarmCommandHandlerTests [Fact] public void Subscribe_AutoModeWithEmptyWatchListAndFallbackEnabled_BuildsReplaceableStandby() { - FakeConsumer primary = new FakeConsumer { AcknowledgeReturn = 11 }; - FakeConsumer initialStandby = new FakeConsumer { AcknowledgeReturn = 22 }; - FakeConsumer replacement = new FakeConsumer { AcknowledgeReturn = 33 }; + FakeConsumer primary = new FakeConsumer { AckTarget = AlarmAckTarget.For("11.AckMsg") }; + FakeConsumer initialStandby = new FakeConsumer { AckTarget = AlarmAckTarget.For("22.AckMsg") }; + FakeConsumer replacement = new FakeConsumer { AckTarget = AlarmAckTarget.For("33.AckMsg") }; int buildCount = 0; AlarmCommandHandler handler = new AlarmCommandHandler( new MxAccessEventQueue(), () => primary, threadAffinityCheck: null, comFactory: null, - standbyFactory: _ => ++buildCount == 1 ? initialStandby : replacement); + standbyFactory: _ => ++buildCount == 1 ? initialStandby : replacement, + ackWriterFactory: () => new RecordingAckWriter()); handler.Subscribe(new SubscribeAlarmsCommand { @@ -443,7 +525,7 @@ public sealed class AlarmCommandHandlerTests Assert.Equal(2, buildCount); Assert.Equal(@"\\HOST\Galaxy!Area", replacement.LastSubscription); Assert.True(initialStandby.Disposed); - Assert.Equal(11, handler.Acknowledge(Guid.Empty, "", "", "", "", "")); + Assert.Equal("11.AckMsg", AckItemOf(handler)); } /// A replacement is rejected when no fallback-capable subscription is active. @@ -471,16 +553,17 @@ public sealed class AlarmCommandHandlerTests [Fact] public void UpdateWatchList_WhenReplacementSubscribeFails_PreservesOldStandbyAndPrimaryMode() { - FakeConsumer primary = new FakeConsumer { AcknowledgeReturn = 11 }; - FakeConsumer oldStandby = new FakeConsumer { AcknowledgeReturn = 22 }; - FakeConsumer failedReplacement = new FakeConsumer { ThrowOnSubscribe = true, AcknowledgeReturn = 33 }; + FakeConsumer primary = new FakeConsumer { AckTarget = AlarmAckTarget.For("11.AckMsg") }; + FakeConsumer oldStandby = new FakeConsumer { AckTarget = AlarmAckTarget.For("22.AckMsg") }; + FakeConsumer failedReplacement = new FakeConsumer { ThrowOnSubscribe = true, AckTarget = AlarmAckTarget.For("33.AckMsg") }; int buildCount = 0; AlarmCommandHandler handler = new AlarmCommandHandler( new MxAccessEventQueue(), () => primary, null, null, - _ => ++buildCount == 1 ? oldStandby : failedReplacement); + _ => ++buildCount == 1 ? oldStandby : failedReplacement, + () => new RecordingAckWriter()); SubscribeAlarmsCommand subscribe = new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!Area", @@ -494,23 +577,24 @@ public sealed class AlarmCommandHandlerTests Assert.False(oldStandby.Disposed); Assert.True(failedReplacement.Disposed); - Assert.Equal(11, handler.Acknowledge(Guid.Empty, "", "", "", "", "")); + Assert.Equal("11.AckMsg", AckItemOf(handler)); } /// A successful replacement while failed over keeps subtag mode active and delegates to the new standby. [Fact] public void UpdateWatchList_WhileSubtagActive_PreservesModeAndUsesReplacement() { - FakeConsumer primary = new FakeConsumer { ThrowOnSubscribe = true, AcknowledgeReturn = 11 }; - FakeConsumer oldStandby = new FakeConsumer { AcknowledgeReturn = 22 }; - FakeConsumer replacement = new FakeConsumer { AcknowledgeReturn = 33 }; + FakeConsumer primary = new FakeConsumer { ThrowOnSubscribe = true, AckTarget = AlarmAckTarget.For("11.AckMsg") }; + FakeConsumer oldStandby = new FakeConsumer { AckTarget = AlarmAckTarget.For("22.AckMsg") }; + FakeConsumer replacement = new FakeConsumer { AckTarget = AlarmAckTarget.For("33.AckMsg") }; int buildCount = 0; AlarmCommandHandler handler = new AlarmCommandHandler( new MxAccessEventQueue(), () => primary, null, null, - _ => ++buildCount == 1 ? oldStandby : replacement); + _ => ++buildCount == 1 ? oldStandby : replacement, + () => new RecordingAckWriter()); SubscribeAlarmsCommand subscribe = new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!Area", @@ -523,7 +607,7 @@ public sealed class AlarmCommandHandlerTests handler.UpdateWatchList(new[] { NewTarget("Galaxy!Area.Tank02.Alarm") }); Assert.True(oldStandby.Disposed); - Assert.Equal(33, handler.Acknowledge(Guid.Empty, "", "", "", "", "")); + Assert.Equal("33.AckMsg", AckItemOf(handler)); } /// @@ -567,6 +651,19 @@ public sealed class AlarmCommandHandlerTests Assert.Equal(at, evt.OnAlarmProviderModeChanged.At.ToDateTime()); } + private static AlarmCommandHandler NewHandlerWithWriter(Func consumerFactory, IAlarmAckWriter writer) => + new AlarmCommandHandler( + new MxAccessEventQueue(), + consumerFactory, + threadAffinityCheck: null, + comFactory: null, + standbyFactory: null, + ackWriterFactory: () => writer); + + /// Acks through the handler and returns the item the active consumer resolved (the fake writer echoes it as the diagnostic). + private static string AckItemOf(AlarmCommandHandler handler) => + handler.Acknowledge(Guid.Empty, string.Empty, new AlarmAckOperator()).Diagnostic; + private static MxAlarmSnapshotRecord NewRecord(string provider, string group, string tag) { return new MxAlarmSnapshotRecord @@ -598,12 +695,12 @@ public sealed class AlarmCommandHandlerTests /// Gets the last subscription request. public string? LastSubscription { get; private set; } - /// Gets the last acknowledged alarm GUID. - public Guid LastAckGuid { get; private set; } - /// Gets the last acknowledged operator name. - public string? LastAckOperatorName { get; private set; } - /// Gets or sets the return value for acknowledge operations. - public int AcknowledgeReturn { get; set; } + /// Gets or sets the ack target both resolve methods return. + public AlarmAckTarget AckTarget { get; set; } = AlarmAckTarget.For("Fake.Alarm.AckMsg"); + /// Gets the GUID from the last ResolveAckTarget call. + public Guid LastResolvedGuid { get; private set; } + /// Gets the (name, provider, group) from the last ResolveAckTargetByName call. + public (string Name, string Provider, string Group)? LastResolvedName { get; private set; } /// Gets or sets the snapshot result to return. public IReadOnlyList SnapshotResult { get; set; } = Array.Empty(); @@ -623,29 +720,19 @@ public sealed class AlarmCommandHandlerTests } /// - public int AcknowledgeByGuid( - Guid alarmGuid, string ackComment, string ackOperatorName, - string ackOperatorNode, string ackOperatorDomain, string ackOperatorFullName) + public AlarmAckTarget ResolveAckTarget(Guid alarmGuid) { - LastAckGuid = alarmGuid; - LastAckOperatorName = ackOperatorName; - return AcknowledgeReturn; + LastResolvedGuid = alarmGuid; + return AckTarget; } /// - public int AcknowledgeByName( - string alarmName, string providerName, string groupName, - string ackComment, string ackOperatorName, string ackOperatorNode, - string ackOperatorDomain, string ackOperatorFullName) + public AlarmAckTarget ResolveAckTargetByName(string alarmName, string providerName, string groupName) { - LastAckByNameTuple = (alarmName, providerName, groupName); - LastAckOperatorName = ackOperatorName; - return AcknowledgeReturn; + LastResolvedName = (alarmName, providerName, groupName); + return AckTarget; } - /// Gets the last acknowledge-by-name parameters. - public (string Name, string Provider, string Group)? LastAckByNameTuple { get; private set; } - /// Gets or sets the truncation verdict this consumer reports with its snapshot. public bool SnapshotTruncated { get; set; } @@ -671,4 +758,22 @@ public sealed class AlarmCommandHandlerTests Disposed = true; } } + + private sealed class RecordingAckWriter : IAlarmAckWriter + { + public string? LastItemAddress { get; private set; } + public string? LastComment { get; private set; } + public AlarmAckOperator? LastOperator { get; private set; } + public bool Disposed { get; private set; } + + public AlarmAckResult Acknowledge(string itemAddress, string comment, AlarmAckOperator ackOperator) + { + LastItemAddress = itemAddress; + LastComment = comment; + LastOperator = ackOperator; + return AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, itemAddress); + } + + public void Dispose() => Disposed = true; + } } diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs index 677dc37..85ffd22 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs @@ -24,7 +24,7 @@ public sealed class AlarmDispatcherTests FakeAlarmConsumer consumer = new FakeAlarmConsumer(); MxAccessEventQueue queue = new MxAccessEventQueue(); MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper()); - using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId); + using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId, new FakeAckWriter()); consumer.RaiseTransition(new MxAlarmTransitionEvent { @@ -56,7 +56,7 @@ public sealed class AlarmDispatcherTests FakeAlarmConsumer consumer = new FakeAlarmConsumer(); MxAccessEventQueue queue = new MxAccessEventQueue(); MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper()); - using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId); + using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId, new FakeAckWriter()); DateTime ts = new DateTime(2026, 5, 1, 17, 26, 14, 709, DateTimeKind.Utc); consumer.RaiseTransition(new MxAlarmTransitionEvent @@ -142,7 +142,7 @@ public sealed class AlarmDispatcherTests FakeAlarmConsumer consumer = new FakeAlarmConsumer(); MxAccessEventQueue queue = new MxAccessEventQueue(); MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper()); - using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId); + using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId, new FakeAckWriter()); consumer.RaiseTransition(new MxAlarmTransitionEvent { @@ -177,7 +177,7 @@ public sealed class AlarmDispatcherTests FakeAlarmConsumer consumer = new FakeAlarmConsumer(); MxAccessEventQueue queue = new MxAccessEventQueue(); MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper()); - using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId); + using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId, new FakeAckWriter()); consumer.RaiseTransition(new MxAlarmTransitionEvent { @@ -205,155 +205,140 @@ public sealed class AlarmDispatcherTests using AlarmDispatcher dispatcher = new AlarmDispatcher( consumer, new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), - SessionId); + SessionId, + new FakeAckWriter()); dispatcher.Subscribe(@"\\HOST\Galaxy!Area1"); Assert.Equal(@"\\HOST\Galaxy!Area1", consumer.LastSubscription); } - /// Verifies that acknowledge calls are forwarded to the consumer with operator identity. + /// Verifies the dispatcher resolves the ack item via the consumer then writes it as the operator. [Fact] - public void Acknowledge_WhenInvoked_ForwardsToConsumerWithFullOperatorIdentity() + public void Acknowledge_ResolvesTargetThenWritesWithOperator() { - FakeAlarmConsumer consumer = new FakeAlarmConsumer(); - consumer.AcknowledgeReturn = 0; + 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); - + consumer, new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), SessionId, writer); Guid guid = Guid.NewGuid(); - int rc = dispatcher.Acknowledge( - guid, "Acked", "alice", "WS01", "CORP", "Alice Smith"); + AlarmAckOperator op = new AlarmAckOperator { UserName = "alice", Password = "pw" }; - Assert.Equal(0, rc); - Assert.Equal(guid, consumer.LastAckGuid); - Assert.Equal("Acked", consumer.LastAckComment); - Assert.Equal("alice", consumer.LastAckOperatorName); - Assert.Equal("WS01", consumer.LastAckOperatorNode); - Assert.Equal("CORP", consumer.LastAckOperatorDomain); - Assert.Equal("Alice Smith", consumer.LastAckOperatorFullName); + 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); } - /// Verifies that a managed ack client takes GUID acks with full operator identity. + /// Verifies an unresolved target short-circuits to its failure without calling the writer. [Fact] - public void Acknowledge_WithManagedAckClient_RoutesGuidAckToManagedClient() + public void Acknowledge_WhenTargetNotFound_DoesNotWrite() { - FakeAlarmConsumer consumer = new FakeAlarmConsumer { AcknowledgeReturn = -1 }; - FakeManagedAckClient managed = new FakeManagedAckClient { AcknowledgeReturn = 0 }; + 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, - managed); - dispatcher.Subscribe(@"\\HOST\Galaxy!Area1"); + consumer, new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), SessionId, writer); - Guid guid = Guid.NewGuid(); - int rc = dispatcher.Acknowledge(guid, "Acked", "alice", "WS01", "CORP", "Alice Smith"); + AlarmAckResult result = dispatcher.Acknowledge(Guid.NewGuid(), "c", new AlarmAckOperator { UserName = "alice" }); - Assert.Equal(0, rc); - Assert.Equal(@"\\HOST\Galaxy!Area1", managed.LastSubscription); - Assert.Equal(guid, managed.LastAckGuid); - Assert.Equal("CORP", managed.LastAckOperatorDomain); - Assert.Equal("Alice Smith", managed.LastAckOperatorFullName); - Assert.Equal(Guid.Empty, consumer.LastAckGuid); + Assert.Equal(AlarmAckOutcome.AlarmNotFound, result.Outcome); + Assert.Equal("gone", result.Diagnostic); + Assert.Null(writer.LastItemAddress); } - /// - /// Verifies that a failed managed-client lifecycle neither fails the alarm - /// subscription nor leaves acks routed to the broken client. - /// + /// Verifies acknowledge-by-name resolves with the full tuple and writes the resolved item. [Fact] - public void Subscribe_WhenManagedAckClientFails_FallsBackToConsumerAck() + public void AcknowledgeByName_ResolvesTupleThenWritesWithOperator() { - FakeAlarmConsumer consumer = new FakeAlarmConsumer { AcknowledgeReturn = 0 }; - FakeManagedAckClient managed = new FakeManagedAckClient { ThrowOnSubscribe = true }; + FakeAlarmConsumer consumer = new FakeAlarmConsumer + { + AckTarget = AlarmAckTarget.For("TestMachine_001.TestAlarm001.AckMsg"), + }; + FakeAckWriter writer = new FakeAckWriter(); using AlarmDispatcher dispatcher = new AlarmDispatcher( - consumer, - new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), - SessionId, - managed); + consumer, new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), SessionId, writer); + AlarmAckOperator op = new AlarmAckOperator { UserName = "alice", Domain = "CORP" }; - dispatcher.Subscribe(@"\\HOST\Galaxy!Area1"); - Guid guid = Guid.NewGuid(); - int rc = dispatcher.Acknowledge(guid, "Acked", "alice", "WS01", "CORP", "Alice Smith"); + AlarmAckResult result = dispatcher.AcknowledgeByName( + "TestMachine_001.TestAlarm001", "Galaxy", "TestArea", "ack", op); - Assert.Equal(@"\\HOST\Galaxy!Area1", consumer.LastSubscription); - Assert.True(managed.Disposed); - Assert.Equal(0, rc); - Assert.Equal(guid, consumer.LastAckGuid); - Assert.Equal(Guid.Empty, managed.LastAckGuid); + Assert.Equal(AlarmAckOutcome.Applied, result.Outcome); + Assert.Equal(("TestMachine_001.TestAlarm001", "Galaxy", "TestArea"), consumer.LastResolvedName); + Assert.Equal("TestMachine_001.TestAlarm001.AckMsg", writer.LastItemAddress); + Assert.Equal("ack", writer.LastComment); + Assert.Same(op, writer.LastOperator); + } + + /// Verifies a NotAckable by-name target short-circuits without calling the writer. + [Fact] + public void AcknowledgeByName_WhenNotAckable_DoesNotWrite() + { + FakeAlarmConsumer consumer = new FakeAlarmConsumer { AckTarget = AlarmAckTarget.NotAckable("intouch") }; + FakeAckWriter writer = new FakeAckWriter(); + using AlarmDispatcher dispatcher = new AlarmDispatcher( + consumer, new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), SessionId, writer); + + AlarmAckResult result = dispatcher.AcknowledgeByName("Tag1", "InTouch", "G", "c", new AlarmAckOperator()); + + Assert.Equal(AlarmAckOutcome.NotAckable, result.Outcome); + Assert.Null(writer.LastItemAddress); } /// /// Verifies that while the failover consumer serves the subtag standby, - /// GUID acks (synthetic GUIDs) stay on the consumer, not the managed client. + /// GUID acks resolve through the standby (its synthetic GUIDs). /// [Fact] - public void Acknowledge_WhenFailoverInSubtagMode_KeepsGuidAckOnConsumer() + public void Acknowledge_WhenFailoverInSubtagMode_ResolvesThroughStandby() { FakeAlarmConsumer primary = new FakeAlarmConsumer { ThrowOnSubscribe = true }; - FakeAlarmConsumer standby = new FakeAlarmConsumer { AcknowledgeReturn = 0 }; + FakeAlarmConsumer standby = new FakeAlarmConsumer { AckTarget = AlarmAckTarget.For("Standby.AckMsg") }; FailoverAlarmConsumer failover = new FailoverAlarmConsumer( primary, standby, new FailoverSettings(threshold: 1, probeIntervalSeconds: 0, stableProbes: 3)); - FakeManagedAckClient managed = new FakeManagedAckClient { AcknowledgeReturn = -1 }; + FakeAckWriter writer = new FakeAckWriter(); using AlarmDispatcher dispatcher = new AlarmDispatcher( failover, new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), SessionId, - managed); + writer); dispatcher.Subscribe(@"\\HOST\Galaxy!Area1"); Assert.Equal(AlarmProviderMode.Subtag, failover.Mode); Guid guid = Guid.NewGuid(); - int rc = dispatcher.Acknowledge(guid, "Acked", "alice", "WS01", "CORP", "Alice Smith"); + dispatcher.Acknowledge(guid, "Acked", new AlarmAckOperator { UserName = "alice" }); - Assert.Equal(0, rc); - Assert.Equal(guid, standby.LastAckGuid); - Assert.Equal(Guid.Empty, managed.LastAckGuid); + Assert.Equal(guid, standby.LastResolvedGuid); + Assert.Equal(Guid.Empty, primary.LastResolvedGuid); + Assert.Equal("Standby.AckMsg", writer.LastItemAddress); } - /// Verifies that disposing the dispatcher disposes the managed ack client. + /// Verifies the dispatcher does not dispose the writer: the handler owns it. [Fact] - public void Dispose_WithManagedAckClient_DisposesManagedClient() + public void Dispose_DoesNotDisposeAckWriter() { - FakeManagedAckClient managed = new FakeManagedAckClient(); + FakeAckWriter writer = new FakeAckWriter(); AlarmDispatcher dispatcher = new AlarmDispatcher( new FakeAlarmConsumer(), new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), SessionId, - managed); + writer); dispatcher.Dispose(); - Assert.True(managed.Disposed); + Assert.False(writer.Disposed); } - /// Verifies that acknowledge-by-name calls are forwarded to the consumer. + /// Verifies the constructor rejects a null ack writer. [Fact] - public void AcknowledgeByName_WhenInvoked_ForwardsToConsumerWithFullTuple() + public void Constructor_NullAckWriter_Throws() { - FakeAlarmConsumer consumer = new FakeAlarmConsumer { AcknowledgeReturn = 0 }; - using AlarmDispatcher dispatcher = new AlarmDispatcher( - consumer, + Assert.Throws(() => new AlarmDispatcher( + new FakeAlarmConsumer(), new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), - SessionId); - - int rc = dispatcher.AcknowledgeByName( - alarmName: "TestMachine_001.TestAlarm001", - providerName: "Galaxy", - groupName: "TestArea", - ackComment: "ack", - ackOperatorName: "alice", - ackOperatorNode: "WS", - ackOperatorDomain: "CORP", - ackOperatorFullName: "Alice Smith"); - - Assert.Equal(0, rc); - Assert.NotNull(consumer.LastAckByNameTuple); - Assert.Equal("TestMachine_001.TestAlarm001", consumer.LastAckByNameTuple!.Value.Name); - Assert.Equal("Galaxy", consumer.LastAckByNameTuple!.Value.Provider); - Assert.Equal("TestArea", consumer.LastAckByNameTuple!.Value.Group); + SessionId, + null!)); } /// Verifies that consumer alarm records are mapped to proto snapshots. @@ -391,7 +376,8 @@ public sealed class AlarmDispatcherTests using AlarmDispatcher dispatcher = new AlarmDispatcher( consumer, new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), - SessionId); + SessionId, + new FakeAckWriter()); IReadOnlyList snapshots = dispatcher.SnapshotActiveAlarms(out _); Assert.Equal(2, snapshots.Count); @@ -447,7 +433,8 @@ public sealed class AlarmDispatcherTests using AlarmDispatcher dispatcher = new AlarmDispatcher( consumer, new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), - SessionId); + SessionId, + new FakeAckWriter()); IReadOnlyList snapshots = dispatcher.SnapshotActiveAlarms(out _); Assert.Equal(2, snapshots.Count); @@ -482,7 +469,8 @@ public sealed class AlarmDispatcherTests using AlarmDispatcher dispatcher = new AlarmDispatcher( consumer, new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), - SessionId); + SessionId, + new FakeAckWriter()); IReadOnlyList snapshots = dispatcher.SnapshotActiveAlarms(out bool truncated); @@ -512,7 +500,8 @@ public sealed class AlarmDispatcherTests using AlarmDispatcher dispatcher = new AlarmDispatcher( consumer, new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), - SessionId); + SessionId, + new FakeAckWriter()); IReadOnlyList snapshots = dispatcher.SnapshotActiveAlarms(out bool truncated); @@ -542,7 +531,7 @@ public sealed class AlarmDispatcherTests FakeAlarmConsumer consumer = new FakeAlarmConsumer(); MxAccessEventQueue queue = new MxAccessEventQueue(); MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper()); - AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId); + AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId, new FakeAckWriter()); dispatcher.Dispose(); @@ -569,20 +558,12 @@ public sealed class AlarmDispatcherTests /// Gets the last subscription reference. public string? LastSubscription { get; private set; } - /// Gets the GUID from the last acknowledge call. - public Guid LastAckGuid { get; private set; } - /// Gets the comment from the last acknowledge call. - public string? LastAckComment { get; private set; } - /// Gets the operator name from the last acknowledge call. - public string? LastAckOperatorName { get; private set; } - /// Gets the operator node from the last acknowledge call. - public string? LastAckOperatorNode { get; private set; } - /// Gets the operator domain from the last acknowledge call. - public string? LastAckOperatorDomain { get; private set; } - /// Gets the operator full name from the last acknowledge call. - public string? LastAckOperatorFullName { get; private set; } - /// Gets or sets the return code for acknowledge operations. - public int AcknowledgeReturn { get; set; } + /// Gets or sets the ack target both resolve methods return. + public AlarmAckTarget AckTarget { get; set; } = AlarmAckTarget.For("Fake.Alarm.AckMsg"); + /// Gets the GUID from the last ResolveAckTarget call. + public Guid LastResolvedGuid { get; private set; } + /// Gets the (name, provider, group) from the last ResolveAckTargetByName call. + public (string Name, string Provider, string Group)? LastResolvedName { get; private set; } /// Gets or sets the result collection for snapshot operations. public IReadOnlyList SnapshotResult { get; set; } = Array.Empty(); @@ -607,37 +588,19 @@ public sealed class AlarmDispatcherTests } /// - public int AcknowledgeByGuid( - Guid alarmGuid, - string ackComment, - string ackOperatorName, - string ackOperatorNode, - string ackOperatorDomain, - string ackOperatorFullName) + public AlarmAckTarget ResolveAckTarget(Guid alarmGuid) { - LastAckGuid = alarmGuid; - LastAckComment = ackComment; - LastAckOperatorName = ackOperatorName; - LastAckOperatorNode = ackOperatorNode; - LastAckOperatorDomain = ackOperatorDomain; - LastAckOperatorFullName = ackOperatorFullName; - return AcknowledgeReturn; + LastResolvedGuid = alarmGuid; + return AckTarget; } /// - public int AcknowledgeByName( - string alarmName, string providerName, string groupName, - string ackComment, string ackOperatorName, string ackOperatorNode, - string ackOperatorDomain, string ackOperatorFullName) + public AlarmAckTarget ResolveAckTargetByName(string alarmName, string providerName, string groupName) { - LastAckByNameTuple = (alarmName, providerName, groupName); - LastAckOperatorName = ackOperatorName; - return AcknowledgeReturn; + LastResolvedName = (alarmName, providerName, groupName); + return AckTarget; } - /// Gets the last acknowledge-by-name tuple (alarm name, provider, group). - public (string Name, string Provider, string Group)? LastAckByNameTuple { get; private set; } - /// Gets or sets the truncation verdict this consumer reports with its snapshot. public bool SnapshotTruncated { get; set; } @@ -664,49 +627,22 @@ public sealed class AlarmDispatcherTests } } - private sealed class FakeManagedAckClient : IManagedAlarmAckClient + private sealed class FakeAckWriter : IAlarmAckWriter { - /// Gets the last subscription expression. - public string? LastSubscription { get; private set; } - /// Gets the GUID from the last acknowledge call. - public Guid LastAckGuid { get; private set; } - /// Gets the operator domain from the last acknowledge call. - public string? LastAckOperatorDomain { get; private set; } - /// Gets the operator full name from the last acknowledge call. - public string? LastAckOperatorFullName { get; private set; } - /// Gets or sets the return code for acknowledge operations. - public int AcknowledgeReturn { get; set; } - /// Gets or sets a value indicating whether throws. - public bool ThrowOnSubscribe { get; set; } - /// Gets a value indicating whether this instance has been disposed. + 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 void Subscribe(string subscription) + public AlarmAckResult Acknowledge(string itemAddress, string comment, AlarmAckOperator ackOperator) { - if (ThrowOnSubscribe) throw new InvalidOperationException("RegisterConsumer returned 5."); - LastSubscription = subscription; + LastItemAddress = itemAddress; + LastComment = comment; + LastOperator = ackOperator; + return Result; } - /// - public int AcknowledgeByGuid( - Guid alarmGuid, - string comment, - string operatorName, - string operatorNode, - string operatorDomain, - string operatorFullName) - { - LastAckGuid = alarmGuid; - LastAckOperatorDomain = operatorDomain; - LastAckOperatorFullName = operatorFullName; - return AcknowledgeReturn; - } - - /// - public void Dispose() - { - Disposed = true; - } + public void Dispose() => Disposed = true; } } diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessStaSessionTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessStaSessionTests.cs index 4659d70..07d378d 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessStaSessionTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessStaSessionTests.cs @@ -663,15 +663,13 @@ public sealed class MxAccessStaSessionTests } /// - public int Acknowledge(Guid alarmGuid, string comment, string operatorUser, - string operatorNode, string operatorDomain, string operatorFullName) - => 0; + public AlarmAckResult Acknowledge(Guid alarmGuid, string comment, AlarmAckOperator ackOperator) + => AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, string.Empty); /// - public int AcknowledgeByName(string alarmName, string providerName, string groupName, - string comment, string operatorUser, string operatorNode, - string operatorDomain, string operatorFullName) - => 0; + public AlarmAckResult AcknowledgeByName(string alarmName, string providerName, string groupName, + string comment, AlarmAckOperator ackOperator) + => AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, string.Empty); /// public IReadOnlyList QueryActive( diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmManagedGuidAckLiveSmokeTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmManagedGuidAckLiveSmokeTests.cs deleted file mode 100644 index f21a93f..0000000 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmManagedGuidAckLiveSmokeTests.cs +++ /dev/null @@ -1,229 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Diagnostics; -using System.Linq; -using System.Threading; -using ZB.MOM.WW.MxGateway.Contracts.Proto; -using ZB.MOM.WW.MxGateway.Worker.MxAccess; -using Xunit; -using Xunit.Abstractions; - -namespace ZB.MOM.WW.MxGateway.Worker.Tests.Probes; - -/// -/// Live dev-rig smoke test for the MANAGED GUID-ack path — the -/// wired into -/// . Where AlarmsLiveSmokeTests acks by -/// name through the wnwrap COM consumer, this acks by GUID through AVEVA's -/// modern aaAlarmManagedClient.AlarmClient.AlarmAckByGUID — the path -/// that carries operator domain + full name and is not E_NOTIMPL. -/// -/// Flow: subscribe a wnwrap reader + a managed ack client to -/// \\<machine>\Galaxy!DEV; wait for a Raise; pull a real -/// (non-degraded) alarm GUID from the reader's snapshot; ack it by GUID via -/// the dispatcher (which routes to the managed client); then assert the ack -/// registers as a subsequent -/// transition (and that the active snapshot shows the alarm acked). -/// -/// This is the runtime confirmation the static work could not close: that an -/// authenticated GUID ack actually flips state. rc=0 alone is "accepted", -/// not "applied" — the Acknowledge transition is the real assertion. For a -/// SECURED alarm the ack must run under an operator with Operate permission; -/// run the dev-rig flip script and, if the test UDA is secured, an -/// authenticated operator session (see docs/AlarmProbeFindings.md). -/// -/// Skip-gated; flip Skip=null on the dev rig with AVEVA + -/// aaAlarmManagedClient.dll present and the flip script running. -/// -public sealed class AlarmManagedGuidAckLiveSmokeTests -{ - private static readonly string SubscriptionExpression = - $@"\\{Environment.MachineName}\Galaxy!DEV"; - private static readonly TimeSpan TransitionWaitTimeout = TimeSpan.FromSeconds(20); - private static readonly TimeSpan RaiseWait = TimeSpan.FromSeconds(30); - private static readonly TimeSpan AckConfirmWait = TimeSpan.FromSeconds(30); - - private const string SessionId = "alarms-managed-guid-ack-smoke"; - - private readonly ITestOutputHelper output; - private readonly Stopwatch elapsed = Stopwatch.StartNew(); - private readonly ConcurrentQueue log = new ConcurrentQueue(); - - /// Initializes a new instance of the AlarmManagedGuidAckLiveSmokeTests class. - /// Test output helper for logging. - public AlarmManagedGuidAckLiveSmokeTests(ITestOutputHelper output) - { - this.output = output; - } - - /// Acks a live alarm by GUID through the managed client and verifies the acked transition. - [Fact(Skip = "Live dev-rig smoke test — flip Skip=null with AVEVA + aaAlarmManagedClient.dll + the alarm flip script, and an operator authorized to ack.")] - public void ManagedGuidAck_AcksActiveAlarm_RegistersAcknowledge() - { - Exception? threadException = null; - var done = new ManualResetEventSlim(false); - var thread = new Thread(() => - { - try { RunSmoke(); } - catch (Exception ex) { threadException = ex; } - finally { done.Set(); } - }); - thread.IsBackground = false; - thread.SetApartmentState(ApartmentState.STA); - thread.Start(); - done.Wait(); - thread.Join(); - - output.WriteLine($"Captured {log.Count} log line(s):"); - while (log.TryDequeue(out string? line)) output.WriteLine(line); - - if (threadException != null) throw threadException; - } - - private void RunSmoke() - { - Log($"Subscription expression: {SubscriptionExpression}"); - - MxAccessEventQueue queue = new MxAccessEventQueue(); - WnWrapAlarmConsumer consumer = new WnWrapAlarmConsumer( - new WNWRAPCONSUMERLib.wwAlarmConsumerClass(), - maxAlarmsPerFetch: 1024); - MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper()); - - // Construct the managed ack client directly (production selects it via - // AaManagedAlarmAckConsumer.EnvGatedFactory when MXGATEWAY_USE_MANAGED_ALARM_ACK=1). - using IManagedAlarmAckClient managedAck = - new AaManagedAlarmAckConsumer(SessionId + ".ack"); - using AlarmDispatcher dispatcher = - new AlarmDispatcher(consumer, sink, SessionId, managedAck); - - Log("Constructed reader + managed ack client + dispatcher."); - dispatcher.Subscribe(SubscriptionExpression); - Log("Subscribe (reader + managed ack) -> ok. Driving PollOnce from this STA..."); - - // 1. Wait for a Raise so there is an active, unacked alarm to ack. - OnAlarmTransitionEvent? raiseBody = null; - DateTime raiseDeadline = DateTime.UtcNow + RaiseWait; - while (DateTime.UtcNow < raiseDeadline && raiseBody is null) - { - WorkerEvent? evt = WaitForTransition(queue, TransitionWaitTimeout, "raise", consumer); - if (evt is null) break; - OnAlarmTransitionEvent body = evt.Event.OnAlarmTransition; - Log("Transition: " + DescribeTransition(body)); - Assert.Equal(SessionId, evt.Event.SessionId); - if (body.TransitionKind == AlarmTransitionKind.Raise) raiseBody = body; - } - Assert.NotNull(raiseBody); - - // 2. Pull a REAL alarm GUID from the reader's snapshot (alarmmgr mode). - // Skip degraded/subtag records — their GUID is synthetic and not - // valid for AlarmAckByGUID. - var records = consumer.SnapshotActiveAlarms(out _); - Log($"Snapshot records: {records.Count}"); - foreach (var r in records) - Log($" rec guid={r.AlarmGuid} state={r.State} degraded={r.Degraded} ref={r.ProviderName}!{r.Group}.{r.TagName}"); - - MxAlarmSnapshotRecord? target = records.FirstOrDefault(r => - !r.Degraded && - r.AlarmGuid != Guid.Empty && - r.State == MxAlarmStateKind.UnackAlm); - Assert.NotNull(target); - Log($"Ack target GUID={target!.AlarmGuid} ref={target.ProviderName}!{target.Group}.{target.TagName}"); - - // 3. Ack by GUID through the dispatcher -> managed AlarmAckByGUID, with - // the current Windows identity (domain + full name are carried - // natively on this path, unlike the wnwrap 6-arg AckByName). - string user = Environment.UserName; - string node = Environment.MachineName; - string domain = Environment.UserDomainName ?? string.Empty; - Log($"Ack identity user='{user}' node='{node}' domain='{domain}'"); - - int rc = dispatcher.Acknowledge( - alarmGuid: target.AlarmGuid, - ackComment: "managed-guid-ack smoke", - ackOperatorName: user, - ackOperatorNode: node, - ackOperatorDomain: domain, - ackOperatorFullName: user); - Log($"dispatcher.Acknowledge(managed GUID) -> rc={rc}"); - Assert.Equal(0, rc); - - // 4. The ack must APPLY, not merely be accepted: wait for an - // Acknowledge transition on the same alarm, or the snapshot showing - // it acked. rc=0 with neither is the inert-ack failure the probe saw. - bool acked = false; - DateTime confirmDeadline = DateTime.UtcNow + AckConfirmWait; - while (DateTime.UtcNow < confirmDeadline && !acked) - { - WorkerEvent? evt = WaitForTransition(queue, TransitionWaitTimeout, "post-ack", consumer); - if (evt is not null) - { - OnAlarmTransitionEvent body = evt.Event.OnAlarmTransition; - Log("Post-ack transition: " + DescribeTransition(body)); - if (body.TransitionKind == AlarmTransitionKind.Acknowledge) - { - acked = true; - break; - } - } - - // Also accept the active snapshot flipping to an acked state for the - // target GUID, in case the ack delta was folded without a discrete - // Acknowledge transition. - var now = consumer.SnapshotActiveAlarms(out _); - if (now.Any(r => r.AlarmGuid == target.AlarmGuid && - (r.State == MxAlarmStateKind.AckAlm || !string.IsNullOrEmpty(r.OperatorName)))) - { - Log("Snapshot shows target GUID acked (AckAlm / operator populated)."); - acked = true; - } - } - - Assert.True( - acked, - "Managed GUID ack returned rc=0 but no Acknowledge transition or acked snapshot was observed " + - "within the confirm window — the ack was accepted but not applied (see docs/AlarmProbeFindings.md; " + - "most likely the alarm's MxSecurityOperate classification requires an authenticated operator)."); - } - - private WorkerEvent? WaitForTransition( - MxAccessEventQueue queue, - TimeSpan timeout, - string label, - WnWrapAlarmConsumer consumer) - { - DateTime deadline = DateTime.UtcNow + timeout; - int pollCount = 0; - while (DateTime.UtcNow < deadline) - { - try - { - consumer.PollOnce(); - pollCount++; - } - catch (Exception ex) - { - Log($"PollOnce threw on poll #{pollCount + 1}: {ex.GetType().Name}: {ex.Message}"); - throw; - } - if (queue.TryDequeue(out WorkerEvent? evt) && evt is not null) - { - if (evt.Event.Family == MxEventFamily.OnAlarmTransition) return evt; - Log($"Skipped non-alarm event (family={evt.Event.Family}) while waiting for {label}."); - } - Thread.Sleep(500); - } - Log($"Timed out waiting for {label} transition after {timeout.TotalSeconds:F0}s (polls={pollCount})."); - return null; - } - - private static string DescribeTransition(OnAlarmTransitionEvent body) - { - return string.Format( - "kind={0} ref='{1}' operator='{2}' comment='{3}' ts={4:o}", - body.TransitionKind, body.AlarmFullReference, body.OperatorUser, body.OperatorComment, - body.TransitionTimestamp?.ToDateTime() ?? DateTime.MinValue); - } - - private void Log(string line) => log.Enqueue($"[t={elapsed.Elapsed.TotalSeconds:F3}s] {line}"); -} diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmsLiveSmokeTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmsLiveSmokeTests.cs index 181add7..d8c6a3d 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmsLiveSmokeTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmsLiveSmokeTests.cs @@ -86,7 +86,12 @@ public sealed class AlarmsLiveSmokeTests new WNWRAPCONSUMERLib.wwAlarmConsumerClass(), maxAlarmsPerFetch: 1024); MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper()); - using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId); + using AlarmAckWriter ackWriter = new AlarmAckWriter( + new MxAccessComObjectFactory(), + () => new ZB.MOM.WW.MxGateway.Worker.Sta.StaMessagePump().PumpPendingMessages(), + TimeSpan.FromSeconds(5), + AlarmAckWriter.DefaultClientName); + using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId, ackWriter); Log("Constructed consumer + sink + dispatcher."); dispatcher.Subscribe(SubscriptionExpression); @@ -139,26 +144,23 @@ public sealed class AlarmsLiveSmokeTests $"Captured reference '{raiseBody.AlarmFullReference}' did not parse as Provider!Group.Tag."); Log($"Ack target: provider='{provider}' group='{group}' name='{alarmName}'"); - // Try the ack with real Windows identity. AVEVA's AlarmAckByName - // may reject synthetic operator strings; using the current process - // identity gives the alarm-history a recognizable principal. - string realUser = Environment.UserName; - string realNode = Environment.MachineName; - string realDomain = Environment.UserDomainName ?? string.Empty; - Log($"Ack identity: user='{realUser}' node='{realNode}' domain='{realDomain}'"); + // The ack authenticates a Galaxy operator and writes .AckMsg. + // Operator credentials come from the environment (never logged); + // the user defaults to the current process identity. + AlarmAckOperator ackOperator = new AlarmAckOperator + { + UserName = Environment.GetEnvironmentVariable("MXGATEWAY_PROBE_ACK_USER") ?? Environment.UserName, + Password = Environment.GetEnvironmentVariable("MXGATEWAY_PROBE_ACK_PASSWORD") ?? string.Empty, + }; + Log($"Ack identity: {ackOperator}"); - int rc = dispatcher.AcknowledgeByName( - alarmName: alarmName, - providerName: provider, - groupName: group, - ackComment: "alarms-live-smoke ack", - ackOperatorName: realUser, - ackOperatorNode: realNode, - ackOperatorDomain: realDomain, - ackOperatorFullName: realUser); - Log($"AcknowledgeByName(real identity) -> rc={rc}"); + AlarmAckResult result = dispatcher.AcknowledgeByName( + alarmName, provider, group, "alarms-live-smoke ack", ackOperator); + Log($"AcknowledgeByName -> outcome={result.Outcome} native={result.NativeStatus} diag='{result.Diagnostic}'"); - Assert.Equal(0, rc); + Assert.True( + result.Outcome == AlarmAckOutcome.Applied || result.Outcome == AlarmAckOutcome.Unconfirmed, + $"Ack outcome {result.Outcome}: {result.Diagnostic}"); // 4. Wait for the post-ack transition. With the alarm flipping every // 10s and the consumer polling every 500ms, the next state diff --git a/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerPipeSession.cs b/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerPipeSession.cs index 44da620..c9afa8d 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerPipeSession.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerPipeSession.cs @@ -76,7 +76,7 @@ public sealed class WorkerPipeSession options, () => Process.GetCurrentProcess().Id, new WorkerPipeSessionOptions(), - () => new MxAccessStaSession((eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null, managedAckFactory: AaManagedAlarmAckConsumer.EnvGatedFactory)), + () => new MxAccessStaSession((eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null)), logger, stream) { @@ -98,7 +98,7 @@ public sealed class WorkerPipeSession options, processIdProvider, new WorkerPipeSessionOptions(), - () => new MxAccessStaSession((eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null, managedAckFactory: AaManagedAlarmAckConsumer.EnvGatedFactory)), + () => new MxAccessStaSession((eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null)), logger: null) { } @@ -1585,7 +1585,7 @@ public sealed class WorkerPipeSession // parameterless CompleteStartupHandshakeAsync is used without a // prior factory call. _runtimeSession ??= new MxAccessStaSession( - (eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null, managedAckFactory: AaManagedAlarmAckConsumer.EnvGatedFactory)); + (eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null)); IWorkerRuntimeSession session = _runtimeSession; try { diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AaManagedAlarmAckConsumer.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AaManagedAlarmAckConsumer.cs deleted file mode 100644 index 8c68f9f..0000000 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AaManagedAlarmAckConsumer.cs +++ /dev/null @@ -1,231 +0,0 @@ -using System; -using System.IO; -using System.Reflection; -using WNWRAPCONSUMERLib; - -namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; - -/// -/// Acknowledge path over AVEVA's modern managed alarm client, -/// aaAlarmManagedClient.AlarmClient (the C++/CLI assembly the AVEVA -/// Alarm Control itself uses: AlarmClient → CwwAlarmConsumer → -/// alarmlst.dll → alarmmgr.exe). Preferred over a hand-rolled native -/// WNAL consumer because AlarmAckByGUID performs the -/// register → match-by-GUID → submit sequence internally — the record -/// layout and selection semantics that are provider-defined (and therefore -/// NOT safely recoverable by static RE of alarmlst.dll) stay inside AVEVA's -/// own code. See for why the native -/// acquisition path is deliberately unfinished. -/// -/// The AlarmClient members are famorasm (protected-internal) -/// and mxaccessgw is not an InternalsVisibleTo friend, so everything -/// here is invoked by reflection. In full-trust .NET Framework 4.8 that is -/// permitted. Method shapes are confirmed from the assembly metadata: -/// .ctor() (internal) -/// int InitializeConsumer(string appName) -/// int RegisterConsumer(int hWnd, string product, string app, string version, bool) -/// int Subscribe(string expr, short fromPri, short toPri, -/// localQueryType, localSortFlags, -/// localAlarmFilterState mask, localAlarmFilterState spec) -/// int AlarmAckByGUID(System.Guid guid, string comment, string oper, -/// string node, string domain, string fullName) -/// int DeregisterConsumer() / int UninitializeConsumer() / Dispose() -/// -/// THREADING: the underlying consumer is apartment-affine (same as the -/// wnwrap COM path). Construct and call this only on the worker STA -/// (marshal via StaRuntime.InvokeAsync). -/// -/// x86 ONLY — aaAlarmManagedClient.dll is a 32-bit mixed-mode PE. -/// -/// OPEN (confirm at runtime, flagged inline): the trailing bool of -/// RegisterConsumer (passed false here); whether -/// Subscribe is actually required before an ack by GUID; and the -/// native status returned on a real secured ack. -/// -public sealed class AaManagedAlarmAckConsumer : IManagedAlarmAckClient -{ - /// Default install location of the managed alarm client (eac.dll folder). - public const string DefaultAssemblyPath = - @"C:\Program Files (x86)\ArchestrA\Framework\Bin\Invensys_Systems__Inc__eac.dll\aaAlarmManagedClient.dll"; - - /// - /// Env var that opts the worker into the managed ack path. Unset/anything - /// other than "1" keeps the existing wnwrap ack behavior. - /// - public const string EnableEnvVar = "MXGATEWAY_USE_MANAGED_ALARM_ACK"; - - private const string AckApplicationName = "ZB.MOM.WW.MxGateway.ack"; - - /// - /// Production factory for the seam: - /// returns a managed ack client only when == "1", - /// otherwise (ack stays on the wnwrap path). Never - /// throws — a construction failure (missing DLL, etc.) degrades to the - /// wnwrap path rather than breaking the alarm subscription. - /// - public static IManagedAlarmAckClient? EnvGatedFactory() - { - if (!string.Equals(Environment.GetEnvironmentVariable(EnableEnvVar), "1", StringComparison.Ordinal)) - return null; - try - { - return new AaManagedAlarmAckConsumer(AckApplicationName); - } - catch - { - // Opt-in enhancement only: if the managed client can't load, fall - // back to the wnwrap ack path rather than failing alarm reads. - return null; - } - } - - private const string TypeName = "aaAlarmManagedClient.AlarmClient"; - private const BindingFlags Inst = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; - - private readonly object syncRoot = new(); - private readonly object client; - private readonly MethodInfo initializeConsumer; - private readonly MethodInfo registerConsumer; - private readonly MethodInfo subscribe; - private readonly MethodInfo alarmAckByGuid; - private readonly MethodInfo? deregisterConsumer; - private readonly MethodInfo? uninitializeConsumer; - - private bool subscribed; - private bool disposed; - - /// Consumer application name (e.g. "MxGateway.ack"). - /// Override for . - public AaManagedAlarmAckConsumer(string applicationName, string? assemblyPath = null) - { - if (string.IsNullOrWhiteSpace(applicationName)) - throw new ArgumentException("Application name is required.", nameof(applicationName)); - - string path = assemblyPath ?? DefaultAssemblyPath; - if (!File.Exists(path)) - throw new FileNotFoundException($"aaAlarmManagedClient.dll not found at '{path}'.", path); - - Assembly asm = Assembly.LoadFrom(path); - Type type = asm.GetType(TypeName, throwOnError: true) - ?? throw new InvalidOperationException($"Type '{TypeName}' not found in {path}."); - - initializeConsumer = Resolve(type, "InitializeConsumer"); - registerConsumer = Resolve(type, "RegisterConsumer"); - subscribe = Resolve(type, "Subscribe"); - alarmAckByGuid = Resolve(type, "AlarmAckByGUID"); - deregisterConsumer = type.GetMethod("DeregisterConsumer", Inst, null, Type.EmptyTypes, null); - uninitializeConsumer = type.GetMethod("UninitializeConsumer", Inst, null, Type.EmptyTypes, null); - - client = Activator.CreateInstance(type, nonPublic: true) - ?? throw new InvalidOperationException($"Could not construct '{TypeName}'."); - - ApplicationName = applicationName; - } - - /// The consumer application name this instance registered under. - public string ApplicationName { get; } - - private static MethodInfo Resolve(Type type, string name) => - type.GetMethod(name, Inst) - ?? throw new MissingMethodException(TypeName, name); - - /// - /// Run the Initialize → Register → Subscribe lifecycle once, mirroring the - /// proven wnwrap argument values. is the - /// alarm query root, e.g. \\NODE\Galaxy!Area. Throws if any native - /// status is non-zero. - /// - public void Subscribe(string subscription) - { - if (subscription is null) throw new ArgumentNullException(nameof(subscription)); - lock (syncRoot) - { - if (disposed) throw new ObjectDisposedException(nameof(AaManagedAlarmAckConsumer)); - if (subscribed) throw new InvalidOperationException("Subscribe was already called."); - - int init = (int)initializeConsumer.Invoke(client, new object[] { ApplicationName })!; - if (init != 0) throw new InvalidOperationException($"InitializeConsumer returned {init}."); - - // RegisterConsumer(hWnd, product, app, version, bool). hWnd=0 => pull model. - // The trailing bool is unconfirmed; false = client/non-modal is the safe default. - int reg = (int)registerConsumer.Invoke(client, new object[] - { - 0, "ZB.MOM.WW.MxGateway", ApplicationName, "1.0", false, - })!; - if (reg != 0) throw new InvalidOperationException($"RegisterConsumer returned {reg}."); - - int sub = (int)subscribe.Invoke(client, BuildSubscribeArgs(subscription))!; - if (sub != 0) throw new InvalidOperationException($"Subscribe('{subscription}') returned {sub}."); - - subscribed = true; - } - } - - /// - /// Acknowledge a single alarm by GUID with full operator identity. Returns - /// AVEVA's native status (0 = success). A zero return means accepted, not - /// necessarily applied — verify the alarm's acked state / Historian row - /// (see docs/AlarmProbeFindings.md). - /// - public int AcknowledgeByGuid( - Guid alarmGuid, - string comment, - string operatorName, - string operatorNode, - string operatorDomain, - string operatorFullName) - { - lock (syncRoot) - { - if (disposed) throw new ObjectDisposedException(nameof(AaManagedAlarmAckConsumer)); - if (!subscribed) throw new InvalidOperationException("Subscribe must be called before acknowledging."); - - return (int)alarmAckByGuid.Invoke(client, new object[] - { - alarmGuid, - comment ?? string.Empty, - operatorName ?? string.Empty, - operatorNode ?? string.Empty, - operatorDomain ?? string.Empty, - operatorFullName ?? string.Empty, - })!; - } - } - - /// - /// Build the 7 Subscribe args, boxing the proven wnwrap enum integers into - /// aaAlarmManagedClient's own enum parameter types (localQueryType / - /// localSortFlags / localAlarmFilterState) via . - /// - private object[] BuildSubscribeArgs(string subscription) - { - ParameterInfo[] p = subscribe.GetParameters(); - if (p.Length != 7) - throw new InvalidOperationException($"Unexpected AlarmClient.Subscribe arity {p.Length}; expected 7."); - - return new object[] - { - subscription, - (short)1, // wFromPri - (short)999, // wToPri - Enum.ToObject(p[3].ParameterType, (int)eQueryType.qtSummary), - Enum.ToObject(p[4].ParameterType, (int)eSortFlags.sfReturnNewestFirst), - Enum.ToObject(p[5].ParameterType, (int)eAlarmFilterState.asAlarmActiveNow), - Enum.ToObject(p[6].ParameterType, (int)eAlarmFilterState.asAlarmActiveNow), - }; - } - - /// - public void Dispose() - { - lock (syncRoot) - { - if (disposed) return; - disposed = true; - - try { if (subscribed) deregisterConsumer?.Invoke(client, Array.Empty()); } catch { /* best effort */ } - try { uninitializeConsumer?.Invoke(client, Array.Empty()); } catch { /* best effort */ } - (client as IDisposable)?.Dispose(); - } - } -} diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmCommandHandler.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmCommandHandler.cs index 3b10631..3648511 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmCommandHandler.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmCommandHandler.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Worker.Sta; namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; @@ -39,10 +40,12 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler private readonly Action? threadAffinityCheck; private readonly IMxAccessComObjectFactory? comFactory; private readonly Func, IMxAccessAlarmConsumer>? standbyFactory; - private readonly Func? managedAckFactory; + private readonly Func? ackWriterFactory; private readonly MxAccessEventMapper mapper = new MxAccessEventMapper(); private readonly object syncRoot = new object(); + private readonly IAlarmAckWriter deferredAckWriter; private AlarmDispatcher? dispatcher; + private IAlarmAckWriter? ackWriter; private FailoverAlarmConsumer? failoverConsumer; private EventHandler? providerModeChangedHandler; private string subscribeSessionId = string.Empty; @@ -118,14 +121,15 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler /// . Tests inject a fake so they need no /// live COM factory. /// - /// - /// Optional per-subscription factory for a managed ack client - /// (). When it yields a non-null - /// client, GUID-keyed acknowledges route through AVEVA's modern - /// AlarmClient.AlarmAckByGUID instead of the wnwrap COM path. - /// Defaults to (wnwrap ack). Production supplies - /// , which is a - /// no-op unless MXGATEWAY_USE_MANAGED_ALARM_ACK=1. + /// + /// Optional seam that builds the used for + /// every acknowledgement. Defaults to an over + /// that registers its own MXAccess server + /// (client name from ). + /// The writer is created once per handler, on the first acknowledgement + /// (so a subscription never fails because ack is unavailable), survives + /// unsubscribe/resubscribe, and is disposed with the handler. Tests + /// inject a fake. /// public AlarmCommandHandler( MxAccessEventQueue eventQueue, @@ -133,14 +137,15 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler Action? threadAffinityCheck, IMxAccessComObjectFactory? comFactory, Func, IMxAccessAlarmConsumer>? standbyFactory, - Func? managedAckFactory = null) + Func? ackWriterFactory = null) { this.eventQueue = eventQueue ?? throw new ArgumentNullException(nameof(eventQueue)); this.consumerFactory = consumerFactory ?? throw new ArgumentNullException(nameof(consumerFactory)); this.threadAffinityCheck = threadAffinityCheck; this.comFactory = comFactory; this.standbyFactory = standbyFactory; - this.managedAckFactory = managedAckFactory; + this.ackWriterFactory = ackWriterFactory; + this.deferredAckWriter = new DeferredAckWriter(this); } /// Gets a value indicating whether the handler is subscribed. @@ -181,14 +186,8 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler failover.ProviderModeChanged += providerModeChangedHandler; } - // Optional managed ack client (opt-in via env); null → wnwrap ack. - // A forced-subtag session never has alarmmgr GUIDs to ack, so it - // skips the extra managed consumer entirely. - IManagedAlarmAckClient? managedAck = command.ForcedMode == AlarmProviderMode.Subtag - ? null - : managedAckFactory?.Invoke(); MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(eventQueue, mapper); - dispatcher = new AlarmDispatcher(consumer, sink, subscribeSessionId, managedAck); + dispatcher = new AlarmDispatcher(consumer, sink, subscribeSessionId, deferredAckWriter); try { dispatcher.Subscribe(subscriptionExpression); @@ -323,47 +322,31 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler } /// - public int Acknowledge( - Guid alarmGuid, - string comment, - string operatorUser, - string operatorNode, - string operatorDomain, - string operatorFullName) + public AlarmAckResult Acknowledge(Guid alarmGuid, string comment, AlarmAckOperator ackOperator) { + if (ackOperator is null) throw new ArgumentNullException(nameof(ackOperator)); threadAffinityCheck?.Invoke(); - AlarmDispatcher? d = GetDispatcherOrThrow(); - return d.Acknowledge( - alarmGuid, - comment ?? string.Empty, - operatorUser ?? string.Empty, - operatorNode ?? string.Empty, - operatorDomain ?? string.Empty, - operatorFullName ?? string.Empty); + AlarmDispatcher d = GetDispatcherOrThrow(); + return d.Acknowledge(alarmGuid, comment ?? string.Empty, ackOperator); } /// - public int AcknowledgeByName( + public AlarmAckResult AcknowledgeByName( string alarmName, string providerName, string groupName, string comment, - string operatorUser, - string operatorNode, - string operatorDomain, - string operatorFullName) + AlarmAckOperator ackOperator) { + if (ackOperator is null) throw new ArgumentNullException(nameof(ackOperator)); threadAffinityCheck?.Invoke(); - AlarmDispatcher? d = GetDispatcherOrThrow(); + AlarmDispatcher d = GetDispatcherOrThrow(); return d.AcknowledgeByName( alarmName ?? string.Empty, providerName ?? string.Empty, groupName ?? string.Empty, comment ?? string.Empty, - operatorUser ?? string.Empty, - operatorNode ?? string.Empty, - operatorDomain ?? string.Empty, - operatorFullName ?? string.Empty); + ackOperator); } /// @@ -398,6 +381,60 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler d?.PollOnce(); } + private IAlarmAckWriter GetOrCreateAckWriter() + { + lock (syncRoot) + { + if (disposed) throw new ObjectDisposedException(nameof(AlarmCommandHandler)); + 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."); + } + + // StaMessagePump.PumpPendingMessages is PeekMessage/DispatchMessage on + // the calling thread — the STA, since acks run there — which is what + // delivers the writer's OnWriteComplete callback. + ackWriter = new AlarmAckWriter( + comFactory, + () => new StaMessagePump().PumpPendingMessages(), + MxAccessStaSession.ResolveWriteCompletionTimeout(), + AlarmAckWriter.ResolveClientName()); + return ackWriter; + } + } + + /// + /// The writer handed to each . Resolves the + /// handler's real writer on first acknowledgement so the writer (and its + /// MXAccess server registration) is created only when an ack happens and + /// outlives dispatcher churn. Disposing it is a no-op; the handler owns + /// the real writer. + /// + private sealed class DeferredAckWriter : IAlarmAckWriter + { + private readonly AlarmCommandHandler owner; + + public DeferredAckWriter(AlarmCommandHandler owner) + { + this.owner = owner; + } + + public AlarmAckResult Acknowledge(string itemAddress, string comment, AlarmAckOperator ackOperator) => + owner.GetOrCreateAckWriter().Acknowledge(itemAddress, comment, ackOperator); + + public void Dispose() + { + } + } + private AlarmDispatcher GetDispatcherOrThrow() { if (disposed) throw new ObjectDisposedException(nameof(AlarmCommandHandler)); @@ -418,5 +455,13 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler if (disposed) return; disposed = true; Unsubscribe(); + IAlarmAckWriter? writerToDispose; + lock (syncRoot) + { + writerToDispose = ackWriter; + ackWriter = null; + } + + try { writerToDispose?.Dispose(); } catch { /* swallow */ } } } diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs index 722db2e..6923565 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs @@ -18,7 +18,9 @@ namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; /// worker's IPC layer issues SubscribeAlarmsCommand / /// AcknowledgeAlarmCommand / QueryActiveAlarmsCommand /// through , which owns one -/// dispatcher per session. +/// dispatcher per session. Acknowledgement resolves the alarm to its +/// MXAccess ack item through the consumer and hands the write to the +/// , which authenticates as the operator. /// /// /// Threading: owns no internal @@ -36,30 +38,29 @@ public sealed class AlarmDispatcher : IDisposable private readonly MxAccessAlarmEventSink sink; private readonly string sessionId; private readonly EventHandler handler; - private IManagedAlarmAckClient? managedAck; + private readonly IAlarmAckWriter ackWriter; private bool disposed; - /// Initializes a new alarm dispatcher for the given consumer, sink, and session ID. + /// Initializes a new alarm dispatcher for the given consumer, sink, session ID and ack writer. /// The alarm consumer. /// The alarm event sink. /// The session identifier. - /// - /// Optional managed ack client. When non-null, GUID-keyed acknowledges - /// route to it (AVEVA's modern AlarmClient.AlarmAckByGUID) instead - /// of the wnwrap consumer while the alarmmgr provider is active; null - /// keeps the existing wnwrap ack path. Subtag-mode acks always stay on - /// the consumer because their GUIDs are synthetic. + /// + /// Writes the resolved ack item as the authenticated operator. Owned by + /// the caller (), which disposes it; the + /// dispatcher never does, so the writer's registered MXAccess server + /// outlives an unsubscribe/resubscribe cycle. /// public AlarmDispatcher( IMxAccessAlarmConsumer consumer, MxAccessAlarmEventSink sink, string sessionId, - IManagedAlarmAckClient? managedAck = null) + IAlarmAckWriter ackWriter) { this.consumer = consumer ?? throw new ArgumentNullException(nameof(consumer)); this.sink = sink ?? throw new ArgumentNullException(nameof(sink)); this.sessionId = sessionId ?? string.Empty; - this.managedAck = managedAck; + this.ackWriter = ackWriter ?? throw new ArgumentNullException(nameof(ackWriter)); // Sink.Attach is the seam that propagates the session id onto the // proto SessionId field of every emitted MxEvent. Pass the consumer // as the "associated COM object" — sink ignores the object reference @@ -80,113 +81,37 @@ public sealed class AlarmDispatcher : IDisposable { if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher)); consumer.Subscribe(subscription); - // The managed ack client maintains its own registered/subscribed - // consumer; run its lifecycle against the same expression so a later - // AlarmAckByGUID has a live consumer to act through. It is an opt-in - // enhancement, so a failed lifecycle drops back to the wnwrap ack path - // rather than failing the alarm subscription. - if (managedAck is not null) - { - try - { - managedAck.Subscribe(subscription); - } - catch - { - try { managedAck.Dispose(); } catch { /* swallow */ } - managedAck = null; - } - } } - /// - /// True when the consumer is currently serving alarms from the subtag - /// fallback, whose alarm GUIDs are synthetic and unknown to alarmmgr. - /// - private bool IsSubtagActive() => - consumer is SubtagAlarmConsumer || - (consumer is FailoverAlarmConsumer failover && failover.Mode == AlarmProviderMode.Subtag); - - /// - /// Forward an AcknowledgeAlarm request to the underlying - /// consumer's AlarmAckByGUID. Returns the AVEVA-native - /// status code (0 = success). - /// + /// Acknowledges the alarm with the given GUID by writing its ack item as the operator. /// The alarm GUID. - /// The acknowledgment comment. - /// The operator name. - /// The operator node. - /// The operator domain. - /// The operator full name. - /// The AVEVA-native status code. - public int Acknowledge( - Guid alarmGuid, - string ackComment, - string ackOperatorName, - string ackOperatorNode, - string ackOperatorDomain, - string ackOperatorFullName) + /// The acknowledgement comment. + /// The operator to authenticate as. + /// The outcome. + public AlarmAckResult Acknowledge(Guid alarmGuid, string ackComment, AlarmAckOperator ackOperator) { if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher)); - if (managedAck is not null && !IsSubtagActive()) - { - // Preferred path: AVEVA's modern AlarmClient.AlarmAckByGUID carries - // operator domain + full name and is not E_NOTIMPL like the wnwrap - // COM AlarmAckByGUID. Skipped in subtag mode: those GUIDs are - // synthetic and only the subtag consumer can resolve them (it acks - // via an AckMsg write). - return managedAck.AcknowledgeByGuid( - alarmGuid, - ackComment, - ackOperatorName, - ackOperatorNode, - ackOperatorDomain, - ackOperatorFullName); - } - - return consumer.AcknowledgeByGuid( - alarmGuid, - ackComment, - ackOperatorName, - ackOperatorNode, - ackOperatorDomain, - ackOperatorFullName); + AlarmAckTarget target = consumer.ResolveAckTarget(alarmGuid); + return target.Found + ? ackWriter.Acknowledge(target.ItemAddress, ackComment, ackOperator) + : AlarmAckResult.FromTarget(target); } - /// - /// Acknowledge an alarm by its (name, provider, group) tuple. - /// Routes to the consumer's AcknowledgeByName path which - /// maps to wwAlarmConsumerClass.AlarmAckByName. - /// - /// The alarm name. - /// The provider name. - /// The group name. - /// The acknowledgment comment. - /// The operator name. - /// The operator node. - /// The operator domain. - /// The operator full name. - /// The AVEVA-native status code. - public int AcknowledgeByName( - string alarmName, - string providerName, - string groupName, - string ackComment, - string ackOperatorName, - string ackOperatorNode, - string ackOperatorDomain, - string ackOperatorFullName) + /// Acknowledges the alarm identified by name/provider/group as the operator. + /// The tag/alarm name. + /// The provider. + /// The group/area. + /// The acknowledgement comment. + /// The operator to authenticate as. + /// The outcome. + public AlarmAckResult AcknowledgeByName( + string alarmName, string providerName, string groupName, string ackComment, AlarmAckOperator ackOperator) { if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher)); - return consumer.AcknowledgeByName( - alarmName, - providerName, - groupName, - ackComment, - ackOperatorName, - ackOperatorNode, - ackOperatorDomain, - ackOperatorFullName); + AlarmAckTarget target = consumer.ResolveAckTargetByName(alarmName, providerName, groupName); + return target.Found + ? ackWriter.Acknowledge(target.ItemAddress, ackComment, ackOperator) + : AlarmAckResult.FromTarget(target); } /// @@ -327,6 +252,5 @@ public sealed class AlarmDispatcher : IDisposable try { consumer.AlarmTransitionEmitted -= handler; } catch { /* swallow */ } try { sink.Detach(); } catch { /* swallow */ } try { consumer.Dispose(); } catch { /* swallow */ } - try { managedAck?.Dispose(); } catch { /* swallow */ } } } diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IAlarmCommandHandler.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IAlarmCommandHandler.cs index 52ecfe0..28c29ee 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IAlarmCommandHandler.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IAlarmCommandHandler.cs @@ -35,44 +35,21 @@ public interface IAlarmCommandHandler : IDisposable /// Tear down the active subscription. No-op if not subscribed. void Unsubscribe(); - /// Acknowledge a single alarm by GUID. Returns AVEVA's native status (0 = success). + /// Acknowledges an alarm by GUID as . /// The alarm GUID. - /// The acknowledgment comment. - /// The operator user name. - /// The operator node name. - /// The operator domain name. - /// The operator full name. - /// AVEVA's native alarm status code (0 = success). - int Acknowledge( - Guid alarmGuid, - string comment, - string operatorUser, - string operatorNode, - string operatorDomain, - string operatorFullName); + /// The acknowledgement comment written to the alarm's ack item. + /// The operator to authenticate as (credential-sensitive). + /// The acknowledgement outcome. + AlarmAckResult Acknowledge(Guid alarmGuid, string comment, AlarmAckOperator ackOperator); - /// - /// Acknowledge a single alarm by (name, provider, group) — used when - /// the caller has the human-readable reference but not the GUID. - /// - /// The alarm name. + /// Acknowledges an alarm by name/provider/group as . + /// The alarm (tag) name. /// The provider name. /// The group name. - /// The acknowledgment comment. - /// The operator user name. - /// The operator node name. - /// The operator domain name. - /// The operator full name. - /// AVEVA's native alarm status code (0 = success). - int AcknowledgeByName( - string alarmName, - string providerName, - string groupName, - string comment, - string operatorUser, - string operatorNode, - string operatorDomain, - string operatorFullName); + /// The acknowledgement comment written to the alarm's ack item. + /// The operator to authenticate as (credential-sensitive). + /// The acknowledgement outcome. + AlarmAckResult AcknowledgeByName(string alarmName, string providerName, string groupName, string comment, AlarmAckOperator ackOperator); /// /// Snapshot the currently-active alarm set, optionally scoped to a diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IManagedAlarmAckClient.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IManagedAlarmAckClient.cs deleted file mode 100644 index ffe0258..0000000 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IManagedAlarmAckClient.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System; - -namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; - -/// -/// Optional ack-only seam over AVEVA's modern managed alarm client -/// (aaAlarmManagedClient.AlarmClient). When supplied to an -/// , GUID-keyed acknowledges route here -/// instead of the wnwrap COM consumer's AlarmAckByGUID (which is -/// E_NOTIMPL on current builds) — carrying full operator identity -/// (domain + full name) that the wnwrap 6-arg AlarmAckByName drops. -/// The production implementation is . -/// -public interface IManagedAlarmAckClient : IDisposable -{ - /// - /// Run the client's Initialize → Register → Subscribe lifecycle against - /// the same subscription expression the read consumer uses - /// (\\<node>\Galaxy!<area>). Must be called once, - /// on the worker STA, before . - /// - /// The subscription expression. - void Subscribe(string subscription); - - /// - /// Acknowledge a single alarm by GUID with full operator identity. - /// Returns AVEVA's native status (0 = success; 0 means accepted, not - /// necessarily applied — verify the alarm's acked state / Historian row). - /// - /// The alarm GUID. - /// The acknowledgment comment. - /// The operator name. - /// The operator node. - /// The operator domain. - /// The operator full name. - /// The AVEVA-native status code. - int AcknowledgeByGuid( - Guid alarmGuid, - string comment, - string operatorName, - string operatorNode, - string operatorDomain, - string operatorFullName); -} diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs index 4442805..a9cfd0b 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using ZB.MOM.WW.MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Worker.Conversion; using ZB.MOM.WW.MxGateway.Worker.Sta; @@ -912,30 +913,25 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor $"AcknowledgeAlarm.alarm_guid is not a valid canonical GUID: '{payload.AlarmGuid}'."); } + // operator_password is a raw credential: it goes into AlarmAckOperator for + // MXAccess AuthenticateUser and nowhere else. Never format the payload + // (its generated ToString renders every field) or the exception text. try { - int rc = alarmCommandHandler.Acknowledge( + AlarmAckResult result = alarmCommandHandler.Acknowledge( alarmGuid, payload.Comment, - payload.OperatorUser, - payload.OperatorNode, - payload.OperatorDomain, - payload.OperatorFullName); - MxCommandReply reply = CreateOkReply(command); - reply.Hresult = rc; - reply.AcknowledgeAlarm = new AcknowledgeAlarmReplyPayload - { - NativeStatus = rc, - }; - if (rc != 0) - { - reply.DiagnosticMessage = $"AVEVA AlarmAckByGUID returned non-zero status {rc}."; - } - return reply; + new AlarmAckOperator + { + UserName = payload.OperatorUser, + Domain = payload.OperatorDomain, + Password = payload.OperatorPassword, + }); + return CreateAcknowledgeReply(command, result); } catch (Exception ex) { - return CreateAlarmFailureReply(command, ex); + return CreateAcknowledgeFailureReply(command, ex); } } @@ -958,35 +954,80 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor return CreateInvalidRequestReply(command, "AcknowledgeAlarmByName.alarm_name is required."); } + // See ExecuteAcknowledgeAlarm: operator_password never leaves AlarmAckOperator. try { - int rc = alarmCommandHandler.AcknowledgeByName( + AlarmAckResult result = alarmCommandHandler.AcknowledgeByName( payload.AlarmName, payload.ProviderName, payload.GroupName, payload.Comment, - payload.OperatorUser, - payload.OperatorNode, - payload.OperatorDomain, - payload.OperatorFullName); - MxCommandReply reply = CreateOkReply(command); - reply.Hresult = rc; - reply.AcknowledgeAlarm = new AcknowledgeAlarmReplyPayload - { - NativeStatus = rc, - }; - if (rc != 0) - { - reply.DiagnosticMessage = $"AVEVA AlarmAckByName returned non-zero status {rc}."; - } - return reply; + new AlarmAckOperator + { + UserName = payload.OperatorUser, + Domain = payload.OperatorDomain, + Password = payload.OperatorPassword, + }); + return CreateAcknowledgeReply(command, result); } catch (Exception ex) { - return CreateAlarmFailureReply(command, ex); + return CreateAcknowledgeFailureReply(command, ex); } } + private static 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; + } + + /// + /// Maps an exception escaping an acknowledgement (e.g. the ack writer's + /// MXAccess Register failing, or no active subscription) to an + /// reply. Only the + /// exception type and HRESULT are reported: exception text on this path + /// is not under our control and must not be able to echo operator input. + /// + private static MxCommandReply CreateAcknowledgeFailureReply(StaCommand command, Exception exception) + { + string diagnostic = string.Format( + CultureInfo.InvariantCulture, + "Alarm acknowledgement failed: {0} (HRESULT 0x{1:X8}).", + exception.GetType().FullName, + exception.HResult); + return new MxCommandReply + { + SessionId = command.SessionId, + CorrelationId = command.CorrelationId, + Kind = command.Kind, + Hresult = exception.HResult, + ProtocolStatus = new ProtocolStatus + { + Code = ProtocolStatusCode.MxaccessFailure, + Message = diagnostic, + }, + DiagnosticMessage = diagnostic, + AcknowledgeAlarm = new AcknowledgeAlarmReplyPayload { NativeStatus = exception.HResult }, + }; + } + private MxCommandReply ExecuteQueryActiveAlarms(StaCommand command) { if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.QueryActiveAlarmsCommand) diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnalNativeAckConsumer.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnalNativeAckConsumer.cs deleted file mode 100644 index b86daa9..0000000 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnalNativeAckConsumer.cs +++ /dev/null @@ -1,273 +0,0 @@ -using System; -using System.Runtime.InteropServices; -using System.Text; - -namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; - -/// -/// Native alarm-acknowledge path over alarmlst.dll's public WNAL* -/// C exports (the same stack the AVEVA Alarm Control uses: -/// aaAlarmManagedClient → CwwAlarmConsumer → alarmlst.dll → alarmmgr.exe). -/// -/// Why this exists alongside : the legacy -/// WNWRAPCONSUMERLib COM surface cannot carry a full/secured ack — -/// AlarmAckByGUID is E_NOTIMPL, the 8-arg AlarmAckByName -/// is a vendor stub (-55), and neither exposes the signed-ack password. The -/// native request record built by has six text -/// fields — node, operator, comment, password, domain, full name — so -/// this path can do a fully-attributed (and signed) ack. -/// -/// THREADING: these calls are not inherently STA-bound the way the COM -/// consumer is, but keep all of them on the worker STA for ordering/parity -/// with the rest of the alarm surface (marshal via StaRuntime.InvokeAsync). -/// -/// x86 ONLY: alarmlst.dll is 32-bit; this compiles into the x86 worker. -/// -/// STATUS: the ack-SUBMIT contract (, -/// , ) and the -/// request-record layout are confirmed by disassembly (see -/// docs/alarm-ack-findings.md in the reverse-engineering workspace). The -/// list-ACQUISITION side (obtaining hAlarmList + alarm handles) is -/// deliberately NOT implemented — see the note at the end of this class. -/// Nothing in the worker calls this type yet; it is kept for when a real -/// list handle can be supplied by a component that owns acquisition. -/// -public sealed class WnalNativeAckConsumer -{ - private const string AlarmLst = "alarmlst.dll"; - - /// Request-record version stamped at WORD offset 2; WNALUserRequest rejects anything else. - private const ushort AlmAckVersion = 0x104; - - /// Byte offset where WNALalarmReqSetText begins the string-offset table + heap. - private const int StringHeapBase = 0x42; - - /// Sentinel the exports reject as an invalid list handle. - public const int InvalidAlarmList = -1; - - // --------------------------------------------------------------------- - // CONFIRMED exports (disassembly-verified signatures). __stdcall, ANSI. - // --------------------------------------------------------------------- - - /// - /// Pack operator identity + comment into an existing request record. - /// Arg order is confirmed from the debug format string. Per-field rule: - /// pass null to KEEP the record's existing value, "" to CLEAR it, - /// a string to SET it. Strings are single-byte ASCII (strcpy_s). - /// Return: positive = bytes used; NEGATIVE = record too small, magnitude - /// is the required recSize; 0 = was null. - /// - [DllImport(AlarmLst, EntryPoint = "WNALalarmReqSetText", - CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Ansi, ExactSpelling = true)] - private static extern int WnalReqSetText( - IntPtr lpAlReq, - [MarshalAs(UnmanagedType.LPStr)] string? nodeName, - [MarshalAs(UnmanagedType.LPStr)] string? oper, - [MarshalAs(UnmanagedType.LPStr)] string? comment, - [MarshalAs(UnmanagedType.LPStr)] string? passWd, - [MarshalAs(UnmanagedType.LPStr)] string? domainName, - [MarshalAs(UnmanagedType.LPStr)] string? fullName); - - /// - /// Submit a user request (an ack) over the alarms SELECTED in - /// . a2 is reserved (0); a3 is a capacity - /// hint (the native client passes the grid row count); a4 is scope - /// (0 = all, 1 = selected). Returns 0 on success (provider status), - /// non-zero on error. - /// - [DllImport(AlarmLst, EntryPoint = "WNALUserRequest", - CallingConvention = CallingConvention.StdCall, ExactSpelling = true)] - private static extern int WnalUserRequest(int hAlarmList, uint a2, uint a3, int a4, IntPtr lpAlmAck); - - /// - /// Submit a user request (an ack) against an EXPLICIT array of - /// 4-byte alarm handles — no selection state - /// required. mirrors the scope/flag scalar. - /// Returns 0 on success. - /// - [DllImport(AlarmLst, EntryPoint = "WNALUserRequestEx", - CallingConvention = CallingConvention.StdCall, ExactSpelling = true)] - private static extern int WnalUserRequestEx( - int hAlarmList, IntPtr handleArray, int count, uint a4, IntPtr lpAlmAck); - - /// Acknowledge-scope values for the a4 argument. - private enum AckScope - { - All = 0, - Selected = 1, - } - - /// - /// Build a request record and submit an ack for the given alarm - /// handles via . Caller supplies a live - /// and the handle(s) obtained from the - /// list's records (acquisition is not implemented — see the class note). - /// Returns the native status (0 == success). - /// - /// A registered, subscribed alarm-list handle. - /// The 4-byte alarm handles to acknowledge. - /// Ack comment. - /// Operator / user name. - /// Operator node name. - /// Operator domain (carried natively, unlike the COM path). - /// Operator full name (carried natively, unlike the COM path). - /// Operator password for a signed/secured ack, or null. - public int AcknowledgeByHandles( - int hAlarmList, - int[] alarmHandles, - string comment, - string operatorName, - string operatorNode, - string operatorDomain, - string operatorFullName, - string? password) - { - if (hAlarmList == InvalidAlarmList) - throw new ArgumentException("Invalid alarm-list handle.", nameof(hAlarmList)); - if (alarmHandles is null || alarmHandles.Length == 0) - throw new ArgumentException("At least one alarm handle is required.", nameof(alarmHandles)); - - IntPtr record = IntPtr.Zero; - IntPtr handles = IntPtr.Zero; - try - { - record = BuildAckRecord( - nodeName: operatorNode, - oper: operatorName, - comment: comment, - passWd: password, - domainName: operatorDomain, - fullName: operatorFullName); - - handles = Marshal.AllocHGlobal(alarmHandles.Length * sizeof(int)); - for (int i = 0; i < alarmHandles.Length; i++) - Marshal.WriteInt32(handles, i * sizeof(int), alarmHandles[i]); - - return WnalUserRequestEx( - hAlarmList, - handles, - alarmHandles.Length, - (uint)AckScope.Selected, - record); - } - finally - { - if (handles != IntPtr.Zero) Marshal.FreeHGlobal(handles); - if (record != IntPtr.Zero) Marshal.FreeHGlobal(record); - } - } - - /// - /// Acknowledge whatever is currently SELECTED in the list (a4 = Selected) - /// via . Use when selection has already been - /// established on the list rather than passing explicit handles. - /// - public int AcknowledgeSelected( - int hAlarmList, - uint rowCountHint, - string comment, - string operatorName, - string operatorNode, - string operatorDomain, - string operatorFullName, - string? password) - { - if (hAlarmList == InvalidAlarmList) - throw new ArgumentException("Invalid alarm-list handle.", nameof(hAlarmList)); - - IntPtr record = IntPtr.Zero; - try - { - record = BuildAckRecord(operatorNode, operatorName, comment, password, operatorDomain, operatorFullName); - return WnalUserRequest(hAlarmList, a2: 0, a3: rowCountHint, a4: (int)AckScope.Selected, record); - } - finally - { - if (record != IntPtr.Zero) Marshal.FreeHGlobal(record); - } - } - - /// - /// Allocate a zeroed request record (header + room for the string heap), - /// stamp recSize + version, then let pack the - /// operator/comment text. Returns an unmanaged buffer the caller must free. - /// - private static IntPtr BuildAckRecord( - string nodeName, string oper, string comment, string? passWd, string domainName, string fullName) - { - // recSize must be >= 0x42 + sum(strlen+1 of each field). Size generously; - // SetText returns a negative "needed size" if this is too small, which we - // honor by growing once. - int capacity = EstimateCapacity(nodeName, oper, comment, passWd, domainName, fullName); - - for (int attempt = 0; attempt < 2; attempt++) - { - IntPtr buf = Marshal.AllocHGlobal(capacity); - try - { - // Zero the whole record (fixed header 0x04..0x31 stays 0 — WNALUserRequest - // does not validate it; it is informational/provider-filled). - for (int off = 0; off < capacity; off += sizeof(long)) - { - int remaining = capacity - off; - if (remaining >= sizeof(long)) Marshal.WriteInt64(buf, off, 0); - else for (int b = off; b < capacity; b++) Marshal.WriteByte(buf, b, 0); - } - - Marshal.WriteInt16(buf, 0, (short)(ushort)capacity); // WORD recSize - Marshal.WriteInt16(buf, 2, unchecked((short)AlmAckVersion)); // WORD version = 0x104 - - int rc = WnalReqSetText(buf, nodeName, oper, comment, passWd, domainName, fullName); - if (rc >= 0) - return buf; - - // rc < 0 => record too small; |rc| is the required size. Grow and retry. - capacity = -rc; - Marshal.FreeHGlobal(buf); - } - catch - { - Marshal.FreeHGlobal(buf); - throw; - } - } - - throw new InvalidOperationException("WNALalarmReqSetText could not size the ack record."); - } - - private static int EstimateCapacity(params string?[] fields) - { - int bytes = StringHeapBase; - foreach (string? f in fields) - bytes += (f is null ? 0 : Encoding.ASCII.GetByteCount(f)) + 1; - // Round up with headroom; records are tiny, over-allocation is cheap. - return Math.Max(1024, bytes + 64); - } - - // --------------------------------------------------------------------- - // LIST ACQUISITION — intentionally NOT implemented here, by RE conclusion. - // - // The ABIs of WNALSelectEntry (func 0x1d) and WNALGetAlarmRecordEx (func - // 0x1c) are recoverable, but their SEMANTICS are not: WNALGetAlarmRecordEx - // does not define the alarm-record layout — it hands alarmmgr/the provider a - // set of opaque output buffers (four 0x41-byte string buffers, a 0x8b-byte - // buffer, scalars) that the PROVIDER fills. So the record's GUID offset and - // the 4-byte alarm handle that WNALUserRequestEx consumes are provider-defined - // and cannot be read out of alarmlst.dll; likewise WNALSelectEntry's arg - // meanings live on the provider side of MakeAlarmMgrFuncCall. Hand-rolling the - // acquisition from static RE alone would be guessing which bytes are the - // handle/GUID — a wrong guess acks the wrong alarm. - // - // Therefore DO NOT complete the native acquisition by inference. Obtain the - // hAlarmList + handles from a component that already encapsulates - // register/query/match correctly: - // * Preferred: aaAlarmManagedClient.dll AlarmClient.AlarmAckByGUID - // (int AlarmAckByGUID(Guid, comment, oper, node, domain, fullName)) — the - // Alarm Control's own code; managed signatures are exact (no ABI risk). - // It is internal, so invoke by reflection from the x86 worker. - // * Or drive ack through the existing WnWrapAlarmConsumer, which already - // owns a registered/subscribed consumer, and fix the auth gap (Stage 0). - // Keep the confirmed SUBMIT methods above for the case where a real - // hAlarmList + handles are supplied by such a component. - // --------------------------------------------------------------------- -} From 2858e42e80d716af5f8d90a3c8f04c2cef436706 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 19 Oct 2026 19:40:23 -0400 Subject: [PATCH 09/25] feat(gateway): forward operator credentials on alarm acknowledgement Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT --- .../Alarms/GatewayAlarmMonitor.cs | 10 ++-- .../Configuration/AlarmsOptions.cs | 7 +++ .../Configuration/GatewayOptionsValidator.cs | 7 +++ .../Diagnostics/GatewayLogRedactor.cs | 5 +- .../Diagnostics/GatewayLogRedactorSeam.cs | 8 ++++ .../Grpc/MxAccessGatewayService.cs | 5 ++ .../Workers/WorkerProcessLauncher.cs | 2 + .../GatewayAlarmMonitorAcknowledgeTests.cs | 46 +++++++++++++++++++ .../GatewayOptionsValidatorTests.cs | 39 ++++++++++++++++ .../GatewayLogRedactorSeamTests.cs | 19 ++++++++ .../Diagnostics/GatewayLogRedactorTests.cs | 2 + .../Grpc/MxAccessGatewayServiceTests.cs | 16 +++++++ .../Workers/WorkerProcessLauncherTests.cs | 31 ++++++++++++- 13 files changed, 192 insertions(+), 5 deletions(-) create mode 100644 src/ZB.MOM.WW.MxGateway.Tests/Alarms/GatewayAlarmMonitorAcknowledgeTests.cs diff --git a/src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs b/src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs index b271c8f..76fb408 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs @@ -1169,7 +1169,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic return string.Empty; } - private static MxCommand? BuildAcknowledgeCommand(AcknowledgeAlarmRequest request, out string? parseError) + internal static MxCommand? BuildAcknowledgeCommand(AcknowledgeAlarmRequest request, out string? parseError) { parseError = null; if (string.IsNullOrWhiteSpace(request.AlarmFullReference)) @@ -1180,6 +1180,8 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic string comment = request.Comment ?? string.Empty; string operatorUser = request.OperatorUser ?? string.Empty; + string operatorPassword = request.OperatorPassword ?? string.Empty; + string operatorDomain = request.OperatorDomain ?? string.Empty; if (Guid.TryParse(request.AlarmFullReference, out Guid guid)) { @@ -1192,7 +1194,8 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic Comment = comment, OperatorUser = operatorUser, OperatorNode = string.Empty, - OperatorDomain = string.Empty, + OperatorDomain = operatorDomain, + OperatorPassword = operatorPassword, OperatorFullName = string.Empty, }, }; @@ -1211,7 +1214,8 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic Comment = comment, OperatorUser = operatorUser, OperatorNode = string.Empty, - OperatorDomain = string.Empty, + OperatorDomain = operatorDomain, + OperatorPassword = operatorPassword, OperatorFullName = string.Empty, }, }; diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/AlarmsOptions.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/AlarmsOptions.cs index 94363bf..bcd72ec 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Configuration/AlarmsOptions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/AlarmsOptions.cs @@ -73,6 +73,13 @@ public sealed class AlarmsOptions /// public int MaxAlarmsPerFetch { get; init; } = 1024; + /// + /// MXAccess client name the alarm worker's ack writer registers with. The + /// AVEVA Historian records it as each acknowledgement's OperatorNode. + /// Conveyed to workers via MXGATEWAY_ALARM_ACK_CLIENT_NAME. + /// + public string AckClientName { get; init; } = "MxGateway"; + /// /// Configuration for the alarm-manager ↔ subtag fallback mechanism: /// operating mode, failure-detection thresholds, discovery, and subtag diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs index f103bdc..c98d6be 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs @@ -488,6 +488,13 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase 64 + || options.AckClientName.Any(char.IsControl)) + { + builder.Add("MxGateway:Alarms:AckClientName must be 1-64 printable characters."); + } + if (!options.Enabled) { return; diff --git a/src/ZB.MOM.WW.MxGateway.Server/Diagnostics/GatewayLogRedactor.cs b/src/ZB.MOM.WW.MxGateway.Server/Diagnostics/GatewayLogRedactor.cs index bc517d9..0d2020b 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Diagnostics/GatewayLogRedactor.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Diagnostics/GatewayLogRedactor.cs @@ -12,7 +12,10 @@ public static class GatewayLogRedactor { "AuthenticateUser", "WriteSecured", - "WriteSecured2" + "WriteSecured2", + // Carry operator_password. + "AcknowledgeAlarm", + "AcknowledgeAlarmByName" }; /// diff --git a/src/ZB.MOM.WW.MxGateway.Server/Diagnostics/GatewayLogRedactorSeam.cs b/src/ZB.MOM.WW.MxGateway.Server/Diagnostics/GatewayLogRedactorSeam.cs index 7976a61..249e3e1 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Diagnostics/GatewayLogRedactorSeam.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Diagnostics/GatewayLogRedactorSeam.cs @@ -33,6 +33,14 @@ public sealed class GatewayLogRedactorSeam : ILogRedactor } RedactCommandValue(properties); + + foreach (string key in properties.Keys.ToList()) + { + if (key.Contains("password", StringComparison.OrdinalIgnoreCase) && properties[key] is not null) + { + properties[key] = GatewayLogRedactor.RedactedValue; + } + } } /// diff --git a/src/ZB.MOM.WW.MxGateway.Server/Grpc/MxAccessGatewayService.cs b/src/ZB.MOM.WW.MxGateway.Server/Grpc/MxAccessGatewayService.cs index 1a326d5..99ba149 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Grpc/MxAccessGatewayService.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Grpc/MxAccessGatewayService.cs @@ -197,6 +197,11 @@ public sealed class MxAccessGatewayService( throw new RpcException(new Status(StatusCode.InvalidArgument, "alarm_full_reference is required.")); } + if (string.IsNullOrWhiteSpace(request.OperatorUser)) + { + throw new RpcException(new Status(StatusCode.InvalidArgument, "operator_user is required.")); + } + return await alarmService.AcknowledgeAsync(request, context.CancellationToken) .ConfigureAwait(false); } diff --git a/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerProcessLauncher.cs b/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerProcessLauncher.cs index de305b5..11ca0f6 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerProcessLauncher.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerProcessLauncher.cs @@ -223,6 +223,8 @@ public sealed class WorkerProcessLauncher : IWorkerProcessLauncher startInfo.Environment[WorkerLaunchEnvironment.MaxAlarmsPerFetch] = _alarmsOptions.MaxAlarmsPerFetch.ToString(System.Globalization.CultureInfo.InvariantCulture); + startInfo.Environment[WorkerLaunchEnvironment.AlarmAckClientName] = _alarmsOptions.AckClientName; + commandLine = new WorkerProcessCommandLine(executablePath, arguments); return startInfo; diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Alarms/GatewayAlarmMonitorAcknowledgeTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Alarms/GatewayAlarmMonitorAcknowledgeTests.cs new file mode 100644 index 0000000..9906087 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Alarms/GatewayAlarmMonitorAcknowledgeTests.cs @@ -0,0 +1,46 @@ +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Server.Alarms; + +namespace ZB.MOM.WW.MxGateway.Tests.Alarms; + +public sealed class GatewayAlarmMonitorAcknowledgeTests +{ + /// A GUID-referenced ack carries the operator identity and password to the worker command. + [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); + } + + /// A name-referenced ack carries the operator identity and password to the by-name command. + [Fact] + public void BuildAcknowledgeCommand_NameReference_ForwardsOperatorCredentials() + { + MxCommand? command = GatewayAlarmMonitor.BuildAcknowledgeCommand(new AcknowledgeAlarmRequest + { + AlarmFullReference = "Galaxy!TestArea.TestMachine_001.TestAlarm001", + OperatorUser = "alice", + OperatorDomain = "CORP", + 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("CORP", command.AcknowledgeAlarmByNameCommand.OperatorDomain); + Assert.Equal("pw-secret", command.AcknowledgeAlarmByNameCommand.OperatorPassword); + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs index cbafda0..e52055f 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs @@ -164,6 +164,45 @@ public sealed class GatewayOptionsValidatorTests Tls = source.Tls, }; + /// Verifies AckClientName defaults to "MxGateway" and passes validation. + [Fact] + public void Validate_Succeeds_WithDefaultAckClientName() + { + Assert.Equal("MxGateway", new AlarmsOptions().AckClientName); + + ValidateOptionsResult result = new GatewayOptionsValidator() + .Validate(null, CloneWithAlarms(ValidOptions(), new AlarmsOptions())); + Assert.True(result.Succeeded); + } + + /// Verifies an empty, over-long, or control-character AckClientName fails validation. + /// Client name under test. + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("bad\nname")] + public void Validate_Fails_WhenAckClientNameInvalid(string name) + { + ValidateOptionsResult result = new GatewayOptionsValidator() + .Validate(null, CloneWithAlarms(ValidOptions(), new AlarmsOptions { AckClientName = name })); + + Assert.False(result.Succeeded); + Assert.Contains( + result.Failures!, + f => f.Contains("MxGateway:Alarms:AckClientName", StringComparison.Ordinal)); + } + + /// Verifies a 65-character AckClientName fails and a 64-character one passes. + [Fact] + public void Validate_AckClientNameLengthBoundary() + { + GatewayOptionsValidator validator = new(); + Assert.False(validator.Validate( + null, CloneWithAlarms(ValidOptions(), new AlarmsOptions { AckClientName = new string('a', 65) })).Succeeded); + Assert.True(validator.Validate( + null, CloneWithAlarms(ValidOptions(), new AlarmsOptions { AckClientName = new string('a', 64) })).Succeeded); + } + /// Verifies the alarm poll cadence and per-fetch cap defaults pass validation. [Fact] public void Validate_Succeeds_WithDefaultAlarmPollCadenceAndFetchCap() diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Diagnostics/GatewayLogRedactorSeamTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Diagnostics/GatewayLogRedactorSeamTests.cs index 31e4071..562b01b 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Diagnostics/GatewayLogRedactorSeamTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Diagnostics/GatewayLogRedactorSeamTests.cs @@ -84,4 +84,23 @@ public sealed class GatewayLogRedactorSeamTests Assert.False(props.ContainsKey("CommandValue")); } + + /// Any property whose name contains "password" is masked regardless of casing. + [Fact] + public void Redact_PasswordNamedProperty_IsRedacted() + { + GatewayLogRedactorSeam redactor = new(); + Dictionary props = new() + { + ["OperatorPassword"] = "pw-secret", + ["verify_user_PASSWORD"] = "pw-secret", + ["OperatorUser"] = "alice", + }; + + redactor.Redact(props); + + Assert.Equal("[redacted]", props["OperatorPassword"]); + Assert.Equal("[redacted]", props["verify_user_PASSWORD"]); + Assert.Equal("alice", props["OperatorUser"]); + } } diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Diagnostics/GatewayLogRedactorTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Diagnostics/GatewayLogRedactorTests.cs index 41df638..82ec1d3 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Diagnostics/GatewayLogRedactorTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Diagnostics/GatewayLogRedactorTests.cs @@ -118,6 +118,8 @@ public sealed class GatewayLogRedactorTests [InlineData("AuthenticateUser")] [InlineData("WriteSecured")] [InlineData("WriteSecured2")] + [InlineData("AcknowledgeAlarm")] + [InlineData("AcknowledgeAlarmByName")] public void IsCredentialBearingCommand_IdentifiesSensitiveMxAccessCommands(string commandMethod) { Assert.True(GatewayLogRedactor.IsCredentialBearingCommand(commandMethod)); diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs index 15c5493..1b473aa 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs @@ -389,6 +389,22 @@ public sealed class MxAccessGatewayServiceTests Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); } + /// Verifies AcknowledgeAlarm rejects an empty operator_user. + /// A task that represents the asynchronous operation. + [Fact] + public async Task AcknowledgeAlarm_WithMissingOperatorUser_ThrowsInvalidArgument() + { + MxAccessGatewayService service = CreateService(new FakeSessionManager()); + + RpcException exception = await Assert.ThrowsAsync( + async () => await service.AcknowledgeAlarm( + new AcknowledgeAlarmRequest { AlarmFullReference = "Galaxy!Area.Tag" }, + new TestServerCallContext())); + + Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); + Assert.Contains("operator_user", exception.Status.Detail, StringComparison.Ordinal); + } + /// Verifies AcknowledgeAlarm delegates a valid request to the alarm service. /// A task that represents the asynchronous operation. [Fact] diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/WorkerProcessLauncherTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/WorkerProcessLauncherTests.cs index 7fbbb3a..0f8a4ef 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/WorkerProcessLauncherTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/WorkerProcessLauncherTests.cs @@ -22,6 +22,7 @@ public sealed class WorkerProcessLauncherTests WorkerLaunchEnvironment.EventQueueCapacity, WorkerLaunchEnvironment.AlarmPollIntervalMilliseconds, WorkerLaunchEnvironment.MaxAlarmsPerFetch, + WorkerLaunchEnvironment.AlarmAckClientName, ]; /// Verifies that a valid worker executable starts with correct bootstrap arguments and nonce environment variable. @@ -75,6 +76,10 @@ public sealed class WorkerProcessLauncherTests "1024", processFactory.LastStartInfo.Environment[ WorkerLaunchEnvironment.MaxAlarmsPerFetch]); + Assert.Equal( + "MxGateway", + processFactory.LastStartInfo.Environment[ + WorkerLaunchEnvironment.AlarmAckClientName]); Assert.All( RequiredLaunchEnvironmentVariables, variable => Assert.True( @@ -86,6 +91,28 @@ public sealed class WorkerProcessLauncherTests Assert.Equal(0, metrics.GetSnapshot().WorkersRunning); } + /// Verifies a configured AckClientName reaches the worker environment. + /// A task that represents the asynchronous operation. + [Fact] + public async Task LaunchAsync_WithConfiguredAckClientName_ExportsItToTheWorkerEnvironment() + { + using TestDirectory directory = TestDirectory.Create(); + string executablePath = directory.CreateWorkerExecutable(machine: 0x014c); + FakeWorkerProcessFactory processFactory = new(new FakeWorkerProcess(processId: 1234)); + WorkerProcessLauncher launcher = CreateLauncher( + executablePath, + processFactory, + new SucceedingStartupProbe(), + ackClientName: "Plant-Ack"); + + using WorkerProcessHandle handle = await launcher.LaunchAsync(CreateRequest()); + + Assert.NotNull(processFactory.LastStartInfo); + Assert.Equal( + "Plant-Ack", + processFactory.LastStartInfo.Environment[WorkerLaunchEnvironment.AlarmAckClientName]); + } + /// /// Verifies that a configured — not the shipped /// default — is what reaches the worker, so the option is deployable without a worker rebuild. @@ -265,10 +292,12 @@ public sealed class WorkerProcessLauncherTests int startupTimeoutSeconds = 30, int startupProbeRetryAttempts = 3, int startupProbeRetryDelayMilliseconds = 250, - int eventQueueCapacity = 10000) + int eventQueueCapacity = 10000, + string ackClientName = "MxGateway") { GatewayOptions options = new() { + Alarms = new AlarmsOptions { AckClientName = ackClientName }, Worker = new WorkerOptions { ExecutablePath = executablePath, From 94094880081b9c160cb4bf6f78fbd58a833276d5 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 19 Oct 2026 19:44:18 -0400 Subject: [PATCH 10/25] docs: alarm acknowledgement writes AckMsg as the authenticated operator Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT --- CLAUDE.md | 2 +- docs/AlarmClientDiscovery.md | 102 +++++++++--------- docs/AlarmProbeFindings.md | 71 ++++++++++++ docs/Authorization.md | 2 +- docs/Contracts.md | 11 ++ docs/DesignDecisions.md | 50 +++++++-- docs/Diagnostics.md | 8 +- docs/GatewayConfiguration.md | 17 +-- docs/Grpc.md | 20 +++- gateway.md | 40 +++---- .../appsettings.json | 3 +- .../MxAccess/WnWrapAlarmConsumer.cs | 22 ++-- 12 files changed, 243 insertions(+), 105 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d3c2dfb..f376839 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -126,7 +126,7 @@ powershell -ExecutionPolicy Bypass -File scripts/run-client-e2e-tests.ps1 - **One worker per session** (invariant). Multi-subscriber event fan-out and reconnect-with-replay have shipped and are config-gated: `AllowMultipleEventSubscribers` (default `false`) enables fan-out up to `MaxEventSubscribersPerSession` (default `8`); `DetachGraceSeconds` (default `30`) retains a session after its last subscriber drops so clients can reconnect; `ReplayBufferCapacity` / `ReplayRetentionSeconds` control how much event history the replay ring keeps. Default config is single-subscriber (`AllowMultipleEventSubscribers` off), but detach-grace and replay retention are **on** by default (`DetachGraceSeconds=30`, `ReplayBufferCapacity=1024`, `ReplayRetentionSeconds=300`): a detached session is retained for 30 s and recent events are buffered for reconnect. The reconnect protocol is consumable end-to-end: a resuming `StreamEvents` (via `after_worker_sequence`) that predates the retained ring gets a `ReplayGap` sentinel, and all five official clients surface it as a typed signal. Orphan-worker reattach after a gateway restart is **deferred, not planned** — see `docs/DesignDecisions.md` (Session-Resilience Epic Scope, session-resilience epic Phase 5); the invariant on the next line stands. See `docs/DesignDecisions.md` and `docs/Sessions.md`. - **Gateway restart does not reattach orphan workers.** The first version terminates orphaned workers on startup; do not design code paths that assume reattachment. - **No Blazor UI component libraries.** Dashboard uses local Bootstrap CSS/JS only — do not introduce MudBlazor, Radzen, FluentUI, etc. -- **Don't log secrets or full tag values by default.** API keys, passwords, `WriteSecured` payloads, and `AuthenticateUser` credentials must never reach logs. Value logging is opt-in and redacted. +- **Don't log secrets or full tag values by default.** API keys, passwords, `WriteSecured` payloads, `AuthenticateUser` credentials, and `AcknowledgeAlarm` `operator_password` must never reach logs. Value logging is opt-in and redacted. - **Generated code** under `src/ZB.MOM.WW.MxGateway.Contracts/Generated/`, `clients/*/generated*/`, `clients/python/src/zb_mom_ww_mxgateway/generated/`, etc., is build output. Don't hand-edit. To regenerate, build the contracts project (`dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj`) or run the per-client generation step in that client's README. - **Documentation style** (`StyleGuide.md`): PascalCase filenames, no marketing language, present tense, explain *why* not *what*. - **Update docs in the same change as the source.** When public APIs, contracts, configuration, build steps, security behavior, event shapes, value conversion, status mapping, or lifecycle rules change, the affected docs (`gateway.md`, `docs/`, client READMEs, design docs) must change in the same commit. Don't leave stale prose describing old behavior. diff --git a/docs/AlarmClientDiscovery.md b/docs/AlarmClientDiscovery.md index 1c040ce..51090de 100644 --- a/docs/AlarmClientDiscovery.md +++ b/docs/AlarmClientDiscovery.md @@ -621,8 +621,9 @@ returned to normal but is unacknowledged — i.e., visible in the "current alarms" list because operator hasn't acked it yet) and `UNACK_ALM` (the alarm is currently active and unacknowledged). The other states from `eAlmState` (`ACK_RTN`, `ACK_ALM`) would -appear when an ack is performed — `wwAlarmConsumerClass.AlarmAckByGUID` -is the method to call. +appear when an ack is performed. The alarm-client ack methods +(`AlarmAckByGUID` and friends) do not produce them headless; an `AckMsg` +write does (`AlarmProbeFindings.md`). ### `GetStatistics` AV — unrelated quirk @@ -735,25 +736,32 @@ for `SetXmlAlarmQuery`. the canonical `\\\Galaxy!` form into the XML's NODE/PROVIDER/GROUP fields. Mangled or not, the call enables reads. -### 2. Two consumers required: read-side vs. ack-side +### 2. Two consumers required: read-side vs. ack-side (historical) + +> Superseded 2026-10-19: acks now write `AckMsg` as the authenticated operator; +> see `AlarmProbeFindings.md`. The worker no longer provisions an ack-only +> wnwrap consumer. The text below records why the split existed. `SetXmlAlarmQuery` enables reads but **breaks `AlarmAckByName` on the same consumer instance**. With SetXml applied, AlarmAckByName returns -55 even with valid name+provider+group+operator. Without SetXml, AlarmAckByName succeeds with rc=0. -The production consumer therefore provisions **two** wnwrap COM +The consumer at the time provisioned **two** wnwrap COM instances: - Primary consumer (`client`): runs full lifecycle including `SetXmlAlarmQuery` for `GetXmlCurrentAlarms2` polls. - Ack-only consumer (`ackClient`): runs Initialize → Register → Subscribe via the v1-prefixed methods, **no SetXmlAlarmQuery**. - All `AcknowledgeByName` calls dispatch through this instance. + All by-name ack calls dispatched through this instance. Both consumers subscribe to the same expression. Disposal cleans up both via a shared `ReleaseConsumerCom` helper. -### 3. `AlarmAckByName` v2 8-arg vs. v1 6-arg +### 3. `AlarmAckByName` v2 8-arg vs. v1 6-arg (historical) + +> Superseded 2026-10-19: the gateway no longer calls `AlarmAckByName`; see +> `AlarmProbeFindings.md`. `wwAlarmConsumerClass` exposes two `AlarmAckByName` overloads: - `IwwAlarmConsumer2` v2: 8 args (`name, provider, group, comment, @@ -762,8 +770,8 @@ both via a shared `ReleaseConsumerCom` helper. The v2 8-arg method returns -55 on this AVEVA build regardless of operator-identity inputs — looks like a stub. The v1 6-arg method -works. Production `WnWrapAlarmConsumer.AcknowledgeByName` calls the -6-arg overload and discards the proto's `domain` + `full_name` fields. +works. `WnWrapAlarmConsumer.AcknowledgeByName` called the +6-arg overload and discarded the proto's `domain` + `full_name` fields. **Correction, 2026-08-18** (`docs/AlarmProbeFindings.md`). Both halves of the paragraph above are narrower than they read. The -55 tracks the @@ -775,42 +783,34 @@ snapshot stay `UNACK_ALM`, `OPERATOR_NAME` stay empty, and the alarm extension's own `.Acked` attribute stay `False` for 16 s after each call. On this rig the wnwrap ack is accepted and then inert, so a zero return code must not be read as an applied acknowledgement. -The proto contract keeps the 8 fields for forward compatibility if -AVEVA fixes the v2 method later. -### 4. `AlarmAckByGUID` is not implemented +### 4. `AlarmAckByGUID` is not implemented (historical) + +> Superseded 2026-10-19: acks now write `AckMsg` as the authenticated operator; +> see `AlarmProbeFindings.md`. The v2 `AlarmAckByGUID(VBGUID, …)` throws `NotImplementedException` (COM `E_NOTIMPL`) on `wwAlarmConsumerClass` against this AVEVA build. The reference→GUID lookup that we initially planned to wire -through `AlarmAckByGUID` is therefore not viable on wnwrap; all acks -must go through `AlarmAckByName`. +through `AlarmAckByGUID` is therefore not viable on wnwrap. -The proto `AcknowledgeAlarmCommand` (GUID-based) and the worker's -`MxAccessCommandExecutor.ExecuteAcknowledgeAlarm` switch arm remain -in the codebase for the forward-compat shape, but the gateway-side -`WorkerAlarmRpcDispatcher.AcknowledgeAsync` now always routes through -`AcknowledgeAlarmByName` when the public RPC supplies a recognizable -`Provider!Group.Tag` reference. +#### Managed GUID ack experiment — did not work (live-tested 2026-10-18, removed 2026-10-19) -**Opt-in managed GUID ack — does not work on this rig (live-tested -2026-10-18).** Setting `MXGATEWAY_USE_MANAGED_ALARM_ACK=1` in the -gateway's environment (workers inherit it) makes the worker build an -`AaManagedAlarmAckConsumer`, which drives -`aaAlarmManagedClient.AlarmClient` by reflection (its members are -non-public) and is meant to take GUID-keyed acks in place of the -wnwrap method. The live test shows it cannot do that as built, so leave -the flag off. +An opt-in experiment drove `aaAlarmManagedClient.AlarmClient` by reflection +(its members are non-public) to take GUID-keyed acks in place of the wnwrap +method. The live test showed it could not work, and the experiment and its +enabling flag were removed once the `AckMsg` write replaced it. The findings +are kept because they explain why the alarm-client route was abandoned. The test raised `TestMachine_001.TestAlarm001` with a secured write, read its GUID from the wnwrap XML snapshot (the public API exposes no alarm GUIDs), and acknowledged it through the `AcknowledgeAlarm` RPC -with the flag on: +with the experiment enabled: | Step | Result | |---|---| -| RPC ack by GUID, flag off (control) | `NotImplementedException`; `.Acked` stays `False` | -| RPC ack by GUID, flag on | Same `NotImplementedException`; `.Acked` `False`, `.AckMsg` empty | +| RPC ack by GUID, experiment off (control) | `NotImplementedException`; `.Acked` stays `False` | +| RPC ack by GUID, experiment on | Same `NotImplementedException`; `.Acked` `False`, `.AckMsg` empty | | `AlarmClient.RegisterConsumer` in a process with a registered wnwrap consumer | `-65` — and wnwrap gets `-65` if the managed client registered first; a real `hWnd` does not help | | Managed client alone in its own process, records loaded | `AlarmAckByGUID` → `-1`, `SelectByGUID` → `-1` for the wnwrap GUID in both byte orders, `AlarmAckSelected` → `-57` | @@ -820,7 +820,7 @@ Three separate problems follow: a wnwrap reader, so the managed `RegisterConsumer` fails with `-65`. The dispatcher is built to drop back to the wnwrap ack path on a lifecycle failure (so alarm reads keep working), and it does so - silently — which is why the flag-on RPC is indistinguishable from the + silently — which is why the experiment-on RPC was indistinguishable from the control. 2. **The managed client does not recognise the wnwrap GUID.** `-1` from `SelectByGUID` is "not found", not a security refusal, even with the @@ -833,14 +833,8 @@ Three separate problems follow: strings, so the domain and full name the managed method carries would not arrive through the public RPC even if 1 and 2 were solved. -A working managed ack would need the client hosted out of process, a way -to resolve the alarm to the identifier the managed client uses, and the -identity fields plumbed through the gateway. Routing rules the worker -already applies stay correct and would carry over: subtag-mode acks -(synthetic GUIDs) stay on the subtag consumer, and by-name acks stay on -wnwrap because `AlarmClient` has no by-name ack. The skip-gated -`AlarmManagedGuidAckLiveSmokeTests` exercises the in-process -arrangement and hits problem 1. +The route was dropped in favour of the authenticated `AckMsg` write +(`AlarmProbeFindings.md`, "Fourth attempt"). ### 5. STA / threading — resolved @@ -996,20 +990,24 @@ AVEVA-internal GUID. ### Acknowledge in subtag mode -`AlarmDispatcher` routes ack calls by active provider mode: +Both provider modes acknowledge through the worker's `AlarmAckWriter`, which +calls `AuthenticateUser` as the requesting operator and writes the comment to +the ack item over MXAccess on its own `LMXProxyServer`: -- **Alarm-manager mode:** `AlarmAckByName` on `wwAlarmConsumerClass` (unchanged). -- **Subtag mode:** `SubtagAlarmConsumer.AcknowledgeByName` resolves the - watch-list entry's `ack_comment_subtag` and issues a `Write(comment)` on - the STA via `LmxSubtagAlarmSource`. Writing the `AckMsg` subtag performs - the acknowledge in AVEVA (`AckMsg` is the confirmed `AlarmExtension` ack-comment - write target). +- **Alarm-manager mode:** the item is `.AckMsg` for Galaxy-provider + alarms. An alarm from another provider is not ackable. +- **Subtag mode:** `SubtagAlarmConsumer` resolves the watch-list entry's + `ack_comment_subtag` and the writer writes that item verbatim. Writing the + `AckMsg` subtag performs the acknowledge in AVEVA (`AckMsg` is the confirmed + `AlarmExtension` ack-comment write target). -If the alarm has no writable ack-comment subtag (`AckComment` config key is -empty, or the entry's `ack_comment_subtag` field is empty), the ack call -returns a failure code that the gateway surfaces as `FailedPrecondition`. -`AcknowledgeByGuid` maps the synthetic GUID back to its reference via an -internal dictionary, then calls the same write path. +Because the write is authenticated, the Historian records the operator's name, +domain and full name, and the writer's MXAccess client name +(`MxGateway:Alarms:AckClientName`) as OperatorNode. If the alarm has no ack +target (`AckComment` config key is empty, the entry's `ack_comment_subtag` is +empty, or the reference or synthetic GUID is unknown), the worker reports it +as not found or not ackable and the gateway surfaces `InvalidRequest` with +`hresult` -1. `SubtagAlarmConsumer.Subscribe` advises the ack-comment subtag alongside the observed ones (active/acked/priority). This is required: MXAccess rejects a @@ -1027,7 +1025,7 @@ The subtag path was validated against live MXAccess on the dev rig `…​.Priority` → `500` (Int32), `…​.AckMsg` → string — confirming the field names **and** the runtime reference shape `..` with **no** intermediate alarm-condition segment. -- `AcknowledgeByName` (AckMsg write) returned `0` once the ack-comment subtag +- The `AckMsg` write returned `0` once the ack-comment subtag was advised — confirming the ack-by-comment-write mechanism end to end. ### Fidelity limitations diff --git a/docs/AlarmProbeFindings.md b/docs/AlarmProbeFindings.md index f47f7e8..6ef466d 100644 --- a/docs/AlarmProbeFindings.md +++ b/docs/AlarmProbeFindings.md @@ -25,6 +25,7 @@ the blocker rather than rediscovering it. | GUID stable across polls and `ALM → RTN` | Answered — yes (2026-05-01 capture in `AlarmClientDiscovery.md`, reconfirmed 2026-08-18) | | GUID stable across clear-then-re-raise | Answered 2026-08-18 — **no**, a re-raise mints a new GUID | | GUID stable across `UNACK → ACK` | **Open** — the rig cannot be driven into an acknowledged state; 2026-08-18 (third attempt) identifies the reason as the test attribute's `MxSecurityOperate` classification, which no non-interactive ack surface on the rig can satisfy | +| Acknowledge an alarm headless | Answered 2026-10-19 — **an `AckMsg` write as the authenticated operator**; every alarm-client ack is inert (see "Fourth attempt") | | `COUNT` = total active vs records-in-reply under a capped fetch | Answered 2026-08-18 — **records in the reply** | The 2026-08-17 run below is kept because it is the record of the wrong-verb blocker. The @@ -420,3 +421,73 @@ The second attempt's inventory, unchanged since: the three snapshot, since nothing can acknowledge them away. `SnapshotActiveAlarms` counts only `UNACK_ALM` and `ACK_ALM` as active, so these are inert for the gateway; they will clear on the next `alarmmgr` restart. + +## Fourth attempt (2026-10-19): authenticated AckMsg write + +The third attempt left the acknowledge leg blocked. This run found a route that +acknowledges and identifies the operator, and ruled out the alarm-client routes. +It drove `TestMachine_001.TestAlarm001`, raised with +`mxa write TestMachine_001.TestAlarm001 true --type bool --secured -u Administrator`. + +### Writing `AckMsg` acknowledges + +Writing the comment to `TestMachine_001.TestAlarm001.AckMsg` over MXAccess +acknowledges the alarm: `.Acked` becomes `True` and the wnwrap record moves to +`STATE=ACK_ALM` with an `ACK_COMMENT`. `AckMsg` is `MxSecurityFreeAccess` even +though the alarm UDA itself is `MxSecurityOperate`, so no Galaxy change is +needed. + +What the Historian records, read from `Runtime.dbo.v_AlarmEventHistory2`: + +| Write | Operator | DomainName | UserFullName | OperatorNode | Description | +|---|---|---|---|---|---| +| Unauthenticated `Write` | `NULL` | `NULL` | `NULL` | `NULL` | the comment | +| `AuthenticateUser` + `Write(value, userId)` | `Administrator` | `ArchestrA` | `Administrator` | `mxa` (the MXAccess `Register()` client name) | the comment | + +The wnwrap XML also gains `ACK_OPERATOR_NAME`, `ACK_DOMAIN_NAME`, +`ACK_OPERATOR_NODE` and `ACK_COMMENT` on the acknowledged record; the worker's +snapshot parser reads them for Acknowledge transitions. An unauthenticated write +therefore acknowledges but leaves no operator in the Historian, which is why the +gateway requires `operator_user` and authenticates it. + +Another actor's `AckMsg` writes on the same day (authenticated as `dohertj2`) +logged `Operator=dohertj2`, `Domain="."` and that machine's name as the node, +which matches the client-name rule. By this date unauthenticated writes to +`MxSecurityOperate` attributes were also accepted Galaxy-wide on the rig, where +August refused them with 1008. + +### Alarm-client acks are inert in every variant + +All of these returned `rc=0` or `-1` and changed neither the snapshot, `.Acked` +nor the Historian: + +- wnwrap `AlarmAckByName` and `AlarmAckByGUID` (`E_NOTIMPL`) on the ack-only and + reader consumers. +- wnwrap `IwwAlarmConsumer2` on a sole consumer with `SetXmlAlarmQuery` applied + (list populated, `GetAlarm(0)` returns the row): `AlarmAck` with ids + 0/1/2/3/-1/65536, and `Select(0)` + `AlarmAckSelected`, with operators + `TestOperator`, `Administrator` and `dohertj2`. `SelectByGUID` is `E_NOTIMPL`. +- The same calls with `TestAlarm001` temporarily reclassified to + `MxSecurityFreeAccess` (template and instance, restored afterwards), so the + security classification is not the cause. +- Managed `aaAlarmManagedClient`: `AlarmAckByGUID` and `SelectByGUID` return + `-1` (the client's list never fills headless, even with a real `hWnd` and a + message pump); `AckSelectedTagAlarms_Ex` returns `0` even for a + `NOSUCHPROVIDER` provider and writes no Historian row. The managed and wnwrap + consumers cannot share a process (`RegisterConsumer` returns `-65`). +- A 20 s continuous message pump after `AlarmAck`, and registering as `InTouch`, + `WindowViewer` or `Wonderware InTouch`. + +Static reverse engineering matched: the five-string COM acks forward domain and +full name with an empty password, and InTouch's `AlarmViewer.ocx` also sends an +empty password. The user-request call is synchronous request-to-return-code with +no completion channel, so the drop happens provider-side. Hooking the user +request in a live WindowViewer that acknowledges successfully and diffing it +against the headless probe is the one definitive step not taken. + +### Consequence + +The gateway acknowledges by authenticating the operator and writing `AckMsg` +(`docs/DesignDecisions.md`, "Alarm acknowledgement writes AckMsg as the +authenticated operator"). The managed ack consumer and the second wnwrap ack +consumer were removed. diff --git a/docs/Authorization.md b/docs/Authorization.md index 12c393d..21e60e7 100644 --- a/docs/Authorization.md +++ b/docs/Authorization.md @@ -125,7 +125,7 @@ public string ResolveRequiredScope(object request) } ``` -The `_ => GatewayScopes.Admin` fallback is intentional: any future request type that the resolver does not recognize fails closed, requiring the strongest scope until the resolver is updated. `AcknowledgeAlarm` is treated as a write — it mutates alarm state, mirroring `MxCommandKind.Write*` — and `StreamAlarms` and `QueryActiveAlarms` share the alarm/event surface with `StreamEvents` and `MxCommandKind.DrainEvents`, so they carry `events:read` (the active-alarm snapshot is the same data reachable through the event surface). All three alarm RPCs are session-less: the scope check is the only authorization gate, since there is no per-session ownership to enforce. +The `_ => GatewayScopes.Admin` fallback is intentional: any future request type that the resolver does not recognize fails closed, requiring the strongest scope until the resolver is updated. `AcknowledgeAlarm` is treated as a write — it mutates alarm state, mirroring `MxCommandKind.Write*` — and stays `invoke:write` rather than `invoke:secure` because the request carries the operator's own Galaxy credential, which the worker authenticates before the `AckMsg` write, so the credential, not the API-key scope, authorizes the identity the Historian records — and `StreamAlarms` and `QueryActiveAlarms` share the alarm/event surface with `StreamEvents` and `MxCommandKind.DrainEvents`, so they carry `events:read` (the active-alarm snapshot is the same data reachable through the event surface). All three alarm RPCs are session-less: the scope check is the only authorization gate, since there is no per-session ownership to enforce. `MxCommandRequest` is special because it multiplexes many MxAccess operations through a single RPC. The resolver inspects the embedded `MxCommandKind` so each operation gets its own scope: diff --git a/docs/Contracts.md b/docs/Contracts.md index dfa9093..d784ddb 100644 --- a/docs/Contracts.md +++ b/docs/Contracts.md @@ -49,6 +49,17 @@ worker prepares the replacement on its STA before swapping it into the existing failover consumer and returns a normal `MxCommandReply`; callers must treat only `protocol_status = OK` as acknowledgement of the new list. +`AcknowledgeAlarmRequest` carries the operator's identity: `operator_user` +(required), `operator_password` (field 6) and `operator_domain` (field 7). The +gateway copies them onto `AcknowledgeAlarmCommand` (`operator_password` = 7) and +`AcknowledgeAlarmByNameCommand` (`operator_password` = 9); the worker +authenticates the operator and writes `AckMsg` as that user. The fields are +additive, so `GatewayProtocolVersion` stays `3`. `operator_password` is a +credential and follows the same never-log rule as +`AuthenticateUserCommand.verify_user_password`: it must not reach logs, metric +labels, `DiagnosticMessage`, `ProtocolStatus.Message`, or exception text. A +not-found or not-ackable alarm is reported with `hresult` -1. + The command model also includes bulk write/read command kinds: `WriteBulk`, `Write2Bulk`, `WriteSecuredBulk`, `WriteSecured2Bulk`, and `ReadBulk`. They are unary `Invoke` payloads on the same `MxAccessGateway` diff --git a/docs/DesignDecisions.md b/docs/DesignDecisions.md index 1ea08f5..66ff30b 100644 --- a/docs/DesignDecisions.md +++ b/docs/DesignDecisions.md @@ -703,14 +703,15 @@ latch in subtag mode. ### Acknowledge via ack-comment write -In subtag mode, `AcknowledgeAlarm` writes the operator comment to the alarm -attribute's ack-comment subtag (`Fallback:Subtags:AckComment`). The write -performs the native ack in AVEVA. This differs from alarmmgr mode, where -`AlarmAckByName` on `wwAlarmConsumerClass` is called directly. The `AckComment` -subtag name is empty by default; configuring it is required for ack to work in -subtag mode. The exact AVEVA subtag names are not hard-coded — the `Subtags` -config block exists precisely so names are not guessed without validation -against the live MXAccess attribute set. +Both provider modes acknowledge by writing the operator comment to `AckMsg` as +the authenticated operator (see "Alarm acknowledgement writes AckMsg as the +authenticated operator" below). In alarm-manager mode the target is +`.AckMsg`; in subtag mode it is the watch-list entry's ack-comment +subtag (`Fallback:Subtags:AckComment`, default `AckMsg`). The write performs +the ack in AVEVA. An entry with an empty ack-comment subtag is not ackable and +the RPC reports `InvalidRequest`. The exact AVEVA subtag names are not +hard-coded — the `Subtags` config block exists so names are not guessed +without validation against the live MXAccess attribute set. ### Related documentation @@ -784,6 +785,39 @@ The following items were previously listed here and have since shipped: `MxGateway:Sessions:MaxEventSubscribersPerSession`. See [Event Subscribers](#event-subscribers) above and [Sessions](./Sessions.md). +## Alarm acknowledgement writes AckMsg as the authenticated operator + +**Decision (2026-10-19).** `AcknowledgeAlarm` authenticates the requesting +operator with `AuthenticateUser` (`operator_user`, `operator_password`, +optional `operator_domain`) and writes the comment to `.AckMsg` over +MXAccess as that user, on a dedicated `LMXProxyServer` registered as +`MxGateway:Alarms:AckClientName`. Both provider modes use this path. + +**Why.** Live tests (2026-10-18/19) showed every alarm-client ack is inert on a +headless consumer: wnwrap `AlarmAckByName`/`AlarmAckByGUID`/`AlarmAck`/ +`AlarmAckSelected`, and the managed `aaAlarmManagedClient` GUID ack, return +rc=0 or -1 and change nothing, regardless of security classification, +operator name or consumer name. An MXAccess write to `AckMsg` does acknowledge +the alarm, and when the write is made under an authenticated user the AVEVA +runtime historizes that identity (`Operator`, `DomainName`, `UserFullName`), +with the MXAccess client name as `OperatorNode`. The requirement is that the +real operator appears in the Historian, as with InTouch, so a shared gateway +service account was rejected. Operators have Galaxy accounts, so the +per-request credential is workable. See +[Alarm Probe Findings](./AlarmProbeFindings.md). + +**Consequences.** + +- `operator_user` is required; `operator_password` may be empty. +- `operator_password` is a credential. It is forwarded to the worker over the + pipe but never logged: `AcknowledgeAlarm` and `AcknowledgeAlarmByName` are + credential-bearing commands and any property named like `password` is + redacted. +- `Applied` and `Unconfirmed` (write sent, no `OnWriteComplete` in the bounded + wait) both report `OK`; clients that need certainty watch for the transition. +- The managed ack consumer, the WNAL native ack consumer, the second wnwrap + ack-only consumer and the opt-in managed-ack environment flag are removed. + ## Related Documentation - [Gateway Process Detailed Design](./GatewayProcessDesign.md) diff --git a/docs/Diagnostics.md b/docs/Diagnostics.md index 0e886b0..f061e11 100644 --- a/docs/Diagnostics.md +++ b/docs/Diagnostics.md @@ -4,7 +4,7 @@ The diagnostics subsystem provides structured logging, credential redaction, and ## Goals -The subsystem exists to satisfy two security rules from `gateway.md`: never log passwords or raw credential values for `AuthenticateUser`, `WriteSecured`, or related secured operations, and never log full MXAccess values by default. Code paths that touch credentials or tag values must therefore route through `GatewayLogRedactor` rather than emitting them directly. +The subsystem exists to satisfy two security rules from `gateway.md`: never log passwords or raw credential values for `AuthenticateUser`, `WriteSecured`, `AcknowledgeAlarm` (its `operator_password`), or related secured operations, and never log full MXAccess values by default. Code paths that touch credentials or tag values must therefore route through `GatewayLogRedactor` rather than emitting them directly. A second goal is parity-test diagnosability. Because MXAccess sessions, workers, correlation ids, and command methods are the units of comparison, every log entry produced inside a request scope must carry those identifiers without each call site having to format them. @@ -70,7 +70,9 @@ private static readonly HashSet SensitiveCommandMethods = new(StringComp { "AuthenticateUser", "WriteSecured", - "WriteSecured2" + "WriteSecured2", + "AcknowledgeAlarm", + "AcknowledgeAlarmByName" }; public static bool IsCredentialBearingCommand(string? commandMethod) @@ -80,7 +82,7 @@ public static bool IsCredentialBearingCommand(string? commandMethod) } ``` -The names match the MXAccess command list in `gateway.md` exactly. `Write` and `Write2` are not in the set because their payloads are tag values, not credentials, and are governed by the `valueLoggingEnabled` flag described below. +The names match the MXAccess command list in `gateway.md` exactly. `Write` and `Write2` are not in the set because their payloads are tag values, not credentials, and are governed by the `valueLoggingEnabled` flag described below. `AcknowledgeAlarm` and `AcknowledgeAlarmByName` are in the set because their commands carry the operator's `operator_password`. Independently of the method set, the name-based seam redacts any property whose name contains `password`, so a credential field added to a payload later is redacted without a code change. ### API key redaction diff --git a/docs/GatewayConfiguration.md b/docs/GatewayConfiguration.md index 98b8535..b9c8e69 100644 --- a/docs/GatewayConfiguration.md +++ b/docs/GatewayConfiguration.md @@ -442,17 +442,18 @@ behavior. | `MxGateway:Alarms:ReconcileIntervalSeconds` | `30` | How often the monitor re-resolves and, on material change, hot-swaps the subtag fallback watch-list, then reconciles its in-process alarm cache against the worker's authoritative active-alarm snapshot. The acknowledged watch-list fingerprint advances only after an OK worker reply. Floored at 5 seconds. | | `MxGateway:Alarms:PollIntervalMilliseconds` | `500` | Cadence at which the worker's STA polls the AVEVA alarm consumer (`GetXmlCurrentAlarms2`) for the active-alarm snapshot the live feed diffs. Must be between `100` and `3600000` (one hour): every poll is a COM call plus an XML parse on the same STA that serves reads and writes, so a tighter cadence starves the command path, while a value above an hour stops being a cadence and silently disables alarm polling. The gateway conveys the value to the worker via the `MXGATEWAY_ALARM_POLL_INTERVAL_MS` environment variable; a missing or out-of-range value leaves the worker on the 500 ms default rather than failing the session. | | `MxGateway:Alarms:MaxAlarmsPerFetch` | `1024` | Cap the worker passes to `GetXmlCurrentAlarms2`'s `maxAlmCnt`. Must be between `64` and `65536` — the worker is a 32-bit process that materializes each reply as one BSTR plus a full `XmlDocument`, so an unbounded cap faults the STA with an out-of-memory rather than merely slowing it. It doubles as the **truncation threshold**: a fetch returning exactly this many records is treated as truncated, because the COM API caps its reply with no "more available" flag. On a truncated poll the worker retains the alarms the capped reply could not mention instead of letting their absence read as a clear, and logs a rate-limited `AlarmSnapshotTruncated` warning to its stderr (identifiers and counts only). **Remediation when you see that warning: raise this value** so the steady-state active-alarm count fits inside one fetch. A galaxy permanently above the cap holds stale entries in the snapshot until a sub-cap poll, and loses clear→re-raise cycles that happen entirely out of window (see `docs/DesignDecisions.md`). Conveyed to the worker via the `MXGATEWAY_ALARM_MAX_ALARMS_PER_FETCH` environment variable; a missing or out-of-range value leaves the worker on the 1024 default. | +| `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`. | The alarm monitor is independent of client sessions: `AcknowledgeAlarm` and `StreamAlarms` are session-less RPCs served by the monitor. -The worker also reads `MXGATEWAY_USE_MANAGED_ALARM_ACK` (inherited from the -gateway process). Setting it to `1` is meant to route GUID-keyed acknowledges -through AVEVA's managed alarm client, but a live test (2026-10-18) showed the -path cannot work as built: the managed client fails to register alongside the -worker's wnwrap reader and the worker silently falls back to wnwrap. Leave it -unset. It has no `MxGateway:*` option. See -[AlarmClientDiscovery](./AlarmClientDiscovery.md) ("Opt-in managed GUID ack"). +`AckClientName` is the MXAccess client name the alarm worker's ack writer +registers its dedicated `LMXProxyServer` with. The Historian records it as the +OperatorNode of each acknowledgement, so it identifies the gateway as the +acknowledging node. The gateway passes it to each worker through the +`MXGATEWAY_ALARM_ACK_CLIENT_NAME` environment variable. Startup validation +rejects an empty value, a value over 64 characters, or one containing control +characters. ### Alarm fallback options @@ -471,7 +472,7 @@ native wnwrap alarm-manager provider and the subtag-monitoring fallback. | `MxGateway:Alarms:Fallback:Discovery:ExcludeAttributes` | _(empty)_ | Attribute paths to remove from the merged watch-list (case-insensitive). The exclude is applied after the Repository-derived rows and the explicit `IncludeAttributes` entries are combined, so an exclude that matches an explicit include suppresses it too — excludes win. Ignored when `UseGalaxyRepository` is `false`. | | `MxGateway:Alarms:Fallback:Subtags:Active` | `InAlarm` | Subtag name for the in-alarm boolean. Confirmed AVEVA `AlarmExtension` field name. | | `MxGateway:Alarms:Fallback:Subtags:Acked` | `Acked` | Subtag name for the acknowledged boolean. Confirmed AVEVA `AlarmExtension` field name. | -| `MxGateway:Alarms:Fallback:Subtags:AckComment` | `AckMsg` | Subtag name for the acknowledgement comment write target. Writing this subtag performs the acknowledge in AVEVA. Confirmed AVEVA `AlarmExtension` field name. When empty, the ack-comment write path is disabled. | +| `MxGateway:Alarms:Fallback:Subtags:AckComment` | `AckMsg` | Subtag name for the acknowledgement comment write target. Writing this subtag, as the authenticated operator, performs the acknowledge in AVEVA. Confirmed AVEVA `AlarmExtension` field name. When empty, the alarm is not ackable and the RPC returns `InvalidRequest`. | | `MxGateway:Alarms:Fallback:Subtags:Priority` | `Priority` | Subtag name for the alarm priority / severity value. Confirmed AVEVA `AlarmExtension` field name. | Validation rules: diff --git a/docs/Grpc.md b/docs/Grpc.md index 32bb4ff..11a2edd 100644 --- a/docs/Grpc.md +++ b/docs/Grpc.md @@ -96,9 +96,24 @@ Selected control commands have additional public behavior beyond their protobuf ### `AcknowledgeAlarm` -`AcknowledgeAlarm` is a unary, **session-less** RPC that acknowledges a single alarm. The handler validates `alarm_full_reference` inline (it does not run through `MxAccessGrpcRequestValidator`) and delegates to `IGatewayAlarmService.AcknowledgeAsync`. The always-on `GatewayAlarmMonitor` routes the ack over its own gateway-managed worker session — clients no longer open a session to acknowledge an alarm. A reference that parses as a canonical GUID forwards to `AcknowledgeAlarmCommand`; a `Provider!Group.Tag` reference forwards to `AcknowledgeAlarmByNameCommand`. +`AcknowledgeAlarm` is a unary, **session-less** RPC that acknowledges a single alarm. The handler validates `alarm_full_reference` and `operator_user` inline (it does not run through `MxAccessGrpcRequestValidator`) and delegates to `IGatewayAlarmService.AcknowledgeAsync`. The always-on `GatewayAlarmMonitor` routes the ack over its own gateway-managed worker session — clients no longer open a session to acknowledge an alarm. A reference that parses as a canonical GUID forwards to `AcknowledgeAlarmCommand`; a `Provider!Group.Tag` reference forwards to `AcknowledgeAlarmByNameCommand`. -An `OK` response means the alarm provider accepted the acknowledgement, not that it applied it: the gateway forwards the ack and reports what the provider returned, in keeping with the parity rule. On galaxies whose alarmed attributes carry a non-free-access security classification the by-name ack is accepted and inert — the underlying `AlarmAckByName` conveys an operator name rather than an authenticated identity, observed on the probe rig with the mechanism inferred rather than confirmed — so a client that needs to confirm an acknowledgement should watch for the resulting transition on `StreamAlarms` instead of treating `OK` as proof. See [Alarm Probe Findings](./AlarmProbeFindings.md). +Request fields that identify the acknowledging operator: + +- `operator_user` (required): the Galaxy account name. An empty value is rejected with `InvalidArgument` so the real operator is always recorded. +- `operator_password` (field 6): the operator's Galaxy password. May be empty because Galaxy accounts can have empty passwords. It is a credential and is never logged. +- `operator_domain` (field 7): optional. When set, the worker authenticates `domain\user`; when empty it authenticates `user`. + +The worker calls `AuthenticateUser` as the operator, then writes `operator_comment` to `.AckMsg` (subtag mode: the configured ack-comment subtag) on a dedicated MXAccess server registered as `MxGateway:Alarms:AckClientName`, and waits a bounded time for `OnWriteComplete`. The AVEVA runtime treats that write as the acknowledgement, and the Historian (`Runtime.dbo.v_AlarmEventHistory2`) records the operator name, domain and full name from the authenticated account, with the ack client name as `OperatorNode`. The alarm-client ack calls (`AlarmAckByName`, `AlarmAckByGUID`) are not used because a headless consumer's acks are accepted and dropped; see [Alarm Probe Findings](./AlarmProbeFindings.md). + +| Worker outcome | Response status | `hresult` | +|---|---|---| +| Write applied, or sent but no `OnWriteComplete` within the wait (unconfirmed, with a diagnostic) | `OK` | `0` | +| Unknown alarm, or no ack target (non-Galaxy provider, no ack-comment subtag) | `INVALID_REQUEST` | `-1` | +| `AuthenticateUser` failed | `MXACCESS_FAILURE` | the `AuthenticateUser` HRESULT | +| Write rejected (`OnWriteComplete` non-OK) | `MXACCESS_FAILURE` | the status `detail` | + +`reply.Statuses` carries the `OnWriteComplete` `MxStatusProxy` rows when they were observed. Because an unconfirmed write also returns `OK`, a client that must be certain should watch for the resulting transition on `StreamAlarms`. `AcknowledgeAlarm` stays under the `invoke:write` scope; see [Authorization](./Authorization.md). ### `StreamAlarms` @@ -217,6 +232,7 @@ receive this event directly. | `StreamEvents` | `session_id` must be non-empty. | `InvalidArgument` | | `Invoke` | `session_id` non-empty, `command` present, `kind` not `Unspecified`, payload oneof must match `kind`. | `InvalidArgument` | | `AcknowledgeAlarm` | `alarm_full_reference` must be non-empty. Validated inline in the handler, not by `MxAccessGrpcRequestValidator`. | `InvalidArgument` | +| `AcknowledgeAlarm` | `operator_user` must be non-empty. Validated inline in the handler. | `InvalidArgument` | | `StreamAlarms` | No required fields — `alarm_filter_prefix` is optional. | — | | `QueryActiveAlarms` | No required fields — `alarm_filter_prefix` is optional. Not routed through `MxAccessGrpcRequestValidator`; the handler only null-checks the request. | — | diff --git a/gateway.md b/gateway.md index f0c5791..c5ae6b4 100644 --- a/gateway.md +++ b/gateway.md @@ -140,8 +140,9 @@ one gateway-owned worker session subscribes the configured AVEVA alarm provider, caches the active-alarm set (reconciled periodically against the worker's snapshot), and fans it out to every client through the session-less `StreamAlarms` RPC — the stream opens with the current active-alarm snapshot, -then streams live transitions. `AcknowledgeAlarm` is session-less and routes -through the monitor. Clients never open a worker session to see alarms, and +then streams live transitions. `AcknowledgeAlarm` is session-less, routes +through the monitor, and carries the operator's credentials (see +"Acknowledge" below). Clients never open a worker session to see alarms, and alarm monitoring is independent of client lifecycle; the monitor re-opens its session if the worker faults. Gated by `MxGateway:Alarms:Enabled` — see `docs/DesignDecisions.md` for why this reverses the v1 single-subscriber rule @@ -221,20 +222,22 @@ forwards only events the worker emits and never synthesizes transitions itself. The synthesis rules are documented in `docs/AlarmClientDiscovery.md`. -**Acknowledge in subtag mode:** the ack-by-name path writes the operator -comment to the alarm attribute's ack-comment subtag. The write performs the -ack. If the attribute has no writable ack-comment subtag configured, the RPC -returns `FailedPrecondition`. In alarm-manager mode, `AlarmAckByName` is -used as before. - -**Acceptance is not application:** the ack is forwarded to the provider, and a -successful return means the provider accepted the call, not that the -acknowledgement was applied. On galaxies whose alarmed attributes carry a -non-free-access security classification, `AlarmAckByName` returns `rc=0` and the -alarm stays `UNACK_ALM` — it carries an operator name, not an authenticated -identity. (Observed on the probe rig; the mechanism is inferred.) Confirm an ack -by the resulting transition, never by the return code. See -`docs/AlarmProbeFindings.md`. +**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. An unknown alarm or one +with no ack target (non-Galaxy provider, no ack-comment subtag) is reported as +`InvalidRequest` with `hresult` -1. 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`). **Degraded state visibility:** every subtag-mode transition carries `degraded = true` and `source_provider = ALARM_PROVIDER_MODE_SUBTAG` on the @@ -1028,8 +1031,9 @@ Internal worker IPC: - gateway validates worker executable path and version, - no secrets in command line when avoidable. -Credential-sensitive commands such as `AuthenticateUser` and `WriteSecured` -must not log passwords or raw credential values. +Credential-sensitive commands such as `AuthenticateUser`, `WriteSecured` and +`AcknowledgeAlarm` (its `operator_password`) must not log passwords or raw +credential values. ## Observability diff --git a/src/ZB.MOM.WW.MxGateway.Server/appsettings.json b/src/ZB.MOM.WW.MxGateway.Server/appsettings.json index ad80bcf..1c6dc8f 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/appsettings.json +++ b/src/ZB.MOM.WW.MxGateway.Server/appsettings.json @@ -88,7 +88,8 @@ "DefaultArea": "", "ReconcileIntervalSeconds": 30, "PollIntervalMilliseconds": 500, - "MaxAlarmsPerFetch": 1024 + "MaxAlarmsPerFetch": 1024, + "AckClientName": "MxGateway" } } } diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs index 85089ea..ed998d9 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs @@ -202,14 +202,14 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer // Use the IwwAlarmConsumer (v1) prefix-named methods for the // lifecycle. Empirically (live dev-rig 2026-05-01, reconfirmed - // 2026-08-18) this is the only path that lets AlarmAckByName - // return rc=0 afterwards. The v2 Initialize/Register/Subscribe - // methods on the class succeed (return 0) but acks against that + // 2026-08-18) the v1 path is the one that leaves the consumer in + // a usable state: the v2 Initialize/Register/Subscribe methods on + // the class succeed (return 0) but later calls against that // consumer state return -55. The v1 prefix path is what - // WIN-911-style code uses against the same wnwrap library. - // Note rc=0 means the call was - // accepted, not that an acknowledgement was applied — see - // docs/AlarmProbeFindings.md. Acks now go through AlarmAckWriter. + // WIN-911-style code uses against the same wnwrap library. This + // consumer only reads the alarm snapshot; acknowledgements go + // through AlarmAckWriter, not through wnwrap (see + // docs/AlarmProbeFindings.md). int init = com.IwwAlarmConsumer_InitializeConsumer(DefaultApplicationName); if (init != 0) { @@ -615,10 +615,10 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer /// place under its original GUID and the re-raise replaced it with a /// freshly minted one. That is the intended reading — the old /// instance ended and a new one began — so the diff emits a Clear - /// plus a Raise, which is what happened. The acknowledge leg remains - /// assumed rather than observed: on the dev rig every ack surface is - /// inert (AlarmAckByName returns 0 and nothing moves), so no - /// record can be driven into an acknowledged state — see + /// plus a Raise, which is what happened. Every alarm-client ack surface + /// is inert on the dev rig (it returns 0 and nothing moves), so + /// acknowledgements go through the AckMsg writer; an AckMsg write + /// moves the record to ACK_ALM (observed 2026-10-19) — see /// docs/AlarmProbeFindings.md. /// /// From cfe9f817a36f496fe926aa79de48854c4861b35a Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 19 Oct 2026 19:42:12 -0400 Subject: [PATCH 11/25] feat(go-client): operator credentials on acknowledge-alarm Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT --- clients/go/README.md | 8 +- clients/go/cmd/mxgw-go/main.go | 24 ++++- .../internal/generated/mxaccess_gateway.pb.go | 93 ++++++++++++++----- clients/go/mxgateway/alarms.go | 9 +- clients/go/mxgateway/alarms_test.go | 29 ++++++ 5 files changed, 136 insertions(+), 27 deletions(-) diff --git a/clients/go/README.md b/clients/go/README.md index 577fd57..5e5fa0f 100644 --- a/clients/go/README.md +++ b/clients/go/README.md @@ -140,7 +140,11 @@ sentinel typed and observable; it never synthesizes or swallows it. For alarms, the package exposes `Client.QueryActiveAlarms` for one-shot snapshots, `Client.StreamAlarms` for the server-streaming feed, and -`Client.AcknowledgeAlarm` to ack an alarm by full reference. The streaming +`Client.AcknowledgeAlarm` to ack an alarm by full reference. Acks carry the +operator's Galaxy credentials (`OperatorUser` required, `OperatorPassword`, +optional `OperatorDomain`) so the gateway authenticates as the operator and the +Historian records that operator; an empty password is allowed, and the password +is scrubbed from any returned error. The streaming call returns a `StreamAlarmsClient`; cancel its context to terminate the stream. All three pass straight through to the gateway's central alarm monitor. @@ -424,7 +428,7 @@ Every subcommand wired into the CLI. All accept the common flags | `bench-read-bulk` | Throughput benchmark (`-duration-seconds`, `-warmup-seconds`, `-bulk-size`). | | `stream-events` | Stream item-value events for a session (`-session-id`, `-limit`). | | `stream-alarms` | Stream the alarm feed (`-filter-prefix`, `-limit`). | -| `acknowledge-alarm` | Acknowledge an alarm reference. | +| `acknowledge-alarm` | Acknowledge an alarm as an operator (`-operator`, `-operator-domain`, `-operator-password-env`, default `MXGATEWAY_OPERATOR_PASSWORD`). | | `smoke` | End-to-end smoke workflow against one item. | | `galaxy-test-connection` | Probe the Galaxy Repository RPC connection. | | `galaxy-last-deploy` | Print the most recent deploy event. | diff --git a/clients/go/cmd/mxgw-go/main.go b/clients/go/cmd/mxgw-go/main.go index 159d3a2..f973d99 100644 --- a/clients/go/cmd/mxgw-go/main.go +++ b/clients/go/cmd/mxgw-go/main.go @@ -1119,6 +1119,10 @@ func formatAlarmFeedMessage(message *mxgateway.AlarmFeedMessage) string { } } +// defaultOperatorPasswordEnv is the CLI environment variable holding the +// operator password for acknowledge-alarm. +const defaultOperatorPasswordEnv = "MXGATEWAY_OPERATOR_PASSWORD" + func runAcknowledgeAlarm(ctx context.Context, args []string, stdout, stderr io.Writer) error { flags := flag.NewFlagSet("acknowledge-alarm", flag.ContinueOnError) flags.SetOutput(stderr) @@ -1126,7 +1130,12 @@ func runAcknowledgeAlarm(ctx context.Context, args []string, stdout, stderr io.W jsonOutput := flags.Bool("json", false, "write JSON output") reference := flags.String("reference", "", "full alarm reference to acknowledge") comment := flags.String("comment", "", "operator acknowledge comment") - operator := flags.String("operator", "", "operator user performing the acknowledge") + operator := flags.String("operator", "", "operator user performing the acknowledge (required)") + operatorDomain := flags.String("operator-domain", "", "operator Galaxy domain (optional)") + // Prefer the environment variable so the credential stays out of shell + // history and the process table; an empty password is allowed. + operatorPassword := flags.String("operator-password", "", "operator password (prefer -operator-password-env)") + operatorPasswordEnv := flags.String("operator-password-env", defaultOperatorPasswordEnv, "environment variable containing the operator password") if err := flags.Parse(args); err != nil { return err @@ -1134,6 +1143,17 @@ func runAcknowledgeAlarm(ctx context.Context, args []string, stdout, stderr io.W if *reference == "" { return errors.New("reference is required") } + if *operator == "" { + return errors.New("operator is required") + } + resolvedOperatorPassword := *operatorPassword + if resolvedOperatorPassword == "" { + envName := *operatorPasswordEnv + if envName == "" { + envName = defaultOperatorPasswordEnv + } + resolvedOperatorPassword = os.Getenv(envName) + } client, options, err := dialForCommand(ctx, common) if err != nil { @@ -1145,6 +1165,8 @@ func runAcknowledgeAlarm(ctx context.Context, args []string, stdout, stderr io.W AlarmFullReference: *reference, Comment: *comment, OperatorUser: *operator, + OperatorDomain: *operatorDomain, + OperatorPassword: resolvedOperatorPassword, }) if err != nil { return err diff --git a/clients/go/internal/generated/mxaccess_gateway.pb.go b/clients/go/internal/generated/mxaccess_gateway.pb.go index 3b7aa9d..c66f9f9 100644 --- a/clients/go/internal/generated/mxaccess_gateway.pb.go +++ b/clients/go/internal/generated/mxaccess_gateway.pb.go @@ -3583,10 +3583,13 @@ func (x *AlarmFailoverConfig) GetFailbackStableProbes() int32 { return 0 } -// Acknowledge a single alarm by its GUID. Operator identity fields are -// recorded atomically with the ack transition in the alarm-history log. -// The reply's hresult / native_status surfaces AVEVA's -// AlarmAckByGUID return code. +// Acknowledge a single alarm by its GUID. The worker resolves the GUID to the +// alarm's Galaxy reference from its active-alarm snapshot and acknowledges it +// by writing `comment` to `.AckMsg` over MXAccess after +// AuthenticateUser as the operator, so the alarm history records the real +// operator. The reply's hresult / native_status is 0 when the write was +// applied (or sent but unconfirmed), otherwise the AuthenticateUser HRESULT or +// the OnWriteComplete status detail. type AcknowledgeAlarmCommand struct { state protoimpl.MessageState `protogen:"open.v1"` // Canonical 8-4-4-4-12 GUID string (e.g. "BCC47053-9542-4D65-BDAA-BCDEA6A32A73"). @@ -3596,6 +3599,10 @@ type AcknowledgeAlarmCommand struct { OperatorNode string `protobuf:"bytes,4,opt,name=operator_node,json=operatorNode,proto3" json:"operator_node,omitempty"` OperatorDomain string `protobuf:"bytes,5,opt,name=operator_domain,json=operatorDomain,proto3" json:"operator_domain,omitempty"` OperatorFullName string `protobuf:"bytes,6,opt,name=operator_full_name,json=operatorFullName,proto3" json:"operator_full_name,omitempty"` + // Operator credential used for AuthenticateUser before the AckMsg write. + // Implementations must keep this field out of logs, metrics labels, command + // lines, and diagnostics. + OperatorPassword string `protobuf:"bytes,7,opt,name=operator_password,json=operatorPassword,proto3" json:"operator_password,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -3672,6 +3679,13 @@ func (x *AcknowledgeAlarmCommand) GetOperatorFullName() string { return "" } +func (x *AcknowledgeAlarmCommand) GetOperatorPassword() string { + if x != nil { + return x.OperatorPassword + } + return "" +} + // Snapshot the currently-active alarm set. Optional filter prefix scopes // the snapshot to alarms whose alarm_full_reference starts with the // supplied string (matches QueryActiveAlarmsRequest.alarm_filter_prefix). @@ -3721,9 +3735,7 @@ func (x *QueryActiveAlarmsCommand) GetAlarmFilterPrefix() string { // Acknowledge a single alarm by its (name, provider, group) tuple. Used // when the public RPC's AlarmFullReference (Provider!Group.Tag) cannot -// be resolved to a GUID directly. The worker invokes -// wwAlarmConsumerClass.AlarmAckByName which reaches the same alarm -// history path as AlarmAckByGUID. +// be resolved to a GUID directly. The worker acknowledges by writing `comment` to `.AckMsg` (Galaxy provider only) after AuthenticateUser as the operator. type AcknowledgeAlarmByNameCommand struct { state protoimpl.MessageState `protogen:"open.v1"` // Tag/alarm name (e.g. "TestMachine_001.TestAlarm001"). Tag itself @@ -3739,6 +3751,10 @@ type AcknowledgeAlarmByNameCommand struct { OperatorNode string `protobuf:"bytes,6,opt,name=operator_node,json=operatorNode,proto3" json:"operator_node,omitempty"` OperatorDomain string `protobuf:"bytes,7,opt,name=operator_domain,json=operatorDomain,proto3" json:"operator_domain,omitempty"` OperatorFullName string `protobuf:"bytes,8,opt,name=operator_full_name,json=operatorFullName,proto3" json:"operator_full_name,omitempty"` + // Operator credential used for AuthenticateUser before the AckMsg write. + // Implementations must keep this field out of logs, metrics labels, command + // lines, and diagnostics. + OperatorPassword string `protobuf:"bytes,9,opt,name=operator_password,json=operatorPassword,proto3" json:"operator_password,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -3829,6 +3845,13 @@ func (x *AcknowledgeAlarmByNameCommand) GetOperatorFullName() string { return "" } +func (x *AcknowledgeAlarmByNameCommand) GetOperatorPassword() string { + if x != nil { + return x.OperatorPassword + } + return "" +} + type UnsubscribeBulkCommand struct { state protoimpl.MessageState `protogen:"open.v1"` ServerHandle int32 `protobuf:"varint,1,opt,name=server_handle,json=serverHandle,proto3" json:"server_handle,omitempty"` @@ -6127,11 +6150,11 @@ func (x *DrainEventsReply) GetEvents() []*MxEvent { // Reply payload for AcknowledgeAlarmCommand AND // AcknowledgeAlarmByNameCommand — both ack command kinds reuse this // payload case (`MxCommandReply.acknowledge_alarm`); there is no -// dedicated by-name reply case. Surfaces AVEVA's native ack return -// code (AlarmAckByGUID for the GUID arm, AlarmAckByName for the -// by-name arm); 0 means success. The MxCommandReply's hresult field +// dedicated by-name reply case. Surfaces the ack outcome: 0 when the +// AckMsg write was applied or sent but unconfirmed; otherwise the +// AuthenticateUser HRESULT or the OnWriteComplete status detail. The MxCommandReply's hresult field // carries the same value and is preferred for protocol consumers — -// this payload exists so the gateway-side WorkerAlarmRpcDispatcher +// this payload exists so GatewayAlarmMonitor // can echo native_status into AcknowledgeAlarmReply.hresult without // unpacking the outer envelope. type AcknowledgeAlarmReplyPayload struct { @@ -7217,9 +7240,18 @@ type AcknowledgeAlarmRequest struct { Comment string `protobuf:"bytes,4,opt,name=comment,proto3" json:"comment,omitempty"` // Operator principal performing the acknowledgement. The lmxopcua side // resolves this from the OPC UA session prior to invoking the RPC. - OperatorUser string `protobuf:"bytes,5,opt,name=operator_user,json=operatorUser,proto3" json:"operator_user,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + OperatorUser string `protobuf:"bytes,5,opt,name=operator_user,json=operatorUser,proto3" json:"operator_user,omitempty"` + // 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. + OperatorPassword string `protobuf:"bytes,6,opt,name=operator_password,json=operatorPassword,proto3" json:"operator_password,omitempty"` + // Optional domain for OS-authenticated galaxies. When set the gateway + // authenticates as "\". + OperatorDomain string `protobuf:"bytes,7,opt,name=operator_domain,json=operatorDomain,proto3" json:"operator_domain,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *AcknowledgeAlarmRequest) Reset() { @@ -7280,14 +7312,27 @@ func (x *AcknowledgeAlarmRequest) GetOperatorUser() string { return "" } +func (x *AcknowledgeAlarmRequest) GetOperatorPassword() string { + if x != nil { + return x.OperatorPassword + } + return "" +} + +func (x *AcknowledgeAlarmRequest) GetOperatorDomain() string { + if x != nil { + return x.OperatorDomain + } + return "" +} + type AcknowledgeAlarmReply struct { state protoimpl.MessageState `protogen:"open.v1"` CorrelationId string `protobuf:"bytes,2,opt,name=correlation_id,json=correlationId,proto3" json:"correlation_id,omitempty"` ProtocolStatus *ProtocolStatus `protobuf:"bytes,3,opt,name=protocol_status,json=protocolStatus,proto3" json:"protocol_status,omitempty"` // Native ack return code echoed from the worker. The worker carries the // ack outcome as a single int32 (AcknowledgeAlarmReplyPayload.native_status, - // = AlarmAckByName / AlarmAckByGUID return code; 0 = success); the gateway's - // WorkerAlarmRpcDispatcher copies that value here. This is the authoritative + // 0 = applied or sent; otherwise the AuthenticateUser HRESULT or OnWriteComplete detail); GatewayAlarmMonitor copies that value here. This is the authoritative // ack-outcome field for the public RPC. Absent only when the worker reply // omitted the value entirely (a protocol violation). Hresult *int32 `protobuf:"varint,4,opt,name=hresult,proto3,oneof" json:"hresult,omitempty"` @@ -8992,7 +9037,7 @@ const file_mxaccess_gateway_proto_rawDesc = "" + "\x13AlarmFailoverConfig\x12B\n" + "\x1dconsecutive_failure_threshold\x18\x01 \x01(\x05R\x1bconsecutiveFailureThreshold\x12E\n" + "\x1ffailback_probe_interval_seconds\x18\x02 \x01(\x05R\x1cfailbackProbeIntervalSeconds\x124\n" + - "\x16failback_stable_probes\x18\x03 \x01(\x05R\x14failbackStableProbes\"\xf3\x01\n" + + "\x16failback_stable_probes\x18\x03 \x01(\x05R\x14failbackStableProbes\"\xa0\x02\n" + "\x17AcknowledgeAlarmCommand\x12\x1d\n" + "\n" + "alarm_guid\x18\x01 \x01(\tR\talarmGuid\x12\x18\n" + @@ -9000,9 +9045,10 @@ const file_mxaccess_gateway_proto_rawDesc = "" + "\roperator_user\x18\x03 \x01(\tR\foperatorUser\x12#\n" + "\roperator_node\x18\x04 \x01(\tR\foperatorNode\x12'\n" + "\x0foperator_domain\x18\x05 \x01(\tR\x0eoperatorDomain\x12,\n" + - "\x12operator_full_name\x18\x06 \x01(\tR\x10operatorFullName\"J\n" + + "\x12operator_full_name\x18\x06 \x01(\tR\x10operatorFullName\x12+\n" + + "\x11operator_password\x18\a \x01(\tR\x10operatorPassword\"J\n" + "\x18QueryActiveAlarmsCommand\x12.\n" + - "\x13alarm_filter_prefix\x18\x01 \x01(\tR\x11alarmFilterPrefix\"\xbd\x02\n" + + "\x13alarm_filter_prefix\x18\x01 \x01(\tR\x11alarmFilterPrefix\"\xea\x02\n" + "\x1dAcknowledgeAlarmByNameCommand\x12\x1d\n" + "\n" + "alarm_name\x18\x01 \x01(\tR\talarmName\x12#\n" + @@ -9013,7 +9059,8 @@ const file_mxaccess_gateway_proto_rawDesc = "" + "\roperator_user\x18\x05 \x01(\tR\foperatorUser\x12#\n" + "\roperator_node\x18\x06 \x01(\tR\foperatorNode\x12'\n" + "\x0foperator_domain\x18\a \x01(\tR\x0eoperatorDomain\x12,\n" + - "\x12operator_full_name\x18\b \x01(\tR\x10operatorFullName\"`\n" + + "\x12operator_full_name\x18\b \x01(\tR\x10operatorFullName\x12+\n" + + "\x11operator_password\x18\t \x01(\tR\x10operatorPassword\"`\n" + "\x16UnsubscribeBulkCommand\x12#\n" + "\rserver_handle\x18\x01 \x01(\x05R\fserverHandle\x12!\n" + "\fitem_handles\x18\x02 \x03(\x05R\vitemHandles\"v\n" + @@ -9262,12 +9309,14 @@ const file_mxaccess_gateway_proto_rawDesc = "" + "limitValue\x12\x1a\n" + "\bdegraded\x18\x0e \x01(\bR\bdegraded\x12O\n" + "\x0fsource_provider\x18\x0f \x01(\x0e2&.mxaccess_gateway.v1.AlarmProviderModeR\x0esourceProvider\x126\n" + - "\x17from_truncated_snapshot\x18\x10 \x01(\bR\x15fromTruncatedSnapshot\"\xd0\x01\n" + + "\x17from_truncated_snapshot\x18\x10 \x01(\bR\x15fromTruncatedSnapshot\"\xa6\x02\n" + "\x17AcknowledgeAlarmRequest\x122\n" + "\x15client_correlation_id\x18\x02 \x01(\tR\x13clientCorrelationId\x120\n" + "\x14alarm_full_reference\x18\x03 \x01(\tR\x12alarmFullReference\x12\x18\n" + "\acomment\x18\x04 \x01(\tR\acomment\x12#\n" + - "\roperator_user\x18\x05 \x01(\tR\foperatorUserJ\x04\b\x01\x10\x02R\n" + + "\roperator_user\x18\x05 \x01(\tR\foperatorUser\x12+\n" + + "\x11operator_password\x18\x06 \x01(\tR\x10operatorPassword\x12'\n" + + "\x0foperator_domain\x18\a \x01(\tR\x0eoperatorDomainJ\x04\b\x01\x10\x02R\n" + "session_id\"\xb4\x02\n" + "\x15AcknowledgeAlarmReply\x12%\n" + "\x0ecorrelation_id\x18\x02 \x01(\tR\rcorrelationId\x12L\n" + diff --git a/clients/go/mxgateway/alarms.go b/clients/go/mxgateway/alarms.go index bc7993b..b0fe2ab 100644 --- a/clients/go/mxgateway/alarms.go +++ b/clients/go/mxgateway/alarms.go @@ -10,6 +10,11 @@ import ( // invoke:alarm-ack scope and forwards the acknowledge to the worker's MXAccess // session; the resulting native MxStatus is returned in the reply. // +// OperatorUser is required: the gateway authenticates the operator (with +// OperatorPassword and optional OperatorDomain) so the Historian records the +// real operator. An empty password is allowed. The password is scrubbed from +// any error this method returns. +// // Acks are idempotent — re-acking an already-acked condition is a no-op at // the MxAccess layer. func (c *Client) AcknowledgeAlarm(ctx context.Context, req *AcknowledgeAlarmRequest) (*AcknowledgeAlarmReply, error) { @@ -22,10 +27,10 @@ func (c *Client) AcknowledgeAlarm(ctx context.Context, req *AcknowledgeAlarmRequ reply, err := c.raw.AcknowledgeAlarm(callCtx, req) if err != nil { - return nil, &GatewayError{Op: "acknowledge alarm", Err: err} + return nil, redactSecrets(&GatewayError{Op: "acknowledge alarm", Err: err}, req.GetOperatorPassword()) } if err := EnsureProtocolSuccess("acknowledge alarm", reply.GetProtocolStatus(), nil); err != nil { - return reply, err + return reply, redactSecrets(err, req.GetOperatorPassword()) } return reply, nil diff --git a/clients/go/mxgateway/alarms_test.go b/clients/go/mxgateway/alarms_test.go index 83005db..073adfb 100644 --- a/clients/go/mxgateway/alarms_test.go +++ b/clients/go/mxgateway/alarms_test.go @@ -5,6 +5,7 @@ import ( "errors" "io" "net" + "strings" "testing" pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated" @@ -38,6 +39,8 @@ func TestAcknowledgeAlarmSendsRequestAndReturnsReply(t *testing.T) { AlarmFullReference: "Tank01.Level.HiHi", Comment: "investigating", OperatorUser: "alice", + OperatorPassword: "pw-secret", + OperatorDomain: "CORP", }) if err != nil { t.Fatalf("AcknowledgeAlarm() error = %v", err) @@ -51,11 +54,37 @@ func TestAcknowledgeAlarmSendsRequestAndReturnsReply(t *testing.T) { if got := fake.acknowledgeRequest.GetComment(); got != "investigating" { t.Fatalf("captured comment = %q", got) } + if got := fake.acknowledgeRequest.GetOperatorPassword(); got != "pw-secret" { + t.Fatalf("captured operator password mismatch") + } + if got := fake.acknowledgeRequest.GetOperatorDomain(); got != "CORP" { + t.Fatalf("captured operator domain = %q", got) + } if got := fake.acknowledgeAuth; got != "Bearer test-api-key" { t.Fatalf("authorization metadata = %q", got) } } +func TestAcknowledgeAlarmRedactsPasswordFromError(t *testing.T) { + fake := &fakeGatewayWithAlarms{ + acknowledgeError: status.Error(codes.Internal, "boom pw-secret"), + } + client, cleanup := newBufconnClientWithAlarms(t, fake) + defer cleanup() + + _, err := client.AcknowledgeAlarm(context.Background(), &pb.AcknowledgeAlarmRequest{ + AlarmFullReference: "Tank01.Level.HiHi", + OperatorUser: "alice", + OperatorPassword: "pw-secret", + }) + if err == nil { + t.Fatal("AcknowledgeAlarm() returned no error") + } + if strings.Contains(err.Error(), "pw-secret") { + t.Fatalf("error leaks operator password: %v", err) + } +} + func TestAcknowledgeAlarmRejectsNilRequest(t *testing.T) { fake := &fakeGatewayWithAlarms{} client, cleanup := newBufconnClientWithAlarms(t, fake) From 417a47bdb5dc1aa4d77318377d794b023255ef5e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 19 Oct 2026 19:42:33 -0400 Subject: [PATCH 12/25] feat(rust-client): operator credentials on acknowledge-alarm Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT --- clients/rust/README.md | 6 +- clients/rust/RustClientDesign.md | 4 +- clients/rust/crates/mxgw-cli/src/main.rs | 19 +++++++ clients/rust/src/client.rs | 21 +++++-- clients/rust/src/error.rs | 70 ++++++++++++++++++++++++ clients/rust/tests/client_behavior.rs | 35 +++++++++++- 6 files changed, 145 insertions(+), 10 deletions(-) diff --git a/clients/rust/README.md b/clients/rust/README.md index 119b36f..963f915 100644 --- a/clients/rust/README.md +++ b/clients/rust/README.md @@ -78,7 +78,8 @@ cargo run -p mxgw-cli -- add-item --session-id --server-handle 1 -- cargo run -p mxgw-cli -- advise --session-id --server-handle 1 --item-handle 1 --json cargo run -p mxgw-cli -- stream-events --session-id --max-events 1 --json cargo run -p mxgw-cli -- stream-alarms --session-id --max-messages 4 --json -cargo run -p mxgw-cli -- acknowledge-alarm --session-id --alarm-reference "\\Galaxy\Area001.Pump001.PumpFault" --json +cargo run -p mxgw-cli -- acknowledge-alarm --reference "Area001.Pump001.PumpFault" --operator [--operator-domain ] --json +# operator password: MXGATEWAY_OPERATOR_PASSWORD, or --operator-password-env / --operator-password cargo run -p mxgw-cli -- write --session-id --server-handle 1 --item-handle 1 --value-type int32 --value 123 --json ``` @@ -124,6 +125,9 @@ creates an authenticated `tonic` client and attaches `authorization: Bearer `stream_alarms`, `acknowledge_alarm`, and `raw_client`. `stream_alarms` returns an `AlarmFeedStream` async stream of alarm-feed messages and shares the gateway's central alarm monitor with every other client. +`acknowledge_alarm` requires `operator_user` and carries `operator_password` (may be +empty) and `operator_domain`; the gateway authenticates as that operator so the Historian +records them. The password is scrubbed from any returned error's `Display`/`Debug`. The `from_truncated_snapshot` field on `ActiveAlarmSnapshot` reports that the record came from a provider fetch which hit the per-fetch cap: the snapshot set may omit active diff --git a/clients/rust/RustClientDesign.md b/clients/rust/RustClientDesign.md index 13056cd..140692f 100644 --- a/clients/rust/RustClientDesign.md +++ b/clients/rust/RustClientDesign.md @@ -174,6 +174,8 @@ alarm monitor — no worker session is opened — so any number of clients may attach. Dropping the stream cancels the gRPC call cooperatively. `acknowledge_alarm` is idempotent at the MxAccess layer; the returned `AcknowledgeAlarmReply` carries the native MxStatus from the worker. +`operator_user` is required; `operator_password` may be empty and is scrubbed from any +returned error via `Error::redact_secrets`. ## Galaxy Repository @@ -346,7 +348,7 @@ mxgw write-secured-bulk ... mxgw write-secured2-bulk ... mxgw stream-events --session-id --json mxgw stream-alarms [--filter-prefix ] [--max-events ] -mxgw acknowledge-alarm --reference [--comment ] [--operator ] +mxgw acknowledge-alarm --reference [--comment ] --operator [--operator-domain ] [--operator-password-env =MXGATEWAY_OPERATOR_PASSWORD] [--operator-password ] mxgw bench-read-bulk [--duration-seconds ] [--warmup-seconds ] [--bulk-size ] mxgw smoke --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --item TestChildObject.TestInt mxgw batch diff --git a/clients/rust/crates/mxgw-cli/src/main.rs b/clients/rust/crates/mxgw-cli/src/main.rs index 41393ec..075f0a3 100644 --- a/clients/rust/crates/mxgw-cli/src/main.rs +++ b/clients/rust/crates/mxgw-cli/src/main.rs @@ -376,6 +376,15 @@ enum Command { comment: String, #[arg(long, default_value = "")] operator: String, + /// Galaxy domain of the operator; empty authenticates the bare user name. + #[arg(long, default_value = "")] + operator_domain: String, + /// Operator password (prefer `--operator-password-env`; empty is allowed). + #[arg(long)] + operator_password: Option, + /// Environment variable holding the operator password. + #[arg(long, default_value = "MXGATEWAY_OPERATOR_PASSWORD")] + operator_password_env: String, #[arg(long)] json: bool, }, @@ -1081,8 +1090,16 @@ async fn dispatch(command: Command) -> Result<(), Error> { reference, comment, operator, + operator_domain, + operator_password, + operator_password_env, json, } => { + // An empty password is allowed (Galaxy accounts may have one), so + // an unset flag and env resolve to an empty string. + let operator_password = operator_password + .or_else(|| env::var(&operator_password_env).ok()) + .unwrap_or_default(); let client = connect(connection).await?; let reply = client .acknowledge_alarm(AcknowledgeAlarmRequest { @@ -1090,6 +1107,8 @@ async fn dispatch(command: Command) -> Result<(), Error> { alarm_full_reference: reference, comment, operator_user: operator, + operator_password, + operator_domain, }) .await?; print_acknowledge_alarm_reply(&reply, json); diff --git a/clients/rust/src/client.rs b/clients/rust/src/client.rs index aa3e9b1..0388915 100644 --- a/clients/rust/src/client.rs +++ b/clients/rust/src/client.rs @@ -327,6 +327,9 @@ impl GatewayClient { /// MXAccess session; the resulting native MxStatus is returned in the /// reply. Acks are idempotent at the MxAccess layer. /// + /// `operator_user` is required; `operator_password` may be empty. The + /// password is scrubbed from any returned error's `Display`/`Debug`. + /// /// # Errors /// /// Returns [`Error::ProtocolStatus`] when the gateway accepts the call but @@ -336,13 +339,19 @@ impl GatewayClient { &self, request: AcknowledgeAlarmRequest, ) -> Result { + let secret = request.operator_password.clone(); let mut client = self.inner.clone(); - 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) + 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; + // The operator password must never surface in a returned error. + result.map_err(|error: Error| error.redact_secrets(&[secret.as_str()])) } /// Open the server-streaming `QueryActiveAlarms` RPC — the gateway's diff --git a/clients/rust/src/error.rs b/clients/rust/src/error.rs index 80654c0..84a6782 100644 --- a/clients/rust/src/error.rs +++ b/clients/rust/src/error.rs @@ -314,6 +314,68 @@ impl std::fmt::Display for MxAccessError { impl std::error::Error for MxAccessError {} +impl Error { + /// Scrub exact caller-supplied secrets (for example an operator password) + /// from the error's rendered text and any wrapped `tonic::Status`, so that + /// neither `Display` nor `Debug` can reveal them. Empty secrets are ignored. + #[must_use] + pub fn redact_secrets(self, secrets: &[&str]) -> Self { + let scrub = |text: &str| { + let mut out = text.to_owned(); + for secret in secrets.iter().filter(|secret| !secret.is_empty()) { + out = out.replace(*secret, ""); + } + out + }; + let scrub_status = |status: Box| { + Box::new(tonic::Status::new(status.code(), scrub(status.message()))) + }; + match self { + Self::Authentication { message, status } => Self::Authentication { + message: scrub(&message), + status: scrub_status(status), + }, + Self::Authorization { message, status } => Self::Authorization { + message: scrub(&message), + status: scrub_status(status), + }, + Self::Timeout { message, status } => Self::Timeout { + message: scrub(&message), + status: scrub_status(status), + }, + Self::Cancelled { message, status } => Self::Cancelled { + message: scrub(&message), + status: scrub_status(status), + }, + Self::Unavailable { message, status } => Self::Unavailable { + message: scrub(&message), + status: scrub_status(status), + }, + Self::Status(status) => Self::Status(scrub_status(status)), + Self::ProtocolStatus { + operation, + code, + message, + } => Self::ProtocolStatus { + operation, + code, + message: scrub(&message), + }, + Self::MalformedReply { detail } => Self::MalformedReply { + detail: scrub(&detail), + }, + Self::InvalidArgument { name, detail } => Self::InvalidArgument { + name, + detail: scrub(&detail), + }, + Self::MxAccess(error) => Self::MxAccess(Box::new( + error.with_secrets(secrets.iter().map(|secret| (*secret).to_owned()).collect()), + )), + other => other, + } + } +} + impl From for Error { fn from(status: tonic::Status) -> Self { let message = redact_credentials(status.message()); @@ -461,6 +523,14 @@ fn redact_credentials(message: &str) -> String { #[cfg(test)] mod tests { + #[test] + fn redact_secrets_scrubs_status_display_and_debug() { + let error: super::Error = tonic::Status::internal("boom pw-secret").into(); + let error = error.redact_secrets(&["pw-secret", ""]); + assert!(!format!("{error:?}").contains("pw-secret")); + assert!(!error.to_string().contains("pw-secret")); + } + use tonic::{Code, Status}; use super::{ensure_mxaccess_success, Error}; diff --git a/clients/rust/tests/client_behavior.rs b/clients/rust/tests/client_behavior.rs index 4b0dba3..1fbeac8 100644 --- a/clients/rust/tests/client_behavior.rs +++ b/clients/rust/tests/client_behavior.rs @@ -229,6 +229,8 @@ async fn acknowledge_alarm_returns_reply_with_native_status() { alarm_full_reference: "Tank01.Level.HiHi".to_owned(), comment: "investigating".to_owned(), operator_user: "alice".to_owned(), + operator_password: String::new(), + operator_domain: String::new(), }) .await .unwrap(); @@ -1036,6 +1038,30 @@ async fn stream_alarms_emits_snapshot_then_complete_then_transition_in_order() { )); } +#[tokio::test] +async fn acknowledge_alarm_error_does_not_leak_operator_password() { + let state = Arc::new(FakeState::default()); + let endpoint = spawn_fake_gateway(state.clone()).await; + let client = GatewayClient::connect(ClientOptions::new(endpoint)) + .await + .unwrap(); + + let err = client + .acknowledge_alarm(AcknowledgeAlarmRequest { + client_correlation_id: "corr-pw".to_owned(), + alarm_full_reference: "Tank01.Level.HiHi".to_owned(), + comment: String::new(), + operator_user: "alice".to_owned(), + operator_password: "pw-secret".to_owned(), + operator_domain: String::new(), + }) + .await + .unwrap_err(); + + assert!(!format!("{err:?}").contains("pw-secret")); + assert!(!err.to_string().contains("pw-secret")); +} + #[tokio::test] async fn cli_subcommands_propagate_unique_correlation_ids_from_next_correlation_id() { // The CLI's `stream-alarms` and `acknowledge-alarm` paths used to @@ -1073,6 +1099,8 @@ async fn cli_subcommands_propagate_unique_correlation_ids_from_next_correlation_ alarm_full_reference: "Tank01.Level.HiHi".to_owned(), comment: String::new(), operator_user: String::new(), + operator_password: String::new(), + operator_domain: String::new(), }) .await .unwrap(); @@ -1424,8 +1452,11 @@ impl MxAccessGateway for FakeGateway { &self, request: Request, ) -> Result, Status> { - *self.state.last_correlation_id.lock().await = - Some(request.into_inner().client_correlation_id); + let request = request.into_inner(); + if request.operator_password == "pw-secret" { + return Err(Status::internal("boom pw-secret")); + } + *self.state.last_correlation_id.lock().await = Some(request.client_correlation_id); Ok(Response::new(AcknowledgeAlarmReply { correlation_id: "corr-1".to_owned(), protocol_status: Some(ok_status("ack ok")), From 84e04b65529e8f5d2afd8ec443f2b927bcaa6f79 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 19 Oct 2026 19:42:10 -0400 Subject: [PATCH 13/25] feat(java-client): operator credentials on acknowledge-alarm Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT --- clients/java/README.md | 9 +- .../mxaccess_gateway/v1/MxaccessGateway.java | 1708 ++++++++++++----- .../zb/mom/ww/mxgateway/cli/MxGatewayCli.java | 27 + .../ww/mxgateway/cli/MxGatewayCliTests.java | 11 + .../ww/mxgateway/client/MxGatewayClient.java | 15 +- .../ww/mxgateway/client/MxGatewayErrors.java | 37 + 6 files changed, 1354 insertions(+), 453 deletions(-) diff --git a/clients/java/README.md b/clients/java/README.md index f530f2e..05e6c08 100644 --- a/clients/java/README.md +++ b/clients/java/README.md @@ -114,8 +114,11 @@ try (MxEventStream events = session.streamEventsAfter(lastSeenSequence)) { For alarms, `MxGatewayClient` exposes `queryActiveAlarms` (one-shot snapshot), `streamAlarms` (returns an `MxGatewayAlarmFeedSubscription` whose iterator yields alarm-feed messages from the gateway's central monitor), and -`acknowledgeAlarm` (ack by full alarm reference with an optional comment and -ack target). Close the subscription to cancel the underlying gRPC stream. +`acknowledgeAlarm` (ack by full alarm reference with an optional comment). +The ack carries operator credentials: `operator_user` is required, and +`operator_password` / `operator_domain` let the gateway authenticate as that +operator so the Historian records the real operator. An empty password is +allowed. The password is scrubbed from exceptions the client throws. Close the subscription to cancel the underlying gRPC stream. `ActiveAlarmSnapshot.getFromTruncatedSnapshot()` reports that the record came from a provider fetch which hit the per-fetch cap: the snapshot set may omit @@ -417,7 +420,7 @@ gradle :zb-mom-ww-mxgateway-cli:run --args="authenticate-user --endpoint localho gradle :zb-mom-ww-mxgateway-cli:run --args="write-secured --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id --server-handle 1 --item-handle 1 --type int32 --value 123 --current-user-id 100 --verifier-user-id 100 --json" gradle :zb-mom-ww-mxgateway-cli:run --args="stream-events --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id --limit 1 --json" gradle :zb-mom-ww-mxgateway-cli:run --args="stream-alarms --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --filter-prefix Galaxy --limit 4 --json" -gradle :zb-mom-ww-mxgateway-cli:run --args="acknowledge-alarm --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --reference \"\\Galaxy\Area001.Pump001.PumpFault\" --json" +gradle :zb-mom-ww-mxgateway-cli:run --args="acknowledge-alarm --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --reference \"\\Galaxy\Area001.Pump001.PumpFault\" --operator operator1 --operator-password-env MXGATEWAY_OPERATOR_PASSWORD --json" gradle :zb-mom-ww-mxgateway-cli:run --args="smoke --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --item TestObject.TestInt --json" ``` diff --git a/clients/java/src/main/generated/main/java/mxaccess_gateway/v1/MxaccessGateway.java b/clients/java/src/main/generated/main/java/mxaccess_gateway/v1/MxaccessGateway.java index a9f9bca..a448289 100644 --- a/clients/java/src/main/generated/main/java/mxaccess_gateway/v1/MxaccessGateway.java +++ b/clients/java/src/main/generated/main/java/mxaccess_gateway/v1/MxaccessGateway.java @@ -37653,13 +37653,40 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { */ com.google.protobuf.ByteString getOperatorFullNameBytes(); + + /** + *
+     * 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; + * @return The operatorPassword. + */ + java.lang.String getOperatorPassword(); + /** + *
+     * 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; + * @return The bytes for operatorPassword. + */ + com.google.protobuf.ByteString + getOperatorPasswordBytes(); } /** *
-   * Acknowledge a single alarm by its GUID. Operator identity fields are
-   * recorded atomically with the ack transition in the alarm-history log.
-   * The reply's hresult / native_status surfaces AVEVA's
-   * AlarmAckByGUID return code.
+   * 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.
    * 
* * Protobuf type {@code mxaccess_gateway.v1.AcknowledgeAlarmCommand} @@ -37689,6 +37716,7 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { operatorNode_ = ""; operatorDomain_ = ""; operatorFullName_ = ""; + operatorPassword_ = ""; } public static final com.google.protobuf.Descriptors.Descriptor @@ -37946,6 +37974,57 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { } } + public static final int OPERATOR_PASSWORD_FIELD_NUMBER = 7; + @SuppressWarnings("serial") + private volatile java.lang.Object operatorPassword_ = ""; + /** + *
+     * 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; + * @return The operatorPassword. + */ + @java.lang.Override + public java.lang.String getOperatorPassword() { + java.lang.Object ref = operatorPassword_; + if (ref instanceof java.lang.String) { + return (java.lang.String) ref; + } else { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + operatorPassword_ = s; + return s; + } + } + /** + *
+     * 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; + * @return The bytes for operatorPassword. + */ + @java.lang.Override + public com.google.protobuf.ByteString + getOperatorPasswordBytes() { + java.lang.Object ref = operatorPassword_; + if (ref instanceof java.lang.String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + operatorPassword_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + private byte memoizedIsInitialized = -1; @java.lang.Override public final boolean isInitialized() { @@ -37978,6 +38057,9 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { if (!com.google.protobuf.GeneratedMessage.isStringEmpty(operatorFullName_)) { com.google.protobuf.GeneratedMessage.writeString(output, 6, operatorFullName_); } + if (!com.google.protobuf.GeneratedMessage.isStringEmpty(operatorPassword_)) { + com.google.protobuf.GeneratedMessage.writeString(output, 7, operatorPassword_); + } getUnknownFields().writeTo(output); } @@ -38005,6 +38087,9 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { if (!com.google.protobuf.GeneratedMessage.isStringEmpty(operatorFullName_)) { size += com.google.protobuf.GeneratedMessage.computeStringSize(6, operatorFullName_); } + if (!com.google.protobuf.GeneratedMessage.isStringEmpty(operatorPassword_)) { + size += com.google.protobuf.GeneratedMessage.computeStringSize(7, operatorPassword_); + } size += getUnknownFields().getSerializedSize(); memoizedSize = size; return size; @@ -38032,6 +38117,8 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { .equals(other.getOperatorDomain())) return false; if (!getOperatorFullName() .equals(other.getOperatorFullName())) return false; + if (!getOperatorPassword() + .equals(other.getOperatorPassword())) return false; if (!getUnknownFields().equals(other.getUnknownFields())) return false; return true; } @@ -38055,6 +38142,8 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { hash = (53 * hash) + getOperatorDomain().hashCode(); hash = (37 * hash) + OPERATOR_FULL_NAME_FIELD_NUMBER; hash = (53 * hash) + getOperatorFullName().hashCode(); + hash = (37 * hash) + OPERATOR_PASSWORD_FIELD_NUMBER; + hash = (53 * hash) + getOperatorPassword().hashCode(); hash = (29 * hash) + getUnknownFields().hashCode(); memoizedHashCode = hash; return hash; @@ -38154,10 +38243,13 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { } /** *
-     * Acknowledge a single alarm by its GUID. Operator identity fields are
-     * recorded atomically with the ack transition in the alarm-history log.
-     * The reply's hresult / native_status surfaces AVEVA's
-     * AlarmAckByGUID return code.
+     * 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.
      * 
* * Protobuf type {@code mxaccess_gateway.v1.AcknowledgeAlarmCommand} @@ -38199,6 +38291,7 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { operatorNode_ = ""; operatorDomain_ = ""; operatorFullName_ = ""; + operatorPassword_ = ""; return this; } @@ -38250,6 +38343,9 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { if (((from_bitField0_ & 0x00000020) != 0)) { result.operatorFullName_ = operatorFullName_; } + if (((from_bitField0_ & 0x00000040) != 0)) { + result.operatorPassword_ = operatorPassword_; + } } @java.lang.Override @@ -38294,6 +38390,11 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { bitField0_ |= 0x00000020; onChanged(); } + if (!other.getOperatorPassword().isEmpty()) { + operatorPassword_ = other.operatorPassword_; + bitField0_ |= 0x00000040; + onChanged(); + } this.mergeUnknownFields(other.getUnknownFields()); onChanged(); return this; @@ -38350,6 +38451,11 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { bitField0_ |= 0x00000020; break; } // case 50 + case 58: { + operatorPassword_ = input.readStringRequireUtf8(); + bitField0_ |= 0x00000040; + break; + } // case 58 default: { if (!super.parseUnknownField(input, extensionRegistry, tag)) { done = true; // was an endgroup tag @@ -38819,6 +38925,108 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { return this; } + private java.lang.Object operatorPassword_ = ""; + /** + *
+       * 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; + * @return The operatorPassword. + */ + public java.lang.String getOperatorPassword() { + java.lang.Object ref = operatorPassword_; + if (!(ref instanceof java.lang.String)) { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + operatorPassword_ = s; + return s; + } else { + return (java.lang.String) ref; + } + } + /** + *
+       * 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; + * @return The bytes for operatorPassword. + */ + public com.google.protobuf.ByteString + getOperatorPasswordBytes() { + java.lang.Object ref = operatorPassword_; + if (ref instanceof String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + operatorPassword_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + /** + *
+       * 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; + * @param value The operatorPassword to set. + * @return This builder for chaining. + */ + public Builder setOperatorPassword( + java.lang.String value) { + if (value == null) { throw new NullPointerException(); } + operatorPassword_ = value; + bitField0_ |= 0x00000040; + onChanged(); + return this; + } + /** + *
+       * 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; + * @return This builder for chaining. + */ + public Builder clearOperatorPassword() { + operatorPassword_ = getDefaultInstance().getOperatorPassword(); + bitField0_ = (bitField0_ & ~0x00000040); + onChanged(); + return this; + } + /** + *
+       * 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; + * @param value The bytes for operatorPassword to set. + * @return This builder for chaining. + */ + public Builder setOperatorPasswordBytes( + com.google.protobuf.ByteString value) { + if (value == null) { throw new NullPointerException(); } + checkByteStringIsUtf8(value); + operatorPassword_ = value; + bitField0_ |= 0x00000040; + onChanged(); + return this; + } + // @@protoc_insertion_point(builder_scope:mxaccess_gateway.v1.AcknowledgeAlarmCommand) } @@ -39519,14 +39727,36 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { */ com.google.protobuf.ByteString getOperatorFullNameBytes(); + + /** + *
+     * 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; + * @return The operatorPassword. + */ + java.lang.String getOperatorPassword(); + /** + *
+     * 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; + * @return The bytes for operatorPassword. + */ + com.google.protobuf.ByteString + getOperatorPasswordBytes(); } /** *
    * Acknowledge a single alarm by its (name, provider, group) tuple. Used
    * when the public RPC's AlarmFullReference (Provider!Group.Tag) cannot
-   * be resolved to a GUID directly. The worker invokes
-   * wwAlarmConsumerClass.AlarmAckByName which reaches the same alarm
-   * history path as AlarmAckByGUID.
+   * be resolved to a GUID directly. The worker acknowledges by writing `comment` to `<alarm_name>.AckMsg` (Galaxy provider only) after AuthenticateUser as the operator.
    * 
* * Protobuf type {@code mxaccess_gateway.v1.AcknowledgeAlarmByNameCommand} @@ -39558,6 +39788,7 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { operatorNode_ = ""; operatorDomain_ = ""; operatorFullName_ = ""; + operatorPassword_ = ""; } public static final com.google.protobuf.Descriptors.Descriptor @@ -39913,6 +40144,57 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { } } + public static final int OPERATOR_PASSWORD_FIELD_NUMBER = 9; + @SuppressWarnings("serial") + private volatile java.lang.Object operatorPassword_ = ""; + /** + *
+     * 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; + * @return The operatorPassword. + */ + @java.lang.Override + public java.lang.String getOperatorPassword() { + java.lang.Object ref = operatorPassword_; + if (ref instanceof java.lang.String) { + return (java.lang.String) ref; + } else { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + operatorPassword_ = s; + return s; + } + } + /** + *
+     * 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; + * @return The bytes for operatorPassword. + */ + @java.lang.Override + public com.google.protobuf.ByteString + getOperatorPasswordBytes() { + java.lang.Object ref = operatorPassword_; + if (ref instanceof java.lang.String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + operatorPassword_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + private byte memoizedIsInitialized = -1; @java.lang.Override public final boolean isInitialized() { @@ -39951,6 +40233,9 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { if (!com.google.protobuf.GeneratedMessage.isStringEmpty(operatorFullName_)) { com.google.protobuf.GeneratedMessage.writeString(output, 8, operatorFullName_); } + if (!com.google.protobuf.GeneratedMessage.isStringEmpty(operatorPassword_)) { + com.google.protobuf.GeneratedMessage.writeString(output, 9, operatorPassword_); + } getUnknownFields().writeTo(output); } @@ -39984,6 +40269,9 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { if (!com.google.protobuf.GeneratedMessage.isStringEmpty(operatorFullName_)) { size += com.google.protobuf.GeneratedMessage.computeStringSize(8, operatorFullName_); } + if (!com.google.protobuf.GeneratedMessage.isStringEmpty(operatorPassword_)) { + size += com.google.protobuf.GeneratedMessage.computeStringSize(9, operatorPassword_); + } size += getUnknownFields().getSerializedSize(); memoizedSize = size; return size; @@ -40015,6 +40303,8 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { .equals(other.getOperatorDomain())) return false; if (!getOperatorFullName() .equals(other.getOperatorFullName())) return false; + if (!getOperatorPassword() + .equals(other.getOperatorPassword())) return false; if (!getUnknownFields().equals(other.getUnknownFields())) return false; return true; } @@ -40042,6 +40332,8 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { hash = (53 * hash) + getOperatorDomain().hashCode(); hash = (37 * hash) + OPERATOR_FULL_NAME_FIELD_NUMBER; hash = (53 * hash) + getOperatorFullName().hashCode(); + hash = (37 * hash) + OPERATOR_PASSWORD_FIELD_NUMBER; + hash = (53 * hash) + getOperatorPassword().hashCode(); hash = (29 * hash) + getUnknownFields().hashCode(); memoizedHashCode = hash; return hash; @@ -40143,9 +40435,7 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { *
      * Acknowledge a single alarm by its (name, provider, group) tuple. Used
      * when the public RPC's AlarmFullReference (Provider!Group.Tag) cannot
-     * be resolved to a GUID directly. The worker invokes
-     * wwAlarmConsumerClass.AlarmAckByName which reaches the same alarm
-     * history path as AlarmAckByGUID.
+     * be resolved to a GUID directly. The worker acknowledges by writing `comment` to `<alarm_name>.AckMsg` (Galaxy provider only) after AuthenticateUser as the operator.
      * 
* * Protobuf type {@code mxaccess_gateway.v1.AcknowledgeAlarmByNameCommand} @@ -40189,6 +40479,7 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { operatorNode_ = ""; operatorDomain_ = ""; operatorFullName_ = ""; + operatorPassword_ = ""; return this; } @@ -40246,6 +40537,9 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { if (((from_bitField0_ & 0x00000080) != 0)) { result.operatorFullName_ = operatorFullName_; } + if (((from_bitField0_ & 0x00000100) != 0)) { + result.operatorPassword_ = operatorPassword_; + } } @java.lang.Override @@ -40300,6 +40594,11 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { bitField0_ |= 0x00000080; onChanged(); } + if (!other.getOperatorPassword().isEmpty()) { + operatorPassword_ = other.operatorPassword_; + bitField0_ |= 0x00000100; + onChanged(); + } this.mergeUnknownFields(other.getUnknownFields()); onChanged(); return this; @@ -40366,6 +40665,11 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { bitField0_ |= 0x00000080; break; } // case 66 + case 74: { + operatorPassword_ = input.readStringRequireUtf8(); + bitField0_ |= 0x00000100; + break; + } // case 74 default: { if (!super.parseUnknownField(input, extensionRegistry, tag)) { done = true; // was an endgroup tag @@ -41029,6 +41333,108 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { return this; } + private java.lang.Object operatorPassword_ = ""; + /** + *
+       * 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; + * @return The operatorPassword. + */ + public java.lang.String getOperatorPassword() { + java.lang.Object ref = operatorPassword_; + if (!(ref instanceof java.lang.String)) { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + operatorPassword_ = s; + return s; + } else { + return (java.lang.String) ref; + } + } + /** + *
+       * 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; + * @return The bytes for operatorPassword. + */ + public com.google.protobuf.ByteString + getOperatorPasswordBytes() { + java.lang.Object ref = operatorPassword_; + if (ref instanceof String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + operatorPassword_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + /** + *
+       * 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; + * @param value The operatorPassword to set. + * @return This builder for chaining. + */ + public Builder setOperatorPassword( + java.lang.String value) { + if (value == null) { throw new NullPointerException(); } + operatorPassword_ = value; + bitField0_ |= 0x00000100; + onChanged(); + return this; + } + /** + *
+       * 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; + * @return This builder for chaining. + */ + public Builder clearOperatorPassword() { + operatorPassword_ = getDefaultInstance().getOperatorPassword(); + bitField0_ = (bitField0_ & ~0x00000100); + onChanged(); + return this; + } + /** + *
+       * 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; + * @param value The bytes for operatorPassword to set. + * @return This builder for chaining. + */ + public Builder setOperatorPasswordBytes( + com.google.protobuf.ByteString value) { + if (value == null) { throw new NullPointerException(); } + checkByteStringIsUtf8(value); + operatorPassword_ = value; + bitField0_ |= 0x00000100; + onChanged(); + return this; + } + // @@protoc_insertion_point(builder_scope:mxaccess_gateway.v1.AcknowledgeAlarmByNameCommand) } @@ -71719,11 +72125,11 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { * Reply payload for AcknowledgeAlarmCommand AND * AcknowledgeAlarmByNameCommand — both ack command kinds reuse this * payload case (`MxCommandReply.acknowledge_alarm`); there is no - * dedicated by-name reply case. Surfaces AVEVA's native ack return - * code (AlarmAckByGUID for the GUID arm, AlarmAckByName for the - * by-name arm); 0 means success. The MxCommandReply's hresult field + * dedicated by-name reply case. Surfaces the ack outcome: 0 when the + * AckMsg write was applied or sent but unconfirmed; otherwise the + * AuthenticateUser HRESULT or the OnWriteComplete status detail. The MxCommandReply's hresult field * carries the same value and is preferred for protocol consumers — - * this payload exists so the gateway-side WorkerAlarmRpcDispatcher + * this payload exists so GatewayAlarmMonitor * can echo native_status into AcknowledgeAlarmReply.hresult without * unpacking the outer envelope. * @@ -71937,11 +72343,11 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { * Reply payload for AcknowledgeAlarmCommand AND * AcknowledgeAlarmByNameCommand — both ack command kinds reuse this * payload case (`MxCommandReply.acknowledge_alarm`); there is no - * dedicated by-name reply case. Surfaces AVEVA's native ack return - * code (AlarmAckByGUID for the GUID arm, AlarmAckByName for the - * by-name arm); 0 means success. The MxCommandReply's hresult field + * dedicated by-name reply case. Surfaces the ack outcome: 0 when the + * AckMsg write was applied or sent but unconfirmed; otherwise the + * AuthenticateUser HRESULT or the OnWriteComplete status detail. The MxCommandReply's hresult field * carries the same value and is preferred for protocol consumers — - * this payload exists so the gateway-side WorkerAlarmRpcDispatcher + * this payload exists so GatewayAlarmMonitor * can echo native_status into AcknowledgeAlarmReply.hresult without * unpacking the outer envelope. * @@ -87166,6 +87572,56 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { */ com.google.protobuf.ByteString getOperatorUserBytes(); + + /** + *
+     * 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; + * @return The operatorPassword. + */ + java.lang.String getOperatorPassword(); + /** + *
+     * 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; + * @return The bytes for operatorPassword. + */ + com.google.protobuf.ByteString + getOperatorPasswordBytes(); + + /** + *
+     * Optional domain for OS-authenticated galaxies. When set the gateway
+     * authenticates as "<operator_domain>\<operator_user>".
+     * 
+ * + * string operator_domain = 7; + * @return The operatorDomain. + */ + java.lang.String getOperatorDomain(); + /** + *
+     * Optional domain for OS-authenticated galaxies. When set the gateway
+     * authenticates as "<operator_domain>\<operator_user>".
+     * 
+ * + * string operator_domain = 7; + * @return The bytes for operatorDomain. + */ + com.google.protobuf.ByteString + getOperatorDomainBytes(); } /** * Protobuf type {@code mxaccess_gateway.v1.AcknowledgeAlarmRequest} @@ -87193,6 +87649,8 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { alarmFullReference_ = ""; comment_ = ""; operatorUser_ = ""; + operatorPassword_ = ""; + operatorDomain_ = ""; } public static final com.google.protobuf.Descriptors.Descriptor @@ -87390,6 +87848,110 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { } } + public static final int OPERATOR_PASSWORD_FIELD_NUMBER = 6; + @SuppressWarnings("serial") + private volatile java.lang.Object operatorPassword_ = ""; + /** + *
+     * 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; + * @return The operatorPassword. + */ + @java.lang.Override + public java.lang.String getOperatorPassword() { + java.lang.Object ref = operatorPassword_; + if (ref instanceof java.lang.String) { + return (java.lang.String) ref; + } else { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + operatorPassword_ = s; + return s; + } + } + /** + *
+     * 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; + * @return The bytes for operatorPassword. + */ + @java.lang.Override + public com.google.protobuf.ByteString + getOperatorPasswordBytes() { + java.lang.Object ref = operatorPassword_; + if (ref instanceof java.lang.String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + operatorPassword_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + + public static final int OPERATOR_DOMAIN_FIELD_NUMBER = 7; + @SuppressWarnings("serial") + private volatile java.lang.Object operatorDomain_ = ""; + /** + *
+     * Optional domain for OS-authenticated galaxies. When set the gateway
+     * authenticates as "<operator_domain>\<operator_user>".
+     * 
+ * + * string operator_domain = 7; + * @return The operatorDomain. + */ + @java.lang.Override + public java.lang.String getOperatorDomain() { + java.lang.Object ref = operatorDomain_; + if (ref instanceof java.lang.String) { + return (java.lang.String) ref; + } else { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + operatorDomain_ = s; + return s; + } + } + /** + *
+     * Optional domain for OS-authenticated galaxies. When set the gateway
+     * authenticates as "<operator_domain>\<operator_user>".
+     * 
+ * + * string operator_domain = 7; + * @return The bytes for operatorDomain. + */ + @java.lang.Override + public com.google.protobuf.ByteString + getOperatorDomainBytes() { + java.lang.Object ref = operatorDomain_; + if (ref instanceof java.lang.String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + operatorDomain_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + private byte memoizedIsInitialized = -1; @java.lang.Override public final boolean isInitialized() { @@ -87416,6 +87978,12 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { if (!com.google.protobuf.GeneratedMessage.isStringEmpty(operatorUser_)) { com.google.protobuf.GeneratedMessage.writeString(output, 5, operatorUser_); } + if (!com.google.protobuf.GeneratedMessage.isStringEmpty(operatorPassword_)) { + com.google.protobuf.GeneratedMessage.writeString(output, 6, operatorPassword_); + } + if (!com.google.protobuf.GeneratedMessage.isStringEmpty(operatorDomain_)) { + com.google.protobuf.GeneratedMessage.writeString(output, 7, operatorDomain_); + } getUnknownFields().writeTo(output); } @@ -87437,6 +88005,12 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { if (!com.google.protobuf.GeneratedMessage.isStringEmpty(operatorUser_)) { size += com.google.protobuf.GeneratedMessage.computeStringSize(5, operatorUser_); } + if (!com.google.protobuf.GeneratedMessage.isStringEmpty(operatorPassword_)) { + size += com.google.protobuf.GeneratedMessage.computeStringSize(6, operatorPassword_); + } + if (!com.google.protobuf.GeneratedMessage.isStringEmpty(operatorDomain_)) { + size += com.google.protobuf.GeneratedMessage.computeStringSize(7, operatorDomain_); + } size += getUnknownFields().getSerializedSize(); memoizedSize = size; return size; @@ -87460,6 +88034,10 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { .equals(other.getComment())) return false; if (!getOperatorUser() .equals(other.getOperatorUser())) return false; + if (!getOperatorPassword() + .equals(other.getOperatorPassword())) return false; + if (!getOperatorDomain() + .equals(other.getOperatorDomain())) return false; if (!getUnknownFields().equals(other.getUnknownFields())) return false; return true; } @@ -87479,6 +88057,10 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { hash = (53 * hash) + getComment().hashCode(); hash = (37 * hash) + OPERATOR_USER_FIELD_NUMBER; hash = (53 * hash) + getOperatorUser().hashCode(); + hash = (37 * hash) + OPERATOR_PASSWORD_FIELD_NUMBER; + hash = (53 * hash) + getOperatorPassword().hashCode(); + hash = (37 * hash) + OPERATOR_DOMAIN_FIELD_NUMBER; + hash = (53 * hash) + getOperatorDomain().hashCode(); hash = (29 * hash) + getUnknownFields().hashCode(); memoizedHashCode = hash; return hash; @@ -87614,6 +88196,8 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { alarmFullReference_ = ""; comment_ = ""; operatorUser_ = ""; + operatorPassword_ = ""; + operatorDomain_ = ""; return this; } @@ -87659,6 +88243,12 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { if (((from_bitField0_ & 0x00000008) != 0)) { result.operatorUser_ = operatorUser_; } + if (((from_bitField0_ & 0x00000010) != 0)) { + result.operatorPassword_ = operatorPassword_; + } + if (((from_bitField0_ & 0x00000020) != 0)) { + result.operatorDomain_ = operatorDomain_; + } } @java.lang.Override @@ -87693,6 +88283,16 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { bitField0_ |= 0x00000008; onChanged(); } + if (!other.getOperatorPassword().isEmpty()) { + operatorPassword_ = other.operatorPassword_; + bitField0_ |= 0x00000010; + onChanged(); + } + if (!other.getOperatorDomain().isEmpty()) { + operatorDomain_ = other.operatorDomain_; + bitField0_ |= 0x00000020; + onChanged(); + } this.mergeUnknownFields(other.getUnknownFields()); onChanged(); return this; @@ -87739,6 +88339,16 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { bitField0_ |= 0x00000008; break; } // case 42 + case 50: { + operatorPassword_ = input.readStringRequireUtf8(); + bitField0_ |= 0x00000010; + break; + } // case 50 + case 58: { + operatorDomain_ = input.readStringRequireUtf8(); + bitField0_ |= 0x00000020; + break; + } // case 58 default: { if (!super.parseUnknownField(input, extensionRegistry, tag)) { done = true; // was an endgroup tag @@ -88109,6 +88719,215 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { return this; } + private java.lang.Object operatorPassword_ = ""; + /** + *
+       * 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; + * @return The operatorPassword. + */ + public java.lang.String getOperatorPassword() { + java.lang.Object ref = operatorPassword_; + if (!(ref instanceof java.lang.String)) { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + operatorPassword_ = s; + return s; + } else { + return (java.lang.String) ref; + } + } + /** + *
+       * 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; + * @return The bytes for operatorPassword. + */ + public com.google.protobuf.ByteString + getOperatorPasswordBytes() { + java.lang.Object ref = operatorPassword_; + if (ref instanceof String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + operatorPassword_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + /** + *
+       * 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; + * @param value The operatorPassword to set. + * @return This builder for chaining. + */ + public Builder setOperatorPassword( + java.lang.String value) { + if (value == null) { throw new NullPointerException(); } + operatorPassword_ = value; + bitField0_ |= 0x00000010; + onChanged(); + return this; + } + /** + *
+       * 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; + * @return This builder for chaining. + */ + public Builder clearOperatorPassword() { + operatorPassword_ = getDefaultInstance().getOperatorPassword(); + bitField0_ = (bitField0_ & ~0x00000010); + onChanged(); + return this; + } + /** + *
+       * 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; + * @param value The bytes for operatorPassword to set. + * @return This builder for chaining. + */ + public Builder setOperatorPasswordBytes( + com.google.protobuf.ByteString value) { + if (value == null) { throw new NullPointerException(); } + checkByteStringIsUtf8(value); + operatorPassword_ = value; + bitField0_ |= 0x00000010; + onChanged(); + return this; + } + + private java.lang.Object operatorDomain_ = ""; + /** + *
+       * Optional domain for OS-authenticated galaxies. When set the gateway
+       * authenticates as "<operator_domain>\<operator_user>".
+       * 
+ * + * string operator_domain = 7; + * @return The operatorDomain. + */ + public java.lang.String getOperatorDomain() { + java.lang.Object ref = operatorDomain_; + if (!(ref instanceof java.lang.String)) { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + operatorDomain_ = s; + return s; + } else { + return (java.lang.String) ref; + } + } + /** + *
+       * Optional domain for OS-authenticated galaxies. When set the gateway
+       * authenticates as "<operator_domain>\<operator_user>".
+       * 
+ * + * string operator_domain = 7; + * @return The bytes for operatorDomain. + */ + public com.google.protobuf.ByteString + getOperatorDomainBytes() { + java.lang.Object ref = operatorDomain_; + if (ref instanceof String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + operatorDomain_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + /** + *
+       * Optional domain for OS-authenticated galaxies. When set the gateway
+       * authenticates as "<operator_domain>\<operator_user>".
+       * 
+ * + * string operator_domain = 7; + * @param value The operatorDomain to set. + * @return This builder for chaining. + */ + public Builder setOperatorDomain( + java.lang.String value) { + if (value == null) { throw new NullPointerException(); } + operatorDomain_ = value; + bitField0_ |= 0x00000020; + onChanged(); + return this; + } + /** + *
+       * Optional domain for OS-authenticated galaxies. When set the gateway
+       * authenticates as "<operator_domain>\<operator_user>".
+       * 
+ * + * string operator_domain = 7; + * @return This builder for chaining. + */ + public Builder clearOperatorDomain() { + operatorDomain_ = getDefaultInstance().getOperatorDomain(); + bitField0_ = (bitField0_ & ~0x00000020); + onChanged(); + return this; + } + /** + *
+       * Optional domain for OS-authenticated galaxies. When set the gateway
+       * authenticates as "<operator_domain>\<operator_user>".
+       * 
+ * + * string operator_domain = 7; + * @param value The bytes for operatorDomain to set. + * @return This builder for chaining. + */ + public Builder setOperatorDomainBytes( + com.google.protobuf.ByteString value) { + if (value == null) { throw new NullPointerException(); } + checkByteStringIsUtf8(value); + operatorDomain_ = value; + bitField0_ |= 0x00000020; + onChanged(); + return this; + } + // @@protoc_insertion_point(builder_scope:mxaccess_gateway.v1.AcknowledgeAlarmRequest) } @@ -88195,8 +89014,7 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { *
      * Native ack return code echoed from the worker. The worker carries the
      * ack outcome as a single int32 (AcknowledgeAlarmReplyPayload.native_status,
-     * = AlarmAckByName / AlarmAckByGUID return code; 0 = success); the gateway's
-     * WorkerAlarmRpcDispatcher copies that value here. This is the authoritative
+     * 0 = applied or sent; otherwise the AuthenticateUser HRESULT or OnWriteComplete detail); GatewayAlarmMonitor copies that value here. This is the authoritative
      * ack-outcome field for the public RPC. Absent only when the worker reply
      * omitted the value entirely (a protocol violation).
      * 
@@ -88209,8 +89027,7 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { *
      * Native ack return code echoed from the worker. The worker carries the
      * ack outcome as a single int32 (AcknowledgeAlarmReplyPayload.native_status,
-     * = AlarmAckByName / AlarmAckByGUID return code; 0 = success); the gateway's
-     * WorkerAlarmRpcDispatcher copies that value here. This is the authoritative
+     * 0 = applied or sent; otherwise the AuthenticateUser HRESULT or OnWriteComplete detail); GatewayAlarmMonitor copies that value here. This is the authoritative
      * ack-outcome field for the public RPC. Absent only when the worker reply
      * omitted the value entirely (a protocol violation).
      * 
@@ -88382,8 +89199,7 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { *
      * Native ack return code echoed from the worker. The worker carries the
      * ack outcome as a single int32 (AcknowledgeAlarmReplyPayload.native_status,
-     * = AlarmAckByName / AlarmAckByGUID return code; 0 = success); the gateway's
-     * WorkerAlarmRpcDispatcher copies that value here. This is the authoritative
+     * 0 = applied or sent; otherwise the AuthenticateUser HRESULT or OnWriteComplete detail); GatewayAlarmMonitor copies that value here. This is the authoritative
      * ack-outcome field for the public RPC. Absent only when the worker reply
      * omitted the value entirely (a protocol violation).
      * 
@@ -88399,8 +89215,7 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { *
      * Native ack return code echoed from the worker. The worker carries the
      * ack outcome as a single int32 (AcknowledgeAlarmReplyPayload.native_status,
-     * = AlarmAckByName / AlarmAckByGUID return code; 0 = success); the gateway's
-     * WorkerAlarmRpcDispatcher copies that value here. This is the authoritative
+     * 0 = applied or sent; otherwise the AuthenticateUser HRESULT or OnWriteComplete detail); GatewayAlarmMonitor copies that value here. This is the authoritative
      * ack-outcome field for the public RPC. Absent only when the worker reply
      * omitted the value entirely (a protocol violation).
      * 
@@ -89130,8 +89945,7 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { *
        * Native ack return code echoed from the worker. The worker carries the
        * ack outcome as a single int32 (AcknowledgeAlarmReplyPayload.native_status,
-       * = AlarmAckByName / AlarmAckByGUID return code; 0 = success); the gateway's
-       * WorkerAlarmRpcDispatcher copies that value here. This is the authoritative
+       * 0 = applied or sent; otherwise the AuthenticateUser HRESULT or OnWriteComplete detail); GatewayAlarmMonitor copies that value here. This is the authoritative
        * ack-outcome field for the public RPC. Absent only when the worker reply
        * omitted the value entirely (a protocol violation).
        * 
@@ -89147,8 +89961,7 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { *
        * Native ack return code echoed from the worker. The worker carries the
        * ack outcome as a single int32 (AcknowledgeAlarmReplyPayload.native_status,
-       * = AlarmAckByName / AlarmAckByGUID return code; 0 = success); the gateway's
-       * WorkerAlarmRpcDispatcher copies that value here. This is the authoritative
+       * 0 = applied or sent; otherwise the AuthenticateUser HRESULT or OnWriteComplete detail); GatewayAlarmMonitor copies that value here. This is the authoritative
        * ack-outcome field for the public RPC. Absent only when the worker reply
        * omitted the value entirely (a protocol violation).
        * 
@@ -89164,8 +89977,7 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { *
        * Native ack return code echoed from the worker. The worker carries the
        * ack outcome as a single int32 (AcknowledgeAlarmReplyPayload.native_status,
-       * = AlarmAckByName / AlarmAckByGUID return code; 0 = success); the gateway's
-       * WorkerAlarmRpcDispatcher copies that value here. This is the authoritative
+       * 0 = applied or sent; otherwise the AuthenticateUser HRESULT or OnWriteComplete detail); GatewayAlarmMonitor copies that value here. This is the authoritative
        * ack-outcome field for the public RPC. Absent only when the worker reply
        * omitted the value entirely (a protocol violation).
        * 
@@ -89185,8 +89997,7 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { *
        * Native ack return code echoed from the worker. The worker carries the
        * ack outcome as a single int32 (AcknowledgeAlarmReplyPayload.native_status,
-       * = AlarmAckByName / AlarmAckByGUID return code; 0 = success); the gateway's
-       * WorkerAlarmRpcDispatcher copies that value here. This is the authoritative
+       * 0 = applied or sent; otherwise the AuthenticateUser HRESULT or OnWriteComplete detail); GatewayAlarmMonitor copies that value here. This is the authoritative
        * ack-outcome field for the public RPC. Absent only when the worker reply
        * omitted the value entirely (a protocol violation).
        * 
@@ -107427,415 +108238,418 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { "verConfig\022%\n\035consecutive_failure_thresho" + "ld\030\001 \001(\005\022\'\n\037failback_probe_interval_seco" + "nds\030\002 \001(\005\022\036\n\026failback_stable_probes\030\003 \001(" + - "\005\"\241\001\n\027AcknowledgeAlarmCommand\022\022\n\nalarm_g" + + "\005\"\274\001\n\027AcknowledgeAlarmCommand\022\022\n\nalarm_g" + "uid\030\001 \001(\t\022\017\n\007comment\030\002 \001(\t\022\025\n\roperator_u" + "ser\030\003 \001(\t\022\025\n\roperator_node\030\004 \001(\t\022\027\n\017oper" + "ator_domain\030\005 \001(\t\022\032\n\022operator_full_name\030" + - "\006 \001(\t\"7\n\030QueryActiveAlarmsCommand\022\033\n\023ala" + - "rm_filter_prefix\030\001 \001(\t\"\322\001\n\035AcknowledgeAl" + - "armByNameCommand\022\022\n\nalarm_name\030\001 \001(\t\022\025\n\r" + - "provider_name\030\002 \001(\t\022\022\n\ngroup_name\030\003 \001(\t\022" + - "\017\n\007comment\030\004 \001(\t\022\025\n\roperator_user\030\005 \001(\t\022" + - "\025\n\roperator_node\030\006 \001(\t\022\027\n\017operator_domai" + - "n\030\007 \001(\t\022\032\n\022operator_full_name\030\010 \001(\t\"E\n\026U" + - "nsubscribeBulkCommand\022\025\n\rserver_handle\030\001" + - " \001(\005\022\024\n\014item_handles\030\002 \003(\005\"_\n\020WriteBulkC" + - "ommand\022\025\n\rserver_handle\030\001 \001(\005\0224\n\007entries" + - "\030\002 \003(\0132#.mxaccess_gateway.v1.WriteBulkEn" + - "try\"c\n\016WriteBulkEntry\022\023\n\013item_handle\030\001 \001" + - "(\005\022+\n\005value\030\002 \001(\0132\034.mxaccess_gateway.v1." + - "MxValue\022\017\n\007user_id\030\003 \001(\005\"a\n\021Write2BulkCo" + - "mmand\022\025\n\rserver_handle\030\001 \001(\005\0225\n\007entries\030" + - "\002 \003(\0132$.mxaccess_gateway.v1.Write2BulkEn" + - "try\"\233\001\n\017Write2BulkEntry\022\023\n\013item_handle\030\001" + - " \001(\005\022+\n\005value\030\002 \001(\0132\034.mxaccess_gateway.v" + - "1.MxValue\0225\n\017timestamp_value\030\003 \001(\0132\034.mxa" + - "ccess_gateway.v1.MxValue\022\017\n\007user_id\030\004 \001(" + - "\005\"m\n\027WriteSecuredBulkCommand\022\025\n\rserver_h" + - "andle\030\001 \001(\005\022;\n\007entries\030\002 \003(\0132*.mxaccess_" + - "gateway.v1.WriteSecuredBulkEntry\"\214\001\n\025Wri" + - "teSecuredBulkEntry\022\023\n\013item_handle\030\001 \001(\005\022" + - "\027\n\017current_user_id\030\002 \001(\005\022\030\n\020verifier_use" + - "r_id\030\003 \001(\005\022+\n\005value\030\004 \001(\0132\034.mxaccess_gat" + - "eway.v1.MxValue\"o\n\030WriteSecured2BulkComm" + - "and\022\025\n\rserver_handle\030\001 \001(\005\022<\n\007entries\030\002 " + - "\003(\0132+.mxaccess_gateway.v1.WriteSecured2B" + - "ulkEntry\"\304\001\n\026WriteSecured2BulkEntry\022\023\n\013i" + - "tem_handle\030\001 \001(\005\022\027\n\017current_user_id\030\002 \001(" + - "\005\022\030\n\020verifier_user_id\030\003 \001(\005\022+\n\005value\030\004 \001" + - "(\0132\034.mxaccess_gateway.v1.MxValue\0225\n\017time" + - "stamp_value\030\005 \001(\0132\034.mxaccess_gateway.v1." + - "MxValue\"S\n\017ReadBulkCommand\022\025\n\rserver_han" + - "dle\030\001 \001(\005\022\025\n\rtag_addresses\030\002 \003(\t\022\022\n\ntime" + - "out_ms\030\003 \001(\r\"\036\n\013PingCommand\022\017\n\007message\030\001" + - " \001(\t\"\030\n\026GetSessionStateCommand\"\026\n\024GetWor" + - "kerInfoCommand\"(\n\022DrainEventsCommand\022\022\n\n" + - "max_events\030\001 \001(\r\"H\n\025ShutdownWorkerComman" + - "d\022/\n\014grace_period\030\001 \001(\0132\031.google.protobu" + - "f.Duration\"\206\017\n\016MxCommandReply\022\022\n\nsession" + - "_id\030\001 \001(\t\022\026\n\016correlation_id\030\002 \001(\t\0220\n\004kin" + - "d\030\003 \001(\0162\".mxaccess_gateway.v1.MxCommandK" + - "ind\022<\n\017protocol_status\030\004 \001(\0132#.mxaccess_" + - "gateway.v1.ProtocolStatus\022\024\n\007hresult\030\005 \001" + - "(\005H\001\210\001\001\0222\n\014return_value\030\006 \001(\0132\034.mxaccess" + - "_gateway.v1.MxValue\0224\n\010statuses\030\007 \003(\0132\"." + - "mxaccess_gateway.v1.MxStatusProxy\022\032\n\022dia" + - "gnostic_message\030\010 \001(\t\0226\n\010register\030\024 \001(\0132" + - "\".mxaccess_gateway.v1.RegisterReplyH\000\0225\n" + - "\010add_item\030\025 \001(\0132!.mxaccess_gateway.v1.Ad" + - "dItemReplyH\000\0227\n\tadd_item2\030\026 \001(\0132\".mxacce" + - "ss_gateway.v1.AddItem2ReplyH\000\022F\n\021add_buf" + - "fered_item\030\027 \001(\0132).mxaccess_gateway.v1.A" + - "ddBufferedItemReplyH\000\0224\n\007suspend\030\030 \001(\0132!" + - ".mxaccess_gateway.v1.SuspendReplyH\000\0226\n\010a" + - "ctivate\030\031 \001(\0132\".mxaccess_gateway.v1.Acti" + - "vateReplyH\000\022G\n\021authenticate_user\030\032 \001(\0132*" + - ".mxaccess_gateway.v1.AuthenticateUserRep" + - "lyH\000\022K\n\024archestra_user_to_id\030\033 \001(\0132+.mxa" + - "ccess_gateway.v1.ArchestrAUserToIdReplyH" + - "\000\022@\n\radd_item_bulk\030\034 \001(\0132\'.mxaccess_gate" + - "way.v1.BulkSubscribeReplyH\000\022C\n\020advise_it" + - "em_bulk\030\035 \001(\0132\'.mxaccess_gateway.v1.Bulk" + - "SubscribeReplyH\000\022C\n\020remove_item_bulk\030\036 \001" + - "(\0132\'.mxaccess_gateway.v1.BulkSubscribeRe" + - "plyH\000\022F\n\023un_advise_item_bulk\030\037 \001(\0132\'.mxa" + - "ccess_gateway.v1.BulkSubscribeReplyH\000\022A\n" + - "\016subscribe_bulk\030 \001(\0132\'.mxaccess_gateway" + - ".v1.BulkSubscribeReplyH\000\022C\n\020unsubscribe_" + - "bulk\030! \001(\0132\'.mxaccess_gateway.v1.BulkSub" + - "scribeReplyH\000\022N\n\021acknowledge_alarm\030\" \001(\013" + - "21.mxaccess_gateway.v1.AcknowledgeAlarmR" + - "eplyPayloadH\000\022Q\n\023query_active_alarms\030# \001" + - "(\01322.mxaccess_gateway.v1.QueryActiveAlar" + - "msReplyPayloadH\000\0229\n\nwrite_bulk\030$ \001(\0132#.m" + - "xaccess_gateway.v1.BulkWriteReplyH\000\022:\n\013w" + - "rite2_bulk\030% \001(\0132#.mxaccess_gateway.v1.B" + - "ulkWriteReplyH\000\022A\n\022write_secured_bulk\030& " + - "\001(\0132#.mxaccess_gateway.v1.BulkWriteReply" + - "H\000\022B\n\023write_secured2_bulk\030\' \001(\0132#.mxacce" + - "ss_gateway.v1.BulkWriteReplyH\000\0227\n\tread_b" + - "ulk\030( \001(\0132\".mxaccess_gateway.v1.BulkRead" + - "ReplyH\000\022?\n\rsession_state\030d \001(\0132&.mxacces" + - "s_gateway.v1.SessionStateReplyH\000\022;\n\013work" + - "er_info\030e \001(\0132$.mxaccess_gateway.v1.Work" + - "erInfoReplyH\000\022=\n\014drain_events\030f \001(\0132%.mx" + - "access_gateway.v1.DrainEventsReplyH\000B\t\n\007" + - "payloadB\n\n\010_hresult\"&\n\rRegisterReply\022\025\n\r" + - "server_handle\030\001 \001(\005\"#\n\014AddItemReply\022\023\n\013i" + - "tem_handle\030\001 \001(\005\"$\n\rAddItem2Reply\022\023\n\013ite" + - "m_handle\030\001 \001(\005\"+\n\024AddBufferedItemReply\022\023" + - "\n\013item_handle\030\001 \001(\005\"B\n\014SuspendReply\0222\n\006s" + - "tatus\030\001 \001(\0132\".mxaccess_gateway.v1.MxStat" + - "usProxy\"C\n\rActivateReply\0222\n\006status\030\001 \001(\013" + - "2\".mxaccess_gateway.v1.MxStatusProxy\"(\n\025" + - "AuthenticateUserReply\022\017\n\007user_id\030\001 \001(\005\")" + - "\n\026ArchestrAUserToIdReply\022\017\n\007user_id\030\001 \001(" + - "\005\"\201\001\n\017SubscribeResult\022\025\n\rserver_handle\030\001" + - " \001(\005\022\023\n\013tag_address\030\002 \001(\t\022\023\n\013item_handle" + - "\030\003 \001(\005\022\026\n\016was_successful\030\004 \001(\010\022\025\n\rerror_" + - "message\030\005 \001(\t\"K\n\022BulkSubscribeReply\0225\n\007r" + - "esults\030\001 \003(\0132$.mxaccess_gateway.v1.Subsc" + - "ribeResult\"\304\001\n\017BulkWriteResult\022\025\n\rserver" + - "_handle\030\001 \001(\005\022\023\n\013item_handle\030\002 \001(\005\022\026\n\016wa" + - "s_successful\030\003 \001(\010\022\024\n\007hresult\030\004 \001(\005H\000\210\001\001" + - "\0224\n\010statuses\030\005 \003(\0132\".mxaccess_gateway.v1" + - ".MxStatusProxy\022\025\n\rerror_message\030\006 \001(\tB\n\n" + - "\010_hresult\"G\n\016BulkWriteReply\0225\n\007results\030\001" + - " \003(\0132$.mxaccess_gateway.v1.BulkWriteResu" + - "lt\"\276\002\n\016BulkReadResult\022\025\n\rserver_handle\030\001" + - " \001(\005\022\023\n\013tag_address\030\002 \001(\t\022\023\n\013item_handle" + - "\030\003 \001(\005\022\026\n\016was_successful\030\004 \001(\010\022\022\n\nwas_ca" + - "ched\030\005 \001(\010\022+\n\005value\030\006 \001(\0132\034.mxaccess_gat" + - "eway.v1.MxValue\022\017\n\007quality\030\007 \001(\005\0224\n\020sour" + - "ce_timestamp\030\010 \001(\0132\032.google.protobuf.Tim" + - "estamp\0224\n\010statuses\030\t \003(\0132\".mxaccess_gate" + - "way.v1.MxStatusProxy\022\025\n\rerror_message\030\n " + - "\001(\t\"E\n\rBulkReadReply\0224\n\007results\030\001 \003(\0132#." + - "mxaccess_gateway.v1.BulkReadResult\"E\n\021Se" + - "ssionStateReply\0220\n\005state\030\001 \001(\0162!.mxacces" + - "s_gateway.v1.SessionState\"u\n\017WorkerInfoR" + - "eply\022\031\n\021worker_process_id\030\001 \001(\005\022\026\n\016worke" + - "r_version\030\002 \001(\t\022\027\n\017mxaccess_progid\030\003 \001(\t" + - "\022\026\n\016mxaccess_clsid\030\004 \001(\t\"@\n\020DrainEventsR" + - "eply\022,\n\006events\030\001 \003(\0132\034.mxaccess_gateway." + - "v1.MxEvent\"5\n\034AcknowledgeAlarmReplyPaylo" + - "ad\022\025\n\rnative_status\030\001 \001(\005\"x\n\035QueryActive" + - "AlarmsReplyPayload\022;\n\tsnapshots\030\001 \003(\0132(." + - "mxaccess_gateway.v1.ActiveAlarmSnapshot\022" + - "\032\n\022snapshot_truncated\030\002 \001(\010\"\217\010\n\007MxEvent\022" + - "2\n\006family\030\001 \001(\0162\".mxaccess_gateway.v1.Mx" + - "EventFamily\022\022\n\nsession_id\030\002 \001(\t\022\025\n\rserve" + - "r_handle\030\003 \001(\005\022\023\n\013item_handle\030\004 \001(\005\022+\n\005v" + - "alue\030\005 \001(\0132\034.mxaccess_gateway.v1.MxValue" + - "\022\017\n\007quality\030\006 \001(\005\0224\n\020source_timestamp\030\007 " + - "\001(\0132\032.google.protobuf.Timestamp\0224\n\010statu" + - "ses\030\010 \003(\0132\".mxaccess_gateway.v1.MxStatus" + - "Proxy\022\027\n\017worker_sequence\030\t \001(\004\0224\n\020worker" + - "_timestamp\030\n \001(\0132\032.google.protobuf.Times" + - "tamp\022=\n\031gateway_receive_timestamp\030\013 \001(\0132" + - "\032.google.protobuf.Timestamp\022\024\n\007hresult\030\014" + - " \001(\005H\001\210\001\001\022\022\n\nraw_status\030\r \001(\t\0227\n\nreplay_" + - "gap\030\016 \001(\0132\036.mxaccess_gateway.v1.ReplayGa" + - "pH\002\210\001\001\022@\n\016on_data_change\030\024 \001(\0132&.mxacces" + - "s_gateway.v1.OnDataChangeEventH\000\022F\n\021on_w" + - "rite_complete\030\025 \001(\0132).mxaccess_gateway.v" + - "1.OnWriteCompleteEventH\000\022I\n\022operation_co" + - "mplete\030\026 \001(\0132+.mxaccess_gateway.v1.Opera" + - "tionCompleteEventH\000\022Q\n\027on_buffered_data_" + - "change\030\027 \001(\0132..mxaccess_gateway.v1.OnBuf" + - "feredDataChangeEventH\000\022J\n\023on_alarm_trans" + - "ition\030\030 \001(\0132+.mxaccess_gateway.v1.OnAlar" + - "mTransitionEventH\000\022^\n\036on_alarm_provider_" + - "mode_changed\030\031 \001(\01324.mxaccess_gateway.v1" + - ".OnAlarmProviderModeChangedEventH\000B\006\n\004bo" + - "dyB\n\n\010_hresultB\r\n\013_replay_gap\"P\n\tReplayG" + - "ap\022 \n\030requested_after_sequence\030\001 \001(\004\022!\n\031" + - "oldest_available_sequence\030\002 \001(\004\"\023\n\021OnDat" + - "aChangeEvent\"\026\n\024OnWriteCompleteEvent\"\030\n\026" + - "OperationCompleteEvent\"\324\001\n\031OnBufferedDat" + - "aChangeEvent\0222\n\tdata_type\030\001 \001(\0162\037.mxacce" + - "ss_gateway.v1.MxDataType\0224\n\016quality_valu" + - "es\030\002 \001(\0132\034.mxaccess_gateway.v1.MxArray\0226" + - "\n\020timestamp_values\030\003 \001(\0132\034.mxaccess_gate" + - "way.v1.MxArray\022\025\n\rraw_data_type\030\004 \001(\005\"\320\004" + - "\n\026OnAlarmTransitionEvent\022\034\n\024alarm_full_r" + - "eference\030\001 \001(\t\022\037\n\027source_object_referenc" + - "e\030\002 \001(\t\022\027\n\017alarm_type_name\030\003 \001(\t\022A\n\017tran" + - "sition_kind\030\004 \001(\0162(.mxaccess_gateway.v1." + - "AlarmTransitionKind\022\020\n\010severity\030\005 \001(\005\022<\n" + - "\030original_raise_timestamp\030\006 \001(\0132\032.google" + - ".protobuf.Timestamp\0228\n\024transition_timest" + - "amp\030\007 \001(\0132\032.google.protobuf.Timestamp\022\025\n" + - "\roperator_user\030\010 \001(\t\022\030\n\020operator_comment" + - "\030\t \001(\t\022\020\n\010category\030\n \001(\t\022\023\n\013description\030" + - "\013 \001(\t\0223\n\rcurrent_value\030\014 \001(\0132\034.mxaccess_" + - "gateway.v1.MxValue\0221\n\013limit_value\030\r \001(\0132" + - "\034.mxaccess_gateway.v1.MxValue\022\020\n\010degrade" + - "d\030\016 \001(\010\022?\n\017source_provider\030\017 \001(\0162&.mxacc" + - "ess_gateway.v1.AlarmProviderMode\"\240\001\n\037OnA" + - "larmProviderModeChangedEvent\0224\n\004mode\030\001 \001" + - "(\0162&.mxaccess_gateway.v1.AlarmProviderMo" + - "de\022\016\n\006reason\030\002 \001(\t\022\017\n\007hresult\030\003 \001(\005\022&\n\002a" + - "t\030\004 \001(\0132\032.google.protobuf.Timestamp\"\361\004\n\023" + - "ActiveAlarmSnapshot\022\034\n\024alarm_full_refere" + - "nce\030\001 \001(\t\022\037\n\027source_object_reference\030\002 \001" + - "(\t\022\027\n\017alarm_type_name\030\003 \001(\t\022\020\n\010severity\030" + - "\004 \001(\005\022<\n\030original_raise_timestamp\030\005 \001(\0132" + - "\032.google.protobuf.Timestamp\022?\n\rcurrent_s" + - "tate\030\006 \001(\0162(.mxaccess_gateway.v1.AlarmCo" + - "nditionState\022\020\n\010category\030\007 \001(\t\022\023\n\013descri" + - "ption\030\010 \001(\t\022=\n\031last_transition_timestamp" + - "\030\t \001(\0132\032.google.protobuf.Timestamp\022\025\n\rop" + - "erator_user\030\n \001(\t\022\030\n\020operator_comment\030\013 " + - "\001(\t\0223\n\rcurrent_value\030\014 \001(\0132\034.mxaccess_ga" + - "teway.v1.MxValue\0221\n\013limit_value\030\r \001(\0132\034." + - "mxaccess_gateway.v1.MxValue\022\020\n\010degraded\030" + - "\016 \001(\010\022?\n\017source_provider\030\017 \001(\0162&.mxacces" + - "s_gateway.v1.AlarmProviderMode\022\037\n\027from_t" + - "runcated_snapshot\030\020 \001(\010\"\220\001\n\027AcknowledgeA" + - "larmRequest\022\035\n\025client_correlation_id\030\002 \001" + - "(\t\022\034\n\024alarm_full_reference\030\003 \001(\t\022\017\n\007comm" + - "ent\030\004 \001(\t\022\025\n\roperator_user\030\005 \001(\tJ\004\010\001\020\002R\n" + - "session_id\"\361\001\n\025AcknowledgeAlarmReply\022\026\n\016" + - "correlation_id\030\002 \001(\t\022<\n\017protocol_status\030" + - "\003 \001(\0132#.mxaccess_gateway.v1.ProtocolStat" + - "us\022\024\n\007hresult\030\004 \001(\005H\000\210\001\001\0222\n\006status\030\005 \001(\013" + - "2\".mxaccess_gateway.v1.MxStatusProxy\022\032\n\022" + - "diagnostic_message\030\006 \001(\tB\n\n\010_hresultJ\004\010\001" + - "\020\002R\nsession_id\"Q\n\023StreamAlarmsRequest\022\035\n" + - "\025client_correlation_id\030\001 \001(\t\022\033\n\023alarm_fi" + - "lter_prefix\030\002 \001(\t\"\311\002\n\020AlarmFeedMessage\022@" + - "\n\014active_alarm\030\001 \001(\0132(.mxaccess_gateway." + - "v1.ActiveAlarmSnapshotH\000\022\033\n\021snapshot_com" + - "plete\030\002 \001(\010H\000\022A\n\ntransition\030\003 \001(\0132+.mxac" + - "cess_gateway.v1.OnAlarmTransitionEventH\000" + - "\022C\n\017provider_status\030\004 \001(\0132(.mxaccess_gat" + - "eway.v1.AlarmProviderStatusH\000\022C\n\017snapsho" + - "t_status\030\005 \001(\0132(.mxaccess_gateway.v1.Ala" + - "rmSnapshotStatusH\000B\t\n\007payload\"\230\001\n\023AlarmP" + - "roviderStatus\0224\n\004mode\030\001 \001(\0162&.mxaccess_g" + - "ateway.v1.AlarmProviderMode\022\020\n\010degraded\030" + - "\002 \001(\010\022\016\n\006reason\030\003 \001(\t\022)\n\005since\030\004 \001(\0132\032.g" + - "oogle.protobuf.Timestamp\"(\n\023AlarmSnapsho" + - "tStatus\022\021\n\ttruncated\030\001 \001(\010\"\353\001\n\rMxStatusP" + - "roxy\022\017\n\007success\030\001 \001(\005\0227\n\010category\030\002 \001(\0162" + - "%.mxaccess_gateway.v1.MxStatusCategory\0228", - "\n\013detected_by\030\003 \001(\0162#.mxaccess_gateway.v" + - "1.MxStatusSource\022\016\n\006detail\030\004 \001(\005\022\024\n\014raw_" + - "category\030\005 \001(\005\022\027\n\017raw_detected_by\030\006 \001(\005\022" + - "\027\n\017diagnostic_text\030\007 \001(\t\"\351\003\n\007MxValue\0222\n\t" + - "data_type\030\001 \001(\0162\037.mxaccess_gateway.v1.Mx" + - "DataType\022\024\n\014variant_type\030\002 \001(\t\022\017\n\007is_nul" + - "l\030\003 \001(\010\022\026\n\016raw_diagnostic\030\004 \001(\t\022\025\n\rraw_d" + - "ata_type\030\005 \001(\005\022\024\n\nbool_value\030\n \001(\010H\000\022\025\n\013" + - "int32_value\030\013 \001(\005H\000\022\025\n\013int64_value\030\014 \001(\003" + - "H\000\022\025\n\013float_value\030\r \001(\002H\000\022\026\n\014double_valu" + - "e\030\016 \001(\001H\000\022\026\n\014string_value\030\017 \001(\tH\000\0225\n\017tim" + - "estamp_value\030\020 \001(\0132\032.google.protobuf.Tim" + - "estampH\000\0223\n\013array_value\030\021 \001(\0132\034.mxaccess" + - "_gateway.v1.MxArrayH\000\022\023\n\traw_value\030\022 \001(\014" + - "H\000\022@\n\022sparse_array_value\030\023 \001(\0132\".mxacces" + - "s_gateway.v1.MxSparseArrayH\000B\006\n\004kind\"\376\004\n" + - "\007MxArray\022:\n\021element_data_type\030\001 \001(\0162\037.mx" + - "access_gateway.v1.MxDataType\022\024\n\014variant_" + - "type\030\002 \001(\t\022\022\n\ndimensions\030\003 \003(\r\022\026\n\016raw_di" + - "agnostic\030\004 \001(\t\022\035\n\025raw_element_data_type\030" + - "\005 \001(\005\0225\n\013bool_values\030\n \001(\0132\036.mxaccess_ga" + - "teway.v1.BoolArrayH\000\0227\n\014int32_values\030\013 \001" + - "(\0132\037.mxaccess_gateway.v1.Int32ArrayH\000\0227\n" + - "\014int64_values\030\014 \001(\0132\037.mxaccess_gateway.v" + - "1.Int64ArrayH\000\0227\n\014float_values\030\r \001(\0132\037.m" + - "xaccess_gateway.v1.FloatArrayH\000\0229\n\rdoubl" + - "e_values\030\016 \001(\0132 .mxaccess_gateway.v1.Dou" + - "bleArrayH\000\0229\n\rstring_values\030\017 \001(\0132 .mxac" + - "cess_gateway.v1.StringArrayH\000\022?\n\020timesta" + - "mp_values\030\020 \001(\0132#.mxaccess_gateway.v1.Ti" + - "mestampArrayH\000\0223\n\nraw_values\030\021 \001(\0132\035.mxa" + - "ccess_gateway.v1.RawArrayH\000B\010\n\006values\"\231\001" + - "\n\rMxSparseArray\022:\n\021element_data_type\030\001 \001" + - "(\0162\037.mxaccess_gateway.v1.MxDataType\022\024\n\014t" + - "otal_length\030\002 \001(\r\0226\n\010elements\030\003 \003(\0132$.mx" + - "access_gateway.v1.MxSparseElement\"M\n\017MxS" + - "parseElement\022\r\n\005index\030\001 \001(\r\022+\n\005value\030\002 \001" + - "(\0132\034.mxaccess_gateway.v1.MxValue\"\033\n\tBool" + - "Array\022\016\n\006values\030\001 \003(\010\"\034\n\nInt32Array\022\016\n\006v" + - "alues\030\001 \003(\005\"\034\n\nInt64Array\022\016\n\006values\030\001 \003(" + - "\003\"\034\n\nFloatArray\022\016\n\006values\030\001 \003(\002\"\035\n\013Doubl" + - "eArray\022\016\n\006values\030\001 \003(\001\"\035\n\013StringArray\022\016\n" + - "\006values\030\001 \003(\t\"<\n\016TimestampArray\022*\n\006value" + - "s\030\001 \003(\0132\032.google.protobuf.Timestamp\"\032\n\010R" + - "awArray\022\016\n\006values\030\001 \003(\014\"X\n\016ProtocolStatu" + - "s\0225\n\004code\030\001 \001(\0162\'.mxaccess_gateway.v1.Pr" + - "otocolStatusCode\022\017\n\007message\030\002 \001(\t*\314\013\n\rMx" + - "CommandKind\022\037\n\033MX_COMMAND_KIND_UNSPECIFI" + - "ED\020\000\022\034\n\030MX_COMMAND_KIND_REGISTER\020\001\022\036\n\032MX" + - "_COMMAND_KIND_UNREGISTER\020\002\022\034\n\030MX_COMMAND" + - "_KIND_ADD_ITEM\020\003\022\035\n\031MX_COMMAND_KIND_ADD_" + - "ITEM2\020\004\022\037\n\033MX_COMMAND_KIND_REMOVE_ITEM\020\005" + - "\022\032\n\026MX_COMMAND_KIND_ADVISE\020\006\022\035\n\031MX_COMMA" + - "ND_KIND_UN_ADVISE\020\007\022&\n\"MX_COMMAND_KIND_A" + - "DVISE_SUPERVISORY\020\010\022%\n!MX_COMMAND_KIND_A" + - "DD_BUFFERED_ITEM\020\t\0220\n,MX_COMMAND_KIND_SE" + - "T_BUFFERED_UPDATE_INTERVAL\020\n\022\033\n\027MX_COMMA" + - "ND_KIND_SUSPEND\020\013\022\034\n\030MX_COMMAND_KIND_ACT" + - "IVATE\020\014\022\031\n\025MX_COMMAND_KIND_WRITE\020\r\022\032\n\026MX" + - "_COMMAND_KIND_WRITE2\020\016\022!\n\035MX_COMMAND_KIN" + - "D_WRITE_SECURED\020\017\022\"\n\036MX_COMMAND_KIND_WRI" + - "TE_SECURED2\020\020\022%\n!MX_COMMAND_KIND_AUTHENT" + - "ICATE_USER\020\021\022(\n$MX_COMMAND_KIND_ARCHESTR" + - "A_USER_TO_ID\020\022\022!\n\035MX_COMMAND_KIND_ADD_IT" + - "EM_BULK\020\023\022$\n MX_COMMAND_KIND_ADVISE_ITEM" + - "_BULK\020\024\022$\n MX_COMMAND_KIND_REMOVE_ITEM_B" + - "ULK\020\025\022\'\n#MX_COMMAND_KIND_UN_ADVISE_ITEM_" + - "BULK\020\026\022\"\n\036MX_COMMAND_KIND_SUBSCRIBE_BULK" + - "\020\027\022$\n MX_COMMAND_KIND_UNSUBSCRIBE_BULK\020\030" + - "\022$\n MX_COMMAND_KIND_SUBSCRIBE_ALARMS\020\031\022&" + - "\n\"MX_COMMAND_KIND_UNSUBSCRIBE_ALARMS\020\032\022%" + - "\n!MX_COMMAND_KIND_ACKNOWLEDGE_ALARM\020\033\022\'\n" + - "#MX_COMMAND_KIND_QUERY_ACTIVE_ALARMS\020\034\022-" + - "\n)MX_COMMAND_KIND_ACKNOWLEDGE_ALARM_BY_N" + - "AME\020\035\022\036\n\032MX_COMMAND_KIND_WRITE_BULK\020\036\022\037\n" + - "\033MX_COMMAND_KIND_WRITE2_BULK\020\037\022&\n\"MX_COM" + - "MAND_KIND_WRITE_SECURED_BULK\020 \022\'\n#MX_COM" + - "MAND_KIND_WRITE_SECURED2_BULK\020!\022\035\n\031MX_CO" + - "MMAND_KIND_READ_BULK\020\"\022+\n\'MX_COMMAND_KIN" + - "D_UPDATE_ALARM_WATCH_LIST\020#\022\030\n\024MX_COMMAN" + - "D_KIND_PING\020d\022%\n!MX_COMMAND_KIND_GET_SES" + - "SION_STATE\020e\022#\n\037MX_COMMAND_KIND_GET_WORK" + - "ER_INFO\020f\022 \n\034MX_COMMAND_KIND_DRAIN_EVENT" + - "S\020g\022#\n\037MX_COMMAND_KIND_SHUTDOWN_WORKER\020h" + - "*z\n\021AlarmProviderMode\022#\n\037ALARM_PROVIDER_" + - "MODE_UNSPECIFIED\020\000\022 \n\034ALARM_PROVIDER_MOD" + - "E_ALARMMGR\020\001\022\036\n\032ALARM_PROVIDER_MODE_SUBT" + - "AG\020\002*\255\002\n\rMxEventFamily\022\037\n\033MX_EVENT_FAMIL" + - "Y_UNSPECIFIED\020\000\022\"\n\036MX_EVENT_FAMILY_ON_DA" + - "TA_CHANGE\020\001\022%\n!MX_EVENT_FAMILY_ON_WRITE_" + - "COMPLETE\020\002\022&\n\"MX_EVENT_FAMILY_OPERATION_" + - "COMPLETE\020\003\022+\n\'MX_EVENT_FAMILY_ON_BUFFERE" + - "D_DATA_CHANGE\020\004\022\'\n#MX_EVENT_FAMILY_ON_AL" + - "ARM_TRANSITION\020\005\0222\n.MX_EVENT_FAMILY_ON_A" + - "LARM_PROVIDER_MODE_CHANGED\020\006*\312\001\n\023AlarmTr" + - "ansitionKind\022%\n!ALARM_TRANSITION_KIND_UN" + - "SPECIFIED\020\000\022\037\n\033ALARM_TRANSITION_KIND_RAI" + - "SE\020\001\022%\n!ALARM_TRANSITION_KIND_ACKNOWLEDG" + - "E\020\002\022\037\n\033ALARM_TRANSITION_KIND_CLEAR\020\003\022#\n\037" + - "ALARM_TRANSITION_KIND_RETRIGGER\020\004*\252\001\n\023Al" + - "armConditionState\022%\n!ALARM_CONDITION_STA" + - "TE_UNSPECIFIED\020\000\022 \n\034ALARM_CONDITION_STAT" + - "E_ACTIVE\020\001\022&\n\"ALARM_CONDITION_STATE_ACTI" + - "VE_ACKED\020\002\022\"\n\036ALARM_CONDITION_STATE_INAC" + - "TIVE\020\003*\245\003\n\020MxStatusCategory\022\"\n\036MX_STATUS" + - "_CATEGORY_UNSPECIFIED\020\000\022\036\n\032MX_STATUS_CAT" + - "EGORY_UNKNOWN\020\001\022\031\n\025MX_STATUS_CATEGORY_OK" + - "\020\002\022\036\n\032MX_STATUS_CATEGORY_PENDING\020\003\022\036\n\032MX" + - "_STATUS_CATEGORY_WARNING\020\004\022*\n&MX_STATUS_" + - "CATEGORY_COMMUNICATION_ERROR\020\005\022*\n&MX_STA" + - "TUS_CATEGORY_CONFIGURATION_ERROR\020\006\022(\n$MX" + - "_STATUS_CATEGORY_OPERATIONAL_ERROR\020\007\022%\n!" + - "MX_STATUS_CATEGORY_SECURITY_ERROR\020\010\022%\n!M" + - "X_STATUS_CATEGORY_SOFTWARE_ERROR\020\t\022\"\n\036MX" + - "_STATUS_CATEGORY_OTHER_ERROR\020\n*\312\002\n\016MxSta" + - "tusSource\022 \n\034MX_STATUS_SOURCE_UNSPECIFIE" + - "D\020\000\022\034\n\030MX_STATUS_SOURCE_UNKNOWN\020\001\022#\n\037MX_" + - "STATUS_SOURCE_REQUESTING_LMX\020\002\022#\n\037MX_STA" + - "TUS_SOURCE_RESPONDING_LMX\020\003\022#\n\037MX_STATUS" + - "_SOURCE_REQUESTING_NMX\020\004\022#\n\037MX_STATUS_SO" + - "URCE_RESPONDING_NMX\020\005\0221\n-MX_STATUS_SOURC" + - "E_REQUESTING_AUTOMATION_OBJECT\020\006\0221\n-MX_S" + - "TATUS_SOURCE_RESPONDING_AUTOMATION_OBJEC" + - "T\020\007*\335\004\n\nMxDataType\022\034\n\030MX_DATA_TYPE_UNSPE" + - "CIFIED\020\000\022\030\n\024MX_DATA_TYPE_UNKNOWN\020\001\022\030\n\024MX" + - "_DATA_TYPE_NO_DATA\020\002\022\030\n\024MX_DATA_TYPE_BOO" + - "LEAN\020\003\022\030\n\024MX_DATA_TYPE_INTEGER\020\004\022\026\n\022MX_D" + - "ATA_TYPE_FLOAT\020\005\022\027\n\023MX_DATA_TYPE_DOUBLE\020" + - "\006\022\027\n\023MX_DATA_TYPE_STRING\020\007\022\025\n\021MX_DATA_TY" + - "PE_TIME\020\010\022\035\n\031MX_DATA_TYPE_ELAPSED_TIME\020\t" + - "\022\037\n\033MX_DATA_TYPE_REFERENCE_TYPE\020\n\022\034\n\030MX_" + - "DATA_TYPE_STATUS_TYPE\020\013\022\025\n\021MX_DATA_TYPE_" + - "ENUM\020\014\022-\n)MX_DATA_TYPE_SECURITY_CLASSIFI" + - "CATION_ENUM\020\r\022\"\n\036MX_DATA_TYPE_DATA_QUALI" + - "TY_TYPE\020\016\022\037\n\033MX_DATA_TYPE_QUALIFIED_ENUM" + - "\020\017\022!\n\035MX_DATA_TYPE_QUALIFIED_STRUCT\020\020\022)\n" + - "%MX_DATA_TYPE_INTERNATIONALIZED_STRING\020\021" + - "\022\033\n\027MX_DATA_TYPE_BIG_STRING\020\022\022\024\n\020MX_DATA" + - "_TYPE_END\020\023*\243\003\n\022ProtocolStatusCode\022$\n PR" + - "OTOCOL_STATUS_CODE_UNSPECIFIED\020\000\022\033\n\027PROT" + - "OCOL_STATUS_CODE_OK\020\001\022(\n$PROTOCOL_STATUS" + - "_CODE_INVALID_REQUEST\020\002\022*\n&PROTOCOL_STAT" + - "US_CODE_SESSION_NOT_FOUND\020\003\022*\n&PROTOCOL_" + - "STATUS_CODE_SESSION_NOT_READY\020\004\022+\n\'PROTO" + - "COL_STATUS_CODE_WORKER_UNAVAILABLE\020\005\022 \n\034" + - "PROTOCOL_STATUS_CODE_TIMEOUT\020\006\022!\n\035PROTOC" + - "OL_STATUS_CODE_CANCELED\020\007\022+\n\'PROTOCOL_ST" + - "ATUS_CODE_PROTOCOL_VIOLATION\020\010\022)\n%PROTOC" + - "OL_STATUS_CODE_MXACCESS_FAILURE\020\t*\277\002\n\014Se" + - "ssionState\022\035\n\031SESSION_STATE_UNSPECIFIED\020" + - "\000\022\032\n\026SESSION_STATE_CREATING\020\001\022!\n\035SESSION" + - "_STATE_STARTING_WORKER\020\002\022\"\n\036SESSION_STAT" + - "E_WAITING_FOR_PIPE\020\003\022\035\n\031SESSION_STATE_HA" + - "NDSHAKING\020\004\022%\n!SESSION_STATE_INITIALIZIN" + - "G_WORKER\020\005\022\027\n\023SESSION_STATE_READY\020\006\022\031\n\025S" + - "ESSION_STATE_CLOSING\020\007\022\030\n\024SESSION_STATE_" + - "CLOSED\020\010\022\031\n\025SESSION_STATE_FAULTED\020\t2\303\005\n\017" + - "MxAccessGateway\022]\n\013OpenSession\022\'.mxacces" + - "s_gateway.v1.OpenSessionRequest\032%.mxacce" + - "ss_gateway.v1.OpenSessionReply\022`\n\014CloseS" + - "ession\022(.mxaccess_gateway.v1.CloseSessio" + - "nRequest\032&.mxaccess_gateway.v1.CloseSess" + - "ionReply\022T\n\006Invoke\022%.mxaccess_gateway.v1" + - ".MxCommandRequest\032#.mxaccess_gateway.v1." + - "MxCommandReply\022X\n\014StreamEvents\022(.mxacces" + - "s_gateway.v1.StreamEventsRequest\032\034.mxacc" + - "ess_gateway.v1.MxEvent0\001\022l\n\020AcknowledgeA" + - "larm\022,.mxaccess_gateway.v1.AcknowledgeAl" + - "armRequest\032*.mxaccess_gateway.v1.Acknowl" + - "edgeAlarmReply\022a\n\014StreamAlarms\022(.mxacces" + - "s_gateway.v1.StreamAlarmsRequest\032%.mxacc" + - "ess_gateway.v1.AlarmFeedMessage0\001\022n\n\021Que" + - "ryActiveAlarms\022-.mxaccess_gateway.v1.Que" + - "ryActiveAlarmsRequest\032(.mxaccess_gateway" + - ".v1.ActiveAlarmSnapshot0\001B&\252\002#ZB.MOM.WW." + - "MxGateway.Contracts.Protob\006proto3" + "\006 \001(\t\022\031\n\021operator_password\030\007 \001(\t\"7\n\030Quer" + + "yActiveAlarmsCommand\022\033\n\023alarm_filter_pre" + + "fix\030\001 \001(\t\"\355\001\n\035AcknowledgeAlarmByNameComm" + + "and\022\022\n\nalarm_name\030\001 \001(\t\022\025\n\rprovider_name" + + "\030\002 \001(\t\022\022\n\ngroup_name\030\003 \001(\t\022\017\n\007comment\030\004 " + + "\001(\t\022\025\n\roperator_user\030\005 \001(\t\022\025\n\roperator_n" + + "ode\030\006 \001(\t\022\027\n\017operator_domain\030\007 \001(\t\022\032\n\022op" + + "erator_full_name\030\010 \001(\t\022\031\n\021operator_passw" + + "ord\030\t \001(\t\"E\n\026UnsubscribeBulkCommand\022\025\n\rs" + + "erver_handle\030\001 \001(\005\022\024\n\014item_handles\030\002 \003(\005" + + "\"_\n\020WriteBulkCommand\022\025\n\rserver_handle\030\001 " + + "\001(\005\0224\n\007entries\030\002 \003(\0132#.mxaccess_gateway." + + "v1.WriteBulkEntry\"c\n\016WriteBulkEntry\022\023\n\013i" + + "tem_handle\030\001 \001(\005\022+\n\005value\030\002 \001(\0132\034.mxacce" + + "ss_gateway.v1.MxValue\022\017\n\007user_id\030\003 \001(\005\"a" + + "\n\021Write2BulkCommand\022\025\n\rserver_handle\030\001 \001" + + "(\005\0225\n\007entries\030\002 \003(\0132$.mxaccess_gateway.v" + + "1.Write2BulkEntry\"\233\001\n\017Write2BulkEntry\022\023\n" + + "\013item_handle\030\001 \001(\005\022+\n\005value\030\002 \001(\0132\034.mxac" + + "cess_gateway.v1.MxValue\0225\n\017timestamp_val" + + "ue\030\003 \001(\0132\034.mxaccess_gateway.v1.MxValue\022\017" + + "\n\007user_id\030\004 \001(\005\"m\n\027WriteSecuredBulkComma" + + "nd\022\025\n\rserver_handle\030\001 \001(\005\022;\n\007entries\030\002 \003" + + "(\0132*.mxaccess_gateway.v1.WriteSecuredBul" + + "kEntry\"\214\001\n\025WriteSecuredBulkEntry\022\023\n\013item" + + "_handle\030\001 \001(\005\022\027\n\017current_user_id\030\002 \001(\005\022\030" + + "\n\020verifier_user_id\030\003 \001(\005\022+\n\005value\030\004 \001(\0132" + + "\034.mxaccess_gateway.v1.MxValue\"o\n\030WriteSe" + + "cured2BulkCommand\022\025\n\rserver_handle\030\001 \001(\005" + + "\022<\n\007entries\030\002 \003(\0132+.mxaccess_gateway.v1." + + "WriteSecured2BulkEntry\"\304\001\n\026WriteSecured2" + + "BulkEntry\022\023\n\013item_handle\030\001 \001(\005\022\027\n\017curren" + + "t_user_id\030\002 \001(\005\022\030\n\020verifier_user_id\030\003 \001(" + + "\005\022+\n\005value\030\004 \001(\0132\034.mxaccess_gateway.v1.M" + + "xValue\0225\n\017timestamp_value\030\005 \001(\0132\034.mxacce" + + "ss_gateway.v1.MxValue\"S\n\017ReadBulkCommand" + + "\022\025\n\rserver_handle\030\001 \001(\005\022\025\n\rtag_addresses" + + "\030\002 \003(\t\022\022\n\ntimeout_ms\030\003 \001(\r\"\036\n\013PingComman" + + "d\022\017\n\007message\030\001 \001(\t\"\030\n\026GetSessionStateCom" + + "mand\"\026\n\024GetWorkerInfoCommand\"(\n\022DrainEve" + + "ntsCommand\022\022\n\nmax_events\030\001 \001(\r\"H\n\025Shutdo" + + "wnWorkerCommand\022/\n\014grace_period\030\001 \001(\0132\031." + + "google.protobuf.Duration\"\206\017\n\016MxCommandRe" + + "ply\022\022\n\nsession_id\030\001 \001(\t\022\026\n\016correlation_i" + + "d\030\002 \001(\t\0220\n\004kind\030\003 \001(\0162\".mxaccess_gateway" + + ".v1.MxCommandKind\022<\n\017protocol_status\030\004 \001" + + "(\0132#.mxaccess_gateway.v1.ProtocolStatus\022" + + "\024\n\007hresult\030\005 \001(\005H\001\210\001\001\0222\n\014return_value\030\006 " + + "\001(\0132\034.mxaccess_gateway.v1.MxValue\0224\n\010sta" + + "tuses\030\007 \003(\0132\".mxaccess_gateway.v1.MxStat" + + "usProxy\022\032\n\022diagnostic_message\030\010 \001(\t\0226\n\010r" + + "egister\030\024 \001(\0132\".mxaccess_gateway.v1.Regi" + + "sterReplyH\000\0225\n\010add_item\030\025 \001(\0132!.mxaccess" + + "_gateway.v1.AddItemReplyH\000\0227\n\tadd_item2\030" + + "\026 \001(\0132\".mxaccess_gateway.v1.AddItem2Repl" + + "yH\000\022F\n\021add_buffered_item\030\027 \001(\0132).mxacces" + + "s_gateway.v1.AddBufferedItemReplyH\000\0224\n\007s" + + "uspend\030\030 \001(\0132!.mxaccess_gateway.v1.Suspe" + + "ndReplyH\000\0226\n\010activate\030\031 \001(\0132\".mxaccess_g" + + "ateway.v1.ActivateReplyH\000\022G\n\021authenticat" + + "e_user\030\032 \001(\0132*.mxaccess_gateway.v1.Authe" + + "nticateUserReplyH\000\022K\n\024archestra_user_to_" + + "id\030\033 \001(\0132+.mxaccess_gateway.v1.ArchestrA" + + "UserToIdReplyH\000\022@\n\radd_item_bulk\030\034 \001(\0132\'" + + ".mxaccess_gateway.v1.BulkSubscribeReplyH" + + "\000\022C\n\020advise_item_bulk\030\035 \001(\0132\'.mxaccess_g" + + "ateway.v1.BulkSubscribeReplyH\000\022C\n\020remove" + + "_item_bulk\030\036 \001(\0132\'.mxaccess_gateway.v1.B" + + "ulkSubscribeReplyH\000\022F\n\023un_advise_item_bu" + + "lk\030\037 \001(\0132\'.mxaccess_gateway.v1.BulkSubsc" + + "ribeReplyH\000\022A\n\016subscribe_bulk\030 \001(\0132\'.mx" + + "access_gateway.v1.BulkSubscribeReplyH\000\022C" + + "\n\020unsubscribe_bulk\030! \001(\0132\'.mxaccess_gate" + + "way.v1.BulkSubscribeReplyH\000\022N\n\021acknowled" + + "ge_alarm\030\" \001(\01321.mxaccess_gateway.v1.Ack" + + "nowledgeAlarmReplyPayloadH\000\022Q\n\023query_act" + + "ive_alarms\030# \001(\01322.mxaccess_gateway.v1.Q" + + "ueryActiveAlarmsReplyPayloadH\000\0229\n\nwrite_" + + "bulk\030$ \001(\0132#.mxaccess_gateway.v1.BulkWri" + + "teReplyH\000\022:\n\013write2_bulk\030% \001(\0132#.mxacces" + + "s_gateway.v1.BulkWriteReplyH\000\022A\n\022write_s" + + "ecured_bulk\030& \001(\0132#.mxaccess_gateway.v1." + + "BulkWriteReplyH\000\022B\n\023write_secured2_bulk\030" + + "\' \001(\0132#.mxaccess_gateway.v1.BulkWriteRep" + + "lyH\000\0227\n\tread_bulk\030( \001(\0132\".mxaccess_gatew" + + "ay.v1.BulkReadReplyH\000\022?\n\rsession_state\030d" + + " \001(\0132&.mxaccess_gateway.v1.SessionStateR" + + "eplyH\000\022;\n\013worker_info\030e \001(\0132$.mxaccess_g" + + "ateway.v1.WorkerInfoReplyH\000\022=\n\014drain_eve" + + "nts\030f \001(\0132%.mxaccess_gateway.v1.DrainEve" + + "ntsReplyH\000B\t\n\007payloadB\n\n\010_hresult\"&\n\rReg" + + "isterReply\022\025\n\rserver_handle\030\001 \001(\005\"#\n\014Add" + + "ItemReply\022\023\n\013item_handle\030\001 \001(\005\"$\n\rAddIte" + + "m2Reply\022\023\n\013item_handle\030\001 \001(\005\"+\n\024AddBuffe" + + "redItemReply\022\023\n\013item_handle\030\001 \001(\005\"B\n\014Sus" + + "pendReply\0222\n\006status\030\001 \001(\0132\".mxaccess_gat" + + "eway.v1.MxStatusProxy\"C\n\rActivateReply\0222" + + "\n\006status\030\001 \001(\0132\".mxaccess_gateway.v1.MxS" + + "tatusProxy\"(\n\025AuthenticateUserReply\022\017\n\007u" + + "ser_id\030\001 \001(\005\")\n\026ArchestrAUserToIdReply\022\017" + + "\n\007user_id\030\001 \001(\005\"\201\001\n\017SubscribeResult\022\025\n\rs" + + "erver_handle\030\001 \001(\005\022\023\n\013tag_address\030\002 \001(\t\022" + + "\023\n\013item_handle\030\003 \001(\005\022\026\n\016was_successful\030\004" + + " \001(\010\022\025\n\rerror_message\030\005 \001(\t\"K\n\022BulkSubsc" + + "ribeReply\0225\n\007results\030\001 \003(\0132$.mxaccess_ga" + + "teway.v1.SubscribeResult\"\304\001\n\017BulkWriteRe" + + "sult\022\025\n\rserver_handle\030\001 \001(\005\022\023\n\013item_hand" + + "le\030\002 \001(\005\022\026\n\016was_successful\030\003 \001(\010\022\024\n\007hres" + + "ult\030\004 \001(\005H\000\210\001\001\0224\n\010statuses\030\005 \003(\0132\".mxacc" + + "ess_gateway.v1.MxStatusProxy\022\025\n\rerror_me" + + "ssage\030\006 \001(\tB\n\n\010_hresult\"G\n\016BulkWriteRepl" + + "y\0225\n\007results\030\001 \003(\0132$.mxaccess_gateway.v1" + + ".BulkWriteResult\"\276\002\n\016BulkReadResult\022\025\n\rs" + + "erver_handle\030\001 \001(\005\022\023\n\013tag_address\030\002 \001(\t\022" + + "\023\n\013item_handle\030\003 \001(\005\022\026\n\016was_successful\030\004" + + " \001(\010\022\022\n\nwas_cached\030\005 \001(\010\022+\n\005value\030\006 \001(\0132" + + "\034.mxaccess_gateway.v1.MxValue\022\017\n\007quality" + + "\030\007 \001(\005\0224\n\020source_timestamp\030\010 \001(\0132\032.googl" + + "e.protobuf.Timestamp\0224\n\010statuses\030\t \003(\0132\"" + + ".mxaccess_gateway.v1.MxStatusProxy\022\025\n\rer" + + "ror_message\030\n \001(\t\"E\n\rBulkReadReply\0224\n\007re" + + "sults\030\001 \003(\0132#.mxaccess_gateway.v1.BulkRe" + + "adResult\"E\n\021SessionStateReply\0220\n\005state\030\001" + + " \001(\0162!.mxaccess_gateway.v1.SessionState\"" + + "u\n\017WorkerInfoReply\022\031\n\021worker_process_id\030" + + "\001 \001(\005\022\026\n\016worker_version\030\002 \001(\t\022\027\n\017mxacces" + + "s_progid\030\003 \001(\t\022\026\n\016mxaccess_clsid\030\004 \001(\t\"@" + + "\n\020DrainEventsReply\022,\n\006events\030\001 \003(\0132\034.mxa" + + "ccess_gateway.v1.MxEvent\"5\n\034AcknowledgeA" + + "larmReplyPayload\022\025\n\rnative_status\030\001 \001(\005\"" + + "x\n\035QueryActiveAlarmsReplyPayload\022;\n\tsnap" + + "shots\030\001 \003(\0132(.mxaccess_gateway.v1.Active" + + "AlarmSnapshot\022\032\n\022snapshot_truncated\030\002 \001(" + + "\010\"\217\010\n\007MxEvent\0222\n\006family\030\001 \001(\0162\".mxaccess" + + "_gateway.v1.MxEventFamily\022\022\n\nsession_id\030" + + "\002 \001(\t\022\025\n\rserver_handle\030\003 \001(\005\022\023\n\013item_han" + + "dle\030\004 \001(\005\022+\n\005value\030\005 \001(\0132\034.mxaccess_gate" + + "way.v1.MxValue\022\017\n\007quality\030\006 \001(\005\0224\n\020sourc" + + "e_timestamp\030\007 \001(\0132\032.google.protobuf.Time" + + "stamp\0224\n\010statuses\030\010 \003(\0132\".mxaccess_gatew" + + "ay.v1.MxStatusProxy\022\027\n\017worker_sequence\030\t" + + " \001(\004\0224\n\020worker_timestamp\030\n \001(\0132\032.google." + + "protobuf.Timestamp\022=\n\031gateway_receive_ti" + + "mestamp\030\013 \001(\0132\032.google.protobuf.Timestam" + + "p\022\024\n\007hresult\030\014 \001(\005H\001\210\001\001\022\022\n\nraw_status\030\r " + + "\001(\t\0227\n\nreplay_gap\030\016 \001(\0132\036.mxaccess_gatew" + + "ay.v1.ReplayGapH\002\210\001\001\022@\n\016on_data_change\030\024" + + " \001(\0132&.mxaccess_gateway.v1.OnDataChangeE" + + "ventH\000\022F\n\021on_write_complete\030\025 \001(\0132).mxac" + + "cess_gateway.v1.OnWriteCompleteEventH\000\022I" + + "\n\022operation_complete\030\026 \001(\0132+.mxaccess_ga" + + "teway.v1.OperationCompleteEventH\000\022Q\n\027on_" + + "buffered_data_change\030\027 \001(\0132..mxaccess_ga" + + "teway.v1.OnBufferedDataChangeEventH\000\022J\n\023" + + "on_alarm_transition\030\030 \001(\0132+.mxaccess_gat" + + "eway.v1.OnAlarmTransitionEventH\000\022^\n\036on_a" + + "larm_provider_mode_changed\030\031 \001(\01324.mxacc" + + "ess_gateway.v1.OnAlarmProviderModeChange" + + "dEventH\000B\006\n\004bodyB\n\n\010_hresultB\r\n\013_replay_" + + "gap\"P\n\tReplayGap\022 \n\030requested_after_sequ" + + "ence\030\001 \001(\004\022!\n\031oldest_available_sequence\030" + + "\002 \001(\004\"\023\n\021OnDataChangeEvent\"\026\n\024OnWriteCom" + + "pleteEvent\"\030\n\026OperationCompleteEvent\"\324\001\n" + + "\031OnBufferedDataChangeEvent\0222\n\tdata_type\030" + + "\001 \001(\0162\037.mxaccess_gateway.v1.MxDataType\0224" + + "\n\016quality_values\030\002 \001(\0132\034.mxaccess_gatewa" + + "y.v1.MxArray\0226\n\020timestamp_values\030\003 \001(\0132\034" + + ".mxaccess_gateway.v1.MxArray\022\025\n\rraw_data" + + "_type\030\004 \001(\005\"\320\004\n\026OnAlarmTransitionEvent\022\034" + + "\n\024alarm_full_reference\030\001 \001(\t\022\037\n\027source_o" + + "bject_reference\030\002 \001(\t\022\027\n\017alarm_type_name" + + "\030\003 \001(\t\022A\n\017transition_kind\030\004 \001(\0162(.mxacce" + + "ss_gateway.v1.AlarmTransitionKind\022\020\n\010sev" + + "erity\030\005 \001(\005\022<\n\030original_raise_timestamp\030" + + "\006 \001(\0132\032.google.protobuf.Timestamp\0228\n\024tra" + + "nsition_timestamp\030\007 \001(\0132\032.google.protobu" + + "f.Timestamp\022\025\n\roperator_user\030\010 \001(\t\022\030\n\020op" + + "erator_comment\030\t \001(\t\022\020\n\010category\030\n \001(\t\022\023" + + "\n\013description\030\013 \001(\t\0223\n\rcurrent_value\030\014 \001" + + "(\0132\034.mxaccess_gateway.v1.MxValue\0221\n\013limi" + + "t_value\030\r \001(\0132\034.mxaccess_gateway.v1.MxVa" + + "lue\022\020\n\010degraded\030\016 \001(\010\022?\n\017source_provider" + + "\030\017 \001(\0162&.mxaccess_gateway.v1.AlarmProvid" + + "erMode\"\240\001\n\037OnAlarmProviderModeChangedEve" + + "nt\0224\n\004mode\030\001 \001(\0162&.mxaccess_gateway.v1.A" + + "larmProviderMode\022\016\n\006reason\030\002 \001(\t\022\017\n\007hres" + + "ult\030\003 \001(\005\022&\n\002at\030\004 \001(\0132\032.google.protobuf." + + "Timestamp\"\361\004\n\023ActiveAlarmSnapshot\022\034\n\024ala" + + "rm_full_reference\030\001 \001(\t\022\037\n\027source_object" + + "_reference\030\002 \001(\t\022\027\n\017alarm_type_name\030\003 \001(" + + "\t\022\020\n\010severity\030\004 \001(\005\022<\n\030original_raise_ti" + + "mestamp\030\005 \001(\0132\032.google.protobuf.Timestam" + + "p\022?\n\rcurrent_state\030\006 \001(\0162(.mxaccess_gate" + + "way.v1.AlarmConditionState\022\020\n\010category\030\007" + + " \001(\t\022\023\n\013description\030\010 \001(\t\022=\n\031last_transi" + + "tion_timestamp\030\t \001(\0132\032.google.protobuf.T" + + "imestamp\022\025\n\roperator_user\030\n \001(\t\022\030\n\020opera" + + "tor_comment\030\013 \001(\t\0223\n\rcurrent_value\030\014 \001(\013" + + "2\034.mxaccess_gateway.v1.MxValue\0221\n\013limit_" + + "value\030\r \001(\0132\034.mxaccess_gateway.v1.MxValu" + + "e\022\020\n\010degraded\030\016 \001(\010\022?\n\017source_provider\030\017" + + " \001(\0162&.mxaccess_gateway.v1.AlarmProvider" + + "Mode\022\037\n\027from_truncated_snapshot\030\020 \001(\010\"\304\001" + + "\n\027AcknowledgeAlarmRequest\022\035\n\025client_corr" + + "elation_id\030\002 \001(\t\022\034\n\024alarm_full_reference" + + "\030\003 \001(\t\022\017\n\007comment\030\004 \001(\t\022\025\n\roperator_user" + + "\030\005 \001(\t\022\031\n\021operator_password\030\006 \001(\t\022\027\n\017ope" + + "rator_domain\030\007 \001(\tJ\004\010\001\020\002R\nsession_id\"\361\001\n" + + "\025AcknowledgeAlarmReply\022\026\n\016correlation_id" + + "\030\002 \001(\t\022<\n\017protocol_status\030\003 \001(\0132#.mxacce" + + "ss_gateway.v1.ProtocolStatus\022\024\n\007hresult\030" + + "\004 \001(\005H\000\210\001\001\0222\n\006status\030\005 \001(\0132\".mxaccess_ga" + + "teway.v1.MxStatusProxy\022\032\n\022diagnostic_mes" + + "sage\030\006 \001(\tB\n\n\010_hresultJ\004\010\001\020\002R\nsession_id" + + "\"Q\n\023StreamAlarmsRequest\022\035\n\025client_correl" + + "ation_id\030\001 \001(\t\022\033\n\023alarm_filter_prefix\030\002 " + + "\001(\t\"\311\002\n\020AlarmFeedMessage\022@\n\014active_alarm" + + "\030\001 \001(\0132(.mxaccess_gateway.v1.ActiveAlarm" + + "SnapshotH\000\022\033\n\021snapshot_complete\030\002 \001(\010H\000\022" + + "A\n\ntransition\030\003 \001(\0132+.mxaccess_gateway.v" + + "1.OnAlarmTransitionEventH\000\022C\n\017provider_s" + + "tatus\030\004 \001(\0132(.mxaccess_gateway.v1.AlarmP" + + "roviderStatusH\000\022C\n\017snapshot_status\030\005 \001(\013" + + "2(.mxaccess_gateway.v1.AlarmSnapshotStat" + + "usH\000B\t\n\007payload\"\230\001\n\023AlarmProviderStatus\022" + + "4\n\004mode\030\001 \001(\0162&.mxaccess_gateway.v1.Alar" + + "mProviderMode\022\020\n\010degraded\030\002 \001(\010\022\016\n\006reaso" + + "n\030\003 \001(\t\022)\n\005since\030\004 \001(\0132\032.google.protobuf" + + ".Timestamp\"(\n\023AlarmSnapshotStatus\022\021\n\ttru", + "ncated\030\001 \001(\010\"\353\001\n\rMxStatusProxy\022\017\n\007succes" + + "s\030\001 \001(\005\0227\n\010category\030\002 \001(\0162%.mxaccess_gat" + + "eway.v1.MxStatusCategory\0228\n\013detected_by\030" + + "\003 \001(\0162#.mxaccess_gateway.v1.MxStatusSour" + + "ce\022\016\n\006detail\030\004 \001(\005\022\024\n\014raw_category\030\005 \001(\005" + + "\022\027\n\017raw_detected_by\030\006 \001(\005\022\027\n\017diagnostic_" + + "text\030\007 \001(\t\"\351\003\n\007MxValue\0222\n\tdata_type\030\001 \001(" + + "\0162\037.mxaccess_gateway.v1.MxDataType\022\024\n\014va" + + "riant_type\030\002 \001(\t\022\017\n\007is_null\030\003 \001(\010\022\026\n\016raw" + + "_diagnostic\030\004 \001(\t\022\025\n\rraw_data_type\030\005 \001(\005" + + "\022\024\n\nbool_value\030\n \001(\010H\000\022\025\n\013int32_value\030\013 " + + "\001(\005H\000\022\025\n\013int64_value\030\014 \001(\003H\000\022\025\n\013float_va" + + "lue\030\r \001(\002H\000\022\026\n\014double_value\030\016 \001(\001H\000\022\026\n\014s" + + "tring_value\030\017 \001(\tH\000\0225\n\017timestamp_value\030\020" + + " \001(\0132\032.google.protobuf.TimestampH\000\0223\n\013ar" + + "ray_value\030\021 \001(\0132\034.mxaccess_gateway.v1.Mx" + + "ArrayH\000\022\023\n\traw_value\030\022 \001(\014H\000\022@\n\022sparse_a" + + "rray_value\030\023 \001(\0132\".mxaccess_gateway.v1.M" + + "xSparseArrayH\000B\006\n\004kind\"\376\004\n\007MxArray\022:\n\021el" + + "ement_data_type\030\001 \001(\0162\037.mxaccess_gateway" + + ".v1.MxDataType\022\024\n\014variant_type\030\002 \001(\t\022\022\n\n" + + "dimensions\030\003 \003(\r\022\026\n\016raw_diagnostic\030\004 \001(\t" + + "\022\035\n\025raw_element_data_type\030\005 \001(\005\0225\n\013bool_" + + "values\030\n \001(\0132\036.mxaccess_gateway.v1.BoolA" + + "rrayH\000\0227\n\014int32_values\030\013 \001(\0132\037.mxaccess_" + + "gateway.v1.Int32ArrayH\000\0227\n\014int64_values\030" + + "\014 \001(\0132\037.mxaccess_gateway.v1.Int64ArrayH\000" + + "\0227\n\014float_values\030\r \001(\0132\037.mxaccess_gatewa" + + "y.v1.FloatArrayH\000\0229\n\rdouble_values\030\016 \001(\013" + + "2 .mxaccess_gateway.v1.DoubleArrayH\000\0229\n\r" + + "string_values\030\017 \001(\0132 .mxaccess_gateway.v" + + "1.StringArrayH\000\022?\n\020timestamp_values\030\020 \001(" + + "\0132#.mxaccess_gateway.v1.TimestampArrayH\000" + + "\0223\n\nraw_values\030\021 \001(\0132\035.mxaccess_gateway." + + "v1.RawArrayH\000B\010\n\006values\"\231\001\n\rMxSparseArra" + + "y\022:\n\021element_data_type\030\001 \001(\0162\037.mxaccess_" + + "gateway.v1.MxDataType\022\024\n\014total_length\030\002 " + + "\001(\r\0226\n\010elements\030\003 \003(\0132$.mxaccess_gateway" + + ".v1.MxSparseElement\"M\n\017MxSparseElement\022\r" + + "\n\005index\030\001 \001(\r\022+\n\005value\030\002 \001(\0132\034.mxaccess_" + + "gateway.v1.MxValue\"\033\n\tBoolArray\022\016\n\006value" + + "s\030\001 \003(\010\"\034\n\nInt32Array\022\016\n\006values\030\001 \003(\005\"\034\n" + + "\nInt64Array\022\016\n\006values\030\001 \003(\003\"\034\n\nFloatArra" + + "y\022\016\n\006values\030\001 \003(\002\"\035\n\013DoubleArray\022\016\n\006valu" + + "es\030\001 \003(\001\"\035\n\013StringArray\022\016\n\006values\030\001 \003(\t\"" + + "<\n\016TimestampArray\022*\n\006values\030\001 \003(\0132\032.goog" + + "le.protobuf.Timestamp\"\032\n\010RawArray\022\016\n\006val" + + "ues\030\001 \003(\014\"X\n\016ProtocolStatus\0225\n\004code\030\001 \001(" + + "\0162\'.mxaccess_gateway.v1.ProtocolStatusCo" + + "de\022\017\n\007message\030\002 \001(\t*\314\013\n\rMxCommandKind\022\037\n" + + "\033MX_COMMAND_KIND_UNSPECIFIED\020\000\022\034\n\030MX_COM" + + "MAND_KIND_REGISTER\020\001\022\036\n\032MX_COMMAND_KIND_" + + "UNREGISTER\020\002\022\034\n\030MX_COMMAND_KIND_ADD_ITEM" + + "\020\003\022\035\n\031MX_COMMAND_KIND_ADD_ITEM2\020\004\022\037\n\033MX_" + + "COMMAND_KIND_REMOVE_ITEM\020\005\022\032\n\026MX_COMMAND" + + "_KIND_ADVISE\020\006\022\035\n\031MX_COMMAND_KIND_UN_ADV" + + "ISE\020\007\022&\n\"MX_COMMAND_KIND_ADVISE_SUPERVIS" + + "ORY\020\010\022%\n!MX_COMMAND_KIND_ADD_BUFFERED_IT" + + "EM\020\t\0220\n,MX_COMMAND_KIND_SET_BUFFERED_UPD" + + "ATE_INTERVAL\020\n\022\033\n\027MX_COMMAND_KIND_SUSPEN" + + "D\020\013\022\034\n\030MX_COMMAND_KIND_ACTIVATE\020\014\022\031\n\025MX_" + + "COMMAND_KIND_WRITE\020\r\022\032\n\026MX_COMMAND_KIND_" + + "WRITE2\020\016\022!\n\035MX_COMMAND_KIND_WRITE_SECURE" + + "D\020\017\022\"\n\036MX_COMMAND_KIND_WRITE_SECURED2\020\020\022" + + "%\n!MX_COMMAND_KIND_AUTHENTICATE_USER\020\021\022(" + + "\n$MX_COMMAND_KIND_ARCHESTRA_USER_TO_ID\020\022" + + "\022!\n\035MX_COMMAND_KIND_ADD_ITEM_BULK\020\023\022$\n M" + + "X_COMMAND_KIND_ADVISE_ITEM_BULK\020\024\022$\n MX_" + + "COMMAND_KIND_REMOVE_ITEM_BULK\020\025\022\'\n#MX_CO" + + "MMAND_KIND_UN_ADVISE_ITEM_BULK\020\026\022\"\n\036MX_C" + + "OMMAND_KIND_SUBSCRIBE_BULK\020\027\022$\n MX_COMMA" + + "ND_KIND_UNSUBSCRIBE_BULK\020\030\022$\n MX_COMMAND" + + "_KIND_SUBSCRIBE_ALARMS\020\031\022&\n\"MX_COMMAND_K" + + "IND_UNSUBSCRIBE_ALARMS\020\032\022%\n!MX_COMMAND_K" + + "IND_ACKNOWLEDGE_ALARM\020\033\022\'\n#MX_COMMAND_KI" + + "ND_QUERY_ACTIVE_ALARMS\020\034\022-\n)MX_COMMAND_K" + + "IND_ACKNOWLEDGE_ALARM_BY_NAME\020\035\022\036\n\032MX_CO" + + "MMAND_KIND_WRITE_BULK\020\036\022\037\n\033MX_COMMAND_KI" + + "ND_WRITE2_BULK\020\037\022&\n\"MX_COMMAND_KIND_WRIT" + + "E_SECURED_BULK\020 \022\'\n#MX_COMMAND_KIND_WRIT" + + "E_SECURED2_BULK\020!\022\035\n\031MX_COMMAND_KIND_REA" + + "D_BULK\020\"\022+\n\'MX_COMMAND_KIND_UPDATE_ALARM" + + "_WATCH_LIST\020#\022\030\n\024MX_COMMAND_KIND_PING\020d\022" + + "%\n!MX_COMMAND_KIND_GET_SESSION_STATE\020e\022#" + + "\n\037MX_COMMAND_KIND_GET_WORKER_INFO\020f\022 \n\034M" + + "X_COMMAND_KIND_DRAIN_EVENTS\020g\022#\n\037MX_COMM" + + "AND_KIND_SHUTDOWN_WORKER\020h*z\n\021AlarmProvi" + + "derMode\022#\n\037ALARM_PROVIDER_MODE_UNSPECIFI" + + "ED\020\000\022 \n\034ALARM_PROVIDER_MODE_ALARMMGR\020\001\022\036" + + "\n\032ALARM_PROVIDER_MODE_SUBTAG\020\002*\255\002\n\rMxEve" + + "ntFamily\022\037\n\033MX_EVENT_FAMILY_UNSPECIFIED\020" + + "\000\022\"\n\036MX_EVENT_FAMILY_ON_DATA_CHANGE\020\001\022%\n" + + "!MX_EVENT_FAMILY_ON_WRITE_COMPLETE\020\002\022&\n\"" + + "MX_EVENT_FAMILY_OPERATION_COMPLETE\020\003\022+\n\'" + + "MX_EVENT_FAMILY_ON_BUFFERED_DATA_CHANGE\020" + + "\004\022\'\n#MX_EVENT_FAMILY_ON_ALARM_TRANSITION" + + "\020\005\0222\n.MX_EVENT_FAMILY_ON_ALARM_PROVIDER_" + + "MODE_CHANGED\020\006*\312\001\n\023AlarmTransitionKind\022%" + + "\n!ALARM_TRANSITION_KIND_UNSPECIFIED\020\000\022\037\n" + + "\033ALARM_TRANSITION_KIND_RAISE\020\001\022%\n!ALARM_" + + "TRANSITION_KIND_ACKNOWLEDGE\020\002\022\037\n\033ALARM_T" + + "RANSITION_KIND_CLEAR\020\003\022#\n\037ALARM_TRANSITI" + + "ON_KIND_RETRIGGER\020\004*\252\001\n\023AlarmConditionSt" + + "ate\022%\n!ALARM_CONDITION_STATE_UNSPECIFIED" + + "\020\000\022 \n\034ALARM_CONDITION_STATE_ACTIVE\020\001\022&\n\"" + + "ALARM_CONDITION_STATE_ACTIVE_ACKED\020\002\022\"\n\036" + + "ALARM_CONDITION_STATE_INACTIVE\020\003*\245\003\n\020MxS" + + "tatusCategory\022\"\n\036MX_STATUS_CATEGORY_UNSP" + + "ECIFIED\020\000\022\036\n\032MX_STATUS_CATEGORY_UNKNOWN\020" + + "\001\022\031\n\025MX_STATUS_CATEGORY_OK\020\002\022\036\n\032MX_STATU" + + "S_CATEGORY_PENDING\020\003\022\036\n\032MX_STATUS_CATEGO" + + "RY_WARNING\020\004\022*\n&MX_STATUS_CATEGORY_COMMU" + + "NICATION_ERROR\020\005\022*\n&MX_STATUS_CATEGORY_C" + + "ONFIGURATION_ERROR\020\006\022(\n$MX_STATUS_CATEGO" + + "RY_OPERATIONAL_ERROR\020\007\022%\n!MX_STATUS_CATE" + + "GORY_SECURITY_ERROR\020\010\022%\n!MX_STATUS_CATEG" + + "ORY_SOFTWARE_ERROR\020\t\022\"\n\036MX_STATUS_CATEGO" + + "RY_OTHER_ERROR\020\n*\312\002\n\016MxStatusSource\022 \n\034M" + + "X_STATUS_SOURCE_UNSPECIFIED\020\000\022\034\n\030MX_STAT" + + "US_SOURCE_UNKNOWN\020\001\022#\n\037MX_STATUS_SOURCE_" + + "REQUESTING_LMX\020\002\022#\n\037MX_STATUS_SOURCE_RES" + + "PONDING_LMX\020\003\022#\n\037MX_STATUS_SOURCE_REQUES" + + "TING_NMX\020\004\022#\n\037MX_STATUS_SOURCE_RESPONDIN" + + "G_NMX\020\005\0221\n-MX_STATUS_SOURCE_REQUESTING_A" + + "UTOMATION_OBJECT\020\006\0221\n-MX_STATUS_SOURCE_R" + + "ESPONDING_AUTOMATION_OBJECT\020\007*\335\004\n\nMxData" + + "Type\022\034\n\030MX_DATA_TYPE_UNSPECIFIED\020\000\022\030\n\024MX" + + "_DATA_TYPE_UNKNOWN\020\001\022\030\n\024MX_DATA_TYPE_NO_" + + "DATA\020\002\022\030\n\024MX_DATA_TYPE_BOOLEAN\020\003\022\030\n\024MX_D" + + "ATA_TYPE_INTEGER\020\004\022\026\n\022MX_DATA_TYPE_FLOAT" + + "\020\005\022\027\n\023MX_DATA_TYPE_DOUBLE\020\006\022\027\n\023MX_DATA_T" + + "YPE_STRING\020\007\022\025\n\021MX_DATA_TYPE_TIME\020\010\022\035\n\031M" + + "X_DATA_TYPE_ELAPSED_TIME\020\t\022\037\n\033MX_DATA_TY" + + "PE_REFERENCE_TYPE\020\n\022\034\n\030MX_DATA_TYPE_STAT" + + "US_TYPE\020\013\022\025\n\021MX_DATA_TYPE_ENUM\020\014\022-\n)MX_D" + + "ATA_TYPE_SECURITY_CLASSIFICATION_ENUM\020\r\022" + + "\"\n\036MX_DATA_TYPE_DATA_QUALITY_TYPE\020\016\022\037\n\033M" + + "X_DATA_TYPE_QUALIFIED_ENUM\020\017\022!\n\035MX_DATA_" + + "TYPE_QUALIFIED_STRUCT\020\020\022)\n%MX_DATA_TYPE_" + + "INTERNATIONALIZED_STRING\020\021\022\033\n\027MX_DATA_TY" + + "PE_BIG_STRING\020\022\022\024\n\020MX_DATA_TYPE_END\020\023*\243\003" + + "\n\022ProtocolStatusCode\022$\n PROTOCOL_STATUS_" + + "CODE_UNSPECIFIED\020\000\022\033\n\027PROTOCOL_STATUS_CO" + + "DE_OK\020\001\022(\n$PROTOCOL_STATUS_CODE_INVALID_" + + "REQUEST\020\002\022*\n&PROTOCOL_STATUS_CODE_SESSIO" + + "N_NOT_FOUND\020\003\022*\n&PROTOCOL_STATUS_CODE_SE" + + "SSION_NOT_READY\020\004\022+\n\'PROTOCOL_STATUS_COD" + + "E_WORKER_UNAVAILABLE\020\005\022 \n\034PROTOCOL_STATU" + + "S_CODE_TIMEOUT\020\006\022!\n\035PROTOCOL_STATUS_CODE" + + "_CANCELED\020\007\022+\n\'PROTOCOL_STATUS_CODE_PROT" + + "OCOL_VIOLATION\020\010\022)\n%PROTOCOL_STATUS_CODE" + + "_MXACCESS_FAILURE\020\t*\277\002\n\014SessionState\022\035\n\031" + + "SESSION_STATE_UNSPECIFIED\020\000\022\032\n\026SESSION_S" + + "TATE_CREATING\020\001\022!\n\035SESSION_STATE_STARTIN" + + "G_WORKER\020\002\022\"\n\036SESSION_STATE_WAITING_FOR_" + + "PIPE\020\003\022\035\n\031SESSION_STATE_HANDSHAKING\020\004\022%\n" + + "!SESSION_STATE_INITIALIZING_WORKER\020\005\022\027\n\023" + + "SESSION_STATE_READY\020\006\022\031\n\025SESSION_STATE_C" + + "LOSING\020\007\022\030\n\024SESSION_STATE_CLOSED\020\010\022\031\n\025SE" + + "SSION_STATE_FAULTED\020\t2\303\005\n\017MxAccessGatewa" + + "y\022]\n\013OpenSession\022\'.mxaccess_gateway.v1.O" + + "penSessionRequest\032%.mxaccess_gateway.v1." + + "OpenSessionReply\022`\n\014CloseSession\022(.mxacc" + + "ess_gateway.v1.CloseSessionRequest\032&.mxa" + + "ccess_gateway.v1.CloseSessionReply\022T\n\006In" + + "voke\022%.mxaccess_gateway.v1.MxCommandRequ" + + "est\032#.mxaccess_gateway.v1.MxCommandReply" + + "\022X\n\014StreamEvents\022(.mxaccess_gateway.v1.S" + + "treamEventsRequest\032\034.mxaccess_gateway.v1" + + ".MxEvent0\001\022l\n\020AcknowledgeAlarm\022,.mxacces" + + "s_gateway.v1.AcknowledgeAlarmRequest\032*.m" + + "xaccess_gateway.v1.AcknowledgeAlarmReply" + + "\022a\n\014StreamAlarms\022(.mxaccess_gateway.v1.S" + + "treamAlarmsRequest\032%.mxaccess_gateway.v1" + + ".AlarmFeedMessage0\001\022n\n\021QueryActiveAlarms" + + "\022-.mxaccess_gateway.v1.QueryActiveAlarms" + + "Request\032(.mxaccess_gateway.v1.ActiveAlar" + + "mSnapshot0\001B&\252\002#ZB.MOM.WW.MxGateway.Cont" + + "racts.Protob\006proto3" }; descriptor = com.google.protobuf.Descriptors.FileDescriptor .internalBuildGeneratedFileFrom(descriptorData, @@ -108064,7 +108878,7 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { internal_static_mxaccess_gateway_v1_AcknowledgeAlarmCommand_fieldAccessorTable = new com.google.protobuf.GeneratedMessage.FieldAccessorTable( internal_static_mxaccess_gateway_v1_AcknowledgeAlarmCommand_descriptor, - new java.lang.String[] { "AlarmGuid", "Comment", "OperatorUser", "OperatorNode", "OperatorDomain", "OperatorFullName", }); + new java.lang.String[] { "AlarmGuid", "Comment", "OperatorUser", "OperatorNode", "OperatorDomain", "OperatorFullName", "OperatorPassword", }); internal_static_mxaccess_gateway_v1_QueryActiveAlarmsCommand_descriptor = getDescriptor().getMessageType(37); internal_static_mxaccess_gateway_v1_QueryActiveAlarmsCommand_fieldAccessorTable = new @@ -108076,7 +108890,7 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { internal_static_mxaccess_gateway_v1_AcknowledgeAlarmByNameCommand_fieldAccessorTable = new com.google.protobuf.GeneratedMessage.FieldAccessorTable( internal_static_mxaccess_gateway_v1_AcknowledgeAlarmByNameCommand_descriptor, - new java.lang.String[] { "AlarmName", "ProviderName", "GroupName", "Comment", "OperatorUser", "OperatorNode", "OperatorDomain", "OperatorFullName", }); + new java.lang.String[] { "AlarmName", "ProviderName", "GroupName", "Comment", "OperatorUser", "OperatorNode", "OperatorDomain", "OperatorFullName", "OperatorPassword", }); internal_static_mxaccess_gateway_v1_UnsubscribeBulkCommand_descriptor = getDescriptor().getMessageType(39); internal_static_mxaccess_gateway_v1_UnsubscribeBulkCommand_fieldAccessorTable = new @@ -108346,7 +109160,7 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { internal_static_mxaccess_gateway_v1_AcknowledgeAlarmRequest_fieldAccessorTable = new com.google.protobuf.GeneratedMessage.FieldAccessorTable( internal_static_mxaccess_gateway_v1_AcknowledgeAlarmRequest_descriptor, - new java.lang.String[] { "ClientCorrelationId", "AlarmFullReference", "Comment", "OperatorUser", }); + new java.lang.String[] { "ClientCorrelationId", "AlarmFullReference", "Comment", "OperatorUser", "OperatorPassword", "OperatorDomain", }); internal_static_mxaccess_gateway_v1_AcknowledgeAlarmReply_descriptor = getDescriptor().getMessageType(84); internal_static_mxaccess_gateway_v1_AcknowledgeAlarmReply_fieldAccessorTable = new diff --git a/clients/java/zb-mom-ww-mxgateway-cli/src/main/java/com/zb/mom/ww/mxgateway/cli/MxGatewayCli.java b/clients/java/zb-mom-ww-mxgateway-cli/src/main/java/com/zb/mom/ww/mxgateway/cli/MxGatewayCli.java index b40fe5b..6965356 100644 --- a/clients/java/zb-mom-ww-mxgateway-cli/src/main/java/com/zb/mom/ww/mxgateway/cli/MxGatewayCli.java +++ b/clients/java/zb-mom-ww-mxgateway-cli/src/main/java/com/zb/mom/ww/mxgateway/cli/MxGatewayCli.java @@ -1822,6 +1822,31 @@ public final class MxGatewayCli implements Callable { @Option(names = "--operator", description = "Operator user performing the acknowledge.") String operator = ""; + @Option(names = "--operator-domain", description = "Operator Galaxy domain (optional).") + String operatorDomain = ""; + + @Option( + names = "--operator-password", + description = "Operator Galaxy password (credential; prefer --operator-password-env). Never echoed.") + String operatorPassword; + + @Option( + names = "--operator-password-env", + defaultValue = "MXGATEWAY_OPERATOR_PASSWORD", + description = "Environment variable holding the operator password when --operator-password is omitted.") + String operatorPasswordEnv; + + private String resolveOperatorPassword() { + if (operatorPassword != null && !operatorPassword.isEmpty()) { + return operatorPassword; + } + String name = operatorPasswordEnv == null || operatorPasswordEnv.isBlank() + ? "MXGATEWAY_OPERATOR_PASSWORD" + : operatorPasswordEnv; + String value = System.getenv(name); + return value == null ? "" : value; + } + AcknowledgeAlarmCommand(MxGatewayCliClientFactory clientFactory) { super(clientFactory); } @@ -1833,6 +1858,8 @@ public final class MxGatewayCli implements Callable { .setAlarmFullReference(reference) .setComment(comment) .setOperatorUser(operator) + .setOperatorDomain(operatorDomain == null ? "" : operatorDomain) + .setOperatorPassword(resolveOperatorPassword()) .build()); writeOutput( "acknowledge-alarm", diff --git a/clients/java/zb-mom-ww-mxgateway-cli/src/test/java/com/zb/mom/ww/mxgateway/cli/MxGatewayCliTests.java b/clients/java/zb-mom-ww-mxgateway-cli/src/test/java/com/zb/mom/ww/mxgateway/cli/MxGatewayCliTests.java index c8e0bb8..49ee7a8 100644 --- a/clients/java/zb-mom-ww-mxgateway-cli/src/test/java/com/zb/mom/ww/mxgateway/cli/MxGatewayCliTests.java +++ b/clients/java/zb-mom-ww-mxgateway-cli/src/test/java/com/zb/mom/ww/mxgateway/cli/MxGatewayCliTests.java @@ -714,9 +714,16 @@ final class MxGatewayCliTests { "checked", "--operator", "operator1", + "--operator-domain", + "CORP", + "--operator-password", + "pw-secret", "--json"); assertEquals(0, run.exitCode()); + assertEquals("CORP", factory.client.lastAcknowledgeAlarmRequest.getOperatorDomain()); + assertEquals("pw-secret", factory.client.lastAcknowledgeAlarmRequest.getOperatorPassword()); + assertFalse(run.output().contains("pw-secret"), run.output()); assertEquals("Tank01.Level.HiHi", factory.client.lastAcknowledgeAlarmRequest.getAlarmFullReference()); assertEquals("checked", factory.client.lastAcknowledgeAlarmRequest.getComment()); assertEquals("operator1", factory.client.lastAcknowledgeAlarmRequest.getOperatorUser()); @@ -770,6 +777,10 @@ final class MxGatewayCliTests { "--plaintext", "--reference", "\\Galaxy\\Area001.Pump001.PumpFault", + "--operator", + "operator1", + "--operator-password-env", + "MXGATEWAY_OPERATOR_PASSWORD", "--json" }; assertReadmeExampleParses(args); diff --git a/clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/MxGatewayClient.java b/clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/MxGatewayClient.java index 8021011..aa23f6a 100644 --- a/clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/MxGatewayClient.java +++ b/clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/MxGatewayClient.java @@ -273,6 +273,11 @@ public final class MxGatewayClient implements AutoCloseable { * worker's MXAccess session; the resulting native MxStatus is returned * in the reply. Acks are idempotent at the MxAccess layer. * + *

{@code operator_user} is required; the gateway authenticates it with + * {@code operator_password} (and optional {@code operator_domain}) and the + * Historian records that operator. An empty password is allowed. The + * password is scrubbed from any exception this method throws. + * * @param request the {@code AcknowledgeAlarmRequest} * @return the raw acknowledge reply * @throws MxGatewayException on transport or protocol failure @@ -283,10 +288,14 @@ public final class MxGatewayClient implements AutoCloseable { MxGatewayErrors.ensureProtocolSuccess("acknowledge alarm", reply.getProtocolStatus(), null); return reply; } catch (RuntimeException error) { - if (error instanceof MxGatewayException) { - throw error; + MxGatewayException mapped = error instanceof MxGatewayException gatewayError + ? gatewayError + : (MxGatewayException) MxGatewayErrors.fromGrpc("acknowledge alarm", error); + String secret = request.getOperatorPassword(); + if (secret.isEmpty()) { + throw mapped; } - throw MxGatewayErrors.fromGrpc("acknowledge alarm", error); + throw MxGatewayErrors.redacted(mapped, secret); } } diff --git a/clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/MxGatewayErrors.java b/clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/MxGatewayErrors.java index 638ad9c..e970a15 100644 --- a/clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/MxGatewayErrors.java +++ b/clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/MxGatewayErrors.java @@ -28,6 +28,43 @@ final class MxGatewayErrors { return new MxGatewayException("gateway " + operation + " failed: " + error.getMessage(), error); } + /** + * Rebuilds a gateway exception with an exact secret scrubbed from its + * message and protocol status. The original is not chained as a cause + * because its message and cause chain may carry the secret. + */ + static MxGatewayException redacted(MxGatewayException error, String secret) { + String message = MxGatewaySecrets.redactExact(error.getMessage(), secret); + if (error instanceof MxAccessException ex) { + return new MxAccessException(message, scrub(ex.protocolStatus(), secret), ex.reply(), null); + } + if (error instanceof MxGatewayCommandException ex) { + return new MxGatewayCommandException(message, scrub(ex.protocolStatus(), secret), ex.reply(), null); + } + if (error instanceof MxGatewaySessionException ex) { + return new MxGatewaySessionException(message, scrub(ex.protocolStatus(), secret), null); + } + if (error instanceof MxGatewayWorkerException ex) { + return new MxGatewayWorkerException(message, scrub(ex.protocolStatus(), secret), null); + } + if (error instanceof MxGatewayAuthenticationException) { + return new MxGatewayAuthenticationException(message, null); + } + if (error instanceof MxGatewayAuthorizationException) { + return new MxGatewayAuthorizationException(message, null); + } + return new MxGatewayException(message); + } + + private static ProtocolStatus scrub(ProtocolStatus status, String secret) { + if (status == null) { + return null; + } + return status.toBuilder() + .setMessage(MxGatewaySecrets.redactExact(status.getMessage(), secret)) + .build(); + } + static void ensureProtocolSuccess(String operation, ProtocolStatus status, MxCommandReply reply) { if (status == null || status.getCode() == ProtocolStatusCode.PROTOCOL_STATUS_CODE_OK) { return; From 9907a97cb084a1a181b2f5bf8b0035684f07b26f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 19 Oct 2026 19:42:58 -0400 Subject: [PATCH 14/25] fix(java-client): redact operator password on async acknowledge-alarm failures Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT --- .../ww/mxgateway/client/MxGatewayClient.java | 16 ++++++- .../client/MxGatewayCredentialReplyTests.java | 46 +++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/MxGatewayClient.java b/clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/MxGatewayClient.java index aa23f6a..59798cb 100644 --- a/clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/MxGatewayClient.java +++ b/clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/MxGatewayClient.java @@ -308,10 +308,24 @@ public final class MxGatewayClient implements AutoCloseable { */ public CompletableFuture acknowledgeAlarmAsync(AcknowledgeAlarmRequest request) { CompletableFuture future = toCompletable(rawFutureStub().acknowledgeAlarm(request)); - return future.thenApply(reply -> { + String secret = request.getOperatorPassword(); + CompletableFuture checked = future.thenApply(reply -> { MxGatewayErrors.ensureProtocolSuccess("acknowledge alarm", reply.getProtocolStatus(), null); return reply; }); + if (secret.isEmpty()) { + return checked; + } + // Scrub the operator password from any failure surfaced through the future. + return checked.exceptionally(error -> { + Throwable cause = error instanceof java.util.concurrent.CompletionException && error.getCause() != null + ? error.getCause() + : error; + if (cause instanceof MxGatewayException gatewayError) { + throw new java.util.concurrent.CompletionException(MxGatewayErrors.redacted(gatewayError, secret)); + } + throw new java.util.concurrent.CompletionException(cause); + }); } /** diff --git a/clients/java/zb-mom-ww-mxgateway-client/src/test/java/com/zb/mom/ww/mxgateway/client/MxGatewayCredentialReplyTests.java b/clients/java/zb-mom-ww-mxgateway-client/src/test/java/com/zb/mom/ww/mxgateway/client/MxGatewayCredentialReplyTests.java index 1657a84..e8a99f5 100644 --- a/clients/java/zb-mom-ww-mxgateway-client/src/test/java/com/zb/mom/ww/mxgateway/client/MxGatewayCredentialReplyTests.java +++ b/clients/java/zb-mom-ww-mxgateway-client/src/test/java/com/zb/mom/ww/mxgateway/client/MxGatewayCredentialReplyTests.java @@ -74,6 +74,52 @@ final class MxGatewayCredentialReplyTests { } } + @Test + void acknowledgeAlarmAsyncRedactsOperatorPasswordFromFailure() throws Exception { + String serverName = "mxgw-java-ack-" + UUID.randomUUID(); + MxAccessGatewayGrpc.MxAccessGatewayImplBase service = new MxAccessGatewayGrpc.MxAccessGatewayImplBase() { + @Override + public void acknowledgeAlarm( + mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest request, + StreamObserver observer) { + observer.onNext(mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply.newBuilder() + .setProtocolStatus(ProtocolStatus.newBuilder() + .setCode(ProtocolStatusCode.PROTOCOL_STATUS_CODE_MXACCESS_FAILURE) + .setMessage("auth failed for operator1 password " + CREDENTIAL)) + .build()); + observer.onCompleted(); + } + }; + Server server = InProcessServerBuilder.forName(serverName) + .directExecutor().addService(service).build().start(); + ManagedChannel channel = InProcessChannelBuilder.forName(serverName).directExecutor().build(); + try (MxGatewayClient client = new MxGatewayClient( + channel, + MxGatewayClientOptions.builder() + .endpoint("in-process").apiKey("").plaintext(true) + .callTimeout(Duration.ofSeconds(5)).build())) { + var request = mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest.newBuilder() + .setAlarmFullReference("Tank01.Level.HiHi") + .setOperatorUser("operator1") + .setOperatorPassword(CREDENTIAL) + .build(); + + java.util.concurrent.ExecutionException async = assertThrows( + java.util.concurrent.ExecutionException.class, + () -> client.acknowledgeAlarmAsync(request).get()); + assertTrue(async.getCause() instanceof MxGatewayException, String.valueOf(async.getCause())); + assertFalse(async.getCause().getMessage().contains(CREDENTIAL)); + assertFalse(((MxGatewayCommandException) async.getCause()).protocolStatus().getMessage() + .contains(CREDENTIAL)); + + MxGatewayException sync = assertThrows(MxGatewayException.class, () -> client.acknowledgeAlarm(request)); + assertFalse(sync.getMessage().contains(CREDENTIAL)); + } finally { + channel.shutdownNow(); + server.shutdownNow(); + } + } + @Test void authenticateUserMissingPayloadThrowsMalformedReply() throws Exception { MxCommandReply reply = loadReply("authenticate-user.missing-payload.reply.json"); From e8ace39418cc720dc0c1b3b8ae872fffa3d34627 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 19 Oct 2026 19:42:19 -0400 Subject: [PATCH 15/25] feat(dotnet-client): operator credentials on acknowledge-alarm Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT --- clients/dotnet/README.md | 13 +++++++-- .../MxGatewayClientCli.cs | 24 +++++++++++++++- .../MxGatewayClientAlarmsTests.cs | 28 +++++++++++++++++++ .../MxGatewayClientCliTests.cs | 8 ++++++ .../MxGatewayClient.cs | 26 +++++++++++------ 5 files changed, 86 insertions(+), 13 deletions(-) diff --git a/clients/dotnet/README.md b/clients/dotnet/README.md index 3c711f5..d651ddf 100644 --- a/clients/dotnet/README.md +++ b/clients/dotnet/README.md @@ -143,8 +143,10 @@ the same `MxEventStreamItem` surface. For alarms, the client exposes `QueryActiveAlarmsAsync` (one-shot snapshot of the active alarms the gateway's central monitor currently holds), `StreamAlarmsAsync` (server-streaming feed of alarm-state-change messages -keyed by the same monitor), and `AcknowledgeAlarmAsync` (ack by alarm -reference, optional comment, ack target). All three accept a cancellation +keyed by the same monitor), and `AcknowledgeAlarmAsync` (acknowledges as +the operator: the gateway authenticates `OperatorUser`/`OperatorPassword` and +writes the comment to the alarm's `AckMsg`, so the Historian records the +operator). All three accept a cancellation token and pass through the `MxGateway:Alarms` configuration on the server — when alarms are disabled, the gateway returns an empty list / empty stream rather than failing. @@ -291,10 +293,15 @@ dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- write --se dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- write2 --session-id --server-handle 1 --item-handle 1 --type int32 --value 123 --timestamp 2026-01-01T00:00:00Z --json dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- stream-events --session-id --max-events 1 --json dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- stream-alarms --filter-prefix Area001 --max-events 4 --json -dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- acknowledge-alarm --reference "\\Galaxy\Area001.Pump001.PumpFault" --comment "ack from cli" --operator operator1 --json +dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- acknowledge-alarm --reference "\\Galaxy\Area001.Pump001.PumpFault" --comment "ack from cli" --operator operator1 --operator-password-env MXGATEWAY_OPERATOR_PASSWORD --json dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- smoke --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --item Area001.Pump001.Speed --json ``` +`acknowledge-alarm` reads the operator password from `--operator-password` or the +environment variable named by `--operator-password-env` (default +`MXGATEWAY_OPERATOR_PASSWORD`); the password is never echoed and is redacted from +any error output. Prefer the environment variable to keep it off the command line. + `smoke` opens a session, registers a client, adds one item, advises it, optionally writes a value when `--type` and `--value` are supplied, reads a bounded event stream, and closes the session in a `finally` block. CLI error diff --git a/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/MxGatewayClientCli.cs b/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/MxGatewayClientCli.cs index f84fb72..9429ef9 100644 --- a/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/MxGatewayClientCli.cs +++ b/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/MxGatewayClientCli.cs @@ -185,6 +185,7 @@ public static class MxGatewayClientCli exception.Message, apiKey, TryResolveVerifyUserPassword(arguments), + ResolveOperatorPassword(arguments), arguments.GetOptional("value")); if (forceJsonErrors || arguments.HasFlag("json")) @@ -356,6 +357,7 @@ public static class MxGatewayClientCli /// Legacy environment variable, still honoured as a deprecated fallback /// for one release so existing scripts keep working. /// + private const string OperatorPasswordEnvironmentName = "MXGATEWAY_OPERATOR_PASSWORD"; private const string LegacyVerifyPasswordEnvironmentName = "MXGATEWAY_VERIFY_USER_PASSWORD"; ///

@@ -388,6 +390,24 @@ public static class MxGatewayClientCli /// the error-redaction catch block can strip it from any surfaced error, /// mirroring . /// + private static string? ResolveOperatorPassword(CliArguments arguments) + { + string? password = arguments.GetOptional("operator-password"); + if (!string.IsNullOrEmpty(password)) + { + return password; + } + + string environmentName = arguments.GetOptional("operator-password-env") ?? string.Empty; + if (string.IsNullOrEmpty(environmentName)) + { + environmentName = OperatorPasswordEnvironmentName; + } + + password = Environment.GetEnvironmentVariable(environmentName); + return string.IsNullOrEmpty(password) ? null : password; + } + private static string? TryResolveVerifyUserPassword(CliArguments arguments) { string? password = arguments.GetOptional("password"); @@ -1535,6 +1555,8 @@ public static class MxGatewayClientCli AlarmFullReference = arguments.GetRequired("reference"), Comment = arguments.GetOptional("comment") ?? string.Empty, OperatorUser = arguments.GetOptional("operator") ?? string.Empty, + OperatorDomain = arguments.GetOptional("operator-domain") ?? string.Empty, + OperatorPassword = ResolveOperatorPassword(arguments) ?? string.Empty, }; return WriteReplyAsync( @@ -2465,7 +2487,7 @@ public static class MxGatewayClientCli writer.WriteLine("mxgw-dotnet bench-read-bulk [--duration-seconds ] [--warmup-seconds ] [--bulk-size ] [--tag-start ] [--tag-prefix ] [--tag-attribute ] [--timeout-ms ] [--client-name ]"); writer.WriteLine("mxgw-dotnet stream-events --session-id [--max-events ] [--json]"); writer.WriteLine("mxgw-dotnet stream-alarms [--filter-prefix ] [--max-events ] [--json] [--jsonl]"); - writer.WriteLine("mxgw-dotnet acknowledge-alarm --reference [--comment ] [--operator ] [--json]"); + writer.WriteLine("mxgw-dotnet acknowledge-alarm --reference --operator [--operator-domain ] [--operator-password | --operator-password-env ] [--comment ] [--json]"); writer.WriteLine("mxgw-dotnet write --session-id --server-handle --item-handle --type --value [--json]"); writer.WriteLine("mxgw-dotnet write2 --session-id --server-handle --item-handle --type --value [--timestamp ] [--json]"); writer.WriteLine("mxgw-dotnet smoke --item [--value --type ] [--json]"); diff --git a/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/MxGatewayClientAlarmsTests.cs b/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/MxGatewayClientAlarmsTests.cs index 61582b6..803dbec 100644 --- a/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/MxGatewayClientAlarmsTests.cs +++ b/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/MxGatewayClientAlarmsTests.cs @@ -36,6 +36,8 @@ public sealed class MxGatewayClientAlarmsTests AlarmFullReference = "Tank01.Level.HiHi", Comment = "investigating", OperatorUser = "alice", + OperatorPassword = "pw-secret", + OperatorDomain = "CORP", }); Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); @@ -45,9 +47,35 @@ public sealed class MxGatewayClientAlarmsTests Assert.Equal("Tank01.Level.HiHi", call.Request.AlarmFullReference); Assert.Equal("investigating", call.Request.Comment); Assert.Equal("alice", call.Request.OperatorUser); + Assert.Equal("pw-secret", call.Request.OperatorPassword); + Assert.Equal("CORP", call.Request.OperatorDomain); Assert.Equal("Bearer test-api-key", call.CallOptions.Headers?.GetValue("authorization")); } + /// AcknowledgeAlarmAsync redacts the operator password from a failing call's message. + /// A task that represents the asynchronous operation. + [Fact] + public async Task AcknowledgeAlarmAsync_FailureMessage_DoesNotContainOperatorPassword() + { + FakeGatewayTransport transport = CreateTransport(); + + // The real transport maps RpcException to MxGatewayException using the status + // detail; the fake passes exceptions verbatim, so enqueue the mapped shape. + transport.AcknowledgeAlarmExceptions.Enqueue(new MxGatewayException("boom pw-secret")); + await using MxGatewayClient client = CreateClient(transport); + + MxGatewayException ex = await Assert.ThrowsAsync( + () => client.AcknowledgeAlarmAsync(new AcknowledgeAlarmRequest + { + AlarmFullReference = "Tank01.Level.HiHi", + OperatorUser = "alice", + OperatorPassword = "pw-secret", + })); + + Assert.DoesNotContain("pw-secret", ex.Message); + Assert.Contains("boom", ex.Message); + } + /// AcknowledgeAlarmAsync honors cancellation. /// A task that represents the asynchronous operation. [Fact] diff --git a/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/MxGatewayClientCliTests.cs b/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/MxGatewayClientCliTests.cs index 43da1f1..cdae036 100644 --- a/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/MxGatewayClientCliTests.cs +++ b/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/MxGatewayClientCliTests.cs @@ -763,6 +763,7 @@ public sealed class MxGatewayClientCliTests [Fact] public async Task RunAsync_AcknowledgeAlarm_BuildsRequestAndPrintsJsonReply() { + Environment.SetEnvironmentVariable("MXGW_TEST_OPPW", "op-pw-secret"); using var output = new StringWriter(); using var error = new StringWriter(); FakeCliClient fakeClient = new(); @@ -786,6 +787,10 @@ public sealed class MxGatewayClientCliTests "ack from cli", "--operator", "operator1", + "--operator-domain", + "CORP", + "--operator-password-env", + "MXGW_TEST_OPPW", "--json", ], output, @@ -797,6 +802,9 @@ public sealed class MxGatewayClientCliTests Assert.Equal("Tank01.Level.HiHi", request.AlarmFullReference); Assert.Equal("ack from cli", request.Comment); Assert.Equal("operator1", request.OperatorUser); + Assert.Equal("CORP", request.OperatorDomain); + Assert.Equal("op-pw-secret", request.OperatorPassword); + Assert.DoesNotContain("op-pw-secret", output.ToString()); Assert.Contains("ack-fixture", output.ToString()); Assert.Equal(string.Empty, error.ToString()); } diff --git a/clients/dotnet/ZB.MOM.WW.MxGateway.Client/MxGatewayClient.cs b/clients/dotnet/ZB.MOM.WW.MxGateway.Client/MxGatewayClient.cs index ed321b3..9ed1dcd 100644 --- a/clients/dotnet/ZB.MOM.WW.MxGateway.Client/MxGatewayClient.cs +++ b/clients/dotnet/ZB.MOM.WW.MxGateway.Client/MxGatewayClient.cs @@ -183,24 +183,32 @@ public sealed class MxGatewayClient : IAsyncDisposable } /// - /// Acknowledges an active MXAccess alarm condition through the gateway. The - /// gateway authenticates the request against the API key's invoke:alarm-ack - /// scope and forwards the acknowledge to the worker's MXAccess session; - /// the resulting is returned in the reply. + /// Acknowledges an alarm as the operator. The gateway authenticates + /// with + /// and writes the + /// comment to the alarm's AckMsg attribute, so the alarm history records + /// the operator. The password is redacted from any surfaced error. /// - /// The acknowledge request — alarm reference, comment, operator user. + /// The acknowledge request: alarm reference, comment, operator credentials. /// Cancellation token for the operation. /// The acknowledge reply with protocol + native MxStatus. - public Task AcknowledgeAlarmAsync( + public async Task AcknowledgeAlarmAsync( AcknowledgeAlarmRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); ThrowIfDisposed(); - return ExecuteSafeUnaryAsync( - token => _transport.AcknowledgeAlarmAsync(request, CreateCallOptions(token)), - cancellationToken); + 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); + } } /// From 01435918582397e88d8341f5e152403cb3a187dc Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 19 Oct 2026 19:44:49 -0400 Subject: [PATCH 16/25] docs(dotnet-client): keep XML docs with their CLI members Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT --- .../MxGatewayClientCli.cs | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/MxGatewayClientCli.cs b/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/MxGatewayClientCli.cs index 9429ef9..a345908 100644 --- a/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/MxGatewayClientCli.cs +++ b/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/MxGatewayClientCli.cs @@ -353,11 +353,16 @@ public static class MxGatewayClientCli /// private const string DefaultVerifyPasswordEnvironmentName = "MXGATEWAY_VERIFY_PASSWORD"; + /// + /// Default environment variable holding the operator password for + /// acknowledge-alarm when --operator-password-env is not given. + /// + private const string OperatorPasswordEnvironmentName = "MXGATEWAY_OPERATOR_PASSWORD"; + /// /// Legacy environment variable, still honoured as a deprecated fallback /// for one release so existing scripts keep working. /// - private const string OperatorPasswordEnvironmentName = "MXGATEWAY_OPERATOR_PASSWORD"; private const string LegacyVerifyPasswordEnvironmentName = "MXGATEWAY_VERIFY_USER_PASSWORD"; /// @@ -381,14 +386,10 @@ public static class MxGatewayClientCli } /// - /// Resolves the effective MXAccess verify-user credential in this - /// order: --password, the deprecated --verify-user-password - /// alias, the environment variable named by --password-env (default - /// MXGATEWAY_VERIFY_PASSWORD), then the deprecated - /// MXGATEWAY_VERIFY_USER_PASSWORD. An empty value from any source is - /// treated as absent. The credential is never echoed; this resolver exists so - /// the error-redaction catch block can strip it from any surfaced error, - /// mirroring . + /// Resolves the acknowledge-alarm operator password: --operator-password, + /// then the environment variable named by --operator-password-env + /// (default MXGATEWAY_OPERATOR_PASSWORD). An empty value is treated as + /// absent. The value is never echoed; it is also fed to the error redactor. /// private static string? ResolveOperatorPassword(CliArguments arguments) { @@ -408,6 +409,16 @@ public static class MxGatewayClientCli return string.IsNullOrEmpty(password) ? null : password; } + /// + /// Resolves the effective MXAccess verify-user credential in this + /// order: --password, the deprecated --verify-user-password + /// alias, the environment variable named by --password-env (default + /// MXGATEWAY_VERIFY_PASSWORD), then the deprecated + /// MXGATEWAY_VERIFY_USER_PASSWORD. An empty value from any source is + /// treated as absent. The credential is never echoed; this resolver exists so + /// the error-redaction catch block can strip it from any surfaced error, + /// mirroring . + /// private static string? TryResolveVerifyUserPassword(CliArguments arguments) { string? password = arguments.GetOptional("password"); From 7b81cb7f724311918ccb31c242dda84d16301c84 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 19 Oct 2026 19:41:55 -0400 Subject: [PATCH 17/25] feat(python-client): operator credentials on acknowledge-alarm Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT --- clients/python/README.md | 14 +- .../python/src/zb_mom_ww_mxgateway/client.py | 26 +- .../generated/mxaccess_gateway_pb2.py | 312 +++++++++--------- .../src/zb_mom_ww_mxgateway_cli/commands.py | 38 ++- clients/python/tests/test_alarms.py | 27 ++ .../tests/test_review_findings_022_to_026.py | 8 + 6 files changed, 252 insertions(+), 173 deletions(-) diff --git a/clients/python/README.md b/clients/python/README.md index c9904be..64ddd9b 100644 --- a/clients/python/README.md +++ b/clients/python/README.md @@ -111,9 +111,15 @@ MXAccess commands and preserve raw replies on typed command exceptions. For alarms, the client exposes `GatewayClient.query_active_alarms` (one-shot snapshot), `GatewayClient.stream_alarms` (async generator yielding alarm-feed messages from the gateway's central monitor), and -`GatewayClient.acknowledge_alarm` (ack by alarm reference, optional comment -and ack target). Cancel the surrounding task or `aclose()` the iterator to -terminate the stream. +`GatewayClient.acknowledge_alarm` (ack by alarm reference with an optional +comment). The ack is made as the operator: set `operator_user` (required) and +`operator_password` (may be empty) and optionally `operator_domain` on +`AcknowledgeAlarmRequest`. The gateway authenticates the operator and writes the +comment to the alarm's `AckMsg`, so the alarm history records the operator. The +password is redacted from any raised error. + +Cancel the surrounding task or `aclose()` an alarm iterator to terminate the +stream. `ActiveAlarmSnapshot.from_truncated_snapshot` reports that the record came from a provider fetch which hit the per-fetch cap: the snapshot set may omit active @@ -457,7 +463,7 @@ mxgw-py add-item --session-id --server-handle 1 --item Object.Attribute --j mxgw-py advise --session-id --server-handle 1 --item-handle 2 --json mxgw-py stream-events --session-id --max-events 1 --json mxgw-py stream-alarms --max-messages 4 --json -mxgw-py acknowledge-alarm --reference "\\Galaxy\Area001.Pump001.PumpFault" --json +mxgw-py acknowledge-alarm --reference "\\Galaxy\Area001.Pump001.PumpFault" --operator operator1 --operator-password-env MXGATEWAY_OPERATOR_PASSWORD --json mxgw-py write --session-id --server-handle 1 --item-handle 2 --type int32 --value 123 --json ``` diff --git a/clients/python/src/zb_mom_ww_mxgateway/client.py b/clients/python/src/zb_mom_ww_mxgateway/client.py index 4aeceeb..e39a099 100644 --- a/clients/python/src/zb_mom_ww_mxgateway/client.py +++ b/clients/python/src/zb_mom_ww_mxgateway/client.py @@ -9,7 +9,7 @@ from typing import Any import grpc from .auth import merge_metadata -from .errors import ensure_protocol_success, map_rpc_error +from .errors import MxGatewayError, ensure_protocol_success, map_rpc_error from .generated import mxaccess_gateway_pb2 as pb from .generated import mxaccess_gateway_pb2_grpc as pb_grpc from .options import ClientOptions, create_channel, resolve_channel_security @@ -148,17 +148,23 @@ class GatewayClient: self, request: pb.AcknowledgeAlarmRequest, ) -> pb.AcknowledgeAlarmReply: - """Acknowledge an active MXAccess alarm condition through the gateway. + """Acknowledge an alarm as the operator. - The gateway authenticates the request against the API key's - ``invoke:alarm-ack`` scope and forwards the acknowledge to the worker's - MXAccess session; the resulting native ``MxStatus`` is returned in the - reply. Acks are idempotent — re-acking an already-acked condition is a - no-op at the MxAccess layer. + 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. """ - reply = await self._unary("acknowledge alarm", self.raw_stub.AcknowledgeAlarm, request) - ensure_protocol_success("acknowledge alarm", reply.protocol_status, reply) - return reply + 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: + # Imported lazily: session.py imports this module at its tail. + from .session import _redact_error + + _redact_error(error, [request.operator_password]) + raise def query_active_alarms( self, diff --git a/clients/python/src/zb_mom_ww_mxgateway/generated/mxaccess_gateway_pb2.py b/clients/python/src/zb_mom_ww_mxgateway/generated/mxaccess_gateway_pb2.py index 0b11094..4734739 100644 --- a/clients/python/src/zb_mom_ww_mxgateway/generated/mxaccess_gateway_pb2.py +++ b/clients/python/src/zb_mom_ww_mxgateway/generated/mxaccess_gateway_pb2.py @@ -26,7 +26,7 @@ from google.protobuf import duration_pb2 as google_dot_protobuf_dot_duration__pb from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x16mxaccess_gateway.proto\x12\x13mxaccess_gateway.v1\x1a\x1egoogle/protobuf/duration.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"j\n\x18QueryActiveAlarmsRequest\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x1d\n\x15\x63lient_correlation_id\x18\x02 \x01(\t\x12\x1b\n\x13\x61larm_filter_prefix\x18\x03 \x01(\t\"\x9f\x01\n\x12OpenSessionRequest\x12\x19\n\x11requested_backend\x18\x01 \x01(\t\x12\x1b\n\x13\x63lient_session_name\x18\x02 \x01(\t\x12\x1d\n\x15\x63lient_correlation_id\x18\x03 \x01(\t\x12\x32\n\x0f\x63ommand_timeout\x18\x04 \x01(\x0b\x32\x19.google.protobuf.Duration\"\xaa\x02\n\x10OpenSessionReply\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x14\n\x0c\x62\x61\x63kend_name\x18\x02 \x01(\t\x12\x19\n\x11worker_process_id\x18\x03 \x01(\x05\x12\x1f\n\x17worker_protocol_version\x18\x04 \x01(\r\x12\x14\n\x0c\x63\x61pabilities\x18\x05 \x03(\t\x12:\n\x17\x64\x65\x66\x61ult_command_timeout\x18\x06 \x01(\x0b\x32\x19.google.protobuf.Duration\x12<\n\x0fprotocol_status\x18\x07 \x01(\x0b\x32#.mxaccess_gateway.v1.ProtocolStatus\x12 \n\x18gateway_protocol_version\x18\x08 \x01(\r\"H\n\x13\x43loseSessionRequest\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x1d\n\x15\x63lient_correlation_id\x18\x02 \x01(\t\"\x9d\x01\n\x11\x43loseSessionReply\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x36\n\x0b\x66inal_state\x18\x02 \x01(\x0e\x32!.mxaccess_gateway.v1.SessionState\x12<\n\x0fprotocol_status\x18\x03 \x01(\x0b\x32#.mxaccess_gateway.v1.ProtocolStatus\"H\n\x13StreamEventsRequest\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x1d\n\x15\x61\x66ter_worker_sequence\x18\x02 \x01(\x04\"v\n\x10MxCommandRequest\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x1d\n\x15\x63lient_correlation_id\x18\x02 \x01(\t\x12/\n\x07\x63ommand\x18\x03 \x01(\x0b\x32\x1e.mxaccess_gateway.v1.MxCommand\"\x95\x16\n\tMxCommand\x12\x30\n\x04kind\x18\x01 \x01(\x0e\x32\".mxaccess_gateway.v1.MxCommandKind\x12\x38\n\x08register\x18\n \x01(\x0b\x32$.mxaccess_gateway.v1.RegisterCommandH\x00\x12<\n\nunregister\x18\x0b \x01(\x0b\x32&.mxaccess_gateway.v1.UnregisterCommandH\x00\x12\x37\n\x08\x61\x64\x64_item\x18\x0c \x01(\x0b\x32#.mxaccess_gateway.v1.AddItemCommandH\x00\x12\x39\n\tadd_item2\x18\r \x01(\x0b\x32$.mxaccess_gateway.v1.AddItem2CommandH\x00\x12=\n\x0bremove_item\x18\x0e \x01(\x0b\x32&.mxaccess_gateway.v1.RemoveItemCommandH\x00\x12\x34\n\x06\x61\x64vise\x18\x0f \x01(\x0b\x32\".mxaccess_gateway.v1.AdviseCommandH\x00\x12\x39\n\tun_advise\x18\x10 \x01(\x0b\x32$.mxaccess_gateway.v1.UnAdviseCommandH\x00\x12K\n\x12\x61\x64vise_supervisory\x18\x11 \x01(\x0b\x32-.mxaccess_gateway.v1.AdviseSupervisoryCommandH\x00\x12H\n\x11\x61\x64\x64_buffered_item\x18\x12 \x01(\x0b\x32+.mxaccess_gateway.v1.AddBufferedItemCommandH\x00\x12]\n\x1cset_buffered_update_interval\x18\x13 \x01(\x0b\x32\x35.mxaccess_gateway.v1.SetBufferedUpdateIntervalCommandH\x00\x12\x36\n\x07suspend\x18\x14 \x01(\x0b\x32#.mxaccess_gateway.v1.SuspendCommandH\x00\x12\x38\n\x08\x61\x63tivate\x18\x15 \x01(\x0b\x32$.mxaccess_gateway.v1.ActivateCommandH\x00\x12\x32\n\x05write\x18\x16 \x01(\x0b\x32!.mxaccess_gateway.v1.WriteCommandH\x00\x12\x34\n\x06write2\x18\x17 \x01(\x0b\x32\".mxaccess_gateway.v1.Write2CommandH\x00\x12\x41\n\rwrite_secured\x18\x18 \x01(\x0b\x32(.mxaccess_gateway.v1.WriteSecuredCommandH\x00\x12\x43\n\x0ewrite_secured2\x18\x19 \x01(\x0b\x32).mxaccess_gateway.v1.WriteSecured2CommandH\x00\x12I\n\x11\x61uthenticate_user\x18\x1a \x01(\x0b\x32,.mxaccess_gateway.v1.AuthenticateUserCommandH\x00\x12M\n\x14\x61rchestra_user_to_id\x18\x1b \x01(\x0b\x32-.mxaccess_gateway.v1.ArchestrAUserToIdCommandH\x00\x12@\n\radd_item_bulk\x18\x1c \x01(\x0b\x32\'.mxaccess_gateway.v1.AddItemBulkCommandH\x00\x12\x46\n\x10\x61\x64vise_item_bulk\x18\x1d \x01(\x0b\x32*.mxaccess_gateway.v1.AdviseItemBulkCommandH\x00\x12\x46\n\x10remove_item_bulk\x18\x1e \x01(\x0b\x32*.mxaccess_gateway.v1.RemoveItemBulkCommandH\x00\x12K\n\x13un_advise_item_bulk\x18\x1f \x01(\x0b\x32,.mxaccess_gateway.v1.UnAdviseItemBulkCommandH\x00\x12\x43\n\x0esubscribe_bulk\x18 \x01(\x0b\x32).mxaccess_gateway.v1.SubscribeBulkCommandH\x00\x12G\n\x10unsubscribe_bulk\x18! \x01(\x0b\x32+.mxaccess_gateway.v1.UnsubscribeBulkCommandH\x00\x12G\n\x10subscribe_alarms\x18\" \x01(\x0b\x32+.mxaccess_gateway.v1.SubscribeAlarmsCommandH\x00\x12K\n\x12unsubscribe_alarms\x18# \x01(\x0b\x32-.mxaccess_gateway.v1.UnsubscribeAlarmsCommandH\x00\x12Q\n\x19\x61\x63knowledge_alarm_command\x18$ \x01(\x0b\x32,.mxaccess_gateway.v1.AcknowledgeAlarmCommandH\x00\x12T\n\x1bquery_active_alarms_command\x18% \x01(\x0b\x32-.mxaccess_gateway.v1.QueryActiveAlarmsCommandH\x00\x12_\n!acknowledge_alarm_by_name_command\x18& \x01(\x0b\x32\x32.mxaccess_gateway.v1.AcknowledgeAlarmByNameCommandH\x00\x12;\n\nwrite_bulk\x18\' \x01(\x0b\x32%.mxaccess_gateway.v1.WriteBulkCommandH\x00\x12=\n\x0bwrite2_bulk\x18( \x01(\x0b\x32&.mxaccess_gateway.v1.Write2BulkCommandH\x00\x12J\n\x12write_secured_bulk\x18) \x01(\x0b\x32,.mxaccess_gateway.v1.WriteSecuredBulkCommandH\x00\x12L\n\x13write_secured2_bulk\x18* \x01(\x0b\x32-.mxaccess_gateway.v1.WriteSecured2BulkCommandH\x00\x12\x39\n\tread_bulk\x18+ \x01(\x0b\x32$.mxaccess_gateway.v1.ReadBulkCommandH\x00\x12S\n\x17update_alarm_watch_list\x18, \x01(\x0b\x32\x30.mxaccess_gateway.v1.UpdateAlarmWatchListCommandH\x00\x12\x30\n\x04ping\x18\x64 \x01(\x0b\x32 .mxaccess_gateway.v1.PingCommandH\x00\x12H\n\x11get_session_state\x18\x65 \x01(\x0b\x32+.mxaccess_gateway.v1.GetSessionStateCommandH\x00\x12\x44\n\x0fget_worker_info\x18\x66 \x01(\x0b\x32).mxaccess_gateway.v1.GetWorkerInfoCommandH\x00\x12?\n\x0c\x64rain_events\x18g \x01(\x0b\x32\'.mxaccess_gateway.v1.DrainEventsCommandH\x00\x12\x45\n\x0fshutdown_worker\x18h \x01(\x0b\x32*.mxaccess_gateway.v1.ShutdownWorkerCommandH\x00\x42\t\n\x07payload\"&\n\x0fRegisterCommand\x12\x13\n\x0b\x63lient_name\x18\x01 \x01(\t\"*\n\x11UnregisterCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\"@\n\x0e\x41\x64\x64ItemCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x17\n\x0fitem_definition\x18\x02 \x01(\t\"W\n\x0f\x41\x64\x64Item2Command\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x17\n\x0fitem_definition\x18\x02 \x01(\t\x12\x14\n\x0citem_context\x18\x03 \x01(\t\"?\n\x11RemoveItemCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x13\n\x0bitem_handle\x18\x02 \x01(\x05\";\n\rAdviseCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x13\n\x0bitem_handle\x18\x02 \x01(\x05\"=\n\x0fUnAdviseCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x13\n\x0bitem_handle\x18\x02 \x01(\x05\"F\n\x18\x41\x64viseSupervisoryCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x13\n\x0bitem_handle\x18\x02 \x01(\x05\"^\n\x16\x41\x64\x64\x42ufferedItemCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x17\n\x0fitem_definition\x18\x02 \x01(\t\x12\x14\n\x0citem_context\x18\x03 \x01(\t\"_\n SetBufferedUpdateIntervalCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12$\n\x1cupdate_interval_milliseconds\x18\x02 \x01(\x05\"<\n\x0eSuspendCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x13\n\x0bitem_handle\x18\x02 \x01(\x05\"=\n\x0f\x41\x63tivateCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x13\n\x0bitem_handle\x18\x02 \x01(\x05\"x\n\x0cWriteCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x13\n\x0bitem_handle\x18\x02 \x01(\x05\x12+\n\x05value\x18\x03 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x0f\n\x07user_id\x18\x04 \x01(\x05\"\xb0\x01\n\rWrite2Command\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x13\n\x0bitem_handle\x18\x02 \x01(\x05\x12+\n\x05value\x18\x03 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x35\n\x0ftimestamp_value\x18\x04 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x0f\n\x07user_id\x18\x05 \x01(\x05\"\xa1\x01\n\x13WriteSecuredCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x13\n\x0bitem_handle\x18\x02 \x01(\x05\x12\x17\n\x0f\x63urrent_user_id\x18\x03 \x01(\x05\x12\x18\n\x10verifier_user_id\x18\x04 \x01(\x05\x12+\n\x05value\x18\x05 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\"\xd9\x01\n\x14WriteSecured2Command\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x13\n\x0bitem_handle\x18\x02 \x01(\x05\x12\x17\n\x0f\x63urrent_user_id\x18\x03 \x01(\x05\x12\x18\n\x10verifier_user_id\x18\x04 \x01(\x05\x12+\n\x05value\x18\x05 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x35\n\x0ftimestamp_value\x18\x06 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\"c\n\x17\x41uthenticateUserCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x13\n\x0bverify_user\x18\x02 \x01(\t\x12\x1c\n\x14verify_user_password\x18\x03 \x01(\t\"G\n\x18\x41rchestrAUserToIdCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x14\n\x0cuser_id_guid\x18\x02 \x01(\t\"B\n\x12\x41\x64\x64ItemBulkCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x15\n\rtag_addresses\x18\x02 \x03(\t\"D\n\x15\x41\x64viseItemBulkCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x14\n\x0citem_handles\x18\x02 \x03(\x05\"D\n\x15RemoveItemBulkCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x14\n\x0citem_handles\x18\x02 \x03(\x05\"F\n\x17UnAdviseItemBulkCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x14\n\x0citem_handles\x18\x02 \x03(\x05\"D\n\x14SubscribeBulkCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x15\n\rtag_addresses\x18\x02 \x03(\t\"\x88\x02\n\x16SubscribeAlarmsCommand\x12\x1f\n\x17subscription_expression\x18\x01 \x01(\t\x12;\n\x0b\x66orced_mode\x18\x02 \x01(\x0e\x32&.mxaccess_gateway.v1.AlarmProviderMode\x12:\n\nwatch_list\x18\x03 \x03(\x0b\x32&.mxaccess_gateway.v1.AlarmSubtagTarget\x12:\n\x08\x66\x61ilover\x18\x04 \x01(\x0b\x32(.mxaccess_gateway.v1.AlarmFailoverConfig\x12\x18\n\x10\x66\x61llback_enabled\x18\x05 \x01(\x08\"Y\n\x1bUpdateAlarmWatchListCommand\x12:\n\nwatch_list\x18\x01 \x03(\x0b\x32&.mxaccess_gateway.v1.AlarmSubtagTarget\"\x1a\n\x18UnsubscribeAlarmsCommand\"\xb4\x01\n\x11\x41larmSubtagTarget\x12\x1c\n\x14\x61larm_full_reference\x18\x01 \x01(\t\x12\x1f\n\x17source_object_reference\x18\x02 \x01(\t\x12\x15\n\ractive_subtag\x18\x03 \x01(\t\x12\x14\n\x0c\x61\x63ked_subtag\x18\x04 \x01(\t\x12\x1a\n\x12\x61\x63k_comment_subtag\x18\x05 \x01(\t\x12\x17\n\x0fpriority_subtag\x18\x06 \x01(\t\"\x85\x01\n\x13\x41larmFailoverConfig\x12%\n\x1d\x63onsecutive_failure_threshold\x18\x01 \x01(\x05\x12\'\n\x1f\x66\x61ilback_probe_interval_seconds\x18\x02 \x01(\x05\x12\x1e\n\x16\x66\x61ilback_stable_probes\x18\x03 \x01(\x05\"\xa1\x01\n\x17\x41\x63knowledgeAlarmCommand\x12\x12\n\nalarm_guid\x18\x01 \x01(\t\x12\x0f\n\x07\x63omment\x18\x02 \x01(\t\x12\x15\n\roperator_user\x18\x03 \x01(\t\x12\x15\n\roperator_node\x18\x04 \x01(\t\x12\x17\n\x0foperator_domain\x18\x05 \x01(\t\x12\x1a\n\x12operator_full_name\x18\x06 \x01(\t\"7\n\x18QueryActiveAlarmsCommand\x12\x1b\n\x13\x61larm_filter_prefix\x18\x01 \x01(\t\"\xd2\x01\n\x1d\x41\x63knowledgeAlarmByNameCommand\x12\x12\n\nalarm_name\x18\x01 \x01(\t\x12\x15\n\rprovider_name\x18\x02 \x01(\t\x12\x12\n\ngroup_name\x18\x03 \x01(\t\x12\x0f\n\x07\x63omment\x18\x04 \x01(\t\x12\x15\n\roperator_user\x18\x05 \x01(\t\x12\x15\n\roperator_node\x18\x06 \x01(\t\x12\x17\n\x0foperator_domain\x18\x07 \x01(\t\x12\x1a\n\x12operator_full_name\x18\x08 \x01(\t\"E\n\x16UnsubscribeBulkCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x14\n\x0citem_handles\x18\x02 \x03(\x05\"_\n\x10WriteBulkCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x34\n\x07\x65ntries\x18\x02 \x03(\x0b\x32#.mxaccess_gateway.v1.WriteBulkEntry\"c\n\x0eWriteBulkEntry\x12\x13\n\x0bitem_handle\x18\x01 \x01(\x05\x12+\n\x05value\x18\x02 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x0f\n\x07user_id\x18\x03 \x01(\x05\"a\n\x11Write2BulkCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x35\n\x07\x65ntries\x18\x02 \x03(\x0b\x32$.mxaccess_gateway.v1.Write2BulkEntry\"\x9b\x01\n\x0fWrite2BulkEntry\x12\x13\n\x0bitem_handle\x18\x01 \x01(\x05\x12+\n\x05value\x18\x02 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x35\n\x0ftimestamp_value\x18\x03 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x0f\n\x07user_id\x18\x04 \x01(\x05\"m\n\x17WriteSecuredBulkCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12;\n\x07\x65ntries\x18\x02 \x03(\x0b\x32*.mxaccess_gateway.v1.WriteSecuredBulkEntry\"\x8c\x01\n\x15WriteSecuredBulkEntry\x12\x13\n\x0bitem_handle\x18\x01 \x01(\x05\x12\x17\n\x0f\x63urrent_user_id\x18\x02 \x01(\x05\x12\x18\n\x10verifier_user_id\x18\x03 \x01(\x05\x12+\n\x05value\x18\x04 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\"o\n\x18WriteSecured2BulkCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12<\n\x07\x65ntries\x18\x02 \x03(\x0b\x32+.mxaccess_gateway.v1.WriteSecured2BulkEntry\"\xc4\x01\n\x16WriteSecured2BulkEntry\x12\x13\n\x0bitem_handle\x18\x01 \x01(\x05\x12\x17\n\x0f\x63urrent_user_id\x18\x02 \x01(\x05\x12\x18\n\x10verifier_user_id\x18\x03 \x01(\x05\x12+\n\x05value\x18\x04 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x35\n\x0ftimestamp_value\x18\x05 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\"S\n\x0fReadBulkCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x15\n\rtag_addresses\x18\x02 \x03(\t\x12\x12\n\ntimeout_ms\x18\x03 \x01(\r\"\x1e\n\x0bPingCommand\x12\x0f\n\x07message\x18\x01 \x01(\t\"\x18\n\x16GetSessionStateCommand\"\x16\n\x14GetWorkerInfoCommand\"(\n\x12\x44rainEventsCommand\x12\x12\n\nmax_events\x18\x01 \x01(\r\"H\n\x15ShutdownWorkerCommand\x12/\n\x0cgrace_period\x18\x01 \x01(\x0b\x32\x19.google.protobuf.Duration\"\x86\x0f\n\x0eMxCommandReply\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x16\n\x0e\x63orrelation_id\x18\x02 \x01(\t\x12\x30\n\x04kind\x18\x03 \x01(\x0e\x32\".mxaccess_gateway.v1.MxCommandKind\x12<\n\x0fprotocol_status\x18\x04 \x01(\x0b\x32#.mxaccess_gateway.v1.ProtocolStatus\x12\x14\n\x07hresult\x18\x05 \x01(\x05H\x01\x88\x01\x01\x12\x32\n\x0creturn_value\x18\x06 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x34\n\x08statuses\x18\x07 \x03(\x0b\x32\".mxaccess_gateway.v1.MxStatusProxy\x12\x1a\n\x12\x64iagnostic_message\x18\x08 \x01(\t\x12\x36\n\x08register\x18\x14 \x01(\x0b\x32\".mxaccess_gateway.v1.RegisterReplyH\x00\x12\x35\n\x08\x61\x64\x64_item\x18\x15 \x01(\x0b\x32!.mxaccess_gateway.v1.AddItemReplyH\x00\x12\x37\n\tadd_item2\x18\x16 \x01(\x0b\x32\".mxaccess_gateway.v1.AddItem2ReplyH\x00\x12\x46\n\x11\x61\x64\x64_buffered_item\x18\x17 \x01(\x0b\x32).mxaccess_gateway.v1.AddBufferedItemReplyH\x00\x12\x34\n\x07suspend\x18\x18 \x01(\x0b\x32!.mxaccess_gateway.v1.SuspendReplyH\x00\x12\x36\n\x08\x61\x63tivate\x18\x19 \x01(\x0b\x32\".mxaccess_gateway.v1.ActivateReplyH\x00\x12G\n\x11\x61uthenticate_user\x18\x1a \x01(\x0b\x32*.mxaccess_gateway.v1.AuthenticateUserReplyH\x00\x12K\n\x14\x61rchestra_user_to_id\x18\x1b \x01(\x0b\x32+.mxaccess_gateway.v1.ArchestrAUserToIdReplyH\x00\x12@\n\radd_item_bulk\x18\x1c \x01(\x0b\x32\'.mxaccess_gateway.v1.BulkSubscribeReplyH\x00\x12\x43\n\x10\x61\x64vise_item_bulk\x18\x1d \x01(\x0b\x32\'.mxaccess_gateway.v1.BulkSubscribeReplyH\x00\x12\x43\n\x10remove_item_bulk\x18\x1e \x01(\x0b\x32\'.mxaccess_gateway.v1.BulkSubscribeReplyH\x00\x12\x46\n\x13un_advise_item_bulk\x18\x1f \x01(\x0b\x32\'.mxaccess_gateway.v1.BulkSubscribeReplyH\x00\x12\x41\n\x0esubscribe_bulk\x18 \x01(\x0b\x32\'.mxaccess_gateway.v1.BulkSubscribeReplyH\x00\x12\x43\n\x10unsubscribe_bulk\x18! \x01(\x0b\x32\'.mxaccess_gateway.v1.BulkSubscribeReplyH\x00\x12N\n\x11\x61\x63knowledge_alarm\x18\" \x01(\x0b\x32\x31.mxaccess_gateway.v1.AcknowledgeAlarmReplyPayloadH\x00\x12Q\n\x13query_active_alarms\x18# \x01(\x0b\x32\x32.mxaccess_gateway.v1.QueryActiveAlarmsReplyPayloadH\x00\x12\x39\n\nwrite_bulk\x18$ \x01(\x0b\x32#.mxaccess_gateway.v1.BulkWriteReplyH\x00\x12:\n\x0bwrite2_bulk\x18% \x01(\x0b\x32#.mxaccess_gateway.v1.BulkWriteReplyH\x00\x12\x41\n\x12write_secured_bulk\x18& \x01(\x0b\x32#.mxaccess_gateway.v1.BulkWriteReplyH\x00\x12\x42\n\x13write_secured2_bulk\x18\' \x01(\x0b\x32#.mxaccess_gateway.v1.BulkWriteReplyH\x00\x12\x37\n\tread_bulk\x18( \x01(\x0b\x32\".mxaccess_gateway.v1.BulkReadReplyH\x00\x12?\n\rsession_state\x18\x64 \x01(\x0b\x32&.mxaccess_gateway.v1.SessionStateReplyH\x00\x12;\n\x0bworker_info\x18\x65 \x01(\x0b\x32$.mxaccess_gateway.v1.WorkerInfoReplyH\x00\x12=\n\x0c\x64rain_events\x18\x66 \x01(\x0b\x32%.mxaccess_gateway.v1.DrainEventsReplyH\x00\x42\t\n\x07payloadB\n\n\x08_hresult\"&\n\rRegisterReply\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\"#\n\x0c\x41\x64\x64ItemReply\x12\x13\n\x0bitem_handle\x18\x01 \x01(\x05\"$\n\rAddItem2Reply\x12\x13\n\x0bitem_handle\x18\x01 \x01(\x05\"+\n\x14\x41\x64\x64\x42ufferedItemReply\x12\x13\n\x0bitem_handle\x18\x01 \x01(\x05\"B\n\x0cSuspendReply\x12\x32\n\x06status\x18\x01 \x01(\x0b\x32\".mxaccess_gateway.v1.MxStatusProxy\"C\n\rActivateReply\x12\x32\n\x06status\x18\x01 \x01(\x0b\x32\".mxaccess_gateway.v1.MxStatusProxy\"(\n\x15\x41uthenticateUserReply\x12\x0f\n\x07user_id\x18\x01 \x01(\x05\")\n\x16\x41rchestrAUserToIdReply\x12\x0f\n\x07user_id\x18\x01 \x01(\x05\"\x81\x01\n\x0fSubscribeResult\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x13\n\x0btag_address\x18\x02 \x01(\t\x12\x13\n\x0bitem_handle\x18\x03 \x01(\x05\x12\x16\n\x0ewas_successful\x18\x04 \x01(\x08\x12\x15\n\rerror_message\x18\x05 \x01(\t\"K\n\x12\x42ulkSubscribeReply\x12\x35\n\x07results\x18\x01 \x03(\x0b\x32$.mxaccess_gateway.v1.SubscribeResult\"\xc4\x01\n\x0f\x42ulkWriteResult\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x13\n\x0bitem_handle\x18\x02 \x01(\x05\x12\x16\n\x0ewas_successful\x18\x03 \x01(\x08\x12\x14\n\x07hresult\x18\x04 \x01(\x05H\x00\x88\x01\x01\x12\x34\n\x08statuses\x18\x05 \x03(\x0b\x32\".mxaccess_gateway.v1.MxStatusProxy\x12\x15\n\rerror_message\x18\x06 \x01(\tB\n\n\x08_hresult\"G\n\x0e\x42ulkWriteReply\x12\x35\n\x07results\x18\x01 \x03(\x0b\x32$.mxaccess_gateway.v1.BulkWriteResult\"\xbe\x02\n\x0e\x42ulkReadResult\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x13\n\x0btag_address\x18\x02 \x01(\t\x12\x13\n\x0bitem_handle\x18\x03 \x01(\x05\x12\x16\n\x0ewas_successful\x18\x04 \x01(\x08\x12\x12\n\nwas_cached\x18\x05 \x01(\x08\x12+\n\x05value\x18\x06 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x0f\n\x07quality\x18\x07 \x01(\x05\x12\x34\n\x10source_timestamp\x18\x08 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x34\n\x08statuses\x18\t \x03(\x0b\x32\".mxaccess_gateway.v1.MxStatusProxy\x12\x15\n\rerror_message\x18\n \x01(\t\"E\n\rBulkReadReply\x12\x34\n\x07results\x18\x01 \x03(\x0b\x32#.mxaccess_gateway.v1.BulkReadResult\"E\n\x11SessionStateReply\x12\x30\n\x05state\x18\x01 \x01(\x0e\x32!.mxaccess_gateway.v1.SessionState\"u\n\x0fWorkerInfoReply\x12\x19\n\x11worker_process_id\x18\x01 \x01(\x05\x12\x16\n\x0eworker_version\x18\x02 \x01(\t\x12\x17\n\x0fmxaccess_progid\x18\x03 \x01(\t\x12\x16\n\x0emxaccess_clsid\x18\x04 \x01(\t\"@\n\x10\x44rainEventsReply\x12,\n\x06\x65vents\x18\x01 \x03(\x0b\x32\x1c.mxaccess_gateway.v1.MxEvent\"5\n\x1c\x41\x63knowledgeAlarmReplyPayload\x12\x15\n\rnative_status\x18\x01 \x01(\x05\"x\n\x1dQueryActiveAlarmsReplyPayload\x12;\n\tsnapshots\x18\x01 \x03(\x0b\x32(.mxaccess_gateway.v1.ActiveAlarmSnapshot\x12\x1a\n\x12snapshot_truncated\x18\x02 \x01(\x08\"\x8f\x08\n\x07MxEvent\x12\x32\n\x06\x66\x61mily\x18\x01 \x01(\x0e\x32\".mxaccess_gateway.v1.MxEventFamily\x12\x12\n\nsession_id\x18\x02 \x01(\t\x12\x15\n\rserver_handle\x18\x03 \x01(\x05\x12\x13\n\x0bitem_handle\x18\x04 \x01(\x05\x12+\n\x05value\x18\x05 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x0f\n\x07quality\x18\x06 \x01(\x05\x12\x34\n\x10source_timestamp\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x34\n\x08statuses\x18\x08 \x03(\x0b\x32\".mxaccess_gateway.v1.MxStatusProxy\x12\x17\n\x0fworker_sequence\x18\t \x01(\x04\x12\x34\n\x10worker_timestamp\x18\n \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12=\n\x19gateway_receive_timestamp\x18\x0b \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x14\n\x07hresult\x18\x0c \x01(\x05H\x01\x88\x01\x01\x12\x12\n\nraw_status\x18\r \x01(\t\x12\x37\n\nreplay_gap\x18\x0e \x01(\x0b\x32\x1e.mxaccess_gateway.v1.ReplayGapH\x02\x88\x01\x01\x12@\n\x0eon_data_change\x18\x14 \x01(\x0b\x32&.mxaccess_gateway.v1.OnDataChangeEventH\x00\x12\x46\n\x11on_write_complete\x18\x15 \x01(\x0b\x32).mxaccess_gateway.v1.OnWriteCompleteEventH\x00\x12I\n\x12operation_complete\x18\x16 \x01(\x0b\x32+.mxaccess_gateway.v1.OperationCompleteEventH\x00\x12Q\n\x17on_buffered_data_change\x18\x17 \x01(\x0b\x32..mxaccess_gateway.v1.OnBufferedDataChangeEventH\x00\x12J\n\x13on_alarm_transition\x18\x18 \x01(\x0b\x32+.mxaccess_gateway.v1.OnAlarmTransitionEventH\x00\x12^\n\x1eon_alarm_provider_mode_changed\x18\x19 \x01(\x0b\x32\x34.mxaccess_gateway.v1.OnAlarmProviderModeChangedEventH\x00\x42\x06\n\x04\x62odyB\n\n\x08_hresultB\r\n\x0b_replay_gap\"P\n\tReplayGap\x12 \n\x18requested_after_sequence\x18\x01 \x01(\x04\x12!\n\x19oldest_available_sequence\x18\x02 \x01(\x04\"\x13\n\x11OnDataChangeEvent\"\x16\n\x14OnWriteCompleteEvent\"\x18\n\x16OperationCompleteEvent\"\xd4\x01\n\x19OnBufferedDataChangeEvent\x12\x32\n\tdata_type\x18\x01 \x01(\x0e\x32\x1f.mxaccess_gateway.v1.MxDataType\x12\x34\n\x0equality_values\x18\x02 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxArray\x12\x36\n\x10timestamp_values\x18\x03 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxArray\x12\x15\n\rraw_data_type\x18\x04 \x01(\x05\"\xd0\x04\n\x16OnAlarmTransitionEvent\x12\x1c\n\x14\x61larm_full_reference\x18\x01 \x01(\t\x12\x1f\n\x17source_object_reference\x18\x02 \x01(\t\x12\x17\n\x0f\x61larm_type_name\x18\x03 \x01(\t\x12\x41\n\x0ftransition_kind\x18\x04 \x01(\x0e\x32(.mxaccess_gateway.v1.AlarmTransitionKind\x12\x10\n\x08severity\x18\x05 \x01(\x05\x12<\n\x18original_raise_timestamp\x18\x06 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x38\n\x14transition_timestamp\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x15\n\roperator_user\x18\x08 \x01(\t\x12\x18\n\x10operator_comment\x18\t \x01(\t\x12\x10\n\x08\x63\x61tegory\x18\n \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x0b \x01(\t\x12\x33\n\rcurrent_value\x18\x0c \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x31\n\x0blimit_value\x18\r \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x10\n\x08\x64\x65graded\x18\x0e \x01(\x08\x12?\n\x0fsource_provider\x18\x0f \x01(\x0e\x32&.mxaccess_gateway.v1.AlarmProviderMode\"\xa0\x01\n\x1fOnAlarmProviderModeChangedEvent\x12\x34\n\x04mode\x18\x01 \x01(\x0e\x32&.mxaccess_gateway.v1.AlarmProviderMode\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x0f\n\x07hresult\x18\x03 \x01(\x05\x12&\n\x02\x61t\x18\x04 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xf1\x04\n\x13\x41\x63tiveAlarmSnapshot\x12\x1c\n\x14\x61larm_full_reference\x18\x01 \x01(\t\x12\x1f\n\x17source_object_reference\x18\x02 \x01(\t\x12\x17\n\x0f\x61larm_type_name\x18\x03 \x01(\t\x12\x10\n\x08severity\x18\x04 \x01(\x05\x12<\n\x18original_raise_timestamp\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12?\n\rcurrent_state\x18\x06 \x01(\x0e\x32(.mxaccess_gateway.v1.AlarmConditionState\x12\x10\n\x08\x63\x61tegory\x18\x07 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x08 \x01(\t\x12=\n\x19last_transition_timestamp\x18\t \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x15\n\roperator_user\x18\n \x01(\t\x12\x18\n\x10operator_comment\x18\x0b \x01(\t\x12\x33\n\rcurrent_value\x18\x0c \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x31\n\x0blimit_value\x18\r \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x10\n\x08\x64\x65graded\x18\x0e \x01(\x08\x12?\n\x0fsource_provider\x18\x0f \x01(\x0e\x32&.mxaccess_gateway.v1.AlarmProviderMode\x12\x1f\n\x17\x66rom_truncated_snapshot\x18\x10 \x01(\x08\"\x90\x01\n\x17\x41\x63knowledgeAlarmRequest\x12\x1d\n\x15\x63lient_correlation_id\x18\x02 \x01(\t\x12\x1c\n\x14\x61larm_full_reference\x18\x03 \x01(\t\x12\x0f\n\x07\x63omment\x18\x04 \x01(\t\x12\x15\n\roperator_user\x18\x05 \x01(\tJ\x04\x08\x01\x10\x02R\nsession_id\"\xf1\x01\n\x15\x41\x63knowledgeAlarmReply\x12\x16\n\x0e\x63orrelation_id\x18\x02 \x01(\t\x12<\n\x0fprotocol_status\x18\x03 \x01(\x0b\x32#.mxaccess_gateway.v1.ProtocolStatus\x12\x14\n\x07hresult\x18\x04 \x01(\x05H\x00\x88\x01\x01\x12\x32\n\x06status\x18\x05 \x01(\x0b\x32\".mxaccess_gateway.v1.MxStatusProxy\x12\x1a\n\x12\x64iagnostic_message\x18\x06 \x01(\tB\n\n\x08_hresultJ\x04\x08\x01\x10\x02R\nsession_id\"Q\n\x13StreamAlarmsRequest\x12\x1d\n\x15\x63lient_correlation_id\x18\x01 \x01(\t\x12\x1b\n\x13\x61larm_filter_prefix\x18\x02 \x01(\t\"\xc9\x02\n\x10\x41larmFeedMessage\x12@\n\x0c\x61\x63tive_alarm\x18\x01 \x01(\x0b\x32(.mxaccess_gateway.v1.ActiveAlarmSnapshotH\x00\x12\x1b\n\x11snapshot_complete\x18\x02 \x01(\x08H\x00\x12\x41\n\ntransition\x18\x03 \x01(\x0b\x32+.mxaccess_gateway.v1.OnAlarmTransitionEventH\x00\x12\x43\n\x0fprovider_status\x18\x04 \x01(\x0b\x32(.mxaccess_gateway.v1.AlarmProviderStatusH\x00\x12\x43\n\x0fsnapshot_status\x18\x05 \x01(\x0b\x32(.mxaccess_gateway.v1.AlarmSnapshotStatusH\x00\x42\t\n\x07payload\"\x98\x01\n\x13\x41larmProviderStatus\x12\x34\n\x04mode\x18\x01 \x01(\x0e\x32&.mxaccess_gateway.v1.AlarmProviderMode\x12\x10\n\x08\x64\x65graded\x18\x02 \x01(\x08\x12\x0e\n\x06reason\x18\x03 \x01(\t\x12)\n\x05since\x18\x04 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"(\n\x13\x41larmSnapshotStatus\x12\x11\n\ttruncated\x18\x01 \x01(\x08\"\xeb\x01\n\rMxStatusProxy\x12\x0f\n\x07success\x18\x01 \x01(\x05\x12\x37\n\x08\x63\x61tegory\x18\x02 \x01(\x0e\x32%.mxaccess_gateway.v1.MxStatusCategory\x12\x38\n\x0b\x64\x65tected_by\x18\x03 \x01(\x0e\x32#.mxaccess_gateway.v1.MxStatusSource\x12\x0e\n\x06\x64\x65tail\x18\x04 \x01(\x05\x12\x14\n\x0craw_category\x18\x05 \x01(\x05\x12\x17\n\x0fraw_detected_by\x18\x06 \x01(\x05\x12\x17\n\x0f\x64iagnostic_text\x18\x07 \x01(\t\"\xe9\x03\n\x07MxValue\x12\x32\n\tdata_type\x18\x01 \x01(\x0e\x32\x1f.mxaccess_gateway.v1.MxDataType\x12\x14\n\x0cvariant_type\x18\x02 \x01(\t\x12\x0f\n\x07is_null\x18\x03 \x01(\x08\x12\x16\n\x0eraw_diagnostic\x18\x04 \x01(\t\x12\x15\n\rraw_data_type\x18\x05 \x01(\x05\x12\x14\n\nbool_value\x18\n \x01(\x08H\x00\x12\x15\n\x0bint32_value\x18\x0b \x01(\x05H\x00\x12\x15\n\x0bint64_value\x18\x0c \x01(\x03H\x00\x12\x15\n\x0b\x66loat_value\x18\r \x01(\x02H\x00\x12\x16\n\x0c\x64ouble_value\x18\x0e \x01(\x01H\x00\x12\x16\n\x0cstring_value\x18\x0f \x01(\tH\x00\x12\x35\n\x0ftimestamp_value\x18\x10 \x01(\x0b\x32\x1a.google.protobuf.TimestampH\x00\x12\x33\n\x0b\x61rray_value\x18\x11 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxArrayH\x00\x12\x13\n\traw_value\x18\x12 \x01(\x0cH\x00\x12@\n\x12sparse_array_value\x18\x13 \x01(\x0b\x32\".mxaccess_gateway.v1.MxSparseArrayH\x00\x42\x06\n\x04kind\"\xfe\x04\n\x07MxArray\x12:\n\x11\x65lement_data_type\x18\x01 \x01(\x0e\x32\x1f.mxaccess_gateway.v1.MxDataType\x12\x14\n\x0cvariant_type\x18\x02 \x01(\t\x12\x12\n\ndimensions\x18\x03 \x03(\r\x12\x16\n\x0eraw_diagnostic\x18\x04 \x01(\t\x12\x1d\n\x15raw_element_data_type\x18\x05 \x01(\x05\x12\x35\n\x0b\x62ool_values\x18\n \x01(\x0b\x32\x1e.mxaccess_gateway.v1.BoolArrayH\x00\x12\x37\n\x0cint32_values\x18\x0b \x01(\x0b\x32\x1f.mxaccess_gateway.v1.Int32ArrayH\x00\x12\x37\n\x0cint64_values\x18\x0c \x01(\x0b\x32\x1f.mxaccess_gateway.v1.Int64ArrayH\x00\x12\x37\n\x0c\x66loat_values\x18\r \x01(\x0b\x32\x1f.mxaccess_gateway.v1.FloatArrayH\x00\x12\x39\n\rdouble_values\x18\x0e \x01(\x0b\x32 .mxaccess_gateway.v1.DoubleArrayH\x00\x12\x39\n\rstring_values\x18\x0f \x01(\x0b\x32 .mxaccess_gateway.v1.StringArrayH\x00\x12?\n\x10timestamp_values\x18\x10 \x01(\x0b\x32#.mxaccess_gateway.v1.TimestampArrayH\x00\x12\x33\n\nraw_values\x18\x11 \x01(\x0b\x32\x1d.mxaccess_gateway.v1.RawArrayH\x00\x42\x08\n\x06values\"\x99\x01\n\rMxSparseArray\x12:\n\x11\x65lement_data_type\x18\x01 \x01(\x0e\x32\x1f.mxaccess_gateway.v1.MxDataType\x12\x14\n\x0ctotal_length\x18\x02 \x01(\r\x12\x36\n\x08\x65lements\x18\x03 \x03(\x0b\x32$.mxaccess_gateway.v1.MxSparseElement\"M\n\x0fMxSparseElement\x12\r\n\x05index\x18\x01 \x01(\r\x12+\n\x05value\x18\x02 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\"\x1b\n\tBoolArray\x12\x0e\n\x06values\x18\x01 \x03(\x08\"\x1c\n\nInt32Array\x12\x0e\n\x06values\x18\x01 \x03(\x05\"\x1c\n\nInt64Array\x12\x0e\n\x06values\x18\x01 \x03(\x03\"\x1c\n\nFloatArray\x12\x0e\n\x06values\x18\x01 \x03(\x02\"\x1d\n\x0b\x44oubleArray\x12\x0e\n\x06values\x18\x01 \x03(\x01\"\x1d\n\x0bStringArray\x12\x0e\n\x06values\x18\x01 \x03(\t\"<\n\x0eTimestampArray\x12*\n\x06values\x18\x01 \x03(\x0b\x32\x1a.google.protobuf.Timestamp\"\x1a\n\x08RawArray\x12\x0e\n\x06values\x18\x01 \x03(\x0c\"X\n\x0eProtocolStatus\x12\x35\n\x04\x63ode\x18\x01 \x01(\x0e\x32\'.mxaccess_gateway.v1.ProtocolStatusCode\x12\x0f\n\x07message\x18\x02 \x01(\t*\xcc\x0b\n\rMxCommandKind\x12\x1f\n\x1bMX_COMMAND_KIND_UNSPECIFIED\x10\x00\x12\x1c\n\x18MX_COMMAND_KIND_REGISTER\x10\x01\x12\x1e\n\x1aMX_COMMAND_KIND_UNREGISTER\x10\x02\x12\x1c\n\x18MX_COMMAND_KIND_ADD_ITEM\x10\x03\x12\x1d\n\x19MX_COMMAND_KIND_ADD_ITEM2\x10\x04\x12\x1f\n\x1bMX_COMMAND_KIND_REMOVE_ITEM\x10\x05\x12\x1a\n\x16MX_COMMAND_KIND_ADVISE\x10\x06\x12\x1d\n\x19MX_COMMAND_KIND_UN_ADVISE\x10\x07\x12&\n\"MX_COMMAND_KIND_ADVISE_SUPERVISORY\x10\x08\x12%\n!MX_COMMAND_KIND_ADD_BUFFERED_ITEM\x10\t\x12\x30\n,MX_COMMAND_KIND_SET_BUFFERED_UPDATE_INTERVAL\x10\n\x12\x1b\n\x17MX_COMMAND_KIND_SUSPEND\x10\x0b\x12\x1c\n\x18MX_COMMAND_KIND_ACTIVATE\x10\x0c\x12\x19\n\x15MX_COMMAND_KIND_WRITE\x10\r\x12\x1a\n\x16MX_COMMAND_KIND_WRITE2\x10\x0e\x12!\n\x1dMX_COMMAND_KIND_WRITE_SECURED\x10\x0f\x12\"\n\x1eMX_COMMAND_KIND_WRITE_SECURED2\x10\x10\x12%\n!MX_COMMAND_KIND_AUTHENTICATE_USER\x10\x11\x12(\n$MX_COMMAND_KIND_ARCHESTRA_USER_TO_ID\x10\x12\x12!\n\x1dMX_COMMAND_KIND_ADD_ITEM_BULK\x10\x13\x12$\n MX_COMMAND_KIND_ADVISE_ITEM_BULK\x10\x14\x12$\n MX_COMMAND_KIND_REMOVE_ITEM_BULK\x10\x15\x12\'\n#MX_COMMAND_KIND_UN_ADVISE_ITEM_BULK\x10\x16\x12\"\n\x1eMX_COMMAND_KIND_SUBSCRIBE_BULK\x10\x17\x12$\n MX_COMMAND_KIND_UNSUBSCRIBE_BULK\x10\x18\x12$\n MX_COMMAND_KIND_SUBSCRIBE_ALARMS\x10\x19\x12&\n\"MX_COMMAND_KIND_UNSUBSCRIBE_ALARMS\x10\x1a\x12%\n!MX_COMMAND_KIND_ACKNOWLEDGE_ALARM\x10\x1b\x12\'\n#MX_COMMAND_KIND_QUERY_ACTIVE_ALARMS\x10\x1c\x12-\n)MX_COMMAND_KIND_ACKNOWLEDGE_ALARM_BY_NAME\x10\x1d\x12\x1e\n\x1aMX_COMMAND_KIND_WRITE_BULK\x10\x1e\x12\x1f\n\x1bMX_COMMAND_KIND_WRITE2_BULK\x10\x1f\x12&\n\"MX_COMMAND_KIND_WRITE_SECURED_BULK\x10 \x12\'\n#MX_COMMAND_KIND_WRITE_SECURED2_BULK\x10!\x12\x1d\n\x19MX_COMMAND_KIND_READ_BULK\x10\"\x12+\n\'MX_COMMAND_KIND_UPDATE_ALARM_WATCH_LIST\x10#\x12\x18\n\x14MX_COMMAND_KIND_PING\x10\x64\x12%\n!MX_COMMAND_KIND_GET_SESSION_STATE\x10\x65\x12#\n\x1fMX_COMMAND_KIND_GET_WORKER_INFO\x10\x66\x12 \n\x1cMX_COMMAND_KIND_DRAIN_EVENTS\x10g\x12#\n\x1fMX_COMMAND_KIND_SHUTDOWN_WORKER\x10h*z\n\x11\x41larmProviderMode\x12#\n\x1f\x41LARM_PROVIDER_MODE_UNSPECIFIED\x10\x00\x12 \n\x1c\x41LARM_PROVIDER_MODE_ALARMMGR\x10\x01\x12\x1e\n\x1a\x41LARM_PROVIDER_MODE_SUBTAG\x10\x02*\xad\x02\n\rMxEventFamily\x12\x1f\n\x1bMX_EVENT_FAMILY_UNSPECIFIED\x10\x00\x12\"\n\x1eMX_EVENT_FAMILY_ON_DATA_CHANGE\x10\x01\x12%\n!MX_EVENT_FAMILY_ON_WRITE_COMPLETE\x10\x02\x12&\n\"MX_EVENT_FAMILY_OPERATION_COMPLETE\x10\x03\x12+\n\'MX_EVENT_FAMILY_ON_BUFFERED_DATA_CHANGE\x10\x04\x12\'\n#MX_EVENT_FAMILY_ON_ALARM_TRANSITION\x10\x05\x12\x32\n.MX_EVENT_FAMILY_ON_ALARM_PROVIDER_MODE_CHANGED\x10\x06*\xca\x01\n\x13\x41larmTransitionKind\x12%\n!ALARM_TRANSITION_KIND_UNSPECIFIED\x10\x00\x12\x1f\n\x1b\x41LARM_TRANSITION_KIND_RAISE\x10\x01\x12%\n!ALARM_TRANSITION_KIND_ACKNOWLEDGE\x10\x02\x12\x1f\n\x1b\x41LARM_TRANSITION_KIND_CLEAR\x10\x03\x12#\n\x1f\x41LARM_TRANSITION_KIND_RETRIGGER\x10\x04*\xaa\x01\n\x13\x41larmConditionState\x12%\n!ALARM_CONDITION_STATE_UNSPECIFIED\x10\x00\x12 \n\x1c\x41LARM_CONDITION_STATE_ACTIVE\x10\x01\x12&\n\"ALARM_CONDITION_STATE_ACTIVE_ACKED\x10\x02\x12\"\n\x1e\x41LARM_CONDITION_STATE_INACTIVE\x10\x03*\xa5\x03\n\x10MxStatusCategory\x12\"\n\x1eMX_STATUS_CATEGORY_UNSPECIFIED\x10\x00\x12\x1e\n\x1aMX_STATUS_CATEGORY_UNKNOWN\x10\x01\x12\x19\n\x15MX_STATUS_CATEGORY_OK\x10\x02\x12\x1e\n\x1aMX_STATUS_CATEGORY_PENDING\x10\x03\x12\x1e\n\x1aMX_STATUS_CATEGORY_WARNING\x10\x04\x12*\n&MX_STATUS_CATEGORY_COMMUNICATION_ERROR\x10\x05\x12*\n&MX_STATUS_CATEGORY_CONFIGURATION_ERROR\x10\x06\x12(\n$MX_STATUS_CATEGORY_OPERATIONAL_ERROR\x10\x07\x12%\n!MX_STATUS_CATEGORY_SECURITY_ERROR\x10\x08\x12%\n!MX_STATUS_CATEGORY_SOFTWARE_ERROR\x10\t\x12\"\n\x1eMX_STATUS_CATEGORY_OTHER_ERROR\x10\n*\xca\x02\n\x0eMxStatusSource\x12 \n\x1cMX_STATUS_SOURCE_UNSPECIFIED\x10\x00\x12\x1c\n\x18MX_STATUS_SOURCE_UNKNOWN\x10\x01\x12#\n\x1fMX_STATUS_SOURCE_REQUESTING_LMX\x10\x02\x12#\n\x1fMX_STATUS_SOURCE_RESPONDING_LMX\x10\x03\x12#\n\x1fMX_STATUS_SOURCE_REQUESTING_NMX\x10\x04\x12#\n\x1fMX_STATUS_SOURCE_RESPONDING_NMX\x10\x05\x12\x31\n-MX_STATUS_SOURCE_REQUESTING_AUTOMATION_OBJECT\x10\x06\x12\x31\n-MX_STATUS_SOURCE_RESPONDING_AUTOMATION_OBJECT\x10\x07*\xdd\x04\n\nMxDataType\x12\x1c\n\x18MX_DATA_TYPE_UNSPECIFIED\x10\x00\x12\x18\n\x14MX_DATA_TYPE_UNKNOWN\x10\x01\x12\x18\n\x14MX_DATA_TYPE_NO_DATA\x10\x02\x12\x18\n\x14MX_DATA_TYPE_BOOLEAN\x10\x03\x12\x18\n\x14MX_DATA_TYPE_INTEGER\x10\x04\x12\x16\n\x12MX_DATA_TYPE_FLOAT\x10\x05\x12\x17\n\x13MX_DATA_TYPE_DOUBLE\x10\x06\x12\x17\n\x13MX_DATA_TYPE_STRING\x10\x07\x12\x15\n\x11MX_DATA_TYPE_TIME\x10\x08\x12\x1d\n\x19MX_DATA_TYPE_ELAPSED_TIME\x10\t\x12\x1f\n\x1bMX_DATA_TYPE_REFERENCE_TYPE\x10\n\x12\x1c\n\x18MX_DATA_TYPE_STATUS_TYPE\x10\x0b\x12\x15\n\x11MX_DATA_TYPE_ENUM\x10\x0c\x12-\n)MX_DATA_TYPE_SECURITY_CLASSIFICATION_ENUM\x10\r\x12\"\n\x1eMX_DATA_TYPE_DATA_QUALITY_TYPE\x10\x0e\x12\x1f\n\x1bMX_DATA_TYPE_QUALIFIED_ENUM\x10\x0f\x12!\n\x1dMX_DATA_TYPE_QUALIFIED_STRUCT\x10\x10\x12)\n%MX_DATA_TYPE_INTERNATIONALIZED_STRING\x10\x11\x12\x1b\n\x17MX_DATA_TYPE_BIG_STRING\x10\x12\x12\x14\n\x10MX_DATA_TYPE_END\x10\x13*\xa3\x03\n\x12ProtocolStatusCode\x12$\n PROTOCOL_STATUS_CODE_UNSPECIFIED\x10\x00\x12\x1b\n\x17PROTOCOL_STATUS_CODE_OK\x10\x01\x12(\n$PROTOCOL_STATUS_CODE_INVALID_REQUEST\x10\x02\x12*\n&PROTOCOL_STATUS_CODE_SESSION_NOT_FOUND\x10\x03\x12*\n&PROTOCOL_STATUS_CODE_SESSION_NOT_READY\x10\x04\x12+\n\'PROTOCOL_STATUS_CODE_WORKER_UNAVAILABLE\x10\x05\x12 \n\x1cPROTOCOL_STATUS_CODE_TIMEOUT\x10\x06\x12!\n\x1dPROTOCOL_STATUS_CODE_CANCELED\x10\x07\x12+\n\'PROTOCOL_STATUS_CODE_PROTOCOL_VIOLATION\x10\x08\x12)\n%PROTOCOL_STATUS_CODE_MXACCESS_FAILURE\x10\t*\xbf\x02\n\x0cSessionState\x12\x1d\n\x19SESSION_STATE_UNSPECIFIED\x10\x00\x12\x1a\n\x16SESSION_STATE_CREATING\x10\x01\x12!\n\x1dSESSION_STATE_STARTING_WORKER\x10\x02\x12\"\n\x1eSESSION_STATE_WAITING_FOR_PIPE\x10\x03\x12\x1d\n\x19SESSION_STATE_HANDSHAKING\x10\x04\x12%\n!SESSION_STATE_INITIALIZING_WORKER\x10\x05\x12\x17\n\x13SESSION_STATE_READY\x10\x06\x12\x19\n\x15SESSION_STATE_CLOSING\x10\x07\x12\x18\n\x14SESSION_STATE_CLOSED\x10\x08\x12\x19\n\x15SESSION_STATE_FAULTED\x10\t2\xc3\x05\n\x0fMxAccessGateway\x12]\n\x0bOpenSession\x12\'.mxaccess_gateway.v1.OpenSessionRequest\x1a%.mxaccess_gateway.v1.OpenSessionReply\x12`\n\x0c\x43loseSession\x12(.mxaccess_gateway.v1.CloseSessionRequest\x1a&.mxaccess_gateway.v1.CloseSessionReply\x12T\n\x06Invoke\x12%.mxaccess_gateway.v1.MxCommandRequest\x1a#.mxaccess_gateway.v1.MxCommandReply\x12X\n\x0cStreamEvents\x12(.mxaccess_gateway.v1.StreamEventsRequest\x1a\x1c.mxaccess_gateway.v1.MxEvent0\x01\x12l\n\x10\x41\x63knowledgeAlarm\x12,.mxaccess_gateway.v1.AcknowledgeAlarmRequest\x1a*.mxaccess_gateway.v1.AcknowledgeAlarmReply\x12\x61\n\x0cStreamAlarms\x12(.mxaccess_gateway.v1.StreamAlarmsRequest\x1a%.mxaccess_gateway.v1.AlarmFeedMessage0\x01\x12n\n\x11QueryActiveAlarms\x12-.mxaccess_gateway.v1.QueryActiveAlarmsRequest\x1a(.mxaccess_gateway.v1.ActiveAlarmSnapshot0\x01\x42&\xaa\x02#ZB.MOM.WW.MxGateway.Contracts.Protob\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x16mxaccess_gateway.proto\x12\x13mxaccess_gateway.v1\x1a\x1egoogle/protobuf/duration.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"j\n\x18QueryActiveAlarmsRequest\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x1d\n\x15\x63lient_correlation_id\x18\x02 \x01(\t\x12\x1b\n\x13\x61larm_filter_prefix\x18\x03 \x01(\t\"\x9f\x01\n\x12OpenSessionRequest\x12\x19\n\x11requested_backend\x18\x01 \x01(\t\x12\x1b\n\x13\x63lient_session_name\x18\x02 \x01(\t\x12\x1d\n\x15\x63lient_correlation_id\x18\x03 \x01(\t\x12\x32\n\x0f\x63ommand_timeout\x18\x04 \x01(\x0b\x32\x19.google.protobuf.Duration\"\xaa\x02\n\x10OpenSessionReply\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x14\n\x0c\x62\x61\x63kend_name\x18\x02 \x01(\t\x12\x19\n\x11worker_process_id\x18\x03 \x01(\x05\x12\x1f\n\x17worker_protocol_version\x18\x04 \x01(\r\x12\x14\n\x0c\x63\x61pabilities\x18\x05 \x03(\t\x12:\n\x17\x64\x65\x66\x61ult_command_timeout\x18\x06 \x01(\x0b\x32\x19.google.protobuf.Duration\x12<\n\x0fprotocol_status\x18\x07 \x01(\x0b\x32#.mxaccess_gateway.v1.ProtocolStatus\x12 \n\x18gateway_protocol_version\x18\x08 \x01(\r\"H\n\x13\x43loseSessionRequest\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x1d\n\x15\x63lient_correlation_id\x18\x02 \x01(\t\"\x9d\x01\n\x11\x43loseSessionReply\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x36\n\x0b\x66inal_state\x18\x02 \x01(\x0e\x32!.mxaccess_gateway.v1.SessionState\x12<\n\x0fprotocol_status\x18\x03 \x01(\x0b\x32#.mxaccess_gateway.v1.ProtocolStatus\"H\n\x13StreamEventsRequest\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x1d\n\x15\x61\x66ter_worker_sequence\x18\x02 \x01(\x04\"v\n\x10MxCommandRequest\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x1d\n\x15\x63lient_correlation_id\x18\x02 \x01(\t\x12/\n\x07\x63ommand\x18\x03 \x01(\x0b\x32\x1e.mxaccess_gateway.v1.MxCommand\"\x95\x16\n\tMxCommand\x12\x30\n\x04kind\x18\x01 \x01(\x0e\x32\".mxaccess_gateway.v1.MxCommandKind\x12\x38\n\x08register\x18\n \x01(\x0b\x32$.mxaccess_gateway.v1.RegisterCommandH\x00\x12<\n\nunregister\x18\x0b \x01(\x0b\x32&.mxaccess_gateway.v1.UnregisterCommandH\x00\x12\x37\n\x08\x61\x64\x64_item\x18\x0c \x01(\x0b\x32#.mxaccess_gateway.v1.AddItemCommandH\x00\x12\x39\n\tadd_item2\x18\r \x01(\x0b\x32$.mxaccess_gateway.v1.AddItem2CommandH\x00\x12=\n\x0bremove_item\x18\x0e \x01(\x0b\x32&.mxaccess_gateway.v1.RemoveItemCommandH\x00\x12\x34\n\x06\x61\x64vise\x18\x0f \x01(\x0b\x32\".mxaccess_gateway.v1.AdviseCommandH\x00\x12\x39\n\tun_advise\x18\x10 \x01(\x0b\x32$.mxaccess_gateway.v1.UnAdviseCommandH\x00\x12K\n\x12\x61\x64vise_supervisory\x18\x11 \x01(\x0b\x32-.mxaccess_gateway.v1.AdviseSupervisoryCommandH\x00\x12H\n\x11\x61\x64\x64_buffered_item\x18\x12 \x01(\x0b\x32+.mxaccess_gateway.v1.AddBufferedItemCommandH\x00\x12]\n\x1cset_buffered_update_interval\x18\x13 \x01(\x0b\x32\x35.mxaccess_gateway.v1.SetBufferedUpdateIntervalCommandH\x00\x12\x36\n\x07suspend\x18\x14 \x01(\x0b\x32#.mxaccess_gateway.v1.SuspendCommandH\x00\x12\x38\n\x08\x61\x63tivate\x18\x15 \x01(\x0b\x32$.mxaccess_gateway.v1.ActivateCommandH\x00\x12\x32\n\x05write\x18\x16 \x01(\x0b\x32!.mxaccess_gateway.v1.WriteCommandH\x00\x12\x34\n\x06write2\x18\x17 \x01(\x0b\x32\".mxaccess_gateway.v1.Write2CommandH\x00\x12\x41\n\rwrite_secured\x18\x18 \x01(\x0b\x32(.mxaccess_gateway.v1.WriteSecuredCommandH\x00\x12\x43\n\x0ewrite_secured2\x18\x19 \x01(\x0b\x32).mxaccess_gateway.v1.WriteSecured2CommandH\x00\x12I\n\x11\x61uthenticate_user\x18\x1a \x01(\x0b\x32,.mxaccess_gateway.v1.AuthenticateUserCommandH\x00\x12M\n\x14\x61rchestra_user_to_id\x18\x1b \x01(\x0b\x32-.mxaccess_gateway.v1.ArchestrAUserToIdCommandH\x00\x12@\n\radd_item_bulk\x18\x1c \x01(\x0b\x32\'.mxaccess_gateway.v1.AddItemBulkCommandH\x00\x12\x46\n\x10\x61\x64vise_item_bulk\x18\x1d \x01(\x0b\x32*.mxaccess_gateway.v1.AdviseItemBulkCommandH\x00\x12\x46\n\x10remove_item_bulk\x18\x1e \x01(\x0b\x32*.mxaccess_gateway.v1.RemoveItemBulkCommandH\x00\x12K\n\x13un_advise_item_bulk\x18\x1f \x01(\x0b\x32,.mxaccess_gateway.v1.UnAdviseItemBulkCommandH\x00\x12\x43\n\x0esubscribe_bulk\x18 \x01(\x0b\x32).mxaccess_gateway.v1.SubscribeBulkCommandH\x00\x12G\n\x10unsubscribe_bulk\x18! \x01(\x0b\x32+.mxaccess_gateway.v1.UnsubscribeBulkCommandH\x00\x12G\n\x10subscribe_alarms\x18\" \x01(\x0b\x32+.mxaccess_gateway.v1.SubscribeAlarmsCommandH\x00\x12K\n\x12unsubscribe_alarms\x18# \x01(\x0b\x32-.mxaccess_gateway.v1.UnsubscribeAlarmsCommandH\x00\x12Q\n\x19\x61\x63knowledge_alarm_command\x18$ \x01(\x0b\x32,.mxaccess_gateway.v1.AcknowledgeAlarmCommandH\x00\x12T\n\x1bquery_active_alarms_command\x18% \x01(\x0b\x32-.mxaccess_gateway.v1.QueryActiveAlarmsCommandH\x00\x12_\n!acknowledge_alarm_by_name_command\x18& \x01(\x0b\x32\x32.mxaccess_gateway.v1.AcknowledgeAlarmByNameCommandH\x00\x12;\n\nwrite_bulk\x18\' \x01(\x0b\x32%.mxaccess_gateway.v1.WriteBulkCommandH\x00\x12=\n\x0bwrite2_bulk\x18( \x01(\x0b\x32&.mxaccess_gateway.v1.Write2BulkCommandH\x00\x12J\n\x12write_secured_bulk\x18) \x01(\x0b\x32,.mxaccess_gateway.v1.WriteSecuredBulkCommandH\x00\x12L\n\x13write_secured2_bulk\x18* \x01(\x0b\x32-.mxaccess_gateway.v1.WriteSecured2BulkCommandH\x00\x12\x39\n\tread_bulk\x18+ \x01(\x0b\x32$.mxaccess_gateway.v1.ReadBulkCommandH\x00\x12S\n\x17update_alarm_watch_list\x18, \x01(\x0b\x32\x30.mxaccess_gateway.v1.UpdateAlarmWatchListCommandH\x00\x12\x30\n\x04ping\x18\x64 \x01(\x0b\x32 .mxaccess_gateway.v1.PingCommandH\x00\x12H\n\x11get_session_state\x18\x65 \x01(\x0b\x32+.mxaccess_gateway.v1.GetSessionStateCommandH\x00\x12\x44\n\x0fget_worker_info\x18\x66 \x01(\x0b\x32).mxaccess_gateway.v1.GetWorkerInfoCommandH\x00\x12?\n\x0c\x64rain_events\x18g \x01(\x0b\x32\'.mxaccess_gateway.v1.DrainEventsCommandH\x00\x12\x45\n\x0fshutdown_worker\x18h \x01(\x0b\x32*.mxaccess_gateway.v1.ShutdownWorkerCommandH\x00\x42\t\n\x07payload\"&\n\x0fRegisterCommand\x12\x13\n\x0b\x63lient_name\x18\x01 \x01(\t\"*\n\x11UnregisterCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\"@\n\x0e\x41\x64\x64ItemCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x17\n\x0fitem_definition\x18\x02 \x01(\t\"W\n\x0f\x41\x64\x64Item2Command\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x17\n\x0fitem_definition\x18\x02 \x01(\t\x12\x14\n\x0citem_context\x18\x03 \x01(\t\"?\n\x11RemoveItemCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x13\n\x0bitem_handle\x18\x02 \x01(\x05\";\n\rAdviseCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x13\n\x0bitem_handle\x18\x02 \x01(\x05\"=\n\x0fUnAdviseCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x13\n\x0bitem_handle\x18\x02 \x01(\x05\"F\n\x18\x41\x64viseSupervisoryCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x13\n\x0bitem_handle\x18\x02 \x01(\x05\"^\n\x16\x41\x64\x64\x42ufferedItemCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x17\n\x0fitem_definition\x18\x02 \x01(\t\x12\x14\n\x0citem_context\x18\x03 \x01(\t\"_\n SetBufferedUpdateIntervalCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12$\n\x1cupdate_interval_milliseconds\x18\x02 \x01(\x05\"<\n\x0eSuspendCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x13\n\x0bitem_handle\x18\x02 \x01(\x05\"=\n\x0f\x41\x63tivateCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x13\n\x0bitem_handle\x18\x02 \x01(\x05\"x\n\x0cWriteCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x13\n\x0bitem_handle\x18\x02 \x01(\x05\x12+\n\x05value\x18\x03 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x0f\n\x07user_id\x18\x04 \x01(\x05\"\xb0\x01\n\rWrite2Command\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x13\n\x0bitem_handle\x18\x02 \x01(\x05\x12+\n\x05value\x18\x03 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x35\n\x0ftimestamp_value\x18\x04 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x0f\n\x07user_id\x18\x05 \x01(\x05\"\xa1\x01\n\x13WriteSecuredCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x13\n\x0bitem_handle\x18\x02 \x01(\x05\x12\x17\n\x0f\x63urrent_user_id\x18\x03 \x01(\x05\x12\x18\n\x10verifier_user_id\x18\x04 \x01(\x05\x12+\n\x05value\x18\x05 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\"\xd9\x01\n\x14WriteSecured2Command\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x13\n\x0bitem_handle\x18\x02 \x01(\x05\x12\x17\n\x0f\x63urrent_user_id\x18\x03 \x01(\x05\x12\x18\n\x10verifier_user_id\x18\x04 \x01(\x05\x12+\n\x05value\x18\x05 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x35\n\x0ftimestamp_value\x18\x06 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\"c\n\x17\x41uthenticateUserCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x13\n\x0bverify_user\x18\x02 \x01(\t\x12\x1c\n\x14verify_user_password\x18\x03 \x01(\t\"G\n\x18\x41rchestrAUserToIdCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x14\n\x0cuser_id_guid\x18\x02 \x01(\t\"B\n\x12\x41\x64\x64ItemBulkCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x15\n\rtag_addresses\x18\x02 \x03(\t\"D\n\x15\x41\x64viseItemBulkCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x14\n\x0citem_handles\x18\x02 \x03(\x05\"D\n\x15RemoveItemBulkCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x14\n\x0citem_handles\x18\x02 \x03(\x05\"F\n\x17UnAdviseItemBulkCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x14\n\x0citem_handles\x18\x02 \x03(\x05\"D\n\x14SubscribeBulkCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x15\n\rtag_addresses\x18\x02 \x03(\t\"\x88\x02\n\x16SubscribeAlarmsCommand\x12\x1f\n\x17subscription_expression\x18\x01 \x01(\t\x12;\n\x0b\x66orced_mode\x18\x02 \x01(\x0e\x32&.mxaccess_gateway.v1.AlarmProviderMode\x12:\n\nwatch_list\x18\x03 \x03(\x0b\x32&.mxaccess_gateway.v1.AlarmSubtagTarget\x12:\n\x08\x66\x61ilover\x18\x04 \x01(\x0b\x32(.mxaccess_gateway.v1.AlarmFailoverConfig\x12\x18\n\x10\x66\x61llback_enabled\x18\x05 \x01(\x08\"Y\n\x1bUpdateAlarmWatchListCommand\x12:\n\nwatch_list\x18\x01 \x03(\x0b\x32&.mxaccess_gateway.v1.AlarmSubtagTarget\"\x1a\n\x18UnsubscribeAlarmsCommand\"\xb4\x01\n\x11\x41larmSubtagTarget\x12\x1c\n\x14\x61larm_full_reference\x18\x01 \x01(\t\x12\x1f\n\x17source_object_reference\x18\x02 \x01(\t\x12\x15\n\ractive_subtag\x18\x03 \x01(\t\x12\x14\n\x0c\x61\x63ked_subtag\x18\x04 \x01(\t\x12\x1a\n\x12\x61\x63k_comment_subtag\x18\x05 \x01(\t\x12\x17\n\x0fpriority_subtag\x18\x06 \x01(\t\"\x85\x01\n\x13\x41larmFailoverConfig\x12%\n\x1d\x63onsecutive_failure_threshold\x18\x01 \x01(\x05\x12\'\n\x1f\x66\x61ilback_probe_interval_seconds\x18\x02 \x01(\x05\x12\x1e\n\x16\x66\x61ilback_stable_probes\x18\x03 \x01(\x05\"\xbc\x01\n\x17\x41\x63knowledgeAlarmCommand\x12\x12\n\nalarm_guid\x18\x01 \x01(\t\x12\x0f\n\x07\x63omment\x18\x02 \x01(\t\x12\x15\n\roperator_user\x18\x03 \x01(\t\x12\x15\n\roperator_node\x18\x04 \x01(\t\x12\x17\n\x0foperator_domain\x18\x05 \x01(\t\x12\x1a\n\x12operator_full_name\x18\x06 \x01(\t\x12\x19\n\x11operator_password\x18\x07 \x01(\t\"7\n\x18QueryActiveAlarmsCommand\x12\x1b\n\x13\x61larm_filter_prefix\x18\x01 \x01(\t\"\xed\x01\n\x1d\x41\x63knowledgeAlarmByNameCommand\x12\x12\n\nalarm_name\x18\x01 \x01(\t\x12\x15\n\rprovider_name\x18\x02 \x01(\t\x12\x12\n\ngroup_name\x18\x03 \x01(\t\x12\x0f\n\x07\x63omment\x18\x04 \x01(\t\x12\x15\n\roperator_user\x18\x05 \x01(\t\x12\x15\n\roperator_node\x18\x06 \x01(\t\x12\x17\n\x0foperator_domain\x18\x07 \x01(\t\x12\x1a\n\x12operator_full_name\x18\x08 \x01(\t\x12\x19\n\x11operator_password\x18\t \x01(\t\"E\n\x16UnsubscribeBulkCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x14\n\x0citem_handles\x18\x02 \x03(\x05\"_\n\x10WriteBulkCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x34\n\x07\x65ntries\x18\x02 \x03(\x0b\x32#.mxaccess_gateway.v1.WriteBulkEntry\"c\n\x0eWriteBulkEntry\x12\x13\n\x0bitem_handle\x18\x01 \x01(\x05\x12+\n\x05value\x18\x02 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x0f\n\x07user_id\x18\x03 \x01(\x05\"a\n\x11Write2BulkCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x35\n\x07\x65ntries\x18\x02 \x03(\x0b\x32$.mxaccess_gateway.v1.Write2BulkEntry\"\x9b\x01\n\x0fWrite2BulkEntry\x12\x13\n\x0bitem_handle\x18\x01 \x01(\x05\x12+\n\x05value\x18\x02 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x35\n\x0ftimestamp_value\x18\x03 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x0f\n\x07user_id\x18\x04 \x01(\x05\"m\n\x17WriteSecuredBulkCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12;\n\x07\x65ntries\x18\x02 \x03(\x0b\x32*.mxaccess_gateway.v1.WriteSecuredBulkEntry\"\x8c\x01\n\x15WriteSecuredBulkEntry\x12\x13\n\x0bitem_handle\x18\x01 \x01(\x05\x12\x17\n\x0f\x63urrent_user_id\x18\x02 \x01(\x05\x12\x18\n\x10verifier_user_id\x18\x03 \x01(\x05\x12+\n\x05value\x18\x04 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\"o\n\x18WriteSecured2BulkCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12<\n\x07\x65ntries\x18\x02 \x03(\x0b\x32+.mxaccess_gateway.v1.WriteSecured2BulkEntry\"\xc4\x01\n\x16WriteSecured2BulkEntry\x12\x13\n\x0bitem_handle\x18\x01 \x01(\x05\x12\x17\n\x0f\x63urrent_user_id\x18\x02 \x01(\x05\x12\x18\n\x10verifier_user_id\x18\x03 \x01(\x05\x12+\n\x05value\x18\x04 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x35\n\x0ftimestamp_value\x18\x05 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\"S\n\x0fReadBulkCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x15\n\rtag_addresses\x18\x02 \x03(\t\x12\x12\n\ntimeout_ms\x18\x03 \x01(\r\"\x1e\n\x0bPingCommand\x12\x0f\n\x07message\x18\x01 \x01(\t\"\x18\n\x16GetSessionStateCommand\"\x16\n\x14GetWorkerInfoCommand\"(\n\x12\x44rainEventsCommand\x12\x12\n\nmax_events\x18\x01 \x01(\r\"H\n\x15ShutdownWorkerCommand\x12/\n\x0cgrace_period\x18\x01 \x01(\x0b\x32\x19.google.protobuf.Duration\"\x86\x0f\n\x0eMxCommandReply\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x16\n\x0e\x63orrelation_id\x18\x02 \x01(\t\x12\x30\n\x04kind\x18\x03 \x01(\x0e\x32\".mxaccess_gateway.v1.MxCommandKind\x12<\n\x0fprotocol_status\x18\x04 \x01(\x0b\x32#.mxaccess_gateway.v1.ProtocolStatus\x12\x14\n\x07hresult\x18\x05 \x01(\x05H\x01\x88\x01\x01\x12\x32\n\x0creturn_value\x18\x06 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x34\n\x08statuses\x18\x07 \x03(\x0b\x32\".mxaccess_gateway.v1.MxStatusProxy\x12\x1a\n\x12\x64iagnostic_message\x18\x08 \x01(\t\x12\x36\n\x08register\x18\x14 \x01(\x0b\x32\".mxaccess_gateway.v1.RegisterReplyH\x00\x12\x35\n\x08\x61\x64\x64_item\x18\x15 \x01(\x0b\x32!.mxaccess_gateway.v1.AddItemReplyH\x00\x12\x37\n\tadd_item2\x18\x16 \x01(\x0b\x32\".mxaccess_gateway.v1.AddItem2ReplyH\x00\x12\x46\n\x11\x61\x64\x64_buffered_item\x18\x17 \x01(\x0b\x32).mxaccess_gateway.v1.AddBufferedItemReplyH\x00\x12\x34\n\x07suspend\x18\x18 \x01(\x0b\x32!.mxaccess_gateway.v1.SuspendReplyH\x00\x12\x36\n\x08\x61\x63tivate\x18\x19 \x01(\x0b\x32\".mxaccess_gateway.v1.ActivateReplyH\x00\x12G\n\x11\x61uthenticate_user\x18\x1a \x01(\x0b\x32*.mxaccess_gateway.v1.AuthenticateUserReplyH\x00\x12K\n\x14\x61rchestra_user_to_id\x18\x1b \x01(\x0b\x32+.mxaccess_gateway.v1.ArchestrAUserToIdReplyH\x00\x12@\n\radd_item_bulk\x18\x1c \x01(\x0b\x32\'.mxaccess_gateway.v1.BulkSubscribeReplyH\x00\x12\x43\n\x10\x61\x64vise_item_bulk\x18\x1d \x01(\x0b\x32\'.mxaccess_gateway.v1.BulkSubscribeReplyH\x00\x12\x43\n\x10remove_item_bulk\x18\x1e \x01(\x0b\x32\'.mxaccess_gateway.v1.BulkSubscribeReplyH\x00\x12\x46\n\x13un_advise_item_bulk\x18\x1f \x01(\x0b\x32\'.mxaccess_gateway.v1.BulkSubscribeReplyH\x00\x12\x41\n\x0esubscribe_bulk\x18 \x01(\x0b\x32\'.mxaccess_gateway.v1.BulkSubscribeReplyH\x00\x12\x43\n\x10unsubscribe_bulk\x18! \x01(\x0b\x32\'.mxaccess_gateway.v1.BulkSubscribeReplyH\x00\x12N\n\x11\x61\x63knowledge_alarm\x18\" \x01(\x0b\x32\x31.mxaccess_gateway.v1.AcknowledgeAlarmReplyPayloadH\x00\x12Q\n\x13query_active_alarms\x18# \x01(\x0b\x32\x32.mxaccess_gateway.v1.QueryActiveAlarmsReplyPayloadH\x00\x12\x39\n\nwrite_bulk\x18$ \x01(\x0b\x32#.mxaccess_gateway.v1.BulkWriteReplyH\x00\x12:\n\x0bwrite2_bulk\x18% \x01(\x0b\x32#.mxaccess_gateway.v1.BulkWriteReplyH\x00\x12\x41\n\x12write_secured_bulk\x18& \x01(\x0b\x32#.mxaccess_gateway.v1.BulkWriteReplyH\x00\x12\x42\n\x13write_secured2_bulk\x18\' \x01(\x0b\x32#.mxaccess_gateway.v1.BulkWriteReplyH\x00\x12\x37\n\tread_bulk\x18( \x01(\x0b\x32\".mxaccess_gateway.v1.BulkReadReplyH\x00\x12?\n\rsession_state\x18\x64 \x01(\x0b\x32&.mxaccess_gateway.v1.SessionStateReplyH\x00\x12;\n\x0bworker_info\x18\x65 \x01(\x0b\x32$.mxaccess_gateway.v1.WorkerInfoReplyH\x00\x12=\n\x0c\x64rain_events\x18\x66 \x01(\x0b\x32%.mxaccess_gateway.v1.DrainEventsReplyH\x00\x42\t\n\x07payloadB\n\n\x08_hresult\"&\n\rRegisterReply\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\"#\n\x0c\x41\x64\x64ItemReply\x12\x13\n\x0bitem_handle\x18\x01 \x01(\x05\"$\n\rAddItem2Reply\x12\x13\n\x0bitem_handle\x18\x01 \x01(\x05\"+\n\x14\x41\x64\x64\x42ufferedItemReply\x12\x13\n\x0bitem_handle\x18\x01 \x01(\x05\"B\n\x0cSuspendReply\x12\x32\n\x06status\x18\x01 \x01(\x0b\x32\".mxaccess_gateway.v1.MxStatusProxy\"C\n\rActivateReply\x12\x32\n\x06status\x18\x01 \x01(\x0b\x32\".mxaccess_gateway.v1.MxStatusProxy\"(\n\x15\x41uthenticateUserReply\x12\x0f\n\x07user_id\x18\x01 \x01(\x05\")\n\x16\x41rchestrAUserToIdReply\x12\x0f\n\x07user_id\x18\x01 \x01(\x05\"\x81\x01\n\x0fSubscribeResult\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x13\n\x0btag_address\x18\x02 \x01(\t\x12\x13\n\x0bitem_handle\x18\x03 \x01(\x05\x12\x16\n\x0ewas_successful\x18\x04 \x01(\x08\x12\x15\n\rerror_message\x18\x05 \x01(\t\"K\n\x12\x42ulkSubscribeReply\x12\x35\n\x07results\x18\x01 \x03(\x0b\x32$.mxaccess_gateway.v1.SubscribeResult\"\xc4\x01\n\x0f\x42ulkWriteResult\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x13\n\x0bitem_handle\x18\x02 \x01(\x05\x12\x16\n\x0ewas_successful\x18\x03 \x01(\x08\x12\x14\n\x07hresult\x18\x04 \x01(\x05H\x00\x88\x01\x01\x12\x34\n\x08statuses\x18\x05 \x03(\x0b\x32\".mxaccess_gateway.v1.MxStatusProxy\x12\x15\n\rerror_message\x18\x06 \x01(\tB\n\n\x08_hresult\"G\n\x0e\x42ulkWriteReply\x12\x35\n\x07results\x18\x01 \x03(\x0b\x32$.mxaccess_gateway.v1.BulkWriteResult\"\xbe\x02\n\x0e\x42ulkReadResult\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x13\n\x0btag_address\x18\x02 \x01(\t\x12\x13\n\x0bitem_handle\x18\x03 \x01(\x05\x12\x16\n\x0ewas_successful\x18\x04 \x01(\x08\x12\x12\n\nwas_cached\x18\x05 \x01(\x08\x12+\n\x05value\x18\x06 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x0f\n\x07quality\x18\x07 \x01(\x05\x12\x34\n\x10source_timestamp\x18\x08 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x34\n\x08statuses\x18\t \x03(\x0b\x32\".mxaccess_gateway.v1.MxStatusProxy\x12\x15\n\rerror_message\x18\n \x01(\t\"E\n\rBulkReadReply\x12\x34\n\x07results\x18\x01 \x03(\x0b\x32#.mxaccess_gateway.v1.BulkReadResult\"E\n\x11SessionStateReply\x12\x30\n\x05state\x18\x01 \x01(\x0e\x32!.mxaccess_gateway.v1.SessionState\"u\n\x0fWorkerInfoReply\x12\x19\n\x11worker_process_id\x18\x01 \x01(\x05\x12\x16\n\x0eworker_version\x18\x02 \x01(\t\x12\x17\n\x0fmxaccess_progid\x18\x03 \x01(\t\x12\x16\n\x0emxaccess_clsid\x18\x04 \x01(\t\"@\n\x10\x44rainEventsReply\x12,\n\x06\x65vents\x18\x01 \x03(\x0b\x32\x1c.mxaccess_gateway.v1.MxEvent\"5\n\x1c\x41\x63knowledgeAlarmReplyPayload\x12\x15\n\rnative_status\x18\x01 \x01(\x05\"x\n\x1dQueryActiveAlarmsReplyPayload\x12;\n\tsnapshots\x18\x01 \x03(\x0b\x32(.mxaccess_gateway.v1.ActiveAlarmSnapshot\x12\x1a\n\x12snapshot_truncated\x18\x02 \x01(\x08\"\x8f\x08\n\x07MxEvent\x12\x32\n\x06\x66\x61mily\x18\x01 \x01(\x0e\x32\".mxaccess_gateway.v1.MxEventFamily\x12\x12\n\nsession_id\x18\x02 \x01(\t\x12\x15\n\rserver_handle\x18\x03 \x01(\x05\x12\x13\n\x0bitem_handle\x18\x04 \x01(\x05\x12+\n\x05value\x18\x05 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x0f\n\x07quality\x18\x06 \x01(\x05\x12\x34\n\x10source_timestamp\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x34\n\x08statuses\x18\x08 \x03(\x0b\x32\".mxaccess_gateway.v1.MxStatusProxy\x12\x17\n\x0fworker_sequence\x18\t \x01(\x04\x12\x34\n\x10worker_timestamp\x18\n \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12=\n\x19gateway_receive_timestamp\x18\x0b \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x14\n\x07hresult\x18\x0c \x01(\x05H\x01\x88\x01\x01\x12\x12\n\nraw_status\x18\r \x01(\t\x12\x37\n\nreplay_gap\x18\x0e \x01(\x0b\x32\x1e.mxaccess_gateway.v1.ReplayGapH\x02\x88\x01\x01\x12@\n\x0eon_data_change\x18\x14 \x01(\x0b\x32&.mxaccess_gateway.v1.OnDataChangeEventH\x00\x12\x46\n\x11on_write_complete\x18\x15 \x01(\x0b\x32).mxaccess_gateway.v1.OnWriteCompleteEventH\x00\x12I\n\x12operation_complete\x18\x16 \x01(\x0b\x32+.mxaccess_gateway.v1.OperationCompleteEventH\x00\x12Q\n\x17on_buffered_data_change\x18\x17 \x01(\x0b\x32..mxaccess_gateway.v1.OnBufferedDataChangeEventH\x00\x12J\n\x13on_alarm_transition\x18\x18 \x01(\x0b\x32+.mxaccess_gateway.v1.OnAlarmTransitionEventH\x00\x12^\n\x1eon_alarm_provider_mode_changed\x18\x19 \x01(\x0b\x32\x34.mxaccess_gateway.v1.OnAlarmProviderModeChangedEventH\x00\x42\x06\n\x04\x62odyB\n\n\x08_hresultB\r\n\x0b_replay_gap\"P\n\tReplayGap\x12 \n\x18requested_after_sequence\x18\x01 \x01(\x04\x12!\n\x19oldest_available_sequence\x18\x02 \x01(\x04\"\x13\n\x11OnDataChangeEvent\"\x16\n\x14OnWriteCompleteEvent\"\x18\n\x16OperationCompleteEvent\"\xd4\x01\n\x19OnBufferedDataChangeEvent\x12\x32\n\tdata_type\x18\x01 \x01(\x0e\x32\x1f.mxaccess_gateway.v1.MxDataType\x12\x34\n\x0equality_values\x18\x02 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxArray\x12\x36\n\x10timestamp_values\x18\x03 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxArray\x12\x15\n\rraw_data_type\x18\x04 \x01(\x05\"\xd0\x04\n\x16OnAlarmTransitionEvent\x12\x1c\n\x14\x61larm_full_reference\x18\x01 \x01(\t\x12\x1f\n\x17source_object_reference\x18\x02 \x01(\t\x12\x17\n\x0f\x61larm_type_name\x18\x03 \x01(\t\x12\x41\n\x0ftransition_kind\x18\x04 \x01(\x0e\x32(.mxaccess_gateway.v1.AlarmTransitionKind\x12\x10\n\x08severity\x18\x05 \x01(\x05\x12<\n\x18original_raise_timestamp\x18\x06 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x38\n\x14transition_timestamp\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x15\n\roperator_user\x18\x08 \x01(\t\x12\x18\n\x10operator_comment\x18\t \x01(\t\x12\x10\n\x08\x63\x61tegory\x18\n \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x0b \x01(\t\x12\x33\n\rcurrent_value\x18\x0c \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x31\n\x0blimit_value\x18\r \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x10\n\x08\x64\x65graded\x18\x0e \x01(\x08\x12?\n\x0fsource_provider\x18\x0f \x01(\x0e\x32&.mxaccess_gateway.v1.AlarmProviderMode\"\xa0\x01\n\x1fOnAlarmProviderModeChangedEvent\x12\x34\n\x04mode\x18\x01 \x01(\x0e\x32&.mxaccess_gateway.v1.AlarmProviderMode\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x0f\n\x07hresult\x18\x03 \x01(\x05\x12&\n\x02\x61t\x18\x04 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xf1\x04\n\x13\x41\x63tiveAlarmSnapshot\x12\x1c\n\x14\x61larm_full_reference\x18\x01 \x01(\t\x12\x1f\n\x17source_object_reference\x18\x02 \x01(\t\x12\x17\n\x0f\x61larm_type_name\x18\x03 \x01(\t\x12\x10\n\x08severity\x18\x04 \x01(\x05\x12<\n\x18original_raise_timestamp\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12?\n\rcurrent_state\x18\x06 \x01(\x0e\x32(.mxaccess_gateway.v1.AlarmConditionState\x12\x10\n\x08\x63\x61tegory\x18\x07 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x08 \x01(\t\x12=\n\x19last_transition_timestamp\x18\t \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x15\n\roperator_user\x18\n \x01(\t\x12\x18\n\x10operator_comment\x18\x0b \x01(\t\x12\x33\n\rcurrent_value\x18\x0c \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x31\n\x0blimit_value\x18\r \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x10\n\x08\x64\x65graded\x18\x0e \x01(\x08\x12?\n\x0fsource_provider\x18\x0f \x01(\x0e\x32&.mxaccess_gateway.v1.AlarmProviderMode\x12\x1f\n\x17\x66rom_truncated_snapshot\x18\x10 \x01(\x08\"\xc4\x01\n\x17\x41\x63knowledgeAlarmRequest\x12\x1d\n\x15\x63lient_correlation_id\x18\x02 \x01(\t\x12\x1c\n\x14\x61larm_full_reference\x18\x03 \x01(\t\x12\x0f\n\x07\x63omment\x18\x04 \x01(\t\x12\x15\n\roperator_user\x18\x05 \x01(\t\x12\x19\n\x11operator_password\x18\x06 \x01(\t\x12\x17\n\x0foperator_domain\x18\x07 \x01(\tJ\x04\x08\x01\x10\x02R\nsession_id\"\xf1\x01\n\x15\x41\x63knowledgeAlarmReply\x12\x16\n\x0e\x63orrelation_id\x18\x02 \x01(\t\x12<\n\x0fprotocol_status\x18\x03 \x01(\x0b\x32#.mxaccess_gateway.v1.ProtocolStatus\x12\x14\n\x07hresult\x18\x04 \x01(\x05H\x00\x88\x01\x01\x12\x32\n\x06status\x18\x05 \x01(\x0b\x32\".mxaccess_gateway.v1.MxStatusProxy\x12\x1a\n\x12\x64iagnostic_message\x18\x06 \x01(\tB\n\n\x08_hresultJ\x04\x08\x01\x10\x02R\nsession_id\"Q\n\x13StreamAlarmsRequest\x12\x1d\n\x15\x63lient_correlation_id\x18\x01 \x01(\t\x12\x1b\n\x13\x61larm_filter_prefix\x18\x02 \x01(\t\"\xc9\x02\n\x10\x41larmFeedMessage\x12@\n\x0c\x61\x63tive_alarm\x18\x01 \x01(\x0b\x32(.mxaccess_gateway.v1.ActiveAlarmSnapshotH\x00\x12\x1b\n\x11snapshot_complete\x18\x02 \x01(\x08H\x00\x12\x41\n\ntransition\x18\x03 \x01(\x0b\x32+.mxaccess_gateway.v1.OnAlarmTransitionEventH\x00\x12\x43\n\x0fprovider_status\x18\x04 \x01(\x0b\x32(.mxaccess_gateway.v1.AlarmProviderStatusH\x00\x12\x43\n\x0fsnapshot_status\x18\x05 \x01(\x0b\x32(.mxaccess_gateway.v1.AlarmSnapshotStatusH\x00\x42\t\n\x07payload\"\x98\x01\n\x13\x41larmProviderStatus\x12\x34\n\x04mode\x18\x01 \x01(\x0e\x32&.mxaccess_gateway.v1.AlarmProviderMode\x12\x10\n\x08\x64\x65graded\x18\x02 \x01(\x08\x12\x0e\n\x06reason\x18\x03 \x01(\t\x12)\n\x05since\x18\x04 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"(\n\x13\x41larmSnapshotStatus\x12\x11\n\ttruncated\x18\x01 \x01(\x08\"\xeb\x01\n\rMxStatusProxy\x12\x0f\n\x07success\x18\x01 \x01(\x05\x12\x37\n\x08\x63\x61tegory\x18\x02 \x01(\x0e\x32%.mxaccess_gateway.v1.MxStatusCategory\x12\x38\n\x0b\x64\x65tected_by\x18\x03 \x01(\x0e\x32#.mxaccess_gateway.v1.MxStatusSource\x12\x0e\n\x06\x64\x65tail\x18\x04 \x01(\x05\x12\x14\n\x0craw_category\x18\x05 \x01(\x05\x12\x17\n\x0fraw_detected_by\x18\x06 \x01(\x05\x12\x17\n\x0f\x64iagnostic_text\x18\x07 \x01(\t\"\xe9\x03\n\x07MxValue\x12\x32\n\tdata_type\x18\x01 \x01(\x0e\x32\x1f.mxaccess_gateway.v1.MxDataType\x12\x14\n\x0cvariant_type\x18\x02 \x01(\t\x12\x0f\n\x07is_null\x18\x03 \x01(\x08\x12\x16\n\x0eraw_diagnostic\x18\x04 \x01(\t\x12\x15\n\rraw_data_type\x18\x05 \x01(\x05\x12\x14\n\nbool_value\x18\n \x01(\x08H\x00\x12\x15\n\x0bint32_value\x18\x0b \x01(\x05H\x00\x12\x15\n\x0bint64_value\x18\x0c \x01(\x03H\x00\x12\x15\n\x0b\x66loat_value\x18\r \x01(\x02H\x00\x12\x16\n\x0c\x64ouble_value\x18\x0e \x01(\x01H\x00\x12\x16\n\x0cstring_value\x18\x0f \x01(\tH\x00\x12\x35\n\x0ftimestamp_value\x18\x10 \x01(\x0b\x32\x1a.google.protobuf.TimestampH\x00\x12\x33\n\x0b\x61rray_value\x18\x11 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxArrayH\x00\x12\x13\n\traw_value\x18\x12 \x01(\x0cH\x00\x12@\n\x12sparse_array_value\x18\x13 \x01(\x0b\x32\".mxaccess_gateway.v1.MxSparseArrayH\x00\x42\x06\n\x04kind\"\xfe\x04\n\x07MxArray\x12:\n\x11\x65lement_data_type\x18\x01 \x01(\x0e\x32\x1f.mxaccess_gateway.v1.MxDataType\x12\x14\n\x0cvariant_type\x18\x02 \x01(\t\x12\x12\n\ndimensions\x18\x03 \x03(\r\x12\x16\n\x0eraw_diagnostic\x18\x04 \x01(\t\x12\x1d\n\x15raw_element_data_type\x18\x05 \x01(\x05\x12\x35\n\x0b\x62ool_values\x18\n \x01(\x0b\x32\x1e.mxaccess_gateway.v1.BoolArrayH\x00\x12\x37\n\x0cint32_values\x18\x0b \x01(\x0b\x32\x1f.mxaccess_gateway.v1.Int32ArrayH\x00\x12\x37\n\x0cint64_values\x18\x0c \x01(\x0b\x32\x1f.mxaccess_gateway.v1.Int64ArrayH\x00\x12\x37\n\x0c\x66loat_values\x18\r \x01(\x0b\x32\x1f.mxaccess_gateway.v1.FloatArrayH\x00\x12\x39\n\rdouble_values\x18\x0e \x01(\x0b\x32 .mxaccess_gateway.v1.DoubleArrayH\x00\x12\x39\n\rstring_values\x18\x0f \x01(\x0b\x32 .mxaccess_gateway.v1.StringArrayH\x00\x12?\n\x10timestamp_values\x18\x10 \x01(\x0b\x32#.mxaccess_gateway.v1.TimestampArrayH\x00\x12\x33\n\nraw_values\x18\x11 \x01(\x0b\x32\x1d.mxaccess_gateway.v1.RawArrayH\x00\x42\x08\n\x06values\"\x99\x01\n\rMxSparseArray\x12:\n\x11\x65lement_data_type\x18\x01 \x01(\x0e\x32\x1f.mxaccess_gateway.v1.MxDataType\x12\x14\n\x0ctotal_length\x18\x02 \x01(\r\x12\x36\n\x08\x65lements\x18\x03 \x03(\x0b\x32$.mxaccess_gateway.v1.MxSparseElement\"M\n\x0fMxSparseElement\x12\r\n\x05index\x18\x01 \x01(\r\x12+\n\x05value\x18\x02 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\"\x1b\n\tBoolArray\x12\x0e\n\x06values\x18\x01 \x03(\x08\"\x1c\n\nInt32Array\x12\x0e\n\x06values\x18\x01 \x03(\x05\"\x1c\n\nInt64Array\x12\x0e\n\x06values\x18\x01 \x03(\x03\"\x1c\n\nFloatArray\x12\x0e\n\x06values\x18\x01 \x03(\x02\"\x1d\n\x0b\x44oubleArray\x12\x0e\n\x06values\x18\x01 \x03(\x01\"\x1d\n\x0bStringArray\x12\x0e\n\x06values\x18\x01 \x03(\t\"<\n\x0eTimestampArray\x12*\n\x06values\x18\x01 \x03(\x0b\x32\x1a.google.protobuf.Timestamp\"\x1a\n\x08RawArray\x12\x0e\n\x06values\x18\x01 \x03(\x0c\"X\n\x0eProtocolStatus\x12\x35\n\x04\x63ode\x18\x01 \x01(\x0e\x32\'.mxaccess_gateway.v1.ProtocolStatusCode\x12\x0f\n\x07message\x18\x02 \x01(\t*\xcc\x0b\n\rMxCommandKind\x12\x1f\n\x1bMX_COMMAND_KIND_UNSPECIFIED\x10\x00\x12\x1c\n\x18MX_COMMAND_KIND_REGISTER\x10\x01\x12\x1e\n\x1aMX_COMMAND_KIND_UNREGISTER\x10\x02\x12\x1c\n\x18MX_COMMAND_KIND_ADD_ITEM\x10\x03\x12\x1d\n\x19MX_COMMAND_KIND_ADD_ITEM2\x10\x04\x12\x1f\n\x1bMX_COMMAND_KIND_REMOVE_ITEM\x10\x05\x12\x1a\n\x16MX_COMMAND_KIND_ADVISE\x10\x06\x12\x1d\n\x19MX_COMMAND_KIND_UN_ADVISE\x10\x07\x12&\n\"MX_COMMAND_KIND_ADVISE_SUPERVISORY\x10\x08\x12%\n!MX_COMMAND_KIND_ADD_BUFFERED_ITEM\x10\t\x12\x30\n,MX_COMMAND_KIND_SET_BUFFERED_UPDATE_INTERVAL\x10\n\x12\x1b\n\x17MX_COMMAND_KIND_SUSPEND\x10\x0b\x12\x1c\n\x18MX_COMMAND_KIND_ACTIVATE\x10\x0c\x12\x19\n\x15MX_COMMAND_KIND_WRITE\x10\r\x12\x1a\n\x16MX_COMMAND_KIND_WRITE2\x10\x0e\x12!\n\x1dMX_COMMAND_KIND_WRITE_SECURED\x10\x0f\x12\"\n\x1eMX_COMMAND_KIND_WRITE_SECURED2\x10\x10\x12%\n!MX_COMMAND_KIND_AUTHENTICATE_USER\x10\x11\x12(\n$MX_COMMAND_KIND_ARCHESTRA_USER_TO_ID\x10\x12\x12!\n\x1dMX_COMMAND_KIND_ADD_ITEM_BULK\x10\x13\x12$\n MX_COMMAND_KIND_ADVISE_ITEM_BULK\x10\x14\x12$\n MX_COMMAND_KIND_REMOVE_ITEM_BULK\x10\x15\x12\'\n#MX_COMMAND_KIND_UN_ADVISE_ITEM_BULK\x10\x16\x12\"\n\x1eMX_COMMAND_KIND_SUBSCRIBE_BULK\x10\x17\x12$\n MX_COMMAND_KIND_UNSUBSCRIBE_BULK\x10\x18\x12$\n MX_COMMAND_KIND_SUBSCRIBE_ALARMS\x10\x19\x12&\n\"MX_COMMAND_KIND_UNSUBSCRIBE_ALARMS\x10\x1a\x12%\n!MX_COMMAND_KIND_ACKNOWLEDGE_ALARM\x10\x1b\x12\'\n#MX_COMMAND_KIND_QUERY_ACTIVE_ALARMS\x10\x1c\x12-\n)MX_COMMAND_KIND_ACKNOWLEDGE_ALARM_BY_NAME\x10\x1d\x12\x1e\n\x1aMX_COMMAND_KIND_WRITE_BULK\x10\x1e\x12\x1f\n\x1bMX_COMMAND_KIND_WRITE2_BULK\x10\x1f\x12&\n\"MX_COMMAND_KIND_WRITE_SECURED_BULK\x10 \x12\'\n#MX_COMMAND_KIND_WRITE_SECURED2_BULK\x10!\x12\x1d\n\x19MX_COMMAND_KIND_READ_BULK\x10\"\x12+\n\'MX_COMMAND_KIND_UPDATE_ALARM_WATCH_LIST\x10#\x12\x18\n\x14MX_COMMAND_KIND_PING\x10\x64\x12%\n!MX_COMMAND_KIND_GET_SESSION_STATE\x10\x65\x12#\n\x1fMX_COMMAND_KIND_GET_WORKER_INFO\x10\x66\x12 \n\x1cMX_COMMAND_KIND_DRAIN_EVENTS\x10g\x12#\n\x1fMX_COMMAND_KIND_SHUTDOWN_WORKER\x10h*z\n\x11\x41larmProviderMode\x12#\n\x1f\x41LARM_PROVIDER_MODE_UNSPECIFIED\x10\x00\x12 \n\x1c\x41LARM_PROVIDER_MODE_ALARMMGR\x10\x01\x12\x1e\n\x1a\x41LARM_PROVIDER_MODE_SUBTAG\x10\x02*\xad\x02\n\rMxEventFamily\x12\x1f\n\x1bMX_EVENT_FAMILY_UNSPECIFIED\x10\x00\x12\"\n\x1eMX_EVENT_FAMILY_ON_DATA_CHANGE\x10\x01\x12%\n!MX_EVENT_FAMILY_ON_WRITE_COMPLETE\x10\x02\x12&\n\"MX_EVENT_FAMILY_OPERATION_COMPLETE\x10\x03\x12+\n\'MX_EVENT_FAMILY_ON_BUFFERED_DATA_CHANGE\x10\x04\x12\'\n#MX_EVENT_FAMILY_ON_ALARM_TRANSITION\x10\x05\x12\x32\n.MX_EVENT_FAMILY_ON_ALARM_PROVIDER_MODE_CHANGED\x10\x06*\xca\x01\n\x13\x41larmTransitionKind\x12%\n!ALARM_TRANSITION_KIND_UNSPECIFIED\x10\x00\x12\x1f\n\x1b\x41LARM_TRANSITION_KIND_RAISE\x10\x01\x12%\n!ALARM_TRANSITION_KIND_ACKNOWLEDGE\x10\x02\x12\x1f\n\x1b\x41LARM_TRANSITION_KIND_CLEAR\x10\x03\x12#\n\x1f\x41LARM_TRANSITION_KIND_RETRIGGER\x10\x04*\xaa\x01\n\x13\x41larmConditionState\x12%\n!ALARM_CONDITION_STATE_UNSPECIFIED\x10\x00\x12 \n\x1c\x41LARM_CONDITION_STATE_ACTIVE\x10\x01\x12&\n\"ALARM_CONDITION_STATE_ACTIVE_ACKED\x10\x02\x12\"\n\x1e\x41LARM_CONDITION_STATE_INACTIVE\x10\x03*\xa5\x03\n\x10MxStatusCategory\x12\"\n\x1eMX_STATUS_CATEGORY_UNSPECIFIED\x10\x00\x12\x1e\n\x1aMX_STATUS_CATEGORY_UNKNOWN\x10\x01\x12\x19\n\x15MX_STATUS_CATEGORY_OK\x10\x02\x12\x1e\n\x1aMX_STATUS_CATEGORY_PENDING\x10\x03\x12\x1e\n\x1aMX_STATUS_CATEGORY_WARNING\x10\x04\x12*\n&MX_STATUS_CATEGORY_COMMUNICATION_ERROR\x10\x05\x12*\n&MX_STATUS_CATEGORY_CONFIGURATION_ERROR\x10\x06\x12(\n$MX_STATUS_CATEGORY_OPERATIONAL_ERROR\x10\x07\x12%\n!MX_STATUS_CATEGORY_SECURITY_ERROR\x10\x08\x12%\n!MX_STATUS_CATEGORY_SOFTWARE_ERROR\x10\t\x12\"\n\x1eMX_STATUS_CATEGORY_OTHER_ERROR\x10\n*\xca\x02\n\x0eMxStatusSource\x12 \n\x1cMX_STATUS_SOURCE_UNSPECIFIED\x10\x00\x12\x1c\n\x18MX_STATUS_SOURCE_UNKNOWN\x10\x01\x12#\n\x1fMX_STATUS_SOURCE_REQUESTING_LMX\x10\x02\x12#\n\x1fMX_STATUS_SOURCE_RESPONDING_LMX\x10\x03\x12#\n\x1fMX_STATUS_SOURCE_REQUESTING_NMX\x10\x04\x12#\n\x1fMX_STATUS_SOURCE_RESPONDING_NMX\x10\x05\x12\x31\n-MX_STATUS_SOURCE_REQUESTING_AUTOMATION_OBJECT\x10\x06\x12\x31\n-MX_STATUS_SOURCE_RESPONDING_AUTOMATION_OBJECT\x10\x07*\xdd\x04\n\nMxDataType\x12\x1c\n\x18MX_DATA_TYPE_UNSPECIFIED\x10\x00\x12\x18\n\x14MX_DATA_TYPE_UNKNOWN\x10\x01\x12\x18\n\x14MX_DATA_TYPE_NO_DATA\x10\x02\x12\x18\n\x14MX_DATA_TYPE_BOOLEAN\x10\x03\x12\x18\n\x14MX_DATA_TYPE_INTEGER\x10\x04\x12\x16\n\x12MX_DATA_TYPE_FLOAT\x10\x05\x12\x17\n\x13MX_DATA_TYPE_DOUBLE\x10\x06\x12\x17\n\x13MX_DATA_TYPE_STRING\x10\x07\x12\x15\n\x11MX_DATA_TYPE_TIME\x10\x08\x12\x1d\n\x19MX_DATA_TYPE_ELAPSED_TIME\x10\t\x12\x1f\n\x1bMX_DATA_TYPE_REFERENCE_TYPE\x10\n\x12\x1c\n\x18MX_DATA_TYPE_STATUS_TYPE\x10\x0b\x12\x15\n\x11MX_DATA_TYPE_ENUM\x10\x0c\x12-\n)MX_DATA_TYPE_SECURITY_CLASSIFICATION_ENUM\x10\r\x12\"\n\x1eMX_DATA_TYPE_DATA_QUALITY_TYPE\x10\x0e\x12\x1f\n\x1bMX_DATA_TYPE_QUALIFIED_ENUM\x10\x0f\x12!\n\x1dMX_DATA_TYPE_QUALIFIED_STRUCT\x10\x10\x12)\n%MX_DATA_TYPE_INTERNATIONALIZED_STRING\x10\x11\x12\x1b\n\x17MX_DATA_TYPE_BIG_STRING\x10\x12\x12\x14\n\x10MX_DATA_TYPE_END\x10\x13*\xa3\x03\n\x12ProtocolStatusCode\x12$\n PROTOCOL_STATUS_CODE_UNSPECIFIED\x10\x00\x12\x1b\n\x17PROTOCOL_STATUS_CODE_OK\x10\x01\x12(\n$PROTOCOL_STATUS_CODE_INVALID_REQUEST\x10\x02\x12*\n&PROTOCOL_STATUS_CODE_SESSION_NOT_FOUND\x10\x03\x12*\n&PROTOCOL_STATUS_CODE_SESSION_NOT_READY\x10\x04\x12+\n\'PROTOCOL_STATUS_CODE_WORKER_UNAVAILABLE\x10\x05\x12 \n\x1cPROTOCOL_STATUS_CODE_TIMEOUT\x10\x06\x12!\n\x1dPROTOCOL_STATUS_CODE_CANCELED\x10\x07\x12+\n\'PROTOCOL_STATUS_CODE_PROTOCOL_VIOLATION\x10\x08\x12)\n%PROTOCOL_STATUS_CODE_MXACCESS_FAILURE\x10\t*\xbf\x02\n\x0cSessionState\x12\x1d\n\x19SESSION_STATE_UNSPECIFIED\x10\x00\x12\x1a\n\x16SESSION_STATE_CREATING\x10\x01\x12!\n\x1dSESSION_STATE_STARTING_WORKER\x10\x02\x12\"\n\x1eSESSION_STATE_WAITING_FOR_PIPE\x10\x03\x12\x1d\n\x19SESSION_STATE_HANDSHAKING\x10\x04\x12%\n!SESSION_STATE_INITIALIZING_WORKER\x10\x05\x12\x17\n\x13SESSION_STATE_READY\x10\x06\x12\x19\n\x15SESSION_STATE_CLOSING\x10\x07\x12\x18\n\x14SESSION_STATE_CLOSED\x10\x08\x12\x19\n\x15SESSION_STATE_FAULTED\x10\t2\xc3\x05\n\x0fMxAccessGateway\x12]\n\x0bOpenSession\x12\'.mxaccess_gateway.v1.OpenSessionRequest\x1a%.mxaccess_gateway.v1.OpenSessionReply\x12`\n\x0c\x43loseSession\x12(.mxaccess_gateway.v1.CloseSessionRequest\x1a&.mxaccess_gateway.v1.CloseSessionReply\x12T\n\x06Invoke\x12%.mxaccess_gateway.v1.MxCommandRequest\x1a#.mxaccess_gateway.v1.MxCommandReply\x12X\n\x0cStreamEvents\x12(.mxaccess_gateway.v1.StreamEventsRequest\x1a\x1c.mxaccess_gateway.v1.MxEvent0\x01\x12l\n\x10\x41\x63knowledgeAlarm\x12,.mxaccess_gateway.v1.AcknowledgeAlarmRequest\x1a*.mxaccess_gateway.v1.AcknowledgeAlarmReply\x12\x61\n\x0cStreamAlarms\x12(.mxaccess_gateway.v1.StreamAlarmsRequest\x1a%.mxaccess_gateway.v1.AlarmFeedMessage0\x01\x12n\n\x11QueryActiveAlarms\x12-.mxaccess_gateway.v1.QueryActiveAlarmsRequest\x1a(.mxaccess_gateway.v1.ActiveAlarmSnapshot0\x01\x42&\xaa\x02#ZB.MOM.WW.MxGateway.Contracts.Protob\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -34,26 +34,26 @@ _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'mxaccess_gateway_pb2', _glo if not _descriptor._USE_C_DESCRIPTORS: _globals['DESCRIPTOR']._loaded_options = None _globals['DESCRIPTOR']._serialized_options = b'\252\002#ZB.MOM.WW.MxGateway.Contracts.Proto' - _globals['_MXCOMMANDKIND']._serialized_start=17876 - _globals['_MXCOMMANDKIND']._serialized_end=19360 - _globals['_ALARMPROVIDERMODE']._serialized_start=19362 - _globals['_ALARMPROVIDERMODE']._serialized_end=19484 - _globals['_MXEVENTFAMILY']._serialized_start=19487 - _globals['_MXEVENTFAMILY']._serialized_end=19788 - _globals['_ALARMTRANSITIONKIND']._serialized_start=19791 - _globals['_ALARMTRANSITIONKIND']._serialized_end=19993 - _globals['_ALARMCONDITIONSTATE']._serialized_start=19996 - _globals['_ALARMCONDITIONSTATE']._serialized_end=20166 - _globals['_MXSTATUSCATEGORY']._serialized_start=20169 - _globals['_MXSTATUSCATEGORY']._serialized_end=20590 - _globals['_MXSTATUSSOURCE']._serialized_start=20593 - _globals['_MXSTATUSSOURCE']._serialized_end=20923 - _globals['_MXDATATYPE']._serialized_start=20926 - _globals['_MXDATATYPE']._serialized_end=21531 - _globals['_PROTOCOLSTATUSCODE']._serialized_start=21534 - _globals['_PROTOCOLSTATUSCODE']._serialized_end=21953 - _globals['_SESSIONSTATE']._serialized_start=21956 - _globals['_SESSIONSTATE']._serialized_end=22275 + _globals['_MXCOMMANDKIND']._serialized_start=17982 + _globals['_MXCOMMANDKIND']._serialized_end=19466 + _globals['_ALARMPROVIDERMODE']._serialized_start=19468 + _globals['_ALARMPROVIDERMODE']._serialized_end=19590 + _globals['_MXEVENTFAMILY']._serialized_start=19593 + _globals['_MXEVENTFAMILY']._serialized_end=19894 + _globals['_ALARMTRANSITIONKIND']._serialized_start=19897 + _globals['_ALARMTRANSITIONKIND']._serialized_end=20099 + _globals['_ALARMCONDITIONSTATE']._serialized_start=20102 + _globals['_ALARMCONDITIONSTATE']._serialized_end=20272 + _globals['_MXSTATUSCATEGORY']._serialized_start=20275 + _globals['_MXSTATUSCATEGORY']._serialized_end=20696 + _globals['_MXSTATUSSOURCE']._serialized_start=20699 + _globals['_MXSTATUSSOURCE']._serialized_end=21029 + _globals['_MXDATATYPE']._serialized_start=21032 + _globals['_MXDATATYPE']._serialized_end=21637 + _globals['_PROTOCOLSTATUSCODE']._serialized_start=21640 + _globals['_PROTOCOLSTATUSCODE']._serialized_end=22059 + _globals['_SESSIONSTATE']._serialized_start=22062 + _globals['_SESSIONSTATE']._serialized_end=22381 _globals['_QUERYACTIVEALARMSREQUEST']._serialized_start=112 _globals['_QUERYACTIVEALARMSREQUEST']._serialized_end=218 _globals['_OPENSESSIONREQUEST']._serialized_start=221 @@ -127,139 +127,139 @@ if not _descriptor._USE_C_DESCRIPTORS: _globals['_ALARMFAILOVERCONFIG']._serialized_start=6548 _globals['_ALARMFAILOVERCONFIG']._serialized_end=6681 _globals['_ACKNOWLEDGEALARMCOMMAND']._serialized_start=6684 - _globals['_ACKNOWLEDGEALARMCOMMAND']._serialized_end=6845 - _globals['_QUERYACTIVEALARMSCOMMAND']._serialized_start=6847 - _globals['_QUERYACTIVEALARMSCOMMAND']._serialized_end=6902 - _globals['_ACKNOWLEDGEALARMBYNAMECOMMAND']._serialized_start=6905 - _globals['_ACKNOWLEDGEALARMBYNAMECOMMAND']._serialized_end=7115 - _globals['_UNSUBSCRIBEBULKCOMMAND']._serialized_start=7117 - _globals['_UNSUBSCRIBEBULKCOMMAND']._serialized_end=7186 - _globals['_WRITEBULKCOMMAND']._serialized_start=7188 - _globals['_WRITEBULKCOMMAND']._serialized_end=7283 - _globals['_WRITEBULKENTRY']._serialized_start=7285 - _globals['_WRITEBULKENTRY']._serialized_end=7384 - _globals['_WRITE2BULKCOMMAND']._serialized_start=7386 - _globals['_WRITE2BULKCOMMAND']._serialized_end=7483 - _globals['_WRITE2BULKENTRY']._serialized_start=7486 - _globals['_WRITE2BULKENTRY']._serialized_end=7641 - _globals['_WRITESECUREDBULKCOMMAND']._serialized_start=7643 - _globals['_WRITESECUREDBULKCOMMAND']._serialized_end=7752 - _globals['_WRITESECUREDBULKENTRY']._serialized_start=7755 - _globals['_WRITESECUREDBULKENTRY']._serialized_end=7895 - _globals['_WRITESECURED2BULKCOMMAND']._serialized_start=7897 - _globals['_WRITESECURED2BULKCOMMAND']._serialized_end=8008 - _globals['_WRITESECURED2BULKENTRY']._serialized_start=8011 - _globals['_WRITESECURED2BULKENTRY']._serialized_end=8207 - _globals['_READBULKCOMMAND']._serialized_start=8209 - _globals['_READBULKCOMMAND']._serialized_end=8292 - _globals['_PINGCOMMAND']._serialized_start=8294 - _globals['_PINGCOMMAND']._serialized_end=8324 - _globals['_GETSESSIONSTATECOMMAND']._serialized_start=8326 - _globals['_GETSESSIONSTATECOMMAND']._serialized_end=8350 - _globals['_GETWORKERINFOCOMMAND']._serialized_start=8352 - _globals['_GETWORKERINFOCOMMAND']._serialized_end=8374 - _globals['_DRAINEVENTSCOMMAND']._serialized_start=8376 - _globals['_DRAINEVENTSCOMMAND']._serialized_end=8416 - _globals['_SHUTDOWNWORKERCOMMAND']._serialized_start=8418 - _globals['_SHUTDOWNWORKERCOMMAND']._serialized_end=8490 - _globals['_MXCOMMANDREPLY']._serialized_start=8493 - _globals['_MXCOMMANDREPLY']._serialized_end=10419 - _globals['_REGISTERREPLY']._serialized_start=10421 - _globals['_REGISTERREPLY']._serialized_end=10459 - _globals['_ADDITEMREPLY']._serialized_start=10461 - _globals['_ADDITEMREPLY']._serialized_end=10496 - _globals['_ADDITEM2REPLY']._serialized_start=10498 - _globals['_ADDITEM2REPLY']._serialized_end=10534 - _globals['_ADDBUFFEREDITEMREPLY']._serialized_start=10536 - _globals['_ADDBUFFEREDITEMREPLY']._serialized_end=10579 - _globals['_SUSPENDREPLY']._serialized_start=10581 - _globals['_SUSPENDREPLY']._serialized_end=10647 - _globals['_ACTIVATEREPLY']._serialized_start=10649 - _globals['_ACTIVATEREPLY']._serialized_end=10716 - _globals['_AUTHENTICATEUSERREPLY']._serialized_start=10718 - _globals['_AUTHENTICATEUSERREPLY']._serialized_end=10758 - _globals['_ARCHESTRAUSERTOIDREPLY']._serialized_start=10760 - _globals['_ARCHESTRAUSERTOIDREPLY']._serialized_end=10801 - _globals['_SUBSCRIBERESULT']._serialized_start=10804 - _globals['_SUBSCRIBERESULT']._serialized_end=10933 - _globals['_BULKSUBSCRIBEREPLY']._serialized_start=10935 - _globals['_BULKSUBSCRIBEREPLY']._serialized_end=11010 - _globals['_BULKWRITERESULT']._serialized_start=11013 - _globals['_BULKWRITERESULT']._serialized_end=11209 - _globals['_BULKWRITEREPLY']._serialized_start=11211 - _globals['_BULKWRITEREPLY']._serialized_end=11282 - _globals['_BULKREADRESULT']._serialized_start=11285 - _globals['_BULKREADRESULT']._serialized_end=11603 - _globals['_BULKREADREPLY']._serialized_start=11605 - _globals['_BULKREADREPLY']._serialized_end=11674 - _globals['_SESSIONSTATEREPLY']._serialized_start=11676 - _globals['_SESSIONSTATEREPLY']._serialized_end=11745 - _globals['_WORKERINFOREPLY']._serialized_start=11747 - _globals['_WORKERINFOREPLY']._serialized_end=11864 - _globals['_DRAINEVENTSREPLY']._serialized_start=11866 - _globals['_DRAINEVENTSREPLY']._serialized_end=11930 - _globals['_ACKNOWLEDGEALARMREPLYPAYLOAD']._serialized_start=11932 - _globals['_ACKNOWLEDGEALARMREPLYPAYLOAD']._serialized_end=11985 - _globals['_QUERYACTIVEALARMSREPLYPAYLOAD']._serialized_start=11987 - _globals['_QUERYACTIVEALARMSREPLYPAYLOAD']._serialized_end=12107 - _globals['_MXEVENT']._serialized_start=12110 - _globals['_MXEVENT']._serialized_end=13149 - _globals['_REPLAYGAP']._serialized_start=13151 - _globals['_REPLAYGAP']._serialized_end=13231 - _globals['_ONDATACHANGEEVENT']._serialized_start=13233 - _globals['_ONDATACHANGEEVENT']._serialized_end=13252 - _globals['_ONWRITECOMPLETEEVENT']._serialized_start=13254 - _globals['_ONWRITECOMPLETEEVENT']._serialized_end=13276 - _globals['_OPERATIONCOMPLETEEVENT']._serialized_start=13278 - _globals['_OPERATIONCOMPLETEEVENT']._serialized_end=13302 - _globals['_ONBUFFEREDDATACHANGEEVENT']._serialized_start=13305 - _globals['_ONBUFFEREDDATACHANGEEVENT']._serialized_end=13517 - _globals['_ONALARMTRANSITIONEVENT']._serialized_start=13520 - _globals['_ONALARMTRANSITIONEVENT']._serialized_end=14112 - _globals['_ONALARMPROVIDERMODECHANGEDEVENT']._serialized_start=14115 - _globals['_ONALARMPROVIDERMODECHANGEDEVENT']._serialized_end=14275 - _globals['_ACTIVEALARMSNAPSHOT']._serialized_start=14278 - _globals['_ACTIVEALARMSNAPSHOT']._serialized_end=14903 - _globals['_ACKNOWLEDGEALARMREQUEST']._serialized_start=14906 - _globals['_ACKNOWLEDGEALARMREQUEST']._serialized_end=15050 - _globals['_ACKNOWLEDGEALARMREPLY']._serialized_start=15053 - _globals['_ACKNOWLEDGEALARMREPLY']._serialized_end=15294 - _globals['_STREAMALARMSREQUEST']._serialized_start=15296 - _globals['_STREAMALARMSREQUEST']._serialized_end=15377 - _globals['_ALARMFEEDMESSAGE']._serialized_start=15380 - _globals['_ALARMFEEDMESSAGE']._serialized_end=15709 - _globals['_ALARMPROVIDERSTATUS']._serialized_start=15712 - _globals['_ALARMPROVIDERSTATUS']._serialized_end=15864 - _globals['_ALARMSNAPSHOTSTATUS']._serialized_start=15866 - _globals['_ALARMSNAPSHOTSTATUS']._serialized_end=15906 - _globals['_MXSTATUSPROXY']._serialized_start=15909 - _globals['_MXSTATUSPROXY']._serialized_end=16144 - _globals['_MXVALUE']._serialized_start=16147 - _globals['_MXVALUE']._serialized_end=16636 - _globals['_MXARRAY']._serialized_start=16639 - _globals['_MXARRAY']._serialized_end=17277 - _globals['_MXSPARSEARRAY']._serialized_start=17280 - _globals['_MXSPARSEARRAY']._serialized_end=17433 - _globals['_MXSPARSEELEMENT']._serialized_start=17435 - _globals['_MXSPARSEELEMENT']._serialized_end=17512 - _globals['_BOOLARRAY']._serialized_start=17514 - _globals['_BOOLARRAY']._serialized_end=17541 - _globals['_INT32ARRAY']._serialized_start=17543 - _globals['_INT32ARRAY']._serialized_end=17571 - _globals['_INT64ARRAY']._serialized_start=17573 - _globals['_INT64ARRAY']._serialized_end=17601 - _globals['_FLOATARRAY']._serialized_start=17603 - _globals['_FLOATARRAY']._serialized_end=17631 - _globals['_DOUBLEARRAY']._serialized_start=17633 - _globals['_DOUBLEARRAY']._serialized_end=17662 - _globals['_STRINGARRAY']._serialized_start=17664 - _globals['_STRINGARRAY']._serialized_end=17693 - _globals['_TIMESTAMPARRAY']._serialized_start=17695 - _globals['_TIMESTAMPARRAY']._serialized_end=17755 - _globals['_RAWARRAY']._serialized_start=17757 - _globals['_RAWARRAY']._serialized_end=17783 - _globals['_PROTOCOLSTATUS']._serialized_start=17785 - _globals['_PROTOCOLSTATUS']._serialized_end=17873 - _globals['_MXACCESSGATEWAY']._serialized_start=22278 - _globals['_MXACCESSGATEWAY']._serialized_end=22985 + _globals['_ACKNOWLEDGEALARMCOMMAND']._serialized_end=6872 + _globals['_QUERYACTIVEALARMSCOMMAND']._serialized_start=6874 + _globals['_QUERYACTIVEALARMSCOMMAND']._serialized_end=6929 + _globals['_ACKNOWLEDGEALARMBYNAMECOMMAND']._serialized_start=6932 + _globals['_ACKNOWLEDGEALARMBYNAMECOMMAND']._serialized_end=7169 + _globals['_UNSUBSCRIBEBULKCOMMAND']._serialized_start=7171 + _globals['_UNSUBSCRIBEBULKCOMMAND']._serialized_end=7240 + _globals['_WRITEBULKCOMMAND']._serialized_start=7242 + _globals['_WRITEBULKCOMMAND']._serialized_end=7337 + _globals['_WRITEBULKENTRY']._serialized_start=7339 + _globals['_WRITEBULKENTRY']._serialized_end=7438 + _globals['_WRITE2BULKCOMMAND']._serialized_start=7440 + _globals['_WRITE2BULKCOMMAND']._serialized_end=7537 + _globals['_WRITE2BULKENTRY']._serialized_start=7540 + _globals['_WRITE2BULKENTRY']._serialized_end=7695 + _globals['_WRITESECUREDBULKCOMMAND']._serialized_start=7697 + _globals['_WRITESECUREDBULKCOMMAND']._serialized_end=7806 + _globals['_WRITESECUREDBULKENTRY']._serialized_start=7809 + _globals['_WRITESECUREDBULKENTRY']._serialized_end=7949 + _globals['_WRITESECURED2BULKCOMMAND']._serialized_start=7951 + _globals['_WRITESECURED2BULKCOMMAND']._serialized_end=8062 + _globals['_WRITESECURED2BULKENTRY']._serialized_start=8065 + _globals['_WRITESECURED2BULKENTRY']._serialized_end=8261 + _globals['_READBULKCOMMAND']._serialized_start=8263 + _globals['_READBULKCOMMAND']._serialized_end=8346 + _globals['_PINGCOMMAND']._serialized_start=8348 + _globals['_PINGCOMMAND']._serialized_end=8378 + _globals['_GETSESSIONSTATECOMMAND']._serialized_start=8380 + _globals['_GETSESSIONSTATECOMMAND']._serialized_end=8404 + _globals['_GETWORKERINFOCOMMAND']._serialized_start=8406 + _globals['_GETWORKERINFOCOMMAND']._serialized_end=8428 + _globals['_DRAINEVENTSCOMMAND']._serialized_start=8430 + _globals['_DRAINEVENTSCOMMAND']._serialized_end=8470 + _globals['_SHUTDOWNWORKERCOMMAND']._serialized_start=8472 + _globals['_SHUTDOWNWORKERCOMMAND']._serialized_end=8544 + _globals['_MXCOMMANDREPLY']._serialized_start=8547 + _globals['_MXCOMMANDREPLY']._serialized_end=10473 + _globals['_REGISTERREPLY']._serialized_start=10475 + _globals['_REGISTERREPLY']._serialized_end=10513 + _globals['_ADDITEMREPLY']._serialized_start=10515 + _globals['_ADDITEMREPLY']._serialized_end=10550 + _globals['_ADDITEM2REPLY']._serialized_start=10552 + _globals['_ADDITEM2REPLY']._serialized_end=10588 + _globals['_ADDBUFFEREDITEMREPLY']._serialized_start=10590 + _globals['_ADDBUFFEREDITEMREPLY']._serialized_end=10633 + _globals['_SUSPENDREPLY']._serialized_start=10635 + _globals['_SUSPENDREPLY']._serialized_end=10701 + _globals['_ACTIVATEREPLY']._serialized_start=10703 + _globals['_ACTIVATEREPLY']._serialized_end=10770 + _globals['_AUTHENTICATEUSERREPLY']._serialized_start=10772 + _globals['_AUTHENTICATEUSERREPLY']._serialized_end=10812 + _globals['_ARCHESTRAUSERTOIDREPLY']._serialized_start=10814 + _globals['_ARCHESTRAUSERTOIDREPLY']._serialized_end=10855 + _globals['_SUBSCRIBERESULT']._serialized_start=10858 + _globals['_SUBSCRIBERESULT']._serialized_end=10987 + _globals['_BULKSUBSCRIBEREPLY']._serialized_start=10989 + _globals['_BULKSUBSCRIBEREPLY']._serialized_end=11064 + _globals['_BULKWRITERESULT']._serialized_start=11067 + _globals['_BULKWRITERESULT']._serialized_end=11263 + _globals['_BULKWRITEREPLY']._serialized_start=11265 + _globals['_BULKWRITEREPLY']._serialized_end=11336 + _globals['_BULKREADRESULT']._serialized_start=11339 + _globals['_BULKREADRESULT']._serialized_end=11657 + _globals['_BULKREADREPLY']._serialized_start=11659 + _globals['_BULKREADREPLY']._serialized_end=11728 + _globals['_SESSIONSTATEREPLY']._serialized_start=11730 + _globals['_SESSIONSTATEREPLY']._serialized_end=11799 + _globals['_WORKERINFOREPLY']._serialized_start=11801 + _globals['_WORKERINFOREPLY']._serialized_end=11918 + _globals['_DRAINEVENTSREPLY']._serialized_start=11920 + _globals['_DRAINEVENTSREPLY']._serialized_end=11984 + _globals['_ACKNOWLEDGEALARMREPLYPAYLOAD']._serialized_start=11986 + _globals['_ACKNOWLEDGEALARMREPLYPAYLOAD']._serialized_end=12039 + _globals['_QUERYACTIVEALARMSREPLYPAYLOAD']._serialized_start=12041 + _globals['_QUERYACTIVEALARMSREPLYPAYLOAD']._serialized_end=12161 + _globals['_MXEVENT']._serialized_start=12164 + _globals['_MXEVENT']._serialized_end=13203 + _globals['_REPLAYGAP']._serialized_start=13205 + _globals['_REPLAYGAP']._serialized_end=13285 + _globals['_ONDATACHANGEEVENT']._serialized_start=13287 + _globals['_ONDATACHANGEEVENT']._serialized_end=13306 + _globals['_ONWRITECOMPLETEEVENT']._serialized_start=13308 + _globals['_ONWRITECOMPLETEEVENT']._serialized_end=13330 + _globals['_OPERATIONCOMPLETEEVENT']._serialized_start=13332 + _globals['_OPERATIONCOMPLETEEVENT']._serialized_end=13356 + _globals['_ONBUFFEREDDATACHANGEEVENT']._serialized_start=13359 + _globals['_ONBUFFEREDDATACHANGEEVENT']._serialized_end=13571 + _globals['_ONALARMTRANSITIONEVENT']._serialized_start=13574 + _globals['_ONALARMTRANSITIONEVENT']._serialized_end=14166 + _globals['_ONALARMPROVIDERMODECHANGEDEVENT']._serialized_start=14169 + _globals['_ONALARMPROVIDERMODECHANGEDEVENT']._serialized_end=14329 + _globals['_ACTIVEALARMSNAPSHOT']._serialized_start=14332 + _globals['_ACTIVEALARMSNAPSHOT']._serialized_end=14957 + _globals['_ACKNOWLEDGEALARMREQUEST']._serialized_start=14960 + _globals['_ACKNOWLEDGEALARMREQUEST']._serialized_end=15156 + _globals['_ACKNOWLEDGEALARMREPLY']._serialized_start=15159 + _globals['_ACKNOWLEDGEALARMREPLY']._serialized_end=15400 + _globals['_STREAMALARMSREQUEST']._serialized_start=15402 + _globals['_STREAMALARMSREQUEST']._serialized_end=15483 + _globals['_ALARMFEEDMESSAGE']._serialized_start=15486 + _globals['_ALARMFEEDMESSAGE']._serialized_end=15815 + _globals['_ALARMPROVIDERSTATUS']._serialized_start=15818 + _globals['_ALARMPROVIDERSTATUS']._serialized_end=15970 + _globals['_ALARMSNAPSHOTSTATUS']._serialized_start=15972 + _globals['_ALARMSNAPSHOTSTATUS']._serialized_end=16012 + _globals['_MXSTATUSPROXY']._serialized_start=16015 + _globals['_MXSTATUSPROXY']._serialized_end=16250 + _globals['_MXVALUE']._serialized_start=16253 + _globals['_MXVALUE']._serialized_end=16742 + _globals['_MXARRAY']._serialized_start=16745 + _globals['_MXARRAY']._serialized_end=17383 + _globals['_MXSPARSEARRAY']._serialized_start=17386 + _globals['_MXSPARSEARRAY']._serialized_end=17539 + _globals['_MXSPARSEELEMENT']._serialized_start=17541 + _globals['_MXSPARSEELEMENT']._serialized_end=17618 + _globals['_BOOLARRAY']._serialized_start=17620 + _globals['_BOOLARRAY']._serialized_end=17647 + _globals['_INT32ARRAY']._serialized_start=17649 + _globals['_INT32ARRAY']._serialized_end=17677 + _globals['_INT64ARRAY']._serialized_start=17679 + _globals['_INT64ARRAY']._serialized_end=17707 + _globals['_FLOATARRAY']._serialized_start=17709 + _globals['_FLOATARRAY']._serialized_end=17737 + _globals['_DOUBLEARRAY']._serialized_start=17739 + _globals['_DOUBLEARRAY']._serialized_end=17768 + _globals['_STRINGARRAY']._serialized_start=17770 + _globals['_STRINGARRAY']._serialized_end=17799 + _globals['_TIMESTAMPARRAY']._serialized_start=17801 + _globals['_TIMESTAMPARRAY']._serialized_end=17861 + _globals['_RAWARRAY']._serialized_start=17863 + _globals['_RAWARRAY']._serialized_end=17889 + _globals['_PROTOCOLSTATUS']._serialized_start=17891 + _globals['_PROTOCOLSTATUS']._serialized_end=17979 + _globals['_MXACCESSGATEWAY']._serialized_start=22384 + _globals['_MXACCESSGATEWAY']._serialized_end=23091 # @@protoc_insertion_point(module_scope) diff --git a/clients/python/src/zb_mom_ww_mxgateway_cli/commands.py b/clients/python/src/zb_mom_ww_mxgateway_cli/commands.py index 6fbe2aa..e3aae6a 100644 --- a/clients/python/src/zb_mom_ww_mxgateway_cli/commands.py +++ b/clients/python/src/zb_mom_ww_mxgateway_cli/commands.py @@ -36,6 +36,7 @@ MAX_AGGREGATE_EVENTS = 10_000 #: CLI (CLI-45) so one exported variable drives the same operator workflow in all #: five languages. DEFAULT_VERIFY_PASSWORD_ENV = "MXGATEWAY_VERIFY_PASSWORD" +DEFAULT_OPERATOR_PASSWORD_ENV = "MXGATEWAY_OPERATOR_PASSWORD" _BATCH_EOR = "__MXGW_BATCH_EOR__" @@ -528,16 +529,30 @@ def stream_alarms(**kwargs: Any) -> None: @gateway_options @click.option("--reference", required=True, help="Alarm full reference to acknowledge.") @click.option("--comment", default="", help="Acknowledgement comment.") -@click.option("--operator", default="", help="Operator user name.") +@click.option("--operator", required=True, help="Operator user name (recorded in the alarm history).") +@click.option("--operator-domain", default="", help="Operator Windows/Galaxy domain (optional).") +@click.option( + "--operator-password", + default=None, + help="Operator password. Prefer --operator-password-env so the secret is not visible on the command line.", +) +@click.option( + "--operator-password-env", + default=DEFAULT_OPERATOR_PASSWORD_ENV, + show_default=True, + help="Environment variable holding the operator password (an unset variable means an empty password).", +) @click.option("--correlation-id", default="", help="Client correlation id.") @click.option("--json", "output_json", is_flag=True, help="Emit JSON output.") def acknowledge_alarm(**kwargs: Any) -> None: - """Acknowledge an active MXAccess alarm condition (session-less).""" + """Acknowledge an active MXAccess alarm condition as the operator (session-less).""" + password = _resolve_operator_password(kwargs) + kwargs["operator_password"] = password _run( _acknowledge_alarm(**kwargs), output_json=kwargs["output_json"], - secrets=_secrets(kwargs), + secrets=_secrets(kwargs) + [password], ) @@ -836,6 +851,21 @@ async def _authenticate_user(**kwargs: Any) -> dict[str, Any]: return {"userId": user_id} +def _resolve_operator_password(kwargs: dict[str, Any]) -> str: + """Resolve the alarm-ack operator password from --operator-password or its env var. + + Unlike :func:`_resolve_password`, an unset or empty value is allowed: Galaxy + accounts may have empty passwords. The value is never echoed; callers add it + to the ``secrets`` redaction list. + """ + + env_name = kwargs.get("operator_password_env") or DEFAULT_OPERATOR_PASSWORD_ENV + password = kwargs.get("operator_password") + if password is None: + password = os.environ.get(env_name, "") + return password + + def _resolve_password(kwargs: dict[str, Any]) -> str: """Resolve the authenticate-user password from --password or --password-env. @@ -1161,6 +1191,8 @@ async def _acknowledge_alarm(**kwargs: Any) -> dict[str, Any]: alarm_full_reference=kwargs["reference"], comment=kwargs["comment"], operator_user=kwargs["operator"], + operator_domain=kwargs["operator_domain"], + operator_password=kwargs["operator_password"], ), ) return _message_dict(reply) diff --git a/clients/python/tests/test_alarms.py b/clients/python/tests/test_alarms.py index 07b51f4..4319139 100644 --- a/clients/python/tests/test_alarms.py +++ b/clients/python/tests/test_alarms.py @@ -34,6 +34,8 @@ async def test_acknowledge_alarm_sends_request_and_returns_reply() -> None: alarm_full_reference="Tank01.Level.HiHi", comment="investigating", operator_user="alice", + operator_password="pw-secret", + operator_domain="CORP", ), ) @@ -44,9 +46,34 @@ async def test_acknowledge_alarm_sends_request_and_returns_reply() -> None: assert captured_request.alarm_full_reference == "Tank01.Level.HiHi" assert captured_request.comment == "investigating" assert captured_request.operator_user == "alice" + assert captured_request.operator_password == "pw-secret" + assert captured_request.operator_domain == "CORP" assert stub.acknowledge_alarm.metadata == (("authorization", "Bearer mxgw_test_secret"),) +@pytest.mark.asyncio +async def test_acknowledge_alarm_error_redacts_operator_password() -> None: + stub = FakeGatewayStub() + stub.acknowledge_alarm.exception = FakeRpcError( + grpc.StatusCode.UNAUTHENTICATED, "bad credentials pw-secret", + ) + client = await GatewayClient.connect( + ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True), + stub=stub, + ) + + with pytest.raises(MxGatewayAuthenticationError) as excinfo: + await client.acknowledge_alarm( + pb.AcknowledgeAlarmRequest( + alarm_full_reference="Tank01.Level.HiHi", + operator_user="alice", + operator_password="pw-secret", + ), + ) + + assert "pw-secret" not in str(excinfo.value) + + @pytest.mark.asyncio async def test_acknowledge_alarm_unauthenticated_raises_typed_error() -> None: stub = FakeGatewayStub() diff --git a/clients/python/tests/test_review_findings_022_to_026.py b/clients/python/tests/test_review_findings_022_to_026.py index 81a1477..f9591ea 100644 --- a/clients/python/tests/test_review_findings_022_to_026.py +++ b/clients/python/tests/test_review_findings_022_to_026.py @@ -771,6 +771,7 @@ def test_cli_stream_alarms_positive_max_messages_remains_finite(monkeypatch) -> def test_cli_acknowledge_alarm_happy_path(monkeypatch) -> None: + monkeypatch.setenv("MXGW_TEST_OPPW", "pw-secret") stub = _BulkFakeStub() stub.acknowledge_alarm = _BulkFakeUnary( [ @@ -798,12 +799,19 @@ def test_cli_acknowledge_alarm_happy_path(monkeypatch) -> None: "investigating", "--operator", "alice", + "--operator-domain", + "CORP", + "--operator-password-env", + "MXGW_TEST_OPPW", "--json", ], ) assert result.exit_code == 0, result.output + assert "pw-secret" not in result.output captured_request = stub.acknowledge_alarm.requests[0] + assert captured_request.operator_domain == "CORP" + assert captured_request.operator_password == "pw-secret" assert captured_request.alarm_full_reference == "Tank01.Level.HiHi" assert captured_request.comment == "investigating" assert captured_request.operator_user == "alice" From 027cc7393d4720b08c172d808e7e234e9ccc7165 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 19 Oct 2026 19:44:49 -0400 Subject: [PATCH 18/25] fix(python-client): redact operator password from AcknowledgeAlarmReply errors Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT --- .../python/src/zb_mom_ww_mxgateway/session.py | 17 ++++++---- clients/python/tests/test_alarms.py | 34 +++++++++++++++++++ 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/clients/python/src/zb_mom_ww_mxgateway/session.py b/clients/python/src/zb_mom_ww_mxgateway/session.py index 78f75aa..8a8d3bf 100644 --- a/clients/python/src/zb_mom_ww_mxgateway/session.py +++ b/clients/python/src/zb_mom_ww_mxgateway/session.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import AsyncIterator, Sequence +from typing import Any from .auth import redact_secret from .errors import MalformedReplyError, MxGatewayError, ensure_mxaccess_success @@ -943,22 +944,26 @@ def _redact_error(error: MxGatewayError, secrets: Sequence[str | None]) -> None: error.raw_reply = _redact_reply(error.raw_reply, scrubbed) -def _redact_reply(reply: pb.MxCommandReply, secrets: Sequence[str]) -> pb.MxCommandReply: +def _redact_reply(reply: Any, secrets: Sequence[str]) -> Any: """Return a deep copy of *reply* with credential text scrubbed from diagnostics. Operates on a clone so the caller's original reply object is never mutated. Only the free-text diagnostic fields that can echo a client-supplied secret are scrubbed; the structured/enum fields the gateway itself sets are left as-is. + Works for any reply message (``MxCommandReply``, ``AcknowledgeAlarmReply``, ...): + each field is scrubbed only when the message type declares it. """ clone = type(reply)() clone.CopyFrom(reply) - if clone.protocol_status.message: + fields = clone.DESCRIPTOR.fields_by_name + if "protocol_status" in fields and clone.protocol_status.message: clone.protocol_status.message = redact_secret(clone.protocol_status.message, secrets) - if clone.diagnostic_message: + if "diagnostic_message" in fields and clone.diagnostic_message: clone.diagnostic_message = redact_secret(clone.diagnostic_message, secrets) - for status in clone.statuses: - if status.diagnostic_text: - status.diagnostic_text = redact_secret(status.diagnostic_text, secrets) + if "statuses" in fields: + for status in clone.statuses: + if status.diagnostic_text: + status.diagnostic_text = redact_secret(status.diagnostic_text, secrets) return clone diff --git a/clients/python/tests/test_alarms.py b/clients/python/tests/test_alarms.py index 4319139..d8a6e73 100644 --- a/clients/python/tests/test_alarms.py +++ b/clients/python/tests/test_alarms.py @@ -74,6 +74,40 @@ async def test_acknowledge_alarm_error_redacts_operator_password() -> None: assert "pw-secret" not in str(excinfo.value) +@pytest.mark.asyncio +async def test_acknowledge_alarm_protocol_failure_redacts_operator_password() -> None: + from zb_mom_ww_mxgateway.errors import MxGatewayError + + stub = FakeGatewayStub() + stub.acknowledge_alarm.replies = [ + pb.AcknowledgeAlarmReply( + protocol_status=pb.ProtocolStatus( + code=pb.PROTOCOL_STATUS_CODE_INVALID_REQUEST, + message="rejected credential pw-secret", + ), + ), + ] + client = await GatewayClient.connect( + ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True), + stub=stub, + ) + + with pytest.raises(MxGatewayError) as excinfo: + await client.acknowledge_alarm( + pb.AcknowledgeAlarmRequest( + alarm_full_reference="Tank01.Level.HiHi", + operator_user="alice", + operator_password="pw-secret", + ), + ) + + assert "pw-secret" not in str(excinfo.value) + raw = excinfo.value.raw_reply + assert isinstance(raw, pb.AcknowledgeAlarmReply) + assert "pw-secret" not in raw.protocol_status.message + assert "pw-secret" not in str(raw) + + @pytest.mark.asyncio async def test_acknowledge_alarm_unauthenticated_raises_typed_error() -> None: stub = FakeGatewayStub() From 1998ee3e20cd88dbf460fb8bfed24c308586e0b1 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 19 Oct 2026 19:45:39 -0400 Subject: [PATCH 19/25] docs: address Task 12 review (supersede stale ack steps, outcome rows) Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT --- docs/AlarmClientDiscovery.md | 9 ++++++--- docs/AlarmProbeFindings.md | 11 ++++++----- docs/Authorization.md | 2 +- docs/Grpc.md | 2 +- 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/docs/AlarmClientDiscovery.md b/docs/AlarmClientDiscovery.md index 51090de..ad40a4b 100644 --- a/docs/AlarmClientDiscovery.md +++ b/docs/AlarmClientDiscovery.md @@ -661,7 +661,9 @@ alarm-consumer surface unblocks A.2 fully. Outline: events for added/changed/removed records. - `AlarmAckByGUID(VBGUID, comment, oprName, node, domain, fullName)` for client-driven acknowledgements (matches - PR A.5's `AlarmAckCommand` payload). + PR A.5's `AlarmAckCommand` payload). *Ack step superseded + 2026-10-19: acks write `AckMsg` as the authenticated operator + (see "Acknowledge in subtag mode" and `AlarmProbeFindings.md`).* - Lifecycle teardown: `DeregisterConsumer` + `UninitializeConsumer` + `Marshal.FinalReleaseComObject`. 3. **Conversion layer:** map XML record fields to @@ -702,8 +704,9 @@ alarm-consumer surface unblocks A.2 fully. Outline: `bRetainHiddenAlarms` parameter at all. - Subscription expression format: `\\\Galaxy!` (literal `Galaxy` provider) for both libraries. -- Native ack: `AlarmAckByGUID(VBGUID guid, comment, oprName, - node, domain, fullName)` on the v2 surface; ID 5-arg +- Native ack surface (not used by the gateway; superseded 2026-10-19 by + the authenticated `AckMsg` write): `AlarmAckByGUID(VBGUID guid, + comment, oprName, node, domain, fullName)` on the v2 surface; ID 5-arg variant on the legacy `IwwAlarmConsumer` interface. These findings retire the open follow-up probes from the diff --git a/docs/AlarmProbeFindings.md b/docs/AlarmProbeFindings.md index 6ef466d..f7d9f59 100644 --- a/docs/AlarmProbeFindings.md +++ b/docs/AlarmProbeFindings.md @@ -24,14 +24,15 @@ the blocker rather than rediscovering it. |---|---| | GUID stable across polls and `ALM → RTN` | Answered — yes (2026-05-01 capture in `AlarmClientDiscovery.md`, reconfirmed 2026-08-18) | | GUID stable across clear-then-re-raise | Answered 2026-08-18 — **no**, a re-raise mints a new GUID | -| GUID stable across `UNACK → ACK` | **Open** — the rig cannot be driven into an acknowledged state; 2026-08-18 (third attempt) identifies the reason as the test attribute's `MxSecurityOperate` classification, which no non-interactive ack surface on the rig can satisfy | +| GUID stable across `UNACK → ACK` | Blocker resolved 2026-10-19 — the rig is driven to `ACK_ALM` by an `AckMsg` write, and the `MxSecurityOperate` classification was not the cause (see "Fourth attempt"); GUID survival across that transition has not been recorded yet | | Acknowledge an alarm headless | Answered 2026-10-19 — **an `AckMsg` write as the authenticated operator**; every alarm-client ack is inert (see "Fourth attempt") | | `COUNT` = total active vs records-in-reply under a capped fetch | Answered 2026-08-18 — **records in the reply** | The 2026-08-17 run below is kept because it is the record of the wrong-verb blocker. The 2026-08-18 run cleared that blocker with `AuthenticateUser` + `WriteSecured` and answered two of the three questions; the acknowledge leg is now blocked on something narrower and -different, described in "Second attempt" and diagnosed in "Third attempt". +different, described in "Second attempt" and diagnosed in "Third attempt" +(resolved in the Fourth attempt, 2026-10-19). ## First attempt (2026-08-17): plain `Write` @@ -450,8 +451,8 @@ snapshot parser reads them for Acknowledge transitions. An unauthenticated write therefore acknowledges but leaves no operator in the Historian, which is why the gateway requires `operator_user` and authenticates it. -Another actor's `AckMsg` writes on the same day (authenticated as `dohertj2`) -logged `Operator=dohertj2`, `Domain="."` and that machine's name as the node, +Another actor's `AckMsg` writes on the same day (authenticated as another +operator account) logged that account as `Operator`, `Domain="."` and that machine's name as the node, which matches the client-name rule. By this date unauthenticated writes to `MxSecurityOperate` attributes were also accepted Galaxy-wide on the rig, where August refused them with 1008. @@ -466,7 +467,7 @@ nor the Historian: - wnwrap `IwwAlarmConsumer2` on a sole consumer with `SetXmlAlarmQuery` applied (list populated, `GetAlarm(0)` returns the row): `AlarmAck` with ids 0/1/2/3/-1/65536, and `Select(0)` + `AlarmAckSelected`, with operators - `TestOperator`, `Administrator` and `dohertj2`. `SelectByGUID` is `E_NOTIMPL`. + `TestOperator`, `Administrator` and a third account. `SelectByGUID` is `E_NOTIMPL`. - The same calls with `TestAlarm001` temporarily reclassified to `MxSecurityFreeAccess` (template and instance, restored afterwards), so the security classification is not the cause. diff --git a/docs/Authorization.md b/docs/Authorization.md index 21e60e7..7d02679 100644 --- a/docs/Authorization.md +++ b/docs/Authorization.md @@ -125,7 +125,7 @@ public string ResolveRequiredScope(object request) } ``` -The `_ => GatewayScopes.Admin` fallback is intentional: any future request type that the resolver does not recognize fails closed, requiring the strongest scope until the resolver is updated. `AcknowledgeAlarm` is treated as a write — it mutates alarm state, mirroring `MxCommandKind.Write*` — and stays `invoke:write` rather than `invoke:secure` because the request carries the operator's own Galaxy credential, which the worker authenticates before the `AckMsg` write, so the credential, not the API-key scope, authorizes the identity the Historian records — and `StreamAlarms` and `QueryActiveAlarms` share the alarm/event surface with `StreamEvents` and `MxCommandKind.DrainEvents`, so they carry `events:read` (the active-alarm snapshot is the same data reachable through the event surface). All three alarm RPCs are session-less: the scope check is the only authorization gate, since there is no per-session ownership to enforce. +The `_ => GatewayScopes.Admin` fallback is intentional: any future request type that the resolver does not recognize fails closed, requiring the strongest scope until the resolver is updated. `AcknowledgeAlarm` is treated as a write — it mutates alarm state, mirroring `MxCommandKind.Write*`. It stays `invoke:write` rather than `invoke:secure` because the request carries the operator's own Galaxy credential, which the worker authenticates before the `AckMsg` write; the credential, not the API-key scope, authorizes the identity the Historian records. `StreamAlarms` and `QueryActiveAlarms` share the alarm/event surface with `StreamEvents` and `MxCommandKind.DrainEvents`, so they carry `events:read` (the active-alarm snapshot is the same data reachable through the event surface). All three alarm RPCs are session-less: the scope check is the only authorization gate, since there is no per-session ownership to enforce. `MxCommandRequest` is special because it multiplexes many MxAccess operations through a single RPC. The resolver inspects the embedded `MxCommandKind` so each operation gets its own scope: diff --git a/docs/Grpc.md b/docs/Grpc.md index 11a2edd..93333bd 100644 --- a/docs/Grpc.md +++ b/docs/Grpc.md @@ -111,7 +111,7 @@ The worker calls `AuthenticateUser` as the operator, then writes `operator_comme | Write applied, or sent but no `OnWriteComplete` within the wait (unconfirmed, with a diagnostic) | `OK` | `0` | | Unknown alarm, or no ack target (non-Galaxy provider, no ack-comment subtag) | `INVALID_REQUEST` | `-1` | | `AuthenticateUser` failed | `MXACCESS_FAILURE` | the `AuthenticateUser` HRESULT | -| Write rejected (`OnWriteComplete` non-OK) | `MXACCESS_FAILURE` | the status `detail` | +| Write rejected (`OnWriteComplete` non-OK) | `MXACCESS_FAILURE` | the `detail` of the `OnWriteComplete` `MXSTATUS_PROXY` | `reply.Statuses` carries the `OnWriteComplete` `MxStatusProxy` rows when they were observed. Because an unconfirmed write also returns `OK`, a client that must be certain should watch for the resulting transition on `StreamAlarms`. `AcknowledgeAlarm` stays under the `invoke:write` scope; see [Authorization](./Authorization.md). From e7f56622c116f9607a539861e9c42366dae71def Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 19 Oct 2026 19:58:24 -0400 Subject: [PATCH 20/25] fix: address final ackmsg-as-operator review findings - AlarmAckWriter: classify AuthenticateUser and AddItem/Advise/Write failures by HRESULT for any interop exception (ArgumentException for E_INVALIDARG, UnauthorizedAccessException, COMException); server-gone HRESULTs at AuthenticateUser tear down the registration and report WriteRejected. - Bound the ack item cache with a 256-entry LRU (eviction best-effort UnAdvise+RemoveItem). - After an Unconfirmed ack, drop and re-add the item before the next write so a late OnWriteComplete on the old handle is not credited. - Pass StaRuntime.PumpPendingMessages to the ack writer through a pump-aware alarm handler factory so long completion waits refresh the STA watchdog heartbeat. - Rust CLI: --operator is required for acknowledge-alarm. - Docs: full ack status table, credential-oracle trade-off, upgrade note. Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT --- clients/rust/crates/mxgw-cli/src/main.rs | 15 +- docs/Authorization.md | 2 +- docs/DesignDecisions.md | 8 + docs/Grpc.md | 10 +- .../MxAccess/AlarmAckWriterTests.cs | 143 +++++++++++++++++- .../Ipc/WorkerPipeSession.cs | 6 +- .../MxAccess/AlarmAckWriter.cs | 128 ++++++++++++++-- .../MxAccess/AlarmCommandHandler.cs | 22 ++- .../MxAccess/MxAccessStaSession.cs | 29 +++- 9 files changed, 332 insertions(+), 31 deletions(-) diff --git a/clients/rust/crates/mxgw-cli/src/main.rs b/clients/rust/crates/mxgw-cli/src/main.rs index 075f0a3..0dace53 100644 --- a/clients/rust/crates/mxgw-cli/src/main.rs +++ b/clients/rust/crates/mxgw-cli/src/main.rs @@ -374,7 +374,9 @@ enum Command { reference: String, #[arg(long, default_value = "")] comment: String, - #[arg(long, default_value = "")] + /// Galaxy account of the acknowledging operator (required; the gateway + /// rejects an empty operator so the real operator is recorded). + #[arg(long)] operator: String, /// Galaxy domain of the operator; empty authenticates the bare user name. #[arg(long, default_value = "")] @@ -2790,6 +2792,17 @@ mod tests { assert!(parsed.is_ok(), "parse failed: {parsed:?}"); } + #[test] + fn acknowledge_alarm_requires_operator() { + let parsed = Cli::try_parse_from([ + "mxgw", + "acknowledge-alarm", + "--reference", + "Tank01.Level.HiHi", + ]); + assert!(parsed.is_err()); + } + #[test] fn acknowledge_alarm_requires_reference() { let parsed = Cli::try_parse_from(["mxgw", "acknowledge-alarm"]); diff --git a/docs/Authorization.md b/docs/Authorization.md index 7d02679..c22425a 100644 --- a/docs/Authorization.md +++ b/docs/Authorization.md @@ -125,7 +125,7 @@ public string ResolveRequiredScope(object request) } ``` -The `_ => GatewayScopes.Admin` fallback is intentional: any future request type that the resolver does not recognize fails closed, requiring the strongest scope until the resolver is updated. `AcknowledgeAlarm` is treated as a write — it mutates alarm state, mirroring `MxCommandKind.Write*`. It stays `invoke:write` rather than `invoke:secure` because the request carries the operator's own Galaxy credential, which the worker authenticates before the `AckMsg` write; the credential, not the API-key scope, authorizes the identity the Historian records. `StreamAlarms` and `QueryActiveAlarms` share the alarm/event surface with `StreamEvents` and `MxCommandKind.DrainEvents`, so they carry `events:read` (the active-alarm snapshot is the same data reachable through the event surface). All three alarm RPCs are session-less: the scope check is the only authorization gate, since there is no per-session ownership to enforce. +The `_ => GatewayScopes.Admin` fallback is intentional: any future request type that the resolver does not recognize fails closed, requiring the strongest scope until the resolver is updated. `AcknowledgeAlarm` is treated as a write — it mutates alarm state, mirroring `MxCommandKind.Write*`. It stays `invoke:write` rather than `invoke:secure` because the request carries the operator's own Galaxy credential, which the worker authenticates before the `AckMsg` write; the credential, not the API-key scope, authorizes the identity the Historian records. The trade-off is that an `invoke:write` key can tell `AuthenticationFailed` (an `MXACCESS_FAILURE` whose diagnostic reports operator authentication failure) apart from every other outcome, which makes `AcknowledgeAlarm` a Galaxy-password oracle for that key — whereas the `AuthenticateUser` command itself requires `invoke:secure`. What bounds it: the caller must already hold a valid, admin-issued, revocable `invoke:write` key, and every acknowledgement is serialized through the single monitor worker session's STA, so guesses run one at a time. The gateway does **not** throttle failed operator authentications: the `ApiKeyFailureLimiter` counts only failed API-key verifications, and the ASP.NET Core rate limiter covers only the dashboard `/login` endpoint. Any lockout after repeated bad passwords comes from the Galaxy security configuration, not from the gateway. Grant `invoke:write` only to clients trusted with that exposure. `StreamAlarms` and `QueryActiveAlarms` share the alarm/event surface with `StreamEvents` and `MxCommandKind.DrainEvents`, so they carry `events:read` (the active-alarm snapshot is the same data reachable through the event surface). All three alarm RPCs are session-less: the scope check is the only authorization gate, since there is no per-session ownership to enforce. `MxCommandRequest` is special because it multiplexes many MxAccess operations through a single RPC. The resolver inspects the embedded `MxCommandKind` so each operation gets its own scope: diff --git a/docs/DesignDecisions.md b/docs/DesignDecisions.md index 66ff30b..d8ed73d 100644 --- a/docs/DesignDecisions.md +++ b/docs/DesignDecisions.md @@ -818,6 +818,14 @@ per-request credential is workable. See - The managed ack consumer, the WNAL native ack consumer, the second wnwrap ack-only consumer and the opt-in managed-ack environment flag are removed. +**Upgrade note.** This is a breaking change for callers. They must now send +`operator_user` (required; empty is rejected with `InvalidArgument`) and the +operator's Galaxy password; a caller that sends no password for a +password-protected account gets `MXACCESS_FAILURE` from `AuthenticateUser` +instead of an acknowledgement. OtOpcUa's `GalaxyDriver` alarm acknowledger is a +known caller that must be updated to forward the operator's credentials before +the gateway is upgraded. + ## Related Documentation - [Gateway Process Detailed Design](./GatewayProcessDesign.md) diff --git a/docs/Grpc.md b/docs/Grpc.md index 93333bd..cdccebc 100644 --- a/docs/Grpc.md +++ b/docs/Grpc.md @@ -110,11 +110,17 @@ The worker calls `AuthenticateUser` as the operator, then writes `operator_comme |---|---|---| | Write applied, or sent but no `OnWriteComplete` within the wait (unconfirmed, with a diagnostic) | `OK` | `0` | | Unknown alarm, or no ack target (non-Galaxy provider, no ack-comment subtag) | `INVALID_REQUEST` | `-1` | -| `AuthenticateUser` failed | `MXACCESS_FAILURE` | the `AuthenticateUser` HRESULT | -| Write rejected (`OnWriteComplete` non-OK) | `MXACCESS_FAILURE` | the `detail` of the `OnWriteComplete` `MXSTATUS_PROXY` | +| `AuthenticateUser` raised an MXAccess error (bad user or wrong password surfaces as `E_INVALIDARG` `0x80070057`, raised by the interop as `ArgumentException`; `COMException` and `UnauthorizedAccessException` are classified the same way) | `MXACCESS_FAILURE` | the exception's HRESULT | +| `AuthenticateUser` returned user id `0` | `MXACCESS_FAILURE` | `-1` | +| `AuthenticateUser` failed with a server-gone HRESULT (`RPC_E_DISCONNECTED`, `RPC_S_SERVER_UNAVAILABLE`, `RPC_E_SERVERFAULT`); the writer tears down its MXAccess registration and re-registers on the next ack | `MXACCESS_FAILURE` | that HRESULT | +| `AddItem`, `Advise` or `Write` on the `AckMsg` item raised an MXAccess error (`COMException`, or `ArgumentException` for `E_INVALIDARG`); the item is dropped and re-added on the next ack, and a server-gone HRESULT also tears down the registration | `MXACCESS_FAILURE` | the exception's HRESULT | +| Write rejected (`OnWriteComplete` non-OK) | `MXACCESS_FAILURE` | the `detail` of the `OnWriteComplete` `MXSTATUS_PROXY`, or `-1` when `detail` is `0` | +| Any other exception escaping the ack (e.g. the ack writer's MXAccess `Register` failing, or the monitor's worker session having no active alarm subscription — `InvalidOperationException`) | `MXACCESS_FAILURE` | the exception's `HResult`; the diagnostic names only the exception type and HRESULT | `reply.Statuses` carries the `OnWriteComplete` `MxStatusProxy` rows when they were observed. Because an unconfirmed write also returns `OK`, a client that must be certain should watch for the resulting transition on `StreamAlarms`. `AcknowledgeAlarm` stays under the `invoke:write` scope; see [Authorization](./Authorization.md). +**Upgrade note.** Callers must now send `operator_user` (required) and the operator's Galaxy password in `operator_password`. A caller that sends no `operator_user` gets `InvalidArgument`; a caller that sends no password for a password-protected Galaxy account gets `MXACCESS_FAILURE` from `AuthenticateUser` instead of an acknowledgement. Every existing caller must be updated before the gateway is upgraded — known callers include OtOpcUa's `GalaxyDriver` alarm acknowledger, which must supply the operator's credentials. + ### `StreamAlarms` `StreamAlarms` is a server-streaming, **session-less** RPC that attaches to the gateway's central alarm feed. The handler delegates to `IGatewayAlarmService.StreamAsync`. The stream opens with a `provider_status` and a `snapshot_status` `AlarmFeedMessage` (the current provider mode and snapshot-completeness verdict), then one `AlarmFeedMessage` carrying an `active_alarm` per currently-active alarm (the ConditionRefresh snapshot), then a single `snapshot_complete`, then a `transition` for every subsequent raise / acknowledge / clear — interleaved with a further `provider_status` on each failover/failback and a further `snapshot_status` on each change of the truncation verdict. It is served by the always-on `GatewayAlarmMonitor`, which owns a single gateway-managed worker session and fans out to every attached client — clients no longer open a session of their own. `alarm_filter_prefix`, when set, scopes the stream to a sub-tree. diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmAckWriterTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmAckWriterTests.cs index fc1630b..2332d8d 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmAckWriterTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmAckWriterTests.cs @@ -84,7 +84,9 @@ public sealed class AlarmAckWriterTests public void Acknowledge_Twice_ReusesItemHandle() { RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 }; - using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(10), "MxGateway"); + 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"); writer.Acknowledge("T.A.AckMsg", "one", Alice()); writer.Acknowledge("T.A.AckMsg", "two", Alice()); @@ -114,7 +116,9 @@ public sealed class AlarmAckWriterTests public void Acknowledge_WhenCachedWriteThrows_RejectsAndReAddsNextTime() { RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 }; - using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(10), "MxGateway"); + 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"); writer.Acknowledge("T.A.AckMsg", "one", Alice()); server.WriteException = new COMException("x", unchecked((int)0x80004005)); @@ -141,6 +145,130 @@ public sealed class AlarmAckWriterTests Assert.Contains("Unregister", server.Operations); } + [Fact] + public void Acknowledge_WhenAuthenticateUserDisconnected_TearsDownAndRejects() + { + RecordingAckServer server = new RecordingAckServer + { + AuthenticateResult = 5, + AuthenticateException = new COMException("gone pw-secret", unchecked((int)0x80010108)), + }; + using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(10), "MxGateway"); + + AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "one", Alice()); + + Assert.Equal(AlarmAckOutcome.WriteRejected, result.Outcome); + Assert.Equal(unchecked((int)0x80010108), result.NativeStatus); + Assert.Contains("AuthenticateUser", result.Diagnostic, StringComparison.Ordinal); + Assert.Contains("0x80010108", result.Diagnostic, StringComparison.Ordinal); + Assert.DoesNotContain("pw-secret", result.Diagnostic, StringComparison.Ordinal); + Assert.Contains("Unregister", server.Operations); + Assert.DoesNotContain(server.Operations, op => op.StartsWith("AddItem", StringComparison.Ordinal)); + } + + [Fact] + public void Acknowledge_WhenAuthenticateUserThrowsArgumentException_ReturnsAuthenticationFailed() + { + // Live MXAccess raises E_INVALIDARG for a bad user or wrong password, which the + // interop surfaces as ArgumentException rather than COMException. + RecordingAckServer server = new RecordingAckServer + { + AuthenticateResult = 5, + AuthenticateException = new ArgumentException("Value does not fall within the expected range. pw-secret"), + }; + using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(10), "MxGateway"); + + AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "one", Alice()); + + Assert.Equal(AlarmAckOutcome.AuthenticationFailed, result.Outcome); + Assert.Equal(unchecked((int)0x80070057), result.NativeStatus); + Assert.DoesNotContain("pw-secret", result.Diagnostic, StringComparison.Ordinal); + Assert.DoesNotContain(server.Operations, op => op.StartsWith("Write", StringComparison.Ordinal)); + Assert.DoesNotContain("Unregister", server.Operations); + } + + [Fact] + public void Acknowledge_WhenWriteThrowsArgumentException_RejectsAndDropsItem() + { + RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5, WriteException = new ArgumentException("bad") }; + using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(10), "MxGateway"); + + AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "one", Alice()); + + Assert.Equal(AlarmAckOutcome.WriteRejected, result.Outcome); + Assert.Equal(unchecked((int)0x80070057), result.NativeStatus); + Assert.Contains("Write", result.Diagnostic, StringComparison.Ordinal); + Assert.Equal(new[] { 100 }, server.RemovedItemHandles); + Assert.DoesNotContain("Unregister", server.Operations); + } + + [Fact] + public void Acknowledge_PastCacheCap_EvictsLeastRecentlyUsedItem() + { + RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 }; + using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(1), "MxGateway", maxCachedItems: 2); + + writer.Acknowledge("A.X.AckMsg", "1", Alice()); // handle 100 + writer.Acknowledge("B.X.AckMsg", "2", Alice()); // handle 101 + writer.Acknowledge("C.X.AckMsg", "3", Alice()); // handle 102, evicts A (100) + + Assert.Equal(new[] { 100 }, server.RemovedItemHandles); + Assert.Equal(3, server.Operations.Count(op => op.StartsWith("AddItem", StringComparison.Ordinal))); + } + + [Fact] + public void Acknowledge_ReusedItem_IsMostRecentlyUsedForEviction() + { + 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", maxCachedItems: 2); + + writer.Acknowledge("A.X.AckMsg", "1", Alice()); // handle 100 + writer.Acknowledge("B.X.AckMsg", "2", Alice()); // handle 101 + writer.Acknowledge("A.X.AckMsg", "3", Alice()); // reuse 100, now most recent + writer.Acknowledge("C.X.AckMsg", "4", Alice()); // handle 102, evicts B (101) + + Assert.Equal(new[] { 101 }, server.RemovedItemHandles); + Assert.Equal(3, server.Operations.Count(op => op.StartsWith("AddItem", StringComparison.Ordinal))); + Assert.Contains("UnAdvise", server.Operations); + } + + [Fact] + public void Acknowledge_AfterUnconfirmed_UsesFreshHandleSoStaleCompletionIsNotCredited() + { + RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 }; + MxAccessWriteCompletionCache cache = new MxAccessWriteCompletionCache(); + using AlarmAckWriter writer = new AlarmAckWriter(server, cache, () => { }, TimeSpan.FromMilliseconds(50), "MxGateway"); + + AlarmAckResult first = writer.Acknowledge("T.A.AckMsg", "one", Alice()); // handle 100, no completion + // The first write's late OnWriteComplete (handle 100) lands while the + // second write is in flight; the second write's own completion never comes. + server.OnWrite = (handle, item) => cache.Record(handle, 100, Rows(MxStatusCategory.Ok, 0)); + AlarmAckResult second = writer.Acknowledge("T.A.AckMsg", "two", Alice()); + + Assert.Equal(AlarmAckOutcome.Unconfirmed, first.Outcome); + Assert.Equal(AlarmAckOutcome.Unconfirmed, second.Outcome); + Assert.Contains(100, server.RemovedItemHandles); + Assert.Equal(2, server.Operations.Count(op => op.StartsWith("AddItem", StringComparison.Ordinal))); + Assert.Equal(new[] { 100, 101 }, server.WrittenItemHandles); + } + + [Fact] + public void Acknowledge_AfterConfirmed_ReusesHandle() + { + 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"); + + writer.Acknowledge("T.A.AckMsg", "one", Alice()); + writer.Acknowledge("T.A.AckMsg", "two", Alice()); + + Assert.Equal(new[] { 100, 100 }, server.WrittenItemHandles); + Assert.Empty(server.RemovedItemHandles); + } + [Fact] public void Acknowledge_WhenNonOkCompletionHasNoDetail_ReturnsMinusOne() { @@ -180,6 +308,10 @@ internal sealed class RecordingAckServer : IMxAccessServer public Action? OnWrite { get; set; } + public List RemovedItemHandles { get; } = new(); + + public List WrittenItemHandles { get; } = new(); + public int Register(string clientName) { Operations.Add("Register:" + clientName); @@ -196,7 +328,11 @@ internal sealed class RecordingAckServer : IMxAccessServer public int AddItem2(int serverHandle, string itemDefinition, string itemContext) => AddItem(serverHandle, itemDefinition); - public void RemoveItem(int serverHandle, int itemHandle) => Operations.Add("RemoveItem"); + public void RemoveItem(int serverHandle, int itemHandle) + { + Operations.Add("RemoveItem"); + RemovedItemHandles.Add(itemHandle); + } public void Advise(int serverHandle, int itemHandle) { @@ -216,6 +352,7 @@ internal sealed class RecordingAckServer : IMxAccessServer public void Write(int serverHandle, int itemHandle, object? value, int userId) { Operations.Add("Write:" + value + ":" + userId); + WrittenItemHandles.Add(itemHandle); if (WriteException is not null) { throw WriteException; diff --git a/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerPipeSession.cs b/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerPipeSession.cs index c9afa8d..f4f2de0 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerPipeSession.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerPipeSession.cs @@ -76,7 +76,7 @@ public sealed class WorkerPipeSession options, () => Process.GetCurrentProcess().Id, new WorkerPipeSessionOptions(), - () => new MxAccessStaSession((eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null)), + () => new MxAccessStaSession((eq, affinity, comFactory, pump) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null, ackPumpStep: pump)), logger, stream) { @@ -98,7 +98,7 @@ public sealed class WorkerPipeSession options, processIdProvider, new WorkerPipeSessionOptions(), - () => new MxAccessStaSession((eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null)), + () => new MxAccessStaSession((eq, affinity, comFactory, pump) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null, ackPumpStep: pump)), logger: null) { } @@ -1585,7 +1585,7 @@ public sealed class WorkerPipeSession // parameterless CompleteStartupHandshakeAsync is used without a // prior factory call. _runtimeSession ??= new MxAccessStaSession( - (eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null)); + (eq, affinity, comFactory, pump) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null, ackPumpStep: pump)); IWorkerRuntimeSession session = _runtimeSession; try { diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckWriter.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckWriter.cs index ce9bb39..fafe755 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckWriter.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckWriter.cs @@ -26,13 +26,25 @@ public sealed class AlarmAckWriter : IAlarmAckWriter /// Default MXAccess client name when none is configured. public const string DefaultClientName = "MxGateway"; + /// + /// Default cap on cached ack item handles. Each cached item holds an + /// advised MXAccess item for the worker's life, so the cache is bounded + /// (least-recently-used eviction) rather than growing with every + /// distinct alarm ever acknowledged. + /// + public const int DefaultMaxCachedItems = 256; + private readonly IMxAccessComObjectFactory? factory; private readonly MxAccessWriteCompletionCache completionCache; private readonly Action pumpStep; private readonly TimeSpan completionTimeout; private readonly string clientName; private readonly MxStatusProxyConverter statusConverter = new MxStatusProxyConverter(); - private readonly Dictionary itemHandles = new Dictionary(StringComparer.OrdinalIgnoreCase); + private readonly int maxCachedItems; + private readonly Dictionary> itemHandles = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + // Most-recently-used first; the tail is the eviction candidate. + private readonly LinkedList itemLru = new LinkedList(); private object? comObject; private LMXProxyServerClass? comEventSource; private IMxAccessServer? server; @@ -54,6 +66,7 @@ public sealed class AlarmAckWriter : IAlarmAckWriter this.pumpStep = pumpStep ?? throw new ArgumentNullException(nameof(pumpStep)); this.completionTimeout = completionTimeout; this.clientName = string.IsNullOrWhiteSpace(clientName) ? DefaultClientName : clientName!; + maxCachedItems = DefaultMaxCachedItems; completionCache = new MxAccessWriteCompletionCache(); } @@ -63,8 +76,11 @@ public sealed class AlarmAckWriter : IAlarmAckWriter MxAccessWriteCompletionCache completionCache, Action pumpStep, TimeSpan completionTimeout, - string clientName) + string clientName, + int maxCachedItems = DefaultMaxCachedItems) { + if (maxCachedItems < 1) throw new ArgumentOutOfRangeException(nameof(maxCachedItems)); + this.maxCachedItems = maxCachedItems; this.server = server ?? throw new ArgumentNullException(nameof(server)); this.completionCache = completionCache ?? throw new ArgumentNullException(nameof(completionCache)); this.pumpStep = pumpStep ?? throw new ArgumentNullException(nameof(pumpStep)); @@ -95,8 +111,22 @@ public sealed class AlarmAckWriter : IAlarmAckWriter { userId = mx.AuthenticateUser(serverHandle, ackOperator.VerifyUser, ackOperator.Password ?? string.Empty); } - catch (COMException ex) + catch (Exception ex) when (IsInteropFailure(ex)) { + // Bad credentials surface as ArgumentException (E_INVALIDARG) from the + // interop, not only COMException, so classify by the carried HRESULT. + int authHresult = ex.HResult; + if (IsServerGone(authHresult)) + { + // The registration is dead, not the credentials: tear down so the + // next ack re-registers, and report it as a write-path failure. + TearDownServer(); + return AlarmAckResult.Create( + AlarmAckOutcome.WriteRejected, + authHresult, + $"AckMsg AuthenticateUser failed (HRESULT 0x{authHresult:X8})."); + } + return AlarmAckResult.Create( AlarmAckOutcome.AuthenticationFailed, ex.HResult, @@ -113,14 +143,32 @@ public sealed class AlarmAckWriter : IAlarmAckWriter ulong baselineVersion = 0; int itemHandle = 0; - bool cached = itemHandles.TryGetValue(itemAddress, out itemHandle); + LinkedListNode? node; + bool cached = itemHandles.TryGetValue(itemAddress, out node); string step = "AddItem"; try { - if (!cached) + if (cached && node!.Value.Dirty) + { + // The previous ack to this item ended Unconfirmed, so its + // OnWriteComplete may still arrive. Completions are keyed by + // (server, item) handle with no per-write id, so a late one would + // be credited to this write. Drop the item and re-add it so this + // write waits on a fresh handle the stale completion cannot match. + DropItem(mx, node); + cached = false; + } + + if (cached) + { + itemHandle = node!.Value.Handle; + itemLru.Remove(node); + itemLru.AddFirst(node); + } + else { itemHandle = mx.AddItem(serverHandle, itemAddress); - itemHandles[itemAddress] = itemHandle; + node = CacheItem(mx, itemAddress, itemHandle); step = "Advise"; // MXAccess rejects writes to added-but-unadvised items with E_INVALIDARG. mx.Advise(serverHandle, itemHandle); @@ -131,13 +179,15 @@ public sealed class AlarmAckWriter : IAlarmAckWriter baselineVersion = completionCache.CurrentVersion(serverHandle, itemHandle); mx.Write(serverHandle, itemHandle, comment ?? string.Empty, userId); } - catch (COMException ex) + catch (Exception ex) when (IsInteropFailure(ex)) { + // The interop maps E_INVALIDARG to ArgumentException (and + // E_ACCESSDENIED to UnauthorizedAccessException), so the HRESULT, not + // the exception type, is what identifies the MXAccess failure. int hresult = ex.HResult; - if (itemHandles.Remove(itemAddress)) + if (itemHandles.TryGetValue(itemAddress, out LinkedListNode? failed)) { - try { mx.UnAdvise(serverHandle, itemHandle); } catch { /* best effort */ } - try { mx.RemoveItem(serverHandle, itemHandle); } catch { /* best effort */ } + DropItem(mx, failed!); } if (IsServerGone(hresult)) @@ -162,6 +212,7 @@ public sealed class AlarmAckWriter : IAlarmAckWriter pumpStep, out RepeatedField statuses)) { + node!.Value.Dirty = true; return AlarmAckResult.Create( AlarmAckOutcome.Unconfirmed, 0, @@ -192,6 +243,17 @@ public sealed class AlarmAckWriter : IAlarmAckWriter TearDownServer(); } + /// + /// Exceptions the MXAccess COM interop raises for a failed HRESULT: the + /// runtime maps well-known HRESULTs to typed exceptions (E_INVALIDARG to + /// ArgumentException, E_ACCESSDENIED to UnauthorizedAccessException) and + /// the rest to COMException. Each carries the HRESULT in Exception.HResult. + /// + private static bool IsInteropFailure(Exception ex) => + ex is ExternalException + || ex is ArgumentException + || ex is UnauthorizedAccessException; + private static bool IsServerGone(int hresult) => hresult == unchecked((int)0x80010108) // RPC_E_DISCONNECTED || hresult == unchecked((int)0x800706BA) // RPC_S_SERVER_UNAVAILABLE @@ -208,16 +270,17 @@ public sealed class AlarmAckWriter : IAlarmAckWriter IMxAccessServer? mx = server; if (mx is not null) { - foreach (int itemHandle in itemHandles.Values) + foreach (AckItem item in itemLru) { - try { mx.UnAdvise(serverHandle, itemHandle); } catch { /* best effort */ } - try { mx.RemoveItem(serverHandle, itemHandle); } catch { /* best effort */ } + try { mx.UnAdvise(serverHandle, item.Handle); } catch { /* best effort */ } + try { mx.RemoveItem(serverHandle, item.Handle); } catch { /* best effort */ } } try { mx.Unregister(serverHandle); } catch { /* best effort */ } } itemHandles.Clear(); + itemLru.Clear(); server = null; comEventSource = null; object? toRelease = comObject; @@ -228,6 +291,28 @@ public sealed class AlarmAckWriter : IAlarmAckWriter } } + /// Caches a newly added item as most recently used, evicting the least recently used past the cap. + private LinkedListNode CacheItem(IMxAccessServer mx, string itemAddress, int itemHandle) + { + while (itemLru.Count >= maxCachedItems && itemLru.Last is { } leastRecent) + { + DropItem(mx, leastRecent); + } + + LinkedListNode node = itemLru.AddFirst(new AckItem(itemAddress, itemHandle)); + itemHandles[itemAddress] = node; + return node; + } + + /// Removes a cached item and best-effort releases its MXAccess item. + private void DropItem(IMxAccessServer mx, LinkedListNode node) + { + itemHandles.Remove(node.Value.Address); + itemLru.Remove(node); + try { mx.UnAdvise(serverHandle, node.Value.Handle); } catch { /* best effort */ } + try { mx.RemoveItem(serverHandle, node.Value.Handle); } catch { /* best effort */ } + } + private IMxAccessServer EnsureRegistered() { if (server is not null) return server; @@ -276,4 +361,21 @@ public sealed class AlarmAckWriter : IAlarmAckWriter rows.AddRange(statusConverter.ConvertMany(pVars)); completionCache.Record(hLMXServerHandle, phItemHandle, rows); } + + /// A cached ack item. Plain class (not a record) for the net48 worker target. + private sealed class AckItem + { + public AckItem(string address, int handle) + { + Address = address; + Handle = handle; + } + + public string Address { get; } + + public int Handle { get; } + + /// Set when the last write to this handle ended Unconfirmed; forces a fresh handle next time. + public bool Dirty { get; set; } + } } diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmCommandHandler.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmCommandHandler.cs index 3648511..67273e9 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmCommandHandler.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmCommandHandler.cs @@ -41,6 +41,7 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler private readonly IMxAccessComObjectFactory? comFactory; private readonly Func, IMxAccessAlarmConsumer>? standbyFactory; private readonly Func? ackWriterFactory; + private readonly Action? ackPumpStep; private readonly MxAccessEventMapper mapper = new MxAccessEventMapper(); private readonly object syncRoot = new object(); private readonly IAlarmAckWriter deferredAckWriter; @@ -131,13 +132,21 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler /// unsubscribe/resubscribe, and is disposed with the handler. Tests /// inject a fake. /// + /// + /// Pump step the default drives while it waits + /// for OnWriteComplete. Production passes the STA runtime's + /// StaRuntime.PumpPendingMessages, which also refreshes the STA + /// watchdog heartbeat; when a bare + /// is used (no heartbeat refresh). + /// public AlarmCommandHandler( MxAccessEventQueue eventQueue, Func consumerFactory, Action? threadAffinityCheck, IMxAccessComObjectFactory? comFactory, Func, IMxAccessAlarmConsumer>? standbyFactory, - Func? ackWriterFactory = null) + Func? ackWriterFactory = null, + Action? ackPumpStep = null) { this.eventQueue = eventQueue ?? throw new ArgumentNullException(nameof(eventQueue)); this.consumerFactory = consumerFactory ?? throw new ArgumentNullException(nameof(consumerFactory)); @@ -145,6 +154,7 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler this.comFactory = comFactory; this.standbyFactory = standbyFactory; this.ackWriterFactory = ackWriterFactory; + this.ackPumpStep = ackPumpStep; this.deferredAckWriter = new DeferredAckWriter(this); } @@ -399,12 +409,14 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler "Alarm acknowledgement requires an IMxAccessComObjectFactory; the alarm command handler was constructed without one."); } - // StaMessagePump.PumpPendingMessages is PeekMessage/DispatchMessage on - // the calling thread — the STA, since acks run there — which is what - // delivers the writer's OnWriteComplete callback. + // The pump step is PeekMessage/DispatchMessage on the calling thread — + // the STA, since acks run there — which is what delivers the writer's + // OnWriteComplete callback. The injected StaRuntime pump also marks STA + // activity so a long completion wait does not read as a hung STA. + Action pump = ackPumpStep ?? (() => new StaMessagePump().PumpPendingMessages()); ackWriter = new AlarmAckWriter( comFactory, - () => new StaMessagePump().PumpPendingMessages(), + pump, MxAccessStaSession.ResolveWriteCompletionTimeout(), AlarmAckWriter.ResolveClientName()); return ackWriter; diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessStaSession.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessStaSession.cs index ef9022a..086188f 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessStaSession.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessStaSession.cs @@ -48,7 +48,10 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession private readonly IMxAccessEventSink eventSink; private readonly MxAccessEventQueue eventQueue; private readonly StaRuntime staRuntime; - private readonly Func? alarmCommandHandlerFactory; + // Pump-aware form: the fourth argument is the STA runtime's pump step, so + // handler work that pumps while it waits (the alarm ack writer's + // OnWriteComplete wait) also refreshes the STA watchdog heartbeat. + private readonly Func? alarmCommandHandlerFactory; private StaCommandDispatcher? commandDispatcher; private MxAccessSession? session; private IAlarmCommandHandler? alarmCommandHandler; @@ -96,6 +99,23 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession { } + /// + /// Initializes a new instance of with default dependencies and a + /// pump-aware alarm-command handler factory. Its fourth argument is a pump step bound to this + /// session's (), which + /// refreshes the STA watchdog heartbeat on every pump. + /// + /// Factory that constructs the alarm-command handler. + internal MxAccessStaSession(Func pumpAwareAlarmCommandHandlerFactory) + { + staRuntime = new StaRuntime(); + factory = new MxAccessComObjectFactory(); + eventQueue = new MxAccessEventQueue(MxAccessEventQueue.ResolveCapacity()); + eventSink = new MxAccessBaseEventSink(eventQueue); + alarmCommandHandlerFactory = pumpAwareAlarmCommandHandlerFactory + ?? throw new ArgumentNullException(nameof(pumpAwareAlarmCommandHandlerFactory)); + } + /// /// Initializes a new instance of with custom STA runtime and factory. /// @@ -183,7 +203,9 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession this.factory = factory ?? throw new ArgumentNullException(nameof(factory)); this.eventSink = eventSink ?? throw new ArgumentNullException(nameof(eventSink)); this.eventQueue = eventQueue ?? throw new ArgumentNullException(nameof(eventQueue)); - this.alarmCommandHandlerFactory = alarmCommandHandlerFactory; + this.alarmCommandHandlerFactory = alarmCommandHandlerFactory is null + ? null + : (eq, affinity, comFactory, _) => alarmCommandHandlerFactory(eq, affinity, comFactory); } /// @@ -288,7 +310,8 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession alarmCommandHandler = alarmCommandHandlerFactory( eventQueue, EnsureOnAlarmConsumerThread, - factory); + factory, + () => staRuntime.PumpPendingMessages()); } commandDispatcher = new StaCommandDispatcher( staRuntime, From 3e988ede886b1cfe4777fabfd5415c8d8080bce7 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 19 Oct 2026 20:07:36 -0400 Subject: [PATCH 21/25] docs: record gateway-level live verification of AckMsg-as-operator acks Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT --- docs/AlarmProbeFindings.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/AlarmProbeFindings.md b/docs/AlarmProbeFindings.md index f7d9f59..15ab240 100644 --- a/docs/AlarmProbeFindings.md +++ b/docs/AlarmProbeFindings.md @@ -492,3 +492,26 @@ The gateway acknowledges by authenticating the operator and writing `AckMsg` (`docs/DesignDecisions.md`, "Alarm acknowledgement writes AckMsg as the authenticated operator"). The managed ack consumer and the second wnwrap ack consumer were removed. + +### Gateway-level verification + +The same result holds end to end through the gateway. With the feature build deployed on +the windev rig and `TestAlarm001` raised, an `AcknowledgeAlarm` RPC for +`Galaxy!TestArea.TestMachine_001.TestAlarm001` with `operator_user=Administrator` and an +empty password returned `PROTOCOL_STATUS_CODE_OK`, `hresult` 0. `.Acked` turned True, +`.AckMsg` held the comment, and the Historian logged the `ACK_ALM` row with +`Operator=Administrator`, `DomainName=ArchestrA`, `UserFullName=Administrator`, +`OperatorNode=MxGateway` (the default `AckClientName`), and `Description` = the comment. + +A wrong password, or a user Galaxy security does not know, makes `AuthenticateUser` +throw `ArgumentException` (`E_INVALIDARG`, 0x80070057) rather than a `COMException`. +That is why the writer classifies interop failures by HRESULT across exception types. +Such an ack returns `PROTOCOL_STATUS_CODE_MXACCESS_FAILURE`, the alarm stays unacked, +the Historian gains no ack row, and the password string does not appear in the gateway +log, `stdout.log`, or `stderr.log`. A non-Galaxy provider reference returns +`PROTOCOL_STATUS_CODE_INVALID_REQUEST` with `hresult` -1, and an empty `operator_user` +returns gRPC `InvalidArgument`. + +The GUID arm of the ack command was not driven live. The public alarm RPCs expose only +the full reference, so clients reach the by-name arm; worker unit tests cover the GUID +arm. From 219611c82194af11dad45ad9352f3c0203683220 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 20 Oct 2026 07:09:02 -0400 Subject: [PATCH 22/25] fix(worker): narrow AlarmAckWriter ArgumentException interop filter Resolve the operator user, password and comment before the interop try blocks, and match ArgumentException as an MXAccess failure only when it carries E_INVALIDARG and is not ArgumentNullException or ArgumentOutOfRangeException. A programming error inside the try blocks now propagates instead of being reported as an authentication or write failure. Fixes #164 Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT --- docs/Grpc.md | 2 +- .../MxAccess/AlarmAckWriterTests.cs | 74 +++++++++++++++++++ .../MxAccess/AlarmAckWriter.cs | 36 +++++++-- 3 files changed, 105 insertions(+), 7 deletions(-) diff --git a/docs/Grpc.md b/docs/Grpc.md index cdccebc..8873ed9 100644 --- a/docs/Grpc.md +++ b/docs/Grpc.md @@ -115,7 +115,7 @@ The worker calls `AuthenticateUser` as the operator, then writes `operator_comme | `AuthenticateUser` failed with a server-gone HRESULT (`RPC_E_DISCONNECTED`, `RPC_S_SERVER_UNAVAILABLE`, `RPC_E_SERVERFAULT`); the writer tears down its MXAccess registration and re-registers on the next ack | `MXACCESS_FAILURE` | that HRESULT | | `AddItem`, `Advise` or `Write` on the `AckMsg` item raised an MXAccess error (`COMException`, or `ArgumentException` for `E_INVALIDARG`); the item is dropped and re-added on the next ack, and a server-gone HRESULT also tears down the registration | `MXACCESS_FAILURE` | the exception's HRESULT | | Write rejected (`OnWriteComplete` non-OK) | `MXACCESS_FAILURE` | the `detail` of the `OnWriteComplete` `MXSTATUS_PROXY`, or `-1` when `detail` is `0` | -| Any other exception escaping the ack (e.g. the ack writer's MXAccess `Register` failing, or the monitor's worker session having no active alarm subscription — `InvalidOperationException`) | `MXACCESS_FAILURE` | the exception's `HResult`; the diagnostic names only the exception type and HRESULT | +| Any other exception escaping the ack (e.g. the ack writer's MXAccess `Register` failing, the monitor's worker session having no active alarm subscription — `InvalidOperationException` — or an argument error that is not the interop's `E_INVALIDARG`: `ArgumentNullException`, `ArgumentOutOfRangeException`, or an `ArgumentException` with another HRESULT, which signal a bug rather than an MXAccess failure) | `MXACCESS_FAILURE` | the exception's `HResult`; the diagnostic names only the exception type and HRESULT | `reply.Statuses` carries the `OnWriteComplete` `MxStatusProxy` rows when they were observed. Because an unconfirmed write also returns `OK`, a client that must be certain should watch for the resulting transition on `StreamAlarms`. `AcknowledgeAlarm` stays under the `invoke:write` scope; see [Authorization](./Authorization.md). diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmAckWriterTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmAckWriterTests.cs index 2332d8d..0714173 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmAckWriterTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmAckWriterTests.cs @@ -187,6 +187,70 @@ public sealed class AlarmAckWriterTests Assert.DoesNotContain("Unregister", server.Operations); } + [Fact] + public void Acknowledge_WhenAuthenticateUserThrowsArgumentExceptionWithEInvalidArg_ReturnsAuthenticationFailed() + { + // The HRESULT is what the narrowed filter keys on; set it explicitly so the + // test does not depend on ArgumentException's default HResult. + RecordingAckServer server = new RecordingAckServer + { + AuthenticateResult = 5, + AuthenticateException = new HResultArgumentException("bad credentials", unchecked((int)0x80070057)), + }; + using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(10), "MxGateway"); + + AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "one", Alice()); + + Assert.Equal(AlarmAckOutcome.AuthenticationFailed, result.Outcome); + Assert.Equal(unchecked((int)0x80070057), result.NativeStatus); + } + + [Fact] + public void Acknowledge_WhenAuthenticateUserThrowsArgumentNullException_Propagates() + { + // A programming error is a bug, not an operator authentication failure. + RecordingAckServer server = new RecordingAckServer + { + AuthenticateResult = 5, + AuthenticateException = new ArgumentNullException("verifyUser"), + }; + using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(10), "MxGateway"); + + Assert.Throws(() => writer.Acknowledge("T.A.AckMsg", "one", Alice())); + } + + [Fact] + public void Acknowledge_WhenAuthenticateUserThrowsArgumentExceptionWithOtherHResult_Propagates() + { + RecordingAckServer server = new RecordingAckServer + { + AuthenticateResult = 5, + AuthenticateException = new HResultArgumentException("not interop", unchecked((int)0x80131500)), + }; + using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(10), "MxGateway"); + + Assert.Throws(() => writer.Acknowledge("T.A.AckMsg", "one", Alice())); + } + + [Fact] + public void Acknowledge_WhenWriteThrowsArgumentOutOfRangeException_Propagates() + { + RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5, WriteException = new ArgumentOutOfRangeException("value") }; + using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(10), "MxGateway"); + + Assert.Throws(() => writer.Acknowledge("T.A.AckMsg", "one", Alice())); + } + + [Fact] + public void Acknowledge_WhenOperatorUserNameIsNull_ThrowsBeforeAuthenticating() + { + RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 }; + using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(10), "MxGateway"); + + Assert.Throws(() => writer.Acknowledge("T.A.AckMsg", "one", new AlarmAckOperator { UserName = null! })); + Assert.DoesNotContain(server.Operations, op => op.StartsWith("AuthenticateUser", StringComparison.Ordinal)); + } + [Fact] public void Acknowledge_WhenWriteThrowsArgumentException_RejectsAndDropsItem() { @@ -290,6 +354,16 @@ public sealed class AlarmAckWriterTests } } +/// ArgumentException with an explicit HRESULT (the setter is protected on net48). +internal sealed class HResultArgumentException : ArgumentException +{ + public HResultArgumentException(string message, int hresult) + : base(message) + { + HResult = hresult; + } +} + internal sealed class RecordingAckServer : IMxAccessServer { private int nextItemHandle = 100; diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckWriter.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckWriter.cs index fafe755..eed231d 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckWriter.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckWriter.cs @@ -34,6 +34,9 @@ public sealed class AlarmAckWriter : IAlarmAckWriter /// public const int DefaultMaxCachedItems = 256; + /// E_INVALIDARG, which the interop raises as ArgumentException. + private const int EInvalidArg = unchecked((int)0x80070057); + private readonly IMxAccessComObjectFactory? factory; private readonly MxAccessWriteCompletionCache completionCache; private readonly Action pumpStep; @@ -104,12 +107,20 @@ public sealed class AlarmAckWriter : IAlarmAckWriter if (string.IsNullOrWhiteSpace(itemAddress)) throw new ArgumentException("Ack item address is required.", nameof(itemAddress)); if (ackOperator is null) throw new ArgumentNullException(nameof(ackOperator)); + // Resolve every argument before the interop try blocks: their catch + // filters accept E_INVALIDARG ArgumentExceptions as MXAccess failures, so + // an argument error raised by this code must fail before reaching them. + if (ackOperator.UserName is null) throw new ArgumentException("Operator user name is required.", nameof(ackOperator)); + string verifyUser = ackOperator.VerifyUser; + string password = ackOperator.Password ?? string.Empty; + string commentValue = comment ?? string.Empty; + IMxAccessServer mx = EnsureRegistered(); int userId; try { - userId = mx.AuthenticateUser(serverHandle, ackOperator.VerifyUser, ackOperator.Password ?? string.Empty); + userId = mx.AuthenticateUser(serverHandle, verifyUser, password); } catch (Exception ex) when (IsInteropFailure(ex)) { @@ -130,7 +141,7 @@ public sealed class AlarmAckWriter : IAlarmAckWriter return AlarmAckResult.Create( AlarmAckOutcome.AuthenticationFailed, ex.HResult, - $"Operator authentication failed for '{ackOperator.VerifyUser}' (HRESULT 0x{ex.HResult:X8})."); + $"Operator authentication failed for '{verifyUser}' (HRESULT 0x{ex.HResult:X8})."); } if (userId == 0) @@ -138,7 +149,7 @@ public sealed class AlarmAckWriter : IAlarmAckWriter return AlarmAckResult.Create( AlarmAckOutcome.AuthenticationFailed, -1, - $"Operator authentication failed for '{ackOperator.VerifyUser}' (no user id returned)."); + $"Operator authentication failed for '{verifyUser}' (no user id returned)."); } ulong baselineVersion = 0; @@ -177,7 +188,7 @@ public sealed class AlarmAckWriter : IAlarmAckWriter pumpStep(); step = "Write"; baselineVersion = completionCache.CurrentVersion(serverHandle, itemHandle); - mx.Write(serverHandle, itemHandle, comment ?? string.Empty, userId); + mx.Write(serverHandle, itemHandle, commentValue, userId); } catch (Exception ex) when (IsInteropFailure(ex)) { @@ -249,10 +260,23 @@ public sealed class AlarmAckWriter : IAlarmAckWriter /// ArgumentException, E_ACCESSDENIED to UnauthorizedAccessException) and /// the rest to COMException. Each carries the HRESULT in Exception.HResult. /// + /// + /// The ArgumentException arm exists because a wrong password or unknown + /// user makes AuthenticateUser throw ArgumentException with + /// E_INVALIDARG (0x80070057), observed live on windev on 2026-10-19. It + /// is narrowed to that HRESULT, and excludes ArgumentNullException and + /// ArgumentOutOfRangeException, so a programming error inside the try + /// blocks propagates as a bug instead of being reported as an MXAccess + /// authentication or write failure. The interop never raises those two + /// subtypes for a COM HRESULT. + /// private static bool IsInteropFailure(Exception ex) => ex is ExternalException - || ex is ArgumentException - || ex is UnauthorizedAccessException; + || ex is UnauthorizedAccessException + || (ex is ArgumentException + && ex is not ArgumentNullException + && ex is not ArgumentOutOfRangeException + && ex.HResult == EInvalidArg); private static bool IsServerGone(int hresult) => hresult == unchecked((int)0x80010108) // RPC_E_DISCONNECTED From f13950e4536c37e29705196b3f6b0b694fba1994 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 20 Oct 2026 07:09:59 -0400 Subject: [PATCH 23/25] fix(worker): forget write-completion entries for dropped ack item handles Add MxAccessWriteCompletionCache.Remove and RemoveServer and call them from AlarmAckWriter when it drops an item (dirty re-add, LRU eviction, failed AddItem/Advise/Write) and when it tears down its registration, so the cache no longer keeps one entry per dropped handle for the life of the worker. Fixes #165 Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT --- docs/MxAccessWorkerInstanceDesign.md | 11 +++ .../MxAccess/AlarmAckWriterTests.cs | 70 +++++++++++++++++++ .../MxAccessWriteCompletionCacheTests.cs | 33 +++++++++ .../MxAccess/AlarmAckWriter.cs | 7 ++ .../MxAccess/MxAccessWriteCompletionCache.cs | 51 ++++++++++++++ 5 files changed, 172 insertions(+) diff --git a/docs/MxAccessWorkerInstanceDesign.md b/docs/MxAccessWorkerInstanceDesign.md index 5af663e..b648b1a 100644 --- a/docs/MxAccessWorkerInstanceDesign.md +++ b/docs/MxAccessWorkerInstanceDesign.md @@ -351,6 +351,17 @@ native client correlating by handle has the identical ambiguity). The exposure is bounded: `statuses` is an enrichment field, and `Hresult`/`ProtocolStatus` stay acceptance-only regardless of which completion (if any) was attributed. +**The alarm ack writer narrows the window for acknowledgements.** +`AlarmAckWriter` registers its own MXAccess server with its own +`MxAccessWriteCompletionCache` and reuses one advised `AckMsg` item per alarm +(least-recently-used, capped at 256). After an ack ends unconfirmed it marks the +item dirty, and the next ack to that alarm removes and re-adds the item so the +write waits on a fresh item handle the stale completion cannot match. Every +drop of an item (dirty re-add, eviction, or a failed `AddItem`/`Advise`/`Write`) +also removes that handle's completion entry, and tearing down the registration +removes every entry for its server handle, so the cache does not grow by one +entry per dropped handle for the life of the worker. + ## Live alarm watch-list replacement An auto-mode `SubscribeAlarms` with `fallback_enabled = true` always builds a diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmAckWriterTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmAckWriterTests.cs index 0714173..48960bc 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmAckWriterTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmAckWriterTests.cs @@ -333,6 +333,76 @@ public sealed class AlarmAckWriterTests Assert.Empty(server.RemovedItemHandles); } + [Fact] + public void Acknowledge_AfterDirtyReAdd_ForgetsDroppedHandleCompletion() + { + RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 }; + MxAccessWriteCompletionCache cache = new MxAccessWriteCompletionCache(); + using AlarmAckWriter writer = new AlarmAckWriter(server, cache, () => { }, TimeSpan.FromMilliseconds(20), "MxGateway"); + + writer.Acknowledge("T.A.AckMsg", "one", Alice()); // handle 100, Unconfirmed + cache.Record(7, 100, Rows(MxStatusCategory.Ok, 0)); // its late completion + Assert.Equal(1, cache.Count); + server.OnWrite = (handle, item) => cache.Record(handle, item, Rows(MxStatusCategory.Ok, 0)); + + AlarmAckResult second = writer.Acknowledge("T.A.AckMsg", "two", Alice()); // drops 100, adds 101 + + Assert.Equal(AlarmAckOutcome.Applied, second.Outcome); + Assert.Equal(1, cache.Count); + Assert.Equal(0UL, cache.CurrentVersion(7, 100)); + Assert.Equal(1UL, cache.CurrentVersion(7, 101)); + } + + [Fact] + public void Acknowledge_PastCacheCap_ForgetsEvictedHandleCompletion() + { + 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", maxCachedItems: 2); + + writer.Acknowledge("A.X.AckMsg", "1", Alice()); // handle 100 + writer.Acknowledge("B.X.AckMsg", "2", Alice()); // handle 101 + Assert.Equal(2, cache.Count); + writer.Acknowledge("C.X.AckMsg", "3", Alice()); // handle 102, evicts A (100) + + Assert.Equal(2, cache.Count); + Assert.Equal(0UL, cache.CurrentVersion(7, 100)); + } + + [Fact] + public void Dispose_ForgetsServerCompletions() + { + RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 }; + MxAccessWriteCompletionCache cache = new MxAccessWriteCompletionCache(); + server.OnWrite = (handle, item) => cache.Record(handle, item, Rows(MxStatusCategory.Ok, 0)); + AlarmAckWriter writer = new AlarmAckWriter(server, cache, () => { }, TimeSpan.FromSeconds(1), "MxGateway"); + writer.Acknowledge("A.X.AckMsg", "1", Alice()); + writer.Acknowledge("B.X.AckMsg", "2", Alice()); + cache.Record(8, 100, Rows(MxStatusCategory.Ok, 0)); // another server's entry survives + + writer.Dispose(); + + Assert.Equal(1, cache.Count); + Assert.Equal(1UL, cache.CurrentVersion(8, 100)); + } + + [Fact] + public void Acknowledge_WhenWriteDisconnected_ForgetsServerCompletions() + { + 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"); + writer.Acknowledge("A.X.AckMsg", "1", Alice()); // handle 100 recorded + server.WriteException = new COMException("x", unchecked((int)0x80010108)); + + writer.Acknowledge("A.X.AckMsg", "2", Alice()); + + Assert.Contains("Unregister", server.Operations); + Assert.Equal(0, cache.Count); + } + [Fact] public void Acknowledge_WhenNonOkCompletionHasNoDetail_ReturnsMinusOne() { diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessWriteCompletionCacheTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessWriteCompletionCacheTests.cs index 8d4b1c8..c086c10 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessWriteCompletionCacheTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessWriteCompletionCacheTests.cs @@ -183,6 +183,39 @@ public sealed class MxAccessWriteCompletionCacheTests Assert.Equal(77, Assert.Single(statuses).Detail); } + /// Verifies that Remove forgets only the given handle pair. + [Fact] + public void Remove_ForgetsOnlyThatHandlePair() + { + MxAccessWriteCompletionCache cache = new(); + cache.Record(7, 21, BuildStatuses(detail: 1)); + cache.Record(7, 22, BuildStatuses(detail: 2)); + + cache.Remove(7, 21); + cache.Remove(7, 99); // unknown pair is a no-op + + Assert.Equal(1, cache.Count); + Assert.Equal(0UL, cache.CurrentVersion(7, 21)); + Assert.Equal(1UL, cache.CurrentVersion(7, 22)); + } + + /// Verifies that RemoveServer forgets every pair of that server and no other. + [Fact] + public void RemoveServer_ForgetsOnlyThatServersPairs() + { + MxAccessWriteCompletionCache cache = new(); + cache.Record(7, 21, BuildStatuses(detail: 1)); + cache.Record(7, -5, BuildStatuses(detail: 2)); + cache.Record(8, 21, BuildStatuses(detail: 3)); + cache.Record(-7, 21, BuildStatuses(detail: 4)); + + cache.RemoveServer(7); + + Assert.Equal(2, cache.Count); + Assert.Equal(1UL, cache.CurrentVersion(8, 21)); + Assert.Equal(1UL, cache.CurrentVersion(-7, 21)); + } + private static RepeatedField BuildStatuses(int detail) { return new RepeatedField diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckWriter.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckWriter.cs index eed231d..1538607 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckWriter.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckWriter.cs @@ -303,6 +303,9 @@ public sealed class AlarmAckWriter : IAlarmAckWriter try { mx.Unregister(serverHandle); } catch { /* best effort */ } } + // The registration is gone, so no completion can arrive for its handles. + completionCache.RemoveServer(serverHandle); + itemHandles.Clear(); itemLru.Clear(); server = null; @@ -335,6 +338,10 @@ public sealed class AlarmAckWriter : IAlarmAckWriter itemLru.Remove(node); try { mx.UnAdvise(serverHandle, node.Value.Handle); } catch { /* best effort */ } try { mx.RemoveItem(serverHandle, node.Value.Handle); } catch { /* best effort */ } + + // Without this the completion cache keeps one entry per dropped handle + // (each Unconfirmed re-add and each eviction) for the worker's life. + completionCache.Remove(serverHandle, node.Value.Handle); } private IMxAccessServer EnsureRegistered() diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessWriteCompletionCache.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessWriteCompletionCache.cs index 9488e8b..54ed955 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessWriteCompletionCache.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessWriteCompletionCache.cs @@ -184,6 +184,57 @@ public sealed class MxAccessWriteCompletionCache } } + /// + /// Forgets the completion entry of a handle pair whose MXAccess item was + /// removed, so a long-lived writer that drops and re-adds items does not + /// keep one entry per dropped handle for the life of the worker. + /// + /// MXAccess server handle. + /// MXAccess item handle that was removed. + public void Remove( + int serverHandle, + int itemHandle) + { + lock (syncRoot) + { + entries.Remove(CreateItemKey(serverHandle, itemHandle)); + } + } + + /// Forgets every completion entry recorded under a server handle that was unregistered. + /// MXAccess server handle that was unregistered. + public void RemoveServer(int serverHandle) + { + lock (syncRoot) + { + List stale = new(); + foreach (long key in entries.Keys) + { + if ((int)(key >> 32) == serverHandle) + { + stale.Add(key); + } + } + + foreach (long key in stale) + { + entries.Remove(key); + } + } + } + + /// Gets the number of handle pairs with a recorded completion (test seam). + internal int Count + { + get + { + lock (syncRoot) + { + return entries.Count; + } + } + } + private static long CreateItemKey( int serverHandle, int itemHandle) From bd82036d5f825c6afebb778398508c8064a23695 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 20 Oct 2026 07:10:38 -0400 Subject: [PATCH 24/25] docs(worker): record live item-handle-reuse probe for ack writer Add the Skip-gated ItemHandleReuseProbeTests probe and its windev result (2026-10-20): within one MXAccess registration, item handles increase monotonically and are never reused across remove/re-add, so the ack writer's Unconfirmed drop-and-re-add mitigation holds and no per-handle generation counter is needed. Numbering restarts only on a new registration, which teardown already isolates. Document the guarantee at the Dirty re-add and in AlarmProbeFindings.md. Fixes #166 Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT --- docs/AlarmProbeFindings.md | 29 +++ docs/MxAccessWorkerInstanceDesign.md | 5 +- .../Probes/ItemHandleReuseProbeTests.cs | 166 ++++++++++++++++++ .../MxAccess/AlarmAckWriter.cs | 7 + 4 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/ItemHandleReuseProbeTests.cs diff --git a/docs/AlarmProbeFindings.md b/docs/AlarmProbeFindings.md index 15ab240..17a54f7 100644 --- a/docs/AlarmProbeFindings.md +++ b/docs/AlarmProbeFindings.md @@ -515,3 +515,32 @@ returns gRPC `InvalidArgument`. The GUID arm of the ack command was not driven live. The public alarm RPCs expose only the full reference, so clients reach the by-name arm; worker unit tests cover the GUID arm. + +## Item handle reuse + +`AlarmAckWriter` correlates an ack with its `OnWriteComplete` by the +(server handle, item handle) pair, because the callback carries no per-write id. +After an unconfirmed ack it drops the `AckMsg` item and re-adds it so the next +ack waits on a new handle that a late completion for the old one cannot match. +That only works if MXAccess does not hand the same number out again. + +`ItemHandleReuseProbeTests` (Skip-gated, worker tests) checked this on windev +(`10.100.0.48`, Galaxy `DEV`) on 2026-10-20 against +`TestMachine_001.TestAlarm001.AckMsg`, without writing: + +| Sequence on one registration (server handle 1) | Item handles returned | +|---|---| +| `AddItem` + `Advise`, then `UnAdvise` + `RemoveItem`, five times | 1, 2, 3, 4, 5 | +| `AddItem`, then `RemoveItem`, five times | 6, 7, 8, 9, 10 | +| Add and remove `AckMsg`, add `Acked`, add `AckMsg` again | 11, then 12 (`Acked`), 13 | +| Add `AckMsg` twice while both are held | 14, 15 | + +Within a registration the numbers increase and a freed number is never reused, +so the drop-and-re-add mitigation holds. Numbering is per registration: +`Unregister` + `Register` on the same COM object returned server handle 2 and +item handle 1, and a new `LMXProxyServerClass` returned server handle 1 and +item handle 1 again. The writer is not exposed to that restart because its +teardown detaches its `OnWriteComplete` handler from the old COM object and +clears that server handle's completion entries before it creates a new one. +No per-handle generation counter is needed. + diff --git a/docs/MxAccessWorkerInstanceDesign.md b/docs/MxAccessWorkerInstanceDesign.md index b648b1a..8da84e7 100644 --- a/docs/MxAccessWorkerInstanceDesign.md +++ b/docs/MxAccessWorkerInstanceDesign.md @@ -356,7 +356,10 @@ stay acceptance-only regardless of which completion (if any) was attributed. `MxAccessWriteCompletionCache` and reuses one advised `AckMsg` item per alarm (least-recently-used, capped at 256). After an ack ends unconfirmed it marks the item dirty, and the next ack to that alarm removes and re-adds the item so the -write waits on a fresh item handle the stale completion cannot match. Every +write waits on a fresh item handle the stale completion cannot match. This +relies on MXAccess never reusing an item handle number within one +registration, which a live probe confirmed (see "Item handle reuse" in +[Alarm Probe Findings](./AlarmProbeFindings.md)). Every drop of an item (dirty re-add, eviction, or a failed `AddItem`/`Advise`/`Write`) also removes that handle's completion entry, and tearing down the registration removes every entry for its server handle, so the cache does not grow by one diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/ItemHandleReuseProbeTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/ItemHandleReuseProbeTests.cs new file mode 100644 index 0000000..9c294a7 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/ItemHandleReuseProbeTests.cs @@ -0,0 +1,166 @@ +// ItemHandleReuseProbeTests.cs +// +// Probe for issue #166: does MXAccess hand out a new item handle number when the +// same item is removed and re-added on one LMXProxyServer registration? The +// AlarmAckWriter's Unconfirmed mitigation (drop the item, re-add it, wait on the +// fresh handle) relies on the number changing, because OnWriteComplete carries +// only the (server, item) handle pair. +// +// The probe only adds, advises and removes items. It never writes, so it does +// not acknowledge the alarm or create Historian rows. +// +// HOW TO RUN (dev rig with MXAccess installed and the DEV Galaxy running): +// 1. Remove (or set to null) the Skip parameter on the [Fact] below. +// 2. dotnet test src/ZB.MOM.WW.MxGateway.Worker.Tests/ZB.MOM.WW.MxGateway.Worker.Tests.csproj +// -p:Platform=x86 --filter FullyQualifiedName~ItemHandleReuseProbeTests +// --logger "console;verbosity=detailed" +// 3. Read the logged handles. Results are recorded in docs/AlarmProbeFindings.md. +// +// RESULT (windev, 2026-10-20): within one registration item handles increase +// monotonically and are never reused (1..15 across add/remove/re-add of the same +// item, with and without Advise, interleaved with another item, and while held +// twice). Item numbering restarts at 1 after Unregister + Register, and a fresh +// COM object is handed server handle 1 again. + +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Threading; +using ArchestrA.MxAccess; +using ZB.MOM.WW.MxGateway.Worker.MxAccess; +using Xunit.Abstractions; + +namespace ZB.MOM.WW.MxGateway.Worker.Tests.Probes; + +/// +/// Live probe that logs the item handles MXAccess returns when the same +/// AckMsg item is removed and re-added, to decide whether a late +/// OnWriteComplete for a dropped handle could match a re-added one. +/// +public sealed class ItemHandleReuseProbeTests +{ + private const string AckItem = "TestMachine_001.TestAlarm001.AckMsg"; + private const string OtherItem = "TestMachine_001.TestAlarm001.Acked"; + private const int Rounds = 5; + + private readonly ITestOutputHelper output; + private readonly List log = new List(); + + /// Initializes a new instance of the class. + /// Test output helper for logging. + public ItemHandleReuseProbeTests(ITestOutputHelper output) + { + this.output = output; + } + + /// Adds, removes and re-adds the AckMsg item on a fresh registration and logs every handle. + [Fact(Skip = "Live dev-rig probe (issue #166) — flip Skip=null on a host with MXAccess and the DEV Galaxy running.")] + public void RemoveThenReAdd_LogsItemHandles() + { + Exception? threadException = null; + Thread thread = new Thread(() => + { + try { RunProbe(); } + catch (Exception ex) { threadException = ex; } + }); + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + thread.Join(); + + foreach (string line in log) + { + output.WriteLine(line); + } + + if (threadException is not null) + { + throw new InvalidOperationException("Probe failed: " + threadException.Message, threadException); + } + } + + private void RunProbe() + { + LMXProxyServerClass com = new LMXProxyServerClass(); + try + { + IMxAccessServer mx = new MxAccessComServer(com); + int server = mx.Register("MxGatewayProbe166"); + Log($"Register -> server handle {server}"); + + // A: the AlarmAckWriter drop path (Advise, then UnAdvise + RemoveItem). + for (int i = 0; i < Rounds; i++) + { + int h = mx.AddItem(server, AckItem); + mx.Advise(server, h); + Log($"A{i}: AddItem+Advise {AckItem} -> {h}"); + mx.UnAdvise(server, h); + mx.RemoveItem(server, h); + Log($"A{i}: UnAdvise+RemoveItem {h}"); + } + + // B: RemoveItem without Advise. + for (int i = 0; i < Rounds; i++) + { + int h = mx.AddItem(server, AckItem); + Log($"B{i}: AddItem {AckItem} -> {h}"); + mx.RemoveItem(server, h); + Log($"B{i}: RemoveItem {h}"); + } + + // C: interleave another item to see whether a freed number is handed to any item. + int c1 = mx.AddItem(server, AckItem); + Log($"C: AddItem {AckItem} -> {c1}"); + mx.RemoveItem(server, c1); + Log($"C: RemoveItem {c1}"); + int c2 = mx.AddItem(server, OtherItem); + Log($"C: AddItem {OtherItem} -> {c2}"); + int c3 = mx.AddItem(server, AckItem); + Log($"C: AddItem {AckItem} -> {c3}"); + mx.RemoveItem(server, c2); + mx.RemoveItem(server, c3); + Log($"C: RemoveItem {c2}, {c3}"); + + // D: the same item added twice while the first is still held. + int d1 = mx.AddItem(server, AckItem); + int d2 = mx.AddItem(server, AckItem); + Log($"D: AddItem {AckItem} twice while held -> {d1}, {d2}"); + mx.RemoveItem(server, d1); + mx.RemoveItem(server, d2); + + mx.Unregister(server); + Log($"Unregister {server}"); + + // E: the TearDownServer path: re-register on the same COM object and add again. + int server2 = mx.Register("MxGatewayProbe166"); + int e1 = mx.AddItem(server2, AckItem); + Log($"E: re-Register -> server handle {server2}; AddItem {AckItem} -> {e1}"); + mx.RemoveItem(server2, e1); + mx.Unregister(server2); + } + finally + { + Marshal.FinalReleaseComObject(com); + } + + // F: a fresh COM object, as AlarmAckWriter creates after a teardown. + LMXProxyServerClass com2 = new LMXProxyServerClass(); + try + { + IMxAccessServer mx2 = new MxAccessComServer(com2); + int server3 = mx2.Register("MxGatewayProbe166"); + int f1 = mx2.AddItem(server3, AckItem); + Log($"F: new COM object Register -> server handle {server3}; AddItem {AckItem} -> {f1}"); + mx2.RemoveItem(server3, f1); + mx2.Unregister(server3); + } + finally + { + Marshal.FinalReleaseComObject(com2); + } + } + + private void Log(string line) + { + log.Add(DateTime.Now.ToString("HH:mm:ss.fff") + " " + line); + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckWriter.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckWriter.cs index 1538607..d6d774c 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckWriter.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckWriter.cs @@ -166,6 +166,13 @@ public sealed class AlarmAckWriter : IAlarmAckWriter // (server, item) handle with no per-write id, so a late one would // be credited to this write. Drop the item and re-add it so this // write waits on a fresh handle the stale completion cannot match. + // That holds because MXAccess never reuses an item handle number + // within one registration: the live probe (ItemHandleReuseProbeTests, + // windev 2026-10-20) saw strictly increasing handles across repeated + // remove/re-add of the same item. Numbering restarts only on a new + // registration, and TearDownServer detaches this writer's + // OnWriteComplete handler and clears the server's completion entries + // first, so the old registration's completions cannot reach it. DropItem(mx, node); cached = false; } From 463748e395fc2ce967c09d3ec52ad778392028df Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 20 Oct 2026 07:18:02 -0400 Subject: [PATCH 25/25] fix(worker): classify ack interop failures at the COM call; ignore late completions Address review of the #164-#166 fixes: - The HRESULT check could not tell interop from programming errors (a managed ArgumentException already defaults to 0x80070057). Wrap each MXAccess call (AuthenticateUser, AddItem, Advise, Write) in a narrow filter that converts an interop failure to a private marker exception, and catch only that marker, so exceptions from the writer's own code (argument checks, caching, the pump) propagate. - A late OnWriteComplete for a dropped handle recreated the cache entry that DropItem removed. The writer now records completions only for item handles it holds on its live registration. - The item-handle-reuse probe now asserts that handles within one registration strictly increase, so a rerun is a regression check. Refs #164, #165, #166 Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT --- docs/AlarmProbeFindings.md | 3 +- docs/Grpc.md | 2 +- docs/MxAccessWorkerInstanceDesign.md | 5 +- .../MxAccess/AlarmAckWriterTests.cs | 63 ++++----- .../Probes/ItemHandleReuseProbeTests.cs | 33 ++++- .../MxAccess/AlarmAckWriter.cs | 120 +++++++++++++----- 6 files changed, 145 insertions(+), 81 deletions(-) diff --git a/docs/AlarmProbeFindings.md b/docs/AlarmProbeFindings.md index 17a54f7..018ac09 100644 --- a/docs/AlarmProbeFindings.md +++ b/docs/AlarmProbeFindings.md @@ -505,7 +505,8 @@ empty password returned `PROTOCOL_STATUS_CODE_OK`, `hresult` 0. `.Acked` turned A wrong password, or a user Galaxy security does not know, makes `AuthenticateUser` throw `ArgumentException` (`E_INVALIDARG`, 0x80070057) rather than a `COMException`. -That is why the writer classifies interop failures by HRESULT across exception types. +That is why the writer treats `ArgumentException` from the MXAccess call itself as an +interop failure alongside `COMException`, and reports the HRESULT the exception carries. Such an ack returns `PROTOCOL_STATUS_CODE_MXACCESS_FAILURE`, the alarm stays unacked, the Historian gains no ack row, and the password string does not appear in the gateway log, `stdout.log`, or `stderr.log`. A non-Galaxy provider reference returns diff --git a/docs/Grpc.md b/docs/Grpc.md index 8873ed9..28d1cc4 100644 --- a/docs/Grpc.md +++ b/docs/Grpc.md @@ -115,7 +115,7 @@ The worker calls `AuthenticateUser` as the operator, then writes `operator_comme | `AuthenticateUser` failed with a server-gone HRESULT (`RPC_E_DISCONNECTED`, `RPC_S_SERVER_UNAVAILABLE`, `RPC_E_SERVERFAULT`); the writer tears down its MXAccess registration and re-registers on the next ack | `MXACCESS_FAILURE` | that HRESULT | | `AddItem`, `Advise` or `Write` on the `AckMsg` item raised an MXAccess error (`COMException`, or `ArgumentException` for `E_INVALIDARG`); the item is dropped and re-added on the next ack, and a server-gone HRESULT also tears down the registration | `MXACCESS_FAILURE` | the exception's HRESULT | | Write rejected (`OnWriteComplete` non-OK) | `MXACCESS_FAILURE` | the `detail` of the `OnWriteComplete` `MXSTATUS_PROXY`, or `-1` when `detail` is `0` | -| Any other exception escaping the ack (e.g. the ack writer's MXAccess `Register` failing, the monitor's worker session having no active alarm subscription — `InvalidOperationException` — or an argument error that is not the interop's `E_INVALIDARG`: `ArgumentNullException`, `ArgumentOutOfRangeException`, or an `ArgumentException` with another HRESULT, which signal a bug rather than an MXAccess failure) | `MXACCESS_FAILURE` | the exception's `HResult`; the diagnostic names only the exception type and HRESULT | +| Any other exception escaping the ack (e.g. the ack writer's MXAccess `Register` failing, the monitor's worker session having no active alarm subscription — `InvalidOperationException` — or an exception from the worker's own code rather than from an MXAccess call: interop failures are classified only when the `AuthenticateUser`, `AddItem`, `Advise` or `Write` call itself raises them, and an `ArgumentNullException` or `ArgumentOutOfRangeException` is never classified because the interop does not raise them for an HRESULT) | `MXACCESS_FAILURE` | the exception's `HResult`; the diagnostic names only the exception type and HRESULT | `reply.Statuses` carries the `OnWriteComplete` `MxStatusProxy` rows when they were observed. Because an unconfirmed write also returns `OK`, a client that must be certain should watch for the resulting transition on `StreamAlarms`. `AcknowledgeAlarm` stays under the `invoke:write` scope; see [Authorization](./Authorization.md). diff --git a/docs/MxAccessWorkerInstanceDesign.md b/docs/MxAccessWorkerInstanceDesign.md index 8da84e7..4124bb0 100644 --- a/docs/MxAccessWorkerInstanceDesign.md +++ b/docs/MxAccessWorkerInstanceDesign.md @@ -363,7 +363,10 @@ registration, which a live probe confirmed (see "Item handle reuse" in drop of an item (dirty re-add, eviction, or a failed `AddItem`/`Advise`/`Write`) also removes that handle's completion entry, and tearing down the registration removes every entry for its server handle, so the cache does not grow by one -entry per dropped handle for the life of the worker. +entry per dropped handle for the life of the worker. The writer records a +completion only for an item handle it currently holds on its live +registration, so a late `OnWriteComplete` for a dropped handle cannot recreate +its entry. ## Live alarm watch-list replacement diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmAckWriterTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmAckWriterTests.cs index 48960bc..8c7a14a 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmAckWriterTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmAckWriterTests.cs @@ -187,24 +187,6 @@ public sealed class AlarmAckWriterTests Assert.DoesNotContain("Unregister", server.Operations); } - [Fact] - public void Acknowledge_WhenAuthenticateUserThrowsArgumentExceptionWithEInvalidArg_ReturnsAuthenticationFailed() - { - // The HRESULT is what the narrowed filter keys on; set it explicitly so the - // test does not depend on ArgumentException's default HResult. - RecordingAckServer server = new RecordingAckServer - { - AuthenticateResult = 5, - AuthenticateException = new HResultArgumentException("bad credentials", unchecked((int)0x80070057)), - }; - using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(10), "MxGateway"); - - AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "one", Alice()); - - Assert.Equal(AlarmAckOutcome.AuthenticationFailed, result.Outcome); - Assert.Equal(unchecked((int)0x80070057), result.NativeStatus); - } - [Fact] public void Acknowledge_WhenAuthenticateUserThrowsArgumentNullException_Propagates() { @@ -220,16 +202,17 @@ public sealed class AlarmAckWriterTests } [Fact] - public void Acknowledge_WhenAuthenticateUserThrowsArgumentExceptionWithOtherHResult_Propagates() + public void Acknowledge_WhenOwnCodeOutsideComCallThrowsArgumentException_Propagates() { - RecordingAckServer server = new RecordingAckServer - { - AuthenticateResult = 5, - AuthenticateException = new HResultArgumentException("not interop", unchecked((int)0x80131500)), - }; - using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(10), "MxGateway"); + // The pump runs between Advise and Write. An ArgumentException from it carries + // the same default HRESULT (0x80070057) as an interop E_INVALIDARG, so only + // catching at the COM call itself keeps it from being reported as WriteRejected. + RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 }; + using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => throw new ArgumentException("bug"), TimeSpan.FromMilliseconds(10), "MxGateway"); - Assert.Throws(() => writer.Acknowledge("T.A.AckMsg", "one", Alice())); + ArgumentException thrown = Assert.Throws(() => writer.Acknowledge("T.A.AckMsg", "one", Alice())); + Assert.Equal("bug", thrown.Message); + Assert.DoesNotContain(server.Operations, op => op.StartsWith("Write", StringComparison.Ordinal)); } [Fact] @@ -353,6 +336,24 @@ public sealed class AlarmAckWriterTests Assert.Equal(1UL, cache.CurrentVersion(7, 101)); } + [Fact] + public void LateCompletionForDroppedHandle_DoesNotRecreateCacheEntry() + { + RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 }; + MxAccessWriteCompletionCache cache = new MxAccessWriteCompletionCache(); + using AlarmAckWriter writer = new AlarmAckWriter(server, cache, () => { }, TimeSpan.FromMilliseconds(20), "MxGateway"); + writer.Acknowledge("T.A.AckMsg", "one", Alice()); // handle 100, Unconfirmed + server.OnWrite = (handle, item) => writer.RecordWriteCompletion(handle, item, Rows(MxStatusCategory.Ok, 0)); + writer.Acknowledge("T.A.AckMsg", "two", Alice()); // drops 100, adds 101, applied + + writer.RecordWriteCompletion(7, 100, Rows(MxStatusCategory.Ok, 0)); // 100's late completion + writer.RecordWriteCompletion(8, 101, Rows(MxStatusCategory.Ok, 0)); // another registration + + Assert.Equal(1, cache.Count); + Assert.Equal(0UL, cache.CurrentVersion(7, 100)); + Assert.Equal(1UL, cache.CurrentVersion(7, 101)); + } + [Fact] public void Acknowledge_PastCacheCap_ForgetsEvictedHandleCompletion() { @@ -424,16 +425,6 @@ public sealed class AlarmAckWriterTests } } -/// ArgumentException with an explicit HRESULT (the setter is protected on net48). -internal sealed class HResultArgumentException : ArgumentException -{ - public HResultArgumentException(string message, int hresult) - : base(message) - { - HResult = hresult; - } -} - internal sealed class RecordingAckServer : IMxAccessServer { private int nextItemHandle = 100; diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/ItemHandleReuseProbeTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/ItemHandleReuseProbeTests.cs index 9c294a7..baaa8aa 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/ItemHandleReuseProbeTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/ItemHandleReuseProbeTests.cs @@ -46,6 +46,9 @@ public sealed class ItemHandleReuseProbeTests private readonly ITestOutputHelper output; private readonly List log = new List(); + // Item handles returned on the first registration, in the order they were handed out. + private readonly List firstRegistrationHandles = new List(); + /// Initializes a new instance of the class. /// Test output helper for logging. public ItemHandleReuseProbeTests(ITestOutputHelper output) @@ -76,6 +79,16 @@ public sealed class ItemHandleReuseProbeTests { throw new InvalidOperationException("Probe failed: " + threadException.Message, threadException); } + + // The ack writer's drop-and-re-add mitigation depends on this: within one + // registration a handle number is never handed out twice. + Assert.Equal((2 * Rounds) + 5, firstRegistrationHandles.Count); + for (int i = 1; i < firstRegistrationHandles.Count; i++) + { + Assert.True( + firstRegistrationHandles[i] > firstRegistrationHandles[i - 1], + $"Item handle {firstRegistrationHandles[i]} did not increase past {firstRegistrationHandles[i - 1]}; MXAccess reused or reordered item handles within one registration."); + } } private void RunProbe() @@ -90,7 +103,7 @@ public sealed class ItemHandleReuseProbeTests // A: the AlarmAckWriter drop path (Advise, then UnAdvise + RemoveItem). for (int i = 0; i < Rounds; i++) { - int h = mx.AddItem(server, AckItem); + int h = Held(mx.AddItem(server, AckItem)); mx.Advise(server, h); Log($"A{i}: AddItem+Advise {AckItem} -> {h}"); mx.UnAdvise(server, h); @@ -101,28 +114,28 @@ public sealed class ItemHandleReuseProbeTests // B: RemoveItem without Advise. for (int i = 0; i < Rounds; i++) { - int h = mx.AddItem(server, AckItem); + int h = Held(mx.AddItem(server, AckItem)); Log($"B{i}: AddItem {AckItem} -> {h}"); mx.RemoveItem(server, h); Log($"B{i}: RemoveItem {h}"); } // C: interleave another item to see whether a freed number is handed to any item. - int c1 = mx.AddItem(server, AckItem); + int c1 = Held(mx.AddItem(server, AckItem)); Log($"C: AddItem {AckItem} -> {c1}"); mx.RemoveItem(server, c1); Log($"C: RemoveItem {c1}"); - int c2 = mx.AddItem(server, OtherItem); + int c2 = Held(mx.AddItem(server, OtherItem)); Log($"C: AddItem {OtherItem} -> {c2}"); - int c3 = mx.AddItem(server, AckItem); + int c3 = Held(mx.AddItem(server, AckItem)); Log($"C: AddItem {AckItem} -> {c3}"); mx.RemoveItem(server, c2); mx.RemoveItem(server, c3); Log($"C: RemoveItem {c2}, {c3}"); // D: the same item added twice while the first is still held. - int d1 = mx.AddItem(server, AckItem); - int d2 = mx.AddItem(server, AckItem); + int d1 = Held(mx.AddItem(server, AckItem)); + int d2 = Held(mx.AddItem(server, AckItem)); Log($"D: AddItem {AckItem} twice while held -> {d1}, {d2}"); mx.RemoveItem(server, d1); mx.RemoveItem(server, d2); @@ -159,6 +172,12 @@ public sealed class ItemHandleReuseProbeTests } } + private int Held(int itemHandle) + { + firstRegistrationHandles.Add(itemHandle); + return itemHandle; + } + private void Log(string line) { log.Add(DateTime.Now.ToString("HH:mm:ss.fff") + " " + line); diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckWriter.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckWriter.cs index d6d774c..d79df70 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckWriter.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckWriter.cs @@ -34,9 +34,6 @@ public sealed class AlarmAckWriter : IAlarmAckWriter /// public const int DefaultMaxCachedItems = 256; - /// E_INVALIDARG, which the interop raises as ArgumentException. - private const int EInvalidArg = unchecked((int)0x80070057); - private readonly IMxAccessComObjectFactory? factory; private readonly MxAccessWriteCompletionCache completionCache; private readonly Action pumpStep; @@ -48,6 +45,11 @@ public sealed class AlarmAckWriter : IAlarmAckWriter // Most-recently-used first; the tail is the eviction candidate. private readonly LinkedList itemLru = new LinkedList(); + + // Item handles currently added on this registration. A completion for any + // other handle (a dropped one whose OnWriteComplete arrives late) is ignored + // so it cannot recreate a completion-cache entry for a dead handle. + private readonly HashSet heldItemHandles = new HashSet(); private object? comObject; private LMXProxyServerClass? comEventSource; private IMxAccessServer? server; @@ -107,9 +109,6 @@ public sealed class AlarmAckWriter : IAlarmAckWriter if (string.IsNullOrWhiteSpace(itemAddress)) throw new ArgumentException("Ack item address is required.", nameof(itemAddress)); if (ackOperator is null) throw new ArgumentNullException(nameof(ackOperator)); - // Resolve every argument before the interop try blocks: their catch - // filters accept E_INVALIDARG ArgumentExceptions as MXAccess failures, so - // an argument error raised by this code must fail before reaching them. if (ackOperator.UserName is null) throw new ArgumentException("Operator user name is required.", nameof(ackOperator)); string verifyUser = ackOperator.VerifyUser; string password = ackOperator.Password ?? string.Empty; @@ -120,12 +119,10 @@ public sealed class AlarmAckWriter : IAlarmAckWriter int userId; try { - userId = mx.AuthenticateUser(serverHandle, verifyUser, password); + userId = Interop("AuthenticateUser", () => mx.AuthenticateUser(serverHandle, verifyUser, password)); } - catch (Exception ex) when (IsInteropFailure(ex)) + catch (AckInteropException ex) { - // Bad credentials surface as ArgumentException (E_INVALIDARG) from the - // interop, not only COMException, so classify by the carried HRESULT. int authHresult = ex.HResult; if (IsServerGone(authHresult)) { @@ -156,7 +153,6 @@ public sealed class AlarmAckWriter : IAlarmAckWriter int itemHandle = 0; LinkedListNode? node; bool cached = itemHandles.TryGetValue(itemAddress, out node); - string step = "AddItem"; try { if (cached && node!.Value.Dirty) @@ -185,23 +181,20 @@ public sealed class AlarmAckWriter : IAlarmAckWriter } else { - itemHandle = mx.AddItem(serverHandle, itemAddress); + itemHandle = Interop("AddItem", () => mx.AddItem(serverHandle, itemAddress)); node = CacheItem(mx, itemAddress, itemHandle); - step = "Advise"; + int added = itemHandle; // MXAccess rejects writes to added-but-unadvised items with E_INVALIDARG. - mx.Advise(serverHandle, itemHandle); + Interop("Advise", () => mx.Advise(serverHandle, added)); } pumpStep(); - step = "Write"; baselineVersion = completionCache.CurrentVersion(serverHandle, itemHandle); - mx.Write(serverHandle, itemHandle, commentValue, userId); + int target = itemHandle; + Interop("Write", () => mx.Write(serverHandle, target, commentValue, userId)); } - catch (Exception ex) when (IsInteropFailure(ex)) + catch (AckInteropException ex) { - // The interop maps E_INVALIDARG to ArgumentException (and - // E_ACCESSDENIED to UnauthorizedAccessException), so the HRESULT, not - // the exception type, is what identifies the MXAccess failure. int hresult = ex.HResult; if (itemHandles.TryGetValue(itemAddress, out LinkedListNode? failed)) { @@ -217,7 +210,7 @@ public sealed class AlarmAckWriter : IAlarmAckWriter return AlarmAckResult.Create( AlarmAckOutcome.WriteRejected, hresult, - $"AckMsg {step} failed (HRESULT 0x{hresult:X8})."); + $"AckMsg {ex.Step} failed (HRESULT 0x{hresult:X8})."); } ulong baseline = baselineVersion; @@ -262,28 +255,52 @@ public sealed class AlarmAckWriter : IAlarmAckWriter } /// - /// Exceptions the MXAccess COM interop raises for a failed HRESULT: the - /// runtime maps well-known HRESULTs to typed exceptions (E_INVALIDARG to - /// ArgumentException, E_ACCESSDENIED to UnauthorizedAccessException) and - /// the rest to COMException. Each carries the HRESULT in Exception.HResult. + /// Runs one MXAccess COM call and converts a failure the interop raised + /// for its HRESULT into . Only the COM + /// call itself runs inside the filter, so an exception thrown by this + /// writer's own code (argument checks, caching, the pump) propagates as + /// a bug instead of being reported as an MXAccess failure. /// /// - /// The ArgumentException arm exists because a wrong password or unknown + /// The runtime maps well-known HRESULTs to typed exceptions and the rest + /// to COMException; each carries the HRESULT in Exception.HResult. The + /// ArgumentException arm is needed because a wrong password or unknown /// user makes AuthenticateUser throw ArgumentException with - /// E_INVALIDARG (0x80070057), observed live on windev on 2026-10-19. It - /// is narrowed to that HRESULT, and excludes ArgumentNullException and - /// ArgumentOutOfRangeException, so a programming error inside the try - /// blocks propagates as a bug instead of being reported as an MXAccess - /// authentication or write failure. The interop never raises those two - /// subtypes for a COM HRESULT. + /// E_INVALIDARG (0x80070057), observed live on windev on 2026-10-19. + /// ArgumentNullException and ArgumentOutOfRangeException are excluded: + /// the interop does not raise them for an HRESULT, so one escaping the + /// call is an argument bug in the adapter, not an MXAccess failure. /// + private static T Interop(string step, Func call) + { + try + { + return call(); + } + catch (Exception ex) when (IsInteropFailure(ex)) + { + throw new AckInteropException(step, ex); + } + } + + private static void Interop(string step, Action call) + { + try + { + call(); + } + catch (Exception ex) when (IsInteropFailure(ex)) + { + throw new AckInteropException(step, ex); + } + } + private static bool IsInteropFailure(Exception ex) => ex is ExternalException || ex is UnauthorizedAccessException || (ex is ArgumentException && ex is not ArgumentNullException - && ex is not ArgumentOutOfRangeException - && ex.HResult == EInvalidArg); + && ex is not ArgumentOutOfRangeException); private static bool IsServerGone(int hresult) => hresult == unchecked((int)0x80010108) // RPC_E_DISCONNECTED @@ -315,6 +332,7 @@ public sealed class AlarmAckWriter : IAlarmAckWriter itemHandles.Clear(); itemLru.Clear(); + heldItemHandles.Clear(); server = null; comEventSource = null; object? toRelease = comObject; @@ -335,6 +353,7 @@ public sealed class AlarmAckWriter : IAlarmAckWriter LinkedListNode node = itemLru.AddFirst(new AckItem(itemAddress, itemHandle)); itemHandles[itemAddress] = node; + heldItemHandles.Add(itemHandle); return node; } @@ -343,6 +362,7 @@ public sealed class AlarmAckWriter : IAlarmAckWriter { itemHandles.Remove(node.Value.Address); itemLru.Remove(node); + heldItemHandles.Remove(node.Value.Handle); try { mx.UnAdvise(serverHandle, node.Value.Handle); } catch { /* best effort */ } try { mx.RemoveItem(serverHandle, node.Value.Handle); } catch { /* best effort */ } @@ -397,7 +417,37 @@ public sealed class AlarmAckWriter : IAlarmAckWriter { RepeatedField rows = new RepeatedField(); rows.AddRange(statusConverter.ConvertMany(pVars)); - completionCache.Record(hLMXServerHandle, phItemHandle, rows); + RecordWriteCompletion(hLMXServerHandle, phItemHandle, rows); + } + + /// + /// Records a write completion only for an item handle this writer + /// currently holds on its live registration. A late completion for a + /// dropped handle would otherwise recreate the cache entry that + /// removed. Internal so tests can stand in for + /// the COM event. + /// + internal void RecordWriteCompletion(int completionServerHandle, int itemHandle, RepeatedField rows) + { + if (server is null || completionServerHandle != serverHandle || !heldItemHandles.Contains(itemHandle)) + { + return; + } + + completionCache.Record(completionServerHandle, itemHandle, rows); + } + + /// An MXAccess failure raised by one COM call, carrying that call's HRESULT. + private sealed class AckInteropException : Exception + { + public AckInteropException(string step, Exception inner) + : base("MXAccess " + step + " failed.", inner) + { + Step = step; + HResult = inner.HResult; + } + + public string Step { get; } } /// A cached ack item. Plain class (not a record) for the net48 worker target.