docs(alarms): Phase 3 implementation plan + tasks (H4 + H2-bit + H6)
This commit is contained in:
@@ -0,0 +1,417 @@
|
||||
# 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 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<<0` …
|
||||
`MethodCall=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`/`OnTimedUnshelve` → `HandleAlarmCommand`). `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**
|
||||
```bash
|
||||
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
|
||||
`OnEnableDisable` → `BadNotSupported`, no route. (Uses Task 2's `_nativeAlarmNodeIds`.)
|
||||
|
||||
**Step 2: Run, expect FAIL.**
|
||||
|
||||
**Step 3: Implement.** In `MaterialiseAlarmCondition`, after the `OnShelve`/`OnTimedUnshelve` wiring:
|
||||
```csharp
|
||||
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:false` → `AlarmCommandRouter` 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:
|
||||
```csharp
|
||||
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.
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-06-15-stillpending-phase-3-opcua-standards.md",
|
||||
"branch": "feat/stillpending-phase-3-opcua-standards",
|
||||
"tasks": [
|
||||
{"id": 423, "subject": "P3 Task 1: H2-bit — NodePermissions.HistoryUpdate + evaluator mapping", "status": "pending"},
|
||||
{"id": 424, "subject": "P3 Task 2: H6a — mark native conditions (isNative through the sink)", "status": "pending"},
|
||||
{"id": 425, "subject": "P3 Task 3: H4 — wire OnEnableDisable over OPC UA", "status": "pending", "blockedBy": [424]},
|
||||
{"id": 426, "subject": "P3 Task 4: H6b — AlarmAcknowledgeRequest.OperatorUser + Galaxy/ScriptedAlarmSource", "status": "pending"},
|
||||
{"id": 427, "subject": "P3 Task 5: H6c — NativeAlarmAckRouter seam + native OnAcknowledge routing", "status": "pending", "blockedBy": [424]},
|
||||
{"id": 428, "subject": "P3 Task 6: H6d — DriverHostActor inverse map + native-ack route to driver", "status": "pending", "blockedBy": [426, 427]},
|
||||
{"id": 429, "subject": "P3 Task 7: H6e — wire NativeAlarmAckRouter in host DI", "status": "pending", "blockedBy": [427, 428]},
|
||||
{"id": 430, "subject": "P3 Task 8: Docs — Enable/Disable + native-ack→AVEVA + HistoryUpdate bit", "status": "pending"},
|
||||
{"id": 431, "subject": "P3 Task 9: Full build + test + final integration review", "status": "pending", "blockedBy": [423, 425, 429, 430]},
|
||||
{"id": 432, "subject": "P3 Task 10: Live /run — H4 Enable/Disable + H6 native-ack route", "status": "pending", "blockedBy": [431]}
|
||||
],
|
||||
"lastUpdated": "2026-06-15"
|
||||
}
|
||||
Reference in New Issue
Block a user