Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/25769-console-queue-affordances.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@object-ui/fields': patch
'@object-ui/app-shell': patch
'@object-ui/plugin-detail': patch
'@object-ui/plugin-list': patch
'@object-ui/i18n': patch
---

feat(console): ownership queues in the Console — a queue picker reading both sources, a Claim button the server decides, and an "In a queue" list filter

Clause-②: no
Metadata-protocol: none

The server half of ownership queues shipped without a Console face. Now:

- **Queue pickers list both sources.** The flow designer's approver Value for a `queue` approver, and a sharing rule's `queue` recipient, list the queues an installed app declares (`GET /api/v1/meta/queue`) together with the organization's own `sys_queue` records, one list by machine name, and store the name. The approver picker follows the binding the server publishes (`registry-and-data`); with no data adapter, or a server that publishes another binding, it keeps the text box.
- **Claim.** A record sitting in a queue (no owner, a `queue` named) shows **Claim** in its header only when the explain engine's answer for a transfer of that record carries an admitting `queue_claim` rule; no answer means no button. Claim sends `{ owner_id: <self> }` through the normal update, and a lost race (`409 CONCURRENT_UPDATE`) is reported as "Someone else claimed this record first."
- **In a queue.** A list of an object served with the injected `queue` column gains an **In a queue** toggle that narrows the fetch (and an export) to `owner_id` empty and `queue` set. Which of those rows come back is still the server's read filter.
- **Docs.** A new authoring guide, *Queues and assignment rules*, covers declaring a queue and an assignment rule, the `load` requirement, what a parked record is and who sees it, how a claim is spelled, and that a queue is not a messaging audience; the approvals, sharing-rule and RLS pages link to it.
4 changes: 3 additions & 1 deletion content/docs/automation/approvals.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -408,7 +408,9 @@ one that silently resolves to nobody when it's mistaken for a business
hierarchy. A `queue` entry names an ownership queue by its machine name — one
an installed app declares, or one of the organization's own — and resolves to
the queue's members at node entry, through the same expansion that decides who
may claim the queue's records (ADR-0142 D6). `field`, `manager`, and `expression` resolve against the record's
may claim the queue's records (ADR-0142 D6) — how to declare a queue, and what
its members can do with the records parked in it, is in
[Queues and assignment rules](/docs/automation/queues-and-assignment-rules). `field`, `manager`, and `expression` resolve against the record's
**live** state at node entry (#3447); `manager` additionally depends on
`sys_user.manager_id`, which a platform admin sets from **Setup → Users → ⋯ →
Set Manager** — see [the `manager` callout above](#3-the-approval-node). `position`, `department`,
Expand Down
2 changes: 2 additions & 0 deletions content/docs/automation/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export const OpportunityStageHook: Hook = {
- **Flows** are node-based processes built from nodes like decision, loop, record operations, http, notify, script, screen, wait, subflow, and parallel/join gateways — and the set is **registry-extensible**: plugins can contribute new node types via `registerNodeExecutor`. Flows launch on record changes, on a schedule, from a screen, or via API ([Flows](/docs/automation/flows)).
- **Workflows** model a record's lifecycle as a **finite state machine**: states, transitions, and guards ([Workflows](/docs/automation/workflows)).
- **Approvals** are flow nodes with approver resolution, approve/reject decisions, and escalation ([Approvals](/docs/automation/approvals)).
- **Queues and assignment rules** route a new, ownerless record to a team: declared rules park it in a queue for a member to claim, or hand it to one member round-robin or by least load — no `beforeInsert` hook picks the owner ([Queues & assignment](/docs/automation/queues-and-assignment-rules)).
- **Webhooks** deliver events to external systems through a **durable outbox** — exponential/linear/fixed retry with dead-lettering, HMAC signing, and an admin redeliver endpoint ([Webhook Delivery](/docs/automation/webhooks)).
- **Connectors** package external systems behind named actions that flows dispatch — registered by plugins, or **declared as pure metadata** (`provider: 'rest' | 'openapi' | 'mcp'`) and materialized at boot, with reference-based credentials ([Connectors](/docs/automation/connectors)).
- **Scheduled jobs** run one named bundle function on a cron, interval, or one-off schedule, with retry, a per-attempt timeout, and cluster leader election — a `schedule`-type flow is registered against the *same* job service, so the choice between them is about what runs, not about timing ([Scheduled Jobs](/docs/automation/jobs)).
Expand All @@ -46,6 +47,7 @@ Rule of thumb: model *state* with workflows, model *steps* with flows, use hooks
<Card href="/docs/automation/flows" title="Flows" description="Node-based automation, screen flows, scheduled flows" />
<Card href="/docs/automation/workflows" title="Workflows" description="State-machine lifecycle modeling" />
<Card href="/docs/automation/approvals" title="Approvals" description="Approval nodes: approvers, decisions, escalation" />
<Card href="/docs/automation/queues-and-assignment-rules" title="Queues & Assignment" description="Park new records for a team, claim them, route by least load" />
<Card href="/docs/automation/webhooks" title="Webhook Delivery" description="Durable outbox, retries, HMAC signing" />
<Card href="/docs/automation/connectors" title="Connectors" description="Declarative rest/openapi/mcp instances, credentialRef auth" />
<Card href="/docs/automation/jobs" title="Scheduled Jobs" description="Cron/interval/once schedules, retry, timeout, run history" />
Expand Down
1 change: 1 addition & 0 deletions content/docs/automation/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"flows",
"workflows",
"approvals",
"queues-and-assignment-rules",
"webhooks",
"connectors",
"jobs",
Expand Down
165 changes: 165 additions & 0 deletions content/docs/automation/queues-and-assignment-rules.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
---
title: Queues and assignment rules — route new work to a team
navTitle: Queues & assignment
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.
---

## Scenario

> 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?

## Recommended solution

Two declarations, both in your stack, and no hook:

1. A **queue** — a named set of people that records can wait in (ADR-0142 D1).
2. An **assignment rule** — per object, which inserted rows go to which queue (or user), and how one member is chosen (ADR-0142 D5).

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.

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.

### 1. Declare a queue

```ts
import { defineStack } from '@objectstack/spec';
import { defineQueue } from '@objectstack/spec/security';

export default defineStack({
// …objects, positions, …
queues: [
defineQueue({
name: 'support_intake', // the one name everything references
label: 'Support Intake',
members: [{ type: 'position', value: 'service_agent' }],
}),
defineQueue({
name: 'escalations',
label: 'Escalations',
members: [{ type: 'position', value: 'support_lead' }],
}),
],
});
```

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.

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.

An empty member list is allowed: it is a queue nobody staffs yet, and records still sit in it.

### 2. Declare an assignment rule

```ts
import { defineAssignmentRule } from '@objectstack/spec/automation';

export default defineStack({
// …
assignmentRules: [
defineAssignmentRule({
name: 'case_intake',
object: 'crm_case',
entries: [
{
name: 'vip_to_escalations',
criteria: 'record.priority == "urgent"',
assignTo: { type: 'queue', value: 'escalations' }, // no strategy: park it
},
{
name: 'everyone_else', // no criteria: every row
assignTo: { type: 'queue', value: 'support_intake' },
strategy: 'least_loaded',
load: 'record.status != "resolved" && record.status != "closed"',
},
],
}),
],
});
```

**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).

**`strategy`** decides what happens inside a queue:

| `strategy` | The row ends up |
|:--|:--|
| *(absent)* | **Parked**: `queue` set, `owner_id` empty, until a member claims it |
| `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) |
| `round_robin` | Owned by the member after the one this entry chose last |

`strategy` applies only to a queue target; on a `user` target it is refused.

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

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.

### 3. `load` is required with `least_loaded`

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

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

## What a parked record is, and who sees it

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.

**Who sees a parked record:**

- **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.
- **Everyone who could see it anyway** — scope depth, `viewAllRecords` / `modifyAllRecords`, an applicable sharing rule or record share — still does.
- **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.

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

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.

## How a claim is spelled

A **claim** is an ordinary update of `owner_id` to your own user id, by record id:

```http
PATCH /api/v1/data/crm_case/cas_01J8…
Content-Type: application/json

{ "owner_id": "<your user id>" }
```

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.

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

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.

Two related writes go through the same gate:

- **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.
- **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`.

## A queue elsewhere: approvers and sharing recipients

The same queue name works in two more places, each resolving to the queue's members:

- **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).
- **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).

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.

## A queue is not a messaging audience

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.

## Checklist

- The object is `ownership: 'user'`, and declares no field of its own named `queue`.
- The stack loads `@objectstack/plugin-sharing`; otherwise `queues` / `assignmentRules` refuse the boot.
- A declared queue's members are positions; organizations add users, teams and units through `sys_queue_member`.
- Every `least_loaded` entry has a `load` your filter compiler can lower; no other entry has one.
- Code that claims a record sends `{ owner_id: <self> }` by id and handles `409` as "someone else took it".
- Alerts about new work address a team or a position, not the queue.

## See also

- [Assignment rule reference](/docs/references/automation/assignment-rule) — the generated schema.
- [Approval chains](/docs/automation/approvals) — the `queue` approver.
- [Sharing rules](/docs/permissions/sharing-rules) — recipient kinds, including `queue`.
- [Row-level security](/docs/permissions/rls) — reserved membership keys such as `current_user.queues`.
1 change: 1 addition & 0 deletions content/docs/permissions/rls.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ not this one):
| `current_user.organization_id` | The acting tenant |
| `current_user.positions` | The user's positions (array) |
| `current_user.org_user_ids` | User ids in the user's org (array) |
| `current_user.queues` | The ownership queues the user belongs to in the active organization (array of queue names) — the set that lets a queue's members see the records parked in it ([Queues and assignment rules](/docs/automation/queues-and-assignment-rules)) |
| `current_user.email` | The user's email |
| `current_user.KEY` | A set a roster object declares under [`membershipKeys`](/docs/references/security/rls#where-a-membership-key-comes-from) (array, read with `in`) |

Expand Down
Loading
Loading