docs: driver-expansion program — 8 research reports + 7 design docs, parallel-reviewed
v2-ci / build (push) Successful in 3m14s
v2-ci / unit-tests (push) Failing after 9m13s

Adds the driver-expansion program design (umbrella: universal Discover-backed
browser + MTConnect, MQTT/Sparkplug B, BACnet/IP, SQL poll, Omron, Modbus RTU;
MELSEC deferred) plus the per-driver research reports.

All docs went through a 7-agent parallel review against the codebase before
this commit. Highlights fixed in review:

- universal browser: FOCAS FixedTree fills post-connect -> UntilStable settle
  + FixedTree.Enabled patch; MQTT reconciled to bespoke (was contradicting the
  program doc's SupportsOnlineDiscovery=false verdict)
- modbus-rtu: SerialPort.ReadTimeout doesn't bound async BaseStream reads ->
  linked-CTS per-op deadline (R2-01 class); BCL enum reuse would leak
  System.IO.Ports into Contracts
- bacnet: DiscoveryRediscoverPolicy enum name; UDP 47808 contention; live
  suite rewritten around unicast Who-Is + BBMD (broadcast doesn't cross VMs)
- sql-poll: real tier registration via DriverFactoryRegistry.Register;
  blackhole gate must not docker-pause the shared central SQL Server
- mqtt: Sparkplug v3.0 STATE topic form; first-in-repo proto codegen noted
- omron: host hardcodes isIdempotent:false today (retry seam unshipped);
  v1 scopes UDTs to dotted-leaf access
- mtconnect: SecurityClassification.ViewOnly; factory ParseEnum<T> pattern
- program doc: both valid enum-serialization patterns; IRediscoverable is
  change-signal-gated; RTU P2 adds System.IO.Ports; label is host-side
This commit is contained in:
Joseph Doherty
2026-07-15 16:40:36 -04:00
parent 37cdbef7a5
commit 8fc147d8d4
17 changed files with 6408 additions and 0 deletions
+470
View File
@@ -0,0 +1,470 @@
# 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).