diff --git a/CLAUDE.md b/CLAUDE.md
index 892546f2..5465302f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -155,17 +155,69 @@ 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.
+
+**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
+confirm intent before deleting). The separable `OnHostStatusChanged` event is the useful half. Tracked
+as Gitea **#521**.
## mxaccessgw
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 b10efb62..c3e619af 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.
---
@@ -342,17 +345,320 @@ 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.
-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
- 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
- (§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.
+ `docs/ReadWriteOperations.md` first; it is the one that could mislead a security review.~~
+ ✅ **Done** — decided *make non-enforcement explicit*; wire-up tracked as Gitea **#520**. See §9.
+ (`docs/ReadWriteOperations.md` was **not** the sharpest one — `docs/security.md` was.)
+2. ~~**#518 + #507 together** — neither fix is observable alone.~~ ✅ **Done.** The framing was right
+ that they had to be handled together, but wrong about the resolution: **#507 was not revivable**,
+ so it was deleted rather than fixed. See §9.
+3. ~~**#516** — silent config discard on 5 drivers; then re-scope #489, which it partly subsumes.~~
+ ✅ **Done.** See §9. #489 is still open and should now be re-scoped.
+4. ~~**G-4** — extend the existing picker-parity test to `DriverConfigModal` + `DeviceModal`; it would
+ have caught G-1 and G-2 for free, and this class has now recurred twice.~~ ✅ **Done**, together with
+ G-1, G-2, G-3, G-5 and G-6. See §9.
+5. ~~**Bookkeeping sweep** — mark the 8 stale `.tasks.json` files complete so the 19-task AdminUI plan
+ (§6.2) is visible as the real backlog it is.~~ ✅ **Done.** See §9.
+6. ~~**§3.3 tier truth** — either pass real tiers at factory registration or delete the Tier-C
+ machinery; today it is documented, authorable and dormant.~~ ✅ **Documented**; the keep-or-delete
+ code decision is Gitea **#522**. See §9.
+
+---
+
+## 9. Execution log
+
+Remediation of §8, 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 | ✅ **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 | ✅ **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
+
+Decision: **do not wire the evaluator, do not delete it** — state plainly that it does not run, and
+track the wire-up as **Gitea #520**.
+
+Applied:
+
+- `docs/security.md` — the `NodeScope`/`PermissionTrie`/"Dispatch gate" block now leads with a
+ **What is enforced today** table (the three real checks) before the design material, which is
+ labelled *Designed but not wired*.
+- `docs/ReadWriteOperations.md` — **rewritten**, not bannered.
+- `docs/v2/acl-design.md` — ⚠️ NEVER WIRED banner.
+- `docs/v2/v2-release-readiness.md` §"Security — Phase 6.2 dispatch wiring" — the CLOSED claim marked
+ as not holding.
+- `ClusterAcls.razor` + `AclEdit.razor` — warning alerts stating rules are stored and deployed but
+ never evaluated.
+
+**Four things this pass found that §7 and the original audit missed** — all worse than what was
+already recorded:
+
+1. **`docs/security.md` was the highest-risk doc, not `ReadWriteOperations.md`.** `:115` claimed an
+ anonymous session is "default-denie[d] any node a session has no ACL grant for". The opposite is
+ true: an anonymous session can Browse, Read, Subscribe and HistoryRead the **entire** address
+ space. It is refused only writes and alarm acks — and only because it carries no roles.
+2. **Three of the five documented data-plane role strings do nothing.** `OpcUaDataPlaneRoles` declares
+ exactly `WriteOperate` and `AlarmAck`. `ReadOnly`, `WriteTune` and `WriteConfigure` are compared
+ against nowhere in `src/`, so mapping a group to `WriteTune` grants nothing and mapping one to
+ `ReadOnly` restricts nothing. The doc called all five "exact, case-insensitive, and **code-true**".
+3. **`ReadWriteOperations.md` was fiction well beyond the ACL claims.** `OnReadValue` has **zero**
+ occurrences in `src/` — the whole documented read path did not exist. Reads never reach a driver at
+ all: the node manager is push-model and the SDK serves a client Read from the cached pushed value.
+ `WriteAuthzPolicy`, `_sourceByFullRef`, `_writeIdempotentByFullRef` and `IRoleBearer` are likewise
+ zero-hit, and `CapabilityInvoker` is not referenced by the `OpcUaServer` project at all.
+4. **`v2-release-readiness.md` claimed a whole enforcement layer shipped.** Beyond the `:41` row §7.1
+ already flagged, `:46-50` describe `FilterBrowseReferences`, `GateCallMethodRequests`,
+ `MapCallOperation`, `AuthorizationBootstrap` and `Node:Authorization:*` config keys as Closed or
+ Partial. None of them exist. Whatever landed on the v2 branch did not survive into the shipped tree.
+
+Also recorded in #520 and not previously known: every existing evaluator unit test runs in
+`PermissionTrieBuilder`'s no-`scopePaths` *deterministic test mode*, so the production hierarchy path
+is effectively untested; and `NodeAcl.ScopeId` has no FK or existence check, so scope ids dangle
+silently.
+
+### 2026-07-27 — §8.2 rediscovery as a signal; injection deleted
+
+Decision: **signal, not mutation.**
+
+`DriverInstanceActor` now consumes `IRediscoverable.OnRediscoveryNeeded` (attach in `PreStart`, detach
+in `PostStop`) and carries it on the driver-health snapshot to `/hosts` as a **re-browse chip**. It is
+advisory — the served address space is unchanged, because v3 authors raw tags through `/raw`
+browse-commit and a runtime graft would materialise nodes nobody approved.
+
+**#507's injection path was deleted, not fixed.** §2 recorded it as a "deferred (deliberate guard)".
+That was too generous: its two inputs — `EquipmentNode.DriverInstanceId` and `EquipmentTags` — are
+*structurally empty* in v3, so removing the guard would have changed a log line and injected nothing.
+Deleted with it: `HandleDiscoveredNodes`, `PartitionDiscoveredByDeviceHost`,
+`ApplyDiscoveredPlansForDriver`, the redeploy re-inject tail, `DiscoveredNodeMapper`,
+`AddressSpaceApplier.MaterialiseDiscoveredNodes`, `OpcUaPublishActor.MaterialiseDiscoveredNodes`, the
+connect-time discovery loop, and 4 files' worth of tests. `ITagDiscovery` stays — the `/raw` browse
+picker drives it through `Commons/Browsing/DiscoveryDriverBrowser`, which never went through the actor.
+
+**§4.4's "18 `DiscoveryInjectionDormantV3` — Real, blocked on #507" was wrong.** They were v2
+characterization tests asserting an equipment-rooted graft (`EquipmentRootNodeId == "EQ-1"`) that v3
+cannot produce. They could never have unskipped as written; they are deleted. Skipped tests in
+`Runtime.Tests` went 31 → 13.
+
+**The planned drift detector was deliberately NOT built.** The plan proposed diffing each connect-time
+discovery pass against the authored raw tags. Reading the drivers killed it: **Modbus, S7, MQTT,
+AbLegacy and Sql all echo authored config from `DiscoverAsync`** rather than browsing the device, so
+the diff is permanently empty for them. It would have been another plausible-looking inert seam —
+precisely the class this register exists to remove. That also removed the last consumer of the
+connect-time loop, which is why the loop went too: it was browsing real devices up to ~15× per connect
+and dropping the result.
+
+Two traps worth keeping:
+
+- `PublishHealthSnapshot` dedups on a health fingerprint, and a rediscovery raise changes none of the
+ four fields it hashed. The timestamp had to join the tuple or the dedup swallows the signal.
+ **Verified by reverting the one line — 3 of the 5 new tests go red.**
+- The shared `StubDriver.GetHealth()` returns `DateTime.UtcNow`, so its fingerprint differs every call
+ and the dedup never engages. A dedup test built on it would pass whether or not the fix is present;
+ the new tests use a stub with stable health.
+
+`IHostConnectivityProbe` was **not** resolved — it is a keep-or-delete decision, and the
+`DriverHostStatus` table was re-created deliberately in the v3 initial migration, so deleting on
+inference would be wrong. Split to Gitea **#521** with both options costed.
+
+### 2026-07-27 — §8.5 bookkeeping + §8.6 tier truth
+
+**13 plan files closed.** The 8 the register named, plus `mesh-phase4` (Task 9 landed `3a590a0c`), the
+4th `stillpending` phase file, and `historian-tcp-transport` closed as **OBSOLETE** (it targets the
+retired Wonderware sidecar; there is no Wonderware backend in the tree). Each carries a `closureNote`
+naming the evidence, so the next reader can check rather than trust.
+
+**11 archreview live gates written back** from `STATUS.md`, which had recorded them passed since
+2026-07-13/15 without ever updating the `.tasks.json` files: R2-01 #11, R2-02 #15/#18, R2-05 T15,
+R2-06 T12, R2-07 T5/T6/T12/T14, R2-11 T22/T24.
+
+**R2-03 and R2-10 were NOT closed** — and reading `STATUS.md` rather than trusting its own headline
+("Every Round-2 live gate is now GREEN") is what kept that honest. Its per-item detail says R2-03 is
+"live-blocked on this data-less rig" and R2-10 verified the pipeline but "breaker-OPEN state not
+forced". Both now carry an `openNote` recording the blocker: R2-03 needs a reachable driver fixture
+feeding a VT; R2-10 needs a rapid idempotent write to a dead endpoint, because R2-09's
+`ConnectionBackoff` throttles retries so failures never accumulate fast enough to open the Polly breaker.
+
+**Net effect (the point of the sweep):** ~140 phantom pending tasks are gone, so the genuinely
+unexecuted **19-task AdminUI follow-ups plan** is now the largest open item in `docs/plans/` rather than
+being buried. The remaining open plans are 8, and one of those (`mesh-phase6` Task 6) is a documented
+`resolved-no-flip` decision rather than work.
+
+**Tier truth:** `docs/v2/driver-stability.md` now states plainly that its tier table is aspirational —
+every driver runs Tier A, and `MemoryRecycle` / `ScheduledRecycleScheduler` have never engaged. It also
+corrects a second stale claim the register did not flag: the **separate-Windows-service hosting** it
+describes for Tier C no longer exists (Galaxy went to the mxaccessgw sidecar in PR 7.2; FOCAS went
+in-process when its managed wire client landed). No runtime change — turning recycle on for two live
+drivers deserves its own live gate, not a docs-pass side effect. Tracked as Gitea **#522**.
+
+### 2026-07-28 — the two things §8.3 deliberately did NOT build (1 of 2)
+
+**Sql and FOCAS now re-parse in place too.** The first pass excluded them for a real reason — each derives
+more than options from config, so adopting new options alone would run a **new tag set against an old
+connection/backend**, and a half-applied change is worse than a discarded one because it looks like it
+worked. Rather than accept that, the blocker itself is fixed: each factory now exposes a **`ParseBinding`**
+that returns *every* config-derived dependency as one value, and the driver adopts them **atomically**.
+
+| Driver | What one parse now yields | Adopted by |
+|---|---|---|
+| Sql | options + `ISqlDialect` + resolved connection string | `ApplyBinding`, which also rebuilds the provider factory, `Endpoint` and the `SqlPollReader` that captures all three |
+| FOCAS | options + the `IFocasClientFactory` the `Backend` key selects | one assignment pair in `InitializeAsync` |
+
+Details worth keeping:
+
+- **Sql adopts BEFORE `BuildTagTable`**, so the tag table and the connection it will be polled over always
+ come from the same revision.
+- **A test-injected `DbProviderFactory` survives a rebind** (`_explicitFactory`), so a re-derived dialect
+ cannot silently displace what a test passed in.
+- **Re-resolving the connection string on reinit is a side benefit**: a rotated credential is picked up
+ without a process restart.
+- A driver constructed **directly** gets no rebinder and keeps its constructor-supplied binding — every
+ existing `"{}"`-passing lifecycle test is unaffected by construction, not by luck.
+
+**The tests pin ATOMICITY, not merely "a re-parse happened"** — a test that only checked options would have
+passed against the broken version. Verified by simulating the *half-fix* (adopt options, skip the backend):
+2 of the 3 FOCAS tests go red. Reverting Sql's rebind turns its connection-string test red. Sql also asserts
+that a reinit which **cannot resolve** its new connection leaves the previous binding whole, rather than
+half-adopting.
+
+All 12 drivers now honour a changed config in place; `DriverSpawnPlanner`'s stop + respawn remains the outer
+guarantee. Sql.Tests 226 / FOCAS.Tests 275 pass.
+
+### 2026-07-28 — the two things §8.3 deliberately did NOT build (2 of 2)
+
+**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
+`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
+"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).
---
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/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/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/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/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`
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/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/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/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/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.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..72b28f91 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);
@@ -100,12 +125,37 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
///
public string DriverType => "FOCAS";
- ///
+ ///
+ /// Opens the configured CNC handles and builds the authored tag table.
+ /// 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.
+ /// A completed task.
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
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.
@@ -467,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.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.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.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.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/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs
index e99c3355..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,9 +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, exactly as ModbusDriver does
- /// (config parsing belongs to the factory, which builds a fresh instance).
+ /// 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
@@ -235,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/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.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
{
+
+ This grant will not be enforced.
+ It is saved and shipped in every deployment artifact, but the OPC UA server never evaluates
+ ACL rows — the permission evaluator has no production call site. Nothing you set below will
+ change what a client can read, write, browse or acknowledge.
+
+ Real access control today is fleet-wide LDAP-group → role mapping
+ (Security:Ldap:GroupToRole):
+ WriteOperate to write any tag,
+ AlarmAck to acknowledge any alarm. Reads are ungated.
+ See docs/security.md.
+
+ These rules are not enforced.
+ ACL rows are saved and shipped in every deployment artifact, but the OPC UA server never
+ evaluates them — the permission evaluator has no production call site. Authoring a grant here
+ changes nothing about what a client can read or write.
+
+ What is enforced today is coarse and fleet-wide: a client needs the
+ WriteOperate role to write any tag and
+ AlarmAck to acknowledge any alarm, both mapped from LDAP groups via
+ Security:Ldap:GroupToRole. Reads, browses, subscriptions and history
+ reads are not restricted at all. See docs/security.md.
+
+
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
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/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.
+ 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/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.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/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.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/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/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..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,37 +244,15 @@ 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
- /// ). 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,8 +872,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
Receive(ForwardToMux);
Receive(ForwardNativeAlarm);
Receive(OnDriverConnectivityChanged);
- Receive(HandleDiscoveredNodes);
Receive(HandleDeltaApplied);
+ Receive(HandleApplyResult);
Receive(HandleRestartDriver);
Receive(HandleReconnectDriver);
Receive(HandleRouteNodeWrite);
@@ -928,8 +906,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
Receive(ForwardToMux);
Receive(ForwardNativeAlarm);
Receive(OnDriverConnectivityChanged);
- Receive(HandleDiscoveredNodes);
Receive(HandleDeltaApplied);
+ Receive(HandleApplyResult);
Receive(HandleRestartDriver);
Receive(HandleReconnectDriver);
Receive(HandleRouteNodeWrite);
@@ -973,349 +951,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,16 +1354,13 @@ 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(_ => { });
// 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);
}
@@ -2316,15 +1948,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 +1964,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 +2000,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)
@@ -2479,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)
@@ -2559,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/DriverInstanceActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs
index c0ca7f97..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,15 +33,10 @@ 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);
+ /// 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);
@@ -124,25 +120,22 @@ 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);
+ /// 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);
- ///
- /// 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 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() { }
+ }
- /// 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();
@@ -167,19 +160,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;
@@ -203,6 +185,24 @@ 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;
+
+ /// 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.
@@ -235,11 +235,10 @@ 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 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.
@@ -250,22 +249,18 @@ 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) =>
+ TimeSpan? healthPollInterval = null,
+ TimeSpan? driftCheckInterval = null) =>
Akka.Actor.Props.Create(() => new DriverInstanceActor(
driver,
reconnectInterval ?? DefaultReconnectInterval,
startStubbed,
healthPublisher ?? NullDriverHealthPublisher.Instance,
clusterId ?? string.Empty,
- rediscoverInterval,
- rediscoverMaxAttempts,
- rediscoverDiscoverTimeout,
invoker,
- healthPollInterval));
+ healthPollInterval,
+ driftCheckInterval));
///
/// Returns true when the driver should boot in DEV-STUB mode based on host platform and
@@ -294,11 +289,10 @@ 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 tag-set drift check; defaults to
+ /// .
/// Period of the health-poll heartbeat, which also drives the
/// subscription reconcile; defaults to when null.
public DriverInstanceActor(
@@ -307,21 +301,17 @@ 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)
+ 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;
- _rediscoverInterval = rediscoverInterval ?? DefaultRediscoverInterval;
- _rediscoverMaxAttempts = rediscoverMaxAttempts;
- _rediscoverDiscoverTimeout = rediscoverDiscoverTimeout ?? DefaultRediscoverDiscoverTimeout;
_healthPollInterval = healthPollInterval ?? HealthPollInterval;
OtOpcUaTelemetry.DriverInstanceLifecycle.Add(1,
new KeyValuePair("event", startStubbed ? "spawn_stub" : "spawn"),
@@ -344,6 +334,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);
}
@@ -362,9 +355,7 @@ 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());
}
@@ -390,7 +381,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
ResubscribeDesired();
AttachAlarmSource();
SubscribeDesiredAlarms();
- StartDiscovery();
+ StartDriftCheck();
});
Receive(msg =>
{
@@ -418,12 +409,7 @@ 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());
}
@@ -439,34 +425,20 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
{
_log.Warning("DriverInstance {Id}: disconnect observed ({Reason}); reconnecting",
_driverInstanceId, msg.Reason);
- Timers.Cancel("rediscover");
DetachSubscription();
RecordFault();
+ StopDriftCheck();
Become(Reconnecting);
PublishHealthSnapshot();
});
Receive(_ =>
{
_log.Info("DriverInstance {Id}: ForceReconnect requested by admin; re-entering Reconnecting", _driverInstanceId);
- Timers.Cancel("rediscover");
DetachSubscription();
+ StopDriftCheck();
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);
@@ -486,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.
@@ -499,6 +472,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();
@@ -579,7 +553,7 @@ 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
+ 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.
@@ -602,12 +576,7 @@ 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);
}
@@ -829,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.
@@ -853,6 +826,157 @@ 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;
+ }
+
+ ///
+ /// 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
+ /// 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()
@@ -909,97 +1033,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.
@@ -1081,11 +1114,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 +1132,16 @@ 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()
{
+ 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.
+ 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/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/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/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/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.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.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/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);
+ }
+}
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))]
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