diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttSubscriptionManager.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttSubscriptionManager.cs
index 8a4ed603..4d28f0da 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttSubscriptionManager.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttSubscriptionManager.cs
@@ -755,6 +755,10 @@ public sealed class MqttSubscriptionManager
// sink write) per message. The cache is still freshened — a read must serve the current
// timestamp — and only the notification is skipped. "Cached but not raised" is already a valid
// state on this path (see Raise: an authored-but-unsubscribed tag does exactly that).
+ //
+ // Sparkplug bypasses this entirely. SparkplugIngestor.cs updates Values and raises directly
+ // (its own Values.Update()/Raise() calls, not through this Deliver method), so Sparkplug B
+ // ingestion gets no unchanged-value suppression — deliberately out of scope here.
var prior = Values.Read(def.Name);
var unchanged = prior.StatusCode == snapshot.StatusCode && !ValuesAreDifferent(prior.Value, snapshot.Value);
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttSubscriptionManagerTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttSubscriptionManagerTests.cs
index 6ee9255a..558d4477 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttSubscriptionManagerTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttSubscriptionManagerTests.cs
@@ -1032,6 +1032,13 @@ public sealed class MqttSubscriptionManagerTests
/// DegradeTopic: a Good value followed by a refused subscription must reach the
/// subscriber as Bad, or the client keeps a stale Good forever.
///
+ ///
+ /// This pins the DegradeTopic path specifically — DegradeTopic's own
+ /// Raise call was already unconditional before the suppression change and is untouched
+ /// by it. It does NOT exercise the prior.StatusCode == snapshot.StatusCode comparison
+ /// added inside ; see
+ /// for that.
+ ///
[Fact]
public async Task A_status_transition_still_raises()
{
@@ -1057,6 +1064,39 @@ public sealed class MqttSubscriptionManagerTests
mgr.Values.Read(OvenTempPath).StatusCode.ShouldBe(BadCommunicationError);
}
+ ///
+ /// Pins the status-code half of Deliver's suppression comparison
+ /// (prior.StatusCode == snapshot.StatusCode) through the LIVE
+ /// path, which
+ /// does not reach (that test goes through the
+ /// unconditional DegradeTopic path instead). Two different malformed payloads on the
+ /// SAME topic each decode to a null value but a DIFFERENT Bad status code — a decode
+ /// failure (BadDecodingError) followed by a value that decodes but fails type coercion
+ /// (BadTypeMismatch). A value-only comparison (ignoring status) would see
+ /// null == null on both transitions and wrongly suppress the second.
+ ///
+ [Fact]
+ public async Task Different_malformed_payloads_with_the_same_null_value_both_raise()
+ {
+ var mgr = NewManager();
+ mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"$.value","dataType":"Float64"}""")]);
+ await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
+
+ var raised = new List();
+ mgr.OnDataChange += (_, e) => raised.Add(e.Snapshot);
+
+ // Decode failure: not valid JSON at all.
+ mgr.HandleMessage(OvenTopic, "{not json"u8.ToArray(), retained: false);
+ // Decodes fine, but the selected value does not fit the declared Float64 type.
+ mgr.HandleMessage(OvenTopic, """{"value":"not-a-number"}"""u8.ToArray(), retained: false);
+
+ raised.Count.ShouldBe(2);
+ raised[0].StatusCode.ShouldBe(BadDecodingError);
+ raised[0].Value.ShouldBeNull();
+ raised[1].StatusCode.ShouldBe(BadTypeMismatch);
+ raised[1].Value.ShouldBeNull();
+ }
+
/// Records every subscribe call so dedupe / re-subscribe can be asserted without a broker.
private sealed class FakeTransport : IMqttSubscribeTransport
{