# Alarms and the Rules Engine

## Alarms and the rules engine

Axiom Border decides locally. A probe that only forwards data is useless the moment the uplink drops,
so alarm evaluation runs on the device, against data it already holds, with no dependency on the
platform.

There are two ways to express what should raise an alarm:

| Mechanism | Use it for | Defined via |
| --- | --- | --- |
| **Checks** — declarative rules | Thresholds, event matches, dead-machine detection | `/checks` API |
| **Scripts** — rules written in JavaScript | Anything with logic: correlation, state, arithmetic, calls to the AI model | `/scripts` API |

Checks cover most of what monitoring needs and need no code. Scripts exist for the cases checks cannot
express.

## Where you work with alarms

**Alerts** in the console is the operator's view: the alarms currently raised, the history of what has
been logged, and the **Rules configuration** button that opens the editor for the rules described below.
An autoreload toggle keeps the list current while you watch an incident develop.

{{< staticImage "edge_products/axiom_border/console-alerts.png" "The Alerts view listing raised alarms with their severity and originating rule" >}}

Each row names the alarm, the check type behind it, the node it came from, the agent, the severity and a
description that includes when the condition was first seen. **ACK** acknowledges the selected alarm.

Two tabs sit above the table. **Recent** reads the live feed — the most recent alarms the probe already
holds, answered immediately. **Range** queries a time window from history instead, which is what you want
when reconstructing an incident after the fact:

{{< staticImage "edge_products/axiom_border/console-alerts-range.png" "The Range tab, querying alarm history over a time window" >}}

**Rules configuration** opens the *Alarm config* screen, which is where both kinds of rule live — the
declarative alarm rules in the upper table, and the JavaScript **Expert System** at the bottom with its
own enable switch:

{{< staticImage "edge_products/axiom_border/wizard-rules-config.png" "The Alarm config screen, listing alarm rules and the Expert System section" >}}

Everything on this page can be done from that screen or from the API, and both paths are shown together
wherever they differ.

## Checks — declarative rules

A check watches one event type from one agent over a time window, and raises an alarm when its condition
holds.

```mermaid
flowchart TB
    EV["Event arrives"] --> MATCH{"Matches<br>a check?"}
    MATCH -->|no| DROP["Stored only"]
    MATCH -->|yes| TYPE{"Check type"}
    TYPE -->|count| CNT{"Count over<br>threshold?"}
    TYPE -->|deadmachine| SILENT{"Silent for<br>the window?"}
    CNT -->|no| WAIT["Keep counting"]
    CNT -->|yes| FIRE["Raise alarm"]
    SILENT -->|yes| FIRE
    FIRE --> COAL{"Alarm<br>already open?"}
    COAL -->|yes| INC["Increment count"]
    COAL -->|no| NEW["New alarm"]
```

### The two check types

| `ctype` | Fires when | `threshold` |
| --- | --- | --- |
| `count` | The event occurs at least `threshold` times within `freqs` | Required |
| `deadmachine` | **No** events arrive from the node within `freqs` | Not used, send `0` |

`deadmachine` is the one worth calling out: it alarms on *absence*, which is how you detect a node that
stopped reporting rather than a node reporting something bad.

{{% notice style="tip" title="Alarming on every occurrence" icon="lightbulb" %}}
To raise an alarm whenever an event happens at all, use a `count` check with `"threshold": 1` and a short
`freqs`. The first matching event trips the condition immediately.
{{% /notice %}}

### Creating a check

{{< tabs groupid="ui-or-api" >}}
{{% tab title="Web console" %}}
From **Alerts → Rules configuration**, use **New alarm rule**. The wizard walks three steps — *Rule
config*, *Alarm config* and *Summary* — and collects exactly the fields described above:

{{< staticImage "edge_products/axiom_border/wizard-alarm-rule.png" "Step one of the new alarm rule wizard" >}}

*Rule type* is the check type, *Agent and event* choose what to watch, *Trigger* sets the window and the
count, and *UUIDs* selects the nodes. Only agents actually reporting in are offered, which is why a rule
cannot be created before its agent has sent data.

{{% /tab %}}
{{% tab title="REST API" %}}

```bash
curl -s -X POST http://192.168.1.10:8083/checks \
  -H "Authorization: Bearer <jwt-token>" \
  -H 'Content-Type: application/json' \
  -d '{
        "checkName": "ssh-bruteforce",
        "agent": "ssh",
        "ctype": "count",
        "event": "login_attempt_fail",
        "freqs": "5m",
        "threshold": 10,
        "level": "critical",
        "alarmName": "SSH brute force attempt",
        "alarmDescription": "More than 10 failed SSH logins in 5 minutes",
        "uuids": ["<machine-id>"],
        "enable": true
      }'
```

Every field is required on create. `freqs` is a duration string — `30s`, `5m`, `1h`. `level` is `info`,
`low`, `medium`, `high` or `critical`. `uuids` lists the nodes the check applies to, and accepts alias names when
the request carries `?alias=true`.

{{% /tab %}}
{{< /tabs >}}

A dead-machine check for the same node:

```bash
curl -s -X POST http://192.168.1.10:8083/checks \
  -H "Authorization: Bearer <jwt-token>" \
  -H 'Content-Type: application/json' \
  -d '{
        "checkName": "node-silent",
        "agent": "metrics",
        "ctype": "deadmachine",
        "event": "cpu",
        "freqs": "10m",
        "threshold": 0,
        "level": "critical",
        "alarmName": "Node stopped reporting",
        "alarmDescription": "No metrics received from the node in 10 minutes",
        "uuids": ["<machine-id>"],
        "enable": true
      }'
```

### Managing checks

| Method | Path | Notes |
| --- | --- | --- |
| GET | `/checks` | Lists all checks. Returns `204` when there are none |
| POST | `/checks` | Create. Returns `201` |
| PUT | `/checks` | Update |
| DELETE | `/checks` | Body `{"name": "<checkName>"}` |

{{% notice style="note" title="Three fields cannot be updated" icon="lightbulb" %}}
`PUT /checks` keeps the stored `ctype`, `agent` and `event`. To change what a check watches or how it
evaluates, delete it and create it again. Everything else — threshold, window, level, alarm text, nodes,
enablement — updates normally.
{{% /notice %}}

To pause a check without losing its definition, set `enable: false` rather than deleting it.

## Alarm coalescing

A brute-force attempt that trips a threshold every five minutes for an hour should be **one** alarm with
a count, not twelve identical alarms. Axiom Border coalesces repeated firings of the same rule against
the same node into a single open alarm:

| Field | Meaning |
| --- | --- |
| `firstSeen` | When the condition first held |
| `lastSeen` | The most recent firing |
| `count` | How many times it has fired |

The alarm stays open and accumulating until someone acknowledges it. **Acknowledgement is what closes
the coalescing window** — the next occurrence after an acknowledgement opens a fresh alarm, so the
operator gets a new signal rather than a silent increment on something they already dealt with.

### Acknowledging

```bash
curl -s -X PATCH 'http://192.168.1.10:8083/alarms?alarmid=<alarm-id>' \
  -H "Authorization: Bearer <jwt-token>"
```

This sets `ack=1` and records `ackTime`. Acknowledging an already-acknowledged alarm returns `400`, so a
UI can treat that as "someone else got there first" rather than an error worth surfacing loudly.

### Reading alarms

```bash
# Live feed
curl -s -X POST http://192.168.1.10:8083/recent/alarms \
  -H "Authorization: Bearer <jwt-token>" \
  -H 'Content-Type: application/json' \
  -d '{"limit": 50, "orderBy": "desc"}'

# Historical, paginated
curl -s -X POST http://192.168.1.10:8083/alarms \
  -H "Authorization: Bearer <jwt-token>" \
  -H 'Content-Type: application/json' \
  -d '{"from": "2026-08-01T00:00:00Z", "to": "2026-08-03T00:00:00Z", "pageSize": 100}'
```

An alarm carries `alarmId`, `alarmName`, `alarmDescription`, `criticality`, `eventType`, `agent`,
`agentUuid`, `time`, `ackTime`, and the coalescing trio.

Alarms are stored in the `eg_alarms` bucket. Evaluation applies a tolerance window set by
`alerts.offset` (default 10 s) to absorb clock skew and ingestion latency — without it, an event landing
milliseconds outside a window boundary would be missed.

## Rule scripts

For logic that a declarative check cannot express, Axiom Border runs rule scripts written in JavaScript.
They are evaluated on the device, server-side, on events you did not trigger.

In the console these are the **Expert System**, edited from **Alerts → Rules configuration → Edit script**.
The editor opens on a working skeleton, so you can see the shape a script must have before writing one:

{{< staticImage "edge_products/axiom_border/wizard-expert-system.png" "The Expert System script editor, showing the process(metrics) skeleton" >}}

Note the **Enable** switch beside the editor on the Alarm config screen: a saved script does nothing until
it is turned on.

### How scripts are organised

A script is identified by name, and the name determines when it runs. Scripts named after an agent —
`ssh`, `iface`, `usb`, `metrics` — run on events for that agent. Scripts named after a probe — `network`,
`vulns`, `snmp`, `sniff` — run on that probe's findings. A general-purpose `rulesengine` script runs over
every ingested batch.

Every script has an enabled state, set with the `?enabled=` query parameter when you upload it and
reported by `GET /scripts`. A disabled script is stored but never invoked.

### The script contract

A script must define a function called `process`, which receives the batch of metrics:

```javascript
function process(metrics) {
  for (var i = 0; i < metrics.length; i++) {
    var metric = metrics[i];
    metric.SetT0();
    // your logic here
    metric.SetT1();
  }
}
```

{{% notice style="warning" title="`metrics` is not a real JavaScript array" icon="triangle-exclamation" %}}
Iterate it by index with `.length`, as above. **`.filter()`, `.map()` and `.forEach()` are not available**
and will throw. This is the single most common mistake when writing a first rule.
{{% /notice %}}

`SetT0()` and `SetT1()` bracket your processing and are what populate the timing fields in the audit
record. They are not required for the rule to work, but including them is the convention and it makes
slow rules visible.

### Available globals

| Function | Purpose |
| --- | --- |
| `newAlarm(metric, criticality, description, alarmName, eventType)` | Raise an alarm |
| `newPredict(payloadJSON, agentType)` | Call the AI model for inference |
| `newGetMetrics(agentType)` | Fetch the AI model's own metrics |
| `filterByIp(metrics, ip)` | Subset of the batch matching an IP |
| `filterByProbe(metrics, probe)` | Subset matching a probe name |
| `distinctIps(metrics)` | Distinct IPs, in first-appearance order |
| `print(...)` | Write to the log at debug level |

Each metric exposes: `GetTagByName(key)`, `GetFieldByName(key)`, `ExistsField(key)`, `GetMetricName()`,
`GetAliasName()`, `GetAgentType()`, `GetMonitoredEvents()`, `GetEventValues()`, `GetTime()`, `SetT0()`,
`SetT1()` and `String()`.

A worked example, raising an alarm when a scan finds a risky port open:

```javascript
var RISKY_PORTS = { 23: "telnet", 21: "ftp", 3389: "rdp", 445: "smb", 5900: "vnc" };

function process(metrics) {
  for (var i = 0; i < metrics.length; i++) {
    var m = metrics[i];
    m.SetT0();

    if (m.GetTagByName("kind") === "port" && m.GetFieldByName("portStatus") === "open") {
      var port = m.GetFieldByName("portNumber");
      if (RISKY_PORTS[port]) {
        newAlarm(m, "critical",
          "Risky service " + RISKY_PORTS[port] + " exposed on port " + port,
          "Risky port open", "port_discovery");
      }
    }

    m.SetT1();
  }
}
```

The tags and fields available per probe are documented in the header comment of each deployed script, so
read the script with `GET /scripts/{scriptname}` before writing rules against it.

{{% notice style="note" title="What probe rules can react to" icon="lightbulb" %}}
Probe rules react to *appearances* and *changes*: a new host, a new open port, a changed SNMP value. To
alarm on a node that stops responding, use a `deadmachine` check against that node's agent data instead —
that is exactly what checks on absence are for.
{{% /notice %}}

### Managing scripts

| Method | Path | Notes |
| --- | --- | --- |
| GET | `/scripts` | Names and enabled state. `204` when there are none |
| GET | `/scripts/{scriptname}` | Source, as `text/plain` |
| POST | `/scripts/{scriptname}?enabled=<bool>` | Create. Body is the JavaScript source as `text/plain` |
| PUT | `/scripts/{scriptname}?enabled=<bool>` | Replace the source |
| DELETE | `/scripts/{scriptname}` | Delete |

```bash
# Upload a rule for SSH events
curl -s -X POST 'http://192.168.1.10:8083/scripts/ssh?enabled=true' \
  -H "Authorization: Bearer <jwt-token>" \
  -H 'Content-Type: text/plain' \
  --data-binary @ssh-rule.js

# Read back what is deployed
curl -s http://192.168.1.10:8083/scripts/ssh \
  -H "Authorization: Bearer <jwt-token>"
```

Uploading through the API is the recommended path — it works on a probe you have no shell access to, and
it is the same mechanism the web console uses.

{{% notice style="warning" title="Always set a script timeout" icon="triangle-exclamation" %}}
Each script invocation is bounded by `gojaTimeout`. Define it explicitly: without a value the limit is
zero and no script runs at all, which shows up as missing alarms rather than as an error. The shipped
value is `8s`.

```yaml
gojaTimeout: "8s"
```

{{% /notice %}}

### Scripts can call the AI model

A script can invoke inference against a deployed AI capability, using the `predict` endpoint configured
under `trainerDetails.predict`. This is what connects anomaly detection to alarm generation: the model
scores an event, and the script decides whether that score warrants an alarm.

Timeouts and retries for that call come from `trainerDetails.predict` — default 10 s, one retry. Note
that the call is bounded by `gojaTimeout` as well, so a `predict` timeout longer than the script timeout
cannot complete. Keep `gojaTimeout` comfortably above the `predict` timeout if your rules use inference.

See [AI capabilities](../ai_capabilities/) for deploying a model in the first place.

### Security probe results reach the rules engine too

Security probe findings are evaluated by the rules engine and audited exactly like agent metrics. A scan
finding can therefore raise an alarm the same way a metric can — a newly discovered host, a critical
vulnerability, a port that opened when it should not have. See [Security probes](../security_probes/).

## Choosing between a check and a script

Reach for a **check** when the condition is "this event, this many times, this window". It is
declarative, visible in the API, and cannot fail in interesting ways.

Reach for a **script** when you need to remember something between events, combine two signals,
compute a value, or ask the model. The cost is that scripts are code: they need a timeout that works,
they fail in ways checks do not, and they are harder to audit at a glance.

If a check can express it, use the check.

## Where alarms go next

Locally, alarms land in `eg_alarms` and surface through the feeds above and in the web console.

If OpenGate integration is enabled, alarms and executions also publish over the embedded MQTT broker and
are forwarded to the platform, which is how a fleet of probes becomes a single operational picture. See
[MQTT and OpenGate operations](../mqtt_and_opengate/).
