# OtOpcUa v3 cutover — `nsu=` hardening + browse UX (phase 2) Implementation Plan

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

**Goal:** Ship the pure ScadaBridge-side slice of the OtOpcUa v3 dual-namespace cutover (Gitea [#14](https://gitea.dohertylan.com/dohertj2/ScadaBridge/issues/14)) — nudge operators toward durable `nsu=` bindings and tidy the two-subtree browse UX — without a live v3 server.

**Architecture:** Add one pure string helper in Commons (`OpcUaReferenceForm.IsDurable`) that flags bare `ns=<index>` references (which the v3 raw/uns split makes ambiguous — see scope doc §4 item A). Surface it as a non-blocking authoring warning in the node-browser picker and fix the manual-entry placeholder. Sweep the `ns=`-only doc examples to `nsu=`. This is the D-2-recommended **warn, don't reject** posture: existing `ns=` bindings keep resolving (the `OpcUaNodeReference.Resolve` seam is unchanged and still accepts both forms), so nothing breaks on the current v2 rig.

**Tech Stack:** C#/.NET 10, xunit, Blazor Server (Bootstrap), `TreatWarningsAsErrors`.

**Scope boundary (read first):** This plan is **phase 2** of the scope doc `docs/plans/2026-07-23-otopcua-v3-dual-namespace-cutover-scope.md` — items **D, E, F** only. Items **A** (re-author legacy bindings), **B** (native-alarm dedup), and **C** (UNS-bound alarm routing) are **deferred — they require a re-seeded live OtOpcUa v3.0 rig** and live alarm-fan-out validation (scope doc §5 phases 3–5, §7). Do NOT attempt them here. Decisions folded in: **D-1 = ingest both subtrees** (already recorded); **D-2 = warn on bare `ns=`** (this plan); **D-3 = re-author, no migration code** (nothing to build).

---

### Task 1: Commons — `OpcUaReferenceForm.IsDurable` durability helper

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

**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/DataConnections/OpcUaReferenceForm.cs`
- Test: `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/DataConnections/OpcUaReferenceFormTests.cs`

A **pure string** helper — no `Opc.Ua` dependency (Commons has none, and CentralUI references only Commons). It answers one question for the authoring UI: *is this reference index-durable, or a bare server-namespace index we should warn about?* A bare `ns=<n>` with n ≥ 1 is exactly the form the v3 raw/uns namespace split makes ambiguous (scope doc §4 item A). `nsu=`, spec-fixed `ns=0`, and short forms with no namespace prefix (`i=85`, `s=Foo`) are durable and must not warn. Whitespace/empty returns `true` (nothing typed → nothing to warn about).

**Step 1: Write the failing tests**

```csharp
using ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections;

namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types.DataConnections;

public class OpcUaReferenceFormTests
{
    [Theory]
    // durable → true
    [InlineData("nsu=https://zb.com/otopcua/raw;s=Line1.Pump.Speed", true)]
    [InlineData("nsu=https://zb.com/otopcua/uns;s=AreaA/Line1/Pump/Speed", true)]
    [InlineData("NSU=https://x;s=y", true)]           // case-insensitive prefix
    [InlineData("ns=0;i=85", true)]                    // spec-fixed namespace 0
    [InlineData("i=85", true)]                         // short form, implicit ns0
    [InlineData("s=Devices.Pump1", true)]              // no namespace prefix
    [InlineData("", true)]                             // nothing typed
    [InlineData("   ", true)]
    [InlineData(null, true)]
    // bare server-namespace index → false (warn)
    [InlineData("ns=2;s=MyDevice.Temperature", false)]
    [InlineData("ns=1;i=1001", false)]
    [InlineData("ns=10;s=x", false)]
    [InlineData("  ns=3;s=x  ", false)]                // trimmed before inspection
    public void IsDurable_classifies_reference_forms(string? reference, bool expected)
    {
        Assert.Equal(expected, OpcUaReferenceForm.IsDurable(reference));
    }
}
```

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

Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/ZB.MOM.WW.ScadaBridge.Commons.Tests.csproj --filter OpcUaReferenceFormTests`
Expected: FAIL — `OpcUaReferenceForm` does not exist (compile error).

**Step 3: Write the minimal implementation**

```csharp
using System.Globalization;

namespace ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections;

/// <summary>
/// Pure, protocol-package-free classification of an OPC UA node-reference string's
/// <i>form</i> — used by authoring UI to nudge operators toward index-durable bindings.
/// </summary>
/// <remarks>
/// A bare <c>ns=&lt;index&gt;;…</c> reference hard-codes a namespace <i>index</i>, which is
/// only meaningful relative to one server's namespace table. The OtOpcUa v3.0 raw/uns
/// split makes this actively dangerous: v2's sole custom namespace and v3's <c>raw</c>
/// tree both land at <c>ns=2</c>, so a stored <c>ns=2;s=…</c> resolves without error but
/// silently now means a raw-tree node (scope doc §4 item A). The durable form is
/// <c>nsu=&lt;uri&gt;;…</c>, resolved against the live namespace table at use time by
/// <c>OpcUaNodeReference.Resolve</c>. This helper only classifies the string; it does not
/// parse or resolve NodeIds (that stays in the DataConnectionLayer seam, which owns the
/// <c>Opc.Ua</c> dependency).
/// </remarks>
public static class OpcUaReferenceForm
{
    /// <summary>
    /// True when <paramref name="reference"/> is index-durable — the durable
    /// <c>nsu=&lt;uri&gt;;…</c> form, spec-fixed <c>ns=0</c>, a short form with no namespace
    /// prefix, or empty/whitespace (nothing to warn about). False only for a bare
    /// server-namespace index (<c>ns=&lt;n&gt;;…</c> with n ≥ 1).
    /// </summary>
    public static bool IsDurable(string? reference)
    {
        if (string.IsNullOrWhiteSpace(reference))
            return true;

        var trimmed = reference.Trim();

        // nsu=<uri>;… is the durable form (and its "ns" prefix must be checked before the
        // bare "ns=" branch below, since "nsu=" also starts with "ns").
        if (trimmed.StartsWith("nsu=", StringComparison.OrdinalIgnoreCase))
            return true;

        // Not a bare namespace-index reference at all → short forms (i=85, s=Foo) that
        // imply namespace 0, which is spec-fixed and already durable.
        if (!trimmed.StartsWith("ns=", StringComparison.OrdinalIgnoreCase))
            return true;

        // ns=<digits>;…  — parse the index; ns=0 is spec-fixed (durable), n ≥ 1 is bare.
        var semicolon = trimmed.IndexOf(';');
        var digits = semicolon >= 0 ? trimmed[3..semicolon] : trimmed[3..];
        if (int.TryParse(digits, NumberStyles.None, CultureInfo.InvariantCulture, out var index))
            return index == 0;

        // Malformed "ns=" with non-numeric index — not a recognisable bare index; leave
        // the real parse/reject to OpcUaNodeReference.Resolve. Don't warn on it here.
        return true;
    }
}
```

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

Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/ZB.MOM.WW.ScadaBridge.Commons.Tests.csproj --filter OpcUaReferenceFormTests`
Expected: PASS (all theory rows).

**Step 5: Commit**

```bash
git add src/ZB.MOM.WW.ScadaBridge.Commons/Types/DataConnections/OpcUaReferenceForm.cs \
        tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/DataConnections/OpcUaReferenceFormTests.cs
git commit -m "feat(dcl): add OpcUaReferenceForm.IsDurable — flags bare ns= bindings (v3 cutover #14)"
```

---

### Task 2: Central UI — durable-binding nudge in the node-browser picker (items D + E)

**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** none (uses the Task 1 helper)

**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/NodeBrowserDialog.razor`

Two UX changes, both non-blocking (D-2 = warn, not reject):
1. Manual-entry **placeholder** `ns=2;s=...` → `nsu=<namespace-uri>;s=...` (stop nudging the index form).
2. A muted inline **warning** below the manual-entry input when the typed reference is a bare `ns=` index (`!OpcUaReferenceForm.IsDurable(_manualNodeId)`), explaining that index bindings can silently re-point after a server namespace change and to prefer the browse tree / `nsu=` form. Safe for every protocol that uses this dialog: MxGateway node ids carry no `ns=` prefix, so `IsDurable` returns `true` and the warning never shows.

> Not in scope here: the picker already renders whatever root nodes the server exposes, so the two v3 subtrees (`raw`/`uns`) appear as sibling roots with **no code change**. Tuning the search `maxDepth: 6` for the deeper raw hierarchy is deferred — it needs a live v3 rig to calibrate (scope doc §5 phase 3).

**Step 1: Add the using and update the placeholder**

At the top of the file, add to the existing `@using` block:

```razor
@using ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections
```

Change the manual-node-id input (currently line ~92) placeholder:

```razor
<input class="form-control" @bind="_manualNodeId" @bind:event="oninput"
       placeholder="nsu=&lt;namespace-uri&gt;;s=..." />
```

(Note the added `@bind:event="oninput"` so the warning below updates as the operator types.)

**Step 2: Add the inline durability warning**

Immediately after the manual-entry `input-group` `</div>` (line ~94, before the `modal-footer`), add:

```razor
@if (!OpcUaReferenceForm.IsDurable(_manualNodeId))
{
    <small class="text-warning-emphasis d-block mt-1" data-test="ns-index-warning">
        This is a bare namespace-<em>index</em> binding. Indexes can silently re-point after a
        server namespace change — prefer selecting from the tree above (it stores the durable
        <code>nsu=&lt;uri&gt;;…</code> form).
    </small>
}
```

**Step 3: Build to verify it compiles**

Run: `dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI/ZB.MOM.WW.ScadaBridge.CentralUI.csproj`
Expected: Build succeeded, 0 warnings (project is `TreatWarningsAsErrors`).

**Step 4: Commit**

```bash
git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/NodeBrowserDialog.razor
git commit -m "feat(ui): warn on bare ns= node bindings in the browse picker (v3 cutover #14)"
```

---

### Task 3: Docs — sweep `ns=` examples to `nsu=` (item F)

**Classification:** trivial
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 1

**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaDataConnection.cs:15`
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Protocol/IBrowsableDataConnection.cs:36`
- Modify: `docs/requirements/Component-DataConnectionLayer.md`
- Modify: `../scadaproj/CLAUDE.md` (umbrella index — CLAUDE.md propagation rule)

Only the sole-example `ns=2;s=…` comments get updated. **Leave `OpcUaNodeReference.cs` alone** — it deliberately documents *both* forms and explains why. Do not touch any `ns=` string in code/tests that is an actual binding value or assertion.

**Step 1: Update the two code-comment examples**

`OpcUaDataConnection.cs:15` — change:
```
/// - TagPath → NodeId (e.g., "ns=2;s=MyDevice.Temperature")
```
to:
```
/// - TagPath → NodeId (durable form, e.g. "nsu=https://server/ns;s=MyDevice.Temperature";
///   the legacy "ns=2;s=..." index form is still accepted — see OpcUaNodeReference)
```

`IBrowsableDataConnection.cs:36` — change the `<param name="NodeId">` example from `"ns=2;s=Devices.Pump1.Speed"` to `"nsu=https://server/ns;s=Devices.Pump1.Speed"`.

**Step 2: Update the component doc**

In `docs/requirements/Component-DataConnectionLayer.md`: update any `ns=<index>` example bindings to the `nsu=<uri>` durable form, and add a short note (cross-referencing `docs/plans/2026-07-23-otopcua-v3-dual-namespace-cutover-scope.md`) that OtOpcUa v3.0 splits its address space into `raw` + `uns` namespaces, that ScadaBridge is namespace-URI-durable via `OpcUaNodeReference`, and that the picker nudges operators to the `nsu=` form. First read the file to place the note in the OPC UA / node-reference section.

**Step 3: Update the umbrella index**

In `../scadaproj/CLAUDE.md`, find the ScadaBridge entry's OtOpcUa relationship line and note that ScadaBridge is v3-`nsu=`-durable and the #14 dual-namespace cutover is in progress (phase 2 shipped: `nsu=` authoring nudge + browse UX; alarm-fan-out live-gate deferred to a v3 rig). Read the surrounding lines first to match the index's style; keep it to one or two lines.

**Step 4: Commit**

```bash
git add src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaDataConnection.cs \
        src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Protocol/IBrowsableDataConnection.cs \
        docs/requirements/Component-DataConnectionLayer.md
git commit -m "docs(dcl): sweep ns= examples to durable nsu= form (v3 cutover #14)"
# umbrella index is a separate repo — commit it there
git -C ../scadaproj add CLAUDE.md && git -C ../scadaproj commit -m "docs: note ScadaBridge OtOpcUa v3 nsu= cutover phase 2 (#14)"
```

---

## Deferred — requires a live OtOpcUa v3.0 rig (NOT in this plan)

Tracked in the scope doc (§5 phases 3–5, §7). Do not attempt without a re-seeded v3 server:

- **Item A** — **STILL OPEN** — re-author existing dev/test bindings across both subtrees via the picker (data, greenfield; D-3 = no migration code).
- ~~**Item B**~~ — **CLOSED 2026-07-23** by the native-alarm B/C live gate (**PASS** — `docs/plans/2026-07-23-otopcua-v3-alarm-bc-live-gate.md`): the live v3 server does **not** double-deliver — one subscription per connection delivers each retained condition once, to the most-specific monitored notifier — so no `ConditionId` dedup was added. *(Original: native-alarm dedup across the raw+uns notifier fan-out; add `ConditionId` dedup only if the live server double-delivers.)*
- ~~**Item C**~~ — **CLOSED 2026-07-23** by the same gate (**PASS**): the premise is obsolete. ScadaBridge routes native alarms by the **subscribed node reference** (`OpcUaAlarmMapper.BuildIdentity` → `SourceObjectReference`), not the event `SourceName` (Gitea #17), so a UNS-bound source routes correctly with no code change. *(Original: a v3 condition's `SourceName` is always the RawPath, so a UNS-bound source won't `StartsWith`-match — resolve UNS→Raw via the browseable `Organizes` reference.)*

Also still open (tracked, needs a live v3 rig): calibrating the address-space search `maxDepth: 6`
for the deeper raw hierarchy — see the note under Task 2.

## Verification (whole-plan, after Task 3)

```bash
dotnet build ZB.MOM.WW.ScadaBridge.slnx
dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/ZB.MOM.WW.ScadaBridge.Commons.Tests.csproj
```
Expected: build 0/0; Commons tests green (incl. `OpcUaReferenceFormTests`). No EF migration is added (scope doc §7 — `OpcUaEndpointConfig` has no namespace field).
