# 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=` 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=` 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; /// /// Pure, protocol-package-free classification of an OPC UA node-reference string's /// form — used by authoring UI to nudge operators toward index-durable bindings. /// /// /// A bare ns=<index>;… reference hard-codes a namespace index, 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 raw /// tree both land at ns=2, so a stored ns=2;s=… resolves without error but /// silently now means a raw-tree node (scope doc §4 item A). The durable form is /// nsu=<uri>;…, resolved against the live namespace table at use time by /// OpcUaNodeReference.Resolve. This helper only classifies the string; it does not /// parse or resolve NodeIds (that stays in the DataConnectionLayer seam, which owns the /// Opc.Ua dependency). /// public static class OpcUaReferenceForm { /// /// True when is index-durable — the durable /// nsu=<uri>;… form, spec-fixed ns=0, a short form with no namespace /// prefix, or empty/whitespace (nothing to warn about). False only for a bare /// server-namespace index (ns=<n>;… with n ≥ 1). /// public static bool IsDurable(string? reference) { if (string.IsNullOrWhiteSpace(reference)) return true; var trimmed = reference.Trim(); // nsu=;… 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=;… — 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=;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 ``` (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` `` (line ~94, before the `modal-footer`), add: ```razor @if (!OpcUaReferenceForm.IsDurable(_manualNodeId)) { This is a bare namespace-index binding. Indexes can silently re-point after a server namespace change — prefer selecting from the tree above (it stores the durable nsu=<uri>;… form). } ``` **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 `` 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=` example bindings to the `nsu=` 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** — re-author existing dev/test bindings across both subtrees via the picker (data, greenfield; D-3 = no migration code). - **Item B** — native-alarm dedup across the raw+uns notifier fan-out; add `ConditionId` dedup **only if** the live server double-delivers. - **Item C** — UNS-bound alarm routing: 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. Build against the real `SourceName`/`SourceNode` payload captured on the rig. ## 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).