diff --git a/docs/AlarmProbeFindings.md b/docs/AlarmProbeFindings.md new file mode 100644 index 0000000..f67e360 --- /dev/null +++ b/docs/AlarmProbeFindings.md @@ -0,0 +1,123 @@ +# Alarm Probe Findings + +`WnWrapAlarmConsumer` rests on two assumptions that no unit test can settle, because both +are properties of AVEVA's alarm provider rather than of our code: + +1. **GUID identity.** The snapshot diff in `ComputeTransitions` keys on the alarm record's + `GUID`. If wnwrap mints a fresh GUID when an alarm changes state, a single + `UNACK_ALM → ACK_ALM` transition reads as one alarm disappearing and a different one + appearing — a spurious clear plus a spurious raise on every acknowledge. +2. **`ALARM_RECORDS/@COUNT` semantics.** `IsTruncatedFetch` treats a reply holding exactly + `maxAlmCnt` records as truncated, because `GetXmlCurrentAlarms2` exposes no explicit + "more available" flag. If the reply's `COUNT` attribute carries the *total* active count + rather than the records-in-reply count, truncation detection can become exact instead of + conservative, and the bounded staleness `ApplySnapshotUpdate` accepts goes away. + +This document records what a live probe run against the dev rig (`DESKTOP-6JL3KKO`, +2026-08-17) could and could not establish, so the next attempt starts from the blocker +rather than rediscovering it. + +## Outcome + +| Question | Status | +|---|---| +| GUID stable across polls and `ALM → RTN` | Answered — yes, by the 2026-05-01 capture in `AlarmClientDiscovery.md` | +| GUID stable across `UNACK → ACK`, and across clear-then-re-raise | **Open** | +| `COUNT` = total active vs records-in-reply under a capped fetch | **Open** | + +Both open questions are blocked by the same thing: the rig has no active alarm and cannot +be driven into one over MXAccess, so there is no alarm instance whose GUID can be followed +through an acknowledge and no population large enough to overflow a capped fetch. + +## Why The Rig Cannot Raise An Alarm + +The rig is otherwise healthy, which is what makes the blocker specific rather than a +general "nothing works": + +- `aaEngine`, `alarmmgr`, `NmxSvc`, and `wnwrapServerEx` are all running. +- `TestArea` (area of `TestMachine_001`…`_003`) and the objects themselves are deployed + (`deployed_version` non-null in the `ZB` Galaxy Repository) and on scan — the probe's + advised `ScanState` subtags report true, and every advised alarm attribute delivers an + initial value, so the MXAccess read path is live. +- The wnwrap consumer subscribes cleanly: `InitializeConsumer`, `RegisterConsumer`, + `Subscribe(\\DESKTOP-6JL3KKO\Galaxy!TestArea)`, and `SetXmlAlarmQuery` all return 0, and + `GetXmlCurrentAlarms2` returns well-formed XML on every poll. + +What fails is the *write* that would set the alarm condition. Every `Write` to the alarm +UDAs completes with a security failure: + +``` +WRITE-COMPLETE hLMX=1 hItem=1 statuses=[success=0 category=SecurityError detectedBy=RespondingAutomationObject detail=1008 text=] +``` + +The status comes back from the responding automation object, not from the proxy, so the +request reaches the engine and the engine refuses it. The advised value confirms the +refusal is total rather than transient: neither the alarm UDA nor its `.InAlarm` /`.Acked` +subtags report any change after a write attempt, across six write attempts in one session +(raise, clear, re-raise, cleanup). The attributes carry a security classification that a +plain `Write` cannot satisfy. + +The 2026-05-01 capture that answered the `ALM → RTN` leg did not hit this, because the +alarm condition was driven from *inside* the engine by a System Platform script rather than +from an external MXAccess client. That script is not running now, and the values sat idle +for the whole probe session. + +### Unblocking + +Any one of these makes both questions answerable, in rough order of cost: + +- Re-enable the System Platform script that flips `TestMachine_001.TestAlarm001` + (referenced throughout `AlarmClientDiscovery.md`). It writes from inside the engine, so + the attribute's security classification does not apply. +- Drive the write through `AuthenticateUser` + `WriteSecured` with a Galaxy account + permitted on that classification. The worker already implements both verbs; the probe + used plain `Write`, which is the wrong verb for a secured attribute. +- Reclassify the test UDAs to free access in the IDE and redeploy `TestMachine_001`…`_003`. + +Three separate objects are wired to the same alarm UDA name, so once writes land, a +`maxAlmCnt` of 1 or 2 forces truncation against three active alarms and answers the `COUNT` +question in the same run. + +## Evidence + +Snapshot payload, identical at every cap (1, 2, and 1024) and at every poll across the +~100-second session: + +```xml + +``` + +Two things follow from the empty case alone. `COUNT` is present on the root element in +every reply, so the attribute exists as a candidate signal rather than something wnwrap +omits. And `COUNT` agrees with the element count here — but trivially, since both are zero, +which is exactly the case that cannot discriminate the two hypotheses. + +The probe used for the run was a throwaway file in the windev CI clone +(`C:\build\mxaccessgw-ci`), deleted afterwards; the clone is back to a clean tree at +`origin/main`. Nothing in this repository changed to run it. The reusable, Skip-gated +harness it was modelled on is +`src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/WnWrapConsumerProbeTests.cs`. + +## Implications + +### Transition identity + +`ComputeTransitions` keying on GUID is safe for the raise and clear legs, which is the +evidence `AlarmClientDiscovery.md` already carries. The acknowledge leg — the one where a +re-minted GUID would corrupt the feed, because an ack is the state change most likely to +create a new record in a provider that models acknowledgement as a separate event — is +still assumed rather than observed. Nothing here justifies changing the diff, but the +assumption should not be described in code as established. + +### Truncation detection + +`IsTruncatedFetch` stays as written. Tightening it to an exact test requires knowing that +`COUNT` reports the total, and this run cannot show that. The conservative rule keeps its +justification: at the cap, treating a complete fetch as truncated costs one poll of +staleness, while treating a truncated fetch as complete broadcasts clears for every alarm +past the cap. + +The one substantive correction is to the phrasing rather than the logic. The reply is not +featureless — it carries a `COUNT` attribute the parser currently ignores. Whether that +attribute is a usable "more available" signal is unverified, not absent, and the comments +in `WnWrapAlarmConsumer` now say so. diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs index b9f44f4..041e901 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs @@ -402,11 +402,15 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer Dictionary next = ParseSnapshotXml(xml, out int fetchedRecordCount); // TRUNCATION CLIFF. GetXmlCurrentAlarms2 caps its reply at maxAlmCnt - // and gives no "there is more" flag, so a reply holding exactly the - // cap is indistinguishable from a galaxy that happens to have exactly - // that many active alarms. Treat the ambiguous case as truncated: the - // false-positive cost is a snapshot that stays stale for one poll, the - // false-negative cost is every alarm past the cap reading as cleared. + // and gives no *verified* "there is more" flag, so a reply holding + // exactly the cap is indistinguishable from a galaxy that happens to + // have exactly that many active alarms. Treat the ambiguous case as + // truncated: the false-positive cost is a snapshot that stays stale for + // one poll, the false-negative cost is every alarm past the cap reading + // as cleared. (The reply's ALARM_RECORDS/@COUNT attribute is a + // candidate exact signal, but only if it reports the total rather than + // the records in the reply — untested on a live rig, see + // docs/AlarmProbeFindings.md.) bool truncated = IsTruncatedFetch(fetchedRecordCount, maxAlarmsPerFetch); IReadOnlyList transitions; @@ -436,11 +440,15 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer /// Decides whether a fetch that came back holding /// records hit the cap. /// GetXmlCurrentAlarms2 caps its reply at maxAlmCnt and - /// offers no "more available" flag, so a reply at exactly the cap is - /// indistinguishable from a galaxy that happens to hold exactly that - /// many active alarms; both are treated as truncated. Exposed as - /// internal static so the rule is unit-testable without the - /// wnwrapConsumer COM object. + /// offers no confirmed "more available" flag, so a reply at exactly the + /// cap is indistinguishable from a galaxy that happens to hold exactly + /// that many active alarms; both are treated as truncated. The reply + /// root carries an ALARM_RECORDS/@COUNT attribute that would make + /// the test exact if it reported the total active count rather than the + /// records in this reply; a live probe could not discriminate the two + /// (see docs/AlarmProbeFindings.md), so the count is deliberately + /// not trusted here. Exposed as internal static so the rule is + /// unit-testable without the wnwrapConsumer COM object. /// /// ALARM records the reply carried. /// The cap that was passed to the fetch. @@ -599,6 +607,17 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer /// stays correct for the alarms it does carry — first sightings /// and state changes are computed from presence alone. /// + /// + /// Every rule above assumes the GUID identifies the alarm + /// instance rather than the state it is in: a re-minted + /// GUID would read as the old alarm vanishing and a new one + /// appearing, i.e. a spurious Clear plus a spurious Raise. Live + /// capture confirms stability across the active→returned leg only + /// (docs/AlarmClientDiscovery.md); the acknowledge leg and + /// re-raise-after-clear are assumed, not observed, because the dev + /// rig's alarm attributes reject unauthenticated writes — see + /// docs/AlarmProbeFindings.md. + /// /// /// The snapshot from the previous poll (or empty on first call). /// The snapshot just parsed from GetXmlCurrentAlarms2.