14 KiB
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) — 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
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
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=<index>;…</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=<uri>;…</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=<uri>;…</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=<n>;…</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
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):
- Manual-entry placeholder
ns=2;s=...→nsu=<namespace-uri>;s=...(stop nudging the index form). - 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 nons=prefix, soIsDurablereturnstrueand 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 searchmaxDepth: 6for 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:
@using ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections
Change the manual-node-id input (currently line ~92) placeholder:
<input class="form-control" @bind="_manualNodeId" @bind:event="oninput"
placeholder="nsu=<namespace-uri>;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:
@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=<uri>;…</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
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
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
ConditionIddedup only if the live server double-delivers. - Item C — UNS-bound alarm routing: a v3 condition's
SourceNameis always the RawPath, so a UNS-bound source won'tStartsWith-match — resolve UNS→Raw via the browseableOrganizesreference. Build against the realSourceName/SourceNodepayload captured on the rig.
Verification (whole-plan, after Task 3)
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).