25 KiB
Phase 3 — OPC UA standards completeness (H4 + H2-bit + H6) Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development to implement this plan task-by-task.
Goal: Let an OPC UA client Enable/Disable a Part 9 condition (H4); give HistoryUpdate its own
permission bit so a HistoryRead grant can't authorize it (H2-bit); route a native (Galaxy) condition's
Acknowledge to the owning driver → AVEVA, carrying the authenticated principal (H6).
Architecture: All in the OPC UA node-manager + alarm command path + driver inbound-ack route. The engine
already handles Enable/Disable; only the OnEnableDisable node-manager delegate is missing. Native vs scripted
conditions are distinguished by a new bool isNative threaded through the single shared
MaterialiseAlarmCondition (Phase7Applier already knows which is which); native acks route through a new
NativeAlarmAckRouter → DriverHostActor (inverse condition→driver map) → driver.AcknowledgeAsync.
Tech Stack: C# / .NET 10, OPC UA Foundation UA-.NETStandard (CustomNodeManager2 / AlarmConditionState),
Akka.NET, xUnit + Shouldly. Design: docs/plans/2026-06-15-stillpending-phase-3-opcua-standards-design.md.
Branch: feat/stillpending-phase-3-opcua-standards (created off master 4af8e65a; design committed 40b883ef).
Hard rules (every task)
- Stage by path — never
git add .. Never stagesql_login.txt,src/Server/.../Host/pki/,pending.md,current.md,docker-dev/docker-compose.yml,stillpending.md. Never echo/commit secrets (gateway API key). No force-push, no--no-verify. - NO Configuration entity / EF migration. The ONLY contract touches are additive:
bool isNativeon the internalIOpcUaAddressSpaceSink.MaterialiseAlarmConditionseam, andstring? OperatorUseron theAlarmAcknowledgeRequestCore.Abstractions record.NodePermissionsis an int-backed[Flags]enum (NodeAcl.PermissionFlagsis an int column) — adding a member is NOT a schema change and needs NO migration. - TDD fail-then-pass. xUnit + Shouldly. NO bUnit. Cross-node/runtime behavior proven only by live
/run. - Production projects are
TreatWarningsAsErrors— fix all warnings (incl. XML docs on new public members).
Verified facts the implementer needs
NodePermissions(src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NodePermissions.cs):Browse=1<<0…MethodCall=1<<11; compositesReadOnly/Operator/Engineer/Admin. Highest used bit is1<<11.OpcUaOperation(src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/OpcUaOperation.cs) ALREADY has aHistoryUpdatemember.TriePermissionEvaluator.MapOperationToPermission(src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/TriePermissionEvaluator.cs:78-86) mapsHistoryUpdate => NodePermissions.HistoryReadwith a// TODO— the bug.MaterialiseAlarmConditionis the SINGLE shared materializer. InterfaceIOpcUaAddressSpaceSink.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity); impls:DeferredAddressSpaceSink.cs:47,SdkAddressSpaceSink.cs:45,NullOpcUaAddressSpaceSink(in Commons/OpcUa),OtOpcUaNodeManager.cs:538. Phase7Applier calls it at:204(native equipment-tag alarms,tag.Alarm) and:295(scripted,alarm.ScriptedAlarmId).OtOpcUaNodeManager.MaterialiseAlarmConditionwires the inbound method delegates at:594-624(OnAcknowledge/OnConfirm/OnAddComment/OnShelve/OnTimedUnshelve→HandleAlarmCommand).OnEnableDisableis absent.HandleAlarmCommand(:654-679) AlarmAck-gates viaRoleCarryingUserIdentityand routes via theAction<AlarmCommand>? AlarmCommandRouterproperty (:91).AlarmCommandvocab already hasEnable/Disable.- The engine already handles them:
ScriptedAlarmEngine.EnableAsync/DisableAsync, dispatched inScriptedAlarmHostActor.cs:408-412onAlarmCommand"Enable"/"Disable". DriverHostActorforward map_alarmNodeIdByDriverRef: (DriverInstanceId, FullName) → condition NodeId(s)(field:127, cleared+rebuilt:902-904);ForwardNativeAlarm(:537) resolves onmsg.Args.ConditionId(= dotted FullName). No inbound OPC-UA-ack→driver path exists.AlarmAcknowledgeRequest(src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs:44) =(string SourceNodeId, string ConditionId, string? Comment)— NO principal field.GalaxyDriver.AcknowledgeAsync(src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs:1095) forwards toIGalaxyAlarmAcknowledger.AcknowledgeAsync(alarmFullReference, comment, operatorUser, ct)(which already takesoperatorUser).ScriptedAlarmSource.cs:75hardcodes"opcua-client".- Test homes:
tests/Core/.../Authorization/TriePermissionEvaluatorTests.cs(H2);tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs(H4/H6 node-manager seam);tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/...(DriverHostActor). Galaxy:tests/Drivers/...Driver.Galaxy.Tests.
Task 1: H2-bit — NodePermissions.HistoryUpdate + evaluator mapping
Classification: small Estimated implement time: ~3 min Parallelizable with: Task 2
Files:
- Modify:
src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NodePermissions.cs - Modify:
src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/TriePermissionEvaluator.cs:86 - Test:
tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Authorization/TriePermissionEvaluatorTests.cs
Step 1: Failing tests (add to TriePermissionEvaluatorTests.cs):
HistoryRead_grant_does_not_authorize_HistoryUpdate: build a scope/ACL granting onlyNodePermissions.HistoryRead;Authorize(session, OpcUaOperation.HistoryUpdate, scope)→ NOT Allow (NotGranted). (Mirror an existing test's ACL/session construction.)HistoryUpdate_grant_authorizes_HistoryUpdate: a scope granting the newNodePermissions.HistoryUpdatebit →Authorize(…, HistoryUpdate, …)→ Allow.HistoryUpdate_bit_does_not_collide:((int)NodePermissions.HistoryUpdate).ShouldBe(1 << 12)and(NodePermissions.HistoryUpdate & NodePermissions.MethodCall).ShouldBe(NodePermissions.None).
Step 2: Run, expect FAIL (the bit doesn't exist / HistoryUpdate currently maps to the HistoryRead bit so the
first test wrongly passes-as-Allow):
dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests --filter "FullyQualifiedName~TriePermissionEvaluator"
Step 3: Implement.
- In
NodePermissions.csaddHistoryUpdate = 1 << 12,afterMethodCall = 1 << 11,(with an XML///doc). Do NOT add it to any composite (ReadOnly/Operator/Engineer/Admin) — the HistoryUpdate service is deferred, so no role needs to grant it yet; the bit exists only to close the HistoryRead⇒HistoryUpdate hole. - In
TriePermissionEvaluator.cs:86changeOpcUaOperation.HistoryUpdate => NodePermissions.HistoryRead,toOpcUaOperation.HistoryUpdate => NodePermissions.HistoryUpdate,and delete the stale// TODOcomment.
Step 4: Run, expect PASS. Also dotnet build ZB.MOM.WW.OtOpcUa.slnx clean.
Step 5: Commit
git add src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NodePermissions.cs \
src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/TriePermissionEvaluator.cs \
tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Authorization/TriePermissionEvaluatorTests.cs
git commit -m "fix(authz): give HistoryUpdate its own NodePermissions bit (was aliased to HistoryRead) [H2]"
Task 2: H6a — mark native conditions (isNative through the sink)
Classification: high-risk Estimated implement time: ~5 min Parallelizable with: Task 1, Task 4
Files:
- Modify:
src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IOpcUaAddressSpaceSink.cs(or wherever the interface lives —grep -rn "void MaterialiseAlarmCondition" src --include=*.csto confirm; the design noted the sink interface declares it) - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs:45 - Modify:
src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs:47 - Modify:
src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/NullOpcUaAddressSpaceSink.cs(no-op impl) - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs:538(signature +_nativeAlarmNodeIdsset) - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs:204,295,336-339(SafeMaterialiseAlarmCondition) - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs(+SdkAddressSpaceSinkTestsif it asserts the signature)
Step 1: Failing test. In AlarmCommandRouterTests.cs, add a test that a condition materialised with
isNative: true is tracked as native (assert via behavior introduced in this task: e.g. a public/internal
IsNativeAlarm(nodeId) test hook on the node manager, OR defer the behavioral assertion to Task 5 and here
just assert the signature compiles + Phase7Applier passes the flag). Prefer adding an internal bool IsNativeAlarmNode(string alarmNodeId) test accessor on OtOpcUaNodeManager and asserting
true after a native materialise / false after a scripted one.
Step 2: Run, expect FAIL (param/accessor missing).
Step 3: Implement.
- Add
bool isNative = falseas the LAST, defaulted param toMaterialiseAlarmConditionon the interface + every impl. Defaulted so no other caller breaks. OtOpcUaNodeManager: addprivate readonly HashSet<string> _nativeAlarmNodeIds = new();; whenisNative,_nativeAlarmNodeIds.Add(alarmNodeId). Addinternal bool IsNativeAlarmNode(string alarmNodeId) => _nativeAlarmNodeIds.Contains(alarmNodeId);. (Clear the set on address-space rebuild if the manager resets_alarmConditions— match that lifecycle.)Phase7Applier.SafeMaterialiseAlarmCondition: add abool isNativeparam; passisNative: trueat the native equipment-tag-alarm call (:204) andisNative: falseat the scripted call (:295).
Step 4: Run, expect PASS + dotnet build ZB.MOM.WW.OtOpcUa.slnx clean (all sink impls updated).
Step 5: Commit (stage each modified file by path).
git commit -m "feat(alarms): thread isNative through MaterialiseAlarmCondition; node manager tracks native conditions [H6a]"
Task 3: H4 — wire OnEnableDisable
Classification: high-risk Estimated implement time: ~5 min Parallelizable with: none
Files:
- Modify:
src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs(inMaterialiseAlarmCondition, near:594-624) - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs
Step 0 — VERIFY the SDK delegate. Confirm AlarmConditionState/ConditionState exposes
OnEnableDisable and its exact delegate signature (likely ServiceResult (ISystemContext context, ConditionState condition, bool enabling), matching the OnShelve/OnAcknowledge settable-handler pattern).
Use IntelliSense/decompile (Opc.Ua.Server 1.5.x). If the SDK instead exposes separate OnEnable/OnDisable
handlers, adapt — wire both to the same body with the right enabling value. If OnEnableDisable genuinely
does not exist on this SDK version, STOP and report (the design assumed it per AlarmCommand.cs:37).
Step 1: Failing tests (mirror the existing OnAcknowledge/OnShelve tests in AlarmCommandRouterTests.cs):
OnEnableDisable_with_AlarmAck_routes_Disable_command: materialise a SCRIPTED condition (isNative:false); captureAlarmCommandRouter; invokeOnEnableDisable(ctx-with-AlarmAck-RoleCarryingUserIdentity, condition, enabling:false)→ resultGood, capturedAlarmCommand.Operation == "Disable",User == DisplayName.OnEnableDisable_enabling_routes_Enable_command: same withenabling:true→"Enable".OnEnableDisable_anonymous_is_denied: no/role-less identity →BadUserAccessDenied, no route.OnEnableDisable_on_native_condition_is_BadNotSupported: materialise withisNative:true; invokeOnEnableDisable→BadNotSupported, no route. (Uses Task 2's_nativeAlarmNodeIds.)
Step 2: Run, expect FAIL.
Step 3: Implement. In MaterialiseAlarmCondition, after the OnShelve/OnTimedUnshelve wiring:
alarm.OnEnableDisable = (context, condition, enabling) =>
{
// Native (driver-fed) conditions have no engine enable/disable surface (decision #2).
if (_nativeAlarmNodeIds.Contains(alarmNodeId))
return new ServiceResult(StatusCodes.BadNotSupported);
return HandleAlarmCommand(context, condition, enabling ? "Enable" : "Disable", comment: null, unshelveAt: null);
};
(Adapt the delegate shape to Step 0's finding. _nativeAlarmNodeIds is populated in Task 2 — this task is
ordered after Task 2.)
Step 4: Run, expect PASS + full build clean. Note: ScriptedAlarmHostActor Enable/Disable dispatch is
already covered — no engine change needed.
Step 5: Commit
git commit -m "feat(alarms): wire OnEnableDisable over OPC UA (AlarmAck-gated; native→BadNotSupported) [H4]"
Task 4: H6b — AlarmAcknowledgeRequest.OperatorUser + Galaxy forwarding + ScriptedAlarmSource tidy
Classification: standard Estimated implement time: ~4 min Parallelizable with: Task 1, Task 2
Files:
- Modify:
src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs:44(record) - Modify:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs:1095(forwardOperatorUser) - Modify:
src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmSource.cs:65-78 - Test:
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/...(Galaxy ack forwarding) + a Core.Abstractions or ScriptedAlarms test for the record/ScriptedAlarmSourceprincipal.
Step 1: Failing tests.
GalaxyDriver_AcknowledgeAsync_forwards_OperatorUser: with a fakeIGalaxyAlarmAcknowledger, callAcknowledgeAsync([ new AlarmAcknowledgeRequest("src","cond.alarm","cmt"){ OperatorUser="alice" } ])(or positional) → the fake receivedoperatorUser == "alice". (Find the existing Galaxy acknowledger test/fake.)ScriptedAlarmSource_uses_supplied_principal_else_opcua_client: a request withOperatorUser="bob"→ engine.AcknowledgeAsync called with"bob"; withOperatorUser=null→"opcua-client".
Step 2: Run, expect FAIL (the field doesn't exist).
Step 3: Implement.
AlarmAcknowledgeRequest→ addstring? OperatorUser = nullas the LAST positional param (keeps existing positional construction working). XML-doc it ("authenticated principal; null on the raw path").GalaxyDriver.AcknowledgeAsync: passa.OperatorUser ?? <existing-fallback>asoperatorUserto the acknowledger (preserve whatever it currently passes when null).ScriptedAlarmSource.AcknowledgeAsync(:75):await _engine.AcknowledgeAsync(a.ConditionId, a.OperatorUser ?? "opcua-client", a.Comment, ct).- AbCip/FOCAS
AcknowledgeAsyncneed no change (they ignore the new field) — confirm they still compile.
Step 4: Run, expect PASS + full build clean.
Step 5: Commit
git commit -m "feat(alarms): AlarmAcknowledgeRequest carries OperatorUser; Galaxy/ScriptedAlarmSource honor it [H6b]"
Task 5: H6c — NativeAlarmAckRouter seam + native OnAcknowledge routing
Classification: high-risk
Estimated implement time: ~5 min
Parallelizable with: none (needs Task 2's _nativeAlarmNodeIds)
Files:
- Create:
src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/NativeAlarmAck.cs(small record — OpcUaServer-local, smallest scope) - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs(NativeAlarmAckRouterproperty + nativeOnAcknowledge) - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs
Step 1: Failing tests.
Native_OnAcknowledge_routes_to_NativeAlarmAckRouter_not_scripted: materialiseisNative:true; capture both routers; invokeOnAcknowledge(ctx-with-AlarmAck, condition, _, comment)→Good; theNativeAlarmAckRoutergotNativeAlarmAck(conditionNodeId, comment, OperatorUser=DisplayName); the scriptedAlarmCommandRoutergot NOTHING.Scripted_OnAcknowledge_still_uses_AlarmCommandRouter:isNative:false→AlarmCommandRoutergets theAlarmCommand,NativeAlarmAckRoutergets nothing.Native_OnAcknowledge_anonymous_is_denied: no AlarmAck role →BadUserAccessDenied, neither router invoked.
Step 2: Run, expect FAIL.
Step 3: Implement.
NativeAlarmAck.cs:public sealed record NativeAlarmAck(string ConditionNodeId, string? Comment, string OperatorUser);OtOpcUaNodeManager: addpublic Action<NativeAlarmAck>? NativeAlarmAckRouter { get; set; }.- In
MaterialiseAlarmCondition, setOnAcknowledgebased on native-ness:
alarm.OnAcknowledge = isNative
? (context, condition, _, comment) => HandleNativeAlarmAck(context, condition, comment)
: (context, condition, _, comment) => HandleAlarmCommand(context, condition, "Acknowledge", comment, null);
(Capture isNative per-condition; don't rely on the set lookup inside the closure unless you prefer it —
either is fine, but the set lookup also works: _nativeAlarmNodeIds.Contains(alarmNodeId).)
- Add
HandleNativeAlarmAck: resolve the AlarmAck-gatedRoleCarryingUserIdentityEXACTLY likeHandleAlarmCommand(fail-closed →BadUserAccessDenied); elseNativeAlarmAckRouter?.Invoke(new NativeAlarmAck(condition.NodeId.Identifier?.ToString() ?? "", comment?.Text, identity.DisplayName ?? "")); returnGood. (Confirm whether the native condition's NodeId identifier is the folder-scoped condition node id the inverse map in Task 6 will key by — align with_alarmNodeIdByDriverRef's value set.)
Step 4: Run, expect PASS + full build clean.
Step 5: Commit
git commit -m "feat(alarms): native condition Acknowledge routes to NativeAlarmAckRouter with principal [H6c]"
Task 6: H6d — DriverHostActor inverse map + native-ack route to the driver
Classification: high-risk
Estimated implement time: ~5 min
Parallelizable with: none (needs Task 4 OperatorUser + Task 5 NativeAlarmAck)
Files:
- Modify:
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs(inverse map +Receive<NativeAlarmAck-or-RouteNativeAck>) - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs(aRouteAlarmAckreceive →driver.AcknowledgeAsync) — only if the host routes via the instance actor; otherwise the host calls the driver via its existing handle - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/...(mirror an existing DriverHostActor test)
Step 1: Failing tests.
Inverse_map_resolves_condition_node_to_driver_ref: after anApplyDelta/composition with a native alarm tag, the inverse map yields(DriverInstanceId, FullName)for the materialised condition node id.NativeAlarmAck_routes_to_driver_AcknowledgeAsync_with_OperatorUser: feed the host a native-ack message (the Runtime-side envelope the host wires theNativeAlarmAckRouterto) referencing a known condition node; a stubIAlarmSourcedriver receivesAcknowledgeAsyncwithConditionId == alarmRef,OperatorUser == "alice",Commentpassed through. Use the sharedStubDrivers.csharness.NativeAlarmAck_unknown_node_is_dropped: an ack for an unmapped node → logged + dropped, no throw.
Step 2: Run, expect FAIL.
Step 3: Implement.
- Build the inverse
Dictionary<string, (string DriverInstanceId, string FullName)> _driverRefByAlarmNodeIdin the SAME clear-and-rebuild pass that builds_alarmNodeIdByDriverRef(:902-904): for each(driverId, fullName) → {nodeIds}, add eachnodeId → (driverId, fullName). - Decide the host's inbound envelope: the node manager's
NativeAlarmAckRouterisAction<NativeAlarmAck>wired (in Task 7) toTella Runtime message intoDriverHostActor. Define that Runtime message (e.g.DriverHostActor.RouteNativeAlarmAck(string ConditionNodeId, string? Comment, string OperatorUser)) — do NOT reference the OpcUaServerNativeAlarmAcktype from Runtime if it creates a bad dependency; map at the wiring boundary (Task 7).Receive<RouteNativeAlarmAck>: look up_driverRefByAlarmNodeId; on miss →_log.Debug+ return; on hit → route to the driver via the same mechanismForwardNativeAlarm/the write path uses (Tell theDriverInstanceActoraRouteAlarmAckit turns intodriver.AcknowledgeAsync([ new AlarmAcknowledgeRequest(SourceNodeId: <owning object or fullName>, ConditionId: fullName, Comment, OperatorUser) ], CancellationToken.None), fire-and-forget). Mirror the primary-gating the write/ack paths use if applicable.
Step 4: Run, expect PASS + full build clean.
Step 5: Commit
git commit -m "feat(alarms): DriverHostActor routes native-condition acks to the owning driver [H6d]"
Task 7: H6e — wire NativeAlarmAckRouter in host DI
Classification: high-risk Estimated implement time: ~3 min Parallelizable with: none
Files:
- Modify: the host wiring that sets
AlarmCommandRoutertoday (grep -rn "AlarmCommandRouter =" src --include=*.cs— likelyOtOpcUaServerHostedService/ a DI/bootstrap seam). SetNativeAlarmAckRouteranalogously to a fire-and-forgetTellof the RuntimeRouteNativeAlarmAckinto theDriverHostActor(resolve itsIActorRefthe same way the AlarmCommandRouter resolves the ScriptedAlarmHost / mediator). - Test: extend the existing wiring/smoke test if one asserts
AlarmCommandRouteris set; else none (covered by the live/run).
Step 1: Implement the router assignment (map NativeAlarmAck → the Runtime RouteNativeAlarmAck at this
boundary so Runtime needn't reference OpcUaServer's record). Confirm the DriverHostActor ref is available at
the wiring site (it's registered in the actor registry).
Step 2: Build + full test. dotnet build ZB.MOM.WW.OtOpcUa.slnx clean; dotnet test on the affected
projects green.
Step 3: Commit
git commit -m "feat(alarms): wire NativeAlarmAckRouter to DriverHostActor in host DI [H6e]"
Task 8: Docs
Classification: small Estimated implement time: ~3 min Parallelizable with: Task 7
Files:
- Modify:
docs/ScriptedAlarms.md,docs/AlarmTracking.md - Modify:
docs/security.md(HistoryUpdate permission bit note, if the § on NodePermissions warrants it)
Step 1: Document: H4 — OPC UA clients can now Enable/Disable Part 9 conditions (AlarmAck-gated; the engine
applies it; native conditions return BadNotSupported). H6 — an inbound Acknowledge on a native (Galaxy)
condition now routes to the owning driver → AVEVA, carrying the authenticated principal (not the generic
opcua-client); AlarmAcknowledgeRequest gained OperatorUser. H2 — HistoryUpdate has its own permission
bit (a HistoryRead grant no longer authorizes it); the HistoryUpdate service remains deferred (infra-gated).
Step 2: No build needed. Grep for now-false claims (e.g. AlarmCommand.cs's "OnEnableDisable … future
task" comment — update it; ScriptedAlarmSource.cs's "opcua-client" remark).
Step 3: Commit by path.
git commit -m "docs(alarms): OPC UA Enable/Disable wired + native-ack→AVEVA + HistoryUpdate permission bit"
Task 9: Full build + test + final integration review
Classification: high-risk Estimated implement time: ~4 min Parallelizable with: none
Step 1: dotnet build ZB.MOM.WW.OtOpcUa.slnx clean (TreatWarningsAsErrors).
Step 2: dotnet test on Core.Tests, OpcUaServer.Tests, Runtime.Tests, Driver.Galaxy.Tests — all green.
Step 3: Final integration reviewer: confirm (a) no EF/Configuration migration; the only contract touches are
the isNative sink param + AlarmAcknowledgeRequest.OperatorUser (both additive/defaulted); (b) native acks
route to the driver and scripted acks/enable-disable still route to the engine (no cross-wiring); (c) the
AlarmAck fail-closed gate holds on every new path (H4 Enable/Disable + H6 native ack); (d) the NativeAlarmAck
→ Runtime RouteNativeAlarmAck boundary doesn't create a Runtime→OpcUaServer bad dependency; (e) staging
by-path only. Apply actionable findings.
Step 4: Update pending.md (working-tree only, never staged).
Task 10: Live /run
Classification: high-risk (verification) Estimated implement time: ~6 min (agent-driven on docker-dev; login disabled — do NOT sign in)
H4 (fully local-provable): ensure a scripted alarm exists on the dev config (or author one), deploy, then
dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- ... — Disable the condition via the Part 9
Disable method (Client.CLI shelve/ack style command, or a call on the Disable method node) as a
user holding the AlarmAck role (LDAP opc-... per the data-plane role map) → confirm the condition's
EnabledState goes Disabled in the server log / a follow-up read; anon → BadUserAccessDenied.
H6 (local half): trip a native (Galaxy) alarm, Acknowledge it over OPC UA as an AlarmAck user → confirm
from the central-1 log that the ack ROUTED to the driver (DriverHostActor native-ack route log line) with the
authenticated principal. The AVEVA commit (gateway → Historian on 10.100.0.48) is operator-gated — drive
it against the reachable gateway if feasible this session (the Galaxy live-gw smokes pattern), else record it
as operator-gated (like H5).
Done when: build clean + dotnet test green + H4 live-proven + H6 local route proven (AVEVA commit recorded
as driven-or-operator-gated). Then finishing-a-development-branch → merge to master + push.