Rules
Manage routing rules — CEL expressions attached to a pool, with priority, enable/disable, validation, and the cross-instance cache invalidation that makes edits take effect live.
A rule is a CEL expression attached to a pool that shapes a routing decision — filtering candidates, boosting weights, or redirecting to another pool. Rules are how routing logic changes without a redeploy: you edit a rule over the API and the change propagates to every pod through cache invalidation. This page covers the rule lifecycle and the hot-reload mechanism that makes edits take effect immediately.
Where they live
Rules are served by RulesService (paths under /api/pools/{pool_id}/rules
and /api/rules) and persisted to the rules table, with version history in
rule_versions. The domain aggregate is at domain/rule/; the cache and its
invalidator live in application/routing/.
Why rules exist as a separate resource
Pools and recipients describe who can receive work. Rules describe the policy layered on top — "VIP leads skip tier-2," "route Spanish tickets to bilingual agents," "after 6pm, overflow to the on-call pool." Keeping that policy in versioned, hot-reloadable rules means product and ops can change routing behavior at runtime, and every change is a small, auditable diff rather than a code change.
The rule object
| Field | Type | Notes |
|---|---|---|
id | string (uuid) | Output-only. |
name | string | Required. |
pool_id | string (uuid) | The pool this rule is attached to. |
cel_expression | string | The CEL predicate/logic. |
condition | string | Optional human-readable description of the condition. |
priority | int32 | Evaluation order — lower runs earlier (default 100). |
enabled | bool | Whether the rule is active (default true). |
stop | bool | If true, stop evaluating further rules once this one matches. |
status | Status | STATUS_ACTIVE / PAUSED / DISABLED. |
level | RuleLevel | TENANT, POOL (default), or SUB_POOL. |
actions | repeated RuleAction | What to do on match — see below. |
definition | RuleDefinition | Optional structured condition tree (alternative to raw CEL). |
policy_ref | PolicyRef | Reference to an external policy (mutually exclusive with cel_expression). |
version | int32 | Output-only. Bumped on each change. |
metadata | map<string,string> | Your bookkeeping. |
An active executable rule must carry exactly one of cel_expression or
policy_ref — never both, never neither. Supplying both is a validation error;
supplying neither for an active rule is too.
Rule actions
RuleAction says what happens when the rule matches:
type | Effect | Key parameter |
|---|---|---|
filter | Exclude non-matching recipients from the candidate set. | — |
boost | Adjust matching recipients' selection weight. | parameters |
redirect | Send the decision to a different pool. | redirect_pool_id |
Levels
level scopes where a rule applies: TENANT rules apply across the tenant,
POOL rules to a single pool (the default; an empty level is treated as pool
level), and SUB_POOL to a sub-pool within a hierarchy.
Writing the CEL expression
Code is the source of truth
The wire and storage field is cel_expression — the JSON tag on the
domain aggregate (domain/rule/rule.go, CELExpression) and the proto field
in RulesService (api/proto/api/rules_service.proto). It is not named
expression. The rule CEL environment exposes exactly three variables —
routable, recipient, and now — and no tenant variable. If another
page shows expression or tenant.region, this page is authoritative:
it matches the code.
Rules evaluate against the routing context. The engine binds the variable set
routable, recipient, now (pkg/celengine VarsRoutableRecipientNow,
"used by: rule engine"). Both routable and recipient are maps, so you read
their fields — and your typed attributes — by name.
The CEL environment
| Variable | Type | Keys you can read |
|---|---|---|
routable | map | id, type, pool_id, priority (int), source, attributes (your payload data), location.{latitude,longitude,address} |
recipient | map | id, external_id, name, pool_id, state, weight, tags, attributes (your recipient data), capacity.{current,max_concurrent,daily_limit,daily_count}, location.{…} |
now | timestamp | Evaluation time. |
attributes on both sides is where your own key/value data lives — the typed
Values you set on recipients
and on the incoming routable. Everything else is a first-class field.
// Only bilingual billing agents for Spanish-language billing tickets
recipient.attributes.skill == "billing" && recipient.attributes.language == "es"// High-priority work prefers senior agents with room to spare.
// routable.priority is a NUMBER — compare it numerically, not to a string.
routable.priority >= 8 && recipient.attributes.seniority >= 3// Tier is your own attribute (a string), so read it from attributes
routable.attributes.tier == "vip" && recipient.capacity.current < recipient.capacity.max_concurrentroutable.priority is an integer, not a label — routable.priority == "vip" is a type error the validator rejects. Model a named tier as your own
attribute (routable.attributes.tier == "vip") and reserve priority for
numeric comparisons.
Validate an expression before you save it — see
ValidateRule. The condition field is a
human-readable label for the rule; cel_expression is what actually executes.
Create a rule
CreateRule — POST /api/pools/{pool_id}/rules (rule:write). There's also an
additional binding at POST /api/rules if you'd rather pass the pool in the
body.
curl -s -X POST https://api.ductor.io/api/pools/$POOL_ID/rules \
-H "Authorization: Bearer $DUCTOR_API_KEY" \
-H "X-Tenant-ID: $DUCTOR_TENANT" \
-H "Content-Type: application/json" \
-d '{
"name": "vip-to-senior",
"cel_expression": "routable.attributes.tier == \"vip\" && recipient.attributes.seniority >= 3",
"condition": "VIP work goes to senior agents",
"priority": 10,
"enabled": true,
"stop": false,
"level": "RULE_LEVEL_POOL",
"actions": [ { "type": "filter" } ]
}'{
"data": {
"id": "7a6b5c4d-...",
"name": "vip-to-senior",
"pool_id": "3f1e2d3c-...",
"cel_expression": "routable.attributes.tier == \"vip\" && recipient.attributes.seniority >= 3",
"priority": 10,
"enabled": true,
"version": 1,
"level": "RULE_LEVEL_POOL",
"created_at": "2026-07-11T11:00:00Z"
}
}Validate before you save
ValidateRule — POST /api/rules/validate (rule:read) — checks a CEL
expression for syntax and type errors without persisting anything. Wire this into
your admin UI or CI so a bad expression is caught before it reaches a pool.
curl -s -X POST https://api.ductor.io/api/rules/validate \
-H "Authorization: Bearer $DUCTOR_API_KEY" \
-H "X-Tenant-ID: $DUCTOR_TENANT" \
-H "Content-Type: application/json" \
-d '{ "cel_expression": "recipient.attributes.skill == \"billing\"" }'{ "valid": true, "error": "", "message": "expression is valid" }An invalid expression returns valid: false with the parser error in error.
Read, update, delete
# One rule
curl -s https://api.ductor.io/api/rules/$RULE_ID \
-H "Authorization: Bearer $DUCTOR_API_KEY" -H "X-Tenant-ID: $DUCTOR_TENANT"
# Rules in a pool, filter by enabled
curl -s "https://api.ductor.io/api/pools/$POOL_ID/rules?enabled=true" \
-H "Authorization: Bearer $DUCTOR_API_KEY" -H "X-Tenant-ID: $DUCTOR_TENANT"GetRule and ListRules require rule:read.
UpdateRule — PATCH /api/rules/{rule_id} (rule:write) — partial update.
Toggling enabled is the fast way to disable a rule without deleting it; setting
pool_id retargets it to another pool.
# Disable a rule without losing it
curl -s -X PATCH https://api.ductor.io/api/rules/$RULE_ID \
-H "Authorization: Bearer $DUCTOR_API_KEY" \
-H "X-Tenant-ID: $DUCTOR_TENANT" \
-H "Content-Type: application/json" \
-d '{ "enabled": false }'DeleteRule — DELETE /api/rules/{rule_id} (rule:delete).
Priority, stop, and evaluation order
Rules on a pool are evaluated in ascending priority (lower first). Each
matching rule contributes its actions. A rule with stop: true halts evaluation
of the remaining rules once it matches — use it for terminal decisions like a
redirect where later rules shouldn't second-guess the outcome.
Design rules to be order-independent where you can
Priority is a real lever, but a rule set whose outcome depends subtly on
evaluation order is hard to reason about. Prefer specific, non-overlapping
conditions; reserve stop for genuinely terminal rules (redirects, hard
exclusions).
Hot reload: how edits take effect live
Rules are read on the routing hot path through a per-pool cache (LRU + TTL). When you create, update, or delete a rule, the cache entry for that pool must be dropped everywhere — not just on the pod that served your API call — or other pods would keep routing on the stale rule set.
Ductor handles this with pub/sub invalidation:
The result is a redeploy-free change that converges across the fleet within the propagation budget, with no pod left serving stale rules. This is the same invalidation pattern described in Connectors and the cache and the routing pipeline; rules and pool config each ride their own channel.
Because propagation is asynchronous, there is a brief window (sub-second in a healthy cluster) where different pods may have different cache generations. Rule changes are eventually consistent across the fleet, not instantaneously atomic — design changes so a momentary mix of old and new is safe (which order-independent, additive rules naturally are).
Versioning and audit
Every change bumps the rule's version, and prior versions are retained in
rule_versions. That history is your audit trail for "who changed routing and
when" and the basis for rolling a rule back to a known-good expression.
Where to go next
Workers
The worker registry — one durable identity for anyone who can take Work, human or agent, with ownership, fenced readiness, and bid eligibility built in.
Route Authoring (Route DSL v2)
The single public format for authoring routes — one versioned RouteAggregate per tenant that compiles down to the shared workflow DAG and routing primitives.