docs(drivers): plan — protocol-driver equipment-tag linkage + write pipeline (13 tasks)

This commit is contained in:
Joseph Doherty
2026-06-13 10:51:18 -04:00
parent e58f33584f
commit 6b041c6daa
2 changed files with 501 additions and 0 deletions
@@ -0,0 +1,482 @@
# Protocol-driver equipment-tag linkage + inbound write pipeline — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task.
**Goal:** An equipment `Tag` bound to any protocol driver (Modbus, S7, AbCip, AbLegacy, TwinCAT, Focas) subscribes + publishes a live value (delivered by the shipped `FullName→NodeId` router `c4435e4f`), and an authorized operator can write that node back to the device.
**Architecture:** **Part A** — each protocol driver gains a shared `EquipmentTagRefResolver<TDef>` that, on a `_tagsByName` miss, parses the incoming ref (the equipment `TagConfig` JSON the router already keys on) into a transient driver tag-definition (Approach B; mirrors the OpcUaClient direct-ref precedent). **Part B** — a server-side inbound write pipeline: writable equipment-tag nodes + an `OnWriteValue` authz gate + a write gateway that routes through `DriverHostActor` (NodeId→driver reverse map, primary-gated) to the driver's `WriteAsync`.
**Tech Stack:** .NET 10, System.Text.Json, Akka.NET, OPC UA Foundation stack, xUnit + Shouldly + Akka.TestKit. Design: `docs/plans/2026-06-13-protocol-equipment-tag-linkage-design.md` (master `e58f3358`).
**Branch:** `feat/protocol-equipment-tag-linkage` off master `e58f3358`.
---
## Hard rules (every task)
- Stage by path; never `git add .`. Never stage `sql_login.txt`, `src/Server/ZB.MOM.WW.OtOpcUa.Host/pki/`, `pending.md`, `current.md`.
- Never echo secrets. No force-push, no `--no-verify`. **No Configuration entity / EF migration change.**
- No bUnit. Razor/JS/live behaviour proven only by docker-dev `/run`.
- Done = full build clean + `dotnet test` green for touched suites + both live gates (Task 8 read, Task 12 write) pass.
## Task order & dependencies
T0 branch → **Part A:** T1 resolver → T2T7 (six drivers, parallel) → T8 live read-gate → **Part B:** T9 (writable nodes) ∥ T10 (reverse map+route) → T11 (gateway+authz) → T12 live write-gate → finish.
---
### Task 0: Create feature branch
**Classification:** trivial
**Estimated implement time:** ~1 min
**Parallelizable with:** none
**Files:** git only
```bash
git checkout master && git rev-parse --short HEAD # expect e58f3358
git checkout -b feat/protocol-equipment-tag-linkage
```
Confirm clean tree (ignore untracked `pending.md` / `current.md` / `sql_login.txt` / `pki/`).
---
### Task 1: Shared `EquipmentTagRefResolver<TDef>` + unit tests
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**blockedBy:** Task 0
**Files:**
- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/EquipmentTagRefResolver.cs`
- Test: `tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/EquipmentTagRefResolverTests.cs`
**Context:** Protocol drivers key their tag-table on the authored `DriverConfig.Tags[].Name` (`_tagsByName`). An equipment tag arrives as a ref that is the raw `TagConfig` JSON blob (no `FullName`). This resolver wraps the by-name lookup with a parse-on-miss + cache so the driver resolves both legacy and equipment refs through one call. Generic so every driver reuses it with its own def type + parser.
**Step 1: Write the failing test** (`EquipmentTagRefResolverTests.cs`)
```csharp
using Shouldly;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;
public class EquipmentTagRefResolverTests
{
private sealed record Def(string Id);
private static EquipmentTagRefResolver<Def> Make(
Dictionary<string, Def> byName, Func<string, Def?> parse)
=> new(r => byName.TryGetValue(r, out var d) ? d : null, parse);
[Fact]
public void Legacy_name_resolves_via_byName()
{
var r = Make(new() { ["Temp"] = new Def("Temp") }, _ => null);
r.TryResolve("Temp", out var def).ShouldBeTrue();
def!.Id.ShouldBe("Temp");
}
[Fact]
public void Equipment_ref_resolves_via_parse_and_is_cached()
{
var calls = 0;
var r = Make(new(), ref => { calls++; return ref.StartsWith("{") ? new Def(ref) : null; });
r.TryResolve("{\"a\":1}", out var d1).ShouldBeTrue();
r.TryResolve("{\"a\":1}", out var d2).ShouldBeTrue();
d1!.Id.ShouldBe("{\"a\":1}");
calls.ShouldBe(1); // second call served from cache
}
[Fact]
public void Garbage_ref_returns_false_and_caches_negative()
{
var calls = 0;
var r = Make(new(), _ => { calls++; return null; });
r.TryResolve("nope", out _).ShouldBeFalse();
r.TryResolve("nope", out _).ShouldBeFalse();
calls.ShouldBe(1);
}
[Fact]
public void Clear_drops_transient_cache()
{
var calls = 0;
var r = Make(new(), ref => { calls++; return new Def(ref); });
r.TryResolve("{\"a\":1}", out _).ShouldBeTrue();
r.Clear();
r.TryResolve("{\"a\":1}", out _).ShouldBeTrue();
calls.ShouldBe(2);
}
}
```
**Step 2: Run — expect FAIL** (type not defined):
`dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests --filter "FullyQualifiedName~EquipmentTagRefResolver"`
**Step 3: Implement** (`EquipmentTagRefResolver.cs`)
```csharp
using System.Collections.Concurrent;
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Resolves a driver subscription/read/write <c>fullReference</c> to a driver tag-definition,
/// bridging the two authoring models: a legacy authored tag-table entry (looked up by name)
/// OR an equipment tag whose reference is its raw <c>TagConfig</c> JSON (parsed on first use
/// and cached). Negative results are cached too, so a genuinely-unknown reference is parsed once.
/// </summary>
/// <typeparam name="TDef">The driver's internal tag-definition type.</typeparam>
public sealed class EquipmentTagRefResolver<TDef> where TDef : class
{
private readonly Func<string, TDef?> _byName;
private readonly Func<string, TDef?> _parseRef;
private readonly ConcurrentDictionary<string, TDef?> _cache = new(StringComparer.Ordinal);
/// <param name="byName">Authored tag-table lookup (returns null on miss).</param>
/// <param name="parseRef">Parses an equipment-tag reference (TagConfig JSON) into a transient def, or null.</param>
public EquipmentTagRefResolver(Func<string, TDef?> byName, Func<string, TDef?> parseRef)
{
ArgumentNullException.ThrowIfNull(byName);
ArgumentNullException.ThrowIfNull(parseRef);
_byName = byName;
_parseRef = parseRef;
}
/// <summary>True when <paramref name="fullReference"/> resolves to a def (authored or equipment).</summary>
/// <param name="fullReference">The wire reference handed to the driver.</param>
/// <param name="def">The resolved tag-definition when this returns true.</param>
/// <returns><see langword="true"/> when a definition was found.</returns>
public bool TryResolve(string fullReference, out TDef def)
{
var authored = _byName(fullReference);
if (authored is not null) { def = authored; return true; }
var resolved = _cache.GetOrAdd(fullReference, _parseRef);
if (resolved is not null) { def = resolved; return true; }
def = null!;
return false;
}
/// <summary>Drops the transient-parse cache (call on driver reinitialise so a config change re-parses).</summary>
public void Clear() => _cache.Clear();
}
```
**Step 4: Run — expect PASS.** **Step 5: Build** `dotnet build src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions`.
**Step 6: Commit** (by path):
```bash
git add src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/EquipmentTagRefResolver.cs tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/EquipmentTagRefResolverTests.cs
git commit -m "feat(drivers): shared EquipmentTagRefResolver (by-name + parse-on-miss + cache)"
```
---
### Task 2: Modbus — equipment-tag parser + wire resolver (read + write) — **the exemplar**
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Tasks 3, 4, 5, 6, 7
**blockedBy:** Task 1
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusEquipmentTagParser.cs`
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs` (the four `_tagsByName.TryGetValue` sites: `:151` deadband/ShouldPublish, `:300` ReadAsync, `:714` coalesced read, `:934` WriteAsync; instantiate the resolver near `_tagsByName` decl `:35`; `Clear()` in `ReinitializeAsync` `:218`)
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusEquipmentTagTests.cs`
**Context — the shape to parse:** the equipment `TagConfig` JSON is exactly what `ModbusTagConfigModel` reads (`src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/ModbusTagConfigModel.cs`): camelCase keys `region,address,dataType,byteOrder,bitIndex,stringLength` with enum-name string values (`"HoldingRegisters"`, `"Int16"`, …). Map to `ModbusTagDefinition` (`src/Drivers/.../Modbus.Contracts/ModbusDriverOptions.cs:283`: `Name, Region, Address(ushort), DataType, Writable=true, ByteOrder, BitIndex(byte), StringLength(ushort), …`). **The transient def's `Name` MUST equal the ref string** so the value the driver publishes back keys the router's forward map. Default `Writable=true` (node-level authz governs writes). Return false if the JSON isn't an object or lacks `region`+`address`.
**Step 1: Write the failing test** (`ModbusEquipmentTagTests.cs`)
```csharp
using Shouldly;
using ZB.MOM.WW.OtOpcUa.Driver.Modbus; // ModbusTagDefinition, ModbusRegion, ModbusDataType
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
public class ModbusEquipmentTagTests
{
[Fact]
public void Parses_equipment_tagconfig_into_a_transient_definition()
{
var json = """{"region":"HoldingRegisters","address":40001,"dataType":"UInt16","byteOrder":"BigEndian","bitIndex":0,"stringLength":0}""";
ModbusEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.Name.ShouldBe(json); // identity == the ref (router publish-back key)
def.Region.ShouldBe(ModbusRegion.HoldingRegisters);
def.Address.ShouldBe((ushort)40001);
def.DataType.ShouldBe(ModbusDataType.UInt16);
}
[Fact]
public void Rejects_a_non_address_blob()
=> ModbusEquipmentTagParser.TryParse("""{"FullName":"x"}""", out _).ShouldBeFalse();
[Fact]
public void Rejects_garbage()
=> ModbusEquipmentTagParser.TryParse("not json", out _).ShouldBeFalse();
}
```
(Also add a driver-level test: build a `ModbusDriver` with an empty authored `Tags`, then `ReadAsync([equipmentRefJson])` against a fake transport returns a value — **not** `BadNodeIdUnknown`. Mirror existing `ModbusDriver` read tests for the fake-transport setup.)
**Step 2: Run — expect FAIL.**
`dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests --filter "FullyQualifiedName~ModbusEquipmentTag"`
**Step 3: Implement the parser** (`ModbusEquipmentTagParser.cs`)
```csharp
using System.Text.Json;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <summary>Parses an equipment tag's <c>TagConfig</c> JSON (the shape authored by the AdminUI
/// <c>ModbusTagConfigModel</c>) into a transient <see cref="ModbusTagDefinition"/> whose
/// <see cref="ModbusTagDefinition.Name"/> equals the reference string itself.</summary>
public static class ModbusEquipmentTagParser
{
private static readonly JsonSerializerOptions Opts = new() { PropertyNameCaseInsensitive = true };
/// <summary>Attempts to parse an equipment-tag reference into a transient definition.</summary>
/// <param name="reference">The equipment tag's TagConfig JSON (also used as the def identity).</param>
/// <param name="def">The transient definition when parsing succeeds.</param>
/// <returns><see langword="true"/> when <paramref name="reference"/> is a Modbus address object.</returns>
public static bool TryParse(string reference, out ModbusTagDefinition def)
{
def = null!;
if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return false;
try
{
using var doc = JsonDocument.Parse(reference);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object
|| !root.TryGetProperty("region", out _) || !root.TryGetProperty("address", out var addr))
return false;
var region = ReadEnum(root, "region", ModbusRegion.HoldingRegisters);
var dataType = ReadEnum(root, "dataType", ModbusDataType.Int16);
var byteOrder = ReadEnum(root, "byteOrder", ModbusByteOrder.BigEndian);
var bitIndex = (byte)ReadInt(root, "bitIndex");
var stringLength = (ushort)ReadInt(root, "stringLength");
def = new ModbusTagDefinition(
Name: reference, Region: region, Address: (ushort)addr.GetInt32(), DataType: dataType,
Writable: true, ByteOrder: byteOrder, BitIndex: bitIndex, StringLength: stringLength);
return true;
}
catch (JsonException) { return false; }
catch (FormatException) { return false; }
}
private static TEnum ReadEnum<TEnum>(JsonElement o, string name, TEnum fallback) where TEnum : struct, Enum
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String
&& Enum.TryParse<TEnum>(e.GetString(), ignoreCase: true, out var v) ? v : fallback;
private static int ReadInt(JsonElement o, string name)
=> o.TryGetProperty(name, out var e) && e.TryGetInt32(out var v) ? v : 0;
}
```
(Confirm `ModbusByteOrder` is in the same namespace; adjust enum names to the real ones in `ModbusDriverOptions.cs`.)
**Step 4: Wire the resolver into `ModbusDriver.cs`.** Add a field near `_tagsByName` (`:35`):
```csharp
private readonly EquipmentTagRefResolver<ModbusTagDefinition> _resolver;
```
Initialise it in the ctor (after `_tagsByName` exists) — note `_tagsByName` is populated in `InitializeAsync`, but the resolver only needs the *delegate*, so construct it once in the ctor:
```csharp
_resolver = new EquipmentTagRefResolver<ModbusTagDefinition>(
r => _tagsByName.TryGetValue(r, out var t) ? t : null,
r => ModbusEquipmentTagParser.TryParse(r, out var d) ? d : null);
```
Replace each `if (!_tagsByName.TryGetValue(<ref>, out var tag))` / `if (_tagsByName.TryGetValue(<ref>, out var tag))` at `:151,:300,:714,:934` with `_resolver.TryResolve(<ref>, out var tag)`. In `ReinitializeAsync` (`:218`, before/after `InitializeAsync`) call `_resolver.Clear();`. Add `using ZB.MOM.WW.OtOpcUa.Core.Abstractions;`. Confirm the Modbus driver project references `Core.Abstractions` (it does — `IDriver` lives there).
**Step 5: Run tests + build**
```bash
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests
dotnet build src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus
```
Expect green + 0 errors. (Existing Modbus tests must stay green — the resolver is a superset of `_tagsByName`.)
**Step 6: Commit** (by path): `git commit -m "feat(modbus): resolve equipment-tag refs (read + write) via EquipmentTagRefResolver"`.
---
### Tasks 37: S7 / AbCip / AbLegacy / TwinCAT / Focas — same pattern as Task 2
**Classification:** standard (each)
**Estimated implement time:** ~5 min each
**Parallelizable with:** Task 2 and each other (disjoint files)
**blockedBy:** Task 1
Each task repeats Task 2's recipe for one driver. Per driver, do the three steps: (1) a `…EquipmentTagParser.TryParse` in that driver's `*.Contracts` project mirroring its AdminUI editor model's keys; (2) instantiate `EquipmentTagRefResolver<TDef>` in the driver; (3) replace its `_tagsByName.TryGetValue` lookup sites with `_resolver.TryResolve` + `Clear()` on reinit; plus parser + resolve-on-miss unit tests in that driver's `*.Tests` project.
| Task | Driver | Driver file (grep `_tagsByName` for sites) | Contracts project (parser) | Editor model to mirror | Tests project |
|---|---|---|---|---|---|
| 3 | S7 | `src/Drivers/.../Driver.S7/S7Driver.cs` | `…Driver.S7.Contracts` | `AdminUI/Uns/TagEditors/S7TagConfigModel.cs` | `…Driver.S7.Tests` |
| 4 | AbCip | `src/Drivers/.../Driver.AbCip/AbCipDriver.cs` | `…Driver.AbCip.Contracts` | `AbCipTagConfigModel.cs` | `…Driver.AbCip.Tests` |
| 5 | AbLegacy | `src/Drivers/.../Driver.AbLegacy/AbLegacyDriver.cs` | `…Driver.AbLegacy.Contracts` | `AbLegacyTagConfigModel.cs` | `…Driver.AbLegacy.Tests` |
| 6 | TwinCAT | `src/Drivers/.../Driver.TwinCAT/TwinCATDriver.cs` | `…Driver.TwinCAT.Contracts` | `TwinCATTagConfigModel.cs` | `…Driver.TwinCAT.Tests` |
| 7 | Focas | `src/Drivers/.../Driver.FOCAS/FocasDriver.cs` | `…Driver.FOCAS.Contracts` | `FocasTagConfigModel.cs` | `…Driver.FOCAS.Tests` |
**Per-driver notes (read the editor model + the driver's tag-definition record first):**
- The transient def's identity field (the equivalent of Modbus `Name` — the key `_tagsByName` / the publish-back uses) MUST equal the ref string.
- Each driver's `_tagsByName` site count differs (S7 5, AbCip 10, AbLegacy 8, TwinCAT 9, Focas 6 — includes the decl + populate loop, which you do NOT change; only the *lookup* sites become `_resolver.TryResolve`).
- Some drivers route reads through a shared `PollGroupEngine`; the lookup still happens inside the driver's read/write methods — wire the resolver there.
- Default the transient def writable; node-level authz (Part B) governs actual writes.
- Required-field guard: return false unless the JSON object carries that driver's mandatory address fields (e.g. S7 area+db+offset; AbCip tag path) so a non-address blob still skips.
**Commit each** (by path): `git commit -m "feat(<driver>): resolve equipment-tag refs via EquipmentTagRefResolver"`.
---
### Task 8: Live read-gate — Modbus equipment tag shows a live changing value (Part A done)
**Classification:** verification
**Estimated implement time:** ~5 min + live
**Parallelizable with:** none
**blockedBy:** Tasks 2, 3, 4, 5, 6, 7
**Files:** none (rig + DB only; never commit rig artifacts).
**Step 1:** Full build + driver suites:
```bash
dotnet build ZB.MOM.WW.OtOpcUa.slnx
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests
```
Expect 0 errors; green. (Optionally run S7/AbCip/AbLegacy/TwinCAT/FOCAS `.Tests` too.)
**Step 2: Author a Modbus equipment tag in the dev rig** (agent-driven; dev UI login disabled, drive the DB or AdminUI). The pymodbus sim is at `10.100.0.35:5020` (start if down: `ssh 10.100.0.35 'cd /opt/otopcua-modbus && docker compose up -d'`). On the `nw-uns` Equipment namespace + the `MAIN` Modbus driver instance, author an equipment `Tag` (EquipmentId set, DriverInstanceId = the Modbus driver, `TagConfig = {"region":"HoldingRegisters","address":<a register the sim serves>,"dataType":"UInt16","byteOrder":"BigEndian","bitIndex":0,"stringLength":0}`, `AccessLevel = Read`). If unsure which Modbus driver/registers the rig exposes, inspect `dbo.DriverInstance` (DriverType='Modbus') + the sim's served registers first.
**Step 3: Rebuild central on the branch + redeploy**
```bash
docker compose -f docker-dev/docker-compose.yml up -d --build migrator central-1 central-2
curl -sS -X POST http://localhost:9200/api/deployments -H "X-Api-Key: docker-dev-deploy-key" -H "Content-Type: application/json" -d '{}'
```
If the Modbus driver is stuck (faulted/Reconnecting ignores `ApplyDelta`), restart `central-1 central-2` to respawn it fresh from the artifact (known gotcha).
**Step 4: Confirm the live value** — read the equipment NodeId (`ns=2;s=<EquipmentId>/<TagName>`) from `opc.tcp://localhost:4840` via Client.CLI twice; expect a Good, **changing** value (the sim increments), not `BadWaitingForInitialData`:
```bash
dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp://localhost:4840 -n "ns=2;s=<EquipmentId>/<TagName>"
```
This live-proves Part A end-to-end. **Gate: do not start Part B until this passes.**
---
### Task 9: Part B — writable equipment-tag nodes (`Writable` plan-parity + AccessLevel)
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 10
**blockedBy:** Task 8
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs` (`EquipmentTagPlan` record `:76-83`; the equipment-tag `.Select` that builds plans `~:310-326`)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs` (`BuildEquipmentTagPlans` `:365-447`)
- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs` (`EnsureVariable` signature)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs` + `src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs` (forward the new arg)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs` (`EnsureVariable` `:636-669` — set AccessLevel)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs` (`MaterialiseEquipmentTags` `:162-199` — pass `plan.Writable`)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/` (plan-parity) + `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/` (artifact derivation)
**Step 1 (TDD): plan-parity test** — for a Tag with `AccessLevel = ReadWrite` bound to an Equipment-kind driver, `Phase7Composer`'s `EquipmentTagPlan.Writable == true`, and `DeploymentArtifact.BuildEquipmentTagPlans` over the *same* composed artifact yields `Writable == true` (and `Read ⇒ false`). Mirror the existing Composer↔Artifact parity tests for equipment tags.
**Step 2: Implement**
- `EquipmentTagPlan`: add `bool Writable` (last positional field). Update the existing materialisation consumers (the EquipmentNodeIds router build in DriverHostActor reads `DriverInstanceId/FullName/EquipmentId/FolderPath/Name` — adding a field is additive).
- `Phase7Composer`: in the equipment-tag `.Select`, set `Writable: t.AccessLevel == TagAccessLevel.ReadWrite` (`Tag.cs:52`, `Enums/TagAccessLevel.cs`).
- `DeploymentArtifact.BuildEquipmentTagPlans`: read the Tag's `AccessLevel` from the artifact Tag JSON the same way (the field is already snapshotted by `ConfigComposer`), set `Writable` identically. **Byte-parity required.**
- `IOpcUaAddressSpaceSink.EnsureVariable(string,string?,string,string)` → add `bool writable` (default false to keep other callers compiling, or update all callers). `SdkAddressSpaceSink` + `DeferredAddressSpaceSink` forward it. `OtOpcUaNodeManager.EnsureVariable`: when `writable``AccessLevel = UserAccessLevel = AccessLevels.CurrentReadWrite` else `CurrentRead` (do NOT attach OnWriteValue here — Task 11 owns that).
- `Phase7Applier.MaterialiseEquipmentTags`: pass `plan.Writable` to `EnsureVariable`.
**Step 3: Run tests + build** the full solution (signature change ripples). Expect 0 errors; green.
**Step 4: Commit** (by path): `git commit -m "feat(server): equipment-tag node writability from Tag.AccessLevel (parity-safe, no migration)"`.
---
### Task 10: Part B — reverse NodeId→driver map + `RouteNodeWrite` (primary-gated)
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 9
**blockedBy:** Task 8
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs` (the `_nodeIdByDriverRef` build in `PushDesiredSubscriptions` `:611-633`; the `_children` dict `:87`; add message handler)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs` (reuse `WriteAttribute`/`WriteAttributeResult` `:41-42` — no change expected; confirm `WriteAttribute` is `Forward`-able)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/` (Akka.TestKit)
**Step 1 (TDD): Akka.TestKit tests** — on a `DriverHostActor` (or a focused harness) with a child + a known equipment tag: `RouteNodeWrite(nodeId, value)` on the **primary** resolves `nodeId → (driver, fullName)` and the child receives `WriteAttribute(fullName, value)` (probe as child) → replies `WriteAttributeResult` → asker gets `NodeWriteResult(true,…)`; on a **secondary**`NodeWriteResult(false,"not primary")` and no child message; unknown nodeId → `NodeWriteResult(false,…)`.
**Step 2: Implement**
- Add `private readonly Dictionary<string,(string DriverInstanceId,string FullName)> _driverRefByNodeId = new(StringComparer.Ordinal);` Build it in `PushDesiredSubscriptions` alongside the forward map (clear + repopulate each apply): for each composition equipment tag, `_driverRefByNodeId[EquipmentNodeIds.Variable(eq,folder,name)] = (DriverInstanceId, FullName)`.
- Messages: `public sealed record RouteNodeWrite(string NodeId, object? Value);` `public sealed record NodeWriteResult(bool Success, string? Reason);` (place near the actor's other message records).
- Handler `Receive<RouteNodeWrite>`: primary gate first — reuse the actor's existing redundancy/role signal (it already consumes the driver role-leader / `RedundancyStateActor` snapshot for the alarm-emit gate; reuse the same `_isPrimary`/role field — find it in `DriverHostActor`). If not primary → `Sender.Tell(new NodeWriteResult(false, "not primary"))`. Else resolve `_driverRefByNodeId.TryGetValue(NodeId)``_children.TryGetValue(driverId)``entry.Actor.Forward(new DriverInstanceActor.WriteAttribute(fullName, Value!))` (Forward preserves the asker as Sender so the child's `WriteAttributeResult` returns to it). The gateway (Task 11) maps `WriteAttributeResult` → outcome, so it must `Ask<…>` and accept either `NodeWriteResult` (gate/resolve failures) or `WriteAttributeResult` (driver result) — to keep one reply type, instead have the handler do `entry.Actor.Ask<DriverInstanceActor.WriteAttributeResult>(…)` with a bounded timeout and `.PipeTo(Sender, …)` translating to `NodeWriteResult`. **Pick the PipeTo-translate approach** so the asker always receives `NodeWriteResult`. Unknown node/missing child → `NodeWriteResult(false,…)`.
(Implementer: confirm how `DriverHostActor` currently learns it is primary — grep `Primary`/`RoleLeader`/`_isPrimary` in `DriverHostActor.cs`; reuse that exact signal. If the actor does not already track primary, subscribe to the same redundancy snapshot the alarm-emit gate uses — see `project_redundancy_state_delivery` / the alerts-emit Primary gate — do NOT invent a second mechanism.)
**Step 3: Run Runtime.Tests + build.** Expect green.
**Step 4: Commit** (by path): `git commit -m "feat(runtime): NodeId→driver reverse routing + primary-gated RouteNodeWrite"`.
---
### Task 11: Part B — write gateway + `OnWriteValue` authz handler (the inbound bridge)
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**blockedBy:** Tasks 9, 10
**Files:**
- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaNodeWriteGateway.cs` (+ `NodeWriteOutcome`)
- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredNodeWriteGateway.cs` (mirror `DeferredAddressSpaceSink`)
- Create: a production impl bridging to Akka (e.g. `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/ActorNodeWriteGateway.cs`) that `Ask`s `DriverHostActor.RouteNodeWrite` (bounded ~10 s) → `NodeWriteOutcome`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs` (attach `OnWriteValue` in `EnsureVariable` when writable; add the handler mirroring `HandleAlarmCommand` `:527-552`; hold an `IOpcUaNodeWriteGateway`)
- Modify: DI registration (where `IOpcUaAddressSpaceSink`/`DeferredAddressSpaceSink` are registered + where the deferred sink is set at host `StartAsync``src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs` + `OtOpcUaServerHostedService`)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/` (authz gate + outcome mapping)
**Step 1 (TDD): authz-gate unit tests** — a small testable handler `EvaluateWrite(RoleCarryingUserIdentity? identity, …)`: identity null → `BadUserAccessDenied`; roles lacking `WriteOperate``BadUserAccessDenied`; roles containing `WriteOperate` → calls the gateway (inject a fake returning success) → `Good`; gateway failure → mapped `Bad*`. Keep the role-extraction/gate logic in a method you can unit-test without a live SDK session (pass the `RoleCarryingUserIdentity` + a fake gateway).
**Step 2: Implement**
- `IOpcUaNodeWriteGateway { Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, CancellationToken ct); }`; `NodeWriteOutcome(bool Success, string? Reason)`. `DeferredNodeWriteGateway` queues/forwards to a real gateway set at startup (mirror `DeferredAddressSpaceSink`). `ActorNodeWriteGateway` holds the `DriverHostActor` ref and does `Ask<NodeWriteResult>(new RouteNodeWrite(nodeId,value), 10s)``NodeWriteOutcome`.
- `OtOpcUaNodeManager`: take an `IOpcUaNodeWriteGateway` (ctor/DI). In `EnsureVariable`, when `writable`, set `variable.OnWriteValue = OnEquipmentTagWrite;`. Handler (mirror `HandleAlarmCommand`):
```csharp
private ServiceResult OnEquipmentTagWrite(ISystemContext context, NodeState node, NumericRange indexRange,
QualifiedName dataEncoding, ref object value, ref StatusCode statusCode, ref DateTime timestamp)
{
var identity = (context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity;
if (identity is null || !identity.Roles.Contains(OpcUaDataPlaneRoles.WriteOperate, StringComparer.OrdinalIgnoreCase))
return StatusCodes.BadUserAccessDenied;
var nodeId = node.NodeId.Identifier?.ToString() ?? string.Empty;
var outcome = _writeGateway.WriteAsync(nodeId, value, CancellationToken.None).GetAwaiter().GetResult();
return outcome.Success ? ServiceResult.Good : new ServiceResult(StatusCodes.BadNotWritable, outcome.Reason);
}
```
(Blocking `.GetAwaiter().GetResult()` is acceptable: the SDK `OnWriteValue` delegate is synchronous; writes are infrequent and bounded ~10 s. Fail closed on null identity. Returning `Good` lets the SDK apply the value optimistically; the next poll republishes the confirmed register value.)
- DI: register `DeferredNodeWriteGateway` as the `IOpcUaNodeWriteGateway` singleton; set its inner `ActorNodeWriteGateway` at host `StartAsync` once the `DriverHostActor` ref + node manager exist (same place `SdkAddressSpaceSink` is set on the deferred sink).
**Step 3: Run OpcUaServer.Tests + full build.** Expect green; 0 errors.
**Step 4: Commit** (by path): `git commit -m "feat(server): inbound operator-write pipeline — OnWriteValue authz gate + node-write gateway"`.
---
### Task 12: Live write-gate + finish
**Classification:** verification
**Estimated implement time:** ~10 min + live
**Parallelizable with:** none
**blockedBy:** Task 11
**Files:** none.
**Step 1:** `dotnet build ZB.MOM.WW.OtOpcUa.slnx` + `dotnet test` the touched server suites (OpcUaServer.Tests, Runtime.Tests) + driver suites. Expect green.
**Step 2: Rebuild central on the branch + redeploy** (as Task 8). Make the Task-8 Modbus equipment tag `AccessLevel = ReadWrite` (so its node is writable). Redeploy; confirm the node now advertises write access.
**Step 3: Authorized write** — connect Client.CLI as an LDAP user holding `WriteOperate` against the shared GLAuth (`10.100.0.35:3893`, baseDN `dc=zb,dc=local`, user password `password`; OPC UA session auth is separate from the disabled dev UI login — see `docs/Client.CLI.md` for the username/password connect flags + map the user's LDAP group to `WriteOperate` per `Security:Ldap`). Write a value to `ns=2;s=<EquipmentId>/<TagName>`, then read the corresponding holding register back from the pymodbus sim (or re-read the node after the next poll) to confirm the register changed.
**Step 4: Unauthorized write is denied** — connect as an anonymous/ReadOnly session and write the same node → expect `BadUserAccessDenied`.
**Step 5:** On both live gates green (Task 8 read + this write), finish via `superpowers-extended-cc:finishing-a-development-branch` (intent: merge-to-master + push). Update `pending.md` (gap b closed; note write-through shipped) and the `project_galaxy_standard_driver` memory — do NOT stage `pending.md`.
---
## Out of scope
Tune/Configure write-role granularity (needs equipment-tag SecurityClassification — future schema); unifying driver parsers with the AdminUI editor models; secondary-node writes; Phase B native alarms / Phase C server historian.
@@ -0,0 +1,19 @@
{
"planPath": "docs/plans/2026-06-13-protocol-equipment-tag-linkage-plan.md",
"tasks": [
{"id": 341, "subject": "Task 0: Create feature branch", "status": "pending"},
{"id": 342, "subject": "Task 1: Shared EquipmentTagRefResolver + tests", "status": "pending", "blockedBy": [341]},
{"id": 343, "subject": "Task 2: Modbus equipment-tag resolver (exemplar)", "status": "pending", "blockedBy": [342]},
{"id": 344, "subject": "Task 3: S7 equipment-tag resolver", "status": "pending", "blockedBy": [342]},
{"id": 345, "subject": "Task 4: AbCip equipment-tag resolver", "status": "pending", "blockedBy": [342]},
{"id": 346, "subject": "Task 5: AbLegacy equipment-tag resolver", "status": "pending", "blockedBy": [342]},
{"id": 347, "subject": "Task 6: TwinCAT equipment-tag resolver", "status": "pending", "blockedBy": [342]},
{"id": 348, "subject": "Task 7: Focas equipment-tag resolver", "status": "pending", "blockedBy": [342]},
{"id": 349, "subject": "Task 8: Live read-gate (Part A done)", "status": "pending", "blockedBy": [343, 344, 345, 346, 347, 348]},
{"id": 350, "subject": "Task 9: Writable equipment-tag nodes (Part B)", "status": "pending", "blockedBy": [349]},
{"id": 351, "subject": "Task 10: Reverse map + RouteNodeWrite (Part B)", "status": "pending", "blockedBy": [349]},
{"id": 352, "subject": "Task 11: Write gateway + OnWriteValue authz (Part B)", "status": "pending", "blockedBy": [350, 351]},
{"id": 353, "subject": "Task 12: Live write-gate + finish", "status": "pending", "blockedBy": [352]}
],
"lastUpdated": "2026-06-13"
}