# 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`](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).