diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor
index d2140099..16402154 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor
@@ -57,6 +57,9 @@
case DriverTypeNames.Galaxy:
break;
+ case DriverTypeNames.Sql:
+
+ break;
default:
No typed config form for driver type @_driverType .
break;
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverIdentitySection.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverIdentitySection.razor
index 5bf707fc..8b90303c 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverIdentitySection.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverIdentitySection.razor
@@ -36,6 +36,7 @@
Focas
OpcUaClient
Galaxy
+ Sql
Cannot be changed after creation — drives the actor type that owns this instance.
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/SqlDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/SqlDriverForm.razor
new file mode 100644
index 00000000..744aee9f
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/SqlDriverForm.razor
@@ -0,0 +1,198 @@
+@* Embeddable read-only Sql driver (connection/dialect) config form body. There is NO per-device
+ endpoint — the whole database connection is named indirectly by ConnectionStringRef (an env-var name,
+ resolved at Initialize), so no connection string is ever authored into the config. Hosted by
+ DriverConfigModal. Exposes GetConfigJson() for the parent to pull at save time and fires
+ DriverConfigJsonChanged for @bind support. Enums serialize by NAME (JsonStringEnumConverter) so the
+ factory's string-typed deserialize accepts them. *@
+@using System.Text.Json
+@using System.Text.Json.Nodes
+@using System.Text.Json.Serialization
+@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
+@using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts
+
+@* Connection *@
+
+ Database connection
+
+
+
+
Provider
+
+ @* v1 constructs only SqlServer; the other SqlProvider members are reserved (P2). *@
+ @SqlProvider.SqlServer
+
+
Only Microsoft SQL Server is supported in v1.
+
+
+
Connection string ref *
+
+
+ Name of the Sql__ConnectionStrings__<ref> env var set on the driver + AdminUI hosts —
+ not a connection string. The credential is resolved at startup and never stored in the config.
+
+ @if (string.IsNullOrWhiteSpace(_form.ConnectionStringRef))
+ {
+
Connection string ref is required.
+ }
+
+
+
+
+
+@* Polling / timeouts *@
+
+ Polling & timeouts
+
+
+
+
Default poll interval (s)
+
+
Blank = factory default. Applied to groups without their own interval.
+
+
+
Operation timeout (s)
+
+
Client-side per-query deadline. Should exceed the command timeout.
+
+
+
Command timeout (s)
+
+
ADO.NET server-side CommandTimeout backstop.
+
+
+
Max concurrent groups
+
+
Blank = factory default. Caps concurrent group queries (pool guard).
+
+
+
+
+
+@* Value handling *@
+
+ Value handling
+
+
+
+
+
+ NULL cell publishes Bad
+
+
Unchecked = factory default (a NULL cell publishes Uncertain).
+
+
+
+
+ Allow writes
+
+
+ Read-only v1 does not implement writes — this flag is inert and is not authored.
+
+
+
+
+
+
+
+
+@code {
+ /// The driver-level DriverConfig JSON (connection/dialect; there is no device endpoint).
+ [Parameter] public string DriverConfigJson { get; set; } = "{}";
+ /// Fired (camelCase-serialized) whenever a field changes — enables @bind-DriverConfigJson.
+ [Parameter] public EventCallback DriverConfigJsonChanged { get; set; }
+ /// The per-instance resilience-pipeline overrides JSON, or null.
+ [Parameter] public string? ResilienceConfig { get; set; }
+ /// Fired when the resilience overrides change.
+ [Parameter] public EventCallback ResilienceConfigChanged { get; set; }
+
+ // camelCase + string enums + omit-nulls, matching the factory's JsonStringEnumConverter deserialize.
+ // WhenWritingNull keeps absent optionals out of the blob (absent ⇒ the factory applies its default) and
+ // drops the composer-owned rawTags (never authored here).
+ private static readonly JsonSerializerOptions _jsonOpts = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ WriteIndented = false,
+ Converters = { new JsonStringEnumConverter() },
+ };
+
+ private FormModel _form = new();
+ private string? _lastParsed;
+
+ protected override void OnParametersSet()
+ {
+ // Re-parse only when the inbound value actually changed (don't clobber in-progress edits).
+ if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal))
+ {
+ var dto = TryDeserialize(DriverConfigJson) ?? new SqlDriverConfigDto();
+ _form = FormModel.FromDto(dto);
+ _lastParsed = DriverConfigJson;
+ }
+ }
+
+ /// Serializes the current connection/dialect config to camelCase JSON with enums as NAME strings.
+ /// rawTags is never emitted (the composer adds it at deploy); allowWrites is never emitted (v1 read-only).
+ public string GetConfigJson() => JsonSerializer.Serialize(_form.ToDto(), _jsonOpts);
+
+ private async Task EmitAsync()
+ {
+ var json = GetConfigJson();
+ _lastParsed = json;
+ await DriverConfigJsonChanged.InvokeAsync(json);
+ }
+
+ private async Task OnResilienceChanged(string? r)
+ {
+ ResilienceConfig = r;
+ await ResilienceConfigChanged.InvokeAsync(r);
+ }
+
+ private static SqlDriverConfigDto? TryDeserialize(string json)
+ {
+ try { return JsonSerializer.Deserialize(json, _jsonOpts); }
+ catch { return null; }
+ }
+
+ /// Flat mutable mirror of the driver-level fields of . TimeSpans are
+ /// edited as nullable whole-second ints (blank ⇒ null ⇒ the factory default). rawTags + allowWrites are not
+ /// mirrored — the composer owns rawTags and v1 is read-only.
+ public sealed class FormModel
+ {
+ public SqlProvider Provider { get; set; } = SqlProvider.SqlServer;
+ public string? ConnectionStringRef { get; set; }
+ public int? DefaultPollIntervalSeconds { get; set; }
+ public int? OperationTimeoutSeconds { get; set; }
+ public int? CommandTimeoutSeconds { get; set; }
+ public int? MaxConcurrentGroups { get; set; }
+ public bool NullIsBad { get; set; }
+
+ public static FormModel FromDto(SqlDriverConfigDto d) => new()
+ {
+ Provider = d.Provider,
+ ConnectionStringRef = d.ConnectionStringRef,
+ DefaultPollIntervalSeconds = ToSeconds(d.DefaultPollInterval),
+ OperationTimeoutSeconds = ToSeconds(d.OperationTimeout),
+ CommandTimeoutSeconds = ToSeconds(d.CommandTimeout),
+ MaxConcurrentGroups = d.MaxConcurrentGroups,
+ NullIsBad = d.NullIsBad ?? false,
+ };
+
+ public SqlDriverConfigDto ToDto() => new()
+ {
+ Provider = Provider,
+ ConnectionStringRef = string.IsNullOrWhiteSpace(ConnectionStringRef) ? null : ConnectionStringRef.Trim(),
+ DefaultPollInterval = ToTimeSpan(DefaultPollIntervalSeconds),
+ OperationTimeout = ToTimeSpan(OperationTimeoutSeconds),
+ CommandTimeout = ToTimeSpan(CommandTimeoutSeconds),
+ MaxConcurrentGroups = MaxConcurrentGroups,
+ // Emit nullIsBad only when true; unchecked ⇒ null ⇒ the factory default (Uncertain). allowWrites
+ // and rawTags are deliberately left null so they are omitted from the authored blob.
+ NullIsBad = NullIsBad ? true : null,
+ };
+
+ private static int? ToSeconds(TimeSpan? ts) => ts.HasValue ? (int)ts.Value.TotalSeconds : null;
+ private static TimeSpan? ToTimeSpan(int? secs) => secs.HasValue ? TimeSpan.FromSeconds(secs.Value) : null;
+ }
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawDriverTypeDialog.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawDriverTypeDialog.razor
index e43dc14a..24b37fa2 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawDriverTypeDialog.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawDriverTypeDialog.razor
@@ -68,6 +68,7 @@
("FOCAS", DriverTypeNames.FOCAS),
("OpcUaClient", DriverTypeNames.OpcUaClient),
("Galaxy", DriverTypeNames.Galaxy),
+ ("Sql", DriverTypeNames.Sql),
("Calculation", "Calculation"),
];
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/SqlDriverFormContractTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/SqlDriverFormContractTests.cs
new file mode 100644
index 00000000..8bc28e88
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/SqlDriverFormContractTests.cs
@@ -0,0 +1,89 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.Sql;
+using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
+
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
+
+///
+/// Guards the AdminUI SqlDriverForm 's driver-config output against the runtime factory that consumes
+/// it. The form is razor (no bUnit here), so these tests pin two things the live verify then confirms:
+/// (1) the exact JSON bytes the form emits for a minimal + fuller config are accepted by the authoritative
+/// reader, ; and (2) the
+/// serialization contract — provider as a NAME string (never an ordinal), camelCase keys, and no
+/// composer-owned rawTags / inert allowWrites leaking into the authored blob. A numeric enum
+/// or a stray connection string is the "authors fine, never reads / leaks a secret" defect class this guards.
+///
+public sealed class SqlDriverFormContractTests
+{
+ // The JsonSerializerOptions SqlDriverForm serializes with (camelCase + string enums + omit-nulls).
+ private static readonly JsonSerializerOptions FormOptions = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ WriteIndented = false,
+ Converters = { new JsonStringEnumConverter() },
+ };
+
+ // ---- LOAD-BEARING: the exact bytes SqlDriverForm emits must construct a driver through the factory ----
+
+ [Theory]
+ // Minimal valid config: provider + required connectionStringRef only (all timeouts left to the factory).
+ [InlineData("""{"provider":"SqlServer","connectionStringRef":"AdminUiFormTest"}""")]
+ // Fuller config with every optional the form authors (timeouts satisfy operationTimeout > commandTimeout).
+ [InlineData("""{"provider":"SqlServer","connectionStringRef":"AdminUiFormTest","defaultPollInterval":"00:00:05","operationTimeout":"00:00:20","commandTimeout":"00:00:10","maxConcurrentGroups":8,"nullIsBad":true}""")]
+ public void FormEmittedJson_constructs_a_driver_through_the_factory(string formEmittedJson)
+ {
+ var envVar = SqlConnectionStringResolver.EnvironmentVariableFor("AdminUiFormTest");
+ var previous = Environment.GetEnvironmentVariable(envVar);
+ Environment.SetEnvironmentVariable(envVar, "Server=localhost;Database=Test;Trusted_Connection=True;");
+ try
+ {
+ var driver = SqlDriverFactoryExtensions.CreateInstance("adminui-form-test", formEmittedJson);
+ driver.ShouldNotBeNull();
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable(envVar, previous);
+ }
+ }
+
+ [Fact]
+ public void Factory_rejects_a_blob_missing_connectionStringRef()
+ {
+ // The form marks connectionStringRef required; the factory is the authoritative enforcement.
+ var ex = Should.Throw(
+ () => SqlDriverFactoryExtensions.CreateInstance("adminui-form-test", """{"provider":"SqlServer"}"""));
+ ex.Message.ShouldContain("connectionStringRef");
+ }
+
+ // ---- serialization contract: enum-by-name, camelCase, no rawTags / allowWrites / null optionals --------
+
+ [Fact]
+ public void Minimal_config_serializes_provider_as_a_name_and_omits_owned_and_null_fields()
+ {
+ // Mirrors SqlDriverForm.FormModel.ToDto() for a minimal config (only the required ref set).
+ var dto = new SqlDriverConfigDto { Provider = SqlProvider.SqlServer, ConnectionStringRef = "MesStaging" };
+
+ var json = JsonSerializer.Serialize(dto, FormOptions);
+
+ json.ShouldBe("""{"provider":"SqlServer","connectionStringRef":"MesStaging"}""");
+ json.ShouldNotContain("\"provider\":0"); // never an ordinal — that faults the string-typed factory read
+ json.ShouldNotContain("rawTags"); // composer-owned; never authored by the form
+ json.ShouldNotContain("allowWrites"); // inert in v1 (read-only); never authored
+ json.ShouldNotContain("defaultPollInterval"); // null optional omitted ⇒ factory default applies
+ }
+
+ [Fact]
+ public void Provider_round_trips_as_the_SqlServer_name()
+ {
+ var json = JsonSerializer.Serialize(
+ new SqlDriverConfigDto { Provider = SqlProvider.SqlServer, ConnectionStringRef = "R" }, FormOptions);
+
+ json.ShouldContain("\"SqlServer\"");
+ JsonSerializer.Deserialize(json, FormOptions)!.Provider.ShouldBe(SqlProvider.SqlServer);
+ }
+}