12 KiB
F10b surgical in-place tag-attribute writes — Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development to implement this plan task-by-task.
Goal: Surgical in-place updates for equipment-tag Writable + IsHistorized/HistorianTagname toggles, avoiding a full RebuildAddressSpace (preserving client subscriptions). DataType/array keep rebuild; alarm severity dropped.
Architecture: A new optional ISurgicalAddressSpaceSink capability (Commons) backed by OtOpcUaNodeManager.UpdateTagAttributes (mirrors EnsureVariable's access/Historizing/handler/historian-map logic). Phase7Applier detects surgical-eligible tag deltas (only Writable/IsHistorized/HistorianTagname differ, no alarm, identity stable) and applies them in place, falling back to a full rebuild for anything else or if the sink lacks the capability / a node is missing.
Tech Stack: .NET 10, OPC UA SDK (BaseDataVariableState), Akka.NET, xUnit + Shouldly.
Design: docs/plans/2026-06-18-f10b-surgical-tag-attribute-writes-design.md (committed 4fe88519).
Standing constraints: NO Commons wire/proto change · NO breaking interface change (ADD a new interface, do NOT modify IOpcUaAddressSpaceSink) · 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/rig · finish = merge + push.
Task 1: Surgical-write capability — interface + node-manager + sink
Classification: standard Estimated implement time: ~5 min Parallelizable with: none
Files:
- Create:
src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/ISurgicalAddressSpaceSink.cs - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs(addUpdateTagAttributes+ extractComposeAccessLevel) - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs(implement the new interface)
Context: EnsureVariable (OtOpcUaNodeManager.cs:1318-1370) is the source of truth for tag node attributes:
historized = historianTagname is not null; access = writable ? (byte)(CurrentRead|CurrentWrite) : CurrentRead, then if (historized) access = (byte)(access | HistoryRead); Historizing = historized; if (writable) variable.OnWriteValue = OnEquipmentTagWrite; if (historized) _historizedTagnames[nodeId] = historianTagname!. _historizedTagnames is a ConcurrentDictionary<string,string> (:50). The node lookup is _variables.TryGetValue under lock (Lock). The existing in-place pattern is WriteValue (:256-275): mutate the node + ClearChangeMasks(SystemContext, includeChildren: false).
Steps:
- Interface
ISurgicalAddressSpaceSink.cs(namespaceZB.MOM.WW.OtOpcUa.Commons.OpcUa) — exactly as in the design:public interface ISurgicalAddressSpaceSink { /// <summary>Update an existing variable node's Writable (AccessLevel + inbound-write handler) and /// Historizing (+ historian-tagname binding) IN PLACE, notifying subscribers without a rebuild. /// Returns false if the node does not exist (caller should fall back to a full rebuild).</summary> bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname); } OtOpcUaNodeManager: extract aprivate static byte ComposeAccessLevel(bool writable, bool historized)matching the EnsureVariable byte logic exactly, and use it INEnsureVariable(replace the two inlineaccesslines with aComposeAccessLevel(writable, historized)call — verify the resulting byte is identical). Then add:Add an XML doc summary explaining this is the surgical counterpart ofpublic bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname) { ArgumentException.ThrowIfNullOrEmpty(variableNodeId); lock (Lock) { if (!_variables.TryGetValue(variableNodeId, out var v)) return false; var historized = historianTagname is not null; var access = ComposeAccessLevel(writable, historized); v.AccessLevel = access; v.UserAccessLevel = access; v.Historizing = historized; v.OnWriteValue = writable ? OnEquipmentTagWrite : null; if (historized) _historizedTagnames[variableNodeId] = historianTagname!; else _historizedTagnames.TryRemove(variableNodeId, out _); v.ClearChangeMasks(SystemContext, includeChildren: false); return true; } }EnsureVariablefor F10b (Writable/Historizing toggles), mutating the live node so client subscriptions survive.SdkAddressSpaceSink: add, ISurgicalAddressSpaceSinkto the class declaration andpublic bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname) => _nodeManager.UpdateTagAttributes(variableNodeId, writable, historianTagname);(match the field name for the node manager — read the file).- Build:
dotnet build src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer(dangerouslyDisableSandbox:true) → 0 errors. (Commons builds transitively.) - Commit (explicit paths): the new interface + the two modified files.
Acceptance: the interface exists; UpdateTagAttributes mirrors EnsureVariable via the shared ComposeAccessLevel; SdkAddressSpaceSink delegates; build clean. NO change to IOpcUaAddressSpaceSink.
Task 2: Phase7Applier surgical-eligibility + apply/rebuild-fallback + tests
Classification: standard Estimated implement time: ~5 min Parallelizable with: none (needs T1's interface)
Files:
- Modify:
src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs
Context: Phase7Applier.Apply (:48-115) computes needsRebuild (the just-shipped vtag-skip uses plan.ChangedEquipmentVirtualTags.Any(d => !VtagDeltaIsNodeIrrelevant(d))). EquipmentTagPlan (Phase7Composer.cs:92-115) = (TagId, EquipmentId, DriverInstanceId, FolderPath, Name, DataType, FullName, Writable, EquipmentTagAlarmInfo? Alarm, IsHistorized=false, HistorianTagname=null, IsArray=false, ArrayLength=null) with AUTO record equality. Phase7Plan.EquipmentTagDelta(Previous, Current). MaterialiseEquipmentTags (:194-220) computes writable = tag.Writable && !tag.IsArray and historianTagname = tag.IsHistorized ? (blank HistorianTagname ? tag.FullName : tag.HistorianTagname) : null, node id EquipmentNodeIds.Variable(tag.EquipmentId, tag.FolderPath, tag.Name). Read Phase7ApplierTests.cs for the RecordingSink + CompositionWithVirtualTags-style helpers + the just-added vtag-skip tests to mirror.
Steps:
- Add the eligibility helper (mirror the vtag helper's whitelist-of-may-differ style):
// F10b: a CHANGED equipment tag whose ONLY differences are Writable / IsHistorized / HistorianTagname // (a plain value variable — no alarm condition node) can be updated IN PLACE on the existing node via // ISurgicalAddressSpaceSink.UpdateTagAttributes, avoiding a full rebuild (preserving subscriptions). // DataType / IsArray / ArrayLength / FullName / identity / alarm differences fall through to a rebuild // (the override-unequal default also covers any future field). private static bool TagDeltaIsSurgicalEligible(Phase7Plan.EquipmentTagDelta d) => d.Previous.Alarm is null && d.Current.Alarm is null && (d.Previous with { Writable = d.Current.Writable, IsHistorized = d.Current.IsHistorized, HistorianTagname = d.Current.HistorianTagname, }).Equals(d.Current); - Change the
needsRebuildChangedEquipmentTags.Count > 0term toplan.ChangedEquipmentTags.Any(d => !TagDeltaIsSurgicalEligible(d))(keep Added/Removed tag terms forcing rebuild). Rename the local tostructuralRebuildif clearer. - Replace the
if (needsRebuild) { _sink.RebuildAddressSpace(); }block with the apply/fallback control flow from the design §"Control flow in Apply": if structural ⇒ rebuild; else if there are surgical-eligible tag deltas ⇒ if_sink is ISurgicalAddressSpaceSink surgical, loop them computingnodeId/writable/historianexactly asMaterialiseEquipmentTagsdoes and callsurgical.UpdateTagAttributes(...); if any returns false ⇒_sink.RebuildAddressSpace()(fallback); if the sink lacks the capability ⇒ rebuild. Track arebuiltbool and return it asPhase7ApplyOutcome.RebuildCalled. KeepchangedCount/ChangedNodesunchanged. Preserve the existing try/catch aroundRebuildAddressSpace+ the log line (extend the log to note surgical vs rebuild if cheap). - Update the
needsRebuildcomment block to document the surgical-tag path alongside the vtag-skip. - Tests in
Phase7ApplierTests.cs:- Extend
RecordingSinkto ALSO implementISurgicalAddressSpaceSink, recording eachUpdateTagAttributes(nodeId, writable, historianTagname)into a queue + a counter; returntrue. Add a second fake (or a flag) for the "node missing ⇒ returns false" + the "sink without the capability" cases. - Facts: Writable-only ⇒ no rebuild + 1 surgical call (writable=new); IsHistorized toggle ⇒ no rebuild + surgical(historian = FullName-or-override / null); HistorianTagname-only (historized) ⇒ no rebuild + surgical(historian=new); DataType change ⇒ rebuild; IsArray change ⇒ rebuild; FullName change ⇒ rebuild; Name change ⇒ rebuild; Alarm-presence change ⇒ rebuild; surgical-eligible mixed-with-another-change ⇒ rebuild; sink-without-capability + surgical delta ⇒ rebuild (fallback); surgical returns false (node missing) ⇒ rebuild (fallback). Assert
outcome.RebuildCalled+sink.RebuildCalls+ the recorded surgical calls +ChangedNodes.
- Extend
- Build + test:
dotnet build src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer+dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests --filter "FullyQualifiedName~Phase7Applier"→ all green. - Commit (explicit paths):
Phase7Applier.cs+Phase7ApplierTests.cs.
Acceptance: surgical-eligible tag deltas apply in place (no rebuild); everything else + the fallbacks rebuild; tests green.
Task 3: Reconcile + live /run + finish (merge + push)
Classification: small Estimated implement time: ~5 min Parallelizable with: none (last)
Files:
- Modify (NEVER staged):
stillpending.md(#11 — note the surgical Writable/Historize slice) - Modify: memory
project_stillpending_backlog.md+MEMORY.mdindex line
Steps:
- Full solution build:
dotnet build ZB.MOM.WW.OtOpcUa.slnx— 0 errors. - Targeted tests:
OpcUaServer.Tests(Phase7Applier) green; report counts. - Live
/run(confirmatory — the surgical path needs the real SDK sink, which runs on docker-dev): rebuild BOTH central nodes (docker compose -f docker-dev/docker-compose.yml build central-1 central-2thenup -d --no-deps --force-recreate central-1 central-2); via the AdminUI/unstoggle one equipment tag's Historize (or Writable), Deploy current configuration, and confirm the server log showsrebuild=Falsefor that deploy + the server stays healthy + the node remains readable (a Client.CLI read still works). If observing the live toggle is impractical in the time budget, record that the unit tests pin the logic and the live gate is the rebuild=False log line; do NOT fake it. stillpending.md(never staged): update #11 — the surgical Writable/IsHistorized/HistorianTagname in-place slice SHIPPED (alarm severity dropped as live-shadowed; DataType/array kept-rebuild as dirty — both documented).- Memory: top marker in
project_stillpending_backlog.md+ conciseMEMORY.mdindex update (keep under the soft cap). - Finish (superpowers-extended-cc:finishing-a-development-branch): verify tests → ff-merge
feat/f10b-surgical-tag-attribute-writes→ master → push → delete branch → confirmlocal master = origin/master.
Acceptance: build clean, tests green, live /run shows rebuild=False on a Writable/Historize toggle (or the documented confirmatory evidence), backlog/memory reconciled, merged + pushed.
Dependency graph
T1 → T2 → T3 (T1/T2 coupled by the new interface; T3 last).