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

31 KiB
Raw Blame History

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 FullReferenceno 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 ReconcileDriversRebuildAddressSpacePushDesiredSubscriptions. 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:

[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:

/// <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):

/// <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 scriptedAlarmsadd 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:

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:

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-equipmentcurl -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 RestoreAppliedApplyVirtualTags 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).