# MES-Reportable Alarm Flag — Design **Date:** 2026-09-11 **Status:** IMPLEMENTED 2026-09-11 — merged to main @ 4cfa645b; rig live gate PASS 14/14 + Playwright override round-trip PASS (§8). QA/prod NOT deployed (remote exec on hold). **Supersedes (partially):** the severity-band decision in [`2026-06-30-mes-alarm-status-api.md`](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`](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 `TemplateAlarm`s**. 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): ```json { "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 ''`, 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.