# Dispatch Rules

> The dispatch_rule object: pattern matching on caller and dialed number, priority evaluation, global and trunk-scoped rules, and how a saved rule reaches the engine.

A **dispatch rule** routes an inbound call to a trunk and, optionally, to a
voice agent. Rules live in the `dispatch_rule` table, scoped to an org, and
are evaluated in priority order — lowest number first, first match wins.

The console lists them straight from the table
(`dispatch_rule` where `org_id` = the active org, ordered by `priority`) and
writes through the admin API: `admin.upsertDispatchRule` and
`admin.deleteDispatchRule`. Every write also mirrors the rule into the Redis
keys the TeleQuick engine matches on.

## The dispatch rule object

| Field          | Type              | Console default        | Meaning                                                                 |
| -------------- | ----------------- | ---------------------- | ----------------------------------------------------------------------- |
| `id`           | uuid              | assigned on insert     | Omit on upsert to create; supply it to update in place.                  |
| `org_id`       | string            | the active org         | Stamped by the upsert procedure; not an input field on the form.         |
| `name`         | string            | — (required)           | Human label. The console refuses to save an empty name.                  |
| `priority`     | integer           | `10`                   | Lowest is evaluated first. The console validates an integer 0–10000.     |
| `from_pattern` | string            | `*`                    | Matches the caller.                                                      |
| `to_pattern`   | string            | `*`                    | Matches the dialed number (DID).                                         |
| `trunk_id`     | uuid or `null`    | `null` — any trunk     | The `trunk` row id, shown as `display_name` (falling back to `trunk_id`). |
| `agent_id`     | uuid or `null`    | `null` — dialplan only | An `agent_config` row. Null means no agent is attached.                  |
| `is_global`    | boolean           | `false`                | "Applies regardless of target trunk."                                    |
| `enabled`      | boolean           | `true`                 | Only enabled rules are mirrored into Redis.                              |

The three narrowing fields are `from_pattern`, `to_pattern` and `trunk_id`.
Their defaults (`*`, `*`, `null`) each mean "do not narrow on this". A rule
whose fields are all left at their defaults narrows on nothing.

`name` and `org_id` stay in Postgres. The payload the control plane ships to
the engine carries only `id`, `priority`, `from_pattern`, `to_pattern`,
`trunk_id`, `agent_id`, `is_global` and `enabled` — the org is encoded in the
Redis key, and the name is a console label only.

## Pattern syntax for from_pattern and to_pattern

The control plane stores both patterns verbatim. `admin.upsertDispatchRule`
does not normalise, canonicalise or validate them — it writes the string you
typed into Postgres and into the Redis payload. There is no E.164
normalisation step, so write the pattern in the form the call actually
presents the number.

The forms the console documents:

| Pattern        | Matches                                             |
| -------------- | --------------------------------------------------- |
| `*`            | Anything. This is the default for both patterns.     |
| `+1555*`       | A prefix — everything beginning `+1555`, e.g. an area code. |
| `+14155550123` | That number exactly.                                 |

The console normalises only emptiness: a field left blank is trimmed and saved
as `*`, never as an empty string. Because `*` is the "match anything" value,
clearing a pattern widens the rule rather than disabling it.

Both patterns belong to the same rule, alongside `trunk_id`. A call has to
satisfy every one of them that has been narrowed away from its default before
the rule matches.

## Priority and first-match evaluation

Rules are evaluated from the lowest `priority` value upward, and the first
rule that matches the call wins. Nothing after it is considered, so order your
rules from most specific to least specific and keep any catch-all (`*` / `*` /
any trunk) at the highest priority number.

`0` is a legal priority and is the most precedent value — the console accepts
it and preserves it rather than substituting the default of `10`.

Two rules may carry the same priority, but doing so leaves you without a
defined order you control. The engine reads the evaluation order from a Redis
sorted set whose members are rule ids and whose scores are priorities; rules
sharing a score fall back to ordering by rule id, which carries no routing
meaning. Give rules that could match the same call distinct priorities.

## Global rules and trunk scope

`trunk_id` and `is_global` are separate controls:

- **`trunk_id` set** — the rule is scoped to that one trunk. The list shows the
  trunk's display name in the **To trunk** column.
- **`trunk_id` null** — the rule is not scoped to a trunk. The list shows `*`.
- **`is_global`** — a flag on the rule, labelled in the console as "applies
  regardless of target trunk". Global rules are badged **global** in the list.

The control plane does not treat a global rule differently at write time: it is
stored in the same hash, ordered in the same sorted set by the same `priority`,
and shipped to the engine in the same payload shape. `is_global` travels as a
field of that payload.

## Agent assignment vs dialplan-only

`agent_id` is optional at the schema and API level. Leaving it unset —
"— None (dialplan only) —" in the admin and portal screens — produces a rule
that matches and routes without attaching an AI agent; the list renders the
agent column as `—`.

The Voice AI dispatch screen is stricter than the API. It exists to bind an
inbound number to a voice agent, so its modal refuses to save without one
("Pick an agent to route to") and its agent picker only offers rows from
`agent_config` where `active` is true. A dialplan-only rule created elsewhere
is still listed there; it simply has no agent to show.

## Precedence against per-number bindings

A dispatch rule is not the only thing that can bind an inbound call to an
agent. A `phone_number` row carries its own `agent_id` and `trunk_id`, and
those per-DID bindings are mirrored into their own Redis DID hash.

The engine's dialplan resolves inbound routing in this precedence:

1. per-DID rule
2. trunk rule
3. park

That means a per-DID override can silently beat the agent a trunk-level rule
would have chosen. Because the trunk screen and the number screen each only see
their own half, use the **Effective inbound routing** view
(`admin.dialplanRoutes`) to see what the engine will actually do: it reads the
same `telequick:dialplan:rules` blob the dialplan module routes on and
applies the same precedence, per trunk and per DID. It only answers for trunks
this org owns.

## Enabling, disabling, and deleting

The **Enabled / Disabled** chip in the list toggles `enabled` through
`admin.upsertDispatchRule` — the same procedure as a full save, with the flag
flipped.

Disabling is the reversible alternative to deleting. The upsert keeps the
Postgres row and removes the rule from Redis, so the engine stops matching it
immediately while the rule stays in the list (dimmed, filterable under
**Disabled**) and can be switched back on. The admin suite's **Deactivate**
button is exactly this: a soft delete that sets `enabled` to false.

`admin.deleteDispatchRule` is permanent. It deletes the row from Postgres and
removes the rule from both Redis keys. There is no undo; a delete from the
list is confirmed with a browser prompt only.

## How a rule reaches the engine

`admin.upsertDispatchRule` runs in one pass:

1. Update the row by `id` (scoped to `org_id`), or insert a new one.
2. Build the engine payload from the **saved** row, not the submitted form.
3. If the saved rule is enabled — write the payload into the org's dispatch-rule
   hash under the rule id, and add the rule id to the org's dispatch-order
   sorted set scored by `priority`.
4. If the saved rule is disabled — delete the rule id from both of those keys,
   so the engine never matches it.
5. Rebuild the dialplan rules blob.

`admin.deleteDispatchRule` performs steps 1, 4 and 5 for the deleted id.

Both writes are Postgres-first. If the database write fails the procedure
throws a `INTERNAL_SERVER_ERROR` carrying the database message, and nothing is
pushed to Redis — the console surfaces that message in the form and the rule
you see in the list is still the rule the engine has.

## Re-syncing after a failed save or delete

`admin.resyncTenantRouting` rebuilds this org's inbound routing in Redis from
Postgres. It is the recovery path when a write succeeded in the database but
the push to the engine did not.

It wipes three keys for the org — the DID hash, the dispatch-rule hash, and the
dispatch-order sorted set — then repopulates them:

- every `phone_number` with `status` = `active`, written to the DID hash keyed
  by `e164` and carrying `agent_id`, `trunk_id`, `rate_plan_id` and `status`;
- every `dispatch_rule` with `enabled` = true, written to the dispatch-rule hash
  and scored into the order set by `priority`.

It returns `{ ok: true, phone_numbers, dispatch_rules }` — the counts it
wrote. Because it re-reads Postgres as the source of truth, anything the
database does not have (a stale agent binding, a rule you deleted) is gone
from Redis afterwards.

The Voice AI screen calls it automatically after every save and after every
delete, and reports the two failure modes separately:

- **Delete failed.** Nothing else runs and the row stays in the list:
  *"Couldn't delete this rule — … Inbound calls still match it."*
- **Delete succeeded, resync failed.** The row is gone from the database but
  the engine has not been refreshed: *"Rule deleted, but the engine's routing
  wasn't refreshed … Use Re-sync to push it."* Run the re-sync again.

A save that succeeds but whose resync fails is treated as best-effort and the
modal closes; if a new rule does not take effect, re-sync before assuming the
patterns are wrong.

## Routing to an unpublished agent

The engine serves only **published** agent snapshots. A dispatch rule pointing
at an agent that has never been published will match inbound calls and then
have nothing to hand them to — the calls drop, with no error on the rule
itself.

The Voice AI dispatch list guards against this. It queries
`agentVersions.publishedAgents` for the org (refreshed periodically) and badges
any rule whose `agent_id` is not in that set with a **not published** warning
tag. Publish the agent from its Versions tab to clear it.

## Managing rules over the admin API

| Procedure                     | Input                        | Effect                                                                 |
| ----------------------------- | ---------------------------- | ---------------------------------------------------------------------- |
| `admin.upsertDispatchRule`    | `{ orgId, rule }`            | Insert or update by `rule.id`, then mirror to (or remove from) Redis and rebuild the dialplan blob. Returns the saved row. |
| `admin.deleteDispatchRule`    | `{ orgId, id }`              | Delete the row, remove it from both Redis keys, rebuild. Returns `{ ok: true }`. |
| `admin.resyncTenantRouting`   | `{ orgId }`                  | Wipe and rebuild the org's DID hash and dispatch keys from Postgres. Returns the row counts written. |
| `admin.dialplanRoutes`        | `{ orgId, trunkIds, numbers }` | Read-only. Report the route the engine will take per trunk and per
