Managing Resources

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

FieldTypeNotes
idstring (uuid)Output-only.
namestringRequired.
pool_idstring (uuid)The pool this rule is attached to.
cel_expressionstringThe CEL predicate/logic.
conditionstringOptional human-readable description of the condition.
priorityint32Evaluation order — lower runs earlier (default 100).
enabledboolWhether the rule is active (default true).
stopboolIf true, stop evaluating further rules once this one matches.
statusStatusSTATUS_ACTIVE / PAUSED / DISABLED.
levelRuleLevelTENANT, POOL (default), or SUB_POOL.
actionsrepeated RuleActionWhat to do on match — see below.
definitionRuleDefinitionOptional structured condition tree (alternative to raw CEL).
policy_refPolicyRefReference to an external policy (mutually exclusive with cel_expression).
versionint32Output-only. Bumped on each change.
metadatamap<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:

typeEffectKey parameter
filterExclude non-matching recipients from the candidate set.
boostAdjust matching recipients' selection weight.parameters
redirectSend 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

VariableTypeKeys you can read
routablemapid, type, pool_id, priority (int), source, attributes (your payload data), location.{latitude,longitude,address}
recipientmapid, external_id, name, pool_id, state, weight, tags, attributes (your recipient data), capacity.{current,max_concurrent,daily_limit,daily_count}, location.{…}
nowtimestampEvaluation 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_concurrent

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

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

ValidateRulePOST /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.

UpdateRulePATCH /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 }'

DeleteRuleDELETE /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:

Create / update / delete rule Update rules row, bump version Drop local cache entry for pool Publish invalidation Deliver to subscribers Drop local cache entry for pool Next decision re-reads fresh rules, repopulates cache Client (write) Serving pod Redis channel Other pods

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