# 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`, 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: ` (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 net10.0 enable enable true true $(NoWarn);CS1591 ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts ``` 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 1–2 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; /// Which ADO.NET provider/dialect backs a Sql driver instance. v1 constructs only SqlServer. public enum SqlProvider { SqlServer, Postgres, MySql, Odbc, Oracle } /// Tag→value mapping model. v1 ships KeyValue + WideRow; Query is deferred (design §5.4 / P3). 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(() => 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 ... 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 Parameters, IReadOnlyList Members, Func }` (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 ,[,] FROM WHERE IN (@k0..@kN)` with distinct-key params; WideRow → GroupKey `(table, rowSelector)`, emit `SELECT [,] FROM
WHERE =@w` (or `ORDER BY DESC` + dialect TOP-1 for `topByTimestamp`). Identifiers via `dialect.QuoteIdentifier`; `
` 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 ``. 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(...)`; `_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__`; **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(() => 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());` 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(() => 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__`) 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();` 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 , 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="`) — 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__` 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.