CEL Expressions
Write rules, gates, and predicates using Syncropel's canonical expression language — one syntax for triggers, routing, preconditions, fold rules, health checks, AITL, permissions, and fan-out join predicates.
Overview
Syncropel uses CEL (Common Expression Language) as its canonical decision language. Every decision point in the system — event triggers, routing rules, task preconditions, status fold, health checks, AITL (Actor-in-the-Loop), and permissions — accepts CEL expressions. Write the predicate once; it works the same way everywhere.
CEL is Google's type-safe, sandboxed expression language, used in production by Kubernetes admission controllers, gRPC, Envoy, Tekton, and GCP IAM. Syncropel extends CEL with a standard library exposing records, tasks, threads, trust, and system state.
See the full specification in the expression language specification.
Quick Start
Validate an expression without running it:
spl expr check 'record.act == "KNOW" && record.body.verdict == "accept"' --context triggerEvaluate an expression against real daemon state:
spl expr eval 'system.uptime_secs > 3600' --context health_checkBoth commands report compile errors at the exact character position so you can fix syntax before shipping.
The Eight Contexts
Each decision point has its own evaluation context with a specific set of bindings. Expressions are always bound to ONE context — you specify it with --context on the CLI or context: in a stored record. A trigger expression referencing tasks will fail to compile, because tasks isn't bound in trigger context.
| Context | Used For | Primary Bindings |
|---|---|---|
trigger | Event triggers matching incoming records | record, records, system, task(), trust(), thread(), now(), current_actor() |
routing | Routing rules | record, thread(), system, trust(), task(), now(), current_actor(), fanout_status() ** |
precondition | Task readiness gating | records, tasks, task(), trust(), thread(), now(), system, current_actor() |
fold_rule | Task status derivation | records (the task thread), task, thread(), now(), current_actor() |
health_check | spl health check predicates | system, tasks, trust(), now(), current_actor(), fleet_instances_live() ** |
aitl | Intelligence proposal disposition | record, proposal_confidence, meta_trust, system, now(), current_actor() |
permission | API request gating | record, current_actor(), resource, trust(), task(), system, now(), fleet_frozen_namespaces() / fleet_emergency_active() ** |
join ** | Fan-out barrier — when is a parent INTEND satisfied? | children, parent, now() |
Standard Library Reference
record
A single Syncropel record (available in trigger, routing, aitl, permission contexts):
record.act // string: "KNOW", "DO", "INTEND", "LEARN", etc.
record.actor // string: actor DID
record.thread // string: thread ID
record.body // map: the payload — access as record.body.topic, record.body.domain, etc.
record.body.topic // dot-access works for any body field
record.clock // int: logical timestamp
record.data_type // string: "SCALAR", "FORMULA", etc.
record.parents // list<string>: parent record IDsrecords
A list of records (available in trigger, precondition, fold_rule contexts). Use CEL's built-in macros:
records.exists(r, r.act == "KNOW" && r.body.topic == "feature_enabled")
records.filter(r, r.actor.startsWith("did:sync:agent:")).size()
records.all(r, r.body.reviewed == true)
records.map(r, r.cost_usd).reduce(acc, x, acc + x, 0.0)task(alias)
Look up a single task by alias. Returns a projection:
task("TASK-0163").status // string: "approved", "blocked", "active", etc.
task("TASK-0163").priority // string: "critical", "high", etc.
task("TASK-0163").assigned_to // string: actor DID
task("TASK-0163").age_secs // int: seconds since creation
task("TASK-0163").cost_usd // double
task("TASK-0163").exists // bool: false for unknown aliasesDefensive check: task("TASK-0163").exists && task("TASK-0163").status == "approved".
tasks
List of all known tasks (available in precondition and health_check contexts):
tasks.filter(t, t.status == "blocked").size()
tasks.exists(t, t.priority == "critical" && t.age_secs > 3600)
tasks.all(t, t.cost_usd < 5.0)thread(id)
Look up a thread by ID:
thread("th_abc...").status // string
thread("th_abc...").last_clock // int
thread("th_abc...").record_count // int
thread("th_abc...").participants // list<string>: actor DIDstrust(actor, domain) and trust_count(actor, domain)
Wilson lower-bound trust score + observation count:
trust("did:sync:agent:dev", "code") > 0.5
trust_count("did:sync:agent:dev", "code") >= 10 // require minimum evidencesystem
Daemon-level state (available in most contexts):
system.uptime_secs // int
system.records_total // int
system.threads_total // int
system.daemon_version // string
system.active_dispatches // list<Dispatch>
system.active_dispatches.all(d, d.duration_secs < d.timeout_secs * 2)
system.today.dispatches // int
system.today.cost_usd // double
system.today.tasks_completed // intEach Dispatch has: task_alias, actor, duration_secs, cost_usd, timeout_secs, sub_thread.
current_actor(), current_namespace(), and now()
current_actor() // string: caller's DID
current_actor().startsWith("did:sync:user:") // is a user (not agent)
current_namespace() // string: current namespace ID
current_namespace().startsWith("acme-corp/") // scope to all descendants
now() // int: unix secondscurrent_namespace() was added alongside the namespace hierarchy. Returns the namespace ID the current evaluation is scoped to (defaults to "default" when unset). Use .startsWith("parent/") to match an entire subtree of the 5-level hierarchy. Per-record namespace is accessible via record.body.namespace.
proposal_confidence and meta_trust (AITL context only)
For gating intelligence proposals:
proposal_confidence // double in [0, 1]
meta_trust.wilson_lb // double: intelligence's track record
meta_trust.total_proposals // int
meta_trust.accepted // int
meta_trust.rejected // intresource (Permission context only)
The resource being accessed, for gating API requests:
resource // string: "record_write", "dispatch", "aitl_decide", etc.
resource == "record_delete" && current_actor() == "did:sync:user:admin"Fleet bindings
Added with the fleet primitives. Scoped to specific contexts to avoid cross-context leakage.
fleet_frozen_namespaces() — Permission context only. Returns a list<string> of namespace IDs currently under soft or hard freeze per the th_fleet_control fold. Used in the default permission rule template that denies new INTEND/DO/CALL in frozen namespaces:
!fleet_frozen_namespaces().exists(ns,
record.body.namespace == ns &&
(record.act == "INTEND" || record.act == "DO" || record.act == "CALL"))fleet_hard_frozen_past_grace() — Permission context only. Returns a list<string> of namespaces where a hard freeze has passed its grace window. Used in the terminal-deny rule:
!fleet_hard_frozen_past_grace().exists(ns, record.body.namespace == ns)fleet_emergency_active() — Permission context only. Returns bool. True if an engine-wide emergency stop is active. Paired with an allow-only-reads clause:
!fleet_emergency_active() ||
(record.act == "GET" || record.act == "KNOW" || record.act == "LEARN")fleet_instances_live() — Health check context only. Returns list<string> of instance DIDs currently classified as live per the registry fold. Useful for health rules like "at least one worker must be reachable":
fleet_instances_live().filter(did, did.contains("worker-")).size() >= 1fanout_status(thread_id) — Routing and Permission contexts. Given a thread ID, returns one of "none", "spawning", "in_progress", "ready_to_join", "done". Useful for routing rules that check whether a parent fan-out has already completed before acting on child records.
children (Join context only)
The list of completed child results for a fan-out parent. Available only in the join context, which is evaluated to determine when a fan-out parent INTEND is satisfied.
children // list<ChildResult>
children.size() // int: how many reported so far
children.all(c, c.verdict == "accept") // "all" shorthand
children.exists(c, c.verdict == "accept") // "any" shorthand
children.filter(c, c.verdict == "accept").size() >= 3 // k-of-n with K=3Each ChildResult has the fields:
c.index // int: subtask index (0-based, matches body.subtasks position)
c.thread // string: child thread ID
c.worker // string: worker DID that executed the subtask
c.verdict // string: "accept" | "reject" | "failed" | "timeout"
c.cost_usd // double: subtask cost
c.wall_time_secs // int: elapsed wall-clock seconds
c.summary // string: from the child's KNOW bodyCustom join predicates can combine these for nuanced barriers:
// All succeed AND total cost is under budget AND no single child went wild
children.all(c, c.verdict == "accept") &&
children.map(c, c.cost_usd).reduce(acc, x, acc + x, 0.0) < 10.00 &&
children.all(c, c.cost_usd < 5.00)// K-of-N with at most M failures allowed
children.filter(c, c.verdict == "accept").size() >= 3 &&
children.filter(c, c.verdict == "failed" || c.verdict == "timeout").size() <= 1The parent binding in the join context is the fan-out parent record itself, available for referencing the original body or metadata:
parent.body.namespace // the fan-out's declared namespace
parent.body.subtasks.size() // total subtask countCommon Recipes
Trigger on Task Approval
spl config add-trigger \
--name on-approval \
--expression 'record.act == "KNOW" && record.body.verdict == "accept"' \
--target did:sync:agent:reviewerRouting Rule by Domain
spl config add-rule \
--name code-to-dev \
--expression 'record.act == "INTEND" && record.body.domain == "code"' \
--target did:sync:agent:devPrecondition via Expression
spl task add "ship v1.0" \
--alias REL-0001 \
--expression 'task("TASK-0163").status == "approved" && task("TASK-0164").status == "approved" && trust("did:sync:agent:dev", "code") > 0.5'The shortcut --depends-on TASK-0163,TASK-0164 compiles to an equivalent CEL expression internally, so you can mix the legacy sugar with the expression form.
Health Check for Stuck Dispatches
spl config add-health \
--name no-stuck-dispatches \
--severity critical \
--description "Alert if any dispatch runs past 2x its timeout" \
--expression 'system.active_dispatches.all(d, d.duration_secs < d.timeout_secs * 2)'Health Check for Daily Cost Budget
spl config add-health \
--name daily-cost-budget \
--severity warn \
--expression 'system.today.cost_usd < 10.0'AITL Auto-Apply High-Confidence Proposals
spl config add-aitl-rule \
--name auto-apply-high-confidence \
--action auto_apply \
--priority 100 \
--expression 'proposal_confidence > 0.8 && meta_trust.wilson_lb > 0.7'Permission: Users Can Only Approve Their Own Tasks
spl config add-permission-rule \
--name users-approve-own \
--action allow \
--priority 100 \
--expression 'current_actor().startsWith("did:sync:user:") && resource == "aitl_decide"'See the Permission Enforcement section below for the full security model.
Debugging
Compile Errors
spl expr check reports the exact position:
$ spl expr check 'record.act == "KNO" && record.clock + "x"' --context trigger
✗ compile error: type error: cannot add int and string at column 36Type errors are caught at compile time, not at evaluation. A stored expression has already been validated before it reaches the runtime — the daemon refuses to ingest a rule with bad CEL.
Evaluation Errors
Field-access errors against missing keys in record.body.* return runtime errors. These are data-dependent and can't be caught at compile time. Defend with has():
has(record.body.priority) && record.body.priority == "critical"Cache Stats
Monitor the compiled expression cache (how often expressions hit vs recompile):
curl -s http://localhost:9100/v1/engine/expression_cache/stats | jqFields: hits, misses, compile_errors, hit_rate_pct, avg_compile_micros, size, capacity. A well-tuned daemon under steady-state load should show hit rate >99%.
Performance
Per §10/20 performance characteristics:
| Operation | Bound |
|---|---|
| Compile a typical expression | < 1 ms (once, at config load) |
| Evaluate a simple expression | < 50 μs |
| Evaluate a complex expression | < 500 μs |
records.exists over a 10K snapshot | < 100 ms |
task() lookup | < 5 ms |
trust() lookup | < 1 ms |
Compiled expressions are cached in a 1024-entry LRU by default. Cache lifetime = daemon process lifetime.
Permission Enforcement
Permission rules are the one CEL feature that's off by default for backward compatibility. Enable them explicitly:
# 1. Author an admin allow rule FIRST. It must cover EVERY resource
# `spl config permissions-disable` touches (otherwise you'll lock
# yourself out and need permissions-unlock to recover):
# * record_write — POST /v1/records (the disable LEARN itself)
# * thread_read — GET /v1/threads/.../state (read last_clock first)
# * config_read — GET /v1/config/* (so you can list rules)
spl config add-permission-rule \
--name admin-self \
--action allow \
--priority 10000 \
--expression 'current_actor() == "did:sync:user:YOUR_ADMIN_DID" && (resource == "record_write" || resource == "thread_read" || resource == "config_read")'
# 2. Then author your policy rules
spl config add-permission-rule \
--name users-read-only \
--action allow \
--priority 100 \
--expression 'current_actor().startsWith("did:sync:user:") && (resource == "thread_read" || resource == "record_read")'
# 3. Finally, enable enforcement. A pre-flight runs first
# and refuses if your admin rule doesn't cover all three resources
# above — preventing the lockout trap before it happens.
spl config permissions-enableSecurity model: when enforcement is on, rules are evaluated in descending priority order. First match wins. If no rule matches, the request is denied (fail-closed). Every denial emits an audit log entry. GET /health is always exempt so liveness checks work.
Pre-flight check: spl config permissions-enable simulates the disable path before flipping the switch. It refuses with a clear error and prints the exact add-permission-rule invocation needed when your loaded rules don't cover (record_write, thread_read, config_read) for the daemon's identity.actor.
Emergency unlock: if you locked yourself out on an older version (or by other means — disabling the admin rule, dropping a deny rule, etc.), recovery is now one command instead of SQL surgery:
spl serve --stop # the daemon caches config in memory
spl config permissions-unlock # writes permissions_enabled=false directly to the store
spl serve --daemon # restart — enforcement is offpermissions-unlock opens the SQLite store directly and appends a permissions_enabled=false LEARN record on th_engine_config, bypassing the HTTP middleware that would otherwise deny the request. It refuses to run if the daemon is still up (use --force to override the safety check). Only sqlite stores are supported — for --memory daemons, just restart.
Migration from Legacy Forms
All legacy match forms still work — CEL is additive, not replacing. When both CEL and a legacy form are present, CEL takes precedence.
| Decision point | Legacy form | CEL form (preferred) |
|---|---|---|
| Triggers | --match "act=KNOW,body.topic=verdict" | --expression 'record.act == "KNOW" && record.body.topic == "verdict"' |
| Routing | --domain code --act INTEND | --expression 'record.act == "INTEND" && record.body.domain == "code"' |
| Preconditions | --depends-on TASK-0163,TASK-0164 | --expression 'task("TASK-0163").status == "approved" && task("TASK-0164").status == "approved"' |
The legacy --depends-on sugar compiles to CEL internally, so there's no behavior difference — it's still the recommended path for the simple "all of these tasks approved" case.
References
- Spec: the expression language specification — normative reference with test vectors
- CEL spec: github.com/google/cel-spec — upstream language reference
- Rust implementation: github.com/clarkmcc/cel-rust — the
cel-interpretercrate Syncropel uses
Scheduled triggers
Use cron-schedule event triggers to run periodic work — daily summaries, stale-task reminders, trust decay audits — without external schedulers.
Query
Filter records server-side with structured query documents. Supports nested body fields, logical combinators, pagination, and an EXPLAIN plan so you know when a filter hits an index.