diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs
new file mode 100644
index 00000000..43c92e86
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs
@@ -0,0 +1,138 @@
+using Akka.Actor;
+using Akka.Event;
+using ZB.MOM.WW.OtOpcUa.Commons.Engines;
+using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
+using ZB.MOM.WW.OtOpcUa.OpcUaServer;
+using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
+
+namespace ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
+
+///
+/// Supervisor that gives Equipment-namespace VirtualTags live values. For each
+/// in the desired set it spawns one child
+/// (which self-registers with the dependency mux and evaluates its
+/// expression on dependency changes) and remembers the plan's folder-scoped NodeId. When a
+/// child reports a fresh , the host bridges it onto
+/// an targeting that NodeId so the
+/// already-materialised Variable node (currently BadWaitingForInitialData) reflects the value.
+///
+///
+/// The published NodeId is computed with the identical formula
+/// Phase7Applier.MaterialiseEquipmentVirtualTags uses to materialise the variable —
+/// {parent}/{Name} where parent = IsNullOrWhiteSpace(FolderPath) ? EquipmentId :
+/// {EquipmentId}/{FolderPath} — or the value would land on a NodeId that does not exist.
+///
+///
+public sealed class VirtualTagHostActor : ReceiveActor
+{
+ /// Reconciles the live VirtualTag children to exactly the supplied desired set:
+ /// stops children whose vtagId is gone, spawns children for new vtagIds, and rebuilds the
+ /// vtagId→NodeId map so renames are reflected.
+ /// The desired Equipment-namespace VirtualTag plans.
+ public sealed record ApplyVirtualTags(IReadOnlyList Plans);
+
+ private readonly IActorRef _publishActor;
+ private readonly IActorRef? _mux;
+ private readonly IVirtualTagEvaluator _evaluator;
+ private readonly ILoggingAdapter _log = Context.GetLogger();
+
+ // vtagId -> spawned child VirtualTagActor.
+ private readonly Dictionary _children = new(StringComparer.Ordinal);
+ // vtagId -> folder-scoped OPC UA NodeId the materialiser placed the variable at.
+ private readonly Dictionary _nodeIdByVtag = new(StringComparer.Ordinal);
+
+ /// Factory method to create Props for a VirtualTagHostActor.
+ /// The OPC UA publish actor that consumes
+ /// bridged from child results.
+ /// Optional dependency multiplexer; passed to each spawned child so it can
+ /// register interest in its dependency refs. Null on the dev/Mac path (no live values).
+ /// The evaluator each child uses to compute its expression.
+ public static Props Props(IActorRef publishActor, IActorRef? mux, IVirtualTagEvaluator evaluator) =>
+ Akka.Actor.Props.Create(() => new VirtualTagHostActor(publishActor, mux, evaluator));
+
+ /// Initializes a new instance of the class.
+ /// The OPC UA publish actor results are bridged to.
+ /// Optional dependency multiplexer passed to each spawned child.
+ /// The evaluator each child uses to compute its expression.
+ public VirtualTagHostActor(IActorRef publishActor, IActorRef? mux, IVirtualTagEvaluator evaluator)
+ {
+ ArgumentNullException.ThrowIfNull(publishActor);
+ ArgumentNullException.ThrowIfNull(evaluator);
+ _publishActor = publishActor;
+ _mux = mux;
+ _evaluator = evaluator;
+
+ Receive(OnApply);
+ Receive(OnResult);
+ }
+
+ private void OnApply(ApplyVirtualTags msg)
+ {
+ var desired = new HashSet(msg.Plans.Select(p => p.VirtualTagId), StringComparer.Ordinal);
+
+ // Stop + forget children whose vtagId is no longer desired. Stopping the child triggers its
+ // PostStop, which unregisters its interest from the mux.
+ foreach (var vtagId in _children.Keys.Where(id => !desired.Contains(id)).ToList())
+ {
+ Context.Stop(_children[vtagId]);
+ _children.Remove(vtagId);
+ }
+
+ // Rebuild the NodeId map every apply so renames (Name/FolderPath/EquipmentId changes) are
+ // picked up. The map only contains currently-desired vtags, so a result for a removed vtag
+ // finds no entry and is dropped.
+ _nodeIdByVtag.Clear();
+ foreach (var p in msg.Plans)
+ {
+ _nodeIdByVtag[p.VirtualTagId] = NodeIdFor(p);
+ }
+
+ // Spawn children for new vtagIds only — existing children keep their mux subscriptions and
+ // last-value dedup state. Expression/dependency changes on an existing vtag are NOT
+ // re-applied here; the loader's vtags are stable, and a future enhancement can stop+respawn
+ // a child whose plan changed (the diff already identifies ChangedEquipmentVirtualTags).
+ foreach (var p in msg.Plans)
+ {
+ if (_children.ContainsKey(p.VirtualTagId)) continue;
+
+ // Auto-name the child: vtagIds can contain characters illegal in actor names, so let Akka
+ // assign a safe unique name. The child self-registers with the mux in PreStart.
+ var child = Context.ActorOf(VirtualTagActor.Props(
+ virtualTagId: p.VirtualTagId,
+ expression: p.Expression,
+ evaluator: _evaluator,
+ scriptId: p.VirtualTagId,
+ publisherFactory: null,
+ dependencyRefs: p.DependencyRefs,
+ mux: _mux));
+ _children[p.VirtualTagId] = child;
+ }
+
+ _log.Debug("VirtualTagHost: applied (desired={Desired}, children={Children})",
+ desired.Count, _children.Count);
+ }
+
+ private void OnResult(VirtualTagActor.EvaluationResult result)
+ {
+ // A result may arrive for a vtag that was just removed from the desired set (the child's
+ // last in-flight message). With no NodeId mapping we have nowhere to land it — drop silently.
+ if (!_nodeIdByVtag.TryGetValue(result.VirtualTagId, out var nodeId))
+ {
+ return;
+ }
+
+ _publishActor.Tell(new OpcUaPublishActor.AttributeValueUpdate(
+ nodeId, result.Value, OpcUaQuality.Good, result.TimestampUtc));
+ }
+
+ /// Folder-scoped NodeId for a VirtualTag plan — MUST match
+ /// Phase7Applier.MaterialiseEquipmentVirtualTags exactly, or the published value lands on a
+ /// NodeId that was never materialised.
+ private static string NodeIdFor(EquipmentVirtualTagPlan p)
+ {
+ var parent = string.IsNullOrWhiteSpace(p.FolderPath)
+ ? p.EquipmentId
+ : $"{p.EquipmentId}/{p.FolderPath}";
+ return $"{parent}/{p.Name}";
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs
new file mode 100644
index 00000000..347d0e74
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs
@@ -0,0 +1,130 @@
+using Akka.Actor;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Commons.Engines;
+using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
+using ZB.MOM.WW.OtOpcUa.Commons.Types;
+using ZB.MOM.WW.OtOpcUa.OpcUaServer;
+using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
+using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
+using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
+
+namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.VirtualTags;
+
+///
+/// Verifies reconciles a desired set of
+/// into child s and bridges each
+/// child's onto an
+/// carrying the folder-scoped NodeId computed by
+/// the materialiser.
+///
+public sealed class VirtualTagHostActorTests : RuntimeActorTestBase
+{
+ /// A plan with no FolderPath maps onto NodeId "EquipmentId/Name".
+ private static EquipmentVirtualTagPlan Plan(
+ string vtagId, string equipmentId, string name, string folderPath = "") =>
+ new(
+ VirtualTagId: vtagId,
+ EquipmentId: equipmentId,
+ FolderPath: folderPath,
+ Name: name,
+ DataType: "Double",
+ Expression: "ctx.GetTag(\"a\")",
+ DependencyRefs: new[] { "a" });
+
+ /// Spawn: an apply with one plan spins up exactly one live child VirtualTagActor.
+ [Fact]
+ public void ApplyVirtualTags_spawns_one_child_per_plan()
+ {
+ var publish = CreateTestProbe();
+ var mux = CreateTestProbe();
+ var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux.Ref, new StubEvaluator()));
+
+ host.Tell(new VirtualTagHostActor.ApplyVirtualTags(new[] { Plan("vt-1", "eq-1", "speed-rpm") }));
+
+ // The child self-registers with the mux in PreStart, so a RegisterInterest landing on the
+ // mux probe is proof the host spawned a live child.
+ var reg = mux.ExpectMsg();
+ reg.TagRefs.ShouldContain("a");
+ }
+
+ /// KEY TEST: a child EvaluationResult is bridged to the publish actor with the
+ /// folder-scoped NodeId, Value, Good quality, and source timestamp preserved.
+ [Fact]
+ public void EvaluationResult_is_bridged_with_folder_scoped_NodeId()
+ {
+ var publish = CreateTestProbe();
+ var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux: null, new StubEvaluator()));
+
+ host.Tell(new VirtualTagHostActor.ApplyVirtualTags(new[] { Plan("vt-1", "eq-1", "speed-rpm") }));
+
+ var ts = new DateTime(2026, 6, 7, 12, 0, 0, DateTimeKind.Utc);
+ host.Tell(new VirtualTagActor.EvaluationResult("vt-1", 42.0, ts, CorrelationId.NewId()));
+
+ var update = publish.ExpectMsg();
+ update.NodeId.ShouldBe("eq-1/speed-rpm");
+ update.Value.ShouldBe(42.0);
+ update.Quality.ShouldBe(OpcUaQuality.Good);
+ update.TimestampUtc.ShouldBe(ts);
+ }
+
+ /// FolderPath is honoured in the published NodeId (EquipmentId/FolderPath/Name).
+ [Fact]
+ public void EvaluationResult_NodeId_includes_folder_path_when_set()
+ {
+ var publish = CreateTestProbe();
+ var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux: null, new StubEvaluator()));
+
+ host.Tell(new VirtualTagHostActor.ApplyVirtualTags(
+ new[] { Plan("vt-1", "eq-1", "speed-rpm", folderPath: "metrics") }));
+
+ host.Tell(new VirtualTagActor.EvaluationResult("vt-1", 1.0, DateTime.UtcNow, CorrelationId.NewId()));
+
+ var update = publish.ExpectMsg();
+ update.NodeId.ShouldBe("eq-1/metrics/speed-rpm");
+ }
+
+ /// Stop-removed: a vtag dropped from the desired set is unmapped, so a later result for
+ /// it produces NO publish.
+ [Fact]
+ public void Removed_vtag_is_no_longer_bridged()
+ {
+ var publish = CreateTestProbe();
+ var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux: null, new StubEvaluator()));
+
+ host.Tell(new VirtualTagHostActor.ApplyVirtualTags(new[] { Plan("vt-1", "eq-1", "speed-rpm") }));
+ // Re-apply without vt-1 — it should be stopped + unmapped.
+ host.Tell(new VirtualTagHostActor.ApplyVirtualTags(Array.Empty()));
+
+ host.Tell(new VirtualTagActor.EvaluationResult("vt-1", 99.0, DateTime.UtcNow, CorrelationId.NewId()));
+
+ publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
+ }
+
+ /// Unmapped result dropped: a result for an unknown vtagId is silently ignored.
+ [Fact]
+ public void Result_for_unknown_vtag_is_dropped()
+ {
+ var publish = CreateTestProbe();
+ var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux: null, new StubEvaluator()));
+
+ host.Tell(new VirtualTagHostActor.ApplyVirtualTags(new[] { Plan("vt-1", "eq-1", "speed-rpm") }));
+
+ host.Tell(new VirtualTagActor.EvaluationResult("vt-unknown", 7.0, DateTime.UtcNow, CorrelationId.NewId()));
+
+ publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
+ }
+
+ /// Deterministic no-op evaluator: keeps spawned children inert so tests drive the host's
+ /// OnResult path directly via synthetic EvaluationResults.
+ private sealed class StubEvaluator : IVirtualTagEvaluator
+ {
+ /// Returns NoChange so the child never emits on its own.
+ /// The tag identifier.
+ /// The expression string.
+ /// The dependency values.
+ /// A NoChange result.
+ public VirtualTagEvalResult Evaluate(string id, string expr, IReadOnlyDictionary deps)
+ => VirtualTagEvalResult.NoChange;
+ }
+}