Files
ScadaBridge/docs/plans/2026-06-30-mes-alarm-status-api.md
T
Joseph Doherty 3c9b101dfc docs(alarms): document the Alarms accessor + AckTime, tick MES plan Phase 1
Design doc and code travel together (CLAUDE.md editing rules), so this records
what the two preceding commits shipped and, more usefully, WHY the non-obvious
choices were made -- the parts a future reader would otherwise re-litigate:

  - Component-SiteRuntime.md: the Alarms.CurrentAsync() runtime API entry (why
    it is not scope-prefixed, why it is read-only, why placeholder rows are
    included), the full ScriptAlarm shape, AckTime on the enriched
    AlarmStateChanged, proto field 24, and the metadata_json-vs-new-column
    persistence rationale (native_alarm_state is RegisterReplicated; LocalDb
    builds its CDC triggers from the column list at registration time).
  - Component-DataConnectionLayer.md already carried the AckTime section in the
    first commit; this adds the SiteRuntime/ScriptAnalysis/InboundAPI halves.
  - Component-ScriptAnalysis.md: accessors returning domain types return the
    SAME type on both surfaces, and the trust-model note that a deny-list needs
    no entry for a new globals member -- only that its return type resolves in
    a permitted namespace.
  - Component-InboundAPI.md records the NEGATIVE decision: there is
    deliberately no Route.To(...).GetAlarms(...) verb. Alarm state is
    per-instance and lives on the site's Instance Actor, so the read goes
    through a routed site script and the filtering happens where the data is;
    central stays a thin router.
  - CLAUDE.md native-alarm bullet gains the enrichment + accessor summary.
  - The plan's §7 Phase 1 rows are ticked with 2026-08-01 and annotated with
    what was actually built (incl. the two choices that differ from the plan's
    "or" options: a dedicated snapshot message rather than DebugSnapshotRequest,
    and the extra SandboxScriptHost mirror the plan did not list). Phases 2-4
    stay open -- they are deployed config and need a live rig.
2026-08-01 13:12:46 -04:00

306 lines
31 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.
# MES Alarm-Status API — Implementation Plan
**Date:** 2026-06-30
**Status:** **Phase 1 [repo] COMPLETE 2026-08-01** — the `Alarms.CurrentAsync()` script accessor and the `AckTime` native-mirror enrichment are shipped (see §7). Phases 24 are **deployed config** (inbound methods + CvdReactor template scripts) and still pending; they need a live rig. All §6 open questions DECIDED 2026-08-01 (design review with user).
**Component touchpoints:** Inbound API (#14), Script Analysis (#25), Site Runtime (#3), Template Engine (#1) — plus deployed config (inbound methods + CvdReactor template scripts).
---
## 1. Goal
Port the legacy **WWSupport / APIServer** MES alarm-status endpoints onto ScadaBridge's Inbound API, mirroring how `MesMoveIn` / `MesMoveOut` were ported:
1. **Update** the existing `SimpleAlarmStatusRequest` inbound method (currently a stub) to do real work.
2. **Create** a new `AlarmStatus` inbound method (the full, filtered endpoint).
3. Both inbound methods **forward to site scripts** (`SAPID → BTDB machine lookup → Route.To(code).Call(...)`), exactly like `MesMoveIn`.
4. ~~The generic version on the `MESReceiver` template queries the BTDB `MachineAlarm` table.~~ **DROPPED (Q2, 2026-08-01):** no MESReceiver version ships — a config-only answer with no live state was judged not worth having. Machines whose template doesn't implement the scripts get a clean `WasSuccessful=false` "not supported on this machine" error from the inbound router.
5. The **`CvdReactor` template provides the (only) implementation**, reading the **alarms actually defined on the CvdReactor object** (its 7 native alarm-source bindings) directly — no `MachineAlarm` lookup anywhere.
Legacy spec being mirrored: [`docs/former-api-specs/mes/Alarm-API.md`](../former-api-specs/mes/Alarm-API.md).
---
## 2. Legacy contract recap (target parity)
Two endpoints, **one** response shape (`AlarmStatusResponse`):
| Legacy route | New inbound method | Request |
|---|---|---|
| `POST /mes/simplealarmstatus` | `SimpleAlarmStatusRequest` (exists, id 9) | `{ "SAPID": "..." }` |
| `POST /mes/alarmstatus` | `AlarmStatus` (NEW) | `{ MachineFilter, AlarmFilter }` |
**`AlarmStatusResponse`** = `{ WasSuccessful: bool, ErrorText: string, Alarms: AlarmInfo[] }`; on failure `WasSuccessful=false` and `Alarms` is **cleared** (all-or-nothing).
**`AlarmInfo`** = `Name`, `HierarchicalName` (`{Code}.{Name}`), `Description`, `IsFlaggedForMES`, `Severity` (0999), `StatusCode` (`"Triggered"` / `"Triggered.Acked"`), `TriggeredDT`, `AckDT?`, `AckComment`.
**Behaviors to preserve:**
- Only **triggered** alarms returned (`InAlarm == true`). No "all configured" mode. `IncludeTriggered` is a no-op (legacy declared-but-unused).
- `/simplealarmstatus`: always **flagged-only**, ack filtering ignored (acked alarms always included, tagged `Triggered.Acked`).
- `/alarmstatus`: `MachineFilter` precedence `SAPID → Code → ZTag → MachineID`; `AlarmFilter` order `FlaggedOnly → MinSeverity → MaxSeverity → NameFilter` (substring, case-insensitive; severity bounds inclusive); `IncludeAcked=false` excludes acked.
---
## 3. Current state in ScadaBridge (verified 2026-06-30 against wonder-app-vd03)
**Inbound methods (`api-method list`):**
- `SimpleAlarmStatusRequest` (id 9) — **stub**: `return new { WasSuccessful = true, Alarms = Array.Empty<Object>() };`. Param: `SAPID` (string). **Return definition already shaped to `AlarmInfo`** (Name/HierarchicalName/Description/IsFlaggedForMES/Severity/StatusCode/TriggeredDT/AckDT/AckComment). ✅ no return-contract change needed.
- No `AlarmStatus` method yet.
- Reference pattern (`MesMoveIn`, id 2): `SAPID → Database.QuerySingleAsync<string>("BTDB", "SELECT TOP 1 Code FROM dbo.Machine WHERE SAPID=@s") → Route.To(code).Call("MesMoveIn", {...})`.
**Templates (`template list` / `template get`):**
- `CvdReactor` (id 1, parent=none): root scripts `IpsenMoveIn`, `ProcessRecipeDownload`, `MesMoveIn`, `MesMoveOut`; **7 native alarm sources** (all over `MxGateway`): `ReactorAlarms` (`Z28061.`), `LeftSideAlarms` (`Left_002.`), `RightSideAlarms` (`Right_002.`), `LeftLeakTestAlarms` (`LeakTest_003.`), `RightLeakTestAlarms` (`LeakTest_004.`), `LeftTableAlarms` (`TableAlarms_003.`), `RightTableAlarms` (`TableAlarms_004.`); composes templates 5,6,8,9,13,14,26. No computed `TemplateAlarm`s.
- `MESReceiver` (id 3, parent=none): root scripts `MoveIn`, `MoveOut` (scope-bound to the composed receiver, bare `Attributes`); `Left`/`RightMESReceiver` (5/6) derive from it and are composed into CvdReactor. No alarms, no native sources.
**Established override pattern (MoveIn):** `CvdReactor.MesMoveIn` (root) does side-dispatch off the SAPID suffix and writes **directly** into `Children["LeftMESReceiver"|"RightMESReceiver"].Attributes` via `WriteBatchAndWaitAsync` — it does **not** `CallScript` the base. `MESReceiver.MoveIn` is the generic scope-bound implementation. So "override" here = **CvdReactor supplies its own root-level script that `Route.Call` resolves for CvdReactor instances**; the MESReceiver version is the reusable/generic one. (CvdReactor does **not inherit** MESReceiver — it composes it — so this is parallel-definition, not inheritance-override.)
---
## 4. The blocking gap (drives most of the code work)
**Site `call`-type scripts currently have NO public API to read alarm condition state.**
From the site-script API map:
- The runtime context is `ScriptRuntimeContext` (`src/.../SiteRuntime/Scripts/ScriptRuntimeContext.cs`); compile-time mirror is `ScriptCompileSurface` (`src/.../ScriptAnalysis/ScriptCompileSurface.cs`). Members today: `Attributes`, `Children`, `Parent`, `Instance` (GetAttribute/SetAttribute/CallScript), `Database`, `ExternalSystem`, `Notify`, `Scripts`, `Tracking`, `Parameters`, `Alarm` (on-trigger only), `CancellationToken`.
- Computed alarm state is only exposed via the `Alarm` global **passed into on-trigger scripts**. Native alarm state (`AlarmConditionState`) lives in the Instance Actor's `_latestAlarmEvents`; **no script-facing query method exists**.
⇒ The CvdReactor override ("read the defined alarms directly") **cannot be written as a script today**. It requires a new enabling capability: a script-facing **`Alarms` read accessor**.
**Good news — the data already exists and is local.** `DebugViewSnapshot` (`Commons/Messages/DebugView/DebugViewSnapshot.cs`) carries `IReadOnlyList<AlarmStateChanged> AlarmStates`, and the M7 Alarm Summary page already fans this out per instance. `AlarmStateChanged` (`Commons/Messages/Streaming/AlarmStateChanged.cs`) carries everything we need: `AlarmName`, `Condition` (`AlarmConditionState`: Active/Acknowledged/Confirmed/Shelve/Suppressed/Severity), `Priority`, `Level`, `Message`, `Kind`, `SourceReference`, `AlarmTypeName`, `Category`, `OperatorUser`, `OperatorComment`, `OriginalRaiseTime`, `Timestamp`, `NativeSourceCanonicalName`, `IsConfiguredPlaceholder`. The script runs **inside the Instance Actor's context**, so an `Alarms` accessor reads this via a **local Ask** — same mechanism as `GetAttribute`, no cross-cluster hop.
---
## 5. Target architecture (three layers)
```
INBOUND (central) ENABLING CODE (repo) SITE TEMPLATE SCRIPTS (deployed config)
───────────────── ──────────────────── ───────────────────────────────────────
SimpleAlarmStatusRequest ─┐ Alarms script accessor
├─ Route.To(code).Call(...) ─────────► CvdReactor.SimpleAlarmStatus (native sources)
AlarmStatus (new) ─┘ + AckTime mirror enrichment ──► CvdReactor.AlarmStatus (native sources)
(ScriptRuntimeContext +
ScriptCompileSurface) (no MESReceiver version — Q2 DECIDED: dropped)
```
### 5.1 Layer A — Inbound API methods (deployed config; central)
Thin routers, identical shape to `MesMoveIn`. They own machine resolution (the `MachineFilter` precedence) against BTDB, then `Route.To(code).Call(<siteScript>, <filters>)` and return the site result verbatim.
**`SimpleAlarmStatusRequest` (update id 9):** extract the **numeric** SAP id for the BTDB lookup, but forward the **raw** SAPID (with `_A`/`_B`[`_LT`] suffix) to the site so the CvdReactor override can resolve the side — exactly as `MesMoveIn` does.
```csharp
try {
var raw = ((Parameters.ContainsKey("SAPID") ? Parameters["SAPID"]?.ToString() : null) ?? "").Trim();
var m = System.Text.RegularExpressions.Regex.Match(raw, @"^(\d+)");
if (!m.Success)
return new { WasSuccessful = false, ErrorText = $"SAPID does not contain a numeric SAP id: '{raw}'", Alarms = Array.Empty<object>() };
var sap = m.Groups[1].Value; // numeric SAP for the DB lookup
var code = await Database.QuerySingleAsync<string>("BTDB",
"SELECT TOP 1 Code FROM dbo.Machine WHERE SAPID=@s", new { s = sap });
if (string.IsNullOrEmpty(code))
return new { WasSuccessful = false, ErrorText = $"Failed to find machine with SAPID '{sap}'", Alarms = Array.Empty<object>() };
// SimpleAlarmStatus semantics: flagged-only, acked always included. Pass RAW SAPID for side routing.
return await Route.To(code).Call("SimpleAlarmStatus", new { SAPID = raw, MachineCode = code });
} catch (Exception ex) {
return new { WasSuccessful = false, ErrorText = "SimpleAlarmStatus failed: " + ex.Message, Alarms = Array.Empty<object>() };
}
```
**`AlarmStatus` (new method):** nested `MachineFilter` + `AlarmFilter` params (Inbound API extended type system supports Object). Resolve machine via precedence (all selectors come from `dbo.Machine`), then forward the filter to the site script.
- Param definition (object): `MachineFilter { MachineID:int?, SAPID:string, ZTag:string, Code:string }`, `AlarmFilter { NameFilter:string, MinSeverity:int?, MaxSeverity:int?, IncludeTriggered:bool, IncludeAcked:bool, FlaggedOnly:bool }`.
- Return definition: same `AlarmInfo[]` shape as id 9.
- Body: resolve `code` by `SAPID → Code → ZTag → MachineID` (each its own `SELECT TOP 1 Code FROM dbo.Machine WHERE ...`), selector-specific error text on unmatched, then `Route.To(code).Call("AlarmStatus", new { MachineCode = code, SAPID = <raw MachineFilter.SAPID or "">, ...AlarmFilter fields... })`. The raw `MachineFilter.SAPID` (suffix included) is forwarded so the CvdReactor override can scope by side; when the machine was selected by `Code`/`ZTag`/`MachineID` (no SAPID suffix), side scoping is skipped and all sources are returned (see §6.6).
> **Note:** Machine resolution stays on the inbound side (central) because it is environment/SQL-shaped and identical for both endpoints; site scripts receive an already-resolved `MachineCode` plus the alarm filter.
> **Unsupported machines (Q2 DECIDED):** since only CvdReactor implements the site scripts, `Route.Call` on any other machine fails script-not-found. Both routers catch that case specifically and return `{ WasSuccessful=false, ErrorText = $"Alarm status is not supported on machine '{code}'" }` instead of the raw exception text (verify during impl what exception/message `Route.Call` yields for a missing script so the catch can distinguish it).
### 5.2 Layer B — Enabling code change: script-facing `Alarms` accessor (repo)
New read-only accessor on the site script surface so scripts can enumerate the instance's current alarms.
**Proposed shape** (final names TBD in review):
```csharp
// On ScriptRuntimeContext (runtime) + ScriptCompileSurface (compile-time stub):
public AlarmsAccessor Alarms { get; }
public sealed class AlarmsAccessor {
// Snapshot of the instance's CURRENT alarm conditions (computed + native),
// read locally from the Instance Actor (same Ask path as attribute reads).
Task<IReadOnlyList<ScriptAlarm>> CurrentAsync(CancellationToken ct = default);
}
public sealed record ScriptAlarm(
string Name, string SourceReference, string NativeSourceCanonicalName,
bool Active, bool Acknowledged, bool? Confirmed, bool Shelved, bool Suppressed,
int Severity, string Kind, // "Computed" | "NativeOpcUa" | "NativeMxAccess"
string Message, string AlarmTypeName, string Category,
string OperatorUser, string OperatorComment,
DateTimeOffset? OriginalRaiseTime, DateTimeOffset Timestamp,
DateTimeOffset? AckTime, // Q4 DECIDED: real ack timestamp from the enriched mirror
string CurrentValue, string LimitValue, bool IsConfiguredPlaceholder);
```
**Ack-timestamp enrichment (Q4 DECIDED 2026-08-01 — enrich the mirror now, not a follow-up):**
- Add an additive `AckTime` (`DateTimeOffset?`) to `AlarmStateChanged`, the vendored `AlarmStateUpdate` proto (additive field number, never reuse — manual toggle-build-copy-untoggle regen), and the site `native_alarm_state` persistence so it survives failover.
- Semantics: when the underlying source supplies a true ack time (OPC UA A&C ack transitions do), use it; when it doesn't (MxGateway events without one), stamp the observation time of the ack transition at the DCL — accurate to when the system saw the ack, never fabricated. Null while unacked; cleared on re-raise.
Implementation:
- Runtime: `Alarms.CurrentAsync()` Asks the Instance Actor for its alarm snapshot (reuse the existing internal alarm-state map that feeds `DebugViewSnapshot.AlarmStates`; project each `AlarmStateChanged``ScriptAlarm`). New internal request/response message (or reuse `DebugSnapshotRequest` and project off `AlarmStates`).
- Compile surface: matching stub returning `Task.FromResult(empty)` so design-time compile + Test Run pass.
- `ScriptTrustPolicy` (#25): no new forbidden surface; `Alarms` is an allow-listed context member like `Attributes`. Confirm the validator's allow-list includes it.
- Additive message-contract evolution only.
### 5.3 Layer C — Site template scripts (deployed config)
**MESReceiver version — DROPPED (Q2 DECIDED 2026-08-01).** With no native alarm sources, MESReceiver has no live "triggered" signal; a configured-catalog-only answer was judged misleading rather than useful. No BTDB `MachineAlarm` read ships anywhere in this feature. Machines without the CvdReactor-style scripts get the router's "not supported on machine" error (§5.1).
**CvdReactor (the only implementation — reads native sources directly, no DB):** `SimpleAlarmStatus` and `AlarmStatus` (Q5 DECIDED: names match the endpoints, template-agnostic contract) as **root-level scripts** (mirrors `MesMoveIn`).
The override **routes left vs right off the SAPID suffix**`_A` ⇒ Left, `_B` ⇒ Right — and scopes the returned alarms to that side's native sources plus the shared reactor-wide source. The `_LT` (leak-test) suffix is **ignored** for scoping: it is stripped before reading the side, and both the side's run and leak-test sources are included. Source → side map (the 7 `CvdReactor` native sources):
| Side (`_A`/`_B`) | Native sources included |
|---|---|
| `Left` (`_A`) | `LeftSideAlarms`, `LeftLeakTestAlarms`, `LeftTableAlarms`, **+ shared `ReactorAlarms`** |
| `Right` (`_B`) | `RightSideAlarms`, `RightLeakTestAlarms`, `RightTableAlarms`, **+ shared `ReactorAlarms`** |
```csharp
// CvdReactor.SimpleAlarmStatus (sketch)
// Q1 DECIDED: MES relevance = dedicated severity band. Galaxy alarm priorities for
// MES-relevant alarms are configured into 900-999; nothing else may use that band.
const int MesBandMin = 900;
const int MesBandMax = 999;
try {
var raw = (Parameters["SAPID"] as string) ?? "";
var code = Parameters["MachineCode"]?.ToString() ?? "";
// Side routing from the SAPID suffix: _A => Left, _B => Right. _LT (leak test) is ignored.
var core = raw.EndsWith("_LT") ? raw.Substring(0, raw.Length - 3) : raw;
var side = core.EndsWith("_A") ? "Left" : core.EndsWith("_B") ? "Right" : null;
if (side == null)
return new { WasSuccessful = false, ErrorText = "SAPID missing _A/_B side suffix", Alarms = Array.Empty<object>() };
// Keep this side's sources (Left*/Right*) + the shared reactor-wide source; drop the other side's.
System.Func<string,bool> InScope = src =>
string.IsNullOrEmpty(src) // computed/unbound — keep
|| src.StartsWith(side, System.StringComparison.OrdinalIgnoreCase) // Left* or Right*
|| src.StartsWith("Reactor", System.StringComparison.OrdinalIgnoreCase); // shared ReactorAlarms
var alarms = await Alarms.CurrentAsync(); // native + computed, all 7 sources
var infos = alarms
.Where(a => a.Active && !a.IsConfiguredPlaceholder) // only triggered
.Where(a => InScope(a.NativeSourceCanonicalName)) // _A => Left*, _B => Right*, + ReactorAlarms
// SimpleAlarmStatus semantics: flagged-only (= MES band, Q1) + acked always included
.Where(a => a.Severity >= MesBandMin && a.Severity <= MesBandMax)
.Select(a => new {
Name = a.Name,
HierarchicalName = code + "." + a.Name,
Description = string.IsNullOrEmpty(a.Message) ? a.AlarmTypeName : a.Message, // Q3 DECIDED
IsFlaggedForMES = a.Severity >= MesBandMin && a.Severity <= MesBandMax, // Q1 DECIDED: real predicate
Severity = a.Severity,
StatusCode = a.Acknowledged ? "Triggered.Acked" : "Triggered",
TriggeredDT = (a.OriginalRaiseTime ?? a.Timestamp),
AckDT = a.AckTime, // Q4 DECIDED: enriched mirror
AckComment = a.OperatorComment,
}).ToList();
return new { WasSuccessful = true, ErrorText = (string)null, Alarms = infos };
} catch (Exception ex) {
return new { WasSuccessful = false, ErrorText = "SimpleAlarmStatus failed: " + ex.Message, Alarms = Array.Empty<object>() };
}
```
`CvdReactor.AlarmStatus` is the same — same side parsing + `InScope` filter, WITHOUT the always-on band filter — but applies the passed `AlarmFilter` (NameFilter/MinSeverity/MaxSeverity/FlaggedOnly/IncludeAcked) over the scoped native list; `FlaggedOnly=true` means the MES-band predicate (Q1). `IsFlaggedForMES` is always reported per-row from the band predicate. (When `AlarmStatus` was selected by `Code`/`ZTag`/`MachineID` with no SAPID suffix, `side == null`; in that case return all sources instead of erroring — Q6b DECIDED.)
> **Shared `ReactorAlarms` (`Z28061.`) is included on both sides — Q6a DECIDED 2026-08-01:** reactor-wide faults apply regardless of which side MES asks about.
---
## 6. Key design decisions — ALL DECIDED 2026-08-01 (design review with user)
1. **`IsFlaggedForMES` for native alarms — DECIDED: severity band.** MES relevance is encoded in the alarm severity itself: MES-relevant alarms are configured (in the Galaxy alarm priority) into a **dedicated band 900999** reserved exclusively for MES-relevant alarms, so it cannot collide with ordinary criticality tuning. Scripts carry `MesBandMin = 900` / `MesBandMax = 999` as named constants; `IsFlaggedForMES = (Severity in band)` is reported honestly per row; `SimpleAlarmStatus` (always flagged-only) and `AlarmStatus` with `FlaggedOnly=true` filter by the band predicate. Since `Severity` is returned verbatim, MES's own `MinSeverity`/`MaxSeverity` filters compose naturally with the band. No new tables, code flags, or allow-lists. **Operational prerequisite:** the Galaxy alarm priorities for MES-relevant CvdReactor alarms must be set into 900999 before the endpoints are meaningful.
2. **MESReceiver version — DECIDED: dropped entirely.** With no native alarm sources there is no live state; a config-only catalog answer was judged not worth shipping. Only CvdReactor implements the scripts; other machines get the router's clean "not supported on machine" error (§5.1). No BTDB `MachineAlarm` dependency remains.
3. **`Description` mapping — DECIDED:** `Message`, falling back to `AlarmTypeName` when `Message` is empty.
4. **`AckDT` mapping — DECIDED: enrich the native mirror now.** Additive `AckTime` on `AlarmStateChanged` + vendored proto + `native_alarm_state` (survives failover). True source ack time where available (OPC UA A&C); DCL observation time of the ack transition where the source lacks one (MxGateway); null while unacked; cleared on re-raise. See §5.2.
5. **Script naming — DECIDED: `SimpleAlarmStatus` / `AlarmStatus`** (match the endpoint names; template-agnostic contract any future machine template can implement). With the MESReceiver version dropped there is no parallel definition, so no override-resolution concern remains.
6. **Side scoping — DECIDED (all parts).** CvdReactor-only: `_A` ⇒ Left, `_B` ⇒ Right; `_LT` stripped before reading the side (leak-test sources still included). (a) shared `ReactorAlarms` (`Z28061.`) **included on both sides** — reactor-wide faults apply regardless of side. (b) missing suffix: `SimpleAlarmStatus` **errors** on a SAPID without `_A`/`_B` (matches `MesMoveIn`); `AlarmStatus` selected by `Code`/`ZTag`/`MachineID` (no SAPID) **returns all sources**; a SAPID selector without a suffix errors.
7. **Roles / auth — DECIDED:** no new roles; create `AlarmStatus` as Designer via CLI; authorize the **existing MES API key** (the one already calling `MesMoveIn`/`MesMoveOut`) for both alarm endpoints — one key per external system.
---
## 7. Implementation task breakdown (ordered)
> Two artifact classes: **[repo]** = source/tests/docs committed to git; **[deployed]** = inbound methods / template scripts pushed to the cluster via CLI/UI (not in repo). The user said "don't execute yet" — this is the ordered plan only.
**Phase 1 — Enabling `Alarms` script API + AckTime mirror enrichment [repo]** — ✅ **DONE 2026-08-01**
1.**DONE 2026-08-01****AckTime enrichment (§6.4):** additive `AckTime` on `AlarmStateChanged`, the vendored `AlarmStateUpdate` proto (manual regen), `native_alarm_state` persistence, and the DCL ack-transition stamping (source ack time where supplied, else observation time).
- `AlarmStateChanged.AckTime` (init-only, `null` default) + `NativeAlarmTransition.AckTime` (trailing optional positional — all 14-arg call sites unchanged).
- Proto: **field 24** `google.protobuf.Timestamp ack_time` on `AlarmStateUpdate`; regenerated with `docker/regen-proto.sh sitestream` (csproj diff verified empty). Packed/unpacked by `StreamRelayActor` / `SiteStreamGrpcClient`; an absent Timestamp round-trips to `null`.
- Stamping rule (both protocols): non-null **only** while the condition is active AND acknowledged — that one predicate yields "null while unacked", "cleared on re-raise", and no phantom ack on the MxGateway return-to-normal (which maps `INACTIVE → Acknowledged = true`). Lives in the pure `OpcUaAlarmMapper.DeriveAckTime` / `MxGatewayAlarmMapper.DeriveAckTime`.
- OPC UA gets a **true source ack instant**: new SelectClause **index 18** = `AcknowledgeableConditionType/AckedState/TransitionTime`, appended so indices 017 keep their meaning; falls back to the event's `Time` when the server omits it. MxGateway uses the ack transition's own timestamp (its feed carries no ack time), and an `ACTIVE_ACKED` re-subscribe snapshot restores one from `LastTransitionTimestamp`.
- Persistence: rides `native_alarm_state`'s existing `metadata_json` blob, **not** a new column — that table is `RegisterReplicated` in `SiteLocalDbSetup` and LocalDb builds its CDC triggers from the column list at registration time, so an additive JSON property changes no schema, no triggers and no replication contract (`metadata_json` is exactly the extension point UA4 added). Pre-AckTime rows deserialize it as `null`.
2.**DONE 2026-08-01** — Add `AlarmsAccessor` + `ScriptAlarm` to the runtime context (`ScriptRuntimeContext`) — local Ask to the Instance Actor; project `AlarmStateChanged``ScriptAlarm` (incl. `AckTime`). Add internal request/response message if not reusing `DebugSnapshotRequest`.
- Dedicated `GetAlarmSnapshotRequest`/`GetAlarmSnapshotResponse` (Commons `Messages/Instance`) rather than reusing `DebugSnapshotRequest`, which would materialise every attribute value on every alarm poll. Served from the same `BuildAlarmStatesSnapshot()` the Debug View uses, so the two can never disagree.
- `AlarmsAccessor` sits in `ScopeAccessors.cs` beside the other accessors but is **not scope-prefixed** — alarm identity is not a scope-relative attribute name, so every scope sees the whole list. Exposed as `ScriptRuntimeContext.Alarms` and the top-level `ScriptGlobals.Alarms`.
3.**DONE 2026-08-01** — Mirror the stub on `ScriptCompileSurface` (and confirm `TriggerCompileSurface` not needed — trigger expressions don't read alarms).
- `CompileAlarmsAccessor` returns the **same** `ScriptAlarm` type as the runtime (Commons is already in `DefaultAssemblies`), so field access binds identically at the gate and at the site. `TriggerCompileSurface` confirmed not needed.
- Also mirrored on the **third** hand-maintained surface, the Central UI Test-Run `SandboxScriptHost` — without it the design page would false-flag CS1061 on scripts the deploy gate accepts. It throws a labelled `ScriptSandboxException` at run time (no central route to per-instance alarm state) rather than returning an empty list that would read as "nothing is in alarm".
4.**DONE 2026-08-01** — Confirm/extend `ScriptTrustPolicy` allow-list so `Alarms` is permitted; no new forbidden APIs.
- **No change needed, and the reason is structural:** the trust boundary is a deny-list over API roots, not an allow-list of context members. Pinned by a test asserting no `ForbiddenScopes` entry prefixes the Commons script-surface namespace, so a future deny-list entry cannot silently make `ScriptAlarm` untouchable.
5.**DONE 2026-08-01** — Unit tests: runtime accessor projection (active/acked/severity/timestamps/AckTime), AckTime stamping + failover persistence, compile-surface compiles a representative `Alarms.CurrentAsync()` script, trust-policy accepts it.
- New `AlarmsAccessorTests` (6), `NativeAlarmActor` AckTime emit/rehydrate/pre-AckTime-row (3), `InstanceActor` alarm-snapshot (2), mapper AckTime (4 OPC UA + 6 MxGateway), proto round-trip (1), Commons additive/back-compat (4), compile-surface + trust (4), `SandboxScriptHost` diagnose-clean (1). `AlarmsAccessor` added to the `CompileSurfaceParityTests` mirror pairs; the OPC UA SelectClause count lock-in went 18 → 19 with an index-18 assertion (intended — the clause is appended).
6.**DONE 2026-08-01** — Doc: update `Component-SiteRuntime.md` + `Component-DataConnectionLayer.md` (native-mirror AckTime) + Script Analysis #25 surface list; note the accessor in `Component-InboundAPI.md` routing examples.
- Also updated the `CLAUDE.md` native-alarm bullet. The Inbound API note records the *negative* decision: no `Route.To(...).GetAlarms(...)` verb — alarm reads go through a routed site script so the filtering happens where the data lives.
**Phase 2 — Inbound methods [deployed] + doc [repo]**
7. Update `SimpleAlarmStatusRequest` (id 9) body to the §5.1 router incl. the not-supported-machine catch (validate via design page first to avoid the stale-handler trap — see memory `inbound-noncompiling-update-keeps-old-handler`).
8. Create `AlarmStatus` method (params/return/script per §5.1); authorize the existing MES API key for both methods (§6.7).
9. Doc the two endpoints in `docs/requirements/Component-InboundAPI.md` (or a dedicated MES-integration note) cross-referencing the legacy spec.
**Phase 3 — Site template scripts [deployed]** *(MESReceiver task removed — §6.2 decided dropped)*
10. Add `SimpleAlarmStatus` + `AlarmStatus` to **CvdReactor** (native sources via `Alarms.CurrentAsync()`, MES band constants), per §5.3.
11. `template validate` (script compilation gate) before redeploy; redeploy affected instances.
**Phase 4 — Verify**
12. Build affected projects + run targeted tests (per memory `targeted-tests-not-full-suite`).
13. Live smoke against a real reactor instance (see §8) — both endpoints, success + machine-not-found + not-supported-machine + filtered. Requires the Galaxy MES-band priorities (§6.1) to be set for a meaningful flagged-only result.
---
## 8. Testing & verification
- **Unit [repo]:** `Alarms` accessor projection; compile-surface acceptance; trust-policy allow; inbound param/return validation for `AlarmStatus` (nested objects).
- **Design-page [deployed]:** paste both site scripts into the api-method/template design pages; confirm no CS errors and Test Run executes (this is the gate that catches `Database.QuerySingle`-style mistakes pre-deploy).
- **Live smoke [deployed]:** `curl` `simplealarmstatus` and `alarmstatus` against the deployed reactor; cross-check returned alarms against the CLI debug snapshot (`debug snapshot --id <instance>`) alarm table for the same instance; verify only `Active` alarms appear, ack mapping, severity filtering, and machine-not-found error text.
- **Caveat:** native MxGateway alarms need a real gateway; the docker cluster can't fully exercise the CvdReactor native path (cf. memory on no-local-gateway). Validate the native-read path on the wonder-app-vd03 / real-gateway environment; unit-test the projection in isolation.
---
## 9. Out of scope / follow-ups
- ~~True `IsFlaggedForMES` allow-list~~ — resolved by the §6.1 severity-band decision (no follow-up needed).
- ~~Precise `AckDT` follow-up~~ — pulled INTO scope by §6.4 (mirror enriched now).
- A live/aggregated central alarm store or stream (the M7 follow-up) — these endpoints stay pull-based, per-instance, like the Alarm Summary page.
- Alarm-status support for non-CvdReactor machine types (any future template just implements `SimpleAlarmStatus`/`AlarmStatus` root scripts against the same contract).
---
## 10. Summary of changes
| Artifact | Type | Change |
|---|---|---|
| `AlarmStateChanged` + vendored `AlarmStateUpdate` proto (**field 24**) + `native_alarm_state` (`metadata_json`) + DCL stamping (OPC UA SelectClause **index 18**) | [repo] ✅ | Additive `AckTime` enrichment (§6.4) |
| `ScriptRuntimeContext` + `ScriptGlobals` | [repo] ✅ | New `Alarms` accessor + `ScriptAlarm` (incl. `AckTime`); local alarm-snapshot Ask |
| `ScriptCompileSurface` + Central UI `SandboxScriptHost` | [repo] ✅ | Mirror `Alarms` stub on both design-time surfaces |
| `ScriptTrustPolicy` (#25) | [repo] ✅ | Verified — **no change needed** (deny-list, not member allow-list); pinned by test |
| `GetAlarmSnapshotRequest`/`Response` (Commons) | [repo] ✅ | New, additive — chosen over reusing `DebugSnapshotRequest` |
| Site Runtime + DCL + Script Analysis + Inbound API docs + `CLAUDE.md` | [repo] ✅ | Document `Alarms` accessor, `AckTime`, endpoints |
| Unit tests (Commons / DCL / SiteRuntime / ScriptAnalysis / Communication / CentralUI) | [repo] ✅ | New |
| `SimpleAlarmStatusRequest` (id 9) | [deployed] | Stub → real router (+ not-supported catch) |
| `AlarmStatus` (new) | [deployed] | New inbound method, existing MES key authorized |
| `CvdReactor.SimpleAlarmStatus` / `.AlarmStatus` | [deployed] | New native-source scripts (MES band 900999) |
| Galaxy alarm priorities | [external] | MES-relevant CvdReactor alarms configured into 900999 (§6.1 prerequisite) |