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
@@ -88,8 +88,8 @@ public sealed class SequenceTracker
}
/// <summary>
/// Records a birth (NBIRTH/DBIRTH): restarts the sequence at the birth's <c>seq</c> and adopts
/// its <c>bdSeq</c> as the current session token.
/// Records an <b>NBIRTH</b>: restarts the sequence at the birth's <c>seq</c> and adopts its
/// <c>bdSeq</c> as the current session token.
/// </summary>
/// <param name="seq">
/// The birth payload's <c>seq</c> — <see cref="SparkplugPayload.Seq"/>, which is
@@ -105,11 +105,22 @@ public sealed class SequenceTracker
/// </returns>
/// <remarks>
/// <para>
/// <b>A birth is never a gap.</b> It restarts the sequence by definition, so it must not
/// <b>An NBIRTH is never a gap.</b> It restarts the sequence by definition, so it must not
/// be run through <see cref="Accept"/> — doing so would flag a gap on the very message
/// that just resynchronized everything, and answer a birth with a request for another one.
/// </para>
/// <para>
/// <b>A DBIRTH must NOT be offered here — it belongs to <see cref="Accept"/>.</b> Only the
/// NBIRTH restarts the sequence; a DBIRTH carries the next number in the edge node's
/// ongoing stream, and only the NBIRTH (with the NDEATH) carries <c>bdSeq</c> at all.
/// Routing a DBIRTH here would do two wrong things at once: silently rebase the sequence
/// mid-stream, and — because the DBIRTH has no <c>bdSeq</c> to pass — <b>wipe the node's
/// session token to null</b>. A stale Last Will arriving afterwards would then find
/// nothing to be compared against, fall through the fail-toward-stale rule in
/// <see cref="IsDeathForCurrentSession"/>, and kill the live node this type exists to
/// protect. The method is named for the node deliberately, so that call site reads wrong.
/// </para>
/// <para>
/// <b>An in-range <c>seq</c> other than the spec-required 0 is adopted, not refused.</b>
/// 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
@@ -122,7 +133,7 @@ public sealed class SequenceTracker
/// thing that lets a stale will kill a live node.
/// </para>
/// </remarks>
public bool AcceptBirth(ulong? seq, ulong? bdSeq = null)
public bool AcceptNodeBirth(ulong? seq, ulong? bdSeq = null)
{
lock (_gate)
{
@@ -143,8 +154,8 @@ public sealed class SequenceTracker
}
/// <summary>
/// Offers the next sequenced message's <c>seq</c> (NDATA/DDATA/DDEATH) and reports whether the
/// stream is still contiguous.
/// Offers the next sequenced message's <c>seq</c> — <b>DBIRTH</b>, NDATA, DDATA or DDEATH
/// and reports whether the stream is still contiguous.
/// </summary>
/// <param name="seq">
/// The payload's <c>seq</c> — <see cref="SparkplugPayload.Seq"/>, which is
@@ -252,7 +263,7 @@ public sealed class SequenceTracker
/// <para>
/// <b>Pure.</b> It answers a question and changes nothing, so the caller can ask it while
/// logging or deciding. A death that <i>is</i> acted on needs no reset here: the node is
/// offline, and the next birth calls <see cref="AcceptBirth"/> which restarts the sequence
/// offline, and the next birth calls <see cref="AcceptNodeBirth"/> which restarts the sequence
/// anyway.
/// </para>
/// </remarks>