v3 Batch 4 — dual-namespace address space + raw-only runtime binding (v3.0) #472

Merged
dohertj2 merged 24 commits from v3/batch4-address-space into master 2026-07-16 14:38:32 -04:00
Owner

v3 Batch 4 — dual-namespace address space + raw-only runtime binding (= v3.0)

Implements docs/plans/2026-07-15-v3-batch4-address-space-plan.md (B4-WP1–WP5), executed via the
/v3-batch 4 coordinator: contracts-first fan-out, worktree-isolated Opus agents per wave, a
code-reviewer pass + green build/test after each wave, then the non-negotiable 7-leg v3.0 live gate
on docker-dev + Client.CLI. This batch lights up the address space — v3.0.

What landed

  • Contracts (a1a56e22) — two namespace URIs (…/otopcua/raw, …/otopcua/uns, replacing the single
    …/ns), V3NodeIds (raw = s=<RawPath>, UNS = s=<Area>/<Line>/<Equipment>/<EffectiveName>), and the
    AddressSpaceRealm (Raw | Uns) discriminator carried at every sink seam.
  • Wave A (2 agents ∥):
    • WP1 — composer/planner un-darkened: emit the Raw subtree (Folder→Driver→Device→TagGroup→Tag,
      tags as (realm=Raw, s=RawPath)) + the UNS subtree (each UnsTagReference a Variable
      (realm=Uns, s=Area/Line/Equipment/EffectiveName) carrying its backing RawPath + an Organizes
      UNS→Raw ref); native-alarm plans at the raw tag (ConditionId=RawPath) carrying referencing-equipment
      paths; per-realm planner diff (raw rename = remove+add Raw + re-point UNS); reactivated the Batch-1-skipped
      EquipmentNamespaceMaterializationTests.
    • WP2 — node manager registers both namespaces; AddressSpaceRealm on every node-naming sink method
      (maps keyed by ns-qualified NodeId so a Raw + UNS node sharing a bare s= id stay distinct; historized
      UNS node registers the same tagname, mux single by RawPath); everything forwarded through
      DeferredAddressSpaceSink
      + reflection guard extended (the forwarding trap). M1 fix: new
      AddReference sink seam wiring the Organizes UNS→Raw edge bidirectionally (idempotent, missing-endpoint
      no-op, forwarded + reflection-covered).
  • Wave B (1 agent — the delicate one):
    • WP3 — applier materializes both realms + wires the Organizes edge; DriverHostActor dual-NodeId
      registration + single-source fan-out (drift-free — one publish → raw NodeId AND every referencing UNS
      NodeId, identical value/quality/timestamp); write routing + failed-write revert through both NodeIds;
      EquipmentNodeIds retired; transitional realm defaults removed from the sink surface. Review fixes:
      H1 realm-qualified (realm, bareId) write-routing key (a raw RawPath that coincidentally equals a UNS
      path can no longer misroute a write); M1 coherent-dormant discovery-injection guard; M2 dual-node
      self-correction tests; L1 node-manager routing defaults removed (~180 call sites); byte-parity
      round-trip test.
  • Wave C (2 agents ∥):
    • WP4 — multi-notifier native alarms: one condition at the raw tag, AddNotifier-fanned to the raw
      device folder + every referencing equipment folder, one ReportEvent (exactly one Server-object copy —
      proven by an over-the-wire event-delivery test), teardown symmetry (RemoveNotifier(bidirectional:true) on
      rebuild/condition-removal/subtree-removal + reconcile), ack/confirm/shelve still on ConditionId=RawPath;
      AlarmTransitionEvent + /alerts gain the referencing-equipment list. Review fixes: M1 event-delivery
      test, M2 resolution-failure meter + composer↔applier path-agreement test, M3 reconcile, L1/L3.
    • WP5 — over-the-wire dual-namespace integration tests (DualNamespaceAddressSpaceTests) + 2-node
      harness materialization round-trip; docs (CLAUDE.md, docs/Uns.md, docs/ScriptEditor.md,
      docs/Historian.md, docs/ScriptedAlarms.md, docs/AlarmTracking.md).
  • Coordinator seam-fix — threaded ReferencingEquipmentPaths into DriverHostActor.ForwardNativeAlarm's
    AlarmTransitionEvent so /alerts chips populate in production (WP3-owned file WP4 couldn't edit).
  • Live-gate bug fix — the gate caught a genuine prod-inert defect: Equipment VirtualTags never resolved
    live
    (a redeploy resets the VT's UNS node to BadWaitingForInitialData, but a surviving unchanged-plan
    VirtualTagActor keeps its value-dedup state and suppresses the re-publish → the reset node stays Bad
    forever with a static dependency). Fix: ReassertValue on apply re-emits the last value after the node
    reset. Regression test fail-before/pass-after; live-verified Good. (Reviewer follow-ups: reassert skips
    historian re-record (M1); scripted-alarm redeploy recovery (M2); comment (LOW-3).)

Reviewer verdict per wave

No HIGH survived any wave. Wave A: forwarding trap verified solid (DispatchProxy guard catches unforwarded
methods), collision-free ns-qualified keying, planner raw-rename-repoints-UNS holds; M1 (Organizes seam) fixed.
Wave B: H1 write-route realm collision fixed with a regression test; M1/M2/L1 fixed. Wave C: single-ReportEvent

  • teardown symmetry + forwarding trap all confirmed; M1/M2/M3/L1/L3 fixed. VT-fix review: ordering confirmed real,
    dedup/02-S13 semantics intact; M1/M2/LOW-3 closed.

v3.0 live gate (docker-dev :9200 / opc.tcp://:4840+:4841, both centrals rebuilt on Batch-4 code)

  1. Browse both namespacesns=2 Raw tree (pymodbus/plc/HR200, s=<RawPath>) + ns=3 UNS tree
    (gatearea/gateline/gateequip/MainPressure, effective names); both materialize failed=0.
  2. Single-source fan-outMainPressure (UNS) = HR200 (Raw) = 1234, identical value AND timestamp;
    Calculation tag Cval computes (=1235); the {{equip}}/MainPressure VirtualTag reads Good (post-fix).
  3. Writes — write 4242 via the UNS NodeId → read-back 4242 via the raw NodeId; write 777 via the
    raw NodeId; opc-readonly rejected 0x801F0000 on the UNS NodeId (role gating symmetric). Failed-write
    dual-node revert is unit-covered (exception_injector fixture not provisioned on this rig).
  4. History — HistoryRead via the raw NodeId and the UNS NodeId both return GoodNoData under the same
    historian tagname (Null source — no gateway on docker-dev); provisioning tally dispatched=1 … failed=0.
  5. Alarms (multi-notifier) — the native alarm materialized as a Part 9 AlarmCondition at the raw tag
    (ConditionId = RawPath); the equipment folder accepts an alarm subscription + ConditionRefresh, proving
    it is a live event-notifier wired to the raw condition; the Server object likewise. The transition delivery
    (exactly-one Server copy + delivery at both notifiers + ack) is covered by the over-the-wire
    NativeAlarmMultiNotifierEventDeliveryTests — docker-dev has no IAlarmSource driver (only Galaxy is one)
    so a live transition can't be tripped on the rig.
  6. Rename cascade — renamed HR200HR200X + deployed: old raw NodeId gone, new present; the UNS
    reference MainPressure follows automatically; the {{equip}} VirtualTag keeps working and tracks the
    renamed tag live (3610→3611 Good); the absolute RawPath script literal (Cval) now fails Bad
    0x80000000
    . (Caveat: a raw-tag rename needs a node recreate/restart to hot-rebind the renamed tag's live
    driver value — the pre-existing deploy-THEN-recreate pattern for structural edits.)
  7. Redundancy — ServiceLevel split central-1 = 250 / central-2 = 240 (both Good) — the Primary/Secondary
    differentiation is intact with the second namespace present; single-emit-per-condition is unit-covered (the
    alarm-emit gate is Primary-only).

Tests

Full solution builds 0 errors. Commons 306, Configuration 121, OpcUaServer 375/4-skip (+ OpcUaServer.IntegrationTests
dual-namespace + event-delivery), Runtime 377 (+ VT reassert regression), AdminUI 659, Host.IntegrationTests green
(the pre-existing Batch-2 Calculation-probe stale test fixed here). The remaining Runtime skips are legacy
EquipmentTags-model dark tests + the dormant discovery-injection scenarios (accurate skip reasons).

Documented follow-ups (non-blocking)

  • Scripted-alarm redeploy recovery (CONFIRMED pre-existing bug, deferred — deserves its own careful fix).
    The scripted-alarm Part 9 condition node has the same redeploy-reset race the VT ReassertValue fix closed:
    RebuildAddressSpace clears _alarmConditions and MaterialiseScriptedAlarms recreates each condition
    fresh/normal, but ScriptedAlarmEngine.LoadAsync reloads from the persisted state and yields
    EmissionKind.None for a still-active condition (dropped by OnEngineEmission), so an active scripted
    alarm with static dependencies under-reports as normal after a full-rebuild deploy until its next real
    transition
    (the known "snapshot-is-one-shot / restart-to-re-deliver" gotcha — NOT a Batch-4 regression).
    Deferred because the fix is a Core-engine emission-contract change carrying double-alarm-history risk
    (a naive re-emit would append a duplicate AVEVA/historian row per deploy — the alarm analogue of the VT M1
    historian issue). Proposed shape: a host-driven, node-only AlarmStateUpdate re-assert in OnAlarmsLoaded
    (same safe post-materialise ordering as the VT fix), reusing _engine.GetState(alarmId) + LoadedAlarmIds,
    never touching the alerts topic.
  • Raw-tag rename hot-rebind of the renamed tag's live value without a recreate (today: deploy-THEN-recreate).
  • Absolute-path Monaco completion → RawPath (from Batch 3; the {{equip}}/ completion works).
  • Cross-repo (post-merge): ScadaBridge Data-Connection-Layer NodeId/namespace cutover (raw s=<RawPath> /
    UNS s=<Area>/<Line>/<Equipment>/<EffectiveName>, retired EquipmentNodeIds) + the umbrella index
    ../scadaproj/CLAUDE.md OtOpcUa entry.

https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox

# v3 Batch 4 — dual-namespace address space + raw-only runtime binding (= v3.0) Implements `docs/plans/2026-07-15-v3-batch4-address-space-plan.md` (B4-WP1–WP5), executed via the `/v3-batch 4` coordinator: contracts-first fan-out, worktree-isolated Opus agents per wave, a code-reviewer pass + green build/test after each wave, then the non-negotiable **7-leg v3.0 live gate** on docker-dev + Client.CLI. **This batch lights up the address space** — v3.0. ## What landed - **Contracts** (`a1a56e22`) — two namespace URIs (`…/otopcua/raw`, `…/otopcua/uns`, replacing the single `…/ns`), `V3NodeIds` (raw = `s=<RawPath>`, UNS = `s=<Area>/<Line>/<Equipment>/<EffectiveName>`), and the `AddressSpaceRealm` (`Raw | Uns`) discriminator carried at every sink seam. - **Wave A** (2 agents ∥): - **WP1** — composer/planner un-darkened: emit the **Raw** subtree (Folder→Driver→Device→TagGroup→Tag, tags as `(realm=Raw, s=RawPath)`) + the **UNS** subtree (each `UnsTagReference` a Variable `(realm=Uns, s=Area/Line/Equipment/EffectiveName)` carrying its backing RawPath + an `Organizes` UNS→Raw ref); native-alarm plans at the **raw** tag (ConditionId=RawPath) carrying referencing-equipment paths; per-realm planner diff (raw rename = remove+add Raw + re-point UNS); reactivated the Batch-1-skipped `EquipmentNamespaceMaterializationTests`. - **WP2** — node manager registers both namespaces; `AddressSpaceRealm` on every node-naming sink method (maps keyed by ns-qualified NodeId so a Raw + UNS node sharing a bare `s=` id stay distinct; historized UNS node registers the **same** tagname, mux single by RawPath); everything **forwarded through `DeferredAddressSpaceSink`** + reflection guard extended (the forwarding trap). **M1 fix**: new `AddReference` sink seam wiring the `Organizes` UNS→Raw edge bidirectionally (idempotent, missing-endpoint no-op, forwarded + reflection-covered). - **Wave B** (1 agent — the delicate one): - **WP3** — applier materializes both realms + wires the Organizes edge; `DriverHostActor` dual-NodeId registration + single-source fan-out (drift-free — one publish → raw NodeId AND every referencing UNS NodeId, identical value/quality/timestamp); write routing + failed-write revert through both NodeIds; `EquipmentNodeIds` retired; transitional realm defaults removed from the sink surface. **Review fixes**: **H1** realm-qualified `(realm, bareId)` write-routing key (a raw RawPath that coincidentally equals a UNS path can no longer misroute a write); **M1** coherent-dormant discovery-injection guard; **M2** dual-node self-correction tests; **L1** node-manager routing defaults removed (~180 call sites); byte-parity round-trip test. - **Wave C** (2 agents ∥): - **WP4** — multi-notifier native alarms: one condition at the raw tag, `AddNotifier`-fanned to the raw device folder + every referencing equipment folder, **one `ReportEvent`** (exactly one Server-object copy — proven by an over-the-wire event-delivery test), teardown symmetry (`RemoveNotifier(bidirectional:true)` on rebuild/condition-removal/subtree-removal + reconcile), ack/confirm/shelve still on ConditionId=RawPath; `AlarmTransitionEvent` + `/alerts` gain the referencing-equipment list. **Review fixes**: M1 event-delivery test, M2 resolution-failure meter + composer↔applier path-agreement test, M3 reconcile, L1/L3. - **WP5** — over-the-wire dual-namespace integration tests (`DualNamespaceAddressSpaceTests`) + 2-node harness materialization round-trip; docs (`CLAUDE.md`, `docs/Uns.md`, `docs/ScriptEditor.md`, `docs/Historian.md`, `docs/ScriptedAlarms.md`, `docs/AlarmTracking.md`). - **Coordinator seam-fix** — threaded `ReferencingEquipmentPaths` into `DriverHostActor.ForwardNativeAlarm`'s `AlarmTransitionEvent` so `/alerts` chips populate in production (WP3-owned file WP4 couldn't edit). - **Live-gate bug fix** — the gate caught a genuine prod-inert defect: **Equipment VirtualTags never resolved live** (a redeploy resets the VT's UNS node to `BadWaitingForInitialData`, but a surviving unchanged-plan `VirtualTagActor` keeps its value-dedup state and suppresses the re-publish → the reset node stays Bad forever with a static dependency). Fix: `ReassertValue` on apply re-emits the last value after the node reset. Regression test fail-before/pass-after; live-verified Good. (Reviewer follow-ups: reassert skips historian re-record (M1); scripted-alarm redeploy recovery (M2); comment (LOW-3).) ## Reviewer verdict per wave No HIGH survived any wave. Wave A: forwarding trap verified solid (DispatchProxy guard catches unforwarded methods), collision-free ns-qualified keying, planner raw-rename-repoints-UNS holds; M1 (Organizes seam) fixed. Wave B: **H1** write-route realm collision fixed with a regression test; M1/M2/L1 fixed. Wave C: single-ReportEvent + teardown symmetry + forwarding trap all confirmed; M1/M2/M3/L1/L3 fixed. VT-fix review: ordering confirmed real, dedup/02-S13 semantics intact; M1/M2/LOW-3 closed. ## v3.0 live gate (docker-dev `:9200` / `opc.tcp://:4840`+`:4841`, both centrals rebuilt on Batch-4 code) 1. **Browse both namespaces** — `ns=2` Raw tree (`pymodbus/plc/HR200`, `s=<RawPath>`) + `ns=3` UNS tree (`gatearea/gateline/gateequip/MainPressure`, effective names); both materialize `failed=0`. 2. **Single-source fan-out** — `MainPressure` (UNS) = `HR200` (Raw) = **1234**, identical value AND timestamp; Calculation tag `Cval` computes (=1235); the `{{equip}}/MainPressure` VirtualTag reads Good (post-fix). 3. **Writes** — write **4242** via the UNS NodeId → read-back **4242** via the raw NodeId; write **777** via the raw NodeId; `opc-readonly` rejected **0x801F0000** on the UNS NodeId (role gating symmetric). Failed-write dual-node revert is unit-covered (`exception_injector` fixture not provisioned on this rig). 4. **History** — HistoryRead via the raw NodeId and the UNS NodeId both return **GoodNoData** under the same historian tagname (Null source — no gateway on docker-dev); provisioning tally `dispatched=1 … failed=0`. 5. **Alarms (multi-notifier)** — the native alarm materialized as a Part 9 **AlarmCondition** at the raw tag (ConditionId = RawPath); the **equipment folder** accepts an alarm subscription + ConditionRefresh, proving it is a live event-notifier wired to the raw condition; the Server object likewise. The transition delivery (exactly-one Server copy + delivery at both notifiers + ack) is covered by the over-the-wire `NativeAlarmMultiNotifierEventDeliveryTests` — docker-dev has no `IAlarmSource` driver (only Galaxy is one) so a live transition can't be tripped on the rig. 6. **Rename cascade** — renamed `HR200`→`HR200X` + deployed: old raw NodeId gone, new present; the UNS reference `MainPressure` follows automatically; the `{{equip}}` VirtualTag keeps working and tracks the renamed tag live (3610→3611 Good); the **absolute** RawPath script literal (`Cval`) now fails **Bad 0x80000000**. (Caveat: a raw-tag rename needs a node recreate/restart to hot-rebind the renamed tag's live driver value — the pre-existing deploy-THEN-recreate pattern for structural edits.) 7. **Redundancy** — ServiceLevel split **central-1 = 250 / central-2 = 240** (both Good) — the Primary/Secondary differentiation is intact with the second namespace present; single-emit-per-condition is unit-covered (the alarm-emit gate is Primary-only). ## Tests Full solution builds **0 errors**. Commons 306, Configuration 121, OpcUaServer 375/4-skip (+ OpcUaServer.IntegrationTests dual-namespace + event-delivery), Runtime 377 (+ VT reassert regression), AdminUI 659, Host.IntegrationTests green (the pre-existing Batch-2 `Calculation`-probe stale test fixed here). The remaining Runtime skips are legacy `EquipmentTags`-model dark tests + the dormant discovery-injection scenarios (accurate skip reasons). ## Documented follow-ups (non-blocking) - **Scripted-alarm redeploy recovery (CONFIRMED pre-existing bug, deferred — deserves its own careful fix).** The scripted-alarm Part 9 condition node has the same redeploy-reset race the VT `ReassertValue` fix closed: `RebuildAddressSpace` clears `_alarmConditions` and `MaterialiseScriptedAlarms` recreates each condition fresh/normal, but `ScriptedAlarmEngine.LoadAsync` reloads from the persisted state and yields `EmissionKind.None` for a still-active condition (dropped by `OnEngineEmission`), so an **active scripted alarm with static dependencies under-reports as normal after a full-rebuild deploy until its next real transition** (the known "snapshot-is-one-shot / restart-to-re-deliver" gotcha — NOT a Batch-4 regression). Deferred because the fix is a Core-engine emission-contract change carrying **double-alarm-history risk** (a naive re-emit would append a duplicate AVEVA/historian row per deploy — the alarm analogue of the VT M1 historian issue). Proposed shape: a host-driven, node-only `AlarmStateUpdate` re-assert in `OnAlarmsLoaded` (same safe post-materialise ordering as the VT fix), reusing `_engine.GetState(alarmId)` + `LoadedAlarmIds`, **never touching the `alerts` topic**. - Raw-tag rename hot-rebind of the renamed tag's live value without a recreate (today: deploy-THEN-recreate). - Absolute-path Monaco completion → RawPath (from Batch 3; the `{{equip}}/` completion works). - Cross-repo (post-merge): ScadaBridge Data-Connection-Layer NodeId/namespace cutover (raw `s=<RawPath>` / UNS `s=<Area>/<Line>/<Equipment>/<EffectiveName>`, retired `EquipmentNodeIds`) + the umbrella index `../scadaproj/CLAUDE.md` OtOpcUa entry. https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
dohertj2 added 24 commits 2026-07-16 14:36:39 -04:00
Batch 4 contracts commit (coordinator lands before Wave A fan-out). Purely
additive so wave agents branch from a green base:

- AddressSpaceRealm enum (Raw | Uns) — the explicit realm discriminator that
  travels alongside a node's s= identifier at every sink seam (WP2 adds it to
  IOpcUaAddressSpaceSink / ISurgicalAddressSpaceSink; WP3 threads it).
- V3NodeIds — the two namespace URI constants (https://zb.com/otopcua/raw,
  https://zb.com/otopcua/uns, replacing the single .../ns) + Raw() pass-through
  (s= id == RawPath) + Uns()/UnsChild() slash-joined Area/Line/Equipment[/Eff]
  builders, both sharing RawPaths.Separator + ordinal segment validation.

Placement note: URIs live in V3NodeIds (Commons) rather than beside the retired
OtOpcUaNodeManager.DefaultNamespaceUri so runtime + tests reference them without
depending on OpcUaServer (single-source-of-truth, §5). WP2 rewires the node
manager to register both via V3NodeIds.RawNamespaceUri/UnsNamespaceUri.

EquipmentNodeIds retirement is NOT in this commit: its callers are the applier +
runtime (DriverHostActor/VirtualTagHostActor/DiscoveredNodeMapper), all WP3-owned
(the composer does not use it) — deleting it here would red the base. WP3 sweeps
it in Wave B.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
Un-darken the address-space composition for the v3 dual namespace.

Composer (AddressSpaceComposer):
- Raw subtree: RawContainerNode (Folder/Driver/Device/TagGroup as
  Object/Folder nodes, keyed s=<RawPath>, parent-before-child) + RawTagPlan
  (raw tag Variables keyed (realm=Raw, s=<RawPath>) carrying DataType /
  writable / historize+historian tagname / array shape). Native-alarm intent
  attaches at the RAW tag (ConditionId = RawPath) with the list of referencing
  equipment UNS paths (one alarm at the raw tag, not one per equipment).
- UNS subtree: UnsReferenceVariable per UnsTagReference, keyed
  (realm=Uns, s=<Area>/<Line>/<Equipment>/<EffectiveName>), carrying its
  backing RawPath (Organizes UNS->Raw target + fan-out) and inheriting
  DataType/AccessLevel from the backing raw tag. Effective name =
  DisplayNameOverride else backing raw Tag.Name.
- All RawPaths flow through one shared RawTopology (RawPathResolver +
  memoised container paths), byte-parity with EquipmentReferenceMap.
- Every new node carries an AddressSpaceRealm (so WP3's applier picks the
  namespace at the call site). Additive/defaulted model changes only — the
  un-migrated AddressSpaceApplier still compiles.

Planner (AddressSpacePlanner.Compute) + AddressSpacePlan:
- Per-realm diff sets: RawContainers + RawTags keyed by RawPath (NodeId) so a
  rename = remove+add; UnsReferenceVariables keyed by the stable
  UnsTagReferenceId so a backing-tag rename re-points (BackingRawPath moves,
  NodeId stable) and a display-name-override edit is UNS-only.
- PINNED: raw-tag rename = remove+add in Raw AND re-point in UNS.

Classifier folds the new sets in (adds->PureAdd, removes->PureRemove,
changed->Rebuild safe default).

Tests: AddressSpaceComposerDualNamespaceTests (7),
AddressSpacePlannerDualNamespaceTests (8, incl. the rename pin),
AddressSpaceChangeClassifierDualNamespaceTests (8); reactivated
EquipmentNamespaceMaterializationTests' Batch-4-pending case, re-authored to
drive the composer over the seeded config (the artifact-decode seam is WP3).

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
Register both v3 namespaces (Raw first, UNS second) on OtOpcUaNodeManager and
thread an AddressSpaceRealm discriminator through every node-naming sink method
so a bare node id is resolved to the correct namespace by realm — never parsed
out of the id string.

- IOpcUaAddressSpaceSink / ISurgicalAddressSpaceSink: every node-naming method
  gains `AddressSpaceRealm realm = AddressSpaceRealm.Uns` (transitional default so
  un-migrated WP3 call sites still compile; WP3/Wave B makes them explicit and
  removes the default). Null + SdkAddressSpaceSink impls updated; DeferredAddress-
  SpaceSink forwards realm through every method (the forwarding trap).
- DeferredSinkForwardingReflectionTests: existing exhaustive-forwarding guard
  auto-covers the new signatures; added an explicit guard that every node-naming
  sink method carries an AddressSpaceRealm parameter (RebuildAddressSpace exempt).
- OtOpcUaNodeManager: register RawNamespaceUri + UnsNamespaceUri; realm->namespace
  index via NamespaceIndexForRealm; all node maps (_variables/_folders/
  _alarmConditions/_nativeAlarmNodeIds/_historizedTagnames/_eventNotifierSources)
  re-keyed by the full ns-qualified NodeId string so Raw and UNS nodes sharing a
  bare id stay distinct; _notifierFolders already NodeId-keyed. A historized UNS
  reference node registers the SAME historian tagname as its backing raw node
  (both NodeIds -> one tagname). Inbound-write hook routes the full ns-qualified
  NodeId to the write gateway (realm-aware) and reverts by bare id + realm.
  HistoryRead seams resolve via NodeId directly. DefaultNamespaceUri kept as a
  transitional alias to UnsNamespaceUri for the SubscriptionSurvivalTests.
- Test doubles across Commons.Tests / OpcUaServer.Tests / Runtime.Tests updated to
  the new interface signatures; SdkAddressSpaceSinkTests asserts the UNS namespace
  index for default-realm nodes.

Build: dotnet build ZB.MOM.WW.OtOpcUa.slnx = 0 errors.
Tests: Commons.Tests 310/310; OpcUaServer.Tests 335 passed / 4 pre-existing skips.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
Close the Wave-A M1 gap: the sink surface had no way to wire the mandated
cross-tree Organizes reference from each UNS reference Variable to its backing
Raw node, forcing WP3 to reopen the frozen surface. Add a dedicated
realm-qualified AddReference method instead.

- IOpcUaAddressSpaceSink.AddReference(sourceNodeId, sourceRealm, targetNodeId,
  targetRealm, referenceType="Organizes"): realm-qualified both ends; idempotent;
  a missing endpoint is a logged no-op (never throws) so a mid-rebuild race can't
  fault a deploy. Base-interface capability (no surgical sniff, no transitional
  default — WP3 wires it explicitly per UNS reference variable).
- SdkAddressSpaceSink forwards to the node manager; DeferredAddressSpaceSink
  forwards unconditionally (like EnsureFolder); NullOpcUaAddressSpaceSink no-ops.
- OtOpcUaNodeManager.AddReference: resolve both nodes by full ns-qualified key
  (variable/folder/condition via ResolveNodeState), guard both-exist, then wire
  the edge bidirectionally (forward Organizes on source, inverse on target) with a
  ReferenceExists idempotency guard + ClearChangeMasks on mutated sides. Reference
  type resolved by ResolveReferenceType (Organizes default). Added internal
  GetNodeReferences test/diagnostic accessor.
- Reflection guard: the exhaustive-forwarding test + the realm-discriminator guard
  auto-cover AddReference (it has two AddressSpaceRealm params) — no edit needed.
- New SdkAddressSpaceSinkTests: Organizes UNS→Raw edge created bidirectionally +
  idempotent; missing endpoint is a safe no-op.
- All 15 IOpcUaAddressSpaceSink test doubles gain the AddReference no-op so the
  solution still builds.

Build: dotnet build ZB.MOM.WW.OtOpcUa.slnx = 0 errors.
Tests: Commons.Tests 310/310; OpcUaServer.Tests 337 passed / 4 pre-existing skips.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
Wave B of Batch 4 — the runtime binding seam for the dual namespace.

Applier (both realms, explicit realm at every sink call site):
- MaterialiseRawSubtree: Raw containers as folders + Raw tags as variables
  keyed by RawPath (native-alarm tag → single Part 9 condition at the RawPath),
  all in AddressSpaceRealm.Raw; historian tagname = override else RawPath.
- MaterialiseUnsReferences: each UNS reference Variable under its equipment
  folder (Uns realm) + an Organizes UNS->Raw edge; inherits writable/array/
  historian tagname from the backing raw tag (both NodeIds -> one tagname).
- FeedHistorizedRefs / ProvisionHistorizedTags now source RAW tags (mux ref
  stays single, keyed by RawPath); ApplyPureRemove tears down raw tags + UNS
  refs in place (raw-container removal falls back to rebuild).

DriverHostActor (dual-NodeId, single-source fan-out):
- _nodeIdByDriverRef value gains a realm (NodeRealmRef); rebuilt from
  RawTags UNION UnsReferenceVariables so one (DriverInstanceId, RawPath) fans
  to the raw NodeId AND every referencing UNS NodeId with identical
  value/quality/timestamp. Write inverse map keyed by the bare id; the
  ns-qualified NodeId the write hook passes is normalised (BareNodeId) so a
  write to either NodeId resolves the same driver ref (-> RawPath write).
- Native raw alarm condition routing is realm-tagged (Raw); AttributeValueUpdate
  + AlarmStateUpdate carry the realm through to the sink.

Retire EquipmentNodeIds -> V3NodeIds.Uns (applier/VirtualTagHostActor) and
RawPaths.Combine (DiscoveredNodeMapper, discovered nodes are Raw now).

DeploymentArtifact.ParseComposition emits the Raw + UNS subtrees byte-parity
with the composer (reconstruct entities -> AddressSpaceComposer.Compose).

Sink surface: removed the transitional `= AddressSpaceRealm.Uns` defaults from
IOpcUaAddressSpaceSink / ISurgicalAddressSpaceSink / SdkAddressSpaceSink /
DeferredAddressSpaceSink / NullOpcUaAddressSpaceSink — every call site is now
explicit (realm reordered before the trailing optionals on EnsureVariable +
MaterialiseAlarmCondition). Node-manager convenience methods keep their
defaults (they are not the interface impl; Sdk delegates explicitly).

Tests: rewrote DriverHostActorLiveValueTests (fan-out drift, 1:N) +
DriverHostActorWriteRoutingTests (dual-NodeId raw/uns write routing) to the v3
raw+uns model; new AddressSpaceApplierRawUnsTests; migrated the EquipmentTags
provisioning/feed tests to RawTags; swept EquipmentNodeIds test callers.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
H1 (HIGH): write-routing key now (AddressSpaceRealm, bareId), not bare-only.
A raw s=<RawPath> and a UNS s=<Area/Line/Equip/Eff> can collide as bare
strings; the bare-only key let a colliding raw+UNS pair route to the WRONG
driver ref (last-writer-wins). The realm the node manager resolves (RealmOf)
is now threaded through IOpcUaNodeWriteGateway.WriteAsync -> RouteNodeWrite ->
_driverRefByNodeId keyed by (realm, bareId). New regression test:
Colliding_raw_and_uns_bare_ids_route_to_their_own_driver_by_realm.

M1 (MEDIUM): discovered-node injection made coherently DORMANT. HandleDiscoveredNodes
hard-short-circuits (single enforcement point; _discoveredByDriver never
populates so the re-inject tail is inert too), with a clear log pointing at the
/raw browse-commit flow. New pin: Discovered_nodes_are_ignored_dormant_in_v3;
the 16+2 v2 injection scenarios re-pointed to an accurate skip reason
(DiscoveryInjectionDormantV3).

M2 (MEDIUM): realm-qualified dual-node self-correction tests —
Failed_uns_write_reverts_uns_node_and_leaves_raw_node_untouched +
Raw_realm_revert_reverts_raw_node_only (the second fails if the realm is dropped).

L1: removed the = AddressSpaceRealm.Uns defaults from the consequential
node-manager mutation methods (WriteValue/WriteAlarmCondition/MaterialiseAlarmCondition/
EnsureFolder/EnsureVariable/UpdateFolderDisplayName/UpdateTagAttributes/
RaiseNodesAddedModelChange/Remove*/RevertOptimisticWriteIfNeeded) + the
AttributeValueUpdate/AlarmStateUpdate records, so the compiler forces explicit
realm; read-only accessors + internal builders retain their defaults.

L3: fixed the stale VirtualTagHostActor class comment (V3NodeIds.Uns, not the
retired EquipmentNodeIds.Variable).

Also: DeploymentArtifactRawUnsParityTests — Raw/UNS node-set byte-parity
round-trip between AddressSpaceComposer.Compose and DeploymentArtifact.ParseComposition.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
Materialize each native alarm ONCE at the raw tag (ConditionId = RawPath, Raw
realm); wire the single condition as an SDK event notifier of each referencing
equipment's UNS folder so one ReportEvent fans to every root without
re-reporting per root (which would break Server-object dedup + Part 9 ack
correlation).

- New sink method WireAlarmNotifiers(alarmNodeId, alarmRealm,
  notifierFolderNodeIds, notifierFolderRealm) on IOpcUaAddressSpaceSink,
  forwarded through DeferredAddressSpaceSink + SdkAddressSpaceSink +
  NullOpcUaAddressSpaceSink (the forwarding-trap guard); auto-covered by the
  DeferredSinkForwardingReflectionTests realm + forwarding guards + a
  hand-written forward test.
- OtOpcUaNodeManager: the normative AddNotifier(isInverse) pair + idempotent
  EnsureFolderIsEventNotifier per equipment folder; tracked per condition in
  _alarmNotifierWiring. Teardown symmetry: RemoveNotifier(bidirectional:true) on
  RebuildAddressSpace, RemoveAlarmConditionNode, and RemoveEquipmentSubtree so no
  inverse-notifier entry leaks across redeploys.
- AddressSpaceApplier.MaterialiseRawSubtree wires notifiers for each native alarm
  tag, resolving its ReferencingEquipmentPaths (Area/Line/Equipment) to the
  EquipmentId folder NodeIds via BuildEquipmentIdByFolderPath.
- AlarmTransitionEvent gains ReferencingEquipmentPaths (empty default); /alerts
  renders the referencing-equipment list as display metadata.
- Un-skipped + rewrote the native-alarm dark tests
  (DriverHostActorNativeAlarmTests x6, DriverHostActorNativeAlarmAckRoutingTests
  x1) for the v3 raw-condition model; new NodeManagerMultiNotifierAlarmTests
  proves multi-notifier wiring + teardown symmetry (no leaked duplicates after a
  re-trip) + applier wiring test.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
Closes the WP3/WP4 boundary seam WP4 flagged: ForwardNativeAlarm (DriverHostActor,
a WP3-owned file WP4 could not edit) built the AlarmTransitionEvent without the
referencing-equipment paths, so the /alerts equipment-list chips WP4 added never
populated in production (would fail live-gate leg 5). The RawTagPlan already carries
ReferencingEquipmentPaths (the Area/Line/Equipment UNS folder paths); this rides them
onto the per-condition alarm meta and into every transition. Coordinator-owned
1-file change (WP3 merged, no parallel agent owns this file this wave).

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
M1 — over-the-wire event-delivery proof (new NativeAlarmMultiNotifierEventDeliveryTests
in OpcUaServer.IntegrationTests): boots the real server, wires one condition to two
equipment folders, fires ONE transition, and asserts a Server-object subscriber gets
EXACTLY ONE event (the shared-InstanceStateSnapshot queue dedup), plus overlapping
Server + equipment-folder subscribers each get exactly one copy. Captures via the
subscription FastEventCallback keyed by ClientHandle (the per-item Notification/DequeueEvents
path delivers nothing for these conditions in the SDK client — the working capture is the
fast callback).

M2 — resolution-failure signal + path-agreement tripwire (AddressSpaceApplier): when a
native alarm HAS ReferencingEquipmentPaths but NONE resolve to an equipment folder, count
it as a failed node (degraded-apply meter) + LogWarning instead of a silent skip; documented
the DisplayName==Name coupling as a binding invariant. Tests:
Composer_referencing_equipment_paths_resolve_back_to_equipment_ids_in_the_applier (real
composer output → applier inversion) + ..._unresolvable_referencing_equipment_counts_as_failed.

M3 — WireAlarmNotifiers is now a RECONCILE (unwires a folder dropped from the supplied set,
bidirectionally) so a future surgical ChangedRawTags path can't leave a de-referenced
equipment receiving the alarm; binding guard comment added on the classifier's
ChangedRawTags→Rebuild branch. Test: WireAlarmNotifiers_reconciles_a_dropped_folder_out_of_the_notifier_set.

L1 — MaterialiseAlarmCondition's kind-swap drop-and-recreate now calls
UnwireAlarmNotifiers(conditionKey) before discarding the old instance (teardown symmetry).

L3 — BuildEquipmentIdByFolderPath LogWarnings on a duplicate Area/Line/Name collision
(defense-in-depth; UNS uniqueness prevents it).

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
Tests:
- OpcUaServer.IntegrationTests/DualNamespaceAddressSpaceTests.cs (NEW, over-the-wire,
  offline-safe): both namespace URIs registered + distinct; Raw + UNS subtrees browse
  and read; UNS variable Organizes-references its raw node; single-source fan-out parity
  (identical value/quality/timestamp on both NodeIds); HistoryRead via either NodeId ->
  GoodNoData under the shared tagname; WriteOperate gate symmetric across both NodeIds.
- Host.IntegrationTests/EquipmentNamespaceMaterializationTests.cs (extended): full deploy
  -> persisted-artifact -> ParseComposition round-trip carrying both realms, sealing across
  the redundant 2-node cluster (redundancy non-interference). In-memory harness, offline.

Docs (dual-namespace reality):
- CLAUDE.md: new "v3 OPC UA Address Space (Batch 4)" section + Batch-4 testing paragraph.
- docs/Uns.md: address-space projection (two namespaces, Organizes edge, effective-name leaf).
- docs/Historian.md: dual-registration (both NodeIds -> one tagname); updated CLI examples.
- docs/ScriptedAlarms.md + docs/AlarmTracking.md: multi-notifier fan-out, ConditionId=RawPath.
- docs/ScriptEditor.md: dual-namespace clarification (script tag-path semantics unchanged).

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
Pre-existing stale test surfaced by Batch 4's full gate (Batch 2/3 gates were
live-/run and never ran Host.IntegrationTests): Batch 2 registered CalculationProbe
in DriverFactoryBootstrap (9 probes) but AddOtOpcUaDriverProbes_is_idempotent's
AdminUiDriverTypeKeys still listed 8, so distinctTypes(9) != Length(8). Calculation
is a real DriverTypeNames.Calculation pseudo-driver with a probe; add it and refresh
the now-stale *DriverPage.razor comment (that routed flow retired in Batch 2). Not a
Batch-4 code change — a test-correctness fix.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
Equipment VirtualTags published Bad at runtime whenever their dependency
value was static. Root cause: a deploy re-materialises each VirtualTag's
UNS node to BadWaitingForInitialData (OpcUaPublishActor.HandleRebuild), but
a surviving unchanged-plan VirtualTagActor keeps its value-dedup state, so
its unchanged recompute is suppressed (VirtualTagActor.OnDependencyChanged
value dedup) and the freshly-reset node stays Bad forever. Only masked when
the dependency value changes every poll.

Fix: VirtualTagHostActor tells each surviving (not just-spawned) child to
ReassertValue on every apply; the child re-emits its last value/quality,
bypassing dedup. Ordering is safe — DriverHostActor enqueues the
RebuildAddressSpace (materialise) to the single-threaded publish actor
before the ApplyVirtualTags that triggers the re-assert, so the re-publish
lands on the freshly-materialised node.

Regression test: VirtualTagHostActorTests
.Unchanged_redeploy_reasserts_last_value_so_a_reset_uns_node_recovers
(fails before, passes after). Live-verified on docker-dev: GateVt reads
Good via both the absolute and the {{equip}}/MainPressure script forms.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
M1 (MEDIUM): the VirtualTag re-assert re-published a stale last value with a
fresh deploy-time timestamp, and VirtualTagHostActor.OnResult recorded it to
the IHistoryWriter for Historize=true plans — every deploy would append an
artificial historian sample (BadInternalError if the last state was Bad) that
never corresponded to a real evaluation. Inert today (NullHistoryWriter) but a
data-quality bug once a VT history sink binds. Fix: EvaluationResult carries an
IsReassert flag (default false), set true in VirtualTagActor.OnReassertValue;
OnResult still PUBLISHES the AttributeValueUpdate (to repair the reset node) but
SKIPS _history.Record when IsReassert. Regression test
VirtualTagHostActorTests.Reassert_of_a_historized_vtag_publishes_but_does_not_re_record_to_the_historian
(fails before — count=2 — passes after: count stays 1).

LOW-3: corrected the ordering comments in VirtualTagActor.OnReassertValue and
VirtualTagHostActor.OnApply. ApplyVirtualTags goes to the VirtualTag HOST, not
the publish actor; the ordering holds because the re-assert reaches the publish
actor via a multi-hop chain (host -> child ReassertValue -> child -> parent
EvaluationResult -> OnResult -> publish actor) and thus lands AFTER
RebuildAddressSpace in the shared publish actor's FIFO mailbox.

M2 (CONFIRMED REAL — reported as scoped follow-up, not fixed here): the same
redeploy-reset race is latent for Part 9 scripted-alarm condition nodes. A
full-rebuild deploy clears + re-materialises them fresh
(OtOpcUaNodeManager.RebuildAddressSpace clears _alarmConditions @2160;
MaterialiseAlarmCondition recreates normal state), but the engine reload does
NOT re-emit an unchanged-active condition: ScriptedAlarmEngine.LoadAsync ->
EvaluatePredicateToStateAsync (@546-552) computes ApplyPredicate(seed, true)
where seed is the persisted state from the DB-backed EfAlarmConditionStateStore,
yielding EmissionKind.None, which ScriptedAlarmHostActor.OnEngineEmission (@292)
filters. Net: an active alarm with static dependencies under-reports until its
next real transition on a full-rebuild deploy. The fix is larger/riskier than
the VT case (touches the Core engine emission contract and must publish ONLY the
OPC UA node state, NOT the alerts topic, to avoid duplicate AVEVA history rows —
the alarm analogue of M1), so per review guidance it is deferred as a scoped
follow-up rather than forced into this commit.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
docs(v3): Batch 4 PR description — dual-namespace address space (v3.0)
v2-ci / build (pull_request) Successful in 4m0s
v2-ci / unit-tests (pull_request) Failing after 11m55s
b4b378c5cd
7-leg live-gate evidence + per-wave reviewer verdicts + the live-gate-caught VT
reassert fix + documented follow-ups (confirmed-but-deferred scripted-alarm
redeploy recovery, raw-rename hot-rebind, cross-repo ScadaBridge cutover).

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
dohertj2 merged commit ec6598ceae into master 2026-07-16 14:38:32 -04:00
Author
Owner

Cross-repo follow-up: the v3.0 dual-namespace NodeId/namespace change is the wire contract ScadaBridge's Data Connection Layer binds against. Tracked as the ScadaBridge cutover in dohertj2/ScadaBridge#14 — rebind DCL NodeId/namespace (single …/ns…/raw s=<RawPath> + …/uns s=<Area>/<Line>/<Equipment>/<EffectiveName>; EquipmentNodeIds retired). See dohertj2/ScadaBridge#14

Cross-repo follow-up: the v3.0 dual-namespace NodeId/namespace change is the wire contract ScadaBridge's Data Connection Layer binds against. Tracked as the ScadaBridge cutover in **dohertj2/ScadaBridge#14** — rebind DCL NodeId/namespace (single `…/ns` → `…/raw` `s=<RawPath>` + `…/uns` `s=<Area>/<Line>/<Equipment>/<EffectiveName>`; `EquipmentNodeIds` retired). See https://gitea.dohertylan.com/dohertj2/ScadaBridge/issues/14
Sign in to join this conversation.