Rules are IFTTT-style condition → action mappings stored in the DB. They are evaluated on every MessageEvent by the router. Rules are data, not code — no runtime eval.
Conditions are JSON structures stored in rules.conditions. The evaluator in routing/rules_eval.py processes them against a flattened view of MessageEvent.
{ "all": [condition, ...] }
{ "any": [condition, ...] }
{ "none": [condition, ...] }all = AND (all must match); any = OR (at least one); none = NOR (none must match). Combinators nest up to MAX_NESTING_DEPTH = 5.
{ "field": "dotted.path", "op": "eq", "value": "string" }
{ "field": "dotted.path", "op": "neq", "value": "string" }
{ "field": "dotted.path", "op": "in", "value": ["a", "b"] }
{ "field": "dotted.path", "op": "not_in", "value": ["a", "b"] }
{ "field": "dotted.path", "op": "contains", "value": "substring" }
{ "field": "dotted.path", "op": "not_contains","value": "substring" }
{ "field": "dotted.path", "op": "matches", "value": "glob*pattern" }
{ "field": "dotted.path", "op": "lt", "value": 100 }
{ "field": "dotted.path", "op": "lte", "value": 100 }
{ "field": "dotted.path", "op": "gt", "value": 100 }
{ "field": "dotted.path", "op": "gte", "value": 100 }
{ "field": "dotted.path", "op": "exists" }
{ "field": "dotted.path", "op": "not_exists" }matches uses glob patterns only — no regex. This is a hard constraint. Glob patterns (*, ?, [...]) bound evaluation complexity and eliminate ReDoS risk. Regex is not supported and will be rejected by the evaluator.
{
"time_window":
{
"days": ["Mon","Tue"],
"start": "09:00",
"end": "17:00", "timezone": "Europe/London"
}
}
{ "autonomy_gte": "A2" }
{ "system_status": "healthy" }Dot notation resolves against a flattened dict view of MessageEvent. Maximum 5 path segments.
"source.channel" → event.source.channel
"content.structured.device_id" → event.content.structured["device_id"]
"actor.actor_type" → event.actor.actor_type
"context.device_id" → event.context.device_id{
"all": [
{ "field": "source.channel", "op": "eq", "value": "ha_event" },
{
"field": "content.structured.device_id",
"op": "eq",
"value": "sensor_1"
},
{ "field": "content.structured.state", "op": "eq", "value": "on" }
]
}When a rule matches, it dispatches one or more actions:
Injects a new MessageEvent as a child of the triggering event. The child event carries parent_event_id and shares trace_id.
EmitEventAction
channel: str — source.channel of the emitted event
structured: dict — payload
text: str | NoneInstantiates a named task template.
StartTaskAction
template_id: str — references a registered TaskTemplate
input_mapping: dict — maps event fields to task inputsShorthand EmitEventAction targeting a notification tool.
NotifyAction
message_template: str — simple {variable} substitution only; no eval
channel: str
urgency: str — normal | highBefore dispatching actions, the evaluator checks:
now - last_fired_at < debounce_ms, suppress and emit AuditEvent("rule.suppressed", reason="debounce").dedupe_key matches the stored last_dedupe_key and is within dedupe_window_ms, suppress and emit AuditEvent("rule.suppressed", reason="dedupe").quiet_hours_policy_id is set and the current time falls within the window, suppress and emit AuditEvent("rule.suppressed", reason="quiet_hours").A suppressed rule still appears in the audit log with a suppressed outcome and the reason.
Rules are loaded from DB and cached in memory. The cache is invalidated:
Evaluation safety limits enforced by ConditionEvaluator: MAX_NESTING_DEPTH = 5, MAX_CONDITIONS = 20, EVAL_TIMEOUT_MS = 10.