Files
lmxopcua/docs/plans/2026-06-07-equipment-namespace-live-values.md
T

404 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Equipment-Namespace Live Values (VirtualTag route) Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task.
**Goal:** Make Equipment-kind namespace signals carry live `Good` OPC UA values by representing each as a `VirtualTag` whose script mirrors a live SystemPlatform (Galaxy-mirror) tag in-process — completing WS-3a of the equipment-namespace materialization scope.
**Architecture:** The structure milestone (`febe462..9a67ebc`) already materialises the Area/Line/Equipment/Signal *folders+variables* for Equipment **Tag** rows (folder-scoped NodeId, `BadWaitingForInitialData`). This milestone adds the **VirtualTag** path: carry `VirtualTag`(+`Script`) rows through the composition/artifact, materialise their variables, and stand up the production VirtualTag actor wiring — a per-deployment `VirtualTagHostActor` that spawns one already-built `VirtualTagActor` per VirtualTag, subscribes each to the `DependencyMuxActor` for its dependency ref(s), and bridges each `EvaluationResult` into `OpcUaPublishActor.AttributeValueUpdate` under the VirtualTag variable's **folder-scoped NodeId**. Cross-namespace reads work because the mux keys subscriptions on a flat `FullReference` string with no namespace scoping. Live Galaxy values already reach the mux via `DriverHostActor.ForwardToMux` (commit `b1b3f3f`).
**Tech Stack:** .NET 10, Akka.NET actors, Roslyn-scripted VirtualTags, OPC UA (sink-based address space), MSSQL config DB, Python loader (pymssql + asyncua).
**Repos touched:** `~/Desktop/OtOpcUa` (the engine work, Tasks 06, 8) and `~/Desktop/scadaproj/otopcua-uns-loader` (the loader + verify, Task 7). Do the OtOpcUa work on a feature branch `feat/equipment-namespace-live-values` off `master` (9a67ebc) — **do not commit on `master`**.
---
## Background: the load-bearing facts (verified in code 2026-06-07)
These were confirmed by reading the actually-wired code, and some **contradict** the convenience summaries that cite `EquipmentNodeWalker` (which is built but **unwired** — ignore it; the live path is the sink-based `Phase7Applier`):
1. **Materialised Equipment variable NodeId is FOLDER-SCOPED, not `FullName` and not `VirtualTagId`.**
`Phase7Applier.MaterialiseEquipmentTags` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs:215-228`) sets `nodeId = $"{parent}/{tag.Name}"` where `parent` is the equipment folder (or a per-tag sub-folder). The published value must therefore carry **that** NodeId.
2. **`VirtualTagActor` is fully built** (`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs`): `Props(virtualTagId, expression, evaluator, scriptId, publisherFactory, dependencyRefs, mux)`. It self-registers with the mux in `PreStart` (`RegisterInterest(_dependencyRefs, Self)`), evaluates on `DependencyValueChanged`, dedups, and `Context.Parent.Tell(new EvaluationResult(VirtualTagId, value, ts, corr))` (line 147). It is **never spawned in production**.
3. **`DependencyMuxActor`** (`.../VirtualTags/DependencyMuxActor.cs`): `RegisterInterest(IReadOnlyList<string> TagRefs, IActorRef Subscriber)`; `_byRef` is `Dictionary<string, HashSet<IActorRef>>` keyed by the flat `FullReference`**no namespace scoping** (this is what makes cross-namespace mirroring work). On `AttributeValuePublished(FullReference,…)` it fans out `DependencyValueChanged(FullReference, value, ts)` to subscribers.
4. **`RoslynVirtualTagEvaluator`** (`src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynVirtualTagEvaluator.cs`): `Evaluate(virtualTagId, expression, IReadOnlyDictionary<string,object?> dependencies)`. Scripts read deps via `ctx.GetTag("ref").Value`; the dictionary key is the **ref string** (e.g. `TestMachine_001.TestChangingInt`). It is already registered in the Host (`Host/Program.cs`).
5. **VirtualTag declares no explicit dependency list** — deps are the set of `ctx.GetTag("literal")` string literals in the script source.
6. **The artifact snapshot already includes `Scripts` and `VirtualTags`** (`ConfigComposer.SnapshotAndFlattenAsync`, `src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/ConfigComposer.cs:44-45`). **No seal-side change needed.** The gap is only on the *derive* side:
- `Phase7CompositionResult` has no VirtualTag list (`Phase7Composer.cs`).
- `DeploymentArtifact.ParseComposition` builds Galaxy + Equipment tag plans only (`BuildGalaxyTagPlans`, `BuildEquipmentTagPlans`) — no VirtualTag builder.
7. **Spawn/restore hook points** (`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs`): the fresh-apply path calls `PushDesiredSubscriptions(deploymentId)`; the restart path `RestoreApplied()` calls `ReconcileDrivers``RebuildAddressSpace``PushDesiredSubscriptions`. VirtualTag spawning must run on **both** (immediately after `PushDesiredSubscriptions` in each).
8. **Sink resolves the published string NodeId** to the variable created with that same NodeId (`OpcUaPublishActor.HandleAttributeUpdate``_sink.WriteValue(msg.NodeId, …)`). So if the VirtualTag variable is materialised with NodeId `eq/Name` and we publish `AttributeValueUpdate(NodeId="eq/Name")`, it lands.
**The crux:** `VirtualTagActor` emits `EvaluationResult` keyed by `VirtualTagId`, but the variable's NodeId is folder-scoped (`eq/Name`). The new `VirtualTagHostActor` (Task 5) holds the `VirtualTagId → folder-scoped-NodeId` map and translates at the bridge.
---
## Task graph / parallelism
```
T0 (record + composition member)
├─ T1 (composer populate) ┐ parallelizable with each other
├─ T2 (artifact parse) │ (disjoint files)
├─ T3 (Phase7Plan diff) ┘
└─ T5 (VirtualTagHostActor, new file) (parallelizable with T1/T2/T3)
T4 (applier + HandleRebuild) ← T1, T2, T3
T6 (DriverHostActor wiring + evaluator inject) ← T4, T5
T7 (loader VirtualTag rows + verify Good) — scadaproj repo, parallelizable with T1T6
T8 (docker-dev integration verify) ← T6, T7
T9 (docs + memory) ← T8
```
---
### Task 0: `EquipmentVirtualTagPlan` record + carry on `Phase7CompositionResult`
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** none (T1, T2, T3, T5 all depend on this)
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs` (add record + init-only member, mirroring `EquipmentTagPlan`/`EquipmentTags` at lines 58, 94-101)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ComposerTests.cs` (or the existing composer test file — find it)
**Step 1 — Write the failing test.** Assert the composition default-constructs `EquipmentVirtualTags` to empty and that the record carries the expected fields:
```csharp
[Fact]
public void Composition_carries_empty_equipment_virtualtags_by_default()
{
var r = new Phase7CompositionResult(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());
Assert.Empty(r.EquipmentVirtualTags);
}
[Fact]
public void EquipmentVirtualTagPlan_holds_id_equipment_name_datatype_expression_and_deps()
{
var p = new EquipmentVirtualTagPlan("vt-1", "eq-1", "", "speed-rpm", "Float64",
"return ctx.GetTag(\"TestMachine_001.TestDouble\").Value;",
new[] { "TestMachine_001.TestDouble" });
Assert.Equal("vt-1", p.VirtualTagId);
Assert.Equal("eq-1", p.EquipmentId);
Assert.Equal("speed-rpm", p.Name);
Assert.Single(p.DependencyRefs);
}
```
**Step 2 — Run, verify it fails to compile** (`EquipmentVirtualTagPlan` undefined).
Run: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ --filter "FullyQualifiedName~Phase7Composer"`
**Step 3 — Add the record + member.** In `Phase7Composer.cs`, after `EquipmentTagPlan` (line ~101) add:
```csharp
/// <summary>
/// One Equipment-namespace VirtualTag from a <see cref="VirtualTag"/> row (joined to its
/// <see cref="Script"/> for the expression). The VirtualTag value analogue of
/// <see cref="EquipmentTagPlan"/>: <c>Phase7Applier.MaterialiseEquipmentVirtualTags</c>
/// materialises each as a Variable under its equipment folder with a folder-scoped NodeId
/// (<c>EquipmentId/Name</c>, or <c>EquipmentId/FolderPath/Name</c> when a sub-folder is set),
/// and <c>VirtualTagHostActor</c> spawns a <c>VirtualTagActor</c> per plan that evaluates
/// <see cref="Expression"/> over <see cref="DependencyRefs"/> and publishes the value back to
/// that NodeId. <see cref="DependencyRefs"/> = the distinct <c>ctx.GetTag("…")</c> literals in
/// the script source.
/// </summary>
public sealed record EquipmentVirtualTagPlan(
string VirtualTagId,
string EquipmentId,
string FolderPath,
string Name,
string DataType,
string Expression,
IReadOnlyList<string> DependencyRefs);
```
And on `Phase7CompositionResult` (after the `EquipmentTags` member, line 58):
```csharp
/// <summary>Equipment-namespace VirtualTags. See <see cref="EquipmentVirtualTagPlan"/>. Init-only,
/// defaults empty so every existing constructor + call site keeps compiling.</summary>
public IReadOnlyList<EquipmentVirtualTagPlan> EquipmentVirtualTags { get; init; } = Array.Empty<EquipmentVirtualTagPlan>();
```
**Step 4 — Run the test, verify PASS.**
**Step 5 — Commit.** `git commit -m "feat(opcua): add EquipmentVirtualTagPlan to Phase7 composition"`
---
### Task 1: Populate `EquipmentVirtualTags` in `Phase7Composer.Compose`
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 2, Task 3, Task 5
**Files:**
- Read first: `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/VirtualTag.cs` and `.../Entities/Script.cs` (confirm exact field names: `VirtualTagId`, `EquipmentId`, `Name`, `DataType`, `ScriptId`; `Script.ScriptId`, `Script.Source`; note whether `VirtualTag` has a `FolderPath` — if not, pass `""`).
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs` (the 7-arg `Compose` overload at lines 155-241; it already receives `scriptedAlarms`**add a `virtualTags` + `scripts` parameter** to that overload and the convenience overloads, defaulting to empty so existing callers compile; thread it from the snapshot. Check the call site in `DeploymentArtifact`/driver-host build path consumes the composition from the artifact, not this overload — so the production producer is Task 2; this overload is used by tests + any direct composer caller).
- Test: the composer test file.
**Design note — dependency extraction.** Replicate the local-helper pattern already used for `ExtractTagFullName` (lines 253-268): add a private `static IReadOnlyList<string> ExtractDependencyRefs(string scriptSource)` that regex-matches `ctx.GetTag("<literal>")` and returns the distinct literals in source order. Do **not** add a project reference to `Core.VirtualTags` (OpcUaServer deliberately doesn't reference the driver/engine assemblies — see the `ExtractTagFullName` comment). Regex: `ctx\s*\.\s*GetTag\s*\(\s*"([^"]+)"\s*\)`.
**Step 1 — Write failing tests.** Given one `VirtualTag{VirtualTagId="vt-1", EquipmentId="eq-1", Name="speed-rpm", DataType="Float64", ScriptId="s-1"}` + `Script{ScriptId="s-1", Source="return ctx.GetTag(\"TestMachine_001.TestDouble\").Value;"}`, `Compose(...)` emits one `EquipmentVirtualTagPlan` with `Expression` = the source and `DependencyRefs` = `["TestMachine_001.TestDouble"]`. Add a second test: a script with two distinct `ctx.GetTag` calls yields two deps, de-duplicated.
**Step 2 — Run, verify fail.**
**Step 3 — Implement.** After the `equipmentTags` block (line ~235) add:
```csharp
var scriptsById = scripts.ToDictionary(s => s.ScriptId, StringComparer.Ordinal);
var equipmentVirtualTags = virtualTags
.OrderBy(v => v.EquipmentId, StringComparer.Ordinal)
.ThenBy(v => v.Name, StringComparer.Ordinal)
.Select(v =>
{
var src = scriptsById.TryGetValue(v.ScriptId, out var s) ? s.Source : string.Empty;
return new EquipmentVirtualTagPlan(
VirtualTagId: v.VirtualTagId,
EquipmentId: v.EquipmentId,
FolderPath: string.Empty, // VirtualTags hang directly under the equipment folder
Name: v.Name,
DataType: v.DataType,
Expression: src,
DependencyRefs: ExtractDependencyRefs(src));
})
.ToList();
```
Add `equipmentVirtualTags` to the returned object initializer (alongside `EquipmentTags`). Add the `ExtractDependencyRefs` helper.
**Step 4 — Run tests, verify PASS.**
**Step 5 — Commit.** `git commit -m "feat(opcua): compose Equipment VirtualTag plans from VirtualTag+Script rows"`
---
### Task 2: Parse `EquipmentVirtualTags` in `DeploymentArtifact`
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 1, Task 3, Task 5
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs` — add `BuildEquipmentVirtualTagPlans(JsonElement root)` mirroring `BuildEquipmentTagPlans` (lines 383-450); call it in **both** `ParseComposition` overloads (set `EquipmentVirtualTags = …` on the result at line ~199, and add the cluster-filter projection at line ~229). The snapshot arrays are `root.GetProperty("VirtualTags")` and `root.GetProperty("Scripts")` (PascalCase — `ConfigComposer` serialises entity property names; verify against the existing `"ScriptedAlarms"`/`"Tags"` reads). Re-derive `DependencyRefs` from `Script.Source` with the **same** regex as Task 1 (keep a single source of truth — put the extractor in a shared internal static helper if both assemblies can see it; otherwise replicate, matching the existing `ExtractFullName` replication pattern, and note the duplication in a comment).
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/` artifact round-trip test file (find the existing `DeploymentArtifact`/`ParseComposition` tests).
**Cluster-filter note:** VirtualTags have no `DriverInstanceId`. Filter the cluster-scoped overload by `EquipmentId` against `sets.EquipmentIds` (mirroring how `ScriptedAlarmPlans` is filtered at line 226: `.Where(a => sets.EquipmentIds.Contains(a.EquipmentId))`).
**Step 1 — Write a failing round-trip test.** Build a snapshot JSON (or use the existing test's snapshot builder) containing a `VirtualTags` array + a matching `Scripts` array; assert `ParseComposition(blob).EquipmentVirtualTags` has the expected single plan with the right `VirtualTagId`, `EquipmentId`, `Name`, `Expression`, and `DependencyRefs`.
**Step 2 — Run, verify fail.**
**Step 3 — Implement** `BuildEquipmentVirtualTagPlans` + wire into both overloads.
**Step 4 — Run tests, verify PASS.** Also run the full `Runtime.Tests` artifact suite to confirm no regression in existing parse tests.
**Step 5 — Commit.** `git commit -m "feat(opcua): parse Equipment VirtualTag plans from the deployment artifact"`
---
### Task 3: `Phase7Plan` diff dimension for Equipment VirtualTags
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 1, Task 2, Task 5
**Files:**
- Read first: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Plan.cs` (the diff record + the differ that produces `Added*/Removed*/Changed*` lists — see how `AddedEquipmentTags`/`RemovedEquipmentTags`/`ChangedEquipmentTags` are computed; `Phase7Applier.Apply` reads them at lines 73-85).
- Modify: `Phase7Plan.cs` — add `AddedEquipmentVirtualTags`/`RemovedEquipmentVirtualTags`/`ChangedEquipmentVirtualTags` (keyed by `VirtualTagId`), populated by the same diff routine that handles `EquipmentTags`.
- Modify: `Phase7Applier.cs` `Apply` — include the new lists in `addedCount`/`changedCount`/`removedCount` and in the `needsRebuild` predicate (lines 71-85), exactly like `EquipmentTags`.
- Test: `Phase7Plan` differ test file — add cases for added/removed/changed VirtualTags driving `needsRebuild = true`.
**Step 1 — Failing test:** a plan with one `AddedEquipmentVirtualTags` entry → `Phase7Applier.Apply` returns `RebuildCalled == true` and `AddedNodes >= 1`.
**Step 2 — Run, verify fail.**
**Step 3 — Implement** the diff dimension + applier accounting.
**Step 4 — Run tests, verify PASS.**
**Step 5 — Commit.** `git commit -m "feat(opcua): diff Equipment VirtualTags in Phase7Plan + rebuild trigger"`
---
### Task 4: Materialise Equipment VirtualTag variables + call in `HandleRebuild`
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (depends on T1, T2, T3)
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs` — add `MaterialiseEquipmentVirtualTags(Phase7CompositionResult composition)` mirroring `MaterialiseEquipmentTags` (lines 197-234) but reading `composition.EquipmentVirtualTags`. **NodeId must be folder-scoped exactly like the tag pass:** `parent = string.IsNullOrWhiteSpace(v.FolderPath) ? v.EquipmentId : EquipmentSubFolderNodeId(v.EquipmentId, v.FolderPath); nodeId = $"{parent}/{v.Name}"`; `SafeEnsureVariable(nodeId, parent, v.Name, v.DataType)`. Log `equipment virtualtags materialised (vtags=…, equipment=…)`.
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs` — in `HandleRebuild`, after the `MaterialiseEquipmentTags(composition)` call, add `applier.MaterialiseEquipmentVirtualTags(composition);` (same applier instance/order).
- Test: `Phase7ApplierTests` — a composition with one `EquipmentVirtualTagPlan` ensures exactly one Variable at the folder-scoped NodeId with `BadWaitingForInitialData` (use the capturing/fake `IOpcUaAddressSpaceSink`).
**Step 1 — Failing test** asserting the captured sink got `EnsureVariable("eq-1/speed-rpm", "eq-1", "speed-rpm", "Float64")`.
**Step 2 — Run, verify fail.**
**Step 3 — Implement** `MaterialiseEquipmentVirtualTags` + the `HandleRebuild` call.
**Step 4 — Run tests, verify PASS.** Run the full `OpcUaServer.Tests` to confirm no materialiser regression.
**Step 5 — Commit.** `git commit -m "feat(opcua): materialise Equipment VirtualTag variables on rebuild"`
---
### Task 5: `VirtualTagHostActor` — spawn, subscribe, bridge results
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 1, Task 2, Task 3
**Files:**
- Read first: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs` (Props + `EvaluationResult` shape) and `.../OpcUa/OpcUaPublishActor.cs` (the `AttributeValueUpdate` record shape + its fully-qualified name, used in `DriverHostActor.ForwardToMux`).
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs`
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs` (Akka.TestKit — find the existing TestKit base used by the runtime tests).
**Behaviour.** A supervisor that owns the live set of VirtualTag child actors and bridges their results to the publish actor:
```csharp
public sealed class VirtualTagHostActor : ReceiveActor
{
public sealed record ApplyVirtualTags(IReadOnlyList<EquipmentVirtualTagPlan> Plans);
private readonly IActorRef _publishActor;
private readonly IActorRef? _mux;
private readonly IVirtualTagEvaluator _evaluator;
private readonly Dictionary<string, IActorRef> _children = new(StringComparer.Ordinal); // vtagId -> child
private readonly Dictionary<string, string> _nodeIdByVtag = new(StringComparer.Ordinal); // vtagId -> folder-scoped NodeId
public static Props Props(IActorRef publishActor, IActorRef? mux, IVirtualTagEvaluator evaluator) =>
Akka.Actor.Props.Create(() => new VirtualTagHostActor(publishActor, mux, evaluator));
public VirtualTagHostActor(IActorRef publishActor, IActorRef? mux, IVirtualTagEvaluator evaluator)
{
_publishActor = publishActor; _mux = mux; _evaluator = evaluator;
Receive<ApplyVirtualTags>(OnApply);
Receive<VirtualTagActor.EvaluationResult>(OnResult);
}
private void OnApply(ApplyVirtualTags msg)
{
var desired = msg.Plans.ToDictionary(p => p.VirtualTagId, StringComparer.Ordinal);
// Stop children no longer present.
foreach (var id in _children.Keys.Where(k => !desired.ContainsKey(k)).ToList())
{
Context.Stop(_children[id]);
_children.Remove(id); _nodeIdByVtag.Remove(id);
}
// Spawn newly-added children; rebuild the NodeId map for all.
foreach (var p in msg.Plans)
{
var parent = string.IsNullOrWhiteSpace(p.FolderPath) ? p.EquipmentId : $"{p.EquipmentId}/{p.FolderPath}";
_nodeIdByVtag[p.VirtualTagId] = $"{parent}/{p.Name}";
if (_children.ContainsKey(p.VirtualTagId)) continue;
var child = Context.ActorOf(
VirtualTagActor.Props(p.VirtualTagId, p.Expression, _evaluator,
scriptId: p.VirtualTagId, publisherFactory: null,
dependencyRefs: p.DependencyRefs, mux: _mux),
name: Akka.Util.Internal.ActorNameUtils... /* sanitise vtagId to a legal actor name; see note */);
_children[p.VirtualTagId] = child;
}
}
private void OnResult(VirtualTagActor.EvaluationResult r)
{
if (!_nodeIdByVtag.TryGetValue(r.VirtualTagId, out var nodeId)) return;
_publishActor.Tell(new OpcUaPublishActor.AttributeValueUpdate(
nodeId, r.Value, OpcUaQuality.Good, r.TimestampUtc));
}
}
```
**Notes for the implementer:**
- Child actor **name** must be a legal Akka name — sanitise the VirtualTagId (replace `/`,`.`,`:` etc.) or use `Context.ActorOf(props)` (auto-named) and key `_children` by vtagId only. Auto-naming is simplest and avoids collisions; prefer it.
- `OpcUaQuality.Good` — confirm the enum/namespace (`Commons.OpcUa`?) used by `AttributeValueUpdate`. Match what `DriverHostActor.ForwardToMux` passes.
- `AttributeValueUpdate` is a nested record on `OpcUaPublishActor` — use its real fully-qualified name as in `DriverHostActor`.
- Because the child self-registers with the mux in `PreStart`, **no explicit `RegisterInterest` send is needed here** — just spawn with the right `mux` + `dependencyRefs`.
**Step 1 — Failing tests (TestKit):**
1. `ApplyVirtualTags` with one plan spawns one child (assert `Context.Child` count / a probe).
2. When the host receives a `VirtualTagActor.EvaluationResult("vt-1", 42.0, ts, corr)` and it has a plan mapping `vt-1 → eq-1/speed-rpm`, the **publishActor probe** receives `AttributeValueUpdate("eq-1/speed-rpm", 42.0, Good, ts)`.
3. A second `ApplyVirtualTags` without `vt-1` stops the child (watch + `ExpectTerminated`).
**Step 2 — Run, verify fail.**
**Step 3 — Implement** `VirtualTagHostActor`.
**Step 4 — Run tests, verify PASS.**
**Step 5 — Commit.** `git commit -m "feat(runtime): VirtualTagHostActor spawns VTag actors + bridges results to OPC UA"`
---
### Task 6: Wire `VirtualTagHostActor` into `DriverHostActor` (apply + restore) + inject the evaluator
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** none (depends on T4, T5)
**Files:**
- Read first: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs` (confirm: `_dependencyMux` is non-null in production — i.e. the mux **is** spawned; `_opcUaPublishActor` ref; the `PushDesiredSubscriptions(deploymentId)` call sites in the apply path and in `RestoreApplied`; how the composition is loaded there — reuse the same `DeploymentArtifact.ParseComposition` result already loaded for `PushDesiredSubscriptions` so we don't parse twice). **If `_dependencyMux` is null in prod, that's a blocker — stop and report** (the value-streaming fix `b1b3f3f` added `ForwardToMux`, so it should be live; verify).
- Read first: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs` and wherever `DriverHostActor.Props` is constructed (the Host driver-role startup) to thread an `IVirtualTagEvaluator` into `DriverHostActor` (the Roslyn evaluator is already DI-registered in the Host).
- Modify: `DriverHostActor.cs` — accept `IVirtualTagEvaluator` in `Props`/ctor; spawn one `VirtualTagHostActor` child in `PreStart` (or lazily on first apply) with `(_opcUaPublishActor, _dependencyMux, evaluator)`; after **each** `PushDesiredSubscriptions(deploymentId)` (apply path **and** `RestoreApplied`), load the composition and `_virtualTagHost.Tell(new VirtualTagHostActor.ApplyVirtualTags(composition.EquipmentVirtualTags))`.
- Modify: the Host startup that builds `DriverHostActor.Props` to pass the resolved `IVirtualTagEvaluator`.
- Test: extend the `DriverHostActor` apply/restore tests (the ones that assert `SetDesiredSubscriptions` is pushed) to also assert an `ApplyVirtualTags` is sent to the spawned host with the composition's VirtualTags on **both** apply and restore. Use the existing TestKit harness + a probe evaluator/publish.
**Step 1 — Failing test:** on apply of a deployment whose artifact has one Equipment VirtualTag, the driver host sends `ApplyVirtualTags([that plan])` to its VirtualTag host child; repeat for the restore path.
**Step 2 — Run, verify fail.**
**Step 3 — Implement** the wiring (ctor param, child spawn, two `ApplyVirtualTags` sends, Host Props threading).
**Step 4 — Run tests, verify PASS.** Run the full `Runtime.Tests` driver-host suite — this path is load-bearing for the live galaxy mirror; **no regression** in `SetDesiredSubscriptions`/restore behaviour is acceptable.
**Step 5 — Commit.** `git commit -m "feat(runtime): spawn+apply VirtualTagHostActor on deploy apply and restore"`
---
### Task 7: Loader emits VirtualTag+Script rows; `verify-equipment` asserts live values
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 16 (different repo)
**Repo:** `~/Desktop/scadaproj/otopcua-uns-loader` (commit on `scadaproj` `main` per existing convention; this dir is not a nested repo).
**Files:**
- Modify: `otopcua_uns.py` — change `cmd_populate_equipment` so each company signal is loaded as a **VirtualTag** (a `VirtualTag` row + a `Script` row), **not** a driver `Tag`. The script source mirrors the live SystemPlatform mirror tag for that signal: `return ctx.GetTag("<machine>.<signal>").Value;` where `<machine>.<signal>` is the galaxy-mirror MXAccess ref (the same `source.fullTagReference` already in `company-uns.json`). Reuse the existing `nweq-…` id scheme for `VirtualTagId`/`ScriptId`. Keep the namespace `nw-uns` (Equipment-kind), the UnsArea/UnsLine/Equipment rows, and the friendly DisplayNames unchanged.
- Modify: `cmd_verify_equipment` — after browsing the company tree (still `nw-area-*` scoped, `--expect 1036`), additionally read each leaf's value and assert a `--require-good N` count of `Good` values (default 0 to stay back-compat; pass `--require-good 1036` post-deploy once values settle). Galaxy mirror tags change over time, so allow a `--wait` poll (reuse the `verify --wait` polling helper) before asserting Good.
- Read first: confirm the config-DB schema for `VirtualTag` + `Script` (table/column names, NOT-NULL columns, `SET QUOTED_IDENTIFIER ON` need like `Tag`) by inspecting the live DB (`otopcua-dev-sql-1`, port 14330, `OtOpcUa!Dev123`) — `SELECT TOP 0 * FROM VirtualTag; SELECT TOP 0 * FROM Script;`. Confirm an Equipment namespace with **only** VirtualTags (no driver Tags) passes the deploy `DraftValidator` (the namespace-kind↔driver rule). If a driver is still required for the namespace, keep the existing `nw-uns-modbus` stub driver but bind **no** Tags to it.
**Step 1 — Dry-run the SQL shape** against the live DB (read-only `SELECT TOP 0`), confirm columns. **Step 2 — Implement** the VirtualTag/Script upsert (mirror the existing idempotent upsert-by-natural-key + `nweq-` prefix so `clean` still removes exactly what it created). **Step 3 — `populate-equipment`** then headless deploy then `verify-equipment --expect 1036` (structure) — should still pass (variables now come from VirtualTags). **Step 4** — extend `verify-equipment` to optionally assert Good. **Step 5 — Commit** on `scadaproj` `main`: `git commit -m "feat(loader): company overlay as VirtualTags mirroring the galaxy mirror + verify live values"` (do NOT commit the `.venv`).
---
### Task 8: docker-dev end-to-end — deploy and verify live `Good` values
**Classification:** standard
**Estimated implement time:** ~5 min (plus deploy/settle wait)
**Parallelizable with:** none (depends on T6, T7)
**Steps (no new code — this is the integration gate):**
1. Build the docker-dev image with the Task 06 changes: `cd ~/Desktop/OtOpcUa/docker-dev && docker compose build admin-a && docker compose up -d` (all 4 host nodes share `otopcua-host:dev`). Confirm the galaxy mirror restores live (`b1b3f3f` RestoreApplied): 396 `Good` on `:4840`.
2. From the loader: `populate-equipment``curl -s -X POST http://localhost:9200/api/deployments -H 'X-Api-Key: docker-dev-deploy-key'` → wait for the `equipment virtualtags materialised (vtags=1036…)` + a driver-host `ApplyVirtualTags` log line.
3. `verify-equipment --expect 1036 --require-good 1036 --wait` — assert the company tree browses **and** every leaf reaches `Good` (allow the poll for the first change-triggered evaluation per VirtualTag; note in output any that stay `BadWaitingForInitialData` because their upstream galaxy tag hasn't changed yet — those are expected for genuinely-static signals, not a failure of the wiring).
4. **Restart safety:** `docker restart otopcua-dev-driver-a-1 otopcua-dev-driver-b-1`; without re-deploying, confirm the company VirtualTags return to `Good` (the `RestoreApplied``ApplyVirtualTags` path).
**Acceptance:** company-shape leaves carry live `Good` values, and survive a driver-node restart with no re-deploy. Record the deploy id + any static-signal exceptions.
**If integration fails:** prefer an inline fix in the most-likely file (the bridge NodeId in Task 5, or the apply/restore wiring in Task 6); only dispatch the debugger subagent (timeboxed ~10 min) if the cause isn't obvious from logs.
---
### Task 9: Docs + memory update
**Classification:** trivial
**Estimated implement time:** ~3 min
**Parallelizable with:** none (depends on T8)
**Files:**
- Modify: `~/Desktop/scadaproj/otopcua-uns-loader/README.md` — flip the "Company-shape overlay" section from "structure-only / BadWaitingForInitialData" to "live values via VirtualTags"; document `--require-good`.
- Modify: `OtOpcUa/docs/plans/2026-06-06-equipment-namespace-materialization-scope.md` — mark WS-3a **done**.
- Update memory: `galaxy-uns-project-state.md` + `otopcua-uns-deploy-and-value-streaming.md` (company shape now carries live values; route taken = VirtualTag; commits + deploy id). Refresh `MEMORY.md` hooks.
- **Do not** auto-merge to `master`/push — the finishing-a-development-branch step presents merge/PR options to the user.
**Commit:** `git commit -m "docs: company-shape UNS now carries live values (WS-3a done)"`
---
## After all tasks
Use **superpowers-extended-cc:finishing-a-development-branch**: verify the OtOpcUa test suite is green (note known pre-existing reds — live-infra integration tests), then present merge/PR/keep/discard options for `feat/equipment-namespace-live-values` (OtOpcUa) and the `scadaproj` loader commit. Merge/push only on the user's explicit go (per the project's standing rule).