Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
15 KiB
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:
- Add a
IsMesReportableboolean (checkbox, defaultfalse) to template alarms, and a matching tri-state override (inherited/true/false) on instance alarm overrides. - 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. - Ship an inbound API method that routes to the instance and performs the alarm check,
returning the legacy
AlarmStatusResponseshape withIsFlaggedForMESdriven by the flag. - 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:
InstanceActorrecordsIsMesReportableper alarm at Alarm Actor spawn (beside the existing_alarmPrioritiesmap) and stamps it inBuildAlarmStatesSnapshot()on every row (with { IsMesReportable = … }), retained or fallback. One place, so it cannot drift from an emit site.AlarmActoritself is untouched.AlarmStateChangedgains an additive init-onlybool IsMesReportable(defaultfalse) — the same additive convention asAckTime. It is deliberately NOT added to the gRPC protos in this slice; central never sees it (see §2.4).ScriptAlarmgains a trailingbool IsMesReportablepositional parameter. Native rows are alwaysfalse.AlarmsAccessorgainsMesReportableAsync(ct)=CurrentAsyncfiltered toIsMesReportable.CurrentAsync()is unchanged (an optional bool before theCancellationTokenwould 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 UISandboxScriptHost(SandboxAlarmsAccessor) — and pinned byCompileSurfaceParityTests.FlowShapePredictordoes 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.IsFlaggedForMESfor a native row therefore staysSeverity in band; for a computed row it becomes the flag. - Follow-up options, recorded in the deferred-work register: (a) a source-level
IsMesReportableonTemplateNativeAlarmSource+ 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:
flow-debug run-value-change --instance-id … --attribute Temperature --value 96→ HighTemp active.curl POST /api/AlarmStatus {"MachineCode":"MesAlarmDemo01"}→ 1 row,IsFlaggedForMES=true.--value 100→ TempSpike active;AlarmStatuswithFlaggedOnly=true→ still only HighTemp-style rows (TempSpike excluded);FlaggedOnly=false→ both, TempSpikefalse.--value -1→ SensorFault active;SimpleAlarmStatus→ SensorFault present withIsFlaggedForMES=true(override wins over the template'sfalse).--value 20→ all clear;AlarmStatus→ emptyAlarms,WasSuccessful=true.AlarmStatus {"MachineCode":"DelmiaSim01"}(no script) →WasSuccessful=false, "not supported on machine".- 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
LockEnforcererror; 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. AFlattenedConfigurationserialized before the change deserializes withIsMesReportable=false. - Mixed version: a NEW central deploying to an OLD site — the site ignores the unknown JSON
property; scripts there see
falseeverywhere. Upgrade sites with the central, as always. - Inbound method: unknown machine →
Instance '…' not foundtext passed through asErrorText; missing script → the "not supported" mapping; everything else →WasSuccessful=falsewithAlarmscleared. 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).