🧪 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 by enabled)
  • DELETE /rules/:rule_id — disable a rule
  • GET /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_id is a server-generated UUID. You'll need it for DELETE and 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 enabled to false — 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:
        break

5. 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

StepEndpointEffect
Create rulePOST /rules?purpose=Rule enters store, firing index rebuilds
List rulesGET /rules?enabled=Tenant-scoped list
Disable ruleDELETE /rules/:rule_idenabled: false, stops matching immediately
List alertsGET /alerts/list?status=&since=Paginated, with context snapshot
Update alertPATCH /alerts/update/:idopen → acknowledged → resolved / false_positive
Stream alertsGET /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.