diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx
index c5a50698..0d9e5f18 100644
--- a/ZB.MOM.WW.OtOpcUa.slnx
+++ b/ZB.MOM.WW.OtOpcUa.slnx
@@ -32,6 +32,7 @@
+
diff --git a/docs/plans/2026-07-15-sql-poll-driver-design.md b/docs/plans/2026-07-15-sql-poll-driver-design.md
index 0aa81f58..62a13701 100644
--- a/docs/plans/2026-07-15-sql-poll-driver-design.md
+++ b/docs/plans/2026-07-15-sql-poll-driver-design.md
@@ -62,7 +62,8 @@ All `net10.0`, `Nullable`/`ImplicitUsings` enabled, `TreatWarningsAsErrors`, mat
|---|---|
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/` | Options DTOs, `SqlProvider` enum, tag-model records, `SqlEquipmentTagParser`. **Zero transport deps** (refs only `Core.Abstractions`) so AdminUI + browser can reference it without dragging `Microsoft.Data.SqlClient`. |
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/` | `SqlDriver` (runtime capabilities), `ISqlDialect` + `SqlServerDialect`, `SqlPollReader`, factory extensions. **Owns the `Microsoft.Data.SqlClient` package ref.** |
-| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/` | `SqlDriverBrowser : IDriverBrowser`, `SqlBrowseSession : IBrowseSession` (schema walk). Refs `Commons` + `Sql.Contracts` + **`Driver.Sql`** (for `ISqlDialect`/`SqlServerDialect` — the dialect's catalog SQL *is* the browse engine, so it must be shared, not duplicated; `Microsoft.Data.SqlClient` comes transitively). This runtime-project ref is a deliberate deviation from `OpcUaClient.Browser` (which refs only its Contracts and owns the OPC UA client packages itself): the browser still opens its own transient connection. The resulting **transitive `Microsoft.Data.SqlClient` reference on AdminUI is reviewed and accepted** — AdminUI already uses SqlClient for ConfigDb, so this adds no new package to its closure. Recorded here deliberately so nobody "fixes" the deviation later without this context. |
+| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/` | `SqlDriverBrowser : IDriverBrowser`, `SqlBrowseSession : IBrowseSession` (schema walk). Refs `Commons` + `Sql.Contracts` + **`Sql.Dialect`** (for `ISqlDialect`/`SqlServerDialect` — the dialect's catalog SQL *is* the browse engine, so it must be shared, not duplicated; `Microsoft.Data.SqlClient` comes transitively). The browser opens its own transient connection. The **transitive `Microsoft.Data.SqlClient` reference on AdminUI is reviewed and accepted** — AdminUI already uses SqlClient for ConfigDb, so this adds no new package to its closure. |
+| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/` | `ISqlDialect` + `SqlServerDialect`, assembly-split out of `Driver.Sql` (namespace unchanged). **Added by Gitea #526**, which corrected the row above: it used to say the browser referenced the runtime **`Driver.Sql`**, called that "a deliberate deviation from `OpcUaClient.Browser`", and asked that nobody "fix" it without reading this context. The *sharing* premise was right and survives; the conclusion that sharing required the runtime reference was not. The AdminUI references the browser, so that edge put the whole SQL driver runtime — and `ZB.MOM.WW.OtOpcUa.Core` through it — into the AdminUI's build/deploy graph, invisibly (measured before and after: both DLLs present, then absent). The dialect depends on nothing in `Driver.Sql`, so it moved to this leaf, referenced by the runtime driver and the browser alike. It cannot live in `.Contracts`: `ISqlDialect` exposes `DbProviderFactory` and `SqlServerDialect` returns `SqlClientFactory.Instance`, and `.Contracts` is deliberately provider-free — the same shape as `Driver.Mqtt.Transport`. Guarded by `DriverBrowserLayeringTests`, which scans every `*.Browser` csproj in the tree. |
| `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/` | Unit suite (SQLite fixture, §9). |
| `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/` | Env-gated integration suite (central SQL Server, §9). |
| `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/` | Browser unit tests (SQLite `PRAGMA` dialect). |
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlDriverBrowser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlDriverBrowser.cs
index bd43f089..2b0bd7a1 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlDriverBrowser.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlDriverBrowser.cs
@@ -4,6 +4,7 @@ using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser;
@@ -100,12 +101,14 @@ public sealed class SqlDriverBrowser : IDriverBrowser
///
///
- /// Sourced from , the driver's interim local constant, rather
- /// than from DriverTypeNames — the shared constant set is added by the driver-factory task,
- /// because adding a member there reddens DriverTypeNamesGuardTests until a factory is
- /// registered against it. Repointing that one constant repoints this too.
+ /// Sourced from the shared set, which is what the driver's own
+ /// SqlDriver.DriverTypeName resolves to. This used to read that interim local constant, and
+ /// the comment here said so; repointing it was deferred until a factory was registered against
+ /// (else DriverTypeNamesGuardTests goes red). One exists
+ /// now, and reading the shared constant is also what lets this browser stay off the runtime driver
+ /// — SqlDriver is the only type it still needed from there (Gitea #526).
///
- public string DriverType => SqlDriver.DriverTypeName;
+ public string DriverType => DriverTypeNames.Sql;
/// The environment variable a connectionStringRef resolves through.
/// The authored reference name.
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj
index daa01255..127b7caa 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj
@@ -11,15 +11,34 @@
-
-
+
+
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ISqlDialect.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ISqlDialect.cs
similarity index 97%
rename from src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ISqlDialect.cs
rename to src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ISqlDialect.cs
index 13f26329..31dae6ef 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ISqlDialect.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ISqlDialect.cs
@@ -15,7 +15,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// . Any code path that builds SQL by concatenating an authored tag field
/// without going through is a defect (design §8.1).
/// Quoting is the backstop, not the only defence. Design §8.1's catalog gate is
-/// implemented: resolves every authored table/column against the live
+/// implemented: SqlCatalogGate resolves every authored table/column against the live
/// catalog at Initialize and replaces it with the catalog's own spelling, so an identifier
/// reaching on the poll path is a string this driver read back out of
/// / / — not
@@ -96,7 +96,7 @@ public interface ISqlDialect
/// principal — T-SQL's SELECT SCHEMA_NAME(). Takes no parameters.
///
///
- /// Needed by : a tag authored as TagValues rather than
+ /// Needed by SqlCatalogGate: a tag authored as TagValues rather than
/// dbo.TagValues has to be looked up in some schema, and guessing dbo would be a
/// silent lie on any estate that maps its service accounts to their own default schema. Asking the
/// server is the only answer that matches how the poll query itself will resolve the name.
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlServerDialect.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/SqlServerDialect.cs
similarity index 98%
rename from src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlServerDialect.cs
rename to src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/SqlServerDialect.cs
index ca38e8bb..1f9b4c5b 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlServerDialect.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/SqlServerDialect.cs
@@ -77,7 +77,7 @@ public sealed class SqlServerDialect : ISqlDialect
/// The rejection messages deliberately do not echo the offending value — it is untrusted
/// input and this exception's text reaches the driver log.
/// This is a backstop, not the only defence. Design §8.1's catalog gate
- /// () resolves an authored table/column against the live catalog at
+ /// (SqlCatalogGate) resolves an authored table/column against the live catalog at
/// Initialize and substitutes the catalog's own spelling, so on the poll path the value arriving here
/// is a string read back out of the catalog. The rules below still run — the gate uses them as its own
/// charset filter, and they must hold for any future caller that has no catalog to check against.
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect.csproj
new file mode 100644
index 00000000..dd9f264b
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect.csproj
@@ -0,0 +1,30 @@
+
+
+ net10.0
+ enable
+ enable
+ latest
+ true
+ true
+ $(NoWarn);CS1591
+
+ ZB.MOM.WW.OtOpcUa.Driver.Sql
+ ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj
index 66ddb720..559f1ab6 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj
@@ -13,6 +13,9 @@
+
+
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/SqlAddressPickerBody.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/SqlAddressPickerBody.razor
index b616571a..94a76250 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/SqlAddressPickerBody.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/SqlAddressPickerBody.razor
@@ -269,7 +269,7 @@ else
// never written to the composed TagConfig blob nor back to the driver config — this is the
// sole place it is read, and it dies with the local variable.
var json = BuildBrowseConfig();
- var result = await BrowserService.OpenAsync(SqlDriver.DriverTypeName, json, default);
+ var result = await BrowserService.OpenAsync(DriverTypeNames.Sql, json, default);
if (result.Ok) { _token = result.Token; }
else { _openError = result.Message; }
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs
index 978e4d9c..bd58ccf3 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs
@@ -22,10 +22,7 @@ public static class TagConfigEditorMap
[DriverTypeNames.FOCAS] = typeof(Components.Shared.Uns.TagEditors.FocasTagConfigEditor),
[DriverTypeNames.OpcUaClient] = typeof(Components.Shared.Uns.TagEditors.OpcUaClientTagConfigEditor),
[DriverTypeNames.Calculation] = typeof(Components.Shared.Uns.TagEditors.CalculationTagConfigEditor),
- // Keyed off SqlDriver.DriverTypeName, which is value-identical to DriverTypeNames.Sql. The
- // note that used to sit here — "that shared constant is deliberately absent until Task 11 wires
- // the factory" — went stale when the constant landed; the key is correct either way.
- [SqlDriver.DriverTypeName] = typeof(Components.Shared.Uns.TagEditors.SqlTagConfigEditor),
+ [DriverTypeNames.Sql] = typeof(Components.Shared.Uns.TagEditors.SqlTagConfigEditor),
[DriverTypeNames.Mqtt] = typeof(Components.Shared.Uns.TagEditors.MqttTagConfigEditor),
[DriverTypeNames.MTConnect] = typeof(Components.Shared.Uns.TagEditors.MTConnectTagConfigEditor),
};
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs
index 863f1552..fb713cf6 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs
@@ -24,8 +24,7 @@ public static class TagConfigValidator
[DriverTypeNames.FOCAS] = j => FocasTagConfigModel.FromJson(j).Validate(),
[DriverTypeNames.OpcUaClient] = j => OpcUaClientTagConfigModel.FromJson(j).Validate(),
[DriverTypeNames.Calculation] = j => CalculationTagConfigModel.FromJson(j).Validate(),
- // Keyed off SqlDriver.DriverTypeName (= "Sql") — see the note in TagConfigEditorMap.
- [SqlDriver.DriverTypeName] = j => SqlTagConfigModel.FromJson(j).Validate(),
+ [DriverTypeNames.Sql] = j => SqlTagConfigModel.FromJson(j).Validate(),
[DriverTypeNames.Mqtt] = j => MqttTagConfigModel.FromJson(j).Validate(),
[DriverTypeNames.MTConnect] = j => MTConnectTagConfigModel.FromJson(j).Validate(),
};
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj
index a81f6b25..4b1727c9 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj
@@ -41,8 +41,10 @@
+ .Contracts + .Dialect + Microsoft.Data.SqlClient transitively — SqlClient is
+ reviewed/accepted (AdminUI already carries it for ConfigDb). It no longer pulls the
+ runtime Driver.Sql, nor Core through it: see Gitea #526 and the Browser project's own
+ csproj comment. -->
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DriverBrowserLayeringTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DriverBrowserLayeringTests.cs
new file mode 100644
index 00000000..c8e70589
--- /dev/null
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DriverBrowserLayeringTests.cs
@@ -0,0 +1,152 @@
+using System.Text.RegularExpressions;
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.Browsing;
+
+///
+/// Pins the layering rule for every bespoke driver browser in the tree: a *.Browser
+/// project must not reach its runtime *.Driver project, by any path.
+///
+///
+///
+/// Why this exists rather than a per-driver test. Gitea #512 removed this violation from
+/// the MQTT browser and pinned it with MqttBrowserLayeringTests. The identical violation was
+/// sitting in Driver.Sql.Browser the whole time and no guard saw it, because the guard was
+/// written for the driver that happened to have an issue filed (#526). This test enumerates the
+/// browsers off disk, so a browser added later is covered on the day it is added.
+///
+///
+/// Why it reads csproj files rather than loaded assemblies. The cost being prevented is a
+/// build/deploy-graph cost: the AdminUI references these browsers, so a runtime driver reachable
+/// from one lands in the AdminUI's output whether or not a single type is used. The compiler emits
+/// no assembly reference for an unused ProjectReference, so an assembly-reference test —
+/// which is what MqttBrowserLayeringTests does, and says so — cannot see that case. The two
+/// are complementary: this one catches the reference, that one catches the usage.
+///
+///
+/// The walk is transitive. Asserting only on direct references would pass a browser that
+/// reached its runtime driver through a shared leaf, which is the same defect one hop further out
+/// — the hazard MqttBrowserLayeringTests guards for .Transport by hand.
+///
+///
+[Trait("Category", "Unit")]
+public sealed class DriverBrowserLayeringTests
+{
+ ///
+ /// Browsers known to exist when this guard was written. Asserted as a MINIMUM, not an exact set —
+ /// a new browser must be covered automatically, so this list only defends against the discovery
+ /// itself silently finding nothing (a wrong repo root, a moved directory), which would make every
+ /// assertion below vacuously true.
+ ///
+ private static readonly string[] KnownBrowsers =
+ [
+ "ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser",
+ "ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser",
+ "ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser",
+ "ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser",
+ ];
+
+ [Fact]
+ public void Every_bespoke_browser_is_discovered()
+ {
+ // The positive control for the whole file. Every other test here is an ABSENCE assertion over a
+ // discovered set, so an empty set passes them all.
+ var discovered = DiscoverBrowsers().Select(p => Path.GetFileNameWithoutExtension(p)!).ToArray();
+
+ foreach (var known in KnownBrowsers)
+ discovered.ShouldContain(known);
+ }
+
+ [Fact]
+ public void Every_browser_declares_at_least_one_project_reference()
+ // The second half of the control: proves the csproj parse actually returns something, so a
+ // regex that silently stopped matching cannot turn the rule below into a no-op.
+ => DiscoverBrowsers().ShouldAllBe(p => DirectReferences(p).Count > 0);
+
+ [Fact]
+ public void No_browser_reaches_its_runtime_driver_project()
+ {
+ var offenders = new List();
+
+ foreach (var browser in DiscoverBrowsers())
+ {
+ var browserName = Path.GetFileNameWithoutExtension(browser)!;
+
+ // "ZB…Driver.Sql.Browser" -> "ZB…Driver.Sql": the sibling runtime project.
+ var runtime = browserName[..^".Browser".Length];
+
+ var reached = TransitiveReferences(browser);
+ if (reached.Contains(runtime))
+ offenders.Add($"{browserName} -> {runtime}");
+ }
+
+ offenders.ShouldBeEmpty(
+ "a driver browser reaches its runtime driver project. The AdminUI references these browsers, "
+ + "so the whole driver runtime lands in its build/deploy graph — the cost is invisible, which "
+ + "is exactly why it needs a guard. If shared code is needed, put transport-free protocol or "
+ + "contract types in .Contracts, and anything that must name a provider/vendor type in a leaf "
+ + "project of its own (see Driver.Mqtt.Transport and Driver.Sql.Dialect). Do NOT resolve this "
+ + "by copying the shared code into the browser: for anything touching credentials, TLS or "
+ + "identifier quoting, a second copy is a silent security divergence rather than a build break.");
+ }
+
+ // ---- csproj graph ------------------------------------------------------------------------------
+
+ private static readonly Regex ProjectReferencePattern = new(
+ " DiscoverBrowsers() =>
+ [.. Directory
+ .EnumerateDirectories(Path.Combine(RepoRoot.Value, "src", "Drivers"), "*.Browser")
+ .Select(d => Path.Combine(d, Path.GetFileName(d) + ".csproj"))
+ .Where(File.Exists)
+ .OrderBy(p => p, StringComparer.Ordinal)];
+
+ /// Absolute paths of the csproj files one project references directly.
+ private static IReadOnlyList DirectReferences(string csprojPath)
+ {
+ var dir = Path.GetDirectoryName(csprojPath)!;
+
+ return
+ [
+ .. ProjectReferencePattern
+ .Matches(File.ReadAllText(csprojPath))
+ .Select(m => Path.GetFullPath(
+ Path.Combine(dir, m.Groups[1].Value.Replace('\\', Path.DirectorySeparatorChar))))
+ ];
+ }
+
+ /// Assembly-ish names of every project reachable from .
+ private static HashSet TransitiveReferences(string csprojPath)
+ {
+ var reached = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var pending = new Stack(DirectReferences(csprojPath));
+
+ while (pending.Count > 0)
+ {
+ var next = pending.Pop();
+ if (!reached.Add(Path.GetFileNameWithoutExtension(next)!) || !File.Exists(next)) continue;
+
+ foreach (var onward in DirectReferences(next)) pending.Push(onward);
+ }
+
+ return reached;
+ }
+
+ ///
+ /// Walks up from the test binary until the solution file appears. The tests run out of
+ /// bin/Debug/net10.0, so no fixed relative depth is reliable across host layouts.
+ ///
+ private static readonly Lazy RepoRoot = new(() =>
+ {
+ var dir = new DirectoryInfo(AppContext.BaseDirectory);
+
+ while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "ZB.MOM.WW.OtOpcUa.slnx")))
+ dir = dir.Parent;
+
+ dir.ShouldNotBeNull("could not locate the repository root (no ZB.MOM.WW.OtOpcUa.slnx above the test binary)");
+ return dir.FullName;
+ });
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttBrowserLayeringTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttBrowserLayeringTests.cs
index 79c36015..cc3f1a2f 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttBrowserLayeringTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttBrowserLayeringTests.cs
@@ -22,6 +22,14 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
/// ProjectReference re-added but never used would not trip it (the compiler emits no
/// reference), so the csproj comment remains the primary record of the decision.
///
+///
+/// That gap is now covered by DriverBrowserLayeringTests (Commons.Tests), which
+/// scans the csproj graph of every *.Browser project in the tree — added for Gitea #526,
+/// after the identical violation was found sitting in Driver.Sql.Browser with no guard
+/// watching it, because this file was written for the one driver that had an issue filed. What
+/// stays here is the MQTT-specific half: the .Transport leaf shape and the
+/// MQTTnet-free-ness of .Contracts.
+///
///
[Trait("Category", "Unit")]
public sealed class MqttBrowserLayeringTests
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj
index 4ccd43cf..88277f85 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj
@@ -25,6 +25,13 @@
+
+