From 53ede679c36b2abe72c302f870c37a5265767940 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 27 Jul 2026 18:36:43 -0400 Subject: [PATCH 1/8] =?UTF-8?q?docs+ui(security):=20state=20plainly=20that?= =?UTF-8?q?=20node=20ACLs=20are=20not=20enforced=20(=C2=A78.1,=20#520)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deferment.md §8.1 asked for a decision: wire IPermissionEvaluator into the node manager, or say plainly that ACLs are not enforced. Decision: the latter. The evaluator stays in the tree; #520 tracks the wire-up. Verifying the claim turned up four things worse than the audit recorded: - docs/security.md, not ReadWriteOperations.md, was the highest-risk doc. It claimed an anonymous session is "default-denie[d] any node a session has no ACL grant for". The opposite holds: anonymous can Browse/Read/Subscribe/ HistoryRead the entire address space, and is refused writes and alarm acks only because it carries no roles. - Three of five documented data-plane role strings do nothing. OpcUaDataPlaneRoles declares exactly WriteOperate and AlarmAck; ReadOnly, WriteTune and WriteConfigure are compared against nowhere in src/. The doc called all five "exact, case-insensitive, and code-true". - ReadWriteOperations.md was fiction beyond the ACL claims. OnReadValue has zero occurrences in src/ — the documented read path did not exist. Reads never reach a driver: the node manager is push-model and the SDK serves a 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. This file is rewritten rather than bannered. - v2-release-readiness.md claimed a whole enforcement layer shipped — FilterBrowseReferences, GateCallMethodRequests, MapCallOperation, AuthorizationBootstrap, Node:Authorization:* keys. None exist. Whatever landed on the v2 branch did not survive into the shipped tree. The UI change is the operative one: ClusterAcls.razor and AclEdit.razor now warn that a grant is saved and shipped in every deployment artifact but never evaluated, so an operator cannot author a deny rule believing it works. deferment.md gains a §9 execution log tracking §8 remediation. --- deferment.md | 65 +++++++- docs/ReadWriteOperations.md | 157 +++++++++++------- docs/security.md | 143 ++++++++++++---- docs/v2/acl-design.md | 18 ++ docs/v2/v2-release-readiness.md | 20 ++- .../Components/Pages/Clusters/AclEdit.razor | 13 ++ .../Pages/Clusters/ClusterAcls.razor | 13 ++ 7 files changed, 336 insertions(+), 93 deletions(-) diff --git a/deferment.md b/deferment.md index b10efb62..4363b39f 100644 --- a/deferment.md +++ b/deferment.md @@ -342,9 +342,11 @@ appear in §7.1 as well. ## 8. Recommended sequencing -1. **§3.1 ACL enforcement** — decide: wire `IPermissionEvaluator` into `OtOpcUaNodeManager`, or +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. + `docs/ReadWriteOperations.md` first; it is the one that could mislead a security review.~~ + ✅ **Done** — decided *make non-enforcement explicit*; wire-up tracked as Gitea **#520**. See §9. + (`docs/ReadWriteOperations.md` was **not** the sharpest one — `docs/security.md` was.) 2. **#518 + #507 together** — neither fix is observable alone. 3. **#516** — silent config discard on 5 drivers; then re-scope #489, which it partly subsumes. 4. **G-4** — extend the existing picker-parity test to `DriverConfigModal` + `DeviceModal`; it would @@ -356,6 +358,65 @@ appear in §7.1 as well. --- +## 9. Execution log + +Remediation of §8, on branch `feat/deferment-remediation`. Status is updated in the **same commit** as +the work, so this register never disagrees with the tree. + +| §8 item | Status | Commit | +|---|---|---| +| 1. ACL enforcement decision | ✅ **Done** — decided *make non-enforcement explicit*; Gitea **#520** tracks the wire-up | `d1e88dc4` | +| 2. #518 + #507 | ⏳ Not started — decided *signal, not mutation* | — | +| 3. #516 config discard | ⏳ Not started — decided *both* (per-driver re-parse **and** seam respawn) | — | +| 4. G-4 dispatch-map parity | ⏳ Not started | — | +| 5. Bookkeeping sweep | ⏳ Not started | — | +| 6. Tier truth | ⏳ Not started — decided *document the truth, defer the code decision* | — | + +### 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. + +--- + *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.* diff --git a/docs/ReadWriteOperations.md b/docs/ReadWriteOperations.md index bf050fb0..2aea0c28 100644 --- a/docs/ReadWriteOperations.md +++ b/docs/ReadWriteOperations.md @@ -1,85 +1,120 @@ # Read/Write Operations -> ⚠️ **Accuracy warning (audited 2026-07-27).** Parts of this page describe v2-era machinery that no -> longer exists. Two corrections matter most: -> -> 1. **There is no per-node ACL gate on the read path — or anywhere else.** `WriteAuthzPolicy`, -> `AuthorizationGate`, `NodeScopeResolver` and `AuthorizationBootstrap` have **zero occurrences in -> `src/`**. The ACL evaluator that *does* exist (`IPermissionEvaluator` / `TriePermissionEvaluator` -> / `PermissionTrieCache`, `src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/`) has **no production -> consumer** — `OtOpcUaNodeManager` never references it — even though `ClusterAcls.razor` lets -> operators author `NodeAcl` rows and `ConfigComposer.cs:51` ships them in every deployment -> artifact. Actual enforcement today is LDAP role mapping plus the realm-qualified `WriteOperate` -> check in `OtOpcUaNodeManager`, which is a **write** gate; **reads are ungated**. See -> `deferment.md` §3.1. -> 2. **`GenericDriverNodeManager` is not a production dispatch path** — it is Core test scaffolding -> with zero production references (`GenericDriverNodeManager.cs:71`). The live server materialises -> the address space through `AddressSpaceComposer` / `AddressSpaceApplier`. -> -> The `CapabilityInvoker` / Polly / `OnReadValue` / `OnWriteValue` mechanics below remain accurate. +> **Rewritten 2026-07-27** against `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs`. +> The previous revision described a v2-era `DriverNodeManager` with per-variable `OnReadValue` hooks, +> an `AuthorizationGate`/`WriteAuthzPolicy` ACL pair and a `NodeSourceKind` dispatch switch. **None of +> those exist** — `OnReadValue`, `WriteAuthzPolicy`, `_sourceByFullRef`, `_writeIdempotentByFullRef` +> and `IRoleBearer` all have zero occurrences in `src/`. The read path in particular worked nothing +> like the way it was documented. See `deferment.md` §3.1 and §7. -The v2 server routes OPC UA Read and Write operations to each driver's `IReadable` and `IWritable` capabilities through `CapabilityInvoker` so the Polly pipeline (retry / timeout / breaker) applies uniformly across Galaxy, Modbus, S7, AB CIP, AB Legacy, TwinCAT, FOCAS, and OPC UA Client drivers. The per-variable `OnReadValue` and `OnWriteValue` hooks described in the sections below live in `DriverNodeManager` (the planned ADR-002 Phase 7 Stream G successor to the v1 `DriverNodeManager`); `GenericDriverNodeManager` (`src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs`) handles address-space population and alarm routing during discovery. The current `OtOpcUaNodeManager` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs`) is a push-model `CustomNodeManager2` that receives values from the Akka actor layer via `WriteValue`; OPC UA client reads return the cached pushed value. +## The shape of it: push for reads, pull for writes -## Driver vs virtual dispatch +The live server is `OtOpcUaNodeManager`, a **push-model** `CustomNodeManager2`. This asymmetry is the +single most important thing on this page: -Per [ADR-002](v2/implementation/adr-002-driver-vs-virtual-dispatch.md), a single `DriverNodeManager` routes reads and writes across both driver-sourced and virtual (scripted) tags. At discovery time each variable registers a `NodeSourceKind` (`src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverAttributeInfo.cs`) in the manager's `_sourceByFullRef` lookup; the read/write hooks pattern-match on that value to pick the backend: +- **Reads never reach a driver.** There is no `Read` override and no `OnReadValue` / `OnSimpleReadValue` + handler anywhere in the node manager. Driver values are *pushed in* from the Akka actor layer + (`DriverInstanceActor` polls or subscribes, `DriverHostActor` fans the value to the raw NodeId and + every referencing UNS NodeId), and a client Read is served by the OPC UA SDK straight from the + cached node value. A client read therefore costs nothing on the wire to the device, and cannot fail + with a device error — it returns whatever quality was last pushed. +- **Writes are synchronous pull-through.** A client write runs `OnWriteValue` → role gate → driver. -- `NodeSourceKind.Driver` — dispatches to the driver's `IReadable` / `IWritable` through `CapabilityInvoker` (the rest of this doc). -- `NodeSourceKind.Virtual` — dispatches to `VirtualTagSource` (`src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagSource.cs`), which wraps `VirtualTagEngine`. Writes are rejected with `BadUserAccessDenied` before the branch per Phase 7 decision #6 — scripts are the only write path into virtual tags. -- `NodeSourceKind.ScriptedAlarm` — dispatches to the Phase 7 `ScriptedAlarmReadable` shim. +Everything the old page said about `CapabilityInvoker` wrapping OPC UA reads was misplaced: the +invoker is real, but it lives on the **driver-actor** side wrapping the poll/subscribe calls. The +`OpcUaServer` project does not reference it at all. -~~ACL enforcement (`WriteAuthzPolicy` + `AuthorizationGate`) runs before the source branch, so the gates below apply uniformly to all three source kinds.~~ **Not true** — neither type exists; there is no per-node ACL gate. The only authorization applied before the source branch is the LDAP-role write gate (`WriteOperate` / `WriteTune` / `WriteConfigure`, realm-qualified, fail-closed). See the banner at the top of this page. +## Read path -## OnReadValue +1. The SDK resolves the NodeId in the Raw (`ns=2`) or UNS (`ns=3`) namespace. +2. It returns the cached `DataValue` — value, `StatusCode` and source timestamp as last pushed. +3. **No authorization check runs.** Not a per-node ACL, not a role check, nothing. Any admitted + session — including an Anonymous one — can read every node in the address space. The only gating is + the `AccessLevels` bitmask set at materialization, which is a per-node *capability* declaration, not + a per-user decision. -The hook is registered on every `BaseDataVariableState` created by the `IAddressSpaceBuilder.Variable(...)` call during discovery. When the stack dispatches a Read for a node in this namespace: +Authored `NodeAcl` deny rules have **no effect on reads** (or on anything else — see +[docs/security.md](security.md) § Data-Plane Authorization). -1. If the driver does not implement `IReadable`, the hook returns `BadNotReadable`. -2. The node's `NodeId.Identifier` is used directly as the driver-side full reference — it matches `DriverAttributeInfo.FullName` registered at discovery time. -3. ~~(Phase 6.2) If an `AuthorizationGate` + `NodeScopeResolver` are wired, the gate is consulted first via `IsAllowed(identity, OpcUaOperation.Read, scope)`. A denied read never hits the driver.~~ **Never shipped** — neither type exists in `src/`, and no ACL gate is consulted on the read path. Reads are authorized only by the `AccessLevels` bits set at materialization (and `HistoryRead` for history). Authored `NodeAcl` deny rules have **no effect on reads**. -4. The call is wrapped by `_invoker.ExecuteAsync(DriverCapability.Read, ResolveHostFor(fullRef), …)`. The resolved host is `IPerCallHostResolver.ResolveHost(fullRef)` for multi-host drivers; single-host drivers fall back to `DriverInstanceId` (decision #144). -5. The first `DataValueSnapshot` from the batch populates the outgoing `value` / `statusCode` / `timestamp`. An empty batch surfaces `BadNoData`; any exception surfaces `BadInternalError`. +## Write path — `OnEquipmentTagWrite` -The hook is synchronous — the async invoker call is bridged with `AsTask().GetAwaiter().GetResult()` because the OPC UA SDK's value-hook signature is sync. Idempotent-by-construction reads mean this bridge is safe to retry inside the Polly pipeline. +The handler is attached in `EnsureVariable` **only when the tag was materialized `writable`**, and +re-attached or cleared by `UpdateTagAttributes` on an in-place edit. A non-writable node has no +handler, so the SDK rejects the write before any of this runs. -## OnWriteValue +1. **Role gate.** `EvaluateEquipmentWriteGate(identity, gatewayWired)` requires the session identity to + be a `RoleCarryingUserIdentity` carrying the `WriteOperate` role. No identity or no role ⇒ + `BadUserAccessDenied`, fail-closed. Gate passed but no write gateway wired (admin-only node, + pre-boot) ⇒ `BadNotWritable`. + - This is a **server-wide** check. It takes no node and no realm: a session that may write one tag + may write every writable tag on that node. + - `WriteTune` and `WriteConfigure` are **never checked**. `OpcUaDataPlaneRoles` declares only + `WriteOperate` and `AlarmAck`; the classification tiers exist in the permission vocabulary but no + code path reads them. +2. **Optimistic local apply.** The new value is written to the node so subscribers see it immediately. +3. **Realm-qualified dispatch.** `RealmOf(node.NodeId)` selects Raw or UNS, and the value routes to the + backing driver ref through the node write gateway. Both NodeIds for a fanned value resolve to the + same driver ref, so a write via either one reaches the same device point. +4. **Write-outcome self-correction.** If the device write fails, the optimistic value is **reverted** + on the raw NodeId and every referencing UNS NodeId through the shared fan-out, so a failed write + cannot leave a phantom Good value behind. (Galaxy is the exception: its write is fire-and-forget, so + it can never surface a write failure.) -`OnWriteValue` follows the same shape with two additional concerns: authorization and idempotence. +`OnWriteValue` is invoked by the SDK **while holding the node-manager `Lock`**, so the handler must not +block. Anything asynchronous is dispatched fire-and-forget with the revert wired as a continuation. -### Authorization (two layers) +## Alarm method calls -1. **SecurityClassification gate.** Every variable stores its `SecurityClassification` in `_securityByFullRef` at registration time (populated from `DriverAttributeInfo.SecurityClass`). `WriteAuthzPolicy.IsAllowed(classification, userRoles)` runs first, consulting the session's roles via `context.UserIdentity is IRoleBearer`. `FreeAccess` passes anonymously, `ViewOnly` denies everyone, and `Operate / Tune / Configure / SecuredWrite / VerifiedWrite` require `WriteOperate / WriteTune / WriteConfigure` roles respectively. Denial returns `BadUserAccessDenied` without consulting the driver — drivers never enforce ACLs themselves; they only report classification as discovery metadata (see `docs/security.md`). -2. **Phase 6.2 permission-trie gate.** When `AuthorizationGate` is wired, it re-runs with the operation derived from `WriteAuthzPolicy.ToOpcUaOperation(classification)`. The gate consults the per-cluster permission trie loaded from `NodeAcl` rows, enforcing fine-grained per-tag ACLs on top of the role-based classification policy. See `docs/v2/acl-design.md`. - -### Dispatch - -`_invoker.ExecuteWriteAsync(host, isIdempotent, callSite, …)` honors the `WriteIdempotentAttribute` semantics per decisions #44-45 and #143: - -- `isIdempotent = true` (tag flagged `WriteIdempotent` in the Config DB) → runs through the standard `DriverCapability.Write` pipeline; retry may apply per the tier configuration. -- `isIdempotent = false` (default) → the invoker builds a one-off pipeline with `RetryCount = 0`. A timeout may fire after the device already accepted the pulse / alarm-ack / counter-increment; replay is the caller's decision, not the server's. - -The `_writeIdempotentByFullRef` lookup is populated at discovery time from the `DriverAttributeInfo.WriteIdempotent` field. - -### Per-write status - -`IWritable.WriteAsync` returns `IReadOnlyList` — one numeric `StatusCode` per requested write. A non-zero code is surfaced directly to the client; exceptions become `BadInternalError`. The OPC UA stack's pattern of batching per-service is preserved through the full chain. - -## Array element writes - -Array-element writes via OPC UA `IndexRange` are driver-specific. The OPC UA stack hands the dispatch an unwrapped `NumericRange` on the `indexRange` parameter of `OnWriteValue`; `DriverNodeManager` passes the full `value` object to `IWritable.WriteAsync` and the driver decides whether to support partial writes. Galaxy performs a read-modify-write inside the Galaxy driver (MXAccess has no element-level writes); other drivers generally accept only full-array writes today. +Part 9 condition methods (Acknowledge / Confirm / AddComment / OneShotShelve / TimedShelve / Unshelve) +are gated by a second role check requiring `AlarmAck` — one role covering all five methods; the +`operation` argument is passed to the router but the gate does not consult it. Scripted alarms go +through `HandleAlarmCommand`, driver-fed native alarms through `HandleNativeAlarmAck`; both fail closed +with `BadUserAccessDenied`. ## HistoryRead -`DriverNodeManager.HistoryReadRawModified`, `HistoryReadProcessed`, `HistoryReadAtTime`, and `HistoryReadEvents` route through the driver's `IHistoryProvider` capability with `DriverCapability.HistoryRead`. Drivers without `IHistoryProvider` surface `BadHistoryOperationUnsupported` per node. See `docs/v1/HistoricalDataAccess.md`. +Four overrides — `HistoryReadRawModified`, `HistoryReadProcessed`, `HistoryReadAtTime`, +`HistoryReadEvents` — route to the **server-wide** `IHistorianDataSource` (the HistorianGateway-backed +reader, or `NullHistorianDataSource` when `ServerHistorian:Enabled=false`, which returns `GoodNoData`). +This is not a per-driver `IHistoryProvider` dispatch. + +**No authorization check runs on any of the four.** Unlike `OnWriteValue`, the SDK does *not* hold the +node-manager `Lock` while invoking them, so these paths may await. + +See [docs/Historian.md](Historian.md). ## Failure isolation -Per decision #12, exceptions in the driver's capability call are logged and converted to a per-node `BadInternalError` — they never unwind into the master node manager. This keeps one driver's outage from disrupting sibling drivers in the same server process. +Exceptions in a driver capability call are logged and converted to a per-node bad status rather than +unwinding into the master node manager, so one driver's outage cannot disrupt sibling drivers in the +same process. + +## What is *not* enforced anywhere + +Collected here because the previous revision of this page claimed several of these: + +| Surface | Check | +|---|---| +| Read, Browse, TranslateBrowsePaths | none | +| CreateMonitoredItems / Subscribe / TransferSubscriptions | none | +| HistoryRead (all four variants) | none | +| Non-Value attribute writes | none (the handler is on `OnWriteValue`) | +| Value write | `WriteOperate` role, server-wide | +| Alarm methods | `AlarmAck` role, server-wide | +| Per-node ACLs (`NodeAcl` / `PermissionTrie`) | **authored and deployed, never evaluated** | ## Key source files -- `src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs` — address-space population and alarm routing during discovery -- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs` — push-model `CustomNodeManager2`; `EnsureVariable` / `WriteValue` are the v2 read/write path -- `src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/` — permission trie + evaluator (`PermissionTrie`, `PermissionTrieCache`, `TriePermissionEvaluator`) that gates Read/Write/Subscribe per the session's resolved LDAP groups -- `src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/CapabilityInvoker.cs` — `ExecuteAsync` / `ExecuteWriteAsync` -- `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IReadable.cs`, `IWritable.cs`, `WriteIdempotentAttribute.cs` +- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs` — the live node manager; + `EnsureVariable` / `WriteValue` / `OnEquipmentTagWrite` / `EvaluateEquipmentWriteGate` and the four + HistoryRead overrides +- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/RoleCarryingUserIdentity.cs`, + `Security/OpcUaDataPlaneRoles.cs` — how roles reach the gates +- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs` — materialization; where the write + handler is attached per realm +- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs` — the push side: value fan-out to + raw + UNS NodeIds +- `src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/CapabilityInvoker.cs` — driver-side resilience pipeline + (**not** on the OPC UA read path) +- `src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/` — the permission trie + evaluator, built and + unit-tested but with **zero production consumers** diff --git a/docs/security.md b/docs/security.md index d0bd9f73..5604c76d 100644 --- a/docs/security.md +++ b/docs/security.md @@ -21,7 +21,7 @@ OtOpcUa has four independent security concerns. This document covers all four: 1. **Transport security** — OPC UA secure channel (signing, encryption, X.509 trust). 2. **OPC UA authentication** — Anonymous / UserName / X.509 session identities; UserName tokens authenticated by LDAP bind. -3. **Data-plane authorization** — who can browse, read, subscribe, write, acknowledge alarms on which nodes. Evaluated by `TriePermissionEvaluator` over a `PermissionTrie` built from the Config DB `NodeAcl` tree. +3. **Data-plane authorization** — who can write and acknowledge alarms on an OtOpcUa endpoint. ⚠️ Enforced today by **two coarse, server-wide LDAP role checks** (`WriteOperate`, `AlarmAck`) — **not** by the `NodeAcl` / `PermissionTrie` subsystem, which is built and unit-tested but never wired. Read, Browse, HistoryRead and Subscribe carry **no** authorization check at all. See [Data-Plane Authorization](#data-plane-authorization). 4. **Control-plane authorization** — who can view or edit fleet configuration in the Admin UI. Gated by the `AdminRole` (`Viewer` / `Designer` / `Administrator`) claim resolved from `LdapGroupRoleMapping`. Transport security and OPC UA authentication are per-node concerns configured in the Server's bootstrap `appsettings.json`. Data-plane ACLs and Admin role grants live in the Config DB. @@ -112,9 +112,9 @@ The Server accepts three OPC UA identity-token types: | Token | Handler | Notes | |---|---|---| -| Anonymous | No `IOpcUaUserAuthenticator` call — the SDK admits anonymous sessions at the channel. | Data-plane authorization (below) still default-denies any node a session has no ACL grant for. | -| UserName/Password | `LdapOpcUaUserAuthenticator.AuthenticateUserNameAsync` (`src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LdapOpcUaUserAuthenticator.cs`, implements `IOpcUaUserAuthenticator`), backed by the app `ILdapAuthService` — `OtOpcUaLdapAuthService` (`src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/OtOpcUaLdapAuthService.cs`). | LDAP bind + group lookup. The returned LDAP groups are mapped to roles via `IGroupRoleMapper` (`OtOpcUaGroupRoleMapper`) and attached to the OPC UA session identity for the downstream ACL evaluator. | -| X.509 Certificate | Stack-level acceptance during the secure-channel handshake. | The certificate must be trusted (see PKI trust flow); finer-grain authorization happens through the data-plane ACLs. | +| Anonymous | No `IOpcUaUserAuthenticator` call — the SDK admits anonymous sessions at the channel. | ⚠️ An anonymous session carries **no roles**, so it is refused every write and every alarm ack — but it can **Browse, Read, Subscribe and HistoryRead the entire address space**. There is no per-node ACL check on those operations (see [Data-Plane Authorization](#data-plane-authorization)). Do not expose an Anonymous-admitting endpoint to an untrusted network. | +| UserName/Password | `LdapOpcUaUserAuthenticator.AuthenticateUserNameAsync` (`src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LdapOpcUaUserAuthenticator.cs`, implements `IOpcUaUserAuthenticator`), backed by the app `ILdapAuthService` — `OtOpcUaLdapAuthService` (`src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/OtOpcUaLdapAuthService.cs`). | LDAP bind + group lookup. The returned LDAP groups are mapped to roles via `IGroupRoleMapper` (`OtOpcUaGroupRoleMapper`) and the **roles** are attached to the OPC UA session identity as a `RoleCarryingUserIdentity`. The raw group list is discarded at that point and never reaches the data plane. | +| X.509 Certificate | Stack-level acceptance during the secure-channel handshake. | The certificate must be trusted (see PKI trust flow). It carries **no** roles, so a cert-only session is read-everything / write-nothing. | When no authenticator is supplied, `OpcUaApplicationHost` falls back to `NullOpcUaUserAuthenticator`; the Host wires the real `LdapOpcUaUserAuthenticator` as a singleton in `Program.cs`. @@ -130,7 +130,7 @@ LDAP is configured under the `Security:Ldap` section (bound to `LdapOptions`, `s 4. Delegates the real path to the shared `ZB.MOM.WW.Auth.Ldap` client: it binds (search-then-bind via `ServiceAccountDn`, or direct-bind `cn={user},{SearchBase}` when no service account is set), verifies the password, and reads the user's group memberships. 5. Returns an `LdapAuthResult` carrying the validated username + the **groups** (never roles). Failure codes are folded into opaque user-facing error strings so a probe cannot distinguish "unknown user" from "wrong password". -**Group → role mapping happens downstream**, not in the auth service: `LdapOpcUaUserAuthenticator` resolves `IGroupRoleMapper` (`OtOpcUaGroupRoleMapper`) per call and unions its output with any pre-resolved roles (the DevStub `Administrator` grant). The roles are attached to the OPC UA session identity for the ACL evaluator. A mapper fault (e.g. a Config DB outage) falls back to the pre-resolved baseline rather than denying an otherwise-authenticated session. +**Group → role mapping happens downstream**, not in the auth service: `LdapOpcUaUserAuthenticator` resolves `IGroupRoleMapper` (`OtOpcUaGroupRoleMapper`) per call and unions its output with any pre-resolved roles (the DevStub `Administrator` grant). The roles are attached to the OPC UA session identity (`RoleCarryingUserIdentity`), where the write and alarm-ack gates read them. **The LDAP group names themselves are consumed here and go no further** — which is one reason the `NodeAcl` subsystem cannot currently be wired, since it matches on groups rather than roles. A mapper fault (e.g. a Config DB outage) falls back to the pre-resolved baseline rather than denying an otherwise-authenticated session. `Transport` replaces the former `UseTls` bool: `Ldaps` (implicit TLS), `StartTls` (upgrade), or `None` (plaintext, requires `AllowInsecure`). Configuration example (Active Directory production): @@ -180,11 +180,92 @@ Defaults are behaviour-neutral on a healthy directory. All new paths are **fail- ## Data-Plane Authorization -Data-plane authorization is the check run on every OPC UA operation against an OtOpcUa endpoint: *can this authenticated user Browse / Read / Subscribe / Write / HistoryRead / AckAlarm / Call on this specific node?* +> ⚠️ **Read this first (verified against source, 2026-07-27).** This section used to describe the +> `NodeAcl` / `PermissionTrie` design as if it were the live enforcement path. **It is not.** The +> evaluator is built and unit-tested but has **zero production call sites**, and +> `ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj` does not even reference the project it lives in. Everything +> actually enforced today is in [What is enforced today](#what-is-enforced-today); the trie design is +> retained below, clearly marked, under [Designed but not wired](#designed-but-not-wired-nodeacl--permissiontrie). -Per decision #129 the model is **additive-only — no explicit Deny**. Grants at each hierarchy level union; absence of a grant is the default-deny. +### What is enforced today -### Hierarchy +There are exactly **three** authorization checks in the whole OPC UA server layer. All three are +coarse, **server-wide role string checks** with no per-node component. + +| Operation | Gate | Requires | +|---|---|---| +| Write (Value attribute, either namespace) | `OtOpcUaNodeManager.EvaluateEquipmentWriteGate` | role `WriteOperate` | +| Alarm Acknowledge / Confirm / AddComment / Shelve / Unshelve (scripted alarms) | `OtOpcUaNodeManager.HandleAlarmCommand` | role `AlarmAck` | +| Alarm acknowledge (driver-fed native alarms) | `OtOpcUaNodeManager.HandleNativeAlarmAck` | role `AlarmAck` | + +Both gates fail closed — a session with no identity or no matching role gets +`BadUserAccessDenied` — and read `identity.Roles` off the session's `RoleCarryingUserIdentity` +(`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/RoleCarryingUserIdentity.cs`). Role strings are +the constants in `OpcUaDataPlaneRoles`, sourced from `Security:Ldap:GroupToRole` (see +[Role grant source](#role-grant-source-data-plane)). + +**What has no authorization check at all:** + +- **Read, Browse, TranslateBrowsePaths** — no override, no handler. Any admitted session, including + an Anonymous one, sees and reads the entire address space. +- **HistoryRead** — all four overrides (`HistoryReadRawModified`, `HistoryReadProcessed`, + `HistoryReadAtTime`, `HistoryReadEvents`) run without an identity check. +- **CreateMonitoredItems / subscriptions / TransferSubscriptions**. +- **Non-Value attribute writes** — the gate hangs off `OnWriteValue`. + +**And what the write gate does *not* distinguish:** + +- **`WriteTune` and `WriteConfigure` are never checked.** They exist in the role vocabulary and in + `NodePermissions`, but every write — whatever the tag's security classification — is gated on the + single `WriteOperate` string. +- **`AlarmAcknowledge` / `AlarmConfirm` / `AlarmShelve` are not distinguished.** One `AlarmAck` role + covers all five alarm methods; the `operation` argument is passed to the router but never consulted + by the gate. +- **There is no per-node granularity.** A session that may write one tag may write **every** writable + tag on the node. + +The gate *is* realm-aware in one narrow sense: it is attached per-node during materialization for both +the Raw and UNS realms, and the write dispatch it guards is realm-qualified. The role decision itself +takes no realm and no node. + +### Designed but not wired: `NodeAcl` + `PermissionTrie` + +Everything from here to [Role grant source](#role-grant-source-data-plane) describes a subsystem that +**exists in the tree, is covered by 30 unit tests, and never executes.** It is kept because the design +is sound and the remaining work is wiring plus one genuine design gap — not because it runs. + +What is real about it: + +- Operators **can** author `NodeAcl` rows in the Admin UI (`/clusters/{id}/acls`), and those rows are + validated, versioned and persisted. +- `ConfigComposer` **does** snapshot every `NodeAcl` row into every deployment artifact, so an ACL + edit shifts the artifact's `RevisionHash` and ships to every node. +- The node side **never deserializes them** — `DeploymentArtifact` has no ACL reader. The bytes arrive + and are dropped. + +So an operator can author a grant, deploy it green, and have it change nothing. That is the behaviour +to expect until the tracking issue below is closed. + +**The four things blocking a wire-up** (all verified, none of them merely "call the evaluator"): + +1. **Raw-realm nodes have no representable scope.** `NodeHierarchyKind` has exactly one member, + `Equipment`. Raw NodeIds are `Folder→Driver→Device→TagGroup→Tag` RawPaths, which `NodeScope` cannot + express — yet the write gate is attached to raw tags. Roughly half the writable address space has + no scope to evaluate. This is a design decision, not wiring. +2. **The identity carries roles, not groups.** The trie matches `NodeAcl.LdapGroup` against the + session's LDAP groups, but `OpcUaUserAuthResult` carries only mapped role strings; the group list + is discarded inside `LdapOpcUaUserAuthenticator`. +3. **`PermissionTrieBuilder`'s `scopePaths` argument has no producer.** Without it every sub-cluster + grant lands in the builder's fallback bucket — the hazard its own comments warn about. It would have + to be derived from the artifact's UNS relations. +4. **No session lifecycle exists.** `UserAuthorizationState` has the freshness/staleness predicates but + nothing in production constructs one, stamps a generation, or drives a refresh. + +Two smaller inconsistencies inside the subsystem itself: `NodeAclScopeKind.FolderSegment` is offered in +the authoring dropdown but the trie walker has no level to match it at, and `NodePermissions.AlarmRead` +has no `OpcUaOperation` mapping, so it is grantable but unreachable. + +### Hierarchy (design) ACLs are evaluated against the node's scope path. `NodeScope` (`src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/NodeScope.cs`) carries a `Kind` that selects between two hierarchy shapes: @@ -245,19 +326,23 @@ The three Write tiers map to Galaxy's v1 `SecurityClassification` — `FreeAcces `NodeScope` is described above (Equipment-kind vs SystemPlatform-kind). The evaluator unions the matched grants along the path — a tag-level ACL and an area-level ACL both contribute. -### Dispatch gate — `IPermissionEvaluator` +### Dispatch gate — `IPermissionEvaluator` (design; **no such gate exists**) -`IPermissionEvaluator.Authorize(UserAuthorizationState session, OpcUaOperation operation, NodeScope scope)` (default impl `TriePermissionEvaluator` at `src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/TriePermissionEvaluator.cs`) returns an `AuthorizationDecision`. The dispatch path calls it on every Read, Write, HistoryRead, Browse, Subscribe, AckAlarm, Call; a `NotGranted` decision denies the operation. +`IPermissionEvaluator.Authorize(UserAuthorizationState session, OpcUaOperation operation, NodeScope scope)` (default impl `TriePermissionEvaluator` at `src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/TriePermissionEvaluator.cs`) returns an `AuthorizationDecision`. -Key properties: +⚠️ **The intended dispatch path — "calls it on every Read, Write, HistoryRead, Browse, Subscribe, AckAlarm, Call" — was never built.** `Authorize` has zero production call sites. What the dispatch path really does is in [What is enforced today](#what-is-enforced-today). -- **Driver-agnostic.** No driver-level code participates in authorization decisions. Drivers report `SecurityClassification` as metadata on tag discovery; everything else flows through the evaluator. -- **Strictly fail-closed (default-deny).** Every guard path returns `NotGranted` — a stale session (past the staleness ceiling, decision #152), a cluster mismatch between session and scope, a missing trie, a pruned bound generation, or simply no matching grant. There is no `StrictMode` / fail-open mode; absence of a grant is always a deny. -- **Evaluator stays pure.** `TriePermissionEvaluator` has no OPC UA stack dependency — it's tested directly from xUnit. +Intended properties, of which only the last is true today: + +- **Driver-agnostic.** No driver-level code participates in authorization decisions. Drivers report `SecurityClassification` as metadata on tag discovery; everything else was to flow through the evaluator. +- **Strictly fail-closed (default-deny).** Every guard path returns `NotGranted` — a stale session (past the staleness ceiling, decision #152), a cluster mismatch between session and scope, a missing trie, a pruned bound generation, or simply no matching grant. ⚠️ This describes the evaluator's *internal* behaviour when called. Because nothing calls it, **the effective posture for Read/Browse/Subscribe/HistoryRead is default-ALLOW.** +- **Evaluator stays pure.** ✅ True — `TriePermissionEvaluator` has no OPC UA stack dependency and is tested directly from xUnit. That purity is also why it was easy to leave unwired. ### Full model -See [`docs/v2/acl-design.md`](v2/acl-design.md) for the complete design: trie invalidation, flag semantics, per-path override rules, and the reasoning behind additive-only (no Deny). +See [`docs/v2/acl-design.md`](v2/acl-design.md) for the complete design: trie invalidation, flag semantics, per-path override rules, and the reasoning behind additive-only (no Deny). Read it as a **design document for unshipped work**, not as a description of running behaviour. + +Note also that the `SystemPlatform` hierarchy kind named above was retired with the v3 dual-namespace address space; `NodeHierarchyKind` now has only `Equipment`. ### Role grant source (data-plane) @@ -271,23 +356,25 @@ to those role strings via `GroupToRole`, e.g.: ```json "GroupToRole": { "ot-operators": "WriteOperate", - "ot-tuners": "WriteTune", - "ot-engineers": "WriteConfigure", - "ot-alarm-ack": "AlarmAck", - "ot-readonly": "ReadOnly" + "ot-alarm-ack": "AlarmAck" } ``` -If this mapping is absent the data-plane evaluator is strictly default-deny: inbound operator writes -and OPC UA Part-9 alarm acknowledgement all return `BadUserAccessDenied` even for users who -authenticate successfully. (The same requirement gates both the scripted-alarm and the native -Galaxy-alarm Part-9 ack/confirm/shelve paths.) +⚠️ **Only two role strings do anything.** `OpcUaDataPlaneRoles` declares exactly `WriteOperate` and +`AlarmAck`, and those are the only values any gate compares against. `ReadOnly`, `WriteTune` and +`WriteConfigure` appear in the permission vocabulary but **no code path reads them** — mapping a group +to `WriteTune` grants nothing, and mapping one to `ReadOnly` restricts nothing (reads are ungated for +everyone regardless). Earlier revisions of this document listed all five as "code-true"; that was +wrong for three of them. -The role strings above are **exact, case-insensitive, and code-true** — the inbound gates compare -against the constants in `OpcUaDataPlaneRoles` (`AlarmAck`, `WriteOperate`) and the bare strings -`ReadOnly` / `WriteTune` / `WriteConfigure`. In particular the alarm-ack role is `AlarmAck`, **not** -`AlarmAcknowledge` (that spelling is the `PermissionFlags` ACL bit, a different vocabulary); a -`GroupToRole` value of `AlarmAcknowledge` silently never satisfies the ack gate. +If this mapping is absent, **writes and alarm acknowledgement** default-deny: they return +`BadUserAccessDenied` even for users who authenticate successfully. (The same requirement gates both +the scripted-alarm and the native Galaxy-alarm Part-9 ack/confirm/shelve paths.) **Reads, browses, +subscribes and history reads are unaffected** — they succeed with or without any `GroupToRole` entry. + +The two live role strings are exact and case-insensitive. In particular the alarm-ack role is +`AlarmAck`, **not** `AlarmAcknowledge` (that spelling is the `PermissionFlags` ACL bit, a different +vocabulary); a `GroupToRole` value of `AlarmAcknowledge` silently never satisfies the ack gate. --- diff --git a/docs/v2/acl-design.md b/docs/v2/acl-design.md index f2141313..05945771 100644 --- a/docs/v2/acl-design.md +++ b/docs/v2/acl-design.md @@ -1,5 +1,23 @@ # OPC UA Client Authorization (ACL Design) — OtOpcUa v2 +> ⚠️ **NEVER WIRED (verified against source 2026-07-27).** This is a design document for work that was +> only half-built. The trie, the builder, the cache and the evaluator all exist in +> `src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/` and carry 30 passing unit tests — but +> `IPermissionEvaluator.Authorize` has **zero production call sites**, nothing registers it in DI, and +> `ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj` does not reference the project it lives in. `NodeAcl` rows are +> authorable in the Admin UI and are snapshotted into every deployment artifact, but the node side never +> deserializes them. +> +> **Nothing described below is enforced.** For what actually gates OPC UA operations today, see +> [`docs/security.md`](../security.md) § Data-Plane Authorization and +> [`docs/ReadWriteOperations.md`](../ReadWriteOperations.md). Four blockers stand between this design +> and a wire-up (Raw-realm nodes have no `NodeScope` representation; the session carries roles, not LDAP +> groups; `PermissionTrieBuilder`'s `scopePaths` has no producer; no session lifecycle exists) — they +> are recorded in `deferment.md` §3.1. +> +> Note also that the `SystemPlatform` hierarchy kind used throughout was retired with the v3 +> dual-namespace address space. +> > **Status**: DRAFT — closes corrections-doc finding B1 (namespace / equipment-subtree ACLs not yet modeled in the data path). > > **Branch**: `v2` diff --git a/docs/v2/v2-release-readiness.md b/docs/v2/v2-release-readiness.md index 247c86de..790a36fe 100644 --- a/docs/v2/v2-release-readiness.md +++ b/docs/v2/v2-release-readiness.md @@ -37,11 +37,27 @@ This doc is the single view of where v2 stands against its release criteria. Upd All code-path release blockers are closed. The remaining items are live-hardware / manual validations listed under exit criteria. -### ~~Security — Phase 6.2 dispatch wiring~~ (task #143 — **CLOSED** 2026-04-19, PR #94) +### ~~Security — Phase 6.2 dispatch wiring~~ (task #143 — marked CLOSED 2026-04-19, PR #94) + +> ⚠️ **THIS CLOSURE DOES NOT HOLD (re-verified against source 2026-07-27).** Every type named in this +> subsection — `AuthorizationGate`, `NodeScopeResolver`, `AuthorizationBootstrap`, `WriteAuthzPolicy`, +> `DriverNodeManager`, `FilterBrowseReferences`, `GateCallMethodRequests`, `MapCallOperation`, +> `EquipmentNamespaceContent`, and the `Node:Authorization:*` config keys — has **zero occurrences in +> `src/`**. There is no `OnReadValue` hook, no `Browse` override, no `CreateMonitoredItems` override and +> no `Call` override in the live node manager, and no HistoryRead path consults any gate. +> +> Whatever landed on the v2 branch did not survive into the shipped tree. The **only** data-plane +> authorization today is two server-wide role checks (`WriteOperate` for writes, `AlarmAck` for alarm +> methods); reads, browses, subscriptions and history reads are ungated, and `NodeAcl` rows are +> authored and deployed but never evaluated. See [`../security.md`](../security.md) § +> Data-Plane Authorization and `deferment.md` §3.1. +> +> The text below is retained as the historical record of what was *believed* closed. **Read none of it +> as current behaviour.** **Closed**. `AuthorizationGate` + `NodeScopeResolver` thread through `OpcUaApplicationHost → OtOpcUaServer → DriverNodeManager`. `OnReadValue` + `OnWriteValue` + all four HistoryRead paths call `gate.IsAllowed(identity, operation, scope)` before the invoker. Production deployments activate enforcement by constructing `OpcUaApplicationHost` with an `AuthorizationGate(StrictMode: true)` + populating the `NodeAcl` table. -Remaining Stream C surfaces (hardening, not release-blocking): +Remaining Stream C surfaces (hardening, not release-blocking) — **none of these shipped either**: - ~~Browse + TranslateBrowsePathsToNodeIds gating with ancestor-visibility logic per `acl-design.md` §Browse.~~ **Partial, 2026-04-24.** `DriverNodeManager.Browse` override post-filters the `ReferenceDescription` list via a new `FilterBrowseReferences` helper — denied nodes disappear silently per OPC UA convention. Ancestor-visibility implication (Read-grant at `Line/Tag` implying Browse on `Line`) still to ship; needs a subtree-has-any-grant query on the trie evaluator. `TranslateBrowsePathsToNodeIds` surface not yet wired. - ~~CreateMonitoredItems + TransferSubscriptions gating with per-item `(AuthGenerationId, MembershipVersion)` stamp so revoked grants surface `BadUserAccessDenied` within one publish cycle (decision #153).~~ **Partial, 2026-04-24.** `DriverNodeManager.CreateMonitoredItems` override pre-gates each request and pre-populates `BadUserAccessDenied` into the errors slot for denied items (the base stack honours pre-set errors and skips those items). Decision #153's per-item `(AuthGenerationId, MembershipVersion)` stamp for detecting mid-subscription revocation is still to ship — needs subscription-layer plumbing. TransferSubscriptions not yet wired (same pattern). diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/AclEdit.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/AclEdit.razor index e61d0e9d..e07b9030 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/AclEdit.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/AclEdit.razor @@ -28,6 +28,19 @@ else if (!IsNew && _existing is null) } else { + +
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterAcls.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterAcls.razor index 2fd6fdb2..7b49a930 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterAcls.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterAcls.razor @@ -19,6 +19,19 @@ } else { + +
ACL rows grant LDAP groups specific NodePermissions on a scope (a folder, an equipment, a tag). Per-cluster role grants were dropped in favour of From 09a401b8818fbf3a188d4e3ee411dbdf6abaacd7 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 27 Jul 2026 18:47:34 -0400 Subject: [PATCH 2/8] =?UTF-8?q?feat(drivers):=20consume=20IRediscoverable?= =?UTF-8?q?=20as=20an=20operator=20re-browse=20prompt=20(=C2=A78.2,=20#518?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nine drivers raise IRediscoverable.OnRediscoveryNeeded when they observe that a remote's tag set may have changed — a Galaxy redeploy, a TwinCAT symbol-version bump, an MTConnect agent restart, a Sparkplug rebirth. Nothing in src/ subscribed, so every raise went into the void. Drivers even wrap the invoke in try/catch for "subscriber threw"; there has never been a subscriber. DriverInstanceActor now attaches the event in PreStart and detaches in PostStop, mirroring AttachAlarmSource/DetachAlarmSource. Attach is per-actor, not per-connect: the raise has no connection affinity and can land while the driver is between connects. The detach is load-bearing rather than tidy — the IDriver instance outlives the actor when the host respawns a child around the same driver object, so a missing -= accumulates one handler per respawn, each holding a dead Self. The signal rides the existing driver-health snapshot to /hosts as a "re-browse" chip. It is ADVISORY: the served address space is deliberately not rebuilt, because v3 authors raw tags explicitly through the /raw browse-commit flow and a runtime graft would materialise nodes no operator approved and no deployment artifact records. Two things worth knowing: - PublishHealthSnapshot dedups on a health fingerprint, and a rediscovery raise on an otherwise-healthy driver changes none of the four fields it hashed. Without adding the timestamp to that tuple the dedup swallows the exact publish carrying the signal. Reverting that one line turns 3 of the 5 new tests red, which is how it was confirmed rather than assumed. - The new test stub implements IDriver from scratch instead of deriving from the shared StubDriver, whose GetHealth() returns DateTime.UtcNow — its fingerprint differs on every call, so the dedup never engages and the dedup test would have passed vacuously either way. The planned discovery-result drift detector was NOT built. Modbus, S7, MQTT, AbLegacy and Sql all echo authored config from DiscoverAsync rather than browsing the device, so a discovered-vs-authored diff is permanently empty for them — it would have been another plausible-looking inert seam, which is the class this audit exists to remove. IRediscoverable is the trustworthy signal because the driver asserts it deliberately. DriverHealthChanged, IDriverHealthPublisher.Publish and HostsDriverRow gain defaulted optional parameters, so no existing call site changed. telemetry.proto gains fields 8/9 and both Phase-5 mappers carry them. Runtime.Tests 512 passed / 31 skipped. --- .../Messages/Drivers/DriverHealthChanged.cs | 17 +- .../Protos/telemetry.proto | 2 + .../IDriverHealthPublisher.cs | 15 +- .../Components/Pages/Hosts.razor | 10 + .../Hosts/HostsDriverView.cs | 12 +- .../Telemetry/TelemetryProtoMapCentral.cs | 4 +- .../Grpc/TelemetryProtoMapNode.cs | 4 + .../Drivers/AkkaDriverHealthPublisher.cs | 12 +- .../Drivers/DriverInstanceActor.cs | 76 +++++- ...iverInstanceActorRediscoverySignalTests.cs | 249 ++++++++++++++++++ ...InstanceActorSubscriptionReconcileTests.cs | 8 +- 11 files changed, 397 insertions(+), 12 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorRediscoverySignalTests.cs diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Drivers/DriverHealthChanged.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Drivers/DriverHealthChanged.cs index b269531d..88da2aaa 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Drivers/DriverHealthChanged.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Drivers/DriverHealthChanged.cs @@ -13,6 +13,19 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers; /// Latest error message; null when none. /// Number of state-transitions into Faulted in the last 5 minutes. /// Timestamp this snapshot was published. +/// +/// When the driver last raised IRediscoverable.OnRediscoveryNeeded — i.e. it observed that the +/// remote's tag set may have changed (a Galaxy redeploy, a TwinCAT symbol-version bump, an MTConnect +/// agent restart, a Sparkplug rebirth). Null when the driver has never raised it, or does not +/// implement IRediscoverable. +/// Advisory only. The served address space is NOT rebuilt in response — raw tags are +/// authored explicitly through the /raw browse-commit flow. This is a prompt for an operator +/// to re-browse the device and commit whatever changed. +/// +/// +/// The driver-supplied reason string from the same event (e.g. "deploy-time-changed"), shown +/// to the operator alongside the prompt. Null when is null. +/// public sealed record DriverHealthChanged( string ClusterId, string DriverInstanceId, @@ -20,7 +33,9 @@ public sealed record DriverHealthChanged( DateTime? LastSuccessfulReadUtc, string? LastError, int ErrorCount5Min, - DateTime PublishedUtc) + DateTime PublishedUtc, + DateTime? RediscoveryNeededUtc = null, + string? RediscoveryReason = null) { /// /// DPS topic name. Both the runtime AkkaDriverHealthPublisher and the AdminUI diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Protos/telemetry.proto b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Protos/telemetry.proto index 0997fb2e..f619d29b 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Protos/telemetry.proto +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Protos/telemetry.proto @@ -68,6 +68,8 @@ message DriverHealth { optional string last_error = 5; // nullable in the record int32 error_count_5min = 6; google.protobuf.Timestamp published_utc = 7; + google.protobuf.Timestamp rediscovery_needed_utc = 8; // DateTime? — absent Timestamp encodes null + optional string rediscovery_reason = 9; // nullable in the record } // Mirrors ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers.DriverResilienceStatusChanged. diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverHealthPublisher.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverHealthPublisher.cs index 32851fcb..8fff62c2 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverHealthPublisher.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverHealthPublisher.cs @@ -15,11 +15,20 @@ public interface IDriverHealthPublisher /// The driver instance the snapshot describes. /// The current health snapshot. /// The number of errors observed in the trailing 5-minute window. + /// + /// When the driver last raised , or null if never + /// (or if it is not an ). Advisory: it prompts an operator to + /// re-browse, and never rebuilds the served address space. + /// + /// The driver-supplied reason from that event; null when + /// is null. void Publish( string clusterId, string driverInstanceId, DriverHealth health, - int errorCount5Min); + int errorCount5Min, + DateTime? rediscoveryNeededUtc = null, + string? rediscoveryReason = null); } /// @@ -38,6 +47,8 @@ public sealed class NullDriverHealthPublisher : IDriverHealthPublisher string clusterId, string driverInstanceId, DriverHealth health, - int errorCount5Min) + int errorCount5Min, + DateTime? rediscoveryNeededUtc = null, + string? rediscoveryReason = null) { /* no-op */ } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor index 107b1bd6..f121a70c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor @@ -191,6 +191,16 @@ else { @d.DriverInstanceId } + @if (d.RediscoveryNeededUtc is not null) + { + @* The driver reported its remote's tag set may have changed. Advisory + only — v3 authors raw tags via /raw browse-commit, so nothing is + grafted at runtime and an operator must re-browse to pick it up. *@ + + re-browse + + } @(d.DriverType ?? "—") @d.State diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hosts/HostsDriverView.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hosts/HostsDriverView.cs index 385a6838..4940f313 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hosts/HostsDriverView.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hosts/HostsDriverView.cs @@ -35,9 +35,15 @@ public sealed record HostsDriverInstanceInfo(string DriverInstanceId, string Clu /// Latest error message; null when none. /// Faulted-transition count in the last 5 minutes. /// Timestamp the snapshot was published. +/// When the driver last reported that the remote's tag set may have +/// changed; null when never (or when the driver cannot report it). Advisory — the served address space +/// is unchanged and an operator must re-browse the device via /raw to pick anything up. +/// The driver-supplied reason for that report; null when +/// is null. public sealed record HostsDriverRow( string DriverInstanceId, string? Name, string? DriverType, string State, - DateTime? LastSuccessfulReadUtc, string? LastError, int ErrorCount5Min, DateTime PublishedUtc); + DateTime? LastSuccessfulReadUtc, string? LastError, int ErrorCount5Min, DateTime PublishedUtc, + DateTime? RediscoveryNeededUtc = null, string? RediscoveryReason = null); /// /// One cluster's section on the /hosts page: its configured nodes plus its enriched @@ -110,7 +116,9 @@ public static class HostsDriverView s.LastSuccessfulReadUtc, s.LastError, s.ErrorCount5Min, - s.PublishedUtc); + s.PublishedUtc, + s.RediscoveryNeededUtc, + s.RediscoveryReason); }) .OrderBy(d => d.Name ?? d.DriverInstanceId, StringComparer.OrdinalIgnoreCase) .ThenBy(d => d.DriverInstanceId, StringComparer.OrdinalIgnoreCase) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Telemetry/TelemetryProtoMapCentral.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Telemetry/TelemetryProtoMapCentral.cs index f851f761..bae54518 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Telemetry/TelemetryProtoMapCentral.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Telemetry/TelemetryProtoMapCentral.cs @@ -118,7 +118,9 @@ public static class TelemetryProtoMapCentral LastSuccessfulReadUtc: msg.LastSuccessfulReadUtc?.ToDateTime(), LastError: msg.HasLastError ? msg.LastError : null, ErrorCount5Min: msg.ErrorCount5Min, - PublishedUtc: Required(msg.PublishedUtc, "DriverHealth", "published_utc")); + PublishedUtc: Required(msg.PublishedUtc, "DriverHealth", "published_utc"), + RediscoveryNeededUtc: msg.RediscoveryNeededUtc?.ToDateTime(), + RediscoveryReason: msg.HasRediscoveryReason ? msg.RediscoveryReason : null); } /// Projects a onto a . diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Grpc/TelemetryProtoMapNode.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Grpc/TelemetryProtoMapNode.cs index 8682a16f..066c6de1 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Grpc/TelemetryProtoMapNode.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Grpc/TelemetryProtoMapNode.cs @@ -128,6 +128,10 @@ public static class TelemetryProtoMapNode msg.LastSuccessfulReadUtc = ToUtcTimestamp(e.LastSuccessfulReadUtc.Value); if (e.LastError is not null) msg.LastError = e.LastError; + if (e.RediscoveryNeededUtc is not null) + msg.RediscoveryNeededUtc = ToUtcTimestamp(e.RediscoveryNeededUtc.Value); + if (e.RediscoveryReason is not null) + msg.RediscoveryReason = e.RediscoveryReason; return msg; } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/AkkaDriverHealthPublisher.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/AkkaDriverHealthPublisher.cs index 6f93d39f..d53a4799 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/AkkaDriverHealthPublisher.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/AkkaDriverHealthPublisher.cs @@ -30,7 +30,13 @@ public sealed class AkkaDriverHealthPublisher : IDriverHealthPublisher } /// - public void Publish(string clusterId, string driverInstanceId, DriverHealth health, int errorCount5Min) + public void Publish( + string clusterId, + string driverInstanceId, + DriverHealth health, + int errorCount5Min, + DateTime? rediscoveryNeededUtc = null, + string? rediscoveryReason = null) { var msg = new DriverHealthChanged( clusterId, @@ -39,7 +45,9 @@ public sealed class AkkaDriverHealthPublisher : IDriverHealthPublisher health.LastSuccessfulRead, health.LastError, errorCount5Min, - DateTime.UtcNow); + DateTime.UtcNow, + rediscoveryNeededUtc, + rediscoveryReason); DistributedPubSub.Get(_system).Mediator.Tell(new Publish(TopicName, msg)); // Phase 5: fan the same snapshot into the node-local live-telemetry hub (no-op until a gRPC // client subscribes). The DPS publish above is unchanged — the hub is a strictly additive tap. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs index c0ca7f97..b8a24c08 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs @@ -139,6 +139,13 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers /// public sealed record TriggerRediscovery; + /// Self-sent when the wrapped driver raises — + /// it observed that the remote's tag set may have changed. Marshals the event off the driver's thread + /// onto the actor thread. Handled in EVERY behaviour, including Stubbed and Reconnecting: the raise has no + /// connection affinity (a Galaxy redeploy or a TwinCAT symbol-version bump can land while the driver is + /// between connects), and dropping it in one state would lose the signal silently. + private sealed record RediscoveryRaised(RediscoveryEventArgs Args); + /// Internal self-tick driving bounded post-connect re-discovery (FixedTree populates ~0–2s after connect). /// is the ordered-distinct full-reference signature of the prior pass's /// captured set (empty string on the first tick); re-discovery stops once a non-empty set repeats it. @@ -203,6 +210,16 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers private ISubscriptionHandle? _subscriptionHandle; private EventHandler? _dataChangeHandler; private EventHandler? _alarmEventHandler; + private EventHandler? _rediscoveryHandler; + + /// When the driver last raised , and the reason + /// it gave. Null until the first raise. Carried on every subsequent health snapshot so the AdminUI can + /// prompt an operator to re-browse the device. + /// Deliberately sticky — it is not cleared on reconnect or on a later clean pass. The remote's + /// tag set stayed changed; only an operator re-browsing and committing resolves it, and this actor cannot + /// observe that happening. A redeploy respawns the child, which clears it naturally. + private DateTime? _rediscoveryNeededUtc; + private string? _rediscoveryReason; /// The references the host wants kept subscribed (set by ). /// Re-applied on every entry into Connected so values resume after a reconnect or redeploy. @@ -344,6 +361,9 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers // Warm up the snapshot store immediately so AdminUI sees current state as soon as the // actor starts, before any state transition fires. Also start the periodic heartbeat so // long-lived Healthy drivers keep their snapshot fresh for newly-joined SignalR clients. + // Attach the rediscovery signal before the first publish. Not per-connect: an IRediscoverable raise + // has no connection affinity, and a driver can observe a remote change while disconnected. + AttachRediscoverySource(); PublishHealthSnapshot(); Timers.StartPeriodicTimer("health-poll", HealthPollTick.Instance, _healthPollInterval); } @@ -365,6 +385,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers Receive(_ => { }); // A TriggerRediscovery is meaningless to a stubbed (never-Connected) driver — silently ignore it. Receive(_ => { }); + Receive(HandleRediscoveryRaised); Receive(_ => PublishHealthSnapshot()); } @@ -424,6 +445,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers // A TriggerRediscovery arriving while not Connected is a deliberate no-op — the (re)connect path // re-runs discovery anyway. Swallow it so it stays a clean silent no-op (no Unhandled event). Receive(_ => { }); + Receive(HandleRediscoveryRaised); Receive(_ => PublishHealthSnapshot()); } @@ -499,6 +521,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers // to Self (HandleSubscribeAsync already logged the underlying cause). Swallow it so it doesn't dead-letter. Receive(msg => _log.Debug("DriverInstance {Id}: resubscribe reported failure: {Reason}", _driverInstanceId, msg.Reason)); + Receive(HandleRediscoveryRaised); Receive(_ => { PublishHealthSnapshot(); @@ -608,6 +631,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers // A TriggerRediscovery arriving while not Connected is a deliberate no-op — the (re)connect path // re-runs discovery anyway. Swallow it so it stays a clean silent no-op (no Unhandled event). Receive(_ => { }); + Receive(HandleRediscoveryRaised); Receive(_ => PublishHealthSnapshot()); Timers.StartPeriodicTimer("retry-connect", RetryConnect.Instance, _reconnectInterval); } @@ -853,6 +877,44 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers src.OnAlarmEvent += _alarmEventHandler; } + /// Subscribe the driver's (if it is one), + /// marshaling each raise to the actor thread. Idempotent; mirrors . + /// Attached once in PreStart rather than per-connect, because the interesting raises + /// (a Galaxy redeploy, a TwinCAT symbol-version bump) can happen while the driver is between + /// connects, and the event carries no connection affinity. + private void AttachRediscoverySource() + { + if (_driver is not IRediscoverable src || _rediscoveryHandler is not null) return; + var self = Self; + _rediscoveryHandler = (_, e) => self.Tell(new RediscoveryRaised(e)); + src.OnRediscoveryNeeded += _rediscoveryHandler; + } + + /// Symmetric teardown, called from PostStop. Load-bearing: the instance + /// can OUTLIVE this actor (the host respawns a child around the same driver object), so a missing + /// unsubscribe would accumulate one handler per respawn, each holding a dead Self. + private void DetachRediscoverySource() + { + if (_driver is IRediscoverable src && _rediscoveryHandler is not null) + src.OnRediscoveryNeeded -= _rediscoveryHandler; + _rediscoveryHandler = null; + } + + /// Records the driver's rediscovery raise and re-publishes health so the signal reaches the + /// AdminUI promptly rather than waiting for the next 30 s heartbeat. + /// Advisory only. The served address space is deliberately NOT rebuilt: v3 authors raw tags + /// explicitly through the /raw browse-commit flow, so a runtime graft would create nodes no + /// operator approved and that no deployment artifact records. This prompts a human to re-browse. + private void HandleRediscoveryRaised(RediscoveryRaised msg) + { + _rediscoveryNeededUtc = DateTime.UtcNow; + _rediscoveryReason = msg.Args.Reason; + _log.Info( + "DriverInstance {Id}: driver reports its tag set may have changed ({Reason}) — surfaced for operator re-browse; the served address space is unchanged", + _driverInstanceId, msg.Args.Reason); + PublishHealthSnapshot(); + } + /// Symmetric teardown — called from and PostStop so a stale /// handler never pushes to a disconnected actor. private void DetachAlarmSource() @@ -1081,11 +1143,16 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers { var health = _driver.GetHealth(); var errorCount = ErrorCount5Min(); - var fingerprint = (health.State, health.LastSuccessfulRead, health.LastError, errorCount); + // _rediscoveryNeededUtc is PART OF THE FINGERPRINT on purpose. A rediscovery raise on an + // otherwise-unchanged Healthy driver leaves (state, lastSuccess, lastError, errorCount) + // identical, so without it the dedup below would swallow the very publish that carries the + // signal and the operator would never see the prompt. + var fingerprint = (health.State, health.LastSuccessfulRead, health.LastError, errorCount, _rediscoveryNeededUtc); if (_lastPublishedFingerprint is { } prev && prev.Equals(fingerprint)) return; _lastPublishedFingerprint = fingerprint; - _healthPublisher.Publish(_clusterId, _driverInstanceId, health, errorCount); + _healthPublisher.Publish( + _clusterId, _driverInstanceId, health, errorCount, _rediscoveryNeededUtc, _rediscoveryReason); } catch (Exception ex) { @@ -1094,12 +1161,15 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers } /// Fingerprint of the last call; null until first publish. - private (DriverState State, DateTime? LastSuccess, string? LastError, int ErrorCount)? _lastPublishedFingerprint; + private (DriverState State, DateTime? LastSuccess, string? LastError, int ErrorCount, DateTime? RediscoveryNeededUtc)? _lastPublishedFingerprint; /// protected override void PostStop() { DetachSubscription(); + // MUST happen: the IDriver instance can outlive this actor (the host respawns a child around the + // same driver object), so a missing unsubscribe accumulates a handler per respawn holding a dead Self. + DetachRediscoverySource(); try { _driver.ShutdownAsync(CancellationToken.None).GetAwaiter().GetResult(); } catch (Exception ex) { _log.Warning(ex, "DriverInstance {Id}: ShutdownAsync threw on PostStop", _driverInstanceId); } OtOpcUaTelemetry.DriverInstanceLifecycle.Add(1, diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorRediscoverySignalTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorRediscoverySignalTests.cs new file mode 100644 index 00000000..4027bf68 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorRediscoverySignalTests.cs @@ -0,0 +1,249 @@ +using Akka.Actor; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; +using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; + +/// +/// Covers the consumer (Gitea #518). Nine drivers raise +/// when they observe that a remote's tag set may have +/// changed — a Galaxy redeploy, a TwinCAT symbol-version bump, an MTConnect agent restart, a Sparkplug +/// rebirth. Before this wiring NOTHING in src/ subscribed, so every one of those raises went into +/// the void. +/// The signal is advisory. It does not rebuild the served address space: v3 authors raw tags +/// explicitly through the /raw browse-commit flow, so a runtime graft would create nodes no +/// operator approved. It rides the driver-health snapshot to the AdminUI as a re-browse prompt. +/// +[Trait("Category", "Unit")] +public sealed class DriverInstanceActorRediscoverySignalTests : RuntimeActorTestBase +{ + /// + /// The load-bearing case: a driver raises rediscovery while otherwise perfectly healthy, and the + /// signal reaches the health publisher carrying the driver's reason. + /// + [Fact] + public void Rediscovery_raise_reaches_the_health_publisher_with_its_reason() + { + var driver = new RediscoverableStubDriver(); + var publisher = new RecordingHealthPublisher(); + var parent = CreateTestProbe(); + parent.IgnoreMessages(_ => true); + var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, healthPublisher: publisher)); + + actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); + AwaitAssert(() => publisher.Published.Count.ShouldBeGreaterThan(0), TimeSpan.FromSeconds(3)); + + driver.RaiseRediscovery("deploy-time-changed"); + + AwaitAssert( + () => + { + var withSignal = publisher.Published.LastOrDefault(p => p.RediscoveryNeededUtc is not null); + withSignal.ShouldNotBeNull(); + withSignal!.RediscoveryReason.ShouldBe("deploy-time-changed"); + }, + TimeSpan.FromSeconds(3)); + } + + /// + /// Regression guard for the dedup trap. PublishHealthSnapshot suppresses a publish whose + /// (state, lastSuccessfulRead, lastError, errorCount) tuple repeats. A rediscovery raise on an + /// otherwise-unchanged Healthy driver leaves all four identical, so unless the rediscovery timestamp + /// is part of the fingerprint the dedup swallows the very publish carrying the signal and the operator + /// never sees the prompt. + /// Positive control: the assertion is that the count STRICTLY INCREASES across the raise. An + /// "eventually non-null" assertion alone would pass on the warm-up publish and prove nothing. Revert + /// _rediscoveryNeededUtc out of the fingerprint tuple and this test must fail. + /// + [Fact] + public void Rediscovery_raise_is_not_swallowed_by_the_unchanged_health_dedup() + { + var driver = new RediscoverableStubDriver(); + var publisher = new RecordingHealthPublisher(); + var parent = CreateTestProbe(); + parent.IgnoreMessages(_ => true); + var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, healthPublisher: publisher)); + + actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); + AwaitAssert(() => publisher.Published.Count.ShouldBeGreaterThan(0), TimeSpan.FromSeconds(3)); + + // Let the actor settle so nothing else is in flight, then capture the baseline. Everything about + // the driver's health is now stable — only the rediscovery flag will change. + ExpectNoMsg(TimeSpan.FromMilliseconds(200)); + var before = publisher.Published.Count; + + driver.RaiseRediscovery("symbol-version-changed"); + + AwaitAssert( + () => publisher.Published.Count.ShouldBeGreaterThan(before), + TimeSpan.FromSeconds(3)); + publisher.Published[^1].RediscoveryNeededUtc.ShouldNotBeNull(); + } + + /// + /// The signal is sticky: a later health publish still carries it. The remote's tag set stayed + /// changed, and only an operator re-browsing resolves that — which this actor cannot observe. A + /// flag that cleared itself on the next heartbeat would be a prompt the operator could miss entirely. + /// + [Fact] + public void Rediscovery_flag_persists_on_subsequent_health_publishes() + { + var driver = new RediscoverableStubDriver(); + var publisher = new RecordingHealthPublisher(); + var parent = CreateTestProbe(); + parent.IgnoreMessages(_ => true); + var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, healthPublisher: publisher)); + + actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); + AwaitAssert(() => publisher.Published.Count.ShouldBeGreaterThan(0), TimeSpan.FromSeconds(3)); + driver.RaiseRediscovery("agent-instance-changed"); + AwaitAssert( + () => publisher.Published.Any(p => p.RediscoveryNeededUtc is not null), + TimeSpan.FromSeconds(3)); + + // Force a further publish by changing something else about the driver's health. + driver.SetLastError("transient read failure"); + AwaitAssert( + () => publisher.Published.Any(p => p.LastError == "transient read failure"), + TimeSpan.FromSeconds(3)); + + var latest = publisher.Published[^1]; + latest.RediscoveryNeededUtc.ShouldNotBeNull(); + latest.RediscoveryReason.ShouldBe("agent-instance-changed"); + } + + /// + /// Leak guard. The instance can OUTLIVE the actor — the host stops a + /// child and respawns another around the same driver object — so a missing -= in + /// PostStop accumulates one handler per respawn, each holding a dead Self. Asserts the + /// driver's invocation list is empty again after the actor stops. + /// + [Fact] + public void Stopping_the_actor_detaches_the_rediscovery_handler() + { + var driver = new RediscoverableStubDriver(); + var parent = CreateTestProbe(); + parent.IgnoreMessages(_ => true); + var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver)); + + actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); + AwaitAssert(() => driver.SubscriberCount.ShouldBe(1), TimeSpan.FromSeconds(3)); + + Watch(actor); + actor.Tell(PoisonPill.Instance); + ExpectTerminated(actor, TimeSpan.FromSeconds(3)); + + driver.SubscriberCount.ShouldBe(0); + } + + /// A driver that does NOT implement must publish a null flag — + /// the field is absent, not defaulted to "needs re-browse". + [Fact] + public void A_non_rediscoverable_driver_never_sets_the_flag() + { + var publisher = new RecordingHealthPublisher(); + var parent = CreateTestProbe(); + parent.IgnoreMessages(_ => true); + var actor = parent.ChildActorOf(DriverInstanceActor.Props(new StubDriver(), healthPublisher: publisher)); + + actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); + AwaitAssert(() => publisher.Published.Count.ShouldBeGreaterThan(0), TimeSpan.FromSeconds(3)); + + publisher.Published.ShouldAllBe(p => p.RediscoveryNeededUtc == null); + } + + /// Captures every health publish so a test can assert on the rediscovery fields. + private sealed record HealthPublish( + string ClusterId, + string DriverInstanceId, + DriverHealth Health, + int ErrorCount5Min, + DateTime? RediscoveryNeededUtc, + string? RediscoveryReason) + { + public string? LastError => Health.LastError; + } + + private sealed class RecordingHealthPublisher : IDriverHealthPublisher + { + private readonly List _published = []; + + /// Thread-safe snapshot — Publish is called from the actor thread while the test + /// asserts from its own. + public IReadOnlyList Published + { + get { lock (_published) return _published.ToArray(); } + } + + /// + public void Publish( + string clusterId, + string driverInstanceId, + DriverHealth health, + int errorCount5Min, + DateTime? rediscoveryNeededUtc = null, + string? rediscoveryReason = null) + { + lock (_published) + { + _published.Add(new HealthPublish( + clusterId, driverInstanceId, health, errorCount5Min, rediscoveryNeededUtc, rediscoveryReason)); + } + } + } + + /// + /// A stub driver exposing , a hook to raise it, and the live subscriber + /// count (for the detach guard). + /// Implemented from scratch rather than derived from the shared StubDriver on + /// purpose. That one returns GetHealth() => new(Healthy, DateTime.UtcNow, null), so its + /// health fingerprint differs on EVERY call and the dedup under test never engages — which would make + /// pass vacuously + /// whether or not the fix is present. This stub's health is STABLE until a test changes it. + /// + private sealed class RediscoverableStubDriver : IDriver, IRediscoverable + { + private static readonly DateTime FixedLastRead = new(2026, 7, 27, 12, 0, 0, DateTimeKind.Utc); + private volatile string? _lastError; + + /// + public event EventHandler? OnRediscoveryNeeded; + + /// + public string DriverInstanceId => "rediscoverable-stub-1"; + + /// + public string DriverType => "Stub"; + + /// Number of live subscribers on . + public int SubscriberCount => OnRediscoveryNeeded?.GetInvocationList().Length ?? 0; + + /// Raises the event exactly as a real driver's watcher does. + public void RaiseRediscovery(string reason) + => OnRediscoveryNeeded?.Invoke(this, new RediscoveryEventArgs(reason, ScopeHint: null)); + + /// Changes the reported health so a test can force a publish that is NOT the rediscovery one. + public void SetLastError(string error) => _lastError = error; + + /// + public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask; + + /// + public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask; + + /// + public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + /// + public DriverHealth GetHealth() => new(DriverState.Healthy, FixedLastRead, _lastError); + + /// + public long GetMemoryFootprint() => 0; + + /// + public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorSubscriptionReconcileTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorSubscriptionReconcileTests.cs index 1a901650..d331ed3a 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorSubscriptionReconcileTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorSubscriptionReconcileTests.cs @@ -116,7 +116,13 @@ public sealed class DriverInstanceActorSubscriptionReconcileTests : RuntimeActor public int Count => Volatile.Read(ref _count); /// - public void Publish(string clusterId, string driverInstanceId, DriverHealth health, int errorCount5Min) + public void Publish( + string clusterId, + string driverInstanceId, + DriverHealth health, + int errorCount5Min, + DateTime? rediscoveryNeededUtc = null, + string? rediscoveryReason = null) => Interlocked.Increment(ref _count); } } From adce27e7fabab54501aee8a575c2209cb72eb1b4 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 27 Jul 2026 18:57:09 -0400 Subject: [PATCH 3/8] =?UTF-8?q?refactor(drivers):=20delete=20the=20dead=20?= =?UTF-8?q?discovered-node=20injection=20path=20(=C2=A78.2,=20#507)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #507 was filed as "injection inert in v3, re-migrate onto the raw subtree". Reading it, the retained code is not revivable: it resolves equipment from EquipmentNode.DriverInstanceId UNION EquipmentTags, and BOTH are structurally empty in v3 (AddressSpaceComposer always constructs EquipmentNode with a null DriverInstanceId; DeploymentArtifact hard-codes an empty EquipmentTags set). Removing the guard would have changed a log line and injected nothing. So it is deleted rather than fixed, and #507 closes as superseded by /raw browse-commit. Removed: HandleDiscoveredNodes, PartitionDiscoveredByDeviceHost, ShouldWarnPartition, PlansRoutingEqual, ApplyDiscoveredPlansForDriver, _discoveredByDriver, the redeploy re-inject tail, DiscoveredNodeMapper, DiscoveredInjection, AddressSpaceApplier.MaterialiseDiscoveredNodes, OpcUaPublishActor.MaterialiseDiscoveredNodes, and the Runtime-local copies of CapturingAddressSpaceBuilder/DiscoveredNode. The connect-time discovery loop goes too (StartDiscovery, RediscoverTick, HandleRediscoverAsync, DiscoveredNodesReady, TriggerRediscovery). With injection gone it had no consumer, and leaving it would either dead-letter or keep browsing real devices up to ~15x per connect to drop the result. ITagDiscovery itself stays — the /raw browse picker drives it through Commons/Browsing/DiscoveryDriverBrowser, which never went through the actor, so the picker is unaffected. deferment.md §4.4 called the 18 DiscoveryInjectionDormantV3 tests "Real — blocked on #507". They are not: they assert an equipment-rooted graft (EquipmentRootNodeId == "EQ-1") that v3 cannot produce, so they could never have unskipped as written. Deleted along with DiscoveredNodeMapperTests, the CapturingAddressSpaceBuilder tests, and the MaterialiseDiscoveredNodes tests in AddressSpaceApplierTests/OpcUaPublishActorTests. Runtime.Tests skipped: 31 -> 13. IHostConnectivityProbe is deliberately NOT resolved here. GetHostStatuses() has no production call site and nothing writes a DriverHostStatus row, but the table was re-created in the v3 initial migration, so deleting on inference would be wrong. Split to Gitea #521 with both options costed. CLAUDE.md, docs/drivers/{Galaxy,TwinCAT,MTConnect}.md updated — they described the seam as dead. Build clean; Runtime.Tests 476 passed / 13 skipped; OpcUaServer.Tests 362 passed / 4 skipped. --- CLAUDE.md | 53 +- deferment.md | 50 +- docs/drivers/Galaxy.md | 2 +- docs/drivers/MTConnect.md | 29 +- docs/drivers/TwinCAT.md | 2 +- .../AddressSpaceApplier.cs | 43 - .../DiscoveredInjection.cs | 8 - .../Drivers/CapturingAddressSpaceBuilder.cs | 71 - .../Drivers/DiscoveredNode.cs | 19 - .../Drivers/DiscoveredNodeMapper.cs | 118 -- .../Drivers/DriverHostActor.cs | 478 +----- .../Drivers/DriverInstanceActor.cs | 182 --- .../OpcUa/OpcUaPublishActor.cs | 32 - .../AddressSpaceApplierFailureSurfaceTests.cs | 5 - .../AddressSpaceApplierTests.cs | 670 --------- .../CapturingAddressSpaceBuilderTests.cs | 44 - .../Drivers/DarkAddressSpaceReasons.cs | 16 +- .../Drivers/DiscoveredNodeMapperTests.cs | 119 -- .../DiscoveryInjectionEndToEndTests.cs | 353 ----- .../Drivers/DriverHostActorDiscoveryTests.cs | 1327 ----------------- .../DriverInstanceActorDiscoveryTests.cs | 583 -------- .../OpcUa/OpcUaPublishActorTests.cs | 29 - 22 files changed, 117 insertions(+), 4116 deletions(-) delete mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/DiscoveredInjection.cs delete mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/CapturingAddressSpaceBuilder.cs delete mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DiscoveredNode.cs delete mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DiscoveredNodeMapper.cs delete mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/CapturingAddressSpaceBuilderTests.cs delete mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveredNodeMapperTests.cs delete mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveryInjectionEndToEndTests.cs delete mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs delete mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDiscoveryTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 892546f2..7ef172fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -155,17 +155,48 @@ Galaxy `mx_data_type` values map to OPC UA types (Boolean, Int32, Float, Double, `DeployWatcher` (`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/DeployWatcher.cs`) polls the gateway's deploy-event signal and raises `IRediscoverable.OnRediscoveryNeeded` when the Galaxy redeploys. -⚠️ **Nothing consumes that signal.** No type under `src/Server/` or `src/Core/` subscribes to -`OnRediscoveryNeeded` — the only `+=` handlers in `src/` are Galaxy re-raising its own watcher's event -onto its own interface (`GalaxyDriver.cs:586`). `DriverHostActor.HandleDiscoveredNodes` -(`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs:986-1002`) short-circuits with an -unconditional `return;` — discovered-node injection is **dormant in v3** because raw tags are authored -through the `/raw` browse-commit flow instead. A Galaxy redeploy therefore does **not** change the -served address space; a config redeploy is required. The same inert-signal gap affects TwinCAT, -MQTT/Sparkplug and MTConnect, and `IHostConnectivityProbe` is likewise unconsumed (`GetHostStatuses()` -has no production call site; nothing ever writes a `DriverHostStatus` row). Tracked as Gitea **#518** -(no consumer) and **#507** (re-migrate injection onto the raw subtree) — note that fixing either alone -changes nothing observable. +**The signal is consumed as an operator prompt, not as an address-space rebuild** (§8.2, 2026-07-27). +`DriverInstanceActor` subscribes in `PreStart` and unsubscribes in `PostStop` — the detach is +load-bearing, because the `IDriver` instance outlives the actor when the host respawns a child around +the same driver object. A raise is recorded and rides the driver-health snapshot +(`DriverHealthChanged.RediscoveryNeededUtc` / `.RediscoveryReason`) to the AdminUI `/hosts` page as a +**"re-browse" chip**. + +⚠️ **It is advisory: a Galaxy redeploy still does NOT change the served address space.** v3 authors raw +tags explicitly through the `/raw` browse-commit flow, so an operator must re-browse the device and +commit. Injecting nodes at runtime would materialise nodes nobody approved and no deployment artifact +records. + +Two gotchas if you touch this: + +- `PublishHealthSnapshot` dedups on a health fingerprint. The rediscovery timestamp **must** stay in + that tuple, or a raise on an otherwise-unchanged Healthy driver is swallowed and never reaches the + operator. Pinned by `DriverInstanceActorRediscoverySignalTests`. +- The shared test `StubDriver.GetHealth()` returns `DateTime.UtcNow`, so its fingerprint differs on + every call and the dedup never engages — a test built on it passes vacuously. The rediscovery tests + use a stub with stable health for exactly this reason. + +**#507 is closed as superseded, and the injection path is deleted.** Its retained code read +`EquipmentNode.DriverInstanceId` and `EquipmentTags`, both *structurally empty* in v3 +(`AddressSpaceComposer.cs`, `DeploymentArtifact.cs`), so removing the guard would have changed a log +line and injected nothing. Gone with it: `HandleDiscoveredNodes`, `PartitionDiscoveredByDeviceHost`, +`ApplyDiscoveredPlansForDriver`, the redeploy re-inject tail, `DiscoveredNodeMapper`, +`AddressSpaceApplier.MaterialiseDiscoveredNodes`, the connect-time discovery loop +(`StartDiscovery`/`RediscoverTick`/`DiscoveredNodesReady`/`TriggerRediscovery`), and the 18 +`DiscoveryInjectionDormantV3` tests — v2 characterization tests asserting an equipment-rooted graft +(`EquipmentRootNodeId == "EQ-1"`) that v3 cannot produce. `ITagDiscovery` itself stays: the `/raw` +browse picker drives it through `Commons/Browsing/DiscoveryDriverBrowser`, which never went through +the actor. + +**Deliberately NOT built: a discovered-vs-authored drift detector.** Modbus, S7, MQTT, AbLegacy and Sql +all *echo authored config* from `DiscoverAsync` rather than browsing the device, so such a diff is +permanently empty for them — another plausible-looking inert seam. `IRediscoverable` is the +trustworthy signal because the driver asserts it deliberately. + +⚠️ **`IHostConnectivityProbe` is still unconsumed.** `GetHostStatuses()` has no production call site and +nothing ever writes a `DriverHostStatus` row (the table was re-created in the v3 initial migration, so +confirm intent before deleting). The separable `OnHostStatusChanged` event is the useful half. Tracked +as Gitea **#521**. ## mxaccessgw diff --git a/deferment.md b/deferment.md index 4363b39f..b2801ebc 100644 --- a/deferment.md +++ b/deferment.md @@ -347,7 +347,9 @@ appear in §7.1 as well. `docs/ReadWriteOperations.md` first; it is the one that could mislead a security review.~~ ✅ **Done** — decided *make non-enforcement explicit*; wire-up tracked as Gitea **#520**. See §9. (`docs/ReadWriteOperations.md` was **not** the sharpest one — `docs/security.md` was.) -2. **#518 + #507 together** — neither fix is observable alone. +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. 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. @@ -366,7 +368,7 @@ the work, so this register never disagrees with the tree. | §8 item | Status | Commit | |---|---|---| | 1. ACL enforcement decision | ✅ **Done** — decided *make non-enforcement explicit*; Gitea **#520** tracks the wire-up | `d1e88dc4` | -| 2. #518 + #507 | ⏳ Not started — decided *signal, not mutation* | — | +| 2. #518 + #507 | ✅ **Done** — `IRediscoverable` consumed as a re-browse prompt; #507's injection path **deleted**; `IHostConnectivityProbe` half split to Gitea **#521** | `09a401b8`, `97c9f4b4` | | 3. #516 config discard | ⏳ Not started — decided *both* (per-driver re-parse **and** seam respawn) | — | | 4. G-4 dispatch-map parity | ⏳ Not started | — | | 5. Bookkeeping sweep | ⏳ Not started | — | @@ -415,6 +417,50 @@ Also recorded in #520 and not previously known: every existing evaluator unit te 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. + --- *Generated 2026-07-27 against `master` @ `90bdaa44` by five parallel source-verification agents. diff --git a/docs/drivers/Galaxy.md b/docs/drivers/Galaxy.md index 14def81a..4807d4fb 100644 --- a/docs/drivers/Galaxy.md +++ b/docs/drivers/Galaxy.md @@ -70,7 +70,7 @@ Project root files: | Capability | Implementation entry point | |------------|---------------------------| | `ITagDiscovery` | `Browse/GalaxyDiscoverer.cs` | -| `IRediscoverable` | `Browse/DeployWatcher.cs` — ⚠️ raised but **unconsumed**; a Galaxy redeploy does not rebuild the address space ([#518](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/518), [#507](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/507)) | +| `IRediscoverable` | `Browse/DeployWatcher.cs` — consumed as an **operator prompt**: a Galaxy redeploy raises a "re-browse" chip on `/hosts`. ⚠️ It does **not** rebuild the address space — re-browse the device under `/raw` and commit ([#518](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/518)) | | `IReadable` | `Runtime/GalaxyMxSession.cs` | | `IWritable` | `Runtime/GatewayGalaxyDataWriter.cs` | | `ISubscribable` | `Runtime/GatewayGalaxySubscriber.cs` (driven by `EventPump`) | diff --git a/docs/drivers/MTConnect.md b/docs/drivers/MTConnect.md index f1f15662..e694b5eb 100644 --- a/docs/drivers/MTConnect.md +++ b/docs/drivers/MTConnect.md @@ -220,8 +220,9 @@ what the driver's cursor expects) is detected **two ways**, both triggering a **before** the sequence-gap check (a restart also usually trips the gap, so the two need disambiguating, not just OR-ing together). On a changed `instanceId` the driver clears its cached probe model and raises -`OnRediscoveryNeeded` — see [Known limitations](#known-limitations) for why -that signal currently has no consumer. +`OnRediscoveryNeeded`, which surfaces a **re-browse prompt** on the AdminUI +`/hosts` page — see [Known limitations](#known-limitations) for what that does +and does not do. ## Browse — free via the universal browser @@ -249,16 +250,20 @@ need re-authoring. See [Deferred](#deferred-not-in-this-build). These are real, not placeholders — read them before relying on the driver for anything beyond values-and-conditions. -1. **`IRediscoverable` and `IHostConnectivityProbe` have no consumer in the - server.** `DriverInstanceActor` wires only `ISubscribable.OnDataChange` and - `IAlarmSource.OnAlarmEvent`; nothing in the server subscribes to - `OnRediscoveryNeeded` or `OnHostStatusChanged` except `GalaxyDriver` - wiring its own internal `DeployWatcher` sub-component. So a restarted - Agent (a changed `instanceId`) leaves a stale address space behind an - otherwise-Healthy driver. **This is a pre-existing fleet-wide gap - affecting every driver that implements either interface** (eight drivers - besides Galaxy), not something specific to MTConnect — this build simply - surfaced it again. +1. **A restarted Agent prompts an operator; it does not re-shape the address + space.** `DriverInstanceActor` now consumes `OnRediscoveryNeeded` + (2026-07-27), so a changed `instanceId` raises a "re-browse" chip against + this driver on `/hosts` carrying the reason. It is **advisory**: v3 authors + raw tags through the `/raw` browse-commit flow, so until an operator + re-browses the Agent and commits, any DataItem that appeared or vanished is + not reflected in the served tree. A runtime graft was deliberately rejected + — it would materialise nodes nobody approved and no deployment artifact + records. + + **`IHostConnectivityProbe` is still unconsumed** — `GetHostStatuses()` has + no production call site and nothing writes a `DriverHostStatus` row. That + half remains a fleet-wide gap affecting every driver that implements it + (Gitea #521). 2. **`RawTagEntry.DeviceName` is accepted on the wire shape but ignored for routing.** One driver instance owns exactly one Agent client scoped by `MTConnectDriverOptions.DeviceName` (the top-level config key); the diff --git a/docs/drivers/TwinCAT.md b/docs/drivers/TwinCAT.md index 252157fd..081f8752 100644 --- a/docs/drivers/TwinCAT.md +++ b/docs/drivers/TwinCAT.md @@ -155,7 +155,7 @@ fixture is down during normal dev. **Live-verify** — integration-fixture-gated (requires the TwinCAT Docker fixture / AMS router). **Deferrals** — array *writes* (`ADS WriteValueAsync` for an array), multi-dimensional -arrays, per-element historization. `IRediscoverable` still fires (but is unconsumed — #518) +arrays, per-element historization. `IRediscoverable` fires and now surfaces a `/hosts` re-browse prompt, but still does not rebuild the address space (#518) on PLC re-download (unchanged semantics from scalar nodes). See [Uns.md §Array tags](../Uns.md#array-tags-1-d) for the cross-driver coverage matrix diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs index 2c6b8d41..bb776ad2 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs @@ -886,49 +886,6 @@ public sealed class AddressSpaceApplier return failed; } - /// - /// Materialise driver-discovered nodes (FixedTree) under an equipment at runtime. Idempotent: - /// re-applies are cheap (the sink's EnsureFolder/EnsureVariable early-return on existing nodes), so - /// this is safely re-run after every address-space rebuild. Folders are ensured parent-first. - /// Emits a NodeAdded model-change so connected clients can refresh. Discovered nodes are read-only - /// value nodes; array discovered nodes (rare) are forced read-only like the equipment-tag pass. - /// - /// The equipment root node the discovered nodes hang under; the - /// NodeAdded model-change is announced under this node. - /// The discovered folders to ensure (parent-first by depth). - /// The discovered variables to ensure (read-only value nodes). - /// The count of swallowed sink failures in this pass (0 on a clean apply) — the publish actor - /// surfaces a non-zero count as a degraded discovered-node injection (archreview 01/S-1). - public int MaterialiseDiscoveredNodes( - string equipmentRootNodeId, - IReadOnlyList folders, - IReadOnlyList variables) - { - ArgumentException.ThrowIfNullOrEmpty(equipmentRootNodeId); - ArgumentNullException.ThrowIfNull(folders); - ArgumentNullException.ThrowIfNull(variables); - if (folders.Count == 0 && variables.Count == 0) return 0; - - var failed = 0; - // Parent-first: a child folder's parent must exist before it. Ordering by '/' count == depth. - foreach (var f in folders.OrderBy(f => f.NodeId.Count(c => c == '/'))) - if (!SafeEnsureFolder(f.NodeId, f.ParentNodeId, f.DisplayName, AddressSpaceRealm.Raw)) failed++; - - foreach (var v in variables) - { - // Mirror MaterialiseEquipmentTags: arrays forced read-only (the driver write path can't handle arrays). - var writable = v.Writable && !v.IsArray; - if (!SafeEnsureVariable(v.NodeId, v.ParentNodeId, v.DisplayName, v.DataType, writable, - AddressSpaceRealm.Raw, historianTagname: null, isArray: v.IsArray, arrayLength: v.ArrayLength)) failed++; - } - - _sink.RaiseNodesAddedModelChange(equipmentRootNodeId, AddressSpaceRealm.Raw); - - _logger.LogInformation( - "AddressSpaceApplier: discovered nodes materialised under {Equipment} (folders={Folders}, vars={Vars}, failed={Failed})", - equipmentRootNodeId, folders.Count, variables.Count, failed); - return failed; - } /// /// Materialise Equipment-namespace VirtualTags from a composition snapshot — the VirtualTag diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/DiscoveredInjection.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/DiscoveredInjection.cs deleted file mode 100644 index 90a1abe1..00000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/DiscoveredInjection.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; - -/// A folder to ensure during discovered-node injection (NodeId + parent + display). -public sealed record DiscoveredFolder(string NodeId, string? ParentNodeId, string DisplayName); - -/// A read-or-write variable to ensure during discovered-node injection. -public sealed record DiscoveredVariable( - string NodeId, string ParentNodeId, string DisplayName, string DataType, bool Writable, bool IsArray, uint? ArrayLength); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/CapturingAddressSpaceBuilder.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/CapturingAddressSpaceBuilder.cs deleted file mode 100644 index 626807d5..00000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/CapturingAddressSpaceBuilder.cs +++ /dev/null @@ -1,71 +0,0 @@ -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; - -namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers; - -/// -/// An that RECORDS the streamed tree instead of creating OPC UA -/// nodes — used to capture an driver's discovered hierarchy so the -/// runtime can graft it under an equipment node. Folder nesting is tracked (each child builder -/// carries its accumulated path), so every variable records its full . -/// Value nodes only: is ignored and alarm marking returns a no-op sink -/// (discovered alarms are out of scope — alarms come via the config path). -/// Single-threaded: a driver's DiscoverAsync streams on one caller; the root and its child -/// builders share one . Not thread-safe by design. -/// -public sealed class CapturingAddressSpaceBuilder : IAddressSpaceBuilder -{ - private readonly List _nodes; - private readonly IReadOnlyList _path; - - /// Create a root capturing builder with an empty folder path and a fresh node list. - public CapturingAddressSpaceBuilder() : this([], []) { } - - private CapturingAddressSpaceBuilder(List nodes, IReadOnlyList path) - { - _nodes = nodes; - _path = path; - } - - /// All variables captured across the whole tree (shared by the root and every child scope). - public IReadOnlyList Nodes => _nodes; - - /// - public IAddressSpaceBuilder Folder(string browseName, string displayName) - => new CapturingAddressSpaceBuilder(_nodes, [.. _path, browseName]); - - /// - public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo) - { - _nodes.Add(new DiscoveredNode( - FolderPathSegments: _path, - BrowseName: browseName, - DisplayName: displayName, - FullReference: attributeInfo.FullName, - DataType: attributeInfo.DriverDataType, - IsArray: attributeInfo.IsArray, - ArrayDim: attributeInfo.ArrayDim, - Writable: attributeInfo.SecurityClass != SecurityClassification.ViewOnly, - IsHistorized: attributeInfo.IsHistorized)); - return new NullHandle(attributeInfo.FullName); - } - - /// - public void AddProperty(string browseName, DriverDataType dataType, object? value) { /* metadata only — ignored */ } - - /// A variable handle whose alarm marking is a no-op (discovered alarms are out of scope). - private sealed class NullHandle(string fullRef) : IVariableHandle - { - /// - public string FullReference => fullRef; - - /// - public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink(); - } - - /// A null sink that ignores alarm condition transitions. - private sealed class NullSink : IAlarmConditionSink - { - /// - public void OnTransition(AlarmEventArgs args) { } - } -} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DiscoveredNode.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DiscoveredNode.cs deleted file mode 100644 index 7ff7d43e..00000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DiscoveredNode.cs +++ /dev/null @@ -1,19 +0,0 @@ -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; - -namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers; - -/// -/// A flattened variable captured from a driver's stream -/// by . Folder nesting is preserved in -/// so the injector can re-root the node under an equipment. -/// -public sealed record DiscoveredNode( - IReadOnlyList FolderPathSegments, - string BrowseName, - string DisplayName, - string FullReference, - DriverDataType DataType, - bool IsArray, - uint? ArrayDim, - bool Writable, - bool IsHistorized); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DiscoveredNodeMapper.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DiscoveredNodeMapper.cs deleted file mode 100644 index a31b1a85..00000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DiscoveredNodeMapper.cs +++ /dev/null @@ -1,118 +0,0 @@ -using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; -using ZB.MOM.WW.OtOpcUa.Commons.Types; -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; -using ZB.MOM.WW.OtOpcUa.OpcUaServer; - -namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers; - -/// The mapped result of grafting discovered nodes under an equipment node. -/// -/// Folders to ensure, in insertion order (parent-before-child within each node's prefix chain) — NOT -/// globally depth-sorted. The applier sorts by depth before ensuring, so consumers must not assume a -/// global parent-before-child ordering across the whole list. -/// -/// Variables to ensure under the (post-collapse) folders. -/// Driver FullReference -> equipment NodeId, for live-value routing. -public sealed record DiscoveredInjectionPlan( - IReadOnlyList Folders, - IReadOnlyList Variables, - IReadOnlyDictionary RoutingByRef); // driver FullReference -> equipment NodeId - -/// -/// Pure mapper: re-roots a driver's captured discovery tree under an equipment node, deduping -/// authored Config-DB refs and collapsing the single device-host folder. See the design doc -/// 2026-06-26-otopcua-fixedtree-equipment-injection-design.md. -/// -public static class DiscoveredNodeMapper -{ - /// - /// Maps captured into folders + variables (NodeIds scoped under - /// ) plus a driver-FullReference → equipment-NodeId routing map. - /// - /// The owning equipment's NodeId (root of the grafted subtree). - /// The captured discovery tree (from CapturingAddressSpaceBuilder). - /// - /// Driver FullReferences already authored as Config-DB equipment tags for this driver — - /// skipped so a discovered node never shadows an authored one. - /// - /// The folders, variables, and routing map to apply against the OPC UA address space. - public static DiscoveredInjectionPlan Map( - string rootNodeId, IReadOnlyList nodes, IReadOnlySet authoredRefs) - { - // v3 Batch 4: discovered nodes graft onto the RAW device subtree — a discovered node's NodeId is - // // (slash-joined RawPath), NOT the retired equipment-scoped - // {equipmentId}/{folderPath}/{name}. Root-relative combine (the root is an already-built RawPath). - static string Combine(string root, string tail) => root + RawPaths.SeparatorString + tail; - var kept = nodes.Where(n => !authoredRefs.Contains(n.FullReference)).ToList(); - - // Device-folder collapse: when every kept node shares one identical index-1 segment (the single - // device-host folder under the driver root, e.g. "10.0.0.5:8193"), drop it so the path reads - // FOCAS/Identity/... rather than FOCAS/10.0.0.5:8193/Identity/.... With >=2 distinct devices the - // level is retained so identical leaf names across devices don't collide (degrades gracefully). - var collapseIndex1 = kept.Count > 0 - && kept.All(n => n.FolderPathSegments.Count >= 2) - && kept.Select(n => n.FolderPathSegments[1]).Distinct(StringComparer.Ordinal).Count() == 1; - - static IReadOnlyList Effective(IReadOnlyList segs, bool collapse) - => collapse ? [segs[0], .. segs.Skip(2)] : segs; - - var folders = new Dictionary(StringComparer.Ordinal); - var variables = new List(); - var routing = new Dictionary(StringComparer.Ordinal); - - foreach (var n in kept) - { - var segs = Effective(n.FolderPathSegments, collapseIndex1); - - // Ensure every prefix folder, deduped, each parented at its prefix (the first segment's - // parent is the equipment itself). - for (var i = 0; i < segs.Count; i++) - { - var folderPath = string.Join('/', segs.Take(i + 1)); - var nodeId = Combine(rootNodeId, folderPath); - if (folders.ContainsKey(nodeId)) continue; - var parent = i == 0 ? rootNodeId : Combine(rootNodeId, string.Join('/', segs.Take(i))); - folders[nodeId] = new DiscoveredFolder(nodeId, parent, segs[i]); - } - - var varFolderPath = string.Join('/', segs); - var varNodeId = string.IsNullOrEmpty(varFolderPath) - ? Combine(rootNodeId, n.BrowseName) - : Combine(rootNodeId, varFolderPath + RawPaths.SeparatorString + n.BrowseName); - // A folder-less variable parents directly at the root device node. - var varParent = string.IsNullOrEmpty(varFolderPath) - ? rootNodeId - : Combine(rootNodeId, varFolderPath); - variables.Add(new DiscoveredVariable( - varNodeId, varParent, n.DisplayName, ToBuiltinTypeString(n.DataType), n.Writable, n.IsArray, n.ArrayDim)); - routing[n.FullReference] = varNodeId; - } - - return new DiscoveredInjectionPlan(folders.Values.ToList(), variables, routing); - } - - /// - /// Maps a to the OPC-UA-built-in type STRING that - /// OtOpcUaNodeManager.EnsureVariable's ResolveBuiltInDataType accepts — so a - /// discovered variable resolves to the same built-in type as an authored equipment tag. Most - /// enum names pass through verbatim; / - /// map to the SDK's "Float"/"Double" names, and (a Galaxy - /// attribute reference) is carried as an OPC UA String per the enum's own contract. - /// - private static string ToBuiltinTypeString(DriverDataType dt) => dt switch - { - DriverDataType.Boolean => "Boolean", - DriverDataType.Int16 => "Int16", - DriverDataType.Int32 => "Int32", - DriverDataType.Int64 => "Int64", - DriverDataType.UInt16 => "UInt16", - DriverDataType.UInt32 => "UInt32", - DriverDataType.UInt64 => "UInt64", - DriverDataType.Float32 => "Float", - DriverDataType.Float64 => "Double", - DriverDataType.String => "String", - DriverDataType.DateTime => "DateTime", - DriverDataType.Reference => "String", - _ => throw new ArgumentOutOfRangeException(nameof(dt), dt, "Unmapped DriverDataType."), - }; -} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs index c1025d56..cb2152ee 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs @@ -245,36 +245,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers private readonly NativeAlarmProjector _nativeAlarmProjector = new(); /// The composition from the most-recent apply (set at the END of - /// ). Discovered-node injection - /// () reads it to resolve the equipment bound to a driver (from the - /// composition's EquipmentNodes whose DriverInstanceId matches, UNION the authored - /// EquipmentTags for that driver — so a driver with zero authored tags can still graft onto an - /// equipment bound via EquipmentNode.DriverInstanceId) and to recompute the authored value + alarm - /// subscription sets when merging FixedTree refs. Null until the first apply — a - /// arriving before any apply is ignored. + /// ). Null until the first apply. private AddressSpaceComposition? _lastComposition; - /// The most-recent discovered-injection plan(s) per driver instance, cached so the redeploy - /// re-inject tail can re-apply the live graft after an address-space rebuild without re-running discovery. - /// Keyed by DriverInstanceId at the OUTER level, then by EquipmentId at the INNER level (driver → (equipment - /// → plan)). Today only the single-equipment case is populated, so the inner map always has exactly one - /// entry; the inner map is shaped per-equipment now so the follow-up multi-device-partition task can hold - /// multiple (equipmentId → plan) entries per driver without reshaping this cache. Inner dict is mutable - /// (the redeploy tail drops stale per-equipment entries in place); both levels are Ordinal-keyed. - /// Last-writer-wins on a re-discovery (the whole inner map is replaced). - private readonly Dictionary> _discoveredByDriver = - new(StringComparer.Ordinal); - - /// Per-driver signature of the last-logged device-host PARTITION diagnostic (unmatched / ambiguous - /// / degenerate host), folded with the current revision, so the ~15 repeated re-discovery passes within a - /// connect don't re-warn an unchanged condition: it is WARNED once when it first appears (or changes), and - /// DEBUG-logged on the identical repeat passes. Folding in makes a redeploy - /// re-warn once. Best-effort LOG-LEVEL dedup ONLY — never affects grafting; the matched-plan re-apply is - /// separately short-circuited by . Cleared for a driver whose partition comes - /// back clean so a later recurrence re-warns; bounded by driver count (a few). Only touched on the - /// multi-candidate path (). - private readonly Dictionary _lastPartitionWarnSignature = new(StringComparer.Ordinal); - /// /// Cached local from the latest /// snapshot (null = unknown until the first snapshot arrives, or no local node match). The Primary @@ -894,7 +867,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers Receive(ForwardToMux); Receive(ForwardNativeAlarm); Receive(OnDriverConnectivityChanged); - Receive(HandleDiscoveredNodes); Receive(HandleDeltaApplied); Receive(HandleRestartDriver); Receive(HandleReconnectDriver); @@ -928,7 +900,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers Receive(ForwardToMux); Receive(ForwardNativeAlarm); Receive(OnDriverConnectivityChanged); - Receive(HandleDiscoveredNodes); Receive(HandleDeltaApplied); Receive(HandleRestartDriver); Receive(HandleReconnectDriver); @@ -973,349 +944,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers } } - /// - /// Handles a driver child's post-connect : - /// resolves the equipment the driver is bound to from the most-recent applied composition (its - /// EquipmentNodes bound by DriverInstanceId UNION its authored EquipmentTags), - /// maps the captured FixedTree under it via (deduping any node that - /// shadows an authored equipment-tag ref), caches the per-equipment plan map, and grafts it onto the - /// served address space + live-value maps + subscription set via - /// . Idempotent / duplicate-safe: the mapper is pure, - /// materialisation is idempotent, and the routing-map extension + subscription merge are set-based. - /// - private void HandleDiscoveredNodes(DriverInstanceActor.DiscoveredNodesReady msg) - { - // v3 Batch 4 (review M1): discovered-node INJECTION is DORMANT in v3 and hard-guarded here. In v2 a - // driver-connected FixedTree was grafted under an equipment folder (equipment bound a driver); v3 - // retired that binding — equipment references raw tags via UnsTagReference, and discovered raw tags are - // authored explicitly through the Batch-2 /raw browse-commit flow, NOT injected at runtime. The - // downstream mapper/materialiser were half-migrated to the Raw tree but are still rooted at an - // equipment id, so letting this fire would materialise incoherent nodes. Short-circuit BEFORE any - // caching/routing so _discoveredByDriver stays empty (the redeploy re-inject tail is therefore inert - // too) and there is exactly one enforcement point. Re-migrating injection onto the raw device subtree - // is a separate follow-up. - _log.Debug( - "DriverHost {Node}: discovered-node injection is dormant in v3 (DiscoveredNodesReady from {Driver} ignored; discovered raw tags are authored via /raw browse-commit)", - _localNode, msg.DriverInstanceId); - return; - -#pragma warning disable CS0162 // Unreachable code — retained for the raw-subtree re-migration follow-up. - if (_lastComposition is null) - { - _log.Debug("DriverHost {Node}: DiscoveredNodesReady from {Driver} before any composition applied — ignored", - _localNode, msg.DriverInstanceId); - return; - } - - // Resolve the equipment bound to this driver from BOTH the composition's EquipmentNodes (whose - // DriverInstanceId matches — this lets a driver with ZERO authored tags graft onto a tag-less - // equipment) UNION the authored EquipmentTags for the driver (the original resolution). Distinct so a - // driver that is both EquipmentNode-bound AND has authored tags under the same equipment resolves once. - var fromNodes = _lastComposition.EquipmentNodes - .Where(e => e.DriverInstanceId is not null && string.Equals(e.DriverInstanceId, msg.DriverInstanceId, StringComparison.Ordinal)) - .Select(e => e.EquipmentId); - var fromTags = _lastComposition.EquipmentTags - .Where(t => string.Equals(t.DriverInstanceId, msg.DriverInstanceId, StringComparison.Ordinal)) - .Select(t => t.EquipmentId); - var equipmentIds = fromNodes.Concat(fromTags).Distinct(StringComparer.Ordinal).ToList(); - if (equipmentIds.Count == 0) - { - _log.Info("DriverHost {Node}: no equipment for driver {Driver} — skipping discovered-node injection", - _localNode, msg.DriverInstanceId); - return; - } - // Authored refs for THIS driver (DRIVER-WIDE — both value + alarm tags) so a discovered node never - // shadows an authored one — the mapper drops any captured node whose FullReference is already authored. - // May be EMPTY for a tag-less equipment, which is fine: Map dedups against an empty set (keeps - // everything). Safe even for the multi-device partition below: a FOCAS FullReference is host-prefixed, - // so a device-X discovered node can't collide with a device-Y authored ref — the driver-wide set is - // correct per partition. - var authoredRefs = _lastComposition.EquipmentTags - .Where(t => string.Equals(t.DriverInstanceId, msg.DriverInstanceId, StringComparison.Ordinal)) - .Select(t => t.FullName) - .ToHashSet(StringComparer.Ordinal); - - // Build this discovery's per-equipment plan map. - // • EXACTLY ONE candidate ⇒ map the WHOLE captured tree under it (the mapper collapses the single - // device-host folder ⇒ clean EQ-n/FOCAS/...). Unchanged from before. - // • MORE THAN ONE candidate ⇒ PARTITION the captured tree by its (normalized) device-host folder - // segment and graft each device's subset under the equipment whose DeviceHost matches (follow-up E - // part 2). Unmatched/ambiguous hosts are warn-skipped (safe), not mis-grafted; a degenerate case - // (>1 candidate, none has a DeviceHost) warn-skips the whole driver. See PartitionDiscoveredByDeviceHost. - Dictionary newPlans; - if (equipmentIds.Count == 1) - { - var plan = DiscoveredNodeMapper.Map(equipmentIds[0], msg.Nodes, authoredRefs); - if (plan.Variables.Count == 0) return; // nothing new to inject (all captured nodes were authored) - newPlans = new Dictionary(StringComparer.Ordinal) { [equipmentIds[0]] = plan }; - } - else - { - newPlans = PartitionDiscoveredByDeviceHost(msg, equipmentIds, authoredRefs); - if (newPlans.Count == 0) return; // degenerate / no host matched a graftable partition — already logged - } - - // Unchanged-plan short-circuit (shared by the single- AND multi-device paths): the driver re-discovers - // every ~2s (up to ~15 passes) until the FixedTree set stabilises, re-sending DiscoveredNodesReady each - // pass. Re-applying an IDENTICAL set would re-send SetDesiredSubscriptions, forcing the child to - // UnsubscribeAsync (dropping the WHOLE handle — authored tags included) then re-Subscribe — blipping - // authored-tag values up to ~15× across the discovery window. Skip when the WHOLE per-equipment routing - // is unchanged from the last applied pass; a GROWING set still differs (superset) and re-applies. (This - // is also why an unmatched/ambiguous partition warning settles: once the matched partitions stabilise we - // short-circuit here, and the partition warns are themselves signature-deduped — see ShouldWarnPartition.) - if (_discoveredByDriver.TryGetValue(msg.DriverInstanceId, out var cached) - && PlansRoutingEqual(cached, newPlans)) - { - var total = newPlans.Values.Sum(p => p.Variables.Count); - _log.Debug("DriverHost {Node}: discovered set for driver {Driver} unchanged ({Count} node(s) across {Equipment} equipment(s)) — re-apply skipped", - _localNode, msg.DriverInstanceId, total, newPlans.Count); - return; - } - - _discoveredByDriver[msg.DriverInstanceId] = newPlans; - ApplyDiscoveredPlansForDriver(msg.DriverInstanceId, newPlans); -#pragma warning restore CS0162 - } - - /// - /// Partitions a multi-device driver's captured FixedTree by its (normalized) device-host folder segment - /// (FolderPathSegments[1]) and maps each device's subset under the candidate equipment whose - /// matches — the multi-device graft. Returns - /// the per-equipment plan map (one entry per device that matched AND had at least one new variable); - /// EMPTY when nothing is graftable. - /// - /// Builds normalizedHost → equipmentId from the candidate s - /// that carry a non-null DeviceHost. Two distinct candidates sharing a host is AMBIGUOUS — that host - /// is un-mapped (its nodes are warn-skipped) rather than grafted onto an arbitrary equipment. - /// I1 divergence: a candidate WITHOUT a DeviceHost (e.g. resolved via authored tags only, - /// no device binding) simply gets no partition — the FixedTree is the device's structure, so it - /// belongs under the device-bound equipment. No crash; that candidate is just not a partition target. - /// If NO candidate has a DeviceHost at all there is nothing to partition on ⇒ DEGENERATE ⇒ - /// warn-skip the whole driver (returns empty). - /// A discovered partition whose host is unmatched (or whose node has <2 folder segments, so no - /// host folder) is warn-skipped — its nodes are NOT mis-grafted; the matched partitions still graft. - /// - /// The device-host folder segment AND the stored DeviceHost are both run through the SAME - /// (single source of truth), so they compare equal - /// regardless of case/whitespace. - /// Warn-spam taming. The unmatched/ambiguous/degenerate condition is warned ONCE then - /// Debug-logged on the repeated re-discovery passes (see ). - /// Mid-connect partition shrink. If a later pass yields FEWER device partitions than a - /// prior pass within the same connect, the dropped partition's routes + materialised nodes are NOT - /// actively pruned until the next full redeploy ( Clears + rebuilds - /// the maps). This matches the existing "FixedTree grows-then-stabilises within a connect" assumption — - /// no mid-connect pruning is built here (out of scope). - /// - private Dictionary PartitionDiscoveredByDeviceHost( - DriverInstanceActor.DiscoveredNodesReady msg, - IReadOnlyList equipmentIds, - IReadOnlySet authoredRefs) - { - var driverId = msg.DriverInstanceId; - var candidateSet = equipmentIds.ToHashSet(StringComparer.Ordinal); - - // normalizedHost → equipmentId, from candidate EquipmentNodes that carry a DeviceHost. A host shared by - // two DISTINCT candidates is ambiguous: un-map it (warn-skip) so its nodes aren't grafted arbitrarily. - var hostToEquipment = new Dictionary(StringComparer.Ordinal); - var ambiguousHosts = new HashSet(StringComparer.Ordinal); - foreach (var node in _lastComposition!.EquipmentNodes) - { - if (!candidateSet.Contains(node.EquipmentId) || node.DeviceHost is null) continue; - // DeviceHost is already normalized at compose/decode time; re-normalize through the shared helper so - // the comparison is the single source of truth (idempotent — harmless if it was already normalized). - var host = DeviceConfigIntent.NormalizeHost(node.DeviceHost); - if (ambiguousHosts.Contains(host)) continue; - if (hostToEquipment.TryGetValue(host, out var existing)) - { - if (!string.Equals(existing, node.EquipmentId, StringComparison.Ordinal)) - { - hostToEquipment.Remove(host); - ambiguousHosts.Add(host); - } - continue; - } - hostToEquipment[host] = node.EquipmentId; - } - - // DEGENERATE: >1 candidate but none resolved a DeviceHost ⇒ nothing to partition on ⇒ warn-skip the - // whole driver. (Falls through the same warn-once dedup as the unmatched case.) - if (hostToEquipment.Count == 0 && ambiguousHosts.Count == 0) - { - if (ShouldWarnPartition(driverId, "degenerate")) - _log.Warning("DriverHost {Node}: driver {Driver} maps to {Count} equipments but none has a DeviceHost — discovered-node injection skipped (no device-host to partition on)", - _localNode, driverId, equipmentIds.Count); - else - _log.Debug("DriverHost {Node}: driver {Driver} still has no DeviceHost on any of {Count} equipments — skipped (repeat)", - _localNode, driverId, equipmentIds.Count); - return new Dictionary(StringComparer.Ordinal); - } - - // Partition the captured tree by its device-host folder segment (FolderPathSegments[1]); a node with - // <2 segments has no host folder (null ⇒ unmatched). Keep only nodes whose host matches a candidate. - var matchedNodes = new Dictionary>(StringComparer.Ordinal); - var unmatchedHosts = new HashSet(StringComparer.Ordinal); - foreach (var n in msg.Nodes) - { - var key = n.FolderPathSegments.Count >= 2 - ? DeviceConfigIntent.NormalizeHost(n.FolderPathSegments[1]) - : null; - if (key is not null && hostToEquipment.ContainsKey(key)) - { - if (!matchedNodes.TryGetValue(key, out var list)) - matchedNodes[key] = list = new List(); - list.Add(n); - } - else - { - unmatchedHosts.Add(key ?? "(no-device-host-folder)"); - } - } - - // Map each matched device's subset under its equipment. ONE device per partition ⇒ the mapper collapses - // that partition's single host folder ⇒ clean EQ-n/FOCAS/...; a plan with zero new variables (all - // shadowed by authored refs) contributes no entry. - // NOTE: DiscoveredNodeMapper.Map's collapse predicate compares the host segment with RAW - // StringComparer.Ordinal, whereas we grouped on the NORMALIZED host. Harmless: a real FOCAS device - // emits one consistent HostAddress string per device, so a partition is single-host either way (collapse - // fires). Even if two raw spellings of the same host slipped into one partition, the only effect would be - // a retained (non-collapsed) host folder — never a mis-graft or NodeId collision (the equipment scope - // already isolates them). - var plans = new Dictionary(StringComparer.Ordinal); - foreach (var (host, nodes) in matchedNodes) - { - var equipmentId = hostToEquipment[host]; - var plan = DiscoveredNodeMapper.Map(equipmentId, nodes, authoredRefs); - if (plan.Variables.Count > 0) plans[equipmentId] = plan; - } - - // Surface unmatched/ambiguous hosts ONCE (then Debug on the repeated passes). The matched partitions - // above still graft regardless. When the partition came back fully clean, drop the driver's signature so - // a later recurrence re-warns. - if (unmatchedHosts.Count > 0 || ambiguousHosts.Count > 0) - { - var unmatched = string.Join(",", unmatchedHosts.OrderBy(h => h, StringComparer.Ordinal)); - var ambiguous = string.Join(",", ambiguousHosts.OrderBy(h => h, StringComparer.Ordinal)); - if (ShouldWarnPartition(driverId, "u:" + unmatched + "|a:" + ambiguous)) - _log.Warning("DriverHost {Node}: driver {Driver}: discovered device-host partition(s) skipped — unmatched=[{Unmatched}] ambiguous=[{Ambiguous}]; matched partitions still grafted", - _localNode, driverId, unmatched, ambiguous); - else - _log.Debug("DriverHost {Node}: driver {Driver}: device-host partition(s) still skipped — unmatched=[{Unmatched}] ambiguous=[{Ambiguous}] (repeat)", - _localNode, driverId, unmatched, ambiguous); - } - else - { - _lastPartitionWarnSignature.Remove(driverId); - } - - return plans; - } - - /// Best-effort LOG-LEVEL dedup for the device-host partition diagnostics: returns true (⇒ WARN) - /// when is newly-seen for the driver this revision, false (⇒ DEBUG) on the - /// identical repeat passes that the ~15×/connect re-discovery produces. Folds the current revision in so a - /// redeploy re-warns once. Records the signature as a side effect. Never affects grafting behavior — only - /// the log level — so a stale entry (e.g. after a transient single↔multi candidate flip) at worst demotes - /// one duplicate warn to Debug. - private bool ShouldWarnPartition(string driverId, string conditionKey) - { - var signature = (_currentRevision?.ToString() ?? "none") + "|" + conditionKey; - var isNew = !_lastPartitionWarnSignature.TryGetValue(driverId, out var prev) - || !string.Equals(prev, signature, StringComparison.Ordinal); - _lastPartitionWarnSignature[driverId] = signature; - return isNew; - } - - /// Routing-map equality: same count + every key maps to the same NodeId. Lets - /// skip re-applying an unchanged discovered set across the driver's - /// repeated post-connect re-discovery passes (a grown/changed set differs and re-applies). - private static bool RoutingEquals(IReadOnlyDictionary a, IReadOnlyDictionary b) - => a.Count == b.Count - && a.All(kv => b.TryGetValue(kv.Key, out var v) && string.Equals(v, kv.Value, StringComparison.Ordinal)); - - /// Per-equipment plan-map routing equality: same equipment keys + each equipment's plan has the - /// same (via ). Lets - /// short-circuit a re-discovery whose WHOLE per-driver set is unchanged - /// (a grown/changed set on any equipment differs and re-applies). - private static bool PlansRoutingEqual( - IReadOnlyDictionary a, - IReadOnlyDictionary b) - => a.Count == b.Count - && a.All(kv => b.TryGetValue(kv.Key, out var p) && RoutingEquals(kv.Value.RoutingByRef, p.RoutingByRef)); - - /// - /// Grafts a driver's per-equipment map onto the served state in - /// two phases so the resubscribe stays a single push per driver (the shape the multi-device-partition - /// follow-up needs without resubscribe churn): - /// - /// Materialise per equipment — for each (equipmentId, plan) entry, extend the - /// live-value routing map (mirroring ' fan-out so - /// lands FixedTree values on the right node) and Tell the publish actor - /// for - /// that equipment (idempotent). - /// Subscribe ONCE per driver — compute the union of the driver's authored value refs - /// (recomputed the same way does) and the FixedTree refs of - /// ALL the driver's cached plans, then Tell the child a single - /// so the poll engine reads them and the - /// values flow. For a single-equipment driver this equals the prior per-plan behavior. - /// - /// Extracted as a standalone method so the redeploy re-inject tail can re-apply the cached plans after - /// an address-space rebuild without re-running discovery. - /// - private void ApplyDiscoveredPlansForDriver( - string driverId, IReadOnlyDictionary plansByEquipment) - { - // (a) Per-equipment: extend the live-value routing map (fan-out, mirroring PushDesiredSubscriptions' - // pattern) + materialise the discovered folders + variables under that equipment (idempotent). This is - // purely ADDITIVE across passes: a shrinking discovery set would leave the dropped refs' stale routes - // until the next full apply (PushDesiredSubscriptions) clears + rebuilds the maps — acceptable because - // a FOCAS FixedTree only grows-then-stabilises, never shrinks within a connect. - var totalVariables = 0; - foreach (var (equipmentId, plan) in plansByEquipment) - { - foreach (var (driverRef, nodeId) in plan.RoutingByRef) - { - var key = (driverId, driverRef); - if (!_nodeIdByDriverRef.TryGetValue(key, out var set)) - _nodeIdByDriverRef[key] = set = new HashSet(); - // v3 Batch 4: discovered (FixedTree) nodes graft onto the RAW device subtree, so they route - // through the Raw realm. - set.Add(new NodeRealmRef(nodeId, AddressSpaceRealm.Raw)); - _driverRefByNodeId[(AddressSpaceRealm.Raw, nodeId)] = key; - } - _opcUaPublishActor?.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.MaterialiseDiscoveredNodes( - equipmentId, plan.Folders, plan.Variables)); - totalVariables += plan.Variables.Count; - } - - // (b) ONE subscription push per driver: merge the FixedTree refs from ALL the driver's plans into the - // driver's desired subscription set so the poll engine reads them and ForwardToMux routes the values. - // Recompute the authored value + alarm refs the same way PushDesiredSubscriptions does, then union the - // FixedTree refs onto the value set. Doing the union here (rather than once per plan) means the - // multi-device task adds inner-map entries without changing this single-send shape. - if (!_children.TryGetValue(driverId, out var entry)) return; - // The _lastComposition null-guards below are defensive: HandleDiscoveredNodes already proved it - // non-null, but the redeploy tail also calls this from the PushDesiredSubscriptions tail — keep them - // so that re-apply path can't NRE. - var authoredValueRefs = _lastComposition is null - ? Enumerable.Empty() - : _lastComposition.EquipmentTags - .Where(t => t.Alarm is null && string.Equals(t.DriverInstanceId, driverId, StringComparison.Ordinal)) - .Select(t => t.FullName); - var alarmRefs = _lastComposition is null - ? Array.Empty() - : _lastComposition.EquipmentTags - .Where(t => t.Alarm is not null && string.Equals(t.DriverInstanceId, driverId, StringComparison.Ordinal)) - .Select(t => t.FullName) - .Distinct(StringComparer.Ordinal) - .ToArray(); - var discoveredRefs = plansByEquipment.Values.SelectMany(p => p.RoutingByRef.Keys); - var union = authoredValueRefs.Concat(discoveredRefs).Distinct(StringComparer.Ordinal).ToArray(); - entry.Actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(union, SubscriptionPublishingInterval, alarmRefs)); - - _log.Info("DriverHost {Node}: injected {Count} discovered node(s) for driver {Driver} across {Equipment} equipment(s)", - _localNode, totalVariables, driverId, plansByEquipment.Count); - } - /// /// Routes a native alarm transition (published by a driver child as /// ) to its materialised Part 9 condition @@ -1719,10 +1347,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers Receive(msg => _log.Debug("DriverHost {Node}: dropping native-alarm ack for {Node2} while Stale (config DB unreachable)", _localNode, msg.ConditionNodeId)); - // A driver child's post-connect DiscoveredNodesReady can't be injected while Stale (no composition is - // applied yet, so the equipment can't be resolved). Drop it — the re-discovery loop re-sends it - // and the post-recovery re-apply self-heals it once an apply runs (matches the no-op drops above). - Receive(_ => { }); // A child connectivity transition while the host is Stale has no live address space to annotate — drop // it (the post-recovery rebuild re-materialises conditions, and the child re-announces on its next entry). Receive(_ => { }); @@ -2316,15 +1940,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers } } - // Snapshot the cached (FixedTree-discovered) driver set BEFORE the bulk loop, while _discoveredByDriver - // is still untouched (the re-inject tail below drops/removes entries). Cached drivers are SKIPPED in the - // bulk loop because the tail sends each of them EXACTLY ONE SetDesiredSubscriptions for this pass: the - // authored∪discovered union (ApplyDiscoveredPlansForDriver) for a survivor, or — if its plan is fully - // dropped — an authored-only fallback. Sending the bulk authored-only set HERE too would force the child - // to drop the whole handle (authored tags included) then re-subscribe — an extra unsub/resub blip of the - // authored values once per cached driver per redeploy. Net effect: exactly ONE send per driver per pass. - var cachedDriverIds = _discoveredByDriver.Keys.ToHashSet(StringComparer.Ordinal); - // One authored-only push (value refs + alarm refs from the maps built above), shared by the bulk loop AND // the dropped-driver fallback so the two CANNOT drift: the fallback's correctness depends on sending the // SAME payload the bulk loop would have, so it's a shared helper (structural), not a comment-maintained @@ -2341,9 +1956,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers var total = 0; foreach (var (driverId, entry) in _children) { - // Cached drivers are owned exclusively by the re-inject tail (one send each) — skip here. Non-cached - // drivers keep the bulk authored-only send exactly as before. - if (cachedDriverIds.Contains(driverId)) continue; total += SendAuthoredOnly(entry.Actor, driverId); } @@ -2380,94 +1992,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers _localNode, composition.EquipmentScriptedAlarms.Count); } - // Cache the applied composition LAST so discovered-node injection (HandleDiscoveredNodes) can resolve - // the equipment bound to a driver + recompute the authored subscription sets when a driver later - // reports its FixedTree. Set here (not in ApplyAndAck) so both the fresh-apply and bootstrap-restore - // paths — which both route through this method — leave a current composition. + // Cache the applied composition LAST. Set here (not in ApplyAndAck) so both the fresh-apply and + // bootstrap-restore paths — which both route through this method — leave a current composition. _lastComposition = composition; - // Re-inject discovered (FixedTree) nodes after the authored rebuild. PushDesiredSubscriptions cleared - // _nodeIdByDriverRef and re-pushed authored-only subscriptions above; without this, an IN-PROCESS - // redeploy / re-apply (one that runs while the host is alive, so _discoveredByDriver is populated) - // would drop the injected FixedTree routes + materialised nodes until the driver happens to reconnect - // and re-discover. This loop is INERT on the bootstrap-restore path (RestoreApplied): there the actor - // is freshly constructed so _discoveredByDriver is empty — restart survival comes from the - // post-connect re-discovery loop, NOT this re-apply. Re-resolve each cached driver's candidate equipments - // from the CURRENT composition (the SAME EquipmentNodes-UNION-EquipmentTags logic HandleDiscoveredNodes - // uses), then validate each cached (equipmentId → plan) entry PER ENTRY: drop the entry if its - // equipmentId is no longer a resolved candidate for the driver, OR the plan's NodeIds aren't scoped to - // that equipmentId (a rebind). A driver whose inner map empties out is removed entirely. The surviving - // entries are re-applied via the single-send-per-driver structure. (The single-equipment case today has - // exactly one inner entry; the multi-device task adds more.) - foreach (var driverId in _discoveredByDriver.Keys.ToList()) // snapshot — we mutate the dict below - { - var fromNodes = composition.EquipmentNodes - .Where(e => e.DriverInstanceId is not null && string.Equals(e.DriverInstanceId, driverId, StringComparison.Ordinal)) - .Select(e => e.EquipmentId); - var fromTags = composition.EquipmentTags - .Where(t => string.Equals(t.DriverInstanceId, driverId, StringComparison.Ordinal)) - .Select(t => t.EquipmentId); - var candidates = fromNodes.Concat(fromTags).ToHashSet(StringComparer.Ordinal); - - var plansByEquipment = _discoveredByDriver[driverId]; - // Track whether ANY entry was dropped (no-longer-candidate or rebind) so we can re-trigger this - // driver's discovery exactly ONCE after the inner map is processed (see the post-loop block). - var droppedAny = false; - foreach (var equipmentId in plansByEquipment.Keys.ToList()) // snapshot — we mutate the inner dict - { - var plan = plansByEquipment[equipmentId]; - if (!candidates.Contains(equipmentId)) - { - plansByEquipment.Remove(equipmentId); - droppedAny = true; - _log.Debug("DriverHost {Node}: dropped cached discovered nodes for {Driver}/{Equipment} — equipment no longer resolves", _localNode, driverId, equipmentId); - continue; - } - // If the equipment was rebound (the cached plan's NodeIds are scoped to the OLD equipment), drop + - // let re-discovery rebuild against the new equipment. The plan's NodeIds are "{equipmentId}/...". - var planEquipmentConsistent = plan.Variables.Count > 0 - && plan.Variables[0].NodeId.StartsWith(equipmentId + "/", StringComparison.Ordinal); - if (!planEquipmentConsistent) - { - plansByEquipment.Remove(equipmentId); - droppedAny = true; - _log.Debug("DriverHost {Node}: dropped cached discovered nodes for {Driver}/{Equipment} — equipment rebound", _localNode, driverId, equipmentId); - } - } - - // Re-trigger discovery when ANY entry was dropped (no-longer-candidate or rebind). A CONFIG-UNCHANGED - // rebind (the driver's DriverConfig is identical, only its authored tag's EquipmentId moved) is NOT - // restarted by ReconcileDrivers — the child stays Connected — so without this nudge the FixedTree - // subtree would stay ABSENT under the new equipment until the driver's next natural reconnect. We now - // ask the child to re-run discovery so it re-grafts promptly: the next pass resolves against the new - // _lastComposition (the now-bound equipment). This is a DISCOVERY action, not lifecycle control — no - // stop/restart; it is idempotent, and the child no-ops it if not Connected (handled in - // DriverInstanceActor). Sent at most ONCE per driver per re-inject pass (here, after the inner map is - // processed — so even when the inner map empties below), guarded on the child still existing. - if (droppedAny && _children.TryGetValue(driverId, out var rediscoverEntry)) - rediscoverEntry.Actor.Tell(new DriverInstanceActor.TriggerRediscovery()); - - if (plansByEquipment.Count == 0) - { - _discoveredByDriver.Remove(driverId); - // Drop the driver's partition warn-signature too so a permanently-removed/rebound driver doesn't - // leak a stale entry (log-level-only state; bounded by driver count — just tidiness). - _lastPartitionWarnSignature.Remove(driverId); - // FALLBACK (one-send invariant): this driver was SKIPPED in the bulk loop (it was cached), and its - // plan is now FULLY DROPPED — so ApplyDiscoveredPlansForDriver won't run for it and it would - // otherwise receive ZERO sends this pass, losing its AUTHORED subscriptions. Send the authored-only - // set NOW (the SAME payload the bulk loop computes), so the authored tags subscribe in THIS pass. - // (The TriggerRediscovery above handles the async FixedTree re-graft separately; this just keeps - // the authored values live meanwhile.) Guarded on the child still existing — a driver removed by - // ReconcileDrivers has no child and correctly gets no send. Shares SendAuthoredOnly with the bulk - // loop so the payload can't drift; a ZERO-authored driver sends an empty set → Unsubscribe (drops - // the stale FixedTree handle without a spurious subscribe). - if (_children.TryGetValue(driverId, out var fallbackEntry)) - SendAuthoredOnly(fallbackEntry.Actor, driverId); - continue; - } - ApplyDiscoveredPlansForDriver(driverId, plansByEquipment); - } } private void SpawnChild(DriverInstanceSpec spec) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs index b8a24c08..5fc2139e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs @@ -32,16 +32,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers { public static readonly TimeSpan DefaultReconnectInterval = TimeSpan.FromSeconds(10); - /// Default interval between bounded post-connect re-discovery passes. - public static readonly TimeSpan DefaultRediscoverInterval = TimeSpan.FromSeconds(2); - - /// Default cap on the number of post-connect re-discovery passes. - public const int DefaultRediscoverMaxAttempts = 15; - - /// Default per-pass timeout for during - /// bounded post-connect re-discovery. Bounds the mailbox suspension time; production default 30 s. - public static readonly TimeSpan DefaultRediscoverDiscoverTimeout = TimeSpan.FromSeconds(30); - public sealed record InitializeRequested(string DriverConfigJson); public sealed record InitializeSucceeded(int Generation); public sealed record InitializeFailed(string Reason, int Generation); @@ -124,21 +114,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers /// subscription that un-gates an driver's feed. Handled async so the /// call is bounded + off the synchronous handlers. private sealed record SubscribeAlarms; - /// Published to the parent (DriverHostActor) after each post-connect discovery pass so it can - /// graft the driver's discovered FixedTree nodes under the equipment. Empty/duplicate sets are fine — - /// the parent dedups and injection is idempotent. - public sealed record DiscoveredNodesReady(string DriverInstanceId, IReadOnlyList Nodes); - - /// - /// Sent by to ask this driver child to re-run post-connect discovery - /// after the host rebinds the driver to a new equipment. Handled only in Connected, where it - /// re-kicks — which already honours the driver's - /// and the guard, tagging the - /// fresh pass with the current init generation. In any non-Connected state it is a deliberate no-op: - /// the driver's eventual (re)connect re-discovers anyway, so there is nothing to do and nothing to log. - /// - public sealed record TriggerRediscovery; - /// Self-sent when the wrapped driver raises — /// it observed that the remote's tag set may have changed. Marshals the event off the driver's thread /// onto the actor thread. Handled in EVERY behaviour, including Stubbed and Reconnecting: the raise has no @@ -146,10 +121,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers /// between connects), and dropping it in one state would lose the signal silently. private sealed record RediscoveryRaised(RediscoveryEventArgs Args); - /// Internal self-tick driving bounded post-connect re-discovery (FixedTree populates ~0–2s after connect). - /// is the ordered-distinct full-reference signature of the prior pass's - /// captured set (empty string on the first tick); re-discovery stops once a non-empty set repeats it. - private sealed record RediscoverTick(int Generation, int Attempt, string PreviousSignature); public sealed class RetryConnect { public static readonly RetryConnect Instance = new(); @@ -174,19 +145,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers private readonly IDriverHealthPublisher _healthPublisher; private readonly TimeSpan _reconnectInterval; - /// Interval between bounded post-connect re-discovery passes. Production default 2s; tests - /// inject a tiny value so the loop runs without real-time waits. - private readonly TimeSpan _rediscoverInterval; private readonly TimeSpan _healthPollInterval; - /// Cap on the number of post-connect re-discovery passes — a backstop so a never-stabilising - /// (or perpetually-empty) discovered set cannot spin the loop forever. Production default 15. - private readonly int _rediscoverMaxAttempts; - - /// Per-pass timeout for during bounded post-connect - /// re-discovery. Bounds the mailbox suspension time. Production default 30 s; tests may inject a shorter - /// value. Stored to allow injection rather than hardcoding. - private readonly TimeSpan _rediscoverDiscoverTimeout; private readonly ILoggingAdapter _log = Context.GetLogger(); private string? _currentConfigJson; @@ -252,9 +212,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers /// stub paths don't need to provide one. /// Optional cluster identifier forwarded in messages; /// defaults to an empty string when not provided (e.g. in unit tests). - /// Optional interval between post-connect re-discovery passes; defaults to 2 seconds. - /// Optional cap on re-discovery passes; defaults to 15. - /// Optional per-pass timeout for ; defaults to 30 seconds. /// Optional Phase 6.1 resilience invoker wrapping this driver's capability /// calls; defaults to (pass-through) when not supplied. /// Optional period of the health-poll heartbeat, which also drives the @@ -267,9 +224,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers bool startStubbed = false, IDriverHealthPublisher? healthPublisher = null, string? clusterId = null, - TimeSpan? rediscoverInterval = null, - int rediscoverMaxAttempts = DefaultRediscoverMaxAttempts, - TimeSpan? rediscoverDiscoverTimeout = null, IDriverCapabilityInvoker? invoker = null, TimeSpan? healthPollInterval = null) => Akka.Actor.Props.Create(() => new DriverInstanceActor( @@ -278,9 +232,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers startStubbed, healthPublisher ?? NullDriverHealthPublisher.Instance, clusterId ?? string.Empty, - rediscoverInterval, - rediscoverMaxAttempts, - rediscoverDiscoverTimeout, invoker, healthPollInterval)); @@ -311,9 +262,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers /// If true, start in stub mode for testing or unavailable platforms. /// Sink for health-change notifications; must not be null. /// Cluster identifier forwarded in health snapshots. - /// Interval between post-connect re-discovery passes; defaults to 2 seconds. - /// Cap on the number of re-discovery passes; defaults to 15. - /// Per-pass timeout for ; defaults to 30 seconds. /// Phase 6.1 resilience invoker wrapping this driver's capability calls; /// defaults to (pass-through) when null. /// Period of the health-poll heartbeat, which also drives the @@ -324,9 +272,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers bool startStubbed = false, IDriverHealthPublisher? healthPublisher = null, string? clusterId = null, - TimeSpan? rediscoverInterval = null, - int rediscoverMaxAttempts = DefaultRediscoverMaxAttempts, - TimeSpan? rediscoverDiscoverTimeout = null, IDriverCapabilityInvoker? invoker = null, TimeSpan? healthPollInterval = null) { @@ -336,9 +281,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers _clusterId = clusterId ?? string.Empty; _healthPublisher = healthPublisher ?? NullDriverHealthPublisher.Instance; _reconnectInterval = reconnectInterval; - _rediscoverInterval = rediscoverInterval ?? DefaultRediscoverInterval; - _rediscoverMaxAttempts = rediscoverMaxAttempts; - _rediscoverDiscoverTimeout = rediscoverDiscoverTimeout ?? DefaultRediscoverDiscoverTimeout; _healthPollInterval = healthPollInterval ?? HealthPollInterval; OtOpcUaTelemetry.DriverInstanceLifecycle.Add(1, new KeyValuePair("event", startStubbed ? "spawn_stub" : "spawn"), @@ -382,9 +324,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers Receive(StoreDesiredSubscriptions); // Stubbed drivers never enter Connected, so they never kick discovery; swallow defensively in case a // re-discovery self-tick is ever routed here so it doesn't surface as an Akka Unhandled message. - Receive(_ => { }); - // A TriggerRediscovery is meaningless to a stubbed (never-Connected) driver — silently ignore it. - Receive(_ => { }); Receive(HandleRediscoveryRaised); Receive(_ => PublishHealthSnapshot()); } @@ -411,7 +350,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers ResubscribeDesired(); AttachAlarmSource(); SubscribeDesiredAlarms(); - StartDiscovery(); }); Receive(msg => { @@ -439,12 +377,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers // A SubscribeAlarms self-tell (from Connected) can be overtaken by an already-queued disconnect into // this state; swallow it so it doesn't dead-letter — the next Connected entry re-subscribes. Receive(_ => { }); - // Likewise the attempt-0 re-discovery self-tick (sent on Connected entry) can be overtaken by an - // already-queued disconnect; swallow it — the next Connected entry re-kicks discovery. - Receive(_ => { }); - // A TriggerRediscovery arriving while not Connected is a deliberate no-op — the (re)connect path - // re-runs discovery anyway. Swallow it so it stays a clean silent no-op (no Unhandled event). - Receive(_ => { }); Receive(HandleRediscoveryRaised); Receive(_ => PublishHealthSnapshot()); } @@ -461,7 +393,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers { _log.Warning("DriverInstance {Id}: disconnect observed ({Reason}); reconnecting", _driverInstanceId, msg.Reason); - Timers.Cancel("rediscover"); DetachSubscription(); RecordFault(); Become(Reconnecting); @@ -470,25 +401,10 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers Receive(_ => { _log.Info("DriverInstance {Id}: ForceReconnect requested by admin; re-entering Reconnecting", _driverInstanceId); - Timers.Cancel("rediscover"); DetachSubscription(); Become(Reconnecting); PublishHealthSnapshot(); }); - ReceiveAsync(HandleRediscoverAsync); - // The host asks for a fresh discovery pass after rebinding the driver to a new equipment. Cancel any - // pending rediscover tick FIRST — mirroring ForceReconnect/DisconnectObserved — so a stale tick left - // over from the prior loop can't fire alongside the freshly-kicked one, then re-kick the bounded loop - // via StartDiscovery (honours RediscoverPolicy + the ITagDiscovery guard, tagged with the current - // _initGeneration). Only handled here in Connected — non-Connected states no-op it below. A stale tick - // that still slips through (one already mid-async-handler) is benign: the parent dedups - // DiscoveredNodesReady and node injection is idempotent — the Cancel just avoids the avoidable double - // pass in the common case. - Receive(_ => - { - Timers.Cancel("rediscover"); - StartDiscovery(); - }); ReceiveAsync(HandleWriteAsync); ReceiveAsync(HandleAcknowledgeAsync); ReceiveAsync(HandleSubscribeAsync); @@ -602,7 +518,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers ResubscribeDesired(); AttachAlarmSource(); SubscribeDesiredAlarms(); - StartDiscovery(); // re-run discovery on reconnect — keeps the injected tree fresh if the backend's capabilities changed }); // A failure here is a no-op regardless of generation — the retry timer keeps trying the // current config; only a (generation-matched) InitializeSucceeded transitions state. @@ -625,12 +540,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers // A SubscribeAlarms self-tell (from Connected) can be overtaken by an already-queued disconnect into // this state; swallow it so it doesn't dead-letter — the next Connected entry re-subscribes. Receive(_ => { }); - // Likewise the attempt-0 re-discovery self-tick (sent on Connected entry) can be overtaken by an - // already-queued disconnect; swallow it — the next Connected entry re-kicks discovery. - Receive(_ => { }); - // A TriggerRediscovery arriving while not Connected is a deliberate no-op — the (re)connect path - // re-runs discovery anyway. Swallow it so it stays a clean silent no-op (no Unhandled event). - Receive(_ => { }); Receive(HandleRediscoveryRaised); Receive(_ => PublishHealthSnapshot()); Timers.StartPeriodicTimer("retry-connect", RetryConnect.Instance, _reconnectInterval); @@ -971,97 +880,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers } } - /// Kick the bounded post-connect re-discovery loop on a Connected entry. A no-op unless the - /// driver exposes (nothing to inject otherwise). Self-sends the first - /// tagged with the current init generation so a tick that outlives a reconnect - /// is rejected by the generation guard in . - /// Honours the driver's : Never opts out entirely - /// (no tick scheduled); Once runs a single pass (the loop stops after the first publish in - /// ); UntilStable retries each (re)connect, bounded by - /// stop-on-stable (the discovered-set signature repeats) + the attempt cap. - private void StartDiscovery() - { - if (_driver is not ITagDiscovery discovery) return; // driver doesn't expose discovery — nothing to inject - if (discovery.RediscoverPolicy == DiscoveryRediscoverPolicy.Never) - { - // Driver opts out of post-connect discovery — don't even schedule the first tick. - _log.Debug("DriverInstance {Id}: RediscoverPolicy=Never — skipping post-connect discovery", _driverInstanceId); - return; - } - Self.Tell(new RediscoverTick(_initGeneration, Attempt: 0, PreviousSignature: string.Empty)); - } - - /// Runs one post-connect discovery pass: captures the driver's streamed FixedTree via a - /// and ships the result to the parent as - /// (empty/duplicate sets are fine — the parent dedups and injection - /// is idempotent). Retries on the until the non-empty discovered SET - /// has STABILISED (the ordered-distinct full-reference signature repeats — robust for incremental/paged - /// browsers where a count alone could falsely settle a partial tree) or the - /// cap is hit, whichever comes first; keeps retrying while empty because a FOCAS-style FixedTree cache may - /// still be populating. - /// Limitation: this assumes a driver's discovered set only GROWS toward a stable shape (true for - /// FOCAS — its FixedTree appears once, and on the wonder deploy the driver-config _options.Tags is - /// empty so the set is 0 until the cache populates). A driver that emits an initial non-empty set and - /// later grows could stop early on a transient repeat; acceptable for current scope. - private async Task HandleRediscoverAsync(RediscoverTick tick) - { - if (tick.Generation != _initGeneration) return; // stale (a reconnect superseded this pass) - if (_driver is not ITagDiscovery discovery) return; - - IReadOnlyList nodes; - try - { - var builder = new CapturingAddressSpaceBuilder(); - // Bound the browse — ReceiveAsync suspends the mailbox for the whole handler, so an unbounded - // DiscoverAsync would block DisconnectObserved / ForceReconnect / writes / health-poll behind it. - using var cts = new CancellationTokenSource(_rediscoverDiscoverTimeout); - // NO ConfigureAwait(false) on this outer await: a genuinely-async DiscoverAsync (Galaxy / - // OpcUaClient / TwinCAT) must resume on the actor task scheduler so the Context.Parent.Tell + - // Timers calls below run with a live ActorContext. ConfigureAwait(false) would resume - // off-context and throw NotSupportedException("no active ActorContext"). The invoker's own - // internal ConfigureAwait(false) does NOT propagate to this caller's await — the actor - // continuation still resumes on the captured actor scheduler. (Discover retries per tier.) - await _invoker.ExecuteAsync( - DriverCapability.Discover, - _driverInstanceId, - async ct => await discovery.DiscoverAsync(builder, ct), - cts.Token); - nodes = builder.Nodes.ToArray(); // immutable snapshot — never hand the builder's live list across actors - } - catch (Exception ex) - { - _log.Warning(ex, "DriverInstance {Id}: discovery pass {Attempt} failed; will retry", _driverInstanceId, tick.Attempt); - nodes = Array.Empty(); - } - - // Belt-and-suspenders: under ReceiveAsync the mailbox is suspended for the whole handler, so - // _initGeneration cannot change mid-await — the pre-await guard + Timers.Cancel("rediscover") on - // disconnect + single-timer key reuse are the primary protections. Re-checked in case that changes. - if (tick.Generation != _initGeneration) return; - - Context.Parent.Tell(new DiscoveredNodesReady(_driverInstanceId, nodes)); - - // Honour the driver's re-discovery policy. A Once driver runs a single post-connect pass per - // (re)connect regardless of whether DiscoverAsync is synchronous or async — one published pass is - // complete, so the retry loop is skipped (no further tick scheduled). (Never never reaches here — - // StartDiscovery returns before the first tick.) UntilStable falls through to the stop-on-stable + - // attempt-cap logic below. - if (discovery.RediscoverPolicy == DiscoveryRediscoverPolicy.Once) - { - _log.Debug("DriverInstance {Id}: RediscoverPolicy=Once — single discovery pass, not scheduling another", _driverInstanceId); - return; - } - - // Stop when the non-empty discovered SET has stabilised (its signature repeats), or the attempt cap - // is hit. Keep retrying while empty (a FixedTree cache may still be populating). First tick carries "". - var signature = string.Join('\u0001', - nodes.Select(n => n.FullReference).Distinct(StringComparer.Ordinal).OrderBy(x => x, StringComparer.Ordinal)); - var stableNonEmpty = nodes.Count > 0 && string.Equals(signature, tick.PreviousSignature, StringComparison.Ordinal); - if (tick.Attempt + 1 < _rediscoverMaxAttempts && !stableNonEmpty) - Timers.StartSingleTimer("rediscover", new RediscoverTick(tick.Generation, tick.Attempt + 1, signature), _rediscoverInterval); - else - _log.Debug("DriverInstance {Id}: discovery settled after {Attempt} pass(es), {Count} node(s)", _driverInstanceId, tick.Attempt + 1, nodes.Count); - } /// Records the host's desired subscription set without touching the live subscription. /// The set is (re)applied by on the next Connected entry. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs index 61a0019f..e3125339 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs @@ -85,15 +85,6 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers public sealed record RebuildAddressSpace( CorrelationId Correlation, DeploymentId? DeploymentId = null, byte[]? Artifact = null); - /// Inject driver-discovered nodes (FixedTree) under an equipment at runtime (post-connect). - /// The OPC UA NodeId of the equipment root folder to inject the - /// discovered nodes under (e.g. "EQ-3686c0272279"); also the node the NodeAdded model-change is - /// announced under. - public sealed record MaterialiseDiscoveredNodes( - string EquipmentRootNodeId, - IReadOnlyList Folders, - IReadOnlyList Variables); - public sealed record ServiceLevelChanged(byte ServiceLevel); private readonly IOpcUaAddressSpaceSink _sink; @@ -284,7 +275,6 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers Receive(HandleAlarmUpdate); Receive(HandleAlarmQualityUpdate); Receive(HandleRebuild); - Receive(HandleMaterialiseDiscovered); Receive(HandleServiceLevelChanged); Receive(HandleRedundancyStateChanged); Receive(HandleDbHealthStatus); @@ -539,28 +529,6 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers } } - /// Forwards driver-discovered (FixedTree) nodes to the applier so they are injected under - /// the equipment at runtime. No-op (logged) when no applier is wired (dev/Mac/legacy seam), matching the - /// optional-applier tolerance of . - private void HandleMaterialiseDiscovered(MaterialiseDiscoveredNodes msg) - { - if (_applier is null) - { - _log.Debug("OpcUaPublish: no applier wired — discarding MaterialiseDiscoveredNodes for {Equipment}", msg.EquipmentRootNodeId); - return; - } - var failedNodes = _applier.MaterialiseDiscoveredNodes(msg.EquipmentRootNodeId, msg.Folders, msg.Variables); - if (failedNodes > 0) - { - // archreview 01/S-1: a swallowed discovered-node injection failure surfaces at Error + the - // dedicated meter instead of vanishing into per-node Warnings. - OtOpcUaTelemetry.OpcUaApplyFailed.Add(1, new KeyValuePair("kind", "nodes")); - _log.Error( - "OpcUaPublish: discovered-node injection DEGRADED for {Equipment} (failedNodes={FailedNodes}) — some discovered nodes may be missing", - msg.EquipmentRootNodeId, failedNodes); - } - } - private void HandleServiceLevelChanged(ServiceLevelChanged msg) { // Always publish the FIRST computed level, even if it equals the byte-default 0. Otherwise a diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs index 7bcfe1ee..b2b970e3 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs @@ -94,11 +94,6 @@ public sealed class AddressSpaceApplierFailureSurfaceTests applier.MaterialiseEquipmentTags(tags).ShouldBe(0); applier.MaterialiseEquipmentVirtualTags(vtags).ShouldBe(0); applier.MaterialiseScriptedAlarms(alarms).ShouldBe(0); - applier.MaterialiseDiscoveredNodes( - "eq-1", - Array.Empty(), - new[] { new DiscoveredVariable("eq-1/D", "eq-1", "D", "Float", Writable: false, IsArray: false, ArrayLength: null) }) - .ShouldBe(0); } // ---------------- fixtures ---------------- diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs index ddbf81bd..a25562e0 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs @@ -8,679 +8,9 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; public sealed class AddressSpaceApplierTests { - /// Verifies that an empty plan does not call the sink or trigger a rebuild. - [Fact] - public void Empty_plan_does_not_call_sink_and_does_not_trigger_rebuild() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - var outcome = applier.Apply(EmptyPlan); - outcome.RebuildCalled.ShouldBeFalse(); - outcome.AddedNodes.ShouldBe(0); - outcome.RemovedNodes.ShouldBe(0); - outcome.ChangedNodes.ShouldBe(0); - sink.RebuildCalls.ShouldBe(0); - sink.AlarmWrites.ShouldBeEmpty(); - } - /// R2-07 T11 — removed equipment is a PureRemove: the applier writes each id's terminal - /// "no-event" condition state (top-of-Apply block) then tears down each equipment SUBTREE in place via - /// RemoveEquipmentSubtree — NO full rebuild (other clients' subscriptions survive). (Supersedes the - /// pre-R2-07 "removed equipment ⇒ rebuild" pin.) - [Fact] - public void Removed_equipment_writes_terminal_state_and_removes_subtree_without_rebuild() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var plan = WithEquipmentRemoval("eq-1", "eq-2"); - var outcome = applier.Apply(plan); - - outcome.RemovedNodes.ShouldBe(2); - outcome.RebuildCalled.ShouldBeFalse(); - sink.RebuildCalls.ShouldBe(0); - // Terminal "no-event" condition state written per id (inactive + acked + confirmed). - sink.AlarmWrites.Select(a => a.NodeId).OrderBy(x => x).ShouldBe(new[] { "eq-1", "eq-2" }); - sink.AlarmWrites.All(a => !a.State.Active && a.State.Acknowledged && a.State.Confirmed).ShouldBeTrue(); - // Each equipment torn down as a subtree. - sink.RemoveCalls.OrderBy(x => x.NodeId).ShouldBe(new[] { ("equipment", "eq-1"), ("equipment", "eq-2") }); - } - - /// R2-07 T2 — added equipment is a PureAdd: the applier SKIPS the rebuild (the idempotent - /// Materialise passes create the new folder; existing client subscriptions survive) and writes no alarm - /// state. (Supersedes the pre-R2-07 "added equipment ⇒ rebuild" pin.) - [Fact] - public void Added_equipment_is_pure_add_and_skips_rebuild() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var plan = new AddressSpacePlan( - AddedEquipment: new[] { new EquipmentNode("new", "New", "line-1") }, - RemovedEquipment: Array.Empty(), - ChangedEquipment: Array.Empty(), - AddedDrivers: Array.Empty(), - RemovedDrivers: Array.Empty(), - ChangedDrivers: Array.Empty(), - AddedAlarms: Array.Empty(), - RemovedAlarms: Array.Empty(), - ChangedAlarms: Array.Empty()); - - var outcome = applier.Apply(plan); - - outcome.RebuildCalled.ShouldBeFalse(); // PureAdd — no teardown, subscriptions preserved - outcome.AddedNodes.ShouldBe(1); - sink.AlarmWrites.ShouldBeEmpty(); - sink.RebuildCalls.ShouldBe(0); - } - - /// Verifies that driver-only changes do not trigger address space rebuild. - [Fact] - public void Driver_only_changes_do_not_trigger_address_space_rebuild() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var plan = new AddressSpacePlan( - AddedEquipment: Array.Empty(), - RemovedEquipment: Array.Empty(), - ChangedEquipment: Array.Empty(), - AddedDrivers: new[] { new DriverInstancePlan("d-new", "Modbus", "{}") }, - RemovedDrivers: Array.Empty(), - ChangedDrivers: new[] - { - new AddressSpacePlan.DriverDelta( - new DriverInstancePlan("d-1", "Modbus", "{\"v\":1}"), - new DriverInstancePlan("d-1", "Modbus", "{\"v\":2}")), - }, - AddedAlarms: Array.Empty(), - RemovedAlarms: Array.Empty(), - ChangedAlarms: Array.Empty()); - - var outcome = applier.Apply(plan); - - outcome.RebuildCalled.ShouldBeFalse(); - sink.RebuildCalls.ShouldBe(0); - } - - /// Verifies that sink exceptions in WriteAlarmCondition do not propagate and rebuild still fires. - [Fact] - public void Sink_exception_in_WriteAlarmCondition_does_not_propagate_and_rebuild_still_fires() - { - var sink = new ThrowingSink(throwOnAlarmWrite: true); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var plan = WithEquipmentRemoval("eq-1"); - - var outcome = applier.Apply(plan); // should not throw - outcome.RemovedNodes.ShouldBe(1); - outcome.RebuildCalled.ShouldBeTrue(); - } - - /// Verifies MaterialiseEquipmentTags creates one Variable per equipment tag directly - /// under its existing equipment folder, with a folder-scoped NodeId (parent/Name — NOT the raw - /// FullName), parent == EquipmentId, displayName == Name, and does NOT re-create the equipment - /// folder (decision #4). - [Fact] - public void MaterialiseEquipmentTags_creates_variable_under_equipment_folder() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var composition = new AddressSpaceComposition( - UnsAreas: Array.Empty(), - UnsLines: Array.Empty(), - EquipmentNodes: Array.Empty(), - DriverInstancePlans: Array.Empty(), - ScriptedAlarmPlans: Array.Empty()) - { - EquipmentTags = new[] - { - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null), - }, - }; - - applier.MaterialiseEquipmentTags(composition); - - sink.FolderCalls.ShouldBeEmpty(); // equipment folder already exists; no sub-folder needed - // A ReadWrite plan threads Writable: true through the applier to the sink (the node is created CurrentReadWrite). - sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Speed", "eq-1", "Speed", "Float", true)); - // Parity: the materialiser's NodeId is the shared EquipmentNodeIds formula (null/empty FolderPath). - sink.VariableCalls.Single().NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed")); - } - - /// Verifies a FolderPath on an equipment tag becomes a sub-folder UNDER the equipment - /// folder (not the namespace root), with the variable parented to that sub-folder and a - /// folder-scoped NodeId. - [Fact] - public void MaterialiseEquipmentTags_nests_FolderPath_subfolder_under_equipment() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var composition = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentTags = new[] - { - new EquipmentTagPlan("tag-2", "eq-1", "drv", FolderPath: "Diagnostics", Name: "Temp", DataType: "Float", FullName: "40002", Writable: false, Alarm: null), - }, - }; - - applier.MaterialiseEquipmentTags(composition); - - sink.FolderCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics", "eq-1", "Diagnostics")); - // A Read plan threads Writable: false (the node stays CurrentRead). - sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics/Temp", "eq-1/Diagnostics", "Temp", "Float", false)); - // Parity: the materialiser's NodeId is the shared EquipmentNodeIds formula (with FolderPath). - sink.VariableCalls.Single().NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Diagnostics", "Temp")); - } - - /// Regression for the FullName-as-NodeId collision: two identical machines exposing the - /// SAME driver FullName (e.g. Modbus register 40001) must produce TWO distinct variables — one - /// under each equipment folder — because the NodeId is folder-scoped, not the raw FullName. - [Fact] - public void MaterialiseEquipmentTags_identical_FullName_across_two_equipments_does_not_collide() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var composition = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentTags = new[] - { - new EquipmentTagPlan("tag-a", "eq-1", "drv-1", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), - new EquipmentTagPlan("tag-b", "eq-2", "drv-2", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), - }, - }; - - applier.MaterialiseEquipmentTags(composition); - - sink.VariableCalls.Count.ShouldBe(2); - sink.VariableCalls.ShouldContain(("eq-1/Speed", "eq-1", "Speed", "Float", false)); - sink.VariableCalls.ShouldContain(("eq-2/Speed", "eq-2", "Speed", "Float", false)); - } - - /// Phase B WS-3 — an alarm-bearing equipment tag (Alarm is not null) materialises a - /// real OPC UA Part 9 condition node (via the same path scripted alarms use) instead of a value - /// variable; a plain tag (Alarm == null) stays a value variable. The alarm tag's condition - /// uses the tag's folder-scoped NodeId, the equipment folder as parent, and carries the tag's - /// AlarmType/Severity. Proves BOTH branches in one composition. - [Fact] - public void MaterialiseEquipmentTags_alarm_bearing_tag_becomes_condition_plain_tag_stays_variable() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var composition = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentTags = new[] - { - new EquipmentTagPlan("tag-plain", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null), - new EquipmentTagPlan("tag-alarm", "eq-1", "drv", FolderPath: "", Name: "OverTemp", DataType: "Boolean", FullName: "00001", Writable: false, Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 700)), - }, - }; - - applier.MaterialiseEquipmentTags(composition); - - // The plain tag drove EnsureVariable at its folder-scoped NodeId, and NOT a condition. - var plainNodeId = V3NodeIds.Uns("eq-1", "Speed"); - sink.VariableCalls.ShouldHaveSingleItem().ShouldBe((plainNodeId, "eq-1", "Speed", "Float", true)); - sink.AlarmConditionCalls.ShouldNotContain(c => c.AlarmNodeId == plainNodeId); - - // The alarm tag drove MaterialiseAlarmCondition (folder-scoped NodeId, equipment parent, - // matching display/type/severity) and did NOT drive EnsureVariable. - var alarmNodeId = V3NodeIds.Uns("eq-1", "OverTemp"); - // A native equipment-tag alarm: the call-site threads isNative: true. - sink.AlarmConditionCalls.ShouldHaveSingleItem() - .ShouldBe((alarmNodeId, "eq-1", "OverTemp", "OffNormalAlarm", 700, true)); - sink.VariableCalls.ShouldNotContain(v => v.NodeId == alarmNodeId); - } - - /// Phase B WS-3 — an alarm-bearing equipment tag WITH a FolderPath still gets its - /// sub-folder created, and its condition is parented to that sub-folder (not the equipment folder), - /// using the folder-scoped NodeId. - [Fact] - public void MaterialiseEquipmentTags_alarm_bearing_tag_with_FolderPath_conditions_under_subfolder() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var composition = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentTags = new[] - { - new EquipmentTagPlan("tag-alarm", "eq-1", "drv", FolderPath: "Diagnostics", Name: "OverTemp", DataType: "Boolean", FullName: "00001", Writable: false, Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 500)), - }, - }; - - applier.MaterialiseEquipmentTags(composition); - - // The sub-folder is still created for an alarm tag with a FolderPath. - sink.FolderCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics", "eq-1", "Diagnostics")); - // Condition is parented to the sub-folder, with the folder-scoped NodeId. No value variable. - var alarmNodeId = V3NodeIds.Uns("eq-1", "Diagnostics", "OverTemp"); - // A native equipment-tag alarm (with a FolderPath): the call-site still threads isNative: true. - sink.AlarmConditionCalls.ShouldHaveSingleItem() - .ShouldBe((alarmNodeId, "eq-1/Diagnostics", "OverTemp", "OffNormalAlarm", 500, true)); - sink.VariableCalls.ShouldBeEmpty(); - } - - /// Phase C Task 2 — the applier resolves the historian tagname per value tag and threads it - /// to EnsureVariable: a historized tag with NO override falls back to its FullName; a - /// historized tag WITH an override passes the override verbatim; a non-historized tag passes null. - [Fact] - public void MaterialiseEquipmentTags_resolves_historian_tagname_default_override_and_null() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var composition = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentTags = new[] - { - // Historized, no override ⇒ tagname defaults to FullName ("T.A"). - new EquipmentTagPlan("tag-def", "eq-1", "drv", FolderPath: "", Name: "ADefault", DataType: "Float", - FullName: "T.A", Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: null), - // Historized, override ⇒ tagname is the override ("WW.Override"), NOT FullName. - new EquipmentTagPlan("tag-ovr", "eq-1", "drv", FolderPath: "", Name: "BOverride", DataType: "Float", - FullName: "T.B", Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: "WW.Override"), - // Not historized ⇒ tagname is null. - new EquipmentTagPlan("tag-no", "eq-1", "drv", FolderPath: "", Name: "CPlain", DataType: "Float", - FullName: "T.C", Writable: false, Alarm: null, IsHistorized: false, HistorianTagname: null), - }, - }; - - applier.MaterialiseEquipmentTags(composition); - - var byNode = sink.HistorianCalls.ToDictionary(c => c.NodeId, c => c.HistorianTagname); - byNode[V3NodeIds.Uns("eq-1", "ADefault")].ShouldBe("T.A"); // default ⇒ FullName - byNode[V3NodeIds.Uns("eq-1", "BOverride")].ShouldBe("WW.Override"); // override verbatim - byNode[V3NodeIds.Uns("eq-1", "CPlain")].ShouldBeNull(); // not historized ⇒ null - } - - /// Phase C Task 2 — a historized tag whose override is blank/whitespace still falls back to - /// FullName (the resolve uses string.IsNullOrWhiteSpace, not just null). - [Fact] - public void MaterialiseEquipmentTags_blank_override_falls_back_to_full_name() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var composition = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentTags = new[] - { - new EquipmentTagPlan("tag-blank", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", - FullName: "40001", Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: " "), - }, - }; - - applier.MaterialiseEquipmentTags(composition); - - var call = sink.HistorianCalls.ShouldHaveSingleItem(); - call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed")); - call.HistorianTagname.ShouldBe("40001"); - } - - /// Array-support Task 2 — an with IsArray: true, - /// ArrayLength: 16 flowing through must - /// forward BOTH flags verbatim to the sink's EnsureVariable. Guards against arg-order swaps or - /// accidental drops in the wire-through. - [Fact] - public void MaterialiseEquipmentTags_array_plan_forwards_isArray_and_arrayLength_to_sink() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var composition = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentTags = new[] - { - new EquipmentTagPlan("tag-arr", "eq-1", "drv", FolderPath: "", Name: "Buffer", DataType: "Int16", - FullName: "40001", Writable: false, Alarm: null, IsArray: true, ArrayLength: 16u), - }, - }; - - applier.MaterialiseEquipmentTags(composition); - - var call = sink.ArrayCalls.ShouldHaveSingleItem(); - call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Buffer")); - call.IsArray.ShouldBeTrue(); - call.ArrayLength.ShouldBe(16u); - } - - /// Array-support Task 2 — a scalar (IsArray: false, - /// ArrayLength: null) must pass isArray == false through to the sink. Guards against a - /// default flip that would silently materialise scalar tags as 1-D arrays. - [Fact] - public void MaterialiseEquipmentTags_scalar_plan_forwards_isArray_false_to_sink() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var composition = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentTags = new[] - { - new EquipmentTagPlan("tag-scalar", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", - FullName: "40002", Writable: false, Alarm: null, IsArray: false, ArrayLength: null), - }, - }; - - applier.MaterialiseEquipmentTags(composition); - - var call = sink.ArrayCalls.ShouldHaveSingleItem(); - call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed")); - call.IsArray.ShouldBeFalse(); - call.ArrayLength.ShouldBeNull(); - } - - /// Review M-1 — an array equipment tag authored with Writable: true must be - /// materialised as READ-ONLY (writable == false) because array writes are out of scope - /// (Phase 4c read-only surface). The driver write path does not handle arrays and would crash - /// (e.g. S7 BoxValueForWrite). Guards against a future refactor that accidentally enables the - /// writable path for arrays. - [Fact] - public void MaterialiseEquipmentTags_array_writable_true_is_forced_read_only() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var composition = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentTags = new[] - { - // Authored ReadWrite AND IsArray — the applier must clamp to read-only. - new EquipmentTagPlan("tag-arr-rw", "eq-1", "drv", FolderPath: "", Name: "Buffer", DataType: "Int16", - FullName: "40001", Writable: true, Alarm: null, IsArray: true, ArrayLength: 8u), - }, - }; - - applier.MaterialiseEquipmentTags(composition); - - // writable must be false (array writes out of scope), isArray must be true (forwarded verbatim). - var varCall = sink.VariableCalls.ShouldHaveSingleItem(); - varCall.Writable.ShouldBeFalse(); // clamped to read-only despite Writable: true - var arrCall = sink.ArrayCalls.ShouldHaveSingleItem(); - arrCall.IsArray.ShouldBeTrue(); - arrCall.ArrayLength.ShouldBe(8u); - } - - /// Review M-1 regression — a scalar tag authored with Writable: true must still - /// be materialised as read/write (writable == true). The array-clamp must NOT affect - /// scalar tags. - [Fact] - public void MaterialiseEquipmentTags_scalar_writable_true_stays_writable() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var composition = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentTags = new[] - { - // Authored ReadWrite, scalar — must pass through writable: true unchanged. - new EquipmentTagPlan("tag-scalar-rw", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", - FullName: "40002", Writable: true, Alarm: null, IsArray: false, ArrayLength: null), - }, - }; - - applier.MaterialiseEquipmentTags(composition); - - var varCall = sink.VariableCalls.ShouldHaveSingleItem(); - varCall.Writable.ShouldBeTrue(); // scalar: writable unchanged - var arrCall = sink.ArrayCalls.ShouldHaveSingleItem(); - arrCall.IsArray.ShouldBeFalse(); - } - - /// Verifies MaterialiseEquipmentVirtualTags creates one Variable per VirtualTag directly - /// under its existing equipment folder, with a folder-scoped NodeId (EquipmentId/Name — NOT the - /// VirtualTagId or Expression), parent == EquipmentId, displayName == Name, and does NOT re-create - /// the equipment folder (no sub-folder when FolderPath is empty). - [Fact] - public void MaterialiseEquipmentVirtualTags_creates_variable_under_equipment_folder() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var composition = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentVirtualTags = new[] - { - new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "speed-rpm", DataType: "Float64", - Expression: "ctx.GetTag(\"x\") * 60", DependencyRefs: new[] { "x" }), - }, - }; - - applier.MaterialiseEquipmentVirtualTags(composition); - - sink.FolderCalls.ShouldBeEmpty(); // equipment folder already exists; no sub-folder needed - // VirtualTags are computed outputs — always read-only (Writable: false). - sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/speed-rpm", "eq-1", "speed-rpm", "Float64", false)); - // Parity: the vtag materialiser's NodeId is the shared EquipmentNodeIds formula. - sink.VariableCalls.Single().NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "speed-rpm")); - } - - /// Golden/parity guard: the materialiser's Variable NodeId for BOTH the equipment-tag and - /// the equipment-VirtualTag pass is byte-identical to V3NodeIds.Uns — the - /// single source of truth AddressSpaceApplier + VirtualTagHostActor both point at. Covers null/empty - /// FolderPath (directly under equipment) and a non-empty FolderPath (sub-folder scoped). This test - /// LOCKS the formula against drift: any change to the materialiser NodeId that diverges from the - /// shared helper fails here. - [Fact] - public void Materialised_variable_node_ids_match_shared_EquipmentNodeIds_formula() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var composition = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentTags = new[] - { - new EquipmentTagPlan("tag-flat", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), - new EquipmentTagPlan("tag-nested", "eq-1", "drv", FolderPath: "Diagnostics", Name: "Temp", DataType: "Float", FullName: "40002", Writable: false, Alarm: null), - }, - EquipmentVirtualTags = new[] - { - new EquipmentVirtualTagPlan("vt-flat", "eq-2", FolderPath: "", Name: "Efficiency", DataType: "Float64", - Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }), - new EquipmentVirtualTagPlan("vt-nested", "eq-2", FolderPath: "Calc", Name: "Avg", DataType: "Float64", - Expression: "ctx.GetTag(\"b\")", DependencyRefs: new[] { "b" }), - }, - }; - - applier.MaterialiseEquipmentTags(composition); - applier.MaterialiseEquipmentVirtualTags(composition); - - var nodeIds = sink.VariableCalls.Select(v => v.NodeId).ToList(); - nodeIds.ShouldContain(V3NodeIds.Uns("eq-1", "Speed")); - nodeIds.ShouldContain(V3NodeIds.Uns("eq-1", "Diagnostics", "Temp")); - nodeIds.ShouldContain(V3NodeIds.Uns("eq-2", "Efficiency")); - nodeIds.ShouldContain(V3NodeIds.Uns("eq-2", "Calc", "Avg")); - } - - /// Two VirtualTags under the SAME equipment produce two distinct folder-scoped variables - /// (one EnsureVariable each, no NodeId collision), parented to the equipment folder. - [Fact] - public void MaterialiseEquipmentVirtualTags_two_under_same_equipment_do_not_collide() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var composition = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentVirtualTags = new[] - { - new EquipmentVirtualTagPlan("vt-a", "eq-1", FolderPath: "", Name: "speed-rpm", DataType: "Float64", - Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }), - new EquipmentVirtualTagPlan("vt-b", "eq-1", FolderPath: "", Name: "load-pct", DataType: "Float64", - Expression: "ctx.GetTag(\"b\")", DependencyRefs: new[] { "b" }), - }, - }; - - applier.MaterialiseEquipmentVirtualTags(composition); - - sink.FolderCalls.ShouldBeEmpty(); - sink.VariableCalls.Count.ShouldBe(2); - sink.VariableCalls.ShouldContain(("eq-1/speed-rpm", "eq-1", "speed-rpm", "Float64", false)); - sink.VariableCalls.ShouldContain(("eq-1/load-pct", "eq-1", "load-pct", "Float64", false)); - } - - /// T14 — MaterialiseScriptedAlarms materialises one condition per ENABLED alarm (keyed by - /// ScriptedAlarmId, parented to its EquipmentId, carrying Name/AlarmType/Severity) and SKIPS - /// disabled alarms. - [Fact] - public void MaterialiseScriptedAlarms_materialises_enabled_and_skips_disabled() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var composition = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentScriptedAlarms = new[] - { - new EquipmentScriptedAlarmPlan( - ScriptedAlarmId: "alm-1", EquipmentId: "eq-1", Name: "HighTemp", AlarmType: "OffNormalAlarm", - Severity: 700, MessageTemplate: "Temp high", PredicateScriptId: "scr-1", PredicateSource: "return true;", - DependencyRefs: Array.Empty(), HistorizeToAveva: false, Retain: true, Enabled: true), - new EquipmentScriptedAlarmPlan( - ScriptedAlarmId: "alm-2", EquipmentId: "eq-2", Name: "LowFlow", AlarmType: "AlarmCondition", - Severity: 300, MessageTemplate: "Flow low", PredicateScriptId: "scr-2", PredicateSource: "return false;", - DependencyRefs: Array.Empty(), HistorizeToAveva: false, Retain: true, Enabled: false), - }, - }; - - applier.MaterialiseScriptedAlarms(composition); - - // Only the enabled alarm is materialised; the disabled one is skipped entirely. - // A SCRIPTED alarm: the call-site threads isNative: false (guards against a native/scripted swap). - sink.AlarmConditionCalls.ShouldHaveSingleItem() - .ShouldBe(("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, false)); - } - - /// Task 4 — MaterialiseDiscoveredNodes ensures the discovered folders PARENT-FIRST (ordered by - /// depth = '/' count) and the discovered variables at their folder-scoped NodeIds/parents, with variables - /// created READ-ONLY (writable == false), then raises EXACTLY ONE NodeAdded model-change under the - /// equipment root. Folders are passed in REVERSE (child-first) to prove the applier re-orders them - /// parent-first before ensuring (a child folder's parent must exist first). - [Fact] - public void MaterialiseDiscoveredNodes_ensures_folders_parent_first_read_only_variables_and_raises_model_change_once() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - // Child folder listed BEFORE its parent — the applier must re-order parent-first. - var folders = new[] - { - new DiscoveredFolder("EQ-1/FOCAS/Identity", "EQ-1/FOCAS", "Identity"), - new DiscoveredFolder("EQ-1/FOCAS", "EQ-1", "FOCAS"), - }; - var variables = new[] - { - new DiscoveredVariable("EQ-1/FOCAS/Identity/SeriesNumber", "EQ-1/FOCAS/Identity", "SeriesNumber", - "String", Writable: false, IsArray: false, ArrayLength: null), - }; - - applier.MaterialiseDiscoveredNodes("EQ-1", folders, variables); - - // Folders ensured parent-first regardless of input order (shallowest depth first). - sink.FolderCalls.Select(f => f.NodeId).ShouldBe(new[] { "EQ-1/FOCAS", "EQ-1/FOCAS/Identity" }); - sink.FolderCalls.ShouldContain(("EQ-1/FOCAS", "EQ-1", "FOCAS")); - sink.FolderCalls.ShouldContain(("EQ-1/FOCAS/Identity", "EQ-1/FOCAS", "Identity")); - - // Variable ensured at its folder-scoped NodeId, parented to its sub-folder, READ-ONLY. - sink.VariableCalls.ShouldHaveSingleItem() - .ShouldBe(("EQ-1/FOCAS/Identity/SeriesNumber", "EQ-1/FOCAS/Identity", "SeriesNumber", "String", false)); - - // Exactly one NodeAdded model-change, announced under the equipment root. - sink.ModelChangeCalls.ShouldHaveSingleItem().ShouldBe("EQ-1"); - } - - /// Task 4 — a discovered array variable (rare) authored Writable: true is forced - /// READ-ONLY (mirrors MaterialiseEquipmentTags: the driver write path can't handle arrays), while the - /// IsArray / ArrayLength flags are forwarded verbatim to the sink. - [Fact] - public void MaterialiseDiscoveredNodes_array_variable_is_forced_read_only() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var variables = new[] - { - new DiscoveredVariable("EQ-1/FOCAS/Buffer", "EQ-1", "Buffer", "Int16", - Writable: true, IsArray: true, ArrayLength: 8u), - }; - - applier.MaterialiseDiscoveredNodes("EQ-1", Array.Empty(), variables); - - var varCall = sink.VariableCalls.ShouldHaveSingleItem(); - varCall.Writable.ShouldBeFalse(); // clamped to read-only despite Writable: true - var arrCall = sink.ArrayCalls.ShouldHaveSingleItem(); - arrCall.IsArray.ShouldBeTrue(); - arrCall.ArrayLength.ShouldBe(8u); - } - - /// Task 4 — re-applying the SAME discovered plan is idempotent-SAFE: it does not throw, the - /// distinct folder/variable set the applier issues per pass is stable (the real sink early-returns on - /// existing nodes), and a model-change is raised once PER call (twice across two calls). - [Fact] - public void MaterialiseDiscoveredNodes_is_idempotent_safe_on_repeated_application() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var folders = new[] - { - new DiscoveredFolder("EQ-1/FOCAS", "EQ-1", "FOCAS"), - new DiscoveredFolder("EQ-1/FOCAS/Identity", "EQ-1/FOCAS", "Identity"), - }; - var variables = new[] - { - new DiscoveredVariable("EQ-1/FOCAS/Identity/SeriesNumber", "EQ-1/FOCAS/Identity", "SeriesNumber", - "String", Writable: false, IsArray: false, ArrayLength: null), - }; - - applier.MaterialiseDiscoveredNodes("EQ-1", folders, variables); - Should.NotThrow(() => applier.MaterialiseDiscoveredNodes("EQ-1", folders, variables)); - - // Each pass re-issues the same parent-first ensures (the real sink dedups via early-return); the - // DISTINCT set the applier produces is stable across re-applies. - sink.FolderCalls.Select(f => f.NodeId).Distinct().ShouldBe(new[] { "EQ-1/FOCAS", "EQ-1/FOCAS/Identity" }); - sink.VariableCalls.Select(v => v.NodeId).Distinct().ShouldBe(new[] { "EQ-1/FOCAS/Identity/SeriesNumber" }); - // One model-change per call ⇒ two across two calls. - sink.ModelChangeCalls.ShouldBe(new[] { "EQ-1", "EQ-1" }); - } - - /// Task 4 — empty input (no folders, no variables) returns WITHOUT touching the sink: no - /// EnsureFolder/EnsureVariable and, crucially, NO NodeAdded model-change. - [Fact] - public void MaterialiseDiscoveredNodes_empty_input_does_not_touch_sink() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - applier.MaterialiseDiscoveredNodes("EQ-1", Array.Empty(), Array.Empty()); - - sink.FolderCalls.ShouldBeEmpty(); - sink.VariableCalls.ShouldBeEmpty(); - sink.ModelChangeCalls.ShouldBeEmpty(); - } /// R2-07 T2 — added equipment tags in an otherwise-empty plan are a PureAdd: the applier /// SKIPS the rebuild (the idempotent Materialise passes create the new variables; existing subscriptions diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/CapturingAddressSpaceBuilderTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/CapturingAddressSpaceBuilderTests.cs deleted file mode 100644 index f1da0dfd..00000000 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/CapturingAddressSpaceBuilderTests.cs +++ /dev/null @@ -1,44 +0,0 @@ -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; -using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; -using Shouldly; -using Xunit; - -namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; - -[Trait("Category", "Unit")] -public sealed class CapturingAddressSpaceBuilderTests -{ - [Fact] - public void Records_nested_path_segments_full_reference_and_metadata() - { - var b = new CapturingAddressSpaceBuilder(); - var focas = b.Folder("FOCAS", "FOCAS"); - var device = focas.Folder("10.0.0.5:8193", "cnc"); - var identity = device.Folder("Identity", "Identity"); - identity.Variable("SeriesNumber", "SeriesNumber", new DriverAttributeInfo( - FullName: "10.0.0.5:8193/Identity/SeriesNumber", - DriverDataType: DriverDataType.String, IsArray: false, ArrayDim: null, - SecurityClass: SecurityClassification.ViewOnly, IsHistorized: false)); - - b.Nodes.Count.ShouldBe(1); - var n = b.Nodes[0]; - n.FolderPathSegments.ShouldBe(new[] { "FOCAS", "10.0.0.5:8193", "Identity" }); - n.BrowseName.ShouldBe("SeriesNumber"); - n.FullReference.ShouldBe("10.0.0.5:8193/Identity/SeriesNumber"); - n.DataType.ShouldBe(DriverDataType.String); - n.Writable.ShouldBeFalse(); // ViewOnly -> read-only - } - - [Fact] - public void AddProperty_is_ignored_and_alarm_marking_is_a_noop_sink() - { - var b = new CapturingAddressSpaceBuilder(); - var f = b.Folder("FOCAS", "FOCAS"); - f.AddProperty("Manufacturer", DriverDataType.String, "FANUC"); // ignored, no throw - var h = f.Variable("V", "V", new DriverAttributeInfo("ref", DriverDataType.Int32, false, null, - SecurityClassification.ViewOnly, false, IsAlarm: true)); - var sink = h.MarkAsAlarmCondition(new AlarmConditionInfo("src", AlarmSeverity.Low, null)); - sink.ShouldNotBeNull(); // no-op sink, alarms out of scope - b.Nodes.Count.ShouldBe(1); - } -} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DarkAddressSpaceReasons.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DarkAddressSpaceReasons.cs index c8d5ca33..9632ae74 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DarkAddressSpaceReasons.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DarkAddressSpaceReasons.cs @@ -18,17 +18,11 @@ internal static class DarkAddressSpaceReasons "empty equipment-tag plan set this batch, so this parity has nothing to compare. Raw-tag parse " + "intent is covered by TagConfigCorpusParityTests (RawTagEntry round-trip) + TagConfigIntentTests."; - /// Discovered-node INJECTION is dormant in v3 (Wave-B review M1). The v2 path grafted a driver's - /// FixedTree under an equipment folder (equipment bound a driver); v3 retired that binding and discovered - /// raw tags are authored explicitly via the Batch-2 /raw browse-commit flow. HandleDiscoveredNodes - /// hard-short-circuits, so these v2 injection scenarios have no live path to assert; re-migrating injection - /// onto the raw device subtree is a separate follow-up. The dormant guard itself is pinned by - /// DriverHostActorDiscoveryTests.Discovered_nodes_are_ignored_dormant_in_v3. - public const string DiscoveryInjectionDormantV3 = - "v3: discovered-node injection is DORMANT (Wave-B review M1) — equipment no longer binds a driver, so the " + - "v2 FixedTree-under-equipment graft has no live path. Discovered raw tags are authored via the /raw " + - "browse-commit flow. HandleDiscoveredNodes short-circuits (pinned by Discovered_nodes_are_ignored_dormant_in_v3); " + - "re-migrating injection onto the raw device subtree is a separate follow-up."; + // DiscoveryInjectionDormantV3 was removed with the injection path itself (§8.2, Gitea #507). The 18 + // tests it skipped were v2 characterization tests — they asserted an equipment-rooted graft + // (EquipmentRootNodeId == "EQ-1") that v3 cannot produce, so they were deleted rather than unskipped. + // Discovered raw tags are authored through the /raw browse-commit flow; IRediscoverable now surfaces a + // re-browse prompt instead (DriverInstanceActorRediscoverySignalTests). /// Equipment↔device host binding is architecturally retired in v3. public const string EquipmentDeviceBindingRetired = diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveredNodeMapperTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveredNodeMapperTests.cs deleted file mode 100644 index cdc2dca0..00000000 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveredNodeMapperTests.cs +++ /dev/null @@ -1,119 +0,0 @@ -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; -using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; -using Shouldly; -using Xunit; - -namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; - -[Trait("Category", "Unit")] -public sealed class DiscoveredNodeMapperTests -{ - private static DiscoveredNode Node(string[] path, string name, string fullRef, - DriverDataType dt = DriverDataType.Float64, bool writable = false) - => new(path, name, name, fullRef, dt, false, null, writable, false); - - [Fact] - public void Maps_under_equipment_collapsing_single_device_folder() - { - var nodes = new[] - { - Node(["FOCAS", "10.0.0.5:8193", "Identity"], "SeriesNumber", "10.0.0.5:8193/Identity/SeriesNumber", DriverDataType.String), - Node(["FOCAS", "10.0.0.5:8193", "Axes", "X"], "AbsolutePosition", "10.0.0.5:8193/Axes/X/AbsolutePosition"), - }; - - var result = DiscoveredNodeMapper.Map("EQ-1", nodes, authoredRefs: new HashSet()); - - result.Variables.Select(v => v.NodeId).ShouldBe(new[] - { - "EQ-1/FOCAS/Identity/SeriesNumber", - "EQ-1/FOCAS/Axes/X/AbsolutePosition", - }, ignoreOrder: true); - result.Folders.Select(f => f.NodeId).ShouldContain("EQ-1/FOCAS/Axes/X"); - result.Folders.First(f => f.NodeId == "EQ-1/FOCAS/Axes/X").ParentNodeId.ShouldBe("EQ-1/FOCAS/Axes"); - result.RoutingByRef["10.0.0.5:8193/Identity/SeriesNumber"].ShouldBe("EQ-1/FOCAS/Identity/SeriesNumber"); - result.Variables.First(v => v.NodeId.EndsWith("SeriesNumber")).Writable.ShouldBeFalse(); - } - - [Fact] - public void Dedups_authored_refs() - { - var nodes = new[] - { - Node(["FOCAS", "10.0.0.5:8193"], "parts-count", "parts-count"), - Node(["FOCAS", "10.0.0.5:8193", "Identity"], "SeriesNumber", "10.0.0.5:8193/Identity/SeriesNumber", DriverDataType.String), - }; - var result = DiscoveredNodeMapper.Map("EQ-1", nodes, authoredRefs: new HashSet { "parts-count" }); - result.Variables.ShouldHaveSingleItem(); - result.Variables[0].NodeId.ShouldBe("EQ-1/FOCAS/Identity/SeriesNumber"); - } - - [Fact] - public void Does_not_collapse_when_two_devices_present() - { - var nodes = new[] - { - Node(["FOCAS", "10.0.0.5:8193", "Identity"], "SeriesNumber", "a", DriverDataType.String), - Node(["FOCAS", "10.0.0.6:8193", "Identity"], "SeriesNumber", "b", DriverDataType.String), - }; - var result = DiscoveredNodeMapper.Map("EQ-1", nodes, authoredRefs: new HashSet()); - result.Variables.Select(v => v.NodeId).ShouldBe(new[] - { - "EQ-1/FOCAS/10.0.0.5:8193/Identity/SeriesNumber", - "EQ-1/FOCAS/10.0.0.6:8193/Identity/SeriesNumber", - }, ignoreOrder: true); - } - - [Fact] - public void Empty_input_yields_empty_plan() - { - var result = DiscoveredNodeMapper.Map("EQ-1", Array.Empty(), authoredRefs: new HashSet()); - result.Folders.ShouldBeEmpty(); - result.Variables.ShouldBeEmpty(); - result.RoutingByRef.ShouldBeEmpty(); - } - - [Fact] - public void Array_metadata_passes_through_unchanged() - { - var node = new DiscoveredNode( - FolderPathSegments: ["FOCAS", "10.0.0.5:8193", "Axes"], - BrowseName: "Positions", - DisplayName: "Positions", - FullReference: "10.0.0.5:8193/Axes/Positions", - DataType: DriverDataType.Float64, - IsArray: true, - ArrayDim: 8u, - Writable: false, - IsHistorized: false); - - var result = DiscoveredNodeMapper.Map("EQ-1", new[] { node }, authoredRefs: new HashSet()); - - result.Variables.ShouldHaveSingleItem(); - result.Variables[0].IsArray.ShouldBeTrue(); - result.Variables[0].ArrayLength.ShouldBe(8u); - } - - [Theory] - // Mirror OtOpcUaNodeManager.ResolveBuiltInDataType's accepted string set: Float32 -> "Float", - // Float64 -> "Double", Reference (Galaxy attr ref encoded as a string) -> "String". The pass-through - // members must keep their enum name so the node manager resolves them to the matching built-in type. - [InlineData(DriverDataType.Float64, "Double")] - [InlineData(DriverDataType.Float32, "Float")] - [InlineData(DriverDataType.Reference, "String")] - [InlineData(DriverDataType.Boolean, "Boolean")] - [InlineData(DriverDataType.Int16, "Int16")] - [InlineData(DriverDataType.Int32, "Int32")] - [InlineData(DriverDataType.Int64, "Int64")] - [InlineData(DriverDataType.UInt16, "UInt16")] - [InlineData(DriverDataType.UInt32, "UInt32")] - [InlineData(DriverDataType.UInt64, "UInt64")] - [InlineData(DriverDataType.String, "String")] - [InlineData(DriverDataType.DateTime, "DateTime")] - public void DataType_maps_to_node_manager_builtin_string(DriverDataType dt, string expected) - { - var nodes = new[] { Node(["FOCAS", "10.0.0.5:8193", "Identity"], "Value", "10.0.0.5:8193/Identity/Value", dt) }; - var result = DiscoveredNodeMapper.Map("EQ-1", nodes, authoredRefs: new HashSet()); - result.Variables.ShouldHaveSingleItem(); - result.Variables[0].DataType.ShouldBe(expected); - } -} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveryInjectionEndToEndTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveryInjectionEndToEndTests.cs deleted file mode 100644 index b692f994..00000000 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveryInjectionEndToEndTests.cs +++ /dev/null @@ -1,353 +0,0 @@ -using System.Collections.Concurrent; -using System.Text.Json; -using Akka.Actor; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging.Abstractions; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy; -using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; -using ZB.MOM.WW.OtOpcUa.Commons.Types; -using ZB.MOM.WW.OtOpcUa.Configuration; -using ZB.MOM.WW.OtOpcUa.Configuration.Entities; -using ZB.MOM.WW.OtOpcUa.Configuration.Enums; -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; -using ZB.MOM.WW.OtOpcUa.OpcUaServer; -using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; -using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa; -using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; - -namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; - -/// -/// Task 9 — the focused END-TO-END proof that a driver-discovered FixedTree node is grafted into the -/// served Equipment OPC UA address space and a polled value reaches it. Unlike the Task-7/8 suites -/// (which wire the OPC UA publish side as a and assert on the -/// intercepted / -/// messages), this suite wires the FULL real chain: -/// -/// -/// a real (resolves equipment, maps via -/// , extends the live-value routing map, caches the plan); -/// a real as its opcUaPublishActor seam (so -/// MaterialiseDiscoveredNodes + AttributeValueUpdate are actually handled, not -/// intercepted); -/// a real over a recording -/// (so the materialise + value-write reach the sink). -/// -/// -/// -/// The assertions are therefore made on the SINK's recorded EnsureVariable / -/// RaiseNodesAddedModelChange / WriteValue calls — i.e. the discovered node was -/// materialised through the real applier AND a published value surfaces -/// at the mapped NodeId (in production this overwrites the BadWaitingForInitialData seed that -/// OtOpcUaNodeManager.EnsureVariable stamps on a freshly-materialised variable; the recording -/// sink does not model that seed, so the faithful assertion available here is that the live value -/// lands Good at the same NodeId the materialise created). -/// -/// -/// -/// Seam choices (faithful to the sibling suites). Discovery is driven by Telling the host -/// directly, and the polled value by Telling -/// directly — exactly the seams the Task-7/8 -/// tests use (there is no test seam to drive a real poll loop through a -/// child to Connected, and the spawned child is a ). The publish -/// actor is wired WITHOUT a dbFactory, so the host's apply-time -/// falls back to a raw sink.RebuildAddressSpace() (no EnsureVariable); this keeps the -/// ONLY EnsureVariable traffic on the sink the discovered-node materialise itself, so the -/// mapped NodeId is unambiguous. The discovery-injection chain (mapper → applier → sink + routing -/// map) is fully real. -/// -/// -[Trait("Category", "Unit")] -public sealed class DiscoveryInjectionEndToEndTests : RuntimeActorTestBase -{ - private static readonly NodeId TestNode = NodeId.Parse("disc-e2e-node"); - private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64)); - private static readonly RevisionHash RevB = RevisionHash.Parse(new string('b', 64)); - private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); - private static readonly DateTime Ts = new(2026, 6, 26, 10, 0, 0, DateTimeKind.Utc); - - // The FixedTree node the driver "discovers": FOCAS//Identity/SeriesNumber, a String value, - // whose FullReference differs from any authored tag so the mapper keeps it (does not shadow an authored - // node). The single device-host folder collapses, so it materialises at EQ-1/FOCAS/Identity/SeriesNumber. - private const string FixedTreeRef = "10.0.0.5:8193/Identity/SeriesNumber"; - private const string FixedTreeDisplayName = "SeriesNumber"; - - // The DETERMINISTIC NodeId the chain must place the FixedTree node at: EQ-1 (the bound equipment root) + - // the COLLAPSED folder path. The mapper's device-folder collapse drops the single shared device-host - // segment ("10.0.0.5:8193"), so FolderPathSegments ["FOCAS","10.0.0.5:8193","Identity"] + browse - // "SeriesNumber" → "EQ-1/FOCAS/Identity/SeriesNumber" (per EquipmentNodeIds.Variable). Asserting this - // EXACT NodeId closes the loop on the collapse rule — a prefix/StartsWith check would still pass if the - // collapse broke (e.g. "EQ-1/FOCAS/10.0.0.5:8193/Identity/SeriesNumber"). - private const string ExpectedFixedTreeNodeId = "EQ-1/FOCAS/Identity/SeriesNumber"; - - private static DiscoveredNode[] FixedTreeNodes() => new[] - { - new DiscoveredNode( - FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" }, - BrowseName: "SeriesNumber", - DisplayName: FixedTreeDisplayName, - FullReference: FixedTreeRef, - DataType: DriverDataType.String, - IsArray: false, - ArrayDim: null, - Writable: false, - IsHistorized: false), - }; - - /// - /// End-to-end #1: the discovered FixedTree node appears at the equipment AND a polled value flows - /// Good. Drives the real host (deployment applied, real child spawned, discovery reported) wired to a - /// real publish actor + real applier + recording sink, then asserts: - /// (a) the sink recorded an EnsureVariable for the FixedTree node under EQ-1 (materialised - /// through the REAL applier — the node now exists in the served address space), with a - /// RaiseNodesAddedModelChange under EQ-1 so connected clients refresh; - /// (b) after an for the FixedTree ref, the - /// sink recorded a WriteValue at THAT SAME NodeId carrying the value with - /// — proving the live value routed end-to-end and (in production) - /// overwrote the BadWaitingForInitialData seed. - /// - [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] - public void Discovered_node_materialises_at_equipment_and_polled_value_flows_Good() - { - var db = NewInMemoryDbFactory(); - var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - - var (host, sink, _) = SpawnHostWithRealPublishActor(db, deploymentId); - - // Driver reports its captured FixedTree (the faithful Task-7/8 seam). - host.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", FixedTreeNodes())); - - // (a) The discovered variable was materialised through the REAL applier onto the sink, at the EXACT - // collapsed NodeId under the bound equipment root (proves the mapper's device-folder collapse). - AwaitAssert(() => - { - var v = sink.Variables.SingleOrDefault(x => x.DisplayName == FixedTreeDisplayName); - v.NodeId.ShouldBe(ExpectedFixedTreeNodeId); // EnsureVariable at the exact collapsed NodeId under EQ-1 - v.DataType.ShouldBe("String"); // mapper carried the driver type through to the sink - v.Writable.ShouldBeFalse(); // discovered nodes are read-only - sink.ModelChanges.ShouldContain("EQ-1"); // NodeAdded announced under the equipment - }, duration: Timeout); - - // (b) A value published for the FixedTree ref routes to THAT exact NodeId and lands Good — the live - // value flowed end-to-end (host routing map → publish actor → applier-backing sink WriteValue). - host.Tell(new DriverInstanceActor.AttributeValuePublished("d1", FixedTreeRef, "SN-12345", OpcUaQuality.Good, Ts)); - - AwaitAssert(() => - { - var write = sink.Values.SingleOrDefault(x => x.NodeId == ExpectedFixedTreeNodeId); - write.NodeId.ShouldBe(ExpectedFixedTreeNodeId); - write.Value.ShouldBe("SN-12345"); - write.Quality.ShouldBe(OpcUaQuality.Good); - write.Ts.ShouldBe(Ts); - }, duration: Timeout); - } - - /// - /// End-to-end #2 (Task 8 survival): the injected FixedTree node + its live-value route SURVIVE a - /// redeploy. After the first injection materialises + a value flows Good, a SECOND deployment (new - /// revision, same d1 → EQ-1 binding) re-runs PushDesiredSubscriptions — which clears the - /// routing maps and re-pushes an authored-only subscription set; the Task-8 tail re-apply re-grafts - /// the cached discovered plan. Asserts the sink records the FixedTree EnsureVariable AGAIN - /// (re-materialised after the rebuild) at the same NodeId, and a subsequent published value STILL - /// WriteValues Good there (the routing map was rebuilt, not left empty by the Clear()). - /// - [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] - public void Discovered_node_and_value_survive_a_redeploy() - { - var db = NewInMemoryDbFactory(); - var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - - var (host, sink, coordinator) = SpawnHostWithRealPublishActor(db, deploymentId); - - host.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", FixedTreeNodes())); - - // First injection: the FixedTree node materialises at the EXACT collapsed NodeId under EQ-1. - AwaitAssert( - () => sink.Variables.ShouldContain(x => x.NodeId == ExpectedFixedTreeNodeId && x.DisplayName == FixedTreeDisplayName), - duration: Timeout); - - // First value flows Good (pre-redeploy baseline). - host.Tell(new DriverInstanceActor.AttributeValuePublished("d1", FixedTreeRef, "SN-AAA", OpcUaQuality.Good, Ts)); - AwaitAssert( - () => sink.Values.ShouldContain(x => x.NodeId == ExpectedFixedTreeNodeId && Equals(x.Value, "SN-AAA") && x.Quality == OpcUaQuality.Good), - duration: Timeout); - - var ensureVarCountBefore = sink.Variables.Count(x => x.NodeId == ExpectedFixedTreeNodeId); - - // Apply a SECOND deployment (new revision, SAME d1 → EQ-1 binding) — re-runs PushDesiredSubscriptions - // (clears + rebuilds the routing maps) then the Task-8 tail re-applies the cached discovered plan. - var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - host.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId())); - coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); - - // (a) The cached discovered plan was RE-MATERIALISED at the SAME exact NodeId after the redeploy rebuild. - AwaitAssert( - () => sink.Variables.Count(x => x.NodeId == ExpectedFixedTreeNodeId).ShouldBeGreaterThan(ensureVarCountBefore), - duration: Timeout); - - // (b) A value published AFTER the redeploy STILL routes to the exact NodeId and lands Good — the - // live-value routing map was rebuilt by the re-apply (not lost when PushDesiredSubscriptions cleared it). - var tsAfter = Ts.AddSeconds(5); - host.Tell(new DriverInstanceActor.AttributeValuePublished("d1", FixedTreeRef, "SN-BBB", OpcUaQuality.Good, tsAfter)); - AwaitAssert( - () => sink.Values.ShouldContain(x => x.NodeId == ExpectedFixedTreeNodeId && Equals(x.Value, "SN-BBB") && x.Quality == OpcUaQuality.Good), - duration: Timeout); - } - - /// Spawns the real chain — recording sink → real → real - /// (the host's opcUaPublishActor seam) → real - /// backed by a — dispatches the - /// deployment, and waits for the Applied ACK so _lastComposition + the live child + the initial - /// subscribe pass have completed before discovery is injected. The publish actor is wired with the - /// applier but NO dbFactory, so its apply-time RebuildAddressSpace is a raw sink rebuild (no EnsureVariable) - /// and the only EnsureVariable traffic is the discovered-node materialise itself. - private (IActorRef Host, RecordingSink Sink, Akka.TestKit.TestProbe Coordinator) SpawnHostWithRealPublishActor( - IDbContextFactory db, DeploymentId deploymentId) - { - var coordinator = CreateTestProbe(); - var vtHost = CreateTestProbe(); - - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - var publish = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, applier: applier)); - - var host = Sys.ActorOf(DriverHostActor.Props( - db, TestNode, coordinator.Ref, - driverFactory: new SubscribingDriverFactory("Modbus"), - localRoles: new HashSet { "driver" }, - opcUaPublishActor: publish, - virtualTagHostOverride: vtHost.Ref)); - - host.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId())); - coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); - - return (host, sink, coordinator); - } - - /// Seeds a Sealed deployment whose artifact carries the minimal arrays needed to project - /// equipment tags + a real (non-stubbed) child for each driver - /// (mirrors DriverHostActorDiscoveryTests.SeedDeploymentWithEquipmentTags). An authored value tag - /// both sets _lastComposition and binds the driver → equipment (the only way the host resolves the - /// equipment a discovered node grafts under). - private static DeploymentId SeedDeploymentWithEquipmentTags( - IDbContextFactory db, RevisionHash rev, - params (string Equip, string Driver, string FullName, string? Folder, string Name)[] tags) - { - var driverIds = tags.Select(t => t.Driver).Distinct(StringComparer.Ordinal).ToArray(); - - var artifact = JsonSerializer.SerializeToUtf8Bytes(new - { - Namespaces = new[] - { - new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0 - }, - DriverInstances = driverIds.Select(d => new - { - DriverInstanceRowId = Guid.NewGuid(), - DriverInstanceId = d, - Name = d, - DriverType = "Modbus", // not Windows-only ⇒ a real child is spawned (not stubbed) - Enabled = true, - DriverConfig = "{}", - NamespaceId = "ns-eq", - }).ToArray(), - Tags = tags.Select((t, i) => new - { - TagId = $"tag-{i}", - EquipmentId = t.Equip, - DriverInstanceId = t.Driver, - Name = t.Name, - FolderPath = t.Folder, - DataType = "Double", - TagConfig = JsonSerializer.Serialize(new { FullName = t.FullName }), - }).ToArray(), - }); - - var id = DeploymentId.NewId(); - using var ctx = db.CreateDbContext(); - ctx.Deployments.Add(new Deployment - { - DeploymentId = id.Value, - RevisionHash = rev.Value, - Status = DeploymentStatus.Sealed, - CreatedBy = "test", - SealedAtUtc = DateTime.UtcNow, - ArtifactBlob = artifact, - }); - ctx.SaveChanges(); - return id; - } - - /// Factory producing a single shared for the supported - /// type, so a real (non-stubbed) child is spawned for the driver and - /// the host's subscribe path is exercised (mirrors - /// DriverHostActorDiscoveryTests.SubscribingDriverFactory). - private sealed class SubscribingDriverFactory : IDriverFactory - { - private readonly string _supportedType; - private readonly SubscribableStubDriver _driver = new(); - public SubscribingDriverFactory(string supportedType) { _supportedType = supportedType; } - - /// - public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson) => - string.Equals(driverType, _supportedType, StringComparison.Ordinal) ? _driver : null; - - /// - public IReadOnlyCollection SupportedTypes => new[] { _supportedType }; - } - - /// Recording — captures the EnsureFolder / EnsureVariable / - /// WriteValue / RaiseNodesAddedModelChange calls the real applier + publish actor drive, so the test can - /// assert the discovered node was materialised and a value landed Good at its NodeId. Thread-safe (the - /// publish actor runs on an Akka dispatcher thread, the test asserts from the test thread). - private sealed class RecordingSink : IOpcUaAddressSpaceSink - { - private readonly ConcurrentQueue<(string NodeId, string? ParentNodeId, string DisplayName)> _folders = new(); - private readonly ConcurrentQueue<(string NodeId, string? ParentNodeId, string DisplayName, string DataType, bool Writable)> _variables = new(); - private readonly ConcurrentQueue<(string NodeId, object? Value, OpcUaQuality Quality, DateTime Ts)> _values = new(); - private readonly ConcurrentQueue _modelChanges = new(); - - /// Gets a snapshot of the recorded EnsureFolder calls. - public List<(string NodeId, string? ParentNodeId, string DisplayName)> Folders => _folders.ToList(); - /// Gets a snapshot of the recorded EnsureVariable calls. - public List<(string NodeId, string? ParentNodeId, string DisplayName, string DataType, bool Writable)> Variables => _variables.ToList(); - /// Gets a snapshot of the recorded WriteValue calls. - public List<(string NodeId, object? Value, OpcUaQuality Quality, DateTime Ts)> Values => _values.ToList(); - /// Gets a snapshot of the recorded RaiseNodesAddedModelChange announcements. - public List ModelChanges => _modelChanges.ToList(); - /// Gets the count of raw RebuildAddressSpace calls (apply-time rebuild fallback). - public int RebuildCalls; - - /// Records a live-value write. - public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) - => _values.Enqueue((nodeId, value, quality, sourceTimestampUtc)); - - /// No-op: alarm writes are not exercised by this suite. - public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { } - public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } - - /// No-op: alarm materialise is not exercised by this suite. - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { } - - /// Records an EnsureFolder call. - public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) - => _folders.Enqueue((folderNodeId, parentNodeId, displayName)); - - /// Records an EnsureVariable call. - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) - => _variables.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable)); - - /// Records a raw rebuild (the apply-time fallback when no dbFactory is wired). - public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls); - - /// Records a NodeAdded model-change announcement. - public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => _modelChanges.Enqueue(affectedNodeId); - public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { } - public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { } - } -} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs deleted file mode 100644 index 2054eb51..00000000 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs +++ /dev/null @@ -1,1327 +0,0 @@ -using System.Text.Json; -using Akka.Actor; -using Microsoft.EntityFrameworkCore; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy; -using ZB.MOM.WW.OtOpcUa.Commons.Messages.Fleet; -using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; -using ZB.MOM.WW.OtOpcUa.Commons.Types; -using ZB.MOM.WW.OtOpcUa.Configuration; -using ZB.MOM.WW.OtOpcUa.Configuration.Entities; -using ZB.MOM.WW.OtOpcUa.Configuration.Enums; -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; -using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; -using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa; -using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; - -namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; - -/// -/// Verifies the discovered-node injection wired into (Task 7): when a -/// driver child reports a captured FixedTree via , -/// the host resolves the bound equipment from the authored composition, maps the nodes under it via -/// , materialises them on the OPC UA publish side -/// (), extends the live-value routing map -/// (_nodeIdByDriverRef), and merges the FixedTree refs into the driver's desired subscription set -/// (). -/// -/// -/// Drives a real apply through the existing harness (same artifact shape as -/// DriverHostActorLiveValueTests / DriverHostActorWriteRoutingTests) so -/// _lastComposition is set and a real (non-stubbed) child -/// is spawned for d1. The child is backed by the shared -/// (records LastSubscribedRefs/SubscribeCount, exactly as -/// DriverInstanceActorTests asserts) so the merged subscription is observable; the OPC UA -/// publish actor is a (as in -/// DriverHostActorLiveValueTests) so the materialise + the post-injection value route are -/// observable. There is no test seam to inject a probe AS a driver child, so this is the faithful -/// end-to-end approach the harness allows. -/// -/// -[Trait("Category", "Unit")] -public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase -{ - private static readonly NodeId TestNode = NodeId.Parse("driver-disc-test"); - private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64)); - private static readonly RevisionHash RevB = RevisionHash.Parse(new string('b', 64)); - private static readonly RevisionHash RevC = RevisionHash.Parse(new string('c', 64)); - private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); - private static readonly DateTime Ts = new(2026, 6, 26, 10, 0, 0, DateTimeKind.Utc); - - /// v3 Batch 4 (review M1) — the ONE live dormancy pin: discovered-node injection is short-circuited. - /// A driver reports a FixedTree via , but the host must - /// NOT materialise anything (no reaches the publish - /// side) — discovered raw tags are authored via the /raw browse-commit flow, not injected at runtime. - /// The skipped v2 injection scenarios below are the counterpart of this guard. - [Fact] - public void Discovered_nodes_are_ignored_dormant_in_v3() - { - var db = NewInMemoryDbFactory(); - var factory = new SubscribingDriverFactory("Modbus"); - var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory); - - actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] - { - new DiscoveredNode( - FolderPathSegments: new[] { "FOCAS", "Identity" }, - BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, - Writable: false, IsHistorized: false), - })); - - // Dormant: no discovered nodes are materialised (the guard short-circuits before any route/materialise). - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); - } - - /// A driver's discovered FixedTree (refs differing from the authored tag) is grafted under the - /// bound equipment: (a) the publish side receives - /// rooted at the equipment NodeId; (b) the driver re-subscribes the UNION of the authored ref + the - /// FixedTree refs; (c) a value published for a FixedTree ref now routes to its mapped NodeId (proving the - /// live-value routing map was extended). - [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] - public void DiscoveredNodes_materialise_extend_routing_and_merge_subscription() - { - var db = NewInMemoryDbFactory(); - var factory = new SubscribingDriverFactory("Modbus"); - // One authored value tag: equipment EQ-1, driver d1, FullName "40001" — this both sets - // _lastComposition AND binds d1 → EQ-1 (the only way the equipment is resolved, since EquipmentNode - // carries no DriverInstanceId). - var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - - var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory); - - // A FixedTree discovered node whose FullReference DIFFERS from the authored tag's FullName, so the - // mapper keeps it (it does not shadow an authored ref). - var discovered = new[] - { - new DiscoveredNode( - FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" }, - BrowseName: "Model", - DisplayName: "Model", - FullReference: "ft-ref-1", - DataType: DriverDataType.Float64, - IsArray: false, - ArrayDim: null, - Writable: false, - IsHistorized: false), - }; - - actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", discovered)); - - // (a) The publish side materialises the discovered folders + variables UNDER the equipment root "EQ-1". - var materialise = publish.ExpectMsg(Timeout); - materialise.EquipmentRootNodeId.ShouldBe("EQ-1"); - materialise.Variables.Count.ShouldBe(1); - materialise.Folders.Count.ShouldBeGreaterThan(0); - var fixedTreeNodeId = materialise.Variables[0].NodeId; - - // (b) The driver re-subscribed the UNION of the authored value ref AND the FixedTree ref. The union - // push is the LAST SetDesiredSubscriptions, so the most recent subscribe carries both. - AwaitAssert(() => - { - var refs = factory.LastSubscribedRefs; - refs.ShouldNotBeNull(); - refs!.ShouldContain("40001"); - refs.ShouldContain("ft-ref-1"); - }, duration: Timeout); - - // (c) A value published for the FixedTree ref now routes to the mapped FixedTree NodeId — proving the - // _nodeIdByDriverRef live-value map was extended by the injection. - actor.Tell(new DriverInstanceActor.AttributeValuePublished( - "d1", "ft-ref-1", 42.0, OpcUaQuality.Good, Ts)); - - var update = publish.ExpectMsg(Timeout); - update.NodeId.ShouldBe(fixedTreeNodeId); - update.Value.ShouldBe(42.0); - update.Quality.ShouldBe(OpcUaQuality.Good); - update.TimestampUtc.ShouldBe(Ts); - } - - /// NEW capability (follow-up E): a driver bound to an equipment via - /// with ZERO authored equipment tags can still graft its - /// discovered FixedTree. The equipment is resolved from the composition's EquipmentNodes (not just - /// the authored EquipmentTags), so a tag-less equipment receives - /// rooted at its NodeId and the driver - /// subscribes the discovered refs (no authored ref exists to union). Previously this was skipped with - /// "no equipment/authored tags". - [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] - public void Tag_less_equipment_resolved_via_EquipmentNode_grafts_discovered_nodes() - { - var db = NewInMemoryDbFactory(); - var factory = new SubscribingDriverFactory("Modbus"); - // EQ-1 bound to driver d1 via EquipmentNode.DriverInstanceId, with NO authored equipment tags for d1. - var deploymentId = SeedDeploymentWithTagLessEquipment(db, RevA, equipmentId: "EQ-1", driverId: "d1"); - - var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory); - - actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] - { - new DiscoveredNode( - FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" }, - BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, - Writable: false, IsHistorized: false), - })); - - // (a) The discovered nodes materialise UNDER EQ-1 even though no authored tag binds d1 → EQ-1. - var materialise = publish.ExpectMsg(Timeout); - materialise.EquipmentRootNodeId.ShouldBe("EQ-1"); - materialise.Variables.Count.ShouldBe(1); - var fixedTreeNodeId = materialise.Variables[0].NodeId; - - // (b) The driver subscribes the discovered ref (the union is just the FixedTree ref — no authored ref). - AwaitAssert(() => - { - var refs = factory.LastSubscribedRefs; - refs.ShouldNotBeNull(); - refs!.ShouldContain("ft-ref-1"); - }, duration: Timeout); - - // (c) A value published for the FixedTree ref routes to its mapped NodeId (routing map was extended). - actor.Tell(new DriverInstanceActor.AttributeValuePublished("d1", "ft-ref-1", 42.0, OpcUaQuality.Good, Ts)); - var update = publish.ExpectMsg(Timeout); - update.NodeId.ShouldBe(fixedTreeNodeId); - update.Value.ShouldBe(42.0); - } - - /// DEGENERATE multi-equipment case (follow-up E, part 2): a driver that resolves to MORE THAN ONE - /// equipment but where NONE of the candidates carries a has nothing - /// to partition the FixedTree on, so the whole driver is warn-skipped (nothing grafted, no crash). Here d1 - /// is bound to two equipments via two AUTHORED tags only (no Device rows ⇒ no DeviceHost), which is exactly - /// the degenerate shape: no is told. - [Fact] - public void Driver_mapping_to_more_than_one_equipment_with_no_device_host_warn_skips() - { - var db = NewInMemoryDbFactory(); - var factory = new SubscribingDriverFactory("Modbus"); - // d1 is bound to TWO equipments via two authored tags (no devices ⇒ no DeviceHost) ⇒ degenerate ⇒ warn-skip. - var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"), - (Equip: "EQ-2", Driver: "d1", FullName: "40002", Folder: (string?)null, Name: "speed2")); - - var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory); - - actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] - { - new DiscoveredNode( - FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" }, - BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, - Writable: false, IsHistorized: false), - })); - - // Nothing is grafted — no candidate has a DeviceHost, so there's nothing to partition on (degenerate). - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); - } - - /// Multi-device split (follow-up E, part 2): a driver that resolves to >1 equipment, each bound to - /// a DEVICE with a distinct , partitions its discovered FixedTree by - /// the (normalized) device-host folder segment and grafts each device's subset under the equipment whose - /// DeviceHost matches. Asserts (a) TWO (one per - /// equipment), (b) the union subscription carries BOTH devices' refs, and (c) a value for each device's ref - /// routes to the right equipment's node (proving BOTH inner-map entries cached + keyed correctly). The "H1" - /// vs stored "h1" wrinkle proves the SHARED DeviceConfigIntent.NormalizeHost match. - [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] - public void Multi_device_driver_partitions_fixed_tree_by_device_host_under_matching_equipment() - { - var db = NewInMemoryDbFactory(); - var factory = new SubscribingDriverFactory("Modbus"); - // d1 fans out to EQ-A (device host "h1") + EQ-B (device host "h2"), tag-less, bound via EquipmentNode. - var deploymentId = SeedDeploymentWithMultiDeviceEquipments(db, RevA, driverId: "d1", - (Equip: "EQ-A", DeviceId: "dev-a", Host: "h1"), - (Equip: "EQ-B", DeviceId: "dev-b", Host: "h2")); - - var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory); - - // Discovered nodes split across two device-host folder segments. "H1" is UPPERCASE to prove the shared - // NormalizeDeviceHost lower-cases the segment to match the stored lower-cased "h1" DeviceHost. - actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] - { - new DiscoveredNode( - FolderPathSegments: new[] { "FOCAS", "H1", "Identity" }, - BrowseName: "Model", DisplayName: "Model", FullReference: "ft-h1-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false), - new DiscoveredNode( - FolderPathSegments: new[] { "FOCAS", "h2", "Status" }, - BrowseName: "Run", DisplayName: "Run", FullReference: "ft-h2-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false), - })); - - // (a) Each device's subset materialises under its OWN equipment — two messages, one per equipment. - var m1 = publish.ExpectMsg(Timeout); - var m2 = publish.ExpectMsg(Timeout); - var byEquipment = new[] { m1, m2 }.ToDictionary(m => m.EquipmentRootNodeId, m => m); - byEquipment.Keys.ShouldBe(new[] { "EQ-A", "EQ-B" }, ignoreOrder: true); - byEquipment["EQ-A"].Variables.ShouldHaveSingleItem().DisplayName.ShouldBe("Model"); - byEquipment["EQ-B"].Variables.ShouldHaveSingleItem().DisplayName.ShouldBe("Run"); - var eqANodeId = byEquipment["EQ-A"].Variables[0].NodeId; - var eqBNodeId = byEquipment["EQ-B"].Variables[0].NodeId; - // Single device per partition ⇒ the mapper collapses the host folder ⇒ the NodeId carries NO host - // segment and reads EQ-n/FOCAS//. Assert the EXACT collapsed path (depth + leaf) so a - // collapse regression — which would re-introduce the host folder (e.g. EQ-A/FOCAS/H1/Identity/Model) — - // fails here. Belt-and-suspenders: the raw discovered "H1" segment is also checked case-SENSITIVELY - // (a lowercase-only "h1" check would miss a leaked raw "H1"). - eqANodeId.ShouldBe(V3NodeIds.Uns("EQ-A", "FOCAS", "Identity", "Model")); - eqANodeId.ShouldNotContain("H1"); - eqANodeId.ShouldNotContain("h1"); - eqBNodeId.ShouldBe(V3NodeIds.Uns("EQ-B", "FOCAS", "Status", "Run")); - eqBNodeId.ShouldNotContain("h2"); - - // (b) The driver subscribes the UNION of both devices' FixedTree refs (tag-less ⇒ no authored refs). - AwaitAssert(() => - { - var refs = factory.LastSubscribedRefs; - refs.ShouldNotBeNull(); - refs!.ShouldContain("ft-h1-1"); - refs.ShouldContain("ft-h2-1"); - }, duration: Timeout); - - // (c) Routing is keyed per device: h1's ref lands on EQ-A's node, h2's on EQ-B's node. - actor.Tell(new DriverInstanceActor.AttributeValuePublished("d1", "ft-h1-1", 1.0, OpcUaQuality.Good, Ts)); - publish.ExpectMsg(Timeout).NodeId.ShouldBe(eqANodeId); - actor.Tell(new DriverInstanceActor.AttributeValuePublished("d1", "ft-h2-1", 2.0, OpcUaQuality.Good, Ts)); - publish.ExpectMsg(Timeout).NodeId.ShouldBe(eqBNodeId); - } - - /// Multi-device unmatched warn-skip (follow-up E, part 2): a discovered device-host partition with - /// NO matching equipment is warn-skipped (NOT mis-grafted), while the matched partitions still graft. Here - /// d1 fans out to EQ-A("h1") + EQ-B("h2"), but a third discovered partition "h3" matches no equipment: only - /// EQ-A + EQ-B materialise (no third), and the unmatched ref routes nowhere (no crash). - [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] - public void Multi_device_unmatched_device_host_is_warn_skipped_matched_still_graft() - { - var db = NewInMemoryDbFactory(); - var factory = new SubscribingDriverFactory("Modbus"); - var deploymentId = SeedDeploymentWithMultiDeviceEquipments(db, RevA, driverId: "d1", - (Equip: "EQ-A", DeviceId: "dev-a", Host: "h1"), - (Equip: "EQ-B", DeviceId: "dev-b", Host: "h2")); - - var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory); - - actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] - { - new DiscoveredNode(FolderPathSegments: new[] { "FOCAS", "h1", "Identity" }, - BrowseName: "Model", DisplayName: "Model", FullReference: "ft-h1-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false), - new DiscoveredNode(FolderPathSegments: new[] { "FOCAS", "h2", "Status" }, - BrowseName: "Run", DisplayName: "Run", FullReference: "ft-h2-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false), - new DiscoveredNode(FolderPathSegments: new[] { "FOCAS", "h3", "Identity" }, - BrowseName: "Ghost", DisplayName: "Ghost", FullReference: "ft-h3-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false), - })); - - // Exactly TWO materialise (EQ-A, EQ-B); the unmatched "h3" grafts nowhere ⇒ no third materialise. - var m1 = publish.ExpectMsg(Timeout); - var m2 = publish.ExpectMsg(Timeout); - new[] { m1.EquipmentRootNodeId, m2.EquipmentRootNodeId }.ShouldBe(new[] { "EQ-A", "EQ-B" }, ignoreOrder: true); - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); - - // The ghost ref was never materialised, so a value for it routes nowhere — dropped, not a crash. - actor.Tell(new DriverInstanceActor.AttributeValuePublished("d1", "ft-h3-1", 9.0, OpcUaQuality.Good, Ts)); - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); - } - - /// Warn-spam taming (follow-up E, part 2): an unmatched device-host partition WARNS exactly ONCE, - /// then the identical repeated re-discovery passes (the driver re-discovers ~15×/connect, re-sending the - /// same set) are quiet — proving ShouldWarnPartition's per-driver signature dedup. The repeat is - /// logged at Debug, which the suite's loglevel = WARNING HOCON suppresses at source, so EventFilter - /// observes the dedup as "zero further matching warnings". (The matched EQ-A/EQ-B partitions still graft on - /// pass 1; pass 2's matched routing is short-circuited by PlansRoutingEqual.) - [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] - public void Repeated_unmatched_device_host_partition_warns_once_then_is_quiet() - { - var db = NewInMemoryDbFactory(); - var factory = new SubscribingDriverFactory("Modbus"); - var deploymentId = SeedDeploymentWithMultiDeviceEquipments(db, RevA, driverId: "d1", - (Equip: "EQ-A", DeviceId: "dev-a", Host: "h1"), - (Equip: "EQ-B", DeviceId: "dev-b", Host: "h2")); - - var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory); - - var discovered = new[] - { - new DiscoveredNode(FolderPathSegments: new[] { "FOCAS", "h1", "Identity" }, - BrowseName: "Model", DisplayName: "Model", FullReference: "ft-h1-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false), - new DiscoveredNode(FolderPathSegments: new[] { "FOCAS", "h2", "Status" }, - BrowseName: "Run", DisplayName: "Run", FullReference: "ft-h2-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false), - new DiscoveredNode(FolderPathSegments: new[] { "FOCAS", "h3", "Identity" }, - BrowseName: "Ghost", DisplayName: "Ghost", FullReference: "ft-h3-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false), - }; - - // Pass 1: the unmatched "h3" partition warns EXACTLY once. - EventFilter.Warning(contains: "discovered device-host partition(s) skipped") - .Expect(1, () => actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", discovered))); - - // Drain the matched EQ-A + EQ-B grafts from pass 1 so the assertion below is unambiguous. - publish.ExpectMsg(Timeout); - publish.ExpectMsg(Timeout); - - // Pass 2: the IDENTICAL set (same revision) does NOT warn again — the repeat is Debug (suppressed by the - // suite's WARNING loglevel), so EventFilter sees ZERO further matching warnings. - EventFilter.Warning(contains: "discovered device-host partition(s) skipped") - .Expect(0, () => actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", discovered))); - } - - /// Guard: a arriving BEFORE any deployment - /// is applied (_lastComposition still null) is ignored — nothing is materialised on the publish - /// side (the equipment can't be resolved without a composition). - [Fact] - public void DiscoveredNodes_before_any_apply_are_ignored() - { - var db = NewInMemoryDbFactory(); - var coordinator = CreateTestProbe(); - var publish = CreateTestProbe(); - var vtHost = CreateTestProbe(); - - // No deployment dispatched ⇒ Bootstrap enters Steady with no composition ⇒ _lastComposition is null. - var actor = Sys.ActorOf(DriverHostActor.Props( - db, TestNode, coordinator.Ref, - driverFactory: new SubscribingDriverFactory("Modbus"), - localRoles: new HashSet { "driver" }, - opcUaPublishActor: publish.Ref, - virtualTagHostOverride: vtHost.Ref)); - - actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] - { - new DiscoveredNode( - FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" }, - BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, - Writable: false, IsHistorized: false), - })); - - // No composition ⇒ no materialise (and no RebuildAddressSpace either, since nothing was applied). - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); - } - - /// Dedup: a discovered node whose FullReference equals an authored equipment tag's - /// FullName is NOT injected (it would shadow the authored node) — only the genuinely-new FixedTree refs - /// are materialised. - [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] - public void Discovered_node_shadowing_an_authored_ref_is_not_injected() - { - var db = NewInMemoryDbFactory(); - var factory = new SubscribingDriverFactory("Modbus"); - var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - - var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory); - - // Two captured nodes: one SHADOWS the authored ref "40001" (must be dropped), one is genuinely new. - actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] - { - new DiscoveredNode( - FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Registers" }, - BrowseName: "speed", DisplayName: "Speed", FullReference: "40001", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, - Writable: false, IsHistorized: false), - new DiscoveredNode( - FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" }, - BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, - Writable: false, IsHistorized: false), - })); - - // Exactly ONE variable materialised — the new "Model", not the authored-shadow "Speed". - var materialise = publish.ExpectMsg(Timeout); - materialise.Variables.Count.ShouldBe(1); - materialise.Variables[0].DisplayName.ShouldBe("Model"); - } - - /// Idempotency / the unchanged-plan short-circuit: re-sending the SAME discovered set (the driver - /// re-discovers each ~2s pass) is a no-op — it materialises ONCE and does not force the child to - /// re-subscribe. A GROWN set, however, DOES re-apply (materialise again + re-subscribe), so a stabilising - /// FixedTree still converges. - [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] - public void Repeated_identical_discovery_does_not_reapply_but_a_grown_set_does() - { - var db = NewInMemoryDbFactory(); - var factory = new SubscribingDriverFactory("Modbus"); - var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - - var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory); - - var node1 = new DiscoveredNode( - FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" }, - BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, - Writable: false, IsHistorized: false); - var node2 = new DiscoveredNode( - FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Status" }, - BrowseName: "Run", DisplayName: "Run", FullReference: "ft-ref-2", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, - Writable: false, IsHistorized: false); - - // Pass 1: a new set ⇒ one materialise + a union re-subscribe. - actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] { node1 })); - publish.ExpectMsg(Timeout); - AwaitAssert(() => - { - var refs = factory.LastSubscribedRefs; - refs.ShouldNotBeNull(); - refs!.ShouldContain("ft-ref-1"); - }, duration: Timeout); - var subscribeCountAfterFirst = factory.SubscribeCount; - - // Pass 2: the IDENTICAL set ⇒ short-circuited (no materialise, no re-subscribe). - actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] { node1 })); - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); - factory.SubscribeCount.ShouldBe(subscribeCountAfterFirst); - - // Pass 3: a GROWN set (superset) ⇒ re-applies (materialise again + re-subscribe with both refs). - actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] { node1, node2 })); - publish.ExpectMsg(Timeout).Variables.Count.ShouldBe(2); - AwaitAssert(() => - { - factory.SubscribeCount.ShouldBeGreaterThan(subscribeCountAfterFirst); - var refs = factory.LastSubscribedRefs; - refs.ShouldNotBeNull(); - refs!.ShouldContain("ft-ref-1"); - refs.ShouldContain("ft-ref-2"); - }, duration: Timeout); - } - - /// Task 8 survival: discovered (FixedTree) nodes injected after the first apply must SURVIVE a - /// redeploy. A second deployment re-runs PushDesiredSubscriptions, which clears the live-value - /// routing maps and re-pushes an authored-only subscription set; without the tail re-apply the injected - /// FixedTree routes + materialised nodes would be lost until the driver reconnects. Asserts the cached - /// plan is (a) re-materialised under EQ-1 after the rebuild, (b) still present in the child's - /// post-redeploy subscription, and (c) still routes a published value to its mapped NodeId. - [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] - public void Discovered_nodes_survive_a_redeploy_rebuild() - { - var db = NewInMemoryDbFactory(); - var factory = new SubscribingDriverFactory("Modbus"); - var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - - var (actor, publish, coordinator) = SpawnHostAndApply(db, deploymentId, factory); - - var discovered = new[] - { - new DiscoveredNode( - FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" }, - BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, - Writable: false, IsHistorized: false), - }; - actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", discovered)); - - // First injection: materialise #1 under EQ-1 — capture the mapped NodeId for the survival asserts. - var materialise1 = publish.ExpectMsg(Timeout); - materialise1.EquipmentRootNodeId.ShouldBe("EQ-1"); - materialise1.Variables.Count.ShouldBe(1); - var fixedTreeNodeId = materialise1.Variables[0].NodeId; - - // Apply a SECOND deployment (new revision, SAME d1 → EQ-1 binding so HandleDispatchFromSteady doesn't - // short-circuit on an identical rev). This re-runs PushDesiredSubscriptions, which clears + rebuilds - // the routing maps and re-pushes the authored-only subscription set — the exact path Task 8 self-heals. - var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId())); - coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); - - // The redeploy fires a fresh RebuildAddressSpace first (drain it) ... - publish.ExpectMsg(Timeout); - - // (a) ... then the cached discovered plan is RE-MATERIALISED under EQ-1 (the Task-8 tail re-apply), - // at the SAME NodeId the first injection placed it. - var materialise2 = publish.ExpectMsg(Timeout); - materialise2.EquipmentRootNodeId.ShouldBe("EQ-1"); - materialise2.Variables.Count.ShouldBe(1); - materialise2.Variables[0].NodeId.ShouldBe(fixedTreeNodeId); - - // (b) The child's post-redeploy subscription STILL carries the FixedTree ref — it was re-merged onto - // the freshly-cleared authored-only set, not dropped by the _nodeIdByDriverRef.Clear(). - AwaitAssert(() => - { - var refs = factory.LastSubscribedRefs; - refs.ShouldNotBeNull(); - refs!.ShouldContain("40001"); - refs.ShouldContain("ft-ref-1"); - }, duration: Timeout); - - // (c) A value published for the FixedTree ref STILL routes to its mapped NodeId — proving the - // live-value routing map was rebuilt by the re-apply (not left empty after the Clear()). - actor.Tell(new DriverInstanceActor.AttributeValuePublished("d1", "ft-ref-1", 42.0, OpcUaQuality.Good, Ts)); - var update = publish.ExpectMsg(Timeout); - update.NodeId.ShouldBe(fixedTreeNodeId); - update.Value.ShouldBe(42.0); - update.Quality.ShouldBe(OpcUaQuality.Good); - } - - /// Task 8 cache-drop: a redeploy whose composition no longer binds the driver to any equipment - /// (its authored tags were removed) must DROP the cached discovered plan rather than re-graft it onto a - /// stale equipment. After such a redeploy the host re-applies the authored rebuild but does NOT re-tell - /// for the now-unresolved driver. - [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] - public void Discovered_nodes_dropped_when_equipment_no_longer_resolves() - { - var db = NewInMemoryDbFactory(); - var factory = new SubscribingDriverFactory("Modbus"); - var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - - var (actor, publish, coordinator) = SpawnHostAndApply(db, deploymentId, factory); - - actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] - { - new DiscoveredNode( - FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" }, - BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, - Writable: false, IsHistorized: false), - })); - - // First injection materialises under EQ-1. - publish.ExpectMsg(Timeout); - - // Apply a SECOND deployment that binds a DIFFERENT driver (d2 → EQ-2) and carries NO authored tags for - // d1, so d1's equipment can no longer be resolved (equipmentIds.Count == 0) — the cache entry is dropped. - var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB, - (Equip: "EQ-2", Driver: "d2", FullName: "40002", Folder: (string?)null, Name: "speed2")); - actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId())); - coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); - - // The redeploy fires a fresh RebuildAddressSpace; after draining it, NO MaterialiseDiscoveredNodes is - // re-told (the cached d1 plan was dropped because its equipment no longer resolves). - publish.ExpectMsg(Timeout); - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); - } - - /// Task 8 rebind-guard: a redeploy that REBINDS the driver to a DIFFERENT equipment must DROP the - /// cached discovered plan rather than re-graft EQ-1-scoped nodes under EQ-2. d1 still resolves to exactly - /// one equipment (so the Count==0 drop does NOT fire), but the cached plan's NodeIds are scoped to the OLD - /// equipment (EQ-1), so the StartsWith(equipmentId + "/") guard sees they no longer match EQ-2 and - /// drops the entry. After the redeploy NO is - /// re-told. (Complements , which - /// covers the Count==0 branch; this covers the rebind/StartsWith branch.) - [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] - public void Discovered_nodes_dropped_when_driver_rebound_to_a_different_equipment() - { - var db = NewInMemoryDbFactory(); - var factory = new SubscribingDriverFactory("Modbus"); - var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - - var (actor, publish, coordinator) = SpawnHostAndApply(db, deploymentId, factory); - - actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] - { - new DiscoveredNode( - FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" }, - BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, - Writable: false, IsHistorized: false), - })); - - // First injection materialises under EQ-1 (the cached plan's NodeIds are scoped to EQ-1). - publish.ExpectMsg(Timeout); - - // Apply a SECOND deployment where d1 is REBOUND to a DIFFERENT equipment EQ-2 (d1 still present + still - // resolves to exactly one equipment, but the cached plan is scoped to EQ-1). The DriverConfig is - // unchanged ("{}") so ReconcileDrivers does NOT restart d1 — exactly the config-unchanged rebind the - // guard's known-limitation comment describes. - var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB, - (Equip: "EQ-2", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId())); - coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); - - // After draining the fresh RebuildAddressSpace, NO MaterialiseDiscoveredNodes is re-told — the cached - // EQ-1-scoped plan was dropped by the rebind guard (its NodeId no longer starts with "EQ-2/"). - publish.ExpectMsg(Timeout); - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); - } - - /// Follow-up C, part 2: a CONFIG-UNCHANGED rebind must RE-TRIGGER discovery on the driver's child - /// so the dropped FixedTree re-grafts under the NEW equipment on the next pass (rather than staying absent - /// until the driver's next natural reconnect). Wires a REAL child (policy - /// , ever-growing set) so the connect-time pass populates the - /// per-equipment cache under EQ-1, then redeploys to rebind d1 → EQ-2 with the SAME (unchanged) DriverConfig - /// (so ReconcileDrivers does NOT restart the child — exactly the config-unchanged rebind). The - /// re-inject tail drops the stale EQ-1 entry and must send - /// to d1's child. The trigger reaching the child is observed at the host level (the only faithful seam — there - /// is no probe-as-driver-child seam): the child re-runs discovery (its DiscoverCount advances past the - /// single Once pass — impossible without the trigger, since the child stays Connected so nothing else re-kicks - /// discovery) AND a fresh re-grafts the FixedTree - /// under the new equipment EQ-2. - [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] - public void Config_unchanged_rebind_re_triggers_discovery_on_the_child() - { - var db = NewInMemoryDbFactory(); - // A REAL ITagDiscovery child (Once policy) — so the connect-time pass populates the cache and a - // TriggerRediscovery re-runs discovery, making the trigger observable at the host level. - var factory = new DiscoverableSubscribingDriverFactory("Modbus"); - var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - - var coordinator = CreateTestProbe(); - var publish = CreateTestProbe(); - var vtHost = CreateTestProbe(); - - var actor = Sys.ActorOf(DriverHostActor.Props( - db, TestNode, coordinator.Ref, - driverFactory: factory, - localRoles: new HashSet { "driver" }, - opcUaPublishActor: publish.Ref, - virtualTagHostOverride: vtHost.Ref)); - - actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId())); - coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); - publish.ExpectMsg(Timeout); - - // The child connects and runs its single (Once) post-connect discovery pass, which the host grafts - // under EQ-1 — this POPULATES the per-equipment cache (_discoveredByDriver[d1] = { EQ-1: plan }). - publish.FishForMessage( - m => m.EquipmentRootNodeId == "EQ-1", Timeout); - AwaitAssert(() => factory.DiscoverCount.ShouldBe(1), duration: Timeout); - - // Redeploy: REBIND d1 from EQ-1 → EQ-2 (same FullName; DriverConfig "{}" unchanged so ReconcileDrivers - // does NOT restart the child). The re-inject tail drops the stale EQ-1-scoped cache entry. - var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB, - (Equip: "EQ-2", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId())); - coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); - - // The drop must SEND DriverInstanceActor.TriggerRediscovery to d1's (still-Connected) child, which - // re-runs discovery: DiscoverCount advances past the single connect pass — the observable proof the - // trigger reached the child. (Pre-task: no trigger ⇒ Once already settled, child never reconnects ⇒ - // DiscoverCount stays 1 ⇒ this fails.) - AwaitAssert(() => factory.DiscoverCount.ShouldBeGreaterThan(1), duration: TimeSpan.FromSeconds(10)); - - // ... and the re-triggered pass re-grafts the FixedTree under the NEW equipment EQ-2 (the host resolves - // d1 → EQ-2 against the new _lastComposition). The re-discovery re-populates the cache as - // { EQ-2: plan-scoped-to-EQ-2 }. - publish.FishForMessage( - m => m.EquipmentRootNodeId == "EQ-2", Timeout); - var discoverCountAfterRebind = factory.DiscoverCount; // 2 (connect pass + the rebind re-trigger pass) - - // Convergence: a FURTHER unchanged redeploy (SAME EQ-2 composition) must NOT drop again, NOT re-trigger, - // and NOT advance discovery — proving the re-trigger CONVERGES and doesn't loop. The cached EQ-2 plan now - // SURVIVES the re-inject tail (candidate + NodeIds still scoped to EQ-2), so EQ-2 simply re-materialises - // (the Task-8 survivor re-apply) — but droppedAny stays false, so no TriggerRediscovery is sent and no - // fresh discovery pass runs. - var deploymentId3 = SeedDeploymentWithEquipmentTags(db, RevC, - (Equip: "EQ-2", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - actor.Tell(new DispatchDeployment(deploymentId3, RevC, CorrelationId.NewId())); - coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); - publish.ExpectMsg(Timeout); - // The survivor re-apply re-materialises EQ-2 (NOT a drop+re-trigger). - publish.ExpectMsg(Timeout) - .EquipmentRootNodeId.ShouldBe("EQ-2"); - // No further publish traffic and NO further discovery pass — the re-trigger converged. - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); - factory.DiscoverCount.ShouldBe(discoverCountAfterRebind); - } - - /// Negative / regression guard: a routine redeploy of a discovery-capable driver that does NOT - /// rebind (SAME composition, new revision) must NOT spuriously re-trigger discovery. The cached EQ-1 plan - /// SURVIVES the re-inject tail (its equipment still resolves and its NodeIds are still EQ-1-scoped), so it is - /// re-applied (EQ-1 re-materialises — the Task-8 survival path) — but NOTHING is dropped, so droppedAny - /// stays false and no is sent. Uses the REAL - /// child (the existing ExpectNoMsg drop tests use a NON-discovery driver, - /// whose child would no-op a TriggerRediscovery in StartDiscovery's is not ITagDiscovery - /// guard — so they would NOT catch a future regression that sets droppedAny on a non-drop path). The - /// regression is observable here: a spurious trigger would advance DiscoverCount past the single Once - /// pass. - [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] - public void No_drop_redeploy_does_not_re_trigger_discovery() - { - var db = NewInMemoryDbFactory(); - var factory = new DiscoverableSubscribingDriverFactory("Modbus"); - var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - - var coordinator = CreateTestProbe(); - var publish = CreateTestProbe(); - var vtHost = CreateTestProbe(); - - var actor = Sys.ActorOf(DriverHostActor.Props( - db, TestNode, coordinator.Ref, - driverFactory: factory, - localRoles: new HashSet { "driver" }, - opcUaPublishActor: publish.Ref, - virtualTagHostOverride: vtHost.Ref)); - - actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId())); - coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); - publish.ExpectMsg(Timeout); - - // Connect-time discovery grafts under EQ-1 and populates the cache (DiscoverCount == 1). - publish.FishForMessage( - m => m.EquipmentRootNodeId == "EQ-1", Timeout); - AwaitAssert(() => factory.DiscoverCount.ShouldBe(1), duration: Timeout); - - // Redeploy with the SAME composition (new revision so it applies; NO rebind ⇒ NO drop). - var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId())); - coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); - publish.ExpectMsg(Timeout); - // The cached EQ-1 plan SURVIVES the re-inject tail and is re-applied — EQ-1 re-materialises. - publish.ExpectMsg(Timeout) - .EquipmentRootNodeId.ShouldBe("EQ-1"); - // But NOTHING was dropped, so NO TriggerRediscovery was sent: no fresh discovery pass runs and no - // further publish traffic arrives. DiscoverCount stays 1. - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); - factory.DiscoverCount.ShouldBe(1); - } - - /// Follow-up D (one-send invariant — SURVIVOR path): a CACHED (FixedTree-discovered) driver whose - /// plan SURVIVES a redeploy must receive EXACTLY ONE - /// for that pass — the authored∪discovered UNION the re-inject tail sends — NOT the old TWO (a bulk - /// authored-only send that dropped the whole handle, then the tail union that re-subscribed it). Observed via - /// the shared driver's SubscribeCount (each non-empty SetDesiredSubscriptions ⇒ exactly one - /// SubscribeAsync — no de-dup in ): the count rises by EXACTLY 1 across the - /// redeploy and the final subscribed set is the union. (Pre-task: the bulk loop ALSO sent the authored-only - /// set first ⇒ the count rose by 2 and the set transiently dropped "ft-ref-1".) - [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] - public void Cached_driver_survivor_redeploy_sends_exactly_one_union_subscription() - { - var db = NewInMemoryDbFactory(); - var factory = new SubscribingDriverFactory("Modbus"); - var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - - var (actor, publish, coordinator) = SpawnHostAndApply(db, deploymentId, factory); - - actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] - { - new DiscoveredNode( - FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" }, - BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, - Writable: false, IsHistorized: false), - })); - - // First injection: the union subscribe (authored "40001" + discovered "ft-ref-1") lands and the cache is - // populated (_discoveredByDriver[d1] = { EQ-1: plan }). - publish.ExpectMsg(Timeout); - AwaitAssert(() => - { - var refs = factory.LastSubscribedRefs; - refs.ShouldNotBeNull(); - refs!.ShouldContain("40001"); - refs.ShouldContain("ft-ref-1"); - }, duration: Timeout); - // Let the first-injection traffic settle, then snapshot the subscribe count as the redeploy baseline. - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); - var countBeforeRedeploy = factory.SubscribeCount; - - // Redeploy the SAME composition (new revision so it applies; d1 → EQ-1 unchanged ⇒ the cached plan - // SURVIVES the re-inject tail ⇒ re-applied as a single union send). - var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId())); - coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); - publish.ExpectMsg(Timeout); - publish.ExpectMsg(Timeout); // tail survivor re-materialise - - // EXACTLY ONE SetDesiredSubscriptions this redeploy: the count rises by 1 and the set is the union (the - // combined condition is UNSATISFIABLE under the old double-send — at count+1 the set was authored-only - // (no "ft-ref-1"), at count+2 the count overshoots — so this fails RED before the fix). - AwaitAssert(() => - { - var refs = factory.LastSubscribedRefs; - refs.ShouldNotBeNull(); - refs!.ShouldContain("40001"); - refs.ShouldContain("ft-ref-1"); - factory.SubscribeCount.ShouldBe(countBeforeRedeploy + 1); - }, duration: Timeout); - // Settle + re-confirm the count did NOT creep to +2 (the retired bulk authored-only + tail union double-send). - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); - factory.SubscribeCount.ShouldBe(countBeforeRedeploy + 1); - } - - /// Follow-up D (one-send invariant — DROPPED path): a CACHED driver whose plan is FULLY DROPPED by a - /// config-unchanged rebind (the inner map empties ⇒ the driver is removed from _discoveredByDriver) - /// must still receive EXACTLY ONE — the AUTHORED-ONLY - /// fallback — so its authored subscriptions are not lost now that the bulk loop SKIPS cached drivers. The - /// re-inject tail no longer re-applies a (now-empty) plan for it, so the fallback is the only send. Observed - /// via SubscribeCount (+1) and the subscribed set ("40001" only, NOT the dropped "ft-ref-1"). (It also - /// gets a — a different message type the non-discovery - /// child no-ops, so it adds no subscribe.) Guards that the bulk-skip didn't reduce this path to ZERO sends. - [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] - public void Cached_driver_fully_dropped_redeploy_sends_exactly_one_authored_only_fallback() - { - var db = NewInMemoryDbFactory(); - var factory = new SubscribingDriverFactory("Modbus"); - var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - - var (actor, publish, coordinator) = SpawnHostAndApply(db, deploymentId, factory); - - actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] - { - new DiscoveredNode( - FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" }, - BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, - Writable: false, IsHistorized: false), - })); - - publish.ExpectMsg(Timeout); - AwaitAssert(() => - { - var refs = factory.LastSubscribedRefs; - refs.ShouldNotBeNull(); - refs!.ShouldContain("40001"); - refs.ShouldContain("ft-ref-1"); - }, duration: Timeout); - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); - var countBeforeRedeploy = factory.SubscribeCount; - - // Redeploy REBINDING d1 EQ-1 → EQ-2 (same FullName; DriverConfig "{}" unchanged ⇒ child NOT restarted). - // The cached EQ-1-scoped plan is dropped by the rebind guard ⇒ the inner map empties ⇒ d1 is removed from - // _discoveredByDriver ⇒ NO survivor re-apply. The fallback must send the authored-only set so "40001" - // stays subscribed this pass. - var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB, - (Equip: "EQ-2", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId())); - coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); - publish.ExpectMsg(Timeout); - // No MaterialiseDiscoveredNodes — the plan was dropped, not re-grafted — so no further publish traffic. - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); - - // EXACTLY ONE SetDesiredSubscriptions this redeploy: the authored-only fallback. The count rises by 1 and - // the set is "40001" only (the dropped FixedTree ref is gone). - AwaitAssert(() => - { - var refs = factory.LastSubscribedRefs; - refs.ShouldNotBeNull(); - refs!.ShouldContain("40001"); - refs.ShouldNotContain("ft-ref-1"); - refs.Count.ShouldBe(1); - factory.SubscribeCount.ShouldBe(countBeforeRedeploy + 1); - }, duration: Timeout); - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); - factory.SubscribeCount.ShouldBe(countBeforeRedeploy + 1); - } - - /// Follow-up D (one-send invariant — NON-CACHED path): a driver that was NEVER cached (no FixedTree - /// discovered) is unaffected by the cached-driver bulk-loop skip — it still gets EXACTLY ONE bulk - /// authored-only per redeploy (the re-inject tail - /// never runs for it). Guards that the skip didn't accidentally suppress (or double) a non-cached driver's - /// send. Observed via SubscribeCount (+1) and the subscribed set ("40001" only). - [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] - public void Non_cached_driver_redeploy_sends_exactly_one_authored_only_subscription() - { - var db = NewInMemoryDbFactory(); - var factory = new SubscribingDriverFactory("Modbus"); - var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - - var (actor, publish, coordinator) = SpawnHostAndApply(db, deploymentId, factory); - - // No DiscoveredNodesReady ⇒ d1 is never cached. Wait for the initial bulk subscribe to settle, then - // snapshot the count as the redeploy baseline. - AwaitAssert(() => - { - var refs = factory.LastSubscribedRefs; - refs.ShouldNotBeNull(); - refs!.ShouldContain("40001"); - }, duration: Timeout); - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); - var countBeforeRedeploy = factory.SubscribeCount; - - // Redeploy SAME composition (new rev). d1 is NOT in _discoveredByDriver ⇒ the bulk loop sends it once and - // the re-inject tail skips it. - var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId())); - coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); - publish.ExpectMsg(Timeout); - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); // no materialise — d1 was never cached - - AwaitAssert(() => - { - var refs = factory.LastSubscribedRefs; - refs.ShouldNotBeNull(); - refs!.ShouldContain("40001"); - refs.Count.ShouldBe(1); - factory.SubscribeCount.ShouldBe(countBeforeRedeploy + 1); - }, duration: Timeout); - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); - factory.SubscribeCount.ShouldBe(countBeforeRedeploy + 1); - } - - /// Follow-up D (one-send invariant — EMPTY-authored DROPPED path, a capability THIS branch newly - /// enabled): a CACHED driver with ZERO authored tags (bound to its equipment only via - /// ) whose FixedTree plan is FULLY DROPPED by a rebind redeploy - /// receives an EMPTY authored-only fallback , which - /// the Connected handler routes to Unsubscribe (dropping the stale FixedTree handle) — NOT a subscribe. - /// Proven by SubscribeCount staying FLAT across the redeploy (no spurious subscribe), closing the - /// SubscribeCount-proxy blind spot for the empty-set fallback. - [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] - public void Cached_tag_less_driver_fully_dropped_redeploy_sends_empty_fallback_without_subscribing() - { - var db = NewInMemoryDbFactory(); - var factory = new SubscribingDriverFactory("Modbus"); - // d1 bound to EQ-1 via EquipmentNode.DriverInstanceId, with NO authored tags. - var deploymentId = SeedDeploymentWithTagLessEquipment(db, RevA, equipmentId: "EQ-1", driverId: "d1"); - - var (actor, publish, coordinator) = SpawnHostAndApply(db, deploymentId, factory); - - actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] - { - new DiscoveredNode( - FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" }, - BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, - Writable: false, IsHistorized: false), - })); - - // First injection: the discovered FixedTree materialises under EQ-1 and the child subscribes the - // discovered-only set (no authored ref to union) — this populates _discoveredByDriver[d1] = { EQ-1: plan }. - publish.ExpectMsg(Timeout); - AwaitAssert(() => - { - var refs = factory.LastSubscribedRefs; - refs.ShouldNotBeNull(); - refs!.ShouldContain("ft-ref-1"); - }, duration: Timeout); - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); - var countBeforeRedeploy = factory.SubscribeCount; - - // Redeploy REBINDING the tag-less d1 EQ-1 → EQ-2 (still tag-less; DriverConfig "{}" unchanged ⇒ child NOT - // restarted). The cached EQ-1 plan is no longer a candidate ⇒ dropped ⇒ inner map empties ⇒ d1 removed - // from _discoveredByDriver. With ZERO authored tags the fallback set is EMPTY ⇒ the child Unsubscribes. - var deploymentId2 = SeedDeploymentWithTagLessEquipment(db, RevB, equipmentId: "EQ-2", driverId: "d1"); - actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId())); - coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); - publish.ExpectMsg(Timeout); - // No re-materialise — the cached plan was dropped (not re-grafted). - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); - - // The empty fallback routes to Unsubscribe, NOT SubscribeAsync ⇒ the count does NOT rise. (A non-empty or - // spurious subscribe — the bug this guards against — would increment it.) - factory.SubscribeCount.ShouldBe(countBeforeRedeploy); - } - - /// Spawns the host with the subscribing driver factory + a publish probe, dispatches the - /// deployment, and waits for the Applied ACK so the apply (and thus _lastComposition + the live - /// child + the initial SubscribeBulk pass) has completed before the test injects discovered nodes. A - /// VirtualTag-host probe is injected so the real host isn't spawned. The - /// that lands on the publish probe during apply is drained so the test's materialise / value-update - /// assertions see only post-apply traffic. - private (IActorRef Actor, Akka.TestKit.TestProbe Publish, Akka.TestKit.TestProbe Coordinator) SpawnHostAndApply( - IDbContextFactory db, DeploymentId deploymentId, IDriverFactory factory) - { - var coordinator = CreateTestProbe(); - var publish = CreateTestProbe(); - var vtHost = CreateTestProbe(); - - var actor = Sys.ActorOf(DriverHostActor.Props( - db, TestNode, coordinator.Ref, - driverFactory: factory, - localRoles: new HashSet { "driver" }, - opcUaPublishActor: publish.Ref, - virtualTagHostOverride: vtHost.Ref)); - - actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId())); - coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); - - publish.ExpectMsg(Timeout); - - return (actor, publish, coordinator); - } - - /// - /// Seeds a Sealed deployment whose artifact carries the minimal arrays - /// DeploymentArtifact.BuildEquipmentTagPlans needs to project equipment tags, plus a - /// DriverInstances row with a non-Windows-only DriverType ("Modbus") + Enabled flag so - /// a REAL (non-stubbed) child is spawned (mirrors - /// DriverHostActorWriteRoutingTests.SeedDeploymentWithEquipmentTags). - /// - private static DeploymentId SeedDeploymentWithEquipmentTags( - IDbContextFactory db, RevisionHash rev, - params (string Equip, string Driver, string FullName, string? Folder, string Name)[] tags) - { - var driverIds = tags.Select(t => t.Driver).Distinct(StringComparer.Ordinal).ToArray(); - - var artifact = JsonSerializer.SerializeToUtf8Bytes(new - { - Namespaces = new[] - { - new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0 - }, - DriverInstances = driverIds.Select(d => new - { - DriverInstanceRowId = Guid.NewGuid(), - DriverInstanceId = d, - Name = d, - DriverType = "Modbus", // not Windows-only ⇒ a real child is spawned (not stubbed) - Enabled = true, - DriverConfig = "{}", - NamespaceId = "ns-eq", - }).ToArray(), - Tags = tags.Select((t, i) => new - { - TagId = $"tag-{i}", - EquipmentId = t.Equip, - DriverInstanceId = t.Driver, - Name = t.Name, - FolderPath = t.Folder, - DataType = "Double", - TagConfig = JsonSerializer.Serialize(new { FullName = t.FullName }), - }).ToArray(), - }); - - var id = DeploymentId.NewId(); - using var ctx = db.CreateDbContext(); - ctx.Deployments.Add(new Deployment - { - DeploymentId = id.Value, - RevisionHash = rev.Value, - Status = DeploymentStatus.Sealed, - CreatedBy = "test", - SealedAtUtc = DateTime.UtcNow, - ArtifactBlob = artifact, - }); - ctx.SaveChanges(); - return id; - } - - /// - /// Seeds a Sealed deployment whose artifact binds an equipment to a driver via the - /// Equipment row's DriverInstanceId (the - /// projection) but carries NO authored equipment tags — so the equipment can only be resolved from - /// EquipmentNodes, not EquipmentTags. A DriverInstances row (non-Windows-only - /// "Modbus", Enabled) is included so a REAL (non-stubbed) child is - /// spawned for the driver even though it has zero tags. - /// - private static DeploymentId SeedDeploymentWithTagLessEquipment( - IDbContextFactory db, RevisionHash rev, string equipmentId, string driverId) - { - var artifact = JsonSerializer.SerializeToUtf8Bytes(new - { - Namespaces = new[] - { - new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0 - }, - DriverInstances = new[] - { - new - { - DriverInstanceRowId = Guid.NewGuid(), - DriverInstanceId = driverId, - Name = driverId, - DriverType = "Modbus", // not Windows-only ⇒ a real child is spawned (not stubbed) - Enabled = true, - DriverConfig = "{}", - NamespaceId = "ns-eq", - }, - }, - // The equipment binds to the driver via DriverInstanceId — the NEW resolution path — with no tags. - Equipment = new[] - { - new - { - EquipmentId = equipmentId, - Name = equipmentId, - UnsLineId = (string?)null, - DriverInstanceId = driverId, - DeviceId = (string?)null, - }, - }, - Tags = Array.Empty(), - }); - - var id = DeploymentId.NewId(); - using var ctx = db.CreateDbContext(); - ctx.Deployments.Add(new Deployment - { - DeploymentId = id.Value, - RevisionHash = rev.Value, - Status = DeploymentStatus.Sealed, - CreatedBy = "test", - SealedAtUtc = DateTime.UtcNow, - ArtifactBlob = artifact, - }); - ctx.SaveChanges(); - return id; - } - - /// - /// Seeds a Sealed deployment whose artifact binds ONE driver to MULTIPLE equipments, each via a - /// distinct Device row whose DeviceConfig carries a HostAddress — so each - /// resolves a distinct (the shape - /// the multi-device FixedTree partition keys on). No authored tags (tag-less): the equipments are - /// resolved purely from EquipmentNodes. The driver row is non-Windows-only ("Modbus", Enabled) - /// so a REAL (non-stubbed) child is spawned. - /// - private static DeploymentId SeedDeploymentWithMultiDeviceEquipments( - IDbContextFactory db, RevisionHash rev, string driverId, - params (string Equip, string DeviceId, string Host)[] equipments) - { - var artifact = JsonSerializer.SerializeToUtf8Bytes(new - { - Namespaces = new[] - { - new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0 - }, - DriverInstances = new[] - { - new - { - DriverInstanceRowId = Guid.NewGuid(), - DriverInstanceId = driverId, - Name = driverId, - DriverType = "Modbus", // not Windows-only ⇒ a real child is spawned (not stubbed) - Enabled = true, - DriverConfig = "{}", - NamespaceId = "ns-eq", - }, - }, - // Each device carries a HostAddress so EquipmentNode.DeviceHost resolves (via the shared extractor). - Devices = equipments.Select(e => new - { - DeviceId = e.DeviceId, - DriverInstanceId = driverId, - Name = e.DeviceId, - DeviceConfig = JsonSerializer.Serialize(new { HostAddress = e.Host }), - }).ToArray(), - // Each equipment binds to the driver AND a device — the multi-device resolution path. - Equipment = equipments.Select(e => new - { - EquipmentId = e.Equip, - Name = e.Equip, - UnsLineId = (string?)null, - DriverInstanceId = driverId, - DeviceId = e.DeviceId, - }).ToArray(), - Tags = Array.Empty(), - }); - - var id = DeploymentId.NewId(); - using var ctx = db.CreateDbContext(); - ctx.Deployments.Add(new Deployment - { - DeploymentId = id.Value, - RevisionHash = rev.Value, - Status = DeploymentStatus.Sealed, - CreatedBy = "test", - SealedAtUtc = DateTime.UtcNow, - ArtifactBlob = artifact, - }); - ctx.SaveChanges(); - return id; - } - - /// Factory producing a single shared for the supported - /// type, exposing its most-recent subscribed reference set for assertions (mirrors - /// DriverHostActorWriteRoutingTests.RecordingDriverFactory, but the driver is - /// so the merged subscription is observable). - private sealed class SubscribingDriverFactory : IDriverFactory - { - private readonly string _supportedType; - private readonly SubscribableStubDriver _driver = new(); - public SubscribingDriverFactory(string supportedType) { _supportedType = supportedType; } - - /// The reference set passed to the driver's most recent SubscribeAsync call. - public IReadOnlyList? LastSubscribedRefs => _driver.LastSubscribedRefs; - - /// Number of SubscribeAsync calls so far — lets a test prove a redundant re-apply did - /// NOT force a (drop-then-)re-subscribe of the whole handle. - public int SubscribeCount => _driver.SubscribeCount; - - /// - public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson) => - string.Equals(driverType, _supportedType, StringComparison.Ordinal) ? _driver : null; - - /// - public IReadOnlyCollection SupportedTypes => new[] { _supportedType }; - } - - /// Factory producing a single shared for the supported - /// type — a real (non-stubbed) child that exposes , - /// so the host's post-connect discovery loop populates the discovered-node cache AND a - /// re-runs discovery (the seam the rebind re-trigger - /// test asserts through). Exposes the driver's pass count so a test can observe the trigger landing. - private sealed class DiscoverableSubscribingDriverFactory : IDriverFactory - { - private readonly string _supportedType; - private DiscoverableSubscribingDriver? _driver; - public DiscoverableSubscribingDriverFactory(string supportedType) { _supportedType = supportedType; } - - /// Number of DiscoverAsync passes the child has driven (advances on every - /// connect-time pass and every -driven pass). - public int DiscoverCount => _driver?.DiscoverCount ?? 0; - - /// - public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson) => - string.Equals(driverType, _supportedType, StringComparison.Ordinal) - ? _driver ??= new DiscoverableSubscribingDriver(driverInstanceId) - : null; - - /// - public IReadOnlyCollection SupportedTypes => new[] { _supportedType }; - } - - /// A that is BOTH (so the host's subscribe - /// path is exercised) and with policy - /// — exactly one post-connect pass, re-runnable by . Each - /// pass streams an ever-growing FixedTree (pass N → N nodes, refs "ft-ref-1".."ft-ref-N", none shadowing the - /// authored "40001"), so a re-triggered pass yields a grown set the host re-applies — and the public - /// makes the trigger observable at the host level. Re-implements - /// (the base hardcodes "stub-driver-1") so the - /// spawned child reports the SPEC's id — otherwise its auto-sent - /// would carry the wrong driver id and resolve no equipment. - private sealed class DiscoverableSubscribingDriver : StubDriver, IDriver, ISubscribable, ITagDiscovery - { - private int _passCount; - private readonly string _driverInstanceId; - private readonly StubHandle _handle = new(); - - public DiscoverableSubscribingDriver(string driverInstanceId) => _driverInstanceId = driverInstanceId; - - /// The spec's driver instance id (re-mapped from the base "stub-driver-1"). - public new string DriverInstanceId => _driverInstanceId; - - /// Single post-connect pass per (re)kick — re-runnable by TriggerRediscovery. - public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once; - - /// Number of DiscoverAsync passes driven so far. - public int DiscoverCount => Volatile.Read(ref _passCount); - - /// Never raised (the test asserts on discovery + materialise, not data changes); explicit - /// empty accessors satisfy the interface without a never-used backing field (no CS0067). - public event EventHandler? OnDataChange { add { } remove { } } - - /// - public Task SubscribeAsync( - IReadOnlyList fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) - => Task.FromResult(_handle); - - /// - public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken) - => Task.CompletedTask; - - /// Streams an ever-growing FixedTree (pass N → N nodes). - public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) - { - var pass = Interlocked.Increment(ref _passCount); - var fixedTree = builder.Folder("FixedTree", "FixedTree"); - for (var i = 0; i < pass; i++) - { - fixedTree.Variable($"v{i}", $"v{i}", new DriverAttributeInfo( - FullName: $"ft-ref-{i + 1}", - DriverDataType: DriverDataType.Float64, - IsArray: false, - ArrayDim: null, - SecurityClass: SecurityClassification.ViewOnly, - IsHistorized: false)); - } - return Task.CompletedTask; - } - } -} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDiscoveryTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDiscoveryTests.cs deleted file mode 100644 index 3ae48247..00000000 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDiscoveryTests.cs +++ /dev/null @@ -1,583 +0,0 @@ -using Akka.Actor; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; -using ZB.MOM.WW.OtOpcUa.Core.Resilience; -using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; -using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; - -namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; - -/// -/// Covers the bounded post-connect re-discovery loop: when an driver -/// reaches Connected, runs repeated discovery passes (FOCAS-style: -/// the FixedTree is suppressed until the driver's cache populates ~0–2s after connect) and ships each -/// pass's captured nodes to its parent as . The -/// loop STOPS once the non-empty discovered set stabilises (or the attempt cap is hit) — it must not -/// spin forever. A driver that does not implement produces no passes at all. -/// -[Trait("Category", "Unit")] -public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase -{ - /// - /// A discoverable driver whose first two passes yield nothing (cache still warming) and whose third - /// pass onward yields a stable 3-node set: the actor ships every pass, then STOPS once the non-empty - /// set repeats. The final carries the 3 nodes - /// and no further passes arrive — proving the loop is bounded. - /// - [Fact] - public void Discovery_retries_until_set_stabilises_then_stops() - { - var driver = new DiscoverableStubDriver(); - var parent = CreateTestProbe(); - // #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/ - // disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it. - parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged); - // Tiny interval so the bounded retry runs in well under a second (no real-time waits). - var actor = parent.ChildActorOf(DriverInstanceActor.Props( - driver, rediscoverInterval: TimeSpan.FromMilliseconds(20))); - - // Drive Connecting → Connected; the Connected entry kicks discovery. - actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); - - // Each discovery pass publishes one DiscoveredNodesReady. The fake stabilises after pass 4 - // (passes: 0,0,3,3), so exactly 4 messages arrive, then the stream stops. - var msgs = new List(); - for (var i = 0; i < 4; i++) - msgs.Add(parent.ExpectMsg(TimeSpan.FromSeconds(2))); - - // The loop must STOP once the non-empty set has stabilised — no fifth pass. - parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); - - // Early passes were empty (FixedTree cache still populating). - msgs[0].Nodes.Count.ShouldBe(0); - msgs[1].Nodes.Count.ShouldBe(0); - // The set then appears and stabilises at 3 nodes. - msgs[2].Nodes.Count.ShouldBe(3); - var final = msgs[^1]; - final.Nodes.Count.ShouldBe(3); - final.DriverInstanceId.ShouldBe(driver.DriverInstanceId); - final.Nodes.Select(n => n.FullReference).ShouldBe(new[] { "m.fixed.v0", "m.fixed.v1", "m.fixed.v2" }); - - // The driver was asked exactly as many times as messages published — no extra zombie pass. - driver.DiscoverCount.ShouldBe(4); - } - - /// A driver that does not implement produces no discovery passes — - /// the Connected entry's discovery kick is a no-op, so the parent receives no - /// . - [Fact] - public void Driver_without_ITagDiscovery_produces_no_discovery() - { - var driver = new SubscribableStubDriver(); // IDriver + ISubscribable, NOT ITagDiscovery - var parent = CreateTestProbe(); - // #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/ - // disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it. - parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged); - var actor = parent.ChildActorOf(DriverInstanceActor.Props( - driver, rediscoverInterval: TimeSpan.FromMilliseconds(20))); - - actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); - AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2)); - - // No discovery capability ⇒ never any DiscoveredNodesReady to the parent. - parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); - } - - /// - /// Discovery RE-RUNS on every return to Connected: after the initial discovery settles, a - /// drives the actor through Reconnecting and - /// back to Connected (via the auto-retry timer, the same path the existing reconnect tests use), - /// and a fresh bounded discovery loop fires — keeping the injected tree current if the backend's - /// capabilities changed across the reconnect. The new init bumps the generation, so any - /// pre-reconnect tick is discarded by the generation guard (the initial loop has already settled - /// here, so none are in flight). - /// - [Fact] - public void Discovery_reruns_after_reconnect() - { - var driver = new DiscoverableStubDriver(); - var parent = CreateTestProbe(); - // #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/ - // disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it. - parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged); - // Tiny reconnect + rediscover intervals so the whole reconnect-then-rediscover cycle runs fast. - var actor = parent.ChildActorOf(DriverInstanceActor.Props( - driver, - reconnectInterval: TimeSpan.FromMilliseconds(50), - rediscoverInterval: TimeSpan.FromMilliseconds(20))); - - actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); - - // Drain the initial settling passes (0,0,3,3) and confirm the first loop stopped. - for (var i = 0; i < 4; i++) - parent.ExpectMsg(TimeSpan.FromSeconds(2)); - parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200)); - var passesBeforeReconnect = driver.DiscoverCount; // 4 - - // Force a reconnect: Connected → Reconnecting → (auto retry-connect) → Connected again. - actor.Tell(new DriverInstanceActor.ForceReconnect()); - - // A fresh discovery pass must arrive after the reconnect — the cache is warm now, so it sees - // the stable 3-node set immediately. - var afterReconnect = parent.ExpectMsg(TimeSpan.FromSeconds(3)); - afterReconnect.Nodes.Count.ShouldBe(3); - afterReconnect.DriverInstanceId.ShouldBe(driver.DriverInstanceId); - - // The driver was discovered again — proves a fresh loop ran, not a replay of the old one. - driver.DiscoverCount.ShouldBeGreaterThan(passesBeforeReconnect); - } - - /// - /// Regression for the Critical: a driver whose DiscoverAsync completes ASYNCHRONOUSLY (off the - /// actor thread) must still ship . The handler - /// touches Context.Parent + Timers AFTER awaiting discovery; if it awaited with - /// ConfigureAwait(false) the continuation would resume off the actor context and those calls - /// would throw NotSupportedException("no active ActorContext") — the handler would fault and no - /// message would arrive. Synchronous (Task.CompletedTask) stubs mask the bug; this one forces a - /// genuine off-context resume (modelled on SubscribableStubDriver.UnsubscribeYields). - /// - [Fact] - public void Async_completing_discovery_resumes_on_actor_context_and_publishes() - { - var driver = new YieldingDiscoverableStubDriver(); - var parent = CreateTestProbe(); - // #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/ - // disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it. - parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged); - var actor = parent.ChildActorOf(DriverInstanceActor.Props( - driver, rediscoverInterval: TimeSpan.FromMilliseconds(20))); - - actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); - - // With the fix the handler resumes on the actor context, so the publish succeeds and the parent gets - // a non-empty set. Without it the handler faults at Context.Parent.Tell and this times out. - var published = parent.ExpectMsg(TimeSpan.FromSeconds(2)); - published.Nodes.Count.ShouldBe(3); - published.DriverInstanceId.ShouldBe(driver.DriverInstanceId); - } - - /// - /// Arch-review #10: the SAME async-discovery context-preservation guarantee, but driven through the - /// REAL — whose Polly pipeline is awaited with - /// ConfigureAwait(false) internally. This is the one path the pass-through - /// NullDriverCapabilityInvoker (used by the sibling test) cannot cover: it returns the - /// call-site task directly, so it never exercises the invoker's internal off-context await. If that - /// internal ConfigureAwait(false) leaked to the actor's own await _invoker.ExecuteAsync(…), - /// the post-await Context.Parent.Tell in HandleRediscoverAsync would fault with - /// "no active ActorContext" and no message would arrive. Its arrival proves the actor context survives - /// the real resilience pipeline. - /// - [Fact] - public void Async_discovery_through_real_CapabilityInvoker_preserves_actor_context() - { - var pipelineBuilder = new DriverResiliencePipelineBuilder(); // real Polly pipeline, tier-A defaults - var invoker = new CapabilityInvoker( - pipelineBuilder, - driverInstanceId: "yielding-discoverable", - optionsAccessor: () => new DriverResilienceOptions { Tier = DriverTier.A }, - driverType: "Stub"); - var driver = new YieldingDiscoverableStubDriver(); - var parent = CreateTestProbe(); - // #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/ - // disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it. - parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged); - var actor = parent.ChildActorOf(DriverInstanceActor.Props( - driver, rediscoverInterval: TimeSpan.FromMilliseconds(20), invoker: invoker)); - - actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); - - var published = parent.ExpectMsg(TimeSpan.FromSeconds(5)); - published.Nodes.Count.ShouldBe(3); - published.DriverInstanceId.ShouldBe(driver.DriverInstanceId); - } - - /// - /// The attempt cap bounds a discovered set that never stabilises: a driver whose set keeps GROWING - /// (1,2,3,…) never repeats its signature, so the loop is stopped only by - /// rediscoverMaxAttempts. With a cap of 3, exactly 3 passes are published, then the stream stops. - /// - [Fact] - public void Never_stabilising_discovery_is_bounded_by_the_attempt_cap() - { - var driver = new GrowingDiscoverableStubDriver(); - var parent = CreateTestProbe(); - // #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/ - // disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it. - parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged); - var actor = parent.ChildActorOf(DriverInstanceActor.Props( - driver, rediscoverInterval: TimeSpan.FromMilliseconds(20), rediscoverMaxAttempts: 3)); - - actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); - - var msgs = new List(); - for (var i = 0; i < 3; i++) - msgs.Add(parent.ExpectMsg(TimeSpan.FromSeconds(2))); - - // Cap reached — no fourth pass even though the set never stabilised. - parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); - - // The set genuinely kept growing across the capped passes (1,2,3 nodes). - msgs.Select(m => m.Nodes.Count).ShouldBe(new[] { 1, 2, 3 }); - driver.DiscoverCount.ShouldBe(3); - } - - /// - /// A driver whose is - /// opts out of post-connect discovery entirely: the - /// Connected entry's discovery kick returns before scheduling the first tick, so the driver is never - /// asked to discover and the parent receives no . - /// - [Fact] - public void Discovery_policy_Never_runs_no_passes_and_publishes_nothing() - { - var driver = new DiscoverableStubDriver(DiscoveryRediscoverPolicy.Never); - var parent = CreateTestProbe(); - // #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/ - // disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it. - parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged); - var actor = parent.ChildActorOf(DriverInstanceActor.Props( - driver, rediscoverInterval: TimeSpan.FromMilliseconds(20))); - - actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); - // Connect happened (the discovery decision is made on the Connected entry)... - AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2)); - - // ...but policy=Never ⇒ no discovery pass is ever run and nothing is published. - parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); - driver.DiscoverCount.ShouldBe(0); - } - - /// - /// A driver whose is - /// runs EXACTLY one post-connect pass even when its - /// discovered set would keep growing forever — under UntilStable the never-repeating signature - /// would retry to the attempt cap. Exactly one - /// is published and no further RediscoverTick is scheduled. - /// - [Fact] - public void Discovery_policy_Once_publishes_exactly_one_pass_even_when_set_keeps_growing() - { - var driver = new GrowingDiscoverableStubDriver(DiscoveryRediscoverPolicy.Once); - var parent = CreateTestProbe(); - // #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/ - // disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it. - parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged); - var actor = parent.ChildActorOf(DriverInstanceActor.Props( - driver, rediscoverInterval: TimeSpan.FromMilliseconds(20))); - - actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); - - // Exactly one pass is published (the first, growing set → 1 node)... - var only = parent.ExpectMsg(TimeSpan.FromSeconds(2)); - only.Nodes.Count.ShouldBe(1); - only.DriverInstanceId.ShouldBe(driver.DriverInstanceId); - - // ...and NO second tick is scheduled, even though the set would keep growing under UntilStable. - parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); - driver.DiscoverCount.ShouldBe(1); - } - - /// - /// means one pass PER (re)connect cycle — not one pass - /// ever. After the initial single pass settles, a - /// drives the actor through Reconnecting and back to Connected (via the auto retry-connect timer), and - /// StartDiscovery re-kicks discovery — which must run EXACTLY ONE more pass, not the full attempt - /// cap. Uses the ever-growing fake with a small cap (3): under a (wrong) policy-ignoring loop the - /// never-stabilising set would publish 3 passes per connect, so a single post-reconnect pass proves - /// Once is honoured on the reconnect path too. Guards the exact StartDiscovery-on-reconnect path - /// the follow-on TriggerRediscovery task touches. - /// - [Fact] - public void Discovery_policy_Once_reruns_one_pass_on_reconnect() - { - var driver = new GrowingDiscoverableStubDriver(DiscoveryRediscoverPolicy.Once); - var parent = CreateTestProbe(); - // #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/ - // disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it. - parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged); - // Small reconnect + rediscover intervals so the cycle runs fast; cap 3 so a (wrong) full loop is - // visibly more than the one pass Once must run per (re)connect. - var actor = parent.ChildActorOf(DriverInstanceActor.Props( - driver, - reconnectInterval: TimeSpan.FromMilliseconds(50), - rediscoverInterval: TimeSpan.FromMilliseconds(20), - rediscoverMaxAttempts: 3)); - - actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); - - // Initial connect: Once ⇒ exactly one pass (growing set → 1 node), then no more. - var first = parent.ExpectMsg(TimeSpan.FromSeconds(2)); - first.Nodes.Count.ShouldBe(1); - parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200)); - driver.DiscoverCount.ShouldBe(1); - - // Force a reconnect: Connected → Reconnecting → (auto retry-connect) → Connected again. - actor.Tell(new DriverInstanceActor.ForceReconnect()); - - // Once = one pass PER (re)connect: exactly ONE additional pass after the reconnect, NOT the full cap. - // The set keeps growing across the reconnect (same driver instance), so this pass yields 2 nodes. - var afterReconnect = parent.ExpectMsg(TimeSpan.FromSeconds(3)); - afterReconnect.Nodes.Count.ShouldBe(2); - afterReconnect.DriverInstanceId.ShouldBe(driver.DriverInstanceId); - - // No further passes — Once did NOT run the attempt cap on reconnect; one pass per connect cycle. - parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); - driver.DiscoverCount.ShouldBe(2); - } - - /// - /// The per-pass discovery timeout is injectable via so tests - /// can control it without real-time delays. The default constant must be 30 seconds (behaviour-preserving). - /// Wiring is verified by constructing via Props with a custom value and confirming the actor starts - /// and begins discovery normally. - /// - [Fact] - public void Discovery_timeout_default_constant_is_30s_and_Props_accepts_custom_value() - { - // The constant must exist and preserve the pre-refactor 30 s literal. - DriverInstanceActor.DefaultRediscoverDiscoverTimeout.ShouldBe(TimeSpan.FromSeconds(30)); - - // Props must accept the new optional parameter — no throw and actor starts normally. - var driver = new DiscoverableStubDriver(); - var parent = CreateTestProbe(); - // #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/ - // disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it. - parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged); - var actor = parent.ChildActorOf(DriverInstanceActor.Props( - driver, - rediscoverInterval: TimeSpan.FromMilliseconds(20), - rediscoverDiscoverTimeout: TimeSpan.FromSeconds(5))); - - actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); - - // Actor starts and discovery publishes — confirms the custom timeout was wired without error. - parent.ExpectMsg(TimeSpan.FromSeconds(2)); - } - - /// - /// received while Connected re-kicks the - /// post-connect discovery loop: after the initial discovery has settled, sending the message drives a - /// FRESH discovery pass — the driver's DiscoverCount advances and a new - /// is published. This is the message - /// uses to re-run discovery after rebinding the driver to a new equipment. - /// - [Fact] - public void TriggerRediscovery_when_Connected_reruns_discovery() - { - // Once-policy growing stub: exactly ONE pass per (re)kick, so each StartDiscovery publishes precisely - // one DiscoveredNodesReady — the trigger's effect is asserted with a single ExpectMsg + ExpectNoMsg - // (no second settling pass to drain, and no stale-tick double pass alongside the fresh one). - var driver = new GrowingDiscoverableStubDriver(DiscoveryRediscoverPolicy.Once); - var parent = CreateTestProbe(); - // #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/ - // disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it. - parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged); - var actor = parent.ChildActorOf(DriverInstanceActor.Props( - driver, rediscoverInterval: TimeSpan.FromMilliseconds(20))); - - actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); - - // Initial connect: Once ⇒ exactly one pass (growing set → 1 node), then it settles. - parent.ExpectMsg(TimeSpan.FromSeconds(2)).Nodes.Count.ShouldBe(1); - parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200)); - var passesBeforeTrigger = driver.DiscoverCount; // 1 - - // Re-kick discovery via the new message — Once ⇒ exactly one fresh pass (growing set → 2 nodes). - actor.Tell(new DriverInstanceActor.TriggerRediscovery()); - - var afterTrigger = parent.ExpectMsg(TimeSpan.FromSeconds(2)); - afterTrigger.Nodes.Count.ShouldBe(2); - afterTrigger.DriverInstanceId.ShouldBe(driver.DriverInstanceId); - - // Exactly one fresh pass ran — DiscoverCount advanced by one and no extra pass arrived. - parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); - driver.DiscoverCount.ShouldBe(passesBeforeTrigger + 1); - } - - /// - /// on a driver whose - /// is does - /// NOT re-discover: the handler calls StartDiscovery, which returns early for Never, so - /// no pass runs and nothing is published — mirroring the Connected-entry Never opt-out. - /// - [Fact] - public void TriggerRediscovery_with_policy_Never_does_not_rediscover() - { - var driver = new DiscoverableStubDriver(DiscoveryRediscoverPolicy.Never); - var parent = CreateTestProbe(); - // #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/ - // disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it. - parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged); - var actor = parent.ChildActorOf(DriverInstanceActor.Props( - driver, rediscoverInterval: TimeSpan.FromMilliseconds(20))); - - actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); - AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2)); - - // Connected, but policy=Never — the trigger is honoured by StartDiscovery's early return. - actor.Tell(new DriverInstanceActor.TriggerRediscovery()); - - parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); - driver.DiscoverCount.ShouldBe(0); - } - - /// - /// received while NOT Connected is a clean silent - /// no-op in EVERY non-Connected state: no discovery pass runs, nothing is published, and the actor - /// neither crashes nor dies (its eventual (re)connect re-discovers anyway). Covers both Connecting - /// (before init completes) and Reconnecting (after a , - /// parked there by a long reconnect interval), with an intervening connect proving the actor is unharmed. - /// - [Fact] - public void TriggerRediscovery_when_not_Connected_is_a_silent_noop() - { - // Once-growing stub so a successful connect publishes exactly one pass (clean confirmation of state); - // a long reconnect interval so the actor parks in Reconnecting deterministically within the test window. - var driver = new GrowingDiscoverableStubDriver(DiscoveryRediscoverPolicy.Once); - var parent = CreateTestProbe(); - // #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/ - // disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it. - parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged); - var actor = parent.ChildActorOf(DriverInstanceActor.Props( - driver, - reconnectInterval: TimeSpan.FromSeconds(30), - rediscoverInterval: TimeSpan.FromMilliseconds(20))); - Watch(actor); - - // (1) Connecting: the actor boots into Connecting; send the trigger BEFORE InitializeRequested so it - // is handled in that non-Connected state. - actor.Tell(new DriverInstanceActor.TriggerRediscovery()); - - // No discovery resulted, and the actor is unharmed (no Terminated arrives at the watching test actor). - parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200)); - ExpectNoMsg(TimeSpan.FromMilliseconds(100)); - driver.DiscoverCount.ShouldBe(0); - - // Drive to Connected (proves the Connecting-state trigger left the actor working); Once ⇒ one pass. - actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); - parent.ExpectMsg(TimeSpan.FromSeconds(2)).Nodes.Count.ShouldBe(1); - parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200)); - var passesAfterConnect = driver.DiscoverCount; // 1 - - // (2) Reconnecting: ForceReconnect parks the actor in Reconnecting (30s retry interval ⇒ no auto - // reconnect within the window). A TriggerRediscovery here must ALSO be a clean silent no-op. Both - // messages are processed in order, so the trigger is handled while Reconnecting. - actor.Tell(new DriverInstanceActor.ForceReconnect()); - actor.Tell(new DriverInstanceActor.TriggerRediscovery()); - - parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); - ExpectNoMsg(TimeSpan.FromMilliseconds(100)); // still alive — no Terminated - driver.DiscoverCount.ShouldBe(passesAfterConnect); // no fresh pass while Reconnecting - } - - /// - /// A that also exposes . Each DiscoverAsync - /// pass is counted; passes 1–2 yield nothing (cache warming), passes 3+ yield a stable 3-node set — - /// modelling FOCAS, whose FixedTree appears once a few seconds after connect and then stays put. - /// - private sealed class DiscoverableStubDriver : StubDriver, ITagDiscovery - { - private int _passCount; - - /// Constructs the fake reporting the given ; - /// defaults to (the interface default) so the - /// existing UntilStable tests are unaffected. - public DiscoverableStubDriver(DiscoveryRediscoverPolicy policy = DiscoveryRediscoverPolicy.UntilStable) - => RediscoverPolicy = policy; - - /// The post-connect re-discovery policy this fake reports to the actor. - public DiscoveryRediscoverPolicy RediscoverPolicy { get; } - - /// Number of passes the actor has driven. - public int DiscoverCount => Volatile.Read(ref _passCount); - - /// Streams a growing-then-stable node set into the builder (0,0,3,3,…). - public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) - { - var pass = Interlocked.Increment(ref _passCount); // 1-based pass number - var count = pass >= 3 ? 3 : 0; - var fixedTree = builder.Folder("FixedTree", "FixedTree"); - for (var i = 0; i < count; i++) - { - fixedTree.Variable($"v{i}", $"v{i}", new DriverAttributeInfo( - FullName: $"m.fixed.v{i}", - DriverDataType: DriverDataType.Float64, - IsArray: false, - ArrayDim: null, - SecurityClass: SecurityClassification.ViewOnly, - IsHistorized: false)); - } - return Task.CompletedTask; - } - } - - /// - /// A discoverable driver whose DiscoverAsync genuinely SUSPENDS and resumes on a fresh - /// thread-pool thread that carries NO Akka actor cell — modelled on - /// SubscribableStubDriver.UnsubscribeYields. This forces the actor's await DiscoverAsync(...) - /// continuation to resume off-context unless the handler omits ConfigureAwait(false), so it is a - /// deterministic repro of the no-ActorContext race. Returns a stable 3-node set on every pass. - /// - private sealed class YieldingDiscoverableStubDriver : StubDriver, ITagDiscovery - { - /// Suspends on a TCS completed from a background thread, then streams 3 nodes. - public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) - { - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - _ = Task.Run(() => tcs.SetResult(), cancellationToken); - await tcs.Task.ConfigureAwait(false); // resume on a clean thread-pool thread (no actor cell) - var fixedTree = builder.Folder("FixedTree", "FixedTree"); - for (var i = 0; i < 3; i++) - { - fixedTree.Variable($"v{i}", $"v{i}", new DriverAttributeInfo( - FullName: $"m.fixed.v{i}", - DriverDataType: DriverDataType.Float64, - IsArray: false, - ArrayDim: null, - SecurityClass: SecurityClassification.ViewOnly, - IsHistorized: false)); - } - } - } - - /// - /// A discoverable driver whose set NEVER stabilises: pass N yields N nodes (1,2,3,…), so the - /// full-reference signature differs every pass and the loop can only be bounded by the attempt cap. - /// - private sealed class GrowingDiscoverableStubDriver : StubDriver, ITagDiscovery - { - private int _passCount; - - /// Constructs the fake reporting the given ; - /// defaults to (the interface default) so the - /// existing attempt-cap test is unaffected. With the - /// ever-growing set proves the actor stops after a single pass (UntilStable would keep retrying). - public GrowingDiscoverableStubDriver(DiscoveryRediscoverPolicy policy = DiscoveryRediscoverPolicy.UntilStable) - => RediscoverPolicy = policy; - - /// The post-connect re-discovery policy this fake reports to the actor. - public DiscoveryRediscoverPolicy RediscoverPolicy { get; } - - /// Number of passes the actor has driven. - public int DiscoverCount => Volatile.Read(ref _passCount); - - /// Streams an ever-growing node set (pass N → N nodes). - public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) - { - var pass = Interlocked.Increment(ref _passCount); // 1-based pass number - var fixedTree = builder.Folder("FixedTree", "FixedTree"); - for (var i = 0; i < pass; i++) - { - fixedTree.Variable($"v{i}", $"v{i}", new DriverAttributeInfo( - FullName: $"m.fixed.v{i}", - DriverDataType: DriverDataType.Float64, - IsArray: false, - ArrayDim: null, - SecurityClass: SecurityClassification.ViewOnly, - IsHistorized: false)); - } - return Task.CompletedTask; - } - } -} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs index a6f10ef3..3190713e 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs @@ -120,35 +120,6 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: PresenceBudget); } - /// Verifies that forwards to the - /// applier, which drives the sink to ensure the discovered folder + (read-only) variable and announce a - /// NodeAdded model-change under the equipment root — proving the message → handler → applier → sink path - /// end to end (mirrors the real-applier-over-recording-sink harness in - /// OpcUaPublishActorRebuildTests). - [Fact] - public void MaterialiseDiscoveredNodes_routes_through_applier_to_sink() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, applier: applier)); - - var folders = new[] { new DiscoveredFolder("EQ-1/Axes", "EQ-1", "Axes") }; - var variables = new[] - { - new DiscoveredVariable("EQ-1/Axes/X", "EQ-1/Axes", "X", "Double", - Writable: false, IsArray: false, ArrayLength: null), - }; - - actor.Tell(new OpcUaPublishActor.MaterialiseDiscoveredNodes("EQ-1", folders, variables)); - - AwaitAssert(() => - { - sink.Folders.ShouldContain(("EQ-1/Axes", "EQ-1", "Axes")); - sink.Variables.ShouldContain(("EQ-1/Axes/X", "EQ-1/Axes", "X", "Double", false)); - sink.ModelChanges.ShouldContain("EQ-1"); - }, duration: PresenceBudget); - } - /// Verifies that ServiceLevelChanged publishes to IServiceLevelPublisher once per unique level. [Fact] public void ServiceLevelChanged_publishes_to_IServiceLevelPublisher_once_per_unique_level() From d32d89c340cfd8d4a57a5ce77df85f2bc983231f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 27 Jul 2026 19:32:46 -0400 Subject: [PATCH 4/8] =?UTF-8?q?fix(drivers):=20stop=20silently=20discardin?= =?UTF-8?q?g=20driver=20config=20edits=20(=C2=A78.3,=20#516)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five drivers ignored the config handed to them on reinitialize, serving the options their constructor captured — and the deployment sealed green anyway. Fixed on both halves, as chosen. Seam (load-bearing): DriverSpawnPlanner routes a changed DriverConfig to ToStop + ToSpawn instead of an in-place delta, making the factory the single parse authority. 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 correct about the resilience pipeline and wrong about the driver. The accepted price is a reconnect per config edit. Per-driver, and this split was not anticipated: - 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 and FOCAS. Each builds more than options from config (Sql's dialect + resolved connection string, FOCAS's client-factory backend, both injected at construction), so adopting new options alone would run a NEW tag set against an OLD connection. Half a re-parse is worse than none. SqlDriver's doc-comment asserted the opposite premise — "config parsing belongs to the factory, which builds a fresh instance" — which was false when written and is true only now. Two green seals removed from ApplyChildDelta: it overwrote the cached Spec synchronously BEFORE the child dequeued the message, so the host believed the new config was live and the next reconcile computed no delta, sealing the drift permanently; and it Tell'd with no Receive registered, so a failed reinit — including Galaxy's deliberate NotSupportedException — dead-lettered. Exposed (not created) by the change: a factory throw is a CONFIG error, and SpawnChild catches it and silently substitutes a stub. Only a brand-new driver could reach that before; an ordinary config edit can now. Raised Warning -> Error with an actionable message. It still does not fail the deployment — doing so would let one malformed driver block a fleet deploy, so that is a follow-up rather than a drive-by. Tests: every pre-existing reinit test in these suites passes "{}", the exact input a guarded re-parser treats as "keep constructor options" — blind to this defect by construction. New tests pass CHANGED json; the Modbus one was verified falsifiable by deleting the re-parse (goes red). Two tests asserted the old behaviour and were rewritten, including the positive control in DriverHostActorUnreadableArtifactTests, whose observable moved from UnsubscribeAsync to ShutdownAsync now that teardown happens by stopping the child rather than emptying its desired set. Remaining full-suite failures are pre-existing and environmental, verified identical on the pre-change tree: Host.IntegrationTests (3) and Driver.AbLegacy.IntegrationTests (4, docker fixture-gated). --- deferment.md | 57 ++++++++++++++- .../AbLegacyDriver.cs | 20 +++++- .../AbLegacyDriverFactoryExtensions.cs | 22 ++++-- .../FocasDriver.cs | 14 +++- .../ModbusDriver.cs | 21 +++++- .../ModbusDriverFactoryExtensions.cs | 22 ++++-- .../OpcUaClientDriver.cs | 21 +++++- .../OpcUaClientDriverFactoryExtensions.cs | 25 +++++-- .../ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs | 12 +++- .../Drivers/DriverHostActor.cs | 63 ++++++++++++++-- .../Drivers/DriverSpawnPlan.cs | 39 ++++++---- .../AbLegacyReinitConfigAdoptionTests.cs | 49 +++++++++++++ .../ModbusReinitConfigAdoptionTests.cs | 72 +++++++++++++++++++ .../OpcUaClientReinitConfigAdoptionTests.cs | 50 +++++++++++++ .../DriverHostActorUnreadableArtifactTests.cs | 24 +++++-- .../Drivers/DriverSpawnPlannerTests.cs | 36 +++++++--- .../Drivers/StubDrivers.cs | 10 ++- 17 files changed, 500 insertions(+), 57 deletions(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReinitConfigAdoptionTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusReinitConfigAdoptionTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientReinitConfigAdoptionTests.cs diff --git a/deferment.md b/deferment.md index b2801ebc..4c29ad33 100644 --- a/deferment.md +++ b/deferment.md @@ -77,7 +77,10 @@ Every gap below is in the **authoring/dispatch layer, not driver runtime code**. - `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`. Should be corrected alongside #516. +- ~~`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. --- @@ -350,7 +353,8 @@ appear in §7.1 as well. 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. +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. 5. **Bookkeeping sweep** — mark the 8 stale `.tasks.json` files complete so the 19-task AdminUI plan @@ -369,7 +373,7 @@ the work, so this register never disagrees with the tree. |---|---|---| | 1. ACL enforcement decision | ✅ **Done** — decided *make non-enforcement explicit*; Gitea **#520** tracks the wire-up | `d1e88dc4` | | 2. #518 + #507 | ✅ **Done** — `IRediscoverable` consumed as a re-browse prompt; #507's injection path **deleted**; `IHostConnectivityProbe` half split to Gitea **#521** | `09a401b8`, `97c9f4b4` | -| 3. #516 config discard | ⏳ Not started — decided *both* (per-driver re-parse **and** seam respawn) | — | +| 3. #516 config discard | ✅ **Done** — seam respawn + re-parse on 3 drivers; Sql/FOCAS deliberately respawn-only | `2dc19f30` | | 4. G-4 dispatch-map parity | ⏳ Not started | — | | 5. Bookkeeping sweep | ⏳ Not started | — | | 6. Tier truth | ⏳ Not started — decided *document the truth, defer the code decision* | — | @@ -461,6 +465,53 @@ Two traps worth keeping: `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.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` 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). + --- *Generated 2026-07-27 against `master` @ `90bdaa44` by five parallel source-verification agents. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs index 94add6bd..faadaafc 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs @@ -13,7 +13,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable { - private readonly AbLegacyDriverOptions _options; + /// Mutable so can adopt a re-parsed config on reinitialize + /// (#516). Only ever written on the init path, before any reader/session uses it. + private AbLegacyDriverOptions _options; private readonly string _driverInstanceId; private readonly IAbLegacyTagFactory _tagFactory; private readonly ILogger _logger; @@ -94,12 +96,28 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover /// public string DriverType => "AbLegacy"; + /// True when the supplied DriverConfig JSON carries a real body. The bootstrapper always + /// passes a populated document; some unit tests pass "{}" or an empty string to exercise + /// lifecycle shape without a config — those keep the constructor-supplied options. + private static bool HasConfigBody(string? driverConfigJson) + { + if (string.IsNullOrWhiteSpace(driverConfigJson)) return false; + var trimmed = driverConfigJson.Trim(); + return trimmed is not "{}" and not "[]"; + } + /// public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) { _health = new DriverHealth(DriverState.Initializing, null, null); try { + // #516: re-parse the supplied DriverConfig so a config change delivered through the IDriver + // contract is honoured. Without this the driver keeps serving the options it was CONSTRUCTED + // with, so an operator's edit is discarded while the deployment still seals green. + if (HasConfigBody(driverConfigJson)) + _options = AbLegacyDriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson); + foreach (var device in _options.Devices) { var addr = AbLegacyHostAddress.TryParse(device.HostAddress) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs index fdd3dd57..cf55b578 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs @@ -49,6 +49,23 @@ public static class AbLegacyDriverFactoryExtensions /// Optional logger factory for the driver instance. /// A configured instance. internal static AbLegacyDriver CreateInstance(string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory) + { + return new AbLegacyDriver( + ParseOptions(driverInstanceId, driverConfigJson), driverInstanceId, + tagFactory: null, + logger: loggerFactory?.CreateLogger()); + } + + /// + /// Parses an AB Legacy DriverConfig JSON document into typed options. Extracted so + /// can re-parse a CHANGED config on reinitialize + /// (Gitea #516) instead of serving the options it was constructed with — which silently discarded + /// every operator edit while the deployment still sealed green. + /// + /// The unique driver instance identifier. + /// The driver configuration as a JSON string. + /// The parsed . + internal static AbLegacyDriverOptions ParseOptions(string driverInstanceId, string driverConfigJson) { ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId); ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson); @@ -82,10 +99,7 @@ public static class AbLegacyDriverFactoryExtensions Timeout = PositiveTimeoutOrDefault(dto.TimeoutMs, 2_000), }; - return new AbLegacyDriver( - options, driverInstanceId, - tagFactory: null, - logger: loggerFactory?.CreateLogger()); + return options; } /// diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs index 1c3b654c..0d834c59 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs @@ -100,7 +100,19 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, /// public string DriverType => "FOCAS"; - /// + /// + /// Opens the configured CNC handles and builds the authored tag table. + /// is deliberately not re-parsed here, unlike + /// Modbus / AbLegacy / OpcUaClient (Gitea #516). This driver builds more than options from config — + /// its IFocasClientFactory is selected from the Backend key and injected at + /// construction — so adopting a new FocasDriverOptions alone would run a NEW device/tag set + /// against the OLD backend. Half a re-parse is worse than none. + /// A config change is covered by the stop + respawn in DriverSpawnPlanner, which + /// rebuilds options and backend together through the factory. + /// + /// The driver configuration JSON (see remarks — not re-parsed). + /// Cancellation token for the operation. + /// A completed task. public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) { Volatile.Write(ref _health, new DriverHealth(DriverState.Initializing, null, null)); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs index 12854f7c..3d972795 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs @@ -23,7 +23,9 @@ public sealed class ModbusDriver { // ---- instance fields (grouped at top for auditability) ---- - private readonly ModbusDriverOptions _options; + /// Mutable so can adopt a re-parsed config on reinitialize + /// (#516). Only ever written on the init path, before any reader/transport uses it. + private ModbusDriverOptions _options; private readonly Func _transportFactory; private readonly string _driverInstanceId; private readonly ILogger _logger; @@ -188,12 +190,29 @@ public sealed class ModbusDriver /// public string DriverType => "Modbus"; + /// True when the supplied DriverConfig JSON carries a real body. The bootstrapper always + /// passes a populated document; some unit tests pass "{}" or an empty string to exercise + /// lifecycle shape without a config — those keep the constructor-supplied options. + private static bool HasConfigBody(string? driverConfigJson) + { + if (string.IsNullOrWhiteSpace(driverConfigJson)) return false; + var trimmed = driverConfigJson.Trim(); + return trimmed is not "{}" and not "[]"; + } + /// public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) { WriteHealth(new DriverHealth(DriverState.Initializing, null, null)); try { + // #516: re-parse the supplied DriverConfig so a config change delivered through the IDriver + // contract is honoured. Without this the driver keeps serving the options it was CONSTRUCTED + // with, so an operator's edit is discarded while the deployment still seals green. An empty / + // placeholder document (the "{}" some unit tests pass) keeps the constructor-supplied options. + if (HasConfigBody(driverConfigJson)) + _options = ModbusDriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson); + _transport = _transportFactory(_options); await _transport.ConnectAsync(cancellationToken).ConfigureAwait(false); // Build the RawPath → definition table from the authored raw tags. Each entry's TagConfig diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverFactoryExtensions.cs index 93fdb4a7..ba587ce6 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverFactoryExtensions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverFactoryExtensions.cs @@ -44,6 +44,23 @@ public static class ModbusDriverFactoryExtensions /// Optional logger factory for creating loggers per driver instance. /// The constructed instance. public static ModbusDriver CreateInstance(string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory) + { + return new ModbusDriver( + ParseOptions(driverInstanceId, driverConfigJson), driverInstanceId, + transportFactory: null, + logger: loggerFactory?.CreateLogger()); + } + + /// + /// Parses a Modbus DriverConfig JSON document into typed options. Extracted from + /// so + /// can re-parse a CHANGED config on reinitialize (Gitea #516) rather than serving the options it was + /// constructed with — which silently discarded every edit while the deployment still sealed green. + /// + /// The unique identifier for the driver instance. + /// The JSON configuration string for the driver. + /// The parsed . + internal static ModbusDriverOptions ParseOptions(string driverInstanceId, string driverConfigJson) { ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId); ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson); @@ -106,10 +123,7 @@ public static class ModbusDriverFactoryExtensions }, }; - return new ModbusDriver( - options, driverInstanceId, - transportFactory: null, - logger: loggerFactory?.CreateLogger()); + return options; } private static T ParseEnum(string? raw, string? tagName, string driverInstanceId, string field) where T : struct, Enum diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs index d9ac8555..26dece00 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs @@ -54,7 +54,9 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit _secretResolver = secretResolver ?? NullSecretResolver.Instance; } - private readonly OpcUaClientDriverOptions _options; + /// Mutable so can adopt a re-parsed config on reinitialize + /// (#516). Only ever written on the init path, before any reader/session uses it. + private OpcUaClientDriverOptions _options; private readonly ISecretResolver _secretResolver; private readonly string _driverInstanceId; // ---- IAlarmSource state ---- @@ -156,12 +158,29 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit /// public string DriverType => "OpcUaClient"; + /// True when the supplied DriverConfig JSON carries a real body. The bootstrapper always + /// passes a populated document; some unit tests pass "{}" or an empty string to exercise + /// lifecycle shape without a config — those keep the constructor-supplied options. + private static bool HasConfigBody(string? driverConfigJson) + { + if (string.IsNullOrWhiteSpace(driverConfigJson)) return false; + var trimmed = driverConfigJson.Trim(); + return trimmed is not "{}" and not "[]"; + } + /// public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) { _health = new DriverHealth(DriverState.Initializing, null, null); try { + // #516: re-parse the supplied DriverConfig so a config change delivered through the IDriver + // contract is honoured. Without this the driver keeps serving the options it was CONSTRUCTED + // with — the endpoint, security policy and tag set were all frozen at construction, and only + // secret rotation was picked up. + if (HasConfigBody(driverConfigJson)) + _options = OpcUaClientDriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson); + // Enforce the Equipment-vs-SystemPlatform choice at startup per driver-specs.md // §8 "Namespace Assignment" — a misconfigured remote fails draft validation here, // not as a runtime surprise. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverFactoryExtensions.cs index 9da3a22f..dfd53972 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverFactoryExtensions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverFactoryExtensions.cs @@ -63,16 +63,31 @@ public static class OpcUaClientDriverFactoryExtensions public static OpcUaClientDriver CreateInstance( string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory = null, ISecretResolver? secretResolver = null) + { + return new OpcUaClientDriver( + ParseOptions(driverInstanceId, driverConfigJson), driverInstanceId, + loggerFactory?.CreateLogger(), + secretResolver ?? NullSecretResolver.Instance); + } + + /// + /// Parses an OpcUaClient DriverConfig JSON document into typed options. Extracted so + /// can re-parse a CHANGED config on reinitialize + /// (Gitea #516) instead of serving the options it was constructed with. Note the driver's own + /// doc-comment previously claimed "resolving on every InitializeAsync picks up rotations" — that was + /// true of SECRET rotation only; the endpoint, security policy and tag set were all frozen at + /// construction. + /// + /// The unique driver instance identifier. + /// The driver configuration as a JSON string. + /// The parsed . + internal static OpcUaClientDriverOptions ParseOptions(string driverInstanceId, string driverConfigJson) { ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId); ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson); - var options = JsonSerializer.Deserialize(driverConfigJson, JsonOptions) + return JsonSerializer.Deserialize(driverConfigJson, JsonOptions) ?? throw new InvalidOperationException( $"OpcUaClient driver config for '{driverInstanceId}' deserialised to null"); - - return new OpcUaClientDriver( - options, driverInstanceId, loggerFactory?.CreateLogger(), - secretResolver ?? NullSecretResolver.Instance); } } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs index e99c3355..6af0b649 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs @@ -216,8 +216,16 @@ public sealed class SqlDriver /// /// Builds the authored RawPath table and proves the database is reachable. /// is not re-parsed here: the driver serves the - /// typed it was constructed with, exactly as ModbusDriver does - /// (config parsing belongs to the factory, which builds a fresh instance). + /// typed it was constructed with. That premise — "config parsing + /// belongs to the factory, which builds a fresh instance" — was false when written + /// (Gitea #516): the host reinitialized the EXISTING child in place, so every config edit was + /// silently discarded and the deployment still sealed green. It is true now, because + /// DriverSpawnPlanner routes a changed DriverConfig through a stop + respawn. + /// This driver deliberately does not also re-parse in place, unlike Modbus / AbLegacy / + /// OpcUaClient. It builds more than options from config — the and the + /// resolved connection string are both injected at construction — so adopting a new + /// alone would run a NEW tag set against the OLD connection. Half a + /// re-parse is worse than none; the respawn rebuilds all three together. /// The table is built first because it is pure and cannot fail; the liveness check is the only /// I/O, and on failure this method records and rethrows — /// DriverInstanceActor reads a throw as InitializeFailed and lands in Reconnecting with its diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs index cb2152ee..82d5267a 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs @@ -244,6 +244,11 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers /// value maps so stale condition state never leaks across redeploys. private readonly NativeAlarmProjector _nativeAlarmProjector = new(); + /// In-flight sends, keyed by correlation, so + /// can advance the cached spec only once the child confirms it adopted + /// the config (#516). Bounded by the number of driver children with a delta in flight. + private readonly Dictionary _pendingDelta = new(); + /// The composition from the most-recent apply (set at the END of /// ). Null until the first apply. private AddressSpaceComposition? _lastComposition; @@ -868,6 +873,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers Receive(ForwardNativeAlarm); Receive(OnDriverConnectivityChanged); Receive(HandleDeltaApplied); + Receive(HandleApplyResult); Receive(HandleRestartDriver); Receive(HandleReconnectDriver); Receive(HandleRouteNodeWrite); @@ -901,6 +907,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers Receive(ForwardNativeAlarm); Receive(OnDriverConnectivityChanged); Receive(HandleDeltaApplied); + Receive(HandleApplyResult); Receive(HandleRestartDriver); Receive(HandleReconnectDriver); Receive(HandleRouteNodeWrite); @@ -1353,6 +1360,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers // A late DeltaApplied (an apply completed just before the DB went Stale) — re-register the driver's // mux adapter anyway; it simply re-reads the driver's current refs (harmless, no DB access). Receive(HandleDeltaApplied); + Receive(HandleApplyResult); Receive(_ => { /* PubSub ack */ }); Timers.StartPeriodicTimer("retry-db", RetryConfigDbConnection.Instance, ReconnectInterval); } @@ -2007,7 +2015,15 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers try { driver = _driverFactory.TryCreate(spec.DriverType, spec.DriverInstanceId, spec.DriverConfig); } catch (Exception ex) { - _log.Warning(ex, "DriverHost {Node}: factory for {Type} threw on {Id}; stubbing", + // A factory throw is a CONFIG error, not a connectivity one — TryCreate is pure parsing; + // device I/O happens later in InitializeAsync. Logged at Error because the node silently + // degrades to a stub that answers nothing, and since #516 routed DriverConfig changes + // through a respawn this path is now reachable by an ordinary operator edit rather than + // only by a brand-new driver. It still does NOT fail the deployment — making it do so is + // a deliberate follow-up, since it would let one malformed driver block a fleet deploy. + _log.Error(ex, + "DriverHost {Node}: factory for {Type} REJECTED the config for {Id} — the driver is " + + "stubbed and will serve nothing until the config is fixed and redeployed", _localNode, spec.DriverType, spec.DriverInstanceId); } if (driver is null) @@ -2087,15 +2103,54 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers Context.Stop(adapter); } + /// + /// Sends an in-place config delta to a running child. + /// Unreachable from the reconcile path since #516 — a DriverConfig change is now a + /// stop + respawn (), so ToApplyDelta is always empty. + /// Kept because the seam is still exercised by DriverInstanceActor's own mid-connect config + /// adoption. + /// Two seals were removed here. This used to overwrite the cached + /// Spec SYNCHRONOUSLY, before the child had even dequeued the message — so the host + /// immediately believed the new config was live, the NEXT reconcile computed no delta against it, + /// and any drift was sealed permanently. It also Telld with no Receive<ApplyResult> + /// handler registered, so a FAILED reinit — including Galaxy's deliberate + /// NotSupportedException — dead-lettered and was never surfaced. + /// private void ApplyChildDelta(DriverInstanceSpec spec) { if (!_children.TryGetValue(spec.DriverInstanceId, out var entry)) return; - entry.Actor.Tell(new DriverInstanceActor.ApplyDelta(spec.DriverConfig, CorrelationId.NewId())); - // Store the full new spec — a delta can change Name, Enabled, ClusterId, etc. in addition to config. - _children[spec.DriverInstanceId] = entry with { Spec = spec }; + var correlation = CorrelationId.NewId(); + _pendingDelta[correlation] = spec; + entry.Actor.Tell(new DriverInstanceActor.ApplyDelta(spec.DriverConfig, correlation), Self); _log.Debug("DriverHost {Node}: ApplyDelta queued for {Id}", _localNode, spec.DriverInstanceId); } + /// + /// A child's reply to . On success the cached Spec is advanced — + /// only now, when the child has actually adopted the config, so a failed reinit leaves the host + /// believing the OLD config is live and the next reconcile re-attempts the change instead of + /// silently treating the drift as applied. A failure is logged at Error rather than swallowed. + /// + private void HandleApplyResult(DriverInstanceActor.ApplyResult msg) + { + if (msg.Success) + { + if (_pendingDelta.Remove(msg.Correlation, out var spec) + && _children.TryGetValue(spec.DriverInstanceId, out var entry)) + { + // A delta can change Name, Enabled, ClusterId etc. alongside the config. + _children[spec.DriverInstanceId] = entry with { Spec = spec }; + } + return; + } + + _pendingDelta.Remove(msg.Correlation, out var failed); + _log.Error( + "DriverHost {Node}: driver {Id} REJECTED an in-place config change ({Reason}) — the host keeps " + + "the previous config so the next reconcile re-attempts it", + _localNode, failed?.DriverInstanceId ?? "", msg.Reason ?? "no reason given"); + } + /// /// A driver child finished applying an in-place (its /// completed). Re-register THAT driver's dependency-consumer diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverSpawnPlan.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverSpawnPlan.cs index 5399691f..37ba98ab 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverSpawnPlan.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverSpawnPlan.cs @@ -7,7 +7,12 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers; /// spawn / ApplyDelta / stop on its child actors accordingly. /// /// Specs with no current child — create a new actor. -/// Specs whose child exists but config JSON or type differs. +/// +/// In-place config deltas. Always empty since #516 — a DriverConfig change is now a +/// stop + respawn so the factory is the single parse authority. Retained because +/// DriverInstanceActor still handles ApplyDelta on its own config-adoption path +/// (a config arriving mid-connect), and removing the list would hide that seam. +/// /// DriverInstanceIds currently running but missing from the new artifact, or now disabled. public sealed record DriverSpawnPlan( IReadOnlyList ToSpawn, @@ -42,23 +47,33 @@ public static class DriverSpawnPlanner toStop.Add(id); continue; } - // A driver TYPE change can't be reinitialized in-place (factory-bound) — stop + respawn. - // A RESILIENCE-CONFIG change likewise forces a respawn: the CapabilityInvoker (and its - // resolved options) is bound to the child actor at spawn time, so the only way a changed - // ResilienceConfig takes effect is to rebuild the child. The factory invalidates the stale - // cached pipelines on the respawn's Create call. (A pure DriverConfig change stays an - // in-place delta — no reconnect — because it doesn't touch the resilience pipeline.) + // ANY of the three config surfaces changing forces a stop + respawn, so the FACTORY is the + // single parse authority for everything a driver is built from. + // + // • DriverType — factory-bound, can't be reinitialized in place. + // • ResilienceConfig — the CapabilityInvoker (and its resolved options) binds to the child + // actor at spawn time; the factory invalidates the stale cached pipelines on respawn. + // • DriverConfig — was an in-place ApplyDelta until #516. It is now a respawn. + // + // #516: the in-place delta silently DISCARDED the edit on five drivers (Modbus, FOCAS, + // OpcUaClient, AbLegacy, Sql), whose InitializeAsync served the options captured by their + // constructor and never looked at the driverConfigJson they were handed. The deployment still + // sealed green. Those five now re-parse (belt), and this respawn is the braces: several + // drivers build MORE than options from config — Sql derives its dialect + connection string + // and FOCAS its client-factory backend, both injected at construction — so an in-place + // re-parse of options ALONE would apply a new tag set against an old connection. Only a + // respawn rebuilds all of it. + // + // The accepted cost is a reconnect on every DriverConfig edit, replacing the prior + // no-reconnect in-place path. That is deliberate: a correct reconnect beats a silent no-op. if (!string.Equals(snap.DriverType, spec.DriverType, StringComparison.Ordinal) - || !string.Equals(snap.ResilienceConfig, spec.ResilienceConfig, StringComparison.Ordinal)) + || !string.Equals(snap.ResilienceConfig, spec.ResilienceConfig, StringComparison.Ordinal) + || !string.Equals(snap.LastConfigJson, spec.DriverConfig, StringComparison.Ordinal)) { toStop.Add(id); toSpawn.Add(spec); continue; } - if (!string.Equals(snap.LastConfigJson, spec.DriverConfig, StringComparison.Ordinal)) - { - toDelta.Add(spec); - } } foreach (var (id, spec) in targetById) diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReinitConfigAdoptionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReinitConfigAdoptionTests.cs new file mode 100644 index 00000000..4aaa3feb --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReinitConfigAdoptionTests.cs @@ -0,0 +1,49 @@ +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; + +/// +/// Gitea #516 — AbLegacyDriver.InitializeAsync used to serve the options its CONSTRUCTOR +/// captured and never look at the driverConfigJson it was handed, so an operator's config edit +/// was silently discarded while the deployment still sealed green. +/// Every pre-existing reinit test in this suite passes "{}" — the exact input the guarded +/// re-parser treats as "keep the constructor options" — so they were blind to the defect. +/// +[Trait("Category", "Unit")] +public sealed class AbLegacyReinitConfigAdoptionTests +{ + /// + /// The driver validates every device's HostAddress during init. Reinitializing with a config + /// whose device address is structurally invalid must therefore THROW — a driver still serving the + /// constructor's valid device list would validate that instead and succeed, which is precisely how + /// the discarded edit stayed invisible. + /// + [Fact] + public async Task Reinitialize_adopts_a_changed_device_list() + { + var driver = AbLegacyDriverFactoryExtensions.CreateInstance( + "ab1", + """{"devices":[{"hostAddress":"ab://10.0.0.1:44818/1,0","plcFamily":"Slc500"}]}""", + loggerFactory: null); + + await Should.ThrowAsync( + () => driver.ReinitializeAsync( + """{"devices":[{"hostAddress":"not-a-valid-ab-address","plcFamily":"Slc500"}]}""", + CancellationToken.None), + "AbLegacyDriver kept its constructor-supplied device list after a reinitialize with a changed config (#516)"); + } + + /// An empty/placeholder document must still keep the constructor options, so the many + /// lifecycle tests that pass "{}" keep meaning what they meant. + [Fact] + public async Task Reinitialize_with_an_empty_document_keeps_the_constructor_options() + { + var driver = AbLegacyDriverFactoryExtensions.CreateInstance( + "ab1", + """{"devices":[{"hostAddress":"ab://10.0.0.1:44818/1,0","plcFamily":"Slc500"}]}""", + loggerFactory: null); + + await Should.NotThrowAsync(() => driver.ReinitializeAsync("{}", CancellationToken.None)); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusReinitConfigAdoptionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusReinitConfigAdoptionTests.cs new file mode 100644 index 00000000..98481200 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusReinitConfigAdoptionTests.cs @@ -0,0 +1,72 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Modbus; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests; + +/// +/// Gitea #516 — used to serve the options its CONSTRUCTOR +/// captured and never look at the driverConfigJson it was handed, so an operator's config edit +/// was silently discarded while the deployment still sealed green. +/// This test passes a CHANGED config on purpose. Every pre-existing reinit test in this +/// suite passes "{}" — exactly the input the guarded re-parser treats as "keep the constructor +/// options" — so those tests were blind to the defect by construction. +/// +[Trait("Category", "Unit")] +public sealed class ModbusReinitConfigAdoptionTests +{ + /// The transport factory receives the options the driver actually decided to use, so it is a + /// direct read of which config won — no log-string matching. + [Fact] + public async Task Reinitialize_with_a_changed_host_rebuilds_the_transport_against_the_new_host() + { + var seen = new List(); + var options = ModbusDriverFactoryExtensions.ParseOptions("m1", """{"host":"10.0.0.1","port":502}"""); + var driver = new ModbusDriver(options, "m1", opts => + { + seen.Add($"{opts.Host}:{opts.Port}"); + return new NeverConnectingTransport(); + }); + + try { await driver.InitializeAsync("""{"host":"10.0.0.1","port":502}""", CancellationToken.None); } + catch { /* connect failure is expected and irrelevant */ } + + try { await driver.ReinitializeAsync("""{"host":"10.0.0.99","port":1502}""", CancellationToken.None); } + catch { /* ditto */ } + + seen.Count.ShouldBeGreaterThanOrEqualTo(2); + seen[^1].ShouldBe("10.0.0.99:1502", + "ModbusDriver kept its constructor-supplied host after a reinitialize with a changed config (#516)"); + } + + /// A placeholder / empty document must still keep the constructor options — the guard exists so + /// the many lifecycle tests that pass "{}" keep meaning what they meant. + [Fact] + public async Task Reinitialize_with_an_empty_document_keeps_the_constructor_options() + { + var seen = new List(); + var options = ModbusDriverFactoryExtensions.ParseOptions("m1", """{"host":"10.0.0.1","port":502}"""); + var driver = new ModbusDriver(options, "m1", opts => + { + seen.Add($"{opts.Host}:{opts.Port}"); + return new NeverConnectingTransport(); + }); + + try { await driver.ReinitializeAsync("{}", CancellationToken.None); } + catch { /* connect failure is expected */ } + + seen.ShouldAllBe(s => s == "10.0.0.1:502"); + } + + private sealed class NeverConnectingTransport : IModbusTransport + { + public bool IsConnected => false; + public Task ConnectAsync(CancellationToken cancellationToken) + => throw new IOException("test transport never connects"); + public Task DisconnectAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public Task SendAsync(byte unitId, byte[] pdu, CancellationToken cancellationToken) + => throw new IOException("test transport never connects"); + public void Dispose() { } + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientReinitConfigAdoptionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientReinitConfigAdoptionTests.cs new file mode 100644 index 00000000..5299fdb5 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientReinitConfigAdoptionTests.cs @@ -0,0 +1,50 @@ +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests; + +/// +/// Gitea #516 — OpcUaClientDriver.InitializeAsync used to serve the options its CONSTRUCTOR +/// captured. Its own comment claimed "resolving on every InitializeAsync … picks up rotations", which +/// was true of SECRET rotation only: the endpoint, the security policy and the tag set were all frozen +/// at construction, so an operator's edit was discarded while the deployment still sealed green. +/// +[Trait("Category", "Unit")] +public sealed class OpcUaClientReinitConfigAdoptionTests +{ + /// + /// ValidateNamespaceKind runs at the top of init and rejects TargetNamespaceKind=Equipment + /// with an empty UNS mapping table. Reinitializing INTO that shape must therefore throw — a driver + /// still serving the constructor's valid options would validate those and succeed, which is exactly + /// how the discarded edit stayed invisible. + /// + [Fact] + public async Task Reinitialize_adopts_a_changed_config() + { + var driver = OpcUaClientDriverFactoryExtensions.CreateInstance( + "opc1", + """{"endpointUrl":"opc.tcp://10.0.0.1:4840","targetNamespaceKind":"Equipment","unsMappingTable":{"a":"b"}}"""); + + await Should.ThrowAsync( + () => driver.ReinitializeAsync( + """{"endpointUrl":"opc.tcp://10.0.0.1:4840","targetNamespaceKind":"Equipment","unsMappingTable":{}}""", + CancellationToken.None), + "OpcUaClientDriver kept its constructor-supplied options after a reinitialize with a changed config (#516)"); + } + + /// An empty/placeholder document must still keep the constructor options. + [Fact] + public async Task Reinitialize_with_an_empty_document_keeps_the_constructor_options() + { + var driver = OpcUaClientDriverFactoryExtensions.CreateInstance( + "opc1", + """{"endpointUrl":"opc.tcp://10.0.0.1:4840","targetNamespaceKind":"Equipment","unsMappingTable":{"a":"b"}}"""); + + // "{}" keeps the (valid) constructor options, so ValidateNamespaceKind passes and the driver gets as + // far as the connect — which fails against an unreachable endpoint. Anything BUT the validation + // throw proves the constructor options survived. + var ex = await Record.ExceptionAsync(() => driver.ReinitializeAsync("{}", CancellationToken.None)); + (ex is InvalidOperationException ioe && ioe.Message.Contains("UnsMappingTable", StringComparison.OrdinalIgnoreCase)) + .ShouldBeFalse("an empty document must keep the constructor-supplied options, not re-validate an empty config"); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorUnreadableArtifactTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorUnreadableArtifactTests.cs index 1036d4a3..a3f5f1bc 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorUnreadableArtifactTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorUnreadableArtifactTests.cs @@ -78,10 +78,17 @@ public sealed class DriverHostActorUnreadableArtifactTests : RuntimeActorTestBas factory.LastSubscribedRefs.ShouldNotBeNull()!.ShouldContain(SpeedRef); } - /// Positive control for the subscription half: a READABLE artifact that keeps the driver but - /// drops its last tag genuinely does clear the live subscription — the child receives an empty desired - /// set and unsubscribes. This proves both that the unsubscribe is observable through this factory and - /// that it lands well within , so the absence asserted above is real. + /// + /// Positive control for the subscription half: a READABLE artifact that drops the driver's last tag + /// genuinely does tear the live subscription down, and does so well within + /// — without this, the absence asserted above would pass on a race. + /// The observable changed with #516. Dropping a tag changes the driver's + /// DriverConfig, which used to be an in-place delta: the child received an empty desired set + /// and called UnsubscribeAsync. A config change is now a stop + respawn, so the old child is + /// STOPPED (taking its subscription with it via ShutdownAsync) and a fresh one spawns with + /// nothing to subscribe to. The teardown is therefore observed as a shutdown, not an unsubscribe — + /// the same event, reached by a different route. + /// [Fact] public void Dropping_a_drivers_last_tag_does_clear_its_subscription() { @@ -90,13 +97,13 @@ public sealed class DriverHostActorUnreadableArtifactTests : RuntimeActorTestBas var (actor, coordinator) = SpawnHostAndApply(db, SeedV3Deployment(db, RevA), factory); AwaitAssert(() => factory.LastSubscribedRefs.ShouldNotBeNull()!.ShouldContain(SpeedRef), duration: Timeout); - factory.UnsubscribeCount.ShouldBe(0); + factory.ShutdownCount.ShouldBe(0); actor.Tell(new DispatchDeployment(SeedTaglessDriverDeployment(db, RevB), RevB, CorrelationId.NewId())); coordinator.ExpectMsg(Timeout); - AwaitAssert(() => factory.UnsubscribeCount.ShouldBe(1), duration: SettleWindow); - AskDiagnostics(actor).Drivers.Select(d => d.Name).ShouldContain("drv-1"); // the driver itself stayed + AwaitAssert(() => factory.ShutdownCount.ShouldBeGreaterThanOrEqualTo(1), duration: SettleWindow); + AskDiagnostics(actor).Drivers.Select(d => d.Name).ShouldContain("drv-1"); // respawned under the same id } /// Positive control: the guard keys on "the read gave us nothing", not on "the new config has @@ -318,6 +325,9 @@ public sealed class DriverHostActorUnreadableArtifactTests : RuntimeActorTestBas /// Number of UnsubscribeAsync calls — non-zero means a live handle was torn down. public int UnsubscribeCount => Volatile.Read(ref _driver.UnsubscribeCount); + /// Number of ShutdownAsync calls — non-zero means a driver child was stopped. + public int ShutdownCount => Volatile.Read(ref _driver.ShutdownCount); + /// public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson) => string.Equals(driverType, supportedType, StringComparison.Ordinal) ? _driver : null; diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverSpawnPlannerTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverSpawnPlannerTests.cs index f9e18d4a..541d5a1f 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverSpawnPlannerTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverSpawnPlannerTests.cs @@ -40,9 +40,18 @@ public sealed class DriverSpawnPlannerTests plan.ToStop.ShouldBeEmpty(); } - /// Verifies that different configuration is routed to ApplyDelta. + /// + /// A changed DriverConfig forces a stop + respawn (#516), NOT an in-place delta. + /// This test previously asserted the opposite. The in-place path silently discarded the edit + /// on five drivers whose InitializeAsync served constructor-captured options and never read + /// the driverConfigJson they were handed — and the deployment still sealed green. Routing + /// through the factory makes it the single parse authority, which matters most for the drivers that + /// build MORE than options from config (Sql's dialect + connection string, FOCAS's client-factory + /// backend) where re-parsing options alone would apply a new tag set against an old connection. + /// The accepted cost is a reconnect on every config edit. + /// [Fact] - public void Different_config_routes_to_ApplyDelta() + public void Different_config_routes_to_stop_and_respawn() { var current = new Dictionary { @@ -52,9 +61,11 @@ public sealed class DriverSpawnPlannerTests var plan = DriverSpawnPlanner.Compute(current, target); - plan.ToApplyDelta.Single().DriverInstanceId.ShouldBe("a"); - plan.ToSpawn.ShouldBeEmpty(); - plan.ToStop.ShouldBeEmpty(); + plan.ToStop.Single().ShouldBe("a"); + plan.ToSpawn.Single().DriverInstanceId.ShouldBe("a"); + plan.ToSpawn.Single().DriverConfig.ShouldBe("{\"host\":\"new\"}"); + // The whole point: nothing is left on the in-place path that could discard the edit. + plan.ToApplyDelta.ShouldBeEmpty(); } /// Verifies that removed drivers are routed to ToStop. @@ -166,11 +177,14 @@ public sealed class DriverSpawnPlannerTests } /// - /// A pure DriverConfig change with an UNCHANGED ResilienceConfig stays an in-place delta (no - /// reconnect) — the resilience pipeline is untouched, so there's no reason to respawn. + /// A pure DriverConfig change respawns even when the ResilienceConfig is UNCHANGED. + /// This asserted the opposite until #516. The reasoning then was "the resilience pipeline is + /// untouched, so there's no reason to respawn" — correct about resilience, wrong about the driver: + /// five drivers ignored the config the in-place delta handed them, and the deployment sealed green + /// anyway. The reconnect is the accepted price of the edit actually taking effect. /// [Fact] - public void DriverConfig_change_with_unchanged_ResilienceConfig_stays_ApplyDelta() + public void DriverConfig_change_respawns_even_when_ResilienceConfig_is_unchanged() { var current = new Dictionary { @@ -180,8 +194,8 @@ public sealed class DriverSpawnPlannerTests var plan = DriverSpawnPlanner.Compute(current, target); - plan.ToApplyDelta.Single().DriverInstanceId.ShouldBe("a"); - plan.ToSpawn.ShouldBeEmpty(); - plan.ToStop.ShouldBeEmpty(); + plan.ToStop.Single().ShouldBe("a"); + plan.ToSpawn.Single().DriverInstanceId.ShouldBe("a"); + plan.ToApplyDelta.ShouldBeEmpty(); } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/StubDrivers.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/StubDrivers.cs index 2ab4213d..b9780be4 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/StubDrivers.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/StubDrivers.cs @@ -50,9 +50,17 @@ internal class StubDriver : IDriver return Task.CompletedTask; } + /// Number of calls — a child actor stopping raises it, so a test + /// can observe a driver child being torn down (e.g. by the #516 config-change respawn). + public int ShutdownCount; + /// Shuts down the driver. /// Cancellation token for the operation. - public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public Task ShutdownAsync(CancellationToken cancellationToken) + { + Interlocked.Increment(ref ShutdownCount); + return Task.CompletedTask; + } /// Gets the health status of the driver. public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null); /// Gets the memory footprint of the driver. From 6a01358b9a5003f2c0373e456e5e165ae14c41e2 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 27 Jul 2026 19:52:02 -0400 Subject: [PATCH 5/8] =?UTF-8?q?fix(adminui):=20guard=20the=20driver=20disp?= =?UTF-8?q?atch=20maps=20and=20close=20G-1..G-6=20(=C2=A78.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A parity guard already existed for DriverTypeNames <-> the /raw driver picker, but not for the two dispatch maps downstream, so a driver could be registered, offered in the picker, and then have no config form. That is exactly what happened to Calculation (G-1) and to Sql/MTConnect/Calculation on the device modal (G-2) — both survived review because nothing could see them. The maps had to become data first. A Razor @switch compiles into BuildRenderTree's IL, so no test can enumerate its cases; the picker guard works only because RawDriverTypeDialog keeps its data in a field the markup enumerates. Both modals now render from DriverConfigFormMap / DeviceFormMap through , and being public those maps need none of the picker test's BindingFlags.NonPublic fragility. - G-1 CalculationDriverForm.razor — RunTimeout was unauthorable via the UI. - G-2 Sql/MTConnect/Calculation declared single-connection rather than given hollow device forms; each holds one connection at the driver level. - G-3 DriverConfigModal's hardcoded "Galaxy or Mqtt" -> IsSingleConnection, so Sql/MTConnect authors are no longer sent to a device form with no endpoint. - G-4 DriverFormMapParityTests (5, both directions) + DriverDispatchMapParityTests. - G-5 CsvColumnMap entries for Sql, Mqtt, MTConnect. - G-6 RawBrowseCommitMapper Sql branch — and the browser end, which the audit missed: SqlBrowseSession emitted NO AddressFields, so a browsed leaf carried only a column name, and a column alone cannot address a Sql tag. It now travels schema/table/column and the mapper builds a WideRow config from them. A branch alone would have produced a half-built blob. Two findings while writing the guards. The G-6 test showed Modbus and Calculation also hit the generic address fallback — correctly, as neither is browsable — so it 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. And TagConfigDriverTypeNameGuardTests was hollow: it enumerated a hand-written TheoryData that had already drifted (omitting Galaxy, Sql and Mqtt), guarding renames but not gaps. Now enumerated from the map with a coverage test facing the other way. Live-verified on docker-dev, since this repo has no bUnit and no unit test reaches Blazor parameter binding: opened Calculation's config (previously "No typed config form"), typed 3500, saved, reopened — the value persisted, so the DynamicComponent two-way binding round-trips. Sql's form renders fully and now reads "This driver holds a single connection, authored above". AdminUI.Tests 930 passed. --- deferment.md | 49 +++++++- .../DriverTypeNames.cs | 6 +- .../SqlBrowseSession.cs | 14 ++- .../Shared/Drivers/DeviceModal.razor | 55 ++++----- .../Shared/Drivers/DriverConfigModal.razor | 68 +++++------- .../Shared/Drivers/DriverFormMaps.cs | 105 ++++++++++++++++++ .../Drivers/Forms/CalculationDriverForm.razor | 103 +++++++++++++++++ .../Shared/Raw/RawDriverTypeDialog.razor | 7 +- .../Uns/RawBrowseCommitMapper.cs | 48 +++++++- .../Uns/TagEditors/CsvColumnMap.cs | 48 +++++++- .../Uns/TagEditors/TagConfigEditorMap.cs | 6 +- .../Drivers/DriverFormMapParityTests.cs | 104 +++++++++++++++++ .../Uns/DriverDispatchMapParityTests.cs | 98 ++++++++++++++++ .../Uns/TagConfigDriverTypeNameGuardTests.cs | 49 ++++++-- 14 files changed, 658 insertions(+), 102 deletions(-) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverFormMaps.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/CalculationDriverForm.razor create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Drivers/DriverFormMapParityTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/DriverDispatchMapParityTests.cs diff --git a/deferment.md b/deferment.md index 4c29ad33..47f063e7 100644 --- a/deferment.md +++ b/deferment.md @@ -355,8 +355,9 @@ appear in §7.1 as well. 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. +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. 6. **§3.3 tier truth** — either pass real tiers at factory registration or delete the Tier-C @@ -374,7 +375,7 @@ the work, so this register never disagrees with the tree. | 1. ACL enforcement decision | ✅ **Done** — decided *make non-enforcement explicit*; Gitea **#520** tracks the wire-up | `d1e88dc4` | | 2. #518 + #507 | ✅ **Done** — `IRediscoverable` consumed as a re-browse prompt; #507's injection path **deleted**; `IHostConnectivityProbe` half split to Gitea **#521** | `09a401b8`, `97c9f4b4` | | 3. #516 config discard | ✅ **Done** — seam respawn + re-parse on 3 drivers; Sql/FOCAS deliberately respawn-only | `2dc19f30` | -| 4. G-4 dispatch-map parity | ⏳ Not started | — | +| 4. G-4 dispatch-map parity | ✅ **Done** — G-1…G-6 all closed, live-verified on docker-dev | `abacf4cf` | | 5. Bookkeeping sweep | ⏳ Not started | — | | 6. Tier truth | ⏳ Not started — decided *document the truth, defer the code decision* | — | @@ -465,6 +466,48 @@ Two traps worth keeping: `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.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 ``, 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 diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs index 4ae116f1..b08039a4 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs @@ -19,9 +19,9 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; /// (DriverTypeNamesGuardTests) asserts bidirectional parity between these /// constants and the set the driver factories actually register, so a rename on /// either side breaks the build's test gate. Only constants for -/// currently-registered factories belong here — a not-yet-registered driver -/// (e.g. the Calculation driver landing in a later Batch 2 package) adds its own -/// constant when its factory is wired in. +/// currently-registered factories belong here — a driver adds its own constant +/// when its factory is wired in. (This used to name Calculation as the example of a +/// not-yet-registered driver; it registers in DriverFactoryBootstrap like the rest.) /// /// public static class DriverTypeNames diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs index 06c05d8f..ff397fcd 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs @@ -153,7 +153,19 @@ internal sealed class SqlBrowseSession : IBrowseSession Name: column.Name, DriverDataType: _dialect.MapColumnType(column.DataType).ToString(), IsArray: false, - SecurityClass: ReadOnlySecurityClass), + SecurityClass: ReadOnlySecurityClass, + IsAlarm: false, + // A column NAME alone cannot address a Sql tag — the table has to travel with it, or the + // browse-commit has nothing to build a SqlTagConfigModel from and falls through to the + // generic {"address": ...} blob the typed editor cannot read (deferment.md G-6). The + // schema is carried separately so the commit mapper can qualify the table itself rather + // than parsing a joined string back apart. + AddressFields: new Dictionary(StringComparer.Ordinal) + { + ["schema"] = reference.Schema, + ["table"] = reference.Table!, + ["columnName"] = column.Name, + }), ]; } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceModal.razor index 8d8d851e..52db7687 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceModal.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceModal.razor @@ -39,38 +39,23 @@ - @switch (_driverType) + @* Rendered from DeviceFormMap — see DriverConfigFormMap for why this is a map and + not a @switch. Drivers in DeviceFormMap.SingleConnectionDriverTypes legitimately + have no per-device form; the parity test consults that set. *@ + @if (DeviceFormMap.Resolve(_driverType) is { } deviceFormType) { - case DriverTypeNames.Modbus: - - break; - case DriverTypeNames.S7: - - break; - case DriverTypeNames.AbCip: - - break; - case DriverTypeNames.AbLegacy: - - break; - case DriverTypeNames.TwinCAT: - - break; - case DriverTypeNames.FOCAS: - - break; - case DriverTypeNames.OpcUaClient: - - break; - case DriverTypeNames.Galaxy: - - break; - case DriverTypeNames.Mqtt: - - break; - default: -
No typed device form for driver type @_driverType.
- break; + + } + else if (DeviceFormMap.IsSingleConnection(_driverType)) + { +
+ This driver holds a single connection, authored on the driver. + There is nothing to configure per device. +
+ } + else + { +
No typed device form for driver type @_driverType.
}
@@ -120,6 +105,14 @@ private string _name = ""; private bool _enabled = true; private string _deviceConfigJson = "{}"; + + /// Parameters handed to the DynamicComponent-rendered device form. Rebuilt on every render — + /// see DriverConfigModal for why this must not be cached. + private Dictionary _deviceFormParameters => new() + { + ["DeviceConfigJson"] = _deviceConfigJson, + ["DeviceConfigJsonChanged"] = EventCallback.Factory.Create(this, v => _deviceConfigJson = v), + }; private byte[] _rowVersion = []; private string _parentDriverConfig = "{}"; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor index 5fa88406..376aeb4d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor @@ -31,50 +31,25 @@
- @switch (_driverType) + @* Rendered from DriverConfigFormMap rather than a @switch: a Razor switch compiles + into BuildRenderTree's IL, so no test can enumerate its cases — which is how this + modal came to be missing a Calculation arm while the driver-picker guard stayed + green (deferment.md G-1/G-4). The map is guarded by DriverFormMapParityTests. *@ + @if (DriverConfigFormMap.Resolve(_driverType) is { } formType) { - case DriverTypeNames.Modbus: - - break; - case DriverTypeNames.S7: - - break; - case DriverTypeNames.AbCip: - - break; - case DriverTypeNames.AbLegacy: - - break; - case DriverTypeNames.TwinCAT: - - break; - case DriverTypeNames.FOCAS: - - break; - case DriverTypeNames.OpcUaClient: - - break; - case DriverTypeNames.Galaxy: - - break; - case DriverTypeNames.Sql: - - break; - case DriverTypeNames.Mqtt: - - break; - case DriverTypeNames.MTConnect: - - break; - default: -
No typed config form for driver type @_driverType.
- break; + + } + else + { +
No typed config form for driver type @_driverType.
} - @* Galaxy + MQTT hold ONE connection per driver instance, so they author it here and - their device forms are informational. Every other driver splits the endpoint onto - the device (the v3 endpoint→DeviceConfig split). *@ - @if (_driverType is DriverTypeNames.Galaxy or DriverTypeNames.Mqtt) + @* Some drivers hold ONE connection per driver instance and author it here; the rest + split the endpoint onto the device (the v3 endpoint→DeviceConfig split). The set is + DeviceFormMap.SingleConnectionDriverTypes — this used to hardcode Galaxy-or-Mqtt and + so told Sql and MTConnect authors to go find an endpoint field on a device form that + does not exist (deferment.md G-3). *@ + @if (DeviceFormMap.IsSingleConnection(_driverType)) {

This driver holds a single connection, authored above. Test-connect lives on its @@ -133,6 +108,17 @@ private string _driverType = ""; private string _name = ""; private string _driverConfigJson = "{}"; + + ///

Parameters handed to the DynamicComponent-rendered driver form. Rebuilt on every render so + /// the child receives the CURRENT json — a cached dictionary would pin the value captured at first + /// render and silently strand every subsequent edit. + private Dictionary _formParameters => new() + { + ["DriverConfigJson"] = _driverConfigJson, + ["DriverConfigJsonChanged"] = EventCallback.Factory.Create(this, v => _driverConfigJson = v), + ["ResilienceConfig"] = _resilienceConfig!, + ["ResilienceConfigChanged"] = EventCallback.Factory.Create(this, v => _resilienceConfig = v), + }; private string? _resilienceConfig; private byte[] _rowVersion = []; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverFormMaps.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverFormMaps.cs new file mode 100644 index 00000000..3357f68d --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverFormMaps.cs @@ -0,0 +1,105 @@ +using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms; +using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers; + +/// +/// DriverType → typed driver-config form component, rendered by DriverConfigModal through +/// <DynamicComponent>. +/// Why this is a map and not the @switch it replaced. A Razor @switch compiles +/// into BuildRenderTree's IL, so no test can enumerate its cases — which is exactly how the modal +/// came to be missing a Calculation arm while the sibling driver-picker guard stayed green +/// (deferment.md G-1/G-4). The picker test only works because RawDriverTypeDialog keeps its +/// data in a field the markup enumerates; this type gives the two config modals the same property, and +/// being public it needs none of that test's BindingFlags.NonPublic fragility. +/// Modelled on TagConfigEditorMap. Guarded by DriverFormMapParityTests. +/// +public static class DriverConfigFormMap +{ + private static readonly IReadOnlyDictionary Map = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [DriverTypeNames.Modbus] = typeof(ModbusDriverForm), + [DriverTypeNames.S7] = typeof(S7DriverForm), + [DriverTypeNames.AbCip] = typeof(AbCipDriverForm), + [DriverTypeNames.AbLegacy] = typeof(AbLegacyDriverForm), + [DriverTypeNames.TwinCAT] = typeof(TwinCATDriverForm), + [DriverTypeNames.FOCAS] = typeof(FocasDriverForm), + [DriverTypeNames.OpcUaClient] = typeof(OpcUaClientDriverForm), + [DriverTypeNames.Galaxy] = typeof(GalaxyDriverForm), + [DriverTypeNames.Sql] = typeof(SqlDriverForm), + [DriverTypeNames.Mqtt] = typeof(MqttDriverForm), + [DriverTypeNames.MTConnect] = typeof(MTConnectDriverForm), + [DriverTypeNames.Calculation] = typeof(CalculationDriverForm), + }; + + /// The driver types that have a typed config form. + public static IReadOnlyCollection MappedDriverTypes => (IReadOnlyCollection)Map.Keys; + + /// Resolves the form component for a driver type, or null when none is mapped (the modal + /// then renders its "no typed config form" warning rather than crashing). + /// The driver type name. + /// The form component type, or . + public static Type? Resolve(string? driverType) => + driverType is not null && Map.TryGetValue(driverType, out var t) ? t : null; +} + +/// +/// DriverType → typed device-config form component, rendered by DeviceModal. +/// Not every driver has one, by design. The types in +/// hold ONE connection per driver instance and author it on the +/// driver form, so a per-device endpoint editor would be meaningless. The parity test consults that set +/// rather than demanding total coverage — which keeps it a real guard instead of one that has to be +/// suppressed. +/// +public static class DeviceFormMap +{ + private static readonly IReadOnlyDictionary Map = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [DriverTypeNames.Modbus] = typeof(ModbusDeviceForm), + [DriverTypeNames.S7] = typeof(S7DeviceForm), + [DriverTypeNames.AbCip] = typeof(AbCipDeviceForm), + [DriverTypeNames.AbLegacy] = typeof(AbLegacyDeviceForm), + [DriverTypeNames.TwinCAT] = typeof(TwinCATDeviceForm), + [DriverTypeNames.FOCAS] = typeof(FocasDeviceForm), + [DriverTypeNames.OpcUaClient] = typeof(OpcUaClientDeviceForm), + [DriverTypeNames.Galaxy] = typeof(GalaxyDeviceForm), + [DriverTypeNames.Mqtt] = typeof(MqttDeviceForm), + }; + + /// + /// Driver types that hold a single connection at the DRIVER level, so they legitimately have no + /// per-device form. Galaxy and Mqtt author a connection on the driver form and keep an informational + /// device form; Sql (one connection string), MTConnect (one Agent URI) and Calculation (no + /// connection at all) have no device form to render. + /// Read by DriverConfigModal so the operator is told where the endpoint actually lives — + /// it previously hardcoded Galaxy-or-Mqtt and told Sql/MTConnect authors to go and find an endpoint + /// field on the device that does not exist (deferment.md G-3). + /// + public static readonly IReadOnlySet SingleConnectionDriverTypes = + new HashSet(StringComparer.OrdinalIgnoreCase) + { + DriverTypeNames.Galaxy, + DriverTypeNames.Mqtt, + DriverTypeNames.Sql, + DriverTypeNames.MTConnect, + DriverTypeNames.Calculation, + }; + + /// The driver types that have a typed device form. + public static IReadOnlyCollection MappedDriverTypes => (IReadOnlyCollection)Map.Keys; + + /// Resolves the device form component for a driver type, or null when none is mapped. + /// The driver type name. + /// The form component type, or . + public static Type? Resolve(string? driverType) => + driverType is not null && Map.TryGetValue(driverType, out var t) ? t : null; + + /// True when this driver authors its connection on the DRIVER form rather than per-device. + /// The driver type name. + /// when the connection is driver-level. + public static bool IsSingleConnection(string? driverType) => + driverType is not null && SingleConnectionDriverTypes.Contains(driverType); +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/CalculationDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/CalculationDriverForm.razor new file mode 100644 index 00000000..33d5a278 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/CalculationDriverForm.razor @@ -0,0 +1,103 @@ +@* Embeddable Calculation pseudo-driver config form body. The Calculation driver has NO connection of any + kind — it computes signal-level tags from other tags' RawPaths — so its only driver-level knob is the + per-run script timeout. rawTags is composer-owned and never authored here. + + Closes deferment.md G-1: Calculation was offered in the /raw driver picker and registered by the factory, + but DriverConfigModal had no arm for it, so it fell through to the "No typed config form" warning and + RunTimeout was unauthorable through the UI. Hosted by DriverConfigModal via DriverConfigFormMap. *@ +@using System.Text.Json +@using System.Text.Json.Serialization +@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers + +
+
Evaluation
+
+
+
+ + +
+ Per-evaluation deadline for a calculated tag's script. Blank = the driver default (2000 ms). + A script exceeding it is abandoned and the tag publishes Bad. +
+
+
+

+ This driver has no connection to author — it reads other tags by RawPath. Its calculated tags and + their scripts are authored on the tags themselves under /raw. +

+
+
+ + + +@code { + /// The driver-level DriverConfig JSON. + [Parameter] public string DriverConfigJson { get; set; } = "{}"; + /// Fired (camelCase-serialized) whenever a field changes — enables @bind-DriverConfigJson. + [Parameter] public EventCallback DriverConfigJsonChanged { get; set; } + /// The per-instance resilience-pipeline overrides JSON, or null. + [Parameter] public string? ResilienceConfig { get; set; } + /// Fired when the resilience overrides change. + [Parameter] public EventCallback ResilienceConfigChanged { get; set; } + + // camelCase + omit-nulls, matching the driver's case-insensitive deserialize. Omitting runTimeoutMs + // means "use the driver default" rather than pinning the current default into the blob. + private static readonly JsonSerializerOptions _jsonOpts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false, + Converters = { new JsonStringEnumConverter() }, + }; + + private FormModel _form = new(); + private string? _lastParsed; + + protected override void OnParametersSet() + { + // Re-parse only when the inbound value actually changed, so an in-progress edit is not clobbered. + if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal)) + { + _form = new FormModel { RunTimeoutMs = TryDeserialize(DriverConfigJson)?.RunTimeoutMs }; + _lastParsed = DriverConfigJson; + } + } + + /// Serializes the current config to camelCase JSON. rawTags is never emitted — the composer + /// adds it at deploy time. + public string GetConfigJson() => JsonSerializer.Serialize(new Dto { RunTimeoutMs = _form.RunTimeoutMs }, _jsonOpts); + + private async Task EmitAsync() + { + var json = GetConfigJson(); + _lastParsed = json; + await DriverConfigJsonChanged.InvokeAsync(json); + } + + private async Task OnResilienceChanged(string? r) + { + ResilienceConfig = r; + await ResilienceConfigChanged.InvokeAsync(r); + } + + private static Dto? TryDeserialize(string json) + { + if (string.IsNullOrWhiteSpace(json)) return null; + try { return JsonSerializer.Deserialize(json, _jsonOpts); } + catch (JsonException) { return null; } // a hand-edited blob must not crash the modal + } + + /// Mirrors the driver's own config DTO. rawTags is deliberately absent — it is composer-owned, + /// and round-tripping it through this form would let the editor drop tags on save. + private sealed class Dto + { + public int? RunTimeoutMs { get; init; } + } + + private sealed class FormModel + { + public int? RunTimeoutMs { get; set; } + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawDriverTypeDialog.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawDriverTypeDialog.razor index 510a97b1..a14c7140 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawDriverTypeDialog.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawDriverTypeDialog.razor @@ -1,7 +1,6 @@ @* Driver-type picker for the /raw "New driver" action: pick a driver type + name, then the caller creates a minimal driver (CreateDriverAsync with "{}") and the operator configures it afterwards via - the Configure-driver modal. Calculation is included so a Calculation driver row is authorable now - (its factory lands in Wave C). Name is inline-validated as a RawPath segment. Markup mirrors the other + the Configure-driver modal. Name is inline-validated as a RawPath segment. Markup mirrors the other /raw modal shells. *@ @using ZB.MOM.WW.OtOpcUa.Commons.Types @using ZB.MOM.WW.OtOpcUa.Core.Abstractions @@ -56,8 +55,8 @@ /// Raised with the chosen driver type + name when the operator confirms; the dialog self-closes after. [Parameter] public EventCallback<(string DriverType, string Name)> OnSubmit { get; set; } - // Label → DriverType value. Galaxy's factory registers as "GalaxyMxGateway"; Calculation is - // authorable now though its factory arrives in Wave C. + // Label → DriverType value. Galaxy's factory registers as "GalaxyMxGateway". Guarded against + // DriverTypeNames by RawDriverTypeDialogParityTests, which reads this field by reflection. private static readonly (string Label, string Value)[] Types = [ ("Modbus", DriverTypeNames.Modbus), diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawBrowseCommitMapper.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawBrowseCommitMapper.cs index 49c48764..43fde69d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawBrowseCommitMapper.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawBrowseCommitMapper.cs @@ -3,6 +3,7 @@ using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; using ZB.MOM.WW.OtOpcUa.Driver.Mqtt; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; @@ -139,9 +140,19 @@ public static class RawBrowseCommitMapper return new MTConnectTagConfigModel { FullName = address }.ToJson(); if (Is(driverType, DriverTypeNames.Galaxy)) return WriteSingleKey("attributeRef", address); + // Sql: a browsed leaf is a COLUMN, and a column name alone cannot address a tag — SqlTagConfigModel + // needs the table too. SqlBrowseSession therefore travels schema/table/columnName in AddressFields. + // Without this branch a Sql commit fell through to the generic "address" key below, which the typed + // Sql editor does not read: it would open with empty fields and blank them on save — the exact + // failure the MTConnect branch above was added to avoid (deferment.md G-6). Sql IS browsable + // (SqlDriverBrowser), so the fallback's "browsable drivers are all handled above" was untrue. + if (Is(driverType, DriverTypeNames.Sql)) + return BuildSqlTagConfig(address, addressFields); // Unknown/flat-address driver (e.g. Modbus is not browsable): record the reference under a generic - // "address" key so nothing is lost. Browsable drivers are all handled above. + // "address" key so nothing is lost. Every BROWSABLE driver is handled above — guarded by + // RawBrowseCommitMapperParityTests, which enumerates DriverTypeNames.All rather than trusting + // this comment. return WriteSingleKey("address", address); } @@ -261,6 +272,41 @@ public static class RawBrowseCommitMapper private static bool Is(string driverType, string name) => string.Equals(driverType, name, StringComparison.OrdinalIgnoreCase); + /// + /// Builds a SqlTagConfigModel blob from a browsed COLUMN leaf. Maps to + /// SqlTagModel.WideRow — the browse tree is schema → table → column, which is precisely the + /// wide-row shape (one row holds many signals as columns). A KeyValue (EAV) tag cannot be derived + /// from a browse pick because its key VALUE is data, not schema, so the operator authors that in the + /// typed editor. + /// Falls back to the generic key when the fields are absent — an older browser build, or a + /// hand-made selection — rather than emitting a half-built Sql blob. + /// + private static string BuildSqlTagConfig(string address, IReadOnlyDictionary? addressFields) + { + if (addressFields is null + || !addressFields.TryGetValue("table", out var table) + || string.IsNullOrWhiteSpace(table)) + { + return WriteSingleKey("address", address); + } + + addressFields.TryGetValue("schema", out var schema); + addressFields.TryGetValue("columnName", out var columnName); + + // Qualify the table with its schema when the schema is not the default, so a tag authored against + // "sales.Readings" cannot silently resolve to "dbo.Readings". + var qualified = !string.IsNullOrWhiteSpace(schema) && !string.Equals(schema, "dbo", StringComparison.OrdinalIgnoreCase) + ? $"{schema}.{table}" + : table; + + return new SqlTagConfigModel + { + Model = SqlTagModel.WideRow, + Table = qualified, + ColumnName = string.IsNullOrWhiteSpace(columnName) ? address : columnName, + }.ToJson(); + } + private static string WriteSingleKey(string key, string value) { var o = new JsonObject { [key] = value }; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/CsvColumnMap.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/CsvColumnMap.cs index aa5e71af..74ba100f 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/CsvColumnMap.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/CsvColumnMap.cs @@ -319,9 +319,11 @@ public static class CsvColumnMap public static IReadOnlyList CommonColumns { get; } = [.. CommonLeadingColumns, TagConfigJsonColumn]; - /// The Calculation (virtual-tag) driver-type string. Not yet a - /// constant (its factory lands in a later Batch-2 package), so it is pinned here for the CSV surface. - public const string CalculationDriverType = "Calculation"; + /// The Calculation (virtual-tag) driver-type string. + /// Retained as an alias for , which now exists — the + /// original note here ("not yet a DriverTypeNames constant; its factory lands in a later Batch-2 + /// package") went stale once the factory registered. + public const string CalculationDriverType = DriverTypeNames.Calculation; private static readonly IReadOnlyDictionary Maps = BuildMaps(); @@ -449,6 +451,46 @@ public static class CsvColumnMap new CsvRawKeyColumn(new CsvTypedColumn("ChangeTriggered", "changeTriggered", null), CsvRawKeyKind.Bool), new CsvRawKeyColumn(new CsvTypedColumn("TimerIntervalMs", "timerIntervalMs", null), CsvRawKeyKind.Int), }), + + // deferment.md G-5 — Sql, Mqtt and MTConnect all have typed tag editors but had NO CSV map, so + // import/export silently degraded them to the raw TagConfigJson fallback while the seven older + // drivers got typed columns. Raw-key maps (as Galaxy and Calculation use) rather than + // model-backed ones: they address the identity keys directly, which is what a CSV round-trip + // needs, without duplicating each model's full accessor surface. + new RawKeyCsvDriverTagMap(DriverTypeNames.Sql, new[] + { + new CsvRawKeyColumn(new CsvTypedColumn("Model", "model", null), CsvRawKeyKind.String), + new CsvRawKeyColumn(new CsvTypedColumn("Table", "table", null), CsvRawKeyKind.String), + new CsvRawKeyColumn(new CsvTypedColumn("KeyColumn", "keyColumn", null), CsvRawKeyKind.String), + new CsvRawKeyColumn(new CsvTypedColumn("KeyValue", "keyValue", null), CsvRawKeyKind.String), + new CsvRawKeyColumn(new CsvTypedColumn("ValueColumn", "valueColumn", null), CsvRawKeyKind.String), + new CsvRawKeyColumn(new CsvTypedColumn("ColumnName", "columnName", null), CsvRawKeyKind.String), + new CsvRawKeyColumn(new CsvTypedColumn("SqlType", "type", null), CsvRawKeyKind.String), + }), + + new RawKeyCsvDriverTagMap(DriverTypeNames.Mqtt, new[] + { + new CsvRawKeyColumn(new CsvTypedColumn("Mode", "mode", null), CsvRawKeyKind.String), + new CsvRawKeyColumn(new CsvTypedColumn("Topic", "topic", null), CsvRawKeyKind.String), + new CsvRawKeyColumn(new CsvTypedColumn("PayloadFormat", "payloadFormat", null), CsvRawKeyKind.String), + new CsvRawKeyColumn(new CsvTypedColumn("JsonPath", "jsonPath", null), CsvRawKeyKind.String), + new CsvRawKeyColumn(new CsvTypedColumn("MqttDataType", "dataType", null), CsvRawKeyKind.String), + new CsvRawKeyColumn(new CsvTypedColumn("GroupId", "groupId", null), CsvRawKeyKind.String), + new CsvRawKeyColumn(new CsvTypedColumn("EdgeNodeId", "edgeNodeId", null), CsvRawKeyKind.String), + new CsvRawKeyColumn(new CsvTypedColumn("MqttDeviceId", "deviceId", null), CsvRawKeyKind.String), + }), + + new RawKeyCsvDriverTagMap(DriverTypeNames.MTConnect, new[] + { + new CsvRawKeyColumn(new CsvTypedColumn("DataItemId", "fullName", null), CsvRawKeyKind.String), + new CsvRawKeyColumn(new CsvTypedColumn("MtDataType", "dataType", null), CsvRawKeyKind.String), + new CsvRawKeyColumn(new CsvTypedColumn("MtCategory", "mtCategory", null), CsvRawKeyKind.String), + new CsvRawKeyColumn(new CsvTypedColumn("MtType", "mtType", null), CsvRawKeyKind.String), + new CsvRawKeyColumn(new CsvTypedColumn("MtSubType", "mtSubType", null), CsvRawKeyKind.String), + new CsvRawKeyColumn(new CsvTypedColumn("MtUnits", "units", null), CsvRawKeyKind.String), + new CsvRawKeyColumn(new CsvTypedColumn("MtDevice", "mtDevice", null), CsvRawKeyKind.String), + new CsvRawKeyColumn(new CsvTypedColumn("MtComponent", "mtComponent", null), CsvRawKeyKind.String), + }), }; return maps.ToDictionary(m => m.DriverType, m => m, StringComparer.OrdinalIgnoreCase); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs index 11adc332..978e4d9c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs @@ -22,9 +22,9 @@ public static class TagConfigEditorMap [DriverTypeNames.FOCAS] = typeof(Components.Shared.Uns.TagEditors.FocasTagConfigEditor), [DriverTypeNames.OpcUaClient] = typeof(Components.Shared.Uns.TagEditors.OpcUaClientTagConfigEditor), [DriverTypeNames.Calculation] = typeof(Components.Shared.Uns.TagEditors.CalculationTagConfigEditor), - // Keyed off SqlDriver.DriverTypeName (= "Sql") rather than DriverTypeNames.Sql: that shared - // constant is deliberately absent until Task 11 wires the factory (DriverTypeNamesGuardTests - // asserts bidirectional parity). Task 11 repoints all Sql keys onto DriverTypeNames.Sql. + // Keyed off SqlDriver.DriverTypeName, which is value-identical to DriverTypeNames.Sql. The + // note that used to sit here — "that shared constant is deliberately absent until Task 11 wires + // the factory" — went stale when the constant landed; the key is correct either way. [SqlDriver.DriverTypeName] = typeof(Components.Shared.Uns.TagEditors.SqlTagConfigEditor), [DriverTypeNames.Mqtt] = typeof(Components.Shared.Uns.TagEditors.MqttTagConfigEditor), [DriverTypeNames.MTConnect] = typeof(Components.Shared.Uns.TagEditors.MTConnectTagConfigEditor), diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Drivers/DriverFormMapParityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Drivers/DriverFormMapParityTests.cs new file mode 100644 index 00000000..f9d6e8a5 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Drivers/DriverFormMapParityTests.cs @@ -0,0 +1,104 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Drivers; + +/// +/// Closes deferment.md G-4. A guard already existed for DriverTypeNames ↔ the /raw +/// driver PICKER, but not for the two dispatch maps downstream of it, so a driver could be registered, +/// offered in the picker, and then have no config form — which is exactly what happened to +/// Calculation (G-1) and to Sql/MTConnect/Calculation on the device modal (G-2). Both survived +/// review because nothing could see them. +/// These are ordinary map lookups rather than reflection over a private field: the two modals were +/// converted from a Razor @switch — unreachable from a test, since it compiles into +/// BuildRenderTree's IL — to DriverConfigFormMap / DeviceFormMap. There is no bUnit +/// in this project, so a map is the only thing a test can hold on to. +/// +[Trait("Category", "Unit")] +public sealed class DriverFormMapParityTests +{ + /// Every declared driver type must have a typed config form. There is no exemption: a driver + /// with no configurable surface still needs a form to say so, or the operator meets a bare warning. + [Fact] + public void Every_declared_driver_type_has_a_typed_config_form() + { + var missing = DriverTypeNames.All + .Where(t => DriverConfigFormMap.Resolve(t) is null) + .OrderBy(t => t, StringComparer.Ordinal) + .ToList(); + + missing.ShouldBeEmpty( + $"these driver types are registered and offered in the /raw picker but DriverConfigModal has no " + + $"form for them, so their config is unauthorable through the UI: {string.Join(", ", missing)}"); + } + + /// The reverse direction — a mapped type that is no longer a declared driver type is a stale + /// entry, and would keep a deleted driver's form reachable. + [Fact] + public void Every_config_form_maps_to_a_declared_driver_type() + { + var declared = DriverTypeNames.All.ToHashSet(StringComparer.OrdinalIgnoreCase); + var stale = DriverConfigFormMap.MappedDriverTypes + .Where(t => !declared.Contains(t)) + .OrderBy(t => t, StringComparer.Ordinal) + .ToList(); + + stale.ShouldBeEmpty($"DriverConfigFormMap has entries for undeclared driver types: {string.Join(", ", stale)}"); + } + + /// + /// Every declared driver type must EITHER have a typed device form OR be declared single-connection. + /// The either/or matters: demanding a device form from every driver would be wrong (Sql has one + /// connection string, MTConnect one Agent URI, Calculation no connection at all), and a test that has + /// to be suppressed for five drivers stops being read. Forcing the choice to be explicit is what makes + /// this a guard — a new driver cannot silently fall through. + /// + [Fact] + public void Every_declared_driver_type_has_a_device_form_or_is_declared_single_connection() + { + var unaccounted = DriverTypeNames.All + .Where(t => DeviceFormMap.Resolve(t) is null && !DeviceFormMap.IsSingleConnection(t)) + .OrderBy(t => t, StringComparer.Ordinal) + .ToList(); + + unaccounted.ShouldBeEmpty( + $"these driver types have neither a typed device form nor a place in " + + $"DeviceFormMap.SingleConnectionDriverTypes, so DeviceModal falls through to its " + + $"\"no typed device form\" warning: {string.Join(", ", unaccounted)}"); + } + + /// The reverse direction for the device map. + [Fact] + public void Every_device_form_maps_to_a_declared_driver_type() + { + var declared = DriverTypeNames.All.ToHashSet(StringComparer.OrdinalIgnoreCase); + var stale = DeviceFormMap.MappedDriverTypes + .Concat(DeviceFormMap.SingleConnectionDriverTypes) + .Where(t => !declared.Contains(t)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(t => t, StringComparer.Ordinal) + .ToList(); + + stale.ShouldBeEmpty($"DeviceFormMap references undeclared driver types: {string.Join(", ", stale)}"); + } + + /// + /// A driver that authors its connection on the DRIVER form must have a driver form to author it on. + /// Without this, declaring a type single-connection would be a way to opt out of both maps at once + /// and leave the connection unauthorable anywhere. + /// + [Fact] + public void Every_single_connection_driver_has_a_driver_config_form() + { + var missing = DeviceFormMap.SingleConnectionDriverTypes + .Where(t => DriverConfigFormMap.Resolve(t) is null) + .OrderBy(t => t, StringComparer.Ordinal) + .ToList(); + + missing.ShouldBeEmpty( + $"these types are declared single-connection but have no driver config form, so their connection " + + $"cannot be authored anywhere: {string.Join(", ", missing)}"); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/DriverDispatchMapParityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/DriverDispatchMapParityTests.cs new file mode 100644 index 00000000..0cb9f9ff --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/DriverDispatchMapParityTests.cs @@ -0,0 +1,98 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; + +/// +/// Parity guards for the two remaining per-driver dispatch maps (deferment.md G-5, G-6). +/// Both were already testable structures — unlike the two Razor @switch blocks that needed +/// extracting first — so nothing but the tests was missing. +/// +[Trait("Category", "Unit")] +public sealed class DriverDispatchMapParityTests +{ + /// + /// Driver types with a typed tag editor but no CSV map, so import/export silently degrades them to + /// the raw TagConfigJson fallback. Empty — every editor-backed driver now has typed columns. + /// Deliberately expressed as "has a typed editor ⇒ has typed CSV columns" rather than + /// "every driver type", because a driver with no typed editor has nothing to project into columns. + /// Tying the two maps together is what makes this catch the next omission. + /// + [Fact] + public void Every_driver_with_a_typed_tag_editor_has_typed_csv_columns() + { + var missing = DriverTypeNames.All + .Where(t => TagConfigEditorMap.Resolve(t) is not null && CsvColumnMap.Resolve(t) is null) + .OrderBy(t => t, StringComparer.Ordinal) + .ToList(); + + missing.ShouldBeEmpty( + $"these driver types have a typed tag editor but no CsvColumnMap entry, so CSV import/export " + + $"drops them to the raw TagConfigJson fallback: {string.Join(", ", missing)}"); + } + + /// A CSV map for a driver type that no longer exists is a stale entry. + [Fact] + public void Every_csv_map_targets_a_declared_driver_type() + { + var declared = DriverTypeNames.All.ToHashSet(StringComparer.OrdinalIgnoreCase); + var stale = CsvColumnMap.All + .Select(m => m.DriverType) + .Where(t => !declared.Contains(t)) + .OrderBy(t => t, StringComparer.Ordinal) + .ToList(); + + stale.ShouldBeEmpty($"CsvColumnMap has entries for undeclared driver types: {string.Join(", ", stale)}"); + } + + /// + /// Every driver type either produces a TYPED blob from browse-commit, or is on the documented + /// non-browsable list. The generic {"address": …} fallback opens EMPTY in a typed editor and + /// blanks the field on save — the MTConnect branch was added for exactly that, and Sql had the same + /// bug unnoticed (deferment.md G-6) because the fallback's comment claimed "browsable drivers + /// are all handled above", which stopped being true the moment SqlDriverBrowser registered. + /// Asserted in both directions on purpose. A one-way check would let a newly-browsable + /// driver be quietly added to the exclusion list instead of getting a branch. Requiring the two sets + /// to match exactly means a driver gaining or losing a browser forces this list to be revisited. + /// + [Fact] + public void Browse_commit_falls_back_to_the_generic_address_key_for_exactly_the_non_browsable_drivers() + { + // Address fields as a browser supplies them; a driver whose browser states none must still produce + // something its typed editor can read from the leaf name alone. + var addressFields = new Dictionary(StringComparer.Ordinal) + { + ["schema"] = "dbo", + ["table"] = "Readings", + ["columnName"] = "Speed", + }; + + var fellBack = DriverTypeNames.All + .Where(t => RawBrowseCommitMapper.BuildTagConfig(t, "Speed", addressFields) == """{"address":"Speed"}""") + .OrderBy(t => t, StringComparer.Ordinal) + .ToList(); + + fellBack.ShouldBe( + NonBrowsableDriverTypes.OrderBy(t => t, StringComparer.Ordinal).ToList(), + $"the set of driver types committing to the generic \"address\" key drifted. Extra entries are " + + $"browsable drivers whose typed editor will open EMPTY and blank the field on save; missing " + + $"entries gained a typed branch and should leave this list. Got: {string.Join(", ", fellBack)}"); + } + + /// + /// Driver types with no browser, for which the generic address key is the correct commit. + /// + /// Modbus — register addresses are numeric coordinates, not a browsable namespace. + /// Calculation — a pseudo-driver over other tags' RawPaths; there is no device. + /// + /// Adding a type here is a claim that it cannot be browsed. If it can, give it a branch instead. + /// + private static readonly string[] NonBrowsableDriverTypes = + [ + DriverTypeNames.Modbus, + DriverTypeNames.Calculation, + ]; +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigDriverTypeNameGuardTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigDriverTypeNameGuardTests.cs index cc581114..0219fc2e 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigDriverTypeNameGuardTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigDriverTypeNameGuardTests.cs @@ -17,18 +17,43 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; public sealed class TagConfigDriverTypeNameGuardTests { // Every DriverTypeNames constant that has a typed editor registered. - public static TheoryData EditorMappedDriverTypes() => - [ - DriverTypeNames.Modbus, - DriverTypeNames.S7, - DriverTypeNames.AbCip, - DriverTypeNames.AbLegacy, - DriverTypeNames.TwinCAT, - DriverTypeNames.FOCAS, - DriverTypeNames.OpcUaClient, - DriverTypeNames.Calculation, - DriverTypeNames.MTConnect, - ]; + // + // Enumerated FROM the map rather than hand-written. The hand-written list this replaced guarded + // against RENAMES but not against GAPS: a newly-added DriverTypeNames constant simply never appeared + // here, so the guard stayed green while the map went unmapped — and it had already silently drifted, + // omitting Galaxy, Sql and Mqtt. Pairing it with the coverage test below makes both directions real. + public static TheoryData EditorMappedDriverTypes() + { + var data = new TheoryData(); + foreach (var t in DriverTypeNames.All.Where(t => TagConfigEditorMap.Resolve(t) is not null)) + { + data.Add(t); + } + + return data; + } + + /// + /// Coverage direction: every declared driver type has a typed tag editor, except those documented as + /// having none. Without this, enumerating the map above proves only that the map agrees with itself. + /// + [Fact] + public void Every_declared_driver_type_has_a_typed_tag_editor_or_is_documented_as_raw_json() + { + // Galaxy authors its tag reference as a dotted FullName through the Galaxy address picker rather + // than a typed field editor, so it uses the generic raw-TagConfig-JSON textarea by design. + string[] rawJsonByDesign = [DriverTypeNames.Galaxy]; + + var missing = DriverTypeNames.All + .Where(t => TagConfigEditorMap.Resolve(t) is null) + .Except(rawJsonByDesign, StringComparer.OrdinalIgnoreCase) + .OrderBy(t => t, StringComparer.Ordinal) + .ToList(); + + missing.ShouldBeEmpty( + $"these driver types have no typed tag editor and are not documented as raw-JSON by design, so " + + $"the /uns TagModal falls back to a bare JSON textarea for them: {string.Join(", ", missing)}"); + } [Theory] [MemberData(nameof(EditorMappedDriverTypes))] From 88ce8df099f0a5a2ba16233c856bf7ffa69ce591 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 27 Jul 2026 19:54:41 -0400 Subject: [PATCH 6/8] =?UTF-8?q?docs:=20close=20the=20plan-bookkeeping=20dr?= =?UTF-8?q?ift=20and=20state=20the=20tier=20truth=20(=C2=A78.5,=20=C2=A78.?= =?UTF-8?q?6,=20#522)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bookkeeping: 13 .tasks.json files closed — the 8 the audit 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 rather than just a status flip. 11 archreview live gates written back from STATUS.md, which had recorded them passed since 2026-07-13/15 without ever updating the task 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 deliberately NOT closed. STATUS.md's headline says "every Round-2 live gate is now GREEN", but its own per-item detail says R2-03 is "live-blocked on this data-less rig" and R2-10 verified the pipeline with "breaker-OPEN state not forced". Both now carry an openNote recording the actual blocker. 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/ instead of being buried under work that shipped months ago. Tier truth: docs/v2/driver-stability.md now states that its tier table is aspirational — every driver runs Tier A because no factory passes a tier, so MemoryRecycle and ScheduledRecycleScheduler have never engaged, and the config parser validates a RecycleIntervalSeconds that does nothing. It also corrects a staleness the audit missed: the separate-Windows-service hosting it describes for Tier C no longer exists (Galaxy moved to the mxaccessgw sidecar in PR 7.2, FOCAS went in-process with its managed wire client). docs/drivers/README.md already said Tier A for both, so the two no longer contradict. No runtime change — enabling recycle on two live drivers wants its own live gate, not a docs-pass side effect. Keep-or-delete tracked as Gitea #522. --- ...2-01-s7-fault-hardening-plan.md.tasks.json | 64 +++- ...lience-config-hardening-plan.md.tasks.json | 185 ++++++++-- ...2-03-vt-failure-quality-plan.md.tasks.json | 12 +- .../R2-05-adminui-authz-plan.md.tasks.json | 158 +++++++- ...erverhistorian-failfast-plan.md.tasks.json | 13 +- ...2-07-surgical-pure-adds-plan.md.tasks.json | 22 +- ...esilience-observability-plan.md.tasks.json | 101 +++++- ...tagconfig-consolidation-plan.md.tasks.json | 233 ++++++++++-- deferment.md | 43 ++- ...6-12-historian-tcp-transport.md.tasks.json | 124 ++++++- ...06-15-stillpending-phase-0-1.md.tasks.json | 119 +++++- ...pending-phase-2-servicelevel.md.tasks.json | 77 +++- ...ding-phase-3-opcua-standards.md.tasks.json | 86 ++++- ...ing-phase-4-driver-datatypes.md.tasks.json | 72 +++- ...pending-phase-4b-driver-gaps.md.tasks.json | 66 +++- ...nding-phase-4c-array-support.md.tasks.json | 127 ++++++- ...istorian-gateway-integration.md.tasks.json | 202 +++++++++-- ...-22-per-cluster-mesh-program.md.tasks.json | 73 +++- ...h-phase4-cut-driver-configdb.md.tasks.json | 5 +- ...2026-07-24-modbus-rtu-driver.md.tasks.json | 98 ++++- ...-07-24-mqtt-sparkplug-driver.md.tasks.json | 339 ++++++++++++++++-- .../2026-07-24-mtconnect-driver.md.tasks.json | 218 +++++++++-- .../2026-07-24-sql-poll-driver.md.tasks.json | 209 +++++++++-- docs/v2/driver-stability.md | 20 ++ 24 files changed, 2346 insertions(+), 320 deletions(-) diff --git a/archreview/plans/R2-01-s7-fault-hardening-plan.md.tasks.json b/archreview/plans/R2-01-s7-fault-hardening-plan.md.tasks.json index 1435e5b4..1d44fc98 100644 --- a/archreview/plans/R2-01-s7-fault-hardening-plan.md.tasks.json +++ b/archreview/plans/R2-01-s7-fault-hardening-plan.md.tasks.json @@ -9,78 +9,110 @@ }, { "id": 1, - "subject": "RED: connect-timeout regression tests T1+T3 (read/write degrade instead of throwing OCE) — must FAIL at f6eaa267", + "subject": "RED: connect-timeout regression tests T1+T3 (read/write degrade instead of throwing OCE) \u2014 must FAIL at f6eaa267", "status": "completed", - "blockedBy": [0] + "blockedBy": [ + 0 + ] }, { "id": 2, "subject": "RED: poll-loop-survival test T2 (subscription survives connect-timeout outage) + caller-cancel pinning test T4", "status": "completed", - "blockedBy": [0, 1] + "blockedBy": [ + 0, + 1 + ] }, { "id": 3, "subject": "GREEN: EnsureConnectedAsync + InitializeAsync connect-timeout OCE -> TimeoutException conversion (STAB-14 root fix)", "status": "completed", - "blockedBy": [1, 2] + "blockedBy": [ + 1, + 2 + ] }, { "id": 4, "subject": "Defensive when-filters on ensure-wrapper OCE rethrows (:488/:1000) + poll-loop OCE catches (:1383/:1396/:1404)", "status": "completed", - "blockedBy": [3] + "blockedBy": [ + 3 + ] }, { "id": 5, "subject": "Finding-1 close gate: full S7 unit + CLI test-project sweep green", "status": "completed", - "blockedBy": [4] + "blockedBy": [ + 4 + ] }, { "id": 6, - "subject": "RED: framing-fault classification Theory T5 (TPKT/TPDU/WrongNumberOfBytes/PlcException-WrongNumberReceivedBytes reopen) — must FAIL", + "subject": "RED: framing-fault classification Theory T5 (TPKT/TPDU/WrongNumberOfBytes/PlcException-WrongNumberReceivedBytes reopen) \u2014 must FAIL", "status": "completed", - "blockedBy": [0] + "blockedBy": [ + 0 + ] }, { "id": 7, "subject": "GREEN: broaden IsS7ConnectionFatal to the ISO-on-TCP framing surface (NOT InvalidDataException) + xmldoc (STAB-15a)", "status": "completed", - "blockedBy": [6] + "blockedBy": [ + 6 + ] }, { "id": 8, "subject": "RED->GREEN: cancellation-during-I/O marks handle dead (T6 read + T7 write; per-item OCE catches set _plcDead before propagating)", "status": "completed", - "blockedBy": [7] + "blockedBy": [ + 7 + ] }, { "id": 9, "subject": "RED->GREEN: probe marks handle dead under the gate (T8 persistent probe fault + T9 probe timeout; ProbeLoopAsync inner catch restructure, STAB-15b)", "status": "completed", - "blockedBy": [8] + "blockedBy": [ + 8 + ] }, { "id": 10, "subject": "Doc: R2-09 connect-throttle seam note on EnsureConnectedAsync (STAB-8 S7 part, note-only)", "status": "completed", - "blockedBy": [3] + "blockedBy": [ + 3 + ] }, { "id": 11, "subject": "Env-gated live connect-timeout outage test (S7_TIMEOUT_OUTAGE_START_CMD/STOP_CMD blackhole design; skips cleanly offline)", - "status": "deferred-live", + "status": "completed", "note": "env-gated; runs in serial live pass (integration suite; serialized heavy pass). Test authored + committed; verified it SKIPs cleanly offline.", - "blockedBy": [5] + "blockedBy": [ + 5 + ], + "closureNote": "LIVE GATE CLOSED 2026-07-15 \u2014 S7 blackhole run via `docker pause` on the 10.100.0.35 s7_1500 fixture. Found + fixed a real read-leg wall-clock gap (PR #453). STATUS.md 'docker-dev live-/run pass'." }, { "id": 12, "subject": "Close-out: whole-suite sweep + update archreview STATUS.md and 05 prior-finding table (STAB-14/15 FIXED, STAB-8 seam noted)", "status": "completed", "note": "S7 unit 246/246 green; S7 CLI 48/49 (1 pre-existing unrelated comment-scan failure, see deviations); solution BUILD clean (0 errors). Whole-solution dotnet test NOT run per controller memory-constraint (integration suites deferred to serial heavy pass).", - "blockedBy": [5, 7, 8, 9, 10, 11] + "blockedBy": [ + 5, + 7, + 8, + 9, + 10, + 11 + ] } ], - "lastUpdated": "2026-07-13" + "lastUpdated": "2026-07-27" } diff --git a/archreview/plans/R2-02-resilience-config-hardening-plan.md.tasks.json b/archreview/plans/R2-02-resilience-config-hardening-plan.md.tasks.json index 12113498..3f92570f 100644 --- a/archreview/plans/R2-02-resilience-config-hardening-plan.md.tasks.json +++ b/archreview/plans/R2-02-resilience-config-hardening-plan.md.tasks.json @@ -1,25 +1,170 @@ { "planPath": "archreview/plans/R2-02-resilience-config-hardening-plan.md", "tasks": [ - { "id": 0, "subject": "S-6 brick-repro test (RED): parsed timeoutSeconds:0 / breakerFailureThreshold:1 must not brick capability calls — ResilienceConfigBrickTests, expect 2 failures on f6eaa267", "status": "completed", "blockedBy": [] }, - { "id": 1, "subject": "Add ResiliencePolicyRanges constants to Core.Abstractions (single source of truth for parser/builder/AdminUI, derived from Polly.Core 8.6.6 validation)", "status": "completed", "blockedBy": [] }, - { "id": 2, "subject": "S-6 parser: clamp timeout/retry/breaker overrides with appending diagnostics (timeout<=0 -> tier default; breaker==1 -> 2; caps) + 8 parser tests", "status": "completed", "blockedBy": [0, 1] }, - { "id": 3, "subject": "S-6 builder: belt-and-braces clamp in Build so pipeline construction can never throw ValidationException + Build_NeverThrows_ForHostileDirectOptions (turns task 0 green)", "status": "completed", "blockedBy": [0, 1] }, - { "id": 4, "subject": "S-6 production-wiring guard: DriverCapabilityInvokerFactory.Create with hostile JSON yields a working invoker + logged clamp diagnostic; re-run ResilienceInvokerFactoryRegistrationTests", "status": "completed", "blockedBy": [2, 3] }, - { "id": 5, "subject": "S-6 AdminUI: ResilienceFormModel.Validate() range messages (via ResiliencePolicyRanges through the transitive Core.Abstractions ref) + warning block in DriverResilienceSection", "status": "completed", "blockedBy": [1] }, - { "id": 6, "subject": "S-8 parser layer: force Write/AlarmAcknowledge retryCount overrides to 0 with diagnostic (spec invariant, honest dead-knob surfacing) + tests + GetTierDefaults doc", "status": "completed", "blockedBy": [2] }, - { "id": 7, "subject": "S-8 dispatch layer: flip HandleWriteAsync to isIdempotent:false — RED the DriverInstanceActorResilienceWiringTests expectation first, then flip; full Runtime suite green", "status": "completed", "blockedBy": [6] }, - { "id": 8, "subject": "S-8/P-4 invoker: cache the no-retry options snapshot once per invoker + add RecordCallStart/Complete tracker accounting to the non-idempotent arm + 2 CapabilityInvokerTests", "status": "completed", "blockedBy": [7] }, - { "id": 9, "subject": "S-8 docs + form: fix WriteIdempotentAttribute's false 'reads via reflection' claim; note the non-idempotent production default; disable Write/Ack retry cells in the razor", "status": "completed", "blockedBy": [7, 5] }, - { "id": 10, "subject": "U-6 delete (Core): remove BulkheadMaxConcurrent/MaxQueue from DriverResilienceOptions + parser shape/merge/doc-example; add BulkheadKeys_InStoredJson_AreIgnored (migration-free contract)", "status": "completed", "blockedBy": [2, 6] }, - { "id": 11, "subject": "U-6 delete (AdminUI): strip bulkhead fields from ResilienceFormModel + the two razor inputs; update ResilienceFormModelTests", "status": "completed", "blockedBy": [5, 10] }, - { "id": 12, "subject": "U-6 doc sweep (seam xmldocs, OTOPCUA0001 message + analyzer tests, operator docs; leave migrations/WedgeDetector/historical plans) + Options_properties_are_exactly_the_pipeline_wired_set knob-inertness guard", "status": "completed", "blockedBy": [10, 11] }, - { "id": 13, "subject": "C-7 RED tests: unknown top-level key / unknown capability / unknown per-policy field survive round-trip; malformed JSON sets ParseFailed and ToJson returns the original text", "status": "completed", "blockedBy": [11] }, - { "id": 14, "subject": "C-7 implement: JsonObject-bag FromJson/ToJson (TagConfigJson idiom), ParseFailed + RawStoredJson, blank->null contract preserved, parser-interop test green", "status": "completed", "blockedBy": [13] }, - { "id": 15, "subject": "C-7 razor: ParseFailed warning banner + input lockout + explicit discard button; raw pane shows the stored ResilienceConfig; live-/run on docker-dev :9200 (rebuild BOTH centrals; SQL-mangle + unknown-key survival checks; also drive task 5 warnings and task 9 disabled cells)", "status": "deferred-live", "note": "docker-dev :9200 live-/run; serial live pass", "blockedBy": [14] }, - { "id": 16, "subject": "S-7 builder: add options-generation to PipelineKey + GetOrCreate(optionsGeneration=0) + thread through CapabilityInvoker; StaleGeneration_ReCache_IsNotServed_ToNewGeneration", "status": "completed", "blockedBy": [3] }, - { "id": 17, "subject": "S-7 factory: Interlocked per-Create generation stamp + Respawn_interleaved_with_old_invoker_call_still_applies_new_options wiring proof; update Invalidate comment (cleanup, not correctness)", "status": "completed", "blockedBy": [16] }, - { "id": 18, "subject": "Wrap-up: whole-solution build (0 warnings, analyzer silent) + full dotnet test gate; record the pass + the two report anchor drifts in archreview/plans/STATUS.md", "status": "deferred-live", "note": "integration/full sweep; serialized heavy pass", "blockedBy": [4, 8, 9, 12, 15, 17] } + { + "id": 0, + "subject": "S-6 brick-repro test (RED): parsed timeoutSeconds:0 / breakerFailureThreshold:1 must not brick capability calls \u2014 ResilienceConfigBrickTests, expect 2 failures on f6eaa267", + "status": "completed", + "blockedBy": [] + }, + { + "id": 1, + "subject": "Add ResiliencePolicyRanges constants to Core.Abstractions (single source of truth for parser/builder/AdminUI, derived from Polly.Core 8.6.6 validation)", + "status": "completed", + "blockedBy": [] + }, + { + "id": 2, + "subject": "S-6 parser: clamp timeout/retry/breaker overrides with appending diagnostics (timeout<=0 -> tier default; breaker==1 -> 2; caps) + 8 parser tests", + "status": "completed", + "blockedBy": [ + 0, + 1 + ] + }, + { + "id": 3, + "subject": "S-6 builder: belt-and-braces clamp in Build so pipeline construction can never throw ValidationException + Build_NeverThrows_ForHostileDirectOptions (turns task 0 green)", + "status": "completed", + "blockedBy": [ + 0, + 1 + ] + }, + { + "id": 4, + "subject": "S-6 production-wiring guard: DriverCapabilityInvokerFactory.Create with hostile JSON yields a working invoker + logged clamp diagnostic; re-run ResilienceInvokerFactoryRegistrationTests", + "status": "completed", + "blockedBy": [ + 2, + 3 + ] + }, + { + "id": 5, + "subject": "S-6 AdminUI: ResilienceFormModel.Validate() range messages (via ResiliencePolicyRanges through the transitive Core.Abstractions ref) + warning block in DriverResilienceSection", + "status": "completed", + "blockedBy": [ + 1 + ] + }, + { + "id": 6, + "subject": "S-8 parser layer: force Write/AlarmAcknowledge retryCount overrides to 0 with diagnostic (spec invariant, honest dead-knob surfacing) + tests + GetTierDefaults doc", + "status": "completed", + "blockedBy": [ + 2 + ] + }, + { + "id": 7, + "subject": "S-8 dispatch layer: flip HandleWriteAsync to isIdempotent:false \u2014 RED the DriverInstanceActorResilienceWiringTests expectation first, then flip; full Runtime suite green", + "status": "completed", + "blockedBy": [ + 6 + ] + }, + { + "id": 8, + "subject": "S-8/P-4 invoker: cache the no-retry options snapshot once per invoker + add RecordCallStart/Complete tracker accounting to the non-idempotent arm + 2 CapabilityInvokerTests", + "status": "completed", + "blockedBy": [ + 7 + ] + }, + { + "id": 9, + "subject": "S-8 docs + form: fix WriteIdempotentAttribute's false 'reads via reflection' claim; note the non-idempotent production default; disable Write/Ack retry cells in the razor", + "status": "completed", + "blockedBy": [ + 7, + 5 + ] + }, + { + "id": 10, + "subject": "U-6 delete (Core): remove BulkheadMaxConcurrent/MaxQueue from DriverResilienceOptions + parser shape/merge/doc-example; add BulkheadKeys_InStoredJson_AreIgnored (migration-free contract)", + "status": "completed", + "blockedBy": [ + 2, + 6 + ] + }, + { + "id": 11, + "subject": "U-6 delete (AdminUI): strip bulkhead fields from ResilienceFormModel + the two razor inputs; update ResilienceFormModelTests", + "status": "completed", + "blockedBy": [ + 5, + 10 + ] + }, + { + "id": 12, + "subject": "U-6 doc sweep (seam xmldocs, OTOPCUA0001 message + analyzer tests, operator docs; leave migrations/WedgeDetector/historical plans) + Options_properties_are_exactly_the_pipeline_wired_set knob-inertness guard", + "status": "completed", + "blockedBy": [ + 10, + 11 + ] + }, + { + "id": 13, + "subject": "C-7 RED tests: unknown top-level key / unknown capability / unknown per-policy field survive round-trip; malformed JSON sets ParseFailed and ToJson returns the original text", + "status": "completed", + "blockedBy": [ + 11 + ] + }, + { + "id": 14, + "subject": "C-7 implement: JsonObject-bag FromJson/ToJson (TagConfigJson idiom), ParseFailed + RawStoredJson, blank->null contract preserved, parser-interop test green", + "status": "completed", + "blockedBy": [ + 13 + ] + }, + { + "id": 15, + "subject": "C-7 razor: ParseFailed warning banner + input lockout + explicit discard button; raw pane shows the stored ResilienceConfig; live-/run on docker-dev :9200 (rebuild BOTH centrals; SQL-mangle + unknown-key survival checks; also drive task 5 warnings and task 9 disabled cells)", + "status": "completed", + "note": "docker-dev :9200 live-/run; serial live pass", + "blockedBy": [ + 14 + ], + "closureNote": "LIVE GATE CLOSED 2026-07-13 \u2014 range-validation banner verified on the docker-dev Modbus driver's Resilience overrides (`timeoutSeconds:0`). STATUS.md 'docker-dev live-/run pass'." + }, + { + "id": 16, + "subject": "S-7 builder: add options-generation to PipelineKey + GetOrCreate(optionsGeneration=0) + thread through CapabilityInvoker; StaleGeneration_ReCache_IsNotServed_ToNewGeneration", + "status": "completed", + "blockedBy": [ + 3 + ] + }, + { + "id": 17, + "subject": "S-7 factory: Interlocked per-Create generation stamp + Respawn_interleaved_with_old_invoker_call_still_applies_new_options wiring proof; update Invalidate comment (cleanup, not correctness)", + "status": "completed", + "blockedBy": [ + 16 + ] + }, + { + "id": 18, + "subject": "Wrap-up: whole-solution build (0 warnings, analyzer silent) + full dotnet test gate; record the pass + the two report anchor drifts in archreview/plans/STATUS.md", + "status": "completed", + "note": "integration/full sweep; serialized heavy pass", + "blockedBy": [ + 4, + 8, + 9, + 12, + 15, + 17 + ], + "closureNote": "LIVE GATE CLOSED 2026-07-13 \u2014 S-8 verified: Write and AlarmAcknowledge RETRIES cells disabled ('never'). STATUS.md 'docker-dev live-/run pass'." + } ], - "lastUpdated": "2026-07-12" + "lastUpdated": "2026-07-27" } diff --git a/archreview/plans/R2-03-vt-failure-quality-plan.md.tasks.json b/archreview/plans/R2-03-vt-failure-quality-plan.md.tasks.json index 2c55fffb..794981d2 100644 --- a/archreview/plans/R2-03-vt-failure-quality-plan.md.tasks.json +++ b/archreview/plans/R2-03-vt-failure-quality-plan.md.tasks.json @@ -3,7 +3,7 @@ "tasks": [ { "id": "R2-03-T1", - "subject": "S13 stale-Good repro test at host level (RED — must fail on f6eaa267): fail-after-success evaluator, publish probe sees Good then expects Bad AttributeValueUpdate", + "subject": "S13 stale-Good repro test at host level (RED \u2014 must fail on f6eaa267): fail-after-success evaluator, publish probe sees Good then expects Bad AttributeValueUpdate", "status": "completed", "blockedBy": [] }, @@ -62,7 +62,7 @@ }, { "id": "R2-03-T9", - "subject": "P7 wiring-guard rewrite (RED): ApplyVirtualTags clears the compile cache only when the expression set changes — identical redeploy must NOT clear; expression edit must; shared-expression removal must not", + "subject": "P7 wiring-guard rewrite (RED): ApplyVirtualTags clears the compile cache only when the expression set changes \u2014 identical redeploy must NOT clear; expression edit must; shared-expression removal must not", "status": "completed", "blockedBy": [ "R2-03-T3" @@ -86,7 +86,8 @@ "R2-03-T8", "R2-03-T10" ], - "note": "integration/full sweep; serialized heavy pass (build clean, Runtime.Tests 372/372, RoslynVirtualTagEvaluatorTests 20/20 verified locally; whole-solution/cross-suite sweep deferred per controller memory constraint)" + "note": "integration/full sweep; serialized heavy pass (build clean, Runtime.Tests 372/372, RoslynVirtualTagEvaluatorTests 20/20 verified locally; whole-solution/cross-suite sweep deferred per controller memory constraint)", + "openNote": "STILL OPEN \u2014 live-blocked on a data-less rig: every materialised node reads Bad_WaitingForInitialData because no driver data flows, so a VT cannot be staged Good->Bad. Needs a reachable driver fixture feeding the VT. Unit-verified meanwhile." }, { "id": "R2-03-T12", @@ -95,8 +96,9 @@ "blockedBy": [ "R2-03-T11" ], - "note": "docker-dev live-/run; serial live pass" + "note": "docker-dev live-/run; serial live pass", + "openNote": "STILL OPEN \u2014 same blocker as T11. Confirmed still open 2026-07-27 (deferment.md \u00a78.5)." } ], - "lastUpdated": "2026-07-13" + "lastUpdated": "2026-07-27" } diff --git a/archreview/plans/R2-05-adminui-authz-plan.md.tasks.json b/archreview/plans/R2-05-adminui-authz-plan.md.tasks.json index 58d12f0e..37a9ba4b 100644 --- a/archreview/plans/R2-05-adminui-authz-plan.md.tasks.json +++ b/archreview/plans/R2-05-adminui-authz-plan.md.tasks.json @@ -1,22 +1,146 @@ { "planPath": "archreview/plans/R2-05-adminui-authz-plan.md", - "lastUpdated": "2026-07-12", + "lastUpdated": "2026-07-27", "tasks": [ - { "id": "T1", "subject": "Add AdminUiPolicies constants class (Security/Auth/AdminUiPolicies.cs)", "status": "completed", "blockedBy": [] }, - { "id": "T2", "subject": "Policy-semantics unit test AdminUiPoliciesTests (RED: ConfigEditor/AuthenticatedRead unregistered)", "status": "completed", "blockedBy": ["T1"] }, - { "id": "T3", "subject": "Reflection PageAuthorizationGuardTests (RED: must list ~35 ungated/mis-idiom pages)", "status": "completed", "blockedBy": ["T1"] }, - { "id": "T4", "subject": "Register ConfigEditor + AuthenticatedRead policies; switch existing AddPolicy literals to constants (GREEN for T2)", "status": "completed", "blockedBy": ["T2"] }, - { "id": "T5", "subject": "_Imports.razor usings (Authorization + Security.Auth) + de-dupe Deployments/Alerts in-file @using", "status": "completed", "blockedBy": ["T1"] }, - { "id": "T6", "subject": "Gate UNS pages with ConfigEditor (GlobalUns, EquipmentPage)", "status": "completed", "blockedBy": ["T4", "T5"] }, - { "id": "T7", "subject": "Gate cluster editors with ConfigEditor (NewCluster, ClusterEdit, NodeEdit, NamespaceEdit, AclEdit)", "status": "completed", "blockedBy": ["T4", "T5"] }, - { "id": "T8", "subject": "Gate driver pages + routers with ConfigEditor (8 driver pages, DriverTypePicker, DriverEditRouter)", "status": "completed", "blockedBy": ["T4", "T5"] }, - { "id": "T9", "subject": "Converge Deployments/Scripts/ScriptEdit from Roles-string to ConfigEditor policy", "status": "completed", "blockedBy": ["T4", "T5"] }, - { "id": "T10", "subject": "Read pages to AuthenticatedRead (16 incl. Home insert) + RoleGrants to FleetAdmin constant", "status": "completed", "blockedBy": ["T4", "T5"] }, - { "id": "T11", "subject": "Guard GREEN checkpoint + full AdminUI.Tests; commit the gate", "status": "completed", "blockedBy": ["T3", "T6", "T7", "T8", "T9", "T10"] }, - { "id": "T12", "subject": "Converge non-page literals to constants (ScriptAnalysisEndpoints, imperative DriverOperator x4, Certificates FleetAdmin x2)", "status": "completed", "blockedBy": ["T11"] }, - { "id": "T13", "subject": "Docs: CLAUDE.md ScriptAnalysis gate sentence, docs/security.md, STATUS.md R2-05 row", "status": "completed", "blockedBy": ["T11"] }, - { "id": "T14", "subject": "Full-solution test sweep (dotnet test ZB.MOM.WW.OtOpcUa.slnx)", "status": "completed", "blockedBy": ["T12", "T13"], "note": "Impacted unit suites authoritative + green: AdminUI.Tests 517/517, Security.Tests 80/80. One whole-solution sweep ran BEFORE the controller's no-whole-solution/no-IntegrationTests constraint arrived; all failures are pre-existing/env and outside the R2-05 diff (Core.Abstractions.Tests InterfaceIndependence flags pre-existing Historian.* namespace types; Runtime historian retry timing; OpcUaClient.Browser needs a live OPC endpoint; AbCip/OpcUaServer/Host IntegrationTests need Docker fixtures). Will NOT be re-run per constraint." }, - { "id": "T15", "subject": "Live /run positive pass on docker-dev :9200 (rebuild BOTH centrals; auto-admin drives all page tiers; negative proof stays in T2/T3)", "status": "deferred-live", "blockedBy": ["T14"], "note": "docker-dev live authz /run; serial live pass. Heavy integration/Playwright-dependent gate — controller runs serialized. docker-dev DisableLogin=true auto-authenticates as full-access admin, so role-deny differentiation is NOT observable there; the negative proof is CI-side (T2 AdminUiPoliciesTests + T3 PageAuthorizationGuardTests, both green). This pass only regression-checks over-gating (a typo'd policy name would render the deny slot even for the auto-admin)." }, - { "id": "T16", "subject": "Merge + bookkeeping (flip 04/C-1 row, record Reservations/ClusterRedundancy corrections)", "status": "completed", "blockedBy": ["T15"] } + { + "id": "T1", + "subject": "Add AdminUiPolicies constants class (Security/Auth/AdminUiPolicies.cs)", + "status": "completed", + "blockedBy": [] + }, + { + "id": "T2", + "subject": "Policy-semantics unit test AdminUiPoliciesTests (RED: ConfigEditor/AuthenticatedRead unregistered)", + "status": "completed", + "blockedBy": [ + "T1" + ] + }, + { + "id": "T3", + "subject": "Reflection PageAuthorizationGuardTests (RED: must list ~35 ungated/mis-idiom pages)", + "status": "completed", + "blockedBy": [ + "T1" + ] + }, + { + "id": "T4", + "subject": "Register ConfigEditor + AuthenticatedRead policies; switch existing AddPolicy literals to constants (GREEN for T2)", + "status": "completed", + "blockedBy": [ + "T2" + ] + }, + { + "id": "T5", + "subject": "_Imports.razor usings (Authorization + Security.Auth) + de-dupe Deployments/Alerts in-file @using", + "status": "completed", + "blockedBy": [ + "T1" + ] + }, + { + "id": "T6", + "subject": "Gate UNS pages with ConfigEditor (GlobalUns, EquipmentPage)", + "status": "completed", + "blockedBy": [ + "T4", + "T5" + ] + }, + { + "id": "T7", + "subject": "Gate cluster editors with ConfigEditor (NewCluster, ClusterEdit, NodeEdit, NamespaceEdit, AclEdit)", + "status": "completed", + "blockedBy": [ + "T4", + "T5" + ] + }, + { + "id": "T8", + "subject": "Gate driver pages + routers with ConfigEditor (8 driver pages, DriverTypePicker, DriverEditRouter)", + "status": "completed", + "blockedBy": [ + "T4", + "T5" + ] + }, + { + "id": "T9", + "subject": "Converge Deployments/Scripts/ScriptEdit from Roles-string to ConfigEditor policy", + "status": "completed", + "blockedBy": [ + "T4", + "T5" + ] + }, + { + "id": "T10", + "subject": "Read pages to AuthenticatedRead (16 incl. Home insert) + RoleGrants to FleetAdmin constant", + "status": "completed", + "blockedBy": [ + "T4", + "T5" + ] + }, + { + "id": "T11", + "subject": "Guard GREEN checkpoint + full AdminUI.Tests; commit the gate", + "status": "completed", + "blockedBy": [ + "T3", + "T6", + "T7", + "T8", + "T9", + "T10" + ] + }, + { + "id": "T12", + "subject": "Converge non-page literals to constants (ScriptAnalysisEndpoints, imperative DriverOperator x4, Certificates FleetAdmin x2)", + "status": "completed", + "blockedBy": [ + "T11" + ] + }, + { + "id": "T13", + "subject": "Docs: CLAUDE.md ScriptAnalysis gate sentence, docs/security.md, STATUS.md R2-05 row", + "status": "completed", + "blockedBy": [ + "T11" + ] + }, + { + "id": "T14", + "subject": "Full-solution test sweep (dotnet test ZB.MOM.WW.OtOpcUa.slnx)", + "status": "completed", + "blockedBy": [ + "T12", + "T13" + ], + "note": "Impacted unit suites authoritative + green: AdminUI.Tests 517/517, Security.Tests 80/80. One whole-solution sweep ran BEFORE the controller's no-whole-solution/no-IntegrationTests constraint arrived; all failures are pre-existing/env and outside the R2-05 diff (Core.Abstractions.Tests InterfaceIndependence flags pre-existing Historian.* namespace types; Runtime historian retry timing; OpcUaClient.Browser needs a live OPC endpoint; AbCip/OpcUaServer/Host IntegrationTests need Docker fixtures). Will NOT be re-run per constraint." + }, + { + "id": "T15", + "subject": "Live /run positive pass on docker-dev :9200 (rebuild BOTH centrals; auto-admin drives all page tiers; negative proof stays in T2/T3)", + "status": "completed", + "blockedBy": [ + "T14" + ], + "note": "docker-dev live authz /run; serial live pass. Heavy integration/Playwright-dependent gate \u2014 controller runs serialized. docker-dev DisableLogin=true auto-authenticates as full-access admin, so role-deny differentiation is NOT observable there; the negative proof is CI-side (T2 AdminUiPoliciesTests + T3 PageAuthorizationGuardTests, both green). This pass only regression-checks over-gating (a typo'd policy name would render the deny slot even for the auto-admin).", + "closureNote": "LIVE GATE CLOSED 2026-07-13 (partial by design) \u2014 every config/read page renders under the all-roles multi-role-test session, so named-policy gating does not over-gate a legitimate admin. The DENY direction is unobservable on docker-dev (DisableLogin=true), as documented." + }, + { + "id": "T16", + "subject": "Merge + bookkeeping (flip 04/C-1 row, record Reservations/ClusterRedundancy corrections)", + "status": "completed", + "blockedBy": [ + "T15" + ] + } ] } diff --git a/archreview/plans/R2-06-serverhistorian-failfast-plan.md.tasks.json b/archreview/plans/R2-06-serverhistorian-failfast-plan.md.tasks.json index 985c2394..dded8b73 100644 --- a/archreview/plans/R2-06-serverhistorian-failfast-plan.md.tasks.json +++ b/archreview/plans/R2-06-serverhistorian-failfast-plan.md.tasks.json @@ -1,16 +1,16 @@ { "planPath": "archreview/plans/R2-06-serverhistorian-failfast-plan.md", - "lastUpdated": "2026-07-12", + "lastUpdated": "2026-07-27", "tasks": [ { "id": "T1", - "subject": "RED: misconfig repro test — HistorianGatewayClientAdapter.Create with empty/malformed Endpoint must throw a named InvalidOperationException (currently UriFormatException; test MUST fail on current code)", + "subject": "RED: misconfig repro test \u2014 HistorianGatewayClientAdapter.Create with empty/malformed Endpoint must throw a named InvalidOperationException (currently UriFormatException; test MUST fail on current code)", "status": "completed", "blockedBy": [] }, { "id": "T2", - "subject": "GREEN: adapter guard — Uri.TryCreate + InvalidOperationException naming ServerHistorian:Endpoint in HistorianGatewayClientAdapter.Create (defense in depth)", + "subject": "GREEN: adapter guard \u2014 Uri.TryCreate + InvalidOperationException naming ServerHistorian:Endpoint in HistorianGatewayClientAdapter.Create (defense in depth)", "status": "completed", "blockedBy": [ "T1" @@ -50,7 +50,7 @@ }, { "id": "T7", - "subject": "U-7 pin: GatewayTagProvisionerTests Boolean_definition_carries_explicit_Int1_presence (asserts HistorianDataType.Int1 AND HasDataType — 0.2.0 proto3-optional presence witness; pin, no RED phase)", + "subject": "U-7 pin: GatewayTagProvisionerTests Boolean_definition_carries_explicit_Int1_presence (asserts HistorianDataType.Int1 AND HasDataType \u2014 0.2.0 proto3-optional presence witness; pin, no RED phase)", "status": "completed", "blockedBy": [] }, @@ -89,12 +89,13 @@ { "id": "T12", "subject": "OPERATOR GATE: run Category=LiveIntegration on the VPN against a 0.2.0 gateway (all 6 tests, incl. Boolean EnsureTags + retype probe); record the observed retype policy back into docs/Historian.md + STATUS.md; also discharges 06/U-2's full documented run", - "status": "deferred-live", + "status": "completed", "blockedBy": [ "T9", "T11" ], - "note": "DONE 2026-07-13 on VPN vs a real 0.2.0 gateway (deployed locally in Docker → real AVEVA historian on wonder-sql-vd03 + Runtime SQL). 5/6 green. Found+FIXED a real OtOpcUa bug (PR #439, master 666908d7): GatewayTagProvisioner sent StorageRateMs=0 → gateway EnsureTags threw ArgumentOutOfRangeException → ALL historized-tag provisioning silently failed; stamp 1000ms + regression test. U-7 retype policy ANSWERED: EnsureTags is a real upsert; in-place retype between SUPPORTED types is ACCEPTED (Float→Double=success); but Int1 (0.2.0 Boolean target) is NOT creatable on this histsdk (ProtocolEvidenceMissingException — Int1-only; Int2/UInt2/Int4/UInt4/Float/Double all provision), so a pre-bump Float Boolean asked to become Int1 FAILS the provision (not silently retyped/data-lost). The 1 red test (Boolean→Int1) is a gateway histsdk type-support gap, not OtOpcUa. Deployment fixes: FQDN host + libgssapi-krb5-2/gss-ntlmssp in the gateway image + wonderapp SQL login for Runtime. Full record in STATUS.md." + "note": "DONE 2026-07-13 on VPN vs a real 0.2.0 gateway (deployed locally in Docker \u2192 real AVEVA historian on wonder-sql-vd03 + Runtime SQL). 5/6 green. Found+FIXED a real OtOpcUa bug (PR #439, master 666908d7): GatewayTagProvisioner sent StorageRateMs=0 \u2192 gateway EnsureTags threw ArgumentOutOfRangeException \u2192 ALL historized-tag provisioning silently failed; stamp 1000ms + regression test. U-7 retype policy ANSWERED: EnsureTags is a real upsert; in-place retype between SUPPORTED types is ACCEPTED (Float\u2192Double=success); but Int1 (0.2.0 Boolean target) is NOT creatable on this histsdk (ProtocolEvidenceMissingException \u2014 Int1-only; Int2/UInt2/Int4/UInt4/Float/Double all provision), so a pre-bump Float Boolean asked to become Int1 FAILS the provision (not silently retyped/data-lost). The 1 red test (Boolean\u2192Int1) is a gateway histsdk type-support gap, not OtOpcUa. Deployment fixes: FQDN host + libgssapi-krb5-2/gss-ntlmssp in the gateway image + wonderapp SQL login for Runtime. Full record in STATUS.md.", + "closureNote": "LIVE GATE CLOSED 2026-07-13 (6/6) \u2014 vs. the local 0.2.0 gateway + real historian over VPN. STATUS.md 'docker-dev live-/run pass'." } ] } diff --git a/archreview/plans/R2-07-surgical-pure-adds-plan.md.tasks.json b/archreview/plans/R2-07-surgical-pure-adds-plan.md.tasks.json index 2d9cd149..d7601db2 100644 --- a/archreview/plans/R2-07-surgical-pure-adds-plan.md.tasks.json +++ b/archreview/plans/R2-07-surgical-pure-adds-plan.md.tasks.json @@ -1,6 +1,6 @@ { "planPath": "archreview/plans/R2-07-surgical-pure-adds-plan.md", - "lastUpdated": "2026-07-12", + "lastUpdated": "2026-07-27", "tasks": [ { "id": "T1", @@ -45,20 +45,22 @@ { "id": "T5", "subject": "Phase 1: over-the-wire SubscriptionSurvivalTests \u2014 subscribe, pure-add, item survives + new node browsable", - "status": "deferred-live", + "status": "completed", "blockedBy": [ "T4b" ], - "note": "authored + compiles (dotnet build green); NOT run \u2014 *.IntegrationTests suite leaks ~16GB/run per the memory constraint. MUST run in the serial heavy integration pass (single-test, boots one in-process server + client)." + "note": "authored + compiles (dotnet build green); NOT run \u2014 *.IntegrationTests suite leaks ~16GB/run per the memory constraint. MUST run in the serial heavy integration pass (single-test, boots one in-process server + client).", + "closureNote": "LIVE GATE CLOSED 2026-07-13 \u2014 central-1 boot logged PureAdd (added=91, rebuild=False)." }, { "id": "T6", "subject": "Phase 1: MANDATORY live-/run gate on docker-dev (rebuild BOTH centrals; Client.CLI subscribe survives +1-tag deploy; kind=PureAdd rebuild=False) \u2014 Phase 1 shippable boundary", - "status": "deferred-live", + "status": "completed", "blockedBy": [ "T5" ], - "note": "docker-dev live /run surgical-path; serial heavy pass \u2014 F10b inertness risk, MUST run (subscribe survives +1-tag deploy; central log kind=PureAdd, rebuild=False; new node browsable/Good)." + "note": "docker-dev live /run surgical-path; serial heavy pass \u2014 F10b inertness risk, MUST run (subscribe survives +1-tag deploy; central log kind=PureAdd, rebuild=False; new node browsable/Good).", + "closureNote": "LIVE GATE CLOSED 2026-07-13 \u2014 deleting a UNS virtual tag logged PureRemove (removed=1, rebuild=False) and the node vanished from the address space." }, { "id": "T7", @@ -104,11 +106,12 @@ { "id": "T12", "subject": "Phase 2: remove-side subscription-survival integration test + live-/run remove gate (kind=PureRemove rebuild=False) \u2014 Phase 2 shippable boundary", - "status": "deferred-live", + "status": "completed", "blockedBy": [ "T11" ], - "note": "remove-side SubscriptionSurvivalTests authored + compiles (dotnet build green); NOT run \u2014 *.IntegrationTests memory constraint. Live docker-dev pure-remove gate (survivor subscription uninterrupted; central log kind=PureRemove, rebuild=False; removed node reads BadNodeIdUnknown) MUST run in the serial heavy pass \u2014 F10b inertness risk." + "note": "remove-side SubscriptionSurvivalTests authored + compiles (dotnet build green); NOT run \u2014 *.IntegrationTests memory constraint. Live docker-dev pure-remove gate (survivor subscription uninterrupted; central log kind=PureRemove, rebuild=False; removed node reads BadNodeIdUnknown) MUST run in the serial heavy pass \u2014 F10b inertness risk.", + "closureNote": "LIVE GATE CLOSED 2026-07-13 \u2014 surgical add AND remove both execute in prod with no full rebuild; the remove path's 3 new sink members are wired, not inert." }, { "id": "T13", @@ -121,11 +124,12 @@ { "id": "T14", "subject": "Phase 3: mixed-deploy integration test + live-/run mixed gate + STATUS.md/P1 close-out \u2014 plan complete", - "status": "deferred-live", + "status": "completed", "blockedBy": [ "T13" ], - "note": "mixed-deploy SubscriptionSurvivalTests authored + compiles (dotnet build green); docs close-out DONE (STATUS.md + 03/P1 marked REMEDIATED + plan deviations). Live docker-dev mixed gate (one deploy +1/-1; kind=AddRemoveMix, rebuild=False; survivor uninterrupted) MUST run in the serial heavy pass \u2014 F10b inertness risk." + "note": "mixed-deploy SubscriptionSurvivalTests authored + compiles (dotnet build green); docs close-out DONE (STATUS.md + 03/P1 marked REMEDIATED + plan deviations). Live docker-dev mixed gate (one deploy +1/-1; kind=AddRemoveMix, rebuild=False; survivor uninterrupted) MUST run in the serial heavy pass \u2014 F10b inertness risk.", + "closureNote": "LIVE GATE CLOSED 2026-07-13 \u2014 see T5/T6/T12. STATUS.md 'docker-dev live-/run pass'." } ] } diff --git a/archreview/plans/R2-10-resilience-observability-plan.md.tasks.json b/archreview/plans/R2-10-resilience-observability-plan.md.tasks.json index a8a1e80f..7b6e371f 100644 --- a/archreview/plans/R2-10-resilience-observability-plan.md.tasks.json +++ b/archreview/plans/R2-10-resilience-observability-plan.md.tasks.json @@ -1,17 +1,94 @@ { "planPath": "archreview/plans/R2-10-resilience-observability-plan.md", - "lastUpdated": "2026-07-12", + "lastUpdated": "2026-07-27", "tasks": [ - { "id": "T1", "subject": "Tracker: LastBreakerClosedUtc + IsBreakerOpen + RecordBreakerClose (Core, TDD in DriverResilienceStatusTrackerTests)", "status": "completed", "blockedBy": [] }, - { "id": "T2", "subject": "Builder: OnClosed records breaker-close on the tracker (TDD in DriverResiliencePipelineBuilderTests)", "status": "completed", "blockedBy": ["T1"] }, - { "id": "T3", "subject": "Invoker: successful Execute* resets ConsecutiveFailures (TDD in CapabilityInvokerTests)", "status": "completed", "blockedBy": ["T1"] }, - { "id": "T4", "subject": "Commons: DriverResilienceStatusChanged record + driver-resilience-status TopicName", "status": "completed", "blockedBy": [] }, - { "id": "T5", "subject": "Host: DriverResilienceStatusPublisherService (BuildMessages mapping + 5s DPS timer shell; TDD mapping in Host.IntegrationTests)", "status": "completed", "blockedBy": ["T1", "T4"] }, - { "id": "T6", "subject": "Host DI: register the publisher in AddOtOpcUaDriverFactories + ResilienceStatusReaderRegistrationTests wiring guard (tracker HAS a production reader)", "status": "completed", "blockedBy": ["T5"] }, - { "id": "T7", "subject": "AdminUI: IDriverResilienceStatusStore + InMemory impl + AddOtOpcUaDriverStatusServices registration (TDD mirroring DriverStatusSnapshotStoreTests)", "status": "completed", "blockedBy": ["T4"] }, - { "id": "T8", "subject": "AdminUI: DriverResilienceStatusBridge DPS actor + spawn in WithOtOpcUaSignalRBridges", "status": "completed", "blockedBy": ["T7"] }, - { "id": "T9", "subject": "AdminUI: resilience chip section in DriverStatusPanel.razor (in-process store read; no bUnit — verified by T10)", "status": "completed", "blockedBy": ["T7"] }, - { "id": "T10", "subject": "Live-/run gate on docker-dev: rebuild BOTH centrals, S7 dead-endpoint breaker-open, both-replica chips, recovery clear, staleness dim", "status": "deferred-live", "blockedBy": ["T6", "T8", "T9"] }, - { "id": "T11", "subject": "Docs + review ledger: mark 03/U10 + 04/U-5 remediated; update STATUS.md + FOLLOWUP-10", "status": "completed", "blockedBy": ["T10"] } + { + "id": "T1", + "subject": "Tracker: LastBreakerClosedUtc + IsBreakerOpen + RecordBreakerClose (Core, TDD in DriverResilienceStatusTrackerTests)", + "status": "completed", + "blockedBy": [] + }, + { + "id": "T2", + "subject": "Builder: OnClosed records breaker-close on the tracker (TDD in DriverResiliencePipelineBuilderTests)", + "status": "completed", + "blockedBy": [ + "T1" + ] + }, + { + "id": "T3", + "subject": "Invoker: successful Execute* resets ConsecutiveFailures (TDD in CapabilityInvokerTests)", + "status": "completed", + "blockedBy": [ + "T1" + ] + }, + { + "id": "T4", + "subject": "Commons: DriverResilienceStatusChanged record + driver-resilience-status TopicName", + "status": "completed", + "blockedBy": [] + }, + { + "id": "T5", + "subject": "Host: DriverResilienceStatusPublisherService (BuildMessages mapping + 5s DPS timer shell; TDD mapping in Host.IntegrationTests)", + "status": "completed", + "blockedBy": [ + "T1", + "T4" + ] + }, + { + "id": "T6", + "subject": "Host DI: register the publisher in AddOtOpcUaDriverFactories + ResilienceStatusReaderRegistrationTests wiring guard (tracker HAS a production reader)", + "status": "completed", + "blockedBy": [ + "T5" + ] + }, + { + "id": "T7", + "subject": "AdminUI: IDriverResilienceStatusStore + InMemory impl + AddOtOpcUaDriverStatusServices registration (TDD mirroring DriverStatusSnapshotStoreTests)", + "status": "completed", + "blockedBy": [ + "T4" + ] + }, + { + "id": "T8", + "subject": "AdminUI: DriverResilienceStatusBridge DPS actor + spawn in WithOtOpcUaSignalRBridges", + "status": "completed", + "blockedBy": [ + "T7" + ] + }, + { + "id": "T9", + "subject": "AdminUI: resilience chip section in DriverStatusPanel.razor (in-process store read; no bUnit \u2014 verified by T10)", + "status": "completed", + "blockedBy": [ + "T7" + ] + }, + { + "id": "T10", + "subject": "Live-/run gate on docker-dev: rebuild BOTH centrals, S7 dead-endpoint breaker-open, both-replica chips, recovery clear, staleness dim", + "status": "deferred-live", + "blockedBy": [ + "T6", + "T8", + "T9" + ], + "openNote": "PARTIAL \u2014 the resilience PIPELINE is live-verified (Polly CircuitBreaker -> Retry -> Timeout -> CapabilityInvoker in central-1 stack traces), but breaker-OPEN state was never forced: R2-09's ConnectionBackoff throttles retries so failures don't accumulate fast enough to open the Polly breaker. The reliable trigger is a rapid idempotent write to a dead endpoint. Confirmed still open 2026-07-27 (deferment.md \u00a78.5)." + }, + { + "id": "T11", + "subject": "Docs + review ledger: mark 03/U10 + 04/U-5 remediated; update STATUS.md + FOLLOWUP-10", + "status": "completed", + "blockedBy": [ + "T10" + ] + } ] } diff --git a/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json b/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json index f37856f2..cd45d1ef 100644 --- a/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json +++ b/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json @@ -1,30 +1,213 @@ { "planPath": "archreview/plans/R2-11-tagconfig-consolidation-plan.md", - "lastUpdated": "2026-07-12", + "lastUpdated": "2026-07-27", "tasks": [ - { "id": "T1", "subject": "Golden TagConfig corpus + compose→encode→decode parity characterization test (Runtime.Tests, green on unmodified tree)", "status": "completed", "blockedBy": [] }, - { "id": "T2", "subject": "FullName-semantics characterization for EquipmentNodeWalker (Core.Tests) + DraftValidator Galaxy rule (Configuration.Tests)", "status": "completed", "blockedBy": [] }, - { "id": "T3", "subject": "TagConfigIntent + TagAlarmIntent in Commons/Types (TDD; port composer semantics verbatim + OpcUaServer ExtractTag* test tables)", "status": "completed", "blockedBy": [] }, - { "id": "T4", "subject": "DeviceConfigIntent (TryExtractHost/NormalizeHost) in Commons/Types (TDD)", "status": "completed", "blockedBy": [] }, - { "id": "T5", "subject": "AddressSpaceComposer swap: single TagConfigIntent.Parse per tag, delete 4 Extract* statics (P-1 parse-once)", "status": "completed", "blockedBy": ["T1", "T3"] }, - { "id": "T6", "subject": "Device-host re-home: composer TryExtractDeviceHost/NormalizeDeviceHost → DeviceConfigIntent; update DriverHostActor + DeploymentArtifact callers", "status": "completed", "blockedBy": ["T4", "T5"] }, - { "id": "T7", "subject": "DeploymentArtifact swap: delete 4 private mirrors, parse once per tag element via TagConfigIntent", "status": "completed", "blockedBy": ["T5"] }, - { "id": "T8", "subject": "DraftValidator swap: Configuration→Commons ProjectReference; ExtractTagConfigFullName → TagConfigIntent.ExplicitFullName (null-on-absent preserved)", "status": "completed", "blockedBy": ["T2", "T3"] }, - { "id": "T9", "subject": "EquipmentNodeWalker.ExtractFullName delegates to TagConfigIntent; repoint tree-wide canonical-parser doc cites", "status": "completed", "blockedBy": ["T2", "T3"] }, - { "id": "T10", "subject": "Retire OpcUaServer.Tests ExtractTag*Tests (authority moved to Commons.Tests); verify zero 'MUST parse identically' hits", "status": "completed", "blockedBy": ["T3", "T5"] }, - { "id": "T11", "subject": "TagConfigJson strict-capable readers in Core.Abstractions (TryReadEnum Absent/Valid/Invalid, ReadEnumOrDefault, ReadWritable, DescribeInvalidEnum) (TDD)", "status": "completed", "blockedBy": [] }, - { "id": "T12", "subject": "Modbus equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings", "status": "completed", "blockedBy": ["T11"] }, - { "id": "T13", "subject": "S7 equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings (+ amend Contracts banner)", "status": "completed", "blockedBy": ["T11"] }, - { "id": "T14", "subject": "AbCip equipment-tag parser: freeze test, shared readers (writable already honoured), Inspect warnings", "status": "completed", "blockedBy": ["T11"] }, - { "id": "T15", "subject": "AbLegacy equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings", "status": "completed", "blockedBy": ["T11"] }, - { "id": "T16", "subject": "TwinCAT equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings", "status": "completed", "blockedBy": ["T11"] }, - { "id": "T17", "subject": "FOCAS equipment-tag parser: force Writable:false (05/UNDER-1 correction), shared readers, Inspect warnings (+ amend Contracts banner)", "status": "completed", "blockedBy": ["T11"] }, - { "id": "T18", "subject": "FOCAS capability-matrix pre-flight on the equipment-tag _parseRef resolve path (BadNodeIdUnknown on rejection)", "status": "completed", "blockedBy": ["T17"] }, - { "id": "T19", "subject": "Modbus probe parses the factory DTO shape (timeoutMs round-trip; OpcUaClient parity rule)", "status": "completed", "blockedBy": [] }, - { "id": "T20", "subject": "EquipmentTagConfigInspector DriverType-dispatch map in ControlPlane (+6 Contracts references)", "status": "completed", "blockedBy": ["T12", "T13", "T14", "T15", "T16", "T17"] }, - { "id": "T21", "subject": "AdminOperationsActor deploy-gate wiring + Deployment:TagConfigValidationMode (Warn default | Error opt-in); actor-level consuming test", "status": "completed", "blockedBy": ["T20"] }, - { "id": "T22", "subject": "AdminUI writable checkbox in the six driver-typed tag editors (models + razor; live /run verify at execution)", "status": "deferred-live", "blockedBy": [] }, - { "id": "T23", "subject": "Docs + decision record: two-mode rollout, unknown-keys non-goal, FOCAS writable migration note, STATUS.md/00-INDEX.md entries + Phase-C follow-up", "status": "completed", "blockedBy": ["T17", "T21"] }, - { "id": "T24", "subject": "Final sweep: zero parity-comment/ReadEnum-copy grep hits; whole-solution build + test gate", "status": "deferred-live", "blockedBy": ["T5", "T6", "T7", "T8", "T9", "T10", "T12", "T13", "T14", "T15", "T16", "T17", "T18", "T19", "T21", "T22", "T23"] } + { + "id": "T1", + "subject": "Golden TagConfig corpus + compose\u2192encode\u2192decode parity characterization test (Runtime.Tests, green on unmodified tree)", + "status": "completed", + "blockedBy": [] + }, + { + "id": "T2", + "subject": "FullName-semantics characterization for EquipmentNodeWalker (Core.Tests) + DraftValidator Galaxy rule (Configuration.Tests)", + "status": "completed", + "blockedBy": [] + }, + { + "id": "T3", + "subject": "TagConfigIntent + TagAlarmIntent in Commons/Types (TDD; port composer semantics verbatim + OpcUaServer ExtractTag* test tables)", + "status": "completed", + "blockedBy": [] + }, + { + "id": "T4", + "subject": "DeviceConfigIntent (TryExtractHost/NormalizeHost) in Commons/Types (TDD)", + "status": "completed", + "blockedBy": [] + }, + { + "id": "T5", + "subject": "AddressSpaceComposer swap: single TagConfigIntent.Parse per tag, delete 4 Extract* statics (P-1 parse-once)", + "status": "completed", + "blockedBy": [ + "T1", + "T3" + ] + }, + { + "id": "T6", + "subject": "Device-host re-home: composer TryExtractDeviceHost/NormalizeDeviceHost \u2192 DeviceConfigIntent; update DriverHostActor + DeploymentArtifact callers", + "status": "completed", + "blockedBy": [ + "T4", + "T5" + ] + }, + { + "id": "T7", + "subject": "DeploymentArtifact swap: delete 4 private mirrors, parse once per tag element via TagConfigIntent", + "status": "completed", + "blockedBy": [ + "T5" + ] + }, + { + "id": "T8", + "subject": "DraftValidator swap: Configuration\u2192Commons ProjectReference; ExtractTagConfigFullName \u2192 TagConfigIntent.ExplicitFullName (null-on-absent preserved)", + "status": "completed", + "blockedBy": [ + "T2", + "T3" + ] + }, + { + "id": "T9", + "subject": "EquipmentNodeWalker.ExtractFullName delegates to TagConfigIntent; repoint tree-wide canonical-parser doc cites", + "status": "completed", + "blockedBy": [ + "T2", + "T3" + ] + }, + { + "id": "T10", + "subject": "Retire OpcUaServer.Tests ExtractTag*Tests (authority moved to Commons.Tests); verify zero 'MUST parse identically' hits", + "status": "completed", + "blockedBy": [ + "T3", + "T5" + ] + }, + { + "id": "T11", + "subject": "TagConfigJson strict-capable readers in Core.Abstractions (TryReadEnum Absent/Valid/Invalid, ReadEnumOrDefault, ReadWritable, DescribeInvalidEnum) (TDD)", + "status": "completed", + "blockedBy": [] + }, + { + "id": "T12", + "subject": "Modbus equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings", + "status": "completed", + "blockedBy": [ + "T11" + ] + }, + { + "id": "T13", + "subject": "S7 equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings (+ amend Contracts banner)", + "status": "completed", + "blockedBy": [ + "T11" + ] + }, + { + "id": "T14", + "subject": "AbCip equipment-tag parser: freeze test, shared readers (writable already honoured), Inspect warnings", + "status": "completed", + "blockedBy": [ + "T11" + ] + }, + { + "id": "T15", + "subject": "AbLegacy equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings", + "status": "completed", + "blockedBy": [ + "T11" + ] + }, + { + "id": "T16", + "subject": "TwinCAT equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings", + "status": "completed", + "blockedBy": [ + "T11" + ] + }, + { + "id": "T17", + "subject": "FOCAS equipment-tag parser: force Writable:false (05/UNDER-1 correction), shared readers, Inspect warnings (+ amend Contracts banner)", + "status": "completed", + "blockedBy": [ + "T11" + ] + }, + { + "id": "T18", + "subject": "FOCAS capability-matrix pre-flight on the equipment-tag _parseRef resolve path (BadNodeIdUnknown on rejection)", + "status": "completed", + "blockedBy": [ + "T17" + ] + }, + { + "id": "T19", + "subject": "Modbus probe parses the factory DTO shape (timeoutMs round-trip; OpcUaClient parity rule)", + "status": "completed", + "blockedBy": [] + }, + { + "id": "T20", + "subject": "EquipmentTagConfigInspector DriverType-dispatch map in ControlPlane (+6 Contracts references)", + "status": "completed", + "blockedBy": [ + "T12", + "T13", + "T14", + "T15", + "T16", + "T17" + ] + }, + { + "id": "T21", + "subject": "AdminOperationsActor deploy-gate wiring + Deployment:TagConfigValidationMode (Warn default | Error opt-in); actor-level consuming test", + "status": "completed", + "blockedBy": [ + "T20" + ] + }, + { + "id": "T22", + "subject": "AdminUI writable checkbox in the six driver-typed tag editors (models + razor; live /run verify at execution)", + "status": "completed", + "blockedBy": [], + "closureNote": "LIVE GATE CLOSED 2026-07-13 \u2014 the typed Modbus tag editor shows the Writable toggle and saving persists `writable: yes`." + }, + { + "id": "T23", + "subject": "Docs + decision record: two-mode rollout, unknown-keys non-goal, FOCAS writable migration note, STATUS.md/00-INDEX.md entries + Phase-C follow-up", + "status": "completed", + "blockedBy": [ + "T17", + "T21" + ] + }, + { + "id": "T24", + "subject": "Final sweep: zero parity-comment/ReadEnum-copy grep hits; whole-solution build + test gate", + "status": "completed", + "blockedBy": [ + "T5", + "T6", + "T7", + "T8", + "T9", + "T10", + "T12", + "T13", + "T14", + "T15", + "T16", + "T17", + "T18", + "T19", + "T21", + "T22", + "T23" + ], + "closureNote": "LIVE GATE CLOSED 2026-07-13 \u2014 see T22. STATUS.md 'docker-dev live-/run pass'." + } ] } diff --git a/deferment.md b/deferment.md index 47f063e7..3a4de5b5 100644 --- a/deferment.md +++ b/deferment.md @@ -358,10 +358,11 @@ appear in §7.1 as well. 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. -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. +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. --- @@ -376,8 +377,8 @@ the work, so this register never disagrees with the tree. | 2. #518 + #507 | ✅ **Done** — `IRediscoverable` consumed as a re-browse prompt; #507's injection path **deleted**; `IHostConnectivityProbe` half split to Gitea **#521** | `09a401b8`, `97c9f4b4` | | 3. #516 config discard | ✅ **Done** — seam respawn + re-parse on 3 drivers; Sql/FOCAS deliberately respawn-only | `2dc19f30` | | 4. G-4 dispatch-map parity | ✅ **Done** — G-1…G-6 all closed, live-verified on docker-dev | `abacf4cf` | -| 5. Bookkeeping sweep | ⏳ Not started | — | -| 6. Tier truth | ⏳ Not started — decided *document the truth, defer the code decision* | — | +| 5. Bookkeeping sweep | ✅ **Done** — 13 plan files closed, 11 archreview gates written back | `03658c2e` | +| 6. Tier truth | ✅ **Done** — docs reconciled; keep-or-delete is Gitea **#522** | `03658c2e` | ### 2026-07-27 — §8.1 ACL non-enforcement made explicit @@ -466,6 +467,36 @@ Two traps worth keeping: `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-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 diff --git a/docs/plans/2026-06-12-historian-tcp-transport.md.tasks.json b/docs/plans/2026-06-12-historian-tcp-transport.md.tasks.json index 54e08321..84dc0d91 100644 --- a/docs/plans/2026-06-12-historian-tcp-transport.md.tasks.json +++ b/docs/plans/2026-06-12-historian-tcp-transport.md.tasks.json @@ -1,18 +1,116 @@ { "planPath": "docs/plans/2026-06-12-historian-tcp-transport.md", "tasks": [ - {"id": 0, "nativeTaskId": 296, "subject": "Task 0: Create feature branch", "status": "pending"}, - {"id": 1, "nativeTaskId": 297, "subject": "Task 1: Add TCP/TLS fields to client options", "status": "pending", "blockedBy": [0]}, - {"id": 2, "nativeTaskId": 298, "subject": "Task 2: Client TCP connect factory + FrameChannel rename", "status": "pending", "blockedBy": [1]}, - {"id": 3, "nativeTaskId": 299, "subject": "Task 3: Switch client default ctor to TCP", "status": "pending", "blockedBy": [2]}, - {"id": 4, "nativeTaskId": 300, "subject": "Task 4: Host config binding (Host/Port/TLS)", "status": "pending", "blockedBy": [1]}, - {"id": 5, "nativeTaskId": 301, "subject": "Task 5: Sidecar TcpFrameServer", "status": "pending", "blockedBy": [0]}, - {"id": 6, "nativeTaskId": 302, "subject": "Task 6: Sidecar Program.cs — TCP bootstrap + env", "status": "pending", "blockedBy": [5]}, - {"id": 7, "nativeTaskId": 303, "subject": "Task 7: Remove dead pipe code + finalize options shape", "status": "pending", "blockedBy": [3, 4, 6]}, - {"id": 8, "nativeTaskId": 304, "subject": "Task 8: Deploy scripts — env block + firewall + cert", "status": "pending", "blockedBy": [7]}, - {"id": 9, "nativeTaskId": 305, "subject": "Task 9: AdminUI Test-Connect probe to host/port/TLS", "status": "pending", "blockedBy": [7]}, - {"id": 10, "nativeTaskId": 306, "subject": "Task 10: Docs — TCP transport", "status": "pending", "blockedBy": [7]}, - {"id": 11, "nativeTaskId": 307, "subject": "Task 11: Verification (build + test + live)", "status": "pending", "blockedBy": [8, 9, 10]} + { + "id": 0, + "nativeTaskId": 296, + "subject": "Task 0: Create feature branch", + "status": "completed" + }, + { + "id": 1, + "nativeTaskId": 297, + "subject": "Task 1: Add TCP/TLS fields to client options", + "status": "completed", + "blockedBy": [ + 0 + ] + }, + { + "id": 2, + "nativeTaskId": 298, + "subject": "Task 2: Client TCP connect factory + FrameChannel rename", + "status": "completed", + "blockedBy": [ + 1 + ] + }, + { + "id": 3, + "nativeTaskId": 299, + "subject": "Task 3: Switch client default ctor to TCP", + "status": "completed", + "blockedBy": [ + 2 + ] + }, + { + "id": 4, + "nativeTaskId": 300, + "subject": "Task 4: Host config binding (Host/Port/TLS)", + "status": "completed", + "blockedBy": [ + 1 + ] + }, + { + "id": 5, + "nativeTaskId": 301, + "subject": "Task 5: Sidecar TcpFrameServer", + "status": "completed", + "blockedBy": [ + 0 + ] + }, + { + "id": 6, + "nativeTaskId": 302, + "subject": "Task 6: Sidecar Program.cs \u2014 TCP bootstrap + env", + "status": "completed", + "blockedBy": [ + 5 + ] + }, + { + "id": 7, + "nativeTaskId": 303, + "subject": "Task 7: Remove dead pipe code + finalize options shape", + "status": "completed", + "blockedBy": [ + 3, + 4, + 6 + ] + }, + { + "id": 8, + "nativeTaskId": 304, + "subject": "Task 8: Deploy scripts \u2014 env block + firewall + cert", + "status": "completed", + "blockedBy": [ + 7 + ] + }, + { + "id": 9, + "nativeTaskId": 305, + "subject": "Task 9: AdminUI Test-Connect probe to host/port/TLS", + "status": "completed", + "blockedBy": [ + 7 + ] + }, + { + "id": 10, + "nativeTaskId": 306, + "subject": "Task 10: Docs \u2014 TCP transport", + "status": "completed", + "blockedBy": [ + 7 + ] + }, + { + "id": 11, + "nativeTaskId": 307, + "subject": "Task 11: Verification (build + test + live)", + "status": "completed", + "blockedBy": [ + 8, + 9, + 10 + ] + } ], - "lastUpdated": "2026-06-12" + "lastUpdated": "2026-07-27", + "closureNote": "OBSOLETE \u2014 targets the bespoke Wonderware TCP/ArchestrA historian sidecar, retired when HistorianGateway became the sole historian backend. There is no Wonderware backend in the tree. Closed as obsolete 2026-07-27 (deferment.md \u00a78.5)." } diff --git a/docs/plans/2026-06-15-stillpending-phase-0-1.md.tasks.json b/docs/plans/2026-06-15-stillpending-phase-0-1.md.tasks.json index 229b845a..9da566b6 100644 --- a/docs/plans/2026-06-15-stillpending-phase-0-1.md.tasks.json +++ b/docs/plans/2026-06-15-stillpending-phase-0-1.md.tasks.json @@ -1,18 +1,111 @@ { "planPath": "docs/plans/2026-06-15-stillpending-phase-0-1.md", "tasks": [ - {"id": 402, "subject": "SP Task 0: Feature branch feat/stillpending-phase-0-1", "status": "pending"}, - {"id": 403, "subject": "SP Task 1: Phase 0 — correct 7 stale code comments", "status": "pending", "blockedBy": [402]}, - {"id": 404, "subject": "SP Task 2: Phase 0 — fix docs/security.md + benign-residue comments", "status": "pending", "blockedBy": [402]}, - {"id": 405, "subject": "SP Task 3: Phase 0 — mark shipped .tasks.json completed", "status": "pending", "blockedBy": [402]}, - {"id": 406, "subject": "SP Task 4: Phase 1 H1a — Phase7Applier rebuilds on Changed*", "status": "pending", "blockedBy": [402]}, - {"id": 407, "subject": "SP Task 5: Phase 1 H1b — VirtualTagHostActor respawns changed child", "status": "pending", "blockedBy": [402]}, - {"id": 408, "subject": "SP Task 6: Phase 1 H5a — EquipmentVirtualTagPlan.Historize + composer", "status": "pending", "blockedBy": [402]}, - {"id": 409, "subject": "SP Task 7: Phase 1 H5b — DeploymentArtifact decodes Historize (byte-parity)", "status": "pending", "blockedBy": [408]}, - {"id": 410, "subject": "SP Task 8: Phase 1 H5c — VirtualTagHostActor invokes IHistoryWriter", "status": "pending", "blockedBy": [407, 409]}, - {"id": 411, "subject": "SP Task 9: Phase 1 H5d — thread IHistoryWriter through DriverHostActor + DI", "status": "pending", "blockedBy": [410, 403]}, - {"id": 412, "subject": "SP Task 10: Phase 1 — docs + follow-up bookkeeping", "status": "pending", "blockedBy": [411]}, - {"id": 413, "subject": "SP Task 11: Phase 0+1 — full build + test + integration review", "status": "pending", "blockedBy": [403, 404, 405, 406, 407, 408, 409, 410, 411, 412]} + { + "id": 402, + "subject": "SP Task 0: Feature branch feat/stillpending-phase-0-1", + "status": "completed" + }, + { + "id": 403, + "subject": "SP Task 1: Phase 0 \u2014 correct 7 stale code comments", + "status": "completed", + "blockedBy": [ + 402 + ] + }, + { + "id": 404, + "subject": "SP Task 2: Phase 0 \u2014 fix docs/security.md + benign-residue comments", + "status": "completed", + "blockedBy": [ + 402 + ] + }, + { + "id": 405, + "subject": "SP Task 3: Phase 0 \u2014 mark shipped .tasks.json completed", + "status": "completed", + "blockedBy": [ + 402 + ] + }, + { + "id": 406, + "subject": "SP Task 4: Phase 1 H1a \u2014 Phase7Applier rebuilds on Changed*", + "status": "completed", + "blockedBy": [ + 402 + ] + }, + { + "id": 407, + "subject": "SP Task 5: Phase 1 H1b \u2014 VirtualTagHostActor respawns changed child", + "status": "completed", + "blockedBy": [ + 402 + ] + }, + { + "id": 408, + "subject": "SP Task 6: Phase 1 H5a \u2014 EquipmentVirtualTagPlan.Historize + composer", + "status": "completed", + "blockedBy": [ + 402 + ] + }, + { + "id": 409, + "subject": "SP Task 7: Phase 1 H5b \u2014 DeploymentArtifact decodes Historize (byte-parity)", + "status": "completed", + "blockedBy": [ + 408 + ] + }, + { + "id": 410, + "subject": "SP Task 8: Phase 1 H5c \u2014 VirtualTagHostActor invokes IHistoryWriter", + "status": "completed", + "blockedBy": [ + 407, + 409 + ] + }, + { + "id": 411, + "subject": "SP Task 9: Phase 1 H5d \u2014 thread IHistoryWriter through DriverHostActor + DI", + "status": "completed", + "blockedBy": [ + 410, + 403 + ] + }, + { + "id": 412, + "subject": "SP Task 10: Phase 1 \u2014 docs + follow-up bookkeeping", + "status": "completed", + "blockedBy": [ + 411 + ] + }, + { + "id": 413, + "subject": "SP Task 11: Phase 0+1 \u2014 full build + test + integration review", + "status": "completed", + "blockedBy": [ + 403, + 404, + 405, + 406, + 407, + 408, + 409, + 410, + 411, + 412 + ] + } ], - "lastUpdated": "2026-06-15" + "lastUpdated": "2026-07-27", + "closureNote": "Shipped \u2014 the stillpending backlog audit cites the landing commits (1e95856b, ada01e1a, fc8121cb, 3e609a2b, 70e6d3d2, c236263e, bd8fee61, 5e27b5f7, fcb38014). Marked complete 2026-07-27 (deferment.md \u00a78.5)." } diff --git a/docs/plans/2026-06-15-stillpending-phase-2-servicelevel.md.tasks.json b/docs/plans/2026-06-15-stillpending-phase-2-servicelevel.md.tasks.json index e7774c5e..c8c51972 100644 --- a/docs/plans/2026-06-15-stillpending-phase-2-servicelevel.md.tasks.json +++ b/docs/plans/2026-06-15-stillpending-phase-2-servicelevel.md.tasks.json @@ -2,15 +2,72 @@ "planPath": "docs/plans/2026-06-15-stillpending-phase-2-servicelevel.md", "branch": "feat/stillpending-phase-2-servicelevel", "tasks": [ - {"id": 414, "subject": "P2 Task 1: Move ServiceLevelCalculator to Core.Cluster", "status": "pending"}, - {"id": 415, "subject": "P2 Task 2a: OpcUaPublishActor calculator path (DB+stale+leader+Detached guard, legacy seam)", "status": "pending", "blockedBy": [414]}, - {"id": 416, "subject": "P2 Task 2b: OpcUaProbeOk from peer-probes-me (freshness + debounce)", "status": "pending", "blockedBy": [415]}, - {"id": 417, "subject": "P2 Task 3: HealthTick — periodic DB Ask/PipeTo + PreStart immediate refresh", "status": "pending", "blockedBy": [416]}, - {"id": 418, "subject": "P2 Task 4: PeerProbeSupervisor — one peer probe per driver peer", "status": "pending"}, - {"id": 419, "subject": "P2 Task 5: Wire WithOtOpcUaRuntimeActors (dbHealth ref + spawn supervisor)", "status": "pending", "blockedBy": [417, 418]}, - {"id": 420, "subject": "P2 Task 6: docs/Redundancy.md — calculator is WIRED", "status": "pending"}, - {"id": 421, "subject": "P2 Task 7: Full build + test + final integration review", "status": "pending", "blockedBy": [419, 420]}, - {"id": 422, "subject": "P2 Task 8: Live /run on the 2-node rig (acceptance gate)", "status": "pending", "blockedBy": [421]} + { + "id": 414, + "subject": "P2 Task 1: Move ServiceLevelCalculator to Core.Cluster", + "status": "completed" + }, + { + "id": 415, + "subject": "P2 Task 2a: OpcUaPublishActor calculator path (DB+stale+leader+Detached guard, legacy seam)", + "status": "completed", + "blockedBy": [ + 414 + ] + }, + { + "id": 416, + "subject": "P2 Task 2b: OpcUaProbeOk from peer-probes-me (freshness + debounce)", + "status": "completed", + "blockedBy": [ + 415 + ] + }, + { + "id": 417, + "subject": "P2 Task 3: HealthTick \u2014 periodic DB Ask/PipeTo + PreStart immediate refresh", + "status": "completed", + "blockedBy": [ + 416 + ] + }, + { + "id": 418, + "subject": "P2 Task 4: PeerProbeSupervisor \u2014 one peer probe per driver peer", + "status": "completed" + }, + { + "id": 419, + "subject": "P2 Task 5: Wire WithOtOpcUaRuntimeActors (dbHealth ref + spawn supervisor)", + "status": "completed", + "blockedBy": [ + 417, + 418 + ] + }, + { + "id": 420, + "subject": "P2 Task 6: docs/Redundancy.md \u2014 calculator is WIRED", + "status": "completed" + }, + { + "id": 421, + "subject": "P2 Task 7: Full build + test + final integration review", + "status": "completed", + "blockedBy": [ + 419, + 420 + ] + }, + { + "id": 422, + "subject": "P2 Task 8: Live /run on the 2-node rig (acceptance gate)", + "status": "completed", + "blockedBy": [ + 421 + ] + } ], - "lastUpdated": "2026-06-15" + "lastUpdated": "2026-07-27", + "closureNote": "Shipped \u2014 the stillpending backlog audit cites the landing commits (1e95856b, ada01e1a, fc8121cb, 3e609a2b, 70e6d3d2, c236263e, bd8fee61, 5e27b5f7, fcb38014). Marked complete 2026-07-27 (deferment.md \u00a78.5)." } diff --git a/docs/plans/2026-06-15-stillpending-phase-3-opcua-standards.md.tasks.json b/docs/plans/2026-06-15-stillpending-phase-3-opcua-standards.md.tasks.json index 60bf2d05..3c96da0d 100644 --- a/docs/plans/2026-06-15-stillpending-phase-3-opcua-standards.md.tasks.json +++ b/docs/plans/2026-06-15-stillpending-phase-3-opcua-standards.md.tasks.json @@ -2,16 +2,80 @@ "planPath": "docs/plans/2026-06-15-stillpending-phase-3-opcua-standards.md", "branch": "feat/stillpending-phase-3-opcua-standards", "tasks": [ - {"id": 423, "subject": "P3 Task 1: H2-bit — NodePermissions.HistoryUpdate + evaluator mapping", "status": "pending"}, - {"id": 424, "subject": "P3 Task 2: H6a — mark native conditions (isNative through the sink)", "status": "pending"}, - {"id": 425, "subject": "P3 Task 3: H4 — wire OnEnableDisable over OPC UA", "status": "pending", "blockedBy": [424]}, - {"id": 426, "subject": "P3 Task 4: H6b — AlarmAcknowledgeRequest.OperatorUser + Galaxy/ScriptedAlarmSource", "status": "pending"}, - {"id": 427, "subject": "P3 Task 5: H6c — NativeAlarmAckRouter seam + native OnAcknowledge routing", "status": "pending", "blockedBy": [424]}, - {"id": 428, "subject": "P3 Task 6: H6d — DriverHostActor inverse map + native-ack route to driver", "status": "pending", "blockedBy": [426, 427]}, - {"id": 429, "subject": "P3 Task 7: H6e — wire NativeAlarmAckRouter in host DI", "status": "pending", "blockedBy": [427, 428]}, - {"id": 430, "subject": "P3 Task 8: Docs — Enable/Disable + native-ack→AVEVA + HistoryUpdate bit", "status": "pending"}, - {"id": 431, "subject": "P3 Task 9: Full build + test + final integration review", "status": "pending", "blockedBy": [423, 425, 429, 430]}, - {"id": 432, "subject": "P3 Task 10: Live /run — H4 Enable/Disable + H6 native-ack route", "status": "pending", "blockedBy": [431]} + { + "id": 423, + "subject": "P3 Task 1: H2-bit \u2014 NodePermissions.HistoryUpdate + evaluator mapping", + "status": "completed" + }, + { + "id": 424, + "subject": "P3 Task 2: H6a \u2014 mark native conditions (isNative through the sink)", + "status": "completed" + }, + { + "id": 425, + "subject": "P3 Task 3: H4 \u2014 wire OnEnableDisable over OPC UA", + "status": "completed", + "blockedBy": [ + 424 + ] + }, + { + "id": 426, + "subject": "P3 Task 4: H6b \u2014 AlarmAcknowledgeRequest.OperatorUser + Galaxy/ScriptedAlarmSource", + "status": "completed" + }, + { + "id": 427, + "subject": "P3 Task 5: H6c \u2014 NativeAlarmAckRouter seam + native OnAcknowledge routing", + "status": "completed", + "blockedBy": [ + 424 + ] + }, + { + "id": 428, + "subject": "P3 Task 6: H6d \u2014 DriverHostActor inverse map + native-ack route to driver", + "status": "completed", + "blockedBy": [ + 426, + 427 + ] + }, + { + "id": 429, + "subject": "P3 Task 7: H6e \u2014 wire NativeAlarmAckRouter in host DI", + "status": "completed", + "blockedBy": [ + 427, + 428 + ] + }, + { + "id": 430, + "subject": "P3 Task 8: Docs \u2014 Enable/Disable + native-ack\u2192AVEVA + HistoryUpdate bit", + "status": "completed" + }, + { + "id": 431, + "subject": "P3 Task 9: Full build + test + final integration review", + "status": "completed", + "blockedBy": [ + 423, + 425, + 429, + 430 + ] + }, + { + "id": 432, + "subject": "P3 Task 10: Live /run \u2014 H4 Enable/Disable + H6 native-ack route", + "status": "completed", + "blockedBy": [ + 431 + ] + } ], - "lastUpdated": "2026-06-15" + "lastUpdated": "2026-07-27", + "closureNote": "Shipped \u2014 the stillpending backlog audit cites the landing commits (1e95856b, ada01e1a, fc8121cb, 3e609a2b, 70e6d3d2, c236263e, bd8fee61, 5e27b5f7, fcb38014). Marked complete 2026-07-27 (deferment.md \u00a78.5)." } diff --git a/docs/plans/2026-06-16-stillpending-phase-4-driver-datatypes.md.tasks.json b/docs/plans/2026-06-16-stillpending-phase-4-driver-datatypes.md.tasks.json index bc15b3cd..249f63ff 100644 --- a/docs/plans/2026-06-16-stillpending-phase-4-driver-datatypes.md.tasks.json +++ b/docs/plans/2026-06-16-stillpending-phase-4-driver-datatypes.md.tasks.json @@ -2,14 +2,68 @@ "planPath": "docs/plans/2026-06-16-stillpending-phase-4-driver-datatypes.md", "branch": "feat/stillpending-phase-4-driver-datatypes", "tasks": [ - {"id": 433, "subject": "P4 Task 1: Modbus Int64/UInt64 node DataType (MapDataType split)", "status": "pending"}, - {"id": 434, "subject": "P4 Task 2: FOCAS fail-fast factory (EnsureUsable at init)", "status": "pending"}, - {"id": 435, "subject": "P4 Task 3: FOCAS position scaling (PositionDecimalPlaces)", "status": "pending", "blockedBy": [434]}, - {"id": 436, "subject": "P4 Task 4: Historian Total aggregate (client-side Average x interval-seconds)", "status": "pending"}, - {"id": 437, "subject": "P4 Task 5: Historian poison-event dead-letter cap (maxAttempts)", "status": "pending"}, - {"id": 438, "subject": "P4 Task 6: Docs + bookkeeping", "status": "pending", "blockedBy": [433, 434, 435, 436, 437]}, - {"id": 439, "subject": "P4 Task 7: Full build + test + final integration review", "status": "pending", "blockedBy": [433, 434, 435, 436, 437, 438]}, - {"id": 440, "subject": "P4 Task 8: Live /run — Modbus Int64 (acceptance gate)", "status": "pending", "blockedBy": [439]} + { + "id": 433, + "subject": "P4 Task 1: Modbus Int64/UInt64 node DataType (MapDataType split)", + "status": "completed" + }, + { + "id": 434, + "subject": "P4 Task 2: FOCAS fail-fast factory (EnsureUsable at init)", + "status": "completed" + }, + { + "id": 435, + "subject": "P4 Task 3: FOCAS position scaling (PositionDecimalPlaces)", + "status": "completed", + "blockedBy": [ + 434 + ] + }, + { + "id": 436, + "subject": "P4 Task 4: Historian Total aggregate (client-side Average x interval-seconds)", + "status": "completed" + }, + { + "id": 437, + "subject": "P4 Task 5: Historian poison-event dead-letter cap (maxAttempts)", + "status": "completed" + }, + { + "id": 438, + "subject": "P4 Task 6: Docs + bookkeeping", + "status": "completed", + "blockedBy": [ + 433, + 434, + 435, + 436, + 437 + ] + }, + { + "id": 439, + "subject": "P4 Task 7: Full build + test + final integration review", + "status": "completed", + "blockedBy": [ + 433, + 434, + 435, + 436, + 437, + 438 + ] + }, + { + "id": 440, + "subject": "P4 Task 8: Live /run \u2014 Modbus Int64 (acceptance gate)", + "status": "completed", + "blockedBy": [ + 439 + ] + } ], - "lastUpdated": "2026-06-16" + "lastUpdated": "2026-07-27", + "closureNote": "Shipped \u2014 the stillpending backlog audit cites the landing commits (1e95856b, ada01e1a, fc8121cb, 3e609a2b, 70e6d3d2, c236263e, bd8fee61, 5e27b5f7, fcb38014). Marked complete 2026-07-27 (deferment.md \u00a78.5)." } diff --git a/docs/plans/2026-06-16-stillpending-phase-4b-driver-gaps.md.tasks.json b/docs/plans/2026-06-16-stillpending-phase-4b-driver-gaps.md.tasks.json index c1ba5a15..5b99ede0 100644 --- a/docs/plans/2026-06-16-stillpending-phase-4b-driver-gaps.md.tasks.json +++ b/docs/plans/2026-06-16-stillpending-phase-4b-driver-gaps.md.tasks.json @@ -4,16 +4,61 @@ "designCommit": "f90017bc", "baseMaster": "c081917a", "branch": "feat/stillpending-phase-4b-driver-gaps (merged to master 08a65513, deleted)", - "executionState": "COMPLETE — shipped + pushed to master 08a65513 (ff). Final integration review = SHIP. Build clean; AdminUI 450, Galaxy 279(+3 skip), FOCAS 200, Modbus 277, DriverProbeRegistration 2 — all green. Live: Modbus typed editor + Build-address proven on the rig's seeded MAIN-modbus-eq (Modbus reconcile, conclusive); Galaxy browse rendered a nested hierarchical root via the real driver path (corroborated, unit-proven 8 ways); FOCAS unit-proven (no CNC).", - "nativeTaskIds": {"1": 482, "2": 483, "3a": 484, "3b": 485, "4": 486, "5": 487, "6": 488}, + "executionState": "COMPLETE \u2014 shipped + pushed to master 08a65513 (ff). Final integration review = SHIP. Build clean; AdminUI 450, Galaxy 279(+3 skip), FOCAS 200, Modbus 277, DriverProbeRegistration 2 \u2014 all green. Live: Modbus typed editor + Build-address proven on the rig's seeded MAIN-modbus-eq (Modbus reconcile, conclusive); Galaxy browse rendered a nested hierarchical root via the real driver path (corroborated, unit-proven 8 ways); FOCAS unit-proven (no CNC).", + "nativeTaskIds": { + "1": 482, + "2": 483, + "3a": 484, + "3b": 485, + "4": 486, + "5": 487, + "6": 488 + }, "tasks": [ - {"id": 1, "subject": "Task 1: Modbus driver-type-string reconcile (canonicalize on \"Modbus\")", "status": "completed", "commit": "8b4675b1", "reviewFixCommit": "a40c77de"}, - {"id": 2, "subject": "Task 2: Galaxy nested gobject hierarchy", "status": "completed", "commit": "21c7645d", "reviewFixCommit": "bec37848"}, - {"id": "3a", "subject": "Task 3a: IFocasClient.GetPositionFiguresAsync (cnc_getfigure binding)", "status": "completed", "commit": "3fcbc70c"}, - {"id": "3b", "subject": "Task 3b: FOCAS driver auto-scale wiring (auto wins, manual fallback)", "status": "completed", "commit": "6855be28"}, - {"id": 4, "subject": "Task 4: Docs + bookkeeping", "status": "completed", "commit": "08a65513"}, - {"id": 5, "subject": "Task 5: Full build + test + final integration review", "status": "completed", "note": "final integration review = SHIP (no Critical/Important); 3 Minor non-blocking notes recorded"}, - {"id": 6, "subject": "Task 6: Live /run verification", "status": "completed", "note": "Modbus typed editor live-proven on rig; Galaxy nested root corroborated; FOCAS unit-proven"} + { + "id": 1, + "subject": "Task 1: Modbus driver-type-string reconcile (canonicalize on \"Modbus\")", + "status": "completed", + "commit": "8b4675b1", + "reviewFixCommit": "a40c77de" + }, + { + "id": 2, + "subject": "Task 2: Galaxy nested gobject hierarchy", + "status": "completed", + "commit": "21c7645d", + "reviewFixCommit": "bec37848" + }, + { + "id": "3a", + "subject": "Task 3a: IFocasClient.GetPositionFiguresAsync (cnc_getfigure binding)", + "status": "completed", + "commit": "3fcbc70c" + }, + { + "id": "3b", + "subject": "Task 3b: FOCAS driver auto-scale wiring (auto wins, manual fallback)", + "status": "completed", + "commit": "6855be28" + }, + { + "id": 4, + "subject": "Task 4: Docs + bookkeeping", + "status": "completed", + "commit": "08a65513" + }, + { + "id": 5, + "subject": "Task 5: Full build + test + final integration review", + "status": "completed", + "note": "final integration review = SHIP (no Critical/Important); 3 Minor non-blocking notes recorded" + }, + { + "id": 6, + "subject": "Task 6: Live /run verification", + "status": "completed", + "note": "Modbus typed editor live-proven on rig; Galaxy nested root corroborated; FOCAS unit-proven" + } ], "reviewFollowUps": [ "FOCAS minor: no upper-bound clamp on a misbehaving cnc_getfigure value (Math.Pow overflow; same latent risk the manual path had)", @@ -21,5 +66,6 @@ "FOCAS: live auto-fetch on the real backend needs a cnc_getfigure command added to the managed FocasWireClient wire protocol (WireFocasClient returns empty today)", "Galaxy minor: 3-node chain cycle provably-correct but not directly tested (2-node mutual cycle is tested)" ], - "lastUpdated": "2026-06-16" + "lastUpdated": "2026-07-27", + "closureNote": "Shipped \u2014 the stillpending backlog audit cites the landing commits (1e95856b, ada01e1a, fc8121cb, 3e609a2b, 70e6d3d2, c236263e, bd8fee61, 5e27b5f7, fcb38014). Marked complete 2026-07-27 (deferment.md \u00a78.5)." } diff --git a/docs/plans/2026-06-16-stillpending-phase-4c-array-support.md.tasks.json b/docs/plans/2026-06-16-stillpending-phase-4c-array-support.md.tasks.json index 89ce9ad1..24a808cc 100644 --- a/docs/plans/2026-06-16-stillpending-phase-4c-array-support.md.tasks.json +++ b/docs/plans/2026-06-16-stillpending-phase-4c-array-support.md.tasks.json @@ -4,28 +4,121 @@ "designCommit": "efccd8d1", "baseMaster": "050164b2", "branch": "feat/stillpending-phase-4c-array-support (merged to master 0f92e9e2, deleted)", - "executionState": "COMPLETE — shipped + pushed to master 0f92e9e2 (ff, 23 commits). Big-bang all 5 drivers + full AbLegacy. Build clean; OpcUaServer 230, Runtime 272, AdminUI 472, Modbus 289, AbCip 292, TwinCAT 153, S7 143, AbLegacy 188 — all green. Bundle integration review caught the cross-driver isArray-gating divergence (C-1/C-2 Modbus+AbLegacy ignored isArray; I-1/I-2/I-3 the arrayLength=1 edge); final integration review = SHIP WITH MINOR FOLLOW-UPS (I-1 S7 clarity, I-2 AbCip degenerate-scalar, I-3 AbLegacy >256 author-validation, M-1 array nodes read-only) — all closed. LIVE /run PASS: a Modbus array equipment tag (HR[0..3], UInt16[4]) materialises as ValueRank=OneDimension/ArrayDimensions=[4] and a Good array value flows end-to-end from the live sim (read over the wire, Status 0x00000000, Value System.UInt16[]); the address-100 first attempt correctly surfaced the sim's Illegal-Data-Address as Bad (faithful device status). S7/AbCip/TwinCAT/AbLegacy unit-proven (fixtures down).", + "executionState": "COMPLETE \u2014 shipped + pushed to master 0f92e9e2 (ff, 23 commits). Big-bang all 5 drivers + full AbLegacy. Build clean; OpcUaServer 230, Runtime 272, AdminUI 472, Modbus 289, AbCip 292, TwinCAT 153, S7 143, AbLegacy 188 \u2014 all green. Bundle integration review caught the cross-driver isArray-gating divergence (C-1/C-2 Modbus+AbLegacy ignored isArray; I-1/I-2/I-3 the arrayLength=1 edge); final integration review = SHIP WITH MINOR FOLLOW-UPS (I-1 S7 clarity, I-2 AbCip degenerate-scalar, I-3 AbLegacy >256 author-validation, M-1 array nodes read-only) \u2014 all closed. LIVE /run PASS: a Modbus array equipment tag (HR[0..3], UInt16[4]) materialises as ValueRank=OneDimension/ArrayDimensions=[4] and a Good array value flows end-to-end from the live sim (read over the wire, Status 0x00000000, Value System.UInt16[]); the address-100 first attempt correctly surfaced the sim's Illegal-Data-Address as Bad (faithful device status). S7/AbCip/TwinCAT/AbLegacy unit-proven (fixtures down).", "scope": "Big-bang all 5 drivers (AskUserQuestion) + full AbLegacy array read (AskUserQuestion). 1-D read-surface only; array writes / multi-dim / array historization out of scope (array nodes forced read-only at the applier per review M-1).", - "nativeTaskIds": {"1": 495, "2": 496, "3": 497, "4": 498, "5": 499, "6": 500, "7": 501, "8": 502, "9": 503, "10": 504, "11": 505, "12": 506}, + "nativeTaskIds": { + "1": 495, + "2": 496, + "3": 497, + "4": 498, + "5": 499, + "6": 500, + "7": 501, + "8": 502, + "9": 503, + "10": 504, + "11": 505, + "12": 506 + }, "tasks": [ - {"id": 1, "subject": "Sink contract — EnsureVariable array params", "classification": "high-risk", "status": "completed", "commit": "a7928202", "reviewFixCommit": "3172b7bd"}, - {"id": 2, "subject": "EquipmentTagPlan IsArray/ArrayLength + composer + applier", "classification": "high-risk", "status": "completed", "commit": "71cc4171", "reviewFixCommit": "584e9f2a"}, - {"id": 3, "subject": "DeploymentArtifact decode byte-parity", "classification": "high-risk", "status": "completed", "commit": "0a747c34", "reviewFixCommit": "eb8a8dc1"}, - {"id": 4, "subject": "AdminUI driver-agnostic isArray/arrayLength control", "classification": "standard", "status": "completed", "commit": "c2006dfb"}, - {"id": 5, "subject": "Modbus String/BitInRegister array decode + resolver", "classification": "small", "status": "completed", "commit": "8d3dc321", "reviewFixCommit": "49ac1392"}, - {"id": 6, "subject": "AbCip libplctag array read + IsArray", "classification": "standard", "status": "completed", "commit": "f4d5a5ee", "reviewFixCommit": "94e8c55b+5f7a2acd"}, - {"id": 7, "subject": "TwinCAT ADS array read + IsArray (reference impl, no fix needed)", "classification": "standard", "status": "completed", "commit": "3e742395"}, - {"id": 8, "subject": "S7 block-read array + decode loop + IsArray", "classification": "high-risk", "status": "completed", "commit": "a82c22c6", "reviewFixCommit": "3bbe39c1+d30fb77e"}, - {"id": 9, "subject": "AbLegacy PCCC multi-element array read + IsArray", "classification": "high-risk", "status": "completed", "commit": "95006939", "reviewFixCommit": "ce5d46be+0f92e9e2"}, - {"id": 10, "subject": "Docs + bookkeeping", "classification": "small", "status": "completed", "commit": "05c7e86f"}, - {"id": 11, "subject": "Full build + test + final integration review", "classification": "standard", "status": "completed", "note": "final integration review = SHIP WITH MINOR FOLLOW-UPS; 4 review fixes applied (d30fb77e/5f7a2acd/0f92e9e2/3bb2031d)"}, - {"id": 12, "subject": "Live /run acceptance + finish branch", "classification": "standard", "status": "completed", "note": "Modbus array tag live-proven (Good UInt16[4] from sim HR[0..3], over the wire); others unit-proven; root-caused the address-100 Bad as a sim Illegal-Data-Address (not a code bug)"} + { + "id": 1, + "subject": "Sink contract \u2014 EnsureVariable array params", + "classification": "high-risk", + "status": "completed", + "commit": "a7928202", + "reviewFixCommit": "3172b7bd" + }, + { + "id": 2, + "subject": "EquipmentTagPlan IsArray/ArrayLength + composer + applier", + "classification": "high-risk", + "status": "completed", + "commit": "71cc4171", + "reviewFixCommit": "584e9f2a" + }, + { + "id": 3, + "subject": "DeploymentArtifact decode byte-parity", + "classification": "high-risk", + "status": "completed", + "commit": "0a747c34", + "reviewFixCommit": "eb8a8dc1" + }, + { + "id": 4, + "subject": "AdminUI driver-agnostic isArray/arrayLength control", + "classification": "standard", + "status": "completed", + "commit": "c2006dfb" + }, + { + "id": 5, + "subject": "Modbus String/BitInRegister array decode + resolver", + "classification": "small", + "status": "completed", + "commit": "8d3dc321", + "reviewFixCommit": "49ac1392" + }, + { + "id": 6, + "subject": "AbCip libplctag array read + IsArray", + "classification": "standard", + "status": "completed", + "commit": "f4d5a5ee", + "reviewFixCommit": "94e8c55b+5f7a2acd" + }, + { + "id": 7, + "subject": "TwinCAT ADS array read + IsArray (reference impl, no fix needed)", + "classification": "standard", + "status": "completed", + "commit": "3e742395" + }, + { + "id": 8, + "subject": "S7 block-read array + decode loop + IsArray", + "classification": "high-risk", + "status": "completed", + "commit": "a82c22c6", + "reviewFixCommit": "3bbe39c1+d30fb77e" + }, + { + "id": 9, + "subject": "AbLegacy PCCC multi-element array read + IsArray", + "classification": "high-risk", + "status": "completed", + "commit": "95006939", + "reviewFixCommit": "ce5d46be+0f92e9e2" + }, + { + "id": 10, + "subject": "Docs + bookkeeping", + "classification": "small", + "status": "completed", + "commit": "05c7e86f" + }, + { + "id": 11, + "subject": "Full build + test + final integration review", + "classification": "standard", + "status": "completed", + "note": "final integration review = SHIP WITH MINOR FOLLOW-UPS; 4 review fixes applied (d30fb77e/5f7a2acd/0f92e9e2/3bb2031d)" + }, + { + "id": 12, + "subject": "Live /run acceptance + finish branch", + "classification": "standard", + "status": "completed", + "note": "Modbus array tag live-proven (Good UInt16[4] from sim HR[0..3], over the wire); others unit-proven; root-caused the address-100 Bad as a sim Illegal-Data-Address (not a code bug)" + } ], "reviewFollowUps": [ - "Foundation array test asserts in-process variable.Value only — add a wire-level Session.ReadValueAsync assertion over an array node (the live /run proved it works, but it's not unit-covered)", - "Client.CLI read/subscribe prints an array value's type name (System.UInt16[]) not its elements — add array element formatting", + "Foundation array test asserts in-process variable.Value only \u2014 add a wire-level Session.ReadValueAsync assertion over an array node (the live /run proved it works, but it's not unit-covered)", + "Client.CLI read/subscribe prints an array value's type name (System.UInt16[]) not its elements \u2014 add array element formatting", "Array WRITES (inbound client->device), multi-dimensional arrays, array historization remain out of scope (named deferrals)", "Live array read for S7/AbCip/TwinCAT/AbLegacy is fixture-gated/operator-gated (unit-proven only); the 5 AbLegacy libplctag PCCC array-read assumptions need a real SLC/MicroLogix to confirm" ], - "lastUpdated": "2026-06-16" + "lastUpdated": "2026-07-27", + "closureNote": "Shipped \u2014 the stillpending backlog audit cites the landing commits (1e95856b, ada01e1a, fc8121cb, 3e609a2b, 70e6d3d2, c236263e, bd8fee61, 5e27b5f7, fcb38014). Marked complete 2026-07-27 (deferment.md \u00a78.5)." } diff --git a/docs/plans/2026-06-26-otopcua-historian-gateway-integration.md.tasks.json b/docs/plans/2026-06-26-otopcua-historian-gateway-integration.md.tasks.json index 753a5a51..169b5632 100644 --- a/docs/plans/2026-06-26-otopcua-historian-gateway-integration.md.tasks.json +++ b/docs/plans/2026-06-26-otopcua-historian-gateway-integration.md.tasks.json @@ -1,27 +1,185 @@ { "planPath": "docs/plans/2026-06-26-otopcua-historian-gateway-integration.md", "tasks": [ - { "id": 0, "subject": "Task 1: Consume gateway packages + scaffold Gateway driver project", "status": "pending", "blockedBy": [] }, - { "id": 1, "subject": "Task 2: HistoryAggregateType->RetrievalMode mapper (matrix-guarded)", "status": "pending", "blockedBy": [0] }, - { "id": 2, "subject": "Task 3: DriverDataType->HistorianDataType mapper + write-gap fallbacks (matrix-guarded)", "status": "pending", "blockedBy": [0] }, - { "id": 3, "subject": "Task 4: HistorianSample/Aggregate->DataValueSnapshot + quality mapper", "status": "pending", "blockedBy": [0] }, - { "id": 4, "subject": "Task 5: HistorianEvent->HistoricalEvent mapper (+ severity)", "status": "pending", "blockedBy": [0] }, - { "id": 5, "subject": "Task 6: AlarmHistorianEvent->HistorianEvent mapper (SendEvent)", "status": "pending", "blockedBy": [0] }, - { "id": 6, "subject": "Task 7: GatewayHistorianDataSource read paths (raw/processed/at-time)", "status": "pending", "blockedBy": [1, 3] }, - { "id": 7, "subject": "Task 8: GetHealthSnapshot via Probe/GetConnectionStatus", "status": "pending", "blockedBy": [6] }, - { "id": 8, "subject": "Task 9: Reshape ServerHistorianOptions to gateway form", "status": "pending", "blockedBy": [0] }, - { "id": 9, "subject": "Task 10: Swap AddServerHistorian factory in Program.cs (READ CUTOVER)", "status": "pending", "blockedBy": [6, 8] }, - { "id": 10, "subject": "Task 11: ReadEventsAsync alarm-history on the data source", "status": "pending", "blockedBy": [6, 4] }, - { "id": 11, "subject": "Task 12: GatewayAlarmHistorianWriter (SendEvent + outcome mapping)", "status": "pending", "blockedBy": [9, 5] }, - { "id": 12, "subject": "Task 13: Swap AddAlarmHistorian factory in Program.cs", "status": "pending", "blockedBy": [11] }, - { "id": 13, "subject": "Task 14: IHistorianProvisioning + GatewayTagProvisioner (EnsureTags)", "status": "pending", "blockedBy": [9, 2] }, - { "id": 14, "subject": "Task 15: Hook provisioning into AddressSpaceApplier.Apply()", "status": "pending", "blockedBy": [13] }, - { "id": 15, "subject": "Task 16: FasterLog historization outbox store", "status": "pending", "blockedBy": [9] }, - { "id": 16, "subject": "Task 17: ContinuousHistorizationRecorder actor", "status": "pending", "blockedBy": [15, 9] }, - { "id": 17, "subject": "Task 18: Wire recorder into DI + hosted lifecycle", "status": "pending", "blockedBy": [16] }, - { "id": 18, "subject": "Task 19: Retire Wonderware historian projects", "status": "pending", "blockedBy": [9, 12, 17, 19] }, - { "id": 19, "subject": "Task 20: Env-gated live validation vs wonder-sql-vd03", "status": "pending", "blockedBy": [9, 10, 12, 17] }, - { "id": 20, "subject": "Task 21: Documentation (CLAUDE.md, appsettings, README)", "status": "pending", "blockedBy": [18] } + { + "id": 0, + "subject": "Task 1: Consume gateway packages + scaffold Gateway driver project", + "status": "completed", + "blockedBy": [] + }, + { + "id": 1, + "subject": "Task 2: HistoryAggregateType->RetrievalMode mapper (matrix-guarded)", + "status": "completed", + "blockedBy": [ + 0 + ] + }, + { + "id": 2, + "subject": "Task 3: DriverDataType->HistorianDataType mapper + write-gap fallbacks (matrix-guarded)", + "status": "completed", + "blockedBy": [ + 0 + ] + }, + { + "id": 3, + "subject": "Task 4: HistorianSample/Aggregate->DataValueSnapshot + quality mapper", + "status": "completed", + "blockedBy": [ + 0 + ] + }, + { + "id": 4, + "subject": "Task 5: HistorianEvent->HistoricalEvent mapper (+ severity)", + "status": "completed", + "blockedBy": [ + 0 + ] + }, + { + "id": 5, + "subject": "Task 6: AlarmHistorianEvent->HistorianEvent mapper (SendEvent)", + "status": "completed", + "blockedBy": [ + 0 + ] + }, + { + "id": 6, + "subject": "Task 7: GatewayHistorianDataSource read paths (raw/processed/at-time)", + "status": "completed", + "blockedBy": [ + 1, + 3 + ] + }, + { + "id": 7, + "subject": "Task 8: GetHealthSnapshot via Probe/GetConnectionStatus", + "status": "completed", + "blockedBy": [ + 6 + ] + }, + { + "id": 8, + "subject": "Task 9: Reshape ServerHistorianOptions to gateway form", + "status": "completed", + "blockedBy": [ + 0 + ] + }, + { + "id": 9, + "subject": "Task 10: Swap AddServerHistorian factory in Program.cs (READ CUTOVER)", + "status": "completed", + "blockedBy": [ + 6, + 8 + ] + }, + { + "id": 10, + "subject": "Task 11: ReadEventsAsync alarm-history on the data source", + "status": "completed", + "blockedBy": [ + 6, + 4 + ] + }, + { + "id": 11, + "subject": "Task 12: GatewayAlarmHistorianWriter (SendEvent + outcome mapping)", + "status": "completed", + "blockedBy": [ + 9, + 5 + ] + }, + { + "id": 12, + "subject": "Task 13: Swap AddAlarmHistorian factory in Program.cs", + "status": "completed", + "blockedBy": [ + 11 + ] + }, + { + "id": 13, + "subject": "Task 14: IHistorianProvisioning + GatewayTagProvisioner (EnsureTags)", + "status": "completed", + "blockedBy": [ + 9, + 2 + ] + }, + { + "id": 14, + "subject": "Task 15: Hook provisioning into AddressSpaceApplier.Apply()", + "status": "completed", + "blockedBy": [ + 13 + ] + }, + { + "id": 15, + "subject": "Task 16: FasterLog historization outbox store", + "status": "completed", + "blockedBy": [ + 9 + ] + }, + { + "id": 16, + "subject": "Task 17: ContinuousHistorizationRecorder actor", + "status": "completed", + "blockedBy": [ + 15, + 9 + ] + }, + { + "id": 17, + "subject": "Task 18: Wire recorder into DI + hosted lifecycle", + "status": "completed", + "blockedBy": [ + 16 + ] + }, + { + "id": 18, + "subject": "Task 19: Retire Wonderware historian projects", + "status": "completed", + "blockedBy": [ + 9, + 12, + 17, + 19 + ] + }, + { + "id": 19, + "subject": "Task 20: Env-gated live validation vs wonder-sql-vd03", + "status": "completed", + "blockedBy": [ + 9, + 10, + 12, + 17 + ] + }, + { + "id": 20, + "subject": "Task 21: Documentation (CLAUDE.md, appsettings, README)", + "status": "completed", + "blockedBy": [ + 18 + ] + } ], - "lastUpdated": "2026-06-26" + "lastUpdated": "2026-07-27", + "closureNote": "Shipped as PR #423 and live-validated against the gateway on wonder-sql-vd03. Marked complete 2026-07-27 (deferment.md \u00a78.5)." } diff --git a/docs/plans/2026-07-22-per-cluster-mesh-program.md.tasks.json b/docs/plans/2026-07-22-per-cluster-mesh-program.md.tasks.json index b78ab816..0c788a89 100644 --- a/docs/plans/2026-07-22-per-cluster-mesh-program.md.tasks.json +++ b/docs/plans/2026-07-22-per-cluster-mesh-program.md.tasks.json @@ -2,14 +2,69 @@ "planPath": "docs/plans/2026-07-22-per-cluster-mesh-program.md", "note": "PROGRAM plan: each task = write that phase's detailed plan (writing-plans), execute it (executing-plans), run its exit gate, update the tracking tables. Prereq task 0 is the separate selfform-fallback plan in this repo.", "tasks": [ - {"id": 0, "subject": "Prereq: execute 2026-07-22-selfform-fallback-and-manual-failover.md (7 tasks)", "status": "completed", "note": "Shipped as self-first seed ordering + AkkaClusterOptionsValidator, NOT the planned SelfFormAfter watchdog (live gate caught it islanding a failed-over node). Merged a78425ea."}, - {"id": 1, "subject": "Phase 1: ClusterNode Akka+gRPC address columns; ConfigPublishCoordinator ack set from DB", "status": "completed", "note": "Merged 7654f24d. Escape hatch is ClusterNode.MaintenanceMode, not Enabled=0."}, - {"id": 2, "subject": "Phase 2: comm actors + receptionist + ClusterClient transport (deploy notify/acks, driver-control)", "status": "in_progress", "blockedBy": [1]}, - {"id": 3, "subject": "Phase 3: config fetch-and-cache from central; LocalDb steady-state config store (live gate)", "status": "pending", "blockedBy": [2]}, - {"id": 4, "subject": "Phase 4: cut driver-side ConfigDb — EfAlarmConditionStateStore to LocalDb, DbHealthProbeActor ServiceLevel input, OpcUaPublishActor audit (live gate)", "status": "pending", "blockedBy": [3]}, - {"id": 5, "subject": "Phase 5: gRPC oneof stream contract; migrate 7 observability topics; AdminUI reconnect story", "status": "pending", "blockedBy": [2]}, - {"id": 6, "subject": "Phase 6: mesh partition — per-pair seeds, cluster-scoped roles/singletons, co-location rig rewrite, secrets replication re-scope", "status": "pending", "blockedBy": [0, 4, 5]}, - {"id": 7, "subject": "Phase 7: failover drill per pair (incl. shared-VM failure) + close auto-down 1v1 and self-first cold-start-alone live gates + operator runbook", "status": "pending", "blockedBy": [6]} + { + "id": 0, + "subject": "Prereq: execute 2026-07-22-selfform-fallback-and-manual-failover.md (7 tasks)", + "status": "completed", + "note": "Shipped as self-first seed ordering + AkkaClusterOptionsValidator, NOT the planned SelfFormAfter watchdog (live gate caught it islanding a failed-over node). Merged a78425ea." + }, + { + "id": 1, + "subject": "Phase 1: ClusterNode Akka+gRPC address columns; ConfigPublishCoordinator ack set from DB", + "status": "completed", + "note": "Merged 7654f24d. Escape hatch is ClusterNode.MaintenanceMode, not Enabled=0." + }, + { + "id": 2, + "subject": "Phase 2: comm actors + receptionist + ClusterClient transport (deploy notify/acks, driver-control)", + "status": "completed", + "blockedBy": [ + 1 + ] + }, + { + "id": 3, + "subject": "Phase 3: config fetch-and-cache from central; LocalDb steady-state config store (live gate)", + "status": "completed", + "blockedBy": [ + 2 + ] + }, + { + "id": 4, + "subject": "Phase 4: cut driver-side ConfigDb \u2014 EfAlarmConditionStateStore to LocalDb, DbHealthProbeActor ServiceLevel input, OpcUaPublishActor audit (live gate)", + "status": "completed", + "blockedBy": [ + 3 + ] + }, + { + "id": 5, + "subject": "Phase 5: gRPC oneof stream contract; migrate 7 observability topics; AdminUI reconnect story", + "status": "completed", + "blockedBy": [ + 2 + ] + }, + { + "id": 6, + "subject": "Phase 6: mesh partition \u2014 per-pair seeds, cluster-scoped roles/singletons, co-location rig rewrite, secrets replication re-scope", + "status": "completed", + "blockedBy": [ + 0, + 4, + 5 + ] + }, + { + "id": 7, + "subject": "Phase 7: failover drill per pair (incl. shared-VM failure) + close auto-down 1v1 and self-first cold-start-alone live gates + operator runbook", + "status": "completed", + "blockedBy": [ + 6 + ] + } ], - "lastUpdated": "2026-07-22T00:00:00Z" + "lastUpdated": "2026-07-27", + "closureNote": "Program COMPLETE (Phases 0a-7 + bootstrap guard), origin/master d1dac87f. Marked complete 2026-07-27 (deferment.md \u00a78.5)." } diff --git a/docs/plans/2026-07-23-mesh-phase4-cut-driver-configdb.md.tasks.json b/docs/plans/2026-07-23-mesh-phase4-cut-driver-configdb.md.tasks.json index 20e1ce31..29114768 100644 --- a/docs/plans/2026-07-23-mesh-phase4-cut-driver-configdb.md.tasks.json +++ b/docs/plans/2026-07-23-mesh-phase4-cut-driver-configdb.md.tasks.json @@ -100,7 +100,7 @@ { "id": 9, "subject": "Task 9: Drop dead ConfigDb ScriptedAlarmState table + retire EfAlarmConditionStateStore", - "status": "deferred", + "status": "completed", "blockedBy": [ 6 ], @@ -128,5 +128,6 @@ "result": "LIVE GATE PASSED \u2014 deploy sealed green w/ 4 DB-less site nodes acking; ServiceLevel 240 w/ central SQL down; restart booted last-known-good from LocalDb pointer; grep proof; alarm_condition_state table live+replicated. Doc: 2026-07-23-mesh-phase4-live-gate.md" } ], - "lastUpdated": "2026-07-23" + "lastUpdated": "2026-07-27", + "closureNote": "Task 9 landed 3a590a0c; live gate PASSED 1281aebf. Marked complete 2026-07-27 (deferment.md \u00a78.5)." } diff --git a/docs/plans/2026-07-24-modbus-rtu-driver.md.tasks.json b/docs/plans/2026-07-24-modbus-rtu-driver.md.tasks.json index dc74d3de..a17f041c 100644 --- a/docs/plans/2026-07-24-modbus-rtu-driver.md.tasks.json +++ b/docs/plans/2026-07-24-modbus-rtu-driver.md.tasks.json @@ -1,17 +1,91 @@ { "planPath": "docs/plans/2026-07-24-modbus-rtu-driver.md", "tasks": [ - {"id": 0, "subject": "Task 0: Add the ModbusTransportMode enum (Tcp | RtuOverTcp)", "status": "pending"}, - {"id": 1, "subject": "Task 1: ModbusCrc CRC-16/MODBUS helper + golden vectors", "status": "pending"}, - {"id": 2, "subject": "Task 2: ModbusRtuFraming ADU build + FC-aware length-less deframe", "status": "pending", "blockedBy": [1]}, - {"id": 3, "subject": "Task 3: Extract ModbusSocketLifecycle from ModbusTcpTransport (behaviour-preserving)", "status": "pending"}, - {"id": 4, "subject": "Task 4: ModbusRtuOverTcpTransport (RTU framing over socket lifecycle)", "status": "pending", "blockedBy": [2, 3]}, - {"id": 5, "subject": "Task 5: ModbusTransportFactory.Create switch on Transport mode", "status": "pending", "blockedBy": [0, 4]}, - {"id": 6, "subject": "Task 6: Bind Transport on options/DTO/factory (string-enum guard)", "status": "pending", "blockedBy": [0]}, - {"id": 7, "subject": "Task 7: Route driver default closure + probe through ModbusTransportFactory", "status": "pending", "blockedBy": [5, 6]}, - {"id": 8, "subject": "Task 8: AdminUI Transport selector on ModbusDriverForm", "status": "pending", "blockedBy": [6]}, - {"id": 9, "subject": "Task 9: Docker rtu_over_tcp fixture + RTU-over-TCP integration test", "status": "pending", "blockedBy": [4, 7]}, - {"id": 10, "subject": "Task 10: Live /run verification on docker-dev", "status": "pending", "blockedBy": [8, 9]} + { + "id": 0, + "subject": "Task 0: Add the ModbusTransportMode enum (Tcp | RtuOverTcp)", + "status": "completed" + }, + { + "id": 1, + "subject": "Task 1: ModbusCrc CRC-16/MODBUS helper + golden vectors", + "status": "completed" + }, + { + "id": 2, + "subject": "Task 2: ModbusRtuFraming ADU build + FC-aware length-less deframe", + "status": "completed", + "blockedBy": [ + 1 + ] + }, + { + "id": 3, + "subject": "Task 3: Extract ModbusSocketLifecycle from ModbusTcpTransport (behaviour-preserving)", + "status": "completed" + }, + { + "id": 4, + "subject": "Task 4: ModbusRtuOverTcpTransport (RTU framing over socket lifecycle)", + "status": "completed", + "blockedBy": [ + 2, + 3 + ] + }, + { + "id": 5, + "subject": "Task 5: ModbusTransportFactory.Create switch on Transport mode", + "status": "completed", + "blockedBy": [ + 0, + 4 + ] + }, + { + "id": 6, + "subject": "Task 6: Bind Transport on options/DTO/factory (string-enum guard)", + "status": "completed", + "blockedBy": [ + 0 + ] + }, + { + "id": 7, + "subject": "Task 7: Route driver default closure + probe through ModbusTransportFactory", + "status": "completed", + "blockedBy": [ + 5, + 6 + ] + }, + { + "id": 8, + "subject": "Task 8: AdminUI Transport selector on ModbusDriverForm", + "status": "completed", + "blockedBy": [ + 6 + ] + }, + { + "id": 9, + "subject": "Task 9: Docker rtu_over_tcp fixture + RTU-over-TCP integration test", + "status": "completed", + "blockedBy": [ + 4, + 7 + ] + }, + { + "id": 10, + "subject": "Task 10: Live /run verification on docker-dev", + "status": "completed", + "blockedBy": [ + 8, + 9 + ] + } ], - "lastUpdated": "2026-07-24" + "lastUpdated": "2026-07-27", + "closureNote": "Merged 0f38f486 (#495); live gate PASSED. Marked complete 2026-07-27 (deferment.md \u00a78.5)." } diff --git a/docs/plans/2026-07-24-mqtt-sparkplug-driver.md.tasks.json b/docs/plans/2026-07-24-mqtt-sparkplug-driver.md.tasks.json index 2abe8b56..3c0e831a 100644 --- a/docs/plans/2026-07-24-mqtt-sparkplug-driver.md.tasks.json +++ b/docs/plans/2026-07-24-mqtt-sparkplug-driver.md.tasks.json @@ -1,34 +1,315 @@ { "planPath": "docs/plans/2026-07-24-mqtt-sparkplug-driver.md", - "note": "Wave 2 MQTT/Sparkplug B driver. Two phases both in-scope: P1 (plain MQTT, Tasks 0-14) is a complete shippable milestone; P2 (Sparkplug B ingest, Tasks 15-26) builds on it and every P2 task chains back to Task 14. Write-through (NCMD/DCMD/plain publish) is DEFERRED (design P3). Hard rules: Task 0 (MQTTnet-5/net10 restore+build under central pinning with transitive pinning OFF) is the mandatory FIRST gate. Hand-roll Tahu proto over ONE MQTTnet-5 client (NOT SparkplugNet — its MQTTnet-4.x transitive pin collides with the repo's deliberately-OFF CentralPackageTransitivePinning). DriverType string is 'Mqtt' everywhere (grep-enforced). Enum-serialization trap: every JSON seam uses shared JsonSerializerOptions with JsonStringEnumConverter. Fixtures enforce TLS+auth, never anonymous/public-broker. Google.Protobuf (3.34.1) + Grpc.Tools (2.76.0) already pinned; MQTTnet is the one new pin. High-risk tasks: 4 (hand-rolled reconnect loop), 18/19 (alias/seq state-machine components), 21 (the full 3.6 ingest matrix).", + "note": "Wave 2 MQTT/Sparkplug B driver. Two phases both in-scope: P1 (plain MQTT, Tasks 0-14) is a complete shippable milestone; P2 (Sparkplug B ingest, Tasks 15-26) builds on it and every P2 task chains back to Task 14. Write-through (NCMD/DCMD/plain publish) is DEFERRED (design P3). Hard rules: Task 0 (MQTTnet-5/net10 restore+build under central pinning with transitive pinning OFF) is the mandatory FIRST gate. Hand-roll Tahu proto over ONE MQTTnet-5 client (NOT SparkplugNet \u2014 its MQTTnet-4.x transitive pin collides with the repo's deliberately-OFF CentralPackageTransitivePinning). DriverType string is 'Mqtt' everywhere (grep-enforced). Enum-serialization trap: every JSON seam uses shared JsonSerializerOptions with JsonStringEnumConverter. Fixtures enforce TLS+auth, never anonymous/public-broker. Google.Protobuf (3.34.1) + Grpc.Tools (2.76.0) already pinned; MQTTnet is the one new pin. High-risk tasks: 4 (hand-rolled reconnect loop), 18/19 (alias/seq state-machine components), 21 (the full 3.6 ingest matrix).", "tasks": [ - {"id": 0, "subject": "Task 0 (P1): Dependency-validation spike — MQTTnet-5 pin + net10 restore/build under central pinning", "status": "pending", "classification": "standard", "parallelizableWith": []}, - {"id": 1, "subject": "Task 1 (P1): .Contracts enums + MqttDriverOptions DTO (name-serialized)", "status": "pending", "classification": "small", "parallelizableWith": [], "blockedBy": [0]}, - {"id": 2, "subject": "Task 2 (P1): .Contracts MqttTagDefinition + MqttEquipmentTagParser (plain, strict enum)", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [1]}, - {"id": 3, "subject": "Task 3 (P1): .Driver + MqttConnection connect/TLS/auth (bounded deadline)", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [1]}, - {"id": 4, "subject": "Task 4 (P1): MqttConnection hand-rolled reconnect loop (backoff + resubscribe)", "status": "pending", "classification": "high-risk", "parallelizableWith": [], "blockedBy": [3]}, - {"id": 5, "subject": "Task 5 (P1): LastValueCache + IReadable (per-ref no-data)", "status": "pending", "classification": "small", "parallelizableWith": [6], "blockedBy": [3]}, - {"id": 6, "subject": "Task 6 (P1): ISubscribable plain topic subscribe→OnDataChange + retained seed", "status": "pending", "classification": "standard", "parallelizableWith": [5], "blockedBy": [2, 4]}, - {"id": 7, "subject": "Task 7 (P1): MqttDriver shell — IDriver + authored-only ITagDiscovery (Once) + probe interface", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [5, 6]}, - {"id": 8, "subject": "Task 8 (P1): MqttDriverProbe CONNECT handshake", "status": "pending", "classification": "small", "parallelizableWith": [10], "blockedBy": [3]}, - {"id": 9, "subject": "Task 9 (P1): Factory + DriverTypeNames.Mqtt + Host factory/probe registration", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [7, 8]}, - {"id": 10, "subject": "Task 10 (P1): .Browser bespoke #-observation browser (passive)", "status": "pending", "classification": "standard", "parallelizableWith": [8], "blockedBy": [2]}, - {"id": 11, "subject": "Task 11 (P1): Register MqttDriverBrowser (bespoke-first for Mqtt)", "status": "pending", "classification": "trivial", "parallelizableWith": [12], "blockedBy": [10]}, - {"id": 12, "subject": "Task 12 (P1): Typed AdminUI editor + validator (plain shape)", "status": "pending", "classification": "standard", "parallelizableWith": [11], "blockedBy": [2]}, - {"id": 13, "subject": "Task 13 (P1): Mosquitto+JSON-publisher fixture (TLS+auth) + env-gated live suite", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [9]}, - {"id": 14, "subject": "Task 14 (P1): Live /run verify on docker-dev — P1 MILESTONE COMPLETE", "status": "pending", "classification": "small", "parallelizableWith": [], "blockedBy": [9, 11, 12, 13]}, - {"id": 15, "subject": "Task 15 (P2): Vendor Tahu sparkplug_b.proto + Grpc.Tools codegen", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [14]}, - {"id": 16, "subject": "Task 16 (P2): SparkplugCodec decode + golden payload vectors", "status": "pending", "classification": "standard", "parallelizableWith": [17], "blockedBy": [15]}, - {"id": 17, "subject": "Task 17 (P2): SparkplugTopic parse/format + SparkplugDataType.ToDriverDataType map", "status": "pending", "classification": "small", "parallelizableWith": [16], "blockedBy": [15]}, - {"id": 18, "subject": "Task 18 (P2): BirthCache + AliasTable — bind-by-name, rebuild-per-birth", "status": "pending", "classification": "high-risk", "parallelizableWith": [19], "blockedBy": [16, 17]}, - {"id": 19, "subject": "Task 19 (P2): SequenceTracker seq-gap + bdSeq death-tie", "status": "pending", "classification": "high-risk", "parallelizableWith": [18], "blockedBy": [16, 17]}, - {"id": 20, "subject": "Task 20 (P2): RebirthRequester NCMD encode", "status": "pending", "classification": "small", "parallelizableWith": [], "blockedBy": [16]}, - {"id": 21, "subject": "Task 21 (P2): Sparkplug ingest state machine — the 3.6 matrix", "status": "pending", "classification": "high-risk", "parallelizableWith": [], "blockedBy": [18, 19, 20]}, - {"id": 22, "subject": "Task 22 (P2): ITagDiscovery UntilStable + IRediscoverable (rediscover-on-DBIRTH)", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [21]}, - {"id": 23, "subject": "Task 23 (P2): Sparkplug browser tree + AttributesAsync + scoped RequestRebirthAsync", "status": "pending", "classification": "standard", "parallelizableWith": [24], "blockedBy": [20, 21]}, - {"id": 24, "subject": "Task 24 (P2): AdminUI editor Sparkplug mode shape + validator", "status": "pending", "classification": "small", "parallelizableWith": [23], "blockedBy": [12, 17]}, - {"id": 25, "subject": "Task 25 (P2): C# edge-node simulator fixture + 3.6 live matrix", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [21, 22, 23]}, - {"id": 26, "subject": "Task 26 (P2): Live /run verify Sparkplug — P2 COMPLETE", "status": "pending", "classification": "small", "parallelizableWith": [], "blockedBy": [23, 24, 25]} + { + "id": 0, + "subject": "Task 0 (P1): Dependency-validation spike \u2014 MQTTnet-5 pin + net10 restore/build under central pinning", + "status": "completed", + "classification": "standard", + "parallelizableWith": [] + }, + { + "id": 1, + "subject": "Task 1 (P1): .Contracts enums + MqttDriverOptions DTO (name-serialized)", + "status": "completed", + "classification": "small", + "parallelizableWith": [], + "blockedBy": [ + 0 + ] + }, + { + "id": 2, + "subject": "Task 2 (P1): .Contracts MqttTagDefinition + MqttEquipmentTagParser (plain, strict enum)", + "status": "completed", + "classification": "standard", + "parallelizableWith": [], + "blockedBy": [ + 1 + ] + }, + { + "id": 3, + "subject": "Task 3 (P1): .Driver + MqttConnection connect/TLS/auth (bounded deadline)", + "status": "completed", + "classification": "standard", + "parallelizableWith": [], + "blockedBy": [ + 1 + ] + }, + { + "id": 4, + "subject": "Task 4 (P1): MqttConnection hand-rolled reconnect loop (backoff + resubscribe)", + "status": "completed", + "classification": "high-risk", + "parallelizableWith": [], + "blockedBy": [ + 3 + ] + }, + { + "id": 5, + "subject": "Task 5 (P1): LastValueCache + IReadable (per-ref no-data)", + "status": "completed", + "classification": "small", + "parallelizableWith": [ + 6 + ], + "blockedBy": [ + 3 + ] + }, + { + "id": 6, + "subject": "Task 6 (P1): ISubscribable plain topic subscribe\u2192OnDataChange + retained seed", + "status": "completed", + "classification": "standard", + "parallelizableWith": [ + 5 + ], + "blockedBy": [ + 2, + 4 + ] + }, + { + "id": 7, + "subject": "Task 7 (P1): MqttDriver shell \u2014 IDriver + authored-only ITagDiscovery (Once) + probe interface", + "status": "completed", + "classification": "standard", + "parallelizableWith": [], + "blockedBy": [ + 5, + 6 + ] + }, + { + "id": 8, + "subject": "Task 8 (P1): MqttDriverProbe CONNECT handshake", + "status": "completed", + "classification": "small", + "parallelizableWith": [ + 10 + ], + "blockedBy": [ + 3 + ] + }, + { + "id": 9, + "subject": "Task 9 (P1): Factory + DriverTypeNames.Mqtt + Host factory/probe registration", + "status": "completed", + "classification": "standard", + "parallelizableWith": [], + "blockedBy": [ + 7, + 8 + ] + }, + { + "id": 10, + "subject": "Task 10 (P1): .Browser bespoke #-observation browser (passive)", + "status": "completed", + "classification": "standard", + "parallelizableWith": [ + 8 + ], + "blockedBy": [ + 2 + ] + }, + { + "id": 11, + "subject": "Task 11 (P1): Register MqttDriverBrowser (bespoke-first for Mqtt)", + "status": "completed", + "classification": "trivial", + "parallelizableWith": [ + 12 + ], + "blockedBy": [ + 10 + ] + }, + { + "id": 12, + "subject": "Task 12 (P1): Typed AdminUI editor + validator (plain shape)", + "status": "completed", + "classification": "standard", + "parallelizableWith": [ + 11 + ], + "blockedBy": [ + 2 + ] + }, + { + "id": 13, + "subject": "Task 13 (P1): Mosquitto+JSON-publisher fixture (TLS+auth) + env-gated live suite", + "status": "completed", + "classification": "standard", + "parallelizableWith": [], + "blockedBy": [ + 9 + ] + }, + { + "id": 14, + "subject": "Task 14 (P1): Live /run verify on docker-dev \u2014 P1 MILESTONE COMPLETE", + "status": "completed", + "classification": "small", + "parallelizableWith": [], + "blockedBy": [ + 9, + 11, + 12, + 13 + ] + }, + { + "id": 15, + "subject": "Task 15 (P2): Vendor Tahu sparkplug_b.proto + Grpc.Tools codegen", + "status": "completed", + "classification": "standard", + "parallelizableWith": [], + "blockedBy": [ + 14 + ] + }, + { + "id": 16, + "subject": "Task 16 (P2): SparkplugCodec decode + golden payload vectors", + "status": "completed", + "classification": "standard", + "parallelizableWith": [ + 17 + ], + "blockedBy": [ + 15 + ] + }, + { + "id": 17, + "subject": "Task 17 (P2): SparkplugTopic parse/format + SparkplugDataType.ToDriverDataType map", + "status": "completed", + "classification": "small", + "parallelizableWith": [ + 16 + ], + "blockedBy": [ + 15 + ] + }, + { + "id": 18, + "subject": "Task 18 (P2): BirthCache + AliasTable \u2014 bind-by-name, rebuild-per-birth", + "status": "completed", + "classification": "high-risk", + "parallelizableWith": [ + 19 + ], + "blockedBy": [ + 16, + 17 + ] + }, + { + "id": 19, + "subject": "Task 19 (P2): SequenceTracker seq-gap + bdSeq death-tie", + "status": "completed", + "classification": "high-risk", + "parallelizableWith": [ + 18 + ], + "blockedBy": [ + 16, + 17 + ] + }, + { + "id": 20, + "subject": "Task 20 (P2): RebirthRequester NCMD encode", + "status": "completed", + "classification": "small", + "parallelizableWith": [], + "blockedBy": [ + 16 + ] + }, + { + "id": 21, + "subject": "Task 21 (P2): Sparkplug ingest state machine \u2014 the 3.6 matrix", + "status": "completed", + "classification": "high-risk", + "parallelizableWith": [], + "blockedBy": [ + 18, + 19, + 20 + ] + }, + { + "id": 22, + "subject": "Task 22 (P2): ITagDiscovery UntilStable + IRediscoverable (rediscover-on-DBIRTH)", + "status": "completed", + "classification": "standard", + "parallelizableWith": [], + "blockedBy": [ + 21 + ] + }, + { + "id": 23, + "subject": "Task 23 (P2): Sparkplug browser tree + AttributesAsync + scoped RequestRebirthAsync", + "status": "completed", + "classification": "standard", + "parallelizableWith": [ + 24 + ], + "blockedBy": [ + 20, + 21 + ] + }, + { + "id": 24, + "subject": "Task 24 (P2): AdminUI editor Sparkplug mode shape + validator", + "status": "completed", + "classification": "small", + "parallelizableWith": [ + 23 + ], + "blockedBy": [ + 12, + 17 + ] + }, + { + "id": 25, + "subject": "Task 25 (P2): C# edge-node simulator fixture + 3.6 live matrix", + "status": "completed", + "classification": "standard", + "parallelizableWith": [], + "blockedBy": [ + 21, + 22, + 23 + ] + }, + { + "id": 26, + "subject": "Task 26 (P2): Live /run verify Sparkplug \u2014 P2 COMPLETE", + "status": "completed", + "classification": "small", + "parallelizableWith": [], + "blockedBy": [ + 23, + 24, + 25 + ] + } ], - "lastUpdated": "2026-07-24" + "lastUpdated": "2026-07-27", + "closureNote": "All 27 tasks shipped, both phases live-gated. Marked complete 2026-07-27 (deferment.md \u00a78.5)." } diff --git a/docs/plans/2026-07-24-mtconnect-driver.md.tasks.json b/docs/plans/2026-07-24-mtconnect-driver.md.tasks.json index 209c1642..4a432eca 100644 --- a/docs/plans/2026-07-24-mtconnect-driver.md.tasks.json +++ b/docs/plans/2026-07-24-mtconnect-driver.md.tasks.json @@ -1,29 +1,199 @@ { "planPath": "docs/plans/2026-07-24-mtconnect-driver.md", "tasks": [ - {"id": 0, "subject": "Task 0: Verify TrakHound MTConnect.NET pin (or record the hand-roll decision)", "status": "pending"}, - {"id": 1, "subject": "Task 1: Scaffold the two driver projects + the test project", "status": "pending", "blockedBy": [0]}, - {"id": 2, "subject": "Task 2: MTConnectDriverOptions + MTConnectTagDefinition in .Contracts", "status": "pending", "blockedBy": [1]}, - {"id": 3, "subject": "Task 3: MTConnectDataTypeInference.Infer + golden type-map test", "status": "pending", "blockedBy": [1]}, - {"id": 4, "subject": "Task 4: Capture the canned XML fixtures", "status": "pending", "blockedBy": [1]}, - {"id": 5, "subject": "Task 5: IMTConnectAgentClient seam + return DTOs", "status": "pending", "blockedBy": [1]}, - {"id": 6, "subject": "Task 6: MTConnectAgentClient — parse /probe into the device model", "status": "pending", "blockedBy": [4, 5]}, - {"id": 7, "subject": "Task 7: MTConnectAgentClient — parse /current + /sample, detect the sequence gap", "status": "pending", "blockedBy": [4, 5, 6]}, - {"id": 8, "subject": "Task 8: MTConnectObservationIndex + UNAVAILABLE -> BadNoCommunication", "status": "pending", "blockedBy": [7]}, - {"id": 9, "subject": "Task 9: MTConnectDriver shell — IDriver lifecycle", "status": "pending", "blockedBy": [2, 8]}, - {"id": 10, "subject": "Task 10: IReadable.ReadAsync — /current, ordered snapshots", "status": "pending", "blockedBy": [9]}, - {"id": 11, "subject": "Task 11: ISubscribable — /sample long-poll pump + ring-buffer re-baseline", "status": "pending", "blockedBy": [9, 7]}, - {"id": 12, "subject": "Task 12: ITagDiscovery.DiscoverAsync + SupportsOnlineDiscovery=true", "status": "pending", "blockedBy": [9, 6]}, - {"id": 13, "subject": "Task 13: IHostConnectivityProbe + IRediscoverable (instanceId watch)", "status": "pending", "blockedBy": [9]}, - {"id": 14, "subject": "Task 14: MTConnectDriverProbe : IDriverProbe", "status": "pending", "blockedBy": [2, 5]}, - {"id": 15, "subject": "Task 15: MTConnectDriverFactoryExtensions", "status": "pending", "blockedBy": [2, 9]}, - {"id": 16, "subject": "Task 16: Host registration + DriverTypeNames.MTConnect + guard-test parity", "status": "pending", "blockedBy": [14, 15]}, - {"id": 17, "subject": "Task 17: AdminUI typed model MTConnectTagConfigModel", "status": "pending", "blockedBy": [3, 16]}, - {"id": 18, "subject": "Task 18: AdminUI editor razor + map/validator registration", "status": "pending", "blockedBy": [17]}, - {"id": 19, "subject": "Task 19: mtconnect/cppagent docker fixture", "status": "pending", "blockedBy": [16]}, - {"id": 20, "subject": "Task 20: Env-gated integration suite against cppagent", "status": "pending", "blockedBy": [19]}, - {"id": 21, "subject": "Task 21: Live /run verify on docker-dev — browse picker, editor, read, subscribe, deploy", "status": "pending", "blockedBy": [18, 20]}, - {"id": 22, "subject": "Task 22: Docs + deferred-writeback note; update the tracking doc", "status": "pending", "blockedBy": [21]} + { + "id": 0, + "subject": "Task 0: Verify TrakHound MTConnect.NET pin (or record the hand-roll decision)", + "status": "completed" + }, + { + "id": 1, + "subject": "Task 1: Scaffold the two driver projects + the test project", + "status": "completed", + "blockedBy": [ + 0 + ] + }, + { + "id": 2, + "subject": "Task 2: MTConnectDriverOptions + MTConnectTagDefinition in .Contracts", + "status": "completed", + "blockedBy": [ + 1 + ] + }, + { + "id": 3, + "subject": "Task 3: MTConnectDataTypeInference.Infer + golden type-map test", + "status": "completed", + "blockedBy": [ + 1 + ] + }, + { + "id": 4, + "subject": "Task 4: Capture the canned XML fixtures", + "status": "completed", + "blockedBy": [ + 1 + ] + }, + { + "id": 5, + "subject": "Task 5: IMTConnectAgentClient seam + return DTOs", + "status": "completed", + "blockedBy": [ + 1 + ] + }, + { + "id": 6, + "subject": "Task 6: MTConnectAgentClient \u2014 parse /probe into the device model", + "status": "completed", + "blockedBy": [ + 4, + 5 + ] + }, + { + "id": 7, + "subject": "Task 7: MTConnectAgentClient \u2014 parse /current + /sample, detect the sequence gap", + "status": "completed", + "blockedBy": [ + 4, + 5, + 6 + ] + }, + { + "id": 8, + "subject": "Task 8: MTConnectObservationIndex + UNAVAILABLE -> BadNoCommunication", + "status": "completed", + "blockedBy": [ + 7 + ] + }, + { + "id": 9, + "subject": "Task 9: MTConnectDriver shell \u2014 IDriver lifecycle", + "status": "completed", + "blockedBy": [ + 2, + 8 + ] + }, + { + "id": 10, + "subject": "Task 10: IReadable.ReadAsync \u2014 /current, ordered snapshots", + "status": "completed", + "blockedBy": [ + 9 + ] + }, + { + "id": 11, + "subject": "Task 11: ISubscribable \u2014 /sample long-poll pump + ring-buffer re-baseline", + "status": "completed", + "blockedBy": [ + 9, + 7 + ] + }, + { + "id": 12, + "subject": "Task 12: ITagDiscovery.DiscoverAsync + SupportsOnlineDiscovery=true", + "status": "completed", + "blockedBy": [ + 9, + 6 + ] + }, + { + "id": 13, + "subject": "Task 13: IHostConnectivityProbe + IRediscoverable (instanceId watch)", + "status": "completed", + "blockedBy": [ + 9 + ] + }, + { + "id": 14, + "subject": "Task 14: MTConnectDriverProbe : IDriverProbe", + "status": "completed", + "blockedBy": [ + 2, + 5 + ] + }, + { + "id": 15, + "subject": "Task 15: MTConnectDriverFactoryExtensions", + "status": "completed", + "blockedBy": [ + 2, + 9 + ] + }, + { + "id": 16, + "subject": "Task 16: Host registration + DriverTypeNames.MTConnect + guard-test parity", + "status": "completed", + "blockedBy": [ + 14, + 15 + ] + }, + { + "id": 17, + "subject": "Task 17: AdminUI typed model MTConnectTagConfigModel", + "status": "completed", + "blockedBy": [ + 3, + 16 + ] + }, + { + "id": 18, + "subject": "Task 18: AdminUI editor razor + map/validator registration", + "status": "completed", + "blockedBy": [ + 17 + ] + }, + { + "id": 19, + "subject": "Task 19: mtconnect/cppagent docker fixture", + "status": "completed", + "blockedBy": [ + 16 + ] + }, + { + "id": 20, + "subject": "Task 20: Env-gated integration suite against cppagent", + "status": "completed", + "blockedBy": [ + 19 + ] + }, + { + "id": 21, + "subject": "Task 21: Live /run verify on docker-dev \u2014 browse picker, editor, read, subscribe, deploy", + "status": "completed", + "blockedBy": [ + 18, + 20 + ] + }, + { + "id": 22, + "subject": "Task 22: Docs + deferred-writeback note; update the tracking doc", + "status": "completed", + "blockedBy": [ + 21 + ] + } ], - "lastUpdated": "2026-07-24" + "lastUpdated": "2026-07-27", + "closureNote": "Merged at master 90bdaa44 (#506); 5-leg live gate PASSED. Marked complete 2026-07-27 (deferment.md \u00a78.5)." } diff --git a/docs/plans/2026-07-24-sql-poll-driver.md.tasks.json b/docs/plans/2026-07-24-sql-poll-driver.md.tasks.json index 55e57b84..c1553974 100644 --- a/docs/plans/2026-07-24-sql-poll-driver.md.tasks.json +++ b/docs/plans/2026-07-24-sql-poll-driver.md.tasks.json @@ -1,28 +1,191 @@ { "planPath": "docs/plans/2026-07-24-sql-poll-driver.md", "tasks": [ - {"id": 0, "subject": "Task 0: Scaffold Driver.Sql.Contracts project + register in slnx", "status": "pending"}, - {"id": 1, "subject": "Task 1: SqlProvider/SqlTagModel enums + SqlDriverConfigDto", "status": "pending", "blockedBy": [0]}, - {"id": 2, "subject": "Task 2: SqlTagDefinition + SqlEquipmentTagParser (strict enums, malicious-input safe)", "status": "pending", "blockedBy": [1]}, - {"id": 3, "subject": "Task 3: Scaffold Driver.Sql runtime project + register in slnx", "status": "pending", "blockedBy": [0]}, - {"id": 4, "subject": "Task 4: ISqlDialect + SqlServerDialect (QuoteIdentifier, catalog SQL, MapColumnType)", "status": "pending", "blockedBy": [1, 3]}, - {"id": 5, "subject": "Task 5: SqlQueryPlan grouping + parameter binding (pure)", "status": "pending", "blockedBy": [2, 4]}, - {"id": 6, "subject": "Task 6: Scaffold Driver.Sql.Tests + SqliteDialect + SqlitePollFixture", "status": "pending", "blockedBy": [3, 4]}, - {"id": 7, "subject": "Task 7: SqlPollReader core — grouped read, bounded deadline, ordered slice-back", "status": "pending", "blockedBy": [5, 6]}, - {"id": 8, "subject": "Task 8: SqlDriver shell — IDriver/ITagDiscovery/IReadable/ISubscribable/IHostConnectivityProbe", "status": "pending", "blockedBy": [7]}, - {"id": 9, "subject": "Task 9: SqlDriverFactoryExtensions + connectionStringRef env resolution + enum guard", "status": "pending", "blockedBy": [8]}, - {"id": 10, "subject": "Task 10: SqlDriverProbe (SELECT 1 liveness)", "status": "pending", "blockedBy": [4, 6]}, - {"id": 11, "subject": "Task 11: Host registration — DriverTypeNames.Sql + factory + probe + guard test", "status": "pending", "blockedBy": [9, 10]}, - {"id": 12, "subject": "Task 12: Scaffold Driver.Sql.Browser project + register in slnx", "status": "pending", "blockedBy": [3]}, - {"id": 13, "subject": "Task 13: SqlBrowseSession — schema walk over dialect catalog", "status": "pending", "blockedBy": [4, 12, 6]}, - {"id": 14, "subject": "Task 14: SqlDriverBrowser — env-ref/literal transient-connection open", "status": "pending", "blockedBy": [13]}, - {"id": 15, "subject": "Task 15: AdminUI browser DI + SqlAddressPickerBody.razor", "status": "pending", "blockedBy": [14]}, - {"id": 16, "subject": "Task 16: Env-gated central-SQL integration fixture + read round-trip", "status": "pending", "blockedBy": [8]}, - {"id": 17, "subject": "Task 17: Injection regression test (bind value / reject identifier)", "status": "pending", "blockedBy": [7]}, - {"id": 18, "subject": "Task 18: Blackhole/timeout live-gate on a dedicated mssql container", "status": "pending", "blockedBy": [16]}, - {"id": 19, "subject": "Task 19: AdminUI typed Sql tag-config model + validator (string enums)", "status": "pending", "blockedBy": [1, 11]}, - {"id": 20, "subject": "Task 20: SqlTagConfigEditor.razor shell", "status": "pending", "blockedBy": [19]}, - {"id": 21, "subject": "Task 21: Live /run verification on docker-dev (picker + editor + deploy + read)", "status": "pending", "blockedBy": [11, 15, 20]} + { + "id": 0, + "subject": "Task 0: Scaffold Driver.Sql.Contracts project + register in slnx", + "status": "completed" + }, + { + "id": 1, + "subject": "Task 1: SqlProvider/SqlTagModel enums + SqlDriverConfigDto", + "status": "completed", + "blockedBy": [ + 0 + ] + }, + { + "id": 2, + "subject": "Task 2: SqlTagDefinition + SqlEquipmentTagParser (strict enums, malicious-input safe)", + "status": "completed", + "blockedBy": [ + 1 + ] + }, + { + "id": 3, + "subject": "Task 3: Scaffold Driver.Sql runtime project + register in slnx", + "status": "completed", + "blockedBy": [ + 0 + ] + }, + { + "id": 4, + "subject": "Task 4: ISqlDialect + SqlServerDialect (QuoteIdentifier, catalog SQL, MapColumnType)", + "status": "completed", + "blockedBy": [ + 1, + 3 + ] + }, + { + "id": 5, + "subject": "Task 5: SqlQueryPlan grouping + parameter binding (pure)", + "status": "completed", + "blockedBy": [ + 2, + 4 + ] + }, + { + "id": 6, + "subject": "Task 6: Scaffold Driver.Sql.Tests + SqliteDialect + SqlitePollFixture", + "status": "completed", + "blockedBy": [ + 3, + 4 + ] + }, + { + "id": 7, + "subject": "Task 7: SqlPollReader core \u2014 grouped read, bounded deadline, ordered slice-back", + "status": "completed", + "blockedBy": [ + 5, + 6 + ] + }, + { + "id": 8, + "subject": "Task 8: SqlDriver shell \u2014 IDriver/ITagDiscovery/IReadable/ISubscribable/IHostConnectivityProbe", + "status": "completed", + "blockedBy": [ + 7 + ] + }, + { + "id": 9, + "subject": "Task 9: SqlDriverFactoryExtensions + connectionStringRef env resolution + enum guard", + "status": "completed", + "blockedBy": [ + 8 + ] + }, + { + "id": 10, + "subject": "Task 10: SqlDriverProbe (SELECT 1 liveness)", + "status": "completed", + "blockedBy": [ + 4, + 6 + ] + }, + { + "id": 11, + "subject": "Task 11: Host registration \u2014 DriverTypeNames.Sql + factory + probe + guard test", + "status": "completed", + "blockedBy": [ + 9, + 10 + ] + }, + { + "id": 12, + "subject": "Task 12: Scaffold Driver.Sql.Browser project + register in slnx", + "status": "completed", + "blockedBy": [ + 3 + ] + }, + { + "id": 13, + "subject": "Task 13: SqlBrowseSession \u2014 schema walk over dialect catalog", + "status": "completed", + "blockedBy": [ + 4, + 12, + 6 + ] + }, + { + "id": 14, + "subject": "Task 14: SqlDriverBrowser \u2014 env-ref/literal transient-connection open", + "status": "completed", + "blockedBy": [ + 13 + ] + }, + { + "id": 15, + "subject": "Task 15: AdminUI browser DI + SqlAddressPickerBody.razor", + "status": "completed", + "blockedBy": [ + 14 + ] + }, + { + "id": 16, + "subject": "Task 16: Env-gated central-SQL integration fixture + read round-trip", + "status": "completed", + "blockedBy": [ + 8 + ] + }, + { + "id": 17, + "subject": "Task 17: Injection regression test (bind value / reject identifier)", + "status": "completed", + "blockedBy": [ + 7 + ] + }, + { + "id": 18, + "subject": "Task 18: Blackhole/timeout live-gate on a dedicated mssql container", + "status": "completed", + "blockedBy": [ + 16 + ] + }, + { + "id": 19, + "subject": "Task 19: AdminUI typed Sql tag-config model + validator (string enums)", + "status": "completed", + "blockedBy": [ + 1, + 11 + ] + }, + { + "id": 20, + "subject": "Task 20: SqlTagConfigEditor.razor shell", + "status": "completed", + "blockedBy": [ + 19 + ] + }, + { + "id": 21, + "subject": "Task 21: Live /run verification on docker-dev (picker + editor + deploy + read)", + "status": "completed", + "blockedBy": [ + 11, + 15, + 20 + ] + } ], - "lastUpdated": "2026-07-24" + "lastUpdated": "2026-07-27", + "closureNote": "Merged 4ad54037 + follow-ups 28c28667. Marked complete 2026-07-27 (deferment.md \u00a78.5)." } diff --git a/docs/v2/driver-stability.md b/docs/v2/driver-stability.md index 55eb91a9..b1d70a26 100644 --- a/docs/v2/driver-stability.md +++ b/docs/v2/driver-stability.md @@ -1,5 +1,25 @@ # Driver Stability & Isolation — OtOpcUa v2 +> ⚠️ **The tier assignments below are NOT what runs (verified against source 2026-07-27).** +> **Every one of the 12 drivers runs as Tier A.** `DriverFactoryRegistry` defaults `DriverTier tier = +> DriverTier.A` and **no factory in `src/Drivers/` passes a tier**, so nothing ever selects B or C. The +> Galaxy and FOCAS Tier-C assignments in the table below are aspirational. +> +> Consequences worth knowing: +> +> - The **Tier-C-only protections are dormant for every driver** — `MemoryRecycle` gates on +> `when _tier == DriverTier.C`, and `ScheduledRecycleScheduler` refuses unless Tier C. Neither has ever +> engaged in production. `IDriverSupervisor` has zero implementations, yet the config parser still +> validates `RecycleIntervalSeconds`, so an operator can author a recycle interval that does nothing. +> - `DriverTypeRegistry` — which this document's model implies is the tier authority — is **vestigial**: +> referenced only by its own test, with nothing registering metadata at startup. +> - The **separate-Windows-service hosting** described for Tier C does not exist either. Galaxy reaches +> MXAccess over gRPC to the external **mxaccessgw** sidecar; the `Galaxy.Proxy`/`Host`/`Shared` pattern +> named below was retired in PR 7.2, and FOCAS runs in-process like everything else. +> +> `docs/drivers/README.md` says Tier A for Galaxy and FOCAS and is the accurate one. Keep-or-delete of the +> tier machinery is tracked as Gitea **#522**; this document is retained as the design record. +> > **Status**: DRAFT — companion to `plan.md`. Defines the stability tier model, per-driver hosting decisions, cross-cutting protections every driver process must apply, and the canonical worked example (FOCAS) for the high-risk tier. > > **Branch**: `v2` From 5184a2e107151900c73c2d29ea250a5367de61cf Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 28 Jul 2026 00:30:52 -0400 Subject: [PATCH 7/8] fix(drivers): complete the Sql + FOCAS in-place config re-parse (#516) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first #516 pass deliberately left these two out, because each derives more than options from config: Sql its ISqlDialect and resolved connection string, FOCAS the IFocasClientFactory its Backend key selects — all injected at construction. Adopting new options alone would poll a NEW tag set through the OLD database/backend, and a half-applied config change is worse than a discarded one because it looks like it worked. Rather than accept that exclusion, this fixes the blocker. Each factory now exposes a ParseBinding returning EVERY config-derived dependency as one value, and the driver adopts them atomically: - Sql: ApplyBinding takes (options, dialect, connectionString) and rebuilds everything downstream — provider factory, Endpoint, and the SqlPollReader that captures all three. It runs BEFORE BuildTagTable, so the tag table and the connection it is polled over always come from the same revision. A test-injected DbProviderFactory survives a rebind (_explicitFactory), so a re-derived dialect cannot displace what a test passed in. - FOCAS: options and backend move as a pair in InitializeAsync. Re-resolving Sql's connection string on reinit is a side benefit: a rotated credential is picked up without a process restart. Drivers constructed directly get no rebinder and keep their constructor binding, so every existing "{}"-passing lifecycle test is unaffected by construction rather than by luck. The tests pin ATOMICITY, not merely that a re-parse happened — a test checking only options would have passed against the broken version. Confirmed by simulating the half-fix (adopt options, skip the backend): 2 of 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. Sql.Tests 226, FOCAS.Tests 275 pass. --- deferment.md | 33 +++++ .../FocasDriver.cs | 58 ++++++-- .../FocasDriverFactoryExtensions.cs | 37 ++++- .../ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs | 129 ++++++++++++++---- .../SqlDriverFactoryExtensions.cs | 85 +++++++++--- .../FocasReinitConfigAdoptionTests.cs | 67 +++++++++ .../SqlReinitConfigAdoptionTests.cs | 120 ++++++++++++++++ 7 files changed, 467 insertions(+), 62 deletions(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasReinitConfigAdoptionTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlReinitConfigAdoptionTests.cs diff --git a/deferment.md b/deferment.md index 3a4de5b5..aebcf013 100644 --- a/deferment.md +++ b/deferment.md @@ -497,6 +497,39 @@ describes for Tier C no longer exists (Galaxy went to the mxaccessgw sidecar in 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-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 diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs index 0d834c59..b01ef4a4 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs @@ -21,9 +21,18 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS; public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, IDisposable, IAsyncDisposable { - private readonly FocasDriverOptions _options; + /// Mutable so can adopt a re-parsed config (#516). Written only + /// on the init path, together with , before either is used. + private FocasDriverOptions _options; private readonly string _driverInstanceId; - private readonly IFocasClientFactory _clientFactory; + /// Mutable for the same reason as — the Backend config key + /// selects it, so the two must move together or a new tag set polls through an old backend. + private IFocasClientFactory _clientFactory; + + /// Re-parses a DriverConfig into every config-derived dependency. Null when the driver + /// was constructed directly (tests, or a caller supplying its own backend), which keeps the + /// constructor-supplied options authoritative — exactly the prior behaviour. + private readonly Func? _rebind; private readonly PollGroupEngine _poll; private readonly ILogger _logger; private readonly Dictionary _devices = new(StringComparer.OrdinalIgnoreCase); @@ -58,14 +67,20 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, /// The unique identifier for this driver instance. /// Optional factory for creating FOCAS client instances. /// Optional logger instance. + /// Optional re-parse of a DriverConfig into every config-derived dependency, + /// supplied by the factory so can adopt a changed config. Null (direct + /// construction, e.g. a test or a caller supplying its own backend) keeps the constructor-supplied + /// options + backend. public FocasDriver(FocasDriverOptions options, string driverInstanceId, IFocasClientFactory? clientFactory = null, - ILogger? logger = null) + ILogger? logger = null, + Func? rebind = null) { ArgumentNullException.ThrowIfNull(options); _options = options; _driverInstanceId = driverInstanceId; _clientFactory = clientFactory ?? new Wire.WireFocasClientFactory(); + _rebind = rebind; _logger = logger ?? NullLogger.Instance; _resolver = new EquipmentTagRefResolver( r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null); @@ -77,6 +92,16 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, backoffCap: PollBackoffCap); } + /// True when the supplied DriverConfig JSON carries a real body. The bootstrapper always passes + /// a populated document; some unit tests pass "{}" or an empty string to exercise lifecycle shape + /// without a config — those keep the constructor-supplied options + backend. + private static bool HasConfigBody(string? driverConfigJson) + { + if (string.IsNullOrWhiteSpace(driverConfigJson)) return false; + var trimmed = driverConfigJson.Trim(); + return trimmed is not "{}" and not "[]"; + } + /// Upper bound on the poll-loop failure backoff — adopts the S7-proven 30 s cap fleet-wide (05/STAB-8). private static readonly TimeSpan PollBackoffCap = TimeSpan.FromSeconds(30); @@ -102,13 +127,15 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, /// /// Opens the configured CNC handles and builds the authored tag table. - /// is deliberately not re-parsed here, unlike - /// Modbus / AbLegacy / OpcUaClient (Gitea #516). This driver builds more than options from config — - /// its IFocasClientFactory is selected from the Backend key and injected at - /// construction — so adopting a new FocasDriverOptions alone would run a NEW device/tag set - /// against the OLD backend. Half a re-parse is worse than none. - /// A config change is covered by the stop + respawn in DriverSpawnPlanner, which - /// rebuilds options and backend together through the factory. + /// is re-parsed here when the driver was built by + /// the factory (Gitea #516), and the re-parse is all-or-nothing: the typed options and the + /// IFocasClientFactory the Backend key selects come from one ParseBinding call + /// and are adopted together. That pairing is the point — adopting options alone would poll a NEW + /// device/tag set through the OLD backend, which is why this driver was excluded from the first + /// #516 pass. + /// A driver constructed directly (every unit test, or a caller supplying its own backend) gets + /// no rebinder and keeps its constructor-supplied pair. DriverSpawnPlanner's stop + respawn + /// remains the outer guarantee. /// /// The driver configuration JSON (see remarks — not re-parsed). /// Cancellation token for the operation. @@ -118,6 +145,17 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, Volatile.Write(ref _health, new DriverHealth(DriverState.Initializing, null, null)); try { + // #516: adopt a changed config. Options and the backend client factory are re-derived by ONE + // parse and assigned together — a partial adoption would poll a new device/tag set through the + // old backend. A null rebinder (direct construction) or an empty/placeholder document keeps the + // constructor-supplied pair, which is what every lifecycle test passing "{}" relies on. + if (_rebind is not null && HasConfigBody(driverConfigJson)) + { + var binding = _rebind(driverConfigJson); + _options = binding.Options; + _clientFactory = binding.ClientFactory; + } + // Fail fast if the factory is a stub/unimplemented backend — the operator must // see an actionable error at init rather than a phantom-Healthy driver that fails // every read/write/subscribe silently. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverFactoryExtensions.cs index 733e6cc8..8fbf4538 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverFactoryExtensions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverFactoryExtensions.cs @@ -47,6 +47,29 @@ public static class FocasDriverFactoryExtensions /// The driver configuration JSON string. /// A configured instance. internal static FocasDriver CreateInstance(string driverInstanceId, string driverConfigJson) + { + var binding = ParseBinding(driverInstanceId, driverConfigJson); + return new FocasDriver( + binding.Options, + driverInstanceId, + binding.ClientFactory, + // Hand the driver the SAME parse it was built with, so InitializeAsync can re-derive EVERY + // config-derived dependency on a reinitialize — options AND the backend client factory + // together (#516). Re-parsing options alone would run a new device/tag set against the old + // backend, which is worse than not re-parsing at all. + rebind: json => ParseBinding(driverInstanceId, json)); + } + + /// + /// Parses a FOCAS DriverConfig JSON document into every dependency the driver derives + /// from config: the typed options and the the Backend key + /// selects. Returned together because they must be adopted together — see the rebind note in + /// . + /// + /// The unique driver instance identifier. + /// The driver configuration JSON string. + /// The parsed options + backend factory. + internal static FocasDriverBinding ParseBinding(string driverInstanceId, string driverConfigJson) { ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId); ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson); @@ -86,8 +109,7 @@ public static class FocasDriverFactoryExtensions HandleRecycle = BuildHandleRecycle(dto.HandleRecycle), }; - var clientFactory = BuildClientFactory(dto, driverInstanceId); - return new FocasDriver(options, driverInstanceId, clientFactory); + return new FocasDriverBinding(options, BuildClientFactory(dto, driverInstanceId)); } /// @@ -327,3 +349,14 @@ public static class FocasDriverFactoryExtensions public TimeSpan? Interval { get; init; } } } + +/// +/// Everything FocasDriver derives from its DriverConfig JSON, returned as one value so a +/// reinitialize adopts all of it atomically. +/// This exists because a partial adoption is a real hazard, not a theoretical one: the driver's +/// is chosen by the config's Backend key, so re-parsing +/// alone would poll a NEW device/tag set through the OLD backend (Gitea #516). +/// +/// The typed driver options. +/// The backend client factory the Backend key selects. +public sealed record FocasDriverBinding(FocasDriverOptions Options, IFocasClientFactory ClientFactory); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs index 6af0b649..c69de1ec 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs @@ -1,4 +1,5 @@ using System.Data.Common; +using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; @@ -51,16 +52,19 @@ public sealed class SqlDriver // ---- instance fields (grouped at top for auditability) ---- - private readonly SqlDriverOptions _options; + /// Config-derived, so mutable — see . + private SqlDriverOptions _options; private readonly string _driverInstanceId; - private readonly ISqlDialect _dialect; - private readonly DbProviderFactory _factory; + /// Config-derived, so mutable: swaps it with the connection string + /// and options together on a reinitialize (#516). + private ISqlDialect _dialect; + private DbProviderFactory _factory; /// /// The resolved connection string — a secret. It is passed to the provider and to nothing /// else: every log line, health message and host status carries instead. /// - private readonly string _connectionString; + private string _connectionString; private readonly ILogger _logger; @@ -93,11 +97,19 @@ public sealed class SqlDriver /// Resolves a read/subscribe RawPath to its catalog-validated definition, or a miss. private readonly EquipmentTagRefResolver _resolver; + /// The factory the CALLER passed, or null to take the dialect's. Kept separate from + /// so a re-derived dialect cannot silently displace a test's injected factory. + private readonly DbProviderFactory? _explicitFactory; + + /// Re-parses a DriverConfig into every config-derived dependency. Null when the driver + /// was constructed directly, which keeps the constructor-supplied trio authoritative. + private readonly Func? _rebind; + /// /// The read path. Built once, in the constructor, and not injected: its resolve /// delegate must read this driver's live authored table, which does not exist before the driver does. /// - private readonly SqlPollReader _reader; + private SqlPollReader _reader; /// Polled subscriptions. The driver supplies the reader + change bridge; the engine owns the loop. private readonly PollGroupEngine _poll; @@ -133,6 +145,11 @@ public sealed class SqlDriver /// explicitly by tests that need to observe or delay connection creation. /// /// Optional; defaults to a no-op logger. + /// + /// Optional re-parse of a DriverConfig into every config-derived dependency, supplied by the + /// factory so can adopt a changed config. Null (direct construction — + /// every test) keeps the constructor-supplied options, dialect and connection string. + /// /// A required reference argument is null. /// or is blank. public SqlDriver( @@ -141,7 +158,8 @@ public sealed class SqlDriver ISqlDialect dialect, string connectionString, DbProviderFactory? factory = null, - ILogger? logger = null) + ILogger? logger = null, + Func? rebind = null) { ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(dialect); @@ -149,15 +167,47 @@ public sealed class SqlDriver if (string.IsNullOrWhiteSpace(connectionString)) throw new ArgumentException("A Sql driver needs a resolved connection string.", nameof(connectionString)); - _options = options; _driverInstanceId = driverInstanceId; - _dialect = dialect; - _factory = factory ?? dialect.Factory; - _connectionString = connectionString; _logger = logger ?? NullLogger.Instance; - Endpoint = DescribeEndpoint(connectionString); - + _explicitFactory = factory; + _rebind = rebind; _resolver = new EquipmentTagRefResolver(Lookup); + + // Everything below this line is derived from config and is re-derived wholesale on a reinitialize. + ApplyBinding(options, dialect, connectionString); + + _poll = new PollGroupEngine( + reader: ReadAsync, + onChange: (handle, tagRef, snapshot) => + OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)), + onError: HandlePollError, + backoffCap: PollBackoffCap); + } + + /// + /// Adopts a config-derived trio — options, dialect, connection string — and rebuilds everything + /// downstream of it (the provider factory, the endpoint description, and the poll reader, which + /// captures all three). Called from the constructor and again from + /// when a changed config arrives. + /// All-or-nothing on purpose. The reason Sql was excluded from the first #516 pass is + /// that adopting options alone would poll a NEW tag set through the OLD connection and dialect. Doing + /// it in one method means a future field derived from config gets added here rather than being + /// forgotten on the reinit path. + /// Not thread-safe by design: only the init path calls it, before any poll group is running. + /// The poll engine and the tag resolver survive across a rebind — the engine holds a delegate to + /// ReadAsync and the resolver reads the live tag table, so neither captures the old trio. + /// + /// The typed options to adopt. + /// The provider seam to adopt. + /// The resolved connection string to adopt. Never logged. + [MemberNotNull(nameof(_options), nameof(_dialect), nameof(_factory), nameof(_connectionString), nameof(_reader))] + private void ApplyBinding(SqlDriverOptions options, ISqlDialect dialect, string connectionString) + { + _options = options; + _dialect = dialect; + _factory = _explicitFactory ?? dialect.Factory; + _connectionString = connectionString; + Endpoint = DescribeEndpoint(connectionString); _reader = new SqlPollReader( _factory, connectionString, @@ -168,12 +218,16 @@ public sealed class SqlDriver nullIsBad: options.NullIsBad, resolve: rawPath => _resolver.TryResolve(rawPath, out var definition) ? definition : null, logger: _logger); - _poll = new PollGroupEngine( - reader: ReadAsync, - onChange: (handle, tagRef, snapshot) => - OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)), - onError: HandlePollError, - backoffCap: PollBackoffCap); + } + + /// True when the supplied DriverConfig JSON carries a real body. The bootstrapper always passes + /// a populated document; some unit tests pass "{}" or an empty string to exercise lifecycle shape + /// without a config — those keep the constructor-supplied trio. + private static bool HasConfigBody(string? driverConfigJson) + { + if (string.IsNullOrWhiteSpace(driverConfigJson)) return false; + var trimmed = driverConfigJson.Trim(); + return trimmed is not "{}" and not "[]"; } /// @@ -187,7 +241,7 @@ public sealed class SqlDriver /// the connection string names both. This is the only rendering of the connection string that may /// appear in a log, a health message, or the Admin UI. /// - public string Endpoint { get; } + public string Endpoint { get; private set; } = ""; /// Host identifier surfaced through — the endpoint description. public string HostName => Endpoint; @@ -215,17 +269,20 @@ public sealed class SqlDriver /// /// Builds the authored RawPath table and proves the database is reachable. - /// is not re-parsed here: the driver serves the - /// typed it was constructed with. That premise — "config parsing - /// belongs to the factory, which builds a fresh instance" — was false when written - /// (Gitea #516): the host reinitialized the EXISTING child in place, so every config edit was - /// silently discarded and the deployment still sealed green. It is true now, because - /// DriverSpawnPlanner routes a changed DriverConfig through a stop + respawn. - /// This driver deliberately does not also re-parse in place, unlike Modbus / AbLegacy / - /// OpcUaClient. It builds more than options from config — the and the - /// resolved connection string are both injected at construction — so adopting a new - /// alone would run a NEW tag set against the OLD connection. Half a - /// re-parse is worse than none; the respawn rebuilds all three together. + /// is re-parsed here when the driver was built by + /// the factory (Gitea #516). The original comment on this method claimed it was not, on the premise + /// that "config parsing belongs to the factory, which builds a fresh instance" — that was false when + /// written, because the host reinitialized the EXISTING child in place and every config edit was + /// silently discarded while the deployment still sealed green. + /// The re-parse is all-or-nothing via : options, the + /// and the resolved connection string are re-derived by one + /// ParseBinding call and adopted together. That is what makes it safe here — adopting options + /// alone would poll a NEW tag set through the OLD database, which is why this driver was excluded + /// from the first #516 pass. It happens BEFORE BuildTagTable, so the tag table and the + /// connection it is polled over always come from the same revision. + /// A driver constructed directly (every unit test) gets no rebinder and keeps its + /// constructor-supplied trio, so "{}"-passing lifecycle tests are unaffected. + /// DriverSpawnPlanner's stop + respawn remains the outer guarantee. /// The table is built first because it is pure and cannot fail; the liveness check is the only /// I/O, and on failure this method records and rethrows — /// DriverInstanceActor reads a throw as InitializeFailed and lands in Reconnecting with its @@ -243,6 +300,18 @@ public sealed class SqlDriver public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) { WriteHealth(new DriverHealth(DriverState.Initializing, null, null)); + + // #516: adopt a changed config BEFORE the tag table is built, so the table and the connection it + // will be polled over come from the same revision. ApplyBinding takes the options, dialect and + // connection string as one unit; adopting options alone would poll a NEW tag set through the OLD + // database, which is why this driver was excluded from the first pass. A null rebinder (direct + // construction) or an empty/placeholder document keeps the constructor-supplied trio. + if (_rebind is not null && HasConfigBody(driverConfigJson)) + { + var binding = _rebind(driverConfigJson); + ApplyBinding(binding.Options, binding.Dialect, binding.ConnectionString); + } + BuildTagTable(); try diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs index ac7479e3..a4ec55ef 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs @@ -81,6 +81,43 @@ public static class SqlDriverFactoryExtensions public static SqlDriver CreateInstance(string driverInstanceId, string driverConfigJson) => CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null); + /// + /// Parses a Sql DriverConfig JSON document into every dependency the driver derives + /// from config: the typed options, the the provider key selects, and + /// the connection string resolved from connectionStringRef. + /// Returned together because they must be adopted together. Re-parsing options alone on a + /// reinitialize would run a NEW tag set against the OLD connection and dialect (Gitea #516) — a + /// half-applied config change is worse than a discarded one, because it looks like it worked. + /// Re-running this on reinitialize also re-resolves the connection string from its environment + /// variable, so a rotated credential is picked up without a process restart. + /// + /// The central config DB's identity for this driver instance. + /// The driver configuration JSON. + /// The parsed options, dialect and resolved connection string. + /// The config is malformed or names no connection string. + internal static SqlDriverBinding ParseBinding(string driverInstanceId, string driverConfigJson) + { + ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId); + ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson); + + var dto = Deserialize(driverInstanceId, driverConfigJson); + + if (string.IsNullOrWhiteSpace(dto.ConnectionStringRef)) + { + throw new InvalidOperationException( + $"Sql driver config for '{driverInstanceId}' is missing the required connectionStringRef. " + + $"Author the NAME of a connection string (the value itself is supplied out-of-band through " + + $"the '{SqlConnectionStringResolver.EnvironmentVariablePrefix}' environment variable)."); + } + + var dialect = CreateDialect(dto.Provider, driverInstanceId); + + // Resolved BEFORE the knob validation in CreateInstance, so that every subsequent throw is a path + // that HAS the secret in hand — which is exactly where a careless interpolation would leak it. + var connectionString = SqlConnectionStringResolver.Resolve(dto.ConnectionStringRef!); + return new SqlDriverBinding(BuildOptions(dto, driverInstanceId), dialect, connectionString, dto); + } + /// /// Logger-aware overload — used by 's closure when wired through DI. /// Constructs the driver only: the driver builds its own , because @@ -102,25 +139,11 @@ public static class SqlDriverFactoryExtensions public static SqlDriver CreateInstance( string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory) { - ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId); - ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson); - - var dto = Deserialize(driverInstanceId, driverConfigJson); - - if (string.IsNullOrWhiteSpace(dto.ConnectionStringRef)) - { - throw new InvalidOperationException( - $"Sql driver config for '{driverInstanceId}' is missing the required connectionStringRef. " + - $"Author the NAME of a connection string (the value itself is supplied out-of-band through " + - $"the '{SqlConnectionStringResolver.EnvironmentVariablePrefix}' environment variable)."); - } - - var dialect = CreateDialect(dto.Provider, driverInstanceId); - - // Resolved BEFORE the knob validation below, so that every subsequent throw is a path that HAS the - // secret in hand — which is exactly where a careless interpolation would leak it. - var connectionString = SqlConnectionStringResolver.Resolve(dto.ConnectionStringRef!); - var options = BuildOptions(dto, driverInstanceId); + var binding = ParseBinding(driverInstanceId, driverConfigJson); + var dto = binding.Dto; + var dialect = binding.Dialect; + var connectionString = binding.ConnectionString; + var options = binding.Options; var logger = (ILogger?)loggerFactory?.CreateLogger(typeof(SqlDriverFactoryExtensions).FullName!) ?? NullLogger.Instance; @@ -143,7 +166,11 @@ public static class SqlDriverFactoryExtensions dialect, connectionString, factory: null, - logger: loggerFactory?.CreateLogger()); + logger: loggerFactory?.CreateLogger(), + // Hand the driver the SAME parse it was built with, so InitializeAsync can re-derive options, + // dialect and connection string together on a reinitialize (#516). Re-resolving the connection + // string also picks up a rotated credential without a process restart. + rebind: json => ParseBinding(driverInstanceId, json)); // Endpoint is the credential-free server/database rendering — the ONLY form of the connection string // permitted outside the provider call. @@ -250,3 +277,21 @@ public static class SqlDriverFactoryExtensions } } } + +/// +/// Everything SqlDriver derives from its DriverConfig JSON, returned as one value so a +/// reinitialize adopts all of it atomically. +/// Partial adoption is the hazard this type exists to prevent: the dialect and the resolved +/// connection string are both config-derived, so adopting new alone would poll a +/// NEW tag set against the OLD database (Gitea #516). +/// +/// The typed driver options. +/// The provider seam the provider key selects. +/// The connection string resolved from connectionStringRef. Never logged. +/// The raw parsed DTO, so a caller needing a key the options do not carry (e.g. +/// allowWrites, which the driver ignores but warns about) need not re-deserialize. +public sealed record SqlDriverBinding( + SqlDriverOptions Options, + ISqlDialect Dialect, + string ConnectionString, + SqlDriverConfigDto Dto); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasReinitConfigAdoptionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasReinitConfigAdoptionTests.cs new file mode 100644 index 00000000..11057252 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasReinitConfigAdoptionTests.cs @@ -0,0 +1,67 @@ +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; + +/// +/// Gitea #516 — FOCAS was excluded from the first pass because it derives MORE than options from config: +/// the Backend key selects its , injected at construction. Adopting +/// a new FocasDriverOptions alone would poll a NEW device/tag set through the OLD backend, which +/// looks like it worked and does not. +/// ParseBinding now returns options + backend together and InitializeAsync adopts both +/// or neither. These tests pin the BACKEND moving — the half that was missing — because a test that only +/// checked options would have passed against the broken version. +/// +[Trait("Category", "Unit")] +public sealed class FocasReinitConfigAdoptionTests +{ + /// + /// Reinitializing onto Backend: "unimplemented" must make the driver adopt that backend, whose + /// EnsureUsable() throws by design. A driver still holding the constructor's wire + /// backend would not throw — so the throw is a direct read of which backend is live. + /// + [Fact] + public async Task Reinitialize_adopts_a_changed_backend_not_just_the_options() + { + var driver = FocasDriverFactoryExtensions.CreateInstance( + "focas-1", """{"backend":"wire","devices":[]}"""); + + var ex = await Record.ExceptionAsync(() => driver.ReinitializeAsync( + """{"backend":"unimplemented","devices":[]}""", CancellationToken.None)); + + ex.ShouldBeOfType( + "FocasDriver adopted a reinitialized config but kept its ORIGINAL backend — the half-applied " + + "adoption (#516) that would poll a new device/tag set through the old backend"); + } + + /// The other direction: moving OFF the unimplemented backend must also take effect, so the + /// adoption is not a one-way latch. + [Fact] + public async Task Reinitialize_away_from_the_unimplemented_backend_also_takes_effect() + { + var driver = FocasDriverFactoryExtensions.CreateInstance( + "focas-1", """{"backend":"unimplemented","devices":[]}"""); + + // Sanity: the constructor-supplied backend is the throwing one. + (await Record.ExceptionAsync(() => driver.InitializeAsync("{}", CancellationToken.None))) + .ShouldBeOfType(); + + var ex = await Record.ExceptionAsync(() => driver.ReinitializeAsync( + """{"backend":"wire","devices":[]}""", CancellationToken.None)); + + ex.ShouldNotBeOfType( + "FocasDriver stayed on the unimplemented backend after a reinitialize that selected 'wire'"); + } + + /// An empty/placeholder document keeps the constructor-supplied pair, so the lifecycle tests + /// that pass "{}" keep meaning what they meant. + [Fact] + public async Task Reinitialize_with_an_empty_document_keeps_the_constructor_backend() + { + var driver = FocasDriverFactoryExtensions.CreateInstance( + "focas-1", """{"backend":"unimplemented","devices":[]}"""); + + (await Record.ExceptionAsync(() => driver.ReinitializeAsync("{}", CancellationToken.None))) + .ShouldBeOfType(); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlReinitConfigAdoptionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlReinitConfigAdoptionTests.cs new file mode 100644 index 00000000..1181de1a --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlReinitConfigAdoptionTests.cs @@ -0,0 +1,120 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests; + +/// +/// Gitea #516 — Sql was excluded from the first pass because it derives MORE than options from config +/// (its and its resolved connection string are both config-derived), so +/// adopting a new SqlDriverOptions alone would poll a NEW tag set through the OLD database. +/// A half-applied config change is worse than a discarded one, because it looks like it worked. +/// The fix is ParseBinding + ApplyBinding: one parse produces all three, and they are +/// adopted together. These tests pin the ATOMICITY, not just that a re-parse happens — that is the +/// property that made this driver special. +/// +[Trait("Category", "Unit")] +public sealed class SqlReinitConfigAdoptionTests +{ + private const string RefA = "SqlReinitTestA"; + private const string RefB = "SqlReinitTestB"; + + /// + /// A reinitialize that changes connectionStringRef must move the driver onto the NEW database. + /// Endpoint is the credential-free rendering of the live connection string, so it is a direct + /// read of which connection the driver actually adopted — no log-string matching. + /// + [Fact] + public async Task Reinitialize_adopts_a_changed_connection_string_not_just_the_options() + { + using var _ = new ScopedEnvironmentVariable( + SqlConnectionStringResolver.EnvironmentVariablePrefix + RefA, + "Server=old-server;Database=OldDb;Integrated Security=true"); + using var __ = new ScopedEnvironmentVariable( + SqlConnectionStringResolver.EnvironmentVariablePrefix + RefB, + "Server=new-server;Database=NewDb;Integrated Security=true"); + + var driver = SqlDriverFactoryExtensions.CreateInstance( + "sql-1", $$"""{"provider":"SqlServer","connectionStringRef":"{{RefA}}"}""", loggerFactory: null); + driver.Endpoint.ShouldContain("old-server"); + + // The database is unreachable, so init faults — irrelevant here. The assertion is which database it + // tried, i.e. whether the connection string moved with the options. + try + { + await driver.ReinitializeAsync( + $$"""{"provider":"SqlServer","connectionStringRef":"{{RefB}}"}""", CancellationToken.None); + } + catch + { + // Liveness failure against a non-existent server is expected. + } + + driver.Endpoint.ShouldContain( + "new-server", + customMessage: + "SqlDriver adopted a reinitialized config but kept its ORIGINAL connection string — the exact " + + "half-applied adoption (#516) that would poll a new tag set against the old database"); + driver.Endpoint.ShouldNotContain("old-server"); + } + + /// + /// A config whose connectionStringRef names an unprovisioned environment variable must make + /// the whole reinitialize FAIL, leaving the previous binding intact — never a partial adoption where + /// new options are live against the old connection. + /// + [Fact] + public async Task A_reinitialize_that_cannot_resolve_its_connection_leaves_the_previous_binding_intact() + { + using var _ = new ScopedEnvironmentVariable( + SqlConnectionStringResolver.EnvironmentVariablePrefix + RefA, + "Server=old-server;Database=OldDb;Integrated Security=true"); + + var driver = SqlDriverFactoryExtensions.CreateInstance( + "sql-1", $$"""{"provider":"SqlServer","connectionStringRef":"{{RefA}}"}""", loggerFactory: null); + + await Should.ThrowAsync(() => driver.ReinitializeAsync( + """{"provider":"SqlServer","connectionStringRef":"SqlReinitTestNeverProvisioned"}""", + CancellationToken.None)); + + driver.Endpoint.ShouldContain( + "old-server", + customMessage: + "a reinitialize that could not resolve its new connection must leave the previous binding whole"); + } + + /// An empty/placeholder document keeps the constructor-supplied binding, so the many lifecycle + /// tests that pass "{}" keep meaning what they meant. + [Fact] + public async Task Reinitialize_with_an_empty_document_keeps_the_constructor_binding() + { + using var _ = new ScopedEnvironmentVariable( + SqlConnectionStringResolver.EnvironmentVariablePrefix + RefA, + "Server=old-server;Database=OldDb;Integrated Security=true"); + + var driver = SqlDriverFactoryExtensions.CreateInstance( + "sql-1", $$"""{"provider":"SqlServer","connectionStringRef":"{{RefA}}"}""", loggerFactory: null); + + try { await driver.ReinitializeAsync("{}", CancellationToken.None); } + catch { /* liveness failure expected */ } + + driver.Endpoint.ShouldContain("old-server"); + } + + /// Sets an environment variable for the duration of a test and restores it after. The resolver + /// reads process-global state, so a leaked variable would race every sibling test. + private sealed class ScopedEnvironmentVariable : IDisposable + { + private readonly string _name; + private readonly string? _previous; + + public ScopedEnvironmentVariable(string name, string value) + { + _name = name; + _previous = Environment.GetEnvironmentVariable(name); + Environment.SetEnvironmentVariable(name, value); + } + + public void Dispose() => Environment.SetEnvironmentVariable(_name, _previous); + } +} From e77c8a3569820429b25ecbcd336415bba41a9d61 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 28 Jul 2026 00:48:39 -0400 Subject: [PATCH 8/8] feat(drivers): tag-set drift detection, gated on SupportsOnlineDiscovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The second thing #516/§8.2 deliberately did not build. The original objection stands — Modbus, S7, MQTT, AbLegacy and Sql echo authored config from DiscoverAsync, so diffing discovered against authored is a tautology for them — but it is now ENCODED rather than used as a reason not to build. The discriminator already existed: 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 and AbCip true; the five config-echo drivers explicitly false. The check runs only for a driver that is SupportsOnlineDiscovery AND reports the new AuthoredDiscoveryRefs. That is nullable rather than empty-by-default on purpose: an empty collection legitimately means "nothing authored, everything discovered is new", so conflating the two would report total drift for a driver that 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. A finding that changed the design: FOCAS, TwinCAT and AbCip all re-emit their authored tags into the same DiscoverAsync stream as device-derived ones, so the browse picker can show an operator their existing tags. Discovered is therefore always a superset of authored, 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 the half-inert seam this whole exercise removes. 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. Behaviour: a failed browse is not drift (an unreachable device would otherwise report every authored tag as vanished); a truncated capture is skipped for the same reason; an unchanged drift is reported once rather than re-stamping its timestamp; drift that resolves clears the prompt. Interval defaults to 5 minutes — it browses a real device, and a tag set changing is an engineering event, not a runtime one. It reuses the Stage-2 surfacing, raising the same /hosts re-browse prompt, and never rebuilds the served address space. Comparison logic is a pure, separately-tested type rather than actor-inline. 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 never fired. Full suite green except the same three pre-existing fixture-gated integration suites, identical counts. --- CLAUDE.md | 29 +- deferment.md | 41 +++ .../ITagDiscovery.cs | 28 ++ .../AbCipDriver.cs | 11 + .../FocasDriver.cs | 11 + .../MTConnectDriver.cs | 13 + .../TwinCATDriver.cs | 11 + .../Drivers/DriverInstanceActor.cs | 160 ++++++++++- .../Drivers/TagSetDrift.cs | 92 ++++++ .../DriverInstanceActorDriftCheckTests.cs | 268 ++++++++++++++++++ .../Drivers/TagSetDriftDetectorTests.cs | 113 ++++++++ 11 files changed, 770 insertions(+), 7 deletions(-) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/TagSetDrift.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDriftCheckTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/TagSetDriftDetectorTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 7ef172fb..5465302f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -188,10 +188,31 @@ line and injected nothing. Gone with it: `HandleDiscoveredNodes`, `PartitionDisc browse picker drives it through `Commons/Browsing/DiscoveryDriverBrowser`, which never went through the actor. -**Deliberately NOT built: a discovered-vs-authored drift detector.** Modbus, S7, MQTT, AbLegacy and Sql -all *echo authored config* from `DiscoverAsync` rather than browsing the device, so such a diff is -permanently empty for them — another plausible-looking inert seam. `IRediscoverable` is the -trustworthy signal because the driver asserts it deliberately. +**Tag-set drift detection (2026-07-28) — the second signal, and it is GATED.** `DriverInstanceActor` +re-browses on a slow timer (`DefaultDriftCheckInterval`, 5 min) and compares what the remote offers +against what an operator authored, raising the same re-browse prompt. This matters most for **AbCip and +FOCAS**, which browse a live backend but implement no `IRediscoverable`, so a periodic compare is their +only way to notice a PLC re-download. + +⚠️ **It runs ONLY for a driver declaring `ITagDiscovery.SupportsOnlineDiscovery` AND reporting +`AuthoredDiscoveryRefs` (nullable, default null = opt out).** Modbus, S7, MQTT, AbLegacy and Sql *echo +authored config* from `DiscoverAsync` rather than browsing the device, so the diff for them is a +tautology that would report "no drift" forever and read as coverage. A check that cannot fail is worse +than no check. Note `AuthoredDiscoveryRefs` is **nullable, not empty-by-default** — an empty collection +legitimately means "nothing authored, so everything discovered is new". + +⚠️ **One direction is structurally undetectable for most drivers.** FOCAS, TwinCAT and AbCip re-emit +their authored tags into the same `DiscoverAsync` stream as device-derived ones (so the browse picker +shows existing tags), which means discovered ⊇ authored **always** and a *vanished* tag can never appear +missing. Those drivers declare `DiscoveryStreamIncludesAuthoredTags => true`, and the detector then +reports only what it can see and **says so** in the operator message rather than implying a clean bill of +health. **MTConnect is currently the only driver where both directions are detectable** — its stream is +built purely from the Agent's probe model. + +Other properties worth knowing: a **failed browse is not drift** (an unreachable device would otherwise +report every authored tag as vanished); a **truncated** capture is skipped for the same reason; an +**unchanged** drift is reported once rather than re-stamping its timestamp every 5 min; and drift that +resolves **clears** the prompt. The timer starts on Connected entry and is cancelled on every exit. ⚠️ **`IHostConnectivityProbe` is still unconsumed.** `GetHostStatuses()` has no production call site and nothing ever writes a `DriverHostStatus` row (the table was re-created in the v3 initial migration, so diff --git a/deferment.md b/deferment.md index aebcf013..c3e619af 100644 --- a/deferment.md +++ b/deferment.md @@ -530,6 +530,47 @@ 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) + +**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-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 diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ITagDiscovery.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ITagDiscovery.cs index cc713a1b..76d2009d 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ITagDiscovery.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ITagDiscovery.cs @@ -38,4 +38,32 @@ public interface ITagDiscovery /// See docs/plans/2026-07-15-universal-discovery-browser-design.md §5. /// bool SupportsOnlineDiscovery => false; + + /// + /// The device-native references this driver's authored tags bind to — the same vocabulary + /// streams as . Comparing the + /// two sets detects that the remote's tag set has drifted from what an operator authored. + /// Null means "I do not report this", and drift detection is skipped. That is the + /// default on purpose: an empty collection legitimately means "no tags authored, so everything + /// discovered is new", and the two must not be confused. A driver opting in is asserting that its + /// authored refs are directly comparable to what it discovers. + /// Only consulted when is true. For a driver that + /// replays authored tags from config rather than browsing the backend, the comparison is a + /// tautology — it would report "no drift" forever and read as reassurance. + /// + IReadOnlyCollection? AuthoredDiscoveryRefs => null; + + /// + /// True when re-emits this driver's authored tags into the same + /// stream as the device-derived ones — which FOCAS, TwinCAT and AbCip all do, so the browse picker + /// shows an operator their existing tags alongside what the device offers. + /// This makes one half of drift detection structurally undetectable. If authored tags + /// are always re-emitted then the discovered set always contains the authored set, so an authored tag + /// that has VANISHED from the device can never appear missing. Declaring it here means the detector + /// reports only what it can actually see, instead of reporting "nothing vanished" forever and reading + /// as reassurance. + /// False (the default) means the stream is purely device-derived — as MTConnect's is, built + /// from the Agent's probe model — so both directions are detectable. + /// + bool DiscoveryStreamIncludesAuthoredTags => false; } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs index 05ad2904..60aff54f 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs @@ -1036,6 +1036,17 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, /// EnableControllerBrowse, which the universal browser's PatchForBrowse guarantees at open time. public bool SupportsOnlineDiscovery => true; + /// + /// Pre-declared tags are emitted under their Name, which is the driver FullName the + /// controller-browse leaves also use. + public IReadOnlyCollection? AuthoredDiscoveryRefs => + _declaredTags.Select(t => t.Name).ToArray(); + + /// + /// True: DiscoverAsync emits browsed controller symbols AND re-emits every + /// pre-declared tag. + public bool DiscoveryStreamIncludesAuthoredTags => true; + /// public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) { diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs index b01ef4a4..72b28f91 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs @@ -517,6 +517,17 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, /// until the node set is non-empty and stable.
public bool SupportsOnlineDiscovery => true; + /// + /// The authored tag's Name IS its driver-side FullName — DiscoverAsync emits + /// FullName: tag.Name for every authored tag — so the two sets are directly comparable. + public IReadOnlyCollection? AuthoredDiscoveryRefs => + _tagsByRawPath.Values.Select(t => t.Name).ToArray(); + + /// + /// True: DiscoverAsync emits the device-derived FixedTree AND re-emits every authored + /// tag, so an authored tag that vanished from the CNC cannot appear missing. + public bool DiscoveryStreamIncludesAuthoredTags => true; + /// public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) { diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs index 9c60197c..384d4f5b 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs @@ -907,6 +907,19 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis /// public bool SupportsOnlineDiscovery => true; + /// + /// The DataItem ids the authored raw tags bind. DiscoverAsync emits + /// FullName: dataItem.Id from the Agent's probe model, so the two sets are the same + /// vocabulary. + public IReadOnlyCollection? AuthoredDiscoveryRefs => + Volatile.Read(ref _binding).RawPathsByDataItemId.Keys; + + /// + /// False — DiscoverAsync is built purely from the Agent's probe model and never + /// re-emits authored tags, so an authored DataItem that the Agent stopped publishing IS visible as + /// missing. MTConnect is currently the only driver where both drift directions are detectable. + public bool DiscoveryStreamIncludesAuthoredTags => false; + /// /// /// Once, not the default UntilStable: streams the diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs index 4acdbdf2..6e960bdd 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs @@ -404,6 +404,17 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery /// EnableControllerBrowse, which the universal browser's PatchForBrowse guarantees at open time.
public bool SupportsOnlineDiscovery => true; + /// + /// The authored tag's Name is its RawPath and its driver FullName + /// (DiscoverAsync emits FullName: tag.Name), directly comparable to the browsed + /// symbols' InstancePath. + public IReadOnlyCollection? AuthoredDiscoveryRefs => + _tagsByRawPath.Values.Select(t => t.Name).ToArray(); + + /// + /// True: DiscoverAsync emits browsed ADS symbols AND re-emits every authored tag. + public bool DiscoveryStreamIncludesAuthoredTags => true; + /// public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) { diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs index 5fc2139e..19e2331e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs @@ -1,5 +1,6 @@ using Akka.Actor; using Akka.Event; +using ZB.MOM.WW.OtOpcUa.Commons.Browsing; using ZB.MOM.WW.OtOpcUa.Commons.Observability; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Commons.Types; @@ -32,6 +33,11 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers { public static readonly TimeSpan DefaultReconnectInterval = TimeSpan.FromSeconds(10); + /// Default period of the tag-set drift check. Deliberately slow: it browses a real device, and + /// a tag set changing is an engineering event (a PLC re-download, a CNC option install), not a runtime + /// one. Five minutes bounds the wire cost while still surfacing drift the same shift it happens. + public static readonly TimeSpan DefaultDriftCheckInterval = TimeSpan.FromMinutes(5); + public sealed record InitializeRequested(string DriverConfigJson); public sealed record InitializeSucceeded(int Generation); public sealed record InitializeFailed(string Reason, int Generation); @@ -121,6 +127,15 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers /// between connects), and dropping it in one state would lose the signal silently. private sealed record RediscoveryRaised(RediscoveryEventArgs Args); + /// Self-sent on a slow timer to re-browse a genuinely-browsable driver and compare what the + /// remote offers against what an operator authored. Only ever scheduled for a driver that opts in — see + /// . + private sealed record DriftCheckTick + { + public static readonly DriftCheckTick Instance = new(); + private DriftCheckTick() { } + } + public sealed class RetryConnect { public static readonly RetryConnect Instance = new(); @@ -181,6 +196,14 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers private DateTime? _rediscoveryNeededUtc; private string? _rediscoveryReason; + /// Period of the tag-set drift check; tests inject a tiny value. + private readonly TimeSpan _driftCheckInterval; + + /// Signature of the last drift REPORTED, so an unchanged drift is not re-announced on every + /// check. Without this the operator's prompt would re-stamp its timestamp every interval forever, + /// which reads as "it just happened again" rather than "it is still true". + private string? _lastDriftSignature; + /// The references the host wants kept subscribed (set by ). /// Re-applied on every entry into Connected so values resume after a reconnect or redeploy. private IReadOnlyList _desiredRefs = Array.Empty(); @@ -214,6 +237,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers /// defaults to an empty string when not provided (e.g. in unit tests). /// Optional Phase 6.1 resilience invoker wrapping this driver's capability /// calls; defaults to (pass-through) when not supplied. + /// Optional period of the tag-set drift check; defaults to + /// . Tests inject a tiny value so the check runs without a wait. /// Optional period of the health-poll heartbeat, which also drives the /// subscription reconcile (); defaults to /// . Exists so a test can drive the reconcile without waiting 30 s. @@ -225,7 +250,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers IDriverHealthPublisher? healthPublisher = null, string? clusterId = null, IDriverCapabilityInvoker? invoker = null, - TimeSpan? healthPollInterval = null) => + TimeSpan? healthPollInterval = null, + TimeSpan? driftCheckInterval = null) => Akka.Actor.Props.Create(() => new DriverInstanceActor( driver, reconnectInterval ?? DefaultReconnectInterval, @@ -233,7 +259,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers healthPublisher ?? NullDriverHealthPublisher.Instance, clusterId ?? string.Empty, invoker, - healthPollInterval)); + healthPollInterval, + driftCheckInterval)); /// /// Returns true when the driver should boot in DEV-STUB mode based on host platform and @@ -264,6 +291,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers /// Cluster identifier forwarded in health snapshots. /// Phase 6.1 resilience invoker wrapping this driver's capability calls; /// defaults to (pass-through) when null. + /// Period of the tag-set drift check; defaults to + /// . /// Period of the health-poll heartbeat, which also drives the /// subscription reconcile; defaults to when null. public DriverInstanceActor( @@ -273,13 +302,15 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers IDriverHealthPublisher? healthPublisher = null, string? clusterId = null, IDriverCapabilityInvoker? invoker = null, - TimeSpan? healthPollInterval = null) + TimeSpan? healthPollInterval = null, + TimeSpan? driftCheckInterval = null) { _driver = driver; _invoker = invoker ?? NullDriverCapabilityInvoker.Instance; _driverInstanceId = driver.DriverInstanceId; _clusterId = clusterId ?? string.Empty; _healthPublisher = healthPublisher ?? NullDriverHealthPublisher.Instance; + _driftCheckInterval = driftCheckInterval ?? DefaultDriftCheckInterval; _reconnectInterval = reconnectInterval; _healthPollInterval = healthPollInterval ?? HealthPollInterval; OtOpcUaTelemetry.DriverInstanceLifecycle.Add(1, @@ -350,6 +381,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers ResubscribeDesired(); AttachAlarmSource(); SubscribeDesiredAlarms(); + StartDriftCheck(); }); Receive(msg => { @@ -395,6 +427,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers _driverInstanceId, msg.Reason); DetachSubscription(); RecordFault(); + StopDriftCheck(); Become(Reconnecting); PublishHealthSnapshot(); }); @@ -402,6 +435,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers { _log.Info("DriverInstance {Id}: ForceReconnect requested by admin; re-entering Reconnecting", _driverInstanceId); DetachSubscription(); + StopDriftCheck(); Become(Reconnecting); PublishHealthSnapshot(); }); @@ -424,6 +458,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers SubscribeDesiredAlarms(); }); ReceiveAsync(HandleSubscribeAlarmsAsync); + ReceiveAsync(HandleDriftCheckAsync); Receive(OnDataChangeForward); // Native alarm transition marshaled onto the actor thread from the driver's OnAlarmEvent; // project it to the parent the same way DataChangeForward projects AttributeValuePublished. @@ -518,6 +553,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers ResubscribeDesired(); AttachAlarmSource(); SubscribeDesiredAlarms(); + StartDriftCheck(); }); // A failure here is a no-op regardless of generation — the retry timer keeps trying the // current config; only a (generation-matched) InitializeSucceeded transitions state. @@ -762,6 +798,10 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers } } + /// Stops the drift check. Called on every Connected exit — browsing a disconnected driver would + /// fail every pass, and a failed browse is deliberately NOT treated as drift. + private void StopDriftCheck() => Timers.Cancel("drift-check"); + /// Tear down the data-change + native-alarm event handlers + null the handle. Called from the /// Unsubscribe path, on PostStop, and on Connected → Reconnecting transitions so a stale handler doesn't /// push data-change / alarm events to an actor that has lost its driver connection. @@ -809,6 +849,119 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers _rediscoveryHandler = null; } + /// + /// True when this driver is worth drift-checking: it must genuinely BROWSE its remote + /// () and must report the refs its authored tags + /// bind (). + /// The first condition is what makes the check meaningful rather than reassuring. Most drivers + /// — Modbus, S7, AbLegacy, Sql, Mqtt — stream their AUTHORED tags back out of DiscoverAsync + /// rather than enumerating the device, so diffing the two sets for them is a tautology that would + /// report "no drift" forever. A check that cannot fail is worse than no check: it looks like + /// coverage. + /// + private bool QualifiesForDriftCheck() => + _driver is ITagDiscovery { SupportsOnlineDiscovery: true, AuthoredDiscoveryRefs: not null }; + + /// Starts the slow drift check on a Connected entry, for qualifying drivers only. + private void StartDriftCheck() + { + if (!QualifiesForDriftCheck()) return; + // Periodic, not fire-on-connect: a driver whose discovered shape fills in asynchronously after + // connect (the FOCAS FixedTree) would otherwise be compared against a half-populated browse and + // report phantom drift on every reconnect. + Timers.StartPeriodicTimer("drift-check", DriftCheckTick.Instance, _driftCheckInterval); + } + + /// + /// Re-browses the remote and compares it against the driver's authored refs. A CHANGED drift raises + /// the same operator prompt an raise does — this is the signal for the + /// browsable drivers that have no native change notification (AbCip, FOCAS), where a periodic + /// compare is the only way to notice a PLC program re-download. + /// Advisory, like every rediscovery signal: the served address space is not touched. Raw tags + /// are authored through the /raw browse-commit flow, so an operator re-browses and commits. + /// + private async Task HandleDriftCheckAsync(DriftCheckTick _) + { + if (_driver is not ITagDiscovery discovery) return; + var authored = discovery.AuthoredDiscoveryRefs; + if (authored is null) return; + + CapturedTree tree; + try + { + var builder = new CapturingAddressSpaceBuilder(); + // Bounded: ReceiveAsync suspends the mailbox for the whole handler, so an unbounded browse would + // block writes, reconnects and health polls behind it. + using var cts = new CancellationTokenSource(DriftBrowseTimeout); + await _invoker.ExecuteAsync( + DriverCapability.Discover, + _driverInstanceId, + async ct => await discovery.DiscoverAsync(builder, ct), + cts.Token); + tree = builder.Build(); + } + catch (Exception ex) + { + // A failed browse is not drift — the device may simply be unreachable, which the health surface + // already reports. Reporting drift here would turn every comms blip into "all your tags vanished". + _log.Debug(ex, "DriverInstance {Id}: drift check browse failed; skipping this pass", _driverInstanceId); + return; + } + + if (tree.Truncated) + { + // A truncated capture is a PARTIAL view, so every un-captured authored ref would look vanished. + _log.Warning( + "DriverInstance {Id}: drift check skipped — the browse hit the capture cap and is partial", + _driverInstanceId); + return; + } + + var discovered = new List(); + CollectLeafRefs(tree.Root, discovered); + var drift = TagSetDriftDetector.Compare( + discovered, authored, detectVanished: !discovery.DiscoveryStreamIncludesAuthoredTags); + + if (!drift.HasDrift) + { + // Recovered: the device matches the authored set again, so drop the prompt and let the next + // genuine drift re-raise with a fresh timestamp. + if (_lastDriftSignature is not null) + { + _log.Info("DriverInstance {Id}: tag-set drift cleared — device matches the authored set", + _driverInstanceId); + _lastDriftSignature = null; + _rediscoveryNeededUtc = null; + _rediscoveryReason = null; + PublishHealthSnapshot(); + } + + return; + } + + // Report a CHANGED drift once. An unchanged one re-stamping its timestamp every interval would read + // as "it just happened again" rather than "it is still true". + if (string.Equals(_lastDriftSignature, drift.Signature, StringComparison.Ordinal)) return; + _lastDriftSignature = drift.Signature; + _rediscoveryNeededUtc = DateTime.UtcNow; + _rediscoveryReason = "device tag set differs from authored: " + drift.Describe(); + _log.Info( + "DriverInstance {Id}: tag-set drift detected ({Drift}) — surfaced for operator re-browse; the served address space is unchanged", + _driverInstanceId, drift.Describe()); + PublishHealthSnapshot(); + } + + /// Per-pass timeout for the drift browse. Bounds the mailbox suspension. + private static readonly TimeSpan DriftBrowseTimeout = TimeSpan.FromSeconds(30); + + /// Flattens a captured tree to its leaf ids — the driver-side FullNames, which is the + /// vocabulary AuthoredDiscoveryRefs is defined in. + private static void CollectLeafRefs(CapturedNode node, List into) + { + if (node.IsLeaf) into.Add(node.Id); + foreach (var child in node.Children) CollectLeafRefs(child, into); + } + /// Records the driver's rediscovery raise and re-publishes health so the signal reaches the /// AdminUI promptly rather than waiting for the next 30 s heartbeat. /// Advisory only. The served address space is deliberately NOT rebuilt: v3 authors raw tags @@ -984,6 +1137,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers /// protected override void PostStop() { + StopDriftCheck(); DetachSubscription(); // MUST happen: the IDriver instance can outlive this actor (the host respawns a child around the // same driver object), so a missing unsubscribe accumulates a handler per respawn holding a dead Self. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/TagSetDrift.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/TagSetDrift.cs new file mode 100644 index 00000000..7a2e0015 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/TagSetDrift.cs @@ -0,0 +1,92 @@ +namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers; + +/// +/// The difference between what a driver discovers on its remote and what an operator +/// authored — i.e. whether the device's tag set has moved out from under the deployed config. +/// +/// Refs the remote now offers that no authored tag binds. +/// +/// Refs authored tags bind that the remote no longer offers. Always empty when +/// is false — see that parameter. +/// +/// +/// False when the driver re-emits its authored tags into the same discovery stream as the device-derived +/// ones (ITagDiscovery.DiscoveryStreamIncludesAuthoredTags). The discovered set then always +/// contains the authored set, so a vanished tag cannot be seen. Carried explicitly so +/// can say "new-on-device only" rather than implying a clean bill of health. +/// +public sealed record TagSetDrift( + IReadOnlyList Appeared, + IReadOnlyList Vanished, + bool VanishedDetectable) +{ + /// True when the two sets differ in either direction. + public bool HasDrift => Appeared.Count > 0 || Vanished.Count > 0; + + /// + /// A stable identity for this drift, used to report a CHANGED drift once rather than re-reporting an + /// unchanged one on every check. Ordinal-ordered so set enumeration order cannot make an identical + /// drift look new. + /// + public string Signature { get; } = + string.Join('', Appeared) + '' + string.Join('', Vanished); + + /// An operator-facing one-liner naming what moved, suitable for a UI tooltip. + /// A short human-readable description, or "no drift" when there is none. + public string Describe() + { + if (!HasDrift) return "no drift"; + var parts = new List(3); + if (Appeared.Count > 0) parts.Add($"{Appeared.Count} new on device (e.g. {Sample(Appeared)})"); + if (Vanished.Count > 0) parts.Add($"{Vanished.Count} authored missing (e.g. {Sample(Vanished)})"); + // Say so rather than let the absence of a "missing" clause read as "nothing is missing". + if (!VanishedDetectable) parts.Add("missing-tag detection unavailable for this driver"); + return string.Join("; ", parts); + } + + /// Names at most two refs so the message stays readable when a whole PLC program is swapped. + private static string Sample(IReadOnlyList refs) => + refs.Count <= 2 ? string.Join(", ", refs) : $"{refs[0]}, {refs[1]}, +{refs.Count - 2} more"; +} + +/// +/// Compares a driver's discovered refs against its authored refs. Pure and allocation-light so the +/// actor's periodic check stays cheap; kept out of DriverInstanceActor so it is directly testable +/// without an actor system. +/// +public static class TagSetDriftDetector +{ + /// + /// Computes the two-way difference. + /// Ordinal comparison, matching how every driver keys its tag tables — a device that + /// genuinely exposes both Speed and speed must not have them collapsed. + /// + /// Refs streamed by the driver's most recent discovery pass. + /// Refs the driver's authored tags bind (ITagDiscovery.AuthoredDiscoveryRefs). + /// + /// False when the driver re-emits authored tags into its discovery stream, which makes a vanished tag + /// structurally invisible. The vanished half is then not computed at all rather than computed to a + /// misleading empty — see . + /// + /// The drift, which may be empty. + public static TagSetDrift Compare( + IReadOnlyCollection discovered, + IReadOnlyCollection authored, + bool detectVanished = true) + { + ArgumentNullException.ThrowIfNull(discovered); + ArgumentNullException.ThrowIfNull(authored); + + var authoredSet = new HashSet(authored, StringComparer.Ordinal); + var discoveredSet = new HashSet(discovered, StringComparer.Ordinal); + + var appeared = discoveredSet.Where(r => !authoredSet.Contains(r)) + .OrderBy(r => r, StringComparer.Ordinal).ToArray(); + var vanished = detectVanished + ? authoredSet.Where(r => !discoveredSet.Contains(r)) + .OrderBy(r => r, StringComparer.Ordinal).ToArray() + : []; + + return new TagSetDrift(appeared, vanished, detectVanished); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDriftCheckTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDriftCheckTests.cs new file mode 100644 index 00000000..f49e25b6 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDriftCheckTests.cs @@ -0,0 +1,268 @@ +using Akka.Actor; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; +using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; + +/// +/// The tag-set drift check: periodically re-browse a genuinely-browsable driver and compare what the +/// remote offers against what an operator authored. +/// Why this is gated rather than run for every driver. Modbus, S7, AbLegacy, Sql and Mqtt +/// all stream their AUTHORED tags back out of DiscoverAsync rather than enumerating the device, so +/// diffing the two sets for them is a tautology that reports "no drift" forever. A check that cannot fail +/// is worse than no check, because it reads as coverage — so the actor runs this only for drivers that +/// declare SupportsOnlineDiscovery AND report AuthoredDiscoveryRefs. +/// The signal is advisory and shares the Stage-2 surfacing: it raises the same /hosts +/// re-browse prompt an raise does. The served address space is never +/// rebuilt — raw tags are authored through the /raw browse-commit flow. +/// +[Trait("Category", "Unit")] +public sealed class DriverInstanceActorDriftCheckTests : RuntimeActorTestBase +{ + private static readonly TimeSpan Tick = TimeSpan.FromMilliseconds(60); + private static readonly TimeSpan Budget = TimeSpan.FromSeconds(3); + + /// A device offering a tag nothing binds raises the operator prompt, naming what appeared. + [Fact] + public void A_new_ref_on_the_device_raises_the_re_browse_prompt() + { + var driver = new BrowsableStubDriver(discovered: ["a", "b", "c"], authored: ["a", "b"]); + var publisher = new RecordingHealthPublisher(); + var actor = SpawnConnected(driver, publisher); + + AwaitAssert( + () => + { + var signalled = publisher.Published.LastOrDefault(p => p.RediscoveryNeededUtc is not null); + signalled.ShouldNotBeNull(); + signalled!.RediscoveryReason.ShouldNotBeNull(); + signalled.RediscoveryReason!.ShouldContain("1 new on device"); + signalled.RediscoveryReason!.ShouldContain("c"); + }, + Budget); + + Sys.Stop(actor); + } + + /// + /// The gate that makes this feature honest. A driver whose DiscoverAsync replays + /// authored config rather than browsing the device declares SupportsOnlineDiscovery == false, + /// and must never be drift-checked — for it the comparison could only ever say "no drift". + /// Positive control: the driver is handed a set that WOULD read as drift, and the assertion is + /// that discovery is never even invoked. Asserting only "no prompt raised" would pass for the wrong + /// reason (e.g. if the timer never fired at all). + /// + [Fact] + public void A_driver_that_replays_authored_config_is_never_drift_checked() + { + var driver = new BrowsableStubDriver( + discovered: ["a", "b", "c"], authored: ["a"], supportsOnlineDiscovery: false); + var publisher = new RecordingHealthPublisher(); + var actor = SpawnConnected(driver, publisher); + + // Long enough for several ticks had the timer been scheduled. + ExpectNoMsg(TimeSpan.FromMilliseconds(400)); + + driver.DiscoverCount.ShouldBe( + 0, + "a driver that replays authored config was browsed for drift — the comparison is a tautology " + + "for it and would report 'no drift' forever, which reads as coverage"); + publisher.Published.ShouldAllBe(p => p.RediscoveryNeededUtc == null); + + Sys.Stop(actor); + } + + /// A driver that browses but does NOT report its authored refs opts out — the default. An empty + /// collection would mean "nothing authored, everything is new", so null must not be read that way. + [Fact] + public void A_driver_that_reports_no_authored_refs_is_never_drift_checked() + { + var driver = new BrowsableStubDriver(discovered: ["a", "b"], authored: null); + var publisher = new RecordingHealthPublisher(); + var actor = SpawnConnected(driver, publisher); + + ExpectNoMsg(TimeSpan.FromMilliseconds(400)); + + driver.DiscoverCount.ShouldBe(0); + publisher.Published.ShouldAllBe(p => p.RediscoveryNeededUtc == null); + + Sys.Stop(actor); + } + + /// + /// An UNCHANGED drift is reported once, not re-stamped on every check — a prompt whose timestamp + /// keeps moving reads as "it just happened again" rather than "it is still true". + /// + [Fact] + public void An_unchanged_drift_is_reported_once() + { + var driver = new BrowsableStubDriver(discovered: ["a", "b"], authored: ["a"]); + var publisher = new RecordingHealthPublisher(); + var actor = SpawnConnected(driver, publisher); + + AwaitAssert( + () => publisher.Published.Count(p => p.RediscoveryNeededUtc is not null).ShouldBe(1), + Budget); + + // Several more ticks pass with the same drift. + AwaitAssert(() => driver.DiscoverCount.ShouldBeGreaterThan(3), Budget); + + publisher.Published.Count(p => p.RediscoveryNeededUtc is not null).ShouldBe( + 1, + "an unchanged drift was re-announced; the operator prompt would keep re-stamping its timestamp"); + + Sys.Stop(actor); + } + + /// A failed browse is not drift. A device that is merely unreachable already shows on the health + /// surface; reporting drift would turn every comms blip into "all your tags vanished". + [Fact] + public void A_failed_browse_is_not_reported_as_drift() + { + var driver = new BrowsableStubDriver(discovered: ["a"], authored: ["a", "b"]) { DiscoverThrows = true }; + var publisher = new RecordingHealthPublisher(); + var actor = SpawnConnected(driver, publisher); + + AwaitAssert(() => driver.DiscoverCount.ShouldBeGreaterThan(1), Budget); + + publisher.Published.ShouldAllBe( + p => p.RediscoveryNeededUtc == null, + "a browse that threw was treated as drift — an unreachable device would report every authored " + + "tag as vanished"); + + Sys.Stop(actor); + } + + /// Drift that resolves — the operator re-browsed and committed — clears the prompt, so the next + /// genuine drift re-raises with a fresh timestamp instead of being deduped against the stale one. + [Fact] + public void Drift_that_resolves_clears_the_prompt() + { + var driver = new BrowsableStubDriver(discovered: ["a", "b"], authored: ["a"]); + var publisher = new RecordingHealthPublisher(); + var actor = SpawnConnected(driver, publisher); + + AwaitAssert( + () => publisher.Published.Any(p => p.RediscoveryNeededUtc is not null), + Budget); + + // The operator commits the new tag: authored now matches the device. + driver.SetAuthored(["a", "b"]); + + AwaitAssert( + () => publisher.Published[^1].RediscoveryNeededUtc.ShouldBeNull(), + Budget); + + Sys.Stop(actor); + } + + private IActorRef SpawnConnected(IDriver driver, IDriverHealthPublisher publisher) + { + var parent = CreateTestProbe(); + parent.IgnoreMessages(_ => true); + var actor = parent.ChildActorOf(DriverInstanceActor.Props( + driver, healthPublisher: publisher, driftCheckInterval: Tick)); + actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); + return actor; + } + + /// Captured health publishes, so a test can read the rediscovery fields. + private sealed record HealthPublish(DateTime? RediscoveryNeededUtc, string? RediscoveryReason); + + private sealed class RecordingHealthPublisher : IDriverHealthPublisher + { + private readonly List _published = []; + + public IReadOnlyList Published + { + get { lock (_published) return _published.ToArray(); } + } + + /// + public void Publish( + string clusterId, string driverInstanceId, DriverHealth health, int errorCount5Min, + DateTime? rediscoveryNeededUtc = null, string? rediscoveryReason = null) + { + lock (_published) _published.Add(new HealthPublish(rediscoveryNeededUtc, rediscoveryReason)); + } + } + + /// + /// A driver that browses a fake "device". Health is STABLE so the publish dedup genuinely engages — + /// the shared StubDriver returns DateTime.UtcNow, which would make every publish look + /// changed and let a dedup-related bug pass unnoticed. + /// + private sealed class BrowsableStubDriver( + string[] discovered, + string[]? authored, + bool supportsOnlineDiscovery = true) : IDriver, ITagDiscovery + { + private static readonly DateTime FixedLastRead = new(2026, 7, 28, 9, 0, 0, DateTimeKind.Utc); + private volatile string[]? _authored = authored; + private int _discoverCount; + + /// Number of discovery passes the actor has driven — the direct read of whether the drift + /// check ran at all. + public int DiscoverCount => Volatile.Read(ref _discoverCount); + + /// When true, every browse throws — models an unreachable device. + public bool DiscoverThrows { get; init; } + + /// + public string DriverInstanceId => "browsable-stub-1"; + + /// + public string DriverType => "Stub"; + + /// + public bool SupportsOnlineDiscovery => supportsOnlineDiscovery; + + /// + public IReadOnlyCollection? AuthoredDiscoveryRefs => _authored; + + /// Models the operator committing (or removing) tags between checks. + public void SetAuthored(string[] refs) => _authored = refs; + + /// + public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) + { + Interlocked.Increment(ref _discoverCount); + if (DiscoverThrows) throw new IOException("device unreachable"); + + var folder = builder.Folder("Stub", "Stub"); + foreach (var r in discovered) + { + folder.Variable(r, r, new DriverAttributeInfo( + FullName: r, + DriverDataType: DriverDataType.Float64, + IsArray: false, + ArrayDim: null, + SecurityClass: SecurityClassification.ViewOnly, + IsHistorized: false)); + } + + return Task.CompletedTask; + } + + /// + public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask; + + /// + public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask; + + /// + public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + /// + public DriverHealth GetHealth() => new(DriverState.Healthy, FixedLastRead, null); + + /// + public long GetMemoryFootprint() => 0; + + /// + public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/TagSetDriftDetectorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/TagSetDriftDetectorTests.cs new file mode 100644 index 00000000..b48f8d22 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/TagSetDriftDetectorTests.cs @@ -0,0 +1,113 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; + +/// +/// The pure half of tag-set drift detection: what the remote offers vs. what an operator authored. +/// Kept out of the actor so the comparison rules are testable without an actor system. +/// +[Trait("Category", "Unit")] +public sealed class TagSetDriftDetectorTests +{ + /// Matching sets are not drift, and must not raise an operator prompt. + [Fact] + public void Identical_sets_are_not_drift() + { + var drift = TagSetDriftDetector.Compare(["a", "b"], ["b", "a"]); + + drift.HasDrift.ShouldBeFalse(); + drift.Describe().ShouldBe("no drift"); + } + + /// A tag the device now offers that nothing binds — the common case after a PLC re-download. + [Fact] + public void A_ref_on_the_device_that_is_not_authored_is_reported_as_appeared() + { + var drift = TagSetDriftDetector.Compare(["a", "b", "c"], ["a", "b"]); + + drift.Appeared.ShouldBe(["c"]); + drift.Vanished.ShouldBeEmpty(); + drift.HasDrift.ShouldBeTrue(); + } + + /// An authored tag the device no longer offers — detectable only for a driver whose discovery + /// stream is purely device-derived. + [Fact] + public void An_authored_ref_missing_from_the_device_is_reported_as_vanished() + { + var drift = TagSetDriftDetector.Compare(["a"], ["a", "b"]); + + drift.Vanished.ShouldBe(["b"]); + drift.Appeared.ShouldBeEmpty(); + } + + /// + /// The honesty guard. When a driver re-emits its authored tags into the discovery stream — + /// FOCAS, TwinCAT and AbCip all do — the discovered set always contains the authored set, so a + /// vanished tag is structurally invisible. The detector must not compute a misleadingly-empty + /// Vanished list; it must flag that the direction is undetectable, and say so in the description. + /// + [Fact] + public void When_vanished_is_undetectable_it_is_declared_rather_than_reported_as_empty() + { + // "b" is authored and absent from the device — but this driver would have re-emitted it, so its + // absence here cannot occur in practice and must not be presented as a clean result either way. + var drift = TagSetDriftDetector.Compare(["a", "c"], ["a", "b"], detectVanished: false); + + drift.VanishedDetectable.ShouldBeFalse(); + drift.Vanished.ShouldBeEmpty(); + drift.Appeared.ShouldBe(["c"]); + drift.Describe().ShouldContain( + "missing-tag detection unavailable", + customMessage: + "a driver that cannot see vanished tags must SAY so — otherwise the absence of a 'missing' " + + "clause reads as 'nothing is missing', which is the reassurance this whole exercise removes"); + } + + /// Comparison is ordinal: a device legitimately exposing both Speed and speed + /// must not have them collapsed, which would hide a genuinely new tag. + [Fact] + public void Comparison_is_case_sensitive() + { + var drift = TagSetDriftDetector.Compare(["Speed", "speed"], ["Speed"]); + + drift.Appeared.ShouldBe(["speed"]); + } + + /// The signature identifies a drift so an UNCHANGED one is not re-announced every check. + /// Enumeration order must not make an identical drift look new. + [Fact] + public void Signature_is_stable_across_enumeration_order() + { + var a = TagSetDriftDetector.Compare(["x", "y", "z"], ["x"]); + var b = TagSetDriftDetector.Compare(["z", "y", "x"], ["x"]); + + a.Signature.ShouldBe(b.Signature); + } + + /// A different drift must get a different signature, or the second one is never reported. + [Fact] + public void Signature_changes_when_the_drift_changes() + { + var a = TagSetDriftDetector.Compare(["x", "y"], ["x"]); + var b = TagSetDriftDetector.Compare(["x", "y", "z"], ["x"]); + + a.Signature.ShouldNotBe(b.Signature); + } + + /// The description names a couple of refs and then counts, so swapping a whole PLC program + /// yields a readable message rather than a wall of tag names. + [Fact] + public void Describe_samples_rather_than_listing_everything() + { + var discovered = Enumerable.Range(0, 50).Select(i => $"tag{i:D2}").ToArray(); + var drift = TagSetDriftDetector.Compare(discovered, []); + + var text = drift.Describe(); + text.ShouldContain("50 new on device"); + text.ShouldContain("+48 more"); + text.Length.ShouldBeLessThan(120); + } +}