# Rules (/docs/management/rules)



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.

<Callout title="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/`.
</Callout>

## Why rules exist as a separate resource [#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 [#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](https://github.com/google/cel-spec) 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.                                                           |

<Callout type="info">
  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.
</Callout>

### Rule actions [#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 [#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 [#writing-the-cel-expression]

<Callout title="Code is the source of truth" type="info">
  The wire and storage field is &#x2A;*`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.
</Callout>

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 [#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
`Value`s you set on [recipients](/docs/management/pools-and-recipients#recipients)
and on the incoming routable. Everything else is a first-class field.

```js
// Only bilingual billing agents for Spanish-language billing tickets
recipient.attributes.skill == "billing" && recipient.attributes.language == "es"
```

```js
// 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
```

```js
// Tier is your own attribute (a string), so read it from attributes
routable.attributes.tier == "vip" && recipient.capacity.current < recipient.capacity.max_concurrent
```

<Callout type="warn">
  `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.
</Callout>

<Callout type="info">
  Validate an expression before you save it — see
  [`ValidateRule`](#validate-before-you-save). The `condition` field is a
  human-readable label for the rule; `cel_expression` is what actually executes.
</Callout>

## Create a rule [#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.

```bash
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" } ]
  }'
```

```json
{
  "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 [#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.

```bash
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\"" }'
```

```json
{ "valid": true, "error": "", "message": "expression is valid" }
```

An invalid expression returns `valid: false` with the parser error in `error`.

## Read, update, delete [#read-update-delete]

```bash
# 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.

```bash
# 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 [#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.

<Callout title="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).
</Callout>

## Hot reload: how edits take effect live [#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**:

```mermaid
sequenceDiagram
    participant C as Client (write)
    participant P as Serving pod
    participant R as Redis channel
    participant O as Other pods
    C->>P: Create / update / delete rule
    P->>P: Update rules row, bump version
    P->>P: Drop local cache entry for pool
    P->>R: Publish invalidation
    R->>O: Deliver to subscribers
    O->>O: Drop local cache entry for pool
    Note over P,O: Next decision re-reads fresh rules, repopulates cache
```

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](/docs/concepts/connectors)
and [the routing pipeline](/docs/concepts/routing-pipeline); rules and pool
config each ride their own channel.

<Callout type="info">
  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).
</Callout>

## Versioning and audit [#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 [#where-to-go-next]

<Cards>
  <Card title="Pools & recipients" href="/docs/management/pools-and-recipients">
    The targets and attributes your rules evaluate.
  </Card>

  <Card title="The routing pipeline" href="/docs/concepts/routing-pipeline">
    Where rules run: the Filter and Select stages.
  </Card>

  <Card title="Strategies" href="/docs/strategies">
    How the surviving candidates are turned into one winner.
  </Card>
</Cards>
