31 KiB
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):
- Materialised Equipment variable NodeId is FOLDER-SCOPED, not
FullNameand notVirtualTagId.Phase7Applier.MaterialiseEquipmentTags(src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs:215-228) setsnodeId = $"{parent}/{tag.Name}"whereparentis the equipment folder (or a per-tag sub-folder). The published value must therefore carry that NodeId. VirtualTagActoris 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 inPreStart(RegisterInterest(_dependencyRefs, Self)), evaluates onDependencyValueChanged, dedups, andContext.Parent.Tell(new EvaluationResult(VirtualTagId, value, ts, corr))(line 147). It is never spawned in production.DependencyMuxActor(.../VirtualTags/DependencyMuxActor.cs):RegisterInterest(IReadOnlyList<string> TagRefs, IActorRef Subscriber);_byRefisDictionary<string, HashSet<IActorRef>>keyed by the flatFullReference— no namespace scoping (this is what makes cross-namespace mirroring work). OnAttributeValuePublished(FullReference,…)it fans outDependencyValueChanged(FullReference, value, ts)to subscribers.RoslynVirtualTagEvaluator(src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynVirtualTagEvaluator.cs):Evaluate(virtualTagId, expression, IReadOnlyDictionary<string,object?> dependencies). Scripts read deps viactx.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).- VirtualTag declares no explicit dependency list — deps are the set of
ctx.GetTag("literal")string literals in the script source. - The artifact snapshot already includes
ScriptsandVirtualTags(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:Phase7CompositionResulthas no VirtualTag list (Phase7Composer.cs).DeploymentArtifact.ParseCompositionbuilds Galaxy + Equipment tag plans only (BuildGalaxyTagPlans,BuildEquipmentTagPlans) — no VirtualTag builder.
- Spawn/restore hook points (
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs): the fresh-apply path callsPushDesiredSubscriptions(deploymentId); the restart pathRestoreApplied()callsReconcileDrivers→RebuildAddressSpace→PushDesiredSubscriptions. VirtualTag spawning must run on both (immediately afterPushDesiredSubscriptionsin each). - 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 NodeIdeq/Nameand we publishAttributeValueUpdate(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, mirroringEquipmentTagPlan/EquipmentTagsat 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.csand.../Entities/Script.cs(confirm exact field names:VirtualTagId,EquipmentId,Name,DataType,ScriptId;Script.ScriptId,Script.Source; note whetherVirtualTaghas aFolderPath— if not, pass""). - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs(the 7-argComposeoverload at lines 155-241; it already receivesscriptedAlarms— add avirtualTags+scriptsparameter to that overload and the convenience overloads, defaulting to empty so existing callers compile; thread it from the snapshot. Check the call site inDeploymentArtifact/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— addBuildEquipmentVirtualTagPlans(JsonElement root)mirroringBuildEquipmentTagPlans(lines 383-450); call it in bothParseCompositionoverloads (setEquipmentVirtualTags = …on the result at line ~199, and add the cluster-filter projection at line ~229). The snapshot arrays areroot.GetProperty("VirtualTags")androot.GetProperty("Scripts")(PascalCase —ConfigComposerserialises entity property names; verify against the existing"ScriptedAlarms"/"Tags"reads). Re-deriveDependencyRefsfromScript.Sourcewith 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 existingExtractFullNamereplication pattern, and note the duplication in a comment). - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/artifact round-trip test file (find the existingDeploymentArtifact/ParseCompositiontests).
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 producesAdded*/Removed*/Changed*lists — see howAddedEquipmentTags/RemovedEquipmentTags/ChangedEquipmentTagsare computed;Phase7Applier.Applyreads them at lines 73-85). - Modify:
Phase7Plan.cs— addAddedEquipmentVirtualTags/RemovedEquipmentVirtualTags/ChangedEquipmentVirtualTags(keyed byVirtualTagId), populated by the same diff routine that handlesEquipmentTags. - Modify:
Phase7Applier.csApply— include the new lists inaddedCount/changedCount/removedCountand in theneedsRebuildpredicate (lines 71-85), exactly likeEquipmentTags. - Test:
Phase7Plandiffer test file — add cases for added/removed/changed VirtualTags drivingneedsRebuild = 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— addMaterialiseEquipmentVirtualTags(Phase7CompositionResult composition)mirroringMaterialiseEquipmentTags(lines 197-234) but readingcomposition.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). Logequipment virtualtags materialised (vtags=…, equipment=…). - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs— inHandleRebuild, after theMaterialiseEquipmentTags(composition)call, addapplier.MaterialiseEquipmentVirtualTags(composition);(same applier instance/order). - Test:
Phase7ApplierTests— a composition with oneEquipmentVirtualTagPlanensures exactly one Variable at the folder-scoped NodeId withBadWaitingForInitialData(use the capturing/fakeIOpcUaAddressSpaceSink).
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 +EvaluationResultshape) and.../OpcUa/OpcUaPublishActor.cs(theAttributeValueUpdaterecord shape + its fully-qualified name, used inDriverHostActor.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 useContext.ActorOf(props)(auto-named) and key_childrenby vtagId only. Auto-naming is simplest and avoids collisions; prefer it. OpcUaQuality.Good— confirm the enum/namespace (Commons.OpcUa?) used byAttributeValueUpdate. Match whatDriverHostActor.ForwardToMuxpasses.AttributeValueUpdateis a nested record onOpcUaPublishActor— use its real fully-qualified name as inDriverHostActor.- Because the child self-registers with the mux in
PreStart, no explicitRegisterInterestsend is needed here — just spawn with the rightmux+dependencyRefs.
Step 1 — Failing tests (TestKit):
ApplyVirtualTagswith one plan spawns one child (assertContext.Childcount / a probe).- When the host receives a
VirtualTagActor.EvaluationResult("vt-1", 42.0, ts, corr)and it has a plan mappingvt-1 → eq-1/speed-rpm, the publishActor probe receivesAttributeValueUpdate("eq-1/speed-rpm", 42.0, Good, ts). - A second
ApplyVirtualTagswithoutvt-1stops 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:_dependencyMuxis non-null in production — i.e. the mux is spawned;_opcUaPublishActorref; thePushDesiredSubscriptions(deploymentId)call sites in the apply path and inRestoreApplied; how the composition is loaded there — reuse the sameDeploymentArtifact.ParseCompositionresult already loaded forPushDesiredSubscriptionsso we don't parse twice). If_dependencyMuxis null in prod, that's a blocker — stop and report (the value-streaming fixb1b3f3faddedForwardToMux, so it should be live; verify). - Read first:
src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.csand whereverDriverHostActor.Propsis constructed (the Host driver-role startup) to thread anIVirtualTagEvaluatorintoDriverHostActor(the Roslyn evaluator is already DI-registered in the Host). - Modify:
DriverHostActor.cs— acceptIVirtualTagEvaluatorinProps/ctor; spawn oneVirtualTagHostActorchild inPreStart(or lazily on first apply) with(_opcUaPublishActor, _dependencyMux, evaluator); after eachPushDesiredSubscriptions(deploymentId)(apply path andRestoreApplied), load the composition and_virtualTagHost.Tell(new VirtualTagHostActor.ApplyVirtualTags(composition.EquipmentVirtualTags)). - Modify: the Host startup that builds
DriverHostActor.Propsto pass the resolvedIVirtualTagEvaluator. - Test: extend the
DriverHostActorapply/restore tests (the ones that assertSetDesiredSubscriptionsis pushed) to also assert anApplyVirtualTagsis 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— changecmd_populate_equipmentso each company signal is loaded as a VirtualTag (aVirtualTagrow + aScriptrow), not a driverTag. 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 samesource.fullTagReferencealready incompany-uns.json). Reuse the existingnweq-…id scheme forVirtualTagId/ScriptId. Keep the namespacenw-uns(Equipment-kind), the UnsArea/UnsLine/Equipment rows, and the friendly DisplayNames unchanged. - Modify:
cmd_verify_equipment— after browsing the company tree (stillnw-area-*scoped,--expect 1036), additionally read each leaf's value and assert a--require-good Ncount ofGoodvalues (default 0 to stay back-compat; pass--require-good 1036post-deploy once values settle). Galaxy mirror tags change over time, so allow a--waitpoll (reuse theverify --waitpolling helper) before asserting Good. - Read first: confirm the config-DB schema for
VirtualTag+Script(table/column names, NOT-NULL columns,SET QUOTED_IDENTIFIER ONneed likeTag) 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 deployDraftValidator(the namespace-kind↔driver rule). If a driver is still required for the namespace, keep the existingnw-uns-modbusstub 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):
- 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 shareotopcua-host:dev). Confirm the galaxy mirror restores live (b1b3f3fRestoreApplied): 396Goodon:4840. - From the loader:
populate-equipment→curl -s -X POST http://localhost:9200/api/deployments -H 'X-Api-Key: docker-dev-deploy-key'→ wait for theequipment virtualtags materialised (vtags=1036…)+ a driver-hostApplyVirtualTagslog line. verify-equipment --expect 1036 --require-good 1036 --wait— assert the company tree browses and every leaf reachesGood(allow the poll for the first change-triggered evaluation per VirtualTag; note in output any that stayBadWaitingForInitialDatabecause their upstream galaxy tag hasn't changed yet — those are expected for genuinely-static signals, not a failure of the wiring).- Restart safety:
docker restart otopcua-dev-driver-a-1 otopcua-dev-driver-b-1; without re-deploying, confirm the company VirtualTags return toGood(theRestoreApplied→ApplyVirtualTagspath).
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). RefreshMEMORY.mdhooks. - 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).