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
20 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).
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)
StatusCodeis"Triggered.Acked"for every computed alarm, andAckDTis alwaysnull.AlarmConditionStateFactory.ForComputedreportsAcknowledged: true— computed alarms have no ack-back, so there is nothing to be unacknowledged about. A filter such as the script'sIncludeAcked=falsetherefore drops every computed alarm; it is only meaningful over mirrored native conditions. The plan's expected"Triggered"was wrong and the assertion was corrected.Severityis 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 whyHighTempreads 500 at HiHi andMinSeverity=600cleanly isolatesSensorFault.- The
debug snapshot/ stream DTO reportsisMesReportable: falsefor every alarm, includingHighTemp. 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.) ValueMatchcomparesvalue?.ToString()as an ordinal string, so-1and100matched the Double attribute's rendered form on this rig. A ValueMatch alarm over a Double is a string comparison, not a numeric one —RangeViolationis the numeric trigger.--match-value=-1must use the=form; a space-separated-1is parsed as another option.
8.4 Deviations from the plan
- Step A's
StatusCodeexpectation changed from"Triggered"to"Triggered.Acked"(§8.3). - Step H parses
alarmStates[].condition.active(the snapshot has no textual state field; thestateenum isActive=0,Normal=1). SensorFaultis seeded with--attribute Temperature— aValueMatchtrigger needs the watched attribute, which the plan's sketch omitted.site deploy-artifactsis not called: the demo has no shared scripts or system-wide artifacts, andinstance deployafter the override is what puts the alarms and the script on site-a.