# 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. 1. **Reuse `EquipmentNodeWalker` vs add a sink pass.** `EquipmentNodeWalker.Walk` is fully built + unit-tested but writes to an `IAddressSpaceBuilder` (the driver-discovery API), whereas the rebuild path writes to `IOpcUaAddressSpaceSink`. Two ways to bridge: - **(A, recommended) Add `Phase7Applier.MaterialiseEquipmentTags(composition)`** — sink-based, a near-copy of `MaterialiseGalaxyTags`, 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 `EquipmentNodeWalker` via a sink-backed `IAddressSpaceBuilder`.** Check for an existing capturing builder (`GenericDriverNodeManager.CapturingBuilder`, `src/Core/…/Core/OpcUa/GenericDriverNodeManager.cs`); if one cleanly wraps the sink, call `EquipmentNodeWalker.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. 2. **Where equipment data comes from at rebuild: artifact vs live DB.** `MaterialiseGalaxyTags` uses the sealed-artifact composition. For consistency and snapshot-correctness, carry equipment data in the composition too (Task 1). A pragmatic alternative with precedent (the `b1b3f3f` SubscribeBulk pass queries the live DB) is to load `EquipmentNamespaceContent` directly 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).** 3. **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 = friendly `Name`** (Task 3). `MaterialiseHierarchy` already keys NodeId on the Id and displays `DisplayName`; the bug is that `DisplayName` is currently populated with the Id. The fix is in the composer (Task 3), not the applier. 4. **No double-materialisation.** `MaterialiseHierarchy` already creates the Area/Line/Equipment folders. The new equipment-tag pass must only add the **variables** under existing equipment folders (and any per-tag `FolderPath` sub-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 `_sink` API) - Read: `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IOpcUaAddressSpaceSink.cs` (exact `EnsureFolder`/`EnsureVariable` signatures) - Read: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs` (`Phase7CompositionResult`, `Compose`, how `EquipmentNode.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 an `EquipmentTagPlan` list to `Phase7CompositionResult`; populate it in `Compose` from `Tag` rows where `EquipmentId != null` AND the tag's driver's namespace `Kind == Equipment` (the inverse of the galaxy filter). Set `DisplayName = Name` on Area/Line/Equipment records (decision #3 / Task 3 overlaps — do the field plumbing here). - Modify: the artifact serializer that writes `ArtifactBlob` (find via `grep -rn "ArtifactBlob\|RevisionHash\|Serialize" src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/ConfigComposer.cs`) — emit the equipment tags (with `EquipmentId`, `FolderPath`, `Name`, `DataType`, `DriverInstanceId`, `TagConfig.FullName`) into the `Tags` array (they are likely already there) and ensure Area/Line/Equipment friendly `Name`s are serialised. - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs` — add `BuildEquipmentTagPlans(root, drivers)`: the mirror of `BuildGalaxyTagPlans` that KEEPS `EquipmentId != null` tags whose namespace `Kind == Equipment`, reading `FullName` from `TagConfig`. Wire it into `ParseComposition`. - 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` — add `MaterialiseEquipmentTags(Phase7CompositionResult composition)`, a sink-based near-copy of `MaterialiseGalaxyTags`: for each `EquipmentTagPlan`, ensure its `FolderPath` sub-folder (if any) **under the existing equipment folder** (`parentNodeId = EquipmentId` or the sub-folder), then `EnsureVariable(nodeId: FullName, parent, displayName: Name, attributeInfo: new DriverAttributeInfo(FullName, DataType, …))`. Log `equipment tags materialised (tags=N, equipment=M)`. - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs:HandleRebuild` — after `MaterialiseGalaxyTags(composition)`, call `_applier.MaterialiseEquipmentTags(composition)`. - Test: `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierHierarchyTests.cs` (sibling to the existing hierarchy test, which mocks `IOpcUaAddressSpaceSink`). **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 carry `DisplayName = .Name` (not the logical Id). `MaterialiseHierarchy` already passes `DisplayName` to 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` — assert `SafeEnsureFolder` is called with `displayName == "filling"` (Name) while `nodeId == "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.HandleRebuild` runs on both the apply path and the `DriverHostActor.RestoreApplied` bootstrap path (added in `b1b3f3f`) — 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` — call `MaterialiseEquipmentTags` twice with the same composition and assert no duplicate `EnsureVariable` (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 existing `DriverReconnectE2eTests.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//`, assert the variable exists with `BadWaitingForInitialData` (structure-only). - Modify (in the `scadaproj` repo, not OtOpcUa): `scadaproj/otopcua-uns-loader/otopcua_uns.py` — add a `verify` branch 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//` exists, folders browse-named `filling` / `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.