Files
lmxopcua/docs/plans/2026-07-15-v3-batch4-address-space-plan.md
T
Joseph Doherty cf03ca279d docs(v3): implementation plans — master orchestration + 4 batch plans
- v3 design finalized: all 7 open items resolved (live historian probe:
  255-char tagname limit; SDK multi-notifier spike: native AddNotifier,
  never duplicate ReportEvent; NamespaceKind.Simulated retired; CSV column
  dictionaries; ScadaBridge re-bind sized; rename-warning scan decided),
  reconciled with the pre-v3 universal discovery browser (its new §11
  v3 forward note re-targets the browse commit contract).
- Calculation pseudo-driver mini-design (IDependencyConsumer capability,
  mux-fed change/timer triggers, Tarjan cycle deploy gate, VT-parity
  error semantics).
- Implementation plans for Opus-agent execution, grounded against exact
  files/symbols: master plan (batch DAG, worktree-isolated parallel
  waves, contract-first fan-out, global gotcha list) + one plan per
  batch (1 schema+RawPath identity, 2 /raw UI+Calculation, 3 UNS
  reference-only+{{equip}}, 4 dual-namespace address space = v3.0),
  each with per-wave file ownership and a mandatory docker-dev live gate.
2026-07-15 16:50:30 -04:00

171 lines
11 KiB
Markdown

# 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.