diff --git a/deferment.md b/deferment.md
index 843640ee..b703e827 100644
--- a/deferment.md
+++ b/deferment.md
@@ -47,8 +47,8 @@ themes into tracked decisions. What remains:
| **#521** `IHostConnectivityProbe` / `DriverHostStatus` dead surface | Decision: build the publisher or delete it. The table was deliberately re-created in the v3 initial migration, so deleting on inference would be wrong. | §3.2 |
| **#522** driver stability tiers inert | Decision: pass real tiers or delete the machinery. Docs now say every driver runs Tier A. | §3.3 |
| ~~**#523** remove the periodic 5-min device re-browse~~ | ✅ **Done** — removed. **AbCip and FOCAS now have no tag-change signal at all**; that is the accepted consequence, not an oversight. | §9 (2026-07-28, #523) |
-| **G-7** `EquipmentTagConfigInspector` covers 6 of 12 driver types | Pre-existing gap, not a regression; the only §1.2 gap left. | §1.2 |
-| **13 `EquipmentTagsDarkBatch4` skipped tests** | Still stale — their unblock condition already happened and the path was retired instead. | §4.4 |
+| **G-7** `EquipmentTagConfigInspector` coverage | ◐ **Narrowed + guarded** (2026-07-28): MQTT wired (it shipped an `Inspect` nobody called), and a reflection guard now fails the build if a reachable driver ships one and is not mapped. **Residual:** Sql / MTConnect / Calculation / OpcUaClient have no Contracts-side `Inspect` at all, so the deploy gate stays blind for them while the AdminUI validates them — see §1.2. | §1.2 |
+| ~~**13 `EquipmentTagsDarkBatch4` skipped tests**~~ | ✅ **Done** — 6 revived onto the raw/UNS shape, 3 folded into a widened live parity corpus, 4 deleted as retired. The reason constant is gone. **Skips now: `Runtime.Tests` 13 → 3, `OpcUaServer.Tests` 4 → 1** — all remaining are deliberate `EquipmentDeviceBindingRetired` tombstones. | §9 (2026-07-28, skips) |
| 17 other tracker issues | Driver features + deferred work, unchanged by this remediation. | §2 |
| Open live gates | #491 continuous historization, #468 browser tree, archreview R2-03 / R2-10. *(The un-gated drift detector's gate closed by deletion — see #523.)* | §5 |
@@ -99,7 +99,7 @@ Every gap below is in the **authoring/dispatch layer, not driver runtime code**.
| G-4 | **No parity test guards `DriverConfigModal` or `DeviceModal`** — `RawDriverTypeDialogCoverageTests`/`…ParityTests` assert `DriverTypeNames` ↔ **picker** parity only | — | **This is exactly why G-1 and G-2 survived review.** The guard pattern exists and simply was not extended to the two downstream dispatch maps. | ✅ Fixed — `DriverFormMapParityTests` + `DriverDispatchMapParityTests` |
| G-5 | **`CsvColumnMap` has no typed columns for Sql, MQTT, MTConnect** | `CsvColumnMap.cs:340-452` | CSV import/export degrades to the raw `TagConfigJson` fallback while the 7 older drivers get typed columns. | ✅ Fixed — CSV maps for Sql/Mqtt/MTConnect |
| G-6 | **`RawBrowseCommitMapper` has no Sql branch** — falls to the generic `WriteSingleKey("address", …)` whose comment claims "Browsable drivers are all handled above" | `RawBrowseCommitMapper.cs:121-141`, fallback `:145` | Untrue since `SqlDriverBrowser` was registered (`EndpointRouteBuilderExtensions.cs:83`). Same failure mode the MTConnect branch (`:135-139`) was added to avoid: typed editor opens empty, blanks the field on save. | ✅ Fixed — **and at the browser end too**, which the audit missed |
-| G-7 | **`EquipmentTagConfigInspector` covers only 6 driver types** | `:24-29` | Pre-existing (OpcUaClient + Galaxy absent too); no new driver was added. | ⏳ **Open** — pre-existing, unchanged |
+| G-7 | **`EquipmentTagConfigInspector` covers only 6 driver types** | `:24-29` | Pre-existing (OpcUaClient + Galaxy absent too); no new driver was added. **The audit understated it:** the AdminUI's `TagConfigValidator` covers **11 of 12**, so the *deploy* gate is weaker than the *authoring* gate — and config arriving by CSV import or API only meets the deploy gate. | ◐ **Narrowed + guarded** — MQTT wired, reflection guard added; 4 drivers still have no Contracts-side `Inspect`. See §9 |
### 1.3 Stale in-code comments (harmless at runtime, misleading to the next reader)
@@ -283,8 +283,8 @@ commented-out DI registrations.** Real deferred work lives in prose doc-comments
| Count | Reason | Assessment |
|---|---|---|
| ~~18~~ 0 | ~~`DiscoveryInjectionDormantV3`~~ | ✅ **Deleted** (`adce27e7`). The audit's "Real — blocked on #507" was **wrong**: these were v2 characterization tests asserting an equipment-rooted graft (`EquipmentRootNodeId == "EQ-1"`) that v3 cannot produce, so they could never have unskipped as written. `Runtime.Tests` skipped went 31 → 13. |
-| 13 | `EquipmentTagsDarkBatch4` | ⏳ **Still open.** **STALE** — the reason says these unblock "at Batch 4"; Batch 4 shipped as v3.0 and the path was *retired* (`AddressSpaceApplier.cs:415` calls it "the vestigial equipment-tag path"). These will never unskip as written — delete or rewrite the reason. |
-| 4 + 1 | `EquipmentDeviceBindingRetired`, `DarkBatch4` | Intentional tombstones preserving retirement rationale — fine as-is |
+| ~~13~~ 0 | ~~`EquipmentTagsDarkBatch4`~~ | ✅ **Resolved** (2026-07-28). The audit's "STALE" call was right and the constant is now **deleted**. Not by bulk-unskipping: 6 were **revived** onto the raw/UNS shape that replaced the subject, 3 were **folded** into a live parity test whose corpus was widened to cover them, 4 were **deleted** as genuinely retired. See §9. |
+| 4 | `EquipmentDeviceBindingRetired` | Intentional tombstones preserving retirement rationale — fine as-is |
Plus **4 whole OpcUaClient test suites that are scaffold-only**, awaiting an in-process OPC UA server
fixture that was never built (`OpcUaClientSubscribeAndProbeTests`, `…DiscoveryTests`,
@@ -712,6 +712,135 @@ Build clean; `Runtime.Tests` 476 passed / 13 skipped (unchanged skip set — the
`EquipmentTagsDarkBatch4` group), FOCAS 275, TwinCAT 192, MTConnect 491, AbCip 342,
`Core.Abstractions` 266, all green.
+### 2026-07-28 — three silently-broken OpcUaClient integration tests (found by the full-suite run)
+
+Not a register item — found because the full-solution run for the work above showed **three** failing
+suites where §9's 2026-07-27 entry had recorded two. `Driver.OpcUaClient.IntegrationTests` was failing 3
+of 4.
+
+**It reproduced identically on clean `master`** (verified in a throwaway worktree), so it was not caused
+by this work — but it was also not what it first looked like. The chain of wrong guesses is worth
+recording, because each one would have closed the investigation early:
+
+1. *"The fixture is down."* — `otopcua-opc-plc` was `Up 12 days (unhealthy)`. Restarted it, waited for
+ healthy. **Still 3/4 failing.**
+2. *"The simulator's node set drifted."* — the tests expect `ns=3;s=StepUp`. Browsed the live server:
+ all three nodes present at exactly those ids. Read `ns=3;s=StepUp` through Client.CLI against the
+ same endpoint: **`Good`, value 1379.**
+3. *"So the driver has a read defect."* — the status code was `2150825984`, which I first decoded by
+ hand as `BadAttributeIdInvalid` and started hunting a wrong `AttributeId`. **The decode was wrong**
+ (`0x80330000`, not `0x80350000`) — it is `BadNodeIdInvalid`, and grep found that exact constant on
+ the driver's own *local* fault path, reached without any wire request.
+
+The actual cause: **v3 made the driver resolve every reference through its authored `RawTags` table**
+(RawPath → `TagConfig.nodeId` → live NodeId re-bound against the session's namespace table). A bare
+`ns=3;s=StepUp` is no longer a reference the driver accepts. The smoke tests still passed bare node ids
+and constructed the driver with an empty `RawTags`, so all three failed locally at the resolver — they
+had been testing the unresolved-reference guard, against a perfectly healthy simulator.
+
+Fixed by seeding `OpcPlcProfile.RawTags` with the four nodes in the same `{"nodeId": …}` TagConfig shape
+the deploy artifact delivers, and addressing them by RawPath. **4/4 pass.**
+
+Two things this says about the register itself: the 2026-07-27 "only pre-existing environment failures
+remain" note was measured against an incomplete baseline (this suite was already broken and simply not
+enumerated); and **fixture-gated integration suites rot invisibly** — a driver contract can change under
+them and nothing on the local box notices, because the suite is expected to be red-or-skipped depending
+on whether a remote container happens to be up.
+
+### 2026-07-28 — §7.2 priority 3: the `Phase7*` → `AddressSpace*` rename
+
+Applied across the four **live** docs (`VirtualTags.md`, `ScriptedAlarms.md`, `ScriptEditor.md`,
+`v2/Architecture-v2.md`). The already-bannered historical files (`v2/phase-7-status.md`,
+`v2/implementation/*`) were deliberately **left alone** — they are records of what existed at the time,
+and renaming inside a historical record would falsify it.
+
+§7.2 was right that three of these are not renames, and each was resolved to the real member:
+
+| Cited | Reality |
+|---|---|
+| `Phase7CompositionResult` | → **`AddressSpaceComposition`** (and it now also carries the raw subtree, so the doc text was widened, not just renamed) |
+| `Phase7Composer.ExtractTagFullName` | → **`ScriptTagCatalog.ExtractFullNameFromTagConfig`** |
+| `Phase7EngineComposer.RouteToHistorianAsync` | **Gone, not renamed** — the routing moved into `HistorianAdapterActor` → the registered `IAlarmHistorianSink`. Called out inline so the next reader does not go looking for a renamed symbol. |
+
+**Found en route — the banner pass missed three files.** `GenericDriverNodeManager` is test scaffolding
+(§3.4, its own source says "zero production references"), and the earlier remediation bannered
+`AddressSpace.md` and `IncrementalSync.md` for it. But `VirtualTags.md`, `OpcUaServer.md` and
+`ScriptedAlarms.md` all still presented it as **the** production dispatch path — `VirtualTags.md:3` even
+built its opening explanation of driver-vs-virtual dispatch on it. All three now carry the same
+correction naming the real chain (`OpcUaPublishActor` → `SdkAddressSpaceSink` → `OtOpcUaNodeManager`, two
+namespaces). Banners, not rewrites — the underlying rewrite stays owed, consistent with how §7.2 treats
+the other two.
+
+### 2026-07-28 — G-7, and what the audit missed about it
+
+The register called this "pre-existing (OpcUaClient + Galaxy absent too); no new driver was added" and
+ranked it last. Reading both maps side by side makes it sharper than that:
+
+- The AdminUI's `TagConfigValidator` covers **11 of 12** driver types (all but Galaxy).
+- The deploy-time `EquipmentTagConfigInspector` covers **6**.
+
+So the gate that runs at *authoring* is stronger than the gate that runs at *deploy* — and the deploy
+gate is the only one config sees when it arrives by **CSV import or API** rather than through the modal.
+That is the wrong way round.
+
+**MQTT was a plain hole:** `MqttTagDefinitionFactory.Inspect` has existed since the driver merged and
+nothing ever called it. Now wired. The inspector's own doc-comment also claimed "unmapped drivers
+(Galaxy, OpcUaClient) are skipped exactly as in the AdminUI validator" — untrue, OpcUaClient *is* mapped
+there; corrected.
+
+**A reflection guard replaces the hand-maintained list**, same lesson as G-4: the existing
+`EquipmentTagConfigInspectorTests` enumerates driver-type strings by hand, so it guards renames but can
+never notice a gap — a newly-inspectable driver simply never appears in it.
+
+⚠️ **The first version of that guard was hollow, and only the falsification step caught it.** It
+discovered candidates via `Assembly.GetReferencedAssemblies()`, which is **circular**: the C# compiler
+elides a reference nothing in the IL uses, so removing MQTT from the map also removed its Contracts
+assembly from ControlPlane's manifest, and the sweep stopped looking for exactly the driver that had just
+gone missing. It passed green with the bug reintroduced. Rewritten to scan the output *directory*
+(MSBuild copies project-referenced assemblies regardless of IL usage) — re-falsified, and it now fails
+naming `MqttTagDefinitionFactory`. It also carries a positive control asserting discovery found more than
+one factory, since an empty expected-set is satisfied by an empty map.
+
+**Residual, deliberately not papered over.** Sql, MTConnect, Calculation and OpcUaClient have **no**
+`Inspect` in a Contracts assembly — their only checker is the AdminUI `*TagConfigModel.Validate()`, which
+lives in the AdminUI project and cannot be referenced from ControlPlane. Closing that means promoting
+those models (or an equivalent parser) into each driver's Contracts assembly — a real piece of work, not
+a map entry. Recorded in the inspector's own doc-comment so the next reader finds it at the code.
+
+### 2026-07-28 — the 13 stale `EquipmentTagsDarkBatch4` skips
+
+§4.4 flagged these as stale and it was right: the reason said "dark until Batch 4", Batch 4 shipped as
+v3.0, and the equipment-tag path was **retired** rather than lit — so every one of them was waiting on
+something that had already happened and gone the other way.
+
+**Unskipping them in bulk would have failed, and deleting them in bulk would have thrown away live
+coverage.** Each was resolved on its own merits:
+
+| Disposition | Tests | Why |
+|---|---|---|
+| **Revived** onto the raw/UNS shape | 4 primary-gate cases (`DriverHostActorPrimaryGateTests`), the cluster-scoping rebuild (`OpcUaPublishActorRebuildTests`), the real-SDK materialisation E2E (`AddressSpaceApplierHierarchyTests`) | The *behaviour* never went dark — only the v2 seeding did. Each seeds the v3 chain (RawFolder → Driver → Device → Tag, + `UnsTagReference`) instead. |
+| **Folded** into a live parity test | array intent, historize intent, native-alarm/writable intent | `DeploymentArtifactRawUnsParityTests` already compared `RawTagPlan` **record-equal**, and `RawTagPlan` carries all three fields — so widening its corpus by two tags covers what three empty placeholder suites had been promising. Those suites are deleted. |
+| **Deleted** | equipment-namespace `EquipmentTags` parse, both `{{equip}}`-token suites | The subjects are retired. The token tests used the **dot-joint** `{{equip}}.X` form, whose resolver (`EquipmentScriptPaths.DeriveEquipmentBase`) was deleted in Batch 3; the slash-joint form is covered live by `DeploymentArtifactEquipRefParityTests` (4 tests). |
+
+Three things worth keeping:
+
+- **The revivals are the point, not the unskipping.** `Unknown_role_single_driver_services_write` asserts
+ `recorder.Writes.ShouldNotBeEmpty()` — it needs the write to actually reach a driver, which needs a
+ routable tag. **Verified by deleting the `UnsTagReference` from the seed: it and
+ `Known_role_wins_over_member_count` go red.** Same check on the cluster-scoping test (re-home the SITE-A
+ node to MAIN ⇒ red). The three *denial* cases in that file were passing all along — they deny before
+ routing — which is exactly why the gap was invisible.
+- **Widening a parity corpus needed a non-parity assertion to go with it.** A composer that dropped array
+ intent *entirely* would still satisfy `decoded.RawTags.ShouldBe(composed.RawTags)`, because the artifact
+ decode would drop it too and the two empties compare equal. Parity cannot distinguish "both right" from
+ "both blind", so the widened test also asserts the composed values directly.
+- **The reason constant is deleted, replaced by a note explaining why.** A skip reason is a claim with an
+ expiry date, and this one outlived its own condition by a release. Both `DarkAddressSpaceReasons` files
+ now say so, so the next person writing one states a condition a reader can actually check.
+
+Skipped counts: `Runtime.Tests` **13 → 3**, `OpcUaServer.Tests` **4 → 1**. Every remaining skip is an
+`EquipmentDeviceBindingRetired` tombstone — deliberate, and §4.4 already calls those fine as-is.
+
### 2026-07-27 — §8.4 dispatch-map parity (G-1 … G-6)
**The maps had to become data before they could be guarded.** A Razor `@switch` compiles into
diff --git a/docs/OpcUaServer.md b/docs/OpcUaServer.md
index 301c4a23..9373be48 100644
--- a/docs/OpcUaServer.md
+++ b/docs/OpcUaServer.md
@@ -1,5 +1,13 @@
# OPC UA Server
+> ⚠️ **Correction (2026-07-28): `GenericDriverNodeManager` is NOT a production dispatch path.** It is test
+> scaffolding — its own source says so (`Core/OpcUa/GenericDriverNodeManager.cs:71`, "verified 07/#10: zero
+> production references"). The production chain is `OpcUaPublishActor` → `IOpcUaAddressSpaceSink`
+> (`SdkAddressSpaceSink`) → `OtOpcUaNodeManager`, materialising the **two v3 namespaces** (`.../raw` and
+> `.../uns`). Sections below that describe per-node dispatch, rediscovery re-walks, or alarm-sink capture
+> *through that class* describe a design, not the running server. The same correction is already carried by
+> `AddressSpace.md` and `IncrementalSync.md`; this file was missed in that pass.
+
The OPC UA server component (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/`) hosts the OPC UA stack and exposes a browsable address space built from the registered drivers. The server itself is driver-agnostic — Galaxy/MXAccess, Modbus, S7, AB CIP, AB Legacy, TwinCAT, FOCAS, and OPC UA Client are all plugged in as `IDriver` implementations via the capability interfaces in `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/`.
In v2 the Server and Admin processes were fused into a single role-gated `ZB.MOM.WW.OtOpcUa.Host` binary. Which subsystems start (OPC UA endpoint, Admin UI, control plane, driver runtime) is decided by the `OTOPCUA_ROLES` gate, not by running separate executables. See `docs/ServiceHosting.md` for the role model.
diff --git a/docs/ScriptEditor.md b/docs/ScriptEditor.md
index bcbba33d..1241db9d 100644
--- a/docs/ScriptEditor.md
+++ b/docs/ScriptEditor.md
@@ -202,7 +202,7 @@ prefix. The concrete implementation `ScriptTagCatalog` reads `Tag` +
**Why only resolvable keys?** The live runtime resolves a `ctx.GetTag("X")`
literal against `DriverInstanceActor.AttributeValuePublished.FullReference`,
which is the `FullName` field from `Tag.TagConfig`
-(see `Phase7Composer.ExtractTagFullName` + `EquipmentNodeWalker.ExtractFullName`).
+(see `ScriptTagCatalog.ExtractFullNameFromTagConfig` + `EquipmentNodeWalker.ExtractFullName`).
The UNS-path engine (`Core.VirtualTags.VirtualTagEngine`, keyed by the
slash-joined `Enterprise/Site/Area/Line/Equipment/TagName` browse path) is
dormant — it is not wired into the host — so UNS browse paths never resolve at
@@ -228,7 +228,7 @@ syntax token under the caret to: `LiteralExpressionSyntax` → `ArgumentSyntax`
`GetTag` or `SetVirtualTag`, and (b) the **receiver is the identifier `ctx`** —
a purely syntactic check that deliberately mirrors the runtime dependency
harvest (`EquipmentScriptPaths.GetTagRefRegex` is `ctx`-anchored), so the editor
-offers tag-path completion/hover for exactly what `Phase7Composer` harvests at
+offers tag-path completion/hover for exactly what `AddressSpaceComposer` harvests at
deploy time. An unrelated `anything.GetTag("…")` no longer triggers it.
**`SetVirtualTag` is a no-op in production.** The live single-tag evaluator
diff --git a/docs/ScriptedAlarms.md b/docs/ScriptedAlarms.md
index 0ee00d36..0abde22b 100644
--- a/docs/ScriptedAlarms.md
+++ b/docs/ScriptedAlarms.md
@@ -1,5 +1,13 @@
# Scripted Alarms
+> ⚠️ **Correction (2026-07-28): `GenericDriverNodeManager` is NOT a production dispatch path.** It is test
+> scaffolding — its own source says so (`Core/OpcUa/GenericDriverNodeManager.cs:71`, "verified 07/#10: zero
+> production references"). The production chain is `OpcUaPublishActor` → `IOpcUaAddressSpaceSink`
+> (`SdkAddressSpaceSink`) → `OtOpcUaNodeManager`, materialising the **two v3 namespaces** (`.../raw` and
+> `.../uns`). Sections below that describe per-node dispatch, rediscovery re-walks, or alarm-sink capture
+> *through that class* describe a design, not the running server. The same correction is already carried by
+> `AddressSpace.md` and `IncrementalSync.md`; this file was missed in that pass.
+
`Core.ScriptedAlarms` is the Phase 7 subsystem that raises OPC UA Part 9 alarms from operator-authored C# predicates rather than from driver-native alarm streams. Scripted alarms are additive: Galaxy, AB CIP, FOCAS, and OPC UA Client drivers keep their native `IAlarmSource` implementations unchanged, and a `ScriptedAlarmSource` simply registers as another source in the same fan-out. Predicates read tags from any source (driver tags or virtual tags) through the shared `ITagUpstreamSource` and emit condition transitions through the engine's Part 9 state machine.
This file covers the engine internals — predicate evaluation, state machine, persistence, and the engine-to-`IAlarmSource` adapter. The server-side plumbing that turns those emissions into OPC UA `AlarmConditionState` nodes, applies retries, persists alarm transitions to the Historian, and routes operator acks through the session's `AlarmAck` permission lives in [AlarmTracking.md](AlarmTracking.md) and is not repeated here.
@@ -10,7 +18,7 @@ Scripted-alarm definitions are created and edited per-equipment on the **`/uns/e
## Definition shape
-`ScriptedAlarmDefinition` (`src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmDefinition.cs`) is the runtime contract the engine consumes. The generation-publish path materialises these from the `ScriptedAlarm` + `Script` config tables via `Phase7Composer.Compose` + the driver-role host actor startup path.
+`ScriptedAlarmDefinition` (`src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmDefinition.cs`) is the runtime contract the engine consumes. The generation-publish path materialises these from the `ScriptedAlarm` + `Script` config tables via `AddressSpaceComposer.Compose` + the driver-role host actor startup path.
| Field | Notes |
|---|---|
@@ -332,7 +340,7 @@ Emissions map into `AlarmEventArgs` as `AlarmType = Kind.ToString()`, `SourceNod
## Composition
-`Phase7Composer` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs`) is a pure data composer; it has no knowledge of `ScriptedAlarmEngine`. It maps `ScriptedAlarm` config-DB rows into `ScriptedAlarmPlan` records that the driver-role host actor startup path consumes.
+`AddressSpaceComposer` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs`) is a pure data composer; it has no knowledge of `ScriptedAlarmEngine`. It maps `ScriptedAlarm` config-DB rows into `ScriptedAlarmPlan` records that the driver-role host actor startup path consumes.
In the v2 actor system, scripted-alarm engine composition is owned by the driver-role host:
@@ -355,8 +363,8 @@ Both engine and source are disposed on server shutdown via the driver-role host
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/AlarmTypes.cs` — `AlarmKind` + `ShelvingKind` + four Part 9 state enums (`AlarmEnabledState`, `AlarmActiveState`, `AlarmAckedState`, `AlarmConfirmedState`); `AlarmSeverity` (`Low`/`Medium`/`High`/`Critical`) lives in `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs`
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/MessageTemplate.cs` — `{path}` placeholder resolver
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/IAlarmStateStore.cs` — persistence contract + `InMemoryAlarmStateStore` default
-- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs` — pure data composer: config-DB entities → `Phase7CompositionResult` (UNS topology + driver/alarm plans)
-- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs` — applies the composed Phase 7 plan into the SDK node manager
+- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs` — pure data composer: config-DB entities → `AddressSpaceComposition` (UNS topology + raw subtree + driver/alarm plans)
+- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs` — applies the composed plan into the SDK node manager
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmActor.cs` — actor that owns the per-alarm state machine; publishes `AlarmTransitionEvent` on the cluster `alerts` DPS topic
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/EfAlarmActorStateStore.cs` — production `IAlarmActorStateStore` backed by the `ScriptedAlarmState` config-DB table
- `src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynScriptedAlarmEvaluator.cs` — production Roslyn predicate evaluator
diff --git a/docs/VirtualTags.md b/docs/VirtualTags.md
index 0f9ecdcc..20032d37 100644
--- a/docs/VirtualTags.md
+++ b/docs/VirtualTags.md
@@ -1,5 +1,13 @@
# Virtual Tags
+> ⚠️ **Correction (2026-07-28): `GenericDriverNodeManager` is NOT a production dispatch path.** It is test
+> scaffolding — its own source says so (`Core/OpcUa/GenericDriverNodeManager.cs:71`, "verified 07/#10: zero
+> production references"). The production chain is `OpcUaPublishActor` → `IOpcUaAddressSpaceSink`
+> (`SdkAddressSpaceSink`) → `OtOpcUaNodeManager`, materialising the **two v3 namespaces** (`.../raw` and
+> `.../uns`). Sections below that describe per-node dispatch, rediscovery re-walks, or alarm-sink capture
+> *through that class* describe a design, not the running server. The same correction is already carried by
+> `AddressSpace.md` and `IncrementalSync.md`; this file was missed in that pass.
+
Virtual tags are OPC UA variable nodes whose values are computed by operator-authored C# scripts against other tags (driver or virtual). They live in the Equipment browse tree alongside driver-sourced variables: a client browsing `Enterprise/Site/Area/Line/Equipment/` sees one flat child list that mixes both kinds, and a read / subscribe on a virtual node looks identical to one on a driver node from the wire. The separation is server-side — `EquipmentNodeWalker` stamps each `DriverAttributeInfo` with `NodeSourceKind` (`Driver` / `Virtual` / `ScriptedAlarm`) at address-space build time, and `GenericDriverNodeManager` routes reads to different backends accordingly. See [ADR-002](v2/implementation/adr-002-driver-vs-virtual-dispatch.md) for the dispatch decision.
The runtime is split across two projects: `Core.Scripting` holds the Roslyn sandbox + evaluator primitives that are reused by both virtual tags and scripted alarms; `Core.VirtualTags` holds the engine that owns the dependency graph, the evaluation pipeline, and the `ISubscribable` adapter the server dispatches to. In the v2 actor system, `VirtualTagActor` + `DependencyMuxActor` (in `Core.Runtime`) own the per-instance state and upstream-feed wiring; `RoslynVirtualTagEvaluator` (in `Host.Engines`) is the production `IVirtualTagEvaluator` binding.
@@ -94,7 +102,7 @@ What the engine pulls driver-tag values from. Reads are **synchronous** because
### `IHistoryWriter`
-Fire-and-forget sink for evaluation results when `VirtualTagDefinition.Historize = true`. Implementations must queue internally and drain on their own cadence — a slow historian must not block script evaluation. `NullHistoryWriter.Instance` is the no-op default. Scripted-alarm emissions flow through `Core.AlarmHistorian` via `Phase7EngineComposer.RouteToHistorianAsync` (a separate concern; see [AlarmTracking.md](AlarmTracking.md)).
+Fire-and-forget sink for evaluation results when `VirtualTagDefinition.Historize = true`. Implementations must queue internally and drain on their own cadence — a slow historian must not block script evaluation. `NullHistoryWriter.Instance` is the no-op default. Scripted-alarm emissions flow through `Core.AlarmHistorian` via `HistorianAdapterActor` → the registered `IAlarmHistorianSink` (a separate concern; see [AlarmTracking.md](AlarmTracking.md)). *(The `Phase7EngineComposer.RouteToHistorianAsync` this used to name is gone — not renamed; the routing moved into the actor.)*
**Equipment-namespace path (H5).** The `Historize` flag is threaded end-to-end on the equipment path: `VirtualTag.Historize` → composer + artifact-decode (byte-parity) → `EquipmentVirtualTagPlan.Historize` → `VirtualTagHostActor`, which calls `IHistoryWriter.Record(nodeId, snapshot)` for every historized result (in addition to publishing the live value). The writer is injectable via DI — `DriverHostActor` resolves `IHistoryWriter` (`TryAddSingleton`, `NullHistoryWriter` default) and threads it into `VirtualTagHostActor`. **This `IHistoryWriter` seam still ships no durable binding** (`NullHistoryWriter` default). Durable continuous historization of driver/virtual values is now handled by the separate `ContinuousHistorizationRecorder` (it taps the dependency-mux value fan-out → a crash-safe FasterLog outbox → the HistorianGateway's `WriteLiveValues` path; see [Historian.md](Historian.md)), not through this seam. A deployment can still bind a custom `IHistoryWriter` via DI.
@@ -118,11 +126,11 @@ Per [ADR-002](v2/implementation/adr-002-driver-vs-virtual-dispatch.md) Option B,
## Composition
-`Phase7Composer` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs`) is a pure static function that flattens config-DB entities into a `Phase7CompositionResult` value (UNS topology + driver-instance plans + scripted-alarm plans). `Phase7Applier` applies that result into the OPC UA SDK node manager. Neither class has knowledge of `VirtualTagEngine` or `ScriptedAlarmEngine`.
+`AddressSpaceComposer` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs`) is a pure static function that flattens config-DB entities into an `AddressSpaceComposition` value (UNS topology + raw subtree + driver-instance plans + scripted-alarm plans). `AddressSpaceApplier` applies that result into the OPC UA SDK node manager. Neither class has knowledge of `VirtualTagEngine` or `ScriptedAlarmEngine`.
In the v2 actor system, virtual-tag engine composition is owned by the driver-role host actor tree:
-- `Phase7Composer.Compose` emits `DriverInstancePlan` / `ScriptedAlarmPlan` records; the driver-role `DriverHostActor` spawns one `VirtualTagActor` per virtual-tag expression and one `ScriptedAlarmActor` per scripted alarm.
+- `AddressSpaceComposer.Compose` emits `DriverInstancePlan` / `ScriptedAlarmPlan` records; the driver-role `DriverHostActor` spawns one `VirtualTagActor` per virtual-tag expression and one `ScriptedAlarmActor` per scripted alarm.
- `RoslynVirtualTagEvaluator` (`src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynVirtualTagEvaluator.cs`) is injected into each `VirtualTagActor` as its `IVirtualTagEvaluator`. It holds a per-source `CompiledScriptCache` keyed by script source and compiles on first use.
- `DependencyMuxActor` (`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs`) receives every `DriverInstanceActor.AttributeValuePublished` event and routes it to the `VirtualTagActor` instances that registered interest in that tag ref.
@@ -151,6 +159,6 @@ In the v2 actor system, virtual-tag engine composition is owned by the driver-ro
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs` — actor that receives `DependencyValueChanged` from the mux and invokes `IVirtualTagEvaluator` per expression
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs` — routes `DriverInstanceActor.AttributeValuePublished` to interested `VirtualTagActor` subscribers
- `src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynVirtualTagEvaluator.cs` — production `IVirtualTagEvaluator` binding; holds a per-source `CompiledScriptCache`
-- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs` — pure data composer: config-DB entities → `Phase7CompositionResult` (UNS topology + driver/alarm plans)
-- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs` — applies the composed plan into the SDK node manager
+- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs` — pure data composer: config-DB entities → `AddressSpaceComposition` (UNS topology + raw subtree + driver/alarm plans)
+- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs` — applies the composed plan into the SDK node manager
- `src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs` — driver-agnostic OPC UA node-manager backbone; per-variable `NodeSourceKind` drives dispatch
diff --git a/docs/v2/Architecture-v2.md b/docs/v2/Architecture-v2.md
index 4240635f..35e35e18 100644
--- a/docs/v2/Architecture-v2.md
+++ b/docs/v2/Architecture-v2.md
@@ -43,7 +43,7 @@ src/Server/ server-side projects
ZB.MOM.WW.OtOpcUa.Security cookie+JWT auth, LDAP, JwtTokenService
ZB.MOM.WW.OtOpcUa.ControlPlane admin-role cluster singletons
ZB.MOM.WW.OtOpcUa.Runtime driver-role per-node actors
- ZB.MOM.WW.OtOpcUa.OpcUaServer OPC UA endpoint facade + Phase7Composer
+ ZB.MOM.WW.OtOpcUa.OpcUaServer OPC UA endpoint facade + AddressSpaceComposer
ZB.MOM.WW.OtOpcUa.AdminUI Blazor Razor class library
ZB.MOM.WW.OtOpcUa.Host fused binary (Program.cs)
```
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/EquipmentTagConfigInspector.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/EquipmentTagConfigInspector.cs
index bc6cfa9e..7e8a25e5 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/EquipmentTagConfigInspector.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/EquipmentTagConfigInspector.cs
@@ -3,6 +3,7 @@ using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
+using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
using ZB.MOM.WW.OtOpcUa.Driver.S7;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
@@ -13,8 +14,19 @@ namespace ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
/// AdminUI TagConfigValidator shape: a case-insensitive map from the canonical driver-type string
/// to that driver's lightweight Contracts parser Inspect(), returning human-readable
/// warnings (present-but-invalid enum values that the lenient runtime silently defaults, plus
-/// structurally-unparseable TagConfig). Unmapped drivers (Galaxy, OpcUaClient) are skipped exactly as in
-/// the AdminUI validator. Never throws.
+/// structurally-unparseable TagConfig). Never throws.
+/// Coverage is every driver that ships an Inspect, and that invariant is guarded by
+/// EquipmentTagConfigInspectorCoverageTests, which reflects over the referenced
+/// *.Contracts assemblies — so a driver that gains an inspector cannot be silently left out. This
+/// is the same guard shape the driver-form dispatch maps use, and for the same reason: the previous
+/// hand-maintained list had drifted (MQTT shipped an Inspect and was never added).
+/// Residual gap, deliberately recorded rather than papered over. Galaxy, OpcUaClient, Sql,
+/// MTConnect and Calculation have noInspect in a Contracts assembly, so this deploy-time
+/// gate cannot check them — while the AdminUI's TagConfigValidator DOES cover all but Galaxy via
+/// its *TagConfigModel.Validate() models, which live in the AdminUI project and cannot be
+/// referenced from here. The asymmetry runs the wrong way: config authored through the modal is checked,
+/// but config arriving by CSV import or API is not. Closing it means promoting those models (or an
+/// equivalent parser) into each driver's Contracts assembly. Tracked as deferment-register G-7.
///
public static class EquipmentTagConfigInspector
{
@@ -27,10 +39,12 @@ public static class EquipmentTagConfigInspector
[DriverTypeNames.AbLegacy] = AbLegacyTagDefinitionFactory.Inspect,
[DriverTypeNames.TwinCAT] = TwinCATTagDefinitionFactory.Inspect,
[DriverTypeNames.FOCAS] = FocasTagDefinitionFactory.Inspect,
+ [DriverTypeNames.Mqtt] = MqttTagDefinitionFactory.Inspect,
};
/// Inspects a tag's TagConfig for the given driver type. Returns the driver's warnings,
- /// or an empty list when the driver type is unmapped (Galaxy/OpcUaClient) or the inputs are null.
+ /// or an empty list when the driver type has no inspector (see the class remarks) or the inputs are
+ /// null.
/// The bound driver's DriverType (canonical string, case-insensitive).
/// The equipment tag's TagConfig JSON.
/// The warnings; empty when the driver is unmapped or clean.
@@ -40,9 +54,12 @@ public static class EquipmentTagConfigInspector
return Inspectors.TryGetValue(driverType, out var inspect) ? inspect(tagConfig) : Array.Empty();
}
- /// Whether the given driver type has a mapped inspector (equipment-tag protocol drivers only).
+ /// Whether the given driver type has a mapped inspector.
/// The bound driver's DriverType.
/// when an inspector is registered for the driver type.
public static bool IsMapped(string? driverType) =>
!string.IsNullOrEmpty(driverType) && Inspectors.ContainsKey(driverType);
+
+ /// The driver types this gate can check — the guard test's read of the map.
+ public static IReadOnlyCollection MappedDriverTypes => (IReadOnlyCollection)Inspectors.Keys;
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj
index a20477da..89d2622c 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj
@@ -45,6 +45,7 @@
+
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcPlcProfile.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcPlcProfile.cs
index a2559bcd..d9fbe6b3 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcPlcProfile.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcPlcProfile.cs
@@ -1,3 +1,5 @@
+using System.Text.Json;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests;
@@ -7,6 +9,13 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests;
/// tests address. Node IDs are stable across opc-plc releases — the simulator
/// guarantees the same ns=3;s=... names shipped since v1.0. If a release
/// bump breaks these, the fixture's pinned image tag needs a coordinated bump.
+/// These tests address the driver by RawPath, not by node id (fixed 2026-07-28). v3
+/// made the driver resolve every read/write/subscribe reference through its authored
+/// table — a bare ns=3;s=StepUp is no longer a
+/// reference the driver accepts, and one that does not resolve is failed LOCALLY as
+/// BadNodeIdInvalid with no wire round-trip. The smoke tests still passed bare node ids, so all
+/// three had been failing against a perfectly healthy simulator. Seeding RawTags here is what
+/// makes them exercise the driver instead of its unresolved-reference guard.
///
public static class OpcPlcProfile
{
@@ -22,11 +31,37 @@ public static class OpcPlcProfile
/// opc-plc fast uint node — ticks every 100ms. Used for subscription-cadence tests.
public const string FastUInt1 = "ns=3;s=FastUInt1";
+ /// The RawPath the smoke tests address by.
+ public const string StepUpRef = "Sim/OpcUaClient/plc/StepUp";
+
+ /// The RawPath the smoke tests address by.
+ public const string RandomSignedInt32Ref = "Sim/OpcUaClient/plc/RandomSignedInt32";
+
+ /// The RawPath the smoke tests address by.
+ public const string AlternatingBooleanRef = "Sim/OpcUaClient/plc/AlternatingBoolean";
+
+ /// The RawPath the smoke tests address by.
+ public const string FastUInt1Ref = "Sim/OpcUaClient/plc/FastUInt1";
+
+ /// The authored raw-tag table the driver resolves the *Ref RawPaths through — the
+ /// same {"nodeId": …} TagConfig shape the deploy artifact delivers.
+ public static IReadOnlyList RawTags { get; } =
+ [
+ Entry(StepUpRef, StepUp),
+ Entry(RandomSignedInt32Ref, RandomSignedInt32),
+ Entry(AlternatingBooleanRef, AlternatingBoolean),
+ Entry(FastUInt1Ref, FastUInt1),
+ ];
+
+ private static RawTagEntry Entry(string rawPath, string nodeId) =>
+ new(rawPath, JsonSerializer.Serialize(new { nodeId }), WriteIdempotent: true);
+
/// Builds driver options for the OPC PLC endpoint.
/// The endpoint URL of the OPC PLC simulator.
/// Configured driver options for the OPC PLC.
public static OpcUaClientDriverOptions BuildOptions(string endpointUrl) => new()
{
+ RawTags = RawTags,
EndpointUrl = endpointUrl,
SecurityPolicy = OpcUaSecurityPolicy.None,
SecurityMode = OpcUaSecurityMode.None,
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcUaClientSmokeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcUaClientSmokeTests.cs
index 601cd2a1..96deeaf1 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcUaClientSmokeTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcUaClientSmokeTests.cs
@@ -27,7 +27,7 @@ public sealed class OpcUaClientSmokeTests(OpcPlcFixture sim)
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var snapshots = await drv.ReadAsync(
- [OpcPlcProfile.StepUp], TestContext.Current.CancellationToken);
+ [OpcPlcProfile.StepUpRef], TestContext.Current.CancellationToken);
snapshots.Count.ShouldBe(1);
snapshots[0].StatusCode.ShouldBe(0u, "opc-plc StepUp read must succeed end-to-end");
@@ -45,7 +45,7 @@ public sealed class OpcUaClientSmokeTests(OpcPlcFixture sim)
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var snapshots = await drv.ReadAsync(
- [OpcPlcProfile.StepUp, OpcPlcProfile.RandomSignedInt32, OpcPlcProfile.AlternatingBoolean],
+ [OpcPlcProfile.StepUpRef, OpcPlcProfile.RandomSignedInt32Ref, OpcPlcProfile.AlternatingBooleanRef],
TestContext.Current.CancellationToken);
snapshots.Count.ShouldBe(3);
@@ -78,7 +78,7 @@ public sealed class OpcUaClientSmokeTests(OpcPlcFixture sim)
};
var handle = await drv.SubscribeAsync(
- [OpcPlcProfile.FastUInt1], TimeSpan.FromMilliseconds(250),
+ [OpcPlcProfile.FastUInt1Ref], TimeSpan.FromMilliseconds(250),
TestContext.Current.CancellationToken);
// FastUInt1 ticks every 100 ms — one publishing interval (250 ms) should deliver.
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/EquipmentTagConfigInspectorCoverageTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/EquipmentTagConfigInspectorCoverageTests.cs
new file mode 100644
index 00000000..d453cc3e
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/EquipmentTagConfigInspectorCoverageTests.cs
@@ -0,0 +1,113 @@
+using System.Reflection;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
+
+namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
+
+///
+/// Guards the deploy-time TagConfig gate against the failure mode that produced deferment-register G-7:
+/// a driver ships an Inspect on its *TagDefinitionFactory, nobody adds it to
+/// , and the gate is silently blind for that driver at deploy
+/// while the AdminUI still validates it at authoring time. MQTT sat in exactly that state.
+/// Why reflection rather than a hand-written list. The sibling
+/// EquipmentTagConfigInspectorTests enumerates driver-type strings by hand, so it guards
+/// renames but can never notice a gap — a newly-inspectable driver simply never appears in
+/// it. This is the same hollow-guard shape found in TagConfigDriverTypeNameGuardTests and
+/// CsvColumnMapReflectionTests. Deriving the expected set from the assemblies means the test
+/// fails the moment a new inspector exists, without anyone remembering to update it.
+///
+public sealed class EquipmentTagConfigInspectorCoverageTests
+{
+ ///
+ /// Every *TagDefinitionFactory reachable from ControlPlane that exposes a public static
+ /// Inspect(string) must be wired into the dispatch map.
+ /// Discovery walks the referenced assemblies rather than AppDomain alone, because a
+ /// Contracts assembly with no other use is not loaded until first touched — an
+ /// AppDomain.GetAssemblies() sweep would quietly find nothing and pass vacuously.
+ ///
+ [Fact]
+ public void Every_driver_shipping_an_Inspect_is_wired_into_the_dispatch_map()
+ {
+ var inspectable = DiscoverInspectableFactories();
+
+ // Positive control: the discovery itself must find something, or the assertion below is vacuous —
+ // an empty expected-set is satisfied by an empty map, which is the exact bug this guards.
+ inspectable.Count.ShouldBeGreaterThan(
+ 1,
+ "the reflection sweep found no (or one) driver factory with an Inspect — discovery is broken, "
+ + "and the coverage assertion below would pass no matter what the map contained");
+
+ var mapped = EquipmentTagConfigInspector.MappedDriverTypes;
+
+ var missing = inspectable
+ .Where(t => !mapped.Any(m => LooksLike(m, t.Name)))
+ .Select(t => t.Name)
+ .OrderBy(n => n, StringComparer.Ordinal)
+ .ToArray();
+
+ missing.ShouldBeEmpty(
+ "these drivers ship an Inspect() but the deploy-time gate does not call it, so their TagConfig "
+ + "is checked at authoring time and NOT at deploy — config arriving by CSV import or API is "
+ + "unchecked: " + string.Join(", ", missing));
+ }
+
+ /// The map must not name a driver type with no inspector behind it — a dead key would read as
+ /// coverage while dispatching nowhere.
+ [Fact]
+ public void Every_mapped_driver_type_resolves_to_a_real_inspector()
+ {
+ foreach (var driverType in EquipmentTagConfigInspector.MappedDriverTypes)
+ {
+ EquipmentTagConfigInspector.IsMapped(driverType).ShouldBeTrue(driverType);
+ // A structurally invalid blob must come back as warnings, not an exception — the gate is on the
+ // deploy path and must never throw.
+ Should.NotThrow(() => EquipmentTagConfigInspector.Inspect(driverType, "{not json"));
+ }
+ }
+
+ ///
+ /// Public static IReadOnlyList<string> Inspect(string) is the contract every driver's
+ /// deploy-time checker implements; anything else is not one of these factories.
+ /// Discovery scans the output DIRECTORY, not Assembly.GetReferencedAssemblies().
+ /// The obvious implementation walks the reference graph — and it is circular, because the C# compiler
+ /// elides an assembly reference nothing in the IL uses. Drop a driver from the dispatch map and its
+ /// Contracts assembly drops out of ControlPlane's manifest too, so the sweep stops looking for
+ /// exactly the driver that just went missing and the test passes. That version was written, and
+ /// caught only by deleting the MQTT entry and watching it stay green. MSBuild copies every
+ /// project-referenced assembly to the output regardless of IL usage, so the directory is the honest
+ /// source.
+ /// Boundary, stated so it is not mistaken for more: this sees the Contracts assemblies
+ /// ControlPlane project-references. A driver whose Contracts project is not referenced at all (Sql,
+ /// MTConnect, Calculation, OpcUaClient) is invisible here — adding the reference is itself the act
+ /// that makes this guard start demanding a map entry.
+ ///
+ private static IReadOnlyList DiscoverInspectableFactories() =>
+ Directory.GetFiles(AppContext.BaseDirectory, "ZB.MOM.WW.OtOpcUa.Driver.*.Contracts.dll")
+ .Select(TryLoad)
+ .Where(a => a is not null)
+ .SelectMany(a => a!.GetTypes())
+ .Where(t => t is { IsClass: true, IsPublic: true }
+ && t.Name.EndsWith("TagDefinitionFactory", StringComparison.Ordinal)
+ && HasInspect(t))
+ .DistinctBy(t => t.FullName)
+ .ToArray();
+
+ private static Assembly? TryLoad(string path)
+ {
+ try { return Assembly.LoadFrom(path); }
+ catch (BadImageFormatException) { return null; }
+ }
+
+ private static bool HasInspect(Type t)
+ {
+ var m = t.GetMethod("Inspect", BindingFlags.Public | BindingFlags.Static, [typeof(string)]);
+ return m is not null && typeof(IReadOnlyList).IsAssignableFrom(m.ReturnType);
+ }
+
+ /// Matches a mapped driver-type string to a factory type name (ModbusTagDefinitionFactory
+ /// ↔ Modbus), tolerating the casing differences between the two vocabularies
+ /// (FOCAS/Focas, TwinCAT/TwinCAT).
+ private static bool LooksLike(string driverType, string factoryTypeName) =>
+ factoryTypeName.StartsWith(driverType, StringComparison.OrdinalIgnoreCase);
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierHierarchyTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierHierarchyTests.cs
index df57354f..aaa96f10 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierHierarchyTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierHierarchyTests.cs
@@ -156,17 +156,21 @@ public sealed class AddressSpaceApplierHierarchyTests : IDisposable
}
///
- /// Full structure-materialisation pipeline against a real SDK node manager: real Config
- /// entities (Area / Line / Equipment + an Equipment-namespace Tag) →
- /// → MaterialiseHierarchy + MaterialiseEquipmentTags → . Proves
- /// an Equipment namespace lands its Area/Line/Equipment folder tree + the equipment-signal
- /// Variable in a live OPC UA address space (structure-only; live values are a later milestone).
- /// Also covers the compose-side EquipmentTags extraction. The cluster-level deploy +
- /// network-browse E2E (Host.IntegrationTests) needs the docker-dev fixture and is tracked
- /// as a follow-up.
+ /// Full v3 structure-materialisation pipeline against a real SDK node manager: real Config entities
+ /// (RawFolder → Driver → Device → Tag, plus Area / Line / Equipment and a UnsTagReference) →
+ /// → MaterialiseHierarchy + MaterialiseRawSubtree +
+ /// MaterialiseUnsReferences → . Proves the dual-namespace
+ /// shape lands in a live OPC UA address space: the raw device tree AND the UNS folder tree, with the
+ /// raw tag's variable projected into its referencing equipment as a second variable.
+ /// Revived from the EquipmentTagsDarkBatch4 skip. It was parked pending "the Batch-4
+ /// UnsTagReference fan-out", which shipped as v3.0 — but the equipment-tag path it was written
+ /// against was RETIRED rather than lit, so unskipping alone would have failed. Rewritten to the
+ /// shape that actually replaced it. The sibling MaterialiseHierarchy_against_real_SDK still
+ /// covers folders only; this is the layer's only real-SDK proof that variables materialise in both
+ /// namespaces.
///
- [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
- public async Task Equipment_namespace_structure_materialises_end_to_end_against_real_SDK()
+ [Fact]
+ public async Task Raw_and_uns_variables_materialise_end_to_end_against_real_SDK()
{
await using var host = new OpcUaApplicationHost(
new OpcUaApplicationHostOptions
@@ -183,34 +187,46 @@ public sealed class AddressSpaceApplierHierarchyTests : IDisposable
await host.StartAsync(sdkServer, Ct);
sdkServer.NodeManager.ShouldNotBeNull();
- // One area / line / equipment + a Modbus driver. v3: no Namespace entity, DriverInstance no
- // longer binds a Namespace, and Equipment no longer binds a driver. The equipment-bound Tag
- // (the Speed signal) that this end-to-end test materialised as a Variable node is DARK until
- // Batch 4 — the composer emits an empty EquipmentTags set, so it is no longer seeded/passed
- // here. Equipment.Name is the UNS browse segment. (Folder-only materialisation is covered live
- // by the sibling MaterialiseHierarchy_against_real_SDK test; this suite's equipment-tag
- // Variable end-to-end leg re-enables in Batch 4 via the UnsTagReference fan-out.)
- var driver = new DriverInstance { DriverInstanceId = "drv-modbus", ClusterId = "c1", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}" };
+ // The v3 raw chain: RawFolder "Plant" → Driver "Modbus" → Device "dev1" → Tag "Speed"
+ // (RawPath Plant/Modbus/dev1/Speed), plus the UNS tree it is projected into.
+ var folder = new RawFolder { RawFolderId = "rf-plant", ClusterId = "c1", Name = "Plant", ParentRawFolderId = null };
+ var driver = new DriverInstance { DriverInstanceId = "drv-modbus", ClusterId = "c1", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}", RawFolderId = "rf-plant" };
+ var device = new Device { DeviceId = "dev-1", DriverInstanceId = "drv-modbus", Name = "dev1", DeviceConfig = "{}" };
+ var speed = new Tag
+ {
+ TagId = "tag-speed", DeviceId = "dev-1", TagGroupId = null, Name = "Speed", DataType = "Float",
+ AccessLevel = TagAccessLevel.Read, TagConfig = "{}",
+ };
var area = new UnsArea { UnsAreaId = "nw-area-filling", ClusterId = "c1", Name = "filling" };
var line = new UnsLine { UnsLineId = "nw-line-1", UnsAreaId = "nw-area-filling", Name = "line-1" };
var equipment = new Equipment { EquipmentId = "eq-1", UnsLineId = "nw-line-1", Name = "station-1", MachineCode = "STATION_001" };
+ var reference = new UnsTagReference { UnsTagReferenceId = "ref-1", EquipmentId = "eq-1", TagId = "tag-speed", DisplayNameOverride = null };
var composition = AddressSpaceComposer.Compose(
new[] { area }, new[] { line }, new[] { equipment }, new[] { driver },
- Array.Empty());
+ Array.Empty(),
+ unsTagReferences: new[] { reference }, tags: new[] { speed },
+ rawFolders: new[] { folder }, devices: new[] { device });
- // Compose-side EquipmentTags extraction (Batch-4 subject — empty this batch).
- var planned = composition.EquipmentTags.ShouldHaveSingleItem();
- planned.EquipmentId.ShouldBe("eq-1");
- planned.FullName.ShouldBe("40001");
+ // Compose side: one raw tag at its RawPath, projected once into the referencing equipment.
+ var rawTag = composition.RawTags.ShouldHaveSingleItem();
+ rawTag.NodeId.ShouldBe("Plant/Modbus/dev1/Speed");
+ rawTag.Realm.ShouldBe(AddressSpaceRealm.Raw);
+ var unsVar = composition.UnsReferenceVariables.ShouldHaveSingleItem();
+ unsVar.NodeId.ShouldBe("filling/line-1/station-1/Speed");
+ unsVar.BackingRawPath.ShouldBe("Plant/Modbus/dev1/Speed");
var sink = new SdkAddressSpaceSink(sdkServer.NodeManager!);
var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
applier.MaterialiseHierarchy(composition);
- applier.MaterialiseEquipmentTags(composition);
+ applier.MaterialiseRawSubtree(composition);
+ applier.MaterialiseUnsReferences(composition);
- sdkServer.NodeManager!.FolderCount.ShouldBe(3); // filling area + line-1 + station-1 equipment
- sdkServer.NodeManager!.VariableCount.ShouldBe(1); // the Speed signal under the equipment folder
+ // UNS: filling + line-1 + station-1. Raw: the Plant folder + the Modbus driver + the dev1 device.
+ sdkServer.NodeManager!.FolderCount.ShouldBe(6);
+ // ONE source value, TWO nodes — the raw tag's variable and its UNS projection. This is the
+ // single-source-fan-out shape; a regression that dropped either namespace shows up here as 1.
+ sdkServer.NodeManager!.VariableCount.ShouldBe(2);
}
/// OpcUaServer-001 — a UNS Area / Line rename-only deploy refreshes the EXISTING folder's
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceComposerAliasTagTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceComposerAliasTagTests.cs
deleted file mode 100644
index 70138b07..00000000
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceComposerAliasTagTests.cs
+++ /dev/null
@@ -1,30 +0,0 @@
-using Xunit;
-
-namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
-
-///
-/// Characterized the live-edit compose seam admitting a Galaxy point as an ordinary equipment tag:
-/// an equipment-scoped Tag bound to a GalaxyMxGateway driver surfaced under
-/// carrying its driver-side FullName (coalescing a
-/// null FolderPath to string.Empty).
-/// v3 Batch-1 DARK + reshaped seed: equipment-tag variable plans do not materialize until
-/// Batch 4 (the composer emits an empty EquipmentTags set — the raw + UNS variable nodes light
-/// up in Batch 4's dual-namespace UNS↔Raw fan-out). The seed mechanism this test used is also gone:
-/// Tag is now raw-only (no EquipmentId/DriverInstanceId/FolderPath), the
-/// Compose overload no longer takes tags/namespaces, and equipment references raw tags via
-/// UnsTagReference rather than a Galaxy point carrying an EquipmentId. So there is no
-/// Batch-1 analog to assert. Re-author against the UnsTagReference → EquipmentTags fan-out when Batch 4
-/// lights the equipment-tag variable nodes.
-///
-public sealed class AddressSpaceComposerAliasTagTests
-{
- /// A GalaxyMxGateway equipment tag surfaces under EquipmentTags with its FullName —
- /// dark until Batch 4 (empty equipment-tag plan set) and re-authored via UnsTagReference.
- [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
- public void Compose_admits_galaxy_equipment_tag_in_equipment_tags()
- {
- // Dark until Batch 4: see class summary. EquipmentTags is emitted empty this batch and the
- // Tag.EquipmentId binding this test relied on is retired (Tag is raw-only). The Batch-4 seam
- // fans equipment tags in via UnsTagReference; re-author the assertion then.
- }
-}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceComposerEquipTokenTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceComposerEquipTokenTests.cs
deleted file mode 100644
index 8f7c32c9..00000000
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceComposerEquipTokenTests.cs
+++ /dev/null
@@ -1,81 +0,0 @@
-using Shouldly;
-using Xunit;
-using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
-
-namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
-
-///
-/// Verifies the live-edit compose seam () substitutes the
-/// reserved {{equip}} token in a shared VirtualTag script with each owning equipment's
-/// derived tag base (from its child-tag FullNames) — so one script reused across N
-/// identical machines resolves to N machine-specific dependency graphs.
-///
-/// v3 Batch-1 DARK: the per-equipment tag base is DERIVED from equipment-tag
-/// FullNames, and equipment-tag variable plans do not materialize until Batch 4 (the composer
-/// no longer takes tags — baseByEquip is empty this batch). With no base to substitute, the
-/// {{equip}} token stays literal and these per-machine assertions have nothing to compare, so
-/// the test is Skipped-with-reason. The seed + assertions are preserved verbatim (minus the retired
-/// entity columns) so re-enabling in Batch 4 is a one-line change once the fan-out feeds the base.
-///
-public sealed class AddressSpaceComposerEquipTokenTests
-{
- /// One shared using ctx.GetTag("{{equip}}.Source"), bound
- /// to two equipments (TestMachine_001 / _002) each with one equipment Tag whose FullName carries
- /// the per-machine base. Compose must expand the token per equipment in both the Expression and
- /// the parsed DependencyRefs.
- [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
- public void Compose_substitutes_equip_token_per_equipment()
- {
- // v3: no Namespace entity; DriverInstance no longer binds a Namespace; Equipment no longer
- // binds a driver. In Batch 4 the {{equip}} base derives from the equipment's child-tag
- // FullNames — here TestMachine_001.Source / TestMachine_002.Source — which flow through the
- // (dark this batch) equipment-tag plan set. Seeded for intent; not passed to Compose.
- var driver1 = new DriverInstance
- {
- DriverInstanceId = "drv-1",
- ClusterId = "c1",
- Name = "Modbus1",
- DriverType = "Modbus",
- DriverConfig = "{}",
- };
- var driver2 = new DriverInstance
- {
- DriverInstanceId = "drv-2",
- ClusterId = "c1",
- Name = "Modbus2",
- DriverType = "Modbus",
- DriverConfig = "{}",
- };
- var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c1", Name = "filling" };
- var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line-1" };
- var equip1 = new Equipment { EquipmentId = "eq-1", UnsLineId = "line-1", Name = "TestMachine_001", MachineCode = "TESTMACHINE_001" };
- var equip2 = new Equipment { EquipmentId = "eq-2", UnsLineId = "line-1", Name = "TestMachine_002", MachineCode = "TESTMACHINE_002" };
- var script = new Script
- {
- ScriptId = "s-equip",
- Name = "over-50",
- SourceCode = "return System.Convert.ToInt32(ctx.GetTag(\"{{equip}}.Source\").Value) > 50;",
- SourceHash = "hash-equip",
- };
- var vt1 = new VirtualTag { VirtualTagId = "vt-1", EquipmentId = "eq-1", Name = "over50", DataType = "Boolean", ScriptId = "s-equip" };
- var vt2 = new VirtualTag { VirtualTagId = "vt-2", EquipmentId = "eq-2", Name = "over50", DataType = "Boolean", ScriptId = "s-equip" };
-
- var result = AddressSpaceComposer.Compose(
- new[] { area }, new[] { line }, new[] { equip1, equip2 },
- new[] { driver1, driver2 }, Array.Empty(),
- virtualTags: new[] { vt1, vt2 },
- scripts: new[] { script });
-
- result.EquipmentVirtualTags.Count.ShouldBe(2);
-
- var plan1 = result.EquipmentVirtualTags.Single(p => p.VirtualTagId == "vt-1");
- plan1.Expression.ShouldContain("ctx.GetTag(\"TestMachine_001.Source\")");
- plan1.Expression.ShouldNotContain("{{equip}}");
- plan1.DependencyRefs.ShouldBe(new[] { "TestMachine_001.Source" });
-
- var plan2 = result.EquipmentVirtualTags.Single(p => p.VirtualTagId == "vt-2");
- plan2.Expression.ShouldContain("ctx.GetTag(\"TestMachine_002.Source\")");
- plan2.Expression.ShouldNotContain("{{equip}}");
- plan2.DependencyRefs.ShouldBe(new[] { "TestMachine_002.Source" });
- }
-}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DarkAddressSpaceReasons.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DarkAddressSpaceReasons.cs
index 8716ac61..e445bd99 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DarkAddressSpaceReasons.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DarkAddressSpaceReasons.cs
@@ -1,27 +1,29 @@
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
///
-/// Shared xUnit Skip reason constants for OpcUaServer tests whose intent is retained but
-/// whose subject is not materialized in the v3 Batch-1 DARK address space. The composer
-/// () and the artifact both emit an EMPTY equipment-tag
-/// variable plan set this batch — raw + UNS variable nodes are lit up in Batch 4's dual-namespace
-/// UNS↔Raw fan-out. Until then any assertion whose subject is an equipment-tag variable node (its
-/// FullName, its {{equip}}-token-derived base, its device-host binding) has nothing to
-/// compare, so it is Skipped-with-reason rather than deleted. Mirrors
-/// Runtime.Tests.Drivers.DarkAddressSpaceReasons.
+/// Shared xUnit Skip reason constants for OpcUaServer tests whose subject was RETIRED by v3
+/// rather than merely deferred — architectural tombstones that keep the removed coverage discoverable
+/// and name what replaced it, so nobody re-writes a test for a concept that no longer exists.
+/// A skip reason is a claim with an expiry date. The one this file used to carry
+/// (EquipmentTagsDarkBatch4, "dark until Batch 4") outlived its own condition by a release —
+/// see the note below. If you add one here, state the condition precisely enough that a reader can
+/// check whether it has already happened.
+/// Mirrors Runtime.Tests.Drivers.DarkAddressSpaceReasons.
///
internal static class DarkAddressSpaceReasons
{
- ///
- /// Equipment-tag variable materialization + its per-field intent (FullName / array / historize /
- /// native-alarm / {{equip}}-token base derived from child-tag FullNames) is dark until Batch 4.
- ///
- public const string EquipmentTagsDarkBatch4 =
- "v3 dark address space: equipment-tag variable plans (FullName / array / historize / alarm / " +
- "{{equip}}-token base derived from child-tag FullNames) do not materialize until Batch 4 " +
- "(dual-namespace UNS↔Raw fan-out). The composer emits an empty equipment-tag plan set this batch, " +
- "so there is no per-equipment tag base to substitute and no equipment-tag variable node to assert. " +
- "Migrate + re-enable when Batch 4 lights the raw/UNS variable nodes.";
+ // EquipmentTagsDarkBatch4 was RETIRED (2026-07-28) once its own unblock condition was examined. It read
+ // "dark until Batch 4"; Batch 4 shipped as v3.0 and RETIRED the equipment-tag plan set rather than
+ // lighting it, so every test wearing this reason was waiting on something that had already happened and
+ // gone the other way. Each was resolved on its merits rather than left to read as pending work:
+ // - revived onto the raw/UNS shape that replaced the subject (the real-SDK dual-namespace
+ // materialisation E2E in AddressSpaceApplierHierarchyTests; the cluster-scoping rebuild and the
+ // four primary-gate write/alarm cases in Runtime.Tests),
+ // - folded into a live parity test whose corpus was widened to cover them (array + native-alarm
+ // intent, now in DeploymentArtifactRawUnsParityTests),
+ // - or deleted, where the subject itself is retired (equipment-namespace EquipmentTags, the
+ // dot-joint {{equip}}.X token — superseded by the slash-joint form in
+ // DeploymentArtifactEquipRefParityTests).
/// 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/DarkAddressSpaceReasons.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DarkAddressSpaceReasons.cs
index 9632ae74..7267fdbe 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
@@ -1,22 +1,28 @@
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
///
-/// Shared xUnit Skip reason constants for tests whose intent is retained but whose subject is
-/// not materialized in the v3 Batch-1 DARK address space. Equipment-tag variable nodes (and their
-/// per-field intent: FullName / array / historize / native-alarm) are lit up by the Batch-4 dual
-/// namespace + UNS↔Raw fan-out; until then the composer/artifact both emit an EMPTY equipment-tag
-/// plan set, so any parity assertion over those plans has nothing to compare. The raw-tag parse
-/// intent these tests characterized lives on in (RawTagEntry
-/// round-trip) and TagConfigIntentTests (the parse unit).
+/// Shared xUnit Skip reason constants for tests whose subject was RETIRED by v3 rather than
+/// merely deferred — architectural tombstones that keep the removed coverage discoverable and name
+/// what replaced it, so nobody re-writes a test for a concept that no longer exists.
+/// A skip reason is a claim with an expiry date. The one this file used to carry
+/// (EquipmentTagsDarkBatch4, "dark until Batch 4") outlived its own condition by a release —
+/// see the note below. If you add one here, state the condition precisely enough that a reader can
+/// check whether it has already happened.
///
internal static class DarkAddressSpaceReasons
{
- /// Equipment-tag variable materialization + its per-field intent is dark until Batch 4.
- public const string EquipmentTagsDarkBatch4 =
- "v3 dark address space: equipment-tag variable plans (FullName/array/historize/alarm) do not " +
- "materialize until Batch 4 (dual-namespace UNS↔Raw fan-out). The composer + artifact both emit an " +
- "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.";
+ // EquipmentTagsDarkBatch4 was RETIRED (2026-07-28) once its own unblock condition was examined. It read
+ // "dark until Batch 4"; Batch 4 shipped as v3.0 and RETIRED the equipment-tag plan set rather than
+ // lighting it, so every test wearing this reason was waiting on something that had already happened and
+ // gone the other way. Each was resolved on its merits rather than left to read as pending work:
+ // - revived onto the raw/UNS shape that replaced the subject (the real-SDK dual-namespace
+ // materialisation E2E in AddressSpaceApplierHierarchyTests; the cluster-scoping rebuild and the
+ // four primary-gate write/alarm cases in Runtime.Tests),
+ // - folded into a live parity test whose corpus was widened to cover them (array + native-alarm
+ // intent, now in DeploymentArtifactRawUnsParityTests),
+ // - or deleted, where the subject itself is retired (equipment-namespace EquipmentTags, the
+ // dot-joint {{equip}}.X token — superseded by the slash-joint form in
+ // DeploymentArtifactEquipRefParityTests).
// 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
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactAliasParityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactAliasParityTests.cs
deleted file mode 100644
index 86446705..00000000
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactAliasParityTests.cs
+++ /dev/null
@@ -1,32 +0,0 @@
-using Xunit;
-
-namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
-
-///
-/// Galaxy-as-ordinary-equipment-tag byte-parity: a Galaxy point (equipment-scoped tag bound to a
-/// GalaxyMxGateway driver) surfaced into EquipmentTags with the same FullName /
-/// EquipmentId / DriverInstanceId / AccessLevel→Writable / native-alarm intent on both the composer
-/// and the artifact-decode seam.
-///
-/// v3 DARK (Batch 1): equipment-tag variable plans do not materialize — both producers emit an
-/// EMPTY EquipmentTags set until the Batch-4 dual-namespace UNS↔Raw fan-out. Additionally, the
-/// v3 schema retired the Namespace/NamespaceKind entities and the Galaxy
-/// ExplicitFullName / namespace-Kind gate this test keyed off; Galaxy is an ordinary
-/// Device-bound driver whose raw tags reach the UNS via UnsTagReference. The AccessLevel→Writable
-/// and native-alarm parse this asserted are covered at the parse unit (TagConfigIntentTests) and
-/// the RawTagEntry round-trip (). Unskip + rewrite to the
-/// Batch-4 UNS-projection shape when equipment-tag plans light up.
-///
-///
-public sealed class DeploymentArtifactAliasParityTests
-{
- /// Batch-4 pending: composer and artifact agree on a Galaxy equipment tag surfaced in
- /// EquipmentTags (FullName + Writable + native alarm). See class remarks — dark this batch.
- [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
- public void Composer_and_artifact_agree_on_galaxy_equipment_tag()
- {
- // Restored + rewritten to the Batch-4 UNS-projection shape when equipment-tag variable plans
- // materialize: assert a Galaxy point + a Modbus point round-trip identically (FullName, Writable
- // from AccessLevel, native-alarm intent incl. historizeToAveva) across composer and artifact.
- }
-}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactArrayParityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactArrayParityTests.cs
deleted file mode 100644
index b0f5b6f1..00000000
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactArrayParityTests.cs
+++ /dev/null
@@ -1,28 +0,0 @@
-using Xunit;
-
-namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
-
-///
-/// Phase 4c array intent (isArray + optional arrayLength) byte-parity between the two
-/// equipment-tag producers.
-///
-/// v3 DARK (Batch 1): equipment-tag variable plans do not materialize — the composer and the
-/// artifact both emit an EMPTY EquipmentTags set until the Batch-4 dual-namespace UNS↔Raw
-/// fan-out, so there is nothing to compare. The underlying parse of isArray / arrayLength
-/// from a raw TagConfig blob is characterized today by TagConfigIntentTests and
-/// round-tripped through the artifact by (RawTagEntry
-/// byte-preservation). Unskip when Batch 4 lights the equipment-tag plans.
-///
-///
-public sealed class DeploymentArtifactArrayParityTests
-{
- /// Batch-4 pending: composer and artifact agree on array equipment-tag plans
- /// (IsArray + ArrayLength). See class remarks — equipment-tag plans are dark this batch.
- [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
- public void Composer_and_artifact_agree_on_array_equipment_tags()
- {
- // Restored when Batch 4 materializes equipment-tag variable plans: compose a draft exercising every
- // array branch (isArray:true+length, absent, isArray:true no length, non-number length) and assert
- // decoded EquipmentTags == composed element-wise.
- }
-}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactEquipTokenTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactEquipTokenTests.cs
deleted file mode 100644
index 2759d519..00000000
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactEquipTokenTests.cs
+++ /dev/null
@@ -1,74 +0,0 @@
-using System.Text.Json;
-using Shouldly;
-using Xunit;
-using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
-
-namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
-
-///
-/// Verifies the artifact-decode mirror substitutes the reserved {{equip}} token in a
-/// VirtualTag script's ctx.GetTag("…") literals with the owning equipment's tag base
-/// (derived from its child Equipment-namespace tag's FullName) — byte-parity with
-/// AddressSpaceComposer.Compose's live-edit path, using the same shared
-/// EquipmentScriptPaths helper and the same equipmentTags-derived base.
-///
-public sealed class DeploymentArtifactEquipTokenTests
-{
- // v3 dark: the {{equip}} tag base is DERIVED from the owning equipment's child equipment-tag FullNames,
- // and equipment-tag plans do not materialize until Batch 4 — so baseByEquip is empty and {{equip}} has
- // no per-equipment base to substitute. Unskip when Batch 4 lights the equipment-tag plans.
- [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
- public void ParseComposition_substitutes_equip_token_in_virtual_tag_expression()
- {
- var blob = JsonSerializer.SerializeToUtf8Bytes(new
- {
- Namespaces = new[]
- {
- new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment
- },
- DriverInstances = new[]
- {
- new { DriverInstanceId = "drv-modbus", DriverType = "Modbus", DriverConfig = "{}", NamespaceId = "ns-eq" },
- },
- Tags = new object[]
- {
- new
- {
- TagId = "tag-source",
- DriverInstanceId = "drv-modbus",
- EquipmentId = "TestMachine_001",
- Name = "Source",
- FolderPath = (string?)null,
- DataType = "Float",
- TagConfig = "{\"FullName\":\"TestMachine_001.Source\"}",
- },
- },
- Scripts = new[]
- {
- new
- {
- ScriptId = "s-equip",
- SourceCode = "return System.Convert.ToInt32(ctx.GetTag(\"{{equip}}.Source\").Value) > 50;",
- },
- },
- VirtualTags = new[]
- {
- new
- {
- VirtualTagId = "vt-equip",
- EquipmentId = "TestMachine_001",
- Name = "OverThreshold",
- DataType = "Boolean",
- ScriptId = "s-equip",
- },
- },
- });
-
- var c = DeploymentArtifact.ParseComposition(blob);
-
- var vt = c.EquipmentVirtualTags.ShouldHaveSingleItem();
- vt.Expression.ShouldContain("ctx.GetTag(\"TestMachine_001.Source\")");
- vt.Expression.ShouldNotContain("{{equip}}");
- vt.DependencyRefs.ShouldBe(new[] { "TestMachine_001.Source" });
- }
-}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactHistorizeParityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactHistorizeParityTests.cs
deleted file mode 100644
index 02c11f0e..00000000
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactHistorizeParityTests.cs
+++ /dev/null
@@ -1,30 +0,0 @@
-using Xunit;
-
-namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
-
-///
-/// Phase C HistoryRead intent (isHistorized + optional historianTagname) byte-parity
-/// between the two equipment-tag producers.
-///
-/// v3 DARK (Batch 1): equipment-tag variable plans do not materialize —
-/// AddressSpaceComposer.Compose and DeploymentArtifact.ParseComposition both emit an
-/// EMPTY EquipmentTags set until the Batch-4 dual-namespace UNS↔Raw fan-out. There is nothing
-/// to compare, so the parity assertion is parked. The underlying parse of isHistorized /
-/// historianTagname from a raw TagConfig blob is characterized today by
-/// TagConfigIntentTests (the parse unit) and round-tripped through the artifact by
-/// (RawTagEntry byte-preservation). Unskip + restore the
-/// composer↔artifact equipment-tag comparison when Batch 4 lights the equipment-tag plans.
-///
-///
-public sealed class DeploymentArtifactHistorizeParityTests
-{
- /// Batch-4 pending: composer and artifact agree on historized equipment-tag plans
- /// (IsHistorized + HistorianTagname). See class remarks — equipment-tag plans are dark this batch.
- [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
- public void Composer_and_artifact_agree_on_historized_equipment_tags()
- {
- // Restored when Batch 4 materializes equipment-tag variable plans: compose a draft with a
- // historized tag (no explicit tagname → HistorianTagname null), a historized tag WITH an explicit
- // historianTagname override, and a plain tag; assert decoded EquipmentTags == composed element-wise.
- }
-}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactRawUnsParityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactRawUnsParityTests.cs
index fe2cc5ce..21acd7ff 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactRawUnsParityTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactRawUnsParityTests.cs
@@ -23,8 +23,15 @@ public sealed class DeploymentArtifactRawUnsParityTests
[Fact]
public void Raw_and_uns_node_sets_are_byte_parity_between_compose_and_artifact_decode()
{
- // Entity-side config: folder → driver → device → group → 2 tags (one historized+writable+override,
- // one plain read-only), an area/line/equipment, and a UNS reference with a display-name override.
+ // Entity-side config: folder → driver → device → group → 4 tags, an area/line/equipment, and a UNS
+ // reference with a display-name override. The four tags cover every per-field intent RawTagPlan
+ // carries: historize (+ tagname override), plain read-only, ARRAY (isArray + arrayLength), and
+ // NATIVE ALARM (a TagConfig.alarm object → EquipmentTagAlarmInfo).
+ //
+ // The array and alarm tags were folded in here when the three placeholder suites
+ // (DeploymentArtifact{Array,Historize,Alias}ParityTests) were deleted — they had been parked on
+ // "unskip when Batch 4 lights the equipment-tag plans", but Batch 4 RETIRED equipment-tag plans
+ // instead of lighting them, so their subject moved onto the raw tag and belongs in this comparison.
var folder = new RawFolder { RawFolderId = "fld-1", ClusterId = "c1", Name = "Cell1", ParentRawFolderId = null };
var driver = new DriverInstance { DriverInstanceId = "drv-1", ClusterId = "c1", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}", RawFolderId = "fld-1" };
var device = new Device { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "Dev1", DeviceConfig = "{}" };
@@ -39,6 +46,17 @@ public sealed class DeploymentArtifactRawUnsParityTests
TagId = "tag-run", DeviceId = "dev-1", TagGroupId = null, Name = "Run", DataType = "Boolean",
AccessLevel = TagAccessLevel.Read, TagConfig = "{}",
};
+ var profile = new Tag
+ {
+ TagId = "tag-profile", DeviceId = "dev-1", TagGroupId = "grp-1", Name = "Profile", DataType = "Float",
+ AccessLevel = TagAccessLevel.Read, TagConfig = "{\"isArray\":true,\"arrayLength\":16}",
+ };
+ var tempHi = new Tag
+ {
+ TagId = "tag-temphi", DeviceId = "dev-1", TagGroupId = null, Name = "TempHi", DataType = "Boolean",
+ AccessLevel = TagAccessLevel.ReadWrite,
+ TagConfig = "{\"alarm\":{\"alarmType\":\"OffNormalAlarm\",\"severity\":700}}",
+ };
var area = new UnsArea { UnsAreaId = "a1", ClusterId = "c1", Name = "filling" };
var line = new UnsLine { UnsLineId = "l1", UnsAreaId = "a1", Name = "line1" };
var equip = new Equipment { EquipmentId = "EQ-1", UnsLineId = "l1", Name = "station1", MachineCode = "M1" };
@@ -47,7 +65,7 @@ public sealed class DeploymentArtifactRawUnsParityTests
var composed = AddressSpaceComposer.Compose(
new[] { area }, new[] { line }, new[] { equip },
new[] { driver }, Array.Empty(),
- unsTagReferences: new[] { reference }, tags: new[] { speed, run },
+ unsTagReferences: new[] { reference }, tags: new[] { speed, run, profile, tempHi },
rawFolders: new[] { folder }, devices: new[] { device }, tagGroups: new[] { group });
// Artifact JSON — the same shape ConfigComposer serializes (enums numeric: ReadWrite = 1, Read = 0).
@@ -61,6 +79,8 @@ public sealed class DeploymentArtifactRawUnsParityTests
{
new { TagId = "tag-speed", DeviceId = "dev-1", TagGroupId = "grp-1", Name = "Speed", DataType = "Float", AccessLevel = 1, TagConfig = "{\"isHistorized\":true,\"historianTagname\":\"WW.Speed\"}" },
new { TagId = "tag-run", DeviceId = "dev-1", TagGroupId = (string?)null, Name = "Run", DataType = "Boolean", AccessLevel = 0, TagConfig = "{}" },
+ new { TagId = "tag-profile", DeviceId = "dev-1", TagGroupId = "grp-1", Name = "Profile", DataType = "Float", AccessLevel = 0, TagConfig = "{\"isArray\":true,\"arrayLength\":16}" },
+ new { TagId = "tag-temphi", DeviceId = "dev-1", TagGroupId = (string?)null, Name = "TempHi", DataType = "Boolean", AccessLevel = 1, TagConfig = "{\"alarm\":{\"alarmType\":\"OffNormalAlarm\",\"severity\":700}}" },
},
UnsAreas = new[] { new { UnsAreaId = "a1", Name = "filling", ClusterId = "c1" } },
UnsLines = new[] { new { UnsLineId = "l1", UnsAreaId = "a1", Name = "line1" } },
@@ -70,8 +90,19 @@ public sealed class DeploymentArtifactRawUnsParityTests
var decoded = DeploymentArtifact.ParseComposition(blob);
- // Sanity: the sets are non-empty (both raw tags + the projected UNS variable materialised).
- composed.RawTags.Count.ShouldBe(2);
+ // Sanity: the sets are non-empty (all four raw tags + the projected UNS variable materialised).
+ composed.RawTags.Count.ShouldBe(4);
+
+ // Sanity on the two folded-in branches — without these, a composer that dropped array/alarm intent
+ // ENTIRELY would still pass the parity assertion below, because the artifact decode would drop it too
+ // and the two empties would compare equal. Parity alone cannot tell "both right" from "both blind".
+ var composedArray = composed.RawTags.Single(t => t.Name == "Profile");
+ composedArray.IsArray.ShouldBeTrue();
+ composedArray.ArrayLength.ShouldBe(16u);
+ var composedAlarm = composed.RawTags.Single(t => t.Name == "TempHi");
+ composedAlarm.Alarm.ShouldNotBeNull();
+ composedAlarm.Alarm!.AlarmType.ShouldBe("OffNormalAlarm");
+ composedAlarm.Writable.ShouldBeTrue();
composed.UnsReferenceVariables.Count.ShouldBe(1);
composed.UnsReferenceVariables[0].NodeId.ShouldBe("filling/line1/station1/MotorSpeed");
composed.UnsReferenceVariables[0].BackingRawPath.ShouldBe("Cell1/Modbus/Dev1/Fast/Speed");
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactTests.cs
index fe046209..defb41e0 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactTests.cs
@@ -190,69 +190,6 @@ public sealed class DeploymentArtifactTests
c.ScriptedAlarmPlans.Single().ScriptedAlarmId.ShouldBe("alarm-1");
}
- ///
- /// Verifies ParseComposition surfaces Equipment-namespace tags (non-null EquipmentId in an
- /// Equipment-kind namespace) as EquipmentTags, with FullName extracted
- /// from the tag's TagConfig blob. A tag in a non-Equipment (Simulated) namespace with a
- /// null EquipmentId must NOT surface in EquipmentTags — byte-parity with the composer's pure
- /// ns.Kind == NamespaceKind.Equipment predicate (only Equipment-kind namespaces route
- /// into EquipmentTags, so such a tag routes nowhere).
- ///
- [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
- public void ParseComposition_reads_EquipmentTags_from_equipment_namespace()
- {
- var blob = JsonSerializer.SerializeToUtf8Bytes(new
- {
- Namespaces = new[]
- {
- new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment
- new { NamespaceId = "ns-sim", Kind = 1 }, // NamespaceKind.Simulated (non-Equipment)
- },
- DriverInstances = new[]
- {
- new { DriverInstanceId = "drv-modbus", DriverType = "Modbus", DriverConfig = "{}", NamespaceId = "ns-eq" },
- new { DriverInstanceId = "drv-sim", DriverType = "Modbus", DriverConfig = "{}", NamespaceId = "ns-sim" },
- },
- Tags = new object[]
- {
- new
- {
- TagId = "tag-eq",
- DriverInstanceId = "drv-modbus",
- EquipmentId = "eq-1",
- Name = "Speed",
- FolderPath = (string?)null,
- DataType = "Float",
- TagConfig = "{\"FullName\":\"40001\"}",
- },
- new
- {
- TagId = "tag-sim",
- DriverInstanceId = "drv-sim",
- EquipmentId = (string?)null,
- Name = "Temp",
- FolderPath = "area",
- DataType = "Float",
- TagConfig = "{\"FullName\":\"area.Temp\"}",
- },
- },
- });
-
- var c = DeploymentArtifact.ParseComposition(blob);
-
- var tag = c.EquipmentTags.ShouldHaveSingleItem();
- tag.TagId.ShouldBe("tag-eq");
- tag.EquipmentId.ShouldBe("eq-1");
- tag.DriverInstanceId.ShouldBe("drv-modbus");
- tag.Name.ShouldBe("Speed");
- tag.DataType.ShouldBe("Float");
- tag.FullName.ShouldBe("40001"); // extracted from TagConfig, not the raw blob
-
- // The tag in the non-Equipment (Simulated) namespace, with null EquipmentId, does NOT leak
- // into EquipmentTags — byte-parity with the composer's pure ns.Kind == Equipment predicate.
- c.EquipmentTags.ShouldNotContain(t => t.TagId == "tag-sim");
- }
-
///
/// Verifies ParseComposition surfaces Equipment-namespace VirtualTags (joined to their Script
/// by ScriptId for the expression source) as EquipmentVirtualTags, with the
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorPrimaryGateTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorPrimaryGateTests.cs
index 0b8ce73e..eb0e4e5c 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorPrimaryGateTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorPrimaryGateTests.cs
@@ -39,6 +39,17 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
private static readonly DateTime Ts = new(2026, 7, 13, 10, 0, 0, DateTimeKind.Utc);
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
+ /// v3 RawPath of the seeded write tag: RawFolder "Plant" / driver Name "Modbus" / Device "dev1" /
+ /// Tag "speed". Its UNS projection (Area "filling" / Line "line1" / Equipment "station1") is
+ /// .
+ private const string WriteRawPath = "Plant/Modbus/dev1/speed";
+ private const string WriteUnsNodeId = "filling/line1/station1/speed";
+
+ /// v3 RawPath of the seeded alarm tag. In v3 a native alarm materializes ONCE at the raw tag with
+ /// ConditionId == RawPath, so this is both the wire-ref the driver raises and the condition
+ /// NodeId the host routes to.
+ private const string AlarmRawPath = "Plant/Modbus/dev1/temp_hi";
+
// ---------------- write gate ----------------
/// Role unknown + a real driver peer (count 2) ⇒ RouteNodeWrite is DENIED with the
@@ -52,7 +63,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
var actor = SpawnWriteHost(db, dep, recorder, driverMemberCount: 2);
var asker = CreateTestProbe();
- actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0, AddressSpaceRealm.Uns), asker.Ref);
+ actor.Tell(new DriverHostActor.RouteNodeWrite(WriteUnsNodeId, 123.0, AddressSpaceRealm.Uns), asker.Ref);
var result = asker.ExpectMsg(Timeout);
result.Success.ShouldBeFalse();
@@ -62,7 +73,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
/// Role unknown + no driver peer (count 1) ⇒ the write is SERVICED (single-node / boot-window
/// posture preserved).
- [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
+ [Fact]
public void Unknown_role_single_driver_services_write()
{
var db = NewInMemoryDbFactory();
@@ -76,7 +87,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
AwaitAssert(() =>
{
var asker = CreateTestProbe();
- actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0, AddressSpaceRealm.Uns), asker.Ref);
+ actor.Tell(new DriverHostActor.RouteNodeWrite(WriteUnsNodeId, 123.0, AddressSpaceRealm.Uns), asker.Ref);
var res = asker.ExpectMsg(Timeout);
res.Reason.ShouldNotBe("not primary");
res.Reason.ShouldNotBe("not primary (role unknown)");
@@ -87,7 +98,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
/// A Primary snapshot services even at count 2; a Secondary snapshot denies with the steady-state
/// reason (distinct from the boot-window reason).
- [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
+ [Fact]
public void Known_role_wins_over_member_count()
{
var db = NewInMemoryDbFactory();
@@ -101,7 +112,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
AwaitAssert(() =>
{
var asker1 = CreateTestProbe();
- actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 1.0, AddressSpaceRealm.Uns), asker1.Ref);
+ actor.Tell(new DriverHostActor.RouteNodeWrite(WriteUnsNodeId, 1.0, AddressSpaceRealm.Uns), asker1.Ref);
var res = asker1.ExpectMsg(Timeout);
res.Reason.ShouldNotBe("not primary");
res.Success.ShouldBeTrue(res.Reason);
@@ -110,7 +121,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
// Secondary snapshot ⇒ denied with the steady-state reason.
TellRole(actor, RedundancyRole.Secondary);
var asker2 = CreateTestProbe();
- actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 2.0, AddressSpaceRealm.Uns), asker2.Ref);
+ actor.Tell(new DriverHostActor.RouteNodeWrite(WriteUnsNodeId, 2.0, AddressSpaceRealm.Uns), asker2.Ref);
var denied = asker2.ExpectMsg(Timeout);
denied.Success.ShouldBeFalse();
denied.Reason.ShouldBe("not primary");
@@ -127,7 +138,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
var actor = SpawnWriteHost(db, dep, factory, driverMemberCount: 2);
var asker = CreateTestProbe();
- actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 9.0, AddressSpaceRealm.Uns), asker.Ref);
+ actor.Tell(new DriverHostActor.RouteNodeWrite(WriteUnsNodeId, 9.0, AddressSpaceRealm.Uns), asker.Ref);
asker.ExpectMsg(Timeout).Success.ShouldBeFalse();
AwaitAssert(() =>
@@ -151,7 +162,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
var (actor, _) = SpawnAlarmHost(db, dep, driverMemberCount: 2);
EventFilter.Warning(contains: "role unknown").ExpectOne(() =>
- actor.Tell(new DriverHostActor.RouteNativeAlarmAck("eq-1/temp_hi", Comment: null, OperatorUser: "op")));
+ actor.Tell(new DriverHostActor.RouteNativeAlarmAck(AlarmRawPath, Comment: null, OperatorUser: "op")));
AwaitAssert(() =>
{
@@ -163,7 +174,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
/// Role unknown + count 2 ⇒ ForwardNativeAlarm still writes the (ungated) OPC UA condition update
/// but publishes NO cluster-wide alerts transition.
- [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
+ [Fact]
public void Unknown_role_multi_driver_suppresses_alerts_emit_but_updates_condition()
{
var db = NewInMemoryDbFactory();
@@ -175,14 +186,14 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
actor.Tell(RaiseAlarm());
// The ungated OPC UA condition write still arrives.
- publish.ExpectMsg(Timeout).AlarmNodeId.ShouldBe("eq-1/temp_hi");
+ publish.ExpectMsg(Timeout).AlarmNodeId.ShouldBe(AlarmRawPath);
// The cluster-wide alerts publish is suppressed (a real Primary peer publishes the single copy).
alerts.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
/// Role unknown + count 1 ⇒ ForwardNativeAlarm publishes the alerts transition (single-node
/// posture preserved).
- [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
+ [Fact]
public void Unknown_role_single_driver_publishes_alerts_emit()
{
var db = NewInMemoryDbFactory();
@@ -194,7 +205,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
actor.Tell(RaiseAlarm());
publish.ExpectMsg(Timeout);
- alerts.ExpectMsg(Timeout).AlarmId.ShouldBe("eq-1/temp_hi");
+ alerts.ExpectMsg(Timeout).AlarmId.ShouldBe(AlarmRawPath);
}
// ---------------- helpers ----------------
@@ -202,8 +213,8 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
private static DriverInstanceActor.AttributeAlarmPublished RaiseAlarm() =>
new("drv-1", new AlarmEventArgs(
new StubAlarmHandle(),
- SourceNodeId: "Temp",
- ConditionId: "Temp.HiHi",
+ SourceNodeId: "Temp", // bare owning object — deliberately NOT the lookup key
+ ConditionId: AlarmRawPath, // v3: the condition is keyed by the raw tag's RawPath
AlarmType: "OffNormalAlarm",
Message: "temperature high",
Severity: AlarmSeverity.High,
@@ -266,60 +277,66 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
probe.ExpectMsg(Timeout);
}
+ ///
+ /// Seeds the v3 raw-tag chain a write must traverse: RawFolder "Plant" → DriverInstance "drv-1"
+ /// (Modbus, Enabled so a real child spawns and can service the write) → Device "dev1" → Tag
+ /// "speed" (, ReadWrite), projected into equipment
+ /// filling/line1/station1 by a UnsTagReference so resolves.
+ /// The pre-v3 shape this replaced seeded Namespaces + equipment-bound Tags, which
+ /// v3 parses to an EMPTY tag set — the write had nothing to route to, which is why the
+ /// write-is-serviced cases were skipped rather than failing.
+ ///
private static DeploymentId SeedWriteTagDeployment(IDbContextFactory db)
{
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
{
- Namespaces = new[] { new { NamespaceId = "ns-eq", Kind = 0 } },
+ RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
DriverInstances = new[]
{
- new
- {
- DriverInstanceRowId = Guid.NewGuid(),
- DriverInstanceId = "drv-1",
- Name = "drv-1",
- DriverType = "Modbus",
- Enabled = true,
- DriverConfig = "{}",
- NamespaceId = "ns-eq",
- },
+ new { DriverInstanceId = "drv-1", RawFolderId = "rf-plant", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = true },
},
+ Devices = new[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "dev1", DeviceConfig = "{}" } },
+ TagGroups = Array.Empty