Files
lmxopcua/docs/plans/2026-06-10-script-log-and-scripted-alarm-runtime.md
T

464 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Script-log Engine Emit + Scripted-Alarm Runtime — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: use superpowers-extended-cc:subagent-driven-development
> (or executing-plans) to implement this plan task-by-task.
**Goal:** Make the Script-log page tail real script output, and stand up scripted
alarms end-to-end including real OPC UA Part 9 condition nodes + client ack.
**Architecture:** Three sequenced layers off one shared seam (a root script logger
fanning to file + companion + a new DPS topic sink). Layer 0 = emit (F8 live). Layer 1
= F9 engine runtime on the Akka equipment-namespace runtime. Layer 2 = F14b real Part 9
nodes + events + inbound ack. Design: `docs/plans/2026-06-10-script-log-and-scripted-alarm-runtime-design.md`.
Verified gap analysis: `pending.md`.
**Tech:** .NET 10, Akka.NET, EF Core (SQL prod / InMemory tests), Serilog, OPC
Foundation UA .NET Standard, xUnit + Shouldly, Akka TestKit. No bUnit.
**Hard rules (every task):** stage by explicit path — never `git add .`; never stage
`sql_login.txt` or `src/Server/ZB.MOM.WW.OtOpcUa.Host/pki/`; never echo the gateway API
key into a new tracked file; no force-push, no `--no-verify`. **No Configuration entity
/ EF migration change** (`ScriptedAlarmState` table already exists). Agent does **not**
sign in to the AdminUI — the user drives live `/run`.
**Branch:** `feat/scriptlog-alarm-runtime` off `master @ df4c2657` (design committed there).
**Reference patterns to mirror:** `VirtualTagHostActor` (host actor shape),
`EfAlarmActorStateStore` (EF store shape), the `{{equip}}` two-seam parity work
(`Phase7Composer``DeploymentArtifact`), `RoslynVirtualTagEvaluator` (evaluator).
---
# LAYER 0 — Shared script-log emit + F8 live
### Task 0: Branch + test-project check
**Classification:** small · **~2 min** · **Parallelizable with:** none
**Files:** none created (branch + verification only)
**Steps:**
1. `git switch -c feat/scriptlog-alarm-runtime` (off `master @ df4c2657`).
2. Confirm `tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/` exists and is in the
`.slnx` (it does — `ScriptLoggerFactoryTests.cs` lives there). New Layer-0 tests land
here. Confirm `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/` for Layer-1 tests.
3. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` — green baseline. Commit nothing.
---
### Task 1: `IScriptLogPublisher` + `ScriptLogTopicSink`
**Classification:** standard · **~4 min** · **Parallelizable with:** none
**Files:**
- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/IScriptLogPublisher.cs`
- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptLogTopicSink.cs`
- Test: `tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ScriptLogTopicSinkTests.cs`
- Maybe modify: `Core.Scripting.csproj` (add ProjectReference to `Commons` for
`ScriptLogEntry` if not already referenced — verify first).
**Step 1 — failing tests** (`ScriptLogTopicSinkTests`):
- A `LogEvent` (Information) with properties `ScriptId="S1"`, `VirtualTagId="V1"`,
`EquipmentId="EQ1"`, message `"hello"` → publisher receives one `ScriptLogEntry` with
those fields, `Level=="Information"`, `Message=="hello"`.
- `AlarmId` property maps to `ScriptLogEntry.AlarmId`; absent properties → null fields.
- A `Debug` event with default `minLevel=Information` → publisher receives **nothing**.
- Template message renders (`"v={V}"` + prop V=3 → `"v=3"`).
Use a fake `IScriptLogPublisher` capturing entries.
**Step 2 — run, expect fail** (types don't exist).
**Step 3 — implement:**
```csharp
public interface IScriptLogPublisher { void Publish(ScriptLogEntry entry); }
public sealed class ScriptLogTopicSink : ILogEventSink
{
private readonly IScriptLogPublisher _publisher;
private readonly LogEventLevel _min;
public ScriptLogTopicSink(IScriptLogPublisher publisher,
LogEventLevel min = LogEventLevel.Information) { _publisher = publisher; _min = min; }
public void Emit(LogEvent e)
{
if (e is null || e.Level < _min) return;
string? P(string k) => e.Properties.TryGetValue(k, out var v)
&& v is ScalarValue { Value: string s } ? s : null;
_publisher.Publish(new ScriptLogEntry(
ScriptId: P("ScriptId") ?? P("ScriptName") ?? "unknown",
Level: e.Level.ToString(),
Message: e.RenderMessage(),
TimestampUtc: e.Timestamp.UtcDateTime,
VirtualTagId: P("VirtualTagId"), AlarmId: P("AlarmId"), EquipmentId: P("EquipmentId")));
}
}
```
(Property-name constants — reuse/extend `ScriptLoggerFactory`'s `ScriptNameProperty`;
add `ScriptIdProperty`/`VirtualTagIdProperty`/`AlarmIdProperty`/`EquipmentIdProperty`.)
**Step 4 — run tests, expect pass. Step 5 — commit** (`git add` the 3 files by path).
---
### Task 2: Root script logger + `DpsScriptLogPublisher` + Host wiring
**Classification:** standard · **~5 min** · **Parallelizable with:** none (depends T1)
**Files:**
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Scripting/DpsScriptLogPublisher.cs`
(or Host — wherever the `ActorSystem`/`Mediator` is reachable at construction).
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Logging/ScriptRootLoggerFactory.cs`
(builds the root `ILogger`: rolling `scripts-*.log` + `ScriptLogCompanionSink` +
`ScriptLogTopicSink`).
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs` (build + register root logger;
register `IScriptLogPublisher`).
- Test: `tests/.../Core.Scripting.Tests/ScriptRootLoggerFanoutTests.cs` (or Host.Tests).
**Steps (TDD):**
1. Failing test: a logger built by `ScriptRootLoggerFactory` with a fake publisher +
in-memory companion → an `Error` event reaches the companion mirror AND the topic
publisher; a `Debug` event reaches neither topic nor companion (file only). (Assert
via fakes; don't assert the physical file.)
2. Implement `DpsScriptLogPublisher` — ctor takes the DPS mediator `IActorRef` (or
`ActorSystem`); `Publish``mediator.Tell(new Publish("script-logs", entry))`
(topic constant `VirtualTagActor.ScriptLogsTopic`).
3. Implement `ScriptRootLoggerFactory.Build(IScriptLogPublisher, config)`
`LoggerConfiguration().WriteTo.File(...).WriteTo.Sink(new ScriptLogCompanionSink(Log.Logger))
.WriteTo.Sink(new ScriptLogTopicSink(publisher, minLevel)).CreateLogger()`.
4. `Program.cs`: resolve the mediator after the ActorSystem is up; register
`IScriptLogPublisher` (singleton) + the root `ILogger` (keyed/named for scripts).
Min-level from config (`Scripting:LogTopicMinLevel`, default `Information`).
5. Run + commit by path.
---
### Task 3: Rewire evaluators to the root script logger
**Classification:** standard · **~5 min** · **Parallelizable with:** none (depends T1, T2)
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynVirtualTagEvaluator.cs`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynScriptedAlarmEvaluator.cs`
- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptLoggerFactory.cs` (bind the
full property set, not just `ScriptName`).
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs` (inject root logger into the
evaluators).
- Test: `tests/.../Core.Scripting.Tests/` — evaluator emits via fake publisher.
**Steps:**
1. Failing test: a `RoslynVirtualTagEvaluator` built with a root logger wired to a fake
publisher; evaluate a script `ctx.Logger.Information("hi"); return 1;` → publisher
gets one entry with `ScriptId`/`VirtualTagId` bound and `Message=="hi"`.
2. Replace the static `ScriptLogger` field with a ctor-injected root `ILogger`. Per
evaluation, `var log = _root.ForContext("ScriptId", id).ForContext("VirtualTagId", virtualTagId)`
(+ `EquipmentId` when available) and pass into the `VirtualTagContext`. Same for the
alarm evaluator (binds `AlarmId`).
3. `ScriptLoggerFactory`: add a `Create(scriptId, virtualTagId?, alarmId?, equipmentId?)`
overload binding the standard properties (keep the old `Create(scriptName)` for
compatibility).
4. `Program.cs`: pass the root logger to both evaluator registrations.
5. Run + commit by path.
> Note: `IVirtualTagEvaluator.Evaluate` carries `virtualTagId`; in the live path
> `scriptId == virtualTagId`, so Layer 0 binds both from it. Threading a distinct
> `EquipmentId` (nice-to-have on the page) is optional here — if it requires an
> interface change, defer it to a Layer-1 follow-up rather than expanding T3.
---
### Task 4: Live-verify Layer 0
**Classification:** verification · **Parallelizable with:** none (depends T2, T3)
**Steps:**
1. Rebuild docker-dev central nodes (user-driven `/run`). Author a virtual tag whose
script calls `ctx.Logger.Information(...)`.
2. Open `/script-log`; drive the dependency so the script evaluates; confirm the line
appears live with the right ScriptId/level. Confirm Debug stays off the page,
Information+ shows.
3. **Agent does not sign in** — user signs in and drives. Record outcome. No code unless
a defect surfaces (→ new fix task).
---
# LAYER 1 — F9 engine runtime
### Task 5: `EquipmentScriptedAlarmPlan` + Phase7Composer enrichment
**Classification:** standard · **~5 min** · **Parallelizable with:** Task 7, Task 8
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs` (new record +
build the enriched list from `ScriptedAlarm` + `Script` rows).
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/Phase7/` (or wherever Phase7Composer
is tested) — new `Phase7ComposerScriptedAlarmTests.cs`.
**Steps:**
1. Failing test: compose two equipments each with a scripted alarm referencing a script;
assert each `EquipmentScriptedAlarmPlan` carries the resolved `PredicateSource`,
extracted `DependencyRefs` (via `DependencyExtractor`), `AlarmType`, `Severity`,
`MessageTemplate`, `HistorizeToAveva`, `Retain`, `Enabled`, `Name`.
2. Add `public sealed record EquipmentScriptedAlarmPlan(string ScriptedAlarmId, string
EquipmentId, string Name, string AlarmType, int Severity, string MessageTemplate,
string PredicateScriptId, string PredicateSource, IReadOnlyList<string> DependencyRefs,
bool HistorizeToAveva, bool Retain, bool Enabled);`
3. In `Compose`: join `ScriptedAlarm.PredicateScriptId → Script.SourceCode`; run
`DependencyExtractor.Extract(source).Reads` ( `MessageTemplate` token paths) for
`DependencyRefs`; project into the new list on the composition result. Skip
`Enabled=false` alarms (or carry the flag — carry it; host decides). Drop alarms whose
script is missing with a structured warning (don't throw the whole compose).
4. Run + commit by path.
---
### Task 6: DeploymentArtifact parity for the alarm plan
**Classification:** standard · **~5 min** · **Parallelizable with:** Task 7, Task 8 (depends T5)
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs`
(encode/decode `EquipmentScriptedAlarmPlan`; add
`Phase7CompositionResult.EquipmentScriptedAlarms`; filter-by-equipment like
`EquipmentVirtualTags` at :263).
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/` — artifact round-trip + parity
with the Composer for the same input.
**Steps:**
1. Failing test: build a composition via `Phase7Composer`, serialize to artifact, parse
back → `EquipmentScriptedAlarms` is byte-identical (same discipline as the `{{equip}}`
parity tests). Equipment-filter test (only alarms for resident equipment survive).
2. Add the field to `Phase7CompositionResult`; mirror the `EquipmentVirtualTags`
encode/decode/filter exactly (`:202`, `:263`).
3. Run + commit by path.
---
### Task 7: `DependencyMuxTagUpstreamSource`
**Classification:** standard · **~4 min** · **Parallelizable with:** Task 5, Task 6, Task 8
**Files:**
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/DependencyMuxTagUpstreamSource.cs`
(implements `Core.ScriptedAlarms`/`Core.VirtualTags` `ITagUpstreamSource`).
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/DependencyMuxTagUpstreamSourceTests.cs`
**Steps:**
1. Failing tests: `Push(path, snapshot)` updates cache so `ReadTag(path)` returns it;
`SubscribeTag(path, obs)` → `obs` fires on the next `Push`; `ReadTag` for an unknown
path returns a Bad-quality snapshot; dispose removes the observer.
2. Implement: a thread-safe cache (`ConcurrentDictionary<string, DataValueSnapshot>`) +
per-path observer list; `Push` updates cache then invokes observers; `ReadTag` reads
cache (Bad if absent); `SubscribeTag` returns an `IDisposable` that deregisters. The
host actor calls `Push` from its `DependencyValueChanged` handler. Value wrap:
`new DataValueSnapshot(value, StatusCode:0, ts, ts)`.
3. Run + commit by path.
---
### Task 8: `EfAlarmConditionStateStore : IAlarmStateStore`
**Classification:** standard · **~5 min** · **Parallelizable with:** Task 5, Task 6, Task 7
**Files:**
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/EfAlarmConditionStateStore.cs`
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/EfAlarmConditionStateStoreTests.cs`
(in-memory EF).
**Steps:**
1. Failing tests (in-memory `OtOpcUaConfigDbContext`): `SaveAsync(state)` then
`LoadAsync(alarmId)` round-trips Enabled/Acked/Confirmed/Shelving(+UnshelveAtUtc)/
LastAck*/LastConfirm*/Comments; `LoadAsync` of an unknown id → null; `ActiveState`
is **not** persisted (a saved state's Active is ignored on load — load returns the
stored operator state, Active defaults). Comments JSON round-trips.
2. Implement mapping `AlarmConditionState` ↔ `ScriptedAlarmState` entity (mirror
`EfAlarmActorStateStore`'s `IDbContextFactory` upsert pattern; serialize
`ImmutableList<AlarmComment>` ↔ `CommentsJson`). Map enum states ↔ the entity's string
columns.
3. Run + commit by path.
---
### Task 9: `ScriptedAlarmHostActor`
**Classification:** high-risk · **~5 min** · **Parallelizable with:** none (depends T6, T7, T8; needs Layer 0 T2/T3 root logger)
**Files:**
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs`
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmHostActorTests.cs`
(Akka TestKit + a real engine with the fake upstream, or a fake engine seam).
**Design:** mirrors `VirtualTagHostActor`. Owns one `ScriptedAlarmEngine` (built with the
`DependencyMuxTagUpstreamSource`, the `EfAlarmConditionStateStore`, a `ScriptLoggerFactory`
wrapping the **Layer 0 root logger**, and the engine's root logger). Message
`ApplyScriptedAlarms(IReadOnlyList<EquipmentScriptedAlarmPlan> Plans)`.
**Steps:**
1. Failing TestKit tests:
- `ApplyScriptedAlarms` with one alarm → engine loaded (assert via a probe/seam);
registers interest with the (probe) mux for the alarm's dep refs.
- A `DependencyValueChanged` that makes the predicate true → the host tells the
(probe) `OpcUaPublishActor` a `WriteAlarmState(alarmId, active:true, …)`, tells the
(probe) historian an `AlarmHistorianEvent` (when `HistorizeToAveva`), and publishes
an `AlarmTransitionEvent` on `alerts`.
- Re-`ApplyScriptedAlarms` with a different set reloads the engine (LoadAsync replace).
2. Implement: on `ApplyScriptedAlarms`, build `ScriptedAlarmDefinition`s from the plans
(map `AlarmType`→`AlarmKind`, `Severity`→`AlarmSeverity`, `EquipmentId`→`EquipmentPath`),
`engine.LoadAsync`; register mux interest for ` DependencyRefs`; on
`DependencyValueChanged` → `_upstream.Push(...)`. Subscribe `engine.OnEvent` once →
map `ScriptedAlarmEvent.Condition` to `(active, acknowledged)` →
`OpcUaPublishActor.WriteAlarmState`; map → `AlarmHistorianEvent` → historian (if
Historize); publish `AlarmTransitionEvent` on `alerts`. Dispose engine in `PostStop`.
3. Run targeted tests (`dotnet test --filter ScriptedAlarmHostActor`). Commit by path.
---
### Task 10: Spawn + apply in DriverHostActor
**Classification:** standard · **~4 min** · **Parallelizable with:** none (depends T9)
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs`
(spawn `ScriptedAlarmHostActor` next to `VirtualTagHostActor` ~:197; tell
`ApplyScriptedAlarms(composition.EquipmentScriptedAlarms)` next to the vtag apply ~:532;
add an override field for tests like `_virtualTagHostOverride`).
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs` if the
host needs new injected deps (EF store factory, root logger, historian ref).
- Test: extend `DriverHostActorTests` — apply pushes `ApplyScriptedAlarms`.
**Steps:** mirror the VirtualTag spawn/apply exactly; thread `_opcUaPublishActor`,
`_dependencyMux`, the EF store, the root logger, the historian actor ref. Run + commit.
---
### Task 11: Retire the orphaned actor + F9b evaluator
**Classification:** small · **~3 min** · **Parallelizable with:** none (depends T9, T10)
**Files:**
- Delete: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmActor.cs`
(+ its tests) and `src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynScriptedAlarmEvaluator.cs`.
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs` (remove F9b DI registration,
lines ~110-114) and any `IScriptedAlarmEvaluator` references. Keep
`EfAlarmActorStateStore` only if nothing else uses it — otherwise delete with the actor.
**Steps:** delete, fix build, run full `dotnet test` for the touched projects, commit by
path. (If something unexpectedly depends on these, stop and surface — don't expand scope.)
---
### Task 12: Live-verify Layer 1
**Classification:** verification · **Parallelizable with:** none (depends T10, T11)
**Steps:** rebuild docker-dev; author a scripted alarm whose predicate references a live
tag; drive the tag; confirm the alarm node flips active/clear, the historian queue
advances (`/alarms/historian`), the `alerts`/Alerts page shows it, and predicate
`ctx.Logger` output appears on `/script-log`. User drives sign-in. Defects → new tasks.
---
# LAYER 2 — F14b real Part 9 + client ack
### Task 13: SDK research spike (DeepWiki)
**Classification:** small (research) · **~5 min** · **Parallelizable with:** Layer-1 tasks
**Steps:** Use the DeepWiki MCP (`OPCFoundation/UA-.NETStandard`) to confirm: how to
create + add an `AlarmConditionState` (and Limit/OffNormal/Discrete subtypes) under a
parent in a `CustomNodeManager2`; how to set ActiveState/AckedState/ConfirmedState/
ShelvingState/Severity/Retain; how transitions fire events (`ReportEvent`); how inbound
`Acknowledge`/`Shelve`/`Confirm` method calls are dispatched + where to hook them. Write
findings to `docs/v2/f14b-part9-sdk-notes.md` (committed). This de-risks T14-T17.
---
### Task 14: Real condition-node materialisation
**Classification:** high-risk · **~5 min** · **Parallelizable with:** none (depends T13)
**Files:** `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs` (replace the
placeholder `[active, ack]` variable in `WriteAlarmState` / add a `MaterialiseAlarmCondition`
path per `AlarmType`); `Phase7Applier.cs` (call the new materialiser); tests where the
SDK allows (node existence/type assertions).
**Steps:** create real condition nodes on materialise; keep `WriteAlarmState` as a thin
shim during transition or replace its callers. Run + commit. (SDK threading: all via the
pinned `OpcUaPublishActor` dispatcher.)
---
### Task 15: Richer alarm-state bridge
**Classification:** standard · **~4 min** · **Parallelizable with:** Task 17 (depends T14, T9)
**Files:** `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs` (new message
carrying the full `AlarmConditionState`, not 2 bools); `ScriptedAlarmHostActor` bridge
(send the richer message); `OtOpcUaNodeManager` (apply full state to the condition).
Tests: message mapping.
---
### Task 16: Event firing on transition
**Classification:** high-risk · **~5 min** · **Parallelizable with:** none (depends T14, T15)
**Files:** `OtOpcUaNodeManager.cs` (`condition.ReportEvent(...)` on state change). Tests:
mapping/coverage where feasible; behaviour proven in T19.
---
### Task 17: Inbound method dispatch + ack plumbing
**Classification:** high-risk · **~5 min** · **Parallelizable with:** Task 15 (depends T14)
**Files:** `OtOpcUaNodeManager.cs` (wire Acknowledge/Confirm/AddComment/OneShotShelve/
TimedShelve/Unshelve handlers → route to a control-plane message → `ScriptedAlarmHostActor`
→ `engine.<Op>Async(conditionId, principal, comment, ct)`); the security gate at the
`AlarmAck` tier (reuse the LDAP-group→OPC-UA-permission map). Tests: method→engine routing
with a fake engine; permission gate allows/denies by tier.
---
### Task 18: AdminUI ack/shelve control
**Classification:** standard · **~4 min** · **Parallelizable with:** none (depends T17)
**Files:** `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptedAlarms.razor`
(+ a control-plane command service) — ack/shelve buttons route to the same engine ops as
T17. Tests: the control-plane command service (no bUnit; live-verify the razor in T19).
---
### Task 19: Live-verify Layer 2 (Client.CLI)
**Classification:** verification · **Parallelizable with:** none (depends T16, T17, T18)
**Steps:** with docker-dev up, use `Client.CLI` `alarms` + `subscribe` to confirm a real
condition appears, fires events on transition, and a client `Acknowledge` round-trips
(state flips, event fires, persists). Confirm AdminUI ack does the same. User drives.
---
### Task 20: Docs + finish
**Classification:** small · **~5 min** · **Parallelizable with:** none (depends all)
**Files:** update `docs/ScriptedAlarms.md`, `docs/VirtualTags.md`, `docs/v2/Runtime.md`
(F8/F9 now wired), **correct the stale `docs/v2/phase-7-status.md` alarm-runtime status**,
add a CLAUDE.md note for the script-log emit + scripted-alarm runtime. Delete/condense
`pending.md` (its content now lives in the design + these docs). Then run
superpowers-extended-cc:finishing-a-development-branch (full `dotnet test`, merge to
master).
---
## Execution notes
- **Parallel dispatch:** Layer 0 is serial (T1→T2→T3→T4). Layer 1: **T5→T6** serial
(composer→artifact parity); **T7, T8 parallel** with T5/T6 (disjoint files); T9 waits
on T6/T7/T8; T10→T11→T12 serial. Layer 2: T13 first; **T15 ∥ T17** after T14; T16 after
T15; T18 after T17; T19/T20 last.
- **One writer at a time** within a shared file (Program.cs touched by T2/T3/T11;
OtOpcUaNodeManager by T14/T15/T16/T17 — serialize those).
- **Layer boundaries are natural checkpoints** — Layer 0 is independently shippable; pause
for review after T4 and after T12 before committing to the Layer 2 SDK epic.