Harbor Fresh Foods runs vans through two city-centre geofences with a 50 km/h limit, and wants one push per speeding episode. Their first rule opened six incidents in ten minutes for a van hovering at 50 km/h. Another van sped for half an hour and alerted once.
Both rules did what their policy said. This post covers the rule model the alert engine evaluates, one level below How alert rules fire and clear.
Every rule starts from a template in the rule template catalogue. The template owns the expressions: the condition, the clear condition, the dedup key and, for edge conditions, a hold condition. The rule owns parameters, targets, firing policy, severity and actions. Only custom rules carry their own expressions, and custom rules are off for each account by default.
The engine keeps one state per rule, subject and dedup key. The subject is a device or a unit, set in Scope · evaluated. The dedup key splits a subject further. Severity decides which channels reach each recipient, through their alert preferences by severity.
This is the document the alert rule form saves for the Harbor Fresh Foods rule. The IDs are placeholders.
{
"name": "City centre speeding",
"description": "Old Town and Harbour district, 50 km/h",
"template": "speeding_in_geofence",
"scope": "device",
"params": {
"geofences": ["<old-town-geofence-id>", "<harbour-district-geofence-id>"],
"limit_kmh": 50
},
"targets": {
"units": [],
"groups": ["<city-vans-group-id>"],
"accounts": [],
"all_units": false,
"include_children": false
},
"policy": {
"for_seconds": 10,
"clear_for_seconds": 30,
"rearm": { "mode": "interval", "seconds": 600 },
"cooldown_seconds": 300,
"max_per_hour": 0,
"max_per_day": 20,
"late_data": "evaluate_no_fire",
"schedule": {
"tz": "account",
"windows": [{ "days": [1, 2, 3, 4, 5, 6], "from": "06:00", "to": "22:00" }]
}
},
"severity": "warning",
"actions": [
{
"type": "notify",
"channels": ["push", "in_app"],
"recipients": { "mode": "team", "role_id": "<dispatch-role-id>", "include_child_users": false },
"content": { "subject_override": null, "body_override": null }
}
],
"enabled": true
}
The form sends no condition, source or account. The API takes the account from your session, and the engine takes the source and expressions from the template:
condition: geo.inside(params.geofences) && cm.speed > (geo.fence_speed_limit > 0 ? geo.fence_speed_limit : params.limit_kmh)
clear_condition: !geo.inside(params.geofences) || cm.speed <= (geo.fence_speed_limit > 0 ? geo.fence_speed_limit : params.limit_kmh)
dedup_key: geo.matched_fence_id
A geofence's own speed limit wins over limit_kmh, and the dedup key gives one incident per geofence. Template fixes reach existing rules without a re-save. for_seconds and clear_for_seconds go up to 86,400 (24 hours). Schedule days are ISO weekdays, 1 = Monday.
The template's source decides when the condition runs and what it can read.
| Source | Runs | Main namespaces |
|---|---|---|
| stream | On every message from the device | cm, prev, geo, act, win, state |
| pattern | On every message, against a sequence of events | as stream, plus seq |
| timer | When a device stays silent for a set time | cm (last message), geo, time |
| interval | Every tick, from trips, stops, idling and visits | iv |
| aggregate | Every tick, from daily or weekly totals | agg |
| fleet | Every tick, across a group | fleet |
Stream windows such as win.avg("speed", "10m") live inside the engine, capped at one hour by default; a longer window fails validation.
Tick rules run every 30 seconds by default, and when the report engine flags changed intervals for a unit. They share its idea of a trip, stop or visit, and they lag it. Three behaviours differ from stream rules:
A closed interval opens and clears its incident in one pass. If a recalculation deletes the interval, the engine retracts the incident. The trade-off: stream rules react at once but remember an hour; tick rules look back days but trail the report engine.
The policy is a state machine per rule, subject and dedup key:
IDLE --condition true--------------> PENDING
PENDING --held for_seconds------------> ACTIVE (incident opens)
PENDING --condition false-------------> IDLE
ACTIVE --clear condition true--------> CLEARING
CLEARING --held clear_for_seconds------> IDLE (incident clears)
CLEARING --clear condition false-------> ACTIVE
When both conditions are true, clear wins. Without a clear condition, the rule clears when the condition turns false. Hold and clear times use device time.
Re-arm decides what ACTIVE does while the condition holds. While it stays true offers Notify once (once, the default), a remind interval (interval, one minute minimum in the form) and On every message (every). A reminder adds a fire to the same incident; it never opens a new one.
Edge conditions (a change against prev, a geofence crossing, an activity) are true on one message only. They fire each time, re-arm has no effect, and Confirm before alerting needs a template hold condition.
When a fire is due, the engine checks these in order and records the first match as the reason:
| Reason | Holds back | When |
|---|---|---|
| schedule | opens and reminders | Message time is outside Only during these hours, in the account's time zone |
| cooldown | opens only | The last incident for this key closed less than Cooldown between incidents ago |
| cap_hour | opens and reminders | Sent fires this UTC hour reached the hourly cap |
| cap_day | opens and reminders | Sent fires today reached the daily cap; the day starts at the account's midnight |
| late | opens and reminders | The message is late and the rule uses evaluate, don't fire |
A held-back open still creates the incident without running its actions; Suppressed today in the Alert Centre counts these. Quiet hours and mutes, by contrast, stop deliveries to one user only.
Quotas apply on top, as deployment defaults an administrator can override per account:
A message more than two minutes older than the device's newest is late; Messages that arrive late, under Advanced, decides its fate. Any out-of-order message skips rules that read prev, geo, state.since or a pattern.
Applied to Truck 12: it enters Old Town at 07:58 doing 58 km/h. Ten seconds later the incident opens with a push. At 08:08 a reminder goes out. It slows at 08:09, and the incident clears 30 seconds later. At 08:12 it speeds again; the new incident opens silently, inside the 5-minute cooldown.
Test on history replays the rule as it is on screen, saved or not, over one unit's stored messages. It sends nothing. The form sends this request:
{
"rule": { "template": "speeding_in_geofence", "...": "the rest of the rule document" },
"unit_id": "<truck-12-unit-id>",
"from": "2026-10-05T00:00:00Z",
"to": "2026-10-06T00:00:00Z"
}
Send device_id instead of unit_id to replay one device. The period is 7 days at most, and the reply lists up to 500 incidents with fire and held-back counts. The replay runs the full policy, schedule and caps included, for stream and pattern rules only. The dialog is in Test a rule on past data.