docs(galaxy): Phase B native-alarms implementation plan + tasks

This commit is contained in:
Joseph Doherty
2026-06-14 02:58:12 -04:00
parent 90096e9c00
commit a996f03b5b
2 changed files with 638 additions and 0 deletions
@@ -0,0 +1,622 @@
# Galaxy Phase B — Native Alarms on the Equipment-Tag Path — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task.
**Goal:** A Galaxy equipment `Tag` marked as a native alarm (via its `TagConfig` JSON) materializes a real OPC UA Part 9 `AlarmConditionState` under its equipment folder, and the driver's live `IAlarmSource.OnAlarmEvent` transitions drive that condition (active/severity/message/ack) and fan out to the `alerts` topic — mirroring the scripted-alarm seam. **No EF/schema migration.**
**Architecture:** Three reused layers + one new seam. (1) Alarm intent rides in the schemaless `TagConfig` blob, parsed byte-parity in `Phase7Composer` + `DeploymentArtifact` (`EquipmentTagPlan.Alarm`). (2) `Phase7Applier.MaterialiseEquipmentTags` branches: alarm tag → the existing `MaterialiseAlarmCondition` (reused verbatim), else the existing `EnsureVariable`. (3) A new driver→server alarm seam: `DriverInstanceActor` subscribes `IAlarmSource.OnAlarmEvent` (mirroring its `OnDataChange` subscription) and publishes `AttributeAlarmPublished` to `DriverHostActor`, which projects each transition into the existing `AlarmConditionSnapshot` (`NativeAlarmProjector`) and Tells the unchanged `OpcUaPublishActor.AlarmStateUpdate``OtOpcUaNodeManager.WriteAlarmCondition`, plus a Primary-gated `AlarmTransitionEvent` to `alerts`. A small additive contract change adds the transition `Kind` to `AlarmEventArgs` (the driver already has it; the record's surviving consumers compile via a default).
**Tech Stack:** C#/.NET 10, Akka.NET (fused-host actors, Akka.TestKit.Xunit2 — **xUnit v2**, use `CancellationToken.None` not `TestContext.Current`), xUnit + Shouldly, OPC Foundation UA stack. No bUnit — Razor/live paths proven only by the user-driven docker-dev `/run` gate.
**Design:** `docs/plans/2026-06-14-galaxy-phase-b-native-alarms-design.md` (master `90096e9c`).
**Hard rules (every task):** stage by path, never `git add .`; never stage `sql_login.txt`, `src/Server/ZB.MOM.WW.OtOpcUa.Host/pki/`, `pending.md`, `current.md`, `docker-dev/docker-compose.yml`; never echo secrets; no force-push, no `--no-verify`; **NO Configuration entity / EF migration change**; no bUnit. Commit per task by path.
---
### Task 0: Feature branch
**Classification:** trivial
**Estimated implement time:** ~1 min
**Parallelizable with:** none
**Step 1:** From master at `90096e9c`:
```bash
cd /Users/dohertj2/Desktop/OtOpcUa
git checkout master && git rev-parse --short HEAD # expect 90096e9c
git checkout -b feat/galaxy-phase-b-native-alarms
```
No code change. Do NOT touch the working-tree `docker-dev/docker-compose.yml` or `pending.md`.
---
### Task 1: Transition-kind contract (`AlarmEventArgs.Kind`) + Galaxy populates it — WS-1
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 2
**Files:**
- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs` (add enum `AlarmTransitionKind`; add trailing `Kind` param to `AlarmEventArgs`)
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs:~1128-1167` (`OnAlarmFeedTransition` maps `transition.TransitionKind``Kind`)
- Test: `tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/` (new `AlarmEventArgsTests.cs`)
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/` (extend the existing alarm-feed/transition test, or add `GalaxyAlarmTransitionKindTests.cs`)
**Context:** `GalaxyAlarmTransition` (`Driver.Galaxy/Runtime/GalaxyAlarmTransition.cs`) carries `GalaxyAlarmTransitionKind {Unspecified=0, Raise=1, Acknowledge=2, Clear=3, Retrigger=4}` but `OnAlarmFeedTransition` drops it when building `AlarmEventArgs`. Other `IAlarmSource` implementers (FOCAS/OpcUaClient/AbCip/ScriptedAlarmSource) construct `AlarmEventArgs` without a kind — a record default keeps them compiling untouched.
**Step 1 (failing test — contract):** In `AlarmEventArgsTests.cs`:
```csharp
using Shouldly;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;
public class AlarmEventArgsTests
{
private static AlarmEventArgs Make(AlarmTransitionKind? kind = null) =>
kind is null
? new AlarmEventArgs(new FakeHandle(), "Tank1.Hi", "c1", "LimitAlarm.Hi", "msg", AlarmSeverity.High, DateTime.UnixEpoch)
: new AlarmEventArgs(new FakeHandle(), "Tank1.Hi", "c1", "LimitAlarm.Hi", "msg", AlarmSeverity.High, DateTime.UnixEpoch, Kind: kind.Value);
[Fact]
public void Kind_defaults_to_Unspecified_so_existing_callers_compile()
=> Make().Kind.ShouldBe(AlarmTransitionKind.Unspecified);
[Fact]
public void Kind_round_trips_when_supplied()
=> Make(AlarmTransitionKind.Raise).Kind.ShouldBe(AlarmTransitionKind.Raise);
private sealed class FakeHandle : IAlarmSubscriptionHandle { public string DiagnosticId => "t"; }
}
```
Run: `dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests --filter AlarmEventArgsTests` → FAIL (no `Kind`, no `AlarmTransitionKind`).
**Step 2 (implement contract):** In `IAlarmSource.cs`, add the enum (next to `AlarmSeverity`) and the `Kind` param as the **last** parameter of `AlarmEventArgs` (after `AlarmCategory`):
```csharp
/// <summary>The kind of alarm state change. Mirrors the driver-internal transition kinds so a
/// consumer can derive Part 9 active/ack state without a separate value subscription. Defaults to
/// <see cref="Unspecified"/> so existing <see cref="IAlarmSource"/> implementers compile unchanged.</summary>
public enum AlarmTransitionKind { Unspecified = 0, Raise, Acknowledge, Clear, Retrigger }
```
```csharp
// …existing params…
string? AlarmCategory = null,
AlarmTransitionKind Kind = AlarmTransitionKind.Unspecified);
```
Add a `/// <param name="Kind">…</param>` doc line to the record's XML doc (the project sets `TreatWarningsAsErrors` — a missing `<param>` for a documented record is a build error).
**Step 3 (failing test — Galaxy mapping):** Confirm how the existing Galaxy tests feed a `GalaxyAlarmTransition` onto `OnAlarmEvent` (grep `OnAlarmFeedTransition`/`OnAlarmEvent` in `tests/Drivers/.../Driver.Galaxy.Tests`). Add a test that, for each `GalaxyAlarmTransitionKind`, the surfaced `AlarmEventArgs.Kind` equals the matching `AlarmTransitionKind`. If the test harness can't reach the private `OnAlarmFeedTransition`, extract a tiny `internal static AlarmTransitionKind MapKind(GalaxyAlarmTransitionKind)` and test that directly (mark `Driver.Galaxy` `InternalsVisibleTo` the test project if not already — grep for it first).
Run → FAIL.
**Step 4 (implement Galaxy mapping):** In `GalaxyDriver.OnAlarmFeedTransition`, add `Kind:` to the `new AlarmEventArgs(...)`:
```csharp
Kind: transition.TransitionKind switch
{
GalaxyAlarmTransitionKind.Raise => AlarmTransitionKind.Raise,
GalaxyAlarmTransitionKind.Acknowledge => AlarmTransitionKind.Acknowledge,
GalaxyAlarmTransitionKind.Clear => AlarmTransitionKind.Clear,
GalaxyAlarmTransitionKind.Retrigger => AlarmTransitionKind.Retrigger,
_ => AlarmTransitionKind.Unspecified,
});
```
**Step 5:** `dotnet build src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions` then run both test filters → PASS. Build the full solution (`dotnet build ZB.MOM.WW.OtOpcUa.slnx`) to confirm no other `IAlarmSource` implementer broke.
**Step 6 (commit):**
```bash
git add src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs \
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs \
tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/AlarmEventArgsTests.cs \
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/<the test file>
git commit -m "feat(alarms): carry transition Kind on AlarmEventArgs; Galaxy populates it (Phase B WS-1)"
```
---
### Task 2: Alarm intent in TagConfig → `EquipmentTagPlan.Alarm` (byte-parity) — WS-2
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 1
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs` (`EquipmentTagPlan` record `:80-88`; new `EquipmentTagAlarmInfo`; `Select(...)` `:323-331`; new `ExtractTagAlarm` next to `ExtractTagFullName` `:432`)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs` (`BuildEquipmentTagPlans` `:440-448`; new `ExtractTagAlarm` mirror next to `ExtractTagFullName` `:637`)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/` (new `ExtractTagAlarmTests.cs`; extend the existing composer↔artifact parity test)
**Context — the byte-parity invariant:** `Phase7Composer` (compose from DB rows) and `DeploymentArtifact.BuildEquipmentTagPlans` (decode the deployment artifact JSON) MUST produce identical `EquipmentTagPlan`s for the same input — there is an existing parity test (grep `EquipmentTagPlan` in `OpcUaServer.Tests` for the parity fixture). The `alarm` object rides in the same `TagConfig` blob `FullName` already does, so both sides parse it the same way. Absent/malformed `alarm``null` (plain variable). TagConfig shape:
```json
{ "FullName": "TestMachine_002.HiAlarm", "alarm": { "alarmType": "OffNormalAlarm", "severity": 700 } }
```
`alarmType` default `"AlarmCondition"`, `severity` default `500` (mirrors `ScriptedAlarm` defaults). Valid `alarmType` strings are whatever `OtOpcUaNodeManager.CreateAlarmConditionOfType` accepts (`OffNormalAlarm`/`DiscreteAlarm`/`LimitAlarm`/`AlarmCondition`); an unknown string falls back to the base type downstream — do not validate here.
**Step 1 (failing test — `ExtractTagAlarm`):** In `ExtractTagAlarmTests.cs` (test the composer's private helper via `[InternalsVisibleTo]` if present, else assert through `ComposeAsync`/the plan; prefer a thin `internal static` so both helpers can be unit-tested):
```csharp
[Theory]
[InlineData("{\"FullName\":\"X.Y\"}", false, null, 0)] // no alarm ⇒ null
[InlineData("{\"FullName\":\"X.Y\",\"alarm\":{}}", true, "AlarmCondition", 500)] // defaults
[InlineData("{\"FullName\":\"X.Y\",\"alarm\":{\"alarmType\":\"OffNormalAlarm\",\"severity\":700}}", true, "OffNormalAlarm", 700)]
[InlineData("not json", false, null, 0)] // malformed ⇒ null
[InlineData("{\"FullName\":\"X.Y\",\"alarm\":\"oops\"}", false, null, 0)] // wrong kind ⇒ null
public void ExtractTagAlarm_parses_or_returns_null(string cfg, bool present, string? type, int sev)
{
var info = Phase7Composer.ExtractTagAlarm(cfg); // make it internal static
if (!present) { info.ShouldBeNull(); return; }
info!.AlarmType.ShouldBe(type);
info.Severity.ShouldBe(sev);
}
```
Run → FAIL.
**Step 2 (implement `EquipmentTagAlarmInfo` + `EquipmentTagPlan.Alarm`):** In `Phase7Composer.cs`, next to `EquipmentTagPlan`:
```csharp
/// <summary>Native-alarm intent parsed from an equipment tag's <c>TagConfig.alarm</c> object. Null ⇒
/// the tag is a plain value variable. <see cref="AlarmType"/> is an OPC UA Part 9 subtype string
/// (OffNormalAlarm/DiscreteAlarm/LimitAlarm/AlarmCondition); <see cref="Severity"/> is the 1..1000 scale.</summary>
public sealed record EquipmentTagAlarmInfo(string AlarmType, int Severity);
```
Add `EquipmentTagAlarmInfo? Alarm` as the **last** field of `EquipmentTagPlan` (after `Writable`). Update the record's XML doc with a `<see cref="Alarm"/>` sentence.
**Step 3 (implement `ExtractTagAlarm` — composer):** Next to `ExtractTagFullName`:
```csharp
/// <summary>Parses the optional <c>alarm</c> object from a tag's <c>TagConfig</c> JSON. Returns null
/// when absent, non-object, or non-JSON (the tag is then a plain variable). Never throws. The
/// artifact-decode side (<c>DeploymentArtifact.ExtractTagAlarm</c>) MUST parse identically (byte-parity).</summary>
internal static EquipmentTagAlarmInfo? ExtractTagAlarm(string? tagConfig)
{
if (string.IsNullOrWhiteSpace(tagConfig)) return null;
try
{
using var doc = JsonDocument.Parse(tagConfig);
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
if (!doc.RootElement.TryGetProperty("alarm", out var a) || a.ValueKind != JsonValueKind.Object) return null;
var type = a.TryGetProperty("alarmType", out var tEl) && tEl.ValueKind == JsonValueKind.String
? (tEl.GetString() ?? "AlarmCondition") : "AlarmCondition";
var sev = a.TryGetProperty("severity", out var sEl) && sEl.ValueKind == JsonValueKind.Number
? sEl.GetInt32() : 500;
return new EquipmentTagAlarmInfo(type, sev);
}
catch (JsonException) { return null; }
}
```
Wire it into the `Select(...)` at `:331` (add after `Writable:`):
```csharp
Writable: t.AccessLevel == TagAccessLevel.ReadWrite,
Alarm: ExtractTagAlarm(t.TagConfig)))
```
**Step 4 (implement `ExtractTagAlarm` — artifact mirror, byte-identical):** In `DeploymentArtifact.cs`, add a private `ExtractTagAlarm(string? tagConfig)` with the **same body** (it constructs the same `EquipmentTagAlarmInfo` — Runtime already references the assembly defining `EquipmentTagPlan`, so the type is in scope). Wire into `BuildEquipmentTagPlans` `:448`:
```csharp
Writable: writable,
Alarm: ExtractTagAlarm(tagConfig)));
```
**Step 5 (failing test — parity):** Extend the existing composer↔artifact parity fixture with an alarm-bearing equipment tag (TagConfig carrying the `alarm` object) and assert the composed `EquipmentTagPlan.Alarm` equals the artifact-decoded one (record equality covers it). Run the parity test → it now exercises `Alarm`.
**Step 6:** `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests --filter "ExtractTagAlarm|Parity"` → PASS. Full build clean.
**Step 7 (commit):**
```bash
git add src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs \
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs \
tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ExtractTagAlarmTests.cs \
tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/<parity fixture file>
git commit -m "feat(alarms): EquipmentTagPlan.Alarm parsed byte-parity from TagConfig (Phase B WS-2)"
```
---
### Task 3: Materialize a condition node for an alarm tag — WS-3
**Classification:** standard
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 4, Task 5
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs:186-193` (`MaterialiseEquipmentTags` variable loop)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/` (extend the Phase7Applier materialise tests)
**Context:** `MaterialiseEquipmentTags` currently calls `SafeEnsureVariable(...)` for every tag (`:192`). `SafeMaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity)` already exists (`:309`) — the same one scripted alarms use. An alarm tag becomes a **condition node only** (not also a variable). The condition NodeId = the tag's folder-scoped NodeId (`EquipmentNodeIds.Variable(...)`, same formula). Use the **sub-folder** as the condition's parent when `FolderPath` is set (the variable loop already computes `parent`).
**Step 1 (failing test):** With the existing test sink (grep the materialise tests for the fake/recording `IOpcUaAddressSpaceSink`), feed a composition whose `EquipmentTags` contains one plain tag and one `Alarm != null` tag; assert the plain one called `EnsureVariable` and the alarm one called `MaterialiseAlarmCondition` (with the matching nodeId/type/severity) and did **not** call `EnsureVariable`. Run → FAIL.
**Step 2 (implement the branch):** Replace the `SafeEnsureVariable(...)` call at `:192` with:
```csharp
if (tag.Alarm is not null)
{
// Native alarm tag → a real Part 9 condition node (reuses the scripted-alarm path),
// NOT a value variable. Parent is the sub-folder when set, else the equipment folder.
SafeMaterialiseAlarmCondition(nodeId, parent, tag.Name, tag.Alarm.AlarmType, tag.Alarm.Severity);
}
else
{
SafeEnsureVariable(nodeId, parent, tag.Name, tag.DataType, tag.Writable);
}
```
**Step 3:** `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests --filter MaterialiseEquipmentTags` → PASS.
**Step 4 (commit):**
```bash
git add src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs \
tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/<applier test file>
git commit -m "feat(alarms): materialise a Part 9 condition for an alarm equipment tag (Phase B WS-3)"
```
---
### Task 4: `NativeAlarmProjector` (transition → snapshot) — WS-4a
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 3, Task 5
**Files:**
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/NativeAlarmProjector.cs`
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmProjectorTests.cs`
**Context:** `AlarmEventArgs` is a delta (raise/clear/ack). `AlarmConditionSnapshot` (`Commons/OpcUa/AlarmConditionSnapshot.cs`: `Active, Acknowledged, Confirmed, Enabled, Shelving, Severity(ushort), Message`) is the full state `WriteAlarmCondition` wants. The projector keeps per-condition-NodeId prior `(Active, Acked, Severity, Message)` and derives the next snapshot from `Kind`. It lives in Runtime (references both `Core.Abstractions` and `Commons`). It is owned by the single-threaded `DriverHostActor` → a plain `Dictionary` is safe (no locking).
**Severity map** (`AlarmSeverity` 4-bucket → ushort 1..1000): Low→200, Medium→500, High→700, Critical→900.
**Kind → snapshot:**
| Kind | Active | Acknowledged | Severity/Message |
|---|---|---|---|
| Raise / Retrigger | true | false | from event |
| Acknowledge | prior | true | from event (keep prior severity if event is a pure ack — use event severity, fine) |
| Clear | false | prior | from event |
| Unspecified | prior | prior | from event |
Constant fields: `Enabled=true`, `Confirmed=true`, `Shelving=Unshelved`.
**Step 1 (failing tests):**
```csharp
using Shouldly;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
public class NativeAlarmProjectorTests
{
private static AlarmEventArgs Evt(AlarmTransitionKind kind, AlarmSeverity sev = AlarmSeverity.High, string msg = "m")
=> new(new H(), "Tank1.Hi", "c1", "LimitAlarm.Hi", msg, sev, DateTime.UnixEpoch, Kind: kind);
[Fact]
public void Raise_is_active_and_unacked()
{
var s = new NativeAlarmProjector().Project("n1", Evt(AlarmTransitionKind.Raise));
s.Active.ShouldBeTrue(); s.Acknowledged.ShouldBeFalse();
s.Severity.ShouldBe((ushort)700); s.Enabled.ShouldBeTrue();
s.Shelving.ShouldBe(AlarmShelvingKind.Unshelved);
}
[Fact]
public void Acknowledge_sets_acked_and_keeps_prior_active()
{
var p = new NativeAlarmProjector();
p.Project("n1", Evt(AlarmTransitionKind.Raise));
var s = p.Project("n1", Evt(AlarmTransitionKind.Acknowledge));
s.Active.ShouldBeTrue(); s.Acknowledged.ShouldBeTrue();
}
[Fact]
public void Clear_deactivates_and_keeps_prior_ack()
{
var p = new NativeAlarmProjector();
p.Project("n1", Evt(AlarmTransitionKind.Raise));
var s = p.Project("n1", Evt(AlarmTransitionKind.Clear));
s.Active.ShouldBeFalse(); s.Acknowledged.ShouldBeFalse();
}
[Fact]
public void State_is_isolated_per_nodeId()
{
var p = new NativeAlarmProjector();
p.Project("n1", Evt(AlarmTransitionKind.Raise));
var s2 = p.Project("n2", Evt(AlarmTransitionKind.Clear)); // cold n2: clear from default-inactive
s2.Active.ShouldBeFalse();
}
private sealed class H : IAlarmSubscriptionHandle { public string DiagnosticId => "t"; }
}
```
Run: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests --filter NativeAlarmProjector` → FAIL.
**Step 2 (implement):**
```csharp
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
/// <summary>
/// Derives a full Part 9 <see cref="AlarmConditionSnapshot"/> from each native
/// <see cref="AlarmEventArgs"/> delta, tracking per-condition-NodeId prior state. Owned by the
/// single-threaded <c>DriverHostActor</c> (no locking). Native alarms carry only a transition
/// <see cref="AlarmTransitionKind"/>, not a full state machine, so this is the translation the
/// scripted-alarm engine does internally.
/// </summary>
public sealed class NativeAlarmProjector
{
private readonly Dictionary<string, (bool Active, bool Acked, ushort Severity, string Message)> _prior =
new(StringComparer.Ordinal);
/// <summary>Project an alarm transition onto the full condition snapshot for <paramref name="nodeId"/>.</summary>
public AlarmConditionSnapshot Project(string nodeId, AlarmEventArgs e)
{
var prev = _prior.TryGetValue(nodeId, out var p) ? p : (Active: false, Acked: true, Severity: (ushort)0, Message: string.Empty);
var sev = MapSeverity(e.Severity);
var (active, acked) = e.Kind switch
{
AlarmTransitionKind.Raise or AlarmTransitionKind.Retrigger => (true, false),
AlarmTransitionKind.Acknowledge => (prev.Active, true),
AlarmTransitionKind.Clear => (false, prev.Acked),
_ => (prev.Active, prev.Acked),
};
_prior[nodeId] = (active, acked, sev, e.Message);
return new AlarmConditionSnapshot(
Active: active, Acknowledged: acked, Confirmed: true, Enabled: true,
Shelving: AlarmShelvingKind.Unshelved, Severity: sev, Message: e.Message);
}
/// <summary>Clears tracked state (call on address-space rebuild).</summary>
public void Clear() => _prior.Clear();
private static ushort MapSeverity(AlarmSeverity s) => s switch
{
AlarmSeverity.Low => 200, AlarmSeverity.Medium => 500,
AlarmSeverity.High => 700, AlarmSeverity.Critical => 900, _ => 500,
};
}
```
**Step 3:** Run the filter → PASS.
**Step 4 (commit):**
```bash
git add src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/NativeAlarmProjector.cs \
tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmProjectorTests.cs
git commit -m "feat(alarms): NativeAlarmProjector maps transitions to condition snapshots (Phase B WS-4a)"
```
---
### Task 5: `DriverInstanceActor` subscribes `OnAlarmEvent` + publishes `AttributeAlarmPublished` — WS-4b
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 3, Task 4
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs` (msg defs `:63-66`; new fields; `AttachAlarmSource`/`DetachAlarmSource`; `Receive<NativeAlarmRaised>`; wire attach on Connected-entry, detach in `DetachSubscription` + `PostStop`)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/` (new `DriverInstanceActorNativeAlarmTests.cs`)
**Context — mirror the `OnDataChange` pattern exactly** (`:408-409` attach, `:452-460` `DetachSubscription`): the driver fires `OnAlarmEvent` on its own thread → marshal to the actor thread via `self.Tell(...)`. Galaxy's alarm feed auto-starts in `InitializeAsync` and fires `OnAlarmEvent` independent of `SubscribeAlarmsAsync`, so the actor only subscribes the C# event (no `SubscribeAlarmsAsync` call — that's a deferred follow-up for drivers that gate on it). The server filters by `SourceNodeId` downstream (unknown refs drop), so forward every transition.
**Step 1 (failing test, Akka.TestKit — xUnit v2):**
```csharp
// Fake driver implements IDriver + ISubscribable + IAlarmSource; exposes RaiseAlarm(args).
// Spawn the actor, drive it to Connected, then fakeDriver.RaiseAlarm(...) and
// ExpectMsg<DriverInstanceActor.AttributeAlarmPublished> on a probe wired as the parent.
// Use CancellationToken.None (NOT TestContext.Current — this is xUnit v2 / Akka.TestKit.Xunit2).
```
Model it on the existing `DriverInstanceActor` subscribe tests (grep `AttributeValuePublished` in `Runtime.Tests/Drivers`). Assert `AttributeAlarmPublished.DriverInstanceId` + `.Args.SourceNodeId` match. Run → FAIL.
**Step 2 (implement messages):** After `AttributeValuePublished` (`:65`):
```csharp
/// <summary>Published to the parent whenever the subscribed driver (an <see cref="IAlarmSource"/>) fires
/// <see cref="IAlarmSource.OnAlarmEvent"/>. The parent (<see cref="DriverHostActor"/>) projects + routes it
/// to the materialised Part 9 condition. Parallels <see cref="AttributeValuePublished"/>.</summary>
public sealed record AttributeAlarmPublished(string DriverInstanceId, AlarmEventArgs Args);
private sealed record NativeAlarmRaised(AlarmEventArgs Args);
```
Add fields near `_dataChangeHandler`:
```csharp
private EventHandler<AlarmEventArgs>? _alarmEventHandler;
```
**Step 3 (implement attach/detach + receive):**
```csharp
/// <summary>Subscribe the driver's native alarm event (if it is an <see cref="IAlarmSource"/>),
/// marshaling each transition to the actor thread. Idempotent; mirrors the OnDataChange attach.</summary>
private void AttachAlarmSource()
{
if (_driver is not IAlarmSource src || _alarmEventHandler is not null) return;
var self = Self;
_alarmEventHandler = (_, e) => self.Tell(new NativeAlarmRaised(e));
src.OnAlarmEvent += _alarmEventHandler;
}
/// <summary>Symmetric teardown — called from <see cref="DetachSubscription"/> and PostStop so a stale
/// handler never pushes to a disconnected actor.</summary>
private void DetachAlarmSource()
{
if (_driver is IAlarmSource src && _alarmEventHandler is not null)
src.OnAlarmEvent -= _alarmEventHandler;
_alarmEventHandler = null;
}
```
Register in the `Connected()` behavior (next to `Receive<DataChangeForward>(OnDataChangeForward)` at `:275`):
```csharp
Receive<NativeAlarmRaised>(m => Context.Parent.Tell(new AttributeAlarmPublished(_driverInstanceId, m.Args)));
```
Call `AttachAlarmSource()` on every Connected entry — the cleanest single site is right where `ResubscribeDesired()` is called after `Become(Connected)` (both the first-connect `InitializeSucceeded` path **and** the `Reconnecting` `InitializeSucceeded` at `:295-302`). Add `AttachAlarmSource();` immediately after each `Become(Connected);`. Add `DetachAlarmSource();` inside `DetachSubscription()` (`:452`, so it fires on Connected→Reconnecting + Unsubscribe + PostStop, which all route through it — verify PostStop calls `DetachSubscription`; if not, add `DetachAlarmSource()` to `PostStop` too).
**Step 4:** Run the filter → PASS. Confirm no double-attach (the `_alarmEventHandler is not null` guard) and that a teardown→reconnect re-attaches cleanly (add an assertion that a second Connected entry still delivers exactly one `AttributeAlarmPublished` per raise).
**Step 5 (commit):**
```bash
git add src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs \
tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorNativeAlarmTests.cs
git commit -m "feat(alarms): DriverInstanceActor forwards native OnAlarmEvent to parent (Phase B WS-4b)"
```
---
### Task 6: `DriverHostActor` alarm map + `ForwardNativeAlarm` → live condition — WS-4c
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs` (new `_alarmNodeIdByDriverRef` map + `_nativeAlarmProjector` field `:99`; build in `PushDesiredSubscriptions` `:748-763`, branching on `Alarm != null`; `Receive<AttributeAlarmPublished>` in the two behaviors that hold `Receive<AttributeValuePublished>`; new `ForwardNativeAlarm`)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/` (new `DriverHostActorNativeAlarmTests.cs`)
**Context:** `ForwardToMux` (`:440-467`) is the value analogue. The value maps (`_nodeIdByDriverRef`, `_driverRefByNodeId`) + `refsByDriver` (value subscription) are built in `PushDesiredSubscriptions` (`:734-763`). Alarm tags must be **excluded** from the value maps + value subscription (they're conditions, not variables) and routed via a parallel alarm map. An alarm's `AlarmEventArgs.SourceNodeId` equals the tag's `FullName`, so the alarm map keys on the same `(DriverInstanceId, FullName)`.
**Step 1 (failing test, Akka.TestKit):** Spawn `DriverHostActor` with a wired `_opcUaPublishActor` probe; apply a composition (or call the SubscribeBulk path) containing one alarm-bearing `EquipmentTag`; send `AttributeAlarmPublished(driverId, raiseEvent)`; `ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>` at the alarm tag's NodeId with `State.Active == true`. Second test: an `AttributeAlarmPublished` for an unknown ref produces **no** `AlarmStateUpdate`. Model on the existing `DriverHostActor` value-routing tests (grep `AttributeValueUpdate`/`_nodeIdByDriverRef` in `Runtime.Tests`). Run → FAIL.
**Step 2 (fields):** At `:99` add:
```csharp
/// <summary>(DriverInstanceId, FullName=alarm SourceNodeId) → folder-scoped condition NodeId(s). Built
/// from EquipmentTags whose plan carries Alarm, alongside the value maps; resolves native alarm
/// transitions to their materialised Part 9 condition node(s).</summary>
private readonly Dictionary<(string DriverInstanceId, string FullName), HashSet<string>> _alarmNodeIdByDriverRef = new();
private readonly NativeAlarmProjector _nativeAlarmProjector = new();
```
**Step 3 (build the map — `PushDesiredSubscriptions`):** In the `EquipmentTags` loop (`:755-763`), branch so alarm tags go ONLY into `_alarmNodeIdByDriverRef` and are kept OUT of the value maps; and exclude alarm refs from `refsByDriver` (the value-subscription set at `:734-741`). Concretely:
- Add `_alarmNodeIdByDriverRef.Clear();` next to the other `Clear()`s.
- In the `foreach (var t in composition.EquipmentTags)` loop:
```csharp
var key = (t.DriverInstanceId, t.FullName);
var nodeId = EquipmentNodeIds.Variable(t.EquipmentId, t.FolderPath, t.Name);
if (t.Alarm is not null)
{
if (!_alarmNodeIdByDriverRef.TryGetValue(key, out var aset))
_alarmNodeIdByDriverRef[key] = aset = new HashSet<string>(StringComparer.Ordinal);
aset.Add(nodeId);
continue; // alarm tags are conditions, not value variables
}
// …existing value-map population (unchanged)…
```
- For `refsByDriver` (`:734-741`), add `.Where(t => t.Alarm is null)` before the `GroupBy` so the driver doesn't value-subscribe alarm attributes.
- Reset projector state on rebuild: call `_nativeAlarmProjector.Clear();` alongside the map clears (the condition nodes are torn down + rebuilt each apply, so prior state must not leak).
**Step 4 (`ForwardNativeAlarm` + receives):** Add the handler (model on `ForwardToMux`):
```csharp
private void ForwardNativeAlarm(DriverInstanceActor.AttributeAlarmPublished msg)
{
if (_opcUaPublishActor is null) return;
if (!_alarmNodeIdByDriverRef.TryGetValue((msg.DriverInstanceId, msg.Args.SourceNodeId), out var nodeIds))
{
_log.Debug("DriverHost {Node}: no alarm condition for ({Driver},{Ref}) — transition dropped",
_localNode, msg.DriverInstanceId, msg.Args.SourceNodeId);
return;
}
foreach (var nodeId in nodeIds)
{
var snapshot = _nativeAlarmProjector.Project(nodeId, msg.Args);
_opcUaPublishActor.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.AlarmStateUpdate(
nodeId, snapshot, msg.Args.SourceTimestampUtc));
}
}
```
Register `Receive<DriverInstanceActor.AttributeAlarmPublished>(ForwardNativeAlarm);` immediately after **each** `Receive<DriverInstanceActor.AttributeValuePublished>(ForwardToMux);` (there are two — the steady + applying behaviors; grep `ForwardToMux` to find both registration sites).
**Step 5:** Run the filter → PASS. Full build clean.
**Step 6 (commit):**
```bash
git add src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs \
tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmTests.cs
git commit -m "feat(alarms): DriverHostActor routes native alarm transitions to Part 9 conditions (Phase B WS-4c)"
```
---
### Task 7: Primary-gated `AlarmTransitionEvent` fan-out to `alerts` — WS-5
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs` (alarm-meta map; mediator publish in `ForwardNativeAlarm`, Primary-gated via `_localRole`)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmTests.cs` (extend)
**Context:** `ScriptedAlarmHostActor.OnEngineEmission` (`:289-315`) publishes `AlarmTransitionEvent` to the `alerts` topic (consumed by `HistorianAdapterActor` + AdminUI `/alerts`), **Primary-gated** (suppress on `Secondary`/`Detached`). DriverHostActor already holds `_localRole` (`:121`, same gate it uses for `RouteNodeWrite`) and a DistributedPubSub mediator (it subscribes `RedundancyStateTopic` at `:265`). Reuse both. The event needs `EquipmentPath`/`AlarmName`/`AlarmTypeName` per condition node → add a small meta map built in the same `PushDesiredSubscriptions` pass. Grep `AlertsTopic` + `AlarmTransitionEvent` (Commons `Messages/Alerts/`) and `ScriptedAlarmHostActor` for the exact topic constant, the mediator field name, and the `Publish` usage to copy.
**Step 1 (failing test):** Drive the host to `_localRole = Secondary` (send the `RedundancyStateChanged` it consumes; grep the existing redundancy-gate test for `RouteNodeWrite`/"not primary" to copy the setup) → an `AttributeAlarmPublished` still Tells `AlarmStateUpdate` (condition stays warm) but publishes **no** `AlarmTransitionEvent`. With `_localRole = Primary` (or unset) → it publishes one. Run → FAIL.
**Step 2 (implement meta map):** Add `private readonly Dictionary<string, (string EquipmentId, string Name, string AlarmType)> _alarmMetaByNodeId = new(StringComparer.Ordinal);`; populate it in the alarm branch of the `PushDesiredSubscriptions` loop (Task 6 step 3): `_alarmMetaByNodeId[nodeId] = (t.EquipmentId, t.Name, t.Alarm.AlarmType);` and `Clear()` it with the others.
**Step 3 (implement gated publish):** In `ForwardNativeAlarm`, after the `AlarmStateUpdate` Tell (inside the `foreach`), append the Primary-gated publish, mirroring `ScriptedAlarmHostActor`:
```csharp
if (_localRole is RedundancyRole.Secondary or RedundancyRole.Detached) continue; // warm-standby dedup
var meta = _alarmMetaByNodeId.TryGetValue(nodeId, out var m) ? m : (EquipmentId: nodeId, Name: nodeId, AlarmType: "AlarmCondition");
_mediator.Tell(new Publish(AlertsTopic, new AlarmTransitionEvent(
AlarmId: nodeId,
EquipmentPath: meta.EquipmentId,
AlarmName: meta.Name,
TransitionKind: msg.Args.Kind.ToString(),
Severity: snapshot.Severity,
Message: msg.Args.Message,
User: msg.Args.OperatorComment is null ? string.Empty : "device",
TimestampUtc: msg.Args.SourceTimestampUtc,
AlarmTypeName: meta.AlarmType,
Comment: msg.Args.OperatorComment,
HistorizeToAveva: true)));
```
Use the exact `AlarmTransitionEvent` constructor argument list discovered by grep (adjust names/order if they differ; the historian + `/alerts` consumers already handle this shape from scripted alarms). Resolve the mediator handle the same way the existing redundancy subscription does (the field used at `:265`).
**Step 4:** Run the filter → PASS. Full build + `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests` green.
**Step 5 (commit):**
```bash
git add src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs \
tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmTests.cs
git commit -m "feat(alarms): Primary-gated AlarmTransitionEvent fan-out for native alarms (Phase B WS-5)"
```
---
### Task 8: Document the `TagConfig` alarm schema — Docs
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 3, Task 4, Task 5
**Files:**
- Modify: `docs/ScriptedAlarms.md` (add a "Native driver alarms (equipment-tag path)" section) — or `docs/AlarmTracking.md` if that's the better home; pick whichever already covers condition materialization.
**Step 1:** Add a section documenting: a native driver alarm (Galaxy) is an authored equipment `Tag` whose `TagConfig` carries an `alarm` object — `{"FullName":"tag.attr","alarm":{"alarmType":"OffNormalAlarm","severity":700}}`; absent ⇒ plain variable; `alarmType` ∈ {AlarmCondition, OffNormalAlarm, DiscreteAlarm, LimitAlarm} (default AlarmCondition), `severity` 1..1000 (default 500). Note it materializes a Part 9 condition (not a variable) under the equipment folder, fed by the driver's live `IAlarmSource.OnAlarmEvent`; transitions fan out to `/alerts` + the historian (Primary-gated). State the two deferred follow-ups: inbound device-ack (client Acknowledge → AVEVA) and the AdminUI Galaxy-picker pre-fill.
**Step 2 (commit):**
```bash
git add docs/ScriptedAlarms.md
git commit -m "docs(alarms): native driver-alarm TagConfig schema (Phase B)"
```
---
### Task 9: Live docker-dev `/run` verification (user-driven)
**Classification:** verification
**Estimated implement time:** n/a (user drives; the agent does NOT sign in)
**Parallelizable with:** none
**Gate (the design's live-verify):** On the live-gateway-backed `MAIN-galaxy-eq` (dev rig is LOCAL on this Mac — OrbStack; central-1 @ `localhost:4840`, deploy/AdminUI @ `localhost:9200`, sql @ `localhost:14330`; the Galaxy gateway needs the ephemeral `GALAXY_MXGW_API_KEY` re-exported on container recreate — see `pending.md` Galaxy dev-rig note):
1. Rebuild central on the branch: `docker compose -f docker-dev/docker-compose.yml up -d --build migrator central-1 central-2` (re-export `GALAXY_MXGW_API_KEY=…` on the recreate).
2. Author a Galaxy alarm equipment tag on `EQ-55297329838d` whose `TagConfig` carries the `alarm` object pointing at a real galaxy alarm reference (a `TestMachine_002` attribute with an alarm extension, or the seeded `TestMachine_001.TestAlarm00x`). Deploy: `POST http://localhost:9200/api/deployments`, header `X-Api-Key: docker-dev-deploy-key`. **Order:** deploy FIRST, then recreate central if the driver is faulted (a faulted driver ignores `ApplyDelta`).
3. Trip the Galaxy alarm. Confirm via Client.CLI `alarms`/`read` against `opc.tcp://localhost:4840` that a Part 9 `AlarmConditionState` appears **active** under the equipment NodeId; confirm the AdminUI `/alerts` row appears. Clear → condition goes inactive.
4. (Device-ack round-trip is the deferred follow-up — NOT part of this gate.)
**On pass:** finish via `superpowers-extended-cc:finishing-a-development-branch` (intent: merge-to-master + push). Update `pending.md` (disk-only) marking Phase B done; refresh the memory.
---
## Execution order / dependency summary
```
T0 ─┬─ T1 (contract) ─┬─ T4 (projector) ─┐
│ └─ T5 (instance actor) ─┐
└─ T2 (plan field) ─┬─ T3 (applier) ├─ T6 (host live) ── T7 (host alerts) ── T9 (live)
└─ T8 (docs) ┘
```
- **Parallel batch A** (after T0): T1 ∥ T2.
- **Parallel batch B** (T1+T2 done): T3 ∥ T4 ∥ T5 ∥ T8 (all disjoint files).
- **Serial tail:** T6 (needs T2+T4+T5) → T7 (same file) → T9.
@@ -0,0 +1,16 @@
{
"planPath": "docs/plans/2026-06-14-galaxy-phase-b-native-alarms-plan.md",
"tasks": [
{"id": "367", "subject": "Task 0: Feature branch feat/galaxy-phase-b-native-alarms", "classification": "trivial", "status": "pending"},
{"id": "368", "subject": "Task 1 (WS-1): AlarmEventArgs.Kind contract + Galaxy populates it", "classification": "standard", "status": "pending", "blockedBy": ["367"], "parallelizableWith": ["369"]},
{"id": "369", "subject": "Task 2 (WS-2): EquipmentTagPlan.Alarm parsed byte-parity from TagConfig", "classification": "high-risk", "status": "pending", "blockedBy": ["367"], "parallelizableWith": ["368"]},
{"id": "370", "subject": "Task 3 (WS-3): Materialise a Part 9 condition for an alarm tag", "classification": "standard", "status": "pending", "blockedBy": ["369"], "parallelizableWith": ["371", "372", "375"]},
{"id": "371", "subject": "Task 4 (WS-4a): NativeAlarmProjector (transition -> snapshot)", "classification": "standard", "status": "pending", "blockedBy": ["368"], "parallelizableWith": ["370", "372", "375"]},
{"id": "372", "subject": "Task 5 (WS-4b): DriverInstanceActor forwards native OnAlarmEvent", "classification": "high-risk", "status": "pending", "blockedBy": ["368"], "parallelizableWith": ["370", "371", "375"]},
{"id": "373", "subject": "Task 6 (WS-4c): DriverHostActor routes native alarms to conditions", "classification": "high-risk", "status": "pending", "blockedBy": ["369", "371", "372"]},
{"id": "374", "subject": "Task 7 (WS-5): Primary-gated AlarmTransitionEvent fan-out", "classification": "high-risk", "status": "pending", "blockedBy": ["373"]},
{"id": "375", "subject": "Task 8 (Docs): Native driver-alarm TagConfig schema", "classification": "small", "status": "pending", "blockedBy": ["369"], "parallelizableWith": ["370", "371", "372"]},
{"id": "376", "subject": "Task 9: Live docker-dev /run verification (user-driven)", "classification": "verification", "status": "pending", "blockedBy": ["374", "375"]}
],
"lastUpdated": "2026-06-14"
}