Files
ScadaBridge/docs/plans/2026-09-11-mes-reportable-alarm-flag-design.md
T
Joseph Doherty 421d2d305e feat(docker): seed + verify scripts for the MES-reportable alarm demo — rig live gate PASS
Task 13/14. seed-mes-alarm-demo.sh builds a template whose three computed alarms differ in
how they acquire the flag (template --mes-reportable, nothing, instance override over a
template false), a site Call script projecting them through Alarms.CurrentAsync() /
MesReportableAsync(), and two inbound methods + a scoped API key. Idempotent by name lookup.

verify-mes-alarm-demo.sh drives the real inbound HTTP surface: PASS 14/14 on the docker rig
(migration 20260911135501_AddAlarmMesReportable applied at boot). Playwright
AlarmOverride_MesReportable_RoundTrips passed. Results + the facts the gate established are
in design doc section 8.

Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
2026-09-11 10:33:40 -04:00

20 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).


8. Rig live gate results (2026-09-11)

Seeded by docker/seed-mes-alarm-demo.sh, gated by docker/verify-mes-alarm-demo.sh against the docker/ rig (8 nodes, rebuilt from this branch). Config DB migration applied on boot:

[14:19:43 INF] [Central/scadabridge-central-a] Applying migration '20260911135501_AddAlarmMesReportable'.

8.1 Seeded objects

Object Id Notes
Template MesAlarmDemo 5255 Temperature Double static, value 20
Alarm HighTemp — HiLo hi=80 hihi=95, priority 500, --mes-reportable
Alarm TempSpike — ValueMatch 100, priority 300, not reportable
Alarm SensorFault — ValueMatch -1, priority 700, not reportable on the template
Site script AlarmStatus (Call) 1045 Alarms.CurrentAsync() / Alarms.MesReportableAsync() + the filter params
Instance MesAlarmDemo01 (site-a) 3847 instance alarm-override set --alarm SensorFault --mes-reportable true
Inbound method AlarmStatus 2224 nested AlarmFilter object param, routes via Route.To(MachineCode)
Inbound method SimpleAlarmStatus 2225 flagged-only shorthand
API key mes-alarm-demo — scoped to both methods; token printed once

Second seed run reported exists for template/attribute/all three alarms/instance, updated for both scripts and both methods, and refused to mint a second API key — idempotent, same ids.

8.2 Gate: PASS 14/14

Step Stimulus Result
A1 96 → AlarmStatus WasSuccessful=true, exactly 1 row, HighTemp
A2 same IsFlaggedForMES=true (template flag), Severity=500, StatusCode="Triggered.Acked", AckDT=null
A3 same HierarchicalName="MesAlarmDemo01.HighTemp"
H same moment debug snapshot --id 3847 shows HighTemp Active (condition.active=true)
B1/B2 100 → FlaggedOnly=false 2 rows: HighTemp true, TempSpike false (HiHi still active at 100, so both)
B3 100 → FlaggedOnly=true 1 row, HighTemp; TempSpike excluded by MesReportableAsync()
C1/C2 -1 → SimpleAlarmStatus 1 row, SensorFault, IsFlaggedForMES=true, Severity=700 — the instance override beat the template's false
C3 -1 → AlarmStatus SensorFault only; HighTemp absent (−1 trips no high band)
D1 -1 + MinSeverity=600 SensorFault only (700 ≥ 600; HighTemp 500 and TempSpike 300 filtered)
E1 20 WasSuccessful=true, Alarms=[]
F1 MachineCode=DelmiaSim01 WasSuccessful=false, ErrorText contains "not supported on machine" (not found on instance catch)
G1 MachineCode=NoSuchMachine WasSuccessful=false, ErrorText contains "not found"

Browser round-trip: dotnet test … --filter AlarmOverride_MesReportable_RoundTrips → Passed: 1, Failed: 0 (12 s).

8.3 Facts the gate established (read before extending the demo)

  • StatusCode is "Triggered.Acked" for every computed alarm, and AckDT is always null. AlarmConditionStateFactory.ForComputed reports Acknowledged: true — computed alarms have no ack-back, so there is nothing to be unacknowledged about. A filter such as the script's IncludeAcked=false therefore drops every computed alarm; it is only meaningful over mirrored native conditions. The plan's expected "Triggered" was wrong and the assertion was corrected.
  • Severity is the alarm's effective priority, and for a HiLo alarm it is the band priority (LevelPriority), falling back to the alarm priority when no per-band priority is configured — which is why HighTemp reads 500 at HiHi and MinSeverity=600 cleanly isolates SensorFault.
  • The debug snapshot / stream DTO reports isMesReportable: false for every alarm, including HighTemp. That is §2.2 working as specified — the flag is deliberately not on the gRPC protos in this slice, so central never sees it and only the site-local script snapshot is stamped. Step H therefore cross-checks alarm activity, not the flag. (Register item: central badge/proto.)
  • ValueMatch compares value?.ToString() as an ordinal string, so -1 and 100 matched the Double attribute's rendered form on this rig. A ValueMatch alarm over a Double is a string comparison, not a numeric one — RangeViolation is the numeric trigger.
  • --match-value=-1 must use the = form; a space-separated -1 is parsed as another option.

8.4 Deviations from the plan

  1. Step A's StatusCode expectation changed from "Triggered" to "Triggered.Acked" (§8.3).
  2. Step H parses alarmStates[].condition.active (the snapshot has no textual state field; the state enum is Active=0, Normal=1).
  3. SensorFault is seeded with --attribute Temperature — a ValueMatch trigger needs the watched attribute, which the plan's sketch omitted.
  4. site deploy-artifacts is not called: the demo has no shared scripts or system-wide artifacts, and instance deploy after the override is what puts the alarms and the script on site-a.