diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttEquipmentTagParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttEquipmentTagParser.cs
new file mode 100644
index 00000000..24e33600
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttEquipmentTagParser.cs
@@ -0,0 +1,164 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
+
+///
+/// Turns an authored equipment tag's opaque wire reference — its TagConfig JSON blob
+/// — into a typed . Mirrors ModbusTagDefinitionFactory: a
+/// leading { marks a TagConfig blob, enum fields are read STRICTLY, and the two entry
+/// points carry deliberately different strictness contracts.
+///
+/// -
+///
+/// is the runtime path. It never throws; anything
+/// malformed returns so the driver surfaces
+/// BadNodeIdUnknown rather than a misleading Good off a wrong-typed
+/// default.
+///
+///
+/// -
+///
+/// is the deploy-time path. It returns human-readable
+/// warnings so a bad tag config surfaces at deploy instead of silently going dark at
+/// runtime.
+///
+///
+///
+///
+/// Sparkplug-descriptor parsing (groupId / edgeNodeId / deviceId /
+/// metricName) is a deliberate P1 stub — the fields exist on
+/// but are never populated here until the P2 tasks fill them.
+///
+///
+public static class MqttEquipmentTagParser
+{
+ /// The JSONPath applied when a Json-format blob omits jsonPath: the document root.
+ private const string RootJsonPath = "$";
+
+ /// The MQTT wildcard characters — illegal in a tag's (concrete) subscription topic.
+ private static readonly char[] TopicWildcards = ['+', '#'];
+
+ ///
+ /// Parses a driver wire into a typed definition. Returns
+ /// — never throws — for a null/blank reference, a reference that is not
+ /// a TagConfig blob (no leading {), unparseable JSON, a missing or blank topic, a
+ /// qos outside 0–2, or a present-but-invalid (typo'd) payloadFormat /
+ /// dataType. The produced definition's is
+ /// itself, which is the key published values are routed on.
+ ///
+ /// A wildcard topic (+ / #) is deliberately NOT rejected here: it is
+ /// ambiguous rather than unparseable, and is the operator-visible
+ /// surface for it. Rejecting it at runtime would turn an authoring mistake into a silent
+ /// BadNodeIdUnknown with no stated cause.
+ ///
+ ///
+ /// The tag's wire reference (its authored TagConfig JSON).
+ /// The parsed definition when this returns ; otherwise .
+ /// when is a valid MQTT tag config.
+ public static bool TryParse(string reference, [NotNullWhen(true)] out MqttTagDefinition? def)
+ {
+ def = null;
+ if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return false;
+ try
+ {
+ using var doc = JsonDocument.Parse(reference);
+ var root = doc.RootElement;
+ if (root.ValueKind != JsonValueKind.Object) return false;
+
+ // topic is the whole point of a Plain-mode tag — without one there is nothing to subscribe
+ // to, so an absent/blank value is a hard reject rather than a defaulted empty subscription.
+ var topic = ReadString(root, "topic");
+ if (string.IsNullOrWhiteSpace(topic)) return false;
+
+ // Strict enum reads: a present-but-invalid (typo'd) value rejects the whole tag
+ // (→ BadNodeIdUnknown) instead of silently defaulting to a wrong-typed Good.
+ if (!TagConfigJson.TryReadEnumStrict(root, "payloadFormat", MqttPayloadFormat.Json, out var payloadFormat))
+ return false;
+ if (!TagConfigJson.TryReadEnumStrict(root, "dataType", DriverDataType.String, out var dataType))
+ return false;
+
+ // qos is an MQTT protocol field with exactly three legal values; anything else would be
+ // rejected by the broker at SUBSCRIBE time, so reject it here where the cause is visible.
+ var qos = ReadOptionalInt(root, "qos");
+ if (qos is { } q && q is < 0 or > 2) return false;
+
+ var jsonPath = ReadString(root, "jsonPath");
+ if (string.IsNullOrEmpty(jsonPath)) jsonPath = RootJsonPath;
+
+ def = new MqttTagDefinition(
+ Name: reference,
+ Topic: topic,
+ PayloadFormat: payloadFormat,
+ JsonPath: jsonPath,
+ DataType: dataType,
+ Qos: qos,
+ RetainSeed: ReadBoolOrDefault(root, "retainSeed", defaultValue: true));
+ return true;
+ }
+ catch (JsonException) { return false; }
+ catch (FormatException) { return false; }
+ catch (InvalidOperationException) { return false; }
+ }
+
+ ///
+ /// Deploy-time inspection of an equipment-tag TagConfig blob. Returns human-readable
+ /// warnings for a structurally unparseable blob (which the runtime turns into a silent
+ /// BadNodeIdUnknown), for present-but-invalid payloadFormat / dataType
+ /// enum values, and for a wildcard tag topic (a tag bound to + / # would
+ /// receive values from many topics — ambiguous, and almost never what the operator meant).
+ /// Empty when the blob is clean or is not a TagConfig object at all. Never throws.
+ ///
+ /// The equipment tag's TagConfig JSON.
+ /// The warnings; empty when clean.
+ public static IReadOnlyList Inspect(string reference)
+ {
+ var warnings = new List();
+ if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return warnings;
+ try
+ {
+ using var doc = JsonDocument.Parse(reference);
+ var root = doc.RootElement;
+ if (root.ValueKind != JsonValueKind.Object)
+ {
+ warnings.Add("Mqtt TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown).");
+ return warnings;
+ }
+ foreach (var w in new[]
+ {
+ TagConfigJson.DescribeInvalidEnum(root, "payloadFormat"),
+ TagConfigJson.DescribeInvalidEnum(root, "dataType"),
+ })
+ {
+ if (w is not null) warnings.Add(w);
+ }
+ var topic = ReadString(root, "topic");
+ if (topic.IndexOfAny(TopicWildcards) >= 0)
+ {
+ warnings.Add(
+ $"topic '{topic}' contains an MQTT wildcard (+ or #); a tag's topic must be concrete, " +
+ "or the tag will be fed by every matching topic.");
+ }
+ }
+ catch (JsonException)
+ {
+ warnings.Add("Mqtt TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown).");
+ }
+ return warnings;
+ }
+
+ private static string ReadString(JsonElement o, string name)
+ => o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String
+ ? e.GetString() ?? ""
+ : "";
+
+ private static int? ReadOptionalInt(JsonElement o, string name)
+ => o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.Number
+ && e.TryGetInt32(out var v) ? v : null;
+
+ private static bool ReadBoolOrDefault(JsonElement o, string name, bool defaultValue)
+ => o.TryGetProperty(name, out var e) && e.ValueKind is JsonValueKind.True or JsonValueKind.False
+ ? e.GetBoolean()
+ : defaultValue;
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinition.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinition.cs
new file mode 100644
index 00000000..0d139be5
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinition.cs
@@ -0,0 +1,65 @@
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
+
+///
+/// The driver's internal per-tag descriptor — the parsed form of an authored equipment tag's
+/// TagConfig JSON, produced by and looked up
+/// through the shared exactly as Modbus does. See
+/// docs/plans/2026-07-15-mqtt-sparkplug-driver-design.md §5.2 / §5.3.
+///
+/// The record carries both ingest shapes: the Plain-MQTT fields
+/// ( … ), populated in P1, and the Sparkplug B
+/// descriptor fields (, ,
+/// , ), which are deliberate stubs in P1
+/// — always until the P2 tasks (15–26) teach the parser to read them.
+/// A Sparkplug tag is resolved by the stable (group, node, device, metricName) tuple,
+/// never by the per-birth metric alias.
+///
+///
+///
+/// The definition's identity: the exact wire reference string the driver was handed
+/// (the tag's authored TagConfig blob). Published values key the forward router on this,
+/// so it must round-trip byte-for-byte.
+///
+/// The concrete MQTT topic this tag subscribes to (Plain mode).
+/// How the received payload is decoded (Plain mode).
+///
+/// JSONPath selecting the value inside a payload. Defaults
+/// to the document root ($) when the blob omits it; meaningless for
+/// / .
+///
+///
+/// The tag's declared value type. Explicit authoring is strongly preferred — payload-shape
+/// inference is the brittle fallback (design §4).
+///
+///
+/// The per-tag subscription QoS (0–2), or to inherit the driver-level
+/// .
+///
+///
+/// Whether the broker's retained message for seeds this tag's initial value
+/// on subscribe (the OPC UA initial-data convention). Defaults to .
+///
+/// P2 stub — the Sparkplug group id. Always in P1.
+/// P2 stub — the Sparkplug edge-node id. Always in P1.
+///
+/// P2 stub — the Sparkplug device id (absent for node-level metrics). Always
+/// in P1.
+///
+///
+/// P2 stub — the Sparkplug metric name, the tag's stable identity across rebirths. Always
+/// in P1.
+///
+public sealed record MqttTagDefinition(
+ string Name,
+ string Topic,
+ MqttPayloadFormat PayloadFormat,
+ string JsonPath,
+ DriverDataType DataType,
+ int? Qos,
+ bool RetainSeed,
+ string? GroupId = null,
+ string? EdgeNodeId = null,
+ string? DeviceId = null,
+ string? MetricName = null);
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttEquipmentTagParserTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttEquipmentTagParserTests.cs
new file mode 100644
index 00000000..037f7b2a
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttEquipmentTagParserTests.cs
@@ -0,0 +1,143 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
+
+///
+/// Covers the two entry points of and their deliberately
+/// different strictness contracts: TryParse is the runtime path (never throws, a
+/// malformed blob is a hard reject that upstream turns into BadNodeIdUnknown) and
+/// Inspect is the deploy-time path (human-readable warnings so a bad tag surfaces at
+/// deploy instead of going dark at runtime).
+///
+public sealed class MqttEquipmentTagParserTests
+{
+ [Fact]
+ public void TryParse_PlainJsonBlob_PopulatesTopicAndPath()
+ {
+ // NOTE: the plan's sample blob wrote "dataType":"Double"; the authoritative type is
+ // DriverDataType (design §6.1), whose 64-bit float member is Float64. "Double" is
+ // therefore a typo the strict read rejects — pinned by TryParse_TypoedDataType_RejectsStrict.
+ const string r = """{"topic":"factory/oven/temp","payloadFormat":"Json","jsonPath":"$.value","dataType":"Float64","qos":1}""";
+ MqttEquipmentTagParser.TryParse(r, out var def).ShouldBeTrue();
+ def!.Topic.ShouldBe("factory/oven/temp");
+ def.PayloadFormat.ShouldBe(MqttPayloadFormat.Json);
+ def.JsonPath.ShouldBe("$.value");
+ def.DataType.ShouldBe(DriverDataType.Float64);
+ def.Qos.ShouldBe(1);
+ def.Name.ShouldBe(r); // the def Name == reference string (forward-router key)
+ }
+
+ [Fact]
+ public void TryParse_TypoedPayloadFormat_RejectsStrict()
+ => MqttEquipmentTagParser.TryParse(
+ """{"topic":"a/b","payloadFormat":"Jason","dataType":"Float64"}""", out _).ShouldBeFalse();
+
+ [Fact]
+ public void TryParse_TypoedDataType_RejectsStrict()
+ => MqttEquipmentTagParser.TryParse(
+ """{"topic":"a/b","payloadFormat":"Json","dataType":"Double"}""", out _).ShouldBeFalse();
+
+ [Fact]
+ public void Inspect_WildcardTopic_ReturnsWarning()
+ {
+ // The blob is otherwise clean (payloadFormat parses, dataType absent), so the wildcard check
+ // is the ONLY thing that can produce a warning here — the assertion cannot pass vacuously.
+ var warnings = MqttEquipmentTagParser.Inspect("""{"topic":"a/+/c","payloadFormat":"Raw"}""");
+ warnings.ShouldNotBeEmpty();
+ warnings.ShouldHaveSingleItem().ShouldContain("wildcard", Case.Insensitive);
+ }
+
+ [Theory]
+ [InlineData("a/#")]
+ [InlineData("+/b/c")]
+ [InlineData("a/+/c")]
+ public void Inspect_EachWildcardForm_Warns(string topic)
+ => MqttEquipmentTagParser.Inspect($$"""{"topic":"{{topic}}","payloadFormat":"Raw"}""").ShouldNotBeEmpty();
+
+ [Fact]
+ public void TryParse_WildcardTopic_StillParses_WarningIsDeployTimeOnly()
+ {
+ // A wildcard tag topic is ambiguous, not unparseable: the deploy-time Inspect pass is the
+ // designed surface for it. Rejecting it at runtime would turn an authoring mistake into a
+ // silent BadNodeIdUnknown with no operator-visible cause.
+ MqttEquipmentTagParser.TryParse("""{"topic":"a/+/c","payloadFormat":"Raw"}""", out var def).ShouldBeTrue();
+ def!.Topic.ShouldBe("a/+/c");
+ }
+
+ [Fact]
+ public void TryParse_RawFormat_AppliesDefaults()
+ {
+ const string r = """{"topic":"factory/oven/blob","payloadFormat":"Raw"}""";
+ MqttEquipmentTagParser.TryParse(r, out var def).ShouldBeTrue();
+ def!.PayloadFormat.ShouldBe(MqttPayloadFormat.Raw);
+ def.JsonPath.ShouldBe("$"); // absent ⇒ the root default
+ def.Qos.ShouldBeNull(); // absent ⇒ the driver-level DefaultQos wins
+ def.RetainSeed.ShouldBeTrue(); // absent ⇒ seed from the retained message
+ def.DataType.ShouldBe(DriverDataType.String); // absent ⇒ the documented fallback
+ }
+
+ [Fact]
+ public void TryParse_ExplicitRetainSeedFalse_IsHonoured()
+ {
+ MqttEquipmentTagParser.TryParse(
+ """{"topic":"a/b","payloadFormat":"Raw","retainSeed":false}""", out var def).ShouldBeTrue();
+ def!.RetainSeed.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void TryParse_PlainBlob_LeavesSparkplugDescriptorFieldsNull()
+ {
+ // P2 (Tasks 15–26) fills these; a plain-mode blob must leave them unset so a future
+ // mode discriminator cannot mistake a plain tag for a Sparkplug one.
+ MqttEquipmentTagParser.TryParse(
+ """{"topic":"a/b","payloadFormat":"Raw"}""", out var def).ShouldBeTrue();
+ def!.GroupId.ShouldBeNull();
+ def.EdgeNodeId.ShouldBeNull();
+ def.DeviceId.ShouldBeNull();
+ def.MetricName.ShouldBeNull();
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("factory/oven/temp")] // a bare reference, not a TagConfig blob (no leading '{')
+ [InlineData("[1,2,3]")] // valid JSON, wrong root kind
+ [InlineData("{ not json at all")] // unparseable — must not throw
+ [InlineData("""{"payloadFormat":"Raw"}""")] // no topic ⇒ nothing to subscribe to
+ [InlineData("""{"topic":"","payloadFormat":"Raw"}""")] // blank topic
+ [InlineData("""{"topic":"a/b","qos":5}""")] // QoS outside 0–2 is an illegal subscription
+ public void TryParse_MalformedReference_ReturnsFalseAndNeverThrows(string? reference)
+ => MqttEquipmentTagParser.TryParse(reference!, out _).ShouldBeFalse();
+
+ [Fact]
+ public void Inspect_CleanBlob_ReturnsEmpty()
+ => MqttEquipmentTagParser.Inspect(
+ """{"topic":"a/b","payloadFormat":"Json","jsonPath":"$.v","dataType":"Float64"}""").ShouldBeEmpty();
+
+ [Fact]
+ public void Inspect_TypoedEnums_WarnsPerField()
+ {
+ var warnings = MqttEquipmentTagParser.Inspect(
+ """{"topic":"a/b","payloadFormat":"Jason","dataType":"Double"}""");
+ warnings.Count.ShouldBe(2);
+ warnings.ShouldContain(w => w.Contains("payloadFormat", StringComparison.Ordinal));
+ warnings.ShouldContain(w => w.Contains("dataType", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void Inspect_UnparseableBlob_Warns()
+ => MqttEquipmentTagParser.Inspect("{ not json at all").ShouldNotBeEmpty();
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("factory/oven/temp")]
+ [InlineData("[1,2,3]")] // no leading '{' ⇒ not a TagConfig blob at all (mirrors Modbus)
+ public void Inspect_NotATagConfigBlob_ReturnsEmpty(string? reference)
+ => MqttEquipmentTagParser.Inspect(reference!).ShouldBeEmpty();
+}