docs(drivers): write the two missing driver guides (Sql, Calculation)

Closes the §7.2 priority-3 item. Sql was a shipped, merged driver with NO
user-facing documentation at all -- only design and plan files, which describe
what was intended rather than what an operator needs to author one.

Sql.md covers: SQL Server as the only constructed provider (the other four
SqlProvider names are reserved, and authoring one fails rather than falling
back), the KeyValue / WideRow tag models with worked TagConfig, Query being
rejected end-to-end, the config table with real defaults read from
SqlDriverOptions, connectionStringRef never carrying credentials into an
artifact, per-source query grouping and its case-sensitive group keys, and the
value-bound-vs-identifier-validated split that makes it injection-safe.

Calculation.md covers: what a pseudo-driver is and why it occupies a driver slot,
a Calculation-vs-VirtualTags decision table (the two are easy to confuse), the
literal-only ctx.GetTag dependency extraction and the runtime-built-path trap,
the two deploy gates, the all-deps-arrived publish gate, and the Good->Bad-once
error semantics.

Two stale claims found by checking against source rather than inheriting them:

  - The driver README's Calculation row still carried G-1 ("no typed config form,
    RunTimeout unauthorable"), fixed on 2026-07-27 by CalculationDriverForm.
  - docs/Raw.md's "one auto-created default Engine device" NEVER SHIPPED. I had
    copied it into the new doc before checking; "Engine" has zero occurrences in
    src/ and nothing special-cases Calculation at device creation. Corrected in
    both files. The config key was also wrong in my first draft: runTimeoutMs, an
    int in milliseconds, not a TimeSpan string -- and it is ignored on reinit
    because the evaluator's timeout is constructor-fixed.

Full solution: 49 suites pass; the 2 failures are the known pre-existing
environment ones (AbLegacy.IntegrationTests docker fixture, Host.IntegrationTests).
OpcUaClient.IntegrationTests, which was silently failing, is now green.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
Joseph Doherty
2026-07-28 14:40:51 -04:00
parent 4624141a5d
commit f3cd685da5
5 changed files with 312 additions and 944 deletions
-941
View File
@@ -1,941 +0,0 @@
# Deferment register — new drivers, open issues, deferred work
> **Scope.** A source-verified inventory of what is unfinished in this repo: the status of the
> recently-added drivers, every open tracker issue, in-code deferrals, open live gates, and the
> documentation that misstates any of it.
>
> **Method.** Five parallel agents swept the tree independently; every claim below was checked
> against **source** (`src/`, `tests/`, `git log`), not against documentation. Where a doc and the
> code disagreed, the code won and the doc is listed in §7. Findings that could not be verified
> without hardware or a live rig are marked **CANNOT VERIFY** rather than asserted.
>
> **Baseline.** The audit below was taken against `master` @ `90bdaa44`, 2026-07-27, and **§1–§8 are
> preserved as that audit** — they are the record of what was found, not a description of the tree today.
> **§9 is the live status.**
>
> **Current tree:** `master` @ `f0bd73bc` (remediation merge `914d70bf`), 2026-07-28. Items closed since
> the audit are struck through or marked ✅ in place, so a reader can still see what was found and what
> became of it.
---
## 0. The short version
*(As audited at `90bdaa44`. See §0.1 for what is left.)*
The driver runtime is in good shape — **no new driver contains a stub, a `NotImplementedException`,
or an unregistered factory.** What is unfinished clusters into four groups, in descending order of
how much they should worry you:
| # | Theme | Why it matters | Now |
|---|---|---|---|
| **1** | **Three subsystems are fully authored, persisted, and shipped — and never executed.** Node ACLs, `IRediscoverable`/`IHostConnectivityProbe`, and `DriverTypeRegistry`. | An operator can author a rule, save it, deploy it green, and have it do **nothing**. §3 | ◐ `IRediscoverable` now consumed; the other two are **documented as inert** and tracked (#520, #521, #522) rather than fixed |
| **2** | **AdminUI authoring-dispatch gaps.** Calculation is offered in the driver picker but has no config form; three drivers have no device form; CSV + browse-commit dispatch maps miss the new drivers. | The "registered but unauthorable" class that has now bitten twice. §1.2 | ✅ **Closed** — G-1…G-6 fixed and guarded; G-7 remains |
| **3** | **17 open issues, all confirmed still open**, several broader than filed. | §2 | ◐ 3 resolved by the merge; 4 new filed → **21 open on the tracker**, of which 3 are stale-open |
| **4** | **Bookkeeping drift.** 8 `.tasks.json` files show ~140 "pending" tasks for work that shipped; 5 CLAUDE.md status claims are stale. | Someone follows the tracker and rebuilds a shipped driver. §6, §7 | ✅ **Closed** — 13 plan files + 11 archreview gates written back |
Nothing here is a data-loss or outage defect. The sharpest edge is §3 — silent non-enforcement.
### 0.1 What is actually left (as at `f0bd73bc`, 2026-07-28)
The remediation (§9) closed the two mechanical themes outright and converted the two judgement-call
themes into tracked decisions. What remains:
| Item | Nature | Where |
|---|---|---|
| **#520** node ACLs authored + deployed but **never enforced** | Decision: wire it or retire the surface. Non-enforcement is now stated in docs and warned in the UI, so nobody can author a deny rule believing it works — but reads are still ungated. | §3.1 |
| **#521** `IHostConnectivityProbe` / `DriverHostStatus` dead surface | Decision: build the publisher or delete it. The table was deliberately re-created in the v3 initial migration, so deleting on inference would be wrong. | §3.2 |
| **#522** driver stability tiers inert | Decision: pass real tiers or delete the machinery. Docs now say every driver runs Tier A. | §3.3 |
| ~~**#523** remove the periodic 5-min device re-browse~~ | ✅ **Done** — removed. **AbCip and FOCAS now have no tag-change signal at all**; that is the accepted consequence, not an oversight. | §9 (2026-07-28, #523) |
| **G-7** `EquipmentTagConfigInspector` coverage | ◐ **Narrowed + guarded** (2026-07-28): MQTT wired (it shipped an `Inspect` nobody called), and a reflection guard now fails the build if a reachable driver ships one and is not mapped. **Residual:** Sql / MTConnect / Calculation / OpcUaClient have no Contracts-side `Inspect` at all, so the deploy gate stays blind for them while the AdminUI validates them — see §1.2. | §1.2 |
| ~~**13 `EquipmentTagsDarkBatch4` skipped tests**~~ | ✅ **Done** — 6 revived onto the raw/UNS shape, 3 folded into a widened live parity corpus, 4 deleted as retired. The reason constant is gone. **Skips now: `Runtime.Tests` 13 → 3, `OpcUaServer.Tests` 4 → 1** — all remaining are deliberate `EquipmentDeviceBindingRetired` tombstones. | §9 (2026-07-28, skips) |
| 17 other tracker issues | Driver features + deferred work, unchanged by this remediation. | §2 |
| Open live gates | #491 continuous historization, #468 browser tree, archreview R2-03 / R2-10. *(The un-gated drift detector's gate closed by deletion — see #523.)* | §5 |
**Three issues are resolved but still open on the tracker****#516** (fixed for all 12 drivers),
**#518** (the `IRediscoverable` half fixed; the probe half split to #521) and **#507** (injection deleted,
superseded by `/raw` browse-commit). Closing them is a tracker action nobody has taken yet.
---
## 1. New drivers — source-verified status
Cross-cutting facts, verified once: all 12 driver types register in one place
(`src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs:147-164`); all 12 register a
probe (`:119-130`) via `AddOtOpcUaDriverProbes()`, called from **both** the driver path (`:93`) and
the admin path — **no probe is admin-node-missing**. `DriverTypeNames`
(`src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs`) is guarded bidirectionally by
reflection in `tests/Core/…Core.Abstractions.Tests/DriverTypeNamesGuardTests.cs`.
### 1.1 Status table
| Driver | Verdict | Merged | Capabilities | Notes |
|---|---|---|---|---|
| **MTConnect** | Complete-with-gaps | `90bdaa44` (#506) | `IDriver, IReadable, ISubscribable, ITagDiscovery, IHostConnectivityProbe, IRediscoverable` (`MTConnectDriver.cs:63`) | No `IWritable` **by design** (`:50`). No device form. |
| **MQTT / Sparkplug B** | **Complete** | `c3a2b0f7` | `+ IRediscoverable, IAsyncDisposable` (`MqttDriver.cs:65-66`) | Cleanest of the five: picker + driver form + device form + tag editor all present. 8 open follow-up issues. |
| **Sql** | Complete-with-gaps | `4ad54037`, follow-ups `28c28667` | `IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe` (`SqlDriver.cs:28-29`) | No device form; no browse-commit branch; stale comments. |
| **Modbus RTU-over-TCP** | **Complete** | `0f38f486` (#495) | transport mode inside Modbus (`ModbusTransportMode.cs`, `ModbusRtuOverTcpTransport.cs`) | Parses transport **by name** (`ModbusDriverFactoryExtensions.cs:77-78`) — dodges the systemic numeric-enum authoring bug. |
| **Calculation** | **Partial (authoring gap)** | pre-existing | `IDriver, IDependencyConsumer, ISubscribable, IReadable` (`CalculationDriver.cs:38`) | **Registered + offered in the picker, but has no config form.** See §1.2. |
`IWritable` is absent from MTConnect, MQTT, Sql and Calculation. In all four this is **structural and
documented**, not a stub (`MTConnectDriver.cs:50`, `MqttDriver.cs:483`, `SqlDriver.cs:15`).
**Zero `NotImplementedException`** across all five drivers. Every `NotSupportedException` is either a
`catch` filter or a deliberate domain error.
### 1.2 Gaps found — AdminUI authoring & dispatch layer
Every gap below is in the **authoring/dispatch layer, not driver runtime code**.
> ✅ **G-1 … G-6 are all closed** (`6a01358b`, live-verified on docker-dev). The two Razor `@switch`
> dispatch blocks became testable maps (`DriverConfigFormMap`, `DeviceFormMap`) and are now guarded in both
> directions, so this class cannot recur silently. **G-7 is the only one left.** See §9.
| # | Gap | Evidence | Impact | Now |
|---|---|---|---|---|
| G-1 | **`DriverConfigModal.razor` has no `Calculation` case** — falls to the `default` "No typed config form" warning; no `CalculationDriverForm.razor` exists | `DriverConfigModal.razor:34-72`, default arm `:69-71` | `CalculationDriverOptions.RunTimeout` (`CalculationDriverOptions.cs:19`) is **unauthorable via the UI**. Create/rename still work, so degraded — not a hard block. **This is the same class as the Sql picker defect, one modal further downstream.** | ✅ Fixed — `CalculationDriverForm.razor` |
| G-2 | **`DeviceModal.razor` is missing Sql, MTConnect, Calculation** | `DeviceModal.razor:42-74`, default arm `:71-73` | Mitigated: Sql/MTConnect hold connection at the **driver** level, and Test Connect still works via `BuildMergedProbeConfig` (`:180-184`). | ✅ Fixed — declared **single-connection** rather than given hollow device forms |
| G-3 | **`DriverConfigModal.razor:77` tells Sql/MTConnect operators the wrong thing** — the single-connection branch is hardcoded to `Galaxy or Mqtt`, so Sql/MTConnect users are told the endpoint lives on the *device* when they just authored it on the driver | `DriverConfigModal.razor:77`, `:86-88` | Actively misleading, compounds G-2. | ✅ Fixed — `DeviceFormMap.IsSingleConnection` |
| G-4 | **No parity test guards `DriverConfigModal` or `DeviceModal`**`RawDriverTypeDialogCoverageTests`/`…ParityTests` assert `DriverTypeNames`**picker** parity only | — | **This is exactly why G-1 and G-2 survived review.** The guard pattern exists and simply was not extended to the two downstream dispatch maps. | ✅ Fixed — `DriverFormMapParityTests` + `DriverDispatchMapParityTests` |
| G-5 | **`CsvColumnMap` has no typed columns for Sql, MQTT, MTConnect** | `CsvColumnMap.cs:340-452` | CSV import/export degrades to the raw `TagConfigJson` fallback while the 7 older drivers get typed columns. | ✅ Fixed — CSV maps for Sql/Mqtt/MTConnect |
| G-6 | **`RawBrowseCommitMapper` has no Sql branch** — falls to the generic `WriteSingleKey("address", …)` whose comment claims "Browsable drivers are all handled above" | `RawBrowseCommitMapper.cs:121-141`, fallback `:145` | Untrue since `SqlDriverBrowser` was registered (`EndpointRouteBuilderExtensions.cs:83`). Same failure mode the MTConnect branch (`:135-139`) was added to avoid: typed editor opens empty, blanks the field on save. | ✅ Fixed — **and at the browser end too**, which the audit missed |
| G-7 | **`EquipmentTagConfigInspector` covers only 6 driver types** | `:24-29` | Pre-existing (OpcUaClient + Galaxy absent too); no new driver was added. **The audit understated it:** the AdminUI's `TagConfigValidator` covers **11 of 12**, so the *deploy* gate is weaker than the *authoring* gate — and config arriving by CSV import or API only meets the deploy gate. | ◐ **Narrowed + guarded** — MQTT wired, reflection guard added; 4 drivers still have no Contracts-side `Inspect`. See §9 |
### 1.3 Stale in-code comments (harmless at runtime, misleading to the next reader)
> ✅ **All cleared** (`6a01358b`, `d32d89c3`). Listed as found.
- `DriverTypeNames.cs:22-25` — Calculation described as "not-yet-registered"; it registers at `DriverFactoryBootstrap.cs:149`.
- `TagConfigEditorMap.cs:25-28` + `TagConfigValidator.cs:27-28` — "`DriverTypeNames.Sql` deliberately absent until Task 11"; the constant exists (`DriverTypeNames.cs:57`) and the values are identical, so behaviour is correct.
- `RawDriverTypeDialog.razor:2-4, 59-60` — "its factory lands in Wave C".
- `CsvColumnMap.cs:322-324` — hardcodes `CalculationDriverType = "Calculation"` "Not yet a `DriverTypeNames` constant"; it has been one since `DriverTypeNames.cs:54`.
- ~~`SqlDriver.cs:218-220` — justifies not re-parsing config on the premise "the factory builds a fresh
instance", **disproved** by `DriverInstanceActor.cs:316` + `DriverHostActor.cs:2565`.~~ ✅ **Fixed**
the comment now says the premise was false when written and is true *now* because a config change
respawns. See §9.
---
## 2. Open tracker issues — all 17 verified against source
**No issue was found stale.** 11 CONFIRMED, 4 PARTIALLY (core claim holds, a sub-claim is wrong or
incomplete), 2 CANNOT VERIFY (correctly filed as hardware/fixture-gated).
> **Since the audit:** three of these are **resolved by the remediation but still open on the tracker** —
> #516, #518 and #507 (marked ✅ below). Four new issues were filed from §3 and §9: **#520** (ACL
> enforcement), **#521** (`IHostConnectivityProbe`), **#522** (stability tiers), **#523** (remove the
> periodic re-browse). The tracker therefore shows **21 open**, of which 3 are stale-open. Everything not
> marked below is unchanged.
| # | Title (abbrev.) | Verdict | Key evidence | Kind |
|---|---|---|---|---|
| **518** ✅ | `IRediscoverable` + `IHostConnectivityProbe` have no consumer; CLAUDE.md wrong — **RESOLVED `09a401b8`: rediscovery consumed as an operator prompt; probe half split to #521. Stale-open on tracker.** | **CONFIRMED — worse than filed** | Only `+=` in `src/` are Galaxy self-wiring (`GalaxyDriver.cs:586`, `:221`). `GetHostStatuses()` has **zero call sites** outside the interface + implementations; `DriverHostStatuses` DbSet is referenced only by its declaration and a test. | Defect + doc error |
| **507** ✅ | Discovered-node injection inert in v3 — **RESOLVED `adce27e7`: not revivable (its two inputs are structurally empty in v3), so the path was DELETED, superseded by `/raw` browse-commit. Stale-open on tracker.** | **CONFIRMED** | `DriverHostActor.cs:986-1002` hard-`return;` + `#pragma warning disable CS0162` (restored `:1077`) | Deferred (deliberate guard) |
| **519** | No Bad/Uncertain **sub-code** reaches a client | **CONFIRMED** | Collapse at `DriverInstanceActor.cs:1033-1041` (`statusCode >> 30`), re-expand at `OtOpcUaNodeManager.cs:3318-3322`. Enum is 3-state at `IOpcUaAddressSpaceSink.cs:165`. **Wider than filed**`StatusFromQuality` also used at `:429/:506/:576/:761`, so alarm-condition Quality collapses too. | Design consequence |
| **517** | Health published AFTER teardown | **CONFIRMED, fleet-wide** | `ModbusDriver.cs:244-250`, `MTConnectDriver.cs:579-590`, `S7Driver.cs:292-326` — all `Teardown…` then `WriteHealth(Unknown)`; `GetHealth()` is a plain field read | Defect (race) |
| **516** ✅ | Driver config edits silently discarded — **RESOLVED `d32d89c3` + `5184a2e1`: seam respawn + in-place re-parse; all 12 drivers now honour a changed config. Stale-open on tracker.** | **CONFIRMED — survey now complete** | `DriverInstanceActor.cs:316` (only `_driver` assignment) + `DriverHostActor.cs:2565` (`Tell` existing child, no respawn) + `:653/:660` (green seal). **Affected: Modbus, FOCAS, OpcUaClient, + AbLegacy (issue missed it), + Sql (deliberate/documented).** AbCip/TwinCAT re-parse ✅; Galaxy fails **loud** (throws, `GalaxyDriver.cs:600-641`) | Defect |
| **515** | Sparkplug `DataSet`/`Template`/`PropertySet` | **CONFIRMED** | `SparkplugCodec.cs:207-210` decode to `Unsupported` — visible refusal, not silent drop. *(`PropertySet` isn't a `ValueOneofCase`; it rides `metric.Properties`)* | Deferred feature |
| **514** | Rebirth unarmable on a plant that hasn't birthed | **CONFIRMED** | `RawBrowseModal.razor:119-120` disabled; `_rebirthTarget` set only by tree-click handlers (`:432`, `:437`, `:439-451`). Empty tree ⇒ permanently disabled | Defect (chicken-and-egg) |
| **513** | Meaningless `payloadFormat` in Sparkplug blob | **CONFIRMED** | `MqttTagConfigModel.cs:210` unconditional `Set`, vs. mode-aware `:216` | Defect (cosmetic) |
| **512** | `.Browser``.Driver` layering | **CONFIRMED** | `…Mqtt.Browser.csproj:43` (**not :44**), boxed "KNOWN, DELIBERATE EXCEPTION" at `:16-42` | Deferred refactor |
| **511** | `ActAsPrimaryHost` does nothing | **CONFIRMED** | Option at `MqttDriverOptions.cs:193`, round-tripped in the UI, handled only by a warning at `MqttDriver.cs:933-948`. No STATE publish, no Last Will | Deferred feature |
| **510** | `_canRebirth` captured at browse-open | **CONFIRMED** | `RawBrowseModal.razor:377` sole `true` assignment; failure path `:516-539` never re-evaluates | Defect (stale affordance) |
| **509** | Metric name with `/` unbrowse-committable | **CONFIRMED** | `RawBrowseCommitMapper.cs:63` uses browse name verbatim → `RawPaths.cs:39` rejects `/``RawTreeService.cs:976` **all-or-nothing** kills the whole batch | Defect |
| **508** | MQTT write-through | **CONFIRMED** | `MqttDriver.cs:66` — no `IWritable`; consequence at `DriverInstanceActor.cs:673-677` | Deferred feature |
| **507**→ see above | | | | |
| **491** | Continuous-historization live gate | **CANNOT VERIFY** (needs VPN'd gateway) | Code **is** complete: `AddressSpaceApplier.cs:238``:540``ActorHistorizedTagSubscriptionSink.cs:27-38``ContinuousHistorizationRecorder.cs:84`, wired `ServiceCollectionExtensions.cs:435` | Deferred test |
| **489** | Hot-rebind renamed raw tag | **CONFIRMED** | No implementation exists | Deferred feature |
| **481** | Comms-loss → scripted-alarm quality (Layer 4) | **CONFIRMED** | `DriverHostActor.cs:1356-1379` iterates `_alarmNodeIdByDriverRef` only. **Issue is wrong that the per-driver ref set is missing**`_nodeIdByDriverRef` exists at `:193`; only the fan-out into the mux is absent, so the work is *smaller* than filed | Deferred feature |
| **468** | Wave-0 browser tree render | **CANNOT VERIFY** (fixture) | Code path present (`AbCipDriver.cs:1092-1095`, `LibplctagTagEnumerator.cs:29-37`); `ab_server` returns `ErrorUnsupported` for CIP Symbol Object enumeration | Deferred verification |
### 2.1 Cross-cutting observations
1. **#518#507 are the same failure, stacked.** The event has no subscriber *and* its downstream
handler hard-returns. **Fixing either alone changes nothing observable** — schedule them together.
2. **#516 is a superset of part of #489.** For the five drivers that discard reinit config, the
renamed-tag-never-rebinds symptom follows mechanically (their `_tagsByRawPath` still keys on the
old RawPath). Re-scope #489 after #516.
3. **#519 and #481 share the same `statusCode >> 30` collapse idiom** at three independent sites
(`DriverInstanceActor.cs:1033`, `ScriptedAlarmEngine.cs:745`, `:777`). Widening the status path
must cover all three.
---
## 3. Dead seams — authored, shipped, never executed
**The most consequential category in this register**, and the one nothing in the code flags. Three
subsystems are fully built and reachable by operators, but no production code path executes them.
> **Since the audit:** one of the three is now consumed (§3.2's `IRediscoverable` half). The other two
> were **not** fixed — that was the deliberate call, because each is a keep-or-delete decision rather than
> a defect. What changed is that they can no longer mislead: the non-enforcement is stated in the docs and,
> for ACLs, warned about **in the authoring UI itself**, so an operator cannot save a rule believing it
> does something. Tracked as **#520**, **#521**, **#522**.
### 3.1 Node-level ACLs are authorable, deployed — and unenforced
> ◐ **Still true; now impossible to miss** (`53ede679`, Gitea **#520**). `docs/security.md`,
> `docs/ReadWriteOperations.md`, `docs/v2/acl-design.md` and `v2-release-readiness.md` corrected, and
> `ClusterAcls.razor` / `AclEdit.razor` carry a warning alert. The audit named
> `docs/ReadWriteOperations.md` as the highest-risk doc; **`docs/security.md` was worse** — it claimed an
> anonymous session is default-denied any node it has no grant for, when in fact it can read the entire
> address space. #520 records the four real blockers to a wire-up, of which the Raw-realm scope gap is a
> design decision rather than wiring.
- `IPermissionEvaluator`, `TriePermissionEvaluator`, `PermissionTrieCache`, `PermissionTrieBuilder`
have **zero references outside `src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/` and their own
tests** (independently re-verified for this report: every other grep hit is a `bin/`/`obj/` XML doc artifact).
- `OtOpcUaNodeManager` contains **no reference to `Permission` or `Evaluator` at all.**
- Meanwhile `ClusterAcls.razor` + `AclEdit.razor` let operators author `NodeAcl` rows, and
**`ConfigComposer.cs:51` snapshots them into every deployment artifact.**
**Impact:** an operator authors a deny rule, deploys it green, and it has no effect. Actual
enforcement is coarse LDAP roles plus the realm-qualified `WriteOperate` gate in the node manager —
and that is a **write** gate; reads carry no per-node ACL check.
**`docs/ReadWriteOperations.md:13,21` states the opposite** (see §7.1) — it is the single
highest-risk doc error found.
### 3.2 `IRediscoverable` + `IHostConnectivityProbe` raise into the void
> ✅ **`IRediscoverable` half FIXED** (`09a401b8`) — consumed by `DriverInstanceActor` and surfaced as a
> `/hosts` re-browse prompt. Advisory: the served address space is not rebuilt, because v3 authors raw tags
> through `/raw` browse-commit.
> ⏳ **`IHostConnectivityProbe` half UNRESOLVED** (Gitea **#521**) — `GetHostStatuses()` still has zero
> production call sites and nothing writes a `DriverHostStatus` row. Not deleted on inference: the table was
> deliberately re-created in the v3 initial migration.
Gitea #518/#507. Nine drivers raise; nothing consumes. `DriverHostStatus` table exists
(`OtOpcUaConfigDbContext.cs:48`, migration `20260715230637_V3Initial.cs:108`) with a doc-comment
claiming a writer — **nothing ever writes a row.**
**Impact:** an Agent/PLC/Galaxy restart leaves a stale address space behind a Healthy driver, and the
per-host connectivity table is permanently empty.
### 3.3 `DriverTypeRegistry` is vestigial
> ◐ **Still true; documented** (`88ce8df0`, Gitea **#522**). `docs/v2/driver-stability.md` now states its
> tier table is aspirational. A second staleness the audit missed is corrected there too: the
> **separate-Windows-service hosting** that document describes for Tier C no longer exists either (Galaxy
> moved to the mxaccessgw sidecar in PR 7.2; FOCAS went in-process with its managed wire client). No
> runtime change — enabling recycle on two live drivers wants its own live gate.
Independently verified for this report: `src/Core/…Core.Abstractions/DriverTypeRegistry.cs:20` is
referenced **only** by `tests/Core/…/DriverTypeRegistryTests.cs`. Nothing registers metadata at
startup; nothing validates `DriverInstance.DriverType` against it. Its `AllowedNamespaceKinds` field
was retired with the v3 `Namespace` entity (`DriverTypeRegistry.cs:97`).
**Knock-on:** the driver **stability tier** does not come from here either. The live source is
`DriverFactoryRegistry.cs:46` (`DriverTier tier = DriverTier.A`) and **no factory in `src/Drivers/`
passes a tier** — so all 12 drivers run Tier A, and the Tier-C-only protections are dormant for
every driver: `MemoryRecycle.cs:54` (`when _tier == DriverTier.C`) and
`ScheduledRecycleScheduler.cs:43` (refuses unless Tier C). `docs/v2/driver-stability.md` still
assigns Galaxy and FOCAS to Tier C.
### 3.4 Related dead/dormant code
| Item | Evidence |
|---|---|
| `IDriverConfigEditor` — interface, zero implementors, zero production consumers | `Core.Abstractions/IDriverConfigEditor.cs:9`; superseded by `TagConfigEditorMap` / `DriverConfigModal` |
| `GenericDriverNodeManager` — explicitly **not** a production dispatch path | `Core/OpcUa/GenericDriverNodeManager.cs:71` ("verified 07/#10: zero production references") |
| `IDriverSupervisor` — Tier-C recycle machinery has **zero implementations**, yet the parser still validates `RecycleIntervalSeconds` | archreview 01/U-2 |
| Resilience **bulkhead** — documented, defaulted, parsed, AdminUI-authorable; `Build` never adds a concurrency limiter | archreview 01/U-6 |
| `WriteIdempotentAttribute` — zero readers; write dispatch hardcodes `isIdempotent: true` | archreview 01/S-8 = 03/S12 |
| ~60 lines of unreachable code retained behind #507's guard | `DriverHostActor.cs:1002`, `#pragma warning disable CS0162` |
| Three empty `Driver.Historian.Wonderware*` directories (0 `.cs`, no `.csproj`, absent from `.slnx`) | verified for this report — only stale `bin/`+`obj/` remain |
---
## 4. In-code deferrals by subsystem
The tree is unusually clean on classic markers: **9 `TODO`s across 1,812 files; zero
`FIXME`/`HACK`/`XXX`/`WORKAROUND`; zero `#if false`; zero `[Obsolete]` on project code; zero
commented-out DI registrations.** Real deferred work lives in prose doc-comments and skipped tests.
### 4.1 Highest-consequence
| Item | Evidence |
|---|---|
| **Telemetry snapshot cache never evicted** — a decommissioned driver replays last-known Health/Resilience to new subscribers forever | `TelemetryLocalHub.cs:40` `TODO(mesh-phase5+)` — self-declared, bounded, "pre-production, accepted" |
| **Device host recovered by string-matching instead of `DeviceConfig`** — 7 of the repo's 9 TODOs are this one item, across 4 drivers | `FocasDriver.cs:137,287`; `TwinCATDriver.cs:775`; `AbLegacyDriver.cs:557`; `AbCipDriver.cs:89,1272` + `AbCipDriverOptions.cs:130` — all `TODO(v3 WaveC)` |
| **Galaxy writes can never confirm a COM commit** — empty status array ⇒ provisional Good, metered `galaxy.writes.unconfirmed` | `GatewayGalaxyDataWriter.cs:44,328,354`**cross-repo**, blocked on mxaccessgw |
| **Subscription liveness undetectable**`ISubscriptionHandle` is opaque | `DriverInstanceActor.cs:527` (#488) — well-reasoned, deferred "until a real occurrence" |
| **`/raw` "Browse device" is still a placeholder** despite the universal discovery browser landing | `RawTree.razor:15,426`**verify**; may be reachable now via `DiscoveryDriverBrowser` |
### 4.2 Per-driver
- **S7** — array **writes** unsupported entirely (`S7Driver.cs:1139`); several array *read* types unsupported (`:386,404,413,419,428,442,465,807,959`) — all fail fast at config-validation; legacy C-area counter reinterpretation live-hardware-gated (`:759`); Counter BCD on S7-300/400 undecoded.
- **AbCip** — ALMD-only alarm projection, ALMA deferred (`AbCipAlarmProjection.cs:16,324`); no CIP Template Object reader, UDT layouts declaration-driven (`AbCipDriverOptions.cs:172`); whole-UDT writer deferred (`LibplctagTagRuntime.cs:190`); `AckCmd` pulse must be wired client-side (`:30`).
- **Sql** — `SqlTagModel.Query` deferred to P3, **enforced end-to-end** across parser, validator, planner and editor (`SqlProvider.cs:22,37`; `SqlEquipmentTagParser.cs:24,103`; `SqlGroupPlanner.cs:44,249`; `SqlTagConfigModel.cs:114`) — exemplary deferral discipline. Postgres/ODBC dialects deferred behind `ISqlDialect`.
- **MTConnect** — `TIME_SERIES` vectors deferred to P1.5; `DATA_SET`/`TABLE` coded `BadNotSupported` with a null value rather than approximated (`MTConnectObservationIndex.cs:53,272`) — deliberately chosen over a Good-coded lie. `RawTagEntry.DeviceName` ignored for routing (two `/raw` Devices under one driver share an Agent connection) — "a design change, not a bug fix". No DataType-vs-blob validation at authoring.
- **MQTT** — `MaxPayloadBytes` is a constructor knob, not operator-facing (`MqttSubscriptionManager.cs:170`).
- **TwinCAT** — Flat-mode struct expansion carries a **LIVE RISK** note, unverified against a real TC3 target (`AdsTwinCATClient.cs:270`); VirtualTree-mode browse unverified; TC2 coverage, multi-hop AMS route and lab rig all hardware-blocked (`docs/v3/twincat-backlog.md`).
- **FOCAS** — forces read-only regardless of authored `writable:true`; servo-load semantics inferred from the wire, unconfirmed against a real CNC (`FocasWireClient.cs:944`); `"Backend": "unimplemented"` is a **deliberate fail-fast stub**, not unfinished work.
- **Historian.Gateway** — String/DateTime/Reference writes deferred, gated on the analog SQL write path; `UInt16 → Uint4` widening because the `UInt2` write path is deferred upstream (`HistorianTypeMapper.cs:27-51`).
### 4.3 Fleet-wide OPC UA gaps
- **Array writes deferred across all drivers**; **multi-dimensional arrays (`ValueRank>1`) unsupported**; **array historization** is an opaque blob with no per-element history (`docs/Uns.md:288-292`, `AddressSpaceApplier.cs:873`, `OtOpcUaNodeManager.cs:1764`).
- **`HistoryUpdate`** — permission bit `1<<12` shipped but excluded from every composite bundle; no service surface (`NodePermissions.cs:34`).
- **`Utils.Log*` is `[Obsolete]`** in SDK 1.5.378, suppressed at 8 sites in `OtOpcUaNodeManager` pending an `ITelemetryContext` rewire.
### 4.4 Skipped tests — 36, from 4 reason constants
| Count | Reason | Assessment |
|---|---|---|
| ~~18~~ 0 | ~~`DiscoveryInjectionDormantV3`~~ | ✅ **Deleted** (`adce27e7`). The audit's "Real — blocked on #507" was **wrong**: these were v2 characterization tests asserting an equipment-rooted graft (`EquipmentRootNodeId == "EQ-1"`) that v3 cannot produce, so they could never have unskipped as written. `Runtime.Tests` skipped went 31 → 13. |
| ~~13~~ 0 | ~~`EquipmentTagsDarkBatch4`~~ | ✅ **Resolved** (2026-07-28). The audit's "STALE" call was right and the constant is now **deleted**. Not by bulk-unskipping: 6 were **revived** onto the raw/UNS shape that replaced the subject, 3 were **folded** into a live parity test whose corpus was widened to cover them, 4 were **deleted** as genuinely retired. See §9. |
| 4 | `EquipmentDeviceBindingRetired` | Intentional tombstones preserving retirement rationale — fine as-is |
Plus **4 whole OpcUaClient test suites that are scaffold-only**, awaiting an in-process OPC UA server
fixture that was never built (`OpcUaClientSubscribeAndProbeTests`, `…DiscoveryTests`,
`…ReadWriteTests`, `…HistoryTests`, all `:10`).
All env-gated integration suites **skip cleanly** when their vars are absent. The MTConnect gate is
notably a **real `/probe` reachability + payload-shape check** (`MTConnectAgentFixture.cs:42`), not
mere env-var presence, so it cannot pass vacuously.
---
## 5. Open live gates
| Gate | Status | Evidence |
|---|---|---|
| ~~**Tag-set drift detection** (shipped `e77c8a35`)~~ | ✅ **CLOSED BY DELETION** — removed under Gitea **#523** rather than gated. The gate it needed (a device whose tag set actually changes) required exactly the traffic the removal objected to. | §9, 2026-07-28 (#523) |
| **Continuous-historization value capture** (#491) | **OPEN** — code complete, unproven in production | `CLAUDE.md:710`; no gate-passed commit exists |
| **Wave-0 universal discovery browser** full tree render (#468) | **OPEN** — fixture-blocked | tracking doc §Wave 0; named as blocking BACnet browse |
| **archreview R2-03** — VT Good→Bad on a data-fed rig | **OPEN** — explicitly "live-blocked on this data-less rig" | `archreview/plans/STATUS.md` |
| **archreview R2-10** — force breaker-OPEN live | **OPEN** — breaker-OPEN state never forced live | `archreview/plans/R2-10-*.tasks.json` |
| **Driver-reconfigure-while-faulted** Task 3 | **OPEN** — task subject literally "DEFERRED" | `2026-07-14-driver-reconfigure-while-faulted-plan.md.tasks.json` |
| Galaxy `ScanStateProbeParityTests` | **OPEN** — licence-gated (one `$WinPlatform` on this box) | `docs/v2/Galaxy.ParityRig.md:180` |
| **v2 GA exit criteria** — FOCAS live-CNC wire smoke (#54), OPC UA CTT/compliance pass, Ignition 8.3 redundancy cutover | **OPEN** — manual/hardware/external-tool | `docs/v2/v2-release-readiness.md:105-120` |
| mesh Phase 4 | ✅ **PASSED** `1281aebf` | CLAUDE.md said open — **corrected**, §7.2 |
| mesh Phase 5 | ✅ **PASSED** `f134935f` | CLAUDE.md said pending — **corrected**, §7.2 |
| auto-down 1-vs-1 crash-the-oldest | ✅ **PASSED** — Phase 7 drill D4, `c50ebcf7` | CLAUDE.md said open — **corrected**, §7.2 |
**Note on the 14 archreview `deferred-live` tasks across 8 plans:** `archreview/plans/STATUS.md:242-305`
records a 2026-07-13 live pass closing several (R2-02, R2-07, R2-11, R2-04, R2-05-partial) and
2026-07-15 closures for R2-01/R2-08/R2-06 — **but the `.tasks.json` files were never updated.** Only
R2-03 and R2-10 have no closure record anywhere.
---
## 6. Plan bookkeeping — stale vs. live
**59 of 78 `docs/plans/*.tasks.json` are 100 % complete.** Of the remaining 19, most are stale
bookkeeping, not backlog.
### 6.1 STALE — work shipped, file never updated (~140 phantom "pending" tasks)
> ✅ **Swept** (`88ce8df0`). 13 files closed — the 8 below plus `mesh-phase4`, the 4th `stillpending`
> phase file, and `historian-tcp-transport` closed as **OBSOLETE**. Each carries a `closureNote` naming the
> evidence. 11 archreview gates were also written back from `STATUS.md`; **R2-03 and R2-10 were deliberately
> NOT closed** — `STATUS.md`'s own per-item detail contradicts its "every gate is GREEN" headline. See §9.
| File | Recorded | Reality |
|---|---|---|
| `2026-07-24-mtconnect-driver.md.tasks.json` | 23 pending | Merged at master tip `90bdaa44`, 5-leg live gate PASSED |
| `2026-07-24-mqtt-sparkplug-driver.md.tasks.json` | 27 pending | All 27 done, both phases live-gated |
| `2026-07-24-sql-poll-driver.md.tasks.json` | 22 pending | Merged `4ad54037` + follow-ups `28c28667` |
| `2026-07-24-modbus-rtu-driver.md.tasks.json` | 11 pending | Merged `0f38f486` (#495), live gate PASSED |
| `2026-06-26-otopcua-historian-gateway-integration.md.tasks.json` | 21 pending | Shipped as PR #423, live-validated |
| `2026-06-15/16-stillpending-phase-{0-1,2,3,4}.md.tasks.json` | 12+9+10+8 pending | Shipped — audit §D cites `1e95856b`, `ada01e1a`, `fc8121cb`, `3e609a2b`, `70e6d3d2`, `c236263e`, `bd8fee61`, `5e27b5f7`, `fcb38014` |
| `2026-07-22-per-cluster-mesh-program.md.tasks.json` | Phase 2 in_progress, 37 pending | Plan body `:331-342` says **program COMPLETE** |
| `2026-06-12-historian-tcp-transport.md.tasks.json` | 12 pending | **OBSOLETE** — targets the retired Wonderware sidecar |
### 6.2 GENUINELY LIVE
| File | Open | Note |
|---|---|---|
| **`2026-05-29-adminui-followups.md.tasks.json`** ⬅ **now the largest open item in `docs/plans/`** | **19 pending / 0 completed** | **The largest unexecuted plan in the tree.** Generic `CollectionEditor<TRow>`; per-driver device/tag editors ×7; typed resilience form; `RoleMapper.Merge` + DB-backed LDAP→role mapping + `RoleGrants.razor` + FleetAdmin authz. **Several items look superseded by v3's `TagConfigEditorMap` — verify overlap before executing.** |
| `2026-06-19-followups-batch.md.tasks.json` | 4 pending + 1 partial | B4 F10b surgical DataType/IsArray writes and B5 alarm-severity `SetSeverity` are **OPEN-by-choice, not built** |
| `2026-06-26-otopcua-fixedtree-followups.md.tasks.json` | 1 pending | Task 11 build + regression |
| `2026-05-26-akka-hosting-alignment-plan.md.tasks.json` | 5 partial | F8/F9/F10/F13/F14 — oldest open items; **likely superseded by v3, verify** |
| `2026-05-28-adminui-driver-pages-plan.md.tasks.json` | 1 partial | 10.4 manual smoke checklist |
`docs/plans/2026-07-23-mesh-phase4-…tasks.json` Task 9 was the only `deferred`-flagged mesh task —
**it has since landed** (`3a590a0c`), so that file is now stale too.
### 6.3 Review-directory state
- **`code-reviews/`** — 51 modules, **zero Open findings**. One textual "Left Open pending a decision"
survives in a finding body (`Configuration/findings.md:254`, LDAP collation) whose status field
reads Deferred — the only index inconsistency.
- **`code-reviews/` coverage gap** — **10 shipped source projects have never been reviewed**:
`Driver.Calculation`, `Driver.Historian.Gateway`, `Driver.Mqtt` (+`.Browser`, `.Contracts`),
`Driver.MTConnect` (+`.Contracts`), `Driver.Sql` (+`.Browser`, `.Contracts`). Meanwhile three
modules review the **retired** Wonderware backend.
- **`archreview/`** — `00-OVERALL.md:60-79` carries an explicit "Carried open … no remediation branch
targeted them" block. The largest coherent batch is the **failure-visibility trio**: 01/S-1
(`AddressSpaceApplier` swallows every sink failure, deploy reports Applied even when broken), 03/S4
(primary gate default-allow on unknown role), 06/S-1 (Galaxy optimistic write success).
- **`stillpending.md` does not exist** — the backlog is `docs/plans/2026-06-18-stillpending-backlog-audit.md`.
---
## 7. Documentation corrections
### 7.1 Applied in this pass
| File | Was | Now |
|---|---|---|
| `CLAUDE.md` §Change Detection (`:156`) | "The server's `DriverHost` consumes the signal and rebuilds the address space" | ⚠️ nothing consumes it; names `DriverHostActor.cs:986`, #518/#507, and the four affected drivers |
| `CLAUDE.md` §Redundancy (`:272`) | auto-down "live gate is still open … docker-dev is a single six-node mesh" | gate **CLOSED** by Phase 7 drill D4 (`c50ebcf7`); the six-node-mesh claim also contradicted CLAUDE.md's own Phase 6 paragraph |
| `CLAUDE.md` §Telemetry (`:383`) | Phase 5 "code-complete on `feat/mesh-phase5`, live gate pending" | merged `35552a96`; live gate PASSED `f134935f` |
| `CLAUDE.md` §Phase 4 (`:493`, `:502-503`) | `ScriptedAlarmState` "dropping it is a deferred follow-up, tracked as Task 9"; "Task 9 and the live gate (Task 10) are still open" | table **dropped** (`3a590a0c`, migration `20260723183050_DropScriptedAlarmStateTable`); live gate PASSED `1281aebf` |
| `docs/plans/2026-07-24-driver-expansion-tracking.md` | Modbus RTU "pending merge"; SQL poll "📝 Plan ready (22 tasks)"; **an "Executing a plan" table instructing you to rebuild both** | both marked ✅ Done with merge SHAs; the two shipped rows struck from the command table; Wave 1/2 headings and §Next actions corrected |
| `docs/drivers/README.md` | `DriverTypeRegistry` "registered at startup"; "namespace kinds (Equipment + SystemPlatform)"; no Sql/Calculation rows; "Modbus TCP" only | registry marked vestigial + tier truth; namespace line corrected to Raw+UNS; Sql + Calculation rows added; Modbus row covers RTU-over-TCP |
| `docs/drivers/TwinCAT.md` (`:95`, `:99-103`, `:151`) | "so the address space is rebuilt" / "so Core rebuilds the address space" | raises-but-unconsumed caveat, mirroring the wording `Mqtt.md`/`MTConnect.md` already use correctly |
| `docs/drivers/Galaxy.md` (`:73`) | `IRediscoverable` row with no caveat | same caveat added |
| `docs/v2/Runtime.md` (`:106`) | "named-pipe IPC to the Wonderware historian sidecar + `SqliteStoreAndForwardSink`" | gRPC to HistorianGateway + `LocalDbStoreAndForwardSink` |
| **`docs/ReadWriteOperations.md`** (`:13`, `:21` + banner) | "ACL enforcement (`WriteAuthzPolicy` + `AuthorizationGate`) runs before the source branch"; "**A denied read never hits the driver**" | Both struck through and corrected; banner states plainly that **no per-node ACL gate exists on any operation** and that `GenericDriverNodeManager` is test scaffolding. **This was the highest-risk doc error found.** |
| **`docs/IncrementalSync.md`** (banner) | whole "Rebuild flow" + generation-publish path presented as current | Banner: `GenericDriverNodeManager` is non-production, rediscovery has no consumer, `sp_PublishGeneration`/`sp_ComputeGenerationDiff` `RAISERROR`, `DraftRevisionToken` gone; names the one live path (deploy artifact) |
| **`docs/AddressSpace.md`** (banner) | "single custom namespace"; `Phase7Applier`/`Phase7Composer`/`GalaxyTagPlan`; `SystemPlatform` | Banner: two namespaces w/ `V3NodeIds`, the `AddressSpace*` rename (`40e8a23e`), `AddressSpaceComposition` as the result type, `SystemPlatform` retired |
| `docs/v2/phase-7-status.md`, `v2-release-readiness.md`, `redundancy-interop-playbook.md`, `AdminUI-rebuild-plan.md` (banners) | present-tense claims about types that no longer exist | Dated "⚠️ Historical" banners naming the specific dead types. `v2-release-readiness.md`'s banner also flags that its `:41` "ACL enforcement Closed" row is false, while noting its unchecked GA exit criteria **are** still live. (`docs/StatusDashboard.md` already self-declares Superseded — left alone.) |
| `docs/OpcUaServer.md` (`:14`, `:51`), `docs/README.md` (`:65`), `docs/drivers/OpcUaClient-Test-Fixture.md` (`:127`) | `WriteAlarmState`; `LdapUserAuthenticator` ×2; `RedundancyCoordinator` | `WriteAlarmCondition`; `LdapOpcUaUserAuthenticator`; `RedundancyStateActor` + `IRedundancyRoleView` |
| `docs/Historian.md` | no mention of #491 or the provisioning gap | ⚠️ block added: continuous value capture is **code-complete but unproven in production**, with the wiring chain; plus the `EnsureTags`-skips-existing-tags gap |
### 7.2 Recorded, NOT applied — needs a decision or a rewrite, not a line edit
> ✅ **Priorities 12 are now done** (`53ede679`): `docs/ReadWriteOperations.md` was **rewritten**, not
> bannered — and it turned out to be fiction well beyond the ACL claims (`OnReadValue` has zero occurrences
> in `src/`; reads never reach a driver at all). `docs/security.md`, which this table did not list, was the
> highest-risk of the three. **Priority 3 and the rest remain open** — the `Phase7*`→`AddressSpace*` rename,
> `docs/v2/driver-specs.md` coverage, the missing `docs/drivers/Sql.md` / `Calculation.md`, and the
> `NoOpSecretReplicator` investigation. `docs/v2/driver-stability.md` moved to §3.3 / #522.
The three highest-priority items in this list were **bannered rather than rewritten** in this pass
(cheap, stops the misleading, preserves the record) — the underlying rewrite is still owed. They now
appear in §7.1 as well.
| Priority | File | Problem |
|---|---|---|
| **1 (security-relevant)** | `docs/ReadWriteOperations.md:13,21` | **Bannered + struck through.** Claimed ACL enforcement via `WriteAuthzPolicy` + `AuthorizationGate` + `NodeScopeResolver` and that "a denied read never hits the driver". All four names have zero hits in `src/`; §3.1 shows there is no read-path ACL gate at all. **Still owed:** a proper §-rewrite against the real node-manager write gate. Same false claim at `docs/v2/v2-release-readiness.md:41` (bannered). |
| **2** | `docs/IncrementalSync.md` | **Bannered.** Entire "Rebuild flow" (`:37-47`) built on non-production `GenericDriverNodeManager`; `:26-34` cites `sp_PublishGeneration`/`sp_ComputeGenerationDiff`, both retired stubs that `RAISERROR` (`20260715230637_V3Initial.StoredProcedures.cs:194`); `:31` cites `DraftRevisionToken` (0 hits); `:49-51` describes a `ScopeHint` no consumer reads. **Still owed:** rewrite around the deploy-artifact path, or archive to `docs/v1/`. |
| **2** | `docs/AddressSpace.md` | **Bannered.** v2-era throughout: `:7,42` claim a single namespace (v3 has two); `:7,48,70` cite `Phase7Applier` + `GalaxyTagPlan` + a nonexistent file path; `:62-64` repeats the rediscovery claim. **Still owed:** rewrite §"Root folder" + §"NodeId scheme" against `V3NodeIds` / `AddressSpaceRealm`. |
| 3 | 6 docs, ~20 citations | **`Phase7*``AddressSpace*`** rename (`40e8a23e`). Mostly mechanical, **except three that are not renames** and need the real member identified first: `Phase7CompositionResult`**`AddressSpaceComposition`** (`AddressSpaceComposer.cs:13`, verified for this report); `Phase7Composer.ExtractTagFullName` → nearest live seam is `ScriptTagCatalog.ExtractFullNameFromTagConfig`; **`Phase7EngineComposer.RouteToHistorianAsync` is simply gone** — the real seam is `HistorianAdapterActor``IAlarmHistorianSink`. |
| 3 | `docs/v2/driver-specs.md` | Covers 8 of 12 drivers (missing MQTT, MTConnect, Sql, Calculation, and the Modbus `Transport` selector) while `docs/drivers/README.md` calls it authoritative "for **every** driver". Either add four sections or downgrade the claim. |
| 3 | `docs/v2/driver-stability.md:47-54` | Puts Galaxy and FOCAS in Tier C; `docs/drivers/README.md` says Tier A for both. **Moot at runtime** — see §3.3 — but the two docs contradict each other. |
| 3 | `docs/drivers/Sql.md`, `docs/drivers/Calculation.md` | **Do not exist.** Sql is documented only in design/plan/research files; Calculation only at `docs/Raw.md:66-69`. |
| — | `docs/operations/2026-07-16-secrets-clustered-master-key.md:38` | `NoOpSecretReplicator` exists **only** in a test. **Investigate before editing** — the sentence makes a security-relevant claim ("no built-in cross-node replication today"), so it needs the real behaviour identified, not a rename. Left untouched deliberately. |
| — | `docs/v2/driver-specs.md` §2 | `docs/drivers/Modbus.md:10-11` points readers here for the Modbus config shape, and this file omits the `Transport` selector. Folded into the driver-specs item above. |
---
## 8. Recommended sequencing
> ✅ **All six executed**, merged as `914d70bf`. Each item below is struck through with its outcome; the
> full account is §9. Two items the sequencing did not anticipate were also completed (Sql/FOCAS in-place
> re-parse, tag-set drift detection) — the latter is now slated for removal under #523.
1. ~~**§3.1 ACL enforcement** — decide: wire `IPermissionEvaluator` into `OtOpcUaNodeManager`, or
remove the authoring UI and say plainly that ACLs are not enforced. Either way fix
`docs/ReadWriteOperations.md` first; it is the one that could mislead a security review.~~
**Done** — decided *make non-enforcement explicit*; wire-up tracked as Gitea **#520**. See §9.
(`docs/ReadWriteOperations.md` was **not** the sharpest one — `docs/security.md` was.)
2. ~~**#518 + #507 together** — neither fix is observable alone.~~**Done.** The framing was right
that they had to be handled together, but wrong about the resolution: **#507 was not revivable**,
so it was deleted rather than fixed. See §9.
3. ~~**#516** — silent config discard on 5 drivers; then re-scope #489, which it partly subsumes.~~
**Done.** See §9. #489 is still open and should now be re-scoped.
4. ~~**G-4** — extend the existing picker-parity test to `DriverConfigModal` + `DeviceModal`; it would
have caught G-1 and G-2 for free, and this class has now recurred twice.~~ ✅ **Done**, together with
G-1, G-2, G-3, G-5 and G-6. See §9.
5. ~~**Bookkeeping sweep** — mark the 8 stale `.tasks.json` files complete so the 19-task AdminUI plan
(§6.2) is visible as the real backlog it is.~~ ✅ **Done.** See §9.
6. ~~**§3.3 tier truth** — either pass real tiers at factory registration or delete the Tier-C
machinery; today it is documented, authorable and dormant.~~ ✅ **Documented**; the keep-or-delete
code decision is Gitea **#522**. See §9.
---
## 9. Execution log
Remediation of §8. **MERGED to `master` and pushed 2026-07-28, merge `914d70bf`** (branch
`feat/deferment-remediation`, 8 commits, merged `--no-ff` so each stage stays individually reviewable and
revertable). Status was updated in the **same commit** as the work, so this register never disagreed with
the tree.
> ⚠️ **The commit column was wrong until 2026-07-28 and is now corrected.** Each stage's SHA was captured
> with `git rev-parse HEAD` and then written into this file by amending that same commit — which changes
> the SHA. Every value recorded was therefore the *pre-amend* one: a dangling object reachable only through
> the reflog, on no branch, and due to vanish at the next `gc`. The register cited commits that effectively
> did not exist. The SHAs below are verified against `git log e08855fb..914d70bf`.
| §8 item | Status | Commit |
|---|---|---|
| 1. ACL enforcement decision | ✅ **Done** — decided *make non-enforcement explicit*; Gitea **#520** tracks the wire-up | `53ede679` |
| 2. #518 + #507 | ✅ **Done**`IRediscoverable` consumed as a re-browse prompt; #507's injection path **deleted**; `IHostConnectivityProbe` half split to Gitea **#521** | `09a401b8`, `adce27e7` |
| 3. #516 config discard | ✅ **Done** — seam respawn + in-place re-parse; completed for all 12 drivers by the follow-up below | `d32d89c3`, `5184a2e1` |
| 4. G-4 dispatch-map parity | ✅ **Done** — G-1…G-6 all closed, live-verified on docker-dev | `6a01358b` |
| 5. Bookkeeping sweep | ✅ **Done** — 13 plan files closed, 11 archreview gates written back | `88ce8df0` |
| 6. Tier truth | ✅ **Done** — docs reconciled; keep-or-delete is Gitea **#522** | `88ce8df0` |
| follow-up A | ✅ **Done** — Sql/FOCAS in-place re-parse made **atomic** | `5184a2e1` |
| follow-up B | ⛔ **Shipped, then removed by decision** — tag-set drift detection; **removal landed**, Gitea **#523** | `e77c8a35`, removed below |
### 2026-07-27 — §8.1 ACL non-enforcement made explicit
Decision: **do not wire the evaluator, do not delete it** — state plainly that it does not run, and
track the wire-up as **Gitea #520**.
Applied:
- `docs/security.md` — the `NodeScope`/`PermissionTrie`/"Dispatch gate" block now leads with a
**What is enforced today** table (the three real checks) before the design material, which is
labelled *Designed but not wired*.
- `docs/ReadWriteOperations.md`**rewritten**, not bannered.
- `docs/v2/acl-design.md` — ⚠️ NEVER WIRED banner.
- `docs/v2/v2-release-readiness.md` §"Security — Phase 6.2 dispatch wiring" — the CLOSED claim marked
as not holding.
- `ClusterAcls.razor` + `AclEdit.razor` — warning alerts stating rules are stored and deployed but
never evaluated.
**Four things this pass found that §7 and the original audit missed** — all worse than what was
already recorded:
1. **`docs/security.md` was the highest-risk doc, not `ReadWriteOperations.md`.** `:115` claimed an
anonymous session is "default-denie[d] any node a session has no ACL grant for". The opposite is
true: an anonymous session can Browse, Read, Subscribe and HistoryRead the **entire** address
space. It is refused only writes and alarm acks — and only because it carries no roles.
2. **Three of the five documented data-plane role strings do nothing.** `OpcUaDataPlaneRoles` declares
exactly `WriteOperate` and `AlarmAck`. `ReadOnly`, `WriteTune` and `WriteConfigure` are compared
against nowhere in `src/`, so mapping a group to `WriteTune` grants nothing and mapping one to
`ReadOnly` restricts nothing. The doc called all five "exact, case-insensitive, and **code-true**".
3. **`ReadWriteOperations.md` was fiction well beyond the ACL claims.** `OnReadValue` has **zero**
occurrences in `src/` — the whole documented read path did not exist. Reads never reach a driver at
all: the node manager is push-model and the SDK serves a client Read from the cached pushed value.
`WriteAuthzPolicy`, `_sourceByFullRef`, `_writeIdempotentByFullRef` and `IRoleBearer` are likewise
zero-hit, and `CapabilityInvoker` is not referenced by the `OpcUaServer` project at all.
4. **`v2-release-readiness.md` claimed a whole enforcement layer shipped.** Beyond the `:41` row §7.1
already flagged, `:46-50` describe `FilterBrowseReferences`, `GateCallMethodRequests`,
`MapCallOperation`, `AuthorizationBootstrap` and `Node:Authorization:*` config keys as Closed or
Partial. None of them exist. Whatever landed on the v2 branch did not survive into the shipped tree.
Also recorded in #520 and not previously known: every existing evaluator unit test runs in
`PermissionTrieBuilder`'s no-`scopePaths` *deterministic test mode*, so the production hierarchy path
is effectively untested; and `NodeAcl.ScopeId` has no FK or existence check, so scope ids dangle
silently.
### 2026-07-27 — §8.2 rediscovery as a signal; injection deleted
Decision: **signal, not mutation.**
`DriverInstanceActor` now consumes `IRediscoverable.OnRediscoveryNeeded` (attach in `PreStart`, detach
in `PostStop`) and carries it on the driver-health snapshot to `/hosts` as a **re-browse chip**. It is
advisory — the served address space is unchanged, because v3 authors raw tags through `/raw`
browse-commit and a runtime graft would materialise nodes nobody approved.
**#507's injection path was deleted, not fixed.** §2 recorded it as a "deferred (deliberate guard)".
That was too generous: its two inputs — `EquipmentNode.DriverInstanceId` and `EquipmentTags` — are
*structurally empty* in v3, so removing the guard would have changed a log line and injected nothing.
Deleted with it: `HandleDiscoveredNodes`, `PartitionDiscoveredByDeviceHost`,
`ApplyDiscoveredPlansForDriver`, the redeploy re-inject tail, `DiscoveredNodeMapper`,
`AddressSpaceApplier.MaterialiseDiscoveredNodes`, `OpcUaPublishActor.MaterialiseDiscoveredNodes`, the
connect-time discovery loop, and 4 files' worth of tests. `ITagDiscovery` stays — the `/raw` browse
picker drives it through `Commons/Browsing/DiscoveryDriverBrowser`, which never went through the actor.
**§4.4's "18 `DiscoveryInjectionDormantV3` — Real, blocked on #507" was wrong.** They were v2
characterization tests asserting an equipment-rooted graft (`EquipmentRootNodeId == "EQ-1"`) that v3
cannot produce. They could never have unskipped as written; they are deleted. Skipped tests in
`Runtime.Tests` went 31 → 13.
**The planned drift detector was deliberately NOT built.** The plan proposed diffing each connect-time
discovery pass against the authored raw tags. Reading the drivers killed it: **Modbus, S7, MQTT,
AbLegacy and Sql all echo authored config from `DiscoverAsync`** rather than browsing the device, so
the diff is permanently empty for them. It would have been another plausible-looking inert seam —
precisely the class this register exists to remove. That also removed the last consumer of the
connect-time loop, which is why the loop went too: it was browsing real devices up to ~15× per connect
and dropping the result.
Two traps worth keeping:
- `PublishHealthSnapshot` dedups on a health fingerprint, and a rediscovery raise changes none of the
four fields it hashed. The timestamp had to join the tuple or the dedup swallows the signal.
**Verified by reverting the one line — 3 of the 5 new tests go red.**
- The shared `StubDriver.GetHealth()` returns `DateTime.UtcNow`, so its fingerprint differs every call
and the dedup never engages. A dedup test built on it would pass whether or not the fix is present;
the new tests use a stub with stable health.
`IHostConnectivityProbe` was **not** resolved — it is a keep-or-delete decision, and the
`DriverHostStatus` table was re-created deliberately in the v3 initial migration, so deleting on
inference would be wrong. Split to Gitea **#521** with both options costed.
### 2026-07-27 — §8.5 bookkeeping + §8.6 tier truth
**13 plan files closed.** The 8 the register named, plus `mesh-phase4` (Task 9 landed `3a590a0c`), the
4th `stillpending` phase file, and `historian-tcp-transport` closed as **OBSOLETE** (it targets the
retired Wonderware sidecar; there is no Wonderware backend in the tree). Each carries a `closureNote`
naming the evidence, so the next reader can check rather than trust.
**11 archreview live gates written back** from `STATUS.md`, which had recorded them passed since
2026-07-13/15 without ever updating the `.tasks.json` files: R2-01 #11, R2-02 #15/#18, R2-05 T15,
R2-06 T12, R2-07 T5/T6/T12/T14, R2-11 T22/T24.
**R2-03 and R2-10 were NOT closed** — and reading `STATUS.md` rather than trusting its own headline
("Every Round-2 live gate is now GREEN") is what kept that honest. Its per-item detail says R2-03 is
"live-blocked on this data-less rig" and R2-10 verified the pipeline but "breaker-OPEN state not
forced". Both now carry an `openNote` recording the blocker: R2-03 needs a reachable driver fixture
feeding a VT; R2-10 needs a rapid idempotent write to a dead endpoint, because R2-09's
`ConnectionBackoff` throttles retries so failures never accumulate fast enough to open the Polly breaker.
**Net effect (the point of the sweep):** ~140 phantom pending tasks are gone, so the genuinely
unexecuted **19-task AdminUI follow-ups plan** is now the largest open item in `docs/plans/` rather than
being buried. The remaining open plans are 8, and one of those (`mesh-phase6` Task 6) is a documented
`resolved-no-flip` decision rather than work.
**Tier truth:** `docs/v2/driver-stability.md` now states plainly that its tier table is aspirational —
every driver runs Tier A, and `MemoryRecycle` / `ScheduledRecycleScheduler` have never engaged. It also
corrects a second stale claim the register did not flag: the **separate-Windows-service hosting** it
describes for Tier C no longer exists (Galaxy went to the mxaccessgw sidecar in PR 7.2; FOCAS went
in-process when its managed wire client landed). No runtime change — turning recycle on for two live
drivers deserves its own live gate, not a docs-pass side effect. Tracked as Gitea **#522**.
### 2026-07-28 — the two things §8.3 deliberately did NOT build (1 of 2)
**Sql and FOCAS now re-parse in place too.** The first pass excluded them for a real reason — each derives
more than options from config, so adopting new options alone would run a **new tag set against an old
connection/backend**, and a half-applied change is worse than a discarded one because it looks like it
worked. Rather than accept that, the blocker itself is fixed: each factory now exposes a **`ParseBinding`**
that returns *every* config-derived dependency as one value, and the driver adopts them **atomically**.
| Driver | What one parse now yields | Adopted by |
|---|---|---|
| Sql | options + `ISqlDialect` + resolved connection string | `ApplyBinding`, which also rebuilds the provider factory, `Endpoint` and the `SqlPollReader` that captures all three |
| FOCAS | options + the `IFocasClientFactory` the `Backend` key selects | one assignment pair in `InitializeAsync` |
Details worth keeping:
- **Sql adopts BEFORE `BuildTagTable`**, so the tag table and the connection it will be polled over always
come from the same revision.
- **A test-injected `DbProviderFactory` survives a rebind** (`_explicitFactory`), so a re-derived dialect
cannot silently displace what a test passed in.
- **Re-resolving the connection string on reinit is a side benefit**: a rotated credential is picked up
without a process restart.
- A driver constructed **directly** gets no rebinder and keeps its constructor-supplied binding — every
existing `"{}"`-passing lifecycle test is unaffected by construction, not by luck.
**The tests pin ATOMICITY, not merely "a re-parse happened"** — a test that only checked options would have
passed against the broken version. Verified by simulating the *half-fix* (adopt options, skip the backend):
2 of the 3 FOCAS tests go red. Reverting Sql's rebind turns its connection-string test red. Sql also asserts
that a reinit which **cannot resolve** its new connection leaves the previous binding whole, rather than
half-adopting.
All 12 drivers now honour a changed config in place; `DriverSpawnPlanner`'s stop + respawn remains the outer
guarantee. Sql.Tests 226 / FOCAS.Tests 275 pass.
### 2026-07-28 — the two things §8.3 deliberately did NOT build (2 of 2)
> ⛔ **REMOVED — the periodic re-browse is gone from the tree (owner decision, Gitea #523; removal
> recorded in the next entry).** It shipped in `master` (`e77c8a35`, merged `914d70bf`) and this entry
> stays as the record of what was built and why. The objection is the one this entry does not weigh: the check is recurring, **unconfigurable** (the interval is a constructor parameter with no
> appsettings key) and **never live-gated** outbound traffic to real PLCs, CNCs and MTConnect Agents.
> Frequency of an unsolicited device browse is an operational decision, and the design treated it as an
> implementation detail.
>
> **Consequence to carry forward:** AbCip and FOCAS browse a live backend but implement no
> `IRediscoverable`, so after removal they have **no change signal at all** — a PLC re-download is
> invisible until someone re-browses by hand. TwinCAT and MTConnect keep their native event-driven
> signals and lose only redundant polling. The `IRediscoverable` consumption itself (`09a401b8`) is
> **not** affected: the driver tells us, we do not poll, and it costs nothing when nothing changes.
> If AbCip/FOCAS coverage matters later, the shape to reach for is event-driven — or a check that runs
> only when something else already indicates change (a reconnect, a deploy), not a wall-clock timer.
**The discovery-drift detector is built, and gated.** The original objection stands and is now *encoded*
rather than used as a reason not to build: Modbus, S7, MQTT, AbLegacy and Sql echo authored config from
`DiscoverAsync`, so a diff for them is a tautology. The missing piece was a discriminator — and the
codebase already had one. `ITagDiscovery.SupportsOnlineDiscovery` is documented as "enumerates the tag set
from the **live backend** rather than replaying pre-declared/authored tags", and it separates the fleet
cleanly: **FOCAS, TwinCAT, MTConnect, AbCip** true; the five config-echo drivers explicitly false.
The check runs only for a driver that is `SupportsOnlineDiscovery` **and** reports the new
`AuthoredDiscoveryRefs` (**nullable**, default null = opt out — an *empty* collection legitimately means
"nothing authored, everything discovered is new", and conflating the two would report total drift for a
driver that simply never opted in).
**Where the value is:** AbCip and FOCAS browse a live backend but implement **no `IRediscoverable`**, so
before this they had *no* change signal at all — a periodic compare is the only way to notice a PLC
re-download. TwinCAT and MTConnect already have native signals (symbol-version, agent instanceId); drift
detection is complementary there.
**A finding that changed the design.** FOCAS, TwinCAT and AbCip all **re-emit their authored tags into the
same `DiscoverAsync` stream** as the device-derived ones, so the browse picker can show an operator their
existing tags. That means discovered ⊇ authored *always*, and a **vanished** tag can never appear missing
— one whole direction is structurally undetectable for three of the four. Shipping a `Vanished` list that
is permanently empty for them would have been exactly the half-inert seam this register exists to remove.
So the driver declares `DiscoveryStreamIncludesAuthoredTags`, the detector does not compute the
undetectable half, and the operator message **says** "missing-tag detection unavailable for this driver"
rather than letting the absence of a missing-tag clause read as reassurance. **MTConnect is the only
driver where both directions work** — its stream is purely probe-model-derived.
Behavioural details: a **failed browse is not drift** (an unreachable device would otherwise report every
authored tag as vanished — the health surface already covers unreachability); a **truncated** capture is
skipped for the same reason; an **unchanged** drift is reported once rather than re-stamping its timestamp
every interval; drift that **resolves** clears the prompt so the next one is not deduped against a stale
signature. Interval defaults to 5 minutes — it browses a real device, and a tag set changing is an
engineering event, not a runtime one.
The comparison is a pure, separately-tested type (`TagSetDriftDetector`) rather than actor-inline logic.
14 new tests. The gate was verified load-bearing by deleting the `SupportsOnlineDiscovery` half — the
tautology-driver test goes red, and it asserts discovery was **never invoked** rather than merely "no
prompt raised", which would have passed for the wrong reason if the timer simply never fired.
### 2026-07-28 — #523: the periodic re-browse removed
The feature described in the entry above is **gone from the tree**. Removed:
| Removed | Where |
|---|---|
| `TagSetDrift` + `TagSetDriftDetector` | `Runtime/Drivers/TagSetDrift.cs` (file deleted) |
| The drift timer, `DriftCheckTick`, `DefaultDriftCheckInterval`, the `driftCheckInterval` Props/ctor parameter, `QualifiesForDriftCheck`, `Start`/`StopDriftCheck`, `HandleDriftCheckAsync`, `CollectLeafRefs`, `_lastDriftSignature` | `DriverInstanceActor` |
| `ITagDiscovery.AuthoredDiscoveryRefs` + `.DiscoveryStreamIncludesAuthoredTags`, and their four implementations | `Core.Abstractions`; FOCAS, TwinCAT, MTConnect, AbCip |
| `DriverInstanceActorDriftCheckTests` (6), `TagSetDriftDetectorTests` (8) | `Runtime.Tests` |
**What survives, deliberately.** `ITagDiscovery.SupportsOnlineDiscovery` stays — it predates drift
detection and is what the universal browse picker dispatches on. The `IRediscoverable` consumption
(`09a401b8`) is untouched: the driver tells us, we do not poll, and it costs nothing when nothing
changes. The `/hosts` re-browse chip stays and is now fed by that one source.
**The consequence, stated plainly rather than buried.** **AbCip and FOCAS now have no tag-change signal
at all.** Both browse a live backend, neither implements `IRediscoverable`, and the periodic compare was
the only thing that would have noticed a PLC re-download or a CNC option install. That is the accepted
trade, not an oversight — it is recorded in `CLAUDE.md` §Change Detection so the next reader does not
have to rediscover it, together with the shape to reach for if the coverage is wanted back
(event-driven, or triggered by a reconnect/deploy, not a wall-clock timer).
**Why the earlier entry's reasoning did not save it.** That entry defends the *design* — the gate is
real, the tautology drivers are correctly excluded, the undetectable direction is declared rather than
faked. All of that is true and none of it is the objection. The objection is to the feature existing at
all in that shape: it browsed real plant equipment on a recurring timer, at a frequency nobody could
configure, and that behaviour was never once observed against real hardware. A well-built mechanism for
something that should not run is still something that should not run.
Build clean; `Runtime.Tests` 476 passed / 13 skipped (unchanged skip set — the stale
`EquipmentTagsDarkBatch4` group), FOCAS 275, TwinCAT 192, MTConnect 491, AbCip 342,
`Core.Abstractions` 266, all green.
### 2026-07-28 — three silently-broken OpcUaClient integration tests (found by the full-suite run)
Not a register item — found because the full-solution run for the work above showed **three** failing
suites where §9's 2026-07-27 entry had recorded two. `Driver.OpcUaClient.IntegrationTests` was failing 3
of 4.
**It reproduced identically on clean `master`** (verified in a throwaway worktree), so it was not caused
by this work — but it was also not what it first looked like. The chain of wrong guesses is worth
recording, because each one would have closed the investigation early:
1. *"The fixture is down."*`otopcua-opc-plc` was `Up 12 days (unhealthy)`. Restarted it, waited for
healthy. **Still 3/4 failing.**
2. *"The simulator's node set drifted."* — the tests expect `ns=3;s=StepUp`. Browsed the live server:
all three nodes present at exactly those ids. Read `ns=3;s=StepUp` through Client.CLI against the
same endpoint: **`Good`, value 1379.**
3. *"So the driver has a read defect."* — the status code was `2150825984`, which I first decoded by
hand as `BadAttributeIdInvalid` and started hunting a wrong `AttributeId`. **The decode was wrong**
(`0x80330000`, not `0x80350000`) — it is `BadNodeIdInvalid`, and grep found that exact constant on
the driver's own *local* fault path, reached without any wire request.
The actual cause: **v3 made the driver resolve every reference through its authored `RawTags` table**
(RawPath → `TagConfig.nodeId` → live NodeId re-bound against the session's namespace table). A bare
`ns=3;s=StepUp` is no longer a reference the driver accepts. The smoke tests still passed bare node ids
and constructed the driver with an empty `RawTags`, so all three failed locally at the resolver — they
had been testing the unresolved-reference guard, against a perfectly healthy simulator.
Fixed by seeding `OpcPlcProfile.RawTags` with the four nodes in the same `{"nodeId": …}` TagConfig shape
the deploy artifact delivers, and addressing them by RawPath. **4/4 pass.**
Two things this says about the register itself: the 2026-07-27 "only pre-existing environment failures
remain" note was measured against an incomplete baseline (this suite was already broken and simply not
enumerated); and **fixture-gated integration suites rot invisibly** — a driver contract can change under
them and nothing on the local box notices, because the suite is expected to be red-or-skipped depending
on whether a remote container happens to be up.
### 2026-07-28 — §7.2 priority 3: the `Phase7*` → `AddressSpace*` rename
Applied across the four **live** docs (`VirtualTags.md`, `ScriptedAlarms.md`, `ScriptEditor.md`,
`v2/Architecture-v2.md`). The already-bannered historical files (`v2/phase-7-status.md`,
`v2/implementation/*`) were deliberately **left alone** — they are records of what existed at the time,
and renaming inside a historical record would falsify it.
§7.2 was right that three of these are not renames, and each was resolved to the real member:
| Cited | Reality |
|---|---|
| `Phase7CompositionResult` | → **`AddressSpaceComposition`** (and it now also carries the raw subtree, so the doc text was widened, not just renamed) |
| `Phase7Composer.ExtractTagFullName` | → **`ScriptTagCatalog.ExtractFullNameFromTagConfig`** |
| `Phase7EngineComposer.RouteToHistorianAsync` | **Gone, not renamed** — the routing moved into `HistorianAdapterActor` → the registered `IAlarmHistorianSink`. Called out inline so the next reader does not go looking for a renamed symbol. |
**Found en route — the banner pass missed three files.** `GenericDriverNodeManager` is test scaffolding
(§3.4, its own source says "zero production references"), and the earlier remediation bannered
`AddressSpace.md` and `IncrementalSync.md` for it. But `VirtualTags.md`, `OpcUaServer.md` and
`ScriptedAlarms.md` all still presented it as **the** production dispatch path — `VirtualTags.md:3` even
built its opening explanation of driver-vs-virtual dispatch on it. All three now carry the same
correction naming the real chain (`OpcUaPublishActor``SdkAddressSpaceSink``OtOpcUaNodeManager`, two
namespaces). Banners, not rewrites — the underlying rewrite stays owed, consistent with how §7.2 treats
the other two.
### 2026-07-28 — G-7, and what the audit missed about it
The register called this "pre-existing (OpcUaClient + Galaxy absent too); no new driver was added" and
ranked it last. Reading both maps side by side makes it sharper than that:
- The AdminUI's `TagConfigValidator` covers **11 of 12** driver types (all but Galaxy).
- The deploy-time `EquipmentTagConfigInspector` covers **6**.
So the gate that runs at *authoring* is stronger than the gate that runs at *deploy* — and the deploy
gate is the only one config sees when it arrives by **CSV import or API** rather than through the modal.
That is the wrong way round.
**MQTT was a plain hole:** `MqttTagDefinitionFactory.Inspect` has existed since the driver merged and
nothing ever called it. Now wired. The inspector's own doc-comment also claimed "unmapped drivers
(Galaxy, OpcUaClient) are skipped exactly as in the AdminUI validator" — untrue, OpcUaClient *is* mapped
there; corrected.
**A reflection guard replaces the hand-maintained list**, same lesson as G-4: the existing
`EquipmentTagConfigInspectorTests` enumerates driver-type strings by hand, so it guards renames but can
never notice a gap — a newly-inspectable driver simply never appears in it.
⚠️ **The first version of that guard was hollow, and only the falsification step caught it.** It
discovered candidates via `Assembly.GetReferencedAssemblies()`, which is **circular**: the C# compiler
elides a reference nothing in the IL uses, so removing MQTT from the map also removed its Contracts
assembly from ControlPlane's manifest, and the sweep stopped looking for exactly the driver that had just
gone missing. It passed green with the bug reintroduced. Rewritten to scan the output *directory*
(MSBuild copies project-referenced assemblies regardless of IL usage) — re-falsified, and it now fails
naming `MqttTagDefinitionFactory`. It also carries a positive control asserting discovery found more than
one factory, since an empty expected-set is satisfied by an empty map.
**Residual, deliberately not papered over.** Sql, MTConnect, Calculation and OpcUaClient have **no**
`Inspect` in a Contracts assembly — their only checker is the AdminUI `*TagConfigModel.Validate()`, which
lives in the AdminUI project and cannot be referenced from ControlPlane. Closing that means promoting
those models (or an equivalent parser) into each driver's Contracts assembly — a real piece of work, not
a map entry. Recorded in the inspector's own doc-comment so the next reader finds it at the code.
### 2026-07-28 — the 13 stale `EquipmentTagsDarkBatch4` skips
§4.4 flagged these as stale and it was right: the reason said "dark until Batch 4", Batch 4 shipped as
v3.0, and the equipment-tag path was **retired** rather than lit — so every one of them was waiting on
something that had already happened and gone the other way.
**Unskipping them in bulk would have failed, and deleting them in bulk would have thrown away live
coverage.** Each was resolved on its own merits:
| Disposition | Tests | Why |
|---|---|---|
| **Revived** onto the raw/UNS shape | 4 primary-gate cases (`DriverHostActorPrimaryGateTests`), the cluster-scoping rebuild (`OpcUaPublishActorRebuildTests`), the real-SDK materialisation E2E (`AddressSpaceApplierHierarchyTests`) | The *behaviour* never went dark — only the v2 seeding did. Each seeds the v3 chain (RawFolder → Driver → Device → Tag, + `UnsTagReference`) instead. |
| **Folded** into a live parity test | array intent, historize intent, native-alarm/writable intent | `DeploymentArtifactRawUnsParityTests` already compared `RawTagPlan` **record-equal**, and `RawTagPlan` carries all three fields — so widening its corpus by two tags covers what three empty placeholder suites had been promising. Those suites are deleted. |
| **Deleted** | equipment-namespace `EquipmentTags` parse, both `{{equip}}`-token suites | The subjects are retired. The token tests used the **dot-joint** `{{equip}}.X` form, whose resolver (`EquipmentScriptPaths.DeriveEquipmentBase`) was deleted in Batch 3; the slash-joint form is covered live by `DeploymentArtifactEquipRefParityTests` (4 tests). |
Three things worth keeping:
- **The revivals are the point, not the unskipping.** `Unknown_role_single_driver_services_write` asserts
`recorder.Writes.ShouldNotBeEmpty()` — it needs the write to actually reach a driver, which needs a
routable tag. **Verified by deleting the `UnsTagReference` from the seed: it and
`Known_role_wins_over_member_count` go red.** Same check on the cluster-scoping test (re-home the SITE-A
node to MAIN ⇒ red). The three *denial* cases in that file were passing all along — they deny before
routing — which is exactly why the gap was invisible.
- **Widening a parity corpus needed a non-parity assertion to go with it.** A composer that dropped array
intent *entirely* would still satisfy `decoded.RawTags.ShouldBe(composed.RawTags)`, because the artifact
decode would drop it too and the two empties compare equal. Parity cannot distinguish "both right" from
"both blind", so the widened test also asserts the composed values directly.
- **The reason constant is deleted, replaced by a note explaining why.** A skip reason is a claim with an
expiry date, and this one outlived its own condition by a release. Both `DarkAddressSpaceReasons` files
now say so, so the next person writing one states a condition a reader can actually check.
Skipped counts: `Runtime.Tests` **13 → 3**, `OpcUaServer.Tests` **4 → 1**. Every remaining skip is an
`EquipmentDeviceBindingRetired` tombstone — deliberate, and §4.4 already calls those fine as-is.
### 2026-07-27 — §8.4 dispatch-map parity (G-1 … G-6)
**The maps had to become data before they could be guarded.** A Razor `@switch` compiles into
`BuildRenderTree`'s IL, so nothing can enumerate its cases — that is the whole reason G-1 and G-2
survived review while the sibling picker guard stayed green (the picker test works only because
`RawDriverTypeDialog` keeps its data in a *field* the markup enumerates). Both modals now render from
`DriverConfigFormMap` / `DeviceFormMap` via `<DynamicComponent>`, and being `public` those maps need
none of the picker test's `BindingFlags.NonPublic` fragility.
| Gap | Resolution |
|---|---|
| G-1 | `CalculationDriverForm.razor` added — `RunTimeout` was unauthorable through the UI. |
| G-2 | Sql / MTConnect / Calculation are declared **single-connection** rather than given hollow device forms — each holds one connection at the driver level, so a per-device endpoint editor would be meaningless. |
| G-3 | `DriverConfigModal`'s hardcoded `Galaxy or Mqtt` replaced by `DeviceFormMap.IsSingleConnection`. |
| G-4 | `DriverFormMapParityTests` (5 cases, both directions) + `DriverDispatchMapParityTests` (3). |
| G-5 | `CsvColumnMap` entries for Sql, Mqtt, MTConnect. |
| G-6 | `RawBrowseCommitMapper` Sql branch — **and the browser end too**, which the register missed. |
**G-6 was bigger than filed.** The register said the mapper lacked a Sql branch. It also turned out that
`SqlBrowseSession` emitted **no `AddressFields` at all**, so a browsed leaf carried only a column name —
and a column name alone cannot address a Sql tag, which needs its table. The browser now travels
schema/table/column and the mapper builds a `WideRow` `SqlTagConfigModel` from them. A branch alone
would have produced a half-built blob.
**Two extra findings while writing the guards:**
- The G-6 test found **Modbus and Calculation** also fall through to the generic `{"address": …}` key.
Both are correct — neither is browsable — so the test asserts the fall-through set **equals** a
documented non-browsable list, in both directions. A one-way check would let a newly-browsable driver
be quietly added to the exclusion list instead of getting a branch.
- `TagConfigDriverTypeNameGuardTests` was **hollow**: it enumerated a hand-written `TheoryData` that had
already drifted (omitting Galaxy, Sql and Mqtt), so it guarded against renames but not gaps — a new
`DriverTypeNames` constant would simply never appear. Now enumerated from the map, paired with a
coverage test in the other direction.
**Live-verified on docker-dev** (`localhost:9200`, central pair rebuilt), because this repo has no bUnit
and no unit test can cover Blazor parameter binding: opened Calculation's config — the form renders where
it previously showed "No typed config form" — typed `3500`, saved, reopened, and the value **persisted**,
proving the `DynamicComponent` two-way binding round-trips. Sql's form renders fully (regression check on
the conversion) and now reads "This driver holds a single connection, authored above" (G-3). The rebuilt
image also showed `RediscoveryNeededUtc`/`RediscoveryReason` on the wire, confirming §8.2 end-to-end.
### 2026-07-27 — §8.3 #516 driver config edits silently discarded
Decision: **both** — per-driver re-parse *and* the seam respawn. Reading the drivers split the
"per-driver" half in two, which the register did not anticipate:
- **Re-parse in place** (`Modbus`, `AbLegacy`, `OpcUaClient`) — `ParseOptions` extracted from each
factory, called from `InitializeAsync` behind a `HasConfigBody` guard so `"{}"` still keeps the
constructor options.
- **Respawn-only, deliberately NOT re-parsed** (`Sql`, `FOCAS`) — each builds **more than options**
from config: Sql's `ISqlDialect` + resolved connection string, FOCAS's client-factory backend, both
injected at construction. Adopting new options alone would run a **new tag set against an old
connection**. Half a re-parse is worse than none. Their doc-comments now say so.
**The seam respawn is the load-bearing half.** `DriverSpawnPlanner` now routes a changed
`DriverConfig` to `ToStop` + `ToSpawn`, making the factory the single parse authority.
`ToApplyDelta` is consequently always empty from the reconcile path. This **reverses the deliberate
decision documented at `DriverSpawnPlan.cs:49-50`** ("a pure DriverConfig change stays an in-place
delta — no reconnect"). That reasoning was right about resilience and wrong about the driver; the
accepted price is a reconnect on every config edit.
Two seals removed from `ApplyChildDelta`: it overwrote the cached `Spec` **synchronously, before the
child had dequeued the message**, so the host immediately believed the new config was live and the
next reconcile computed no delta — sealing the drift permanently; and it `Tell`d with no
`Receive<ApplyResult>` registered, so a failed reinit (including Galaxy's deliberate
`NotSupportedException`) dead-lettered.
**A new visibility gap this change exposed, not created:** a factory throw is a *config* error
(`TryCreate` is pure parsing; device I/O happens later), and `SpawnChild` catches it and silently
substitutes a stub. Previously only a brand-new driver could hit it; now an ordinary config edit can.
Raised from `Warning` to `Error` with an actionable message. It still does **not** fail the
deployment — making it do so would let one malformed driver block a fleet deploy, so that is a
deliberate follow-up rather than a drive-by change.
**Tests.** Every pre-existing reinit test in the five suites passes `"{}"` — precisely the input a
guarded re-parser treats as "keep the constructor options", so they were blind to this defect *by
construction*. The new tests pass **changed** JSON. The Modbus one was verified falsifiable by
deleting the re-parse line (goes red). Two existing tests asserted the old behaviour and were
rewritten: `DriverSpawnPlannerTests` (two cases), and
`DriverHostActorUnreadableArtifactTests.Dropping_a_drivers_last_tag_does_clear_its_subscription`
a **positive control** for a sibling absence assertion, whose observable moved from `UnsubscribeAsync`
to `ShutdownAsync` because the teardown now happens by stopping the child rather than emptying its
desired set. Same event, different route; the control still calibrates the settle window.
Full-solution run: only pre-existing environment failures remain — `Host.IntegrationTests` (3,
verified identical on the pre-change tree) and `Driver.AbLegacy.IntegrationTests` (4, docker
fixture-gated, and notably these **fail rather than skip**, unlike every other fixture-gated suite).
---
*§1–§8 generated 2026-07-27 against `master` @ `90bdaa44` by five parallel source-verification agents.
Every `path:line` reference was read from source. Items marked CANNOT VERIFY require hardware, a
VPN'd gateway, or a live rig and are listed as unproven rather than assumed.*
*Remediated 2026-07-27/28; merged `914d70bf`. §9 is the execution record and the live status — where §1–§8
and §9 disagree, **§9 is current**. Path/line references in §1–§8 are as at `90bdaa44` and several no
longer resolve, by design: the code they pointed at was deleted.*
+3 -1
View File
@@ -70,7 +70,9 @@ Signal-level calculated tags are ordinary raw tags bound to a `Calculation` driv
`ctx.GetTag("<RawPath>")`. See `docs/plans/2026-07-15-calculation-driver-mini-design.md` for
the full design. Highlights:
- One auto-created default `Engine` device; per-tag `TagConfig` is
- ⚠️ **Correction (2026-07-28): the "auto-created default `Engine` device" below never shipped.**
`"Engine"` has zero occurrences in `src/`, and nothing special-cases `Calculation` at device
creation — author the device like any other. Per-tag `TagConfig` is
`{ "scriptId": "…", "changeTriggered": true, "timerIntervalMs": 5000 }`.
- The host feeds dependency values via the new `IDependencyConsumer` capability + a
`DependencyConsumerMuxAdapter` on the per-node dependency mux; calc-of-calc chains work
+133
View File
@@ -0,0 +1,133 @@
# Calculation Driver
Getting-started guide for the `Calculation` **pseudo-driver** — signal-level calculated tags computed
by C# scripts over other tags' live values. For the design rationale read
[`docs/plans/2026-07-15-calculation-driver-mini-design.md`](../plans/2026-07-15-calculation-driver-mini-design.md);
[`docs/Raw.md`](../Raw.md) covers where it sits in the `/raw` tree.
## What it talks to
**Nothing.** There is no backend, no endpoint, no connection and no discovery. Its inputs are other
tags' live values, fed to it by the driver host; its outputs are ordinary raw tags. That is why it is
called a pseudo-driver — it occupies a driver slot so its tags get the same authoring, deployment,
fan-out, historization and alarming as any device tag, without any of the I/O.
Because it is an ordinary driver from the host's point of view, a calc tag's value flows out through
`OnDataChange` exactly like a Modbus register's — so the dual-namespace fan-out, historian
registration and **calc-of-calc** (a calc tag reading another calc tag, via mux re-entry) all work
with no special-casing.
## Calculation vs. Virtual Tags — which one to use
They are close cousins and easy to confuse:
| | **Calculation** driver | **Virtual tags** |
|---|---|---|
| Lives in | the `/raw` tree, as a raw tag | the UNS tree, on an equipment |
| Identity | a **RawPath** | `{Equipment}/{EffectiveName}` |
| Reads by | `ctx.GetTag("<RawPath>")` — literal raw paths | `ctx.GetTag("{{equip}}/<RefName>")` — equipment-relative |
| Use when | the computation is a property of the *signal* (scaling, a derived rate, combining two registers) | the computation is a property of the *equipment* (an OEE roll-up, a per-machine state) |
If the same script should apply to many machines, you want a virtual tag with `{{equip}}`. If a single
derived signal belongs next to the device tags it is derived from, you want a calc tag.
## Authoring a calc tag
Calc tags are authored in `/raw` like any other tag: create a `Calculation` driver, add a device under
it, and add tags under that.
> ⚠️ **`docs/Raw.md` says a default `Engine` device is auto-created. It is not** — verified 2026-07-28,
> the string `"Engine"` has zero occurrences in `src/`, and nothing in the AdminUI or the config layer
> special-cases `Calculation` at device-creation time. Create the device yourself; the name is
> arbitrary but becomes a segment of every calc tag's RawPath, so pick it deliberately.
Per-tag `TagConfig`:
```json
{
"scriptId": "scr-oven-delta",
"changeTriggered": true,
"timerIntervalMs": 5000
}
```
| Field | Meaning |
|---|---|
| `scriptId` | FK to the shared `Script` entity. **Required** — a tag with no identifiable script is not a calc tag and is rejected at parse. |
| `changeTriggered` | Re-evaluate whenever any declared dependency changes. |
| `timerIntervalMs` | Also re-evaluate on this cadence. Absent ⇒ change-trigger only. |
`scriptSource` is **resolved at deploy time** from `scriptId` and travels in the artifact — the driver
never reads the config DB. A `scriptId` that resolves to empty source still maps: the driver logs it
and the tag simply never computes, rather than the whole driver failing.
## Dependencies are discovered from the script
You do not declare dependencies. The deploy pipeline extracts them from the script's **literal**
`ctx.GetTag("…")` reads and registers exactly those RawPaths with the dependency mux.
⚠️ **Literal means literal.** A path built at runtime — `ctx.GetTag("Plant/" + line + "/Temp")` — is
invisible to the extractor, so that dependency is never fed and the tag never sees its value. Write
the path as a constant string.
Two deploy-time gates run over the result:
- **`scriptId` existence** — a calc tag naming a script that does not exist fails the deploy.
- **Cycle detection (Tarjan)** — calc-of-calc is supported, but a cycle fails the deploy rather than
livelocking at runtime.
## Trigger and publish semantics
- **Nothing publishes until every declared dependency has arrived at least once.** Before that a read
returns `BadWaitingForInitialData`. This is the same gate `VirtualTagActor` uses, and it exists so a
calculation cannot publish a confident-looking value derived from defaults.
- **Equal results are deduped** — an evaluation producing the same value as last time does not
re-publish.
- **Timers are grouped by interval**, one timer per distinct interval, not one per tag.
## Error semantics
An evaluator failure publishes **Bad quality carrying the last-known value** — the value is not
zeroed, because a stale-but-flagged reading is more useful to an operator than a fabricated one. It
publishes:
- **once per Good→Bad transition**, not on every failed evaluation (a permanently broken script does
not flood), and only after all dependencies have arrived;
- with a `ScriptLogEntry` on the `script-logs` topic, attributed to the tag's `scriptId`, so the
AdminUI `/script-log` page names the failing script;
- recovery to Good is **force-published**, bypassing the dedup, so a recovered tag does not sit Bad
waiting for its value to change.
A historized Bad records `BadInternalError`.
## Config
```json
{ "runTimeoutMs": 2000 }
```
| Key | Default | Notes |
|---|---|---|
| `runTimeoutMs` | `2000` (2 s) | Per-script wall-clock evaluation budget, in **milliseconds** — not a `TimeSpan` string, unlike most driver timeouts in this repo. Parity with the virtual-tag evaluator. |
⚠️ **`runTimeoutMs` is ignored on a config *reinit*** — the evaluator's timeout is fixed at
construction. A changed value takes effect because a changed `DriverConfig` respawns the driver
(#516), not because the driver re-reads it in place.
There is nothing else — no endpoint, no credentials, no pooling. The driver is **single-connection**
in the AdminUI's sense (nothing lives on the device).
## Capabilities
`IDriver` (lifecycle + always-Connected health), `IDependencyConsumer` (the host feeds it upstream
values), `ISubscribable` (computed values flow out), `IReadable` (returns the last computed
snapshot).
No `IWritable` — a computed value has no meaningful write target. No `ITagDiscovery` — there is
nothing to browse.
## See also
- [`docs/Raw.md`](../Raw.md) — the `/raw` authoring tree
- [`docs/VirtualTags.md`](../VirtualTags.md) — the equipment-scoped alternative
- [`docs/ScriptEditor.md`](../ScriptEditor.md) — the Roslyn-backed script editor
+2 -2
View File
@@ -35,8 +35,8 @@ Driver **factories** are registered at startup in `DriverFactoryRegistry` (`src/
| [OPC UA Client](OpcUaClient.md) | `Driver.OpcUaClient` | B | OPCFoundation `Opc.Ua.Client` | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IAlarmSource, IHistoryProvider, IHostConnectivityProbe | Gateway/aggregation driver — the only driver implementing driver-side `IHistoryProvider` (forwards HistoryRead to the upstream server). 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** (no dedicated page — see [design](../plans/2026-07-15-sql-poll-driver-design.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` |
| **Calculation** (see [Raw.md](../Raw.md#the-calculation-driver)) | `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). ⚠️ **No typed config form**`DriverConfigModal` has no `Calculation` case, so `RunTimeout` is currently unauthorable through the AdminUI |
| [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` |
| [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 sole historian backend. 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).) |
## Per-driver documentation
+174
View File
@@ -0,0 +1,174 @@
# SQL Poll Driver
Getting-started guide for the `Sql` driver — a **read-only** Equipment-kind driver that polls rows
out of a relational database and publishes them as OPC UA variables. 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).
## What it talks to
An ordinary SQL database — typically a MES / LIMS / historian staging table that some other system
already writes to. There is no protocol here and nothing to "connect" to a machine: the driver runs
`SELECT`s on a timer and turns cells into tag values.
**v1 constructs SQL Server only** (`Microsoft.Data.SqlClient`). `SqlProvider` also declares
`Postgres`, `MySql`, `Odbc` and `Oracle`, but those are **reserved names, not working backends**
authoring one fails the driver rather than silently falling back.
**Read-only, structurally.** `SqlDriver` does not implement `IWritable`, so no configuration can
produce a write. The `allowWrites` config field exists and is **inert**; the factory *warns* when it
is authored `true` rather than failing, on the grounds that the flag cannot do harm but an operator
who believes writes are enabled can.
## Tag models
A tag says how to find its value. Two models ship; a third is deferred.
### `KeyValue` — the EAV shape
One row per tag. The driver matches `keyColumn = keyValue` and reads `valueColumn`.
```json
{
"model": "KeyValue",
"table": "dbo.TagValues",
"keyColumn": "TagName",
"keyValue": "Line1.Oven.Temp",
"valueColumn": "Value",
"timestampColumn": "UpdatedUtc"
}
```
### `WideRow` — one row, many signals
The tag names its `columnName`; the row is picked either by a `whereColumn`/`whereValue` pair or by
newest-timestamp.
```json
{
"model": "WideRow",
"table": "dbo.LineStatus",
"columnName": "OvenTemp",
"whereColumn": "LineId",
"whereValue": "L1"
}
```
```json
{
"model": "WideRow",
"table": "dbo.Readings",
"columnName": "OvenTemp",
"topByTimestamp": "CapturedUtc"
}
```
### `Query` — deferred to P3
Named arbitrary-`SELECT`. **The v1 parser rejects it**, and that refusal is enforced end-to-end —
parser, validator, planner and the AdminUI editor all decline it rather than accepting the value and
ignoring it later.
## Optional per-tag fields
| Field | Effect |
|---|---|
| `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. |
| `pollIntervalMs` | Per-tag override of the instance's `defaultPollInterval`. |
## Driver config
```json
{
"provider": "SqlServer",
"connectionStringRef": "MesStaging",
"defaultPollInterval": "00:00:05",
"operationTimeout": "00:00:15",
"commandTimeout": "00:00:10",
"maxConcurrentGroups": 4,
"nullIsBad": false
}
```
| Key | Default | Notes |
|---|---|---|
| `provider` | `SqlServer` | The only provider v1 constructs. |
| `connectionStringRef` | — | **A name, never a connection string.** See below. |
| `defaultPollInterval` | `00:00:05` | Applied to any group without its own interval. |
| `operationTimeout` | `00:00:15` | Client-side wall-clock deadline; a breach surfaces `BadTimeout`. |
| `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` | — | **Inert.** v1 has no write path; authoring `true` logs a warning. |
### Credentials never enter the deployment artifact
`connectionStringRef` is a **name** resolved at Initialize from the environment / secret store — e.g.
`Sql__ConnectionStrings__MesStaging`. There is no field that holds a connection string, so a sealed
deployment artifact cannot carry database credentials even by accident.
A useful side effect of the in-place reinit added for [#516](#): a **rotated credential is picked up
on the next config apply without a process restart**, because the driver re-resolves the reference
when it re-parses.
## How polling works
Tags are **grouped by source** and one query is issued per group, not per tag — `SqlGroupPlanner`
emits a `SqlQueryPlan` per distinct group key. Grouping is deterministic (groups appear in the input
order of their first member, members keep their input order, parameters keep first-appearance order),
so an unchanged configuration produces byte-identical SQL.
⚠️ **Group keys are case-sensitive on identifiers.** Two tags authored `dbo.T` and `DBO.T` plan as
**two** groups — one extra round-trip, not a correctness problem, but worth knowing if a group count
looks higher than expected.
## SQL injection: how the driver is safe
The two classes of authored string are handled differently, and deliberately so:
- **Value-bearing** fields (`keyValue`, `whereValue`) are bound as `DbParameter`s. They are never
concatenated into command text.
- **Identifier-bearing** fields (`table`, `keyColumn`, `valueColumn`, `timestampColumn`,
`columnName`, `whereColumn`, `topByTimestamp`) cannot be parameterised in SQL. They are validated
against **`INFORMATION_SCHEMA`** and dialect-quoted before they reach a command.
The parser deliberately does **not** sanitise either kind — sanitising a value would corrupt
legitimate data, and sanitising an identifier would mask an authoring error the catalog check reports
properly.
⚠️ **An unreadable catalog FAULTS the driver rather than rejecting every tag** (Gitea #496). This is
the safer failure: "I cannot verify these identifiers" and "these identifiers are invalid" are
different conditions, and reporting the second when the first is true would send an operator hunting a
configuration bug that does not exist. The catalog gate also substitutes the catalog's own spelling of
an identifier, so a case difference between the authored blob and the database resolves rather than
failing.
## Browse
`SqlDriverBrowser` browses the live catalog — schema → table → column — so the `/raw` browse picker
can commit columns as tags. A committed leaf carries **schema, table and column**: a column name alone
cannot address a SQL tag, and an earlier version that emitted only the column produced tags whose
typed editor opened empty.
## Authoring in the AdminUI
- **Driver config**`/raw` → driver node → *Configure*. Sql is a **single-connection** driver: the
connection lives on the driver, not on a device, and the modal says so.
- **Tags** — the typed `SqlTagConfigModel` editor, or CSV import (Sql has typed CSV columns), or
browse-commit from the picker.
## Capabilities
`IDriver`, `ITagDiscovery`, `IReadable`, `ISubscribable`, `IHostConnectivityProbe`.
No `IWritable` (structural, above). No `IRediscoverable` — a SQL schema change is not signalled, and
`DiscoverAsync` echoes authored config rather than enumerating the database, so this driver is
deliberately excluded from any discovery-diff machinery.
## See also
- [`docs/Raw.md`](../Raw.md) — the `/raw` authoring tree
- [`docs/drivers/README.md`](README.md) — the driver index
- Gitea **#496** / **#497** / **#498** — the shipped follow-ups