|
| 1 | +--- |
| 2 | +title: Queues and assignment rules — route new work to a team |
| 3 | +navTitle: Queues & assignment |
| 4 | +description: Declare an ownership queue and an assignment rule, park new records for a team to claim or hand them to the least-loaded member, and know who sees a parked record. |
| 5 | +--- |
| 6 | + |
| 7 | +## Scenario |
| 8 | + |
| 9 | +> New cases (leads, tickets, requests) arrive with no owner — from a public form, an import, an integration. They should land with **a team**, and either go straight to one of its members or wait until someone takes them. How do I say that without writing a `beforeInsert` hook that picks an owner? |
| 10 | +
|
| 11 | +## Recommended solution |
| 12 | + |
| 13 | +Two declarations, both in your stack, and no hook: |
| 14 | + |
| 15 | +1. A **queue** — a named set of people that records can wait in (ADR-0142 D1). |
| 16 | +2. An **assignment rule** — per object, which inserted rows go to which queue (or user), and how one member is chosen (ADR-0142 D5). |
| 17 | + |
| 18 | +The platform does the rest: it stamps the record's owner or parks it in the queue, lets the queue's members see what is parked, and turns "I'll take this one" into one ordinary update that only one person can win. |
| 19 | + |
| 20 | +Queues and assignment rules apply to objects with `ownership: 'user'` — the tier that has an `owner_id`. Both keys need the ownership-queue service, which `@objectstack/plugin-sharing` registers; a stack that declares either without it is **refused at boot**, naming the key and the missing service, rather than served with queues nothing resolves. |
| 21 | + |
| 22 | +### 1. Declare a queue |
| 23 | + |
| 24 | +```ts |
| 25 | +import { defineStack } from '@objectstack/spec'; |
| 26 | +import { defineQueue } from '@objectstack/spec/security'; |
| 27 | + |
| 28 | +export default defineStack({ |
| 29 | + // …objects, positions, … |
| 30 | + queues: [ |
| 31 | + defineQueue({ |
| 32 | + name: 'support_intake', // the one name everything references |
| 33 | + label: 'Support Intake', |
| 34 | + members: [{ type: 'position', value: 'service_agent' }], |
| 35 | + }), |
| 36 | + defineQueue({ |
| 37 | + name: 'escalations', |
| 38 | + label: 'Escalations', |
| 39 | + members: [{ type: 'position', value: 'support_lead' }], |
| 40 | + }), |
| 41 | + ], |
| 42 | +}); |
| 43 | +``` |
| 44 | + |
| 45 | +A queue's **members** are written with the recipient kinds a sharing rule already uses, and resolve in the organization a record belongs to. A **declared** queue names **positions only**: a position is a catalog item your package can know by name, while a user, a team or a business unit is an organization's own row. |
| 46 | + |
| 47 | +An organization staffs its own queues too. A `sys_queue` record (with `sys_queue_member` records naming any of `user`, `team`, `position`, `business_unit` or `unit_and_subordinates`) is a queue exactly like a declared one. Both live in **one namespace, by name**: a `sys_queue` record may not take a declared queue's name, and every reference — the record's `queue` column, an assignment rule, an approver, a sharing recipient — resolves the name registry-first, then the organization's records. A declared queue is switched off through the activation ledger like any other declared metadata. |
| 48 | + |
| 49 | +An empty member list is allowed: it is a queue nobody staffs yet, and records still sit in it. |
| 50 | + |
| 51 | +### 2. Declare an assignment rule |
| 52 | + |
| 53 | +```ts |
| 54 | +import { defineAssignmentRule } from '@objectstack/spec/automation'; |
| 55 | + |
| 56 | +export default defineStack({ |
| 57 | + // … |
| 58 | + assignmentRules: [ |
| 59 | + defineAssignmentRule({ |
| 60 | + name: 'case_intake', |
| 61 | + object: 'crm_case', |
| 62 | + entries: [ |
| 63 | + { |
| 64 | + name: 'vip_to_escalations', |
| 65 | + criteria: 'record.priority == "urgent"', |
| 66 | + assignTo: { type: 'queue', value: 'escalations' }, // no strategy: park it |
| 67 | + }, |
| 68 | + { |
| 69 | + name: 'everyone_else', // no criteria: every row |
| 70 | + assignTo: { type: 'queue', value: 'support_intake' }, |
| 71 | + strategy: 'least_loaded', |
| 72 | + load: 'record.status != "resolved" && record.status != "closed"', |
| 73 | + }, |
| 74 | + ], |
| 75 | + }), |
| 76 | + ], |
| 77 | +}); |
| 78 | +``` |
| 79 | + |
| 80 | +**Entries are ordered; the first whose `criteria` match wins.** `criteria` is CEL over `record.*`, read against the row being inserted. `assignTo` is a queue (by machine name) or one user (by id). |
| 81 | + |
| 82 | +**`strategy`** decides what happens inside a queue: |
| 83 | + |
| 84 | +| `strategy` | The row ends up | |
| 85 | +|:--|:--| |
| 86 | +| *(absent)* | **Parked**: `queue` set, `owner_id` empty, until a member claims it | |
| 87 | +| `least_loaded` | Owned by the member with the fewest rows matching `load`; a tie goes to the earlier member (members are ordered by user id) | |
| 88 | +| `round_robin` | Owned by the member after the one this entry chose last | |
| 89 | + |
| 90 | +`strategy` applies only to a queue target; on a `user` target it is refused. |
| 91 | + |
| 92 | +**When it runs.** Once per inserted row, after every `beforeInsert` hook and before the write, and only for a row whose `owner_id` and `queue` are both still empty. A row whose writer named an owner, or whose hook set or cleared one, is left alone. **An update never runs a rule.** Assignment never refuses an insert: an empty queue parks the row, and a queue that does not resolve in the row's organization, or a read that fails, leaves the row ownerless — each logged once at `warn`, naming the queue. |
| 93 | + |
| 94 | +The rule writes `owner_id` (when it chose a member) and `queue` (for a queue target), nothing else. The row's audit columns still record who inserted it; the owner the rule chose is ownership, not attribution. |
| 95 | + |
| 96 | +### 3. `load` is required with `least_loaded` |
| 97 | + |
| 98 | +`least_loaded` counts each member's **open** rows of the object, and only you know what "open" means for it — `status != "closed"` for cases, `is_converted == false` for leads. So `load` is **required** with `least_loaded` and has no default: a default such as "every row" would count closed work and quietly make the busiest historical owner look the most loaded. `load` on any other strategy is refused, because nothing reads it. |
| 99 | + |
| 100 | +`load` is a **pushdown** predicate: it is lowered to a query filter, so it may use only what the CEL → filter compiler can lower (comparisons, `&&`, `||`, `in` over the object's own columns). A `load` the compiler cannot lower is an authoring-time error, never a rule that silently counts everything. `criteria`, by contrast, is read against the in-memory row and may use any CEL. |
| 101 | + |
| 102 | +## What a parked record is, and who sees it |
| 103 | + |
| 104 | +A record **sits in** queue Q when its `owner_id` is empty and its `queue` column names Q. Every `ownership: 'user'` object carries that `queue` column — a system-managed text column the platform injects beside `owner_id` (an object that declares its own field named `queue` is refused at registration). Once a record has an owner, `queue` only names its **home queue**: where a release returns it, and a reporting dimension. It grants nothing. |
| 105 | + |
| 106 | +**Who sees a parked record:** |
| 107 | + |
| 108 | +- **The members of Q** read and edit what sits in Q, through the reserved membership set `current_user.queues` (the queues the caller belongs to in the active organization). This widens **rows, never verbs**: a member still needs the object's read and edit grants, and **delete is never conferred** by queue membership. |
| 109 | +- **Everyone who could see it anyway** — scope depth, `viewAllRecords` / `modifyAllRecords`, an applicable sharing rule or record share — still does. |
| 110 | +- **Nobody through ownership.** A parked record is "owned by nobody": it is in no one's "my records" view, and an `owner_of:` notification audience reaches no one. |
| 111 | + |
| 112 | +The moment someone claims it, the queue path stops applying: the other members lose access unless another basis grants it. Ask the explain endpoint (`POST /api/v1/security/explain`) why a user sees a record and it reports the queue basis as its own reason (`queue_membership`) — see [Explaining a decision](/docs/permissions/explain). |
| 113 | + |
| 114 | +In the Console, a list of an object that carries the `queue` column has an **In a queue** toggle in its toolbar. It narrows the list to parked records (`owner_id` empty and `queue` set); which of them you see is still the server's read filter, so a member sees what sits in their own queues. |
| 115 | + |
| 116 | +## How a claim is spelled |
| 117 | + |
| 118 | +A **claim** is an ordinary update of `owner_id` to your own user id, by record id: |
| 119 | + |
| 120 | +```http |
| 121 | +PATCH /api/v1/data/crm_case/cas_01J8… |
| 122 | +Content-Type: application/json |
| 123 | +
|
| 124 | +{ "owner_id": "<your user id>" } |
| 125 | +``` |
| 126 | + |
| 127 | +There is no claim endpoint and no claim hook. The ownership gate admits this one update when the record is **unowned**, the payload names **you** as a single value, the request addresses **one record by id**, and the sharing model positively admits you to the record — a member of its queue, a holder of an `edit` share, or `modifyAllRecords`. A bulk update carrying `owner_id` stays refused. |
| 128 | + |
| 129 | +**Only one person wins.** The write is conditional on the owner still being empty, so two people claiming at once produce exactly one owner. The loser gets **`409 CONCURRENT_UPDATE`** — "The record was taken: another user changed it first. Reload it to see who has it now." Treat it as an answer, not an error: reload and move on. Any other refusal is `403 PERMISSION_DENIED`, and its message names the condition that failed. |
| 130 | + |
| 131 | +The Console's **Claim** button on a record sends exactly this update. It appears only on a record that sits in a queue, and only after the server's explain answer says your claim would be admitted; when someone beats you to it, it says so. |
| 132 | + |
| 133 | +Two related writes go through the same gate: |
| 134 | + |
| 135 | +- **Release** — the current owner sets `owner_id` back to `null` and leaves `queue` unchanged; the record sits in its home queue again. A record with no home queue has nowhere to return to, so releasing it is an ordinary disown that needs the transfer grant. |
| 136 | +- **Move** — writing a different `queue` (to re-route a parked record, or to park an owned one with `owner_id: null` in the same write) is a transfer: it needs `allowTransfer` or `modifyAllRecords`, and a queue that does not resolve in the record's organization is refused with `QUEUE_NOT_FOUND`. |
| 137 | + |
| 138 | +## A queue elsewhere: approvers and sharing recipients |
| 139 | + |
| 140 | +The same queue name works in two more places, each resolving to the queue's members: |
| 141 | + |
| 142 | +- **An approval node's approver** — `{ type: 'queue', value: 'support_intake' }` puts the queue's members on the slate at node entry; see [Approval chains](/docs/automation/approvals). |
| 143 | +- **A sharing rule's recipient** — `sharedWith: { type: 'queue', value: 'support_intake' }` shares the rule's matching records with the queue's members, re-materialised as membership moves; see [Sharing rules](/docs/permissions/sharing-rules#recipient-types). |
| 144 | + |
| 145 | +In the Console both pickers list the declared queues and the organization's own `sys_queue` records together. Note what a sharing rule does **not** do: naming a queue as a recipient shares *the rule's records* with *the queue's people*. It does not make records sit in the queue; only the `queue` column does that. |
| 146 | + |
| 147 | +## A queue is not a messaging audience |
| 148 | + |
| 149 | +A queue routes **records** to people. It is not an address for **messages**: a flow's `notify` audience selectors (`user:`, `team:`, `owner_of:` and the rest) include no `queue:`, so "tell the queue that something new arrived" cannot be written as a queue. Address the people who staff it instead — the team, or the users holding the position the queue's members name — and remember that `owner_of:` on a parked record reaches nobody, because nobody owns it yet. |
| 150 | + |
| 151 | +## Checklist |
| 152 | + |
| 153 | +- The object is `ownership: 'user'`, and declares no field of its own named `queue`. |
| 154 | +- The stack loads `@objectstack/plugin-sharing`; otherwise `queues` / `assignmentRules` refuse the boot. |
| 155 | +- A declared queue's members are positions; organizations add users, teams and units through `sys_queue_member`. |
| 156 | +- Every `least_loaded` entry has a `load` your filter compiler can lower; no other entry has one. |
| 157 | +- Code that claims a record sends `{ owner_id: <self> }` by id and handles `409` as "someone else took it". |
| 158 | +- Alerts about new work address a team or a position, not the queue. |
| 159 | + |
| 160 | +## See also |
| 161 | + |
| 162 | +- [Assignment rule reference](/docs/references/automation/assignment-rule) — the generated schema. |
| 163 | +- [Approval chains](/docs/automation/approvals) — the `queue` approver. |
| 164 | +- [Sharing rules](/docs/permissions/sharing-rules) — recipient kinds, including `queue`. |
| 165 | +- [Row-level security](/docs/permissions/rls) — reserved membership keys such as `current_user.queues`. |
0 commit comments