Files
lmxopcua/docs/plans/2026-07-24-sql-poll-driver.md
T
Joseph Doherty 76e55fc112 docs(drivers): Wave 0-2 tracking doc + Wave 1/2 implementation plans
Add a living progress tracker (docs/plans/2026-07-24-driver-expansion-tracking.md)
for the driver-expansion program's Waves 0-2, linked from the program design doc's
document map. One row per deliverable pointing at design + implementation plan + status.

Add executable implementation plans (plan .md + co-located .tasks.json) for the four
Wave 1/2 drivers, each authored by writing-plans methodology off its design doc and
mirroring the relevant existing driver:

- Modbus RTU        11 tasks  (extends Driver.Modbus; RTU-over-TCP, direct-serial descoped)
- SQL poll          22 tasks  (ISqlDialect seam, SQL Server only in v1; SQLite + central-SQL fixtures)
- MTConnect Agent   23 tasks  (browse free via Wave-0 seam; TrakHound-vs-hand-rolled = Task 0)
- MQTT/Sparkplug B  27 tasks  (P1 plain MQTT 0-14 then P2 Sparkplug 15-26; MQTTnet-5 pinning spike = Task 0)

Wave 0 (universal browser) remains merged (056887d6) with its live gate open (#468).
All four new plans are design-only -> plan-ready; nothing built yet.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 13:17:44 -04:00

671 lines
48 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SQL Poll Driver Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task.
**Goal:** Ship a read-only `Sql` Equipment-kind driver that polls SQL Server tables/views on an interval and publishes selected columns/rows as OPC UA variable nodes, authored through the standard equipment Tags flow with a bespoke schema browser.
**Architecture:** Three new projects mirroring the Modbus split — `Driver.Sql.Contracts` (options/tag DTOs + parser, zero transport deps), `Driver.Sql` (the `IDriver`/`ITagDiscovery`/`IReadable`/`ISubscribable`/`IHostConnectivityProbe` runtime + `ISqlDialect` seam + `SqlServerDialect`, owning `Microsoft.Data.SqlClient`), and `Driver.Sql.Browser` (schema-walk `IBrowseSession`). The `PollGroupEngine`, `EquipmentTagRefResolver<TDef>`, health state machine, and factory shape are all reused from Core; the driver batches tags by query-group (one round-trip per group per poll) and slices result sets back to per-tag snapshots. Every value binds as a `DbParameter`; identifiers are dialect-quoted from catalog-validated names only.
**Tech Stack:** `Microsoft.Data.SqlClient` (6.1.1, already in `Directory.Packages.props`) via `System.Data.Common` base types behind a `DbProviderFactory`; `ISqlDialect` + `SqlProvider` enum seam (only `SqlServer` implemented in v1); `Microsoft.Data.Sqlite` (10.0.7, test-only) for the offline unit fixture; xunit.v3 + Shouldly. **Zero new NuGet dependencies.**
**Source design:** docs/plans/2026-07-15-sql-poll-driver-design.md
**Program design (shared contract):** docs/plans/2026-07-15-driver-expansion-program-design.md §3, §7
**Conventions for every task below:** all projects `net10.0`, `Nullable`/`ImplicitUsings` enabled, `TreatWarningsAsErrors` on product projects. Every commit message ends with the repo trailer `Claude-Session: <session-url>` (omitted from the `git commit` examples for brevity). Run `dotnet build ZB.MOM.WW.OtOpcUa.slnx` green before each commit. New test projects use `xunit.v3` + `Shouldly` (see `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/*.csproj`).
---
## Task 0: Scaffold `Driver.Sql.Contracts` project + register in slnx
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** none (root of the tree)
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj`
- Modify: `ZB.MOM.WW.OtOpcUa.slnx` (add the project under `/src/Drivers/`)
**Steps:**
1. Create the csproj — refs `Core.Abstractions` ONLY (no transport deps, so AdminUI + browser can reference it without dragging `Microsoft.Data.SqlClient`). `InternalsVisibleTo` the Contracts is not needed. `RootNamespace = ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts`.
```xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj" />
</ItemGroup>
</Project>
```
2. Add to `ZB.MOM.WW.OtOpcUa.slnx` under the `/src/Drivers/` folder, beside the `Driver.Modbus.Contracts` line.
3. Run `dotnet build ZB.MOM.WW.OtOpcUa.slnx` — expect PASS (empty project compiles).
4. Commit: `git commit -m "feat(sql): scaffold Driver.Sql.Contracts project"`
---
## Task 1: `SqlProvider` + `SqlTagModel` enums + `SqlDriverConfigDto` (string-enum round-trip)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (blocks most downstream)
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlProvider.cs`
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlDriverConfigDto.cs`
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/*` is NOT created here — this task's test lives temporarily in the Contracts consumer; create the Tests project in Task 6. **Instead**, put this task's test in a throwaway `Contracts`-adjacent xunit project? No — to avoid a project just for one test, fold this task's serialization assertion into Task 9's factory enum-serialization test. **This task ships DTOs only; its guard is Task 9.**
> **Note:** Tasks 12 produce pure records/enums with no I/O. Their behavioural guard (string-enum round-trip, strict parse) is the Task 9 factory test + Task 2's parser test (Task 2 creates its own micro-test via the Tests project created in Task 6). To keep TDD honest without a premature test project, **reorder locally**: implement Task 1 DTOs, then Task 6 (Tests project) can be pulled forward if the implementer prefers. The `blockedBy` graph permits this. The concrete failing-test-first discipline begins at Task 2.
**Steps:**
1. `SqlProvider.cs`:
```csharp
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
/// <summary>Which ADO.NET provider/dialect backs a Sql driver instance. v1 constructs only SqlServer.</summary>
public enum SqlProvider { SqlServer, Postgres, MySql, Odbc, Oracle }
/// <summary>Tag→value mapping model. v1 ships KeyValue + WideRow; Query is deferred (design §5.4 / P3).</summary>
public enum SqlTagModel { KeyValue, WideRow, Query }
```
2. `SqlDriverConfigDto.cs` — the driver-config blob shape (§5.1). Enum-typed fields (`Provider`) so the string converter round-trips names; timeouts as `TimeSpan?`; `ConnectionStringRef` (NOT the connection string). Fields: `Provider`, `ConnectionStringRef`, `DefaultPollInterval`, `OperationTimeout`, `CommandTimeout`, `MaxConcurrentGroups`, `NullIsBad`, `AllowWrites`, `Probe` (`SqlProbeDto{Enabled, Interval}`). `[JsonStringEnumConverter]` is applied at the factory (Task 9), not on the DTO.
3. Build — expect PASS.
4. Commit: `git commit -m "feat(sql): SqlProvider/SqlTagModel enums + SqlDriverConfigDto"`
---
## Task 2: `SqlTagDefinition` + `SqlEquipmentTagParser.TryParse` (strict enum reads, malicious-input safe)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlTagDefinition.cs`
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlEquipmentTagParser.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlEquipmentTagParserTests.cs` (Tests project scaffolded in Task 6 — **pull the csproj creation forward from Task 6 for this task**, or defer this task's test until Task 6 and mark Task 2 blockedBy nothing but verified-at-6. Recommended: create the Tests project now as part of this task's setup.)
**TDD:**
1. **Failing test** — parse a KeyValue blob and a malicious `keyValue`; assert the value is captured verbatim (parser does NOT sanitize — binding is the reader's job) and a typo'd `model` enum rejects:
```csharp
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
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();
}
```
2. Run `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests` — expect FAIL (types don't exist).
3. **Minimal impl**`SqlTagDefinition` record (`Name` = RawPath, `Model`, `Table`, `KeyColumn`, `KeyValue`, `ValueColumn`, `TimestampColumn`, `ColumnName`, `RowSelectorColumn`, `RowSelectorValue`, `RowSelectorTopByTimestamp`, `DeclaredType` = `DriverDataType?`). `SqlEquipmentTagParser.TryParse(string reference, string rawPath, out SqlTagDefinition def)` — mirror `ModbusTagDefinitionFactory.FromTagConfig`: recognize by leading `{`, read the `model` discriminator via `TagConfigJson.TryReadEnumStrict` (a present-but-invalid enum → `false` → the driver surfaces `BadNodeIdUnknown`, per R2-11), read model-specific fields, read the optional `type` override strictly, set `Name = rawPath`. Wrap in try/catch(JsonException/FormatException) → `false`. **No SQL is built here** — the parser only captures strings.
4. Run test — expect PASS.
5. Commit: `git commit -m "feat(sql): SqlTagDefinition + strict-enum equipment-tag parser"`
---
## Task 3: Scaffold `Driver.Sql` runtime project + register in slnx
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 2
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj`
- Modify: `ZB.MOM.WW.OtOpcUa.slnx`
**Steps:**
1. Create the csproj per design §2.1 — refs `Core.Abstractions` + `Core` (for `DriverFactoryRegistry` in `ZB.MOM.WW.OtOpcUa.Core.Hosting`) + `Driver.Sql.Contracts`; packages `Microsoft.Data.SqlClient` + `Microsoft.Extensions.Logging.Abstractions`; `InternalsVisibleTo` the `.Tests`.
2. Add to `ZB.MOM.WW.OtOpcUa.slnx` under `/src/Drivers/`.
3. Build — expect PASS.
4. Commit: `git commit -m "feat(sql): scaffold Driver.Sql runtime project"`
---
## Task 4: `ISqlDialect` seam + `SqlServerDialect` (QuoteIdentifier, catalog SQL, MapColumnType) — golden queries
**Classification:** high-risk (identifier quoting is the injection boundary)
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 2
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ISqlDialect.cs`
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlServerDialect.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlServerDialectTests.cs`
**TDD:**
1. **Failing test** — golden catalog SQL, `QuoteIdentifier` escape + reject, `MapColumnType` table:
```csharp
[Fact]
public void QuoteIdentifier_bracketsAndEscapesEmbeddedBrackets()
{
var d = new SqlServerDialect();
d.QuoteIdentifier("Speed").ShouldBe("[Speed]");
d.QuoteIdentifier("a]b").ShouldBe("[a]]b]"); // ] doubled per T-SQL rules
}
[Fact]
public void QuoteIdentifier_rejectsControlCharsAndNul()
=> Should.Throw<ArgumentException>(() => new SqlServerDialect().QuoteIdentifier("x\0y"));
[Theory]
[InlineData("bit", DriverDataType.Boolean)]
[InlineData("int", DriverDataType.Int32)]
[InlineData("bigint", DriverDataType.Int64)]
[InlineData("real", DriverDataType.Float32)]
[InlineData("float", DriverDataType.Float64)]
[InlineData("decimal", DriverDataType.Float64)]
[InlineData("nvarchar", DriverDataType.String)]
[InlineData("datetime2", DriverDataType.DateTime)]
[InlineData("uniqueidentifier", DriverDataType.String)]
public void MapColumnType_mapsFamilies(string sql, DriverDataType expected)
=> new SqlServerDialect().MapColumnType(sql).ShouldBe(expected);
[Fact]
public void CatalogSql_isInformationSchemaAndParameterized()
{
var d = new SqlServerDialect();
d.LivenessSql.ShouldBe("SELECT 1");
d.ListTablesSql.ShouldContain("INFORMATION_SCHEMA.TABLES");
d.ListTablesSql.ShouldContain("@schema"); // never string-interpolated
d.ListColumnsSql.ShouldContain("@table");
}
```
2. Run — expect FAIL.
3. **Minimal impl**`ISqlDialect` per design §2.2 (`Provider`, `Factory` = `DbProviderFactory`, `QuoteIdentifier`, `LivenessSql`, `ListSchemasSql`, `ListTablesSql`, `ListColumnsSql`, `MapColumnType`). `SqlServerDialect`: `Factory => SqlClientFactory.Instance`; `QuoteIdentifier` doubles `]`, rejects embedded NUL/control chars via `ArgumentException`; catalog SQL uses `INFORMATION_SCHEMA` with `@schema`/`@table` markers; `MapColumnType` folds SQL type families to `DriverDataType` per design §3.7 (`decimal`/`numeric`/`money``Float64` with the documented precision caveat as an XML remark).
4. Run — expect PASS.
5. Commit: `git commit -m "feat(sql): ISqlDialect + SqlServerDialect with golden catalog SQL"`
---
## Task 5: `SqlQueryPlan` grouping + parameter binding (pure, no DB)
**Classification:** high-risk (the query-mapping core; GroupKey correctness governs correctness of every read)
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 10
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlQueryPlan.cs`
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlGroupPlanner.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlGroupPlannerTests.cs`
**TDD:**
1. **Failing test** — KeyValue tags on the same `(table,keyColumn,valueColumn,timestampColumn)` fold into ONE plan whose SQL is `... WHERE [tag_name] IN (@k0,@k1)` with two bound params; WideRow tags on the same `(table,rowSelector)` fold into one `SELECT <cols> ... WHERE [station_id]=@w`; different tables → different plans:
```csharp
[Fact]
public void KeyValueTags_sameSource_foldIntoOnePlan_withBoundInList()
{
var dialect = new SqlServerDialect();
var tags = new[] {
KvTag("A","dbo.TagValues","tag_name","Line1.Speed","num_value","sample_ts"),
KvTag("B","dbo.TagValues","tag_name","Line1.Temp","num_value","sample_ts"),
};
var plans = SqlGroupPlanner.Plan(tags, dialect).ToList();
plans.Count.ShouldBe(1);
plans[0].SqlText.ShouldContain("IN (@k0, @k1)");
plans[0].SqlText.ShouldContain("[tag_name]");
plans[0].Members.Count.ShouldBe(2);
plans[0].Parameters.ShouldBe(new object[] { "Line1.Speed", "Line1.Temp" }); // values are params, not text
}
```
2. Run — expect FAIL.
3. **Minimal impl**`SqlQueryPlan { object GroupKey, string SqlText, IReadOnlyList<object?> Parameters, IReadOnlyList<SqlTagDefinition> Members, Func<DbDataReader, SqlTagDefinition, ...> }` (indexer supplied by the reader in Task 7; here just the shape + SQL/param builder). `SqlGroupPlanner.Plan(tags, dialect)`: KeyValue → GroupKey `(table,keyColumn,valueColumn,timestampColumn)`, emit `SELECT <keyColumn>,<valueColumn>[,<timestampColumn>] FROM <table> WHERE <keyColumn> IN (@k0..@kN)` with distinct-key params; WideRow → GroupKey `(table, rowSelector)`, emit `SELECT <distinct member columns>[,<selector cols>] FROM <table> WHERE <whereColumn>=@w` (or `ORDER BY <col> DESC` + dialect TOP-1 for `topByTimestamp`). Identifiers via `dialect.QuoteIdentifier`; `<table>` split on `.` and each part quoted. **Every value is a param; zero interpolation.**
4. Run — expect PASS.
5. Commit: `git commit -m "feat(sql): SqlGroupPlanner folds tags into one parameterized query per group"`
---
## Task 6: Scaffold `Driver.Sql.Tests` + `SqliteDialect` (test) + `SqlitePollFixture`
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 10, Task 12
**Files:**
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests.csproj`
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqliteDialect.cs`
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlitePollFixture.cs`
- Modify: `ZB.MOM.WW.OtOpcUa.slnx`
> If Task 2 already created the csproj, this task adds the `SqliteDialect`/fixture and the slnx entry only.
**Steps:**
1. csproj — `xunit.v3` + `Shouldly` + `Microsoft.NET.Test.Sdk` + `xunit.runner.visualstudio`; `PackageReference Microsoft.Data.Sqlite` (10.0.7, test-only — no product dep on SQLite); project-refs `Driver.Sql` + `Driver.Sql.Contracts`. Add to slnx under `/tests/Drivers/`.
2. `SqliteDialect : ISqlDialect``Provider = SqlServer` shim is wrong; add a test-only `Provider` value is not needed — implement `ISqlDialect` directly with `Factory => SqliteFactory.Instance`, `QuoteIdentifier` doubling `"`, `LivenessSql = "SELECT 1"`, catalog SQL over `sqlite_schema` + `PRAGMA table_info(...)`, `MapColumnType` mapping declared affinity (design §9 caveat: SQLite is dynamically typed — keep seed columns explicitly typed). This dialect is also linked by the Browser.Tests (Task 13) via `<Compile Include="..\..\Driver.Sql.Tests\SqliteDialect.cs" Link="SqliteDialect.cs" />`.
3. `SqlitePollFixture` — opens an in-memory (or temp-file) SQLite connection, seeds a KeyValue table `TagValues(tag_name TEXT, num_value REAL, sample_ts TEXT)` and a wide-row `LatestStatus(station_id INT, oven_temp REAL, sample_ts TEXT)` with a few rows. Exposes the open `SqliteConnection` + a `DbProviderFactory` shim so the driver-under-test can be injected with an already-open connection provider (see Task 8's ctor seam).
4. Build + run (no tests yet) — expect PASS/empty.
5. Commit: `git commit -m "test(sql): Sql.Tests project + SqliteDialect + poll fixture"`
---
## Task 7: `SqlPollReader` core — one query per group, bounded deadline, slice back in input order
**Classification:** high-risk (connection/timeout/result-slicing core)
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlPollReader.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlPollReaderTests.cs`
**TDD:**
1. **Failing test** (against `SqlitePollFixture`) — read two KeyValue refs + one absent key; assert N-in/N-out order, correct values/types, absent key → `BadNoData`, NULL cell → `Uncertain` (default `nullIsBad=false`), source timestamp from `timestampColumn`:
```csharp
[Fact]
public async Task ReadAsync_returnsSnapshotsInInputOrder_absentKeyIsBadNoData()
{
await using var fx = await SqlitePollFixture.CreateAsync(); // seeds Line1.Speed=42.0, Line1.Temp=NULL
var reader = new SqlPollReader(fx.Factory, fx.ConnectionString, new SqliteDialect(),
commandTimeout: TimeSpan.FromSeconds(10), operationTimeout: TimeSpan.FromSeconds(15),
maxConcurrentGroups: 4, nullIsBad: false, resolve: fx.Resolve);
var refs = new[] { "Speed", "Missing", "Temp" };
var snaps = await reader.ReadAsync(refs, CancellationToken.None);
snaps.Count.ShouldBe(3);
snaps[0].Value.ShouldBe(42.0);
snaps[1].StatusCode.ShouldBe(SqlStatusCodes.BadNoData); // key deleted/absent
snaps[2].Value.ShouldBeNull();
StatusCode.IsUncertain(snaps[2].StatusCode).ShouldBeTrue(); // NULL cell, nullIsBad=false
}
```
2. Run — expect FAIL.
3. **Minimal impl**`SqlPollReader.ReadAsync(refs, ct)`: resolve each ref → `SqlTagDefinition` (miss → `BadNodeIdUnknown`); `SqlGroupPlanner.Plan(...)`; per group under a `SemaphoreSlim(maxConcurrentGroups)`: `await using var conn = factory.CreateConnection(); conn.ConnectionString = connStr; await conn.OpenAsync(linkedCt)`; build `DbCommand` with `CommandTimeout = (int)commandTimeout.TotalSeconds` and bound params; `using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct); linked.CancelAfter(operationTimeout); await using var rdr = await cmd.ExecuteReaderAsync(linked.Token)`; index rows by key/selector; map each member's cell → `DataValueSnapshot` (type per `DeclaredType` ?? `dialect.MapColumnType`-inferred; NULL → `nullIsBad ? Bad : Uncertain`; absent key → `BadNoData`; source timestamp from `timestampColumn` else read wall-clock). **Reassemble in input order** (`DataValueSnapshot[refs.Count]`). Per-tag failure = Bad-coded snapshot, NOT an exception; the whole call throws only if the connection itself is unreachable (→ engine backoff). `await using` both connection and reader — never leak. Add `SqlStatusCodes` static (BadNoData/BadTimeout/BadNodeIdUnknown/BadCommunicationError/Uncertain constants) if not reusing an existing helper.
4. Run — expect PASS. Add a NULL-cell test with `nullIsBad:true``Bad`.
5. Commit: `git commit -m "feat(sql): SqlPollReader — grouped read, bounded deadline, ordered slice-back"`
---
## Task 8: `SqlDriver` shell — IDriver / ITagDiscovery / IReadable / ISubscribable / IHostConnectivityProbe
**Classification:** high-risk (lifecycle + poll-engine wiring + connection validation)
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverTests.cs`
**TDD:**
1. **Failing test** — construct `SqlDriver` with an injected dialect + factory + already-resolved connection string (SQLite fixture) + authored `RawTagEntry` list; `InitializeAsync``Healthy`; `DiscoverAsync` streams one Variable per authored tag with `SecurityClass=ViewOnly` + `RediscoverPolicy=Once` + `SupportsOnlineDiscovery=false`; `ReadAsync` delegates to the reader; a subscribe fires an initial change:
```csharp
[Fact]
public async Task Initialize_thenDiscover_streamsAuthoredTagsReadOnly()
{
await using var fx = await SqlitePollFixture.CreateAsync();
var driver = SqlDriver.ForTest(fx, rawTags: fx.AuthoredRawTags);
await driver.InitializeAsync(fx.ConfigJson, CancellationToken.None);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
((ITagDiscovery)driver).SupportsOnlineDiscovery.ShouldBeFalse();
((ITagDiscovery)driver).RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.Once);
var cap = new CapturingBuilder();
await ((ITagDiscovery)driver).DiscoverAsync(cap, CancellationToken.None);
cap.Variables.ShouldContain(v => v.FullName == "Speed" && v.SecurityClass == SecurityClassification.ViewOnly);
}
```
2. Run — expect FAIL.
3. **Minimal impl**`SqlDriver : IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe, IAsyncDisposable`. Mirror `ModbusDriver`: fields grouped up top; `_resolver = new EquipmentTagRefResolver<SqlTagDefinition>(...)`; `_poll = new PollGroupEngine(reader: ReadAsync, onChange: ..., onError: HandlePollError, backoffCap: 30s)`. `InitializeAsync`: build `_tagsByRawPath` from `_options.RawTags` via `SqlEquipmentTagParser.TryParse` (miss = logged skip, never throw); open ONE connection, run `dialect.LivenessSql` under `CommandTimeout` + linked CTS, dispose; success → `Healthy`, failure → `Faulted` + `LastError`. `ReadAsync``_reader.ReadAsync`. `SubscribeAsync``_poll.Subscribe`; `UnsubscribeAsync``_poll.Unsubscribe`. `IHostConnectivityProbe`: single logical host (server host from the connection string) with `Running/Stopped` from the last poll/liveness outcome + `OnHostStatusChanged` on transitions. `GetMemoryFootprint => 0`; `FlushOptionalCachesAsync => Task.CompletedTask`; `ReinitializeAsync` = teardown+init. `DriverType => DriverTypeNames.Sql`. Add a `ForTest(...)` internal factory that injects dialect+factory+connString (production path resolves those in the factory, Task 9). **Pooling: open-use-dispose per poll (already in the reader); no long-lived connection.**
4. Run — expect PASS.
5. Commit: `git commit -m "feat(sql): SqlDriver shell wiring PollGroupEngine + probe + read-only discovery"`
---
## Task 9: `SqlDriverFactoryExtensions` + `connectionStringRef` env resolution + enum-serialization guard
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 10
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs`
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlConnectionStringResolver.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverFactoryTests.cs`
**TDD:**
1. **Failing test**`CreateInstance` with a config carrying `"provider":"SqlServer"` builds a driver; a missing `connectionStringRef` throws a clean error; the connection-string ref resolves from env `Sql__ConnectionStrings__<ref>`; **enum-serialization guard** — a config author serializing `provider` as a NUMBER still parses (`JsonStringEnumConverter` accepts ordinals) AND the round-trip writes the NAME:
```csharp
[Fact]
public void CreateInstance_missingConnectionStringRef_throwsActionable()
=> Should.Throw<InvalidOperationException>(() =>
SqlDriverFactoryExtensions.CreateInstance("d1", """{"provider":"SqlServer"}""", null))
.Message.ShouldContain("connectionStringRef");
[Fact]
public void ConnectionStringRef_resolvesFromEnv()
{
Environment.SetEnvironmentVariable("Sql__ConnectionStrings__MesStaging", "Server=x;Database=y;");
SqlConnectionStringResolver.Resolve("MesStaging").ShouldBe("Server=x;Database=y;");
}
[Fact]
public void Provider_serializesAsNameNotNumber()
{
var json = JsonSerializer.Serialize(new SqlDriverConfigDto { Provider = SqlProvider.SqlServer },
SqlDriverFactoryExtensions.JsonOptionsForTest);
json.ShouldContain("\"SqlServer\"");
json.ShouldNotContain("\"provider\":0");
}
```
2. Run — expect FAIL.
3. **Minimal impl**`SqlConnectionStringResolver.Resolve(string @ref)``Environment.GetEnvironmentVariable($"Sql__ConnectionStrings__{@ref}")` (direct env read, deliberate — the factory closure has no `IConfiguration`, per design §8.2), throws actionable if absent. `SqlDriverFactoryExtensions`: `const DriverTypeName = DriverTypeNames.Sql`; `Register(registry, loggerFactory)``registry.Register(DriverTypeName, (id,json)=>CreateInstance(id,json,loggerFactory))`; `CreateInstance` deserializes `SqlDriverConfigDto` with `JsonOptions` carrying `JsonStringEnumConverter` + `UnmappedMemberHandling.Skip`, validates `ConnectionStringRef` present, builds `SqlServerDialect` from `Provider` (P1: only `SqlServer` constructible; others throw "provider not available in this build"), resolves the connection string, constructs the `SqlPollReader` + `SqlDriver`. **Never log the resolved connection string** (log provider + server host + database only). Expose `JsonOptionsForTest` internal.
4. Run — expect PASS.
5. Commit: `git commit -m "feat(sql): factory + connectionStringRef env resolution + string-enum guard"`
---
## Task 10: `SqlDriverProbe` (SELECT 1 liveness, bounded timeout)
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 5, Task 9, Task 12
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverProbe.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverProbeTests.cs`
**TDD:**
1. **Failing test** — against the SQLite fixture (inject dialect+factory), `ProbeAsync` returns green with latency; a bad connection string returns a red result with the error message (never throws):
```csharp
[Fact]
public async Task Probe_liveConnection_returnsGreen()
{
await using var fx = await SqlitePollFixture.CreateAsync();
var probe = SqlDriverProbe.ForTest(fx.Factory, new SqliteDialect());
var r = await probe.ProbeAsync(fx.ConfigJson, TimeSpan.FromSeconds(5), CancellationToken.None);
r.Success.ShouldBeTrue();
}
```
2. Run — expect FAIL.
3. **Minimal impl**`SqlDriverProbe : IDriverProbe`, `DriverType => DriverTypeNames.Sql`. Parse config via the SAME factory DTO shape + `JsonStringEnumConverter` (R2-11 factory parity, mirroring `ModbusDriverProbe`), resolve the connection string ref, open a connection under a linked CTS bounded by `timeout`, run `dialect.LivenessSql` (`SELECT 1`), return `DriverProbeResult(true, "SQL SELECT 1 OK", latency)`; catch → red result naming the failure. Never throws. `ForTest` injects factory+dialect for the SQLite path.
4. Run — expect PASS.
5. Commit: `git commit -m "feat(sql): SqlDriverProbe SELECT-1 liveness check"`
---
## Task 11: Host registration — `DriverTypeNames.Sql` + factory + probe + guard test
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** none
**Files:**
- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj` (project-ref `Driver.Sql` if not transitively present)
- Test: `tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/DriverTypeNamesGuardTests.cs` is EXISTING — it asserts bidirectional parity between `DriverTypeNames` constants and the factory-registered set; this task makes it pass by adding both sides.
**TDD:**
1. **Failing step** — add `public const string Sql = "Sql";` to `DriverTypeNames` + include in `All`, but DON'T yet register the factory. Run `DriverTypeNamesGuardTests` → expect FAIL (constant with no registered factory).
2. **Impl** — in `DriverFactoryBootstrap.Register(...)` add `Driver.Sql.SqlDriverFactoryExtensions.Register(registry, loggerFactory);`; in `AddOtOpcUaDriverProbes` add `services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, SqlProbe>());` with a `using SqlProbe = Driver.Sql.SqlDriverProbe;` alias; add the `Driver.Sql` project-ref to the Host csproj. Default tier `DriverTier.A` (no explicit arg — SQL client is cross-platform managed, runs on macOS dev, so `ShouldStub()` needs no change).
3. Run guard test + `dotnet build ZB.MOM.WW.OtOpcUa.slnx` — expect PASS.
4. Commit: `git commit -m "feat(sql): register Sql factory + probe in Host, add DriverTypeNames.Sql"`
---
## Task 12: Scaffold `Driver.Sql.Browser` project + register in slnx
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 5, Task 6, Task 9, Task 10
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj`
- Modify: `ZB.MOM.WW.OtOpcUa.slnx`
**Steps:**
1. csproj refs `Commons` (`ZB.MOM.WW.OtOpcUa.Commons``IDriverBrowser`/`IBrowseSession`/`BrowseNode`) + `Driver.Sql.Contracts` + **`Driver.Sql`** (for `ISqlDialect`/`SqlServerDialect` — the dialect's catalog SQL *is* the browse engine; `Microsoft.Data.SqlClient` comes transitively). This runtime-project ref is the deliberate deviation from `OpcUaClient.Browser` recorded in design §2 — add an XML comment on the ref citing the design so nobody "fixes" it. `InternalsVisibleTo` the `.Browser.Tests`.
2. Add to slnx under `/src/Drivers/`.
3. Build — expect PASS.
4. Commit: `git commit -m "feat(sql): scaffold Driver.Sql.Browser project"`
---
## Task 13: `SqlBrowseSession` — schema walk over the dialect catalog
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 16, Task 17
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs`
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests.csproj` (+ slnx entry, + link `SqliteDialect.cs` from Sql.Tests)
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlBrowseSessionTests.cs`
**TDD:**
1. **Failing test** — over a seeded SQLite DB + `SqliteDialect`: `RootAsync` yields schema folder(s); `ExpandAsync(schema)` yields the seeded tables; `ExpandAsync(table)` yields columns as leaves; `AttributesAsync(column)` yields the mapped `DriverDataType`:
```csharp
[Fact]
public async Task ExpandTable_yieldsColumnsAsLeaves_withMappedType()
{
await using var db = await SqliteBrowseFixture.CreateAsync(); // TagValues(tag_name TEXT, num_value REAL)
var session = new SqlBrowseSession(db.Connection, new SqliteDialect());
var tableNode = (await session.ExpandAsync(db.SchemaNodeId, default)).First(n => n.DisplayName.Contains("TagValues"));
var cols = await session.ExpandAsync(tableNode.NodeId, default);
cols.ShouldContain(c => c.DisplayName == "num_value" && c.IsLeaf);
var attrs = await session.AttributesAsync(cols.First(c => c.DisplayName == "num_value").NodeId, default);
attrs.DriverDataType.ShouldBe(nameof(DriverDataType.Float64));
}
```
2. Run — expect FAIL.
3. **Minimal impl**`SqlBrowseSession : IBrowseSession` (match the `Commons.Browsing` interface shape used by `OpcUaClientBrowseSession`): `RootAsync`/`ExpandAsync(nodeId)`/`AttributesAsync(nodeId)` run the dialect catalog queries with bound `@schema`/`@table` params; encode `NodeId` as `schema` / `schema.table` / `schema.table|column`; a `SemaphoreSlim _gate` serializes (one ADO.NET connection is not concurrent); per-call work bounded by the AdminUI's existing 20 s per-call timeout. Column leaf `AttributesAsync` returns `AttributeInfo(Name: column, DriverDataType: dialect.MapColumnType(dataType).ToString(), IsArray:false, SecurityClass:"ViewOnly")` (mirrors Galaxy two-stage pick).
4. Run — expect PASS.
5. Commit: `git commit -m "feat(sql): SqlBrowseSession schema-walk over dialect catalog"`
---
## Task 14: `SqlDriverBrowser` — IDriverBrowser open (env ref OR pasted literal, transient connection)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 16, Task 17
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlDriverBrowser.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlDriverBrowserTests.cs`
**TDD:**
1. **Failing test**`OpenAsync` with a form JSON whose `connectionStringRef` resolves from env opens a session; an UNresolvable ref fails with a message naming the EXACT missing env var; a pasted literal connection string is used session-only (never persisted):
```csharp
[Fact]
public async Task Open_unresolvableRef_failsNamingExactEnvVar()
{
var browser = new SqlDriverBrowser();
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
browser.OpenAsync("""{"provider":"SqlServer","connectionStringRef":"MesStaging"}""", default));
ex.Message.ShouldContain("Sql__ConnectionStrings__MesStaging");
}
```
2. Run — expect FAIL.
3. **Minimal impl**`SqlDriverBrowser : IDriverBrowser`, `DriverType => DriverTypeNames.Sql`. `OpenAsync(configJson, ct)`: deserialize with the same `JsonStringEnumConverter` options; resolve `connectionStringRef` via a direct env read (`Sql__ConnectionStrings__<ref>`) IN THE ADMINUI PROCESS — on miss throw an actionable message naming the exact missing env var (design §4.4); accept an optional pasted literal `connectionString` for ad-hoc browse (session-only, **never persisted, never logged, no `_lastConfigJson` cached field**); select `SqlServerDialect` (P1); open ONE transient `DbConnection`, return a `SqlBrowseSession`. Reuses the AdminUI `BrowseSessionRegistry` + reaper + idle TTL unchanged.
4. Run — expect PASS.
5. Commit: `git commit -m "feat(sql): SqlDriverBrowser transient-connection open with env-ref + literal"`
---
## Task 15: AdminUI browser DI + `SqlAddressPickerBody.razor` (picker body)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 16, Task 17
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs` (add `services.AddSingleton<IDriverBrowser, SqlDriverBrowser>();` beside the OpcUaClient/Galaxy lines ~:75)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj` (project-ref `Driver.Sql.Browser`)
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/AddressPickers/SqlAddressPickerBody.razor` (reuse `DriverBrowseTree.razor` for the tree + an attribute side-panel + model/selector fields; gated by the existing `DriverOperator` policy; manual entry retained)
**Steps (no unit test — Razor is live-verified in Task 21):**
1. Register the bespoke `IDriverBrowser` (the universal-browser DI line is NOT added for `Sql` — its `CanBrowse` is false and bespoke wins by construction).
2. `SqlAddressPickerBody.razor` — thin body: `DriverBrowseTree` in picker mode + attribute side-panel; on column pick, compose the `TagConfig` blob (DisplayName default `table.column`, prefill `type` from `AttributesAsync`, operator picks model + fills key/row selector; `SecurityClass=ViewOnly`).
3. `dotnet build` — expect PASS.
4. Commit: `git commit -m "feat(sql): AdminUI browser DI + Sql address picker body"`
---
## Task 16: Env-gated central-SQL integration fixture (`Driver.Sql.IntegrationTests`)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 13, Task 14, Task 15, Task 17
**Files:**
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests.csproj` (+ slnx entry)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlPollServerFixture.cs`
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlServerReadTests.cs`
**TDD:**
1. **Failing/skipping test** — against the always-on central SQL Server (`10.100.0.35,14330`), seed a `SqlPollFixture` database with the two sample tables (KeyValue + wide-row), then validate the real `Microsoft.Data.SqlClient` path (read round-trip, `INFORMATION_SCHEMA` browse, `CommandTimeout`/cancellation, pooling). **Env-gated** via `SQL_TEST_ENDPOINT` / a full `Sql__ConnectionStrings__Fixture` — absent ⇒ skip cleanly (use `Assert.Skip(...)` / xunit.v3 `[Fact(Skip=...)]` dynamic skip), like every other `*.IntegrationTests`:
```csharp
[Fact]
public async Task ReadAsync_realSqlServer_roundTripsSeededKeyValue()
{
var connStr = Environment.GetEnvironmentVariable("Sql__ConnectionStrings__Fixture");
Assert.SkipWhen(string.IsNullOrEmpty(connStr), "Sql__ConnectionStrings__Fixture not set — offline skip.");
await using var fx = await SqlPollServerFixture.EnsureSeededAsync(connStr!);
var driver = SqlDriver.ForProduction(connStr!, new SqlServerDialect(), fx.AuthoredRawTags);
await driver.InitializeAsync(fx.ConfigJson, default);
var snaps = await driver.ReadAsync(new[] { "Line1.Speed" }, default);
snaps[0].StatusCode.ShouldBe(0u);
}
```
2. Run offline — expect SKIP (green).
3. **Impl** — the fixture (create-if-missing DB + tables + seed rows via `Microsoft.Data.SqlClient`) and the read tests. **The seed DB is `SqlPollFixture`, NOT `ConfigDb`** — the shared central server hosts ConfigDb; the fixture uses its own database on that server.
4. Commit: `git commit -m "test(sql): env-gated central-SQL integration fixture + read round-trip"`
---
## Task 17: Injection regression test (bind-harmless value / reject identifier, never execute)
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 13, Task 14, Task 15, Task 16
**Files:**
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlInjectionRegressionTests.cs`
**TDD:**
1. **Failing test** (offline, SQLite fixture) — a malicious `keyValue` (`'; DROP TABLE TagValues; --`) reads harmlessly as a bound param and the seed table still exists afterward; a malicious `table`/`column` identifier that is not catalog-valid is REJECTED (tag → `BadNodeIdUnknown`), never executed:
```csharp
[Fact]
public async Task MaliciousKeyValue_bindsHarmlessly_tableSurvives()
{
await using var fx = await SqlitePollFixture.CreateAsync();
var reader = fx.NewReader();
var snaps = await reader.ReadAsync(new[] { fx.RefWithKeyValue("'; DROP TABLE TagValues; --") }, default);
snaps[0].StatusCode.ShouldBe(SqlStatusCodes.BadNoData); // no such key — bound, not executed
(await fx.TableRowCountAsync("TagValues")).ShouldBeGreaterThan(0); // table intact
}
```
2. Run — expect FAIL (until reader binds correctly; it should already PASS from Task 7 if binding is right — this test LOCKS the guarantee).
3. **Impl** — none if Task 7 binds correctly; otherwise fix. Add a review-checklist note: "any code path building SQL by string-concatenating a tag field is a defect."
4. Commit: `git commit -m "test(sql): injection regression — bind values, reject unknown identifiers"`
---
## Task 18: Blackhole / timeout live-gate against a DEDICATED mssql container
**Classification:** high-risk (frozen-peer resilience — the single highest-value integration test)
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/docker-compose.yml` (a dedicated disposable `mssql` stack)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/seed.sql`
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlBlackholeTimeoutTests.cs`
> **CRITICAL:** this gate `docker pause`s a **dedicated** `mcr.microsoft.com/mssql/server` container it owns — **NEVER** the shared central SQL Server on `10.100.0.35,14330`, which hosts `ConfigDb` for the whole rig (design §9). The compose stack is deployed via `lmxopcua-fix sync`; `lmxopcua-fix` applies the `project=lmxopcua` label host-side.
**TDD:**
1. **Failing/skipping test** — mirror the R2-01 S7 blackhole (`S7_1500ConnectTimeoutOutageTests`): start reading against the dedicated mssql, then `docker pause` it mid-poll; assert the next read surfaces `BadTimeout` within `operationTimeout` + a small margin (client-side linked-CTS cancellation, not the poll thread wedging), the driver degrades, and the poll loop backs off; `docker unpause` recovers. Env-gated (`SQL_BLACKHOLE_ENDPOINT`) — offline skip:
```csharp
[Fact]
public async Task PausedServer_surfacesBadTimeout_withinDeadline_notWedged()
{
var ep = Environment.GetEnvironmentVariable("SQL_BLACKHOLE_ENDPOINT");
Assert.SkipWhen(string.IsNullOrEmpty(ep), "SQL_BLACKHOLE_ENDPOINT not set — offline skip.");
// ... start driver, docker pause <dedicated-container>, read, assert BadTimeout within ~operationTimeout, unpause ...
}
```
2. Run offline — expect SKIP.
3. **Impl** — dedicated `docker-compose.yml` (`mssql` service, own container name, `restart: "no"`), `seed.sql` (the two sample tables), and the test that shells `docker pause`/`docker unpause` against the OWNED container. Assert the wall-clock bound (the frozen-peer contract): `CommandTimeout` (server-side backstop) + `ExecuteReaderAsync(linkedCt)` bounded by `operationTimeout` (client-side real cancellation) both fire.
4. Commit: `git commit -m "test(sql): blackhole/timeout live-gate on a dedicated mssql container"`
---
## Task 19: AdminUI typed editor model + validator (timeout-order rule, string enums)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 16, Task 17, Task 18
**Files:**
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/SqlTagConfigModel.cs`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs` (add `[DriverTypeNames.Sql] = typeof(...SqlTagConfigEditor)`)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs` (add `[DriverTypeNames.Sql] = j => SqlTagConfigModel.FromJson(j).Validate()`)
- Test: `tests/.../AdminUI.Tests/.../SqlTagConfigModelTests.cs` (locate the existing AdminUI tag-editor test project; mirror `ModbusTagConfigModel` tests)
**TDD:**
1. **Failing test**`FromJson``ToJson` round-trips the model, preserves unknown keys, writes enums (`model`/`type`) as NAME strings not numbers; `Validate()` rejects a WideRow without a row selector and enforces the driver-level timeout-order rule surrogate at the tag layer if the tag carries timeouts (the primary timeout-order check is at the driver page — the model validates model/selector shape):
```csharp
[Fact]
public void ToJson_writesModelAndTypeAsStrings_preservesUnknownKeys()
{
var m = SqlTagConfigModel.FromJson("""{"driver":"Sql","model":"KeyValue","table":"t","keyColumn":"k","keyValue":"v","valueColumn":"c","type":"Float64","customX":1}""");
var json = m.ToJson();
json.ShouldContain("\"KeyValue\"");
json.ShouldContain("\"Float64\"");
json.ShouldContain("customX"); // unknown key survives load→save
json.ShouldNotContain("\"model\":0");
}
```
2. Run — expect FAIL.
3. **Minimal impl**`SqlTagConfigModel` (thin, over `TagConfigJson.ParseOrNew`, preserving the `_bag`): `Model`, `Table`, `KeyColumn`/`KeyValue`/`ValueColumn`/`TimestampColumn`, `ColumnName` + `RowSelector`, `Type` (`DriverDataType?`), `FromJson`/`ToJson` (enums as name strings via `TagConfigJson.Set`) `/Validate` (model discriminator ⇒ required-field shape; WideRow needs a selector). Register in `TagConfigEditorMap` + `TagConfigValidator`. **Enum-serialization trap (systemic):** `model`/`type` MUST round-trip as strings on both sides — the editor `ToJson` and the factory `SqlEquipmentTagParser` both use string enums; add the assertion above.
4. Run — expect PASS.
5. Commit: `git commit -m "feat(sql): typed AdminUI Sql tag-config model + validator (string enums)"`
---
## Task 20: `SqlTagConfigEditor.razor` (thin shell over the model)
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/SqlTagConfigEditor.razor`
**Steps (Razor — live-verified in Task 21):**
1. Copy the Modbus editor template; bind over `SqlTagConfigModel`: a `Model` dropdown (KeyValue/WideRow) that shows the matching field group, `Table`, the key/value/timestamp fields (KeyValue) OR `ColumnName` + row-selector fields (WideRow), and a `Type` override dropdown. `@bind` string component params need the `@` prefix (the Global-UNS gotcha).
2. `dotnet build` — expect PASS.
3. Commit: `git commit -m "feat(sql): SqlTagConfigEditor razor shell"`
---
## Task 21: Live `/run` verification on docker-dev (picker + editor + deploy + read)
**Classification:** trivial (procedure — no product code; may surface fix-forward tasks)
**Estimated implement time:** ~5 min (verification, excluding any bugs found)
**Parallelizable with:** none (last)
**Files:** none (verification only; any defect becomes a new fix-forward task)
**Procedure (docker-dev rig, AdminUI auto-authenticated at `http://localhost:9200` — login disabled, so drive it yourself, don't defer to the user):**
1. Set `Sql__ConnectionStrings__DevSql` on the docker-dev central + driver nodes pointing at a dev SQL Server + seeded table (the dedicated mssql from Task 18, or the central-SQL fixture DB). Rebuild BOTH central-1/central-2 (`:9200` round-robins).
2. In `/raw`, add a `Sql` driver + device (paste a literal connection string or use the ref); Test-Connect → green (`SELECT 1`).
3. Open the Sql address picker → browse schema→table→column → commit a KeyValue tag and a WideRow tag; confirm the typed editor renders and validates.
4. Reference the raw tags into an equipment on `/uns`; deploy via `POST :9200/api/deployments` (X-Api-Key) or the deploy button; confirm the deployment seals green.
5. Read via Client.CLI (`read -u opc.tcp://localhost:4840 -n "ns=2;s=<RawPath>"`) — confirm the live SQL value + quality + source timestamp.
6. Record the run outcome in the plan's completion note. If anything is deploy-inert / mis-bound, open a fix-forward task (Razor binding + deploy-inertness bugs pass unit tests + review — this live gate is the real proof).
---
## Deferred / out of scope (pointers to the design, NOT executable tasks here)
These are recorded so nobody re-derives them; each is a later phase in `docs/plans/2026-07-15-sql-poll-driver-design.md`. The `ISqlDialect` seam is built in from day one (Task 4) precisely so the provider tail below is additive and cheap.
- **Named-query model** (design §5.4, §3.6(c) / design-P3) — the arbitrary-`SELECT` escape hatch (`queries: {...}` + a tag referencing a query by name + projection). Not built in v1; the `SqlTagModel.Query` enum member exists but the reader/planner path is deferred.
- **PostgreSQL + ODBC dialects** (design §2.2, §8.5 / design-P3) — `PostgresDialect` (`Npgsql`) + `OdbcDialect` (`System.Data.Odbc`), each a NEW gated `PackageVersion`. v1 constructs only `SqlServerDialect`; the other `SqlProvider` members throw "provider not available in this build."
- **MySQL/MariaDB + native Oracle** (design §10 / design-P5) — `MySqlConnector` + an `ALL_TAB_COLUMNS` Oracle dialect. Demand-driven.
- **Write mode (`IWritable`)** (design §3.4 / design-P4) — opt-in, triple-gated (`WriteOperate` role + driver `allowWrites` master switch + `TagConfig.writable`), parameterized UPSERT/StoredProc, `WriteIdempotent` retry policy. v1 is read-only; `SecurityClass=ViewOnly` everywhere and `allowWrites` defaults `false`.
- **Split-role env-parity operational note** (design §4.4) — admin nodes must carry the same `Sql__ConnectionStrings__<ref>` env vars as driver nodes for any browseable ref; the browser-open failure names the exact missing env var. This is a deployment/runbook item, not a code task.