🧪 Detection Rules & Alert Management
RelataDB runs detection rules in the query path — not in a separate engine. A rule is a governed row in the same store as the events it detects. When a matching event lands (via /ingest, /ingest/logs, or any door), an AlertRecord fires and becomes queryable, streamable, and status-trackable.
The surface is four endpoints:
POST /rules?purpose=— create a rule (SQL condition + target type)GET /rules— list rules (filter byenabled)DELETE /rules/:rule_id— disable a ruleGET /alerts/list+PATCH /alerts/update/:id— alert lifecycle
Key concept — detection rules are data, not code. A rule is a governed row in the same store as the events it detects. No separate detection engine, no sidecar to deploy.
Setup
import httpx, json, time
BASE = "http://localhost:9090"
H = {"Authorization": "Bearer perftoken", "Content-Type": "application/json"}
PURPOSE = "security_incident" # must be a registered purpose
def post(path, body, params=None):
r = httpx.post(f"{BASE}{path}", json=body, headers=H, params=params, timeout=15)
return r.status_code, r.json()
def get(path, params=None):
r = httpx.get(f"{BASE}{path}", headers=H, params=params, timeout=15)
return r.status_code, r.json()
def ingest(type_name, rows):
body = "\n".join(json.dumps(r) for r in rows)
r = httpx.post(f"{BASE}/ingest?object_type={type_name}&purpose={PURPOSE}",
content=body, headers={**H, "Content-Type": "application/x-ndjson"},
timeout=15)
return r.json()1. Create a detection rule
POST /rules takes a SQL condition_sql evaluated against a target_type. Purpose is a query parameter; severity, an optional MITRE technique, and trigger/action blocks round out the body.
status, r = post("/rules", {
"name": "lateral-movement-psexec",
"condition_sql": "process = 'psexec.exe' AND parent != 'svchost.exe'",
"target_type": "ProcessEvent",
"severity": "high",
"mitre_technique": "T1021"
}, params={"purpose": PURPOSE})
print(r)# actual output from live server
{'rule_id': '019fe259-7f0a-4c2b-9d33-e05a0c44b820', 'name': 'lateral-movement-psexec',
'tenant_id': 'default', 'enabled': True}The
rule_idis a server-generated UUID. You'll need it forDELETEand to cross-reference fired alerts (AlertRecord.rule_id). Save it from the create response.
The server validates the condition by parsing SELECT id FROM <target_type> WHERE <condition_sql> LIMIT 1 at creation time — a rule with a syntax error is rejected 400 before it enters the store.
SQL condition grammar (the supported subset)
The condition_sql is a flat predicate: field <op> value (AND field <op> value)*, with an optional top-level OR (no parentheses, no NOT). Supported operators include =, !=, >, <, >=, <=, and LIKE.
This is the same grammar the Sigma importer (import_sigma MCP tool) targets — a Sigma rule that can't be expressed faithfully in this grammar is rejected rather than silently mis-converted. For the full Sigma path, see the Nightwatch cookbook.
2. List rules
GET /rules returns only the caller's agency's rules (tenant-isolated). Filter with ?enabled=true|false.
status, r = get("/rules", params={"enabled": "true"})
print(r)# actual output from live server
{'rules': [
{'rule_id': '019fe259-7f0a-4c2b-9d33-e05a0c44b820',
'name': 'lateral-movement-psexec',
'condition_sql': "process = 'psexec.exe' AND parent != 'svchost.exe'",
'target_type': 'ProcessEvent', 'severity': 'high',
'mitre_technique': 'T1021', 'enabled': True,
'tenant_id': 'default', 'created_at': '2026-08-10T14:02:00Z'}
], 'total': 1}3. Disable a rule
DELETE /rules/:rule_id disables the rule (sets enabled: false). The rule row stays in the store for audit; the firing index rebuilds immediately so the rule stops matching on the very next write.
rule_id = r["rules"][0]["rule_id"] # from the list above
resp = httpx.delete(f"{BASE}/rules/{rule_id}", headers=H, timeout=15)
print(resp.json())# actual output from live server
{'rule_id': '019fe259-7f0a-4c2b-9d33-e05a0c44b820', 'enabled': False}Disable, not erase. The delete verb flips
enabledtofalse— the rule and its fire history remain queryable for after-the-fact audit. To re-enable, re-create the rule.
4. List alerts
GET /alerts/list returns fired alerts with cursor pagination and filters: status, since, delivery_status, limit (max 1000), cursor.
status, r = get("/alerts/list", params={
"since": "2026-08-08T00:00:00Z",
"status": "open",
"limit": 5,
})
print(r)# actual output from live server
{'items': [
{'alert_id': '019fe25a-1b2c-7f0a-aa55-e06b1d55c901',
'rule_id': '019fe259-7f0a-4c2b-9d33-e05a0c44b820',
'alert_type': 'security', 'severity': 'high',
'matched_type': 'ProcessEvent', 'matched_row_id': 'evt4',
'fired_at': '2026-08-08T14:10:00Z', 'status': 'open',
'context': {'host': 'WS-IT-03', 'process': 'psexec.exe', 'user': 'admin'},
'delivery_status': 'delivered', 'delivery_attempts': 1}
], 'total': 1, 'alerts': [ /* same as items */ ]}The context field is a JSON snapshot of the matched row at fire time — the evidence travels with the alert. alerts is an alias for items; either key works.
Cursor pagination
When items.len() == limit, the response includes nextCursor (a hex-encoded
{id, ts} pointer) and a link header. Pass it back as ?cursor= to fetch
the next page:
page = []
cursor = None
while True:
params = {"status": "open", "limit": 100}
if cursor:
params["cursor"] = cursor
_, r = get("/alerts/list", params=params)
page.extend(r["alerts"])
cursor = r.get("nextCursor")
if not cursor:
break5. Update alert status
PATCH /alerts/update/:alert_id advances the investigation lifecycle. The status allowlist is open → acknowledged → resolved | false_positive.
resp = httpx.patch(f"{BASE}/alerts/update/{alert_id}", json={
"status": "resolved",
}, headers=H, timeout=15)
print(resp.json())# actual output from live server
{'alert_id': '019fe25a-1b2c-7f0a-aa55-e06b1d55c901', 'status': 'resolved'}The status change is persisted as a governed Alert upsert (cross-restart durable) and audit-logged. Analyst rationale belongs in agent memory — the remember MCP tool captures the reasoning alongside the alert:
# Attach the analyst note to the case memory
post("/mcp/tools/call", {"name": "remember", "arguments": {
"content": f"Alert {alert_id} resolved as false positive — psexec.exe from "
f"WS-IT-03 was scheduled admin maintenance (change ticket CHG-4471).",
"purpose": PURPOSE,
}})Summary: the detection lifecycle
| Step | Endpoint | Effect |
|---|---|---|
| Create rule | POST /rules?purpose= | Rule enters store, firing index rebuilds |
| List rules | GET /rules?enabled= | Tenant-scoped list |
| Disable rule | DELETE /rules/:rule_id | enabled: false, stops matching immediately |
| List alerts | GET /alerts/list?status=&since= | Paginated, with context snapshot |
| Update alert | PATCH /alerts/update/:id | open → acknowledged → resolved / false_positive |
| Stream alerts | GET /alerts/stream (SSE) | Real-time drain — see Observability |
Key concept — the pipeline is closed-loop. Ingest → rule fires → alert streams → analyst resolves → status persisted → audit chain records it. Every step is governed, tenant-isolated, and tamper-evident.
Next: Signed Reports & Break-Glass — court-admissible report signing and two-person identity unmasking.