diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx
index 81e15e0c..1bff1974 100644
--- a/ZB.MOM.WW.OtOpcUa.slnx
+++ b/ZB.MOM.WW.OtOpcUa.slnx
@@ -93,6 +93,7 @@
+
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlEquipmentTagParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlEquipmentTagParser.cs
new file mode 100644
index 00000000..006089f8
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlEquipmentTagParser.cs
@@ -0,0 +1,131 @@
+using System.Text.Json;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
+
+///
+/// Pure mapper from an authored raw tag's TagConfig JSON (design §5.2 / §5.3) to a
+/// , mirroring ModbusTagDefinitionFactory.FromTagConfig. The blob is
+/// recognised by a leading { plus a "driver":"Sql" marker or a "model" discriminator;
+/// the model and type enums are read with
+/// , so a present-but-invalid (typo'd) value rejects
+/// the whole tag — the driver then surfaces BadNodeIdUnknown instead of silently defaulting to a
+/// different model and publishing a misleading Good (R2-11).
+/// No SQL is built here. The parser only captures strings; the reader binds value fields as
+/// DbParameters and validates + quotes identifier fields. Tag input is never concatenated into a
+/// command text, so a hostile keyValue is stored — and must be stored — verbatim.
+///
+public static class SqlEquipmentTagParser
+{
+ ///
+ /// Maps an authored TagConfig blob to a typed definition keyed by .
+ /// Returns — never throws — for anything the driver must not serve: a
+ /// non-object / unparseable blob, a blob belonging to another driver, a typo'd model or
+ /// type enum, a missing model-required field, or the P3-deferred
+ /// model.
+ ///
+ /// The authored equipment-tag TagConfig JSON.
+ /// The tag's RawPath — becomes the definition's identity (Name).
+ /// The mapped definition when this returns .
+ /// when is a valid Sql tag blob.
+ public static bool TryParse(string reference, string rawPath, out SqlTagDefinition def)
+ {
+ def = null!;
+ if (string.IsNullOrWhiteSpace(reference)) return false;
+ if (reference.TrimStart().FirstOrDefault() != '{') return false;
+ try
+ {
+ using var doc = JsonDocument.Parse(reference);
+ var root = doc.RootElement;
+ if (root.ValueKind != JsonValueKind.Object) return false;
+
+ // Recognition: either the explicit driver marker or the model discriminator must be present,
+ // so another driver's TagConfig blob is not mistaken for a Sql one.
+ var driver = ReadString(root, "driver");
+ var hasModel = root.TryGetProperty("model", out _);
+ if (!hasModel && !string.Equals(driver, "Sql", StringComparison.OrdinalIgnoreCase)) return false;
+
+ // Strict enum reads: absent ⇒ the fallback, present-but-invalid ⇒ reject the tag.
+ if (!TagConfigJson.TryReadEnumStrict(root, "model", SqlTagModel.KeyValue, out var model)) return false;
+ if (!TagConfigJson.TryReadEnumStrict(root, "type", default(DriverDataType), out var type)) return false;
+ DriverDataType? declaredType =
+ root.TryGetProperty("type", out var typeEl) && typeEl.ValueKind == JsonValueKind.String
+ ? type
+ : null;
+
+ var table = ReadString(root, "table");
+ if (string.IsNullOrWhiteSpace(table)) return false;
+ var timestampColumn = ReadString(root, "timestampColumn");
+
+ switch (model)
+ {
+ case SqlTagModel.KeyValue:
+ {
+ var keyColumn = ReadString(root, "keyColumn");
+ var keyValue = ReadString(root, "keyValue");
+ var valueColumn = ReadString(root, "valueColumn");
+ if (string.IsNullOrWhiteSpace(keyColumn)
+ || string.IsNullOrWhiteSpace(valueColumn)
+ || keyValue is null)
+ return false;
+ def = new SqlTagDefinition(
+ Name: rawPath, Model: model, Table: table!,
+ KeyColumn: keyColumn, KeyValue: keyValue, ValueColumn: valueColumn,
+ TimestampColumn: timestampColumn, DeclaredType: declaredType);
+ return true;
+ }
+
+ case SqlTagModel.WideRow:
+ {
+ var columnName = ReadString(root, "columnName");
+ if (string.IsNullOrWhiteSpace(columnName)) return false;
+ string? selectorColumn = null, selectorValue = null, topByTimestamp = null;
+ if (root.TryGetProperty("rowSelector", out var sel) && sel.ValueKind == JsonValueKind.Object)
+ {
+ selectorColumn = ReadString(sel, "whereColumn");
+ selectorValue = ReadScalar(sel, "whereValue");
+ topByTimestamp = ReadString(sel, "topByTimestamp");
+ }
+ // A wide-row tag must say WHICH row it reads — either a where-pair or newest-by-timestamp.
+ var hasWherePair = !string.IsNullOrWhiteSpace(selectorColumn) && selectorValue is not null;
+ if (!hasWherePair && string.IsNullOrWhiteSpace(topByTimestamp)) return false;
+ def = new SqlTagDefinition(
+ Name: rawPath, Model: model, Table: table!,
+ TimestampColumn: timestampColumn, ColumnName: columnName,
+ RowSelectorColumn: hasWherePair ? selectorColumn : null,
+ RowSelectorValue: hasWherePair ? selectorValue : null,
+ RowSelectorTopByTimestamp: hasWherePair ? null : topByTimestamp,
+ DeclaredType: declaredType);
+ return true;
+ }
+
+ default:
+ // SqlTagModel.Query is design §5.4 / P3 — deferred. Reject rather than serve a model
+ // the runtime cannot read.
+ return false;
+ }
+ }
+ catch (JsonException) { return false; }
+ catch (FormatException) { return false; }
+ }
+
+ /// Reads a string-valued property; null when absent or not a JSON string.
+ private static string? ReadString(JsonElement o, string name)
+ => o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String ? e.GetString() : null;
+
+ ///
+ /// Reads a scalar property as its textual form — a JSON string yields its contents, a number or
+ /// boolean yields its raw token — so an authored whereValue of either shape is captured
+ /// verbatim for later parameter binding. Null when absent or not a scalar.
+ ///
+ private static string? ReadScalar(JsonElement o, string name)
+ {
+ if (!o.TryGetProperty(name, out var e)) return null;
+ return e.ValueKind switch
+ {
+ JsonValueKind.String => e.GetString(),
+ JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => e.GetRawText(),
+ _ => null,
+ };
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlTagDefinition.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlTagDefinition.cs
new file mode 100644
index 00000000..755e2b9d
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlTagDefinition.cs
@@ -0,0 +1,47 @@
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
+
+///
+/// One SQL-backed OPC UA variable — the typed form of an authored raw tag's TagConfig blob
+/// (design §5.2 / §5.3), produced by .
+/// Every string here is captured verbatim from the authored blob and is NEVER concatenated
+/// into SQL. Value-bearing fields (, ) are bound
+/// as DbParameters by the reader; identifier-bearing fields (,
+/// , , ,
+/// , ,
+/// ) must be validated against INFORMATION_SCHEMA and
+/// dialect-quoted by the reader before they reach a command text. Sanitising here would corrupt
+/// legitimate values, so the parser deliberately does not.
+///
+///
+/// The tag's identity — its RawPath, exactly as handed to the parser. The driver's forward
+/// router keys published values on this, so it must never be derived from the blob's contents.
+///
+/// Which tag→value mapping this tag uses. v1 accepts KeyValue and WideRow only.
+/// The table or view to read (e.g. dbo.TagValues).
+/// : the column matched against .
+/// : this tag's key — bound, never interpolated.
+/// : the column the value is read from.
+/// Optional source-timestamp column; absent ⇒ the driver stamps the poll time.
+/// : the column this tag reads from the selected row.
+/// : the whereColumn that picks the row.
+/// : the whereValue — bound, never interpolated.
+/// : newest-row selector — the timestamp column to order by, when no where-pair is authored.
+///
+/// Optional explicit type override from the blob's type field. ⇒ the
+/// driver infers the type from the result set's column metadata.
+///
+public sealed record SqlTagDefinition(
+ string Name,
+ SqlTagModel Model,
+ string Table,
+ string? KeyColumn = null,
+ string? KeyValue = null,
+ string? ValueColumn = null,
+ string? TimestampColumn = null,
+ string? ColumnName = null,
+ string? RowSelectorColumn = null,
+ string? RowSelectorValue = null,
+ string? RowSelectorTopByTimestamp = null,
+ DriverDataType? DeclaredType = null);
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlEquipmentTagParserTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlEquipmentTagParserTests.cs
new file mode 100644
index 00000000..18177ec4
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlEquipmentTagParserTests.cs
@@ -0,0 +1,30 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
+
+public class SqlEquipmentTagParserTests
+{
+ [Fact]
+ public void TryParse_KeyValue_readsFields_andKeepsMaliciousValueVerbatim()
+ {
+ var json = """
+ {"driver":"Sql","model":"KeyValue","table":"dbo.TagValues","keyColumn":"tag_name",
+ "keyValue":"'; DROP TABLE x --","valueColumn":"num_value","timestampColumn":"sample_ts","type":"Float64"}
+ """;
+ SqlEquipmentTagParser.TryParse(json, "Plant/Sql/dev1/Speed", out var def).ShouldBeTrue();
+ def.Model.ShouldBe(SqlTagModel.KeyValue);
+ def.Table.ShouldBe("dbo.TagValues");
+ def.KeyValue.ShouldBe("'; DROP TABLE x --"); // captured as-is; the reader BINDS it, never concatenates
+ def.DeclaredType.ShouldBe(DriverDataType.Float64);
+ def.Name.ShouldBe("Plant/Sql/dev1/Speed"); // Name == RawPath (forward-router key)
+ }
+
+ [Fact]
+ public void TryParse_invalidModelEnum_rejectsTag()
+ => SqlEquipmentTagParser.TryParse(
+ """{"driver":"Sql","model":"Nonsense","table":"t","keyColumn":"k","keyValue":"v","valueColumn":"c"}""",
+ "raw", out _).ShouldBeFalse();
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests.csproj
new file mode 100644
index 00000000..74a7aae6
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests.csproj
@@ -0,0 +1,27 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+ true
+ ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+