# FixedTree → Equipment dynamic-injection Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.

**Goal:** After an `ITagDiscovery` driver connects, dynamically graft its discovered FixedTree nodes into the served Equipment/UNS OPC UA address space under a driver-named subfolder (`EQ-…/FOCAS/…`), carrying live values — reusing the existing materialize → subscribe → poll → push pipeline.

**Architecture:** Treat discovered nodes as "synthetic equipment tags" injected at runtime. A capturing `IAddressSpaceBuilder` records each driver's `DiscoverAsync` output (zero driver changes); `DriverInstanceActor` runs discovery post-connect (bounded retry, since FOCAS's `FixedTreeCache` populates ~0–2 s after connect) and ships a `DiscoveredNodesReady` message; `DriverHostActor` maps the nodes under the equipment, extends `_nodeIdByDriverRef` + the desired-subscription set, and tells `OpcUaPublishActor` to incrementally materialize them (idempotent `EnsureFolder`/`EnsureVariable`, no full teardown), emitting a `GeneralModelChangeEvent`. Survives redeploys (re-applied after `PushDesiredSubscriptions`) and restarts (re-discovered on reconnect).

**Tech Stack:** .NET 10, Akka.NET (Akka.Hosting, Akka.TestKit.Xunit2), OPC UA (`OPCFoundation.NetStandard.Opc.Ua`), xUnit v2 + Shouldly.

**Design doc:** [`2026-06-26-otopcua-fixedtree-equipment-injection-design.md`](2026-06-26-otopcua-fixedtree-equipment-injection-design.md). Base branch: `fix/focas-poll-io-serialization` (this builds on the deployed driver-host bootstrap re-spawn + FOCAS I/O fixes; not yet merged to `master`).

**Key code anchors (verified 2026-06-26):**
- `IAddressSpaceBuilder` / `IVariableHandle` / `DriverAttributeInfo` — `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/`.
- Reference capturing builder (flat collector): `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/Commands/BrowseCommand.cs:120` (`CollectingAddressSpaceBuilder`).
- NodeId scheme: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/EquipmentNodeIds.cs` (`Variable(equipmentId, folderPath, name)` → `{parent}/{name}`; `SubFolder` → `{equipmentId}/{folderPath}`).
- Materialize pattern: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs:248` (`MaterialiseEquipmentTags`) + `SafeEnsureFolder`/`SafeEnsureVariable`.
- Node manager: `OtOpcUaNodeManager.EnsureFolder` (`:1282`), `EnsureVariable` (`:1367`, seeds `BadWaitingForInitialData`), `BuildNodeShapeChangedEvent` (`:1525`, verb `DataTypeChanged` — model for a `NodeAdded` sibling).
- Publish actor receive + materialize calls: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs:217` (Receive block), `HandleRebuild` (`:275`).
- Driver value route: `DriverHostActor.ForwardToMux` (`:525`), `_nodeIdByDriverRef` built in `PushDesiredSubscriptions` (`:1019`, sends `SetDesiredSubscriptions` `:1052`), `ChildEntry` (`:203`), Receive blocks (`:482`, `:512`).
- Driver connect hook: `DriverInstanceActor` `_driver` field (`:110`), `Connected()` (`:317`), transition at `InitializeSucceeded` (`:278`); `SetDesiredSubscriptions` live re-subscribe path (`:340-353`).
- FOCAS discovery (reused verbatim): `FocasDriver.DiscoverAsync` (`:408`) emits `FOCAS/{deviceHost}/<section>/…`; FixedTree leaf `FullName` = `{deviceHost}/{path}`; suppresses FixedTree until `FixedTreeCache` set.

---

## Conventions for every task

- **TDD:** write the failing test first, run it (confirm the expected failure), implement minimally, run again (green), commit.
- **Build:** `dotnet build ZB.MOM.WW.OtOpcUa.slnx` from the repo root.
- **Run a single test class:** `dotnet test ZB.MOM.WW.OtOpcUa.slnx --filter "FullyQualifiedName~<ClassName>"`.
- **Commits:** Conventional Commits, on `fix/focas-poll-io-serialization` (do NOT touch the pre-existing unrelated working-tree edits: `CLAUDE.md`, `docker-dev/docker-compose.yml`, `pending.md`, `stillpending.md`, `docs/plans/2026-06-19-followups-batch.md.tasks.json` — `git add` only this feature's files).
- **No new dependencies, no proto change, no EF migration.** All edits are within existing projects.

---

## Task 1: `DiscoveredNode` DTO + path-tracking `CapturingAddressSpaceBuilder`

**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 3

**Files:**
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DiscoveredNode.cs`
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/CapturingAddressSpaceBuilder.cs`
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/CapturingAddressSpaceBuilderTests.cs`

Unlike the CLI's flat `CollectingAddressSpaceBuilder`, this one **tracks folder nesting** so each variable records its full path segments (e.g. `["FOCAS","10.201.31.5:8193","Identity"]` + browse `SeriesNumber`).

**Step 1: Write the failing test**

```csharp
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using Shouldly;
using Xunit;

namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;

[Trait("Category", "Unit")]
public sealed class CapturingAddressSpaceBuilderTests
{
    [Fact]
    public void Records_nested_path_segments_full_reference_and_metadata()
    {
        var b = new CapturingAddressSpaceBuilder();
        var focas = b.Folder("FOCAS", "FOCAS");
        var device = focas.Folder("10.0.0.5:8193", "cnc");
        var identity = device.Folder("Identity", "Identity");
        identity.Variable("SeriesNumber", "SeriesNumber", new DriverAttributeInfo(
            FullName: "10.0.0.5:8193/Identity/SeriesNumber",
            DriverDataType: DriverDataType.String, IsArray: false, ArrayDim: null,
            SecurityClass: SecurityClassification.ViewOnly, IsHistorized: false));

        b.Nodes.Count.ShouldBe(1);
        var n = b.Nodes[0];
        n.FolderPathSegments.ShouldBe(new[] { "FOCAS", "10.0.0.5:8193", "Identity" });
        n.BrowseName.ShouldBe("SeriesNumber");
        n.FullReference.ShouldBe("10.0.0.5:8193/Identity/SeriesNumber");
        n.DataType.ShouldBe(DriverDataType.String);
        n.Writable.ShouldBeFalse();              // ViewOnly → read-only
    }

    [Fact]
    public void AddProperty_is_ignored_and_alarm_marking_is_a_noop_sink()
    {
        var b = new CapturingAddressSpaceBuilder();
        var f = b.Folder("FOCAS", "FOCAS");
        f.AddProperty("Manufacturer", DriverDataType.String, "FANUC");   // ignored, no throw
        var h = f.Variable("V", "V", new DriverAttributeInfo("ref", DriverDataType.Int32, false, null,
            SecurityClassification.ViewOnly, false, IsAlarm: true));
        var sink = h.MarkAsAlarmCondition(new AlarmConditionInfo("src", AlarmSeverity.Low, null));
        sink.ShouldNotBeNull();                  // no-op sink, alarms out of scope
        b.Nodes.Count.ShouldBe(1);
    }
}
```

**Step 2: Run to verify it fails** — `dotnet test ZB.MOM.WW.OtOpcUa.slnx --filter "FullyQualifiedName~CapturingAddressSpaceBuilderTests"` → FAIL (types don't exist).

**Step 3: Implement `DiscoveredNode.cs`**

```csharp
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;

namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;

/// <summary>
///     A flattened variable captured from a driver's <see cref="ITagDiscovery.DiscoverAsync"/> stream
///     by <see cref="CapturingAddressSpaceBuilder"/>. Folder nesting is preserved in
///     <see cref="FolderPathSegments"/> so the injector can re-root the node under an equipment.
/// </summary>
public sealed record DiscoveredNode(
    IReadOnlyList<string> FolderPathSegments,
    string BrowseName,
    string DisplayName,
    string FullReference,
    DriverDataType DataType,
    bool IsArray,
    uint? ArrayDim,
    bool Writable,
    bool IsHistorized);
```

**Step 3b: Implement `CapturingAddressSpaceBuilder.cs`**

```csharp
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;

namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;

/// <summary>
///     An <see cref="IAddressSpaceBuilder"/> that RECORDS the streamed tree instead of creating OPC UA
///     nodes — used to capture an <see cref="ITagDiscovery"/> driver's discovered hierarchy so the
///     runtime can graft it under an equipment node. Folder nesting is tracked (each child builder
///     carries its accumulated path), so every variable records its full <see cref="DiscoveredNode.FolderPathSegments"/>.
///     <para>Value nodes only: <see cref="AddProperty"/> is ignored and alarm marking returns a no-op sink
///     (discovered alarms are out of scope — alarms come via the config path).</para>
///     <para>Single-threaded: a driver's <c>DiscoverAsync</c> streams on one caller; the root and its child
///     builders share one <see cref="List{T}"/>. Not thread-safe by design.</para>
/// </summary>
public sealed class CapturingAddressSpaceBuilder : IAddressSpaceBuilder
{
    private readonly List<DiscoveredNode> _nodes;
    private readonly IReadOnlyList<string> _path;

    public CapturingAddressSpaceBuilder() : this([], []) { }

    private CapturingAddressSpaceBuilder(List<DiscoveredNode> nodes, IReadOnlyList<string> path)
    {
        _nodes = nodes;
        _path = path;
    }

    /// <summary>All variables captured across the whole tree (shared by the root and every child scope).</summary>
    public IReadOnlyList<DiscoveredNode> Nodes => _nodes;

    public IAddressSpaceBuilder Folder(string browseName, string displayName)
        => new CapturingAddressSpaceBuilder(_nodes, [.. _path, browseName]);

    public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
    {
        _nodes.Add(new DiscoveredNode(
            FolderPathSegments: _path,
            BrowseName: browseName,
            DisplayName: displayName,
            FullReference: attributeInfo.FullName,
            DataType: attributeInfo.DriverDataType,
            IsArray: attributeInfo.IsArray,
            ArrayDim: attributeInfo.ArrayDim,
            Writable: attributeInfo.SecurityClass != SecurityClassification.ViewOnly,
            IsHistorized: attributeInfo.IsHistorized));
        return new NullHandle(attributeInfo.FullName);
    }

    public void AddProperty(string browseName, DriverDataType dataType, object? value) { /* metadata only — ignored */ }

    private sealed class NullHandle(string fullRef) : IVariableHandle
    {
        public string FullReference => fullRef;
        public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
    }

    private sealed class NullSink : IAlarmConditionSink
    {
        public void OnTransition(AlarmEventArgs args) { }
    }
}
```

**Step 4: Run to verify it passes** — same filter → PASS.

**Step 5: Commit**

```bash
git add src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DiscoveredNode.cs \
        src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/CapturingAddressSpaceBuilder.cs \
        tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/CapturingAddressSpaceBuilderTests.cs
git commit -m "feat(otopcua): capturing address-space builder for driver discovery"
```

---

## Task 2: `DiscoveredNodeMapper` — map discovered nodes under an equipment

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 3

**Files:**
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/DiscoveredInjection.cs` (the `DiscoveredFolder`/`DiscoveredVariable` materialize DTOs — placed in OpcUaServer so both the applier and the Runtime mapper can reference them; Runtime already references OpcUaServer)
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DiscoveredNodeMapper.cs`
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveredNodeMapperTests.cs`

**Pure function** turning `IReadOnlyList<DiscoveredNode>` + an `equipmentId` + the driver's authored-tag refs into folders + variables (NodeIds under the equipment) + routing entries. Rules:
- **Device-folder collapse:** if every node shares an identical segment at index 1 (the single device folder under the driver root), drop index 1 → `EQ/FOCAS/Identity/…` rather than `EQ/FOCAS/<deviceHost>/Identity/…`. With ≥2 devices the segments differ → not collapsed (device level retained, degrades gracefully — multi-device equipment mapping itself is a deferred follow-up).
- **Dedup:** skip any node whose `FullReference` is in `authoredRefs` (already a Config-DB equipment tag for this driver — applies to drivers like Galaxy whose discovery refs equal the equipment-tag FullNames; for FOCAS the FixedTree refs never match authored refs, so all FixedTree nodes pass through).
- **NodeId:** `EquipmentNodeIds.Variable(equipmentId, folderPath, name)` where `folderPath` = collapsed segments joined by `/`. Folders deduped, each parented at its prefix.
- **DataType:** convert `DriverDataType` → the OPC-UA-builtin string `OtOpcUaNodeManager.EnsureVariable` expects. **Reuse the existing convention** — grep for how `EquipmentTagPlan.DataType` is produced from `DriverDataType` (e.g. a `DriverDataType.ToString()` / a mapping helper) and `OtOpcUaNodeManager.ResolveBuiltInDataType`; do NOT invent a new mapping. If a helper exists, call it; the switch below is a fallback to align if not.
- **Writable:** from `DiscoveredNode.Writable` (FixedTree is read-only).

**Step 1: Write the failing test**

```csharp
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using Shouldly;
using Xunit;

namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;

[Trait("Category", "Unit")]
public sealed class DiscoveredNodeMapperTests
{
    private static DiscoveredNode Node(string[] path, string name, string fullRef,
        DriverDataType dt = DriverDataType.Float64, bool writable = false)
        => new(path, name, name, fullRef, dt, false, null, writable, false);

    [Fact]
    public void Maps_under_equipment_collapsing_single_device_folder()
    {
        var nodes = new[]
        {
            Node(["FOCAS", "10.0.0.5:8193", "Identity"], "SeriesNumber", "10.0.0.5:8193/Identity/SeriesNumber", DriverDataType.String),
            Node(["FOCAS", "10.0.0.5:8193", "Axes", "X"], "AbsolutePosition", "10.0.0.5:8193/Axes/X/AbsolutePosition"),
        };

        var result = DiscoveredNodeMapper.Map("EQ-1", nodes, authoredRefs: []);

        result.Variables.Select(v => v.NodeId).ShouldBe(new[]
        {
            "EQ-1/FOCAS/Identity/SeriesNumber",
            "EQ-1/FOCAS/Axes/X/AbsolutePosition",
        }, ignoreOrder: true);
        // folders: EQ-1/FOCAS, EQ-1/FOCAS/Identity, EQ-1/FOCAS/Axes, EQ-1/FOCAS/Axes/X
        result.Folders.Select(f => f.NodeId).ShouldContain("EQ-1/FOCAS/Axes/X");
        result.Folders.First(f => f.NodeId == "EQ-1/FOCAS/Axes/X").ParentNodeId.ShouldBe("EQ-1/FOCAS/Axes");
        // routing: driverRef → nodeId
        result.RoutingByRef["10.0.0.5:8193/Identity/SeriesNumber"].ShouldBe("EQ-1/FOCAS/Identity/SeriesNumber");
        result.Variables.First(v => v.NodeId.EndsWith("SeriesNumber")).Writable.ShouldBeFalse();
    }

    [Fact]
    public void Dedups_authored_refs()
    {
        var nodes = new[]
        {
            Node(["FOCAS", "10.0.0.5:8193"], "parts-count", "parts-count"),                       // authored
            Node(["FOCAS", "10.0.0.5:8193", "Identity"], "SeriesNumber", "10.0.0.5:8193/Identity/SeriesNumber", DriverDataType.String),
        };
        var result = DiscoveredNodeMapper.Map("EQ-1", nodes, authoredRefs: new HashSet<string> { "parts-count" });
        result.Variables.ShouldHaveSingleItem();
        result.Variables[0].NodeId.ShouldBe("EQ-1/FOCAS/Identity/SeriesNumber");
    }

    [Fact]
    public void Does_not_collapse_when_two_devices_present()
    {
        var nodes = new[]
        {
            Node(["FOCAS", "10.0.0.5:8193", "Identity"], "SeriesNumber", "a", DriverDataType.String),
            Node(["FOCAS", "10.0.0.6:8193", "Identity"], "SeriesNumber", "b", DriverDataType.String),
        };
        var result = DiscoveredNodeMapper.Map("EQ-1", nodes, authoredRefs: []);
        result.Variables.Select(v => v.NodeId).ShouldBe(new[]
        {
            "EQ-1/FOCAS/10.0.0.5:8193/Identity/SeriesNumber",
            "EQ-1/FOCAS/10.0.0.6:8193/Identity/SeriesNumber",
        }, ignoreOrder: true);
    }
}
```

**Step 2: Run to verify it fails.**

**Step 3: Implement `DiscoveredInjection.cs` (DTOs)**

```csharp
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;

/// <summary>A folder to ensure during discovered-node injection (NodeId + parent + display).</summary>
public sealed record DiscoveredFolder(string NodeId, string? ParentNodeId, string DisplayName);

/// <summary>A read-or-write variable to ensure during discovered-node injection.</summary>
public sealed record DiscoveredVariable(
    string NodeId, string ParentNodeId, string DisplayName, string DataType, bool Writable, bool IsArray, uint? ArrayLength);
```

**Step 3b: Implement `DiscoveredNodeMapper.cs`**

```csharp
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;

namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;

/// <summary>The mapped result of grafting discovered nodes under an equipment.</summary>
public sealed record DiscoveredInjectionPlan(
    IReadOnlyList<DiscoveredFolder> Folders,
    IReadOnlyList<DiscoveredVariable> Variables,
    IReadOnlyDictionary<string, string> RoutingByRef);   // driver FullReference → equipment NodeId

/// <summary>
///     Pure mapper: re-roots a driver's captured discovery tree under an equipment node, deduping
///     authored Config-DB refs and collapsing the single device-host folder. See the design doc.
/// </summary>
public static class DiscoveredNodeMapper
{
    public static DiscoveredInjectionPlan Map(
        string equipmentId, IReadOnlyList<DiscoveredNode> nodes, ISet<string> authoredRefs)
    {
        var kept = nodes.Where(n => !authoredRefs.Contains(n.FullReference)).ToList();

        // Collapse a single shared device-folder level (index 1 under the driver root) when present.
        var collapseIndex1 = kept.Count > 0
            && kept.All(n => n.FolderPathSegments.Count >= 2)
            && kept.Select(n => n.FolderPathSegments[1]).Distinct(StringComparer.Ordinal).Count() == 1;

        static IReadOnlyList<string> Effective(IReadOnlyList<string> segs, bool collapse)
            => collapse ? [segs[0], .. segs.Skip(2)] : segs;

        var folders = new Dictionary<string, DiscoveredFolder>(StringComparer.Ordinal);
        var variables = new List<DiscoveredVariable>();
        var routing = new Dictionary<string, string>(StringComparer.Ordinal);

        foreach (var n in kept)
        {
            var segs = Effective(n.FolderPathSegments, collapseIndex1);

            // Ensure every prefix folder EQ/seg0, EQ/seg0/seg1, …
            for (var i = 0; i < segs.Count; i++)
            {
                var folderPath = string.Join('/', segs.Take(i + 1));
                var nodeId = EquipmentNodeIds.SubFolder(equipmentId, folderPath);
                if (folders.ContainsKey(nodeId)) continue;
                var parent = i == 0 ? equipmentId : EquipmentNodeIds.SubFolder(equipmentId, string.Join('/', segs.Take(i)));
                folders[nodeId] = new DiscoveredFolder(nodeId, parent, segs[i]);
            }

            var varFolderPath = string.Join('/', segs);
            var varNodeId = EquipmentNodeIds.Variable(equipmentId, varFolderPath, n.BrowseName);
            var varParent = EquipmentNodeIds.SubFolder(equipmentId, varFolderPath);
            variables.Add(new DiscoveredVariable(
                varNodeId, varParent, n.DisplayName, ToBuiltinTypeString(n.DataType), n.Writable, n.IsArray, n.ArrayDim));
            routing[n.FullReference] = varNodeId;
        }

        return new DiscoveredInjectionPlan(folders.Values.ToList(), variables, routing);
    }

    // Align with the existing DriverDataType → builtin-string convention used by EquipmentTagPlan /
    // OtOpcUaNodeManager.ResolveBuiltInDataType. VERIFY against that during implementation.
    private static string ToBuiltinTypeString(DriverDataType dt) => dt.ToString();
}
```

> **Implementation note:** before finalizing `ToBuiltinTypeString`, grep how `EquipmentTagPlan.DataType` is produced from a `DriverDataType` and what strings `OtOpcUaNodeManager.ResolveBuiltInDataType` accepts (e.g. `"Float64"`, `"String"`, `"Int32"`). If `DriverDataType.ToString()` already matches, keep it; otherwise mirror the existing mapping helper. The mapper test asserts NodeIds/structure, not the exact type string — add a focused assertion once the convention is confirmed.

**Step 4: Run to verify it passes.**

**Step 5: Commit**

```bash
git add src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/DiscoveredInjection.cs \
        src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DiscoveredNodeMapper.cs \
        tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveredNodeMapperTests.cs
git commit -m "feat(otopcua): map discovered nodes under an equipment subfolder"
```

---

## Task 3: Node-manager `RaiseNodesAddedModelChange()`

**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 1, Task 2

**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs` (add a public method near `BuildNodeShapeChangedEvent:1525`)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerModelChangeOnAddTests.cs` (model on `NodeManagerSurgicalShapeUpdateTests.cs`)

Emit a Part 3 `GeneralModelChangeEvent` with verb `NodeAdded` so already-connected clients can refresh their browse after a runtime add. Mirror the existing `BuildNodeShapeChangedEvent` (verb `DataTypeChanged`) + its `Server.ReportEvent` seam, but build a `NodeAdded` change referencing the equipment subfolder root that gained children.

**Step 1: Write the failing test** — instantiate the node manager as the surgical-shape test does, `EnsureFolder` + `EnsureVariable` a couple of nodes, call `RaiseNodesAddedModelChange(parentNodeId)`, and assert it does not throw and (where the harness exposes reported events, as the surgical test does) that a `GeneralModelChangeEvent` with verb `NodeAdded` was reported. Reuse the surgical test's harness/setup verbatim.

**Step 2: Run to verify it fails** (method missing).

**Step 3: Implement** — add:

```csharp
/// <summary>
///     Announce that nodes were added at runtime (discovered-node injection) so subscribed clients can
///     refresh their browse. Part 3 §8.7.4: a GeneralModelChangeEvent is emitted by the Server object;
///     verb = NodeAdded, affected = the subfolder root that gained children. Mirrors
///     <see cref="BuildNodeShapeChangedEvent"/>'s ReportEvent seam; tolerant if auditing/eventing is off.
/// </summary>
/// <param name="affectedNodeId">The equipment/subfolder NodeId string under which nodes were added.</param>
public void RaiseNodesAddedModelChange(string affectedNodeId)
{
    GeneralModelChangeEventState e;
    lock (Lock)
    {
        // BUILD the event under Lock (consistent snapshot of _folders/_variables), mirroring
        // BuildNodeShapeChangedEvent: EventId, SourceNode = ObjectIds.Server, SourceName, Time,
        // Severity, a ModelChangeStructureDataType with Affected = new NodeId(affectedNodeId,
        // NamespaceIndex) + Verb = (byte)ModelChangeStructureVerbMask.NodeAdded, ClearChangeMasks.
        e = BuildNodesAddedModelChange(affectedNodeId);
    }
    // REPORT OUTSIDE Lock — Server.ReportEvent re-enters the server's own subscription/event path;
    // holding Lock across it risks a lock-order inversion (mirror ReportNodeShapeChangedEvent, NOT
    // ReportConditionEvent which uses alarm.ReportEvent). Tolerant: eventing off / no monitored items.
    try { Server.ReportEvent(SystemContext, e); }
    catch (Exception ex)
    {
#pragma warning disable CS0618
        Utils.LogError(ex, "OtOpcUaNodeManager: failed to report GeneralModelChangeEvent(NodeAdded) for {0}", affectedNodeId);
#pragma warning restore CS0618
    }
}
```

> ⚠️ **Lock discipline (corrected 2026-06-26):** BUILD the `GeneralModelChangeEventState` under `lock (Lock)` (copy the field-population block from `BuildNodeShapeChangedEvent` `:1525`, changing only `Verb` → `NodeAdded` and `Affected`), but **REPORT `Server.ReportEvent` OUTSIDE the lock** — exactly like `ReportNodeShapeChangedEvent` / `RevertOptimisticWriteIfNeeded`. `Server.ReportEvent` re-enters the SDK subscription/event path; holding `Lock` across it risks a lock-order-inversion deadlock with a client that has event subscriptions. (An earlier draft of this plan said "keep it inside `lock (Lock)`" — that was wrong for `Server.ReportEvent`; `ReportConditionEvent` is *not* a valid analogue since it uses `alarm.ReportEvent`, the node's own notifier chain.)

**Step 4: Run to verify it passes.**

**Step 5: Commit**

```bash
git add src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs \
        tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerModelChangeOnAddTests.cs
git commit -m "feat(otopcua): GeneralModelChangeEvent(NodeAdded) for runtime node adds"
```

---

## Task 4: `AddressSpaceApplier.MaterialiseDiscoveredNodes(...)`

**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** none (depends on Tasks 2, 3)

**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs` (add after `MaterialiseEquipmentTags:304`)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs` (add cases)

Add an idempotent pass that ensures the mapped folders then variables via the existing `SafeEnsureFolder`/`SafeEnsureVariable`, then raises the model-change. Folders MUST be ensured parent-before-child (sort by NodeId depth / segment count).

**Step 1: Write the failing test** — using the applier test's existing fake sink, call `MaterialiseDiscoveredNodes` with 2 folders + 2 read-only variables and assert the sink received `EnsureFolder`/`EnsureVariable` with the right NodeIds/parents, `writable: false`, and that a re-apply is a no-op (idempotent — sink early-returns on existing). Assert `RaiseNodesAddedModelChange` is invoked (extend the fake sink/node-manager double to record it, mirroring how the existing test verifies materialize calls).

**Step 2: Run to verify it fails.**

**Step 3: Implement**

```csharp
/// <summary>
///     Materialise driver-discovered nodes (FixedTree) under an equipment at runtime. Idempotent:
///     re-applies are cheap (the sink's EnsureFolder/EnsureVariable early-return on existing nodes), so
///     this is safely re-run after every address-space rebuild. Folders are ensured parent-first.
///     Emits a NodeAdded model-change so connected clients can refresh.
/// </summary>
public void MaterialiseDiscoveredNodes(
    string equipmentRootNodeId,
    IReadOnlyList<DiscoveredFolder> folders,
    IReadOnlyList<DiscoveredVariable> variables)
{
    ArgumentNullException.ThrowIfNull(folders);
    ArgumentNullException.ThrowIfNull(variables);
    if (folders.Count == 0 && variables.Count == 0) return;

    foreach (var f in folders.OrderBy(f => f.NodeId.Count(c => c == '/')))
        SafeEnsureFolder(f.NodeId, f.ParentNodeId, f.DisplayName);

    foreach (var v in variables)
        SafeEnsureVariable(v.NodeId, v.ParentNodeId, v.DisplayName, v.DataType, v.Writable,
            historianTagname: null, isArray: v.IsArray, arrayLength: v.ArrayLength);

    _sink.RaiseNodesAddedModelChange(equipmentRootNodeId);

    _logger.LogInformation(
        "AddressSpaceApplier: discovered nodes materialised under {Equipment} (folders={Folders}, vars={Vars})",
        equipmentRootNodeId, folders.Count, variables.Count);
}
```

> Confirm `_sink`'s interface exposes `RaiseNodesAddedModelChange` (the sink type wraps `OtOpcUaNodeManager`); add it to the sink interface if the applier talks to an `IAddressSpaceSink` abstraction rather than the concrete manager.

**Step 4: Run to verify it passes.**

**Step 5: Commit**

```bash
git add src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs \
        tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs
git commit -m "feat(otopcua): applier pass to materialise discovered nodes idempotently"
```

---

## Task 5: `OpcUaPublishActor.MaterialiseDiscoveredNodes` message + handler

**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** none (depends on Task 4)

**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs` (message record near the other records; `Receive<…>` at the block `:217`; handler near `HandleRebuild`)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs` (add a case)

**Step 1: Write the failing test** — with the publish-actor test harness (fake applier), send `MaterialiseDiscoveredNodes(equipmentRoot, folders, variables)` and assert the handler forwards to `_applier.MaterialiseDiscoveredNodes(...)` with the same payload.

**Step 2: Run to verify it fails.**

**Step 3: Implement** — add the message + Receive + handler:

```csharp
/// <summary>Inject driver-discovered nodes (FixedTree) under an equipment at runtime (post-connect).</summary>
public sealed record MaterialiseDiscoveredNodes(
    string EquipmentRootNodeId,
    IReadOnlyList<DiscoveredFolder> Folders,
    IReadOnlyList<DiscoveredVariable> Variables);
```

In the Receive block (`:217`, alongside `Receive<RebuildAddressSpace>(HandleRebuild)`):

```csharp
Receive<MaterialiseDiscoveredNodes>(HandleMaterialiseDiscovered);
```

Handler:

```csharp
private void HandleMaterialiseDiscovered(MaterialiseDiscoveredNodes msg)
    => _applier.MaterialiseDiscoveredNodes(msg.EquipmentRootNodeId, msg.Folders, msg.Variables);
```

**Step 4: Run to verify it passes.**

**Step 5: Commit**

```bash
git add src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs \
        tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs
git commit -m "feat(otopcua): OpcUaPublishActor handles discovered-node materialisation"
```

---

## Task 6: `DriverInstanceActor` post-connect bounded re-discovery

**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** none (depends on Task 1; touches actor lifecycle)

**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs` (message records area `:60-160`; `Connected()` entry via `InitializeSucceeded:278`; new private async discovery method + a self-scheduled retry tick)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDiscoveryTests.cs`

On reaching `Connected`, if `_driver is ITagDiscovery`, run discovery into a `CapturingAddressSpaceBuilder`, and `Context.Parent.Tell(new DiscoveredNodesReady(_driverInstanceId, nodes))`. Because FOCAS suppresses FixedTree until `FixedTreeCache` populates (~0–2 s), schedule a bounded retry: re-run every ~2 s up to a cap (~30 s / ~15 attempts) **or until the node count stops growing** (whichever first), then stop. `DiscoverAsync` reads in-memory cache → cheap. Reset/cancel the schedule on leaving `Connected` (DisconnectObserved/ForceReconnect) and re-arm on the next `Connected` entry. Use Akka scheduling (`Context.System.Scheduler.ScheduleTellOnce` self-tell of an internal `RediscoverTick`, tracked by an `ICancelable` so it's cancelled on state exit) — do NOT block the actor thread.

**Message records to add** (near the other nested records):

```csharp
/// <summary>Published to the parent (DriverHostActor) after a post-connect discovery pass.</summary>
public sealed record DiscoveredNodesReady(string DriverInstanceId, IReadOnlyList<DiscoveredNode> Nodes);

/// <summary>Internal self-tick driving bounded post-connect re-discovery.</summary>
private sealed record RediscoverTick(int Generation, int Attempt, int LastCount);
```

**Step 1: Write the failing test** — drive a `DriverInstanceActor` with a fake `IDriver` that also implements `ITagDiscovery`, whose `DiscoverAsync` yields 0 nodes on the first ~2 attempts then a non-empty set (simulating FixedTreeCache populating). Bring the actor to `Connected` (send the same init messages the existing `DriverInstanceActorTests` use). Use the TestKit parent probe (`Context.Parent` → the TestKit `TestActor` via `ActorOf` under the testkit, or the existing harness's parent-probe pattern in `DriverInstanceActorTests`) and `ExpectMsg<DiscoveredNodesReady>` — assert the eventually-delivered message carries the non-empty set, and that re-ticks stop after the set stabilises (no infinite stream). Use the TestKit scheduler / `Within` to advance.

**Step 2: Run to verify it fails.**

**Step 3: Implement** — add the discovery kick at the `InitializeSucceeded` Connected transition (after `ResubscribeDesired()`), a `Receive<RediscoverTick>` in `Connected()`, and a `RunDiscoveryAsync` that:
- guards `_driver is ITagDiscovery disc` (else no-op),
- builds a `CapturingAddressSpaceBuilder`, awaits `disc.DiscoverAsync(builder, ct)`,
- `Context.Parent.Tell(new DiscoveredNodesReady(_driverInstanceId, builder.Nodes))`,
- if `attempt < cap` and `builder.Nodes.Count` still growing (or zero), schedules the next `RediscoverTick(_initGeneration, attempt+1, builder.Nodes.Count)` via `ICancelable` (store in a field, cancel on `DetachSubscription`/state exit).
- Tag ticks with `_initGeneration` and ignore stale-generation ticks (mirrors the existing `InitializeSucceeded.Generation` guard) so a reconnect cancels the prior loop.

> Use `ReceiveAsync<RediscoverTick>` (like the other async receives in `Connected()`), and wrap the discovery call in try/catch → log Info + reschedule (bounded). Mirror the existing cancelable-scheduling pattern already used in the actor (grep `Scheduler`/`ICancelable` in this file and `DriverHostActor`).

**Step 4: Run to verify it passes.**

**Step 5: Commit**

```bash
git add src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs \
        tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDiscoveryTests.cs
git commit -m "feat(otopcua): driver-instance post-connect bounded re-discovery"
```

---

## Task 7: `DriverHostActor` — inject discovered nodes (handler + routing + subscribe)

**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** none (depends on Tasks 2, 5, 6; touches actor + routing map)

**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs` (fields near `_nodeIdByDriverRef`; `Receive<…>` in BOTH receive states `:482` and `:512`; new handler; store `_lastComposition` in `PushDesiredSubscriptions`)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs`

Add `Receive<DriverInstanceActor.DiscoveredNodesReady>(HandleDiscoveredNodes)` to the two states that already handle `AttributeValuePublished` (`:484`, `:512`). New fields: `_lastComposition` (set at the end of `PushDesiredSubscriptions`) and `_discoveredByDriver` (`Dictionary<string, DiscoveredInjectionPlan>`). The handler:

1. If `_lastComposition` is null → stash nothing / log Debug and return (composition not applied yet; a later `DiscoveredNodesReady` retry will land after apply).
2. Resolve the equipment: `_lastComposition.EquipmentNodes.Where(e => e.DriverInstanceId == id)`. 0 → log Info skip; >1 → log Warning skip (multi-device deferred). Else take its `EquipmentId`.
3. Compute `authoredRefs` = `_lastComposition.EquipmentTags.Where(t => t.DriverInstanceId == id).Select(t => t.FullName)` set.
4. `var plan = DiscoveredNodeMapper.Map(equipmentId, msg.Nodes, authoredRefs);`
5. If `plan.Variables` empty → return (nothing new yet).
6. `_discoveredByDriver[id] = plan;`
7. For each `(ref, nodeId)` in `plan.RoutingByRef`: add to `_nodeIdByDriverRef[(id, ref)]` (the same `HashSet` fan-out structure used in `PushDesiredSubscriptions:1019`).
8. `_opcUaPublishActor.Tell(new OpcUaPublishActor.MaterialiseDiscoveredNodes(equipmentId, plan.Folders, plan.Variables));`
9. Merge the discovered refs into the driver's desired set and re-push: `child.Actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(union, SubscriptionPublishingInterval, alarmRefs))` where `union` = authored refs already pushed for that driver **plus** `plan.RoutingByRef.Keys`. (Keep the alarmRefs as last pushed.) The actor's `Connected` `SetDesiredSubscriptions` handler immediately re-subscribes (`:340-353`).

**Step 1: Write the failing test** — build a `DriverHostActor` via its existing test harness (`DriverHostActorTests`/`...WriteRoutingTests` show construction with fakes: a fake child/registry, fake OPC publish probe, a composition artifact). Apply a deployment whose composition has one equipment (`EQ-1`, `DriverInstanceId=d1`) + one authored tag, so `_lastComposition` is set and a child `d1` exists. Send `DriverInstanceActor.DiscoveredNodesReady("d1", <fixedtree nodes>)`. Assert: (a) the OPC publish probe received `MaterialiseDiscoveredNodes` with the mapped folders/vars; (b) the child probe received a `SetDesiredSubscriptions` whose refs include both the authored ref and the FixedTree refs; (c) a subsequent `AttributeValuePublished(d1, <fixedtree ref>, value)` routes to an `AttributeValueUpdate` at the mapped NodeId (proves `_nodeIdByDriverRef` updated).

**Step 2: Run to verify it fails.**

**Step 3: Implement** per the steps above. Store `_lastComposition = composition;` at the end of `PushDesiredSubscriptions` (after the existing logic). Reuse the exact fan-out add pattern for `_nodeIdByDriverRef` from `:1019-1045`.

**Step 4: Run to verify it passes.**

**Step 5: Commit**

```bash
git add src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs \
        tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs
git commit -m "feat(otopcua): inject discovered nodes into the equipment projection on connect"
```

---

## Task 8: `DriverHostActor` — re-inject discovered nodes after a rebuild

**Classification:** high-risk
**Estimated implement time:** ~3 min
**Parallelizable with:** none (depends on Task 7; same file)

**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs` (tail of `PushDesiredSubscriptions`)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs` (add a case)

A structural redeploy triggers `RebuildAddressSpace` (full teardown) and `PushDesiredSubscriptions` rebuilds `_nodeIdByDriverRef` from authored tags only — losing the injected FixedTree nodes + mappings. After the existing `PushDesiredSubscriptions` work, **re-apply the cached `_discoveredByDriver`**: for each cached plan, re-add its `RoutingByRef` to `_nodeIdByDriverRef`, re-`Tell` `MaterialiseDiscoveredNodes`, and re-merge its refs into that driver's pushed `SetDesiredSubscriptions`.

**Step 1: Write the failing test** — after Task 7's injection, simulate a second `PushDesiredSubscriptions` (re-apply the same deployment). Assert the OPC publish probe receives `MaterialiseDiscoveredNodes` AGAIN and the child's re-pushed `SetDesiredSubscriptions` still includes the FixedTree refs (i.e. they weren't dropped by the rebuild).

**Step 2: Run to verify it fails** (today the rebuild drops them).

**Step 3: Implement** — extract the per-driver merge-and-materialise into a helper reused by both `HandleDiscoveredNodes` and a new `ReapplyDiscovered()` call at the tail of `PushDesiredSubscriptions` (after `_lastComposition` is set). Guard for the case where the driver no longer exists in `_children` or the equipment was removed (drop that cache entry).

**Step 4: Run to verify it passes.**

**Step 5: Commit**

```bash
git add src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs \
        tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs
git commit -m "feat(otopcua): re-inject discovered nodes after address-space rebuild"
```

---

## Task 9: Integration test — discovered nodes appear + carry values + survive lifecycle

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (depends on Tasks 7, 8)

**Files:**
- Create: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveryInjectionEndToEndTests.cs`
- (Reuse / extend any existing in-memory `IDriver` test double in the Runtime tests; create a `FakeDiscoverableDriver : IDriver, ITagDiscovery, ISubscribable` if none fits.)

A focused in-process integration test (no docker, no CNC): wire `DriverHostActor` + `OpcUaPublishActor` + a real `AddressSpaceApplier`/node manager (as the publish-actor rebuild tests do) + a fake discoverable+subscribable driver whose `DiscoverAsync` exposes a delayed FixedTree set and whose poll returns values for those refs. Assert end-to-end:
1. After connect + the discovery delay, the node manager has variables at `EQ-…/FOCAS/…`.
2. A poll value for a FixedTree ref surfaces as a Good `AttributeValueUpdate` at the mapped NodeId (no longer `BadWaitingForInitialData`).
3. After a simulated rebuild (re-apply), the nodes + values persist.

> If a full wiring proves too heavy for one test fixture, split into (9a) host→publish materialisation reaching a real node manager, and (9b) value-route smoke — but keep both in this file. Do NOT silently drop the lifecycle assertion; if you cannot wire a real node manager here, log that limitation in the test summary and cover it in Task 10's docker-dev step instead.

**Step 5: Commit**

```bash
git add tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveryInjectionEndToEndTests.cs
git commit -m "test(otopcua): end-to-end discovered-node injection + value flow"
```

---

## Task 10: Build + full suite + docker-dev smoke

**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** none (depends on all prior)

**Files:** none (verification only; fix wiring if the build/tests surface gaps)

**Steps:**
1. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` → 0 errors, 0 warnings.
2. `dotnet test ZB.MOM.WW.OtOpcUa.slnx --filter "FullyQualifiedName~Runtime.Tests"` → green.
3. `dotnet test ZB.MOM.WW.OtOpcUa.slnx --filter "FullyQualifiedName~OpcUaServer.Tests"` → green.
4. `dotnet test ZB.MOM.WW.OtOpcUa.slnx --filter "FullyQualifiedName~FOCAS"` → green (no regression).
5. **docker-dev smoke (optional but recommended):** build the docker-dev image, boot `central-1` (fused admin+driver), confirm via logs that a connected discoverable driver injects nodes (`AddressSpaceApplier: discovered nodes materialised`) and that browse shows `EQ-…/FOCAS/…`. (Mirror the symptom-#1 docker-dev confirmation in the investigation plan.)
6. Commit any wiring fixes with a `fix(otopcua):` message.

---

## Task 11: Docs

**Classification:** trivial
**Estimated implement time:** ~3 min
**Parallelizable with:** none (depends on Task 10)

**Files:**
- Modify: `docs/plans/2026-06-25-otopcua-equipment-dataplane-investigation.md` (mark symptom #2 / the FixedTree feature done, link this plan + the design doc)
- Modify: `docs/deployments/wonder-app-vd03-makino-z-34184.md` (note FixedTree now surfaces under `EQ-…/FOCAS/…`)
- Modify: `docs/plans/2026-06-26-otopcua-fixedtree-equipment-injection-design.md` (status → Implemented)

**Step: Commit**

```bash
git add docs/plans/2026-06-25-otopcua-equipment-dataplane-investigation.md \
        docs/deployments/wonder-app-vd03-makino-z-34184.md \
        docs/plans/2026-06-26-otopcua-fixedtree-equipment-injection-design.md
git commit -m "docs(otopcua): record FixedTree-under-Equipment injection feature"
```

---

## Live deploy (post-plan, with user confirmation)

Following the symptom-#1 pattern (self-contained publish-overlay → wonder): after the suite is green and docker-dev confirms, **confirm with the user before deploying to the production CNC node**, then deploy and browse `EQ-3686c0272279/FOCAS/Identity/SeriesNumber` + `…/Axes/X/AbsolutePosition` (assert status Good — values may be 0 on the idle machine). Live deploy is explicitly NOT part of the build/test gate.

## Follow-ups (out of scope, documented)

- Discovered **alarms** injection; **multi-device-per-driver-instance** equipment mapping; **writable** discovered nodes.
- Reconcile the AdminUI↔driver FOCAS config-format mismatch (series-as-number, scheme-less host) at the AdminUI source.
- Shared `AddZbSerilog` not setting static `Serilog.Log.Logger` (latent across all 3 apps).
