Files
ScadaBridge/docs/plans/2026-09-11-mes-reportable-alarm-flag-design.md
T

15 KiB
Raw Blame History

MES-Reportable Alarm Flag — Design

Date: 2026-09-11 Status: IMPLEMENTED 2026-09-11 (rig gate pending — see §8 once Task 14 lands). Supersedes (partially): the severity-band decision in 2026-06-30-mes-alarm-status-api.md §6.1 for computed alarms only (native alarms keep the band — see §2.3). Component touchpoints: Template Engine (#1), Configuration Database, Site Runtime (#3), Script Analysis (#25), Central UI, CLI, Transport (#24), Inbound API (#14, deployed config only). Mockup: https://claude.ai/code/artifact/02ffcbf9-b87f-4e12-852d-38e2910c6056 (artifact "MES Reportable Alarms"). Implementation plan: 2026-09-11-mes-reportable-alarm-flag.md (16 tasks).


1. Goal

The legacy WWSupport / APIServer MES alarm endpoints decided which alarms were MES-relevant by a per-machine row in the BTDB MachineAlarm table (Name, Severity, FlaggedForMES). ScadaBridge already owns the alarm definition on the template, so the flag belongs there too:

  1. Add a IsMesReportable boolean (checkbox, default false) to template alarms, and a matching tri-state override (inherited / true / false) on instance alarm overrides.
  2. Carry the flag through flattening to the site so the script engine's alarm read (Alarms.CurrentAsync()) exposes it and a script can filter by it.
  3. Ship an inbound API method that routes to the instance and performs the alarm check, returning the legacy AlarmStatusResponse shape with IsFlaggedForMES driven by the flag.
  4. Prove all of it on the local docker rig (docker/) — seed template, instance, method, key, trip an alarm, call the endpoint.

No BTDB dependency anywhere. Everything the check needs lives on the deployed configuration.


2. Decisions and scope

2.1 Where the flag lives (data model)

Layer Change Semantics
TemplateAlarm (Commons entity) bool IsMesReportable Default false. Inherits down the template chain like PriorityLevel.
InstanceAlarmOverride (Commons entity) bool? IsMesReportableOverride null = inherit from template (the existing nullable-means-inherited convention, same as PriorityLevelOverride).
ResolvedAlarm (flattened DTO) bool IsMesReportable Effective value after override; rides deployed_configurations.config_json unchanged — no site schema change.
Config DB migration AddAlarmMesReportable TemplateAlarms.IsMesReportable bit NOT NULL DEFAULT 0; InstanceAlarmOverrides.IsMesReportableOverride bit NULL.

Naming follows the entity idiom already in the file (IsLocked, IsInherited). The legacy JSON name IsFlaggedForMES is preserved only on the API response, where MES clients depend on it.

Locking: the flag is an ordinary overridable field. A Locked alarm cannot be overridden at all (existing rule, LockEnforcer.ValidateAlarmOverride); an unlocked alarm's flag can be flipped per instance. Name/TriggerType stay the only fixed fields.

2.2 How the site exposes it (script engine)

The flag is stamped onto the alarm snapshot the script engine already reads:

  • InstanceActor records IsMesReportable per alarm at Alarm Actor spawn (beside the existing _alarmPriorities map) and stamps it in BuildAlarmStatesSnapshot() on every row (with { IsMesReportable = … }), retained or fallback. One place, so it cannot drift from an emit site. AlarmActor itself is untouched.
  • AlarmStateChanged gains an additive init-only bool IsMesReportable (default false) — the same additive convention as AckTime. It is deliberately NOT added to the gRPC protos in this slice; central never sees it (see §2.4).
  • ScriptAlarm gains a trailing bool IsMesReportable positional parameter. Native rows are always false.
  • AlarmsAccessor gains MesReportableAsync(ct) = CurrentAsync filtered to IsMesReportable. CurrentAsync() is unchanged (an optional bool before the CancellationToken would break positional callers). Scripts can equally .Where(a => a.IsMesReportable).
  • The accessor stub is mirrored on both hand-maintained compile surfaces — ScriptCompileSurface (CompileAlarmsAccessor) and the Central UI SandboxScriptHost (SandboxAlarmsAccessor) — and pinned by CompileSurfaceParityTests. FlowShapePredictor does not name accessor members, so it needs no change.

2.3 Native alarms are OUT of scope (and why)

Native alarms (OPC UA A&C / MxGateway mirrors) have no per-alarm configuration row anywhere: only the source binding (TemplateNativeAlarmSource) is configured, and the individual conditions are discovered at runtime. There is nowhere to put a per-alarm checkbox.

Consequences, stated plainly:

  • This feature covers computed TemplateAlarms. The production CvdReactor endpoints, whose alarms are all native sources, are not changed by it and keep the severity-band convention (900–999) from the 2026-06-30 plan. IsFlaggedForMES for a native row therefore stays Severity in band; for a computed row it becomes the flag.
  • Follow-up options, recorded in the deferred-work register: (a) a source-level IsMesReportable on TemplateNativeAlarmSource + its override ("every condition under this binding is reportable"), or (b) a new per-condition config entity — the first of its kind. Neither is designed here.

2.4 Central visibility is a follow-up

The Alarm Summary / Debug View rows are AlarmStateChanged values that arrive over gRPC. Showing a "MES" badge there needs additive proto fields (AlarmStateUpdate field 25, DebugAlarmStateDto field 20, manual regen-proto.sh) plus mapper changes. Not needed for the script check or the inbound API, so it is deferred (register row) to keep this slice free of wire changes. Design-time surfaces (template Alarms tab, Instance Configure overrides) DO show the flag.

2.5 The inbound API method (deployed config, rig)

AlarmStatus — the full-filter endpoint from the legacy spec, minus the BTDB machine lookup (the rig has no BTDB; MachineCode is the instance UniqueName, which is exactly what Route.To(code) resolves). Production adds its SAPID → Code lookup in front, as MesMoveIn does.

Request (--parameters, JSON Schema):

{ "type":"object",
  "properties":{
    "MachineCode":{"type":"string"},
    "AlarmFilter":{"type":"object","properties":{
      "FlaggedOnly":{"type":"boolean"}, "IncludeAcked":{"type":"boolean"},
      "MinSeverity":{"type":"integer"}, "MaxSeverity":{"type":"integer"},
      "NameFilter":{"type":"string"}}}},
  "required":["MachineCode"] }

Response (--return-def): { WasSuccessful, ErrorText, Alarms: AlarmInfo[] } with AlarmInfo = { Name, HierarchicalName, Description, IsFlaggedForMES, Severity, StatusCode, TriggeredDT, AckDT, AckComment } — byte-compatible with the legacy contract.

Router body: Route.To(MachineCode).Call("AlarmStatus", { MachineCode, ...AlarmFilter }), catching "not found on instance" → Alarms status is not supported on machine '<code>', any other exception → WasSuccessful=false with Alarms cleared (legacy all-or-nothing).

Site script AlarmStatus (Call, root of the demo template): Alarms.CurrentAsync() → Active && !IsConfiguredPlaceholder → FlaggedOnly ? IsMesReportable : true → severity bounds → name substring → IncludeAcked → project to AlarmInfo with IsFlaggedForMES = a.IsMesReportable. Filter order matches the legacy spec.

A SimpleAlarmStatus variant (flagged-only, acked always included) is one extra api-method create with the same site script and FlaggedOnly=true hard-wired; included in the seed as the second endpoint so both legacy routes are demonstrated.

2.6 Rig demo (all local)

docker/seed-mes-alarm-demo.sh (idempotent by name lookup, same style as seed-flow-log-demo.sh):

Object Detail
Template MesAlarmDemo static Double attribute Temperature = 20 (static ⇒ a value-change trips alarms synchronously, no device round-trip, no OPC UA String-only trap).
Alarm HighTemp HiLo on Temperature, hi 80 / hihi 95, priority 500, --mes-reportable.
Alarm TempSpike ValueMatch Temperature == 100, priority 300, not reportable.
Alarm SensorFault ValueMatch Temperature == -1, priority 700, not reportable on the template.
Script AlarmStatus Call, root; body per §2.5.
Instance MesAlarmDemo01 site 1; alarm override SensorFault → IsMesReportableOverride = true to prove the override path; deployed.
Inbound AlarmStatus, SimpleAlarmStatus per §2.5; one API key mes-alarm-demo scoped to both.

docker/verify-mes-alarm-demo.sh:

  1. flow-debug run-value-change --instance-id … --attribute Temperature --value 96 → HighTemp active.
  2. curl POST /api/AlarmStatus {"MachineCode":"MesAlarmDemo01"} → 1 row, IsFlaggedForMES=true.
  3. --value 100 → TempSpike active; AlarmStatus with FlaggedOnly=true → still only HighTemp-style rows (TempSpike excluded); FlaggedOnly=false → both, TempSpike false.
  4. --value -1 → SensorFault active; SimpleAlarmStatus → SensorFault present with IsFlaggedForMES=true (override wins over the template's false).
  5. --value 20 → all clear; AlarmStatus → empty Alarms, WasSuccessful=true.
  6. AlarmStatus {"MachineCode":"DelmiaSim01"} (no script) → WasSuccessful=false, "not supported on machine".
  7. Cross-check step 2 against debug snapshot --id … and the Flow Debugger replay.

3. Data flow

Central UI / CLI ──AddTemplateAlarm(IsMesReportable)──► ManagementActor ► TemplateService ► TemplateAlarms
Central UI / CLI ──SetInstanceAlarmOverride(IsMesReportableOverride?)──► InstanceService ► InstanceAlarmOverrides
                                    │
                         FlatteningService.ResolveInheritedAlarms  → ResolvedAlarm.IsMesReportable
                         FlatteningService.ApplyInstanceAlarmOverrides (ovr ?? existing)
                                    │  deploy (config_json, unchanged schema)
                                    ▼
Site: InstanceActor spawns AlarmActors; _alarmMesReportable[name] = alarm.IsMesReportable
      BuildAlarmStatesSnapshot() stamps IsMesReportable on every AlarmStateChanged row
      GetAlarmSnapshotResponse ─► ScriptRuntimeContext.ToScriptAlarm ─► ScriptAlarm.IsMesReportable
                                    │
      site script AlarmStatus: Alarms.CurrentAsync() / MesReportableAsync() → AlarmInfo[]
                                    ▲
Central inbound method AlarmStatus ── Route.To(MachineCode).Call("AlarmStatus", filter) ──┘
      ◄── X-API-Key ── MES client (curl on the rig)

4. Alternatives considered

Option Verdict
Keep the severity band (900–999) as the flag (2026-06-30 decision) Rejected for computed alarms: it couples MES relevance to priority, which operators tune for other reasons, and it is invisible in the template editor. Kept for native alarms only because there is no per-alarm row.
Central lookup table (a MesReportableAlarms table joined at request time, i.e. re-creating BTDB MachineAlarm) Rejected: the check runs at the site (that is the whole point of Route.To().Call) and the central table would be a second source of truth beside the template.
Stamp the flag in AlarmActor emits instead of the snapshot Rejected: three emit sites to keep in sync; the snapshot builder already owns the "fallback row for a quiet alarm" case, so stamping there covers fired and unfired alarms in one place.
CurrentAsync(bool reportableOnly = false, ct) Rejected: an optional bool before the token breaks existing positional CurrentAsync(ct) callers on three mirrored surfaces. A separate MesReportableAsync is additive.
Put IsMesReportable on the protos now Deferred (§2.4): no consumer in this slice; proto regen is manual and adds review surface.

5. Error handling

  • Override on a locked alarm → existing LockEnforcer error; UI hides locked alarms from the override list already.
  • Override with no delta — Instance Configure deletes an override when neither config nor priority is set; the delta check must include the tri-state so a reportable-only override is not silently deleted (known trap, InstanceConfigure.razor ~line 1628).
  • Old bundles / old artifacts: the Transport DTO fields are trailing with defaults; a bundle without them imports as false/null. A FlattenedConfiguration serialized before the change deserializes with IsMesReportable=false.
  • Mixed version: a NEW central deploying to an OLD site — the site ignores the unknown JSON property; scripts there see false everywhere. Upgrade sites with the central, as always.
  • Inbound method: unknown machine → Instance '…' not found text passed through as ErrorText; missing script → the "not supported" mapping; everything else → WasSuccessful=false with Alarms cleared. Never an HTTP 500 for a handled case.

6. Testing

Layer Tests
Commons entity + message convention sweeps pass unchanged (plain bool, record init-prop); ScriptAlarm positional back-compat test.
Config DB migration applies on SQL Server + SQLite test double; SchemaConfigurationTests persist round-trip of the two new columns.
Template Engine FlatteningServiceTests: template true → resolved true; override false wins; override null inherits; inherited (derived-template) alarm carries base value; LockEnforcerTests unchanged.
Site Runtime InstanceActor snapshot stamps the flag on fired AND unfired alarms; AlarmsAccessorTests: MesReportableAsync filters; CompileSurfaceParityTests sees the new member on all mirrors.
Script Analysis / Central UI compile-surface + SandboxScriptHost compile a script using MesReportableAsync() and a.IsMesReportable.
Management ManagementActorTests add/update/override carry the field; authz matrix unchanged (no new command names).
CLI EditingParityTests rows for template alarm add --mes-reportable and instance alarm-override set --mes-reportable false; README updated.
Transport RoundTripEquivalenceTests seeds non-default values (the reflection sweep fails otherwise); TemplateChildEquality/ArtifactDiff tests see the flag as a change.
Central UI structural test on TemplateAlarmDialog.razor (@bind="_isMesReportable"); bUnit/Playwright override round-trip AlarmOverride_MesReportable_RoundTrips beside the priority one.
Rig docker/verify-mes-alarm-demo.sh steps 1–7 (§2.6).

7. Documents to update in the same change

Component-TemplateEngine.md (alarm fields, override granularity table), Component-SiteRuntime.md (Alarms accessor + MesReportableAsync), Component-ScriptAnalysis.md (surface list), Component-InboundAPI.md (routing example: AlarmStatus), Component-CentralUI.md (dialog + override editor), Component-ConfigurationDatabase.md (migration), CLI README.md, CLAUDE.md native-alarm bullet (flag vs band), 2026-06-30-mes-alarm-status-api.md §6.1 (note: superseded for computed alarms), deferred-work register (native source-level flag; central badge/proto).