Ports the legacy WWSupport/APIServer MES alarm-status endpoints onto the Inbound API, mirroring the MesMoveIn/MesMoveOut pattern; draft for review, not yet executed. Committed ahead of PLAN-R2-08 T13's hygiene pass.
25 KiB
MES Alarm-Status API — Implementation Plan
Date: 2026-06-30 Status: Draft plan — NOT yet executed. Captures design + task breakdown for review. Component touchpoints: Inbound API (#14), Script Analysis (#25), Site Runtime (#3), Template Engine (#1) — plus deployed config (inbound methods + CvdReactor/MESReceiver template scripts).
1. Goal
Port the legacy WWSupport / APIServer MES alarm-status endpoints onto ScadaBridge's Inbound API, mirroring how MesMoveIn / MesMoveOut were ported:
- Update the existing
SimpleAlarmStatusRequestinbound method (currently a stub) to do real work. - Create a new
AlarmStatusinbound method (the full, filtered endpoint). - Both inbound methods forward to MES-receiver site scripts (
SAPID → BTDB machine lookup → Route.To(code).Call(...)), exactly likeMesMoveIn. - The generic version on the
MESReceivertemplate resolves the machine's alarms by querying the BTDBMachineAlarmtable (the legacy approach). - The
CvdReactortemplate provides an override version that reads the alarms actually defined on the CvdReactor object (its 7 native alarm-source bindings) directly — noMachineAlarmlookup.
Legacy spec being mirrored: docs/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 (0–999), StatusCode ("Triggered" / "Triggered.Acked"), TriggeredDT, AckDT?, AckComment.
Behaviors to preserve:
- Only triggered alarms returned (
InAlarm == true). No "all configured" mode.IncludeTriggeredis a no-op (legacy declared-but-unused). /simplealarmstatus: always flagged-only, ack filtering ignored (acked alarms always included, taggedTriggered.Acked)./alarmstatus:MachineFilterprecedenceSAPID → Code → ZTag → MachineID;AlarmFilterorderFlaggedOnly → MinSeverity → MaxSeverity → NameFilter(substring, case-insensitive; severity bounds inclusive);IncludeAcked=falseexcludes 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 toAlarmInfo(Name/HierarchicalName/Description/IsFlaggedForMES/Severity/StatusCode/TriggeredDT/AckDT/AckComment). ✅ no return-contract change needed.- No
AlarmStatusmethod 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 scriptsIpsenMoveIn,ProcessRecipeDownload,MesMoveIn,MesMoveOut; 7 native alarm sources (all overMxGateway):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 computedTemplateAlarms.MESReceiver(id 3, parent=none): root scriptsMoveIn,MoveOut(scope-bound to the composed receiver, bareAttributes);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 isScriptCompileSurface(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
Alarmglobal 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 ┌────► MESReceiver.SimpleAlarmStatus (BTDB MachineAlarm)
├─ Route.To(code).Call(...) ──┤ MESReceiver.AlarmStatus (BTDB MachineAlarm)
AlarmStatus (new) ─┘ (ScriptRuntimeContext + └────► CvdReactor.SimpleAlarmStatus (native sources — OVERRIDE)
ScriptCompileSurface) CvdReactor.AlarmStatus (native sources — OVERRIDE)
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.
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
codebySAPID → Code → ZTag → MachineID(each its ownSELECT TOP 1 Code FROM dbo.Machine WHERE ...), selector-specific error text on unmatched, thenRoute.To(code).Call("AlarmStatus", new { MachineCode = code, SAPID = <raw MachineFilter.SAPID or "">, ...AlarmFilter fields... }). The rawMachineFilter.SAPID(suffix included) is forwarded so the CvdReactor override can scope by side; when the machine was selected byCode/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
MachineCodeplus the alarm filter.
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):
// 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,
string CurrentValue, string LimitValue, bool IsConfiguredPlaceholder);
Implementation:
- Runtime:
Alarms.CurrentAsync()Asks the Instance Actor for its alarm snapshot (reuse the existing internal alarm-state map that feedsDebugViewSnapshot.AlarmStates; project eachAlarmStateChanged→ScriptAlarm). New internal request/response message (or reuseDebugSnapshotRequestand project offAlarmStates). - Compile surface: matching stub returning
Task.FromResult(empty)so design-time compile + Test Run pass. ScriptTrustPolicy(#25): no new forbidden surface;Alarmsis an allow-listed context member likeAttributes. Confirm the validator's allow-list includes it.- Additive message-contract evolution only.
5.3 Layer C — Site template scripts (deployed config)
MESReceiver (generic / "as before" — queries BTDB MachineAlarm): SimpleAlarmStatus and AlarmStatus.
- Use
await Database.Connection("BTDB")(raw ADO.NET — site Database helper has noQuerySingleAsync; that's inbound-only) to read the machine'sMachineAlarmrows (Name,Severity,FlaggedForMES, …) for the resolvedMachineCode. - Apply legacy filter semantics (Simple = flagged-only; full =
FlaggedOnly/MinSeverity/MaxSeverity/NameFilter). - Determine live "triggered" state + ack/timestamps and project to
AlarmInfo(live-state source = open question §6.2). - No
_A/_Bside parsing. This generic version is whole-machine, matching the legacy endpoint — it accepts a bare/numeric SAPID and never requires (or errors on) a missing side suffix. Side routing is CvdReactor-only (§5.3).
CvdReactor (override — reads native sources directly, no DB): SimpleAlarmStatus and AlarmStatus as root-level scripts (so Route.To(code).Call("SimpleAlarmStatus") resolves CvdReactor's version for CvdReactor instances — 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 |
// CvdReactor.SimpleAlarmStatus (sketch)
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: flagged-only + acked always included (see §6.1 for FlaggedForMES)
.Select(a => new {
Name = a.Name,
HierarchicalName = code + "." + a.Name,
Description = a.Message, // §6.3
IsFlaggedForMES = true, // §6.1
Severity = a.Severity,
StatusCode = a.Acknowledged ? "Triggered.Acked" : "Triggered",
TriggeredDT = (a.OriginalRaiseTime ?? a.Timestamp),
AckDT = (DateTime?)null, // §6.4
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 — but additionally applies the passed AlarmFilter (NameFilter/MinSeverity/MaxSeverity/FlaggedOnly/IncludeAcked) over the scoped native list. (When AlarmStatus was selected by Code/ZTag/MachineID with no SAPID suffix, side == null; in that case return all sources instead of erroring — see §6.6.)
Shared
ReactorAlarms(Z28061.) is included on both sides — reactor-wide faults apply regardless of side. Flip this if MES wants strictly side-local alarms (drop theStartsWith("Reactor")clause).
6. Key design decisions & OPEN QUESTIONS (resolve before execution)
These are the Socratic checkpoints — they change script bodies and/or the contract.
-
IsFlaggedForMESfor native alarms. The native model has no MES flag (it lived inMachineAlarm.FlaggedForMES). Options: (a) treat all native alarms as MES-relevant (true); (b) reintroduce an MES allow-list (per native source binding, or a small config/table) the override consults; (c) forSimpleAlarmStatus(flagged-only) return all, and only honorFlaggedOnlyin the full endpoint by cross-referencingMachineAlarm. Recommend (a) for v1 with (b) as a follow-up, unless MES needs a true flag. -
MESReceiver live-state source. MESReceiver has no native alarm sources, so after reading
MachineAlarmconfig it has no live "InAlarm" source. Options: (a) MESReceiver version returns configured alarms only (no live triggered filter) — explicitly a degraded/reference path; (b) MESReceiver version ALSO readsAlarms.CurrentAsync()and cross-referencesMachineAlarmby name (works only if the instance happens to mirror native alarms); (c) declare the BTDB-only path returns config and document that live status requires the CvdReactor-style override. Recommend (c) — the real live path is CvdReactor; the MESReceiver version demonstrates the MachineAlarm-driven catalog and is the fallback for machine types catalogued in SQL. -
Descriptionmapping. LegacyDescriptioncame from the MXAccessDescAttrNametag.AlarmStateChangedhas no dedicated description; closest isMessage(per-band operator text) /AlarmTypeName/Category. RecommendMessage, fall back toAlarmTypeName. Confirm acceptable. -
AckDTmapping. No explicit ack-timestamp inAlarmStateChanged(Timestamp= last change,OriginalRaiseTime= raise). For acked alarms we can't precisely fillAckDT. Options: (a) leavenull; (b) useTimestampwhenAcknowledged(approximate); (c) enrich the native mirror to carry an ack timestamp (larger change). Recommend (a) for v1, (c) as a follow-up if MES depends on it. -
Script naming / override resolution. Keep script names identical across both templates (
SimpleAlarmStatus,AlarmStatus) so the inbound method is template-agnostic (Call("AlarmStatus")), and CvdReactor's root-level definition is whatRoute.Callresolves for CvdReactor instances. (Alternative:Mes-prefixed CvdReactor scripts likeMesMoveIn.) Recommend identical names; confirmRoute.Callresolves a CvdReactor root script over any composed-module script of the same name (verify during impl — the MoveIn precedent says yes). -
Side scoping — DECIDED, CvdReactor-only. Only the CvdReactor override routes off the SAPID suffix:
_A⇒ Left,_B⇒ Right;_LTis ignored (stripped before reading the side; leak-test sources still included). MirrorsMesMoveIn. The generic MESReceiver version and the inbound method do not parse or require the suffix — they stay whole-machine (legacy). Two residual sub-choices remain: (a) sharedReactorAlarms(Z28061.) included on both sides (current plan) vs strictly side-local — confirm; (b) missing_A/_Bsuffix — the CvdReactorSimpleAlarmStatuserrors (matchesMesMoveIn); the CvdReactorAlarmStatusselected byCode/ZTag/MachineID(no SAPID) returns all sources rather than erroring; the MESReceiver version never errors on a missing suffix — confirm acceptable. -
New method roles / auth.
AlarmStatusis a new inboundApiMethod(X-API-Key); creating it requiresRoles.Designer. No new global roles. Confirm the API key in use is authorized.
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 [repo]
- Add
AlarmsAccessor+ScriptAlarmto the runtime context (ScriptRuntimeContext) — local Ask to the Instance Actor; projectAlarmStateChanged→ScriptAlarm. Add internal request/response message if not reusingDebugSnapshotRequest. - Mirror the stub on
ScriptCompileSurface(and confirmTriggerCompileSurfacenot needed — trigger expressions don't read alarms). - Confirm/extend
ScriptTrustPolicyallow-list soAlarmsis permitted; no new forbidden APIs. - Unit tests: runtime accessor projection (active/acked/severity/timestamps), compile-surface compiles a representative
Alarms.CurrentAsync()script, trust-policy accepts it. - Doc: update
docs/requirements/Component-SiteRuntime.md(+ Script Analysis #25 surface list) to document theAlarmsaccessor; note it inComponent-InboundAPI.mdrouting examples.
Phase 2 — Inbound methods [deployed] + doc [repo]
6. Update SimpleAlarmStatusRequest (id 9) body to the §5.1 router (validate via design page first to avoid the stale-handler trap — see memory inbound-noncompiling-update-keeps-old-handler).
7. Create AlarmStatus method (params/return/script per §5.1).
8. 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]
9. Add SimpleAlarmStatus + AlarmStatus to MESReceiver (BTDB MachineAlarm via Database.Connection("BTDB")), per §6.2 decision.
10. Add override SimpleAlarmStatus + AlarmStatus to CvdReactor (native sources via Alarms.CurrentAsync()), per §5.3.
11. template validate both templates (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 + filtered.
8. Testing & verification
- Unit [repo]:
Alarmsaccessor projection; compile-surface acceptance; trust-policy allow; inbound param/return validation forAlarmStatus(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]:
curlsimplealarmstatusandalarmstatusagainst the deployed reactor; cross-check returned alarms against the CLI debug snapshot (debug snapshot --id <instance>) alarm table for the same instance; verify onlyActivealarms 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
IsFlaggedForMESfor native alarms (per-source MES allow-list) — §6.1 option (b). - Precise
AckDT(enrich native mirror with an ack timestamp) — §6.4 option (c). - A live/aggregated central alarm store or stream (the M7 follow-up) — these endpoints stay pull-based, per-instance, like the Alarm Summary page.
- MESReceiver live-state path if §6.2 (c) is chosen and MES later needs live status from non-native machines.
10. Summary of changes
| Artifact | Type | Change |
|---|---|---|
ScriptRuntimeContext |
[repo] | New Alarms accessor + ScriptAlarm; local alarm-snapshot Ask |
ScriptCompileSurface |
[repo] | Mirror Alarms stub |
ScriptTrustPolicy (#25) |
[repo] | Allow Alarms member (verify) |
Internal alarm-snapshot message (or reuse DebugSnapshotRequest) |
[repo] | Additive |
| Site Runtime + Script Analysis + Inbound API docs | [repo] | Document Alarms accessor + endpoints |
| Unit tests (SiteRuntime / ScriptAnalysis / InboundAPI) | [repo] | New |
SimpleAlarmStatusRequest (id 9) |
[deployed] | Stub → real router |
AlarmStatus (new) |
[deployed] | New inbound method |
MESReceiver.SimpleAlarmStatus / .AlarmStatus |
[deployed] | New BTDB-driven scripts |
CvdReactor.SimpleAlarmStatus / .AlarmStatus |
[deployed] | New native-source override scripts |