v3 Batch 4 — dual-namespace address space + raw-only runtime binding (v3.0) #472
Reference in New Issue
Block a user
Delete Branch "v3/batch4-address-space"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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 4coordinator: contracts-first fan-out, worktree-isolated Opus agents per wave, acode-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
a1a56e22) — two namespace URIs (…/otopcua/raw,…/otopcua/uns, replacing the single…/ns),V3NodeIds(raw =s=<RawPath>, UNS =s=<Area>/<Line>/<Equipment>/<EffectiveName>), and theAddressSpaceRealm(Raw | Uns) discriminator carried at every sink seam.tags as
(realm=Raw, s=RawPath)) + the UNS subtree (eachUnsTagReferencea Variable(realm=Uns, s=Area/Line/Equipment/EffectiveName)carrying its backing RawPath + anOrganizesUNS→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.AddressSpaceRealmon every node-naming sink method(maps keyed by ns-qualified NodeId so a Raw + UNS node sharing a bare
s=id stay distinct; historizedUNS node registers the same tagname, mux single by RawPath); everything forwarded through
DeferredAddressSpaceSink+ reflection guard extended (the forwarding trap). M1 fix: newAddReferencesink seam wiring theOrganizesUNS→Raw edge bidirectionally (idempotent, missing-endpointno-op, forwarded + reflection-covered).
DriverHostActordual-NodeIdregistration + 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;
EquipmentNodeIdsretired; 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 UNSpath 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.
AddNotifier-fanned to the rawdevice 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)onrebuild/condition-removal/subtree-removal + reconcile), ack/confirm/shelve still on ConditionId=RawPath;
AlarmTransitionEvent+/alertsgain the referencing-equipment list. Review fixes: M1 event-deliverytest, M2 resolution-failure meter + composer↔applier path-agreement test, M3 reconcile, L1/L3.
DualNamespaceAddressSpaceTests) + 2-nodeharness materialization round-trip; docs (
CLAUDE.md,docs/Uns.md,docs/ScriptEditor.md,docs/Historian.md,docs/ScriptedAlarms.md,docs/AlarmTracking.md).ReferencingEquipmentPathsintoDriverHostActor.ForwardNativeAlarm'sAlarmTransitionEventso/alertschips populate in production (WP3-owned file WP4 couldn't edit).live (a redeploy resets the VT's UNS node to
BadWaitingForInitialData, but a surviving unchanged-planVirtualTagActorkeeps its value-dedup state and suppresses the re-publish → the reset node stays Badforever with a static dependency). Fix:
ReassertValueon apply re-emits the last value after the nodereset. 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
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)ns=2Raw tree (pymodbus/plc/HR200,s=<RawPath>) +ns=3UNS tree(
gatearea/gateline/gateequip/MainPressure, effective names); both materializefailed=0.MainPressure(UNS) =HR200(Raw) = 1234, identical value AND timestamp;Calculation tag
Cvalcomputes (=1235); the{{equip}}/MainPressureVirtualTag reads Good (post-fix).raw NodeId;
opc-readonlyrejected 0x801F0000 on the UNS NodeId (role gating symmetric). Failed-writedual-node revert is unit-covered (
exception_injectorfixture not provisioned on this rig).historian tagname (Null source — no gateway on docker-dev); provisioning tally
dispatched=1 … failed=0.(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 noIAlarmSourcedriver (only Galaxy is one)so a live transition can't be tripped on the rig.
HR200→HR200X+ deployed: old raw NodeId gone, new present; the UNSreference
MainPressurefollows automatically; the{{equip}}VirtualTag keeps working and tracks therenamed tag live (3610→3611 Good); the absolute RawPath script literal (
Cval) now fails Bad0x80000000. (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.)
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 legacyEquipmentTags-model dark tests + the dormant discovery-injection scenarios (accurate skip reasons).Documented follow-ups (non-blocking)
The scripted-alarm Part 9 condition node has the same redeploy-reset race the VT
ReassertValuefix closed:RebuildAddressSpaceclears_alarmConditionsandMaterialiseScriptedAlarmsrecreates each conditionfresh/normal, but
ScriptedAlarmEngine.LoadAsyncreloads from the persisted state and yieldsEmissionKind.Nonefor a still-active condition (dropped byOnEngineEmission), so an active scriptedalarm 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
AlarmStateUpdatere-assert inOnAlarmsLoaded(same safe post-materialise ordering as the VT fix), reusing
_engine.GetState(alarmId)+LoadedAlarmIds,never touching the
alertstopic.{{equip}}/completion works).s=<RawPath>/UNS
s=<Area>/<Line>/<Equipment>/<EffectiveName>, retiredEquipmentNodeIds) + the umbrella index../scadaproj/CLAUDE.mdOtOpcUa entry.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_01LVneM3eh1UtJxEisFXgmoxCross-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→…/raws=<RawPath>+…/unss=<Area>/<Line>/<Equipment>/<EffectiveName>;EquipmentNodeIdsretired). See dohertj2/ScadaBridge#14