# 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 = <row>.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/<eq>/<signal>`,
  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/<equipment>/<signal>` 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.
