# v3 Batch 4 — dual-namespace address space + raw-only runtime binding (= v3.0) **Branch:** `v3/batch4-address-space` (from master after Batch 3 merges) **Design authority:** v3 design §"OPC UA address space + runtime binding" (including the resolved multi-notifier SDK mechanism — the `AddNotifier` code block is normative), §"Error handling / edge cases". **End state:** both namespaces live; every value has exactly one source (the raw node's publish path) fanned to both NodeIds; writes, history, alarms, and scripts all work through either namespace. This batch landing = **v3.0**. **This is the batch where the forwarding trap lives.** Every sink-interface change MUST forward through `DeferredAddressSpaceSink` and extend `tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/DeferredSinkForwardingReflectionTests.cs` — that is part of WP2's definition of done, not an afterthought. ## Namespace + NodeId scheme (contracts commit — coordinator lands first) - Two namespace URIs (constants next to the current `OtOpcUaNodeManager.DefaultNamespaceUri = "https://zb.com/otopcua/ns"`, which they replace): **`https://zb.com/otopcua/raw`** and **`https://zb.com/otopcua/uns`**. - `ns=Raw` NodeIds: `s=`. `ns=UNS` NodeIds: `s=///`. - New `src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/V3NodeIds.cs` (raw + UNS path builders, consuming `RawPaths`); `EquipmentNodeIds.cs` (`{EquipmentId}/{FolderPath}/{Name}` logical-id scheme) is **retired** — delete it and sweep callers (`EquipmentNodeWalker`, composer, tests). - Sink discriminator (decided here, used by WP2/WP3): sink methods carry an `AddressSpaceRealm` enum (`Raw` | `Uns`) rather than parsing namespace out of NodeId strings — explicit beats inferred at every call site. ## Work packages ### Wave A — composition + node manager (2 agents) **B4-WP1 — composer/planner emit both subtrees** - `OpcUaServer/AddressSpaceComposer.cs` (+ `AddressSpaceComposition`, `AddressSpaceChangeClassifier`): un-darken. Compose the Raw subtree (Cluster→Folder→Driver→Device→TagGroup→Tag) from the schema — folders/drivers/devices/ groups as Object/Folder nodes, tags as Variables keyed `(realm=Raw, s=)` — and the UNS subtree (Area/Line/Equipment folders; each `UnsTagReference` a Variable keyed `(realm=Uns, s=/)`) carrying its backing RawPath + an `Organizes` reference UNS→Raw. Native-alarm plans attach at the **raw** tag (ConditionId = RawPath) and carry the list of referencing equipment paths. - `AddressSpacePlan.cs` (`AddressSpacePlanner.Compute`) diffs per realm; a raw rename manifests as remove+add in Raw **and** re-point in UNS (reference row unchanged, backing NodeId changed) — pin this in planner tests. - Reactivate the Batch-1-skipped harness assertion (`EquipmentNamespaceMaterializationTests` skip-reason constant) with the new dual-tree expectations. **B4-WP2 — node manager dual-namespace + sink surface (owns the forwarding trap)** - `OpcUaServer/OtOpcUaNodeManager.cs`: register both namespace URIs; node creation/ `EnsureVariable`/`CreateVariable` take the realm; `_historizedTagnames`, `_notifierFolders`, and the write hook wiring become realm-aware. - Sink interfaces `Commons/OpcUa/IOpcUaAddressSpaceSink.cs` + `ISurgicalAddressSpaceSink.cs`: add the `AddressSpaceRealm` discriminator (or realm-qualified node descriptors) to every method that names nodes; update `SdkAddressSpaceSink`, **forward every change through `DeferredAddressSpaceSink`**, and extend `DeferredSinkForwardingReflectionTests` so an unforwarded method fails CI. - HistoryRead access bits: the UNS reference node registers the **same** historian tagname in `_historizedTagnames` (both NodeIds → one tagname; the mux `HistorizedTagRef` stays single, keyed by RawPath — `AddressSpaceApplier.FeedHistorizedRefs` emits raw refs only, no doubles). ### Wave B — runtime binding + writes (1 agent; the single most delicate package) **B4-WP3 — raw-only binding, UNS fan-out, write routing** - `Runtime/Drivers/DriverHostActor.cs`: on apply, register the raw NodeId AND every referencing UNS NodeId against the same `(DriverInstanceId, RawPath)` in `_nodeIdByDriverRef` (the map is already 1:N — this is new entries, not new machinery); `_driverRefByNodeId` gains the UNS NodeId → same ref inverse. Alarm maps (`_alarmNodeIdByDriverRef`/`_driverRefByAlarmNodeId`) likewise carry the equipment notifier NodeIds. `ForwardToMux` unchanged (mux keys stay RawPath-single). - Write path: a write to a UNS NodeId resolves through `_driverRefByNodeId` to the same driver ref — same `WriteOperate` gating, same `RouteNodeWrite` flow. `OnWriteValue` stays fire-and-forget under the node-manager lock. **Write-outcome self-correction (#5) must keep working through both NodeIds**: a failed device write reverts BOTH node states (they share the stored value only via the fan-out — the revert is a publish, so it fans automatically; add the regression test anyway). - `AddressSpaceApplier.cs`: apply both realms; surgical updates (the F10b in-place path) realm-aware; UNS display-name override changes are surgical (no rebuild), raw renames follow WP1's remove+add plan. - Tests: `DriverHostActorLiveValueTests`/`WriteRoutingTests`/`NativeAlarmTests` extended with dual-NodeId cases; a fan-out drift test (publish once → both node states carry identical value/quality/timestamp). ### Wave C — alarms + verification hardening (2 agents) **B4-WP4 — multi-notifier alarms** - Materialize each native alarm ONCE at the raw tag (parent = its device/group folder, promoted via `EnsureFolderIsEventNotifier` — existing method, line ~1286). For each referencing equipment's UNS folder, wire the design's normative pattern: `alarm.AddNotifier(ctx, null, isInverse: true, equipFolder)` + `equipFolder.AddNotifier(ctx, null, isInverse: false, alarm)` + `EnsureFolderIsEventNotifier(equipFolder)`. One `ReportEvent` fans to all roots; **never duplicate ReportEvent per root** (distinct EventIds break Server-object dedup + Part 9 ack correlation — design decision, not implementer discretion). - **Teardown symmetry (implementation obligation):** track wired notifier pairs like `_notifierFolders` and call `RemoveNotifier(..., bidirectional: true)` on rebuild/subtree-removal/reference-removal — inverse-notifier entries otherwise leak across redeploys. - Part 9 ack/confirm/shelve keep routing on **ConditionId = RawPath** (never SourceNodeId); the `AlarmAck` role gate and the `alarm-commands` DPS flow are unchanged. - `Commons/Messages/Alerts/AlarmTransitionEvent.cs` gains the (possibly empty) list of referencing equipment paths; `/alerts` (`AlertHub` + `Alerts.razor`) shows one row per condition (primary identity RawPath + condition NodeId) with the equipment list as display metadata. - `NativeAlarmProjector` + scripted-alarm surfaces untouched except NodeId scheme sweeps. **B4-WP5 — integration tests + docs** - 2-node harness: dual-namespace materialization, UNS-write-reaches-driver, alarm event at both notifiers, HistoryRead via both NodeIds (NullHistorianDataSource `GoodNoData` path suffices offline), redundancy: ServiceLevel + alarm-emit gate unaffected by the second namespace (the redundancy-state delivery bugs were rig-only — verify on the 2-node rig, not just unit tests). - Docs in the same PR: `CLAUDE.md` (address-space + tag-concept sections), `docs/Uns.md`, `docs/ScriptEditor.md`, `docs/Historian.md`, `docs/ScriptedAlarms.md`, `docs/AlarmTracking.md`; the ScadaBridge cutover note + umbrella-index update (`../scadaproj/CLAUDE.md`) per the design's cross-repo plan. ## Wave schedule | Wave | Packages | Agents | Notes | |---|---|---|---| | — | Contracts commit (URIs, `V3NodeIds`, `AddressSpaceRealm`) | coordinator | Before any fan-out | | A | WP1 ∥ WP2 | 2 | Composer/planner vs node-manager/sinks — disjoint files; both build against the contracts commit | | B | WP3 | 1 | Runtime binding integrates A's output — single agent | | C | WP4 ∥ WP5 | 2 | Alarms vs tests/docs — WP5 rebases last | ## Batch verification gate — the v3.0 live gate (docker-dev + Client.CLI) Rebuild **both** centrals; fixtures up (`modbus standard` at minimum); deploy a config with: a Modbus raw tag (historized), a Calculation tag reading it, an equipment referencing both (one with a display-name override), a native alarm on the Modbus tag, and a VirtualTag using `{{equip}}/`. 1. **Browse both namespaces** (Client.CLI `browse -r -d 6`): Raw shows Folder→Driver→Device→Group→Tag with `s=` NodeIds; UNS shows Area/Line/Equipment with effective names; the UNS variable `Organizes`-references its raw node. 2. **Single-source fan-out:** `subscribe` to the raw NodeId and the UNS NodeId simultaneously → identical values/timestamps as the fixture changes; the calc tag computes; break its script → Bad + script-log row; fix → recovery. 3. **Writes:** `write` via the UNS NodeId → device changes (read back via raw NodeId); repeat via raw NodeId. Role gating: an LDAP user without `WriteOperate` is rejected on both. Failed-write revert: enable the modbus `exception_injector` FC06 rule → write via UNS NodeId → both NodeIds revert to prior value (no optimistic-Good phantom). Remember Client.CLI `write` reads first — target a Good node. 4. **History:** `historyread` against the raw NodeId and the UNS NodeId returns the same series under the same historian tagname (live gateway if VPN available, else `GoodNoData` parity from the Null source); provisioning tally logs `dispatched=N, failed=0`. 5. **Alarms:** trip the native alarm → `/alerts` shows ONE row carrying the equipment path list; Client.CLI `alarms` subscribed at (a) the Server object — exactly **one** event per transition (the shared-snapshot dedup check), (b) the raw device folder, (c) the equipment folder — each receives the event; `ack` via the equipment-side subscription works (ConditionId routing). Redeploy (rebuild path) then re-trip → still exactly one Server-object copy (teardown-symmetry check: no leaked notifier duplicates). 6. **Rename cascade:** rename the raw tag in `/raw` (accept the warnings) + deploy → old raw NodeId gone, new present; UNS reference follows automatically; the absolute RawPath script literal now fails as documented (`BadNodeIdUnknown` at evaluation); the `{{equip}}` VirtualTag keeps working (reference-relative). 7. **Redundancy sanity** on the 2-node rig: ServiceLevel 240/100 split intact; alarm rows single (no double-emit from the second namespace). All seven legs recorded with evidence in the PR. Merge = tag/announce **v3.0**; then the cross-repo follow-ups (ScadaBridge re-bind coordination, umbrella index) per WP5.