From 266f001a2e05bde609c10f8f5f952e3307466ef7 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 23 Jul 2026 15:22:22 -0400 Subject: [PATCH] docs(plans): OtOpcUa v3 dual-namespace cutover scope + phase-2 plan (#14) --- ...otopcua-v3-dual-namespace-cutover-scope.md | 140 ++++++++++ ...-otopcua-v3-nsu-hardening-and-browse-ux.md | 263 ++++++++++++++++++ ...-nsu-hardening-and-browse-ux.md.tasks.json | 9 + 3 files changed, 412 insertions(+) create mode 100644 docs/plans/2026-07-23-otopcua-v3-dual-namespace-cutover-scope.md create mode 100644 docs/plans/2026-07-23-otopcua-v3-nsu-hardening-and-browse-ux.md create mode 100644 docs/plans/2026-07-23-otopcua-v3-nsu-hardening-and-browse-ux.md.tasks.json diff --git a/docs/plans/2026-07-23-otopcua-v3-dual-namespace-cutover-scope.md b/docs/plans/2026-07-23-otopcua-v3-dual-namespace-cutover-scope.md new file mode 100644 index 00000000..2dbb7a47 --- /dev/null +++ b/docs/plans/2026-07-23-otopcua-v3-dual-namespace-cutover-scope.md @@ -0,0 +1,140 @@ +# OtOpcUa v3.0 dual-namespace cutover — scoping (Gitea #14) + +**Date:** 2026-07-23 · **Status:** SCOPING (decisions needed — see §6) · **Tracked:** Gitea +[#14](https://gitea.dohertylan.com/dohertj2/ScadaBridge/issues/14) · **Area:** Data Connection +Layer (OPC UA adapter) · **Upstream:** OtOpcUa v3.0 (merged `master` 2026-07-16, PR #472, merge +`ec6598ce`); design `~/Desktop/OtOpcUa/docs/plans/2026-07-15-raw-uns-two-subtree-v3-design.md`. + +## 1. Headline + +**ScadaBridge is already structurally v3-safe.** A prior refactor made the OPC UA reference a +**namespace-URI-durable** string resolved dynamically against the live server `NamespaceArray`, so +the retirement of OtOpcUa's `EquipmentNodeIds` scheme and the split into two namespaces does **not** +break any parsing or binding logic. There is **no hardcoded `otopcua` namespace URI anywhere in +`src/`** (only test fixtures), and **nothing in ScadaBridge parses the `{EquipmentId}/…` NodeId +shape** — the identifier is opaque. + +The genuine cutover is therefore **narrow**: one **data** problem (a namespace-index collision on +legacy bindings), one real **code gap** (native-alarm dedup across the raw+uns notifier fan-out), +and some **UX / validation** polish. Most of it can only be *closed* against a re-seeded live v3 +rig, so this is as much a live-gate as a code change. + +## 2. The v3 wire contract (what changed upstream) + +OtOpcUa replaced its single custom namespace `https://zb.com/otopcua/ns` with **two**: + +| Subtree | Namespace URI | NodeId form | Shape | +|---|---|---|---| +| **Raw** (device tree, source of truth) | `https://zb.com/otopcua/raw` | `ns=;s=` | `Folder/…/Driver/Device/TagGroup/…/Tag` | +| **UNS** (equipment projection) | `https://zb.com/otopcua/uns` | `ns=;s=///` | Area→Line→Equipment→signal | + +Semantics ScadaBridge can rely on (from the v3 design doc): +- Every value has **one source** (the raw tag), fanned to both the raw NodeId and each referencing + UNS NodeId with **identical value/quality/timestamp**. Each UNS variable `Organizes`-references + its raw node (cross-tree link is browseable). +- **Writes** route through **either** NodeId (same `WriteOperate` gating). +- **HistoryRead** works via **both** NodeIds under one historian tagname. +- **Native Part 9 alarms:** one condition instance at the raw tag, `ConditionId`/primary identity = + **RawPath**, fanned by a **single `ReportEvent`** to the raw device folder **and** every + referencing equipment folder. A Server-object subscriber gets **exactly one** copy (the SDK + dedups on the shared `InstanceStateSnapshot`); folder-scoped subscribers in each namespace see the + condition's events. +- The old `EquipmentNodeIds` (`{equipmentId}/{folderPath}/{name}`) scheme is **retired**. + +OtOpcUa's own "Cross-repo impact" note sized this for us: **every ScadaBridge binding re-binds**; +it's a *data* migration, not a code rewrite; the fragile part is that stored refs hard-code the +**namespace index** and ScadaBridge stores no URI; the recommended remediation is to **move to +`nsu=`-qualified references** while re-binding. + +## 3. Current-state assessment (what is already v3-safe) + +Grounded in the DCL code (`src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/`): + +- **`Adapters/OpcUaNodeReference.cs` — the single translation seam, already URI-durable.** + `Resolve` maps `nsu=` to the live index via `ExpandedNodeId.ToNodeId(expanded, + namespaceUris)` against the session's `NamespaceTable`, and **throws** when a URI is absent from + the server's `NamespaceArray` (no silent stale-index binding); rejects `svr=` cross-server refs. + `ToDurable` emits the `nsu=` form by reading `namespaceUris.GetString(NamespaceIndex)`. + **No change needed — this seam is the reason the cutover is low-risk.** +- **`Adapters/RealOpcUaClient.cs`** — every read/write/subscribe/browse routes NodeIds through the + seam with the live `_session.NamespaceUris`. No hardcoded URI, no assumed index. +- **`Adapters/OpcUaDataConnection.cs`** — passes the configured tag-path string straight to the + client; no path parsing/joining. There is **no browse-path→NodeId translation** anywhere + (bindings are absolute NodeIds), so there is no indirection layer to update. +- **`Commons/Types/DataConnections/OpcUaEndpointConfig.cs`** — carries only `EndpointUrl` + timing/ + auth/heartbeat; **no namespace URI, index, or NodeId field**. Namespaces are discovered from the + live server, not configured — inherently v3-tolerant. Nothing to change here. +- **`Adapters/OpcUaAlarmMapper.cs` `BuildIdentity`** — the per-condition key is already + `SourceName (= RawPath post-v3) + "." + ConditionName`, i.e. **already aligned** with v3 keying on + RawPath; the routing identity is the binding string verbatim (namespace-form-independent). + +## 4. The genuine cutover work + +| # | Item | Kind | Primary files | Confidence | +|---|------|------|---------------|------------| +| A | **Legacy `ns=` bindings collide with v3.** v2's sole custom namespace and v3's `raw` both land at `ns=2`, so a stored `ns=2;s=…` resolves **without error** but now means a raw-tree node. Every stored binding must be re-authored (or migrated) to the new address space, ideally in `nsu=` form. | **Data** | `TemplateAttribute.DataSourceReference`, `InstanceConnectionBinding.DataSourceReferenceOverride` (config DB); heartbeat `TagPath`; Transport bundles | High (inspection) | +| B | **Native-alarm dedup across the raw+uns fan-out.** `RealOpcUaClient.HandleAlarmEvent` keys on RawPath+ConditionName with **no `ConditionId`-based dedup**; it assumes one condition arrives on one feed. If a v3 server delivers the same condition through two notifier paths into the same feed, two `EventFieldList`s each call `onTransition`. Must verify the Server-object aggregate feed collapses to one copy (the v3 doc says the SDK dedups Server-object subscribers, so this *should* hold — but it is the biggest genuine gap and needs a live check; add ConditionId-based dedup only if the live server double-delivers). | **Code + live validation** | `Adapters/RealOpcUaClient.cs` (HandleAlarmEvent), `Adapters/OpcUaAlarmMapper.cs` | Medium (needs live v3) | +| C | **Alarm routing for UNS-bound sources (confirmed by D-1 = ingest both).** `DataConnectionActor` routes transitions by `SourceReference.StartsWith(bindingRef)`, but a v3 condition's `SourceName` is the **RawPath** regardless of subtree — so a **UNS-bound** alarm source won't `StartsWith`-match. Needs a routing change: resolve a UNS-bound source to its backing RawPath (via the browseable UNS→Raw `Organizes` reference) or map identities. Confirmed scope; validate the exact `SourceName`/`SourceNode` payload on the live rig before finalizing the mapping. | **Code + live validation** | `Actors/DataConnectionActor.cs` (alarm routing), `Adapters/RealOpcUaClient.cs` | Medium (needs live v3) | +| D | **Browse / search UX now shows two subtrees.** `BrowseChildrenAsync` / `AddressSpaceSearch.SearchAsync` start at the standard Objects root and will enumerate **both** raw and uns as sibling roots. Functional, not breaking — but the picker shows two roots, the manual-entry placeholder still nudges `ns=2;s=…`, and the deeper raw hierarchy stresses `VisitedNodeCeiling`/`maxDepth`. | **UX** | `CentralUI/Components/Dialogs/NodeBrowserDialog.razor`, `IOpcUaClient` search caps | High (inspection) | +| E | **`nsu=` hardening (optional, recommended).** The picker already emits `nsu=` via `ToDurable`, but the seam still **accepts** bare `ns=`. Decide whether to warn/reject bare `ns=` on save (closes A permanently) or leave it permissive. | **Code** | `OpcUaNodeReference`, binding validation, picker | High (inspection) | +| F | **Doc drift.** `ns=` examples in code comments + `Component-DataConnectionLayer.md`; update to `nsu=`. Update the scadaproj umbrella index (cutover step 4). | **Docs** | doc comments, `docs/requirements/Component-DataConnectionLayer.md`, `../scadaproj/CLAUDE.md` | High | + +## 5. Proposed phasing + +1. **Decisions** (§6) — resolve D-1..D-3 before code. +2. **`nsu=` hardening + UX** (items D, E, F) — picker emits/enforces `nsu=`, placeholder + search + caps updated, docs swept. Pure ScadaBridge-side, unit-testable, no live server required. +3. **Re-seed a v3 rig** — bring up an OtOpcUa v3.0 server (docker) and re-author a representative + set of bindings across **both** subtrees via the picker (D-1); confirm subscribe/read/write + round-trip and HistoryRead via each subtree, and capture the exact native-alarm `SourceName`/ + `SourceNode` payload so item C's UNS→Raw routing mapping is built against real data. +4. **Alarm live-gate** (items B, C) — against the v3 rig, drive a native condition that fans to both + raw + equipment notifiers and confirm ScadaBridge sees **exactly one** transition per state + change, correctly routed to the bound source. Add ConditionId-based dedup / routing fix **only if + the live server double-delivers**. +5. **Data migration decision** (item A) — either force re-authoring (greenfield, no automatic map) + or write a one-shot migration if a deterministic old→new mapping exists for the rig's data. +6. **Umbrella index + issue close.** + +## 6. Decisions needed + +- **D-1 — Which subtree does ScadaBridge ingest? → DECIDED (2026-07-23): BOTH.** The picker + surfaces both the Raw (`…/raw`, device tree) and UNS (`…/uns`, Area→Line→Equipment) subtrees, and + an operator binds each attribute / native-alarm source against whichever fits — UNS for + equipment-modelled signals, Raw where device-level identity is wanted. For **values** this is + free: a given ScadaBridge attribute binds to one NodeId, and v3 guarantees identical + value/quality/timestamp on both trees, so no per-value dedup arises. + **Consequence for alarms (promotes item C from "validate" to "fix"):** a native alarm condition is + keyed by its **RawPath** and its `SourceName` is the RawPath *regardless of which subtree the + source was bound on*. So a **UNS-bound** alarm source (prefix `nsu=…/uns;s=//…`) will + **not** `StartsWith`-match a condition whose `SourceName` is the raw path. Ingesting both therefore + **requires** a routing change so a UNS-bound source resolves to the RawPath of its backing raw node + (the UNS→Raw `Organizes` reference is browseable and is the link to follow), or an equivalent + identity mapping. This is confirmed scope, gated on the live v3 rig (§5 phase 4). +- **D-2 — Enforce `nsu=` on save?** *Recommendation: yes* — warn (or reject) bare `ns=` + bindings at authoring time so the index-collision class (item A) can never recur. Low effort, high + durability. +- **D-3 — Migrate legacy bindings, or re-author?** Upstream is greenfield (no reliable automatic + old→new mapping), so *re-authoring via the picker is the default*. A migration is only worth + writing if this environment has a deterministic mapping worth automating. + +## 7. Risks / what needs a live v3 server + +- Items **B** and **C** (alarm dedup + routing) **cannot be fully closed by code inspection** — they + depend on how a live v3 server actually delivers a fanned condition to an aggregate vs + folder-scoped subscription. The plan gates them behind a re-seeded v3 rig. +- No production config data is preserved upstream (greenfield), so there is **no migration + correctness risk** to legacy production rows — only the dev/test rigs need re-authoring. +- The `OpcUaEndpointConfig` has no namespace field, so **no schema/EF change** is anticipated; this + cutover is expected to add **no EF migration** (to be confirmed once D-2's validation surface is + decided). + +## 8. Bottom line + +The prior namespace-durability refactor did the heavy lifting: ScadaBridge already resolves +`nsu=`-qualified references against the live `NamespaceArray` and never assumes an index or a NodeId +shape. What remains is (1) getting existing bindings onto the new address space (data / re-author), +(2) optionally enforcing `nsu=` so the index-collision class is closed for good, (3) tidying the +two-subtree browse UX, and (4) a **live alarm-fan-out validation** that is the only item carrying +real code risk. Once §6 is decided, phase 2 (hardening + UX + docs) is straightforward ScadaBridge +work; phases 3–4 need a re-seeded v3 rig. diff --git a/docs/plans/2026-07-23-otopcua-v3-nsu-hardening-and-browse-ux.md b/docs/plans/2026-07-23-otopcua-v3-nsu-hardening-and-browse-ux.md new file mode 100644 index 00000000..059b7b8d --- /dev/null +++ b/docs/plans/2026-07-23-otopcua-v3-nsu-hardening-and-browse-ux.md @@ -0,0 +1,263 @@ +# 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). diff --git a/docs/plans/2026-07-23-otopcua-v3-nsu-hardening-and-browse-ux.md.tasks.json b/docs/plans/2026-07-23-otopcua-v3-nsu-hardening-and-browse-ux.md.tasks.json new file mode 100644 index 00000000..01022ea9 --- /dev/null +++ b/docs/plans/2026-07-23-otopcua-v3-nsu-hardening-and-browse-ux.md.tasks.json @@ -0,0 +1,9 @@ +{ + "planPath": "docs/plans/2026-07-23-otopcua-v3-nsu-hardening-and-browse-ux.md", + "tasks": [ + {"id": 1, "subject": "Task 1: Commons — OpcUaReferenceForm.IsDurable durability helper + tests", "status": "completed", "classification": "small", "parallelizableWith": [3], "commit": "0eb44314"}, + {"id": 2, "subject": "Task 2: Central UI — durable-binding nudge in node-browser picker (D+E)", "status": "completed", "classification": "small", "blockedBy": [1], "commit": "e04c2617"}, + {"id": 3, "subject": "Task 3: Docs — sweep ns= examples to nsu= (F)", "status": "completed", "classification": "trivial", "parallelizableWith": [1], "commit": "97afa84f"} + ], + "lastUpdated": "2026-07-23" +}