diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ContinuousHistorizationRecorder.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ContinuousHistorizationRecorder.cs
index 7b2667ea..3eb35004 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ContinuousHistorizationRecorder.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ContinuousHistorizationRecorder.cs
@@ -1,3 +1,4 @@
+using System.Collections.Frozen;
using Akka.Actor;
using Akka.Event;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
@@ -28,10 +29,15 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Historian;
/// historized as such when the backend can store it.
///
///
-/// What is NOT historized: the comms-loss watchdog's BadNoCommunication stamps.
-/// DriverHostActor.FanCommsLossToValueNodes writes those to the address-space NODES only
-/// (status-only sink write) and never reaches the mux, so the recorder never sees them. Only a
-/// value a driver itself publishes through the mux is recorded.
+/// Comms loss IS historized (as an outage marker). On a driver disconnect
+/// DriverHostActor.FanCommsLossToDependencyMux (#481) fans (null, BadNoCommunication)
+/// through the mux for every value ref of the driver, so the recorder receives it like any other
+/// sample (it cannot, and does not try to, tell it apart from a driver's own null + Bad publish).
+/// When the backend supports it is stored as an
+/// Empty/BadNoCommunication sample — the marker that records the gap, so reads and
+/// aggregates do not interpolate across the outage. On a numeric-only backend (the gateway's
+/// default kinds) it is dropped and counted in .
+/// (The separate FanCommsLossToValueNodes node stamp is an address-space write only.)
///
///
/// Kind gate. Each value is coerced with ; a
@@ -57,7 +63,7 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
/// The value kinds captured when no backend provider states its own: the numeric kinds the
/// gateway's SQL analog live-value path has always carried (Double, Int64, Boolean).
public static readonly IReadOnlySet DefaultSupportedKinds =
- new HashSet { HistorianValueKind.Double, HistorianValueKind.Int64, HistorianValueKind.Boolean };
+ new[] { HistorianValueKind.Double, HistorianValueKind.Int64, HistorianValueKind.Boolean }.ToFrozenSet();
private const string DrainTimerKey = "drain";
@@ -104,7 +110,9 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
/// durably committed a partial prefix: the outbox does not report how far it got, so the recorder cannot
/// credit that prefix. Those entries still drain; they are simply never counted here.
/// Lifetime count of values dropped because they are not historizable
- /// (arrays, references) or their kind is not one the backend supports.
+ /// (arrays, references) or their kind is not one the backend supports. On a numeric-only backend this
+ /// includes the #481 comms-loss stamps (null + BadNoCommunication ⇒ Empty): every driver
+ /// disconnect adds one per historized ref of that driver.
/// Lifetime count of entries the outbox dropped on capacity overflow.
/// Lifetime count of FAILED BATCH APPENDS at the durable boundary —
/// ONE per failed burst whatever its size, never one per value (the granularity changed with the batched
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ContinuousHistorizationRecorderTypedValueTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ContinuousHistorizationRecorderTypedValueTests.cs
index ecca2015..c11e0e54 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ContinuousHistorizationRecorderTypedValueTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ContinuousHistorizationRecorderTypedValueTests.cs
@@ -88,6 +88,7 @@ public sealed class ContinuousHistorizationRecorderTypedValueTests : TestKit
[Fact]
public async Task A_null_BadNoCommunication_sample_is_written_as_Empty_when_the_backend_supports_Empty()
{
+ // The #481 comms-loss fan-out shape: stored as an Empty/BadNoCommunication outage marker.
var writer = new PoolThreadValueWriter();
IActorRef rec = Spawn(writer, AllKinds);
@@ -96,13 +97,16 @@ public sealed class ContinuousHistorizationRecorderTypedValueTests : TestKit
HistorizationValue written = await AwaitSingleWriteAsync(writer);
written.Value.Kind.ShouldBe(HistorianValueKind.Empty);
written.StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
+ written.StatusCode.ShouldBe(0x80310000u);
}
[Fact]
- public async Task A_null_BadNoCommunication_sample_is_dropped_when_the_backend_does_not_support_Empty()
+ public async Task A_null_BadNoCommunication_sample_is_dropped_under_the_default_gateway_kinds()
{
+ // The #481 comms-loss fan-out shape (DriverHostActor.FanCommsLossToDependencyMux): on the default
+ // (numeric-only, gateway) kinds it is metered as an unsupported kind and nothing is appended.
var writer = new PoolThreadValueWriter();
- IActorRef rec = Spawn(writer, NumericKinds);
+ IActorRef rec = Spawn(writer, supportedKinds: null);
rec.Tell(new VirtualTagActor.DependencyValueChanged(Ref, null, DateTime.UtcNow, OpcUaStatus.BadNoCommunication));