SSyncropel Docs

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 trigger

Evaluate an expression against real daemon state:

spl expr eval 'system.uptime_secs > 3600' --context health_check

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

ContextUsed ForPrimary Bindings
triggerEvent triggers matching incoming recordsrecord, records, system, task(), trust(), thread(), now(), current_actor()
routingRouting rulesrecord, thread(), system, trust(), task(), now(), current_actor(), fanout_status() **
preconditionTask readiness gatingrecords, tasks, task(), trust(), thread(), now(), system, current_actor()
fold_ruleTask status derivationrecords (the task thread), task, thread(), now(), current_actor()
health_checkspl health check predicatessystem, tasks, trust(), now(), current_actor(), fleet_instances_live() **
aitlIntelligence proposal dispositionrecord, proposal_confidence, meta_trust, system, now(), current_actor()
permissionAPI request gatingrecord, 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 IDs

records

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 aliases

Defensive 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 DIDs

trust(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 evidence

system

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                   // int

Each 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 seconds

current_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                    // int

resource (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() >= 1

fanout_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=3

Each 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 body

Custom 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() <= 1

The 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 count

Common Recipes

Trigger on Task Approval

spl config add-trigger \
  --name on-approval \
  --expression 'record.act == "KNOW" && record.body.verdict == "accept"' \
  --target did:sync:agent:reviewer

Routing Rule by Domain

spl config add-rule \
  --name code-to-dev \
  --expression 'record.act == "INTEND" && record.body.domain == "code"' \
  --target did:sync:agent:dev

Precondition 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 36

Type 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 | jq

Fields: 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:

OperationBound
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-enable

Security 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 off

permissions-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 pointLegacy formCEL 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

On this page