Files
lmxopcua/docs/plans/2026-06-18-f10b-vtag-skip-rebuild-galaxy-comment.md
T

11 KiB

F10b vtag rebuild-skip + Client.UI Galaxy comment — Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development to implement this plan task-by-task.

Goal: Close backlog #11 (F10b — skip the address-space rebuild for vtag-node-irrelevant edits) and #12 (Client.UI Galaxy operator-comment accepted-limitation).

Architecture: A: refine needsRebuild in Phase7Applier.Apply so a deploy whose only changes are VirtualTag deltas differing solely in Expression/DependencyRefs/Historize skips RebuildAddressSpace (the vtag node is byte-identical; the engine respawns independently via VirtualTagHostActor). B: a clarifying comment + doc + pinning test for the Galaxy sub-attribute fallback that can't surface the operator comment.

Tech Stack: .NET 10, Akka.NET, OPC UA SDK, xUnit + Shouldly.

Design: docs/plans/2026-06-18-f10b-vtag-skip-rebuild-galaxy-comment-design.md (committed 53f1147b).

Standing constraints: NO Commons/proto change · NO Core.Abstractions/interface change · NO EF migration · NO bUnit · stage by explicit path (never git add .) · never stage the never-stage files · no force-push / no --no-verify · dangerouslyDisableSandbox:true for build/test · finish = merge to master + push.


Task 1: F10b — skip rebuild for vtag-node-irrelevant edits

Classification: small Estimated implement time: ~4 min Parallelizable with: Task 2

Files:

  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs (refine needsRebuild, lines ~84-96)
  • Test: tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs

Context (read to confirm before editing):

  • Phase7Applier.Apply (:48-115) computes needsRebuild (:92-96) and calls _sink.RebuildAddressSpace() iff true; returns Phase7ApplyOutcome(RemovedNodes, AddedNodes, ChangedNodes, RebuildCalled).
  • A vtag's materialised node (MaterialiseEquipmentVirtualTags, :264-272) depends ONLY on {EquipmentId, FolderPath, Name, DataType}EnsureVariable(parent/Name, Name, DataType, writable:false). Expression/DependencyRefs/Historize do NOT touch the node (Historize is write-side only, :260-263).
  • EquipmentVirtualTagPlan (Phase7Composer.cs:133-157) = (VirtualTagId, EquipmentId, FolderPath, Name, DataType, Expression, DependencyRefs, Historize) with a CUSTOM Equals comparing all 8 logical fields (DependencyRefs element-wise).
  • The vtag ENGINE respawns independently via VirtualTagHostActor.OnApply (:97-109), fed by DriverHostActor:1062 ApplyVirtualTags(...) — NOT through Phase7Applier. So skipping the address-space rebuild leaves the engine respawn intact.
  • The downstream idempotent Materialise* passes (OpcUaPublishActor:326-342) short-circuit on an existing node (OtOpcUaNodeManager.EnsureVariable:1324 early-returns when the node already exists) → preserve live value + subscriptions when no rebuild happened.
  • Phase7ApplierTests.cs uses a RecordingSink capturing sink calls (read it for the exact API — how it records RebuildAddressSpace and how a Phase7Plan with ChangedEquipmentVirtualTags is constructed).

Steps:

  1. Add a private static helper to Phase7Applier:
    // A VirtualTag's materialised OPC UA node (MaterialiseEquipmentVirtualTags) is derived ONLY from
    // {EquipmentId, FolderPath, Name, DataType}. Expression/DependencyRefs/Historize are engine/write-side
    // only and are adopted by VirtualTagHostActor's independent respawn (DriverHostActor → ApplyVirtualTags),
    // so a delta that changes ONLY those three leaves a byte-identical node and needs no address-space
    // rebuild. Whitelist-of-may-differ via `with`+custom-Equals: any OTHER field difference (current or
    // future) makes the override unequal → falls back to a full rebuild (safe default).
    private static bool VtagDeltaIsNodeIrrelevant(Phase7Plan.EquipmentVirtualTagDelta d) =>
        (d.Previous with
        {
            Expression = d.Current.Expression,
            DependencyRefs = d.Current.DependencyRefs,
            Historize = d.Current.Historize,
        }).Equals(d.Current);
    
    (Confirm the delta type's exact name/namespace — likely Phase7Plan.EquipmentVirtualTagDelta with .Previous/.Current — by reading Phase7Plan. Adjust accessors to match.)
  2. Replace the plan.AddedEquipmentVirtualTags... || plan.ChangedEquipmentVirtualTags.Count > 0 term in needsRebuild (:96) with:
    plan.AddedEquipmentVirtualTags.Count > 0 || plan.RemovedEquipmentVirtualTags.Count > 0 ||
    plan.ChangedEquipmentVirtualTags.Any(VtagDeltaIsNodeIrrelevant ... )
    
    — specifically: keep Added/Removed forcing rebuild, and make the Changed term plan.ChangedEquipmentVirtualTags.Any(d => !VtagDeltaIsNodeIrrelevant(d)). So needsRebuild is false only when every effective change is a node-irrelevant vtag edit and nothing else changed. Leave ChangedNodes/changedCount computation as-is (it still counts the vtag change).
  3. Update the :84-91 comment block to document the new vtag-skip + the safety reasoning (node-irrelevant ⇒ engine respawns independently ⇒ subscriptions preserved; any structural or node-affecting change still rebuilds).
  4. Tests in Phase7ApplierTests.cs (mirror the existing plan-construction + RecordingSink assertions):
    • Expression-only vtag delta ⇒ outcome.RebuildCalled == false AND the RecordingSink recorded NO RebuildAddressSpace AND outcome.ChangedNodes == 1.
    • DependencyRefs-only and Historize-only vtag deltas ⇒ no rebuild (parameterize or separate facts).
    • DataType-also-changed vtag delta ⇒ RebuildCalled == true.
    • Name-changed (and FolderPath-changed) vtag delta ⇒ RebuildCalled == true.
    • Node-irrelevant vtag delta MIXED with another change (e.g. a ChangedEquipmentTag) ⇒ RebuildCalled == true.
  5. Build + test: dotnet build src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer + dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests --filter "FullyQualifiedName~Phase7Applier" (dangerouslyDisableSandbox:true). All green.
  6. Commit (explicit paths): Phase7Applier.cs + Phase7ApplierTests.cs.

Acceptance: vtag Expression/DependencyRefs/Historize-only edits skip RebuildAddressSpace; any node-affecting or structural change still rebuilds; tests green. No node-manager/SDK/interface change.


Task 2: Client.UI Galaxy operator-comment — comment + doc + pinning test

Classification: small Estimated implement time: ~3 min Parallelizable with: Task 1

Files:

  • Modify: src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs (inline comment at the fallback AlarmEvent?.Invoke site, ~:817)
  • Modify: src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/AlarmEventArgs.cs (tighten the OperatorComment doc, ~:82-88)
  • Test: tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/OpcUaClientServiceTests.cs (pinning test)

Context: The Galaxy sub-attribute fallback (OpcUaClientService.cs:769-820) reads InAlarm/Acked/TimeAlarmOn/DescAttrName when standard event fields are null, then raises AlarmEventArgs WITHOUT operatorComment (the param defaults null). This is an ACCEPTED LIMITATION — not code-fixable: Galaxy AckMsg is write-only (AlarmRefBuilder.cs:15-20, no readable comment attribute) and the OPC UA event SelectClause (CreateAlarmEventFilter) carries no comment field. The comment is only available via the gateway native alarm feed (GalaxyAlarmTransition.OperatorComment), the driver path — not this client fallback.

Steps:

  1. Read OpcUaClientService.cs:769-826 (both the fallback and standard AlarmEvent?.Invoke sites) + Models/AlarmEventArgs.cs:82-88 + the existing fallback test OnAlarmEvent_MissingAckedActiveButHasConditionNode_FallbackReadsAndRaisesEvent in OpcUaClientServiceTests.cs.
  2. Add an inline comment at the fallback AlarmEvent?.Invoke(...) site explaining OperatorComment is intentionally null here: Galaxy AckMsg is write-only + the OPC UA event SelectClause carries no comment field, so the operator comment is only recoverable via the gateway's native alarm feed (the Galaxy driver path), not this client fallback. (Comment-only — do NOT change the constructor call or behavior.)
  3. Tighten the AlarmEventArgs.OperatorComment XML doc (:82-87) to name the two concrete reasons (write-only AckMsg; no event-field) so it reads as an intentional, documented limitation rather than a TODO.
  4. Add a pinning test (mirror the existing fallback-test fixture): drive the Galaxy sub-attribute fallback path and assert the raised AlarmEventArgs.OperatorComment is null. Name it e.g. OnAlarmEvent_GalaxyFallback_LeavesOperatorCommentNull. xUnit + Shouldly; match the existing test's fake-session/subscription wiring + the Task.Delay/await for the background fallback read.
  5. Build + test: dotnet build src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared + dotnet test tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests --filter "FullyQualifiedName~OperatorComment" (dangerouslyDisableSandbox:true). Green.
  6. Commit (explicit paths): the two src files + the test file.

Acceptance: the limitation is documented at the fallback site + on OperatorComment, and a test pins the null-comment behavior. No behavioral change.


Task 3: Reconcile backlog + memory + finish (merge + push)

Classification: small Estimated implement time: ~4 min Parallelizable with: none (last)

Files:

  • Modify (NEVER staged): stillpending.md (mark #11/#12)
  • Modify: memory project_stillpending_backlog.md + MEMORY.md index line

Steps:

  1. Full solution build: dotnet build ZB.MOM.WW.OtOpcUa.slnx — 0 errors.
  2. Targeted tests green: OpcUaServer.Tests (Phase7Applier) + Client.Shared.Tests (OperatorComment/alarm). Report counts.
  3. stillpending.md (never staged): #11 — F10b vtag-node-irrelevant rebuild-skip shipped (Expression/DependencyRefs/Historize-only edits skip RebuildAddressSpace; broader surgical writes deferred); #12 — Galaxy operator-comment closed as a documented + tested accepted-limitation (not code-fixable: write-only AckMsg + no event-field).
  4. Memory: top marker in project_stillpending_backlog.md + a concise MEMORY.md index update (keep the file under the soft cap).
  5. Finish (superpowers-extended-cc:finishing-a-development-branch): verify tests → ff-merge feat/f10b-vtag-skip-rebuild-galaxy-comment → master → push → delete branch → confirm local master = origin/master.

Acceptance: build clean, tests green, backlog/memory reconciled, merged + pushed.


Dependency graph

{T1 ∥ T2}T3. T1/T2 touch disjoint projects (OpcUaServer / Client.Shared) → dispatch concurrently with worktree isolation or serial-on-shared-tree (one writer at a time). T3 last.