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:
Joseph Doherty
2026-08-17 04:03:39 -04:00
parent a212e145ac
commit ce5d8ae7c2
2 changed files with 152 additions and 10 deletions
+123
View File
@@ -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);
// 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<MxAlarmTransitionEvent> transitions;
@@ -436,11 +440,15 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
/// Decides whether a fetch that came back holding
/// <paramref name="fetchedRecordCount"/> records hit the cap.
/// <c>GetXmlCurrentAlarms2</c> caps its reply at <c>maxAlmCnt</c> 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
/// <c>internal static</c> 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 <c>ALARM_RECORDS/@COUNT</c> 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 <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>
/// <param name="fetchedRecordCount">ALARM records the reply carried.</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
/// and state changes are computed from presence alone.
/// </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>
/// <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>