Files
lmxopcua/docs/plans/2026-06-15-stillpending-phase-3-opcua-standards.md
T

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 NativeAlarmAckRouterDriverHostActor (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 stage sql_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 isNative on the internal IOpcUaAddressSpaceSink.MaterialiseAlarmCondition seam, and string? OperatorUser on the AlarmAcknowledgeRequest Core.Abstractions record. NodePermissions is an int-backed [Flags] enum (NodeAcl.PermissionFlags is 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<<0MethodCall=1<<11; composites ReadOnly/Operator/Engineer/Admin. Highest used bit is 1<<11.
  • OpcUaOperation (src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/OpcUaOperation.cs) ALREADY has a HistoryUpdate member.
  • TriePermissionEvaluator.MapOperationToPermission (src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/TriePermissionEvaluator.cs:78-86) maps HistoryUpdate => NodePermissions.HistoryRead with a // TODO — the bug.
  • MaterialiseAlarmCondition is the SINGLE shared materializer. Interface IOpcUaAddressSpaceSink.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.MaterialiseAlarmCondition wires the inbound method delegates at :594-624 (OnAcknowledge/OnConfirm/OnAddComment/OnShelve/OnTimedUnshelveHandleAlarmCommand). OnEnableDisable is absent. HandleAlarmCommand (:654-679) AlarmAck-gates via RoleCarryingUserIdentity and routes via the Action<AlarmCommand>? AlarmCommandRouter property (:91). AlarmCommand vocab already has Enable/Disable.
  • The engine already handles them: ScriptedAlarmEngine.EnableAsync/DisableAsync, dispatched in ScriptedAlarmHostActor.cs:408-412 on AlarmCommand "Enable"/"Disable".
  • DriverHostActor forward map _alarmNodeIdByDriverRef: (DriverInstanceId, FullName) → condition NodeId(s) (field :127, cleared+rebuilt :902-904); ForwardNativeAlarm (:537) resolves on msg.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 to IGalaxyAlarmAcknowledger.AcknowledgeAsync(alarmFullReference, comment, operatorUser, ct) (which already takes operatorUser). ScriptedAlarmSource.cs:75 hardcodes "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 only NodePermissions.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 new NodePermissions.HistoryUpdate bit → 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.cs add HistoryUpdate = 1 << 12, after MethodCall = 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:86 change OpcUaOperation.HistoryUpdate => NodePermissions.HistoryRead, to OpcUaOperation.HistoryUpdate => NodePermissions.HistoryUpdate, and delete the stale // TODO comment.

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=*.cs to 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 + _nativeAlarmNodeIds set)
  • 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 (+ SdkAddressSpaceSinkTests if 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 = false as the LAST, defaulted param to MaterialiseAlarmCondition on the interface + every impl. Defaulted so no other caller breaks.
  • OtOpcUaNodeManager: add private readonly HashSet<string> _nativeAlarmNodeIds = new();; when isNative, _nativeAlarmNodeIds.Add(alarmNodeId). Add internal 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 a bool isNative param; pass isNative: true at the native equipment-tag-alarm call (:204) and isNative: false at 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 (in MaterialiseAlarmCondition, 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); capture AlarmCommandRouter; invoke OnEnableDisable(ctx-with-AlarmAck-RoleCarryingUserIdentity, condition, enabling:false) → result Good, captured AlarmCommand.Operation == "Disable", User == DisplayName.
  • OnEnableDisable_enabling_routes_Enable_command: same with enabling:true"Enable".
  • OnEnableDisable_anonymous_is_denied: no/role-less identity → BadUserAccessDenied, no route.
  • OnEnableDisable_on_native_condition_is_BadNotSupported: materialise with isNative:true; invoke OnEnableDisableBadNotSupported, 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 (forward OperatorUser)
  • 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/ScriptedAlarmSource principal.

Step 1: Failing tests.

  • GalaxyDriver_AcknowledgeAsync_forwards_OperatorUser: with a fake IGalaxyAlarmAcknowledger, call AcknowledgeAsync([ new AlarmAcknowledgeRequest("src","cond.alarm","cmt"){ OperatorUser="alice" } ]) (or positional) → the fake received operatorUser == "alice". (Find the existing Galaxy acknowledger test/fake.)
  • ScriptedAlarmSource_uses_supplied_principal_else_opcua_client: a request with OperatorUser="bob" → engine.AcknowledgeAsync called with "bob"; with OperatorUser=null"opcua-client".

Step 2: Run, expect FAIL (the field doesn't exist).

Step 3: Implement.

  • AlarmAcknowledgeRequest → add string? OperatorUser = null as the LAST positional param (keeps existing positional construction working). XML-doc it ("authenticated principal; null on the raw path").
  • GalaxyDriver.AcknowledgeAsync: pass a.OperatorUser ?? <existing-fallback> as operatorUser to 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 AcknowledgeAsync need 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 (NativeAlarmAckRouter property + native OnAcknowledge)
  • Test: tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs

Step 1: Failing tests.

  • Native_OnAcknowledge_routes_to_NativeAlarmAckRouter_not_scripted: materialise isNative:true; capture both routers; invoke OnAcknowledge(ctx-with-AlarmAck, condition, _, comment)Good; the NativeAlarmAckRouter got NativeAlarmAck(conditionNodeId, comment, OperatorUser=DisplayName); the scripted AlarmCommandRouter got NOTHING.
  • Scripted_OnAcknowledge_still_uses_AlarmCommandRouter: isNative:falseAlarmCommandRouter gets the AlarmCommand, NativeAlarmAckRouter gets 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: add public Action<NativeAlarmAck>? NativeAlarmAckRouter { get; set; }.
  • In MaterialiseAlarmCondition, set OnAcknowledge based 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-gated RoleCarryingUserIdentity EXACTLY like HandleAlarmCommand (fail-closed → BadUserAccessDenied); else NativeAlarmAckRouter?.Invoke(new NativeAlarmAck(condition.NodeId.Identifier?.ToString() ?? "", comment?.Text, identity.DisplayName ?? "")); return Good. (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 (a RouteAlarmAck receive → 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 an ApplyDelta/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 the NativeAlarmAckRouter to) referencing a known condition node; a stub IAlarmSource driver receives AcknowledgeAsync with ConditionId == alarmRef, OperatorUser == "alice", Comment passed through. Use the shared StubDrivers.cs harness.
  • 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)> _driverRefByAlarmNodeId in the SAME clear-and-rebuild pass that builds _alarmNodeIdByDriverRef (:902-904): for each (driverId, fullName) → {nodeIds}, add each nodeId → (driverId, fullName).
  • Decide the host's inbound envelope: the node manager's NativeAlarmAckRouter is Action<NativeAlarmAck> wired (in Task 7) to Tell a Runtime message into DriverHostActor. Define that Runtime message (e.g. DriverHostActor.RouteNativeAlarmAck(string ConditionNodeId, string? Comment, string OperatorUser)) — do NOT reference the OpcUaServer NativeAlarmAck type 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 mechanism ForwardNativeAlarm/the write path uses (Tell the DriverInstanceActor a RouteAlarmAck it turns into driver.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 AlarmCommandRouter today (grep -rn "AlarmCommandRouter =" src --include=*.cs — likely OtOpcUaServerHostedService / a DI/bootstrap seam). Set NativeAlarmAckRouter analogously to a fire-and-forget Tell of the Runtime RouteNativeAlarmAck into the DriverHostActor (resolve its IActorRef the same way the AlarmCommandRouter resolves the ScriptedAlarmHost / mediator).
  • Test: extend the existing wiring/smoke test if one asserts AlarmCommandRouter is 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.