Files
lmxopcua/deferment.md
Joseph Doherty 4d549571cc
v2-ci / build (push) Successful in 5m12s
v2-ci / unit-tests (push) Failing after 16m53s
docs(deferment): eighth round — all 32 recorded-Open review findings resolved, fleet-wide 0 pending
2026-08-10 08:29:03 -04:00

1832 lines
166 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` @ `4bdce910`, 2026-08-04. 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.
>
> ⚠️ **The tracker is EMPTY as of 2026-08-04 — 0 open issues.** Every issue this register enumerates has
> been closed. What is left is therefore *entirely* untracked: live gates that need hardware, one
> unexecuted plan, three carried-open archreview findings, and a review-coverage gap. See §0.1.
---
## 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 | ✅ **All three settled**: `IRediscoverable` consumed (#518), `IHostConnectivityProbe` re-transported to telemetry (#521), `DriverTypeRegistry` + the tier machinery **deleted** (#522). Node ACLs are **now enforced** — #520 shipped through stage 7 (merges `fbe7403c` stages 1-2/4, `3a6b2a1d` stage 3, `1ccc7400`/`f692f6af` stage 5 write/alarms/HistoryRead, `d4389f57`/`00aa4866` stage 6 read/browse/subscribe, `3796c0bf` stage 7 authoring; wired at `Host/Program.cs:394-398` → `DataPlaneAuthorizer`). Enforcement is **opt-in**: `Security:Acl:Enabled` defaults `false` |
| **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 | ◐ 6 resolved + closed, 6 new filed (one since closed) → **17 open**, none stale. Two of the six closures (#521, #522) resolved *differently from how they were filed* — see §3.2/§3.3 |
| **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 (re-verified at `4bdce910`, 2026-08-04)
> **Read this subsection, not the table below it.** The 2026-07-30 table that follows is preserved as the
> record of what the remediation left behind; every tracker item in it has since closed. The live picture
> is here.
**The tracker is at 0 open issues.** Verified against the API, not inferred: the 14 issues §0.1 listed as
open on 2026-07-31 all closed between 2026-07-31 and 2026-08-04 — #520, #525, the eight MQTT follow-ups
(#508–#515), #489, #481, #468, #491 — as did the 11 filed after this register's audit (#526–#538).
So everything below is **untracked work**. Nothing in it is a data-loss or outage defect.
| What | Nature | Where |
|---|---|---|
| **4 live gates blocked on hardware/licence** — ~~archreview R2-03 (VT Good→Bad on a data-fed rig)~~ ✅ *PASSED 2026-08-07*, ~~R2-10 (force breaker-OPEN)~~ ✅ *PASSED 2026-08-08 by fixing the structural gap (`DriverCapability.Initialize`)*, Galaxy `ScanStateProbeParityTests` (licence-gated), v2 GA exit criteria (~~FOCAS live-CNC smoke #54~~ *closed — live-validated on a real 31i-B 2026-06-25, see `docs/plans/2026-06-25-focas-pdu-v3-30i-b-support.md`*, OPC UA CTT pass, Ignition 8.3 cutover) | Cannot be closed from this box. Not decisions — inputs. *(The two archreview gates turned out to be closable from this box after all — §5.)* | §5 |
| ~~**1 deferred live-verify task** — `2026-06-14-driver-reconfigure-while-faulted` Task 3~~ ✅ **PASSED 2026-08-07.** A faulted driver ("initialize failed: Connection refused") adopted a corrected config and connected while faulted, with the node's `startedAt` byte-identical before and after and `RestartCount=0` — no restart. | §5 |
| ~~**G-7 residual** — Sql / MTConnect / Calculation / OpcUaClient have no Contracts-side `Inspect`~~ ✅ **CLOSED 2026-08-09** (`2947a2b9` + `1469fbb4`, merge `0695f682`). All four now ship a Contracts-side `*TagDefinitionFactory.Inspect` **wrapping the runtime parse, never mirroring the AdminUI model** — Sql wraps the pre-existing `SqlEquipmentTagParser`; the MTConnect / OpcUaClient / Calculation blob readers were **moved** into their Contracts assemblies (the drivers now call them), so never-stricter-than-runtime is structural. `Driver.Calculation.Contracts` was created (no Contracts assembly existed). The reflection coverage guard was falsified (map entry removed → red naming `SqlTagDefinitionFactory`), and `TagConfigAuthoringDeployParityTests` pins authoring-accepts ⇒ deploy-accepts. **Galaxy is now the sole unmapped type, deliberately + symmetrically** (no typed authoring editor either). ~~Found en route, left open: `MTConnectTagConfigModel` reads `dataType` leniently (misspelled name → silently `String`) while the runtime rejects it — authoring weaker than runtime, the *opposite* direction from G-7; documented inline in the parity tests.~~ ✅ Filed and FIXED same day as **#549** — and it was 6 models wide, not 1 (see the "2026-08-09 (later)" §9 entry); residuals **#550**/**#551** are ✅ **CLOSED 2026-08-10** (merges `b50523b1` + `333ea488`, see the 2026-08-10 §9 entry) — their own residuals are **#552** (Sql browse-commit emits a config the gate rejects) / **#553** (CSV typed-column merge silently rewrites typo'd enums pre-gate). | ~~The deploy gate is weaker than the authoring gate.~~ Config arriving by CSV import or API now meets the same checks the modal applies. | §1.2 |
| ~~**3 carried-open archreview findings** — the failure-visibility trio~~ **Update 2026-08-07 (truth sweep): mostly CLOSED.** 01/S-1 **FIXED** — `AddressSpaceApplier.cs:95-97` tallies swallowed removal-pass failures into `AddressSpaceApplyOutcome.FailedNodes` (comment cites 01/S-1). 03/S4 **FIXED** — `PrimaryGatePolicy.cs` default-denies on unknown role when a driver peer exists (doc-comment "Fixes archreview 03/S4"), live-proven R2-04 T15. 06/S-1 **PARTIAL** — fail-closed advise path + `galaxy.writes.unconfirmed` meter shipped in `GatewayGalaxyDataWriter.cs`; residual = gateway-side WriteComplete correlation (cross-repo, mxaccessgw). **Update 2026-08-09: the gateway half SHIPPED** — mxaccessgw main `b948e69` correlates the worker's `OnWriteComplete` onto the unary `WriteSecured` reply (`statuses[0]`; bounded wait default **1.5 s** — deliberately under OtOpcUa's 2 s Tier A Write budget, configurable via `MxGateway:Worker:WriteCompletionWaitMilliseconds`; timeout falls back to today's empty-statuses shape, still metered unconfirmed). **Update 2026-08-09 (later): 06/S-1 CLOSED — live-gated end to end, and the gate found + fixed three defects.** The user-authorized redeploy exposed (1) the correlation covered `WriteSecured` only while OtOpcUa's dominant no-login path emits plain `Write` — fixed gateway-side (worker `53f69cd`, all four unary write kinds, redeployed both hosts); (2) OtOpcUa's `StatusCodeMap.FromMxStatus` truncated the correlated failure row's MxStatus detail code to a byte (`1007 & 0xFF = 239` → OPC DA Good band), mapping a REFUSED write to Good — fixed with the not-IsSuccess-never-Good invariant + category routing (`SECURITY_ERROR → BadUserAccessDenied`); (3) the writer's borrow-from-subscription-registry optimization could never commit (a subscription handle is plain-advised; MXAccess refuses the no-login write `SECURITY_ERROR 1008` AND the advise upgrade `E_INVALIDARG` — one advise per HANDLE, not per attribute) — borrow path REMOVED, writer always owns a supervisory-advised handle. Live legs all PASSED on windev: happy write Good off confirmed `statuses[0]` + both meters flat; read-only refusal surfaces Bad-family + node-revert input; `WriteCompletionWaitMilliseconds=0` control → provisional Good + metered unconfirmed (override removed after). Pinned by `Live_correlated_write_statuses_confirm_commit_and_surface_refusal`, `Live_write_to_subscribed_tag_commits_via_own_handle`, `GatewayGalaxyWriteMeterLiveTests`, and the new `StatusCodeMapTests` rows. | ~~Residual is only the 06/S-1 live leg~~ **CLOSED 2026-08-09** — nothing remains. | §6.3 |
| ~~**1 genuinely unexecuted plan** — `2026-05-29-adminui-followups`, **19 pending / 0 completed**~~ ✅ **CLOSED 2026-08-07 by the overlap audit it asked for — it was bookkeeping drift, not backlog.** All 19 tasks are shipped, superseded by v3, or moot; **no code was needed.** The warning was right and then some: WS1's whole *premise* died with v3 (driver-embedded tag lists in `DriverConfig` are no longer the runtime source of truth — the artifact populates `RawTags`), and its capability was delivered in the v3 shape (`TagConfigEditorMap` + `DeviceFormMap`, both verified **reachable**, not merely present). WS2/WS3 shipped; WS4's target files were deleted by v3 Batch 2 + #542. Per-task evidence in the `.tasks.json`. | §6.2 |
| ~~**11 other open plan tasks**~~ **3 as of 2026-08-10** — ~~5 akka-hosting partials (likely v3-superseded)~~ *(verified superseded 2026-08-07 — F8/F9/F10/F13/F14 marked completed; `Phase7Composer`/`ScriptedAlarmActor` deleted, evaluators/SDK sinks/security policies all production-wired)*, ~~4+1 followups-batch (open *by choice*)~~ *(2026-08-10: Tasks 9 + 10 VERIFIED PASS live — Int64 authoring end-to-end on the rebuilt rig; probe E2E 12/12 with the "needs Windows-VM" premise corrected to TCP-reachability gating)*, ~~fixedtree Task 11~~ *(closed 2026-08-10: obsolete — the injection feature it gated was deleted with #507)*, ~~1 manual smoke checklist~~ *(closed 2026-08-10: obsolete — the driver pages it smokes were retired in v3 Batch 2)*. ~~**Remaining 3: F10b + SetSeverity (reconsider gates, decisions already taken — do not build without a fresh go-ahead) and the device-gated proofs (hardware-blocked).**~~ **Remaining 1 as of 2026-08-10 (later that day): F10b + SetSeverity reconsider gates put to the user and CLOSED decided-against (F10b re-open trigger: operator complaints about rebuild churn on data-type edits; SetSeverity is operationally invisible — engine overwrites on first eval); the device-gated proofs bundle trimmed to its 2 genuinely-gated sub-items (H6 native-ack→AVEVA + AbLegacy/TwinCAT/FOCAS probes).** | Hardware-blocked only. | §6.2, §9 2026-08-10 (sixth) |
| ~~**Review-coverage gap** — 10 shipped projects never reviewed (Calculation, Historian.Gateway, Mqtt +Browser/Contracts, MTConnect +Contracts, Sql +Browser/Contracts) while **3 modules still review the retired Wonderware backend**~~ **CLOSED 2026-08-10**: all 14 unreviewed projects (the 10 + Mqtt.Transport, Sql.Dialect, Calculation.Contracts, OpcUaClient.Session) reviewed by 6 parallel agents — 61 findings, 26 fixed, 35 Open in `findings.md`; Wonderware reviews banner-retired to history; README at 65 modules, `--check` clean. **The recorded Opens were themselves resolved 2026-08-10 (eighth round): fleet-wide 0 pending findings** | §9 2026-08-10 (seventh, eighth) | §6.3, §9 2026-08-10 (seventh) |
| ~~**`RawBrowseModal` stale-open race** (added 2026-08-05) — `OnParametersSetAsync` never re-checks staleness after its awaits, so a superseded open's late resolve can install the wrong device's browse config. The identical shape was found by review and **fixed** in `RawTagModal`/`RawManualTagEntryModal` (post-await staleness re-check; see `docs/plans/2026-08-05-raw-modal-picker-live-config-design.md` §6); the browse modal was deliberately left out of that branch's scope.~~ ✅ **CLOSED 2026-08-09** (`87637e4e`, merge `0739f4b9`) — the sibling `_openGeneration` guard ported to every post-await write, **plus two departures the siblings did not need**: this modal resolves inside `try/finally`, so the `catch` (a stale open's failure would paint its error over the live device) and the `finally` (a stale `return` still runs the tail, dropping the LIVE open's spinner) are generation-guarded too. Four `RawBrowseModalStaleOpenTests` facts, each observed red pre-guard and each re-falsified by reverting only its own guard (1-failed/3-passed per revert — no redundant pin). | ~~Lower stakes than the fixed pair… but the fix pattern is proven and cheap to port.~~ Ported. | design doc §6 |
⚠️ **One correction worth carrying forward.** archreview **03/S4** — "primary gate default-allows on
unknown role" — is the *same shape* as a decision deliberately taken on 2026-08-04 in
`PrimaryGatePolicy.ShouldRecordHistorizedValues`, where an unknown role **captures**. That is right for
historization (a duplicate sample beats a hole in the trend, and the duplicate is bounded by promotion)
and wrong for a write gate. Closing 03/S4 must not sweep both under one rule. **Update 2026-08-07
(truth sweep): it didn't** — 03/S4 is closed with exactly this split preserved: `PrimaryGatePolicy.
ShouldServiceAsPrimary` default-denies on unknown role when a driver peer exists, while
`ShouldRecordHistorizedValues` still captures on unknown.
---
### 0.1-prev What the remediation left behind (as at `2a0461a1`, 2026-07-30)
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** ✅ **CLOSED 2026-08-01** *(truth-sweep note 2026-08-07: shipped through stage 7 — enforcement merges `fbe7403c`/`3a6b2a1d`/`1ccc7400`/`d4389f57`/`3796c0bf`; opt-in via `Security:Acl:Enabled`, default `false`)* | 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.~~ It was wired: with the flag on, scoped grants decide every data-plane operation incl. read/browse/subscribe. | §3.1 |
| **#521** `IHostConnectivityProbe` / `DriverHostStatus` dead surface ✅ **CLOSED** `abda6f9e` | Resolved by changing the *transport*, not by either option as filed — the publisher as designed was unbuildable after Phase 4. Host status now rides `DriverHealthChanged` to `/hosts`; table dropped. | §3.2 |
| **#522** driver stability tiers inert ✅ **CLOSED** `abda6f9e` | Deleted. The inertness was broader than filed (nothing constructed `MemoryTracking` either), and the model's premise — an out-of-process Host to recycle — no longer exists. `DriverTier` kept. | §3.3 |
| ~~**#523** remove the periodic 5-min device re-browse~~ | ✅ **Done + closed on the tracker.** **AbCip and FOCAS now have no tag-change signal at all**; that is the accepted consequence, not an oversight. | §9 (2026-07-30, #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~~ ✅ **both PASSED since** (#468 `c2c3c522`, #491 `e77ea4aa` + the #537/#538 gate `4bdce910`); ~~archreview R2-03 / R2-10~~ ✅ **both PASSED** (R2-03 2026-08-07; R2-10 2026-08-08, closed by fixing the structural gap). *(The un-gated drift detector's gate closed by deletion — see #523.)* | §5 |
✅ **The tracker is now clean of stale-open issues** (2026-07-30). **#516**, **#518**, **#507** and
**#523** were closed, each with a resolution comment recording what was actually done — which differs
from what the issue asked for in three of the four cases, so the comment matters more than the state
change:
| Issue | What the comment records |
|---|---|
| **#516** | The issue's own survey was **incomplete** — it named 3 drivers, the defect affected 5, and the load-bearing fix was the spawn seam rather than any driver. |
| **#518** | **Only half resolved.** `IRediscoverable` is consumed *as an operator prompt, never an address-space rebuild*; `IHostConnectivityProbe` is untouched and split to **#521**. |
| **#507** | **Superseded, not migrated** — the requested migration was impossible, since both of the path's inputs are structurally empty in v3. |
| **#523** | Done, with the consequence carried forward: **AbCip and FOCAS now have no tag-change signal at all.** |
~~**14 issues remain open**~~ ✅ **ALL 14 CLOSED as at 2026-08-04 — the tracker is empty.** Preserved as
found (verified against the tracker API 2026-07-31, not derived): #520 and #525
(the two remaining judgement calls), the eight MQTT follow-ups (#508–#515), #491, #489, #481, #468.
Membership moved across three rounds: **#521 and #522 closed, #524 and #525 opened** (both residuals
their parent issue's decision did not cover — a second dead table and two consumerless `IDriver`
members), then **#524 closed** with the table dropped, then **#517 and #519 closed** — leaving 14.
What is left is no longer a dead-surface cluster: #520 and #525 are keep-or-delete calls, and the
rest are MQTT follow-ups plus four infrastructure-gated items.
---
## 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`).~~ ✅ **`IWritable` added 2026-08-05 (`b29623cf`)** — observation injection via the Agent's `AllowPut` surface, off by default at two gates (`Write.Enabled` + the Agent's own `AllowPut`); MTConnect *Interfaces* stays deferred. No device form. |
| **MQTT / Sparkplug B** | **Complete** | `c3a2b0f7` | `+ IRediscoverable, IAsyncDisposable` (`MqttDriver.cs:65-66`) — **and `IWritable` since `72be1922`** | Cleanest of the five: picker + driver form + device form + tag editor all present. ~~8 open follow-up issues.~~ ✅ All eight (#508–#515) **closed 2026-07-31**. |
| **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`).~~
✅ **Two of the four gained it since the audit. As of 2026-08-05 `IWritable` is absent from Sql and
Calculation only** — and in both that absence is still structural and documented (`SqlDriver.cs:15`).
**MQTT** gained it 2026-07-31 (`72be1922`,
[#508](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/508), live-gated — plain `writeTopic`
publish + Sparkplug NCMD/DCMD); **MTConnect** gained it 2026-08-05 (`b29623cf` — observation injection
through the Agent's `AllowPut` surface, off by default at two independent gates).
**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` (since #558/`7482cf0c` the browse-commit fact lives in `RawBrowseCommitMapperParityTests`; `DriverDispatchMapParityTests` guards CsvColumnMap only) |
| 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. | ✅ **CLOSED 2026-08-09** — MQTT wired + reflection guard (2026-07-28), then the four remaining drivers gained Contracts-side `Inspect` wrapping their runtime parses (merge `0695f682`). The gate now covers **11 of 12** — parity with the AdminUI validator; Galaxy is the sole unmapped type on both, symmetrically. 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 were resolved by the remediation and have now been **closed on the
> tracker** (2026-07-30) — #516, #518 and #507 (marked ✅ below), each with a resolution comment; #518's
> records that only its `IRediscoverable` half was fixed. Four new issues were filed from §3 and §9: **#520** (ACL
> enforcement), **#521** (`IHostConnectivityProbe`), **#522** (stability tiers), **#523** (remove the
> periodic re-browse). #523 has since been done and closed too. After closing the four resolved issues on
> 2026-07-30 the tracker shows **17 open, none stale**. Everything not marked below is unchanged.
> ✅ **SUPERSEDED 2026-08-04 — every issue in the table below is CLOSED, as is every issue filed after this
> audit (#526–#538). The tracker is at 0 open.** The table is retained because the *verdict* column is the
> durable part: four of these were wider or narrower than filed, and three were resolved differently from
> how they were asked for. Closure dates, from the API rather than inferred: #489, #481, #508–#515 and
> #525 on 2026-07-31; #520 and #468 on 2026-08-01; #491 on 2026-08-04. #491's gate then produced **#537**
> (no Primary gate on historization capture) and **#538** (one poison tag re-writes every healthy tag) —
> both filed and closed the same day, `e7fbe977` + `4bdce910`.
| # | 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` — **RESOLVED `c74ccc55`: structured metrics flattened to sibling tags; live-gated.** | **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 — **RESOLVED `7f53bd96`: the browse modal takes a typed `group/edgeNode` scope when no tree scope is armed (edge node only, never a group broadcast).** | **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 — **RESOLVED `7f53bd96`.** | **CONFIRMED** | `MqttTagConfigModel.cs:210` unconditional `Set`, vs. mode-aware `:216` | Defect (cosmetic) |
| **512** ✅ | `.Browser` → `.Driver` layering — **RESOLVED `7f53bd96`.** | **CONFIRMED** | `…Mqtt.Browser.csproj:43` (**not :44**), boxed "KNOWN, DELIBERATE EXCEPTION" at `:16-42` | Deferred refactor |
| **511** ✅ | `ActAsPrimaryHost` does nothing — **RESOLVED `7f2108dc`: Sparkplug primary-host STATE publishing + Will, Primary-gated; live-gated.** | **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 — **RESOLVED `7f53bd96`.** | **CONFIRMED** | `RawBrowseModal.razor:377` sole `true` assignment; failure path `:516-539` never re-evaluates | Defect (stale affordance) |
| **509** ✅ | Metric name with `/` unbrowse-committable — **RESOLVED `7f53bd96`: `RawBrowseCommitMapper.ToRawSegment` sanitises the segment and refuses a collision in words.** | **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 — **RESOLVED `72be1922`: `IWritable` shipped (plain `writeTopic` publish + Sparkplug NCMD/DCMD), live-gated 2026-07-31. The evidence column is the audit snapshot; `MqttDriver.cs:66` now names `IWritable`.** | **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
> ✅ **Update 2026-08-07 (truth sweep): NO LONGER TRUE — #520 shipped through stage 7 and is CLOSED
> (2026-08-01).** Enforcement merges: `fbe7403c` (stages 1-2, 4: API-key identity + keys/ACLs in the
> artifact), `3a6b2a1d` (stage 3: variable-depth scope model), `1ccc7400`/`f692f6af` (stage 5: write,
> alarms, HistoryRead), `d4389f57`/`00aa4866` (stage 6: read, browse, subscribe), `3796c0bf` (stage 7:
> ACL authoring/scope picker). `TriePermissionEvaluator` is now wired at `Host/Program.cs:394-398` into
> `DataPlaneAuthorizer`, registered on every node. Enforcement is **opt-in** — `Security:Acl:Enabled`
> defaults `false`; with it off the two coarse server-wide gates remain the whole of data-plane
> authorization, with it on scoped `NodeAcl` grants decide every data-plane operation. The section
> below is preserved as the pre-#520 record.
> ◐ **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 RESOLVED** (`abda6f9e`, Gitea **#521** closed) — but **not as this
> register or the issue framed it.** The choice was never "build the publisher or delete it": the publisher
> as designed — driver nodes upserting `DriverHostStatus` rows — is **unbuildable**, because per-cluster mesh
> Phase 4 gates `AddOtOpcUaConfigDb` on the `admin` role, so a driver-only node has no ConfigDb connection to
> write with. The design predates Phase 4 and nobody revisited it; "deliberately re-created in the v3 initial
> migration" turned out to mean *carried forward mechanically*, not *intended*.
> So the capability was kept and the **transport** changed: `DriverHealthChanged.HostStatuses` carries the
> probe result to `/hosts` over the Phase 5 telemetry stream, which needs no DB and survives the mesh split.
> The table is dropped (`DropDriverHostStatusTable`). Live-gated on docker-dev, **including the cross-mesh
> gRPC path** — site-a first showed `—`, which is also what a broken mapper produces, so it was rebuilt to
> prove the `has_host_statuses` presence flag round-trips rather than assuming version skew.
> ✅ **A third table, same defect, found en route — now also RESOLVED** (`d979ac69`, Gitea **#524**):
> `DriverInstanceResilienceStatus` had no writer and no reader either — only its DbSet declaration — while
> the live data rides the `driver-resilience-status` telemetry channel into `IDriverResilienceStatusStore`
> (verified consumed: `DriverStatusPanel.razor` injects it, fed by both the DPS bridge and the gRPC dialer).
> Dropped by migration `DropDriverInstanceResilienceStatusTable`. Filed separately rather than folded into
> #521 so a second schema change was an explicit decision, not a side effect.
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
> ✅ **RESOLVED — DELETED** (`abda6f9e`, Gitea **#522** closed). `DriverTypeRegistry`, `MemoryTracking`,
> `MemoryRecycle`, `ScheduledRecycleScheduler`, `IDriverSupervisor` and the operator-authorable
> `RecycleIntervalSeconds` knob are gone.
>
> ⚠️ **This register understated the inertness, exactly as the issue did.** Both said the *Tier-C-only*
> protections were dormant, implying the Tier A/B parts ran. They did not: **nothing constructed
> `MemoryTracking` at all** outside its own unit tests, so the whole `Core/Stability` recycle layer was dead.
> That changed the decision — "pass real tiers" was never a flag flip, it meant writing the wiring that never
> existed *and* arming it on two live drivers at once. Deletion won because the premise is gone, not merely
> unused: Tier C meant an out-of-process Host a supervisor could restart, and `IDriverSupervisor` had zero
> implementations because there is nothing left to implement against.
>
> **`DriverTier` itself survives and is load-bearing** — `GetTierDefaults` supplies the real per-capability
> timeout/retry/breaker policies. Only the isolation-and-recycle layer above it went.
> `WedgeDetector` shared the folder, is equally dead, and is **not** recycle machinery — restored rather than
> swept up in a decision that was not about it. `IDriver.GetMemoryFootprint`/`FlushOptionalCachesAsync` lose
> their only consumer here; documented and filed as Gitea **#525** rather than buried in a 12-driver sweep.
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~~ **Update 2026-08-07 (truth sweep): closed by archreview R2-02** — the bulkhead knob was **deleted end-to-end** (migration-free; stored keys ignored) with an inertness guard test; see `archreview/plans/STATUS.md` R2-02 row | archreview 01/U-6 |
| ~~`WriteIdempotentAttribute` — zero readers; write dispatch hardcodes `isIdempotent: true`~~ **Update 2026-08-07 (truth sweep): closed by archreview R2-02** — dispatch flipped to `isIdempotent: false` (RED-first on the Runtime wiring test) and the attribute's false claim corrected; see `archreview/plans/STATUS.md` R2-02 row | 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`~~ *(update 2026-08-07, truth sweep: **PostgreSQL and Oracle dialects shipped 2026-08-05**, merge `d54f3c83` — managed Npgsql / Oracle.ManagedDataAccess.Core, live-gated; `MySql`/`Odbc` remain reserved enum names that fail at construction)*.
- **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) | ✅ **PASSED** `e77ea4aa` (2026-08-04) — and it found a **production-fatal defect the code-complete reading missed**: `ConfigureAwait(false)` in the Akka handler destroyed `Self`, the actor restarted, and the restart discarded the (empty-seeded) ref set. One value captured, then permanent silence. Two follow-ups filed and closed the same day: **#537** (capture had no Primary gate — both pair nodes wrote duplicates) and **#538** (a poison tag pinned the FIFO head, re-writing every healthy tag forever). | `docs/plans/2026-08-01-continuous-historization-live-gate.md`; `ad744734`, `e77ea4aa`, `e7fbe977`, `4bdce910` |
| **Wave-0 universal discovery browser** full tree render (#468) | ✅ **PASSED** `c2c3c522` (2026-08-01) — unblocked by **building** the missing sim capability (an `ab_server` patch) rather than waiting for hardware. The gate then found a decoder defect **350 unit tests agreed with**. | tracking doc §Wave 0 |
| **archreview R2-03** (T11 + T12) — VT Good→Bad on a data-fed rig | ✅ **PASSED 2026-08-07.** The rig is no longer data-less — the Modbus fixture is reachable from inside it — so the VT was staged Good and tripped **without a deployment** (a register was flipped on the fixture, so the VT child survived and could hold a last value): `42 → 0x80020000 → 42`, plus the P7 identical-redeploy spot-check (zero disturbance). ⚠️ **Found:** the Bad publish's "carrying the last-known value" is **unobservable by any client** — subscription *and* raw Read both return NULL. The actor is correct (new regression test pins it); the docs overstate what a client sees. | `docs/plans/2026-08-07-adminui-followups-and-live-gates.md` §2–3 |
| **archreview R2-10** — force breaker-OPEN live | ✅ **PASSED 2026-08-08 — by FIXING the structural gap** (`feat/wrap-driver-init-in-resilience`), the day after the 2026-08-07 run re-diagnosed it. `IDriver.InitializeAsync` now routes through the invoker as **`DriverCapability.Initialize`** (no retry — the actor's timer is the retry loop; **breaker threshold 2**, because at the 10 s reconnect cadence at most three attempts fit the 30 s sampling window and a Read-sized 5 could literally never open). Live: `rtu-gate` against dead `10.100.0.35:5021` opened its breaker on **both** centrals, the `/hosts` panel showed the red **"BREAKER OPEN"** chip, and a socat 5021→5020 forwarder recovered it — both breakers closed within 1 s, panel back to *"All targets healthy."* Control: healthy `MAIN-modbus`, zero breaker events. Companion fix: a respawn now `Invalidate`s the instance's **tracker** snapshots with its pipelines, or a deploy mid-outage would strand a false OPEN chip forever. Caveats recorded: the UI is served by the **active admin node only** (Traefik `/health/active` 503s the standby — "both-replica chips" means both *trackers*, one UI), and the staleness-dim leg was not exercised. | `docs/plans/2026-08-07-adminui-followups-and-live-gates.md` §7 |
| **Driver-reconfigure-while-faulted** Task 3 | ✅ **PASSED 2026-08-07** (the block indeed no longer held — docker-dev runs `DisableLogin`). A driver faulted on a bad device config adopted a corrected config **while faulted** and connected, with the node's `startedAt` byte-identical before/after and `RestartCount=0` — no restart, which is the whole point (pre-fix it retried the stale config forever). | `docs/plans/2026-08-07-adminui-followups-and-live-gates.md` §5 |
| 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.~~ *(Both now closed with full records: R2-03
2026-08-07, R2-10 2026-08-08 — their `.tasks.json` files carry the closure notes.)*
---
## 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, 3–7 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
> ✅ **Re-counted 2026-08-04 by parsing every `docs/plans/*.tasks.json`, not by reading this table.** The
> five rows below are confirmed unchanged, and the sweep found **one file this section missed**:
> `2026-06-14-driver-reconfigure-while-faulted-plan.md.tasks.json` (3 completed / 1 pending — the §5 live
> gate). **Update 2026-08-07: the count is now 11, not 31** — the 19 AdminUI tasks closed by audit and the
> 1 reconfigure task closed by a passing live gate. Total open plan tasks across the tree: ~~**31** — 19 of them in the single AdminUI file~~. Every
> other `.tasks.json` is fully closed; `2026-07-24-mesh-phase6` carries one task marked
> `resolved-no-flip`, which is a recorded decision, not an open item.
| File | Open | Note |
|---|---|---|
| ~~**`2026-05-29-adminui-followups.md.tasks.json`**~~ ✅ **CLOSED 2026-08-07** | ~~19 pending~~ **0 open** | **It was never backlog.** The overlap audit this row demanded found all 19 tasks shipped, superseded by v3, or moot — **no code written.** `CollectionEditor<TRow>` + the OpcUaClient endpoint editor shipped in the plan's own shape; the per-driver editors ×6 arrived as v3's `TagConfigEditorMap` + `DeviceFormMap`; WS2/WS3 (resilience form, `RoleMapper.Merge`, DB LDAP→role, `RoleGrants`, FleetAdmin) all shipped; WS4's comment targets were deleted with their files. ⚠️ WS1's premise is dead, not merely superseded: driver-embedded tag lists are no longer the runtime source of truth in v3. |
| `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 |
| ~~`2026-06-14-driver-reconfigure-while-faulted-plan.md.tasks.json`~~ ✅ **CLOSED 2026-08-07** | ~~1 pending~~ **0 open** | Task 3 live `/run` verify **PASSED** — see §5. |
`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.
✅ **Re-verified 2026-08-04 against the directory listing — unchanged, both halves.** The three
`Driver.Historian.Wonderware*` module directories are still present and still the only historian
review coverage, while the gateway backend that actually ships has none. Worth noting *which* 10 these
are: they are exactly the drivers added since the review sweep, i.e. the newest and least-exercised
code in the tree carries the least review.
- **`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).
**Update 2026-08-07 (truth sweep): the trio is mostly closed.** 01/S-1 fixed
(`AddressSpaceApplier.cs:95-97` → `AddressSpaceApplyOutcome.FailedNodes`); 03/S4 fixed
(`PrimaryGatePolicy.cs` default-deny on unknown role with a driver peer, live-proven R2-04 T15);
06/S-1 partial (fail-closed advise path + `galaxy.writes.unconfirmed` meter in
`GatewayGalaxyDataWriter.cs`; residual = gateway-side WriteComplete correlation, cross-repo).
- **`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 1–2 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.
>
> ✅ **Update 2026-07-30:** the **`Phase7*`→`AddressSpace*` rename is done** (four live docs; historical
> bannered files deliberately untouched), and the **two missing driver docs are written**. Still open:
> `docs/v2/driver-specs.md` coverage, the `NoOpSecretReplicator` investigation, and the two owed
> *rewrites* (`IncrementalSync.md`, `AddressSpace.md`) that were bannered rather than rewritten.
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.**~~ **Written 2026-07-30** and linked from the driver index. Writing them turned up two stale claims in existing docs — see §9. |
| — | `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.)
**Update 2026-08-07 (truth sweep): the wire-up itself has since shipped** — #520 CLOSED 2026-08-01
through stage 7 (merges `fbe7403c`, `3a6b2a1d`, `1ccc7400`, `d4389f57`, `3796c0bf`); enforcement
opt-in via `Security:Acl:Enabled` (default `false`). See §3.1's banner.
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.
**A second round merged 2026-07-30 as `2a0461a1`** (branch `feat/remove-periodic-rebrowse`, 3 commits,
also `--no-ff`) carrying the items §8 did not cover.
| Round-2 item | Status | Commit |
|---|---|---|
| #523 remove the periodic re-browse | ✅ **Done** | `5fff5973` |
| 13 stale `EquipmentTagsDarkBatch4` skips | ✅ **Done** — 6 revived, 3 folded, 4 deleted | `4624141a` |
| G-7 deploy-time TagConfig gate | ◐ **Narrowed + guarded** — MQTT wired; 4 drivers still uncovered | `4624141a` |
| 3 silently-broken OpcUaClient integration tests | ✅ **Fixed** — found by the full-suite run, not by the register | `4624141a` |
| §7.2 `Phase7*` → `AddressSpace*` rename | ✅ **Done** — 4 live docs | `4624141a` |
| §7.2 missing `Sql.md` / `Calculation.md` | ✅ **Done** | `f3cd685d` |
**A third round merged 2026-07-30 as `abda6f9e`** (branch `fix/521-522-host-status-and-tiers`, 3 commits,
`--no-ff`) closing the two keep-or-delete decisions §8 deliberately left to the user.
| Round-3 item | Status | Commit |
|---|---|---|
| **#521** `IHostConnectivityProbe` | ✅ **Done — resolved differently from how it was filed.** Neither "build the publisher" nor "delete": the publisher as designed was **unbuildable** after Phase 4, so the capability was kept and the transport changed to the Phase 5 telemetry stream. Table dropped. | `30d0697c` |
| **#522** driver stability tiers | ✅ **Done — deleted.** The inertness was broader than either the issue or this register said: nothing constructed `MemoryTracking` either, so "pass real tiers" was never a flag flip. | `cbb88229` |
| Live gate on docker-dev | ✅ **Passed**, one fix (`1 hosts` → `1 host`) | `ce589569` |
| **#524** `DriverInstanceResilienceStatus` | ✅ **Done — deleted.** Sibling of #521, dead for the same Phase-4 reason. | `d979ac69` |
| **#525** `IDriver` memory members | ⏳ **OPEN — recommendation withdrawn mid-execution.** See below. | — |
⚠️ **#525 is a worked example of the register's own recurring failure mode, committed by me.** I recommended
deleting `IDriver.GetMemoryFootprint`/`FlushOptionalCachesAsync` on the stated evidence that *"1 of 12
actually frees memory"*. That number came from grepping for **two specific cache-clear calls** and treating
their absence elsewhere as proof of absence. The real inventory: **8 of 12 flushes are no-ops, 3 free real
memory** (AbCip's UDT template cache, Calculation's compiled scripts, **MTConnect's cached probe model** —
which is a *designed* path with compare-and-swap discipline and tests asserting the footprint drops), and 1
is cosmetic. The sweep had already edited 12 drivers, `IDriver` and ~15 test files; it was **reverted**
rather than completed on a justification that no longer held.
What survives of the argument is narrower: `GetMemoryFootprint` returns `0` in 7 of 12, its computed
constants were invented and never validated against real memory because nothing ever consumed them, and
**the two halves are coherent in exactly one driver** (MTConnect) — AbCip and Calculation free real memory
while reporting footprint `0`; TwinCAT, Galaxy and Mqtt report footprints behind no-op flushes.
**The lesson, which is the same one §6 already records about this file:** a grep for the shapes you expect
is not an inventory. Enumerate the whole set and read each member before drawing a count — especially when
the count is the thing your recommendation rests on.
**Both closures produced a residual that was filed rather than folded in** — a second dead table
(#524 `DriverInstanceResilienceStatus`) and two now-consumerless `IDriver` members (#525). Neither was
in scope; both would have widened a schema change or a 12-driver sweep past what was asked.
**Three things this round got right by not trusting the register:**
- §3.2 recorded that `DriverHostStatus` was *"deliberately re-created in the v3 initial migration, so
deleting on inference would be wrong."* Checking Phase 4 showed the publisher it was waiting for
**cannot be written at all** — a driver-only node has no ConfigDb. "Deliberately re-created" meant
*carried forward mechanically*.
- §3.3 (and #522) said the Tier-C protections were dormant, implying Tier A/B ones ran. Nothing
constructed the trackers at all.
- The live gate treated an ambiguous observation as ambiguous: site-a showed `—` for host status, which
is what version skew **and** a broken cross-mesh mapper both produce. Rebuilding site-a distinguished
them rather than assuming the benign reading.
⚠️ **This file was destroyed and restored during round 2 — see the 2026-07-30 entry at the end of §9.**
It is the second time in two rounds that the register's own bookkeeping step damaged the register.
> ⚠️ **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).
### 2026-07-30 — I emptied this file and committed it
`f3cd685d` deleted all 941 lines of `deferment.md`. The file sat at **0 bytes** through the merge to
`master` and was only noticed when a follow-up edit's `grep` matched nothing. Restored from
`4624141a`; the two edits that commit was supposed to make have been re-applied.
**Cause — a Python evaluation-order trap, not a merge conflict.** The edit script ended with:
```python
open(p, 'w').write(open(p).read().replace(a2, b2))
```
Python evaluates `open(p, 'w').write` before its argument. Opening for write **truncates the file
immediately**, so by the time `open(p).read()` ran there was nothing left to read — it returned `""`,
and `""` was written back. The `assert` guarding the edit had already passed against the in-memory
copy, so nothing failed. `dotnet build` cannot notice an empty Markdown file, and `git add -A` staged
the deletion silently.
**Why it survived to a merge.** Every check I ran was capable of passing on an empty file: the build,
the test suite, and `git status --short` (which showed `M deferment.md` — a modification, which is
exactly what I expected to see). Nothing I ran asserted the file still had content.
**What this says about the process.** This is the second time in two rounds that the register's own
bookkeeping step damaged the register — round 1 recorded eight SHAs that pointed at dangling pre-amend
objects. Both failures share a shape: **the verification step and the mutation step were the same
action**, so a broken mutation produced a clean-looking verification. The cheap guard is to check the
artifact rather than the exit code — `wc -l deferment.md` after an edit, `git rev-parse` after the
commit is final rather than before.
### 2026-07-31 — #517 driver health ordering + #519 status sub-codes
Both closed. Each issue's *symptom* held up; each issue's *stated mechanism* was partly wrong, and in
one case its suggested fix would have made things worse.
**#519 — the sub-code collapse.** Exactly as filed: `DriverInstanceActor.QualityFromStatus` projected
the driver's `uint` onto a 3-state enum, `OtOpcUaNodeManager.StatusFromQuality` re-expanded `Bad` to
`0x80000000`, and no driver's sub-code ever reached a client. Resolved by **option (b)** — the raw
`uint` now rides the whole value path — chosen by the user over "accept and document".
The issue costed option (b) as "the sink interface and its three implementations". The real surface
was **41 files / ~205 references**, because `OpcUaQuality` had producers that are not drivers and
therefore had no `uint` to carry: `AddressSpaceApplier`'s four removal writes, `VirtualTagActor`,
`AlarmConditionSnapshot`, and the alarm-quality path. Most had a natural code (`BadInternalError` for
a failed script, `BadNoCommunication` for comms loss) and **two already computed one and threw it
away** — `ScriptedAlarmHostActor` held a pair of mutually-inverse mappers converting `uint`→enum→`uint`
around the engine, both now deleted.
Two behaviour changes worth knowing, both deliberate:
- `WriteAlarmQuality` fires its Part 9 event on a **status-code** change, where it used to fire on a
severity-bucket change. While the annotation was 3-state those were the same predicate; now a move
between two Bad reasons genuinely changes what a subscriber reads, so suppressing it would leave
clients holding a stale reason.
- `AddressSpaceApplier`'s removal writes **kept the generic `Bad`**. A removed node could say something
truer (`BadNodeIdUnknown`), but #519 was about not *losing* a code the driver already had; inventing
a new one for the deploy path is a separate call, so that stayed behaviour-identical.
**#517 — health published after teardown.** The symptom is real but the issue named the wrong path,
and its fix was unsafe:
- **`ShutdownAsync` cannot race.** `DriverInstanceActor` calls it from `PostStop` — actor thread, after
the mailbox drains — so the health poll is serialised against it. The one class with a lock-protected
concurrent `GetHealth`, `Core/Hosting/DriverHost`, **has no production call site at all** and removes
the driver from its registry before shutting it down anyway.
- **`InitializeAsync` can.** It runs on a fire-and-forget `Task.Run` (`DriverInstanceActor.cs:567`), so
the health tick keeps firing throughout. `MTConnectDriver` writes `Healthy`, then starts its stream
and probe loops — either can throw into a `catch` that disposes the client and only *then* writes
`Faulted`. That window is the issue's claim, made concrete, and it is the only reachable one.
- **The suggested fix — "write the transitional value before disposing" — inverts the terminal state
if applied as a move.** Teardown drains in-flight loops that write health as they unwind, so a
late loop can overwrite the shutdown value with `Healthy` and leave *that* at rest, connections
closed. The correct shape is a **bracket**: conservative before, terminal after. Applied to
MTConnect's init-failure path; the shutdown paths keep their single trailing write, and the rule +
the reachability table are documented on `IDriver.ShutdownAsync` so nobody "fixes" the ordering on
the wrong premise later.
- **One unambiguous defect, deterministic rather than racy:** `CalculationDriver.GetHealth()` returned
a hard-coded `Healthy` that consulted nothing — Healthy before initialize, after shutdown, and after
dispose. Its existing test was literally named `Health_is_always_Connected` and asserted the defect;
removed, and replaced with a lifecycle test.
**Falsification.** Every new guard was proved capable of failing by breaking the production code and
watching it go red: the Commons parity guard (corrupt a constant), the node-manager verbatim write
(restore the collapse), and the MTConnect ordering test (delete the pre-teardown write). The two
sub-code tests each assert **two different** Bad codes, because under the old projection a
single-code assertion could pass against a hard-coded constant.
**One self-inflicted defect, caught by the suite.** The bulk `.Quality` → `.StatusCode` rename ran with
a negative lookbehind for `condition` to protect the SDK's `condition.Quality`, which missed
`BrowseNames.Quality` — an SDK browse-name constant. The event filter then selected a field that does
not exist, and the wire test failed with an `NullReferenceException` on the cast rather than a readable
assertion. Same lesson as the round-3 entries above: a mechanical rewrite needs its output audited by
receiver, not just compiled — `git diff | grep -o '[A-Za-z_]*\.StatusCode' | sort -u` would have shown
it in one line. It hit **twice**: `BrowseNames.Quality` (caught by the wire test) and a `.Quality`
inside a script-source string literal in `EquipmentScriptPathsTests`' `InlineData` — which compiled
and passed, so only reading the diff by hand found it.
**Pre-existing, not chased:** the four `Driver.AbLegacy.IntegrationTests` failures need the
slc500/micrologix/plc5 compose profiles, which are not up on `10.100.0.35` (only the AbCip
controllogix fixture is). Reproduced on a stashed tree before dismissing them.
### 2026-08-04 — reconciled against reality; the tracker is empty
No code changed. This entry records a re-verification pass and the drift it corrected, prompted by the
question "what's left" — which this file existed to answer and, at the time it was asked, answered
**wrongly**.
**The drift.** §0.1 asserted "14 issues remain open" and §5 recorded #491 as `OPEN — code complete,
unproven in production; no gate-passed commit exists`. Both were false: the tracker had been at **0 open
since 2026-08-04**, and #491's gate had passed that morning — after finding a production-fatal defect
that the "code complete" reading had specifically failed to anticipate. §2's row for 491 still read
`CANNOT VERIFY (needs VPN'd gateway)`.
⚠️ **This is the third round in which this file's bookkeeping was the thing that broke** — after the
dangling pre-amend SHAs (round 1) and the truncation (round 2). The failure mode differs, but the shape
does not: **the register is only updated by the work it describes, so work that closes an item elsewhere
leaves the register asserting the item is open.** The two previous failures were mechanical and were
fixed with mechanical guards; this one is structural, and the honest guard is to re-derive rather than
re-read. Which is what this pass did.
**Everything below was re-derived from the source of truth, not from this file:**
| Claim | How re-derived | Result |
|---|---|---|
| Open issue count | Gitea API, `state=open` | **0** — corrected from 14 |
| Closure dates for the 14 | Gitea API per issue | #489/#481/#508–#515/#525 on 07-31; #520/#468 on 08-01; #491 on 08-04 |
| Open plan tasks | parsed **every** `docs/plans/*.tasks.json` | **31** across 6 files — and found a 6th file §6.2 had missed |
| Review coverage | listed `code-reviews/` | 10 projects unreviewed, 3 Wonderware modules retained — **unchanged** |
**What the re-derivation found that reading the file would not have:**
- **§6.2 was missing a file.** `2026-06-14-driver-reconfigure-while-faulted-plan.md.tasks.json` (1
pending) appears in §5 as a live gate but never in §6.2's inventory of live plans, so the two sections
disagreed on whether it existed as work.
- **§5 cited that file at the wrong date** — `2026-07-14-…`, which does not exist in `docs/plans/`.
Anyone following the reference would have found nothing and concluded the gate was already closed.
- **That gate's stated blocker has expired.** Task 3 is a live `/run` verification, deferred when it
needed an operator to sign in. docker-dev has since run the AdminUI with login disabled, so it is
runnable unattended — the only item on §5's list that is.
**One substantive correction carried into §0.1, not merely a status flip.** archreview **03/S4** —
"primary gate default-allows on unknown role" — describes the same shape as a decision *deliberately
taken* on 2026-08-04 in `PrimaryGatePolicy.ShouldRecordHistorizedValues`, where an unknown role
**captures**. Both readings are defensible in their own context (a duplicate historian sample is bounded
and self-correcting; an unauthorized write is not), so whoever closes 03/S4 must not generalise one rule
across both. Recorded now, while the reasoning is fresh, rather than left for a future reader to
rediscover from two files that never reference each other.
**What is left is now entirely untracked**, which is itself the finding: 4 hardware/licence-gated live
gates, 1 runnable deferred gate, the G-7 residual (deploy gate weaker than the authoring gate for 4
drivers), 3 carried-open archreview findings led by 01/S-1 (`AddressSpaceApplier` swallows sink failures
— a deploy reports Applied while broken), the 19-task AdminUI plan, and the review-coverage gap. See
§0.1 for the table.
---
### 2026-08-10 (eighth round) — the seventh round's recorded Opens all resolved: fleet-wide 0 pending findings
The 32 findings the seventh round left Open (35 minus `Driver.MTConnect-003`→#560 and two closed in
passing) were partitioned along disjoint-file boundaries into **8 parallel isolated-worktree Opus
agents** (MQTT driver+transport / MQTT browser+contracts / SQL driver-health / SQL dialect+chunking /
SQL browser+probe / SQL contracts / MTConnect / Calculation), each verified against only its relevant
suites. All 8 branches merged `--no-ff`; README regenerated, `--check` clean — **0 pending findings
across all 65 modules** for the first time since the review system started counting.
**Dispositions: 31 Resolved (27 code fixes TDD-red-first, 4 doc/constants resolutions), 1 documented
Won't Fix** — `Driver.Sql.Browser-003` (the ad-hoc connection-string browse literal): verified rather
than assumed that both reaching surfaces are `ConfigEditor`-gated with no HTTP endpoint, recorded as a
trust boundary in `docs/security.md`. The fixes most worth knowing about:
- **Fleet-wide numeric-enum rejection** (`Driver.Sql.Contracts-002`): `TagConfigJson.ClassifyEnumText`
in Core.Abstractions now refuses digit/sign-leading spellings before `Enum.TryParse` and requires
`IsDefined` after (catches non-`[Flags]` comma-OR too). Three AdminUI restatements routed to the
shared classifier in the same commit — left alone they'd have reopened the #549 drift direction.
- **SqlGroupPlanner chunking** (`Driver.Sql-002` + `Dialect-002`, one design): `ISqlDialect` gained
`MaxBindParameters` (SqlServer 2098 — `sp_executesql` spends two; Oracle 1000 — the `IN`-list limit,
deliberately not its 64k bind count) and the planner chunks key-value groups at the ceiling, members
following their key. Byte-measured identifier ceilings for Postgres/Oracle (`Dialect-001`).
- **Sql evidence clock aligned with the #527 watchdog** (`Sql-004/-005/-006/-010`): `LastSuccessfulRead`
now advances on any proof the database answered (`BadNoData`, `Uncertain`…), connection-class is
`BadTimeout` only, poll-error classification is per-exception-instance not per-type, and all health
transitions ride one CAS loop (monotonicity of the evidence clock was the RED observable).
- **Calculation pump split** (`Driver.Calculation-005`): scripts no longer run on the mux actor thread
or under `_lock` — trigger-time snapshots queue to a single serialized pump (`_evalGate` → `_lock`
order, at-most-one evaluation); a slow script blocked the dependency callback 1.21 s before.
- **MTConnect write-back became authorable** (`Driver.MTConnect-004`): the driver form gained the
Write-back switch; the block is written in BOTH states (an omitted block could never turn writing
off — pinned by a falsification control). Razor markup itself remains a rig live-verify residual.
- **MQTT**: reconnects that re-establish nothing now tear down instead of reporting success
(`Mqtt-007`, reachable when a deploy lands inside a subscribe round-trip); `SparkplugPrimaryHost`
stops disposing a gate a live reconcile still holds (`-006`); password-without-username warns
instead of silently connecting anonymous (`Transport-002`); strict `retainSeed` read through the
same parse the authoring gate consults (`Contracts-004`); dead `[Range]`s deleted because two
contradicted `Normalize`, the real authority (`Contracts-005`); pre-epoch DateTime writes and
wildcard-forming `SparkplugTopic.Format` closed (`-006`/`-007`); browse label-guard convergence,
atomic node cap, Path-keyed sibling sort (`Browser-002/-003/-004`).
Process notes, both carryable: (1) **agent worktrees base on `origin/master`, not local HEAD** — two
agents independently measured the AdminUI baseline at 1,466 against a brief claiming 1,474, because the
unpushed #560 arc wasn't in their base; harmless here, but base-verify matters when local is ahead.
(2) **The one cross-branch composition break was exactly the predicted shape**: the browser agent's new
`OracleFlavouredSqliteDialect` predated the dialect agent's `ISqlDialect.MaxBindParameters` member —
caught only by the consolidated full-solution build (the per-agent suites were green in both
worktrees), fixed by one delegating property. Shared `Driver.Sql/findings.md` header reconciled 7→0 by
the orchestrator after the three parallel SQL merges, per the briefs' carve-out.
Consolidated verification on the merged tree (relevant suites only, per request): build 0 errors;
Mqtt 782, Sql 417, Sql.Browser 84, MTConnect 546, Calculation 53, Core.Abstractions 298, Runtime 558
(+3 pre-existing skips), AdminUI 1,499 — **3,337 passed, 0 failed** (+305 new tests over the bases).
---
### 2026-08-10 (seventh round) — review-coverage gap CLOSED: 14 modules reviewed, Wonderware reviews retired to history
The §0.1 "Review-coverage gap" row is done. Six parallel isolated-worktree agents (4 Opus on the big
families, 2 Sonnet on the small ones) reviewed every never-reviewed project — the row's 10 plus
`Mqtt.Transport`, `Sql.Dialect`, `Calculation.Contracts` and `OpcUaClient.Session` (the last absent
from both the row and the request; found by listing `src/` against `code-reviews/`). All six branches
merged `--no-ff`; consolidated verification on the merged tree: build clean, **11 suites / 4,033
tests green, 0 failed** (Mqtt 729 proves the two Mqtt branches compose: 708 base + 16 + 5). README
regenerated — **65 modules**, `--check` clean.
**61 findings recorded; 26 fixed TDD-red-first on the branches; 35 recorded Open** (cross-module,
design-decision, or Low record-only — the same disposition split as the 2026-06-19 sweep). The fixed
Highs, all previously invisible to every suite:
- **Historian.Gateway**: alarm severity written as enum NAME while the read side `int.TryParse`s it —
every read-back collapsed to severity 1, and an existing test asserted the wrong value as correct.
- **Mqtt.Contracts**: a write to a wildcard `writeTopic` published an MQTT-3.3.2-2 protocol violation —
a conformant broker closes the connection, taking the whole driver (every subscription) dark.
- **Mqtt runtime ×2, same class — "state that outlives the session it describes"**: the plain-path
subscription manager never cleared `_subscribedTopics` across a session rebuild, so the #527
watchdog's automatic reconnect came back deaf (Connected/Healthy, zero subscriptions, values frozen);
and `InitializeCoreAsync` overwrote live `_connection`/`_primaryHost` without teardown, stranding a
connected broker session (reachable via `AdoptConfigDuringInit` racing a reconnect). The Sparkplug
siblings had both hardened already — worth re-checking other drivers keeping per-session bookkeeping
across Shutdown/Initialize.
- **Calculation**: multi-dep tags evaluated against partial snapshots during warm-up — a spurious
"script threw" Warning on every ordinary deploy for the standard calc-tag shape.
Notable Open (recorded in `findings.md`, fix sites named): ~~**Driver.MTConnect-003 (High,
cross-module)** — browse-committed MTConnect tags coerce to `String` at runtime while their nodes are
typed from the same probe inference (the #536 class; fix in AdminUI `RawBrowseCommitMapper`, which got
the typed-model branch without the native-type forwarding its 3 siblings have)~~ *filed as Gitea
**#560** and CLOSED same day (merge `5226f7fd`): `MapLeaf` threads the leaf's `DriverDataType` into
`BuildTagConfig` — a threaded param, NOT a fabricated addressFields entry, because MTConnect's
coercion type IS the agnostic enum and `NativeType`'s contract is read-never-derive; stated field
still wins; pair-asserting tests (row type AND blob type), red-first; AdminUI 1,474 + MTConnect 538
green; finding Resolved, module open count 5→4*; Driver.Sql-002
(unchunked `IN` list vs. the 2100-param SqlClient cap); Driver.Sql.Contracts-002 (cross-module:
`Enum.TryParse` accepts numeric strings, so `"type":"99"` deploys an undefined `DriverDataType` —
belongs in `Core.Abstractions`); Driver.Calculation-005 (evaluation runs under `_lock` on the mux
actor thread, up to `RunTimeout`).
Also this round: the **three `Historian.Wonderware*` review modules got retired-module banners**
(backend deleted in the gateway cutover, zero slnx references; Server/Admin deleted-module precedent)
and the untracked `bin`/`obj` litter dirs under `src/Drivers/` were removed. Process traps for next
time: (1) my brief's regen-readme gotcha was worded over-broadly and one agent made its finding
headings `####` — **invisible to the generator's `^###\s+` split**; `--check` catches it only when the
header claims Open findings, so a 0-open file fails silently — audit `grep -c '^### '` per new file;
(2) the harness Write tool refuses subagent-written `findings.md` (pattern-matches "report files") —
agents worked around via scratchpad-copy or heredoc, legitimately.
---
### 2026-08-10 (sixth round) — open-plan-task sweep: 2 verified live, 2 closed obsolete; 7 → 3
A freshness audit of the 7 open plan tasks (parsed from `*.tasks.json`, per the re-derive rule)
against the current tree, then one Opus agent ran the two runnable verifies. No code changed.
- **followups-batch Task 9 (Modbus-Int64 live authoring) — VERIFIED PASS.** Rig rebuilt to master
`481f4343` first (the running image was 2 days stale; parity asserted across all 6 nodes — the
round-robin trap). Authored `TASK9-INT64` (RawPath `pymodbus/plc/Task9Int64`, HoldingRegisters
address 200, Int64 BigEndian) via ConfigDb + the headless deploy API; deployment sealed. The node
advertises DataType `i=8` (Int64), scalar, Good, **identically on :4840 and :4841**, and the value
is the exact BigEndian decode of HR200–203 — four distinct non-zero words, so a byte-order or span
bug could not coincidentally match. Change propagation proven by driving HR203 via FC06 and
watching the subscribe on both centrals (registers restored after). Fixture caveat recorded: the
standard profile has **no naturally-churning 4-register span** (HR100 is isolated; any span over
it returns ILLEGAL DATA ADDRESS), so churn must be externally driven. Tag row left deployed and
serving, consistent with prior gate rows.
- **followups-batch Task 10 (S7 + AbCip Test-Connect probe E2E) — VERIFIED PASS.**
`DriverProbeHandshakeE2eTests` **12/12, 0 skipped** — S7 green, AbCip green with `"CIP session
OK"` (a real authored-tag read, not the fallback), AbCip no-authored-tags reachability, plus the
Modbus/OpcUaClient/local-rig/Galaxy legs (mxaccessgw on `10.100.0.48` happened to be up).
**Premise correction:** the skip-gating is pure TCP reachability (`DockerFixtureAvailability`),
not env vars, and the checked-in defaults already point at `10.100.0.35` — the task's
"needs Windows-VM fixtures" note had been stale since the Mac migration. S7 fixture started and
removed (net zero); AbCip was already up and left as found.
- **fixedtree Task 11 — CLOSED OBSOLETE.** The FixedTree-under-Equipment injection feature it
gated was deleted wholesale when #507 closed as superseded (`DiscoveredNodesReady`,
`DiscoveredNodeMapper`, the 18 `DiscoveryInjectionDormantV3` tests — all gone). A verification
gate for deleted code verifies nothing.
- **adminui-driver-pages 10.4 (manual smoke checklist) — CLOSED OBSOLETE.** The routed
`/clusters/{id}/drivers` flow it smokes (the 8 `*DriverPage` shells) was retired in v3 Batch 2;
no driver pages exist under `Components/Pages/`. The form bodies live on inside the `/raw`
modals, live-gated repeatedly across the 2026-07/08 rounds.
~~**Open plan tasks are now 3**~~ **now 1** (updated later 2026-08-10), all deliberate: ~~F10b
surgical DataType/IsArray + alarm-severity `SetSeverity` (reconsider gates — premises re-verified
still true on the current tree: `TagDeltaIsSurgicalEligible` still rebuild-forces DataType/IsArray;
the node manager still projects engine-computed severity)~~ *both reconsider gates were put to the
user later that day and CLOSED decided-against — F10b: the rebuild path is correct and the surgical
swap buys a rare edit at the cost of a client-visible atomicity window + ModelChangeEvent surface +
a mandatory live gate (re-open trigger: operator complaints about rebuild churn on data-type edits);
SetSeverity: operationally invisible, the engine overwrites authored severity on first eval* — and
the device-gated proofs (hardware-blocked; ~~two sub-items possibly part-covered by the 2026-06-27
historian gate and `dcb0be65`, worth trimming when next touched~~ *trimmed 2026-08-10: Phase C T7
(backend retired; HistoryRead-vs-AVEVA proven by the cutover gate + #491 read-back) and Phase B T9
(equivalent proof live via `dcb0be65`) both removed — the bundle is now H6 native-ack→AVEVA + the
AbLegacy/TwinCAT/FOCAS probe happy-paths, all genuinely gated*).
Side effect worth knowing: **the docker-dev rig now runs current master** (`6e7028e0856f`).
---
### 2026-08-10 (fifth round) — #559 closed: MQTT joins the fleet's framing contract; the drift arc ends
The fourth round's sole residual, one isolated-worktree Opus agent, fix `621a9e5d` merged to master.
Verification on the merged tree: clean build; 4 affected suites / 2,790 tests green (Mqtt 708,
AdminUI 1466, ControlPlane 261, FOCAS 355).
- **#559 CLOSED** — both MQTT runtime mappers (`FromTagConfig`, `FromSparkplugTagConfig`) dropped
their untrimmed `|| tagConfig[0] != '{'` clause; `JsonDocument.Parse` + the root-`ValueKind`
check are the single framing authority, as in the six sibling drivers. The verdict changes ONLY
for whitespace-padded JSON objects (now earning their content's verdict); bare names, arrays,
numbers, strings and blanks are refused by the same mechanisms as before, and a caller sweep
confirmed nothing relied on the first byte (no name-vs-blob fallback exists in v3). `Inspect`
needed **no logic change** — since #556 it trims its own pre-filter and hands the RAW blob to
the mappers — but four comment sites claiming the padded-blob refusal was real were rewritten.
- **The #556 backstop lost its only vehicle, by design.** The padded blob was the one
production-reachable shape for "refused, but no DescribeRejection branch can name why"; after
the fix no vehicle exists (a union refusal with a topic requires a named strict-field failure;
without topic and without the Sparkplug tuple the no-address branch fires). The backstop stays
as defence-in-depth, re-pinned **FOCAS-style as a count relation**
(`A_rejected_config_always_draws_more_than_its_advisories_alone`), and the old flipped test was
replaced by the stronger parity form `Leading_whitespace_changes_neither_the_verdict_nor_the_inspection`
over the accepted AND rejected corpus with an anti-vacuity control proving the corpus carries
both verdicts. New runtime tests assert padded-vs-unpadded **definition equality**, not just the
accept bool. Falsification: each mapper sabotaged separately — exactly 5 (Plain) / 4 (Sparkplug)
tests reddened, all specific to the sabotaged mapper, nothing unrelated.
- **Honest coverage note:** the three padded entries added to the AdminUI parity corpus stayed
green under BOTH sabotages — `MqttTagConfigModel` consults the same union parse via
`TagConfigParseGate`, so authoring moves with runtime by construction. They pin a future
*divergence* (unwiring the parse-gate consult), not this defect; the load-bearing coverage is
driver-side. A parity test cannot detect a defect both sides share.
**No residual filed.** The authoring-vs-deploy-gate drift arc (#549 → #550/#551 → #552/#553 →
#554/#555 → #556/#557/#558 → #559) is closed end to end.
---
### 2026-08-10 (fourth round) — #556 + #557 + #558 closed: Inspect trims and classifies; the last alias and the last hand-list fall
The three residuals of the #554/#555 round, three isolated-worktree Opus agents, merged `7482cf0c`
(#558), `93a05828` (#557 OpcUaClient half), `be15ed16` (#556 + #557 Mqtt half). Consolidated
verification on the merged tree: full build clean; 14 suites / 4,583 tests green (AdminUI 1460,
ControlPlane 261, Modbus 395, S7 302, AbCip 409, AbLegacy 243, TwinCAT 219, FOCAS 355, Mqtt 686,
OpcUaClient 143, OpcUaClient.Browser 10, Galaxy.Browser 21, Sql.Browser 79).
- **#556 CLOSED** — all seven Contracts factory `Inspect`s `TrimStart()` before the `[0] != '{'`
framing check, and each classifies its findings **structurally** into two lists — `rejections`
(the parse would refuse) and `advisories` (accepted, worth flagging) — with the "rejected blob
never draws zero warnings" backstop gating on `rejections.Count == 0`, not the combined count
(an advisory can no longer satisfy the backstop vacuously; no string matching anywhere). The fix
surfaced a discovery: **MQTT's runtime mappers uniquely also carry the framing gate**, so for
MQTT a leading-whitespace blob is genuinely refused at runtime and its trimmed Inspect reports a
real rejection — accept-gate-first symmetry holds. The other six runtimes tolerate the whitespace
(`JsonDocument.Parse` skips it), a cross-driver served-behavior inconsistency deliberately NOT
changed here (an Inspect-wording issue must not change served behavior) — **filed as #559**.
FOCAS's backstop is unreachable today (no unnamed refusal exists); pinned as a count relation so
it fails the day one appears, rather than deleted.
- **#557 CLOSED, both halves.** *OpcUaClient:* legacy `NodeId` was readable at runtime but the
editor dropped it — `OpcUaClientTagConfigModel` gained `NodeIdKeys` + `ReadNodeId` implementing
the runtime's **presence-shadows** semantics (`TryGetProperty("nodeId") || TryGetProperty("NodeId")`,
then one ValueKind/non-blank check) — deliberately NOT MTConnect's `FirstNonBlank`: a
present-but-blank `nodeId` short-circuits the `||`, blocks fallback, and the blob is REJECTED —
blank-beside-valid-legacy must refuse, and now does in both layers. `ToJson` drops aliases keyed
off the SAME array (#550 pattern). The `TagConfigNonEnumParityTests` OpcUaClient exemption is
removed — the anti-vacuity control (`Every_deliberately_stricter_entry_is_still_exactly_that`)
is what would have caught it going stale. *Mqtt:* the union parse verdict is the **decided
contract**, not a bug — `FromSparkplugTagConfig` hardcodes `PayloadFormat.Json` and never reads
the `payloadFormat` key, so a typo'd value on a sparkplug-shaped blob is genuinely servable and
refusing it would be stricter than runtime. Mitigation: a **mode-dependency advisory** on the
accepted blob (advisories ride accepted blobs by design, so the rejection backstop never trips).
- **#558 CLOSED** — one guard per seam: the browse-commit dispatch fact moved out of
`DriverDispatchMapParityTests` (now CsvColumnMap-only) into `RawBrowseCommitMapperParityTests`
(now 6 facts, the sole guard for that seam; subsumption proven under the same neuters before the
old fact was deleted). The hand-maintained `NonBrowsableDriverTypes` list is gone — the browsable
set derives from the real production `AddAdminUI()` registration, the non-derivable universal
arm covered by `DriverTypeNames.All` enumeration + `GenericKeyExemptions` + a
contradiction-refuting fact. **Ordinal (not IgnoreCase) membership compare is load-bearing** — a
case-only typo in a browser key is exactly the drift the guard exists to catch. Galaxy/OpcUaClient
browser `DriverType` literals became `DriverTypeNames` constants (Galaxy's "GalaxyMxGateway"
exact-matched before substitution), closing the last string-literal channel into the derived guard.
**Residual filed:** **#559** — align MQTT's runtime whitespace handling with the fleet (or declare
the framing gate the contract). The authoring-vs-deploy-gate drift arc (#549 → #550/#551 →
#552/#553 → #554/#555 → #556/#557/#558) is otherwise closed end to end.
---
### 2026-08-10 (third round) — #554 + #555 closed: authoring consults the parse; the cited guard now exists
The two residuals of the #552/#553 round, again two isolated-worktree Opus agents, merged `7cfe7b4a`
(#554) + `4f1c83a7` (#555). Consolidated verification on the merged tree: full build clean; 14
suites / 5,251 tests green (AdminUI 1430, ControlPlane 261, Modbus 373, S7 285, AbCip 390, AbLegacy
230, TwinCAT 205, FOCAS 341, Mqtt 673, Sql 363, MTConnect 534, OpcUaClient 143, Calculation 23).
- **#554 CLOSED** — every mapped `*TagConfigModel.Validate()` consults the driver's own runtime
parse on the AS-LOADED blob via the new `TagConfigParseGate` (AdminUI-side, no factory touched):
**verdict from the parse, wording from `Inspect` only after refusal**. The two design points are
pinned: gating on "Inspect said something" would be stricter than runtime (FOCAS/MQTT/Calculation
advisories ride ACCEPTED blobs), and judging typed fields instead of the source blob would accept
exactly what the lenient load hides. Survey closed real gaps in 5 of 11 drivers: Modbus (absent
`region`, address shapes, `stringLength<1`, `bitIndex>15`, and `{}` itself), S7 (`stringLength`
0–254), TwinCAT (`dataType:"Structure"` — valid enum NAME the reader refuses; no spelling gate
can see it), Sql (the recognition rule), Mqtt (`"qos":null`). Deliberately-stricter editor rules
KEPT and pinned by name, incl. AbLegacy's `arrayLength>256` (the runtime *clamps* but the
materialiser advertises the RAW number in `ArrayDimensions` — the authored value is silently not
honoured, so author-time refusal is the honest treatment). ⚠️ Behaviour change: **an untouched
`{}` Modbus config is now refused at save** — it was the only editor with no required field, and
a `{}` tag reads BadNodeIdUnknown forever under a green deployment. Anti-vacuity guards: the
parity corpus must exercise BOTH verdicts per driver; an exemption that stops being
deliberately-stricter FAILS instead of silently excusing.
- **#555 CLOSED** — `RawBrowseCommitMapperParityTests` now exists, holding the mapper to what its
comment merely claimed. The browsable set is DERIVED: the test runs the production `AddAdminUI()`
registration and reads back every `IDriverBrowser.DriverType` (the set `BrowserSessionService`
indexes). The universal `DiscoveryDriverBrowser` arm is NOT derivable from the AdminUI assembly
(needs runtime drivers + Host's `IDriverFactory`; the #526 cut is deliberate) — covered by
enumerating `DriverTypeNames.All` with a reasoned `GenericKeyExemptions` list (Modbus,
Calculation) plus a fact that mechanically refutes any exemption the derived arm contradicts.
Falsified three ways (disabled Sql branch → red naming Sql; disabled MTConnect branch → red via
the enumeration half that DI derivation cannot reach; bogus exemption → the contradiction fact
red quoting it). The mapper comment now states the two real claims and the residual trust.
- **Residuals filed, not folded in:** **#556** (Inspect polish — the `reference[0] != '{'`
pre-filter has no `TrimStart()` so `" {…}"` draws zero warnings in 7 factories; FOCAS/MQTT
advisories can swallow the rejection backstop via `warnings.Count == 0`), **#557** (reader
asymmetries — OpcUaClient's legacy PascalCase `NodeId` is readable but unsaveable untouched; Mqtt
Sparkplug parse accepts a `payloadFormat` the Plain parse rejects and the union admits it),
**#558** (test hygiene — the pre-existing `DriverDispatchMapParityTests` browse-commit fact
overlaps the new guard and its `NonBrowsableDriverTypes` list is hand-authored AND misnamed;
two browser classes return `DriverType` string literals instead of `DriverTypeNames` constants,
unguarded).
### 2026-08-10 (later) — #552 + #553 closed: the browse states, the merge preserves
The two residuals the #550/#551 round exposed, again two isolated-worktree Opus agents, merged
`1f351250` (#552) + `62fe6c83` (#553). Consolidated verification on the merged tree: full build
clean; 8 suites / 3,362 tests green (AdminUI 1262, ControlPlane 261, Sql 363, Sql.Browser 79,
MTConnect 534, Modbus 373, S7 285, TwinCAT 205).
- **#552 CLOSED** — Sql browse-commit emits a valid `rowSelector`. The **session states, the mapper
never infers** (the MQTT Sparkplug rule): `SqlBrowseSession` names a `topByTimestamp` candidate
from the column `DATA_TYPE` metadata it already reads (`MapColumnType == DateTime` — type only,
never a name heuristic; the fixture pins a `sample_ts TEXT` trap column), tie-break = first in
catalog order (every dialect's `ListColumnsSql` already orders by ordinal), zero extra queries
(pinned by a command-count test). New `SqlBrowseAddressFields` constants in `Driver.Sql.Contracts`
end the producer/consumer agree-by-coincidence on field names. No-candidate tables are refused
**in words at the modal** (`DescribeUncommittableLeaf` — names leaf, table, and the
whereColumn/whereValue fix, whose value is *data* no browse can supply); the emitted blob keeps
the wide-row shape so table/column survive into the typed editor. The blob's top-level
`timestampColumn` (source-timestamp) is deliberately NOT set — ordering by a column is not a
claim it timestamps the value. Emitted blobs are double-accepted (model validator AND the real
`SqlEquipmentTagParser`) with a pre-#552-bytes-refused control; the `RawTagConfigGateTests` Sql
leg flipped from records-the-gap to passes-the-gate.
- **#553 CLOSED — and characterization first shrank it.** On the current tree (post-#550/#551)
MTConnect's invalid-alias/invalid-cell legs were ALREADY caught (raw-key maps never round-trip);
the real defects were the model-backed maps' laundering (Modbus/S7 `"dataType":"Intt16"` →
silent `Int16`) and the **export side actively setting the re-import trap** (`ReadTypedColumns`
read only the canonical key → the typed column printed the *ignored* value, the residual kept the
*winning* alias). Fix is evidence preservation, not a second validator:
`ApplyTypedColumns` restores the base cell's own tokens over the model serialisation for every
key no non-blank column overrode (fragments compose byte-identically; the #551 gate stays the one
authority, now judging unlaundered evidence); `CsvTypedColumn.ReadKeys`/`OwnedJsonKeys` declare
the runtime's full spelling precedence once (MTConnect pairs), so firing columns clear
out-ranking aliases, the export residual strips them, and export cells show the effective value.
Override-contradiction semantics: column wins, OPENLY (review grid names each override) — #549's
doctrine is against *silent* rewrites. Falsification: guard neutered → exactly the 7
defect-closers red while the #551 gate suites stayed 53/53 green (a layer in front of the gate,
not a replacement).
- **The deliberate-trap discipline in the #552 fixtures is worth copying:** the multi-candidate
table's column names are asserted to DISAGREE with alphabetical order (the tie-break test cannot
pass by luck), and the no-candidate table carries the exact `sample_ts TEXT` column a name
heuristic would have bound to.
- **Residuals filed, not folded in:** **#554** — authoring models validate only enum keys; non-enum
rules (Modbus address shapes, S7 stringLength bounds…) meet no gate until deploy, where the #551
accept-gate-first Inspect names them — feedback latency, not silent death; closing it means the
models consult the same runtime parse the Inspects wrap, after a fleet survey (N-1 rule).
**#555** — `RawBrowseCommitMapper`'s fallback comment cites a `RawBrowseCommitMapperParityTests`
guard that DOES NOT EXIST; the "every browsable driver handled" invariant is unguarded (9 types
covered by hand-written InlineData) — write the `DriverTypeNames.All`-driven parity test the
comment already describes.
### 2026-08-10 — #550 + #551 closed: the accept-gate-first fleet conversion
The two #549-survey residuals, again two isolated-worktree Opus agents, merged `b50523b1` (#550) +
`333ea488` (#551). Consolidated verification on the merged tree: full build clean; 12 suites /
5,098 tests green (AdminUI 1233, ControlPlane 261, Runtime 558, Modbus 373, S7 285, AbCip 390,
AbLegacy 230, TwinCAT 205, FOCAS 341, Mqtt 673, MTConnect 534 + 15 integration).
- **#550 CLOSED** — `MTConnectTagConfigModel` reads `DataTypeKeys = ["driverDataType", "dataType"]`
with the runtime's exact `FirstNonBlank` per-key semantics (blank falls through; a NON-string
token STOPS, because the runtime's `string?` DTO deserialise throws and can never fall through)
and `ToJson` drops every non-canonical entry of the same array — read and drop are keyed off one
list so a third spelling can never be read-but-not-dropped. Both half-fixes were falsified
separately (read-only → 9 red; drop-only → 14 red incl. the Int32-blob-retypes-to-String test).
- **#551 CLOSED, structurally** — instead of three more hand-mirrored checks, all seven pre-#549
`Inspect`s (Modbus/S7/AbCip/AbLegacy/TwinCAT/FOCAS + **Mqtt**, an unnamed sibling the re-derive
found) converted to **accept-gate-first** (the G-7/MTConnect shape): parse accepts ⇒ silent,
parse rejects ⇒ a warning must name why, pinned per-factory by a structural invariant test
(rejected blob can never draw zero warnings). That closed the named gaps (S7 absent-`dataType`,
AbCip `members[]`, Modbus `stringByteOrder`) AND the unnamed ones (absent/blank address keys
fleet-wide; TwinCAT's valid-enum-name-but-refused `Structure`; Mqtt's gate is the union of both
payload mappers or every Sparkplug tag false-warns). Authoring is now enforced **in the service**:
`RawTreeService.CreateTagAsync`/`UpdateTagAsync`/`ImportTagsAsync` all run `TagConfigValidator`
(the issue's premise that manual entry rides the import was wrong — it commits via
`CreateTagAsync`), with the CSV review grid keeping the early per-row message; a rejected import
row is a named ERROR in the all-or-nothing result. S7 authoring rejects an absent `dataType` via
a source-shape capture (`_dataTypeMissingInSource`, judged on the KEY like the runtime's
`DeclaresDataType` — browse-commit-built models and load→save round-trips stay clean). Doc sweep
done: every "silently defaulted by the lenient runtime" comment now states the strict reality.
- **Never-stricter preserved deliberately where it bites:** Modbus's narrowing casts are mirrored
(`bitIndex:256` is bit 0 and accepted), AbLegacy's `arrayLength` clamp is not warned, FOCAS's two
accepted-blob advisories stay a distinct warning class (promoting warnings to errors must not
block a deploy the driver would serve).
- **Residuals filed, not folded in:** **#552** — Sql browse-commit emits a `rowSelector`-less
WideRow config both gates refuse (verified live; the tag was NEVER readable, so the #551 gate
correctly moved the failure from silent-dead-tag to a named commit error — but Sql browse-commit
now always fails and needs a `rowSelector` source in the browse UI). **#553** — the CSV
typed-column merge (`CsvDriverTagMap.ApplyTypedColumns`) round-trips the free-form cell through
the lenient model load, silently rewriting a typo'd enum to its default BEFORE any gate can see
it (the CSV analogue of #549), and MTConnect's exported `driverDataType` residual still shadows
the `MtDataType` column on re-import.
### 2026-08-09 (later) — #549 authoring strictness (6 models); NamespaceMap out of Contracts
Follow-ups from the round below, again two isolated-worktree agents, merged `3a70a4e6` + `10c7a0e8`.
Consolidated verification on the merged tree: full build clean; 12 suites / 4,119 tests green
(AdminUI 1175, ControlPlane 261, Core.Abstractions 284, MTConnect 534, AbLegacy 219, TwinCAT 192,
FOCAS 327, OpcUaClient 143 + Browser 10, Modbus 346, S7 264, AbCip 364).
- **#549 CLOSED — and the survey it demanded found the defect was 6 models wide, not 1.** The G-7
round's "MTConnect authoring accepts a dataType the runtime rejects" generalised: **R2-11 Phase C
made every runtime TagConfig enum read strict** (`TagConfigJson.TryReadEnumStrict` rejects the
tag), so every lenient `GetEnum` authoring model was authoring-weaker-than-runtime — MTConnect,
Modbus (region/dataType/byteOrder), S7, AbCip, AbLegacy, TwinCAT, FOCAS. Mqtt and Sql were already
strict on both sides. Fixed one-authority: MTConnect's `Validate` consults the factory's exposed
`IsAcceptedDataTypeName`; the other five consult `TagConfigJson.DescribeInvalidEnum`, which
delegates to the runtime's own `ClassifyEnumText` (verdict AND wording from one implementation).
Deliberately not stricter than runtime: absent keys / non-string tokens stay accepted where the
runtime accepts them, each pinned. MTConnect also captures the raw JSON token — an unquoted
`"dataType": 8` throws in the runtime DTO deserialise, so authoring now rejects it too.
- **Survey residuals filed, not folded in:** **#550** (MTConnect `driverDataType` is a second,
runtime-preferred spelling the editor neither reads nor writes — a valid one silently overrides
what the editor displays; fixing needs both read-both-spellings AND normalise-on-save together) and
**#551** (4 gate-coverage gaps: `RawCsvImportModal`'s free-form TagConfigJson column meets only the
deploy gate; S7's unique absent-`dataType` hard-requirement warns nowhere; AbCip member `dataType`
+ Modbus `stringByteOrder` checked by neither gate; the seven pre-#549 `Inspect` doc summaries
still claim "silently defaulted by the lenient runtime" — stale since R2-11).
- **NamespaceMap moved out of `OpcUaClient.Contracts`** into a new `Driver.OpcUaClient.Session` leaf
(merge `10c7a0e8`), so ControlPlane's G-7 reference no longer drags the OPC UA client SDK into its
dependency graph (before: five `OPCFoundation.*` packages transitively; after: none). The
alternative — Browser referencing the driver, the Mqtt #512 shape — lost on a measurement: AdminUI
references `.Browser`, so it would have pulled the driver runtime into the AdminUI graph, exactly
what #512 spent two extractions removing. The G-7 one-authority seam survives (`BuildRawPathTable`
still calls `OpcUaClientTagDefinitionFactory.TryReadNodeId`, now `.Session → .Contracts`).
`ContractsIsDependencyLightTests` pins it from both directions (Contracts manifest names no
`Opc.Ua*`; `.Session`'s must — so "none anywhere" cannot read as a pass), falsified red before trust.
### 2026-08-09 — G-7 closed; RawBrowseModal race ported
The two items §0.1 flagged as "highest-leverage runnable from here", executed in parallel by two
isolated-worktree agents and merged (`0739f4b9` RawBrowseModal, `0695f682` G-7). Consolidated
verification ran on the **merged** tree, not just each branch: full-solution build clean; AdminUI.Tests
1127, ControlPlane.Tests 260, MTConnect 534, Sql 363, OpcUaClient 141, Calculation 23 — all green.
- **G-7** — the load-bearing design choice is that all four new `Inspect`s **wrap the runtime parse**
rather than mirroring the AdminUI model: Sql wraps the pre-existing `SqlEquipmentTagParser`; the
MTConnect (`MTConnectDriver.TryReadRawTag`), OpcUaClient (`NamespaceMap`'s private node-id reader) and
Calculation blob readers were **moved** into their Contracts assemblies and the drivers now call them —
so "the deploy gate is never stricter than the runtime" is structural, not a convention that can
drift. Two deliberate narrowings: Calculation's `Inspect` does **not** flag a trigger-less tag or a
sub-50 ms timer even though the AdminUI editor refuses both, because the runtime accepts both and
`TagConfigValidationMode.Error` turns warnings into deploy blockers (pinned by a falsification
control); and Galaxy stays unmapped on both gates, symmetrically. `Driver.Calculation.Contracts` is a
new project. ControlPlane now pulls the OPC UA client stack transitively via OpcUaClient.Contracts —
assessed: every host loading ControlPlane already carries it.
- **Found en route, left open (the reverse of G-7):** `MTConnectTagConfigModel` reads `dataType`
leniently — a misspelled enum name is accepted at authoring and silently rewritten to `String`, while
the strict runtime parse rejects it. *Authoring weaker than runtime.* Documented inline in
`TagConfigAuthoringDeployParityTests`; worth a tracker issue when one is next filed.
- **RawBrowseModal** — the sibling `_openGeneration` port needed two extensions the siblings did not:
generation-guarding the `catch` and the `finally` (this modal resolves inside `try/finally`, so a
stale `return` still runs the tail and would clobber the LIVE open's state). Each of the four guards
is pinned by its own test — per-guard reverts produced exactly 1-failed/3-passed each time.
---
### 2026-08-08 — R2-10 closed by fixing the structural gap (`DriverCapability.Initialize`)
The 2026-08-07 entry below left R2-10 T10 "still open — structural." The user chose the fix over the
other three options (gate through a different driver / drivers reporting out-of-band / retire the gate),
and the gate then **PASSED** against the fixed build. Branch `feat/wrap-driver-init-in-resilience`;
evidence in [`docs/plans/2026-08-07-adminui-followups-and-live-gates.md`](docs/plans/2026-08-07-adminui-followups-and-live-gates.md) §7.
- **`IDriver.InitializeAsync` now routes through the invoker** as `DriverCapability.Initialize` — the
one capability call the pipeline never saw, which is why a dead endpoint produced a quiet panel. No
pipeline retry (the actor's `RetryConnect` timer is the retry loop; parser-enforced), and **breaker
threshold 2** — at the 10 s reconnect cadence at most three attempts fit the 30 s sampling window,
so the Read-sized 5 could literally never open. That threshold arithmetic is the load-bearing detail
and is pinned by its own test.
- **Live:** `rtu-gate` against dead `:5021` → breaker OPEN on both centrals + the red panel chip;
socat forwarder up → both breakers closed within 1 s, panel cleared. Healthy `MAIN-modbus`: zero
breaker events (control). Bonus: a config-invalid OpcUaClient driver also now trips — equally
invisible before.
- **Companion fix found by asking "what clears the flag":** a respawn (`Create`) now `Invalidate`s the
instance's tracker snapshots along with its pipelines — a deploy mid-outage discards the only
breaker whose `OnClosed` could clear the OPEN flag, so the old behaviour would have stranded a false
OPEN chip forever. Unreachable until this change made breaker-open reachable at all.
- **Two honest narrowings of the original T10 wording:** "both-replica chips" = both *trackers* (one
active AdminUI — Traefik's `/health/active` deliberately 503s the standby, discovered fighting the
sticky cookie); the staleness-dim leg was not exercised (needs every publisher for a key to stop).
### 2026-08-07 — adminui-followups closed by audit; three of four runnable gates PASSED
Took the two items §0.1 named as the remaining untracked engineering. Full evidence:
[`docs/plans/2026-08-07-adminui-followups-and-live-gates.md`](docs/plans/2026-08-07-adminui-followups-and-live-gates.md).
- **`2026-05-29-adminui-followups` — CLOSED, no code written.** §0.1 called it "the only substantial
engineering left"; the overlap audit it demanded shows all 19 tasks shipped, superseded, or moot.
The lesson is the one this register keeps re-learning: **a `.tasks.json` is not evidence.** The
plan's *premise* had also expired — WS1 assumed driver-embedded tag lists are the runtime source of
truth, which v3 ended. Supersession claims were checked for **reachability** (`TagConfigEditorMap`
is dispatched from `RawTagModal.razor:79`), because "superseded by X" where X is dead surface is
this repo's signature failure.
- **R2-03 T11 ✅ / T12 ✅.** Both blockers the previous pass recorded are gone — the Modbus fixture is
now reachable from inside the rig. The trip had to be staged **without a deployment** (flip a
register on the fixture) because a deploy creates a fresh VT child with no last value, which is
correct-by-design and had masked the result twice.
⚠️ **Finding:** "Bad carrying the last-known value" is **unobservable by any client** — subscription
and raw Read both return NULL. The actor is right; no test asserted that half, so one was added
(`The_Bad_result_carries_the_last_known_value`). The *documentation* is what is wrong.
- **R2-10 T10 ❌ still open — recorded cause was wrong.** Not retry throttling. The dead-endpoint
failure mode never enters the resilience pipeline: `InitializeAsync` is not invoker-wrapped and
`ModbusDriver` swallows per-tag failures in both directions. **Operator consequence:** the panel
reads *"All targets healthy."* for a driver timing out every read against a blackhole.
- **driver-reconfigure Task 3 ✅** — corrected config adopted while faulted, `RestartCount=0`.
- **Method note:** the rig image was 3 h older than the last code commit. Rebuilt, then asserted image
parity **per container** *and* that the specific symbols under test were in the shipped DLLs.
### 2026-08-05 — MTConnect write-back; the MQTT closure records reconciled
Recorded from branch `feature/mqtt-mtconnect-write`. This is exactly the drift shape the 2026-08-04
entry named — **work that closes an item elsewhere leaves the register asserting the item is open** —
so it is written back here in the same change as the work.
**§1.1 corrected on two rows.** `IWritable` is no longer absent from four drivers; **after this branch
it is absent from Sql and Calculation only**, and in both that is still structural.
| Driver | Gained `IWritable` | Commit | Shape |
|---|---|---|---|
| **MQTT / Sparkplug B** | 2026-07-31 ([#508](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/508), live-gated) | `72be1922` | Plain `writeTopic` publish + Sparkplug NCMD/DCMD |
| **MTConnect** | 2026-08-05 | `b29623cf` | **Observation injection** through the Agent's optional `AllowPut` surface — off by default at two independent gates (`Write.Enabled` and the Agent's own `AllowPut`), CONDITION items refused |
⚠️ **MTConnect write-back is not a machine command, and the register should not be read as saying the
driver can now command a CNC.** A write sets what the Agent *reports* for a DataItem; the next
adapter/SHDR update overwrites it. MTConnect *Interfaces* — the mechanism the original deferral
(`2026-07-15-mtconnect-driver-design.md` §3.6) assumed write-back would need — **remains deferred**, and
§3.6 now carries a superseded note saying so. Design:
`docs/plans/2026-08-05-mtconnect-writeback-design.md`.
⚠️ **It also does not make any existing node writable.** The OPC UA `CurrentWrite` bit is composed from
the authored `Tag.AccessLevel` alone; driver capability is never consulted at compose time. An operator
must author the raw tag **ReadWrite**. Browse's new `Operate` stamp on writable leaves is honest
labelling only — nothing persists `SecurityClass`.
**§2's eight MQTT rows (#508–#515) marked ✅ in place** with the commit that resolved each (`72be1922`,
`7f53bd96`, `7f2108dc`, `c74ccc55`), following the register's existing convention. Their evidence
columns are left as the audit snapshot — #508's `MqttDriver.cs:66 — no IWritable` in particular is now
false of the tree, and is annotated as such rather than rewritten.
**Docs reconciled in the same change** (they contradicted the code, and each other):
`docs/drivers/MTConnect.md` (capability surface, new §Write-back, Deferred, the `DeviceName`
double-duty note), `docs/drivers/Mqtt.md` (deleted the #514 "empty tree cannot arm a rebirth" known-gap
block and the "Read-only. No writes reach the broker" operational note — both contradicted by that
file's own closure line), and `docs/plans/2026-07-24-driver-expansion-tracking.md` (P2 gap bullets +
§Next actions, which still listed MQTT P3 and both browse gaps as remaining work).
---
---
*§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 (merge `914d70bf`), 2026-07-30 (merge `2a0461a1`), and reconciled against the
tracker + `docs/plans/` on 2026-08-04 at `4bdce910`. §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.*
*⚠️ **Counts in this file go stale silently.** Three rounds running, the register's own bookkeeping was
what broke. Before trusting any number here — open issues, pending tasks, unreviewed modules — re-derive
it: `curl` the tracker API, parse `docs/plans/*.tasks.json`, list `code-reviews/`. Each is one command,
and each has caught this file being wrong.*