diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SequenceTracker.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SequenceTracker.cs
new file mode 100644
index 00000000..13c91bb3
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SequenceTracker.cs
@@ -0,0 +1,375 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
+
+///
+/// Per-edge-node Sparkplug B stream-continuity state: the wrapping seq counter that says
+/// whether the driver has missed a message, and the bdSeq session token that ties an
+/// NDEATH to the NBIRTH it belongs to. Design doc §3.6 invariant #3.
+///
+///
+///
+/// One instance per edge node — this object is the edge node's stream state. It
+/// holds no key and no map. Sparkplug numbers messages per edge node (an edge node's devices
+/// share its counter, so a DDATA advances the same sequence an NDATA does), and the ingest
+/// state machine already keeps per-scope state for the alias table and birth cache; giving
+/// this type a second internal dictionary would mean two maps whose lifetimes must be kept in
+/// step by hand, and a tracker that outlived a removed edge node would go on answering
+/// questions about a stream nobody is reading.
+///
+///
+/// This type detects; it does not react. It never publishes, never requests a rebirth
+/// and never emits a quality change — the ingest state machine reads its verdicts and decides.
+/// The split matters because "was a message lost" is a fact about the wire, while "request a
+/// rebirth now" is a policy gated on requestRebirthOnGap.
+///
+///
+/// Thread-safe under a plain monitor. Sparkplug messages arrive on MQTTnet's shared
+/// dispatcher thread while the reconnect path can call from another, and
+/// every operation here is a read-modify-write of two coupled fields — the immutable-snapshot
+/// discipline MqttSubscriptionManager uses fits a set that is swapped wholesale, not a
+/// counter that is advanced. The critical sections are a handful of instructions each and
+/// nothing inside them can block, so the simple lock this repo uses elsewhere
+/// (MqttDriver._hostLock) is the right shape.
+///
+///
+/// Nothing here throws, for any input. Same reasoning as
+/// : this sits behind an unauthenticated firehose on the
+/// dispatcher thread, so a spec-violating publisher gets a verdict, never an exception that
+/// would stall delivery for every subscription on the connection.
+///
+///
+public sealed class SequenceTracker
+{
+ ///
+ /// The largest legal Sparkplug seq. The counter is a byte on the wire in spirit but a
+ /// uint64 in the schema, so the range check lives here — see .
+ ///
+ public const ulong MaxSeq = 255;
+
+ private readonly object _gate = new();
+
+ /// The last credible sequence number, meaningful only when .
+ private byte _lastSeq;
+
+ /// Whether any credible sequence number has been observed since the last reset.
+ private bool _hasLastSeq;
+
+ /// Whether the current baseline was established by a birth rather than adopted mid-stream.
+ private bool _birthSynchronized;
+
+ /// The current session's bdSeq, or null when no birth has supplied one.
+ private ulong? _birthBdSeq;
+
+ ///
+ /// Whether the current sequence baseline came from a birth. also after
+ /// a first message that was not a birth — the driver joined mid-stream and holds no
+ /// alias table, which is a different situation from a gap even though both call for a rebirth.
+ ///
+ public bool IsBirthSynchronized
+ {
+ get { lock (_gate) return _birthSynchronized; }
+ }
+
+ ///
+ /// The last credible sequence number observed, or when none has been —
+ /// before the first message, or after .
+ ///
+ public byte? LastSeq
+ {
+ get { lock (_gate) return _hasLastSeq ? _lastSeq : null; }
+ }
+
+ ///
+ /// The bdSeq of the most recent birth, or when no birth has
+ /// supplied one. This is the token matches against.
+ ///
+ public ulong? BirthBdSeq
+ {
+ get { lock (_gate) return _birthBdSeq; }
+ }
+
+ ///
+ /// Records a birth (NBIRTH/DBIRTH): restarts the sequence at the birth's seq and adopts
+ /// its bdSeq as the current session token.
+ ///
+ ///
+ /// The birth payload's seq — , which is
+ /// when the message carried none.
+ ///
+ ///
+ /// The birth's session token, read from its bdSeq metric with
+ /// ; when the birth carried none.
+ ///
+ ///
+ /// when the birth established a usable sequence baseline;
+ /// when its seq was absent or out of range.
+ ///
+ ///
+ ///
+ /// A birth is never a gap. It restarts the sequence by definition, so it must not
+ /// be run through — doing so would flag a gap on the very message
+ /// that just resynchronized everything, and answer a birth with a request for another one.
+ ///
+ ///
+ /// An in-range seq other than the spec-required 0 is adopted, not refused.
+ /// Refusing it would leave the tracker unsynchronized against a publisher whose stream is
+ /// otherwise perfectly followable, and every message that followed would demand a fresh
+ /// rebirth — the storm this type exists to prevent, arrived at by being strict.
+ ///
+ ///
+ /// The bdSeq is recorded even when the seq was unusable. Session
+ /// identity and position-in-stream are independent facts; discarding the tie because the
+ /// sequence number was garbled would leave the next NDEATH unmatchable, which is the one
+ /// thing that lets a stale will kill a live node.
+ ///
+ ///
+ public bool AcceptBirth(ulong? seq, ulong? bdSeq = null)
+ {
+ lock (_gate)
+ {
+ _birthBdSeq = bdSeq;
+
+ if (seq is not { } value || value > MaxSeq)
+ {
+ _hasLastSeq = false;
+ _birthSynchronized = false;
+ return false;
+ }
+
+ _lastSeq = (byte)value;
+ _hasLastSeq = true;
+ _birthSynchronized = true;
+ return true;
+ }
+ }
+
+ ///
+ /// Offers the next sequenced message's seq (NDATA/DDATA/DDEATH) and reports whether the
+ /// stream is still contiguous.
+ ///
+ ///
+ /// The payload's seq — , which is
+ /// when the message carried none.
+ ///
+ ///
+ /// when this seq is the one that follows the last;
+ /// when a message was missed, when the value is out of range, or when
+ /// it is absent. A is the caller's cue to request a rebirth.
+ ///
+ ///
+ ///
+ /// The wrap is the whole point: next-expected is (last + 1) & 0xFF, so
+ /// 255 → 0 is the sequence continuing, not a gap. Getting the boundary wrong fails
+ /// loudly in one direction and silently in the other. Read the wrap as a gap and every
+ /// edge node is asked to rebirth once per 256 messages, so a busy node spends its life
+ /// republishing births instead of publishing data. Miss a real gap and the driver serves
+ /// values it knows are behind the device, at Good quality, indefinitely.
+ ///
+ ///
+ /// A gap is reported once, then the tracker resynchronizes onto the observed value.
+ /// Holding the baseline at the pre-gap number would make every following message report a
+ /// gap too, so a single lost message would become a permanent rebirth storm.
+ ///
+ ///
+ /// An out-of-range seq is refused, never masked into range. The codec hands
+ /// this over as a precisely so a spec-violating publisher's value is
+ /// not silently truncated into a plausible-looking one, and masking would finish the job
+ /// it declined to do: with the baseline at 255, a seq of 256 masks to 0 — exactly
+ /// the value the wrap expects next — and the bogus message would be accepted as
+ /// contiguous. Refused values do not become the baseline either, so the next genuine
+ /// message is still measured against the last credible one.
+ ///
+ ///
+ /// The first message is not a gap — there is nothing to measure it against. It is
+ /// adopted as the baseline, and stays
+ /// to record that the driver joined mid-stream.
+ ///
+ ///
+ /// NDEATH must not be offered here. An NDEATH is the broker-published Last Will and
+ /// carries no seq at all; feeding it in would report a gap on a message that never
+ /// had a place in the sequence. It is tied to its birth by
+ /// instead. A DDEATH is published by the edge node
+ /// and is sequenced, so it does belong here.
+ ///
+ ///
+ public bool Accept(ulong? seq)
+ {
+ lock (_gate)
+ {
+ // Refused without becoming the baseline: see the out-of-range remarks above.
+ if (seq is not { } value || value > MaxSeq)
+ {
+ return false;
+ }
+
+ var current = (byte)value;
+
+ if (!_hasLastSeq)
+ {
+ _lastSeq = current;
+ _hasLastSeq = true;
+ return true;
+ }
+
+ var expected = (byte)((_lastSeq + 1) & 0xFF);
+
+ // Resynchronize even on a gap — one lost message must not become a permanent storm.
+ _lastSeq = current;
+
+ return current == expected;
+ }
+ }
+
+ ///
+ /// Reports whether an NDEATH belongs to the session currently believed alive — the
+ /// bdSeq tie of §3.6 invariant #3.
+ ///
+ ///
+ /// The death payload's bdSeq, read with ;
+ /// when it carried none.
+ ///
+ ///
+ /// when the death applies and the node's metrics should go stale;
+ /// when it is a previous session's will and must be ignored.
+ ///
+ ///
+ ///
+ /// What this prevents. An edge node's connection drops, it reconnects and publishes
+ /// a fresh NBIRTH — and only then does the broker notice the old connection died
+ /// and deliver its retained Last Will. The NDEATH arrives after the NBIRTH, carrying the
+ /// previous session's bdSeq. Without this comparison that stale will marks a live,
+ /// freshly-born node dead and drives every one of its tags to Bad quality, with no further
+ /// message coming to correct it until the node happens to rebirth again.
+ ///
+ ///
+ /// Unknowns fail toward stale, deliberately. A death arriving before any birth was
+ /// seen, a death carrying no bdSeq, or a birth that carried none all resolve to
+ /// . The two error directions are not symmetric: acting on a death
+ /// wrongly marks tags stale until the next birth restores them (§3.6 invariant #4, a
+ /// self-correcting error an operator can see), while ignoring a real death leaves a dead
+ /// node's last values flowing at Good quality forever. Only a positive mismatch —
+ /// two known, different tokens — is evidence enough to discard a death.
+ ///
+ ///
+ /// Pure. It answers a question and changes nothing, so the caller can ask it while
+ /// logging or deciding. A death that is acted on needs no reset here: the node is
+ /// offline, and the next birth calls which restarts the sequence
+ /// anyway.
+ ///
+ ///
+ public bool IsDeathForCurrentSession(ulong? deathBdSeq)
+ {
+ lock (_gate)
+ {
+ if (_birthBdSeq is not { } birth || deathBdSeq is not { } death)
+ {
+ return true;
+ }
+
+ return birth == death;
+ }
+ }
+
+ ///
+ /// Returns the tracker to its virgin state: no sequence baseline, not birth-synchronized, no
+ /// session token.
+ ///
+ ///
+ /// The reconnect seam (§3.6 invariant #5 — late join). After a dropped connection the driver
+ /// has missed an unknown number of messages, so the baseline it was holding is no longer
+ /// evidence of anything: carrying it across would let the stream look contiguous purely
+ /// because the edge node happened to publish the right number of messages while the driver was
+ /// away.
+ ///
+ public void Reset()
+ {
+ lock (_gate)
+ {
+ _lastSeq = 0;
+ _hasLastSeq = false;
+ _birthSynchronized = false;
+ _birthBdSeq = null;
+ }
+ }
+
+ ///
+ /// Reads the bdSeq session token out of a decoded NBIRTH or NDEATH payload.
+ ///
+ /// The decoded payload; and invalid are both handled.
+ /// The token on success; 0 otherwise.
+ /// when the payload carried a usable bdSeq metric.
+ ///
+ ///
+ /// bdSeq is not a top-level payload field — unlike seq it rides as an
+ /// ordinary metric named bdSeq, which is why reading it is a helper rather than a
+ /// property. The name is matched exactly (ordinal): the spec fixes it, so a case variant
+ /// is far more likely to be a different metric than a typo of this one.
+ ///
+ ///
+ /// Signed values are reinterpreted before they are judged. Sparkplug carries
+ /// Int64 as two's complement in an unsigned field, so a negative bdSeq
+ /// reaches the projection as an enormous . Read raw it would become a
+ /// plausible-looking session token minted out of nonsense; run through
+ /// it is visibly negative and refused.
+ ///
+ ///
+ /// The value is treated as an opaque token, not as a 0–255 counter. Its only job
+ /// here is equality against the birth's, and a range check could never make a match more
+ /// or less correct — it could only discard the one piece of evidence tying a death to its
+ /// birth. This is the opposite call from seq, whose range is load-bearing precisely
+ /// because its arithmetic wraps.
+ ///
+ ///
+ public static bool TryReadBdSeq(SparkplugPayload? payload, out ulong bdSeq)
+ {
+ bdSeq = 0;
+
+ if (payload is not { IsValid: true })
+ {
+ return false;
+ }
+
+ foreach (var metric in payload.Metrics)
+ {
+ if (!string.Equals(metric.Name, "bdSeq", StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ if (metric.ValueKind != SparkplugValueKind.Scalar)
+ {
+ return false;
+ }
+
+ var value = metric.DataType is { } datatype
+ ? SparkplugCodec.ReinterpretSigned(metric.Value, datatype)
+ : metric.Value;
+
+ switch (value)
+ {
+ case ulong u:
+ bdSeq = u;
+ return true;
+ case uint u:
+ bdSeq = u;
+ return true;
+ case long l when l >= 0:
+ bdSeq = (ulong)l;
+ return true;
+ case int i when i >= 0:
+ bdSeq = (ulong)i;
+ return true;
+ case short s when s >= 0:
+ bdSeq = (ulong)s;
+ return true;
+ case sbyte sb when sb >= 0:
+ bdSeq = (ulong)sb;
+ return true;
+ default:
+ // Negative, or a wire type no integer token can come from.
+ return false;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SequenceTrackerTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SequenceTrackerTests.cs
new file mode 100644
index 00000000..968a56c8
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SequenceTrackerTests.cs
@@ -0,0 +1,451 @@
+using Org.Eclipse.Tahu.Protobuf;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
+
+///
+/// Covers — design doc §3.6 invariant #3. Two halves, each with a
+/// distinct failure mode the tests below pin from both directions:
+///
+/// -
+/// seq gap detection across the 0–255 wrap. Reading 255→0 as a gap
+/// rebirth-storms every edge node once per 256 messages; missing a real gap serves stale
+/// data at Good quality forever. Both directions are asserted.
+///
+/// -
+/// bdSeq birth↔death tie. A late Last-Will NDEATH from a previous session
+/// arriving after the new NBIRTH must not kill the freshly-born node.
+///
+///
+///
+public sealed class SequenceTrackerTests
+{
+ // ---------------------------------------------------------------------------------------
+ // seq — the wrap boundary
+ // ---------------------------------------------------------------------------------------
+
+ ///
+ /// The plan's headline case: 255 → 0 is the sequence continuing, not a gap. A tracker that
+ /// reads the wrap as a gap requests a rebirth every 256 messages, and a busy edge node then
+ /// spends its life republishing births instead of data.
+ ///
+ [Fact]
+ public void Sequence_WrapAt255IsContiguous_NotAGap()
+ {
+ var s = new SequenceTracker();
+
+ s.Accept(254).ShouldBeTrue();
+ s.Accept(255).ShouldBeTrue();
+ s.Accept(0).ShouldBeTrue(); // wrap
+ }
+
+ ///
+ /// The plan's mirror case: a skipped value is a genuine gap. Missing it means the driver
+ /// silently keeps serving values it knows are behind the edge node's real state.
+ ///
+ [Fact]
+ public void Sequence_SkippedValue_IsGap()
+ {
+ var s = new SequenceTracker();
+
+ s.Accept(10).ShouldBeTrue();
+ s.Accept(12).ShouldBeFalse(); // gap → caller requests rebirth
+ }
+
+ ///
+ /// Two-and-a-bit full cycles of the 0–255 range report no gap at any point. The pair of
+ /// single-step tests above can both pass on an off-by-one wrap that only misfires at one
+ /// specific value; walking every value in the range leaves nowhere for that to hide.
+ ///
+ [Fact]
+ public void Sequence_FullWrapCycles_NeverReportAGap()
+ {
+ var s = new SequenceTracker();
+ s.AcceptBirth(0).ShouldBeTrue();
+
+ for (var i = 1; i <= 600; i++)
+ {
+ var seq = (ulong)(i % 256);
+ s.Accept(seq).ShouldBeTrue($"seq {seq} (message {i}) was reported as a gap");
+ }
+ }
+
+ ///
+ /// A gap is reported once and the tracker then resynchronizes onto the observed
+ /// sequence. Leaving the baseline pinned at the pre-gap value would make every subsequent
+ /// message report a gap too, turning one lost message into a permanent rebirth storm — the
+ /// same pathology as a wrong wrap boundary, reached by a different route.
+ ///
+ [Fact]
+ public void Sequence_GapReportedOnce_ThenResynchronizes()
+ {
+ var s = new SequenceTracker();
+
+ s.Accept(10).ShouldBeTrue();
+ s.Accept(13).ShouldBeFalse(); // three messages lost
+ s.Accept(14).ShouldBeTrue(); // resynchronized onto 13
+ s.Accept(15).ShouldBeTrue();
+ }
+
+ ///
+ /// A repeated sequence number is not contiguous. Sparkplug publishes DATA at QoS 0, so a
+ /// duplicate is not a broker redelivery — it is a publisher whose stream no longer matches
+ /// what the driver can reason about, and a rebirth is the cheap correct answer.
+ ///
+ [Fact]
+ public void Sequence_RepeatedValue_IsGap()
+ {
+ var s = new SequenceTracker();
+
+ s.Accept(10).ShouldBeTrue();
+ s.Accept(10).ShouldBeFalse();
+ }
+
+ /// A sequence number going backwards is a gap, not a wrap.
+ [Fact]
+ public void Sequence_BackwardJump_IsGap()
+ {
+ var s = new SequenceTracker();
+
+ s.Accept(10).ShouldBeTrue();
+ s.Accept(9).ShouldBeFalse();
+ }
+
+ ///
+ /// The first message cannot be a gap — there is no baseline to measure it against — but the
+ /// tracker must still report that it is not birth-synchronized, because a first
+ /// message that was not a birth means the driver joined mid-stream and has no alias table.
+ /// Conflating the two would let a late join look like a healthy synchronized node.
+ ///
+ [Fact]
+ public void Sequence_FirstMessage_IsNotAGap_ButIsNotBirthSynchronized()
+ {
+ var s = new SequenceTracker();
+
+ s.IsBirthSynchronized.ShouldBeFalse();
+ s.LastSeq.ShouldBeNull();
+
+ s.Accept(137).ShouldBeTrue();
+
+ s.LastSeq.ShouldBe((byte)137);
+ s.IsBirthSynchronized.ShouldBeFalse("a mid-stream first message is a late join, not a birth");
+ }
+
+ ///
+ /// The aliasing test. The codec hands over seq as a and
+ /// deliberately declines to truncate it, so an out-of-range value reaches this type intact. A
+ /// tracker that masks it into range turns 256 into a perfectly plausible 0 — and with the
+ /// baseline sitting at 255, that is exactly the value the wrap expects next, so the bogus
+ /// message would be accepted as contiguous.
+ ///
+ [Fact]
+ public void Sequence_OutOfRangeSeq_IsRejected_AndDoesNotAliasOntoTheExpectedValue()
+ {
+ var s = new SequenceTracker();
+ s.Accept(255).ShouldBeTrue();
+
+ // 256 & 0xFF == 0, and 0 is precisely what follows 255. Masking would return true here.
+ s.Accept(256).ShouldBeFalse();
+ }
+
+ ///
+ /// A rejected out-of-range value does not become the baseline either: the tracker keeps the
+ /// last credible sequence number, so the following genuine message is still measured against
+ /// something real.
+ ///
+ [Fact]
+ public void Sequence_OutOfRangeSeq_DoesNotBecomeTheBaseline()
+ {
+ var s = new SequenceTracker();
+ s.Accept(10).ShouldBeTrue();
+
+ s.Accept(ulong.MaxValue).ShouldBeFalse();
+
+ s.LastSeq.ShouldBe((byte)10);
+ s.Accept(11).ShouldBeTrue("the baseline must still be the last credible seq, 10");
+ }
+
+ ///
+ /// A message carrying no sequence number at all is a spec violation the tracker cannot place
+ /// in the stream. It reports a gap (the safe direction — a rebirth resynchronizes everything)
+ /// and leaves the baseline alone.
+ ///
+ [Fact]
+ public void Sequence_AbsentSeq_IsGap_AndDoesNotAdvanceTheBaseline()
+ {
+ var s = new SequenceTracker();
+ s.Accept(10).ShouldBeTrue();
+
+ s.Accept(null).ShouldBeFalse();
+
+ s.LastSeq.ShouldBe((byte)10);
+ s.Accept(11).ShouldBeTrue();
+ }
+
+ // ---------------------------------------------------------------------------------------
+ // seq — births restart the sequence
+ // ---------------------------------------------------------------------------------------
+
+ ///
+ /// A birth restarts the sequence at 0, so it is never itself a gap however far the previous
+ /// session had counted. A tracker that ran the birth through the ordinary contiguity check
+ /// would flag a gap on the very message that just fixed everything, and request a rebirth in
+ /// response to a birth.
+ ///
+ [Fact]
+ public void Birth_RestartsTheSequence_SoABirthIsNeverAGap()
+ {
+ var s = new SequenceTracker();
+ s.Accept(200).ShouldBeTrue();
+
+ s.AcceptBirth(0).ShouldBeTrue();
+
+ s.LastSeq.ShouldBe((byte)0);
+ s.IsBirthSynchronized.ShouldBeTrue();
+ s.Accept(1).ShouldBeTrue();
+ }
+
+ ///
+ /// A gap detected after a birth is genuine — the birth resets the baseline, it does not
+ /// suppress detection. Without this the previous test could be satisfied by a tracker that
+ /// simply stopped checking once a birth arrived.
+ ///
+ [Fact]
+ public void Birth_ThenSkippedValue_IsStillAGap()
+ {
+ var s = new SequenceTracker();
+ s.AcceptBirth(0).ShouldBeTrue();
+
+ s.Accept(1).ShouldBeTrue();
+ s.Accept(3).ShouldBeFalse();
+ }
+
+ ///
+ /// A birth whose seq is in range but not the spec-required 0 is adopted as the
+ /// baseline rather than rejected. Refusing it would leave the tracker unsynchronized against
+ /// a publisher whose stream is otherwise perfectly followable, and every following message
+ /// would demand another rebirth.
+ ///
+ [Fact]
+ public void Birth_WithNonZeroInRangeSeq_IsAdoptedAsTheBaseline()
+ {
+ var s = new SequenceTracker();
+
+ s.AcceptBirth(7).ShouldBeTrue();
+
+ s.LastSeq.ShouldBe((byte)7);
+ s.IsBirthSynchronized.ShouldBeTrue();
+ s.Accept(8).ShouldBeTrue();
+ }
+
+ ///
+ /// A birth whose seq is out of range establishes no baseline — the same
+ /// no-masking rule as the data path, applied where it matters most.
+ ///
+ [Fact]
+ public void Birth_WithOutOfRangeSeq_EstablishesNoBaseline()
+ {
+ var s = new SequenceTracker();
+
+ s.AcceptBirth(256).ShouldBeFalse();
+
+ s.LastSeq.ShouldBeNull();
+ s.IsBirthSynchronized.ShouldBeFalse();
+ }
+
+ ///
+ /// returns the tracker to its virgin state. This is the
+ /// reconnect seam: after a dropped connection the driver has missed an unknown number of
+ /// messages, so the baseline it held is no longer evidence of anything.
+ ///
+ [Fact]
+ public void Reset_ClearsTheBaselineAndTheBirthSynchronizedFlag()
+ {
+ var s = new SequenceTracker();
+ s.AcceptBirth(0, bdSeq: 4).ShouldBeTrue();
+ s.Accept(1).ShouldBeTrue();
+
+ s.Reset();
+
+ s.LastSeq.ShouldBeNull();
+ s.IsBirthSynchronized.ShouldBeFalse();
+ s.BirthBdSeq.ShouldBeNull();
+ s.Accept(99).ShouldBeTrue("a virgin tracker cannot detect a gap on its first message");
+ }
+
+ // ---------------------------------------------------------------------------------------
+ // bdSeq — the birth↔death tie
+ // ---------------------------------------------------------------------------------------
+
+ ///
+ /// The case the tie exists for. A node's connection drops, it reconnects and publishes
+ /// a fresh NBIRTH — and only then does the broker notice the old connection died and deliver
+ /// its Last Will NDEATH, carrying the previous session's bdSeq. Without the compare,
+ /// that stale will marks a live, freshly-born node dead and every one of its tags goes Bad.
+ ///
+ [Fact]
+ public void Death_WithStaleBdSeqArrivingAfterARebirth_DoesNotApply()
+ {
+ var s = new SequenceTracker();
+ s.AcceptBirth(0, bdSeq: 7); // session 7 — the one that is about to die
+ s.AcceptBirth(0, bdSeq: 8); // session 8 — the node is back, and alive
+
+ s.IsDeathForCurrentSession(7).ShouldBeFalse("session 7's will must not kill session 8");
+ }
+
+ /// A death whose bdSeq matches the current birth is the real thing and applies.
+ [Fact]
+ public void Death_WithMatchingBdSeq_Applies()
+ {
+ var s = new SequenceTracker();
+ s.AcceptBirth(0, bdSeq: 8);
+
+ s.IsDeathForCurrentSession(8).ShouldBeTrue();
+ }
+
+ ///
+ /// A death arriving before any birth was ever seen applies. The tracker has nothing to
+ /// disprove it with, and the two failure directions are not symmetric: acting on a death
+ /// wrongly marks tags stale until the next birth restores them (§3.6 invariant #4), while
+ /// ignoring a real death leaves a dead node's last values flowing at Good quality forever.
+ ///
+ [Fact]
+ public void Death_BeforeAnyBirth_Applies()
+ {
+ var s = new SequenceTracker();
+
+ s.IsDeathForCurrentSession(7).ShouldBeTrue();
+ }
+
+ ///
+ /// A death carrying no bdSeq metric cannot be tied to anything, so it applies — same
+ /// fail-toward-stale reasoning as above.
+ ///
+ [Fact]
+ public void Death_WithNoBdSeq_Applies()
+ {
+ var s = new SequenceTracker();
+ s.AcceptBirth(0, bdSeq: 8);
+
+ s.IsDeathForCurrentSession(null).ShouldBeTrue();
+ }
+
+ /// A death applies when the birth was the one that carried no bdSeq.
+ [Fact]
+ public void Death_WhenTheBirthCarriedNoBdSeq_Applies()
+ {
+ var s = new SequenceTracker();
+ s.AcceptBirth(0, bdSeq: null);
+
+ s.IsDeathForCurrentSession(7).ShouldBeTrue();
+ }
+
+ ///
+ /// A birth records its bdSeq even when its seq was unusable. Session identity and
+ /// sequence position are independent facts; discarding the tie because the sequence number
+ /// was garbled would leave the next death unmatchable.
+ ///
+ [Fact]
+ public void Birth_WithUnusableSeq_StillRecordsItsBdSeq()
+ {
+ var s = new SequenceTracker();
+
+ s.AcceptBirth(256, bdSeq: 8).ShouldBeFalse();
+
+ s.BirthBdSeq.ShouldBe(8UL);
+ s.IsDeathForCurrentSession(7).ShouldBeFalse();
+ }
+
+ // ---------------------------------------------------------------------------------------
+ // bdSeq — reading it out of a payload
+ // ---------------------------------------------------------------------------------------
+
+ ///
+ /// bdSeq is not a top-level payload field: it rides as a metric named bdSeq in
+ /// the NBIRTH and the NDEATH.
+ ///
+ [Fact]
+ public void TryReadBdSeq_ReadsTheBdSeqMetric()
+ {
+ var payload = PayloadWith(Metric("bdSeq", DataType.Int64, 8UL));
+
+ SequenceTracker.TryReadBdSeq(payload, out var bdSeq).ShouldBeTrue();
+ bdSeq.ShouldBe(8UL);
+ }
+
+ ///
+ /// Sparkplug carries Int64 as two's complement in an unsigned field, so a negative
+ /// bdSeq reaches the projection as an enormous . Reading it raw would
+ /// mint a plausible-looking session token out of nonsense; the reinterpretation is what
+ /// exposes it as negative so it can be refused.
+ ///
+ [Fact]
+ public void TryReadBdSeq_NegativeSignedValue_IsRefused_NotReadAsAHugeToken()
+ {
+ var payload = PayloadWith(Metric("bdSeq", DataType.Int64, unchecked((ulong)-1L)));
+
+ SequenceTracker.TryReadBdSeq(payload, out var bdSeq).ShouldBeFalse();
+ bdSeq.ShouldBe(0UL);
+ }
+
+ ///
+ /// An unsigned bdSeq is read as-is — no reinterpretation applies. (The generated enum member
+ /// is Uint64, not UInt64: protobuf's C# codegen lowercases the interior of the
+ /// proto's UInt64.)
+ ///
+ [Fact]
+ public void TryReadBdSeq_UnsignedValue_IsReadAsIs()
+ {
+ var payload = PayloadWith(Metric("bdSeq", DataType.Uint64, 9UL));
+
+ SequenceTracker.TryReadBdSeq(payload, out var bdSeq).ShouldBeTrue();
+ bdSeq.ShouldBe(9UL);
+ }
+
+ /// A payload that carries no bdSeq metric reports absence rather than a default.
+ [Fact]
+ public void TryReadBdSeq_PayloadWithoutTheMetric_ReportsAbsence()
+ {
+ var payload = PayloadWith(Metric("Temperature", DataType.Float, 21.5f));
+
+ SequenceTracker.TryReadBdSeq(payload, out var bdSeq).ShouldBeFalse();
+ bdSeq.ShouldBe(0UL);
+ }
+
+ ///
+ /// An undecodable payload yields no bdSeq. It presents as metric-less exactly like a valid
+ /// but empty one, and only tells them apart.
+ ///
+ [Fact]
+ public void TryReadBdSeq_InvalidPayload_ReportsAbsence()
+ {
+ SequenceTracker.TryReadBdSeq(SparkplugPayload.Invalid, out _).ShouldBeFalse();
+ }
+
+ /// A null payload is a verdict, not an exception — this runs on the dispatcher thread.
+ [Fact]
+ public void TryReadBdSeq_NullPayload_ReportsAbsence_DoesNotThrow()
+ {
+ Should.NotThrow(() => SequenceTracker.TryReadBdSeq(null, out _).ShouldBeFalse());
+ }
+
+ ///
+ /// A bdSeq metric with no scalar value (explicitly null, or a kind v1 does not support) is
+ /// not a session token.
+ ///
+ [Fact]
+ public void TryReadBdSeq_NonScalarMetric_ReportsAbsence()
+ {
+ var payload = PayloadWith(new SparkplugMetric("bdSeq", null, DataType.Int64, null, SparkplugValueKind.Null, null));
+
+ SequenceTracker.TryReadBdSeq(payload, out _).ShouldBeFalse();
+ }
+
+ private static SparkplugPayload PayloadWith(params SparkplugMetric[] metrics) =>
+ new(IsValid: true, Seq: 0UL, TimestampMs: 1UL, Metrics: metrics);
+
+ private static SparkplugMetric Metric(string name, DataType dataType, object value) =>
+ new(name, Alias: null, dataType, TimestampMs: null, SparkplugValueKind.Scalar, value);
+}