# SQL Poll driver — design research

> **Status:** research / not-yet-approved. Author: driver research sweep, 2026-07-15.
> **Scope:** a new **`SqlPoll`** Equipment-kind driver that reads values from arbitrary SQL
> databases (MES/ERP staging tables, historians, custom app DBs) and surfaces them as OPC UA
> variable nodes — same shape as the existing `Modbus` / `S7` / `OpcUaClient` drivers.
> **Grounding:** `CLAUDE.md`, `IDriver.cs` + capability neighbours, `Driver.Modbus/` (polling
> template), `Commons/Browsing/` (browse seam), `docs/plans/2026-05-28-driver-browsers-design.md`.

---

## 0. TL;DR verdicts

| Question | Verdict |
|---|---|
| **Browseable?** | **YES.** SQL is self-describing via `INFORMATION_SCHEMA` (databases/schemas → tables/views → columns + types). A full `IDriverBrowser` / `IBrowseSession` is natural. |
| **Provider ship-first?** | **SQL Server** (`Microsoft.Data.SqlClient` — already in the dependency graph at 6.1.1), behind an ADO.NET `DbProviderFactory` abstraction. PostgreSQL (`Npgsql`) second, ODBC (`System.Data.Odbc`) as the universal fallback, MySQL/Oracle later. |
| **Write in v1?** | **NO — read-only v1.** Writing back to MES/ERP staging is operationally risky; ship an *optional*, opt-in, per-tag parameterized-UPSERT write mode as a later phase (design sketched in §2.4). |
| **Top risks** | (1) **SQL injection** — every identifier and value must be parameterized / allow-listed, never string-concatenated; (2) **secret handling** — connection-string credentials must come from env/secret store, never committed (mirror `ServerHistorian__ApiKey`). |

---

## 1. Design summary + provider strategy

### 1.1 Shape

`SqlPoll` is a standard Equipment-kind driver: a `SqlPollDriver : IDriver, ITagDiscovery,
IReadable, ISubscribable, IHostConnectivityProbe` living in
`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.SqlPoll/`, with a `.Contracts` project for the options +
equipment-tag parser, and a `.Browser` project for the AdminUI address picker. It has **no native
push model** — subscriptions overlay a polling loop via the shared `PollGroupEngine`
(`src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/PollGroupEngine.cs`), exactly as Modbus/S7/AB-CIP do.
`IWritable` and `IAlarmSource`/`IHistoryProvider` are **out of scope for v1** (writes deferred;
alarms/history aren't expressible from a poll query).

Points are ordinary equipment `Tag`s bound to the driver via `TagConfig.FullName`; reads/subscribes
resolve `FullName` to a tag definition through the shared `EquipmentTagRefResolver<TDef>`
(`Core.Abstractions/EquipmentTagRefResolver.cs`) — same two-model bridge Modbus uses (an authored
tag-table entry by name **or** an equipment tag whose reference is its raw `TagConfig` JSON,
parsed once and cached).

### 1.2 Provider abstraction

Use ADO.NET's provider-agnostic base types (`System.Data.Common`: `DbConnection`,
`DbCommand`, `DbParameter`, `DbDataReader`) obtained through a `DbProviderFactory`. In .NET
(Core/5+) there is no `machine.config`/GAC provider registry, so factories are registered in code
via `DbProviderFactories.RegisterFactory(invariantName, factory.Instance)` and each provider exposes
a static `Instance` singleton (`SqlClientFactory.Instance`, `NpgsqlFactory.Instance`,
`OdbcFactory.Instance`, `OracleClientFactory.Instance`, `MySqlConnectorFactory.Instance`)
([MS Learn: Obtain a SqlClientFactory](https://learn.microsoft.com/en-us/sql/connect/ado-net/obtain-sqlclientfactory),
[MS Learn: Obtaining a DbProviderFactory](https://learn.microsoft.com/en-us/dotnet/framework/data/adonet/obtaining-a-dbproviderfactory)).

**Recommended approach — a small provider enum mapped to a factory**, not open-ended invariant
strings, because two provider-specific concerns leak past the ADO.NET base types and *must* be
dispatched per-provider:

1. **Identifier quoting** for the browse layer and any generated SQL —
   `[SqlServer]` / `"Postgres"` / `` `MySql` `` / `"Oracle"`.
2. **Parameter marker syntax** — SQL Server & MySQL use `@p`, Npgsql uses `@p` or `:p`, Oracle
   uses `:p`, ODBC uses positional `?`. A `SqlDialect` abstraction owns quoting + parameter
   naming + the metadata-catalog query (see §4).

```csharp
public enum SqlProvider { SqlServer, Postgres, MySql, Odbc, Oracle }

internal interface ISqlDialect
{
    DbProviderFactory Factory { get; }
    string QuoteIdentifier(string ident);          // [x] / "x" / `x`
    string ParameterMarker(int ordinal);           // @p0 / :p0 / ?
    string ListColumnsSql(string? catalog);        // metadata catalog query (browse)
    string ListTablesSql(string? catalog);
}
```

**Ship-first recommendation: SQL Server only.** `Microsoft.Data.SqlClient` is already a repo
dependency (`Directory.Packages.props` line 53, `6.1.1`), the shared central test SQL Server is
already on the docker host (§5), and SQL Server is the dominant MES/historian staging backend in
this AVEVA/Wonderware estate (the Galaxy Repository and the HistorianGateway both sit on SQL Server).
Phase 2 adds **PostgreSQL** (`Npgsql`) and **ODBC** (`System.Data.Odbc`, the universal fallback that
reaches any DSN — DB2, Oracle via the Oracle ODBC driver, Access, etc.). MySQL/MariaDB
(`MySqlConnector`, whose `MySqlConnectorFactory` is fully async-correct, unlike Oracle's official
`MySql.Data`) and native Oracle (`Oracle.ManagedDataProvider`) are later, demand-driven additions.

`Npgsql`, `System.Data.Odbc`, and `Oracle.ManagedDataAccess.Core` are **not** yet in
`Directory.Packages.props` — each is a new `PackageVersion` entry gated behind its phase, so the
ship-first SQL-Server slice adds **zero** new NuGet dependencies.

---

## 2. Capability mapping

### 2.1 Connect — `InitializeAsync`

- Deserialize `SqlPollDriverOptions` from `DriverConfig` JSON (via the factory extensions, mirroring
  `ModbusDriverFactoryExtensions.CreateInstance`). Resolve the connection string (§6 — from env/secret,
  never the committed JSON). Select the `ISqlDialect` from `options.Provider`.
- **Validate** by opening one `DbConnection` and running a cheap liveness query
  (`SELECT 1` — portable across SQL Server/Postgres/MySQL; Oracle needs `SELECT 1 FROM DUAL`, so the
  liveness probe is a dialect method). Set `DriverHealth` = `Healthy` on success, `Faulted` on failure
  (same state machine as `ModbusDriver.InitializeAsync`).
- **Pooling:** rely on ADO.NET's built-in connection pooling (keyed by exact connection-string text).
  Do **not** hold one long-lived connection; open-use-dispose per poll pass so the pool manages
  lifetime and a dropped server recovers transparently. Expose `Max Pool Size` etc. only via the
  connection string.
- **`ReinitializeAsync`** = teardown + init (Modbus pattern). No live sockets to preserve.

### 2.2 Read / Subscribe — poll queries, **one query per table/group**

The whole efficiency story is **batching by query-group, not per-tag**. A naive "one SELECT per
tag" hammers the DB; instead the driver coalesces tags that share a source query and issues **one
round-trip per group per poll**, then slices the result back to per-tag snapshots — structurally the
same idea as `ModbusDriver.ReadCoalescedAsync` (group → single PDU → slice back).

`IReadable.ReadAsync(fullReferences)` groups the referenced tags by their **query group key**
(model-dependent, see §2.5), executes each group's query once, and maps rows/columns back to the
`DataValueSnapshot[]` in input order. `ISubscribable` delegates to `PollGroupEngine` with the same
`ReadAsync` as the reader delegate — the engine owns the loop, the 100 ms interval floor, capped-
exponential failure backoff, and change-diffing. Route poll-loop failures to `DriverHealth` via the
engine's `onError` sink + `PollBackoffCap` (Modbus/S7 precedent, 05/STAB-8/9).

**Poll interval** is driver-level (`DefaultPollInterval`, default e.g. 5 s) with an optional per-tag
or per-group override; the OPC UA subscription's requested publishing interval is clamped up to the
group's configured floor by `PollGroupEngine`.

**Per-call deadline (mandatory).** Every `DbCommand` must set `CommandTimeout` **and** run under a
linked `CancellationTokenSource` bounded by an `OperationTimeout` option. This is the SQL analogue of
the R2-01 S7 blackhole finding: a frozen DB server (network partition, lock wait, slow query) must
surface `BadTimeout`/`Degraded` and let the poll loop back off — **not** wedge the poll thread
forever. Prefer `DbCommand.ExecuteReaderAsync(ct)` with the linked token so cancellation is real, and
still set `CommandTimeout` as a server-side backstop.

### 2.3 Discover — offline / config-driven

Like Modbus, SQL has **no runtime discovery obligation**: `DiscoverAsync` materializes exactly the
authored tags (the `Tags` table in options, plus equipment tags contributed at deploy time). It does
**not** enumerate the DB schema at discovery — schema enumeration is the *browse* concern (§4), an
interactive AdminUI operation, not a deploy-time one. `RediscoverPolicy = Once`.

### 2.4 Write — verdict: **read-only v1**, optional write mode later

**v1 does not implement `IWritable`.** Rationale: MES/ERP/historian staging tables are frequently
downstream-of-record systems where an unexpected `UPDATE`/`INSERT` can corrupt a batch record,
double-post a transaction, or trip a trigger with side effects. A read-only integration is the safe,
common case and removes an entire class of risk from the first ship.

**Optional Phase-N write mode** (opt-in per driver *and* per tag): a tag may carry a
`writeCommand` describing a **parameterized** statement — never generated by string concatenation:

```jsonc
"write": {
  "mode": "Update",                                  // Update | Insert | Upsert | StoredProc
  "sql": "UPDATE dbo.Setpoints SET value=@value, ts=@ts WHERE tag_name=@key",
  "keyParam": "@key",   "keyValue": "Line1.Speed",
  "valueParam": "@value"
}
```

Writes route through `IWritable.WriteAsync` → the tag's parameterized command with the OPC UA
write value bound to `@value` (typed via the tag's declared type). Gate at three layers: (a) the OPC
UA node's `WriteOperate` role (standard `NodeWriteRouter`), (b) a driver-level `AllowWrites` master
switch (default `false`), (c) `TagConfig.writable`. Writes are **not** auto-retried unless the tag is
flagged `WriteIdempotent` (an `UPDATE ... WHERE key=` set-value is idempotent; an `INSERT` / counter
`UPDATE x=x+1` is not — mirror `DriverAttributeInfo.WriteIdempotent`). Because a failed inbound
device write reverts the node to its prior value (the write-outcome self-correction path,
master `1d797c1c`), SQL writes — unlike Galaxy's fire-and-forget — *can* surface a genuine failure,
which is the correct, honest behaviour.

### 2.5 Tag→value mapping models

Support **two** models in v1 (both cover the vast majority of MES/ERP staging shapes), plus an
escape hatch:

1. **Key-value / EAV table** *(primary — ship first).* A table of `(tagname, value, timestamp)`
   rows; a tag names a `keyColumn`+`keyValue`, a `valueColumn`, and an optional `timestampColumn`.
   **Group key = the table.** One `SELECT keyColumn, valueColumn, timestampColumn FROM table WHERE
   keyColumn IN (@k0,@k1,…)` per poll returns every subscribed tag on that table in one round-trip;
   the driver indexes rows by key and slices back. (Values `IN (@params)` — never interpolated.)

2. **Wide row** *(ship first, cheap).* One "latest status" row, many columns, each column a tag. A
   tag names a `table`, a `columnName`, and a row selector (`WHERE id=@id`, or "top 1 by timestamp").
   **Group key = (table, row-selector).** One `SELECT col_a, col_b, … FROM table WHERE …` per poll
   maps each selected column to its tag.

3. **Arbitrary parameterized SELECT** *(escape hatch, per named query group).* Named query definitions
   at the driver level (`queries: { "q1": { sql, params } }`); a tag references a query by name +
   a projection (which output column / which row-key). **Group key = the named query.** This covers
   joins, views, and computed projections the two structured models can't express. The SQL is authored
   by an operator with `ConfigEditor` rights and stored server-side — it is *config*, not user input —
   but it still binds only parameters, never interpolated tag values.

### 2.6 Type mapping (SQL → OPC UA) + timestamps

Map the column's provider metadata (`DbColumn.DataType` from `DbDataReader.GetColumnSchema()`, or the
`INFORMATION_SCHEMA` `DATA_TYPE`) to the driver-agnostic `DriverDataType`
(`Core.Abstractions/DriverDataType.cs`), which the address-space layer maps to OPC UA built-ins:

| SQL type (family) | CLR (`DbDataReader`) | `DriverDataType` |
|---|---|---|
| `bit` / `boolean` | `bool` | `Boolean` |
| `tinyint`/`smallint` | `byte`/`short` | `Int16` |
| `int`/`integer` | `int` | `Int32` |
| `bigint` | `long` | `Int64` |
| `real`/`float(24)` | `float` | `Float32` |
| `float`/`double`/`decimal`/`numeric`/`money` | `double`/`decimal`→`double` | `Float64` |
| `char`/`varchar`/`nvarchar`/`text` | `string` | `String` |
| `date`/`datetime`/`datetime2`/`timestamptz` | `DateTime`/`DateTimeOffset` | `DateTime` |
| `uniqueidentifier`/`uuid` | `Guid` | `String` |

`decimal`/`numeric` collapse to `Float64` (v1) with a documented precision-loss caveat; a tag may
override the inferred type explicitly (`"type": "Int32"`) for cases where the operator knows better
than the column metadata (e.g. a `varchar` column holding a number). `NULL` → `DataValueSnapshot`
with `Value = null` and a `Good`/`Uncertain` status per an option (`NullIsBad`, default treat as
`GoodNoData`-ish `Uncertain`).

**Source timestamp:** if a tag declares a `timestampColumn`, that column's value becomes the
`DataValueSnapshot.SourceTimestamp`; otherwise the poll-read wall-clock is used for both source and
server timestamps (the Modbus behaviour). A source timestamp is what makes SQL-poll data honest about
staleness — strongly recommend authoring one wherever the staging table has it.

---

## 3. TagConfig + driver-config JSON shape

### 3.1 Driver config (the `DriverConfig` blob)

```jsonc
{
  "provider": "SqlServer",
  // Connection string is resolved at Initialize from an env/secret ref — NOT stored here.
  // Option A (recommended): a named ref the Host resolves from env:
  "connectionStringRef": "SqlPoll_MesStaging",     // => env SqlPoll__ConnectionStrings__MesStaging
  "defaultPollInterval": "00:00:05",
  "operationTimeout": "00:00:15",                  // per-query wall-clock deadline (BadTimeout)
  "commandTimeout": "00:00:10",                    // server-side CommandTimeout backstop
  "allowWrites": false,                            // master write kill-switch (v1: always false)
  "probe": { "enabled": true, "interval": "00:00:10" },

  // Optional pre-declared tag table (analogous to Modbus "Tags"); equipment tags may also
  // contribute their address as raw TagConfig JSON at deploy time.
  "tags": [ /* see 3.2 */ ],

  // Optional named queries for the arbitrary-SELECT model.
  "queries": {
    "activeBatch": {
      "sql": "SELECT line, speed, temp, ts FROM dbo.LineStatus WHERE active=@active",
      "params": { "@active": true }
    }
  }
}
```

### 3.2 Per-tag TagConfig — **key-value model**

```jsonc
{
  "driver": "SqlPoll",
  "model": "KeyValue",
  "table": "dbo.TagValues",
  "keyColumn": "tag_name",
  "keyValue": "Line1.Speed",
  "valueColumn": "num_value",
  "timestampColumn": "sample_ts",
  "type": "Float64",                 // optional explicit override of inferred type
  "writable": false
}
```
Group key for batching = `("dbo.TagValues", keyColumn, valueColumn, timestampColumn)`; all such tags
fold into one `… WHERE tag_name IN (@k0,@k1,…)` per poll.

### 3.3 Per-tag TagConfig — **wide-row model**

```jsonc
{
  "driver": "SqlPoll",
  "model": "WideRow",
  "table": "dbo.LatestStatus",
  "columnName": "oven_temp",
  "rowSelector": { "whereColumn": "station_id", "whereValue": 7 },
  // or "rowSelector": { "topByTimestamp": "sample_ts" }  // newest row
  "type": "Float64",
  "writable": false
}
```
Group key = `("dbo.LatestStatus", rowSelector)`; every wide-row tag on the same row folds into one
`SELECT oven_temp, <other cols>, … FROM dbo.LatestStatus WHERE station_id=@id`.

### 3.4 Per-tag TagConfig — **named-query model**

```jsonc
{
  "driver": "SqlPoll",
  "model": "Query",
  "query": "activeBatch",
  "rowKey": { "column": "line", "value": "Line1" },  // which row this tag reads
  "column": "speed",                                  // which projected column is the value
  "timestampColumn": "ts"
}
```

The equipment-tag parser (`SqlPollEquipmentTagParser`, in `.Contracts`) recognizes the blob by a
leading `{` + a `"driver":"SqlPoll"` (or a `"model"` discriminator) and maps it to a transient
`SqlPollTagDefinition` whose `Name` == the reference string — exactly the `ModbusEquipmentTagParser`
pattern, so the value the driver publishes keys the forward router correctly.

---

## 4. Browseability verdict + browse design

### 4.1 Verdict: **YES — SQL is browseable via its metadata catalog.**

Relational databases are self-describing. The picker walks the catalog three levels deep:

- **`RootAsync`** → databases / schemas (Folder nodes). SQL Server: databases (`sys.databases`) then
  schemas; Postgres: databases then schemas; MySQL: schemas (== databases). For SQLite: the single
  main database (Folder).
- **`ExpandAsync(schemaNodeId)`** → tables + views in that schema (Folder nodes).
- **`ExpandAsync(tableNodeId)`** → columns (Leaf nodes).
- **`AttributesAsync(columnNodeId)`** → column data type + nullability + (for key-value tables) a
  hint panel. This mirrors Galaxy's two-stage attribute side-panel: pick a table (Folder), then pick
  the column/key that becomes the tag.

### 4.2 Portability caveat — the metadata query is dialect-specific

`INFORMATION_SCHEMA` is the SQL-92 standard catalog and is supported by **SQL Server, PostgreSQL, and
MySQL/MariaDB** — so `SELECT TABLE_SCHEMA, TABLE_NAME FROM INFORMATION_SCHEMA.TABLES` and
`… COLUMN_NAME, DATA_TYPE, IS_NULLABLE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME=@t` are portable
across those three. But **two important backends do not implement `INFORMATION_SCHEMA`:**

- **Oracle** uses `ALL_TABLES` / `ALL_TAB_COLUMNS` (and `USER_`/`DBA_` variants) instead
  ([Oracle: ALL_TAB_COLUMNS](https://docs.oracle.com/en/database/oracle/oracle-database/19/refrn/ALL_TAB_COLUMNS.html),
  [dataedo: what supports INFORMATION_SCHEMA](https://dataedo.com/kb/databases/all/information_schema)).
- **SQLite** uses `sqlite_schema`/`sqlite_master` + `PRAGMA table_info(<table>)`
  ([SQLite: the schema table](https://sqlite.org/schematab.html)).

So the catalog query **belongs on `ISqlDialect`** (`ListTablesSql` / `ListColumnsSql`), not hardcoded.
The ship-first SQL-Server dialect uses `INFORMATION_SCHEMA`; Postgres/MySQL reuse it; the SQLite
fixture dialect (§5) uses `PRAGMA`; Oracle (later) uses `ALL_TAB_COLUMNS`. A cleaner but slower
alternative that avoids catalog SQL entirely for the *column* level is
`DbDataReader.GetColumnSchema()` on a `SELECT * … WHERE 1=0` (`SchemaOnly`) command — but it still
needs catalog SQL for the table list, so the dialect method stays.

### 4.3 Picked-column → TagConfig

On column pick, the browser emits a `BrowseNode.NodeId` that encodes
`provider|schema.table|column`, and the picker body composes the `TagConfig` blob (§3.2/3.3):
DisplayName defaults to `table.column`, the inferred `DriverDataType` from `AttributesAsync` prefills
`type`, and the operator chooses the model (key-value vs wide-row) + fills the key/row selector.
`AttributeInfo.DriverDataType` carries the SQL type; `SecurityClass` = `ViewOnly` in read-only v1.

### 4.4 New `.Browser` project layout

Follow the exact `OpcUaClient.Browser` template (`docs/plans/2026-05-28-driver-browsers-design.md`):

| Path | Purpose |
|---|---|
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.SqlPoll.Browser/SqlPollDriverBrowser.cs` | `IDriverBrowser`, `DriverType => "SqlPoll"`; opens a `DbConnection` from form JSON (transient) |
| `src/Drivers/…SqlPoll.Browser/SqlPollBrowseSession.cs` | `IBrowseSession`; `RootAsync`/`ExpandAsync`/`AttributesAsync` over the dialect's catalog queries; `SemaphoreSlim` gate (ADO.NET connection not concurrent) |
| register in `AdminUI/EndpointRouteBuilderExtensions.cs` | `services.AddSingleton<IDriverBrowser, SqlPollDriverBrowser>();` (next to the OpcUaClient/Galaxy lines, `:49-50`) |
| `AdminUI/Uns/TagEditors/TagConfigEditorMap.cs` | add `["SqlPoll"] = typeof(…SqlPollTagConfigEditor)` + `TagConfigValidator` entry (typed editor; else it falls to the raw-JSON textarea like Galaxy) |

The browser reuses the AdminUI `BrowseSessionRegistry` + reaper + 2-min idle TTL + per-call timeout
unchanged. Form JSON contains the connection secret (same trust boundary as `TestDriverConnect` and
the OpcUaClient browser) — deserialize → build connection → release, never cache the JSON.

---

## 5. Test-fixture strategy

- **SQLite in-memory / file** — the *unit-test* fixture. `Microsoft.Data.Sqlite` (already in-repo at
  10.0.7) needs no container; seed a `(tag_name, num_value, sample_ts)` key-value table and a
  wide-row `LatestStatus` table in-process. Covers read/subscribe mapping, type mapping, group-batch
  slicing, the poll engine wiring, and the browser over `PRAGMA table_info`. Fast, offline, CI-safe on
  macOS — the primary suite.
- **Central SQL Server on the docker host** (`10.100.0.35,14330`, always-on per `CLAUDE.md`) — the
  *integration* fixture. A seeded `SqlPollFixture` database with the same two sample tables validates
  the real `Microsoft.Data.SqlClient` path, `INFORMATION_SCHEMA` browse, `CommandTimeout`/cancellation,
  and connection pooling. Env-gated (`SQLPOLL_TEST_ENDPOINT`) so it skips cleanly offline, like the
  other `*.IntegrationTests`. Deploy the seed via the docker rig; label the stack `project=lmxopcua`.
- **Postgres/ODBC fixtures** land with their respective phases (a `postgres:16` container on the
  docker host for Npgsql; an ODBC DSN against the same SQL Server for the `System.Data.Odbc` path).
- **Blackhole / timeout live-gate** (mirrors R2-01 S7): `docker pause` the SQL container mid-poll and
  assert the read surfaces `BadTimeout` + the driver degrades + the poll loop backs off, *not* a wedged
  thread. This is the single most important integration test given the frozen-peer risk class.

---

## 6. Security — secret handling

Connection-string credentials are the crown jewels and **must never be committed**. Mirror the
repo's existing pattern for `ServerHistorian:ApiKey` (supplied via env `ServerHistorian__ApiKey`,
appsettings carries only a `_comment` reminder) and the ConfigDb connection string (env-overridable):

- The `DriverConfig` JSON stores a **`connectionStringRef`** (a logical name), **not** the connection
  string. The Host resolves it from configuration/env at `InitializeAsync`
  (`SqlPoll__ConnectionStrings__<ref>`), so the secret lives in the process environment / secret store,
  never in the config DB row or any committed file.
- If an inline `connectionString` is permitted at all (dev convenience), the AdminUI must redact it
  in display/logging and it must be flagged as dev-only. **Never log the resolved connection string**
  (Serilog); log only the provider + server host + database name.
- Prefer integrated/managed auth where the estate supports it (`Integrated Security=true` /
  Azure AD / Kerberos) so no password transits config at all.
- The `.Browser` path receives the secret in form JSON over the authenticated Blazor circuit (same
  boundary as every other driver browser + `TestDriverConnect`); it is used to build one transient
  connection and then released — no `_lastConfigJson` field anywhere.

---

## 7. Effort, risk, phasing

### 7.1 Key risks

1. **SQL injection (critical).** Every value is a bound `DbParameter`; identifiers (table/column/
   schema) that must appear in SQL text come **only** from `INFORMATION_SCHEMA`-validated,
   dialect-`QuoteIdentifier`'d names or an allow-list derived from browse — never from free-form tag
   input concatenated into a query. The named-query model stores operator-authored SQL as *config*
   (ConfigEditor-gated), and even there only parameters bind runtime values. Treat any code path that
   builds SQL by string concatenation of a tag field as a defect.
2. **Secret handling (critical).** Per §6 — env/secret-store only, redact in UI/logs, never commit.
3. **Frozen-peer / timeout wedge (high).** A slow/blocked DB must time out per-query and back off
   (the R2-01 lesson) — `CommandTimeout` + linked-CTS `OperationTimeout` on every command; assert it
   in the blackhole live-gate.
4. **Connection pooling / exhaustion (medium).** Open-use-dispose per poll; never leak a `DbConnection`
   or `DbDataReader` (always `await using`); a group query that fans out to many tables must not open
   an unbounded number of concurrent connections — cap group-parallelism (Modbus/S7 precedent).
5. **Provider portability (medium).** `INFORMATION_SCHEMA` covers SQL Server/Postgres/MySQL but *not*
   Oracle/SQLite (§4.2); parameter markers and identifier quoting differ. The `ISqlDialect` seam
   contains all of it — do not let dialect assumptions leak into the driver core.
6. **Type / precision (low-medium).** `decimal`→`Float64` precision loss; `NULL` semantics; timezone
   handling on `datetime` vs `datetimeoffset`. Documented + per-tag `type` override.

### 7.2 Suggested phasing

1. **Phase 1 — SQL Server read-only, key-value + wide-row.** `.Contracts` (options + equipment-tag
   parser), `SqlPollDriver` (`IDriver`/`ITagDiscovery`/`IReadable`/`ISubscribable` via `PollGroupEngine`),
   `SqlServerDialect`, factory registration, `SqlPollTagConfigEditor` + validator, SQLite unit fixture,
   SQL-Server integration fixture (env-gated) + blackhole live-gate. No new NuGet deps.
2. **Phase 2 — Browse.** `SqlPoll.Browser` project (`IDriverBrowser`/`IBrowseSession` over
   `INFORMATION_SCHEMA`), AdminUI picker body + registry wire-up, column→TagConfig commit.
3. **Phase 3 — Named-query model + PostgreSQL + ODBC.** `PostgresDialect`/`OdbcDialect`, add `Npgsql`
   + `System.Data.Odbc` `PackageVersion`s, Postgres container fixture.
4. **Phase 4 (optional) — Write mode.** `IWritable` with per-tag parameterized UPSERT/StoredProc,
   triple-gated (`WriteOperate` role + `AllowWrites` + `TagConfig.writable`), `WriteIdempotent` retry
   policy, write-failure revert (already provided by the runtime).
5. **Phase 5 (demand-driven) — MySQL/MariaDB (`MySqlConnector`) + native Oracle** (`ALL_TAB_COLUMNS`
   dialect).

### 7.3 Effort estimate

Phase 1 ≈ the Modbus driver minus the wire-protocol codec complexity (no byte-order/register math) but
plus the dialect seam and result-set slicing — **medium**, most of it reusable from Modbus (poll engine,
resolver, health state machine, factory shape are all shared). Phase 2 (browse) is **small** — a near-
mechanical clone of `OpcUaClient.Browser` with a different one-level expansion. The provider/dialect
work (Phase 3+) is where the long tail of portability effort lives; keeping it behind `ISqlDialect`
from day one is what makes the ship-first SQL-Server slice cheap and the rest additive.

---

## Sources

- [MS Learn — Obtain a SqlClientFactory (RegisterFactory in .NET Core, `SqlClientFactory.Instance`)](https://learn.microsoft.com/en-us/sql/connect/ado-net/obtain-sqlclientfactory)
- [MS Learn — Obtaining a DbProviderFactory (`DbProviderFactories`, provider `Instance` pattern)](https://learn.microsoft.com/en-us/dotnet/framework/data/adonet/obtaining-a-dbproviderfactory)
- [Oracle — ALL_TAB_COLUMNS (Oracle has no INFORMATION_SCHEMA)](https://docs.oracle.com/en/database/oracle/oracle-database/19/refrn/ALL_TAB_COLUMNS.html)
- [dataedo — What is INFORMATION_SCHEMA and which databases support it](https://dataedo.com/kb/databases/all/information_schema)
- [SQLite — The Schema Table (`sqlite_schema`/`PRAGMA table_info`)](https://sqlite.org/schematab.html)
- In-repo grounding: `IDriver.cs`, `ISubscribable.cs`, `PollGroupEngine.cs`, `DriverDataType.cs`,
  `Driver.Modbus/ModbusDriver.cs` + `.Contracts`, `Commons/Browsing/*`,
  `Driver.OpcUaClient.Browser/*`, `AdminUI/EndpointRouteBuilderExtensions.cs`,
  `AdminUI/Uns/TagEditors/TagConfigEditorMap.cs`, `docs/plans/2026-05-28-driver-browsers-design.md`,
  `Directory.Packages.props` (Microsoft.Data.SqlClient 6.1.1, Microsoft.Data.Sqlite 10.0.7).
