Implements WS-1/WS-2/WS-4 (+ tests) of the materialization scope: carry Equipment-namespace tags through the composition/artifact, add a sink-based MaterialiseEquipmentTags pass to the live rebuild, browse folders by friendly Name (NodeId stays the logical Id). 6 tasks with dependencies; structure-only (BadWaitingForInitialData leaves) — live values are the next milestone. Resume via /executing-plans docs/plans/2026-06-06-equipment-namespace-structure-milestone.md
14 KiB
Equipment-Namespace Structure Materialization — Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
Goal: A deploy that includes an Equipment-kind namespace materialises its full
Area / Line / Equipment / Signal browse tree into the live OPC UA address space on :4840,
with friendly-Name browse names and BadWaitingForInitialData leaf values. (Live values are
a separate later milestone and are explicitly out of scope here.)
Architecture: The live rebuild (OpcUaPublishActor.HandleRebuild) is sink-based — it drives
Phase7Applier against an IOpcUaAddressSpaceSink, materialising the Area/Line/Equipment folder
skeleton (MaterialiseHierarchy) and SystemPlatform/Galaxy variables (MaterialiseGalaxyTags).
Today there is no equipment-signal pass: Equipment-bound Tag/VirtualTag/ScriptedAlarm
rows never become variables. This plan adds that pass, mirroring MaterialiseGalaxyTags, fed by
equipment data carried in the deployment composition. It also makes the UNS folders browse by their
friendly Name.
Tech Stack: .NET 10, Akka.NET actors, EF Core (SQL Server), OPC UA SDK. Build/test from the repo
root: dotnet build, dotnet test. Per-task tests live under tests/Server/… and tests/Core/….
Background (read first): docs/plans/2026-06-06-equipment-namespace-materialization-scope.md —
this plan implements its WS-1, WS-2, WS-4 (+ tests). The reference implementation to mirror is the
Galaxy path: Phase7Applier.MaterialiseGalaxyTags + OpcUaPublishActor.HandleRebuild.
Architecture decisions (resolve before/while implementing)
These are surfaced from the investigation; Task 0 records the chosen answers in the plan/code.
-
Reuse
EquipmentNodeWalkervs add a sink pass.EquipmentNodeWalker.Walkis fully built + unit-tested but writes to anIAddressSpaceBuilder(the driver-discovery API), whereas the rebuild path writes toIOpcUaAddressSpaceSink. Two ways to bridge:- (A, recommended) Add
Phase7Applier.MaterialiseEquipmentTags(composition)— sink-based, a near-copy ofMaterialiseGalaxyTags, iterating equipment tags and calling_sink.EnsureFolder/_sink.EnsureVariable. Consistent with the rest of the rebuild; no adapter. Downside: re-expresses some grouping logic the walker already has. - (B) Adapt
EquipmentNodeWalkervia a sink-backedIAddressSpaceBuilder. Check for an existing capturing builder (GenericDriverNodeManager.CapturingBuilder,src/Core/…/Core/OpcUa/GenericDriverNodeManager.cs); if one cleanly wraps the sink, callEquipmentNodeWalker.Walk(capturingBuilder, content)and reuse the tested logic. Downside: couples the rebuild to the driver-builder API + that adapter. Recommendation: spend the first 20 min of Task 2 confirming whether a sink→builder adapter exists and is cheap. If yes → B (reuse the tested walker). If not → A. This plan is written for A (lower coupling, self-contained); swap the Task 2 body for B if the adapter is clean.
- (A, recommended) Add
-
Where equipment data comes from at rebuild: artifact vs live DB.
MaterialiseGalaxyTagsuses the sealed-artifact composition. For consistency and snapshot-correctness, carry equipment data in the composition too (Task 1). A pragmatic alternative with precedent (theb1b3f3fSubscribeBulk pass queries the live DB) is to loadEquipmentNamespaceContentdirectly from the DB in the rebuild — simpler, but live-DB-vs-sealed-artifact can diverge. This plan carries it in the composition (the correct, consistent choice). -
Folder NodeId vs BrowseName. Keep the existing scheme: NodeId = logical Id (
UnsAreaId/UnsLineId/EquipmentId) so browse-path resolution + ACLs are unaffected; set the BrowseName/DisplayName = friendlyName(Task 3).MaterialiseHierarchyalready keys NodeId on the Id and displaysDisplayName; the bug is thatDisplayNameis currently populated with the Id. The fix is in the composer (Task 3), not the applier. -
No double-materialisation.
MaterialiseHierarchyalready creates the Area/Line/Equipment folders. The new equipment-tag pass must only add the variables under existing equipment folders (and any per-tagFolderPathsub-folders) — it must NOT re-create the equipment folders.
Task 0: Confirm signatures + record the architecture decisions
Classification: trivial Estimated implement time: ~3 min Parallelizable with: none (do first)
Files:
- Read:
src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs(MaterialiseGalaxyTags,MaterialiseHierarchy,SafeEnsureFolder, the_sinkAPI) - Read:
src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IOpcUaAddressSpaceSink.cs(exactEnsureFolder/EnsureVariablesignatures) - Read:
src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs(Phase7CompositionResult,Compose, howEquipmentNode.DisplayName+ galaxy tags are built) - Read:
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs(ParseComposition,BuildGalaxyTagPlans) - Read:
src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs(the tested logic to mirror:AddTagVariable, identifier properties)
Step 1: Decide A vs B (decision #1) — grep for CapturingBuilder / IAddressSpaceBuilder
implementations that wrap IOpcUaAddressSpaceSink. If a clean adapter exists, note "Task 2 uses B".
Step 2: Confirm the sink's EnsureVariable signature (NodeId, parent, displayName,
DriverAttributeInfo incl. FullName + DataType) — MaterialiseGalaxyTags is the template.
Step 3: Record the confirmed decisions as a comment block at the top of the new
MaterialiseEquipmentTags (created in Task 2). No code/test change in this task.
Task 1: Carry equipment signals in the deployment composition + artifact
Classification: high-risk Estimated implement time: ~5 min Parallelizable with: none (Task 2 depends on it)
Files:
- Modify:
src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs— add anEquipmentTagPlanlist toPhase7CompositionResult; populate it inComposefromTagrows whereEquipmentId != nullAND the tag's driver's namespaceKind == Equipment(the inverse of the galaxy filter). SetDisplayName = Nameon Area/Line/Equipment records (decision #3 / Task 3 overlaps — do the field plumbing here). - Modify: the artifact serializer that writes
ArtifactBlob(find viagrep -rn "ArtifactBlob\|RevisionHash\|Serialize" src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/ConfigComposer.cs) — emit the equipment tags (withEquipmentId,FolderPath,Name,DataType,DriverInstanceId,TagConfig.FullName) into theTagsarray (they are likely already there) and ensure Area/Line/Equipment friendlyNames are serialised. - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs— addBuildEquipmentTagPlans(root, drivers): the mirror ofBuildGalaxyTagPlansthat KEEPSEquipmentId != nulltags whose namespaceKind == Equipment, readingFullNamefromTagConfig. Wire it intoParseComposition. - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactTests.cs(or the existing composition test file).
Step 1 — failing test: add a test that round-trips an artifact containing one Equipment
namespace + one equipment Tag and asserts ParseComposition(...).EquipmentTags contains it with
the right EquipmentId, FullName, DataType. Run: dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests --filter EquipmentTag → FAIL (member missing).
Step 2 — implement the EquipmentTagPlan record + populate in composer + parse in artifact.
Step 3 — run the test → PASS, plus the full Runtime.Tests + OpcUaServer.Tests suites green.
Step 4 — commit: feat(opcua): carry Equipment-namespace tags through the deployment composition.
Design note: EquipmentNamespaceContent (the EquipmentNodeWalker input) uses full entity
types. If Task 2 chooses option B, EquipmentTagPlan should carry enough to reconstruct the
Tag/Equipment fields the walker reads (Name, FolderPath, EquipmentId, DataType,
FullName). For option A, a flat EquipmentTagPlan(EquipmentId, DriverInstanceId, FolderPath, Name, DataType, FullName) is enough.
Task 2: Materialise equipment signals in the live rebuild
Classification: high-risk Estimated implement time: ~5 min Parallelizable with: none (depends on Task 1)
Files:
- Modify:
src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs— addMaterialiseEquipmentTags(Phase7CompositionResult composition), a sink-based near-copy ofMaterialiseGalaxyTags: for eachEquipmentTagPlan, ensure itsFolderPathsub-folder (if any) under the existing equipment folder (parentNodeId = EquipmentIdor the sub-folder), thenEnsureVariable(nodeId: FullName, parent, displayName: Name, attributeInfo: new DriverAttributeInfo(FullName, DataType, …)). Logequipment tags materialised (tags=N, equipment=M). - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs:HandleRebuild— afterMaterialiseGalaxyTags(composition), call_applier.MaterialiseEquipmentTags(composition). - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierHierarchyTests.cs(sibling to the existing hierarchy test, which mocksIOpcUaAddressSpaceSink).
Step 1 — failing test: with a fake sink, call MaterialiseEquipmentTags on a composition with
one equipment tag and assert one EnsureVariable(nodeId == FullName, parent == EquipmentId, displayName == Name) call landed. Run filtered test → FAIL (method missing).
Step 2 — implement MaterialiseEquipmentTags (mirror MaterialiseGalaxyTags; reuse
SafeEnsureFolder; idempotent via the same dedupe the galaxy pass uses) and the
HandleRebuild wire-up.
Step 3 — run the new test + OpcUaServer.Tests + Runtime.Tests → PASS.
Step 4 — commit: feat(opcua): materialise Equipment-namespace tags in the live rebuild.
If Task 0 chose option B: instead of a new method, build EquipmentNamespaceContent from the
composition, obtain the sink-backed IAddressSpaceBuilder, and call EquipmentNodeWalker.Walk.
Keep the same HandleRebuild call site + test assertions.
Task 3: Friendly browse names for UNS folders
Classification: small
Estimated implement time: ~3 min
Parallelizable with: none (verify after Task 1, which plumbs DisplayName)
Files:
- Modify:
src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs— ensure the composition's Area/Line/Equipment records carryDisplayName = <row>.Name(not the logical Id).MaterialiseHierarchyalready passesDisplayNameto the sink as the folder browse name, so this is the only change needed. - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierHierarchyTests.cs— assertSafeEnsureFolderis called withdisplayName == "filling"(Name) whilenodeId == "nw-area-filling"(Id).
Step 1 — failing test asserting DisplayName == Name, NodeId == Id. Run → FAIL (currently DisplayName == Id).
Step 2 — implement the composer change.
Step 3 — run → PASS.
Step 4 — commit: fix(opcua): UNS folders browse by friendly Name, NodeId stays the logical Id.
Task 4: Idempotency + restart-safety
Classification: small Estimated implement time: ~3 min Parallelizable with: none (after Task 2)
Files:
- Read/verify:
OpcUaPublishActor.HandleRebuildruns on both the apply path and theDriverHostActor.RestoreAppliedbootstrap path (added inb1b3f3f) — so the new pass is already restart-covered. Confirm by inspection; no code change expected. - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierHierarchyTests.cs— callMaterialiseEquipmentTagstwice with the same composition and assert no duplicateEnsureVariable(idempotent), matching the galaxy pass's dedupe behaviour.
Step 1 — failing test (double-apply → single variable). Step 2 — fix dedupe if needed.
Step 3 — run → PASS. Step 4 — commit: test(opcua): equipment-tag materialisation is idempotent.
Task 5: docker-dev integration verification + tool support
Classification: standard Estimated implement time: ~5 min Parallelizable with: none (last; needs Tasks 1–3 deployed)
Files:
- Create:
tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/EquipmentNamespaceMaterializationTests.cs(model on the existingDriverReconnectE2eTests.cs/ phase-7 smoke) — seed a 1-area/1-line/1-equipment/1-tag Equipment namespace + a Modbus FK driver, apply a deployment, browse…/filling/line-1/<eq>/<signal>, assert the variable exists withBadWaitingForInitialData(structure-only). - Modify (in the
scadaprojrepo, not OtOpcUa):scadaproj/otopcua-uns-loader/otopcua_uns.py— add averifybranch that browses the Equipment tree (friendly names) and asserts the leaf count matches the loaded equipment tags. (Tracked here for completeness; commit in scadaproj.)
Step 1 — write the integration test (skip-guarded if it needs live infra, per the repo's other
integration tests). Step 2 — run it against docker-dev (docs/v2/implementation/phase-7-e2e-smoke.md
has the harness). Step 3 — manual confirm via the AdminUI Deploy at :9200 + an asyncua browse.
Step 4 — commit: test(opcua): e2e Equipment-namespace structure materialisation.
Verification (whole milestone)
After all tasks: deploy an Equipment namespace via scadaproj/otopcua-uns-loader (extend it to emit
Equipment rows) + the AdminUI Deploy, then browse :4840:
OtOpcUa/filling/line-1/<equipment>/<signal>exists, folders browse-namedfilling/line-1/ …- leaf variables read
BadWaitingForInitialData(values are the next milestone). - A node restart auto-restores the tree (via
RestoreApplied) with no re-deploy.
Out of scope (explicit)
- Live values for equipment signals (driver subscribe / VirtualTag engine / OpcUaClient factory) — the next milestone (scope doc §5 WS-3).
- The Galaxy/SystemPlatform path (works).
- The AdminUI UNS editor.