docs(alarms): wnwrap live-probe findings — GUID identity, ALARM_RECORDS COUNT
Both questions stay open, and the reason is the finding: the dev rig's alarm UDAs reject a plain MXAccess Write with SecurityError/detail=1008 from the responding automation object, so no alarm instance can be created to follow through an acknowledge and no population can be built to overflow a capped fetch. The rig is otherwise live — objects deployed and on scan, wnwrap subscribed, GetXmlCurrentAlarms2 returning well-formed XML — which is what makes the blocker specific and the unblock (engine-side script, or AuthenticateUser + WriteSecured, or reclassifying the UDAs) actionable. Comment-only changes in WnWrapAlarmConsumer: scope the GUID-identity claim to the leg live capture actually covers, and record that ALARM_RECORDS/@COUNT exists as a candidate exact truncation signal but is deliberately not trusted because its semantics under a capped reply are unverified. No behavior change.
This commit is contained in:
@@ -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
|
||||||
|
<?xml version="1.0"?><ALARM_RECORDS COUNT="0"></ALARM_RECORDS>
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -402,11 +402,15 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
|
|||||||
Dictionary<Guid, MxAlarmSnapshotRecord> next = ParseSnapshotXml(xml, out int fetchedRecordCount);
|
Dictionary<Guid, MxAlarmSnapshotRecord> next = ParseSnapshotXml(xml, out int fetchedRecordCount);
|
||||||
|
|
||||||
// TRUNCATION CLIFF. GetXmlCurrentAlarms2 caps its reply at maxAlmCnt
|
// TRUNCATION CLIFF. GetXmlCurrentAlarms2 caps its reply at maxAlmCnt
|
||||||
// and gives no "there is more" flag, so a reply holding exactly the
|
// and gives no *verified* "there is more" flag, so a reply holding
|
||||||
// cap is indistinguishable from a galaxy that happens to have exactly
|
// exactly the cap is indistinguishable from a galaxy that happens to
|
||||||
// that many active alarms. Treat the ambiguous case as truncated: the
|
// have exactly that many active alarms. Treat the ambiguous case as
|
||||||
// false-positive cost is a snapshot that stays stale for one poll, the
|
// truncated: the false-positive cost is a snapshot that stays stale for
|
||||||
// false-negative cost is every alarm past the cap reading as cleared.
|
// 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);
|
bool truncated = IsTruncatedFetch(fetchedRecordCount, maxAlarmsPerFetch);
|
||||||
|
|
||||||
IReadOnlyList<MxAlarmTransitionEvent> transitions;
|
IReadOnlyList<MxAlarmTransitionEvent> transitions;
|
||||||
@@ -436,11 +440,15 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
|
|||||||
/// Decides whether a fetch that came back holding
|
/// Decides whether a fetch that came back holding
|
||||||
/// <paramref name="fetchedRecordCount"/> records hit the cap.
|
/// <paramref name="fetchedRecordCount"/> records hit the cap.
|
||||||
/// <c>GetXmlCurrentAlarms2</c> caps its reply at <c>maxAlmCnt</c> and
|
/// <c>GetXmlCurrentAlarms2</c> caps its reply at <c>maxAlmCnt</c> and
|
||||||
/// offers no "more available" flag, so a reply at exactly the cap is
|
/// offers no confirmed "more available" flag, so a reply at exactly the
|
||||||
/// indistinguishable from a galaxy that happens to hold exactly that
|
/// cap is indistinguishable from a galaxy that happens to hold exactly
|
||||||
/// many active alarms; both are treated as truncated. Exposed as
|
/// that many active alarms; both are treated as truncated. The reply
|
||||||
/// <c>internal static</c> so the rule is unit-testable without the
|
/// root carries an <c>ALARM_RECORDS/@COUNT</c> attribute that would make
|
||||||
/// wnwrapConsumer COM object.
|
/// 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 <c>docs/AlarmProbeFindings.md</c>), so the count is deliberately
|
||||||
|
/// not trusted here. Exposed as <c>internal static</c> so the rule is
|
||||||
|
/// unit-testable without the wnwrapConsumer COM object.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="fetchedRecordCount">ALARM records the reply carried.</param>
|
/// <param name="fetchedRecordCount">ALARM records the reply carried.</param>
|
||||||
/// <param name="maxAlarmsPerFetch">The cap that was passed to the fetch.</param>
|
/// <param name="maxAlarmsPerFetch">The cap that was passed to the fetch.</param>
|
||||||
@@ -599,6 +607,17 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
|
|||||||
/// stays correct for the alarms it does carry — first sightings
|
/// stays correct for the alarms it does carry — first sightings
|
||||||
/// and state changes are computed from presence alone.
|
/// and state changes are computed from presence alone.
|
||||||
/// </para>
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Every rule above assumes the GUID identifies the alarm
|
||||||
|
/// <em>instance</em> 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
|
||||||
|
/// (<c>docs/AlarmClientDiscovery.md</c>); the acknowledge leg and
|
||||||
|
/// re-raise-after-clear are assumed, not observed, because the dev
|
||||||
|
/// rig's alarm attributes reject unauthenticated writes — see
|
||||||
|
/// <c>docs/AlarmProbeFindings.md</c>.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="previous">The snapshot from the previous poll (or empty on first call).</param>
|
/// <param name="previous">The snapshot from the previous poll (or empty on first call).</param>
|
||||||
/// <param name="next">The snapshot just parsed from <c>GetXmlCurrentAlarms2</c>.</param>
|
/// <param name="next">The snapshot just parsed from <c>GetXmlCurrentAlarms2</c>.</param>
|
||||||
|
|||||||
Reference in New Issue
Block a user