feat(alarms): bridge driver comms-loss into scripted-alarm input quality (#481)

Layer 4 of the condition-Quality chain (#477 -> #478). #478 gave a scripted
condition the worst quality of its input tags, but only for inputs whose driver
PUBLISHES a Bad data change. A poll driver whose device goes unreachable
publishes nothing — it raises ConnectivityChanged(false) and goes silent — so
the engine held its last Good value, WorstInputStatus stayed Good, and worse,
AreInputsReady stayed true, so the predicate went on evaluating a stale value
after the source had died.

`DriverHostActor.FanCommsLossToDependencyMux` pushes a synthetic
BadNoCommunication with a null value into the dependency mux for every value ref
the disconnected driver owns.

The issue asked for a driver -> all-tag-refs map to be built for this. It
already exists: `_nodeIdByDriverRef`, keyed (DriverInstanceId, RawPath) and
rebuilt from the composition on every apply. Alarm-bearing tags live in the
separate `_alarmNodeIdByDriverRef` and are deliberately excluded — a native
condition is not a mux value and takes the Part 9 quality path instead.

Nothing is pushed on reconnect, deliberately: publishing Good would assert a
value quality this actor has not observed. Recovery rides the driver's own
republish, and the mechanism was verified rather than assumed — the
Connected -> Reconnecting edge calls DetachSubscription, which nulls the
subscription handle, so the reconnect's ResubscribeDesired builds a FRESH
subscription whose PollGroupEngine initial push runs with forceRaise: true.
Without that detach the poll engine would compare against its retained
LastValues, see an unchanged value, raise nothing, and strand these refs Bad
forever — the value-dedup trap that bit the v3 Batch 4 virtual-tag reassert.

Closes #478 Finding 3, which the above depends on. WorstInputStatus skipped
every input whose VALUE was null as "no data yet" — and Bad-with-no-value is
exactly the shape a comms-lost driver produces, so the skip was hiding the case
that matters most; without this half the fan-out above would be silently inert.
The upstream source now stamps a never-pushed path BadWaitingForInitialData —
the honest Part 4 code for that state — and the engine skips that specific code
rather than every null value.

Deleting the null-skip outright is the obvious fix and is wrong in the other
direction: it makes every not-yet-published input drag its condition Bad at
deploy, flooding the quality path with load-time annotations, which is what the
skip existed to prevent. Both mistakes are pinned by a test pair that goes red
under the opposite error, and both were falsified by reverting the production
code:

  old null-skip restored     -> A_pushed_Bad_carrying_no_value      RED
  null-skip deleted outright -> An_input_that_was_never_pushed      RED
  fan-out call removed       -> the three DriverHostActor tests     RED (1 of 7)

The never-pushed test was vacuous on first writing and is worth recording: the
quality bucket only emits on CHANGE, so the load-time evaluation is the one that
flashes Bad, and subscribing to OnEvent after LoadAsync made the assertion
unfalsifiable — it passed against the broken behaviour. It now subscribes first.

`FakeUpstream.ReadTag` returned 0x80340000 (BadNotConnected) for an unset path,
a different Bad code from production's placeholder. Harmless while any Bad
behaved alike; load-bearing now, so it is aligned and pinned by a reflection
guard asserting the engine's private constant agrees with what the production
source stamps.

Behaviour change to weigh, flagged in the issue and confirmed: virtual tags
computed from a comms-lost input now go Bad too. That follows from using the
shared mux and reads as correct, but it is broader than scripted alarms.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
Joseph Doherty
2026-07-31 09:21:11 -04:00
parent b815446940
commit c81cef39f5
8 changed files with 360 additions and 26 deletions
@@ -1103,6 +1103,75 @@ public sealed class ScriptedAlarmEngineTests
events.ShouldNotContain(e => e.Emission == EmissionKind.QualityChanged);
}
// -------------------------------------------------------------------------
// #481 — cold start vs. a driver reporting Bad (closes #478 Finding 3)
// -------------------------------------------------------------------------
/// <summary>
/// A driver reporting Bad with no value counts as Bad quality.
/// <para>This is the hole #478 Finding 3 recorded. <c>WorstInputStatus</c> skipped every entry whose
/// value was null as "no data yet", which is indistinguishable from a real fault when the fault
/// carries no value — and a null-valued Bad is exactly what a comms-lost driver produces, so the
/// case being skipped was the case that mattered most. Before the fix this test sees no
/// QualityChanged at all.</para>
/// </summary>
[Fact]
public async Task A_pushed_Bad_carrying_no_value_counts_as_Bad_quality()
{
var up = new FakeUpstream();
up.Set("Temp", 50); // Good to start, so the bucket has somewhere to move from
using var eng = Build(up, out _);
await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
TestContext.Current.CancellationToken);
var events = new List<ScriptedAlarmEvent>();
eng.OnEvent += (_, e) => events.Add(e);
// The shape DriverHostActor pushes on ConnectivityChanged(false): Bad, no value.
up.Push("Temp", null, 0x80310000u); // BadNoCommunication
await WaitForAsync(() => events.Any(e => e.Emission == EmissionKind.QualityChanged));
var q = events.First(e => e.Emission == EmissionKind.QualityChanged);
(q.WorstInputStatusCode >> 30).ShouldBeGreaterThanOrEqualTo(2u);
// The sub-code survives, so an operator can tell comms loss from any other fault.
q.WorstInputStatusCode.ShouldBe(0x80310000u);
}
/// <summary>
/// …and the protection the old null-skip existed for still holds: an input nothing has ever pushed
/// does NOT drag the condition Bad.
/// <para>The falsifying control for the test above. Deleting the null-skip outright — the obvious
/// way to make a null-valued Bad count — would also make every not-yet-published input count, which
/// is what the skip was there to prevent: every scripted condition flashing Bad at deploy until its
/// first push, flooding the quality path with load-time annotations. Both tests pass only if the
/// engine keys off the never-pushed status code specifically.</para>
/// </summary>
[Fact]
public async Task An_input_that_was_never_pushed_does_not_drag_the_condition_Bad()
{
var up = new FakeUpstream();
up.Set("Temp", 50);
// "Spare" is never Set or Pushed, so the engine's cache refill resolves it through ReadTag and
// gets the never-pushed placeholder — value null, Bad severity, the same shape a genuine fault has.
using var eng = Build(up, out _);
// Subscribed BEFORE LoadAsync, deliberately. The quality bucket is tracked per alarm and only a
// CHANGE emits, so the load-time evaluation is the one that would flash Bad — subscribing after it
// makes this assertion vacuous, and the first draft of this test was exactly that: it passed
// against the broken behaviour because the flash had already happened by the time it was listening.
var events = new List<ScriptedAlarmEvent>();
eng.OnEvent += (_, e) => events.Add(e);
await eng.LoadAsync(
[Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100 || (int?)ctx.GetTag("Spare").Value == 1;""")],
TestContext.Current.CancellationToken);
up.Push("Temp", 60, 0u);
await Task.Delay(150, TestContext.Current.CancellationToken);
events.ShouldNotContain(e => e.Emission == EmissionKind.QualityChanged);
}
private static async Task WaitForAsync(Func<bool> cond, int timeoutMs = 2000)
{
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);