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
9.6 KiB
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 theAddressSpaceRealm(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 (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-skippedEquipmentNamespaceMaterializationTests. - WP2 — node manager registers both namespaces;
AddressSpaceRealmon every node-naming sink method (maps keyed by ns-qualified NodeId so a Raw + UNS node sharing a bares=id stay distinct; historized UNS node registers the same tagname, mux single by RawPath); everything forwarded throughDeferredAddressSpaceSink+ reflection guard extended (the forwarding trap). M1 fix: newAddReferencesink seam wiring theOrganizesUNS→Raw edge bidirectionally (idempotent, missing-endpoint no-op, forwarded + reflection-covered).
- WP1 — composer/planner un-darkened: emit the Raw subtree (Folder→Driver→Device→TagGroup→Tag,
tags as
- Wave B (1 agent — the delicate one):
- WP3 — applier materializes both realms + wires the Organizes edge;
DriverHostActordual-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;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 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.
- WP3 — applier materializes both realms + wires the Organizes edge;
- 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, oneReportEvent(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+/alertsgain 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).
- WP4 — multi-notifier native alarms: one condition at the raw tag,
- Coordinator seam-fix — threaded
ReferencingEquipmentPathsintoDriverHostActor.ForwardNativeAlarm'sAlarmTransitionEventso/alertschips 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-planVirtualTagActorkeeps its value-dedup state and suppresses the re-publish → the reset node stays Bad forever with a static dependency). Fix:ReassertValueon 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)
- Browse both namespaces —
ns=2Raw tree (pymodbus/plc/HR200,s=<RawPath>) +ns=3UNS tree (gatearea/gateline/gateequip/MainPressure, effective names); both materializefailed=0. - Single-source fan-out —
MainPressure(UNS) =HR200(Raw) = 1234, identical value AND timestamp; Calculation tagCvalcomputes (=1235); the{{equip}}/MainPressureVirtualTag reads Good (post-fix). - Writes — write 4242 via the UNS NodeId → read-back 4242 via the raw NodeId; write 777 via the
raw NodeId;
opc-readonlyrejected 0x801F0000 on the UNS NodeId (role gating symmetric). Failed-write dual-node revert is unit-covered (exception_injectorfixture not provisioned on this rig). - 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. - 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 noIAlarmSourcedriver (only Galaxy is one) so a live transition can't be tripped on the rig. - Rename cascade — renamed
HR200→HR200X+ deployed: old raw NodeId gone, new present; the UNS referenceMainPressurefollows 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.) - 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
ReassertValuefix closed:RebuildAddressSpaceclears_alarmConditionsandMaterialiseScriptedAlarmsrecreates each condition fresh/normal, butScriptedAlarmEngine.LoadAsyncreloads from the persisted state and yieldsEmissionKind.Nonefor a still-active condition (dropped byOnEngineEmission), 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-onlyAlarmStateUpdatere-assert inOnAlarmsLoaded(same safe post-materialise ordering as the VT fix), reusing_engine.GetState(alarmId)+LoadedAlarmIds, never touching thealertstopic. - 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>/ UNSs=<Area>/<Line>/<Equipment>/<EffectiveName>, retiredEquipmentNodeIds) + the umbrella index../scadaproj/CLAUDE.mdOtOpcUa entry.