Post-implementation review (HIGH finding) noted #478's mux-delivered input-quality path does not cover a driver comms-loss: a poll driver (Modbus/S7) whose device goes unreachable emits only ConnectivityChanged and goes silent on the value feed, so a scripted alarm keeps the last Good value. The code as shipped faithfully implements #478's written scope (worst of input tags' qualities via the dependency mux). The comms-loss bridge for scripted alarms (symmetric of native #477-L2, plus the null-value/cold-start asymmetry and its VT-quality ripple) is tracked as #481. Docs updated in AlarmTracking.md + the design doc.
14 KiB
Alarm condition Quality (issue #477) — design
Status: implemented (L1+L2) · Date: 2026-07-17 · Issue: #477 (follow-up chain #473 → #475 → #477) Scope decision: Layer 1 + Layer 2, Bad-direct, annotate-only. Layer 3 (scripted worst-of-input) deferred → #478.
Problem
AlarmConditionState.Quality is never assigned anywhere in src/ — neither by
OtOpcUaNodeManager.MaterialiseAlarmCondition nor by the WriteAlarmCondition transition path.
Because StatusCodes.Good == 0x00000000, default(StatusCode) is Good, so the field is
accidentally valid — clients parse it, but it reports Good unconditionally regardless of the
backing tag's real quality.
This is a wrong-value bug, not the null-value bug class of #473/#475. Part 9 defines
ConditionType.Quality as "the quality of the Condition's source data". OT impact: when a native
alarm's device goes offline (comms lost) the condition still reports Quality = Good, so an operator
(or an HMI bucketing on IsGood) cannot distinguish "genuinely not active" from "we have lost
contact and do not know".
Why it isn't a 2-line default (confirmed by code)
- Alarm-bearing raw tags have no value variable.
AddressSpaceAppliermaterialises a raw tag as either a condition node (tag.Alarm is not null) or a value variable (else) — never both, since they'd share the sames=<RawPath>NodeId. SoWriteValue(the only path carryingOpcUaQuality) is never invoked for an alarm node. Quality has nowhere to land today. - The alarm channel is quality-blind.
AlarmEventArgs(driver → host) andAlarmConditionSnapshot(host → SDK sink) both carry no quality field. - On comms-loss the alarm feed goes silent.
DriverInstanceActoronDisconnectObserveddetaches the alarm subscription and re-entersReconnecting— no transition event ever arrives to carry Bad. So the "device offline" signal must come from driver connectivity, independently of alarm transitions.
Decisions (the issue's open questions)
| # | Question | Decision | Rationale |
|---|---|---|---|
| 1 | Does an alarm tag get quality today? | No | Confirmed above — new plumbing required. |
| 2 | Direct status code vs. policy map | Direct Bad on comms-loss; Good on reconnect | Matches how a value variable would read; unambiguous for IsGood bucketing. |
| 3 | Does Bad also suppress transitions / touch Retain? | No — annotate only | A comms-lost active condition must stay active + retained. Silently clearing an active alarm on comms-loss is the unsafe direction. Quality is a pure annotation; the Active/Ack/Retain state machine is untouched. |
| 4 | Scripted alarms: worst-of-inputs quality? | Deferred (Layer 3) | Scripted conditions stay Good. Filed as a follow-up issue. |
Architecture — reuse the existing publish path, add no sink method
The key move: do not add a new IOpcUaAddressSpaceSink method. A new sink-interface surface would
have to be forwarded through DeferredAddressSpaceSink or it is inert on driver hosts (the F10b
prod-inertness trap). Instead the NativeAlarmProjector becomes the single owner of per-condition
state and quality, and a connectivity change re-projects the last snapshot with a swapped quality
through the existing AlarmStateUpdate → OpcUaPublishActor → WriteAlarmCondition path.
Layer 1 — make Quality a real, plumbed field
AlarmConditionSnapshot(Commons) gainsOpcUaQuality Quality(last positional param, defaultOpcUaQuality.Goodso scripted callers and existing tests keep compiling; Commons already knowsOpcUaQualityviaIOpcUaAddressSpaceSink).MaterialiseAlarmConditionsetsalarm.Quality.Valueat build time: native →BadWaitingForInitialData(honest until connectivity confirms Good, matching the value-variable "waiting for initial data" convention), scripted →Good(script-computed, always live in this scope).WriteAlarmConditionprojectsStatusFromQuality(state.Quality)ontocondition.Quality.Value(+SourceTimestamp).- The delta-gate (
AlarmConditionDelta/ReadConditionDelta/ToConditionDelta) gains aQualitymember, so a Good→Bad bucket change is a genuine delta and fires a Part 9 condition event.
Layer 2 — drive native quality from driver connectivity
DriverInstanceActor: newpublic sealed record ConnectivityChanged(string DriverInstanceId, bool Connected).Context.Parent.Tellit onBecome(Connected)entry (true) and on the transitions intoReconnecting(DisconnectObserved/ForceReconnect) (false). Fire-and-forget, mirrorsDeltaApplied.NativeAlarmProjector: per-node state becomes(bool Active, bool Acked, OpcUaQuality Quality).Project(transition)preserves the current quality; newProjectQuality(nodeId, quality)preserves Active/Acked and swaps only the quality, returning a full snapshot.DriverHostActor:Receive<ConnectivityChanged>iterates_alarmNodeIdByDriverReffor that driver instance and Tells oneAlarmStateUpdateper condition with the re-projected snapshot (connected ? Good : Bad). Ungated — both redundancy nodes track their own driver's comms, matching the existing "condition write stays ungated (Secondary keeps its address space warm)" rule. No/alertsrow for a quality-only change — driver health already has its own status/alerts surface viaIDriverHealthPublisher; a row here would be alarm-fatigue.
Scripted alarms are unaffected: they are not driver instances, receive no ConnectivityChanged, and
their snapshot quality stays Good.
Files
Layer 1
src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/AlarmConditionSnapshot.cssrc/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs(MaterialiseAlarmCondition,WriteAlarmCondition,AlarmConditionDelta/ReadConditionDelta/ToConditionDelta)src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs(ToSnapshot— Quality=Good, or rely on default)
Layer 2
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cssrc/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/NativeAlarmProjector.cssrc/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs
Tests (TDD, RED-first)
- Wire-level (the issue's suggested guard) — extend
NativeAlarmEventIdentityFieldDeliveryTests(OpcUaServer.IntegrationTests): active alarm → eventQuality.IsGood; driver disconnect → condition eventQuality.IsGood == false; reconnect → Good. Verify RED against pre-fix. - Node-level —
NodeManagerAlarmSourceFieldsTests: materialise sets Quality (nativeBadWaitingForInitialData, scriptedGood);WriteAlarmConditionprojects snapshot quality and fires on a quality-bucket change only. NativeAlarmProjectorunit:ProjectQualitykeeps Active/Acked + swaps quality;Projectpreserves quality.DriverInstanceActor:Connectedentry TellsConnectivityChanged(true);DisconnectObservedTellsConnectivityChanged(false).DriverHostActor:ConnectivityChanged(false)pushes a Bad-qualityAlarmStateUpdateto every condition of that driver instance.
Deferred / notes
- Layer 3 (scripted worst-of-input quality) → Gitea #478.
- Implementation note: L2 uses a dedicated
IOpcUaAddressSpaceSink.WriteAlarmQualitypath (not a full-snapshot re-projection). Rationale: a connectivity change must set only Quality; re-projecting a full snapshot would clobber a cold condition's severity/message and can't annotate a condition that never fired a transition. The new sink method is forwarded throughDeferredAddressSpaceSink(the F10b inertness trap) — auto-verified byDeferredSinkForwardingReflectionTests(reflection guard) + its realm-discriminator guard. - Test-harness note: the new
DriverInstanceActor → parentConnectivityChangedTell polluted existing parent-TestProbeassertions in 3DriverInstanceActor*Testsfiles; those tests nowparent.IgnoreMessages(m => m is ConnectivityChanged)since they assert on data/alarm/discovery forwards, not connectivity. Bad_NoCommunicationvs genericBad: v1 mapsOpcUaQuality.Bad → StatusCodes.Bad; refiningStatusFromQualityto emitBadNoCommunicationfor the comms-loss case is a one-line nicety, noted in the issue.docs/AlarmTracking.md§"Condition event identity fields" gains a Quality subsection (Good/Bad semantics, annotation-not-state-change, quality-bucket change fires an event).
Layer 3 — scripted worst-of-input quality (Gitea #478, implemented 2026-07-17)
Problem. A scripted alarm is computed from one or more input tags. Its condition should report the
worst quality of those inputs ("can I trust this condition's state?"), not the hardcoded Good Layer 1
left at ScriptedAlarmHostActor.ToSnapshot.
Two blockers discovered in the live path (both silently discard quality):
DependencyMuxActor.OnAttributeValuePublishedbuildsVirtualTagActor.DependencyValueChangedwithout theAttributeValuePublished.Qualityit already carries.ScriptedAlarmHostActor.OnDependencyChangedpushes each mux value into the engine's upstream with a hardcoded0u(Good) StatusCode. So even aBaddriver value reached the scripted engine as Good — Layer 3 has to plumb quality first.
Design (mirrors Layer 2's native OT semantic through the scripted channel):
- Plumb quality end-to-end.
DependencyValueChangedgainsOpcUaQuality Quality(defaultedGood, so the virtual-tag engine's calls are unchanged); the mux forwardsmsg.Quality;OnDependencyChangedmaps it to a StatusCode (Good→0,Uncertain→0x40000000,Bad→0x80000000) on the pushedDataValueSnapshot. - Engine computes the worst input quality each evaluation (over the refilled read cache, before the
AreInputsReadyshort-circuit so aBadinput is still observed) and carries it asScriptedAlarmEvent.WorstInputStatusCode(a rawuintStatusCode —Core.ScriptedAlarmsdoesn't reference Commons, so it stays in the engine's existing StatusCode vocabulary; the host maps it toOpcUaQuality). - Transitions carry the current worst quality →
ToSnapshotprojects it (no clobber-back-to-Good when a transition fires while an input isUncertain). - Quality-only changes emit out of band. A
Badinput freezes the condition (AreInputsReadyreturns false → no transition), exactly like a comms-lost native driver — so quality can't ride a transition. The engine tracks the last worst-quality bucket per alarm and, when the bucket changes with no transition emission, emits a newEmissionKind.QualityChangedevent. The host routes that to the existing Layer 2OpcUaPublishActor.AlarmQualityUpdate → IOpcUaAddressSpaceSink.WriteAlarmQualitypath (sets ONLY Quality, one Part 9 event on a bucket change, no/alertsrow, no historian write). No new sink surface. ScriptedAlarmSource(theIAlarmSourcefan-out adapter) skipsQualityChanged— quality is delivered through the dedicated node path, never as a phantomAlarmEventArgs(which would materialize/historize a native condition).
Files (Layer 3):
src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/Part9StateMachine.cs—EmissionKind.QualityChanged.src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs— worst-of-input, bucket tracking,WorstInputStatusCodeon the event, quality-only emission.src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmSource.cs— skipQualityChanged.src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs—DependencyValueChanged.Quality.src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs— forwardmsg.Quality.src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs— push real quality;ToSnapshotmapsWorstInputStatusCode;OnEngineEmissionroutesQualityChanged → AlarmQualityUpdate.
Tests (RED-first): engine — transition carries Uncertain worst; Bad input with no transition emits
QualityChanged(Bad); restore emits QualityChanged(Good); no spurious emit when the bucket is unchanged.
ScriptedAlarmSource — QualityChanged raises no OnAlarmEvent. Mux — DependencyValueChanged carries the
published quality. Host — Bad dependency → AlarmQualityUpdate(Bad), no /alerts publish; ToSnapshot
maps the event's worst quality.
Coverage boundary → Layer 4 (#481). L3 covers inputs whose driver publishes a Bad/Uncertain-status
data change (the mux quality path). It does not cover a driver comms loss: a poll driver
(Modbus/S7) whose device goes unreachable emits only ConnectivityChanged and goes silent on the value feed
(DriverInstanceActor.Reconnecting), so the scripted engine keeps the last-known Good value and the
condition stays Good — the same silent-feed problem native solved in L2, but native's OnDriverConnectivityChanged
bridge fans only to native condition nodes (_alarmNodeIdByDriverRef), not into the mux the scripted
engine reads. Bridging connectivity into scripted inputs — plus resolving the null-value/cold-start
asymmetry (a runtime Bad with a null value is currently indistinguishable from cold start and contributes
Good) and its ripple into virtual-tag quality — is Gitea #481 (Layer 4). Found by the post-implementation
code review; the code as shipped faithfully implements #478's written scope (mux-delivered input quality).