Alarms and actions
PartialAn alarm detects a condition and holds it; an action does something about it. A simple
action is a single step (a notify); a multi-step action is a flow. Both are stateful
entities that hold their state directly (not event-sourced). An action’s credentials live
in credentials; the Expr and Go-template machinery in expressions.
The alarm (a stateful entity)
Section titled “The alarm (a stateful entity)”Metaphor: a morning alarm, not a pop-up. A pop-up alert appears and is gone (a notify
action); an alarm goes off when its condition is met and stays high until interacted with.
The alarm row holds its current state directly (status, opened_at, resolved_at in the
target design; built today: severity, message, raised_at, cleared_at, acknowledged_at,
acknowledged_by).
An alarm write is not sample-silent: raising or clearing one recomputes
health in the same transaction, and the rollup records the verdict
transition as a calculated-provenance property sample (provenance='calculated',
source_rule='health-rollup'). Cycle safety therefore rests on provenance, not topology
(ADR-0069,
see Cycle safety).
Acknowledgement: a fact about a person
Section titled “Acknowledgement: a fact about a person”An alarm’s raised state is a fact about its condition: the one-open invariant is per
(component, dedup_key) (ADR-0075),
and it is the condition, not a person, that decides whether the alarm is still up. An
acknowledgement is a fact about a person: somebody has seen this. The two are therefore
orthogonal, stored as two nullable columns rather than one status enum, and an alarm sits in
any of these states:
| | unacknowledged | acknowledged | | --- | --- | --- | | raised | nobody has looked; the queue an operator works | somebody is on it, and it is still broken | | cleared | it came and went unattended | it was seen, then fixed |
POST /components/{name}/alarms/{id}:acknowledge records it, gated by alarm:acknowledge
(held by operator and above). Three properties follow from acknowledgement being a fact about a
person:
- It does not recompute health. Acknowledging is not fixing. A recompute here would record a verdict transition at a moment when nothing about the fleet changed, and the health history is meant to be edges and only edges.
- It carries its own permission and its own scope, resolved on the component tier from
alarm:acknowledgeitself rather than fromcomponent:update. Recording that somebody looked is not editing the component, and a responder role may hold one without the other (identity and access). - It is idempotent. The first sighting is the one that matters operationally (time to acknowledge), so a second acknowledgement keeps the first person and the first time and writes no second audit row, the same shape as a re-raise of an open condition returning the existing incident.
Un-acknowledging is not a verb. If it turns out to matter, it is a second verb and a second decision, not a nullable field somebody flips (#728).
The action entity (ADR-0050)
The action (a stateful entity)
Section titled “The action (a stateful entity)”What an action_rule raises and runs; stateful, holding its own state directly:
- kinds:
notify(in-app),webhook,email,run(execute a command; edge realization in templates / nodes); - a simple action carries delivery state (
queued / sent / failed / retried, the at-least-once JetStream consumer); - a multistep action is a flow, carrying workflow state (current step, waiting, branches).
The action row carries identity, kind, config, and current status.
The action_rule (ADR-0050)
The action_rule (decoupled subscription)
Section titled “The action_rule (decoupled subscription)”Detection and response are separate (the event_rule does not contain its response, avoiding
Zabbix’s action/operation tangle). An action_rule is a NATS consumer on the CDC-published
event / alarm stream, selecting with an Expr predicate, so one rule serves many alarms.
Subscriptions are indexed by event key and label, so dispatch evaluates only predicates whose
key or label matches; a rule may carry multiple triggers and fires if any matches (including a
label or wildcard trigger, e.g. any event labeled room=boardroom-a). It is a subscription, not a
fourth sample-pipeline rule family:
action_rule: on: alarm when: 'transition == "open" && alarm.severity >= "high" && component.type == "device"' action: pagerduty-notifyThe source is polymorphic but guarded (see Cycle safety): an alarm transition, a scheduled
fire (an event with origin=scheduled), an operator (manual), and the declarative runbook
step-list. Bodies are Go templates, sink auth a credential reference
(expressions).
Storm and dependency suppression (ADR-0050)
Storm and dependency suppression
Section titled “Storm and dependency suppression”The alarm grain stays (event_rule, owner); two primitives keep the fan-out from becoming a
page storm. Dependency suppression mutes a child alarm whose owner’s parent entity on the
exclusive-arc structural tree is in an outage health state (open,
not dispatched); the arc gives the parent walk for free. Action-level grouping coalesces alarms
sharing owner / label / correlation_id into one action dispatch (one ticket with N
members) at the dispatch edge; the alarms stay distinct rows.
A system-scoped event_rule is the sanctioned upstream-cause dedup lever. Worked example: a
switch reboot downs 20 endpoints; the system-scoped rule owns the room-level cause as one
system-owned alarm, dependency suppression mutes the 20 child alarms, and grouping coalesces the
rest into one dispatch: the cause, not 20 symptoms.
Action durability and egress (ADR-0050)
Durability and egress
Section titled “Durability and egress”Action state is PG-first + CDC-out: the step transition commits to the action row, the
leader-elected CDC publisher fans it onto JetStream, and the external send is a JetStream
consumer (retry with backoff, dead-letter), not a Postgres SELECT ... FOR UPDATE SKIP LOCKED
relay. Sends are at-least-once; sinks tolerate dupes or an idempotency key (alarm + action +
transition) makes the outcome exactly-once. Pipeline order: render the body, then apply auth over
the rendered bytes (HMAC signs the rendered body), then send. Egress safety is always on:
block internal / metadata IPs, verify TLS, bound timeouts, control redirects.
The dead-letter surface and the operator retry of failed actions.
The observed-use auth-failure feedback from actions into credential health (paired with the credential-health model in config and credentials).
Cycle safety in the action layer (ADR-0069)
Cycle safety in the action layer
Section titled “Cycle safety in the action layer”The collection -> sample -> alarm core is acyclic by construction, and only data authors
events: an event_rule over samples (plus the clock’s origin=scheduled) is the only way an
event enters the log; flows and actions never manufacture events. That leaves one possible loop, the
data-mediated control loop (an action commands a device, the new state arrives as a sample,
opens an alarm, fires the action again), closed with three rules:
- Consequence writes are provenance-labeled, and a rule never routes on its own consequences.
Every consequence write carries
provenance='calculated'with its producing rule named (the health rollup’spropertyrow,source_rule='health-rollup'), and the routing layer refuses to trigger a rule off its own output (ADR-0069). - Operator transitions do not match
action_rules (only open / resolve do). Acknowledgement is the built one and behaves this way already: it fires nothing, because it recomputes nothing. Snooze remains unbuilt and refused (ADR-0109). - The control loop is lineage-guarded at dispatch: before running an action, the engine walks
the triggering event’s causation lineage (carried on NATS message headers); if the same
(action, owner)appears upstream it is suppressed, with a depth bound as backstop. Flows are finite by construction (a step list, per-open-alarm, gated on open, cancelled on resolve or acknowledgement).
The walk crosses the device round trip because the command stamps its correlation_id onto the
intended write and onto the adaptive-poll’s observed sample
(samples), and the event_rule firing off that sample inherits the id.
The carrier crosses the lane boundary as header (data lane) -> PG column -> header (record lane):
the event_rule writes the sample’s correlation_id and a source_event_id parent edge onto
the event row (events), and the CDC publisher re-emits both.
Correlation id: the trace of a causal chain
Section titled “Correlation id: the trace of a causal chain”The same lineage powers a read-side trace: a correlation id threading the whole causal
chain (sample -> event -> alarm -> flow / action -> command -> the value change that clears the
alarm), stamped at the head and propagated along each caused edge on NATS message headers, so
an operator can query “everything this one event set in motion” (the alarm_id links only one
alarm’s own open / clear events). Not a sample kind, not a stored span subsystem: no new
tables, no tracing backend.
Flows (ADR-0050)
Flows: the multi-step action
Section titled “Flows: the multi-step action”A flow is a bounded multi-step action: a DAG of steps (notify, command, wait,
branch, parallel) over one alarm, instantiated per open alarm, gated on the alarm staying
open, cancelled on resolve or ack, cycle-guarded by the lineage walk above, finite by a
depth / step cap, its wait steps on the durable per-incident timer (time).
The canonical case is an escalation: remediate, wait, re-check the real sample the alarm
is built on, escalate if unchanged. The same engine covers a time-bound access grant and an
AI-troubleshooting flow. It is deliberately a bounded workflow engine, not Turing-complete; a
flow does not author events, and any effect it has returns as ordinary data (the only edge that
could re-open its alarm, lineage-bounded). A drag-and-drop editor edits flows. Single-step and
multi-step are one model: only the length of the step list distinguishes them.
Registry namespacing for rules, actions, and severity levels (ADR-0050)
Namespacing
Section titled “Namespacing”Event rules, actions, and severity levels carry the same official boolean discipline as the
rest of the registries, seeded through each registry’s authoritative upsert (UpsertPropertyType
and siblings): official: true ships vetted, official: false is operator-authored and central to
component templates.
Storage
Section titled “Storage”alarm and action are stateful entities that hold their current state directly (not
event-sourced); the physical layout (the owner arc, partitioning) lives on
storage.
The built alarm row is component-local: no owner arc and no event_rule. It does carry its
acknowledgement (#728), as two nullable columns rather than a status enum, because an enum would
force acknowledged and cleared into one column and make them mutually exclusive, which they are
not. The target design shape adds event_rule, the owner arc, status, opened_at, and
resolved_at.
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | uuid | PK, default uuidv7() | |
severity | text | not null | How bad; the rollup and the console sort on it |
message | text | not null, default ''::text | |
raised_at | timestamp with time zone | not null, default now() | When the incident opened |
cleared_at | timestamp with time zone | NULL while open; the one-open index keys on it | |
created_at | timestamp with time zone | not null, default now() | |
updated_at | timestamp with time zone | not null, default now() | |
component_id | uuid | FK → component.id, not null | The component the incident is about (the thin-cut owner) |
dedup_key | text | not null | The condition identity (ADR-0075): one open alarm per (component, dedup_key), enforced by the partial unique index while open |
acknowledged_at | timestamp with time zone | When a human first recorded seeing it; NULL while nobody has. Orthogonal to cleared_at | |
acknowledged_by | uuid | FK → principal.id | Who recorded it; ON DELETE SET NULL, so purging a principal keeps the time, and audit_log keeps the name |
CHECK constraints and unique indexes on alarm
-
alarm_severity_check:CHECK ((severity = ANY (ARRAY['info'::text, 'warning'::text, 'critical'::text]))) -
alarm_open_condition_key:CREATE UNIQUE INDEX alarm_open_condition_key ON public.alarm USING btree (component_id, dedup_key) WHERE (cleared_at IS NULL)
The action entity (ADR-0050)
action is a stateful entity carrying delivery and step state (id, ordered steps of
notify/command/wait/branch, status, current_step), driven by events and alarms. No action
table exists today.
A command is two real tables: the command_type registry and the command invocation row with
its computed settlement; see commands. The event_rule / action_rule
config rows live with the rule families.
Model-keyed command cascade
Section titled “Model-keyed command cascade”An abstract action (reboot) resolves to different concrete commands by component type / model
through the cascade, so one reboot targets a heterogeneous fleet and each device runs the command
its model declares; the edge dispatch and the command declaration live in
templates / nodes.