feat(sql): authored write block — Update/Insert/Upsert/Procedure + timestamp stamp; ODBC scope + login docs (lmxopcua#781)
v2-ci / unit-tests (push) Has been cancelled

Item 2: a tag's optional `write` block picks the statement the driver builds from its own
catalog-validated identifiers (no authored SQL; values always bound):
- Update (default, unchanged back-compat UPDATE ... WHERE key; 0 rows = BadNoData)
- Insert (key+value[+stamp]; append for a WideRow topByTimestamp tag)
- Upsert: SQL Server MERGE WITH (HOLDLOCK), PostgreSQL INSERT ... ON CONFLICT, MySQL
  ON DUPLICATE KEY UPDATE (1-or-2 rows), Oracle MERGE from DUAL
- Procedure: named stored procedure resolved against the routine catalog by the catalog gate
  (INFORMATION_SCHEMA.ROUTINES / ALL_PROCEDURES), value/key/timestamp params by name (MySQL positional)
- stampTimestamp sets the tag's timestamp column to the write time (UTC)
Odbc serves Update/Insert only (shared SqlWriteSupport table). Constraint failures map to
BadEntryExists (unique) / BadOutOfRange (other integrity, data exception); everything else,
permission denied included, stays BadCommunicationError. WriteIdempotent stays false.
The parser refuses malformed blocks (strict keys/enums/param charset); DraftValidator emits
blocking SqlWriteBlockInvalid / SqlWriteModeUnsupported from the driver's own parse; the AdminUI
editor surfaces the same message.

Items 1 + 4 (docs/drivers/Sql.md): ODBC stays the ANSI SQL:2008 profile (native providers for
SQL Server/MySQL, DB2 unsupported) with an ops note on driver manager + vendor driver on every
Odbc and AdminUI host; read-only drivers require a SELECT-only login, writable drivers a login
limited to their write targets.

Tests: SqlWriteModeTests (parse, every dialect's SQL, gate, status map, SQLite end-to-end),
DraftValidatorSqlWriteBlockTests, AdminUI round-trip; live SqlWriteModesLiveTests on SQL Server,
PostgreSQL, MySQL, Oracle (seeds gain write_targets + set_target) and an Odbc write leg.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 22:56:21 -05:00
parent 2da58d0360
commit f5c30ed0ef
33 changed files with 2361 additions and 132 deletions
+1 -1
View File
@@ -73,7 +73,7 @@ Three properties worth knowing:
| [OPC UA Client](OpcUaClient.md) | `Driver.OpcUaClient` | B | OPCFoundation `Opc.Ua.Client` | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IAlarmSource, IHostConnectivityProbe | Gateway/aggregation driver — does not proxy upstream history (no driver has a history capability). Opens a single `Session` against a remote OPC UA server and re-exposes its address space. Owns its own `ApplicationConfiguration` (distinct from `Client.Shared`) because it's always-on with keep-alive + `TransferSubscriptions` across SDK reconnect, not an interactive CLI |
| [MQTT](Mqtt.md) | `Driver.Mqtt` (+ `.Browser`, `.Contracts`) | A | [MQTTnet 5](https://github.com/dotnet/MQTTnet) | IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe, IRediscoverable | **Push, not poll, and read-only** — the broker delivers PUBLISH frames the driver forwards straight to `ISubscribable.OnDataChange`; `IReadable` serves the last retained/received value from cache, and there is **no `IWritable`** (nothing publishes back). Subscriptions are indexed **by topic filter, not by topic**, so wildcard tags survive a reconnect. A rejected CONNACK throws `MqttConnectRejectedException`: unrecoverable codes → `Faulted` + supervisor stop, transient → retry. **Two modes:** `Plain` binds a tag to a concrete topic + JSON path; `SparkplugB` decodes vendored Eclipse-Tahu protobuf under one `spBv1.0/{groupId}/#` subscription and binds by the `group/edgeNode/device?/metric` tuple (birth/alias/seq-gap state machine, death→STALE, scoped rebirth NCMD). `IRediscoverable` fires on a DBIRTH but is **inert platform-side** — see [#507](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/507) |
| [MTConnect](MTConnect.md) | `Driver.MTConnect` (+ `.Contracts`) | — | Hand-rolled `System.Xml.Linq` HTTP/XML client against a vendor-neutral MTConnect Agent (`/probe`, `/current`, `/sample`) — no TrakHound dependency (dropped; see the driver doc) | IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe, IRediscoverable | Read-only by design (no `IWritable`). Production data plane is entirely `ISubscribable`/`OnDataChange` — `IReadable` is CLI/test-only. Browse is free via the Wave-0 universal discovery browser, no bespoke browser project. `IRediscoverable`/`IHostConnectivityProbe` have no server-side consumer yet — a pre-existing fleet-wide gap, not MTConnect-specific |
| [Sql](Sql.md) | `Driver.Sql` (+ `.Browser`, `.Contracts`) | A | `Microsoft.Data.SqlClient` behind an `ISqlDialect` seam (`SqlServerDialect` shipped; Postgres/ODBC deferred) | IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe | Read-only DB-poll driver with a bespoke schema browser. **Credentials are never persisted** — the deploy gate rejects a stored `connectionString` (#498) and catalog identifiers are validated against the live catalog's own spelling (#496). `SupportsOnlineDiscovery => false` by design (the bespoke browser wins). `SqlTagModel.Query` is deferred to P3 and rejected end-to-end by parser, validator, planner and editor. Merged `4ad54037` |
| [Sql](Sql.md) | `Driver.Sql` (+ `.Browser`, `.Contracts`, `.Dialect`) | A | ADO.NET behind an `ISqlDialect` seam — `SqlServer` (`Microsoft.Data.SqlClient`), `Postgres` (Npgsql), `Oracle` (ODP.NET), `MySql` (MySqlConnector), `Odbc` (ANSI SQL:2008 profile only) | IDriver, ITagDiscovery, IReadable, IWritable (opt-in), ISubscribable, IHostConnectivityProbe | DB-poll driver (KeyValue / WideRow / named read-only `Query` tags) with a bespoke schema browser. **Credentials are never persisted** — the deploy gate rejects a stored `connectionString` (#498) and catalog identifiers are validated against the live catalog's own spelling (#496). Writes are off unless `allowWrites` + the tag's `writable`; a tag's `write` block picks Update / Insert / Upsert / stored Procedure (#781; Odbc: Update/Insert). `SupportsOnlineDiscovery => false` by design (the bespoke browser wins). |
| [Calculation](Calculation.md) | `Driver.Calculation` | A | none — computes from other tags' RawPaths | IDriver, IDependencyConsumer, ISubscribable, IReadable | **Pseudo-driver**, not a wire protocol: signal-level tags computed from other tags via a script, with deploy-time `scriptId`-existence + Tarjan cycle gates. Its probe is parse-only (no backend to reach). `runTimeoutMs` is authorable through `CalculationDriverForm` (the "no typed config form" gap — G-1 — was closed 2026-07-27) |
| [Historian.Gateway](../Historian.md) | `Driver.Historian.Gateway` | — | `ZB.MOM.WW.HistorianGateway.Client` gRPC (`historian_gateway.v1`) | IHistorianDataSource (server-side read backend) + alarm `SendEvent` writer + `WriteLiveValues` recorder + `IHistorianProvisioning` | Not a tag driver — the default historian backend (the other is `Historian.SqlServer`, below). Registers `GatewayHistorianDataSource : IHistorianDataSource` for HistoryRead and serves alarm-write + continuous historization through the gateway. No `IDriver`/`ITagDiscovery` surface. (The retired Wonderware sidecar backend it replaced is documented at [Historian.Wonderware.md](Historian.Wonderware.md).) |
| [Historian.SqlServer](../Historian.md#sql-server-backend) | `Driver.Historian.SqlServer` | — | `Microsoft.Data.SqlClient` (a plain SQL Server database, schema `hist` by default) | `IHistorianDataSource` + `IHistorianValueWriter` + `IAlarmHistorianWriter` + `IHistorianProvisioning` + `IHistorianDataUpdater` | Not a tag driver — a pluggable historian backend selected with `ServerHistorian:Backend=SqlServer`. Stores typed values with their real status, serves Raw / AtTime / Events / 13 push-down aggregates, and is the only backend that supports OPC UA **HistoryUpdate**. No `IDriver`/`ITagDiscovery` surface. |
+145 -42
View File
@@ -5,7 +5,7 @@
Getting-started guide for the `Sql` driver — an Equipment-kind driver that polls rows out of a
relational database and publishes them as OPC UA variables. It is **read-only unless writes are
explicitly enabled** at both the driver and the tag (see [Writes](#writes-opt-in-753)). For the design rationale read
explicitly enabled** at both the driver and the tag (see [Writes](#writes-opt-in-753-781)). For the design rationale read
[`docs/plans/2026-07-15-sql-poll-driver-design.md`](../plans/2026-07-15-sql-poll-driver-design.md);
for the build-vs-plan record read
[`docs/plans/2026-07-24-sql-poll-driver.md`](../plans/2026-07-24-sql-poll-driver.md).
@@ -32,19 +32,40 @@ boolean under MySqlConnector's default `TreatTinyAsBoolean`; `BIT(n)`, `TIME`, `
types infer as `String`. Live gate: `Docker/mysql/docker-compose.yml` + `MySqlReadTests`
(`SQL_MYSQL_ENDPOINT` / `SQL_MYSQL_PASSWORD`).
ODBC specifics: `OdbcDialect` is the **ANSI SQL:2008 profile** — double-quoted identifiers,
positional `?` parameters, the standard `INFORMATION_SCHEMA` views and `FETCH FIRST n ROWS ONLY`.
ODBC abstracts the wire, not the SQL, so the backend behind the DSN must accept all four:
PostgreSQL (psqlODBC — the live gate), Snowflake and similar do; **SQL Server** (needs `OFFSET …
FETCH` after an `ORDER BY`), **MySQL** (no `FETCH FIRST`) and **DB2** (no `INFORMATION_SCHEMA`) do
not and should use their native provider. It is also the one provider that is **not fully
managed**: every host running an Odbc instance needs the platform driver manager (unixODBC / iODBC
/ Windows' own) and the vendor's ODBC driver — on the driver nodes and, for browse, the AdminUI
nodes. Parameters bind in order, so the planner, the catalog loader and the browse session all add
them in marker order, and a named query's `params` bind in authored order (the factory refuses a
statement whose `?` count differs). Live gate: `Docker/odbc/run-odbc-gate.sh` runs `OdbcReadTests`
in a throwaway Linux container with unixODBC + psqlODBC against a throwaway PostgreSQL; on macOS
the suite is unit-tested only (`OdbcDialectTests`) because no vendor ODBC driver is installed.
ODBC specifics: `OdbcDialect` is the **ANSI SQL:2008 profile, and only that** — double-quoted
identifiers, positional `?` parameters, the standard `INFORMATION_SCHEMA` views and `FETCH FIRST n
ROWS ONLY`. The scope is deliberately narrow (owner decision 2026-11-09, lmxopcua#781): there are no
per-backend ODBC dialect variants and no `SQLTables`/`SQLColumns` catalog path. ODBC abstracts the
wire, not the SQL, so the backend behind the connection string must accept all four: PostgreSQL
(psqlODBC — the live gate), Snowflake and similar do. **SQL Server** (needs `OFFSET … FETCH` after an
`ORDER BY`) and **MySQL / MariaDB** (no `FETCH FIRST`) do not — use the native `SqlServer` / `MySql`
provider. **DB2** (no `INFORMATION_SCHEMA`) is not supported through Odbc at all. Writes over Odbc
are **Update and Insert only** — see [Writes](#writes-opt-in-753-781).
Parameters bind in order, so the planner, the catalog loader and the browse session all add them in
marker order, and a named query's `params` bind in authored order (the factory refuses a statement
whose `?` count differs). Live gate: `Docker/odbc/run-odbc-gate.sh` runs `OdbcReadTests` (reads,
named queries, browse, and Update/Insert writes) in a throwaway Linux container with unixODBC +
psqlODBC against a throwaway PostgreSQL; on macOS the suite is unit-tested only (`OdbcDialectTests`)
because no vendor ODBC driver is installed.
> **Ops note — Odbc is the one provider that is not fully managed.** `System.Data.Odbc` is only the
> ADO.NET shim; the work is done by native code that must be installed on **every host that runs an
> Odbc driver instance** (every node of its cluster, since either can become Primary) **and every
> AdminUI host** (the browse picker and Test Connect open the connection from the AdminUI process):
>
> - **Linux:** the driver manager (`unixodbc`) plus the vendor's driver package (e.g.
> `odbc-postgresql`), registered in `odbcinst.ini`. Check with `odbcinst -q -d` — the name it prints
> is the `Driver={…}` value the connection string needs.
> - **Windows:** the driver manager ships with the OS; install the vendor's **64-bit** ODBC driver
> (the server is x64) and confirm it in *ODBC Data Sources (64-bit)* → *Drivers*.
> - Prefer a **DSN-less** connection string (`Driver={PostgreSQL Unicode};Server=…;Database=…`) in
> the secret store: a DSN is per-host configuration that every node and AdminUI host would also
> need, identically.
>
> A host missing either piece fails the driver's Initialize (and the AdminUI browse) with the driver
> manager's "data source name not found / no default driver" error — a host-provisioning fault, not a
> tag fault.
Two provider-specific things are worth knowing before you author against them:
@@ -56,7 +77,7 @@ Two provider-specific things are worth knowing before you author against them:
(see [Optional per-tag fields](#optional-per-tag-fields)) when the OPC UA type matters.
**Read-only by default.** `allowWrites` is off unless authored `true`, and even then only a tag whose
own TagConfig says `"writable": true` can be written — see [Writes](#writes-opt-in-753).
own TagConfig says `"writable": true` can be written — see [Writes](#writes-opt-in-753-781).
## Tag models
@@ -173,7 +194,8 @@ row:
comment — so `SELECT … INTO`, `FOR UPDATE` and a data-modifying CTE are refused too. Behind that guard
the statement runs **inside a transaction the driver always rolls back**, so a side effect the guard
cannot see (a function that writes) is never committed. A non-transactional side effect (an e-mail
procedure, a sequence) is outside both, so give the connecting principal `SELECT` only.
procedure, a sequence, a call out to another system) is outside both — which is why a read-only
driver **must** connect with a `SELECT`-only login (see [Database login](#database-login-required)).
- A tag naming a query the driver config does not declare is **skipped** at Initialize (it reads
`BadNodeIdUnknown`, and `/hosts` names the missing query). One unusable `queries` entry **fails the
driver** instead, because every tag that names it would otherwise go dark unexplained.
@@ -187,6 +209,7 @@ row:
| `timestampColumn` | Source timestamp for the value. Absent ⇒ the driver stamps the poll time. |
| `type` | Explicit OPC UA type override. Absent ⇒ inferred from the result set's column metadata. A write is coerced to it. |
| `writable` | The tag's write opt-in — a JSON **boolean**; absent ⇒ read-only. Anything else (`"true"`, `1`) refuses the tag. Effective only with the driver's `allowWrites`. |
| `write` | Optional: how a write is executed — `Update` (default), `Insert`, `Upsert` or `Procedure`, plus the timestamp stamp. See [Writes](#writes-opt-in-753-781). A malformed block refuses the whole tag and blocks deploy. |
There is **no per-tag poll-interval field**. The interval is the subscriber's, floored by
`PollGroupEngine.DefaultMinInterval`, falling back to the instance's `defaultPollInterval` when the
@@ -216,7 +239,7 @@ existed in the parser — nothing reads it.)
| `commandTimeout` | `00:00:10` | ADO.NET server-side backstop. **Must be less than `operationTimeout`** — it is the backstop, not the deadline. |
| `maxConcurrentGroups` | `4` | Connection-pool guard: cap on group queries in flight. |
| `nullIsBad` | `false` | `false` ⇒ a NULL cell publishes **Uncertain**; `true` ⇒ **Bad**. |
| `allowWrites` | `false` | Driver-level write switch. Off ⇒ every write is `BadNotWritable`. See [Writes](#writes-opt-in-753). |
| `allowWrites` | `false` | Driver-level write switch. Off ⇒ every write is `BadNotWritable`. See [Writes](#writes-opt-in-753-781). |
| `queries` | — | Named read-only queries for `Query` tags — see [`Query`](#query--a-named-read-only-query-753). Edited as JSON in the driver form, which applies the factory's own rules. |
### Credentials never enter the deployment artifact
@@ -329,42 +352,122 @@ catalog can supply it. `RawBrowseCommitMapper.DescribeUncommittableLeaf` refuses
names the table, and points at the tag editor; no selector is invented, and the mapper's blob stays
selector-less rather than dropping to a generic `{"address": …}` the typed editor cannot read.
## Writes (opt-in, #753)
## Writes (opt-in, #753, #781)
A write reaches the database only when **every** gate holds, outermost first:
1. the OPC UA node is Writable (the Tag's own flag) and the caller holds `WriteOperate` — the server;
2. the driver config says `"allowWrites": true` — otherwise `BadNotWritable`;
3. the tag's TagConfig says `"writable": true` — otherwise `BadNotWritable`;
4. the model names exactly one row: **KeyValue**, or **WideRow with a `whereColumn`/`whereValue`
pair**. `topByTimestamp` ("the newest row" moves under a concurrent insert) and `Query` (a projection
names no table) are `BadNotWritable`.
4. the tag's write mode has a target on its model (table below) — otherwise `BadNotWritable`;
5. the provider serves the mode — otherwise deploy refuses it (`SqlWriteModeUnsupported`) and, at
runtime, `BadNotSupported`.
The write is the **only** statement shape the driver can produce — there is no authored write SQL,
INSERT, UPSERT or procedure call:
There is **no authored write SQL.** A tag's optional `write` block picks one of four statement
shapes the driver builds itself from the tag's own catalog-validated identifiers:
```sql
UPDATE <table> SET <valueColumn | columnName> = @v WHERE <keyColumn | whereColumn> = @k
```json
{ "model": "KeyValue", "table": "plant.setpoints", "keyColumn": "tag_name", "keyValue": "Line1.Speed",
"valueColumn": "sp_value", "timestampColumn": "updated_at", "type": "Float64", "writable": true,
"write": { "mode": "Upsert", "stampTimestamp": true } }
```
- **Identifiers are the catalog gate's**: a write resolves against the gate-accepted definition, so
every name in that statement was read back out of the catalog and dialect-quoted. A gate-rejected tag
answers `BadNodeIdUnknown`. The value and the key are always **bound parameters**.
- **Exactly one row or nothing.** The UPDATE runs in a transaction that commits only when it touched
one row. Zero rows ⇒ `BadNoData` (the driver never inserts); more than one ⇒ rolled back,
`BadConfigurationError` (the selector is ambiguous — the read path's rule); a provider that cannot
report a row count ⇒ rolled back, `BadNotSupported`.
- The value is coerced to the tag's `type` when it has one (`BadTypeMismatch` when it will not
coerce), else bound as the scalar the client sent. A null writes SQL NULL.
- Bounded by `operationTimeout` (`BadTimeout`). A provider error after the connection opened — a
missing `UPDATE` grant, a constraint — is `BadCommunicationError`, nothing committed. Writes never
change driver health.
- Discovery advertises `Operate` only for a tag where all of 2–4 hold, `ViewOnly` otherwise.
- **Grant the login `UPDATE` on just the tables that carry writable tags.** The live gate
(`MySqlReadTests`) proves the server refuses a write to a SELECT-only table even with every driver
| `mode` | Statement | Models | Rows that count as success |
|---|---|---|---|
| `Update` *(default — also what a writable tag with no `write` block does)* | `UPDATE t SET value [, stamp] WHERE key = @k` | KeyValue; WideRow with `whereColumn`/`whereValue` | exactly 1 |
| `Insert` | `INSERT INTO t (key, value [, stamp]) VALUES (…)` — no key column for a WideRow `topByTimestamp` tag (an append) | KeyValue; WideRow (either selector) | exactly 1 |
| `Upsert` | SQL Server `MERGE … WITH (HOLDLOCK)`; PostgreSQL `INSERT … ON CONFLICT (key) DO UPDATE`; MySQL `INSERT … ON DUPLICATE KEY UPDATE`; Oracle `MERGE … USING (SELECT 1 FROM dual)` | KeyValue; WideRow with a where-pair | 1 (MySQL: 1 or 2) |
| `Procedure` | SQL Server `EXEC p @value = @v, …`; PostgreSQL `CALL p(value => @v, …)`; MySQL `CALL p(@v, …)` (**positional**); Oracle `BEGIN p(value => :p_v, …); END;` | any, including `Query` | the call did not throw |
`write` keys — anything else refuses the tag:
| Key | Modes | Meaning |
|---|---|---|
| `mode` | all | `Update` \| `Insert` \| `Upsert` \| `Procedure`; absent ⇒ `Update`. |
| `stampTimestamp` | Update / Insert / Upsert | `true` ⇒ also set the tag's `timestampColumn` (a WideRow `topByTimestamp` tag: that column) to the write time, **UTC, from the driver host's clock**. Requires such a column. Default `false` — the timestamp column is then untouched and the next read keeps the row's old timestamp unless the database maintains it (a trigger, `ON UPDATE CURRENT_TIMESTAMP`). |
| `procedure` | Procedure (required) | `procedure` or `schema.procedure` — a **stored procedure**, resolved against the routine catalog like a table (case folds; an unknown, non-executable or package-member name rejects the tag). |
| `valueParam` | Procedure | The procedure parameter the value binds to. Default `value`. |
| `keyParam` | Procedure | Optional: the parameter the tag's key binds to (`keyValue`, `whereValue` or a Query tag's `rowKey.value`). |
| `timestampParam` | Procedure | Optional: the parameter the write time (UTC) binds to. |
Parameter names are plain identifiers (letter/underscore, then letters/digits/underscores; a leading
`@`/`:` is stripped) — they reach the statement unquoted, in named notation. **MySQL has no named
notation**: its arguments are passed positionally in the fixed order *value, key, timestamp* (those
configured), so declare the procedure's parameters in that order. **PostgreSQL** resolves procedure
arguments by implicit cast, so declare a numeric value parameter `double precision` (a `Float64`
binds as that) and a timestamp parameter `timestamptz`.
**Provider support** (one table, `SqlWriteSupport`, read by the driver and the deploy gate):
| | Update | Insert | Upsert | Procedure |
|---|---|---|---|---|
| SqlServer, Postgres, MySql, Oracle | ✓ | ✓ | ✓ | ✓ |
| Odbc | ✓ | ✓ | — | — |
Odbc has no upsert every ODBC backend accepts (and positional `?` markers cannot repeat, which every
single-statement upsert needs) and no portable routine catalog or calling convention; use the
backend's native provider for those modes.
**Upsert needs a unique key on the key column.** PostgreSQL refuses `ON CONFLICT (key)` without a
unique constraint or index on exactly that column; MySQL's `ON DUPLICATE KEY` fires on *any* unique
key of the table, so the key column should be the primary key and no other unique key should be able
to collide; SQL Server and Oracle `MERGE` work without one, but then a key matching two rows updates
both and is rolled back (`BadConfigurationError`).
### Outcomes
| Outcome | Status |
|---|---|
| Committed | `Good` |
| `Update` (or a non-MySQL `Upsert`) touched no row — the key is absent; nothing is inserted | `BadNoData` |
| More rows than the mode allows — the key is not unique; rolled back | `BadConfigurationError` |
| The provider could not report a row count; rolled back | `BadNotSupported` |
| Unique / primary-key violation — e.g. an `Insert` of a key that exists (SQLSTATE 23505, SQL Server 2627/2601, MySQL 1062, ORA-00001) | `BadEntryExists` |
| Another integrity rule — NOT NULL, foreign key, CHECK (SQLSTATE class 23, SQL Server 515/547, ORA-01400/02290/02291) — or a data exception: overflow, truncation, an unconvertible value (SQLSTATE class 22, SQL Server 8115/8152/245, ORA-01438/12899/01722) | `BadOutOfRange` |
| Any other provider error after the connection opened — **a missing grant included**, a missing object, a broken link | `BadCommunicationError` |
| The value will not coerce to the tag's `type` | `BadTypeMismatch` |
| `operationTimeout` elapsed | `BadTimeout` |
Nothing is committed on any non-`Good` outcome. Writes are **never retried**
(`WriteIdempotent = false` — an `Insert` or a procedure is not idempotent, and an `Update` is only
idempotent until something else writes the row), and a failed write reverts the node to its prior
value. Writes never change driver health. Discovery advertises `Operate` only for a tag where gates
2–5 hold, `ViewOnly` otherwise.
### Safety
- **Identifiers are the catalog gate's.** A write resolves against the gate-accepted definition, so
every table, column and procedure name in the statement was read back out of the catalog
(`INFORMATION_SCHEMA.ROUTINES` / Oracle `ALL_PROCEDURES` for procedures) and dialect-quoted. A
gate-rejected tag answers `BadNodeIdUnknown`.
- **Values are always bound** — the written value, the key and the stamp. Nothing an OPC UA client
sends reaches command text.
- **Deploy checks the block with the driver's own parser** (`SqlTagDefinitionFactory.DescribeWriteBlock`):
a malformed block is the blocking error `SqlWriteBlockInvalid`, a mode the instance's provider does
not serve is `SqlWriteModeUnsupported`. The AdminUI tag editor shows the same parser message.
- Live gate: `SqlWriteModesLiveTests` runs the same suite (Update+stamp, absent key, Insert and its
duplicate, NOT NULL, Upsert insert-then-update, Procedure, unknown procedure) against SQL Server,
PostgreSQL, MySQL and Oracle; `OdbcReadTests` covers Odbc's Update/Insert.
## Database login (required)
The driver's own guards are not a substitute for the database's grants:
- **A read-only driver (`allowWrites` off) must connect with a `SELECT`-only login** — `SELECT` on
the tables and views its tags and named queries read, and nothing else. The named-query guard is a
keyword check plus an always-rolled-back transaction; neither can stop a **non-transactional** side
effect — a sequence `nextval`, a function that sends mail or calls out, autonomous transactions,
DDL that auto-commits on some backends. Only the grant can.
- **A writable driver must connect with a login limited to its write targets** — `SELECT` as above,
plus exactly what its modes need on exactly the tables / procedures its writable tags name:
`UPDATE` (Update), `INSERT` (Insert), `INSERT` + `UPDATE` (Upsert; SQL Server's `MERGE` also needs
`SELECT` on the target), `EXECUTE` on the named procedures (Procedure). Never `DELETE`, DDL or
ownership. The live gates provision exactly this (`Docker/*/seed-*.sql`) and prove a write to a
table outside the grant is refused by the server (`BadCommunicationError`) even with every driver
gate open.
- A source timestamp column is **not** touched by a write, so the next read keeps the row's old
timestamp unless the database maintains it (a trigger or `ON UPDATE CURRENT_TIMESTAMP`).
- A procedure runs with *its* rights (SQL Server `EXECUTE AS`, PostgreSQL `SECURITY DEFINER`, MySQL
`SQL SECURITY DEFINER`, Oracle definer's rights by default) — review what it can touch, since the
login's narrow grant does not bound it.
## Authoring in the AdminUI
@@ -219,6 +219,12 @@ becomes an `IAddressSpaceBuilder.Variable(browseName, displayName, DriverAttribu
### 3.4 `IWritable` — verdict: NOT in v1
> **Built since (2026-11-09).** #753 shipped the opt-in single-row UPDATE; lmxopcua#781 item 2 built the
> `write` block — but as **driver-built statements** (`mode` Update / Insert / Upsert / Procedure +
> `stampTimestamp`, procedure parameter names), not the authored `sql` text sketched below: every
> identifier goes through the catalog gate and every value is bound. The shipped contract is
> [`docs/drivers/Sql.md` → Writes](../drivers/Sql.md#writes-opt-in-753-781).
**v1 does not implement `IWritable`.** Rationale in §1. The P4 opt-in design: a tag may carry a
`write` block describing a **parameterized** statement (never string-concatenated):