fix(mqtt): AcceptBirth is NBIRTH-only -- a DBIRTH routed there wipes bdSeq

Self-review of the commit before this one caught a defect in the contract it
published rather than in the code it ran. AcceptBirth's doc said it records
"a birth (NBIRTH/DBIRTH)". That is wrong on both halves of this type, and the
wrong version plants exactly the defect the bdSeq tie exists to prevent.

Only the NBIRTH restarts the Sparkplug sequence; a DBIRTH carries the next
number in the edge node's ongoing stream, so it belongs to Accept. And only
the NBIRTH and NDEATH carry bdSeq at all -- a DBIRTH has none to pass. So a
Task 21 that followed the old doc and routed a DBIRTH to AcceptBirth would do
two wrong things at once: rebase the sequence mid-stream, and wipe the node's
session token to null. A stale Last Will arriving afterwards would then find
nothing to compare against, fall through the deliberate fail-toward-stale
rule in IsDeathForCurrentSession, and kill the live node.

Renamed AcceptBirth -> AcceptNodeBirth so the DBIRTH call site reads wrong at
a glance rather than only in prose, documented the hazard on both methods,
and added DeviceBirth_ContinuesTheNodeSequence_AndLeavesTheSessionTokenAlone
to pin the shape at this type's own boundary -- the ingest state machine now
has something to fail against if it wires the call sites the other way round.

Falsifiability: mutating Accept to clear _birthBdSeq reddens exactly the new
test (1 RED), reverted. 29 tests, 438/438 for the MQTT suite.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
Joseph Doherty
2026-07-24 21:32:44 -04:00
parent e4e313cf1c
commit 3fdf24659f
2 changed files with 53 additions and 19 deletions
@@ -63,7 +63,7 @@ public sealed class SequenceTrackerTests
public void Sequence_FullWrapCycles_NeverReportAGap()
{
var s = new SequenceTracker();
s.AcceptBirth(0).ShouldBeTrue();
s.AcceptNodeBirth(0).ShouldBeTrue();
for (var i = 1; i <= 600; i++)
{
@@ -200,7 +200,7 @@ public sealed class SequenceTrackerTests
var s = new SequenceTracker();
s.Accept(200).ShouldBeTrue();
s.AcceptBirth(0).ShouldBeTrue();
s.AcceptNodeBirth(0).ShouldBeTrue();
s.LastSeq.ShouldBe((byte)0);
s.IsBirthSynchronized.ShouldBeTrue();
@@ -216,7 +216,7 @@ public sealed class SequenceTrackerTests
public void Birth_ThenSkippedValue_IsStillAGap()
{
var s = new SequenceTracker();
s.AcceptBirth(0).ShouldBeTrue();
s.AcceptNodeBirth(0).ShouldBeTrue();
s.Accept(1).ShouldBeTrue();
s.Accept(3).ShouldBeFalse();
@@ -233,7 +233,7 @@ public sealed class SequenceTrackerTests
{
var s = new SequenceTracker();
s.AcceptBirth(7).ShouldBeTrue();
s.AcceptNodeBirth(7).ShouldBeTrue();
s.LastSeq.ShouldBe((byte)7);
s.IsBirthSynchronized.ShouldBeTrue();
@@ -249,12 +249,35 @@ public sealed class SequenceTrackerTests
{
var s = new SequenceTracker();
s.AcceptBirth(256).ShouldBeFalse();
s.AcceptNodeBirth(256).ShouldBeFalse();
s.LastSeq.ShouldBeNull();
s.IsBirthSynchronized.ShouldBeFalse();
}
/// <summary>
/// <b>A DBIRTH continues the edge node's sequence — it does not restart it, and it must not be
/// routed through <see cref="SequenceTracker.AcceptNodeBirth"/>.</b> Only the NBIRTH resets
/// <c>seq</c>, and only the NBIRTH/NDEATH carry <c>bdSeq</c>. Sending a DBIRTH to the node-birth
/// entry point would rebase the sequence mid-stream <i>and</i> — having no <c>bdSeq</c> to pass
/// — wipe the session token, at which point a stale Last Will finds nothing to be compared
/// against and kills the live node. This test pins the shape at the tracker's own boundary so
/// the ingest state machine has something to fail against if it wires the call sites the other
/// way round.
/// </summary>
[Fact]
public void DeviceBirth_ContinuesTheNodeSequence_AndLeavesTheSessionTokenAlone()
{
var s = new SequenceTracker();
s.AcceptNodeBirth(0, bdSeq: 7).ShouldBeTrue();
s.Accept(1).ShouldBeTrue(); // the DBIRTH — next in the node's stream, not a restart
s.Accept(2).ShouldBeTrue(); // first DDATA
s.BirthBdSeq.ShouldBe(7UL, "a device birth must not disturb the node's session token");
s.IsDeathForCurrentSession(6).ShouldBeFalse("the stale-will guard must still have a token to compare");
}
/// <summary>
/// <see cref="SequenceTracker.Reset"/> returns the tracker to its virgin state. This is the
/// reconnect seam: after a dropped connection the driver has missed an unknown number of
@@ -264,7 +287,7 @@ public sealed class SequenceTrackerTests
public void Reset_ClearsTheBaselineAndTheBirthSynchronizedFlag()
{
var s = new SequenceTracker();
s.AcceptBirth(0, bdSeq: 4).ShouldBeTrue();
s.AcceptNodeBirth(0, bdSeq: 4).ShouldBeTrue();
s.Accept(1).ShouldBeTrue();
s.Reset();
@@ -289,8 +312,8 @@ public sealed class SequenceTrackerTests
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.AcceptNodeBirth(0, bdSeq: 7); // session 7 — the one that is about to die
s.AcceptNodeBirth(0, bdSeq: 8); // session 8 — the node is back, and alive
s.IsDeathForCurrentSession(7).ShouldBeFalse("session 7's will must not kill session 8");
}
@@ -300,7 +323,7 @@ public sealed class SequenceTrackerTests
public void Death_WithMatchingBdSeq_Applies()
{
var s = new SequenceTracker();
s.AcceptBirth(0, bdSeq: 8);
s.AcceptNodeBirth(0, bdSeq: 8);
s.IsDeathForCurrentSession(8).ShouldBeTrue();
}
@@ -327,7 +350,7 @@ public sealed class SequenceTrackerTests
public void Death_WithNoBdSeq_Applies()
{
var s = new SequenceTracker();
s.AcceptBirth(0, bdSeq: 8);
s.AcceptNodeBirth(0, bdSeq: 8);
s.IsDeathForCurrentSession(null).ShouldBeTrue();
}
@@ -337,7 +360,7 @@ public sealed class SequenceTrackerTests
public void Death_WhenTheBirthCarriedNoBdSeq_Applies()
{
var s = new SequenceTracker();
s.AcceptBirth(0, bdSeq: null);
s.AcceptNodeBirth(0, bdSeq: null);
s.IsDeathForCurrentSession(7).ShouldBeTrue();
}
@@ -352,7 +375,7 @@ public sealed class SequenceTrackerTests
{
var s = new SequenceTracker();
s.AcceptBirth(256, bdSeq: 8).ShouldBeFalse();
s.AcceptNodeBirth(256, bdSeq: 8).ShouldBeFalse();
s.BirthBdSeq.ShouldBe(8UL);
s.IsDeathForCurrentSession(7).ShouldBeFalse();