# Equipment-tag live-value delivery — Design

**Date:** 2026-06-13
**Status:** Approved — ready for implementation planning
**Scope:** Deliver a **driver's published value to its materialised equipment-tag OPC UA variable.** This is the load-bearing milestone deferred from the Galaxy-standard-driver work (`docs/plans/2026-06-12-galaxy-standard-driver-design.md`).

## Goal

Make a driver-published value land on the correct equipment-tag variable so it stops showing `BadWaitingForInitialData`. This benefits **every** equipment-tag driver (Modbus/S7/AbCip/…/Galaxy), not just Galaxy.

## The gap (root cause, confirmed in code)

The live-value publish chain is:

```
driver OnDataChange
  → DriverInstanceActor.AttributeValuePublished(FullReference, Value, Quality, Ts)   [DriverInstanceActor.cs:65,449]
  → DriverHostActor.ForwardToMux                                                      [DriverHostActor.cs:379]
       • _dependencyMux?.Tell(msg)                          (VirtualTag inputs — correct, keep)
       • _opcUaPublishActor?.Tell(AttributeValueUpdate(NodeId = msg.FullReference, …)) [DriverHostActor.cs:391]
  → OpcUaPublishActor.HandleAttributeUpdate → _sink.WriteValue(msg.NodeId, …)          [OpcUaPublishActor.cs:159,163]
  → OtOpcUaNodeManager.WriteValue → _variables.TryGetValue(nodeId)                     [OtOpcUaNodeManager.cs:89]
```

But equipment variables are materialised with **folder-scoped NodeIds** (`{parent}/{Name}`, `parent = IsNullOrWhiteSpace(FolderPath) ? EquipmentId : "{EquipmentId}/{FolderPath}"`) — *not* the `FullName` — because a driver wire-ref (e.g. Modbus register 40001) is **not unique across identical machines** (`Phase7Applier.MaterialiseEquipmentTags` :180-192). So `WriteValue(FullReference)` misses `_variables` and (worse) `OtOpcUaNodeManager.WriteValue` **lazily creates an orphan variable** keyed by the raw FullReference (`OtOpcUaNodeManager.cs:97-101`). The value never reaches the real, browseable variable.

`AttributeValuePublished` / `AttributeValueUpdate` also don't carry `DriverInstanceId`, which a `FullName→NodeId` router needs (FullName is unique only *within* a driver instance).

## Chosen approach — mirror the proven VirtualTag pattern

`VirtualTagHostActor` **already solves the identical problem** for VirtualTags: it rebuilds a `vtagId → folder-scoped NodeId` map (`_nodeIdByVtag`) every apply via `NodeIdFor(plan)` and resolves it **before** publishing `AttributeValueUpdate` (`VirtualTagHostActor.cs:42,85-88,121-131,149-153`). VirtualTag values therefore already land correctly. We mirror this for driver equipment-tag values.

### Components

1. **Shared NodeId helper (kills duplication + the drift risk this milestone depends on).**
   The `{parent}/{Name}` formula is currently copied in `Phase7Applier.EquipmentSubFolderNodeId`/inline and `VirtualTagHostActor.NodeIdFor`, each with a "MUST match" doc-comment. Extract one helper — e.g. `ZB.MOM.WW.OtOpcUa.Commons.OpcUa.EquipmentNodeIds.Variable(equipmentId, folderPath, name)` + `SubFolder(equipmentId, folderPath)` (Commons is referenced by both OpcUaServer and Runtime). Repoint `Phase7Applier` + `VirtualTagHostActor` to it (byte-identical output). The new driver map uses the **same** helper, guaranteeing the router's NodeId equals the materialised NodeId.

2. **Carry `DriverInstanceId` on the publish message.**
   Add `DriverInstanceId` to `DriverInstanceActor.AttributeValuePublished` (the actor already holds `_driverInstanceId`, :77/169) and populate it at the raise site (:449).

3. **Driver `(DriverInstanceId, FullName) → NodeId[]` map + resolution in `DriverHostActor`.**
   - Build `_nodeIdByDriverRef` from `composition.EquipmentTags` (each carries `DriverInstanceId`, `FullName`, `EquipmentId`, `FolderPath`, `Name`) using the shared helper. Rebuild it every apply — the natural place is `PushDesiredSubscriptions` (which already iterates `EquipmentTags` grouping by driver, runs after `RebuildAddressSpace` in the apply pass). Mirror `_nodeIdByVtag`'s clear-and-repopulate.
   - In `ForwardToMux`: keep `_dependencyMux?.Tell(msg)` unchanged; then resolve `(msg.DriverInstanceId, msg.FullReference) → NodeId[]` and `Tell` an `AttributeValueUpdate(nodeId, …)` **per** resolved NodeId. On no-match, skip the OPC UA push (debug-log) — **do not** create orphan variables.
   - Value is `List<NodeId>` to handle the 1→many case (two tags on one driver sharing a FullName).

No change to `OpcUaPublishActor` (the existing `AttributeValueUpdate` is reused exactly as VirtualTags use it).

### Data flow (after)

```
driver value (FullReference) → AttributeValuePublished(DriverInstanceId, FullReference, …)
  → ForwardToMux:
       _dependencyMux.Tell(msg)                                  (unchanged)
       _nodeIdByDriverRef[(DriverInstanceId, FullReference)] → [folder-scoped NodeId, …]
         → for each: _opcUaPublishActor.Tell(AttributeValueUpdate(nodeId, value, quality, ts))
  → WriteValue(folder-scoped nodeId) → hits the real variable → value displays
```

## Error handling / edge cases

- **No-match** (a published ref not in the map): skip the OPC UA push, debug-log; never lazily create an orphan variable. (Optionally tighten `OtOpcUaNodeManager.WriteValue` to not auto-create on miss — but leave that as-is to avoid touching the alarm/vtag callers; the router simply doesn't call it for unmatched refs.)
- **1→many**: write to every NodeId the `(driver, FullName)` maps to.
- **Rebuild lifecycle**: `_nodeIdByDriverRef` is cleared + repopulated every apply (matches the address-space rebuild that clears `_variables`).
- **Multi-node / redundancy**: the path runs on every node (each materialises its own variables + builds its own map). No Primary-gating — values flow on all nodes, same as today's vtag path.
- **FullName uniqueness**: globally non-unique across identical machines, but unique within a driver instance → keying on `(DriverInstanceId, FullName)` is correct. (Galaxy `tag_name`s are globally unique anyway.)

## Testing (no bUnit)

- **Unit (xUnit + Shouldly):** the shared `EquipmentNodeIds` helper (formula incl. null/empty FolderPath); `Phase7Applier` + `VirtualTagHostActor` produce byte-identical NodeIds after repointing (golden/parity assertion).
- **DriverHostActor resolution (Akka.TestKit):** an apply with a composition containing an equipment tag → a subsequent `AttributeValuePublished(driverId, FullName, …)` results in an `AttributeValueUpdate(folder-scoped NodeId, …)` reaching the publish actor (probe); two equipments sharing one `(driver, FullName)` → both NodeIds written; an unmatched ref → no OPC UA push (still forwarded to the mux). Extract the resolution into a testable method if it eases TestKit assertions.
- **Live docker-dev `/run` (agent-driven; dev login disabled):** author a Modbus (live sim on `10.100.0.35:5020`) equipment tag → confirm its variable shows a **live value** over OPC UA (Client.CLI `read`/`subscribe`), not `BadWaitingForInitialData`. (Galaxy needs a real mxaccessgw to show data, so Modbus is the cleaner live proof; the routing is driver-agnostic.)

## Out of scope (separate milestones)

- **Phase B** — native `IAlarmSource` alarms on the equipment-tag path.
- **Phase C** — server-side `HistoryRead` backend.
- Tightening `OtOpcUaNodeManager.WriteValue`'s lazy-create-on-miss (latent smell; not required here).

## Hard rules

Stage by path; never `git add .`; never stage `sql_login.txt` / `src/Server/.../Host/pki/` / `pending.md` / `current.md`. Never echo the gateway API key / historian SharedSecret into a tracked file. No force-push, no `--no-verify`. No Configuration entity/EF migration change (this is runtime-only). Build on a feature branch off master.
