diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/AliasTable.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/AliasTable.cs
new file mode 100644
index 00000000..45d66b0b
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/AliasTable.cs
@@ -0,0 +1,263 @@
+using System.Collections.Frozen;
+using System.Diagnostics.CodeAnalysis;
+using TahuDataType = Org.Eclipse.Tahu.Protobuf.DataType;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
+
+///
+/// One (edgeNode, device) scope's metric catalog, as declared by its most recent
+/// NBIRTH/DBIRTH: the alias→metric cache and the stable name→metric index, rebuilt wholesale
+/// on every birth. Design doc §3.6 invariants #1 and #2.
+///
+///
+///
+/// The alias is a per-birth cache; the NAME is the binding key. Sparkplug's alias
+/// mechanism exists to shrink DATA payloads — a BIRTH declares name ↔ alias and every
+/// subsequent DATA metric carries only the alias — and the alias is scoped to that one
+/// birth. After a rebirth an edge node is entirely free to point alias 5 at a different
+/// metric. So an authored tag binds by ; the alias is
+/// only ever the lookup that turns an incoming DATA metric back into a name. Binding a tag to
+/// an alias instead means a rebirth silently starts routing Pressure into a Temperature tag —
+/// good quality, plausible values, completely wrong, and invisible unless something
+/// specifically reuses an alias across a rebirth.
+///
+///
+/// REPLACES; it never merges or upserts. The whole
+/// snapshot — both indexes — is built fresh and published in one assignment. An alias the new
+/// birth did not declare is gone, including when the new birth declares no metrics at all. This
+/// is the single behaviour the type exists to guarantee: a merge (or an "optimisation" that
+/// skips an empty birth) reintroduces exactly the stale-alias mis-route above.
+///
+///
+/// Both indexes are replaced together, from the same birth. They are deliberately held
+/// in one type rather than split across an "alias table" and a separate "birth cache": they are
+/// two views of one birth, and any seam between them is somewhere the two can be replaced
+/// independently and skew — an alias resolving through the new birth into a name catalog still
+/// holding the old one.
+///
+///
+/// Thread-safety: an immutable snapshot swapped atomically, the same discipline
+/// MqttSubscriptionManager.AuthoredTable uses and for the same reason — this is read on
+/// MQTTnet's shared dispatcher thread as messages arrive, and a reader must never see a
+/// half-rebuilt map. No lock is taken on any path.
+///
+///
+/// Last values do not live here. The running last-observed value per tag is
+/// 's, keyed by RawPath — the identity every publish in this driver
+/// is made under. A second copy keyed by metric name would be a duplicate source of truth with
+/// a different key, and it would grow with every metric the plant births whether or not any tag
+/// was ever authored for it. A birth's own values flow straight through the ingest path to that
+/// cache; nothing is retained here.
+///
+///
+public sealed class AliasTable
+{
+ private volatile Snapshot _snapshot = Snapshot.Empty;
+
+ /// How many distinct metrics the most recent birth declared.
+ public int Count => _snapshot.Metrics.Length;
+
+ ///
+ /// Every metric the most recent birth declared. The consumer's STALE fan-out on a death, and
+ /// the discovery path's "which metrics does this scope have" question, both read this.
+ ///
+ public IReadOnlyList Metrics => _snapshot.Metrics;
+
+ ///
+ /// Replaces this scope's whole catalog with — the metrics of one
+ /// NBIRTH/DBIRTH payload, exactly as projected them.
+ ///
+ /// The birth payload's metrics.
+ ///
+ /// What was installed, and what was refused — the caller's only window onto a malformed birth,
+ /// since this type takes no logger.
+ ///
+ ///
+ ///
+ /// Only an unusable NAME is a rejection. A metric with a blank/absent name cannot be
+ /// bound to an authored tag by any route, and storing it alias-only would create a binding
+ /// that can never resolve (or, worse, one that matches a tag whose authored metric name
+ /// happens to be blank), so it is dropped and counted.
+ ///
+ ///
+ /// A missing datatype is NOT a rejection — it becomes
+ /// . Dropping such a metric would turn every DATA message
+ /// for its alias into an unknown-alias event, which the ingest state machine answers with a
+ /// rebirth request; the edge node would answer with the same malformed birth, and the pair
+ /// would loop. Kept as Unknown, the typed layer refuses the value once
+ /// (ToDriverDataType() returns null for it) and nothing storms.
+ ///
+ ///
+ /// An alias claimed by two metrics in one birth resolves to neither. Any choice
+ /// between them is a coin flip between two different signals, which is the mis-route this
+ /// type exists to prevent; the alias is dropped (the consumer sees an unknown alias and can
+ /// request a rebirth) while both metrics stay bindable by name. A duplicate name, by
+ /// contrast, is last-wins: the name is the binding key, so evicting it would take the
+ /// authored tag dark entirely.
+ ///
+ ///
+ public BirthApplyResult RebuildFromBirth(IEnumerable birthMetrics)
+ {
+ ArgumentNullException.ThrowIfNull(birthMetrics);
+
+ var byName = new Dictionary(StringComparer.Ordinal);
+ var byAlias = new Dictionary();
+ HashSet? collidedAliases = null;
+ var rejectedUnnamed = 0;
+ var duplicateNames = 0;
+ var aliasCollisions = 0;
+
+ foreach (var metric in birthMetrics)
+ {
+ if (string.IsNullOrWhiteSpace(metric.Name))
+ {
+ rejectedUnnamed++;
+ continue;
+ }
+
+ var binding = new SparkplugMetricBinding(metric.Name, metric.Alias, metric.DataType ?? TahuDataType.Unknown);
+
+ if (!byName.TryAdd(binding.Name, binding))
+ {
+ duplicateNames++;
+ byName[binding.Name] = binding;
+ }
+
+ if (binding.Alias is { } alias && !byAlias.TryAdd(alias, binding))
+ {
+ aliasCollisions++;
+ (collidedAliases ??= []).Add(alias);
+ }
+ }
+
+ if (collidedAliases is not null)
+ {
+ foreach (var alias in collidedAliases)
+ {
+ byAlias.Remove(alias);
+ }
+ }
+
+ // A duplicate name is last-wins in `byName`, so the alias index can still hold the losing
+ // binding — drop those too rather than let an alias resolve to a name that resolves elsewhere.
+ if (duplicateNames > 0)
+ {
+ foreach (var alias in byAlias.Where(kv => !ReferenceEquals(byName[kv.Value.Name], kv.Value))
+ .Select(kv => kv.Key).ToList())
+ {
+ byAlias.Remove(alias);
+ }
+ }
+
+ // THE replace. One assignment of a fully-built snapshot — no merge, no upsert, and no
+ // short-circuit for an empty birth (an empty birth legitimately clears the scope).
+ _snapshot = new Snapshot(
+ byAlias.ToFrozenDictionary(),
+ byName.ToFrozenDictionary(StringComparer.Ordinal),
+ [.. byName.Values]);
+
+ return new BirthApplyResult(byName.Count, rejectedUnnamed, aliasCollisions, duplicateNames);
+ }
+
+ ///
+ /// Resolves a DATA metric's alias to the metric the most recent birth bound it to, or
+ /// when this birth declared no such alias.
+ ///
+ /// The alias carried by an inbound DATA metric.
+ /// The binding, or — which the consumer treats as an unknown alias.
+ public SparkplugMetricBinding? Resolve(ulong alias) =>
+ _snapshot.ByAlias.TryGetValue(alias, out var binding) ? binding : null;
+
+ /// The try-shape.
+ /// The alias carried by an inbound DATA metric.
+ /// The binding when this returns .
+ /// when the most recent birth declared this alias.
+ public bool TryResolve(ulong alias, [NotNullWhen(true)] out SparkplugMetricBinding? binding)
+ {
+ binding = Resolve(alias);
+ return binding is not null;
+ }
+
+ ///
+ /// Resolves by the stable metric name — the binding key. Used for a DATA metric that carried its
+ /// name rather than an alias (aliases are optional in Sparkplug), and to answer "does this scope
+ /// publish the metric this tag was authored against".
+ ///
+ /// The stable metric name. Matched ordinally; Sparkplug names are case-sensitive.
+ /// The binding, or .
+ public SparkplugMetricBinding? ResolveByName(string name) =>
+ !string.IsNullOrEmpty(name) && _snapshot.ByName.TryGetValue(name, out var binding) ? binding : null;
+
+ /// The try-shape.
+ /// The stable metric name.
+ /// The binding when this returns .
+ /// when the most recent birth declared this metric.
+ public bool TryResolveByName(string name, [NotNullWhen(true)] out SparkplugMetricBinding? binding)
+ {
+ binding = ResolveByName(name);
+ return binding is not null;
+ }
+
+ ///
+ /// One birth's catalog, immutable and published in a single assignment so the dispatcher thread
+ /// always reads one whole birth's worth of state.
+ ///
+ /// Alias → binding, for the birth that declared it. Collided aliases are absent.
+ /// Stable metric name → binding — the index authored tags bind through.
+ /// Every declared metric, for the STALE fan-out and discovery.
+ private sealed record Snapshot(
+ FrozenDictionary ByAlias,
+ FrozenDictionary ByName,
+ SparkplugMetricBinding[] Metrics)
+ {
+ public static readonly Snapshot Empty = new(
+ FrozenDictionary.Empty,
+ FrozenDictionary.Empty,
+ []);
+ }
+}
+
+/// One metric as its birth declared it: the stable name, the per-birth alias, the datatype.
+///
+/// The stable metric name — the binding key. An authored tag's metricName matches
+/// against this, never against .
+///
+///
+/// The alias this birth assigned, or when the birth declared none (legal —
+/// such a publisher sends names in DATA too). Valid only until the next birth for this scope.
+///
+///
+/// The datatype the birth declared, or when it declared none.
+/// This is the only place a DATA metric's datatype can come from — DATA carries none.
+///
+public sealed record SparkplugMetricBinding(string Name, ulong? Alias, TahuDataType DataType)
+{
+ ///
+ /// Applies to a raw DATA value using
+ /// this birth's datatype.
+ ///
+ /// The raw from a DATA metric.
+ /// The signed value for a signed-integer datatype; otherwise.
+ ///
+ /// Exposed here because this is the one object that holds the datatype at the moment a DATA
+ /// value is being resolved, and forgetting the call is silent: Sparkplug carries Int8/16/32/64
+ /// as two's complement in an unsigned proto field, so a metric whose value is -42
+ /// publishes as 4294967254 — plausible, Good quality, and invisible until someone reads a gauge.
+ ///
+ public object? Reinterpret(object? rawValue) => SparkplugCodec.ReinterpretSigned(rawValue, DataType);
+}
+
+/// What one installed, and what it refused.
+/// Distinct named metrics installed.
+/// Metrics dropped for carrying no usable name.
+/// Aliases claimed by more than one metric, and therefore left unresolvable.
+/// Metrics whose name a later metric in the same birth overwrote.
+public readonly record struct BirthApplyResult(
+ int Accepted,
+ int RejectedUnnamed,
+ int AliasCollisions,
+ int DuplicateNames)
+{
+ /// Whether the birth was malformed in any way worth logging.
+ public bool HasAnomaly => RejectedUnnamed > 0 || AliasCollisions > 0 || DuplicateNames > 0;
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/BirthCache.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/BirthCache.cs
new file mode 100644
index 00000000..126238e3
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/BirthCache.cs
@@ -0,0 +1,234 @@
+using System.Collections.Concurrent;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
+
+///
+/// The driver's live Sparkplug birth state: one per
+/// , with the spec's node→device invalidation rules applied on birth
+/// and on death. Design doc §3.6 invariants #2 and #4.
+///
+///
+///
+/// Scoping. A scope is the full (group, edgeNode, device?) triple — never the
+/// device id alone and never node/device without the group, or one plant's
+/// Filler1 would answer for another's. A node-scoped birth (NBIRTH) owns the metrics the
+/// edge node publishes itself; each DBIRTH owns its device's. They are separate scopes because
+/// Sparkplug gives them separate alias spaces: alias 5 on EdgeA and alias 5 on
+/// EdgeA/Filler1 are unrelated metrics.
+///
+///
+/// An NBIRTH invalidates every device under that node. Per the Sparkplug spec an NBIRTH
+/// means the edge node (re)started and must re-publish a DBIRTH for each of its devices, so
+/// every previously published DBIRTH is void. Keeping a device's alias table across its node's
+/// rebirth is the same stale-alias mis-route as
+/// guards against, one scope up — and it is easier to
+/// miss, because nothing about the DBIRTH itself changed. The invalidated devices are returned
+/// () rather than merely dropped, so the
+/// consumer can fan STALE out over their metrics without a lookup against a table that has
+/// already been evicted.
+///
+///
+/// A death evicts, it does not blank. After an NDEATH/DDEATH the scope is absent,
+/// which is what makes a DATA message arriving before the next birth a data-before-birth event
+/// (⇒ request a rebirth, §3.6 invariant #3) rather than a silent unknown-alias drop against a
+/// table that still looks alive.
+///
+///
+/// Thread-safety. A of scopes, each
+/// holding an whose own state is an atomically swapped immutable
+/// snapshot. A rebirth rebuilds the existing table in place rather than replacing the
+/// table object, so a consumer holding a reference to a scope's table always observes the newest
+/// birth instead of quietly reading a detached older one. No lock is taken on any path — this is
+/// read (and written) on MQTTnet's shared dispatcher thread.
+///
+///
+/// Not a value store. See the remarks on : running values belong
+/// to , keyed by RawPath.
+///
+///
+public sealed class BirthCache
+{
+ private readonly ConcurrentDictionary _scopes = new();
+
+ /// How many scopes currently hold a live birth.
+ public int Count => _scopes.Count;
+
+ /// A snapshot of the scopes currently holding a live birth.
+ public IReadOnlyList Scopes => [.. _scopes.Keys];
+
+ ///
+ /// Applies an NBIRTH: rebuilds the edge node's own catalog and invalidates every device
+ /// under it, per the Sparkplug spec.
+ ///
+ /// The Sparkplug group id.
+ /// The Sparkplug edge-node id.
+ /// The NBIRTH payload's metrics.
+ /// The node's apply result plus the devices this birth invalidated, with their metrics.
+ public NodeBirthOutcome ApplyNodeBirth(string groupId, string edgeNodeId, IEnumerable birthMetrics)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(groupId);
+ ArgumentException.ThrowIfNullOrEmpty(edgeNodeId);
+ ArgumentNullException.ThrowIfNull(birthMetrics);
+
+ // Devices first: a device table must never outlive the node birth that voided it, whatever the
+ // node's own rebuild does.
+ var invalidated = Evict(scope => scope.DeviceId is not null && scope.IsUnder(groupId, edgeNodeId));
+
+ var result = TableFor(new SparkplugScope(groupId, edgeNodeId, null)).RebuildFromBirth(birthMetrics);
+ return new NodeBirthOutcome(result, invalidated);
+ }
+
+ /// Applies a DBIRTH: rebuilds exactly that device's catalog, wholesale.
+ /// The Sparkplug group id.
+ /// The Sparkplug edge-node id.
+ /// The Sparkplug device id.
+ /// The DBIRTH payload's metrics.
+ /// What the birth installed, and what it refused.
+ public BirthApplyResult ApplyDeviceBirth(
+ string groupId,
+ string edgeNodeId,
+ string deviceId,
+ IEnumerable birthMetrics)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(groupId);
+ ArgumentException.ThrowIfNullOrEmpty(edgeNodeId);
+ ArgumentException.ThrowIfNullOrEmpty(deviceId);
+ ArgumentNullException.ThrowIfNull(birthMetrics);
+
+ return TableFor(new SparkplugScope(groupId, edgeNodeId, deviceId)).RebuildFromBirth(birthMetrics);
+ }
+
+ ///
+ /// The scope's catalog, or when no live birth has been seen for it —
+ /// which is the consumer's data-before-birth signal.
+ ///
+ /// The scope to look up.
+ /// The catalog, or .
+ public AliasTable? Find(SparkplugScope scope) => _scopes.TryGetValue(scope, out var table) ? table : null;
+
+ ///
+ /// Applies an NDEATH: forgets the edge node and every device under it, returning what was
+ /// forgotten so the consumer can emit STALE for those metrics.
+ ///
+ /// The Sparkplug group id.
+ /// The Sparkplug edge-node id.
+ /// The evicted scopes and their metrics; empty when nothing was live.
+ public IReadOnlyList ForgetNode(string groupId, string edgeNodeId)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(groupId);
+ ArgumentException.ThrowIfNullOrEmpty(edgeNodeId);
+
+ return Evict(scope => scope.IsUnder(groupId, edgeNodeId));
+ }
+
+ ///
+ /// Applies a DDEATH: forgets exactly one device, returning its metrics for the STALE fan-out.
+ /// The node's own catalog is untouched.
+ ///
+ /// The Sparkplug group id.
+ /// The Sparkplug edge-node id.
+ /// The Sparkplug device id.
+ /// The evicted scope and its metrics when this returns .
+ /// when the device had a live birth to forget.
+ public bool TryForgetDevice(
+ string groupId,
+ string edgeNodeId,
+ string deviceId,
+ out SparkplugScopeMetrics removed)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(groupId);
+ ArgumentException.ThrowIfNullOrEmpty(edgeNodeId);
+ ArgumentException.ThrowIfNullOrEmpty(deviceId);
+
+ var scope = new SparkplugScope(groupId, edgeNodeId, deviceId);
+ if (_scopes.TryRemove(scope, out var table))
+ {
+ removed = new SparkplugScopeMetrics(scope, table.Metrics);
+ return true;
+ }
+
+ removed = default;
+ return false;
+ }
+
+ /// Forgets every scope — the disconnect/reinitialise reset.
+ public void Clear() => _scopes.Clear();
+
+ ///
+ /// The scope's catalog, created empty on first use. Rebuilds happen in place on the
+ /// returned instance, so a consumer holding it observes the newest birth rather than a detached
+ /// older one.
+ ///
+ /// The scope.
+ /// The scope's catalog.
+ private AliasTable TableFor(SparkplugScope scope) => _scopes.GetOrAdd(scope, static _ => new AliasTable());
+
+ /// Removes every scope matching , capturing their metrics first.
+ /// Which scopes to evict.
+ /// The evicted scopes and the metrics they held.
+ private List Evict(Func predicate)
+ {
+ List? evicted = null;
+
+ // ConcurrentDictionary enumeration is weakly consistent, which is exactly right here: this runs
+ // on the dispatcher thread and only needs to see the scopes that existed when the birth/death
+ // arrived.
+ foreach (var scope in _scopes.Keys)
+ {
+ if (!predicate(scope) || !_scopes.TryRemove(scope, out var table))
+ {
+ continue;
+ }
+
+ (evicted ??= []).Add(new SparkplugScopeMetrics(scope, table.Metrics));
+ }
+
+ return evicted ?? [];
+ }
+}
+
+///
+/// A Sparkplug metric-namespace scope: an edge node, or one device under it. The identity every
+/// birth, death and alias resolution is keyed by.
+///
+/// The Sparkplug group id.
+/// The Sparkplug edge-node id.
+/// The Sparkplug device id, or for the node's own scope.
+public readonly record struct SparkplugScope(string GroupId, string EdgeNodeId, string? DeviceId)
+{
+ /// Whether this is a device scope (a DBIRTH's) rather than an edge node's own.
+ public bool IsDevice => DeviceId is not null;
+
+ /// The owning edge node's scope — this instance itself when it is already node-scoped.
+ public SparkplugScope NodeScope => DeviceId is null ? this : new SparkplugScope(GroupId, EdgeNodeId, null);
+
+ /// Whether this scope belongs to the given edge node (the node's own scope included).
+ /// The Sparkplug group id.
+ /// The Sparkplug edge-node id.
+ /// when both segments match ordinally.
+ public bool IsUnder(string groupId, string edgeNodeId) =>
+ string.Equals(GroupId, groupId, StringComparison.Ordinal)
+ && string.Equals(EdgeNodeId, edgeNodeId, StringComparison.Ordinal);
+
+ ///
+ public override string ToString() =>
+ DeviceId is null ? $"{GroupId}/{EdgeNodeId}" : $"{GroupId}/{EdgeNodeId}/{DeviceId}";
+}
+
+///
+/// A scope's metrics, captured at the moment it was evicted — the STALE fan-out's input, taken
+/// before the eviction so it cannot race a re-birth of the same scope.
+///
+/// The scope that was evicted.
+/// The metrics its last birth declared.
+public readonly record struct SparkplugScopeMetrics(SparkplugScope Scope, IReadOnlyList Metrics);
+
+/// The result of an NBIRTH: the node's own apply, plus the devices it invalidated.
+/// What the node's catalog rebuild installed and refused.
+///
+/// The device scopes this NBIRTH voided, with the metrics they held — the STALE fan-out's input
+/// until each device re-DBIRTHs.
+///
+public readonly record struct NodeBirthOutcome(
+ BirthApplyResult Result,
+ IReadOnlyList InvalidatedDevices);
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/AliasBindingTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/AliasBindingTests.cs
new file mode 100644
index 00000000..4fe69960
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/AliasBindingTests.cs
@@ -0,0 +1,393 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
+
+// See SparkplugTopicTests.cs for why this alias is redeclared locally in every consuming project
+// rather than inherited: `SparkplugDataType` is a `global using` alias for the generated
+// `Org.Eclipse.Tahu.Protobuf.DataType` (declared in Contracts/SparkplugDataType.cs), and `global using`
+// scope does not cross a ProjectReference.
+using SparkplugDataType = Org.Eclipse.Tahu.Protobuf.DataType;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
+
+///
+/// Covers + (Task 18) — design doc §3.6
+/// invariants #1 and #2.
+///
+///
+///
+/// What is actually being pinned here. Sparkplug's alias mechanism exists to shrink DATA
+/// payloads: a BIRTH declares name ↔ alias and every subsequent DATA metric carries only
+/// the alias. The alias is scoped to one birth — after a rebirth an edge node is free to
+/// assign alias 5 to an entirely different metric. If this driver merged alias tables across
+/// births, or bound authored tags to aliases rather than to the stable metric name, a rebirth
+/// would silently start routing Pressure readings into a Temperature tag: good quality,
+/// plausible values, completely wrong. It is invisible without a test that specifically
+/// reuses an alias across a rebirth, which is what
+/// and
+/// do.
+///
+///
+/// The cross-scope half of the same hazard is that a device's alias table can outlive the birth
+/// it came from when its edge node re-births — pinned by
+/// .
+///
+///
+public sealed class AliasBindingTests
+{
+ // ---- AliasTable: the two load-bearing invariants ----
+
+ ///
+ /// §3.6 invariant #1/#2, the headline case: a rebirth may point the same alias at a different
+ /// metric, and the table must report the new metric — i.e. the birth replaced the map
+ /// rather than merging into it.
+ ///
+ [Fact]
+ public void Rebirth_ReusesAliasForDifferentMetric_ResolvesByName_NotAlias()
+ {
+ var table = new AliasTable();
+
+ table.RebuildFromBirth([Birth("Temperature", 5, SparkplugDataType.Float)]);
+ table.Resolve(5)!.Name.ShouldBe("Temperature");
+
+ // Rebirth: alias 5 now means a DIFFERENT metric.
+ table.RebuildFromBirth([Birth("Pressure", 5, SparkplugDataType.Float)]);
+
+ table.Resolve(5)!.Name.ShouldBe("Pressure"); // wholesale replace, not merge
+ table.TryResolveByName("Temperature", out _).ShouldBeFalse();
+ }
+
+ ///
+ /// §3.6 invariant #2: an alias the new birth did not declare must be gone, not carried
+ /// forward. A merge would leave alias 1 resolving to a metric the edge node no longer publishes.
+ ///
+ [Fact]
+ public void RebuildFromBirth_DoesNotMergeStaleAliases()
+ {
+ var table = new AliasTable();
+
+ table.RebuildFromBirth([Birth("A", 1, SparkplugDataType.Int32)]);
+ table.RebuildFromBirth([Birth("B", 2, SparkplugDataType.Int32)]);
+
+ table.TryResolve(1, out _).ShouldBeFalse(); // alias 1 gone after rebirth
+ table.TryResolve(2, out _).ShouldBeTrue();
+ table.Count.ShouldBe(1);
+ }
+
+ ///
+ /// The degenerate replace path: a birth carrying no usable metric still clears the table.
+ /// An implementation that short-circuits on an empty input (or merges) keeps every stale alias
+ /// alive behind a birth that declared none of them.
+ ///
+ [Fact]
+ public void RebuildFromBirth_EmptyBirth_ClearsEveryAlias()
+ {
+ var table = new AliasTable();
+ table.RebuildFromBirth([Birth("A", 1, SparkplugDataType.Int32), Birth("B", 2, SparkplugDataType.Int32)]);
+
+ table.RebuildFromBirth([]);
+
+ table.Count.ShouldBe(0);
+ table.TryResolve(1, out _).ShouldBeFalse();
+ table.TryResolve(2, out _).ShouldBeFalse();
+ table.Metrics.ShouldBeEmpty();
+ }
+
+ ///
+ /// The mirror of the headline case: the metric stays put and its alias moves. Binding is
+ /// by name, so the tag keeps resolving; only the per-birth alias cache changed.
+ ///
+ [Fact]
+ public void Rebirth_MovesAMetricToADifferentAlias_NameBindingFollowsIt()
+ {
+ var table = new AliasTable();
+ table.RebuildFromBirth([Birth("Temperature", 5, SparkplugDataType.Float)]);
+
+ table.RebuildFromBirth([Birth("Temperature", 9, SparkplugDataType.Float)]);
+
+ table.TryResolveByName("Temperature", out var byName).ShouldBeTrue();
+ byName!.Alias.ShouldBe(9ul);
+ table.Resolve(9)!.Name.ShouldBe("Temperature");
+ table.TryResolve(5, out _).ShouldBeFalse();
+ }
+
+ // ---- AliasTable: malformed births ----
+
+ ///
+ /// A BIRTH metric carrying an alias but no name is malformed and is rejected, never
+ /// stored alias-only: the name is the binding key, so an alias-only entry could only ever
+ /// resolve to a binding no authored tag can match — or, worse, match a tag whose authored
+ /// metric name happens to be blank.
+ ///
+ [Fact]
+ public void BirthMetricWithAnAliasButNoName_IsRejected_NotStoredAliasOnly()
+ {
+ var table = new AliasTable();
+
+ var result = table.RebuildFromBirth([Birth(null, 5, SparkplugDataType.Float), Birth(" ", 6, SparkplugDataType.Float)]);
+
+ result.Accepted.ShouldBe(0);
+ result.RejectedUnnamed.ShouldBe(2);
+ result.HasAnomaly.ShouldBeTrue();
+ table.TryResolve(5, out _).ShouldBeFalse();
+ table.TryResolve(6, out _).ShouldBeFalse();
+ }
+
+ ///
+ /// Aliases are optional in Sparkplug — a publisher may declare a metric by name alone and then
+ /// send DATA carrying the name. Such a metric is a legitimate binding; it simply never appears
+ /// in the alias index.
+ ///
+ [Fact]
+ public void BirthMetricWithNoAlias_IsBindableByName_AndAbsentFromTheAliasIndex()
+ {
+ var table = new AliasTable();
+
+ table.RebuildFromBirth([Birth("Temperature", null, SparkplugDataType.Float)]);
+
+ table.TryResolveByName("Temperature", out var binding).ShouldBeTrue();
+ binding!.Alias.ShouldBeNull();
+ table.Metrics.Count.ShouldBe(1);
+ }
+
+ ///
+ /// Two metrics claiming the same alias in one birth is malformed, and any resolution is a coin
+ /// flip between two different metrics — precisely the mis-route this whole type exists to
+ /// prevent. The colliding alias is dropped from the alias index (the consumer sees an unknown
+ /// alias and can request a rebirth) while both metrics stay bindable by their names.
+ ///
+ [Fact]
+ public void DuplicateAliasInOneBirth_IsUnresolvable_RatherThanGuessed()
+ {
+ var table = new AliasTable();
+
+ var result = table.RebuildFromBirth(
+ [Birth("Temperature", 5, SparkplugDataType.Float), Birth("Pressure", 5, SparkplugDataType.Float)]);
+
+ result.AliasCollisions.ShouldBe(1);
+ table.TryResolve(5, out _).ShouldBeFalse();
+ table.TryResolveByName("Temperature", out _).ShouldBeTrue();
+ table.TryResolveByName("Pressure", out _).ShouldBeTrue();
+ }
+
+ ///
+ /// Two metrics sharing a name in one birth: last-wins (the name is the binding key, so evicting
+ /// it would take the tag dark entirely), reported so the consumer can warn.
+ ///
+ [Fact]
+ public void DuplicateNameInOneBirth_IsLastWins_AndReported()
+ {
+ var table = new AliasTable();
+
+ var result = table.RebuildFromBirth(
+ [Birth("Temperature", 5, SparkplugDataType.Float), Birth("Temperature", 6, SparkplugDataType.Double)]);
+
+ result.DuplicateNames.ShouldBe(1);
+ result.Accepted.ShouldBe(1);
+ table.TryResolveByName("Temperature", out var binding).ShouldBeTrue();
+ binding!.Alias.ShouldBe(6ul);
+ }
+
+ ///
+ /// A BIRTH metric with no datatype is malformed, but it is kept as
+ /// rather than rejected: rejecting it makes every DATA
+ /// message for its alias an unknown-alias event, which the ingest state machine answers with a
+ /// rebirth request — and the edge node would answer with the same malformed birth, forever. Kept
+ /// as Unknown, the typed layer refuses the value once (ToDriverDataType() returns null)
+ /// and nothing storms.
+ ///
+ [Fact]
+ public void BirthMetricWithNoDatatype_IsKeptAsUnknown_NotRejected()
+ {
+ var table = new AliasTable();
+
+ var result = table.RebuildFromBirth([Untyped("Temperature", 5)]);
+
+ result.Accepted.ShouldBe(1);
+ result.RejectedUnnamed.ShouldBe(0);
+ table.Resolve(5)!.DataType.ShouldBe(SparkplugDataType.Unknown);
+ }
+
+ ///
+ /// The birth datatype is what makes Sparkplug's two's-complement-in-an-unsigned-field encoding
+ /// undoable: a DATA metric carries no datatype of its own, so the binding is the only place the
+ /// signedness is known. Without this, an Int32 metric holding -42 publishes as 4294967254 —
+ /// plausible, Good quality, invisible.
+ ///
+ [Fact]
+ public void Binding_Reinterpret_UndoesTwosComplement_UsingTheBirthDatatype()
+ {
+ var table = new AliasTable();
+ table.RebuildFromBirth([Birth("Setpoint", 5, SparkplugDataType.Int32)]);
+
+ // What a DDATA metric for alias 5 carries: the raw unsigned wire field, no datatype.
+ var raw = unchecked((uint)-42);
+
+ table.Resolve(5)!.Reinterpret(raw).ShouldBe(-42);
+ }
+
+ /// A null birth sequence is a caller bug, not a birth — it must not silently clear the table.
+ [Fact]
+ public void RebuildFromBirth_Null_Throws() =>
+ Should.Throw(() => new AliasTable().RebuildFromBirth(null!));
+
+ // ---- BirthCache: scoping ----
+
+ ///
+ /// Sparkplug spec: an NBIRTH means the edge node (re)started, so every DBIRTH previously
+ /// published under it is invalid until re-published. Keeping a device's alias table across its
+ /// node's rebirth is the same stale-alias mis-route as invariant #2, one scope up.
+ ///
+ [Fact]
+ public void NodeBirth_InvalidatesEveryDeviceUnderThatNode()
+ {
+ var cache = new BirthCache();
+ cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Temperature", 5, SparkplugDataType.Float)]);
+ cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler2", [Birth("Speed", 7, SparkplugDataType.Float)]);
+
+ var outcome = cache.ApplyNodeBirth("Plant1", "EdgeA", [Birth("Node Control/Rebirth", 1, SparkplugDataType.Boolean)]);
+
+ cache.Find(new SparkplugScope("Plant1", "EdgeA", "Filler1")).ShouldBeNull();
+ cache.Find(new SparkplugScope("Plant1", "EdgeA", "Filler2")).ShouldBeNull();
+ cache.Find(new SparkplugScope("Plant1", "EdgeA", null)).ShouldNotBeNull();
+
+ // The invalidated devices come back with their metric sets so the consumer can fan STALE out
+ // without a lookup against a table that has already been evicted.
+ outcome.InvalidatedDevices.Count.ShouldBe(2);
+ outcome.InvalidatedDevices.SelectMany(d => d.Metrics).Select(m => m.Name)
+ .ShouldBe(["Temperature", "Speed"], ignoreOrder: true);
+ }
+
+ /// An NBIRTH is scoped to its own edge node — another node's devices are untouched.
+ [Fact]
+ public void NodeBirth_DoesNotTouchAnotherNodesDevices()
+ {
+ var cache = new BirthCache();
+ cache.ApplyDeviceBirth("Plant1", "EdgeB", "Filler1", [Birth("Temperature", 5, SparkplugDataType.Float)]);
+
+ var outcome = cache.ApplyNodeBirth("Plant1", "EdgeA", [Birth("Uptime", 1, SparkplugDataType.Int64)]);
+
+ outcome.InvalidatedDevices.ShouldBeEmpty();
+ cache.Find(new SparkplugScope("Plant1", "EdgeB", "Filler1")).ShouldNotBeNull();
+ }
+
+ /// A DBIRTH replaces exactly one device's table; its siblings and its node keep theirs.
+ [Fact]
+ public void DeviceBirth_ReplacesOnlyThatDevicesTable()
+ {
+ var cache = new BirthCache();
+ cache.ApplyNodeBirth("Plant1", "EdgeA", [Birth("Uptime", 1, SparkplugDataType.Int64)]);
+ cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Temperature", 5, SparkplugDataType.Float)]);
+ cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler2", [Birth("Speed", 5, SparkplugDataType.Float)]);
+
+ cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Humidity", 5, SparkplugDataType.Float)]);
+
+ cache.Find(new SparkplugScope("Plant1", "EdgeA", "Filler1"))!.Resolve(5)!.Name.ShouldBe("Humidity");
+ cache.Find(new SparkplugScope("Plant1", "EdgeA", "Filler2"))!.Resolve(5)!.Name.ShouldBe("Speed");
+ cache.Find(new SparkplugScope("Plant1", "EdgeA", null))!.Resolve(1)!.Name.ShouldBe("Uptime");
+ }
+
+ ///
+ /// Key completeness. A cache keyed on the device id alone — or on node/device without the
+ /// group — would let one plant's Filler1 answer for another's, which is the same
+ /// wrong-but-plausible mis-route as an alias collision.
+ ///
+ [Fact]
+ public void Scopes_AreDistinctAcrossGroupsAndNodesWithTheSameDeviceId()
+ {
+ var cache = new BirthCache();
+ cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Temperature", 5, SparkplugDataType.Float)]);
+ cache.ApplyDeviceBirth("Plant2", "EdgeA", "Filler1", [Birth("Pressure", 5, SparkplugDataType.Float)]);
+ cache.ApplyDeviceBirth("Plant1", "EdgeB", "Filler1", [Birth("Speed", 5, SparkplugDataType.Float)]);
+
+ cache.Count.ShouldBe(3);
+ cache.Find(new SparkplugScope("Plant1", "EdgeA", "Filler1"))!.Resolve(5)!.Name.ShouldBe("Temperature");
+ cache.Find(new SparkplugScope("Plant2", "EdgeA", "Filler1"))!.Resolve(5)!.Name.ShouldBe("Pressure");
+ cache.Find(new SparkplugScope("Plant1", "EdgeB", "Filler1"))!.Resolve(5)!.Name.ShouldBe("Speed");
+ }
+
+ ///
+ /// §3.6 invariant #4's data half: an NDEATH takes the node and everything under it out of the
+ /// cache and hands back the metric sets, which is what the consumer fans STALE out over.
+ ///
+ [Fact]
+ public void ForgetNode_RemovesNodeAndDevices_AndReturnsTheirMetricsForStaleFanOut()
+ {
+ var cache = new BirthCache();
+ cache.ApplyNodeBirth("Plant1", "EdgeA", [Birth("Uptime", 1, SparkplugDataType.Int64)]);
+ cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Temperature", 5, SparkplugDataType.Float)]);
+ cache.ApplyDeviceBirth("Plant1", "EdgeB", "Filler1", [Birth("Speed", 5, SparkplugDataType.Float)]);
+
+ var removed = cache.ForgetNode("Plant1", "EdgeA");
+
+ removed.SelectMany(r => r.Metrics).Select(m => m.Name).ShouldBe(["Uptime", "Temperature"], ignoreOrder: true);
+ cache.Find(new SparkplugScope("Plant1", "EdgeA", null)).ShouldBeNull();
+ cache.Find(new SparkplugScope("Plant1", "EdgeA", "Filler1")).ShouldBeNull();
+ cache.Find(new SparkplugScope("Plant1", "EdgeB", "Filler1")).ShouldNotBeNull();
+ }
+
+ /// A DDEATH takes out exactly one device and leaves its node's own metrics live.
+ [Fact]
+ public void TryForgetDevice_RemovesOnlyThatDevice_AndReturnsItsMetrics()
+ {
+ var cache = new BirthCache();
+ cache.ApplyNodeBirth("Plant1", "EdgeA", [Birth("Uptime", 1, SparkplugDataType.Int64)]);
+ cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Temperature", 5, SparkplugDataType.Float)]);
+
+ cache.TryForgetDevice("Plant1", "EdgeA", "Filler1", out var removed).ShouldBeTrue();
+
+ removed.Metrics.Select(m => m.Name).ShouldBe(["Temperature"]);
+ removed.Scope.DeviceId.ShouldBe("Filler1");
+ cache.Find(new SparkplugScope("Plant1", "EdgeA", "Filler1")).ShouldBeNull();
+ cache.Find(new SparkplugScope("Plant1", "EdgeA", null)).ShouldNotBeNull();
+ cache.TryForgetDevice("Plant1", "EdgeA", "Filler1", out _).ShouldBeFalse();
+ }
+
+ ///
+ /// After a death the scope is absent, not merely empty — that is what makes DATA arriving
+ /// before the next birth a data-before-birth event (⇒ rebirth request) rather than a silent
+ /// unknown-alias drop against a table that still looks alive.
+ ///
+ [Fact]
+ public void Find_AfterDeath_IsNull_SoDataBeforeRebirthIsNotResolvable()
+ {
+ var cache = new BirthCache();
+ cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Temperature", 5, SparkplugDataType.Float)]);
+
+ cache.TryForgetDevice("Plant1", "EdgeA", "Filler1", out _).ShouldBeTrue();
+
+ cache.Find(new SparkplugScope("Plant1", "EdgeA", "Filler1")).ShouldBeNull();
+ cache.Count.ShouldBe(0);
+ }
+
+ ///
+ /// Invariant #1 end-to-end through the cache: the alias-reuse mis-route must not sneak back in
+ /// through the scoped path the ingest state machine actually calls.
+ ///
+ [Fact]
+ public void DeviceRebirth_ReusingAnAliasForADifferentMetric_ResolvesByName()
+ {
+ var cache = new BirthCache();
+ var scope = new SparkplugScope("Plant1", "EdgeA", "Filler1");
+ cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Temperature", 5, SparkplugDataType.Float)]);
+
+ cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Pressure", 5, SparkplugDataType.Float)]);
+
+ cache.Find(scope)!.Resolve(5)!.Name.ShouldBe("Pressure");
+ cache.Find(scope)!.TryResolveByName("Temperature", out _).ShouldBeFalse();
+ }
+
+ /// Builds a BIRTH metric the way projects one.
+ private static SparkplugMetric Birth(string? name, ulong? alias, SparkplugDataType dataType, object? value = null) =>
+ new(
+ Name: name,
+ Alias: alias,
+ DataType: dataType,
+ TimestampMs: null,
+ ValueKind: value is null ? SparkplugValueKind.Absent : SparkplugValueKind.Scalar,
+ Value: value);
+
+ /// Builds a malformed BIRTH metric that declares no datatype at all.
+ private static SparkplugMetric Untyped(string name, ulong alias) =>
+ new(Name: name, Alias: alias, DataType: null, TimestampMs: null, ValueKind: SparkplugValueKind.Absent, Value: null);
+}