Skip to content

Commit 2302b92

Browse files
feat(console): ownership queues in the Console — two-source queue pickers, Claim on a queued record, an In-a-queue list filter, and the authoring guide (ADR-0142) (#27264)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwB8rwmX7EbpJUiWbaWPRGoeg)_ Part of #25769 Ruling-ref: ADR-0142 accepted by the maintainer's merge of #25816 (2026-10-26T06:44:50Z); this PR carries its Execution plan items 3 (Console) and 4 (docs). Item 2, the HotCRM STOPGAP delete, remains on its own lane, so #25769 stays open. Clause-②: no Metadata-protocol: none Skills-docs: none here. `skills/**` is out of scope (Tier H); the skills-side teaching of queues and assignment rules is owed to #26475. ## 维护者速读 - 改了什么:Console 补上工单队列的三处界面——审批人与分享接收人的队列选择器同时列出包声明的队列和组织自建的 `sys_queue`;坐在队列里的记录头部出现「认领」,只在服务端 explain 对 transfer 回答 `queue_claim: admits` 时显示,点击即发 `{ owner_id: 自己 }`;列表工具栏新增「在队列中」筛选(`owner_id` 为空且 `queue` 有值)。另加一篇作者指南 `content/docs/automation/queues-and-assignment-rules.mdx`。 - 为什么改:ADR-0142 已接受(#25816 合并),服务端在 #26337 落地,但 Console 仍是占位文本框,没有认领入口,也看不到停在队列里的记录;执行计划第 3、4 项要求补齐。 - 风险与回滚:不改 spec、不改服务端;认领失败关闭(没有答复就不显示按钮),输掉并发认领的 409 明确提示「别人先认领了」;筛选只在对象带注入的 `queue` 系统列时出现。回滚即还原本 PR。 - 先看:`ui/packages/plugin-detail/src/queueClaim.ts`、`ui/packages/app-shell/src/views/RecordDetailView.tsx`(认领)、`ui/packages/plugin-list/src/ListView.tsx`(筛选)、`ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.tsx`(审批人选择器)。 - 两分钟验证:`cd ui && pnpm exec vitest run packages/plugin-detail/src/queueClaim-25769.test.tsx packages/plugin-list/src/__tests__/ListView.queueSittingFilter-25769.test.tsx packages/fields/src/widgets/RecipientPickerField.queue-25769.test.tsx packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.queuePicker-25769.test.tsx`,预期 4 个文件 36 条全绿。 - 需要你做的:无,席位落地 ## What changes - **Queue pickers read both sources (ADR-0142 D6/D7).** `@object-ui/fields` gains `mergeQueueCandidates` / `queueCandidateLabel` (one list by machine name, declared first, ordered by name) and a `RECIPIENT_REGISTRY_AND_RECORD_KINDS` table. `RecipientPickerField` lists a `queue` recipient from the metadata store's `queue` items and `sys_queue` rows (`find('sys_queue', { $top: 500, $orderby: { name: 'asc' } })`) and stores the name; with either reader missing it keeps the text input. The read face (`refLabelDirectory`) names a stored queue the same way. In `@object-ui/app-shell`, `FlowReferenceField` replaces the "until the console's two-source queue picker lands" placeholder with `TwoSourcePickCell`, driven by the binding the server publishes (`registry-and-data`, or the bundled spec's when nothing is published); `json-schema-to-fields` now keeps a whole `registry-and-data` binding instead of dropping it. A server that publishes anything else for `queue` keeps the old text cell. - **Claim (ADR-0142 D3/D4).** `@object-ui/plugin-detail` adds `queueClaim.ts`: `recordSitsInQueue` (owner empty and a queue named), `useQueueClaimOffered` (asks `POST /api/v1/security/explain { operation: 'transfer' }` and offers only on a `queue_claim` rule with `effect: 'admits'`; fails closed) and `claimQueuedRecord` (sends `{ owner_id: <self> }` through `update`; a `409 CONCURRENT_UPDATE` is `taken`). `RecordDetailView` adds a `sys_claim` header action on that answer, toasts claimed / taken / refused, and invalidates the record. - **"In a queue" filter (ADR-0142 D3).** `@object-ui/plugin-list` `ListView` offers a toolbar toggle only when the served object definition carries the injected `queue` column (`system: true`). Pressed, it AND-s `['owner_id','is_empty',true]` and `['queue','is_not_empty',true]` (lowered to `$empty`) into the fetch, the self-querying views and the export. Empty means null or `''` on both columns, as the explain engine and the assignment seam read it. - **i18n.** `fields.recipient.selectQueue`, `detail.claim` / `claimed` / `claimTaken` / `claimFailed`, `list.sittingInQueue` / `sittingInQueueHint` in all 10 locales; `engine.inspector.reference.pickFromQueues` in the studio en/zh namespaces. - **Docs (plan item 4).** New `content/docs/automation/queues-and-assignment-rules.mdx`: 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 release / move), and that a queue is not a messaging audience. Linked from `automation/index.mdx`, `automation/approvals.mdx`, `permissions/sharing-rules.mdx` (the recipient table gains its `queue` row) and `permissions/rls.mdx` (`current_user.queues`). The generated `references/automation/assignment-rule.mdx` is not touched; `scripts/docs-audit/handwritten-docs.json` regenerated by its own `--write`. - Changeset `.changeset/25769-console-queue-affordances.md`: patch on `@object-ui/fields`, `@object-ui/app-shell`, `@object-ui/plugin-detail`, `@object-ui/plugin-list`, `@object-ui/i18n`. ## Tests - `RecipientPickerField.queue-25769.test.tsx` (7): the merge helper's order, collisions and shapes; the picker's two requests, both sources listed and a shadowing row hidden, the stored name, one failing source still offering the other, and the text input with no store (no `find` call). - `FlowReferenceField.queuePicker-25769.test.tsx` (9): `json-schema-to-fields` keeps a whole binding and drops a half one; `twoSourceFor` precedence; with an adapter, `metaList('queue')` and `find('sys_queue', …)`, both options listed, the chosen name committed; a stored name neither lists shown flagged `(not found)`; manual entry and back; no adapter reads nothing; another published binding keeps the text cell. - `queueClaim-25769.test.tsx` (12): sits-in-queue for owned / unowned / no queue; `queueClaimAdmitted` true only on `queue_claim` + `admits` (not a visible transfer, not `queue_membership`, not a malformed answer); the hook's POST body, no button on `neutral`, a non-OK or thrown probe, no probe for a non-sitting record or an unknown user; the write is `{ owner_id }` only, a 409 is `taken`, a 403 is `refused` with its error. - `ListView.queueSittingFilter-25769.test.tsx` (8): `objectHasOwnershipQueue` (map and array forms, a non-system `queue` refused); the two triples pinned as `is_empty` / `is_not_empty`; `buildEffectiveFilter` with and without the conditions; the toggle renders on an injected column and its press and release change the fetch `$filter`; no toggle without the injected column. ## Acceptance notes - An unowned record with no `queue` is claimable on the server by anyone with a positive sharing basis (D4), but the Console offers Claim only on a record that sits in a queue, as plan item 3 names it. - The pickers and the read face cap `sys_queue` at 500 rows by name, as the record pickers do. - The "In a queue" toggle is ephemeral toolbar state, not saved with the view. - The approver read face (the label shown for a stored `queue` approver outside the editor) is unchanged and shows the name. - No dogfood or live-boot leg: the harness's queue rows belong to the server PR (#26337); this PR's legs are the component tests above. ## Verification Head `e3e83a00`, merge base `ae16687a`. Every locked run is read from its `VERDICT command-exit` line. - New tests: 4 files, 36 tests, all pass (`VERDICT command-exit 0`). - Touched suites under `os-verify-lock`: `plugin-detail` + `i18n` + the `fields` recipient/queue files, 416 files, 4361 passed / 31 skipped; the app-shell scope (`FlowReferenceField*`, `json-schema-to-fields*`, `flow-node-config*`, `utils/approverValueSources*`, `widget-ref-cells`, `RecordDetailView*`), 103 files, 945 passed / 1 skipped; `plugin-list` whole, 168 files, 1675 passed. Each `VERDICT command-exit 0`. A single combined run of all of them hit the session's 30-minute limit and was split, not narrowed. - `type-check` (`tsc --noEmit` + `tsc -p tsconfig.test.json`) of `@object-ui/fields`, `plugin-detail`, `plugin-list`, `i18n`, `app-shell`: each `VERDICT command-exit 0`. - `node scripts/pm/dispatch-gates.mjs --commands`: 78 commands, all `exit 0`. Four were run twice. `check:role-word` found `role:` in the new guide; the wording was fixed in `4dde047e`, and it exits 0. Three `scripts/` tests timed out at 15s while the locked suites shared the box; on a quiet box, the two files exit 0 and the whole `scripts/` run passes 5019 tests with 2 skipped. `check:skill-examples` exited 3 because `client-react` was not built; after that build it exits 0. `check:type-check-debt` exited 3 because it got no turn on the lock; run under the lock, it exits 0. - `dispatch-gates --ran`: 78 derived, 78 run, 0 NOT-MEASURED, 0 UNRUN. - The `@object-ui/plugin-form` write census (`affordanceGrantMap-12082`, red on CI at `58d26514`) grades the two new write sites: `claimQueuedRecord` is a write helper, and the record page's `handleClaim` is `outOfFamily`, because a claim is admitted from the row's own sharing verdict, not an object grant, and is offered only on the explain answer. Run locked: 55/55 pass, and the `plugin-form` type-check gives `VERDICT command-exit 0`. - Docs: `check:doc-authoring`, `check-adr-links`, `check-doc-frontmatter`, and `check:docs-audit-scope` exit 0. Every new `/docs/…` link resolves to a page in the tree. `Check Documentation Links` (lychee) runs in CI. --- _Generated by [Claude Code](https://claude.ai/code/session_01WGufzfFCwcM21eGjVuqwRz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent abba2c4 commit 2302b92

40 files changed

Lines changed: 1693 additions & 27 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@object-ui/fields': patch
3+
'@object-ui/app-shell': patch
4+
'@object-ui/plugin-detail': patch
5+
'@object-ui/plugin-list': patch
6+
'@object-ui/i18n': patch
7+
---
8+
9+
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
10+
11+
Clause-②: no
12+
Metadata-protocol: none
13+
14+
The server half of ownership queues shipped without a Console face. Now:
15+
16+
- **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.
17+
- **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."
18+
- **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.
19+
- **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.

‎content/docs/automation/approvals.mdx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -409,7 +409,9 @@ one that silently resolves to nobody when it's mistaken for a business
409409
hierarchy. A `queue` entry names an ownership queue by its machine name — one
410410
an installed app declares, or one of the organization's own — and resolves to
411411
the queue's members at node entry, through the same expansion that decides who
412-
may claim the queue's records (ADR-0142 D6). `field`, `manager`, and `expression` resolve against the record's
412+
may claim the queue's records (ADR-0142 D6) — how to declare a queue, and what
413+
its members can do with the records parked in it, is in
414+
[Queues and assignment rules](/docs/automation/queues-and-assignment-rules). `field`, `manager`, and `expression` resolve against the record's
413415
**live** state at node entry (#3447); `manager` additionally depends on
414416
`sys_user.manager_id`, which a platform admin sets from **Setup → Users → ⋯ →
415417
Set Manager** — see [the `manager` callout above](#3-the-approval-node). `position`, `department`,

‎content/docs/automation/index.mdx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export const OpportunityStageHook: Hook = {
3131
- **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)).
3232
- **Workflows** model a record's lifecycle as a **finite state machine**: states, transitions, and guards ([Workflows](/docs/automation/workflows)).
3333
- **Approvals** are flow nodes with approver resolution, approve/reject decisions, and escalation ([Approvals](/docs/automation/approvals)).
34+
- **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)).
3435
- **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)).
3536
- **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)).
3637
- **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)).
@@ -46,6 +47,7 @@ Rule of thumb: model *state* with workflows, model *steps* with flows, use hooks
4647
<Card href="/docs/automation/flows" title="Flows" description="Node-based automation, screen flows, scheduled flows" />
4748
<Card href="/docs/automation/workflows" title="Workflows" description="State-machine lifecycle modeling" />
4849
<Card href="/docs/automation/approvals" title="Approvals" description="Approval nodes: approvers, decisions, escalation" />
50+
<Card href="/docs/automation/queues-and-assignment-rules" title="Queues & Assignment" description="Park new records for a team, claim them, route by least load" />
4951
<Card href="/docs/automation/webhooks" title="Webhook Delivery" description="Durable outbox, retries, HMAC signing" />
5052
<Card href="/docs/automation/connectors" title="Connectors" description="Declarative rest/openapi/mcp instances, credentialRef auth" />
5153
<Card href="/docs/automation/jobs" title="Scheduled Jobs" description="Cron/interval/once schedules, retry, timeout, run history" />

‎content/docs/automation/meta.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"flows",
77
"workflows",
88
"approvals",
9+
"queues-and-assignment-rules",
910
"webhooks",
1011
"connectors",
1112
"jobs",
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
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`.

‎content/docs/permissions/rls.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ not this one):
164164
| `current_user.organization_id` | The acting tenant |
165165
| `current_user.positions` | The user's positions (array) |
166166
| `current_user.org_user_ids` | User ids in the user's org (array) |
167+
| `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)) |
167168
| `current_user.email` | The user's email |
168169
| `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`) |
169170

0 commit comments

Comments
 (0)