Files
ScadaBridge/docs/plans/2026-07-23-otopcua-v3-nsu-hardening-and-browse-ux.md

264 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 35, §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 35, §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).