#Incident Protocol Specification
Version 1.0 — 2026-07-10
A shared contract for publishing machine incidents over MQTT. When a rule engine, threshold monitor, or watchdog detects a condition that requires attention, it publishes an incident to a single well-known topic. A forwarding agent subscribes to that topic and routes incidents to PagerDuty, email, Slack, or any external notification system.
This protocol is deliberately narrow in scope: it covers alerting only —
conditions that a human or an automated responder needs to act on. It is not a
general-purpose event bus. Both Edge Hub services — tsend2mqtt (PLC) and
iolinkmaster2mqtt (IO-Link) — emit incidents in this format from the shared
rules engine.
#Terminology
| Term | Meaning |
|---|---|
| Incident | A condition that requires attention. Has a lifecycle: trigger → (active) → resolve. |
| Producer | Any agent that detects conditions and publishes incidents (PLC gateway, IO-Link agent, watchdog). |
| Forwarder | A single agent that subscribes to incidents/, deduplicates, and routes to external systems (PagerDuty, Slack, etc.). |
| Source | The producer's identity, matching its id in the discovery protocol. |
| Dedup key | Groups related triggers into one logical incident. Same key = same incident. |
#Topic
incidents/
One topic. All producers publish here. The forwarder subscribes here. No per-source or per-severity topic splitting — filtering happens in the payload.
- QoS: 1 (at least once delivery)
- Retained: no (incidents are point-in-time notifications)
#Payload
Every message on incidents/ MUST be a JSON object with exactly the fields
below.
#Trigger
Published when a condition is first detected.
{
"action": "trigger",
"dedup_key": "plc1-Bool137",
"source": "plc1",
"severity": "critical",
"summary": "PLC alarm Bool137 active",
"time": "2026-07-10T14:30:00.123Z",
"data": {"tag": "Bool137", "value": true, "rule": "alarm-camera"}
}
#Resolve
Published when the condition clears. Must use the same dedup_key as the
original trigger. severity and summary are omitted — the forwarder matches by
dedup_key.
{
"action": "resolve",
"dedup_key": "plc1-Bool137",
"source": "plc1",
"time": "2026-07-10T14:30:42.456Z",
"data": {"tag": "Bool137"}
}
#Field definitions
| Field | Type | Required | Description |
|---|---|---|---|
action |
string | always | "trigger" or "resolve". |
dedup_key |
string | always | Unique identifier for this incident. Same key = same incident. Format: {source}-{condition_id}. Used by forwarders to group triggers and match resolves. |
source |
string | always | The id field from the producer's discovery entry. Links to the schema at discovery/{source}/{source}. |
severity |
string | trigger only | One of: critical, error, warning, info. Maps directly to PagerDuty severity levels. |
summary |
string | trigger only | Human-readable one-line description. Appears in push notifications and SMS. Keep under 120 characters. |
time |
string | always | ISO 8601 timestamp, UTC, millisecond precision. When the condition was detected, not when the message was published. |
data |
object | always | Machine-readable context. Structure is producer-specific. May be {}. |
No additional fields. Producers MUST NOT add fields outside those defined above. All incident-specific context goes in
data. This keeps the envelope stable and parseable by any forwarder without source-specific knowledge.datais always an object — never a scalar or array.
#Severity levels
| Severity | Meaning | Typical response |
|---|---|---|
critical |
Immediate action required. Production stopped, safety risk, equipment damage. | Page on-call, wake people up. |
error |
Something is broken but not immediately dangerous. Degraded operation. | Notify team channel, create ticket. |
warning |
Approaching a limit. Action needed soon but not urgently. | Dashboard highlight, daily digest. |
info |
Notable occurrence, no action required. Audit trail. | Log only. |
#Dedup key design
The dedup_key determines incident identity. Two messages with the same
dedup_key refer to the same incident.
Format: {source}-{condition_id} where condition_id is stable across
triggers — typically the rule name or the tag name that caused the incident.
Examples:
plc1-Bool137— alarm on a specific PLC tagplc1-alarm-camera— alarm from a specific ruleiolink_vib_pump_1-vrms_x_high— vibration threshold on a specific sensor
Rules:
- A
triggerwith adedup_keythat is already active (previously triggered, not yet resolved) is a duplicate — the forwarder SHOULD suppress it or update the existing incident. - A
resolvewith adedup_keythat is not active is a no-op — the forwarder SHOULD ignore it. - Producers MUST send
resolvewhen the condition clears. Stale incidents without a resolve are the producer's bug.
#Edge detection and lifecycle
The rule engine tracks the boolean result of each rule's condition across frames. This drives the trigger/resolve lifecycle:
| Transition | incidents/ |
|---|---|
| false → true (rising edge) | Publish trigger |
| true → true (no change) | Nothing |
| true → false (falling edge) | Publish resolve |
| false → false (no change) | Nothing |
The resolve fires automatically when the condition clears — the producer does
not need a separate resolve rule. Cooldown applies only to the rising edge
(trigger). The falling edge (resolve) is never suppressed.
#Producer requirements
- Publish a retained discovery entry at startup.
- When a rule fires: execute the action first, then publish the incident.
- Publish incidents to
incidents/with QoS 1, not retained. - Use a stable
dedup_keyper condition — the same condition re-triggering must use the same key. - Always send a
resolvewhen the triggering condition clears. - Set
timeto the time the condition was detected (e.g., the PLC frame timestamp), not wall clock. - Apply cooldown/debounce before publishing. A condition that bounces at 1 kHz must not produce 1000 incidents per second. Recommended: rising-edge detection + minimum cooldown of 5 seconds.
- Keep
summaryhuman-readable and under 120 characters.
#Forwarder requirements
- Subscribe to
incidents/at startup. - Subscribe to
discovery/#to build a source registry. - Deduplicate by
dedup_key: suppress duplicate triggers for already-active incidents. - Match
resolvemessages to active incidents bydedup_key. - Route based on
severity(e.g., critical → PagerDuty page, warning → Slack). - Handle unknown
sourcevalues gracefully (log warning, still forward). - Handle unknown
severityvalues by treating them aserror. - Track incident duration (time between trigger and resolve) for metrics.
#Mapping to PagerDuty Events API v2
For forwarders that route to PagerDuty, the mapping is direct — no transformation needed:
| Incident field | PagerDuty field |
|---|---|
action |
event_action (trigger / resolve) |
dedup_key |
dedup_key |
severity |
payload.severity |
summary |
payload.summary |
time |
payload.timestamp |
source |
payload.source |
data |
payload.custom_details |
#Wire compatibility checklist
Use this to verify your implementation:
- Incidents published to exactly
incidents/(notincidents/sourceor sub-topics) - QoS 1, not retained
actionis"trigger"or"resolve"dedup_keyis stable per condition, format{source}-{condition_id}sourcematches theidin your discovery entryseverityis one of:critical,error,warning,info(trigger only)summaryis human-readable, under 120 characters (trigger only)timeis ISO 8601 UTC with millisecond precisiondatais always an object (never a scalar or array)- Discovery entry published before first incident
- Every
triggeris eventually followed by aresolvewith the samededup_key - Cooldown/debounce applied — no burst of triggers for a bouncing condition