# 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=<RawPath>`. `ns=UNS` NodeIds:
  `s=<Area>/<Line>/<Equipment>/<EffectiveName>`.
- 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=<RawPath>)` —
  and the UNS subtree (Area/Line/Equipment folders; each `UnsTagReference` a Variable
  keyed `(realm=Uns, s=<equipment path>/<effective name>)`) 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}}/<RefName>`.

1. **Browse both namespaces** (Client.CLI `browse -r -d 6`): Raw shows
   Folder→Driver→Device→Group→Tag with `s=<RawPath>` 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.
