# 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 0–6, 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 T1–T6
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 1–6 (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 0–6 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).
