@@ -73,8 +73,10 @@ Read live `OtOpcUaConfig` on `wonder-sql-vd03` (query run on-box so the SQL pass
**Refuted by this read:** prime-suspect `DriverInstanceId` attribution mismatch (matches exactly) and H5 blank-`deviceHostAddress` (present). The deployed config is **clean**. ⇒ symptom #1 is a pure value-flow-plumbing break. New live leads: **`PollGroupId=NULL`** on both tags (is a poll group required to subscribe/poll?) and the **resolver-registration** path (equipment-tag refs are "resolver-produced, not seeded at `InitializeAsync`" per `FocasDriver.cs:247` — does poll-time `TryResolve` of the JSON-blob ref ever succeed?). A second offline subagent trace of DriverHostActor↔DriverInstanceActor↔PollGroupEngine↔resolver is running to pin the exact broken link.
### ⚠️ FixedTree feature (symptom #2 — user chose "build the feature") — ARCHITECTURE REALITY
Mapped the composition pipeline. Two address-space paths exist: (1) **Equipment/UNS projection**`AddressSpaceComposer.Compose` (config entities only) → `AddressSpaceApplier.MaterialiseEquipmentTags` → the served `ns=2` tree where `EQ-…` lives; (2) **raw-driver namespace**`GenericDriverNodeManager.BuildAddressSpaceAsync` → `driver.DiscoverAsync(IAddressSpaceBuilder)`. **Path 2 is DEAD: `BuildAddressSpaceAsync` has no runtime caller and `OpcUaApplicationHost.PopulateAddressSpaces` (its referenced caller) no longer exists.** Even `GalaxyDriver.DiscoverAsync` (`:588`) is reachable only via that dead path — Galaxy surfaces its hierarchy by being **authored as config equipment/tags**, not via discovery. ⇒ In the current Equipment-kind model **every served node is config-driven; `ITagDiscovery`/`DiscoverAsync` is legacy/dead for serving.** So "build the FixedTree feature" is NOT re-wiring an existing path — it's a **new dynamic-node-injection capability** into the Equipment projection, and it must solve a **timing problem**: composition runs at deploy/apply (before the driver connects), but FixedTree data only exists after the driver's async `FixedTreeCache` bootstrap. The far cheaper alternative that yields the same visible result is to **author FixedTree signals as config Tag rows** (each bound to a FOCAS fixed-tree reference) — same mechanism every other equipment tag uses. **Recommend re-confirming scope with the user given this cost delta before building.**
Mapped the composition pipeline. Two address-space paths exist: (1) **Equipment/UNS projection**`AddressSpaceComposer.Compose` (config entities only) → `AddressSpaceApplier.MaterialiseEquipmentTags` → the served `ns=2` tree where `EQ-…` lives; (2) **raw-driver namespace**`GenericDriverNodeManager.BuildAddressSpaceAsync` → `driver.DiscoverAsync(IAddressSpaceBuilder)`. **Path 2 is DEAD: `BuildAddressSpaceAsync` has no runtime caller and `OpcUaApplicationHost.PopulateAddressSpaces` (its referenced caller) no longer exists.** Even `GalaxyDriver.DiscoverAsync` (`:588`) is reachable only via that dead path — Galaxy surfaces its hierarchy by being **authored as config equipment/tags**, not via discovery. ⇒ In the current Equipment-kind model **every served node is config-driven; `ITagDiscovery`/`DiscoverAsync` is legacy/dead for serving.** So "build the FixedTree feature" is NOT re-wiring an existing path — it's a **new dynamic-node-injection capability** into the Equipment projection, and it must solve a **timing problem**: composition runs at deploy/apply (before the driver connects), but FixedTree data only exists after the driver's async `FixedTreeCache` bootstrap. The far cheaper alternative that yields the same visible result is to **author FixedTree signals as config Tag rows** (each bound to a FOCAS fixed-tree reference) — same mechanism every other equipment tag uses. (The user chose to **build the dynamic feature** over the config-rows alternative.)
**✅ BUILT (2026-06-26).** Implemented as a generic **post-connect `ITagDiscovery` injection pipeline**: when a driver reaches `Connected`, `DriverInstanceActor` runs bounded re-discovery into a capturing `IAddressSpaceBuilder` and ships `DiscoveredNodesReady` to `DriverHostActor`, which maps the nodes under the equipment (`EQ-…/FOCAS/…`, read-only), extends the `_nodeIdByDriverRef` routing map, and tells `OpcUaPublishActor` to incrementally materialise them — reusing the existing materialize→subscribe→poll→push pipeline (no full rebuild). Survives redeploys (re-applied at the tail of `PushDesiredSubscriptions`) and restarts (re-discovered on reconnect). Design: [`2026-06-26-otopcua-fixedtree-equipment-injection-design.md`](2026-06-26-otopcua-fixedtree-equipment-injection-design.md); implementation plan (11 bite-sized tasks, all green): [`2026-06-26-otopcua-fixedtree-equipment-injection.md`](2026-06-26-otopcua-fixedtree-equipment-injection.md). **Offline-complete** on branch `feat/focas-fixedtree-equipment-injection` (solution build 0 errors / 0 warnings; Runtime.Tests 312, OpcUaServer.Tests 304, FOCAS 247 + an end-to-end injection+value-flow test, all green). The review chain caught + fixed three real defects (a `DriverDataType→OPC-UA-type` string mismatch, a `Server.ReportEvent`-under-lock deadlock, and a `ConfigureAwait(false)` off-actor-context crash for async drivers). **Live wonder validation pending** (deploy the current host + browse `ns=2;s=EQ-3686c0272279/FOCAS/Identity/SeriesNumber`, `…/FOCAS/Axes/X/AbsolutePosition`).
**The FOCAS poll read hangs forever because (1) all wire I/O for a device shares one socket with NO serialization, and (2) the steady-state read has NO timeout.**
@@ -149,7 +151,7 @@ After the self-contained overlay (current Host) + two light single-DLL FOCAS swa
4.**`FlexibleStringConverter`** on the FOCAS config `Series` — the AdminUI persists the enum as a number (`"series":6`); the factory now tolerates number-or-string instead of throwing → stub.
5.**Scheme-less host tolerance** in `FocasHostAddress.TryParse` — the AdminUI persists `hostAddress` as a bare `ip:port`; `TryParse` now accepts it (canonical `focas://` unchanged) instead of failing init.
- FOCAS test suite **247 green**; each fix carries a regression test.
- **Follow-up (product quality):** the AdminUI authors FOCAS configs (`series` as number, `hostAddress` without `focas://`) that the driver only now tolerates — the AdminUI↔driver config-format mismatch is worth reconciling at the source. Also: the shared `AddZbSerilog` not setting static `Serilog.Log.Logger` is a latent gap across all 3 apps. And the FixedTree-under-Equipment feature (task #14) is still pending.
- **Follow-up (product quality):** the AdminUI authors FOCAS configs (`series` as number, `hostAddress` without `focas://`) that the driver only now tolerates — the AdminUI↔driver config-format mismatch is worth reconciling at the source. Also: the shared `AddZbSerilog` not setting static `Serilog.Log.Logger` is a latent gap across all 3 apps. The FixedTree-under-Equipment feature (task #14) is now **BUILT** (offline-complete; see the 2026-06-26 design + implementation-plan docs above) — live wonder validation pending.
## Phase 2 — Get OtOpcUa runtime logs on wonder
Make the Host emit driver-level logs so the data plane is observable. Options (least invasive first): point the service at a Serilog file sink via config/env, or temporarily run with `DOTNET_ENVIRONMENT=Development` (file sink + dev errors — cf. MxGateway note), or add a console capture. Preserve `appsettings*`/`data\`; restore the env after. Then read: did `InitializeAsync` start the FixedTree loop, does the bootstrap throw (and on which call), is `ReadAsync` invoked for the equipment tags, what does it return.
# OtOpcUa — dynamic injection of driver-discovered FixedTree nodes into the Equipment projection (design)
**Date:** 2026-06-26
**Status:** ✅ Implemented (2026-06-26) — 11 tasks, offline-complete on branch `feat/focas-fixedtree-equipment-injection` (solution build 0 errors / 0 warnings; Runtime.Tests 312, OpcUaServer.Tests 304, FOCAS 247 + an end-to-end injection+value-flow test, all green). Live wonder validation pending.
**Follow-ups surfaced during the review chain (not blocking):**
- Config-unchanged driver→equipment **rebind** drops the cached plan but does not itself re-trigger discovery (`ReconcileDrivers` only restarts a child on a `DriverConfig` change) → the FixedTree subtree is absent under the new equipment until the driver's next reconnect/restart re-discovers it.
- **Multi-device-per-driver** equipment mapping is deferred (today strictly 1:1; the equipment is resolved from authored `EquipmentTags`, so a driver needs ≥1 authored tag for its FixedTree to graft — `EquipmentNode` carries no `DriverInstanceId`).
- Per-(re)connect re-discovery runs for **every `ITagDiscovery` driver** (Galaxy/OpcUaClient/TwinCAT too), bounded by stop-on-stable + an attempt cap; narrowing/opt-in for heavy network drivers is a follow-up.
- The end-to-end test asserts the recording-sink contract, not the real `OtOpcUaNodeManager``BadWaitingForInitialData`→Good seed-overwrite at the OPC node layer — that is covered by the live wonder deploy.
**Companion to:** [`2026-06-25-otopcua-equipment-dataplane-investigation.md`](2026-06-25-otopcua-equipment-dataplane-investigation.md) (symptom #1 — live FOCAS values — FIXED + deployed; this design addresses **symptom #2**).
**Base branch:**`fix/focas-poll-io-serialization` (this feature builds on the now-deployed driver-host bootstrap re-spawn + FOCAS I/O fixes; that branch is ahead of `master` and not yet merged).
---
## Problem
Deployed FOCAS equipment serves only its **authored** config tags (`parts-count`/`parts-required`). The driver's
**FixedTree** (Identity / Axes / Spindle / Program / Timers — the auto-discovered CNC structure) **never appears** under
the served Equipment/UNS address space.
**Root cause (confirmed in the investigation, H2):** the served Equipment tree is built **purely from Config-DB entities**
(`AddressSpaceComposer.Compose` → `AddressSpaceApplier` → node manager). The only code that emits FixedTree nodes is
`ITagDiscovery.DiscoverAsync` (each driver implements it), reachable **only** through `GenericDriverNodeManager.BuildAddressSpaceAsync`
— which has **no runtime caller** (its referenced host method `OpcUaApplicationHost.PopulateAddressSpaces` no longer exists).
So `DiscoverAsync`/`ITagDiscovery` is **dead for serving**: every served node is config-driven, and nothing surfaces a
driver's discovered hierarchy.
Surfacing FixedTree under the Equipment node is therefore a **new dynamic-node-injection capability**, and it must solve a
**timing problem**: composition runs at deploy/apply time (before the driver connects), but the FixedTree shape
(axis count, spindle presence, which sections exist) is **capability-discovered ~0–2 s after the driver connects**
(`FocasDriver` populates `state.FixedTreeCache` in its bootstrap loop).
## Goal
After a driver connects, dynamically graft its discovered FixedTree nodes into the served Equipment projection under a
driver-named subfolder, e.g.:
```
ns=2;s=EQ-3686c0272279 (equipment "z-34184")
├── parts-count (authored config tag — unchanged)
├── parts-required (authored config tag — unchanged)
└── FOCAS (NEW — driver-named discovered subfolder)
Read-only value nodes carrying live values (e.g. `EQ-…/FOCAS/Axes/X/AbsolutePosition` reads Good).
## Decisions (locked with the user 2026-06-26)
| Decision | Choice |
|---|---|
| Driver scope | **Generic** — keyed off the shared `ITagDiscovery` interface (FOCAS, Galaxy, Modbus all implement it). FOCAS is the first/test consumer; others get it for free. **Zero per-driver code changes.** |
| Tree placement | **Under a driver-named subfolder** — `EQ-…/FOCAS/…` (collision-safe vs. authored tags; self-describing). |
| Device-host folder | **Collapse** the single device-host level → `EQ-…/FOCAS/Identity/…` (not `EQ-…/FOCAS/10.201.31.5:8193/Identity/…`), valid because today's deployment is strictly 1:1 driver↔equipment↔device. |
| Model-change notification | **Emit `GeneralModelChangeEvent`** after a runtime add so already-connected OPC UA clients can refresh their browse. |
| Discovered alarms | **Out of scope** — this feature surfaces value nodes only; alarms continue to come via the config path. |
| Writable discovered nodes | **Out of scope** — FixedTree is read-only CNC state. |
## Approach (chosen): runtime post-connect injection via the actor pipeline
Treat discovered FixedTree nodes as **"synthetic equipment tags" injected at runtime**, reusing the existing
materialize → subscribe → poll → push pipeline end-to-end. Only three new pieces; **no driver changes** (each driver's
existing `DiscoverAsync` is reused verbatim via a capturing builder).
**Rejected alternatives:**
- *Composition-time pre-projection* — can't author the right nodes before the driver discovers capabilities; defeats the purpose.
- *Resurrect `GenericDriverNodeManager` as a 2nd namespace (ns=3)* — puts FixedTree in a separate tree (not **under** the equipment node), and that namespace's value-routing is also dead; more dead code to revive, wrong location.
- *Cheap baseline: author a Config-DB Tag row per FixedTree signal* — no new code, but static (can't adapt to per-CNC capabilities) and per-signal × per-machine manual authoring. User chose to build the dynamic feature instead.
## Components
### 1. `CapturingAddressSpaceBuilder` (new — runtime)
An `IAddressSpaceBuilder` implementation that **records** the streamed tree instead of creating OPC UA nodes. After a
driver's `DiscoverAsync(builder)` returns, it exposes a flat `IReadOnlyList<DiscoveredNode>`:
```
DiscoveredNode {
IReadOnlyList<string> FolderPathSegments, // e.g. ["FOCAS", "<deviceHost>", "Identity"]
# 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).
**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`).
- **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.
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`).
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
usingZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespaceZB.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.
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)
**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).
> **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.
- 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>
// 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(Exceptionex)
{
#pragmawarningdisableCS0618
Utils.LogError(ex,"OtOpcUaNodeManager: failed to report GeneralModelChangeEvent(NodeAdded) for {0}",affectedNodeId);
#pragmawarningrestoreCS0618
}
}
```
> ⚠️ **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.)
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.
> 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.
- 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.
**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)
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>
**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)`,
- 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`).
**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`)
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`.
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`).
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`.
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).
- (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.
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/…`)
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.