Skip to main content

DFE Expression Language — CEL

Status: Adopted Last Updated: 2026-03-03


Decision​

DFE adopts CEL (Common Expression Language) as the unified expression language across all components — Python engine, Rust services, and CLI tooling.

Why CEL:

  • Purpose-built for evaluating conditions against structured data (filtering, matching, policy)
  • Same implementation in both languages — Rust crate cel-interpreter v0.10 powers both Rust services and Python (via common-expression-language PyO3 bindings). Zero behavioural drift.
  • Bounded execution — not Turing-complete, guaranteed termination. No loops, no recursion, no side effects. Critical for per-row evaluation in data pipelines.
  • Compile-once, evaluate-many — parse expression to AST once, evaluate against millions of rows
  • Industry standard — Kubernetes admission control, Envoy proxy, Firebase Security Rules, Google Cloud IAM
  • Extensible — custom functions can be registered for domain-specific needs

Packages:

LanguagePackageVersionNotes
Rustcel-interpreter0.10Native implementation
Pythoncommon-expression-language>=0.5.6PyO3 wrapper of same Rust crate

References:


What CEL Replaces​

ComponentBeforeAfter
Hunt scoring when conditionsHand-rolled regex parser in scoring.pyCEL via scalo.expression
Alert trigger evaluationHardcoded operator dict in alert.pyCEL conditions
dfe-loader transform conditionsCustom if(condition, ...) parserCEL via scalo::expression (scalo-rs)
Routing rules (future)Not yet implementedCEL from day one

What CEL Does NOT Replace​

SystemReason
@directive: body DSL (@source, @generated, etc.)Schema metadata — column mapping instructions, not boolean conditions
ClickHouse SQL in @generated expressionsRuns in ClickHouse, not our evaluator
Hunt SQL queries / rule_rewriterClickHouse-native SQL
Duration strings ("1h", "30m")Simple config values, not expressions

DFE Expression Profile​

DFE uses a subset of CEL. Expressions outside this profile are rejected at validation time.

Allowed Operations​

CategoryOperationsExamples
Comparison==, !=, <, <=, >, >=severity == "critical"
Logical&&, ||, !amount > 1000 && !is_test
Membershipinstatus in ["active", "pending"]
Arithmetic+, -, *, /, %price * quantity
Ternary? :is_admin ? 95 : 50

Allowed Functions​

FunctionPurposeExample
contains()Substring checkmessage.contains("error")
startsWith()Prefix checkpath.startsWith("/api/")
endsWith()Suffix checkfile.endsWith(".log")
matches()Regex match. A hunt rule's filter takes it. A scoring or alert condition takes it only when the config cascade sets expression.allow_regex: truehostname.matches("^web-[0-9]+$")
size()Length of string or listsize(tags) > 0
has()Field existencehas(event.user_id)
int()Cast to integerint(port_str) == 443
double()Cast to floatdouble(score_str) > 0.5
string()Cast to stringstring(code) == "200"
bool()Cast to booleanbool(enabled)

Excluded (Rejected at Validation)​

FeatureReason
map(), filter(), exists(), all()Per-element iteration — unpredictable performance on large lists
timestamp(), duration()ClickHouse handles time natively — no need to duplicate
MacrosComplexity with no benefit for DFE use cases

Supported Types​

CEL TypePythonRustClickHouse
intinti64Int64
doublefloatf64Float64
stringstrStringString
boolboolboolBool
listlistVec<Value>Array
mapdictHashMap<String, Value>—
nullNoneOption::NoneNullable

Usage Examples​

Hunt Scoring​

scoring:
base_score: 50
factors:
- when: 'severity == "critical"'
add: 25
- when: "amount > 10000"
multiply: 1.5
- when: 'status in ["active", "escalated"]'
add: 10
- when: "!has(assignee)"
add: 5

Alert Triggers​

alerts:
triggers:
- when: "result_count >= 10"
- when: 'severity == "critical" && result_count > 0'
- when: "score > 80"

Routing Rules (Future)​

routing:
rules:
- when: 'source.type == "syslog" && facility in [1, 2, 3]'
target: syslog_table
- when: 'message.contains("audit")'
target: audit_table

Computed Values (Transforms)​

columns:
risk_label:
expr: '@computed: risk_score > 80 ? "high" : risk_score > 40 ? "medium" : "low"'

CEL-to-ClickHouse SQL Transpilation​

CEL expressions can be mechanically converted to ClickHouse WHERE clauses for query pushdown:

CELClickHouse SQL
severity == "critical"severity = 'critical'
amount > 10000 && !is_testamount > 10000 AND NOT is_test
status in ["active", "pending"]status IN ('active', 'pending')
message.contains("error")position(message, 'error') > 0
path.startsWith("/api/")startsWith(path, '/api/')
hostname.matches("^web-[0-9]+$")match(hostname, '^web-[0-9]+$')
has(user_id)user_id IS NOT NULL
size(tags) > 0length(tags) > 0

This enables expressions written once in YAML to work both as in-memory evaluation (Rust/Python) and as ClickHouse query filters.


Implementation Architecture​

┌─────────────────────────────────────────────┐
│ DFE Expression Profile │
│ (subset of CEL, documented here) │
└─────────┬───────────────────┬───────────────┘
│ │
┌─────▼──────┐ ┌──────▼──────┐
│ scalo-py │ │ scalo-rs │
│ expression/ │ │ expression/ │
│ │ │ │
│ wraps: │ │ wraps: │
│ common- │ │ cel- │
│ expression- │ │ interpreter │
│ language │ │ crate │
└─────┬──────┘ └──────┬──────┘
│ │
┌─────▼──────┐ ┌──────▼──────┐
│ dfe-engine │ │ dfe-loader │
│ scoring.py │ │ transformer │
│ alert.py │ │ .rs │
└─────────────┘ └─────────────┘

Both wrappers enforce the same DFE profile — identical allowed operators, functions, and types. The Rust crate is the single source of truth for parsing and evaluation semantics.

API Surface (Both Languages)​

compile(expr) → Program # Parse + validate + compile to AST
evaluate(expr, data) → Any # One-shot convenience (compile + execute)
validate(expr) → list[str] # Syntax + profile check, returns errors for UI

Migration Notes​

Syntax Changes from Previous Hand-Rolled Evaluator​

Before (scoring.py)After (CEL)Notes
severity == 'critical'severity == "critical"CEL prefers double quotes (single also works)
amount > 10000amount > 10000No change
status in ('active', 'pending')status in ["active", "pending"]CEL uses [] for lists
status not in ('x', 'y')!(status in ["x", "y"])CEL uses !() for negation
enabled == trueenabled == trueNo change
a == 1 (single condition only)a == 1 && b == 2CEL supports compound conditions

Missing Field Behaviour​

  • Previous: evaluate_condition() returns False if field missing from data dict
  • CEL: Accessing a missing field is a runtime error
  • Resolution: Use has() for optional fields: has(event.user_id) && event.user_id == "admin"
  • Or: Provide default values in the evaluation context